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>
12 KiB
| convoy | brief_number | depends_on | files | cross_brief_commitments | ||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| fix-auth-bypass | 5 |
|
|
|
Brief 5: Install vitest + write the auth unit tests + re-enable the CI test job
Goal (1 sentence)
Install vitest@^3.2.4, write unit tests that lock in the post-Brief-2 behavior of getUserFromRequest (null for missing/malformed/expired tokens; user object for valid tokens) plus thin coverage of auth-utils.generateToken / verifyToken, and re-enable the disabled test: job in .github/workflows/ci.yml.
Files in scope (do not edit anything else)
package.json— modified (add vitest as devDep, addtestandtest:runscripts)package-lock.json— modified (regenerated)vitest.config.js— newtest/setup.js— new (sets test env, mocks@vercel/postgres)test/lib/auth-secret.test.js— newtest/lib/permission-middleware.test.js— newtest/api/auth-utils.test.js— new.github/workflows/ci.yml— modified (uncomment + adjust the disabledtest:job)
Conventions to follow
.cursor/rules/no-go-zones.mdc— do not edit any auth source file (those are Briefs 1 / 2 / 4). Tests read the source; they do not modify it..cursor/rules/auth-and-permissions.mdcis the contract under test — every assertion in these tests should map to a bullet in that rule.package.jsonformatting: 2-space indent, alphabetical key order withindevDependencies. Newscriptskeys go alphabetically among existing keys.- ESM throughout (
"type": "module"is set). All test files useimport. - File naming:
*.test.js(vitest's defaultincludepattern). - Plain JavaScript only. Do not add a
tsconfig.json. Do not use.tsfiles. Do not import@types/*packages. The repo is JavaScript-only; the existingtypescript@^5.9.3devDep is purely a transitive requirement ofeslint-config-next@16and is NOT a language switch (per AGENTS.md and the bump-next-js retro).
Acceptance criteria
package.json changes
devDependenciesgains"vitest": "^3.2.4". (Verified at architect time: vitest@3.2.4 hasviteas a regular dependency, not a peer dependency, so we do not need to install Vite separately. vitest@4.x requiresvite ^6 || ^7 || ^8as a non-optional peer — that's why we pin to v3.)- No
vitedirect devDep. (vitest@3 bundles vite transitively.) - No
@types/nodeor any@types/*package — JS-only. - No
@vitest/ui,@vitest/coverage-v8,happy-dom,jsdom— none needed for unit tests of pure-Node modules. scriptsgains:"test": "vitest"(watch mode, dev convenience)"test:run": "vitest run"(single-pass, CI mode)
scriptsdoes NOT gain atest:uiortest:coveragescript in this brief — those are follow-up.
package-lock.json changes
- Regenerated via
npm install. npm ls vitestreports a single3.2.xversion.npm ls vitereports a single5.x,6.x, or7.xversion (vitest@3.2.4's regular dep range is^5.0.0 || ^6.0.0 || ^7.0.0-0; the locked version depends on what npm resolves at install time).npm installexits cleanly with noERESOLVEerrors.npm warn deprecatedlines are tolerated for transitive deps (vitest's tree pulls inglob@7andinflighthistorically). If the warnings are loud, capture them in the PR description but don't block.
vitest.config.js (new)
- ESM (
export default), 2-space indent. - Verbatim shape:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'node',
globals: false,
setupFiles: ['./test/setup.js'],
include: ['test/**/*.test.js'],
testTimeout: 5000,
},
});
- No
coverage:block. Nopool:override. Notransform:config (vitest's default Vite-based transform handles.jsESM out of the box).
test/setup.js (new)
- Sets stable test env BEFORE any module is imported elsewhere. Verbatim shape:
process.env.JWT_SECRET = 'test-secret-for-vitest-only-do-not-use-in-prod';
process.env.NODE_ENV = 'test';
- Do NOT set
UPSTASH_REDIS_REST_URL/UPSTASH_REDIS_REST_TOKEN. The rate-limit module's no-op fallback fires underNODE_ENV !== 'production'with Upstash unset. If a future test wants to assert rate-limit behavior, it can mock@upstash/ratelimitper-test. - No
dotenvimport. Vitest does not automatically read.env.local, and we do not want production secrets leaking into test runs.
test/lib/auth-secret.test.js (new)
Cover the two exports and the import-time throw.
import { JWT_SECRET, JWT_TOKEN_TTL } from '../../lib/auth-secret.js'succeeds whenprocess.env.JWT_SECRETis set (it is, viatest/setup.js).JWT_SECRETequals the value set intest/setup.js.JWT_TOKEN_TTLequals'24h'.- Import-time throw test: use
vi.resetModules()+vi.stubEnv('JWT_SECRET', '')+await expect(import('../../lib/auth-secret.js')).rejects.toThrow(/JWT_SECRET/). Thenvi.unstubAllEnvs()to restore. (Verbatim pattern lives in vitest docs §"Mocking → Environment Variables"; the test must useawait import(...)because staticimportresolves at file-parse time and would crash the test runner.) - Test count: 3.
test/lib/permission-middleware.test.js (new)
This is the core security test. Lock in Brief 2's behavior.
vi.mock('@vercel/postgres', () => ({ sql: vi.fn() }))at the top of the file. Thesqlmock returnsPromise.resolve({ rows: [...] })per-test, allowing each test to set the user-row shape it expects.- Helper to mint a valid token in tests:
import jwt from 'jsonwebtoken';
import { JWT_SECRET } from '../../lib/auth-secret.js';
function makeToken(payload, opts = {}) {
return jwt.sign(payload, JWT_SECRET, { expiresIn: opts.expiresIn ?? '1h' });
}
- Test cases (each maps to a bullet in
.cursor/rules/auth-and-permissions.mdc§ "Token model"):returns null when Authorization header is missing—getUserFromRequest({ headers: {} })resolves tonull. No DB query is made (assertsqlmock not called).returns null when Authorization header is not Bearer—{ headers: { authorization: 'Basic foo' } }resolves tonull.returns null when token is malformed—{ headers: { authorization: 'Bearer not-a-jwt' } }resolves tonull.returns null when token signature uses a wrong secret— sign a payload with'other-secret', expectnull.returns null when token is expired— sign withexpiresIn: '-1s', expectnull.returns null when token is valid but user-row is missing— setsqlto return{ rows: [] }, expectnull.returns user object when token is valid and user-row exists— setsqlto return{ rows: [{ id: 42, email: 'a@b.c', role: 'user' }] }. Expect{ userId: 42, email: 'a@b.c', role: 'user' }. Note theuserId(notid) field name — that is the helper's documented contract.
- Negative regression test (Brief 2 lock): confirm the helper does NOT return the synthetic admin shape
{ userId: 1, email: 'admin@tcgvault.com', role: 'admin' }when no header is present. This is a smoke against the bug specifically. - Test count: 8.
test/api/auth-utils.test.js (new)
Thin coverage of the JWT-mint contract.
vi.mock('../../lib/database.js', () => ({ db: { query: vi.fn() } }))—auth-utils.jsimportsdb, but the tests only exercisegenerateToken/verifyToken, which don't touch the DB. The mock just satisfies the import.- Test cases:
generateToken issues a token whose expiry is 24h from now (±5s tolerance)— decode the token, checkdecoded.exp - decoded.iat === 86400.generateToken includes userId, email, role from the user arg— decode, assert payload.verifyToken returns the payload for a valid token.verifyToken returns null for a malformed token.verifyToken returns null for a token signed with a different secret.
- Test count: 5.
.github/workflows/ci.yml
The current file has the test: job commented out at lines 87-103. Re-enable it. Verbatim replacement for that block:
test:
name: Unit tests (vitest)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: npm
- run: npm ci
- run: npm run test:run
env:
JWT_SECRET: ci-secret-only-for-tests-do-not-use-in-prod
- No
POSTGRES_URLenv in CI. The unit tests mock@vercel/postgres; they don't need a real connection. Setting it to a fake value would mask import-time validation that may exist inlib/database.js. - No
UPSTASH_REDIS_REST_*envs. Tests don't exercise the rate-limit module. - The job is blocking (no
|| true, no::warning). - Concurrency is inherited from the workflow level; no per-job override.
- Remove the trailing comment block at the bottom of the file (the
# test:placeholder lines 87-103). They become real lines now. - Update the workflow header comment (lines 11-13) to remove the "tcg-vault has no test runner installed yet" note.
Smoke (manual)
npm installfrom a clean tree succeeds.npm run test:runruns all 16 tests and exits 0.npm run test(watch mode) shows the same 16 tests passing on save.- Failure-mode smoke: temporarily revert one line of Brief 2's fix (e.g. add back the
return { userId: 1, ... }synthetic admin ingetUserFromRequest). Runnpm run test:run. Expect:permission-middleware.test.js's "returns null when Authorization header is missing" test FAILS. Restore Brief 2 before opening the PR. - Push to a draft PR and confirm the GitHub Actions
testjob runs and is green.
Out of scope
- No tests for
lib/rate-limit.js(Brief 4). The graceful-fallback branch is hard to test cleanly without an Upstash mock; defer to a follow-up. - No tests for
pages/api/auth/login.js/register.jsintegration paths (would require fluent HTTP-handler mocking; defer to a follow-up Playwright / supertest convoy). - No tests for
withCollectionPermission,checkCollectionPermission,logCollectionActivity. This convoy is scoped to the auth-bypass surface; collection-permission tests are their own follow-up. - No
tsconfig.jsonor.tsfiles. JS-only, per AGENTS.md. - No coverage report or coverage gate. Follow-up convoy.
- No Playwright / E2E. Follow-up convoy (
adopt-playwright).
Rationale (≤3 sentences)
Bringing vitest forward by one slot in the launch sequence is justified by the security blast radius of an auth refactor — the alternative is shipping Brief 2 untested and waiting for the test-runner convoy to backfill, which leaves getUserFromRequest's null-return contract unenforced for an unknown number of PRs. Pinning vitest to v3.2.4 (rather than the latest v4.1.7) avoids the non-optional vite peer-dep that v4 introduced, keeping the devDep set minimal for a JS-only repo. Mocking @vercel/postgres in unit tests rather than spinning up a real Postgres in CI keeps the test job under 30 seconds end-to-end and avoids the operational cost of a CI-only DB.