Architect pass for P0 security-critical convoy (closes ship-blockers #1, #2, #4, #5, #6 partial). Produces 5 briefs with explicit slice_dependencies for /multitask fan-out. Decomposition: - Brief 1: Central JWT secret helper + 24h token TTL (8 files, ~120 LOC) - Brief 2: Remove the synthetic-admin bypass (2 files, ~25 LOC net negative) - Brief 3: Delete 4 dev-only endpoints + CI guard (6 files, ~30 LOC) - Brief 4: Tighten auth surface — CORS + rate limit (5 files, ~150 LOC) - Brief 5: Install vitest + auth tests + re-enable CI test job (8 files, ~280 LOC) Total estimate: ~600 LOC across 5 PRs. All under 400-LOC budget. Wave A (parallel from t=0): Briefs 1 + 3 (disjoint files) Wave B (parallel after Brief 1): Briefs 2 + 4 (disjoint subsets of Brief 1's exports) Wave C (after Briefs 2 + 4): Brief 5 alone (lockfile sequencing + functional dep on Brief 2's null contract) Architect's calls (3 decisions documented in convoy file Decisions log): - Token TTL = 24h (matches current login.js UX; security-conservative) - Rate-limit = @upstash/ratelimit@^2.0.8 + @upstash/redis@^1.38.0 (DIY-Postgres needs schema change OOS; DIY-memory broken on Vercel cold starts; next-rate-limit is stale) - Vitest in this convoy (not split to adopt-vitest); pinned to ^3.2.4 to dodge vitest@4's non-optional vite peer dep Boot-the-brief findings (9 verifications, 0 revisions): - 24/24 getUserFromRequest callers already handle null correctly — Brief 2 is safer than the convoy file predicted - 7 JWT_SECRET literal sites match AGENTS.md gotcha #3 exactly - Dev endpoints have zero runtime references (only doc references) — safe to delete - Cross-brief commitments declared in both directions for every Brief-1 -> {2,4,5} pair Risk list: 12 risks documented (R1-R12). Headlines: - R1: JWT_SECRET fail-loud throws may break unexpected import chains - R3: existing tokens stop verifying once literal fallback removed (one-time "log back in" pre-launch is acceptable) - R5-R6: rate-limit IP extraction + Upstash quota; fail-open mitigation - R10: JWT_SECRET rotation now requires a deploy (no silent fallback) Pre-merge env-var checklist (user action required before Brief 4 ships): - UPSTASH_REDIS_REST_URL (new — Vercel project settings) - UPSTASH_REDIS_REST_TOKEN (new — Vercel project settings) - JWT_SECRET (verify already set — no fallback any more) Awaiting human gate 1 (plan approval) before implementers run. Co-authored-by: Cursor <cursoragent@cursor.com>
6.9 KiB
| convoy | brief_number | depends_on | files | cross_brief_commitments | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| fix-auth-bypass | 2 |
|
|
|
Brief 2: Remove the synthetic-admin bypass
Goal (1 sentence)
Make lib/permission-middleware.js::getUserFromRequest return null for any unauthenticated request, and make pages/api/auth/verify.js return 401 instead of fetching admin@tcgvault.com when no Bearer token is present.
Files in scope (do not edit anything else)
lib/permission-middleware.js— modifiedpages/api/auth/verify.js— modified
Conventions to follow
.cursor/rules/auth-and-permissions.mdc§ "Server-side authorization patterns" —if (!user) return res.status(401)pattern. The 24 callers ofgetUserFromRequestalready follow this; we just need to make the helper actually emitnull..cursor/rules/api-routes.mdc§ "Error handling" — keep thetry/catchwrapper in place; do not throw out of the handler..cursor/rules/no-go-zones.mdc— do not touch any other file.
Acceptance criteria
lib/permission-middleware.js::getUserFromRequest
- Delete lines 14-17 of the post-Brief-1 file (the
console.warnand the synthetic admin return). Replace with a plainreturn null. Verbatim shape:
// before (post-Brief-1, with literal already gone):
if (!authHeader || !authHeader.startsWith('Bearer ')) {
// For development, return user ID 1 if no token (should be removed in production)
console.warn('⚠️ Development mode: Using fallback user authentication');
return { userId: 1, email: 'admin@tcgvault.com', role: 'admin' };
}
// after:
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return null;
}
- No
console.warn. No comment-out. No env-gate (NODE_ENV === 'development'). The fallback is gone, period. If a developer needs an authenticated session locally, they log in. - The rest of
getUserFromRequest(token verify, DB lookup, error catch) is unchanged. - The catch block at lines 38-41 stays:
} catch (error) {
console.error('Error getting user from request:', error);
return null;
}
This means JWT verification errors (expired, malformed, bad signature) AND DB errors all collapse to null. The 401 vs 500 distinction is left to callers (currently every caller treats null as 401, which is correct for an auth helper).
- No change to
checkCollectionPermission,withCollectionPermission,checkRolePermission, orlogCollectionActivity.
pages/api/auth/verify.js
- Delete lines 25-39 of the post-Brief-1 file (the
// For development, return admin user if no token providedblock and theSELECT … WHERE email = 'admin@tcgvault.com'query). Replace with an immediate 401:
// before:
if (!authHeader || !authHeader.startsWith('Bearer ')) {
// For development, return admin user if no token provided
// In production, this should return 401
const result = await sql`
SELECT id, email, role, created_at
FROM users
WHERE email = 'admin@tcgvault.com'
`;
if (result.rows.length > 0) {
return res.status(200).json(result.rows[0]);
} else {
return res.status(401).json({ error: 'No admin user found' });
}
}
// after:
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Authentication required' });
}
- No env-gate. No comment-out.
- The rest of
verify.js(CORS headers, OPTIONS preflight, method gate, JWT verify, DB lookup) is unchanged. CORS tightening is Brief 4's scope and only coverslogin.js+register.js, NOTverify.js(out of scope per the convoy). - The error-message wording matches the existing convention:
{ error: 'Authentication required' }. Do not invent a new shape.
Caller spot-check (do this before opening the PR)
The convoy claims "30+ handlers depend on getUserFromRequest." Re-verify by running the following (results captured at architect time on 2026-05-23 — master revision ebd4fd1; if the count drifts, list the new files in the PR description):
rg "getUserFromRequest" pages/api --type js -l | wc -l→ 24 files (one of which ispermission-middleware.js's import-bookkeeping artifact, leave the count as-is).rg "if \(!user\)" pages/api --type js -A 1(with-A 1) — every match must be followed byreturn res.status(401).json({ error: 'Authentication required' });or a similar 401. If any caller has a different shape (e.g.if (!user) return res.status(403), orif (user) ...inverted, or no null guard at all), stop and re-architect: that caller would need behavioral changes, and this convoy explicitly does not touch caller code.- One file is known to use optional-chaining instead of an early 401 —
pages/api/collections/[identifier].jsusesuser?.userIdbecause it allows anonymous access to public collections. This is intentional and stays correct under the fix (whenuserisnull,user?.userIdisundefined, the public-collection branch still works). Do not "fix" it.
Smoke (manual)
npm run dev; with noAuthorizationheader, hitcurl http://localhost:3000/api/user/profile→ expect HTTP 401 with body{"error":"Authentication required"}. (Pre-fix: returns the admin user's profile.)- Same with
curl http://localhost:3000/api/auth/verify→ expect HTTP 401. (Pre-fix: returns admin user data.) - Log in via the UI; observe the dashboard loads (the helper still works for valid tokens).
- Log out; observe the dashboard redirects to
/login(the helper now correctly returnsnull).
Out of scope
- No edits to any of the 24 callers — they already handle
nullcorrectly. - No CORS changes (Brief 4).
- No rate-limit (Brief 4).
- No tests — Brief 5 ships them.
- No
AGENTS.md/.cursor/rules/*.mdcupdates — doc-writer pass.
Rationale (≤3 sentences)
This is the convoy's actual security fix — removing the synthetic admin makes 24 currently-broken handlers correct in one ~6-line change. Folding verify.js's parallel bug (the no-token branch fetches admin@tcgvault.com directly from the DB) into the same brief keeps "the auth helper returns null" and "the verify endpoint returns 401" coupled, since both have to land before any unauthenticated request can be safely served. Splitting them risks a deploy ordering where one is fixed and the other isn't — exactly the inconsistency that lets a P0 ship-blocker survive.