Skip to content

Outbound Egress Policy

Every outbound call that has a real-world side effect passes through one guard before the network is touched. It exists so a staging or local environment cannot announce a feed to the podcast ecosystem, email a real customer, write to somebody's Google Calendar, create a support ticket agents will work, or POST to a customer-configured webhook.

Source of truth: src/lib/egress/policy.ts.

Why one policy rather than per-feature flags

The blast radius is not uniform, and several of these actions cannot be undone:

  • A Podping notification is written to the Hive chain.
  • A Podcast Index submission creates a permanent public listing pointing at a feed URL.
  • A sent email cannot be recalled.

Per-feature flags drift as features are added. One table that every send path reads does not. This was recorded as constraint C3 of the environments plan, and it has to land before a staging environment exists at all.

The environment variable

A single ENVIRONMENT variable, valued local, staging or production, selects the policy. Nothing infers the environment from a hostname.

It is read from:

  • c.env.ENVIRONMENT in Hono routes, declared on the shared bindings type in src/api/middleware/permissions.ts.
  • env.ENVIRONMENT in workers, declared on each worker's Env interface and set in [vars] in its wrangler.toml.
  • $env/dynamic/private in page servers, narrowed through egressEnvFromRecord().

It fails closed

An unset, empty, misspelled or unrecognised value resolves to local, which suppresses everything.

This is deliberate, and it is the opposite of what "keep production working" would suggest. The trade is between two failure modes:

DefaultFailure mode when misconfiguredRecoverable
productionStaging announces to Podping, submits to Podcast Index, emails real customersNo
localProduction silently stops emailingYes, by setting one variable

Irreversibility beats loudness, so it defaults to local. The cost is that ENVIRONMENT must genuinely be set everywhere, which is enforced two ways: every affected worker's wrangler.toml declares it, asserted by src/lib/egress/__tests__/wrangler-environment.test.ts, and that test derives its worker list from imports rather than a hardcoded list, so a new worker that imports the policy is covered automatically.

The main app is a Worker like the others

The app migrated from Pages to Workers (plan: docs/planning/plans/2026-07-29-pages-to-workers-migration.md), so the root wrangler.toml is an ordinary Workers config and the same rules as the sibling workers apply: ENVIRONMENT = "production" ships in [vars] with every pnpm run deploy, no dashboard step exists, and workers_dev = false plus preview_urls = false mean the only exposure is deliberate route bindings. W6 turns version preview URLs on deliberately, behind Access, as the promotion gate; a version preview runs with production bindings by design, which is exactly what makes it the right final smoke of a promotion candidate and the wrong place for casual testing.

Until the Phase C cutover completes, production traffic is served by the frozen Pages deployment, which still carries its own copy of the variable from before the migration. The frozen project and its secrets stay untouched until W5, because the rollback path (deleting a route) depends on them.

The same applies to every local dev server

[vars] is loaded into the local dev runtime as well as the deployed one. Verified on wrangler 4.85.0 against minimal probes:

Commandenv.ENVIRONMENT
wrangler devproduction
wrangler dev --var ENVIRONMENT:locallocal

Every dev script that starts a wrangler dev server pins the override (--var ENVIRONMENT:local), and wrangler-environment.test.ts asserts it. The assertion is scoped to configs whose own wrangler.toml declares ENVIRONMENT (the six sending workers plus the root app), so it stays correct as workers are added. If anything ever reintroduces wrangler pages dev, note that command takes the Pages-flavoured flag instead: --binding KEY=VALUE.

Running these commands by hand

Use the dev / dev:cf scripts. A bare wrangler dev inherits ENVIRONMENT=production.

This is not theoretical even without secrets: the WebSub hub and Podcast Index pubnotify are unauthenticated, so a laptop wrangler dev of scheduled-publisher can announce a real feed with nothing configured. With provider secrets present it will also send real email, dispatch real push notifications and perform irreversible directory writes.

pnpm dev (Vite) is unaffected: it reads .env, not wrangler.toml, so it stays fail-closed unless you deliberately set ENVIRONMENT.

Categories and the decision table

CategoryWhat it coverslocalstagingproduction
emailEvery Resend delivery: transactional, notification, automation, platform billing, support receipts, audience contact writessuppressallowlistallow
directory_pingPodping, the WebSub hub, Podcast Index pubnotifysuppresssuppressallow
directory_submitPodcast Index add/byfeedurlsuppresssuppressallow
calendar_writeGoogle Calendar event create, update, delete, and OAuth token revocationsuppresssuppressallow
support_writeFreeScout POST, PUT and DELETEsuppresssuppressallow
webhookAutomation send_webhook to customer-configured URLssuppressallowlistallow
pushWeb Push dispatch to a browser push servicesuppresssuppressallow

Three modes:

  • allow performs the call.
  • suppress never performs it.
  • allowlist performs it only when the target matches, where the target is the recipient address for email and the destination hostname for webhook.

Staging keeps email on the real Resend transport, gated by allowlist, so template rendering, bounce handling and the metering path are genuinely rehearsed rather than stubbed.

calendar_write covers revokeToken as well as the event writes. Revocation is destructive rather than a read: if staging held a production calendar connection, disconnecting there would invalidate the real customer's refresh token and break their integration in production.

push is suppressed in staging for the same reason: the destination is a device endpoint stored in notification_push_subscriptions, so a staging database seeded from production would pop a real notification on a customer's phone.

calendar_write is suppressed in staging only because the staging seed-data strategy is still undecided. Once it is settled such that no production Google refresh tokens can reach staging, open it with EGRESS_ALLOW (below) rather than by editing the table.

Configuration

VariablePurpose
ENVIRONMENTlocal / staging / production
EGRESS_EMAIL_ALLOWLISTComma-separated. An entry containing @ is an exact address; anything else is a domain and matches that domain and its subdomains
EGRESS_WEBHOOK_ALLOWLISTComma-separated hostnames, matching the host and its subdomains
EGRESS_ALLOWComma-separated category names to force-enable for a deliberate rehearsal

Domain and host matching is not a plain suffix check: blue37.com matches blue37.com and mail.blue37.com but never notblue37.com or blue37.com.evil.example.

EGRESS_ALLOW can only lift suppress to allow. It cannot weaken an allowlist, so EGRESS_ALLOW=email does not open staging up to arbitrary recipients. Unknown names are ignored.

Local development

With no ENVIRONMENT set, local development sends no email, pings no directories and writes no calendar events. Suppressed calls are logged as egress_blocked, so they are visible rather than silent.

To exercise a real send locally, set both:

bash
ENVIRONMENT=staging
EGRESS_EMAIL_ALLOWLIST=[email protected]

That routes through the real Resend transport but only to you.

What is deliberately not covered

These are decisions, not gaps:

Not gatedReason
StripeIsolated per environment by API key, sandbox against live. A stronger boundary than a runtime check
MooSend (workers/crm-sync)Read-only. It pulls subscriber and campaign data in and never writes
Directory lookups (workers/distribution-monitor)Read-only searches against Apple, Spotify and Podcast Index. Suppressing them would make the monitor untestable in staging for no safety gain
Google Calendar readslistCalendars, getBusyTimes and getCalendarEvent have no side effect, and availability lookups must work in staging
Cloudflare Analytics SQL APIReads our own account
RSS import fetches, artwork import, AI page fetchesGETs of a user-typed URL or a public page, with no side effect on the remote host
Google OAuth token exchange and refreshObtaining an access token has no side effect on the customer. Revocation does, and is gated
Supabase, R2, KV, Queues, HyperdriveInternal infrastructure, separated per environment by binding

Wiring

The guard sits at the choke point for each category, not at each caller, so a new caller is covered by construction.

Guarded atCovers
performResendSend in src/lib/email/index.tsEvery app-path send
src/lib/email/contacts.tsResend audience contact writes
pingDirectories in src/lib/utils/ping-directories.tsAll three freshness pings, from both the app and scheduled-publisher
src/lib/google-calendar/calendar.tsThe four write functions
The request wrapper in src/lib/support/freescout-client.tsEvery non-GET verb
src/api/routes/distribution/index.tsThe Podcast Index submission
workers/automation-executor/src/executors/email.tsAutomation email fan-out
workers/automation-executor/src/executors/webhook.tsAutomation webhooks
workers/podcast-import-executor/src/email.tsImport-complete mail
workers/lifecycle-manager/src/overage-escalation.tsOverage and suspension notices
workers/crm-sync/src/digest.tsThe daily admin digest
createPushChannelExecutor in src/lib/notifications/channels/push.tsWeb Push dispatch
revokeToken in src/lib/google-calendar/auth.tsGoogle OAuth token revocation

Two properties are enforced by the type system rather than by review:

  • EmailConfig.egress is a required field, so TypeScript rejects any new email config that does not state its environment.
  • The Google Calendar write functions take the environment as their first parameter, so a new call site cannot omit it.

Ordering against the entitlement meter

Where a call is both metered (GATE-5) and gated, the egress check runs first. A suppressed call performs no egress, so it must not spend the customer's quota. Otherwise repeated blocked sends in staging would exhaust the allowance before an allowlisted rehearsal could run.

On the app path this means sendEmail checks egress before meterEmailSend, in addition to the check inside performResendSend. The transport-level check is what makes the guard unavoidable for the unmetered entry points; the earlier one is what preserves the ordering. Asserted directly in the tests.

Every metered site, and where its guard sits. This list is the invariant: a new metered egress path must add a row, because the bug has now recurred three times in one change.

Metered siteMeterGuard placed
sendEmail (src/lib/email/index.ts)email_sends_per_monthBefore meterEmailSend, plus the backstop inside performResendSend
Notification email channelemail_sends_per_month (its own consume, before it reaches sendEmail)preflight(), and first statement of execute()
Notification push channelnotification_deliveries_per_month (consumed by the pipeline, before execute)preflight(), and first statement of execute()
automation-executor emailemail_sends_per_monthBefore the consume
automation-executor webhookwebhook_sends_per_monthBefore the consume
podcast-import-executor emailemail_sends_per_monthBefore the consume

The two notification channels need preflight() because their consume happens before execute is reached: for push the delivery pipeline consumes, and for email the channel consumes on its own behalf. NotificationChannelExecutor.preflight() is an optional hook the pipeline runs ahead of the quota gate. The channel owns the decision; the pipeline owns only the ordering.

Each row is asserted by a test that requires the meter was not called, not merely that the send was refused. That distinction is the whole point: a guard placed on the wrong side of the meter still refuses the send, so a refusal-only assertion passes while the bug is live. It did, three times.

The two preflight() rows are additionally asserted through the pipeline rather than by calling the channel directly, because a channel-level test would pass even if the pipeline never invoked the hook.

Suppression is all-or-nothing

A send addressed to several recipients, across to, cc and bcc, is blocked entirely if any one of them fails the allowlist. Half-delivering a message whose recipient list includes two real customers is the exact outcome the policy exists to prevent.

How a suppression surfaces

Call siteResult
EmailA failed EmailResult with errorCode: 'egress_suppressed'
Notification deliveries (email and push)A permanent delivery error, never transient. The environment cannot change between retries, so a transient classification would retry a staging delivery forever
Automation actionsstatus: 'skipped', reason: 'egress_suppressed' in action_results
Calendar writesA typed EgressSuppressedError, so a deliberate suppression is distinguishable from a Google outage
FreeScout writesA FreescoutApiError with status 503
Podcast Index submitHTTP 503
Directory pingsPingResult fields report 'suppressed'

Every block also emits one structured egress_blocked log line carrying the environment, category and reason. The recipient address is deliberately omitted for email so addresses do not end up in logs.

How to sweep for gaps

The original sweep for this work grepped outbound hostname literals. That method cannot find a destination that comes from the database, and it missed Web Push, whose endpoint is a column in notification_push_subscriptions. Sweep both ways:

bash
# 1. Literal hosts
grep -rhoE "https://[a-zA-Z0-9._-]+" src workers --include='*.ts' --exclude-dir=node_modules

# 2. Non-literal destinations: fetch() whose first argument is a variable
grep -rnE "fetch(Impl|Fn)?\(\s*[a-zA-Z_$][a-zA-Z0-9_.\[\]]*\s*[,)]" \
  src/lib src/api workers/*/src --include='*.ts' --exclude-dir=node_modules

The second is the one that finds push subscriptions, automation webhooks, and anything else addressed by stored data.

Testing

  • src/lib/egress/__tests__/policy.test.ts covers the decision table, allowlist matching and the fail-closed default.
  • src/lib/egress/__tests__/call-sites.test.ts proves each guarded module actually consults the policy, by asserting no network call is made. Asserting a return value alone would not be enough: a call site that ignored the policy but returned the same shape would still pass.
  • src/lib/egress/__tests__/wrangler-environment.test.ts asserts the deployed configuration matches.
  • docs/planning/plans/2026-07-27-environments-and-delivery-pipeline.md (W3, constraint C3)

Internal documentation - Not for public distribution