Commit c613a7f
authored
feat(cors): move api-cors-preflight Worker into the repo + CI guardrails (#3932)
* feat(cors): move api-cors-preflight Worker source into the repo + CI guardrails
The 2026-05-27 site-wide CORS outage (every browser request to
api.worldmonitor.app blocked with "Access-Control-Allow-Credentials header
in the response is '' which must be 'true'") could not be fixed by PR
#3923's repo-side CORS work because the OPTIONS preflight never reached
Vercel — it was short-circuited by a Cloudflare Worker whose source lived
only in the Cloudflare dashboard and was missing
`Access-Control-Allow-Credentials: 'true'`.
This makes that Worker visible to source control, code review, and CI so
the same trap cannot recur silently.
What's added:
- workers/api-cors-preflight/src/index.js — canonical Worker source,
exporting isAllowedOrigin + buildCorsHeaders so tests can reuse them.
Mirrors api/_cors.js's allowlist (worldmonitor.app subdomains + Vercel
previews + Tauri desktop + asset:// origins) and Allow-Headers list,
with comments calling out the two-list-sync invariant.
- workers/api-cors-preflight/wrangler.toml — deploy config (route bound
to api.worldmonitor.app/*, account_id from env).
- workers/api-cors-preflight/package.json — wrangler dev dep + scripts
(deploy / deploy:dry-run / test).
- workers/api-cors-preflight/README.md — why this exists separately
from api/_cors.js, deploy how-to, sync-with-function rule.
- workers/api-cors-preflight/index.test.mjs — 12 unit tests pinning
the load-bearing invariants: OPTIONS returns 204+ACAC:true, allowed
origins are echoed, disallowed origins fall back to canonical with
ACAC:true still set, non-/api/ paths pass through, GET responses get
CORS stamped on the way back, 502 fallback still has CORS headers,
Allow-Headers list matches api/_cors.js exactly.
- tests/cors-preflight-live.test.mjs — live smoke test against
api.worldmonitor.app gated by LIVE_SMOKE=1 so it doesn't false-positive
in PR gates during deploys. Asserts ACAC:true on OPTIONS preflight for
/api/health and /api/bootstrap?tier=fast.
- .github/workflows/deploy-worker.yml — runs unit tests → wrangler
deploy → 15s sleep for propagation → live smoke test on push to main
when workers/api-cors-preflight/** changes. Requires repo secrets
CLOUDFLARE_API_TOKEN (Workers Scripts:Edit + Workers Routes:Edit on
worldmonitor.app zone) and CLOUDFLARE_ACCOUNT_ID.
- docs/architecture/pro-monetization.md — CORS bullet expanded to
point at workers/api-cors-preflight/ and call out the two-list-sync
invariant with api/_cors.js.
Verification:
- node --test workers/api-cors-preflight/index.test.mjs → 12/12 pass
- LIVE_SMOKE=1 node --test tests/cors-preflight-live.test.mjs → 2/2 pass
Setup required before merge:
1. Add CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID to repo secrets.
Token scope: Workers Scripts:Edit + Workers Routes:Edit on the
worldmonitor.app zone.
2. (Optional, post-merge) Run `wrangler deploy --dry-run` once locally
to confirm wrangler.toml + secrets resolve before the first real
deploy.
Notes:
- api/_cors.js#PREVIEW_PATTERN regex requires `[a-z0-9]+` (no hyphens)
after `-elie-`, so preview deploys under team `elie-habib-projects`
fall back to canonical origin and fail CORS. Test documents this
latent narrowness with a `false` assertion; widening it is a separate
PR that must update BOTH the Worker AND api/_cors.js together.
Companion learning:
~/.claude/skills/worldmonitor-architecture-gotchas/reference/
cloudflare-worker-overrides-vercel-cors-for-preflight.md
* fix(cors): address PR review — DELETE in Allow-Methods + runnable npm test script
Reviewer P2 findings on the api-cors-preflight Worker:
1. **DELETE missing from Access-Control-Allow-Methods.**
`api/product-catalog.js` advertises `GET, DELETE, OPTIONS` on its own
preflight, but the Worker (which short-circuits OPTIONS at the edge
before Vercel sees it) only allowed `GET, POST, OPTIONS`. Browser-origin
DELETE preflights to /api/product-catalog were silently rejected by the
browser before the authenticated purge could reach Vercel — same trap
class as the original 2026-05-27 outage, just on a different verb.
Fix: extract `ALLOW_METHODS = 'GET, POST, DELETE, HEAD, OPTIONS'` as a
single named constant in src/index.js, with a comment explaining that
the Worker's list must be a superset of every method any api/* route
advertises. Pin the invariant in BOTH:
- workers/api-cors-preflight/index.test.mjs — a new explicit "OPTIONS
preflight advertises DELETE" regression test against the Worker
module, plus an `ACAM_EXPECTED` constant the main preflight test
now asserts against.
- tests/cors-preflight-live.test.mjs — the live smoke now asserts the
ACAM header includes GET + POST + DELETE + OPTIONS. This test
currently FAILS against prod because the deployed Worker still has
the old list — that's the regression catching itself; once
deploy-worker.yml runs on merge and ships the new Worker, the live
smoke goes green.
Method audit confirmed the only verbs any api/* route uses are GET,
POST, DELETE, HEAD, OPTIONS — no PUT or PATCH anywhere.
2. **`npm test` in workers/api-cors-preflight/ was unrunnable.**
The script invoked `tsx --test` but the package only declared
`wrangler` as a devDependency. CI sidesteps this by calling
`node --test` directly, but the README tells maintainers to run
`npm test`, which immediately failed with `tsx: command not found`.
Switched the script to `node --test index.test.mjs` — the Worker
tests only use Node's built-in Fetch primitives + node:test, so tsx
was never actually needed.
Verification:
- `cd workers/api-cors-preflight && npm test` → 13/13 ✓
- `node --test workers/api-cors-preflight/index.test.mjs` → 13/13 ✓
- `LIVE_SMOKE=1 node --test tests/cors-preflight-live.test.mjs` →
intentionally fails on the DELETE assertion against current prod (proof
the assertion is real). Will pass after deploy-worker.yml ships the new
Worker.
Also bumped the manually-pasted /Users/eliehabib/Downloads/worker.txt
copy so the user's "paste into Cloudflare dashboard" path stays in sync
with the in-repo source.
* fix(cors): bump Worker propagation wait from 15s to 30s
Reviewer P2 on .github/workflows/deploy-worker.yml:71. Cloudflare's docs
document Workers propagation to the global edge as up to ~30 s,
occasionally longer. A 15 s wait races the in-flight propagation window
in two ways:
1. False-green: the live-smoke job hits a PoP that's still serving the
OLD Worker, asserts ACAC:true (which the old Worker also has now), and
greenlights the deploy without ever verifying the NEW Worker.
2. False-fail: transient inconsistency during the propagation window
triggers a smoke-test failure that retrying would resolve, wasting CI
cycles + masking real signal.
30 s aligns with Cloudflare's documented propagation SLA. Comment in the
workflow explains the rationale so a future drive-by tightening will
re-read it.
* fix(cors): bypass Worker for MCP / OAuth / public-utility paths
Reviewer P1 on workers/api-cors-preflight/src/index.js:25-31:
Browser clients on https://claude.ai or https://claude.com hitting /api/mcp,
/api/oauth/register, /api/oauth/token, etc. were being silently CORS-blocked
because the Worker:
1. Short-circuited OPTIONS preflights before Vercel could apply its
endpoint-specific public CORS policy (getPublicCorsHeaders ACAO: '*'
or per-endpoint Claude origin validation).
2. Echoed the canonical https://worldmonitor.app fallback origin since
claude.ai/claude.com aren't in the Worker's allowlist, which the
browser then rejects against the request origin.
Adding claude.ai/claude.com to the Worker's master allowlist would have
silently widened ACAO on every endpoint, not just the public ones — the
WM-app credentialed routes intentionally accept only worldmonitor.app
origins. Wrong fix.
Right fix: a path-prefix BYPASS. For paths whose Vercel function owns a
wider CORS policy, the Worker passes the request straight through to
Vercel — no OPTIONS short-circuit, no response-header rewrite, no
imposed ACAC: true (which would be wrong for non-credentialed external
clients anyway).
Bypass set covers every endpoint that uses getPublicCorsHeaders() or
hardcodes ACAO: '*':
- /api/mcp + /api/mcp/* (MCP server, claude.ai client)
- /api/oauth/* (register, token, authorize, authorize-pro — OAuth DCR
+ token exchange from any MCP host)
- /api/oauth-protected-resource (RFC 9728 discovery)
- /api/security/report (CSP/COOP/COEP reports from anywhere)
- /api/geo, /api/version (public, no credentials)
The bypass exports `hasPublicCorsPolicy(pathname)` so unit tests can drive
the prefix logic directly. Six new test cases:
- exact-match paths
- nested OAuth + MCP prefixes
- WM-app routes correctly do NOT bypass (regression guard against
accidental widening)
- tricky prefix collisions ('/api/mcps' ≠ '/api/mcp/', '/api/oauth-...'
≠ '/api/oauth/...', '/api/geographic-data' ≠ '/api/geo')
- OPTIONS preflight from claude.ai to /api/mcp passes through (Vercel
ACAO: * survives, Worker does NOT inject ACAC: true)
- OPTIONS preflight from claude.com to /api/oauth/register passes
through (OAuth DCR)
- POST to /api/oauth/token from claude.ai: Vercel's ACAO: * passes
through unchanged
Live smoke (tests/cors-preflight-live.test.mjs) gets three new probes:
OPTIONS to /api/mcp, /api/oauth/register, /api/oauth-protected-resource
from external Claude origins. Assertion: ACAO must be '*' OR echo the
request origin — anything else (especially the canonical
https://worldmonitor.app fallback) means the Worker short-circuited when
it should have bypassed. These will fail against current prod until the
new Worker deploys via deploy-worker.yml, which is the regression
catching itself.
Also synced /Users/eliehabib/Downloads/worker.txt so the user's manual
CF-dashboard paste path stays current.
Verification:
- `cd workers/api-cors-preflight && npm test` → 19/19 ✓
- `LIVE_SMOKE=1 node --test tests/cors-preflight-live.test.mjs` →
intentionally fails on current prod (proof the assertions are real);
goes green after deploy.1 parent c0488f9 commit c613a7f
8 files changed
Lines changed: 823 additions & 1 deletion
File tree
- .github/workflows
- docs/architecture
- tests
- workers/api-cors-preflight
- src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
91 | 91 | | |
92 | 92 | | |
93 | 93 | | |
94 | | - | |
| 94 | + | |
95 | 95 | | |
96 | 96 | | |
97 | 97 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
0 commit comments