Closes P0 #5 from PARTIAL to RESOLVED. Sweeps the remaining 24 pages/api/** handlers that carried the identical scaffolded wildcard-CORS + OPTIONS preflight pattern (Brief 4 cleaned login + register; this finishes the job). Adds a blocking forbidden-cors-headers CI job modeled on forbidden-endpoints to lock the cleanup against future regression. 25 files changed (+29/-261). Local: lint 128 baseline, vitest 21/21, zero CORS matches, YAML valid. CI: Playwright smoke 3/3 in 3.3s against post-removal preview (login/verify flow still works), new forbidden-cors-headers job passes in 4s, all gates green. PR #19 architect-commitec22b70, implementer-commita843736.
27 KiB
| convoy | brief_number | depends_on | files | |||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| cors-tighten | 1 |
|
Brief 1: Sweep wildcard Access-Control-Allow-Origin from all 24 remaining API handlers + add CI regression-lock
Goal (1 sentence)
Mechanically delete the identical scaffolded 3-line wildcard CORS block (Access-Control-Allow-Origin: '*' + Allow-Methods + Allow-Headers) and the redundant if (req.method === 'OPTIONS') preflight branch from all 24 pages/api/**/*.js files that still carry them, matching the precedent set by fix-auth-bypass Brief 4 (commit 297afca) on login.js + register.js, then add a new blocking forbidden-cors-headers job to .github/workflows/ci.yml (modeled on the existing forbidden-endpoints job) so the cleanup can't accumulate again.
Files in scope (do not edit anything else)
The 24 source files listed in files: above (all modified, no new files, no deletions), plus .github/workflows/ci.yml (modified — add one new job).
Files explicitly out of scope (do not touch even if it seems related):
pages/api/auth/login.js,pages/api/auth/register.js— already cleaned byfix-auth-bypassBrief 4. Re-verify post-edit that they remain CORS-free, but do NOT modify them.pages/api/health.js— never had the wildcard CORS block; not in scope.pages/api/cards/import-*.js— listed under no-go zones (external API rate limits, run-against-staging-only). None of them carry the wildcard CORS block today (parent's grep enumerated only the 24 in this brief). Do NOT touch.lib/permission-middleware.js,lib/rate-limit.js,lib/auth-secret.js— auth surface is untouched by this convoy..cursor/rules/api-routes.mdc— adding a "no CORS" convention is a doc-writer pass at convoy close, NOT this brief.AGENTS.md— same as above; doc-writer owns it.test/**— no per-route handler tests are in scope this convoy (Decision D4 in the convoy file). Adding handler-level tests is the queuedfill-vitest-handler-coverageconvoy.tests/smoke/app.smoke.spec.ts,tests/visual/**— smoke + visual suite is same-origin and unaffected; do NOT modify.- Any
.github/workflows/*.ymlfile other thanci.yml(preview-smoke / visual-diff are owned byadopt-playwright-smoke/fix-vercel-deployment-protection-in-ci).
Conventions to follow
Decisions from the convoy file (cite when implementing)
- Decision D1 (
.convoys/cors-tighten.md§ Decisions): Option B — sweep all 24 files in one PR. Architect-ratified after a 10-file pattern-drift audit confirmed all 24 share the identical scaffolded shape. - Decision D2: Delete the OPTIONS preflight handler entirely. Method-check (whether at the top of the handler or branched inside the try block) safely returns 405 for any future OPTIONS request. Matches Brief 4 precedent for
login.js+register.js(commit297afca). - Decision D3:
pages/api/auth/verify.js's over-permissiveAllow-Methods: 'GET, POST, PUT, DELETE, OPTIONS'is moot — the entire 3-setHeader block is deleted under D2. - Decision D4: No new per-route handler tests in this convoy. Smoke + vitest are unchanged and continue to defend against regression at the boundary they already cover.
- Decision D5: Add a new
forbidden-cors-headersjob to.github/workflows/ci.yml, modeled on the existingforbidden-endpointsjob. Fails the build if anyAccess-Control-Allow-Originreappears underpages/api/.
Repo conventions (cite + match)
- No-go zones (
.cursor/rules/no-go-zones.mdc). None of the 24 source files are listed..github/workflows/ci.ymlis editable perfix-auth-bypassBrief 3 precedent (which added theforbidden-endpointsjob). - API-routes rule (
.cursor/rules/api-routes.mdc). The rule does not currently mention CORS. After this convoy ships, the doc-writer pass will add a one-line "no CORS headers on same-origin Vercel deployment" note; do NOT preempt that edit in this brief. - Brief 4 precedent shape (commit
297afca). That commit deleted, from each oflogin.js+register.js: the leading// Set CORS headerscomment, the threeres.setHeader('Access-Control-Allow-*', ...)calls, the leading// Handle preflight requestscomment, and theif (req.method === 'OPTIONS') { res.status(200).end(); return; }block. Nothing else changed. Apply the same edit 24 times. - CI YAML style. Match the existing
forbidden-endpointsjob verbatim: bash heredoc with aBAD_PATHSarray OR a singlegrep -r-style scan,::error::annotation,exit 1on hit. Nocontinue-on-error. The job is BLOCKING per Decision D5.
Acceptance criteria
Per-file edits (all 24 source files)
Each of the 24 files in files: (excluding ci.yml) MUST end up with:
- Zero
Access-Control-Allow-Originreferences. - Zero
Access-Control-Allow-Methodsreferences. - Zero
Access-Control-Allow-Headersreferences. - Zero
if (req.method === 'OPTIONS')blocks. - Zero
// Set CORS headerscomments. - Zero
// Handle preflight requestscomments. - The first executable line(s) of
export default async function handler(req, res) {are now the existing method check (Pattern A) OR the existingtry { ... } catchblock (Pattern B). Nothing else is reordered.
Two distinct pre-edit shapes exist among the 24 (both safe to sweep mechanically — see § Boot-the-brief findings, Finding 2):
Pattern A — top-level method gate after the CORS block. Example: pages/api/auth/verify.js.
Before:
export default async function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Handle preflight requests
if (req.method === 'OPTIONS') {
res.status(200).end();
return;
}
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// ... handler body ...
After:
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
// ... handler body ...
Pattern B — method-branched inside the try block (no top-level method gate). Example: pages/api/collections/[identifier].js, pages/api/collections/[identifier]/permissions.js, pages/api/collections.js.
Before:
export default async function handler(req, res) {
// Set CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
// Handle preflight requests
if (req.method === 'OPTIONS') {
res.status(200).end();
return;
}
try {
// ... handler that routes on req.method internally ...
After:
export default async function handler(req, res) {
try {
// ... handler that routes on req.method internally ...
In both shapes, the edit is purely a deletion. No new lines are added. No re-indentation. Preserve the blank line that already sits between the deleted block and what follows (matches Brief 4's commit style).
.github/workflows/ci.yml (modified — new job)
- Add a new job named
forbidden-cors-headersto thejobs:block, sequenced AFTER the existingforbidden-endpointsjob and BEFOREtest. Verbatim shape:
forbidden-cors-headers:
name: No wildcard CORS in pages/api
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Fail if any pages/api/ handler carries Access-Control-Allow-Origin
run: |
# The tcg-vault frontend and API are served from the same Vercel
# deployment (same origin), so CORS headers serve no purpose and
# are a documented attack surface (see .convoys/cors-tighten.md
# and AGENTS.md Gotcha #5). Brief 4 of fix-auth-bypass cleaned
# login.js + register.js; the cors-tighten convoy swept the
# remaining 24 files. This job locks the cleanup in.
#
# If a future cross-origin caller is legitimately needed, design
# a proper CORS layer (probably via middleware) rather than
# scaffolding wildcards into individual handlers.
MATCHES=$(grep -rEn 'Access-Control-Allow-(Origin|Methods|Headers)' pages/api/ 2>/dev/null || true)
if [ -n "$MATCHES" ]; then
echo "::error::Forbidden CORS headers present under pages/api/. Remove them — same-origin Vercel deployment does not need CORS."
echo "$MATCHES" | while IFS= read -r line; do
file=$(echo "$line" | cut -d: -f1)
lineno=$(echo "$line" | cut -d: -f2)
echo "::error file=${file},line=${lineno}::Forbidden CORS header — delete this line."
done
exit 1
fi
echo "OK: no Access-Control-Allow-* headers under pages/api/."
Notes:
-
The job is BLOCKING (no
continue-on-error, no|| truewrapper, matches the existingforbidden-endpointsshape per Decision D5). -
The grep pattern matches all three forbidden header families in one pass — Origin, Methods, Headers. A real cross-origin layer (some future convoy) would NOT set these in handlers; it would set them in middleware. So this regression-lock won't be in the way of a legitimate future CORS design.
-
The job sits between
forbidden-endpointsandtestin the YAML; insertion-order matches the logical grouping (bothforbidden-*checks are static-source guards before the runtime test job). -
No new dependencies, no new caching, no
actions/setup-node— the grep is plain bash on the runner. -
No other change to
ci.yml. The existinglint,schema-map-fresh,forbidden-endpoints, andtestjobs all stay byte-identical. Theenv:block,on:,concurrency:, andNODE_VERSIONstay untouched.
Cross-file checks
-
Repo-wide grep clean. After the sweep:
rg 'Access-Control-Allow-Origin' pages/api/Expected: zero matches. (
rgexits 1 on no-match by default; that's the success state. If you prefergrep,grep -r 'Access-Control-Allow-Origin' pages/api/ || echo "OK"is equivalent.)Same check for
Access-Control-Allow-MethodsandAccess-Control-Allow-Headers— both should return zero matches. -
Repo-wide grep for OPTIONS preflight clean.
rg "if \(req\.method === 'OPTIONS'\)" pages/api/Expected: zero matches. (Same
rg-on-no-match exit-1 semantics.) -
npm run lintexit code unchanged. The current baseline is✖ 128 problems (81 errors, 47 warnings)(perbump-next-js§ Decision D andfix-lint-baselinetracking). The sweep is pure deletion of method calls + control-flow blocks; it should NOT introduce any new lint findings, and most likely will reduce the count slightly (each deleted unusedreqaccess could clear a no-unused-expressions warning). If the count grows, investigate before commit. -
npm run test:run(vitest) passes 21/21. The sweep does not touch any module that has a vitest spec (lib/auth-secret.js,lib/permission-middleware.js,pages/api/auth-utils.js,components/Layout.js). Test count and pass/fail status MUST be unchanged. -
npm run buildexit 0. Turbopack compile time should be unchanged (~1-2s perbump-next-js). The 24 modified handlers still export the samedefault async function handlersignature; their compiled output is purely smaller. -
Smoke spec still passes locally and in CI.
tests/smoke/app.smoke.spec.tshits/(homepage),/login, and/api/health— none of which are in the 24 swept files. The smoke spec is also same-origin (it talks to the Vercel preview URL directly via Playwright'sextraHTTPHeadersbypass), so even if it hit a swept handler, the CORS removal would be irrelevant. Runnpm run test:smokelocally againstnext devto verify. -
CI
forbidden-cors-headersjob actually fires the regression-lock. As a one-shot local sanity check before commit (do NOT commit the temporary line):echo "res.setHeader('Access-Control-Allow-Origin', '*');" >> pages/api/health.js grep -rEn 'Access-Control-Allow-(Origin|Methods|Headers)' pages/api/ && echo "FAIL EXPECTED — job would block" git checkout pages/api/health.jsExpected: the grep matches the injected line, confirming the new job would block. Then revert.
-
Diff hygiene.
git diff main..HEAD --statshould show:- 24
pages/api/*.jsfiles with only deletions (each ~9-11 lines removed, no additions per file). - 1
.github/workflows/ci.ymlwith only additions (~20-25 lines for the new job block). - No whitespace-only changes elsewhere.
- 24
pages/api/auth/verify.js post-edit verbatim shape
Because verify.js was the originally-documented narrow target of this convoy (and the architect's primary spot-check file), the post-edit shape is locked here as the canonical reference for the other 23 files:
import { sql } from '@vercel/postgres';
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from '../../../lib/auth-secret.js';
export default async function handler(req, res) {
if (req.method !== 'GET') {
return res.status(405).json({ error: 'Method not allowed' });
}
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
const token = authHeader.substring(7);
try {
const decoded = jwt.verify(token, JWT_SECRET);
// Get user data from database
const result = await sql`
SELECT id, email, role, created_at
FROM users
WHERE id = ${decoded.userId}
`;
if (result.rows.length === 0) {
return res.status(401).json({ error: 'User not found' });
}
const user = result.rows[0];
res.status(200).json(user);
} catch (jwtError) {
console.error('JWT verification error:', jwtError);
return res.status(401).json({ error: 'Invalid token' });
}
} catch (error) {
console.error('Auth verification error:', error);
res.status(500).json({ error: 'Internal server error' });
}
}
Net: 10 lines deleted (3 setHeader calls + 4-line OPTIONS-if block + 2 leading // comments + 1 blank line). No additions.
Manual verification (in addition to CI on push)
Run these in order. Paste relevant output (with secrets redacted) into the PR description.
-
Pre-sweep baseline. Capture the current grep state:
rg -c 'Access-Control-Allow-Origin' pages/api/ | sortExpected output: 24 lines, each with
:1(oneAccess-Control-Allow-Originreference per file). If any file shows:2or higher, an unanticipated drift exists — STOP, investigate, and flag back to the architect before sweeping. -
Apply the sweep. Edit each of the 24 files per the Pattern A / Pattern B shapes above. A
sed-style mechanical edit is acceptable but verify each file post-edit with agit diff <file>review — the diff for each should be 9-11 lines deletion only, no additions. -
Post-sweep grep verification.
rg 'Access-Control-Allow-Origin|Access-Control-Allow-Methods|Access-Control-Allow-Headers' pages/api/Expected: zero matches (exit 1 on no-match for
rg). Same for the OPTIONS-if pattern:rg "req\.method === 'OPTIONS'" pages/api/ -
Per-file pre/post line-count parity for the 24 files. For each file:
for f in $(rg -l 'export default async function handler' pages/api/); do echo "$f: $(wc -l < "$f") lines" doneCompare to a
git show main:<path>snapshot. Each of the 24 should drop by 9-11 lines; the other 3 (login.js,register.js,health.js) stay unchanged. -
Local build smoke.
npm run buildExpected: Turbopack compile success, 23 static pages + 47 API routes per the post-
bump-next-jsbaseline. Any "Module not found" or "Unexpected token" failure means the sweep landed mid-statement on some file — review that file's diff manually. -
Local dev-server functional check (representative sample). Boot
npm run dev, then hit a few of the swept routes viacurlto confirm they still 200 / 401 / 405 correctly:curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/auth/verify # expect 401 (no Bearer) curl -sS -o /dev/null -w "%{http_code}\n" -X GET http://localhost:3000/api/cards/search # expect 401 (no Bearer) or 200 if anon allowed curl -sS -o /dev/null -w "%{http_code}\n" -X OPTIONS http://localhost:3000/api/auth/verify # expect 405 (Pattern A) — was 200 pre-sweep curl -sS -o /dev/null -w "%{http_code}\n" -X OPTIONS http://localhost:3000/api/collections # expect 405 (Pattern B fall-through) — was 200 pre-sweep curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/public/collections # expect 200 (anonymous, GET) curl -sSI http://localhost:3000/api/public/collections | grep -i 'access-control' || echo "OK: no CORS headers in response"The last check is the key assertion: the response from
public/collections.js(the most "intentionally public" of the 24) MUST not carry anyAccess-Control-Allow-*header. -
Smoke spec passes locally.
npm run dev # in one terminal BASE_URL=http://localhost:3000 npm run test:smoke # in anotherExpected: 3/3 tests pass. If any fails, the sweep accidentally hit a smoke-touched path — investigate (unlikely since smoke targets
/,/login,/api/health, none of which are in scope). -
CI
forbidden-cors-headersjob fires on push. After committing and pushing:- The new job appears in the PR's CI checks list.
- It exits 0 (no matches) on this branch.
- As a sanity probe (don't actually push this), if you push a one-line revert of
pages/api/auth/verify.js's CORS block, the job MUST exit 1 with the documented::error::annotation and an explicitfile=+line=pointer.
-
Vitest pass count unchanged.
npm run test:run 2>&1 | tail -5Expected:
Tests 21 passed (21). If the count or any individual test changes, the sweep was not the pure deletion it should have been.
Boot-the-brief findings (preempted by the architect; do not re-investigate)
Finding 1 — Both shapes (Pattern A and Pattern B) are safe to sweep mechanically
The architect read 10 of 24 files (parent spot-checked 3 + architect spot-checked 7 additional). All 10 share the IDENTICAL 3-line CORS block + IDENTICAL OPTIONS-if block. The only structural variation across the 24 is whether the file has a top-level method gate IMMEDIATELY after the OPTIONS block (Pattern A — verify.js, admin/index.js, user/avatar/generate.js, cards/[id]/ownership.js, invite/accept.js, public/collections.js, cards/owned.js, community/collections.js, cards/search.js, invite/decline.js, and likely several more) OR routes by method inside the try block (Pattern B — collections.js, collections/[identifier].js, collections/[identifier]/permissions.js, user/avatar.js, and likely several more). In both shapes, the edit is purely a deletion of the same 9-11 lines (the comment + 3 setHeader calls + the OPTIONS-if). No re-indentation, no re-flow, no behavior change to the post-block code. Post-sweep, an OPTIONS request returns 405 (Pattern A) or falls through to the else { 405 } branch inside the try block (Pattern B) — both strictly safer than the pre-sweep 200-to-everyone.
Finding 2 — No file uses withCollectionPermission(...)
The convoy file's stress-test concern about CORS headers being inside vs outside a withCollectionPermission wrapper turned out to be moot: rg withCollectionPermission pages/api/ returns zero files. The wrapper is documented in .cursor/rules/api-routes.mdc but no current route actually uses it (collection-scoped routes like collections/[identifier]/permissions.js instead call getUserFromRequest directly inside the handler body). So there's no wrap-shape preservation concern.
Finding 3 — No file uses checkAuthRateLimit(...)
Only login.js and register.js import lib/rate-limit.js (post-Brief-4). None of the 24 swept files do. So there's no rate-limit-gate ordering concern. (If a future convoy adds rate limiting to any of the 24, that convoy will sequence the gate the same way Brief 4 did: method check → rate-limit gate → body parsing.)
Finding 4 — pages/api/public/collections.js is NOT a special case
It is GET-only, returns featured public-collections metadata anonymously, and has no documented external consumer. The same-origin Vercel deployment means the existing frontend reaches it without needing the wildcard. If a third-party app ever needs to call this endpoint cross-origin, design a proper CORS layer at that point (probably via Next.js middleware). YAGNI now; sweep it like any other file.
Finding 5 — pages/api/auth/verify.js's Allow-Methods list was over-permissive but it's moot post-sweep
The pre-sweep header read 'GET, POST, PUT, DELETE, OPTIONS' even though the route's actual gate is if (req.method !== 'GET') return 405. Decision D3 in the convoy file calls this out as a no-op because the entire Allow-Methods line is being deleted. Do NOT tighten the verb list — just delete the line.
Finding 6 — Pre-sweep OPTIONS responses currently return 200 with no body
A quick same-origin curl confirms the pre-sweep behavior:
$ curl -sS -o /dev/null -w "%{http_code}\n" -X OPTIONS http://localhost:3000/api/auth/verify
200
Post-sweep behavior (per the new code path):
- Pattern A files: 405 from the top-level method gate.
- Pattern B files: 405 from the in-try
elsebranch (after the try block does itsgetUserFromRequest+ identifier parsing). The body work is wasted but the response is correct.
This is a deliberate behavior change — the convoy spec's success metric explicitly states: "Browser-issued cross-origin POSTs to the auth surface return a CORS error instead of succeeding." A 405 on OPTIONS (or no response at all if the browser's same-origin policy intervenes first) is the desired end state.
Finding 7 — .github/workflows/ci.yml's existing forbidden-endpoints job is the right precedent shape
The new forbidden-cors-headers job uses the same idioms: actions/checkout@v4, plain bash, ::error:: annotation with file= + line= pointers, exit 1 on hit. No npm ci, no setup-node, no caching — the grep is a static-source check on the checked-out tree. The job sits between forbidden-endpoints and test in the YAML for logical grouping (both forbidden-* checks are static-source guards before the runtime test job).
Finding 8 — .cursor/rules/no-go-zones.mdc audit passed
None of the 24 source files are listed under no-go zones. .github/workflows/ci.yml is editable per the fix-auth-bypass Brief 3 precedent (which added the forbidden-endpoints job). No scripts/add-*.js / scripts/fix-*.js / scripts/seed-*.js files are touched. Safe to sweep.
Finding 9 — Smoke + vitest defense remains intact
The smoke spec (tests/smoke/app.smoke.spec.ts) covers /, /login, /api/health — none in scope. Vitest covers lib/auth-secret.js, lib/permission-middleware.js, pages/api/auth-utils.js, components/Layout.js — none in scope. So the sweep ships with no per-route regression coverage for the 24 routes themselves, which the convoy file acknowledges and defers to the queued fill-vitest-handler-coverage convoy. The architect's recommendation NOT to add new tests in this convoy (Decision D4) is the right call: handler-level test scaffolding is its own scope.
Out of scope (do not do these)
- Do NOT introduce a new
lib/cors.jshelper, a middleware layer, or any abstraction. The right answer here is "no CORS at all", same as Brief 4 settled on forlogin.js+register.js. - Do NOT replace the wildcard with a specific origin (
https://tcgvault.comor the preview URL). The branding is unresolved (queuedpick-a-nameconvoy) and the same-origin deployment makes the header unnecessary anyway. YAGNI. - Do NOT add a
next.config.jsheaders()block to enforce CORS globally — that's the inverse of this convoy's intent (no CORS, anywhere). - Do NOT modify
pages/api/auth/login.jsorpages/api/auth/register.js— already cleaned by Brief 4. - Do NOT modify
pages/api/health.js— never had the wildcard; not in scope. - Do NOT modify any
pages/api/cards/import-*.jsfile — listed under no-go zones (AGENTS.mdCommon Gotcha #3) and didn't carry the wildcard anyway. - Do NOT tighten
Allow-Methodsverb lists pre-deletion (e.g.,'GET, POST, PUT, DELETE, OPTIONS'→'GET, OPTIONS'onverify.js). The whole line is deleted; tightening it first is wasted edit churn (Decision D3). - Do NOT add per-route handler tests in this convoy (Decision D4). Queued as
fill-vitest-handler-coverage. - Do NOT touch
.cursor/rules/api-routes.mdcorAGENTS.md— doc-writer pass at convoy close owns those (a one-line "no CORS headers on same-origin Vercel deployment" note will be added there, not here). - Do NOT touch
preview-smoke.ymlorvisual-diff.yml— owned byadopt-playwright-smokeandfix-vercel-deployment-protection-in-ci. - Do NOT add a
continue-on-error: trueto the newforbidden-cors-headersjob — it is BLOCKING per Decision D5 (mirrorsforbidden-endpoints). - Do NOT broaden the grep in the new CI job to scan outside
pages/api/. The convoy's scope is the API surface. Iflib/orcomponents/ever grows a CORS reference, that's a separate concern and a separate convoy. - Do NOT run
npm audit fixas part of this brief. The sweep does not changepackage.jsonorpackage-lock.json.
Rationale (≤3 sentences)
The 24 wildcard CORS blocks across pages/api/** are scaffolding cruft from the original route templates; the same-origin Vercel deployment makes them serve no legitimate purpose, and the wildcard plus credential-stuffing rate-limit gap is the documented P0 #5 remainder from fix-auth-bypass Brief 4. A single mechanical sweep matches Brief 4's precedent shape exactly (same delete-the-3-setHeaders + delete-the-OPTIONS-if pattern, applied 24 times instead of 2) and is reviewable as one PR because every file's diff is structurally identical. Adding the forbidden-cors-headers CI job in the same PR locks the cleanup in — 24 files is enough surface that a future scaffold-style PR could easily re-introduce the pattern without the gate.