--- description: Auth model + permission model for tcg-vault (JWT + collection roles) globs: pages/api/**/*.js,lib/*.js,components/*.js,pages/*.js --- # Auth + permissions There are three parallel client-side auth implementations and one server-side helper. New code should use the canonical set listed below; don't proliferate variants. ## Canonical surface (use these) | Concern | Module | | --- | --- | | Server: extract user from request | `lib/permission-middleware.js::getUserFromRequest` | | Server: gate a collection route | `lib/permission-middleware.js::withCollectionPermission` | | Server: log collection mutation | `lib/permission-middleware.js::logCollectionActivity` | | Server: JWT secret + canonical TTL | `lib/auth-secret.js` (`JWT_SECRET`, `JWT_TOKEN_TTL`) — single source of truth, fail-loud on unset env | | Server: token / password primitives | `pages/api/auth-utils.js` (`generateToken`, `verifyToken`, `hashPassword`, `verifyPassword`) — reads secret + TTL from `lib/auth-secret.js` | | Server: rate-limit auth endpoints | `lib/rate-limit.js::checkAuthRateLimit` (5 attempts / 15 min sliding window via `@upstash/ratelimit`) | | Client: hook | `lib/use-auth.js::useAuth` | | Client: route protection | `components/ProtectedRoute.js` | | Client: admin route protection | `components/AdminProtected.js` | ## Legacy (do not extend) - `lib/auth-context.js::AuthProvider` + `useAuth` — older context. Still wired in `pages/_app.js`; left in place for compatibility. Don't add new consumers. - `lib/admin-auth.js::AdminProvider` + `useAdmin` + `useIsAdmin` — parallel admin context. Same story. A convoy is planned to collapse these three into one provider + one hook. ## Token model - JWT in localStorage under the key `auth_token`. - Signed with `JWT_SECRET` (HS256), **24-hour expiry** (canonical `JWT_TOKEN_TTL = '24h'` from `lib/auth-secret.js`), payload `{ userId, email, role }`. - Sent on every authenticated fetch as `Authorization: Bearer `. - Verified server-side with `jsonwebtoken.verify(token, JWT_SECRET)` (or `verifyToken` from `pages/api/auth-utils.js`). **`JWT_SECRET` MUST be set in the deploy environment.** `lib/auth-secret.js` is the only place either `JWT_SECRET` or `JWT_TOKEN_TTL` is defined; the module **throws at import time** if `process.env.JWT_SECRET` is unset, which fails the request loudly rather than silently signing with a literal fallback. (The legacy `'your-secret-key-change-in-production'` fallback was duplicated across 7 files pre-`fix-auth-bypass`; that whole pattern is gone — do not reintroduce it.) ## Roles Two role surfaces are in play: 1. **User role** — `users.role` column, values `'user'` or `'admin'`. Admin gates `/admin/*` pages and admin-only API endpoints. 2. **Collection role** — `collection_permissions.role` (`viewer` / `editor` / `owner`) + `collections.is_public` (anonymous viewer access). Resolved by `checkCollectionPermission` in priority order: owner → public-viewer → explicit row. When introducing a new permission tier, update both `checkRolePermission`'s hierarchy AND every gate that reads `is_public`. ## Authentication state on the client `useAuth()` returns `{ user, loading, login, logout, refresh }`. `user === null` means logged out; `loading === true` means token verification in flight. Always render against `loading === false` before deciding to redirect. ## Server-side authorization patterns `getUserFromRequest` returns `{ userId, email, role }` for a valid Bearer token, or `null` for any other case (missing header, malformed token, wrong signature, expired token, unknown user id). **`null` means 401** — always early-return before doing anything that depends on the user: ```js const user = await getUserFromRequest(req); if (!user) return res.status(401).json({ error: 'Authentication required' }); ``` There is **no synthetic-admin fallback** for missing tokens. The old dev-mode behavior of returning user 1 as admin is gone (resolved by `fix-auth-bypass` Brief 2, commit `258e479`). Do not reintroduce it under any framing — `test/lib/permission-middleware.test.js` has a negative regression test that will fail if the synthetic-admin shape comes back. From there: - **Owner-only** (delete, settings): inside the handler, `if (user.userId !== resource.user_id) return res.status(403)`. - **Editor-or-owner**: use `withCollectionPermission('editor')`. - **Public read**: use `withCollectionPermission('viewer')` — handles `is_public` and explicit-permission case. - **Admin-only**: check `user.role === 'admin'` directly; consider extracting `withAdmin()` if a third call site appears.