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.ENVIRONMENTin Hono routes, declared on the shared bindings type insrc/api/middleware/permissions.ts.env.ENVIRONMENTin workers, declared on each worker'sEnvinterface and set in[vars]in itswrangler.toml.$env/dynamic/privatein page servers, narrowed throughegressEnvFromRecord().
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:
| Default | Failure mode when misconfigured | Recoverable |
|---|---|---|
production | Staging announces to Podping, submits to Podcast Index, emails real customers | No |
local | Production silently stops emailing | Yes, 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:
| Command | env.ENVIRONMENT |
|---|---|
wrangler dev | production |
wrangler dev --var ENVIRONMENT:local | local |
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
| Category | What it covers | local | staging | production |
|---|---|---|---|---|
email | Every Resend delivery: transactional, notification, automation, platform billing, support receipts, audience contact writes | suppress | allowlist | allow |
directory_ping | Podping, the WebSub hub, Podcast Index pubnotify | suppress | suppress | allow |
directory_submit | Podcast Index add/byfeedurl | suppress | suppress | allow |
calendar_write | Google Calendar event create, update, delete, and OAuth token revocation | suppress | suppress | allow |
support_write | FreeScout POST, PUT and DELETE | suppress | suppress | allow |
webhook | Automation send_webhook to customer-configured URLs | suppress | allowlist | allow |
push | Web Push dispatch to a browser push service | suppress | suppress | allow |
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
emailand the destination hostname forwebhook.
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
| Variable | Purpose |
|---|---|
ENVIRONMENT | local / staging / production |
EGRESS_EMAIL_ALLOWLIST | Comma-separated. An entry containing @ is an exact address; anything else is a domain and matches that domain and its subdomains |
EGRESS_WEBHOOK_ALLOWLIST | Comma-separated hostnames, matching the host and its subdomains |
EGRESS_ALLOW | Comma-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:
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 gated | Reason |
|---|---|
| Stripe | Isolated 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 reads | listCalendars, getBusyTimes and getCalendarEvent have no side effect, and availability lookups must work in staging |
| Cloudflare Analytics SQL API | Reads our own account |
| RSS import fetches, artwork import, AI page fetches | GETs of a user-typed URL or a public page, with no side effect on the remote host |
| Google OAuth token exchange and refresh | Obtaining an access token has no side effect on the customer. Revocation does, and is gated |
| Supabase, R2, KV, Queues, Hyperdrive | Internal 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 at | Covers |
|---|---|
performResendSend in src/lib/email/index.ts | Every app-path send |
src/lib/email/contacts.ts | Resend audience contact writes |
pingDirectories in src/lib/utils/ping-directories.ts | All three freshness pings, from both the app and scheduled-publisher |
src/lib/google-calendar/calendar.ts | The four write functions |
The request wrapper in src/lib/support/freescout-client.ts | Every non-GET verb |
src/api/routes/distribution/index.ts | The Podcast Index submission |
workers/automation-executor/src/executors/email.ts | Automation email fan-out |
workers/automation-executor/src/executors/webhook.ts | Automation webhooks |
workers/podcast-import-executor/src/email.ts | Import-complete mail |
workers/lifecycle-manager/src/overage-escalation.ts | Overage and suspension notices |
workers/crm-sync/src/digest.ts | The daily admin digest |
createPushChannelExecutor in src/lib/notifications/channels/push.ts | Web Push dispatch |
revokeToken in src/lib/google-calendar/auth.ts | Google OAuth token revocation |
Two properties are enforced by the type system rather than by review:
EmailConfig.egressis 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 site | Meter | Guard placed |
|---|---|---|
sendEmail (src/lib/email/index.ts) | email_sends_per_month | Before meterEmailSend, plus the backstop inside performResendSend |
| Notification email channel | email_sends_per_month (its own consume, before it reaches sendEmail) | preflight(), and first statement of execute() |
| Notification push channel | notification_deliveries_per_month (consumed by the pipeline, before execute) | preflight(), and first statement of execute() |
automation-executor email | email_sends_per_month | Before the consume |
automation-executor webhook | webhook_sends_per_month | Before the consume |
podcast-import-executor email | email_sends_per_month | Before 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 site | Result |
|---|---|
A 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 actions | status: 'skipped', reason: 'egress_suppressed' in action_results |
| Calendar writes | A typed EgressSuppressedError, so a deliberate suppression is distinguishable from a Google outage |
| FreeScout writes | A FreescoutApiError with status 503 |
| Podcast Index submit | HTTP 503 |
| Directory pings | PingResult 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:
# 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_modulesThe second is the one that finds push subscriptions, automation webhooks, and anything else addressed by stored data.
Testing
src/lib/egress/__tests__/policy.test.tscovers the decision table, allowlist matching and the fail-closed default.src/lib/egress/__tests__/call-sites.test.tsproves 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.tsasserts the deployed configuration matches.
Related
docs/planning/plans/2026-07-27-environments-and-delivery-pipeline.md(W3, constraint C3)