Ratifies the parent's recommendation (Option B — expanded sweep) after a 10-file pattern-drift audit confirmed all 24 targets share the identical scaffolded 3-line CORS block + OPTIONS-if. Records 5 architect-self-ratifiable decisions (D1 sweep-all, D2 delete- OPTIONS-entirely, D3 verb-tightening-moot, D4 no-new-tests, D5 add forbidden-cors-headers CI job). Single mechanical brief (~260 LOC; 240 deletions + ~20-line new CI job) under the 400-LOC per-brief budget. Awaiting human gate 1. Co-authored-by: Cursor <cursoragent@cursor.com>
443 lines
27 KiB
Markdown
443 lines
27 KiB
Markdown
---
|
|
convoy: cors-tighten
|
|
brief_number: 1
|
|
depends_on: []
|
|
files:
|
|
- pages/api/admin/index.js
|
|
- pages/api/auth/verify.js
|
|
- pages/api/cards/[id]/ownership.js
|
|
- pages/api/cards/owned.js
|
|
- pages/api/cards/search.js
|
|
- pages/api/collections.js
|
|
- pages/api/collections/[identifier].js
|
|
- pages/api/collections/[identifier]/activity.js
|
|
- pages/api/collections/[identifier]/cards.js
|
|
- pages/api/collections/[identifier]/permissions.js
|
|
- pages/api/collections/[identifier]/thumbnails.js
|
|
- pages/api/community/collections.js
|
|
- pages/api/favorites.js
|
|
- pages/api/invite/accept.js
|
|
- pages/api/invite/decline.js
|
|
- pages/api/public/collections.js
|
|
- pages/api/user/avatar.js
|
|
- pages/api/user/avatar/generate.js
|
|
- pages/api/user/delete.js
|
|
- pages/api/user/password.js
|
|
- pages/api/user/profile.js
|
|
- pages/api/user/settings.js
|
|
- pages/api/user/stats.js
|
|
- pages/api/users/search.js
|
|
- .github/workflows/ci.yml
|
|
---
|
|
|
|
# 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 by `fix-auth-bypass` Brief 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 queued `fill-vitest-handler-coverage` convoy.
|
|
- `tests/smoke/app.smoke.spec.ts`, `tests/visual/**` — smoke + visual suite is same-origin and unaffected; do NOT modify.
|
|
- Any `.github/workflows/*.yml` file other than `ci.yml` (preview-smoke / visual-diff are owned by `adopt-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` (commit `297afca`).
|
|
- **Decision D3:** `pages/api/auth/verify.js`'s over-permissive `Allow-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-headers` job to `.github/workflows/ci.yml`, modeled on the existing `forbidden-endpoints` job. Fails the build if any `Access-Control-Allow-Origin` reappears under `pages/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.yml` is editable per `fix-auth-bypass` Brief 3 precedent (which added the `forbidden-endpoints` job).
|
|
- **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 of `login.js` + `register.js`: the leading `// Set CORS headers` comment, the three `res.setHeader('Access-Control-Allow-*', ...)` calls, the leading `// Handle preflight requests` comment, and the `if (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-endpoints` job verbatim: bash heredoc with a `BAD_PATHS` array OR a single `grep -r`-style scan, `::error::` annotation, `exit 1` on hit. No `continue-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-Origin` references.
|
|
- [ ] Zero `Access-Control-Allow-Methods` references.
|
|
- [ ] Zero `Access-Control-Allow-Headers` references.
|
|
- [ ] Zero `if (req.method === 'OPTIONS')` blocks.
|
|
- [ ] Zero `// Set CORS headers` comments.
|
|
- [ ] Zero `// Handle preflight requests` comments.
|
|
- [ ] The first executable line(s) of `export default async function handler(req, res) {` are now the existing method check (Pattern A) OR the existing `try { ... } catch` block (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:
|
|
|
|
```js
|
|
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:
|
|
|
|
```js
|
|
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:
|
|
|
|
```js
|
|
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:
|
|
|
|
```js
|
|
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-headers` to the `jobs:` block, sequenced AFTER the existing `forbidden-endpoints` job and BEFORE `test`. Verbatim shape:
|
|
|
|
```yaml
|
|
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 `|| true` wrapper, matches the existing `forbidden-endpoints` shape 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-endpoints` and `test` in the YAML; insertion-order matches the logical grouping (both `forbidden-*` 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 existing `lint`, `schema-map-fresh`, `forbidden-endpoints`, and `test` jobs all stay byte-identical. The `env:` block, `on:`, `concurrency:`, and `NODE_VERSION` stay untouched.
|
|
|
|
### Cross-file checks
|
|
|
|
- [ ] **Repo-wide grep clean.** After the sweep:
|
|
|
|
```bash
|
|
rg 'Access-Control-Allow-Origin' pages/api/
|
|
```
|
|
|
|
Expected: zero matches. (`rg` exits 1 on no-match by default; that's the success state. If you prefer `grep`, `grep -r 'Access-Control-Allow-Origin' pages/api/ || echo "OK"` is equivalent.)
|
|
|
|
Same check for `Access-Control-Allow-Methods` and `Access-Control-Allow-Headers` — both should return zero matches.
|
|
|
|
- [ ] **Repo-wide grep for OPTIONS preflight clean.**
|
|
|
|
```bash
|
|
rg "if \(req\.method === 'OPTIONS'\)" pages/api/
|
|
```
|
|
|
|
Expected: zero matches. (Same `rg`-on-no-match exit-1 semantics.)
|
|
|
|
- [ ] **`npm run lint` exit code unchanged.** The current baseline is `✖ 128 problems (81 errors, 47 warnings)` (per `bump-next-js` § Decision D and `fix-lint-baseline` tracking). 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 unused `req` access 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 build` exit 0.** Turbopack compile time should be unchanged (~1-2s per `bump-next-js`). The 24 modified handlers still export the same `default async function handler` signature; their compiled output is purely smaller.
|
|
|
|
- [ ] **Smoke spec still passes locally and in CI.** `tests/smoke/app.smoke.spec.ts` hits `/` (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's `extraHTTPHeaders` bypass), so even if it hit a swept handler, the CORS removal would be irrelevant. Run `npm run test:smoke` locally against `next dev` to verify.
|
|
|
|
- [ ] **CI `forbidden-cors-headers` job actually fires the regression-lock.** As a one-shot local sanity check before commit (do NOT commit the temporary line):
|
|
|
|
```bash
|
|
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.js
|
|
```
|
|
|
|
Expected: the grep matches the injected line, confirming the new job would block. Then revert.
|
|
|
|
- [ ] **Diff hygiene.** `git diff main..HEAD --stat` should show:
|
|
- 24 `pages/api/*.js` files with **only deletions** (each ~9-11 lines removed, no additions per file).
|
|
- 1 `.github/workflows/ci.yml` with **only additions** (~20-25 lines for the new job block).
|
|
- No whitespace-only changes elsewhere.
|
|
|
|
### `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:
|
|
|
|
```js
|
|
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:
|
|
|
|
```bash
|
|
rg -c 'Access-Control-Allow-Origin' pages/api/ | sort
|
|
```
|
|
|
|
Expected output: 24 lines, each with `:1` (one `Access-Control-Allow-Origin` reference per file). If any file shows `:2` or 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 a `git diff <file>` review — the diff for each should be 9-11 lines deletion only, no additions.
|
|
|
|
- [ ] **Post-sweep grep verification.**
|
|
|
|
```bash
|
|
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:
|
|
|
|
```bash
|
|
rg "req\.method === 'OPTIONS'" pages/api/
|
|
```
|
|
|
|
- [ ] **Per-file pre/post line-count parity for the 24 files.** For each file:
|
|
|
|
```bash
|
|
for f in $(rg -l 'export default async function handler' pages/api/); do
|
|
echo "$f: $(wc -l < "$f") lines"
|
|
done
|
|
```
|
|
|
|
Compare 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.**
|
|
|
|
```bash
|
|
npm run build
|
|
```
|
|
|
|
Expected: Turbopack compile success, 23 static pages + 47 API routes per the post-`bump-next-js` baseline. 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 via `curl` to confirm they still 200 / 401 / 405 correctly:
|
|
|
|
```bash
|
|
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 any `Access-Control-Allow-*` header.
|
|
|
|
- [ ] **Smoke spec passes locally.**
|
|
|
|
```bash
|
|
npm run dev # in one terminal
|
|
BASE_URL=http://localhost:3000 npm run test:smoke # in another
|
|
```
|
|
|
|
Expected: 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-headers` job 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 explicit `file=` + `line=` pointer.
|
|
|
|
- [ ] **Vitest pass count unchanged.**
|
|
|
|
```bash
|
|
npm run test:run 2>&1 | tail -5
|
|
```
|
|
|
|
Expected: `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 `else` branch (after the try block does its `getUserFromRequest` + 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.js` helper, a middleware layer, or any abstraction. The right answer here is "no CORS at all", same as Brief 4 settled on for `login.js` + `register.js`.
|
|
- [ ] Do NOT replace the wildcard with a specific origin (`https://tcgvault.com` or the preview URL). The branding is unresolved (queued `pick-a-name` convoy) and the same-origin deployment makes the header unnecessary anyway. YAGNI.
|
|
- [ ] Do NOT add a `next.config.js` `headers()` block to enforce CORS globally — that's the inverse of this convoy's intent (no CORS, anywhere).
|
|
- [ ] Do NOT modify `pages/api/auth/login.js` or `pages/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-*.js` file — listed under no-go zones (`AGENTS.md` Common Gotcha #3) and didn't carry the wildcard anyway.
|
|
- [ ] Do NOT tighten `Allow-Methods` verb lists pre-deletion (e.g., `'GET, POST, PUT, DELETE, OPTIONS'` → `'GET, OPTIONS'` on `verify.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.mdc` or `AGENTS.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.yml` or `visual-diff.yml` — owned by `adopt-playwright-smoke` and `fix-vercel-deployment-protection-in-ci`.
|
|
- [ ] Do NOT add a `continue-on-error: true` to the new `forbidden-cors-headers` job — it is BLOCKING per Decision D5 (mirrors `forbidden-endpoints`).
|
|
- [ ] Do NOT broaden the grep in the new CI job to scan outside `pages/api/`. The convoy's scope is the API surface. If `lib/` or `components/` ever grows a CORS reference, that's a separate concern and a separate convoy.
|
|
- [ ] Do NOT run `npm audit fix` as part of this brief. The sweep does not change `package.json` or `package-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.
|