Skip to content

Commit c613a7f

Browse files
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

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
name: Deploy api-cors-preflight Worker
2+
3+
# Deploys the Cloudflare Worker that owns CORS for api.worldmonitor.app.
4+
# Triggers on push to main when workers/api-cors-preflight/** changes, OR on
5+
# manual dispatch. The path filter is the only thing keeping unrelated PRs
6+
# from re-deploying the Worker on every merge.
7+
#
8+
# Required repo secrets:
9+
# CLOUDFLARE_API_TOKEN — token scoped to Workers Scripts:Edit + Workers
10+
# Routes:Edit on the worldmonitor.app zone.
11+
# CLOUDFLARE_ACCOUNT_ID — CF account that owns the Worker.
12+
13+
on:
14+
push:
15+
branches: [main]
16+
paths:
17+
- 'workers/api-cors-preflight/**'
18+
- '.github/workflows/deploy-worker.yml'
19+
workflow_dispatch:
20+
21+
permissions:
22+
contents: read
23+
24+
jobs:
25+
unit-test:
26+
name: Unit tests
27+
runs-on: ubuntu-latest
28+
defaults:
29+
run:
30+
working-directory: workers/api-cors-preflight
31+
steps:
32+
- uses: actions/checkout@v4
33+
- uses: actions/setup-node@v4
34+
with:
35+
node-version: '22'
36+
- name: Install
37+
run: npm install --no-audit --no-fund
38+
- name: Run unit tests
39+
run: node --test index.test.mjs
40+
41+
deploy:
42+
name: Wrangler deploy
43+
needs: unit-test
44+
runs-on: ubuntu-latest
45+
defaults:
46+
run:
47+
working-directory: workers/api-cors-preflight
48+
steps:
49+
- uses: actions/checkout@v4
50+
- uses: actions/setup-node@v4
51+
with:
52+
node-version: '22'
53+
- name: Install
54+
run: npm install --no-audit --no-fund
55+
- name: Deploy
56+
env:
57+
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
58+
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
59+
run: npx wrangler deploy
60+
61+
live-smoke:
62+
name: Live preflight smoke test
63+
needs: deploy
64+
runs-on: ubuntu-latest
65+
steps:
66+
- uses: actions/checkout@v4
67+
- uses: actions/setup-node@v4
68+
with:
69+
node-version: '22'
70+
- name: Wait for Worker propagation
71+
# Cloudflare documents Workers propagation to the global edge as up
72+
# to ~30 s, occasionally longer. A 15 s wait races the in-flight
73+
# propagation window and can either (a) false-green by smoke-testing
74+
# the OLD Worker that's still serving at some PoPs, or (b) false-fail
75+
# transiently. 30 s aligns with the documented propagation SLA.
76+
run: sleep 30
77+
- name: Smoke test live OPTIONS preflight
78+
env:
79+
LIVE_SMOKE: '1'
80+
run: node --test tests/cors-preflight-live.test.mjs

docs/architecture/pro-monetization.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Before creating a session, `getCheckoutBlockingSubscription` checks for active/o
9191
- **Edge endpoints** that accept Clerk JWTs must go through `validateBearerToken` (`server/auth-session.ts`). Applies to `/api/create-checkout`, `/api/customer-portal`, `/api/referral/me`.
9292
- **Middleware UA guard** (`middleware.ts`): short-UA guard 403s non-browser fetches by default. New API endpoints called from Railway cron must be added to `PUBLIC_API_PATHS`.
9393
- **Gateway premium check** (`server/gateway.ts`): accepts either Clerk `publicMetadata.plan === 'pro'` role OR Convex `entitlements.tier >= 1 && validUntil >= now`. Both signals must agree for a request to be treated as paid.
94-
- **CORS**: Cloudflare Worker `api-cors-preflight` is the source of truth for `api.worldmonitor.app`. Overrides `api/_cors.js` + `vercel.json`.
94+
- **CORS**: Cloudflare Worker `api-cors-preflight` is the source of truth for `api.worldmonitor.app`. Overrides `api/_cors.js` + `vercel.json`. Worker source lives at [`workers/api-cors-preflight/`](../../workers/api-cors-preflight/); it short-circuits OPTIONS preflight at the edge (skipping Vercel) and stamps CORS headers onto non-OPTIONS responses on the way back. Unit-tested in `workers/api-cors-preflight/index.test.mjs`, smoke-tested live in `tests/cors-preflight-live.test.mjs` (gated by `LIVE_SMOKE=1`), and deployed by `.github/workflows/deploy-worker.yml` on changes under `workers/api-cors-preflight/`. The Worker's allowlist + Allow-Headers list MUST stay a superset of `api/_cors.js#getCorsHeaders`; drift breaks credentialed CORS site-wide (2026-05-27 outage post-mortem).
9595
- **HMAC identity bridge**: Dodo metadata `wm_user_id` is signed with a server-side key (`convex/lib/identitySigning.ts`) so webhooks can trust the user association without an additional lookup.
9696

9797
## Known gaps & active work

tests/cors-preflight-live.test.mjs

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
// Live CORS preflight smoke test against production.
2+
//
3+
// Gated behind LIVE_SMOKE=1 so it does NOT run in the default PR test gate —
4+
// fetching live api.worldmonitor.app from CI would false-positive during
5+
// deploys, network blips, or Cloudflare incidents.
6+
//
7+
// Run manually before/after a Worker deploy:
8+
// LIVE_SMOKE=1 tsx --test tests/cors-preflight-live.test.mjs
9+
//
10+
// Or wire into a scheduled GitHub Action / Vercel cron if you want continuous
11+
// canary coverage.
12+
//
13+
// What this catches:
14+
// - `Access-Control-Allow-Credentials: true` missing from OPTIONS preflight
15+
// (the 2026-05-27 outage — see worldmonitor-architecture-gotchas/reference/
16+
// cloudflare-worker-overrides-vercel-cors-for-preflight.md).
17+
// - Origin echo broken (preflight echoes `https://worldmonitor.app` for an
18+
// allowed origin → browsers reject as mismatched).
19+
// - Worker bypassed entirely (Vercel fallback served instead — would still
20+
// pass on healthy days but blow up if/when the Worker is re-enabled).
21+
//
22+
// This test deliberately mirrors what a real browser does for CORS preflight,
23+
// so a failure here is a strong signal of a real user-facing outage.
24+
25+
import { strict as assert } from 'node:assert';
26+
import test from 'node:test';
27+
28+
const BROWSER_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
29+
const ORIGIN = 'https://www.worldmonitor.app';
30+
31+
// Endpoints we hit. /api/health is canonical (always available, no auth).
32+
// Add a representative second one to catch route-specific Worker rules if
33+
// anyone ever adds them.
34+
const ENDPOINTS = [
35+
'https://api.worldmonitor.app/api/health',
36+
'https://api.worldmonitor.app/api/bootstrap?tier=fast',
37+
];
38+
39+
const SHOULD_RUN = process.env.LIVE_SMOKE === '1';
40+
41+
if (!SHOULD_RUN) {
42+
test('LIVE smoke gated — set LIVE_SMOKE=1 to run', { skip: true }, () => {});
43+
}
44+
45+
// Public-CORS paths that the Worker MUST pass through to Vercel unchanged.
46+
// External MCP clients (https://claude.ai, https://claude.com) hit these and
47+
// must receive the Vercel function's own CORS policy (typically ACAO: * for
48+
// OAuth/MCP), not the Worker's worldmonitor.app-only echo.
49+
const PUBLIC_CORS_PROBES = [
50+
{ url: 'https://api.worldmonitor.app/api/mcp', origin: 'https://claude.ai' },
51+
{ url: 'https://api.worldmonitor.app/api/oauth/register', origin: 'https://claude.com' },
52+
{ url: 'https://api.worldmonitor.app/api/oauth-protected-resource', origin: 'https://claude.ai' },
53+
];
54+
55+
for (const { url, origin } of PUBLIC_CORS_PROBES) {
56+
test(`OPTIONS ${url} from ${origin} bypasses Worker → Vercel ACAO survives`, { skip: !SHOULD_RUN }, async () => {
57+
const resp = await fetch(url, {
58+
method: 'OPTIONS',
59+
headers: {
60+
Origin: origin,
61+
'User-Agent': BROWSER_UA,
62+
'Access-Control-Request-Method': 'POST',
63+
'Access-Control-Request-Headers': 'content-type',
64+
},
65+
});
66+
await resp.arrayBuffer();
67+
// Acceptance: the response must NOT echo the canonical worldmonitor.app
68+
// fallback (which would mean the Worker short-circuited and the external
69+
// client gets blocked). Either ACAO: * OR ACAO echoes the request origin
70+
// is fine — both are valid public-CORS dispositions.
71+
const acao = resp.headers.get('access-control-allow-origin');
72+
assert.ok(
73+
acao === '*' || acao === origin,
74+
`Public-CORS path ${url} returned ACAO=${acao} for Origin=${origin}; expected '*' or echo. Worker is short-circuiting when it should bypass.`,
75+
);
76+
});
77+
}
78+
79+
for (const url of ENDPOINTS) {
80+
test(`OPTIONS ${url} returns ACAC: true for ${ORIGIN}`, { skip: !SHOULD_RUN }, async () => {
81+
const resp = await fetch(url, {
82+
method: 'OPTIONS',
83+
headers: {
84+
Origin: ORIGIN,
85+
'User-Agent': BROWSER_UA,
86+
'Access-Control-Request-Method': 'GET',
87+
'Access-Control-Request-Headers': 'content-type',
88+
},
89+
});
90+
91+
// Drain body so the socket can be reused.
92+
await resp.arrayBuffer();
93+
94+
assert.equal(
95+
resp.status,
96+
204,
97+
`Preflight should be 204 No Content; got ${resp.status}`,
98+
);
99+
assert.equal(
100+
resp.headers.get('access-control-allow-origin'),
101+
ORIGIN,
102+
'ACAO must echo the request origin (NOT https://worldmonitor.app fallback, NOT *)',
103+
);
104+
assert.equal(
105+
resp.headers.get('access-control-allow-credentials'),
106+
'true',
107+
'ACAC must be present; missing it breaks every credentials:include request site-wide',
108+
);
109+
// Cloudflare may append `accept-encoding` to Vary for compression keying,
110+
// so check that `Origin` is included (case-insensitive) rather than
111+
// asserting exact equality.
112+
const vary = (resp.headers.get('vary') || '').toLowerCase();
113+
assert.ok(
114+
vary.split(',').map((s) => s.trim()).includes('origin'),
115+
`Vary header must include Origin so caches key on origin; got: ${resp.headers.get('vary')}`,
116+
);
117+
const acah = resp.headers.get('access-control-allow-headers') || '';
118+
for (const required of ['Authorization', 'X-WorldMonitor-Key', 'X-Api-Key', 'X-Pro-Key', 'X-Widget-Key']) {
119+
assert.ok(
120+
acah.toLowerCase().includes(required.toLowerCase()),
121+
`ACAH must include ${required}; got: ${acah}`,
122+
);
123+
}
124+
125+
// Worker's Allow-Methods MUST be a superset of every method any api/*
126+
// route advertises. api/product-catalog.js advertises 'GET, DELETE,
127+
// OPTIONS' on its preflight, so DELETE belongs in the global Worker list.
128+
// Missing it silently breaks browser-origin product-catalog purges in
129+
// prod — exactly the regression that PR review caught locally.
130+
const acam = (resp.headers.get('access-control-allow-methods') || '')
131+
.split(',').map((s) => s.trim().toUpperCase());
132+
for (const required of ['GET', 'POST', 'DELETE', 'OPTIONS']) {
133+
assert.ok(
134+
acam.includes(required),
135+
`ACAM must include ${required}; got: ${acam.join(', ')}`,
136+
);
137+
}
138+
});
139+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# api-cors-preflight
2+
3+
Cloudflare Worker bound to `api.worldmonitor.app/*`. Owns CORS at the edge:
4+
short-circuits OPTIONS preflights (without forwarding to Vercel) and stamps
5+
matching CORS headers onto every non-OPTIONS response on the way back to the
6+
browser.
7+
8+
## Why this exists separately from `api/_cors.js`
9+
10+
Three CORS surfaces sit in front of every browser request to `api.worldmonitor.app`:
11+
12+
1. **Cloudflare Worker (this directory)** — sees the request first; the
13+
preflight response the browser actually checks comes from here.
14+
2. **Vercel edge function `api/_cors.js#getCorsHeaders`** — runs per-request
15+
for non-OPTIONS, and supplies CORS headers that the Worker then overrides
16+
with its own copy on the way out.
17+
3. **`vercel.json`** — no longer pins static `/api/*` CORS headers (removed in
18+
PR #3923 because the wildcard `ACAO: *` was incompatible with credentialed
19+
requests).
20+
21+
When the app switched to `credentials: 'include'` (HttpOnly cookies, PR #3913),
22+
the Worker's preflight response was missing
23+
`Access-Control-Allow-Credentials: true`. Repo-side fixes (PR #3923) could not
24+
close the outage because the preflight never reaches Vercel. Moving the Worker
25+
source in-repo means future CORS changes:
26+
27+
- Show up in `git log` / `git blame` / code review / greptile.
28+
- Get unit-tested in this directory (`index.test.mjs`).
29+
- Get smoke-tested against live prod (`tests/cors-preflight-live.test.mjs`).
30+
- Deploy from CI on merge (`.github/workflows/deploy-worker.yml`).
31+
32+
## Deploy
33+
34+
### From CI (preferred)
35+
36+
Merge to `main``.github/workflows/deploy-worker.yml` runs `wrangler deploy`
37+
automatically when `workers/api-cors-preflight/**` changes. Requires repo
38+
secrets:
39+
40+
- `CLOUDFLARE_API_TOKEN` — token with `Workers Scripts:Edit` + `Workers
41+
Routes:Edit` for the `worldmonitor.app` zone.
42+
- `CLOUDFLARE_ACCOUNT_ID` — the CF account that owns the Worker.
43+
44+
### From your laptop (fallback)
45+
46+
```sh
47+
cd workers/api-cors-preflight
48+
npm install
49+
export CLOUDFLARE_API_TOKEN=...
50+
export CLOUDFLARE_ACCOUNT_ID=...
51+
npm run deploy
52+
```
53+
54+
## Tests
55+
56+
```sh
57+
# Unit tests against the Worker module directly (fast, deterministic).
58+
cd workers/api-cors-preflight && npm test
59+
60+
# Live smoke test against prod. Gated by env var so it doesn't run in PR gates
61+
# (false positives during deploys).
62+
LIVE_SMOKE=1 tsx --test tests/cors-preflight-live.test.mjs
63+
```
64+
65+
## Keep in sync
66+
67+
The Worker's allowlist + Allow-Headers list **must be a superset of** what
68+
`api/_cors.js#getCorsHeaders` returns. If the Worker rejects an origin that the
69+
function would accept, the browser sees a mismatched origin echo and CORS
70+
rejects the request. Drift between the two is the load-bearing trap this
71+
package exists to make visible. Update both files together.
72+
73+
## Related learning
74+
75+
`~/.claude/skills/worldmonitor-architecture-gotchas/reference/cloudflare-worker-overrides-vercel-cors-for-preflight.md`
76+
captures the full post-mortem of the 2026-05-27 CORS outage that motivated
77+
pulling the Worker into the repo. Read it before touching this Worker.

0 commit comments

Comments
 (0)