---
convoy: fix-auth-bypass
brief_number: 1
depends_on: []
files:
  - lib/auth-secret.js
  - lib/permission-middleware.js
  - pages/api/auth-utils.js
  - pages/api/auth/login.js
  - pages/api/auth/register.js
  - pages/api/auth/verify.js
  - pages/api/favorites.js
  - pages/api/users/search.js
cross_brief_commitments:
  - brief: 2
    description: |
      Brief 2 modifies `lib/permission-middleware.js` (replaces the synthetic-admin
      fallback in `getUserFromRequest`) and `pages/api/auth/verify.js` (removes the
      no-token admin-fetch branch). This brief MUST land first, because Brief 2
      relies on the `JWT_SECRET` import already being in place.
  - brief: 4
    description: |
      Brief 4 modifies `pages/api/auth/login.js` and `pages/api/auth/register.js`
      (drops `Access-Control-Allow-Origin: '*'`, wraps with rate limiter). This
      brief MUST land first, because Brief 4 builds on the post-refactor login /
      register handlers (no `JWT_SECRET` literal, `generateToken` from `auth-utils`).
  - brief: 5
    description: |
      Brief 5 (vitest + tests) imports `JWT_SECRET` and `JWT_TOKEN_TTL` from
      `lib/auth-secret.js` in test setup. This brief MUST land first.
---

# 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` — **new**
- `lib/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; inline `jwt.sign(...)` → `generateToken(user)` from `auth-utils`; drop now-unused `jwt` import)
- `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 outside `files:` above. In particular: no edits to `lib/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 future `single-auth-provider` convoy.
- `package.json` formatting: 2-space indent, `"type": "module"` is set — use ES module imports throughout.
- Existing `import` style in `pages/api/auth-utils.js`: relative paths, no aliases. Match.
- No `engines` block change.
- No new dependencies in `package.json`. (Brief 4 adds `@upstash/ratelimit`; Brief 5 adds `vitest`. This brief adds nothing.)

## Acceptance criteria

### `lib/auth-secret.js` (new)

- [ ] File contains exactly two named exports: `JWT_SECRET` and `JWT_TOKEN_TTL`.
- [ ] `JWT_SECRET` reads `process.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):

```js
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`, no `dotenv.config()` — Next.js loads `.env.local` automatically, and tests load env via `test/setup.js` in 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)` returns `jwt.sign({...}, JWT_SECRET, { expiresIn: JWT_TOKEN_TTL })` — the literal `'7d'` is replaced. **This is the canonical token-minting function.**
- [ ] `verifyToken(token)` continues to call `jwt.verify(token, JWT_SECRET)` (no expiry param needed on verify).
- [ ] No other behavior change. `hashPassword`, `verifyPassword`, `isAdmin`, `getUserById` are 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) with `import { generateToken } from '../../auth-utils.js';`. The path is `pages/api/auth/login.js` → `pages/api/auth-utils.js`, so relative import is `../auth-utils.js`. Verify by reading line 4 of `pages/api/auth/register.js` for the existing relative-import pattern (`'../../../lib/slug-utils.js'`).
- [ ] Replace the inline JWT mint:

```js
// 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';` with `import { generateToken } from '../auth-utils.js';`. Relative path: `pages/api/auth/register.js` → `pages/api/auth-utils.js` is `'../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.js` is `'../../../lib/auth-secret.js'`.
- [ ] Keep `jwt.verify(token, JWT_SECRET)` inline (do not refactor to call `verifyToken` from `auth-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.js` is `'../../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.js` is `'../../../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 `getUserFromRequest` unchanged in this brief.** The synthetic-admin fallback removal is Brief 2's job.
- [ ] `withCollectionPermission`, `checkCollectionPermission`, `logCollectionActivity` are untouched.

### Repo-wide grep verification (run before opening PR)

- [ ] `rg "process\.env\.JWT_SECRET" --type js` returns **zero hits** in `lib/`, `pages/`. (Hits in `.convoys/`, `.cursor/`, `AGENTS.md`, `docs/` are documentation references — leave them alone in this brief.)
- [ ] `rg "your-secret-key" --type js` returns zero hits.
- [ ] `rg "'7d'" --type js pages/api/auth-utils.js` returns zero hits (replaced by `JWT_TOKEN_TTL`).
- [ ] `rg "'24h'" --type js pages/api/auth/` returns zero hits (replaced via `generateToken`).

### 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 lint` exits 0 (or matches the existing baseline — pre-existing errors are fine, no new ones).
- [ ] `npm run dev` boots; visit `http://localhost:3000/login`; submit valid credentials; observe that `localStorage.auth_token` is set and decoding the token shows `exp - iat ≈ 86400` (24h, not 7 days).
- [ ] Temporarily unset `JWT_SECRET` in `.env.local` and run `npm run dev`. Confirm the server logs the thrown error and the page returns 500. **Re-set `JWT_SECRET` before opening the PR.**
- [ ] `npm run build` succeeds. 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 future `single-auth-provider` convoy.
- [ ] No edit to `AGENTS.md` or `.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.
