deckhearth/.convoys/fix-auth-bypass/brief-2-remove-admin-bypass.md
Randall Stillwell f64a80ba2a docs: post-convoy cleanup for fix-auth-bypass
Closes out the fix-auth-bypass convoy (PRs #6–#11, merged through
1629afb) on the docs side. Code already on main; this PR is docs only.

Updates:

AGENTS.md
  - §1 auth bullet refreshed (auth-secret SoT, 24h TTL, no synthetic
    admin, login/register rate limit)
  - §3 conventions point at lib/auth-secret.js + lib/rate-limit.js
  - §4 gotchas #2/#3/#5 converted to "Resolved" notes in place
    (NOT renumbered, to preserve cross-references)
  - new #12 documents the KV_REST_API_* env-var convention
  - §5 setup list adds the rate-limit env vars
  - §6 testing rewritten for Vitest (16 unit tests, blocking CI gate)

.cursor/rules/auth-and-permissions.mdc
  - canonical-surface table gains lib/auth-secret.js + lib/rate-limit.js
  - token model now 24h (was 7d) with fail-loud explanation
  - server-side authorization patterns lead with null → 401 contract

.cursor/rules/api-routes.mdc
  - removes the "CRITICAL — known bug" callout (resolved by Brief 2)
  - adds a "Rate limiting" section with verbatim shape + env-var notes
  - "Dev/test endpoints" → "Removed" historical note so future agents
    searching for test-db understand why it's gone

.convoys/fix-auth-bypass.md (restored — was on convoy branch only)
  - frontmatter → status: shipped
  - new "Convoy outcome" section: briefs + commits + resolved gotchas,
    R1-R12 risk walk, env-var-rename deviation record, queued follow-up
    convoys, lessons learned

.convoys/fix-auth-bypass/brief-{1..5}-*.md (restored from convoy branch)
  - audit-trail completeness; convoy plan references them by name
  - brief 4 additionally updated: UPSTASH_REDIS_REST_* → KV_REST_API_*
    across init rules, smoke, pre-deploy checklist
  - brief 4 has a new "Post-merge addendum" explaining the rename

.convoys/ship-readiness.md
  - P0 #1, #2, #4 → RESOLVED with merge-commit citations
  - P0 #5 (CORS), #6 (rate limit) → PARTIAL with deferral pointers
    (cors-tighten and add-rate-limiting convoys)
  - each item gains an "As-shipped" line for self-containment

README.md
  - Next.js 15 → 16, TypeScript claim corrected to JS-with-devDep
  - auth + rate-limit + testing bullets updated
  - env-var template extended with KV_REST_API_*
  - deleted dev-endpoints note added to the API list
  - "Default Admin Account" section LEFT ALONE — drop-public-setup territory

Verified: build exit 0 (with JWT_SECRET set), 16/16 vitest tests pass,
lint baseline unchanged (128/81/47).

Convoy: fix-auth-bypass / role-doc-writer (closeout)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 11:27:48 -05:00

131 lines
6.9 KiB
Markdown

---
convoy: fix-auth-bypass
brief_number: 2
depends_on: [1]
files:
- lib/permission-middleware.js
- pages/api/auth/verify.js
cross_brief_commitments:
- brief: 1
description: |
Brief 1 already replaced the `JWT_SECRET` literal in both files with imports
from `lib/auth-secret.js`. This brief preserves those imports and only
removes the synthetic-admin fallback shapes.
- brief: 5
description: |
Brief 5 (vitest) writes the unit tests that prove `getUserFromRequest`
returns `null` for the four shapes (missing header, malformed token,
expired token, valid token-but-no-user-row). The behavior is implemented
here; the harness lands in Brief 5.
---
# 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` — modified
- `pages/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 of `getUserFromRequest` already follow this; we just need to make the helper actually emit `null`.
- `.cursor/rules/api-routes.mdc` § "Error handling" — keep the `try/catch` wrapper 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.warn` and the synthetic admin return). Replace with a plain `return null`. Verbatim shape:
```js
// 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:
```js
} 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`, or `logCollectionActivity`.
### `pages/api/auth/verify.js`
- [ ] **Delete lines 25-39** of the post-Brief-1 file (the `// For development, return admin user if no token provided` block and the `SELECT … WHERE email = 'admin@tcgvault.com'` query). Replace with an immediate 401:
```js
// 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 covers `login.js` + `register.js`, NOT `verify.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 is `permission-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 by `return 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)`, or `if (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].js` uses `user?.userId` because it allows anonymous access to public collections. **This is intentional** and stays correct under the fix (when `user` is `null`, `user?.userId` is `undefined`, the public-collection branch still works). Do not "fix" it.
### Smoke (manual)
- [ ] `npm run dev`; with no `Authorization` header, hit `curl 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 returns `null`).
### Out of scope
- [ ] No edits to any of the 24 callers — they already handle `null` correctly.
- [ ] No CORS changes (Brief 4).
- [ ] No rate-limit (Brief 4).
- [ ] No tests — Brief 5 ships them.
- [ ] No `AGENTS.md` / `.cursor/rules/*.mdc` updates — 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.