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>
11 KiB
| convoy | brief_number | depends_on | files | cross_brief_commitments | |||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| fix-auth-bypass | 1 |
|
|
Brief 1: Central JWT secret helper + 24h token TTL
Goal (1 sentence)
Create lib/auth-secret.js as the single source of truth for JWT_SECRET (fail-loud at module load if unset) and JWT_TOKEN_TTL = '24h', then refactor the 7 files currently embedding process.env.JWT_SECRET || '…' literals to import from it.
Files in scope (do not edit anything else)
lib/auth-secret.js— newlib/permission-middleware.js— modified (literal → import)pages/api/auth-utils.js— modified (literal → import;'7d'→JWT_TOKEN_TTL)pages/api/auth/login.js— modified (literal → import; inlinejwt.sign(...)→generateToken(user)fromauth-utils; drop now-unusedjwtimport)pages/api/auth/register.js— modified (same as login)pages/api/auth/verify.js— modified (literal → import). Do NOT remove the no-token admin-fetch branch here — that's Brief 2's scope. Just swap the secret literal for the import.pages/api/favorites.js— modified (literal → import)pages/api/users/search.js— modified (literal → import)
Conventions to follow
.cursor/rules/auth-and-permissions.mdc§ "Token model" — JWT model + signing surface..cursor/rules/api-routes.mdc§ "Authentication & Authorization" — handler shape stays the same; only the secret source changes..cursor/rules/no-go-zones.mdc— do not edit any file outsidefiles:above. In particular: no edits tolib/auth-context.js,lib/admin-auth.js,lib/use-auth.js,lib/database.js,pages/_app.js, or any UI file. Auth-context cleanup is the futuresingle-auth-providerconvoy.package.jsonformatting: 2-space indent,"type": "module"is set — use ES module imports throughout.- Existing
importstyle inpages/api/auth-utils.js: relative paths, no aliases. Match. - No
enginesblock change. - No new dependencies in
package.json. (Brief 4 adds@upstash/ratelimit; Brief 5 addsvitest. This brief adds nothing.)
Acceptance criteria
lib/auth-secret.js (new)
- File contains exactly two named exports:
JWT_SECRETandJWT_TOKEN_TTL. JWT_SECRETreadsprocess.env.JWT_SECRET. If unset OR empty string, the module throws at import time with a clear, actionable message that names the env var and points at.env.local. Verbatim shape (or near-verbatim — the message body can be reworded but the shape must be):
const JWT_SECRET = process.env.JWT_SECRET;
if (!JWT_SECRET) {
throw new Error(
'JWT_SECRET environment variable is not set. ' +
'Set it in .env.local for local dev, or in the Vercel project settings for deploys. ' +
'Generate a strong secret with: openssl rand -hex 32'
);
}
export { JWT_SECRET };
export const JWT_TOKEN_TTL = '24h';
- No fallback string literal. A previous fallback
'your-secret-key-change-in-production'is what we are explicitly removing — do not reintroduce it under any condition. - No length check (a length check is tempting but not required by the convoy and risks breaking existing valid-but-shorter dev secrets in
.env.local; defer to a future hardening pass). - No default export.
- No top-level side effects beyond the throw on missing env (no
console.log, nodotenv.config()— Next.js loads.env.localautomatically, and tests load env viatest/setup.jsin Brief 5).
pages/api/auth-utils.js
- Line 4 (
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';) deleted. - Add at top of file:
import { JWT_SECRET, JWT_TOKEN_TTL } from '../../lib/auth-secret.js'; generateToken(user)returnsjwt.sign({...}, JWT_SECRET, { expiresIn: JWT_TOKEN_TTL })— the literal'7d'is replaced. This is the canonical token-minting function.verifyToken(token)continues to calljwt.verify(token, JWT_SECRET)(no expiry param needed on verify).- No other behavior change.
hashPassword,verifyPassword,isAdmin,getUserByIdare untouched.
pages/api/auth/login.js
- Line 5 (
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';) deleted. - Replace
import jwt from 'jsonwebtoken';(line 2) withimport { generateToken } from '../../auth-utils.js';. The path ispages/api/auth/login.js→pages/api/auth-utils.js, so relative import is../auth-utils.js. Verify by reading line 4 ofpages/api/auth/register.jsfor the existing relative-import pattern ('../../../lib/slug-utils.js'). - Replace the inline JWT mint:
// before (lines 51-55)
const token = jwt.sign(
{ userId: user.id, email: user.email, role: user.role },
JWT_SECRET,
{ expiresIn: '24h' }
);
// after
const token = generateToken({ id: user.id, email: user.email, role: user.role });
Note the param shape change: generateToken reads user.id (not user.userId), per the existing implementation in auth-utils.js.
- No CORS change here. Brief 4 will tighten
Access-Control-Allow-Origin: '*'. Leave it alone in this brief. - No rate-limit wiring here. Brief 4 wraps with
@upstash/ratelimit. Leave the handler shape alone.
pages/api/auth/register.js
- Line 6 (
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key-change-in-production';) deleted. - Replace
import jwt from 'jsonwebtoken';withimport { generateToken } from '../auth-utils.js';. Relative path:pages/api/auth/register.js→pages/api/auth-utils.jsis'../auth-utils.js'. - Replace the inline JWT mint at lines 142-147 with
const token = generateToken({ id: user.id, email: user.email, role: user.role }); - Same CORS / rate-limit hands-off rule as login.
pages/api/auth/verify.js
- Line 4 literal deleted.
- Add
import { JWT_SECRET } from '../../../lib/auth-secret.js';at top. Path correctness:pages/api/auth/verify.js→lib/auth-secret.jsis'../../../lib/auth-secret.js'. - Keep
jwt.verify(token, JWT_SECRET)inline (do not refactor to callverifyTokenfromauth-utils.js— that would change error semantics, and Brief 2 is already going to touch this file. Keep this brief mechanical). - Do NOT remove the no-token admin-fetch branch (lines 25-39). That is Brief 2's job. Touching it here splits the security fix across two PRs unnecessarily.
pages/api/favorites.js
- Line 4 literal deleted.
- Add
import { JWT_SECRET } from '../../lib/auth-secret.js';at top. Path:pages/api/favorites.js→lib/auth-secret.jsis'../../lib/auth-secret.js'. - Keep
jwt.verify(token, JWT_SECRET)inline. No other change.
pages/api/users/search.js
- Line 4 literal deleted.
- Add
import { JWT_SECRET } from '../../../lib/auth-secret.js';at top. Path:pages/api/users/search.js→lib/auth-secret.jsis'../../../lib/auth-secret.js'. - Keep
jwt.verify(token, JWT_SECRET)inline. No other change.
lib/permission-middleware.js
- Line 4 literal deleted.
- Add
import { JWT_SECRET } from './auth-secret.js';at top. - Keep the rest of
getUserFromRequestunchanged in this brief. The synthetic-admin fallback removal is Brief 2's job. withCollectionPermission,checkCollectionPermission,logCollectionActivityare untouched.
Repo-wide grep verification (run before opening PR)
rg "process\.env\.JWT_SECRET" --type jsreturns zero hits inlib/,pages/. (Hits in.convoys/,.cursor/,AGENTS.md,docs/are documentation references — leave them alone in this brief.)rg "your-secret-key" --type jsreturns zero hits.rg "'7d'" --type js pages/api/auth-utils.jsreturns zero hits (replaced byJWT_TOKEN_TTL).rg "'24h'" --type js pages/api/auth/returns zero hits (replaced viagenerateToken).
Smoke (manual, no test runner yet — Brief 5 adds vitest)
Document that you ran these in the PR description (not enforced in CI):
npm run lintexits 0 (or matches the existing baseline — pre-existing errors are fine, no new ones).npm run devboots; visithttp://localhost:3000/login; submit valid credentials; observe thatlocalStorage.auth_tokenis set and decoding the token showsexp - iat ≈ 86400(24h, not 7 days).- Temporarily unset
JWT_SECRETin.env.localand runnpm run dev. Confirm the server logs the thrown error and the page returns 500. Re-setJWT_SECRETbefore opening the PR. npm run buildsucceeds. Vercel's preview deploy on the PR is green.
Out of scope (do not do these)
- No edit to
pages/_app.js,lib/auth-context.js,lib/admin-auth.js,lib/use-auth.js. Client-side context cleanup is the futuresingle-auth-providerconvoy. - No edit to
AGENTS.mdor.cursor/rules/auth-and-permissions.mdc. Doc-writer pass updates these after the convoy lands. - No removal of the synthetic-admin fallback in
getUserFromRequest— Brief 2. - No removal of the no-token admin branch in
verify.js— Brief 2. - No CORS changes — Brief 4.
- No rate-limit wiring — Brief 4.
- No test files — Brief 5.
- No deletion of
pages/api/test-*.js,pages/api/simple.js,pages/api/setup-database.js— Brief 3.
Rationale (≤3 sentences)
Centralizing JWT_SECRET removes 7 copies of the fallback literal in one PR, making the eventual fail-closed runtime behavior trivial to audit. Co-locating JWT_TOKEN_TTL in the same module canonicalizes 24h (matching current login.js behavior, which is what existing users have been getting) and resolves the silent inconsistency between auth-utils.generateToken ('7d') and login.js ('24h'). Routing login.js and register.js through auth-utils.generateToken removes a second, drift-prone JWT-mint call site; the alternative — leaving inline jwt.sign everywhere — would make the next refactor more painful for no gain.