# single-auth-provider (P1 quality — collapse three client auth surfaces onto one) **Status:** OPEN 2026-05-26 (this convoy) **Priority:** P1 quality (launch sequence step 9 — `.convoys/ship-readiness.md` § P1 entry 9) **Convoy owner:** parent (architect + implementer rolled together — diff is mechanical once the shape-parity decision is made) **Branch:** `convoy/single-auth-provider` **Opened:** 2026-05-26 ## Background The repo carried **three parallel client-side auth implementations** since the early days of the project. `.convoys/ship-readiness.md` § P1 entry 9 ("Three parallel client-side auth implementations") is the canonical spec; AGENTS.md § 3 already documented `lib/use-auth.js` as the canonical surface and instructed new code to avoid the other two. This convoy executes the collapse. The three surfaces: 1. **`lib/use-auth.js::useAuth`** (the keeper). Hook-only — reads `auth_token` from `localStorage` on mount, hits `/api/auth/verify`, exposes `{ user, loading, logout, refreshAuth }`. No React context, no `` wrapper required. 2. **`lib/auth-context.js::{ AuthProvider, useAuth }`** (legacy). Context provider + consumer hook with the same verify-on-mount semantics, plus `login()` and `register()` helpers that `pages/login.js` / `pages/signup.js` no longer use (those pages call `/api/auth/{login,register}` directly and write the token to `localStorage` themselves). Wired in `pages/_app.js` as ``. 3. **`lib/admin-auth.js::{ AdminProvider, useAdmin, useIsAdmin }`** (legacy). A redundant context that does the *same* verify-on-mount roundtrip, plus a hook-only `useIsAdmin()` that does its own verify roundtrip on top of that. `AdminProvider` is **not** wired in `_app.js` (verified by reading `_app.js` pre-convoy: only `` + ``), so `useAdmin()` would have thrown at runtime if anyone called it — nobody does. Only `useIsAdmin()` has a live consumer (`pages/card/[id].js`). **Symptom that drove ranking this P1.** When `pages/card/[id].js` mounts, it calls `useAuth()` from `lib/use-auth.js` AND `useIsAdmin()` from `lib/admin-auth.js`, each issuing its own `GET /api/auth/verify`. With `AuthProvider` mounted on every page via `_app.js`, that's a **third** verify roundtrip on the very first page load. Three roundtrips, identical request, serial cost on a cold connection. Post-convoy: 1 roundtrip per page-load. ## Decisions ### D1 — Shape parity check on `lib/use-auth.js`. Verdict: no parity gap; do **not** extend. `lib/auth-context.js::useAuth()` exposed `{ user, loading, login, register, logout }`. `lib/use-auth.js::useAuth()` exposes `{ user, loading, logout, refreshAuth }`. The apparent gap is `login` / `register`. Verified-by-grep: **zero call sites** invoke `useAuth().login(…)` or `useAuth().register(…)` anywhere in `pages/**` or `components/**`. The only callers of those flows are `pages/login.js` and `pages/signup.js`, both of which `fetch` `/api/auth/{login,register}` directly and write the returned token to `localStorage`. `useAuth()`'s `useEffect` then picks up the new token on the next mount (or the page can call `refreshAuth()` to re-verify in place). Conclusion: do **not** add `login` / `register` to `use-auth.js`. The legacy methods were dead code on the consumer surface; preserving them would be cargo-culting and would re-create a non-DRY login flow (one in `pages/login.js`, one in the hook). `auth-and-permissions.mdc` § "Authentication state on the client" was updated to document the post-convoy `useAuth()` shape and to spell out the `login.js` / `signup.js` direct-fetch pattern. ### D2 — `useIsAdmin()` migration shape. Verdict: collapse onto the existing `useAuth()` call. `pages/card/[id].js` is the **only** consumer of `useIsAdmin()`. The page already called `useAuth()` from `lib/use-auth.js` at line 13 (added by `fix-layout-default-user` Brief 2). The migration is: ```js // Before const { user } = useAuth(); // ... const { isAdmin, loading: adminLoading } = useIsAdmin(); // ... usage at line 524: {isAdmin && !adminLoading && (...)} // After const { user, loading: authLoading } = useAuth(); // ... const isAdmin = user?.role === 'admin'; const adminLoading = authLoading; // ... usage at line 524 unchanged: {isAdmin && !adminLoading && (...)} ``` `adminLoading` is kept as a local alias rather than substituting `authLoading` directly at the call site, to keep the diff minimal and the rendering condition byte-identical. The `loading` window from `useAuth()` covers exactly the same period (`/api/auth/verify` resolution) that `useIsAdmin`'s own loading covered, so there is no UX regression. ### D3 — `pages/_app.js` provider tree. Before / after. ```jsx // Before // After ``` `useAuth()` from `lib/use-auth.js` is hook-only — no Provider needed. The `` wrapper is removed entirely; no replacement Provider is added. `` stays (out of scope). `` was never in the tree to begin with. ### D4 — Token-verify roundtrip count. Per the spec: pre-convoy a worst-case page mount issued **3** identical `GET /api/auth/verify` requests: 1. `` in `_app.js` calls `verifyToken()` on mount. 2. `pages/card/[id].js` calls `useAuth()` from `lib/use-auth.js`, which calls `checkAuth()` on mount → another verify. 3. The same page calls `useIsAdmin()` from `lib/admin-auth.js`, which calls its inline `checkAdmin()` on mount → another verify. Post-convoy: 1. `` is gone. 2. `pages/card/[id].js` calls `useAuth()` once → 1 verify. 3. `useIsAdmin()` call site is gone; admin status is computed synchronously from the same `user` returned by step 2. Net: **3 → 1** verify roundtrip on `card/[id].js` mount. Other pages drop from **2 → 1** (no `useIsAdmin` involved, but `` was). The 1× pattern is the floor; further reduction would require server-side hydration of the user object, which is a separate architectural conversation (out of scope; see Follow-ups). ### D5 — Test impact. Verdict: zero test files modified. The 21-test vitest suite covers: - `test/lib/auth-secret.test.js` (3) — server-side, untouched by this convoy. - `test/lib/permission-middleware.test.js` (8) — server-side, untouched. - `test/api/auth-utils.test.js` (5) — server-side, untouched. - `test/components/Layout.test.js` (5) — passes `user` as a *prop*, not via any hook. The legacy `auth-context` and `admin-auth` modules are not imported. Unaffected. All four files were `grep`-checked for `auth-context|admin-auth|use-auth` references — zero hits. No test was written against the legacy hooks themselves; the deletion is risk-free from a test-suite perspective. Vitest stays green at 21/21 post-convoy. ## Importer inventory Generated via `rg "from ['\"].*lib/auth-context['\"]" --type js` and `rg "from ['\"].*lib/admin-auth['\"]" --type js` against the worktree (excluding docs / convoys). ### Importers of `lib/auth-context.js` (6 source files) | File | Symbol | Migration | | --- | --- | --- | | `pages/_app.js` | `AuthProvider` | Wrapper removed; no replacement (D3) | | `pages/index.js` | `useAuth` | Path swap → `lib/use-auth.js` | | `pages/scanner.js` | `useAuth` | Path swap → `lib/use-auth` | | `pages/decks.js` | `useAuth` | Path swap → `lib/use-auth` | | `pages/deck/[id].js` | `useAuth` | Path swap → `lib/use-auth` (depth `../../`) | | `pages/deck-builder.js` | `useAuth` | Path swap → `lib/use-auth` | All 5 page-level `useAuth` consumers destructured only `{ user }` or `{ user, loading }` (verified by grep). No `login` / `register` / other-method consumer found, confirming D1. ### Importers of `lib/admin-auth.js` (1 source file) | File | Symbol | Migration | | --- | --- | --- | | `pages/card/[id].js` | `useIsAdmin` | Replaced with `user?.role === 'admin'` from existing `useAuth()` (D2) | `AdminProvider` and `useAdmin()` had **zero** importers in the source tree — confirming they were dead exports. ### Adjacent doc / config edits | File | Change | | --- | --- | | `pages/_app.js` | Removed `import { AuthProvider } from '../lib/auth-context.js'` and the wrapper | | `.github/CODEOWNERS` | Removed the two CODEOWNERS lines for the deleted files | | `AGENTS.md` § 2 + § 3 | Updated the Auth row of the architecture table and the "Auth (client)" convention bullet to describe the post-convoy single-surface state | | `.cursor/rules/auth-and-permissions.mdc` | Reframed § "Legacy" to "deleted by this convoy"; updated § "Authentication state on the client" to the post-convoy `useAuth()` shape and the direct-fetch login flow | | `.cursor/rules/no-go-zones.mdc` | Auth-refactors bullet updated to drop the deleted files | | `.cursor/skills/add-page/SKILL.md` | Updated checklist bullet + anti-pattern row to refer to the deletion | `.convoys/**` and `.convoys/fix-layout-default-user/**` were **not** edited — those are historical convoy records and are append-only by repo convention. The doc-writer post-convoy sweep will add the as-shipped section at the bottom of this file plus update `.convoys/ship-readiness.md` § P1 → entry 9 with the squash commit reference. ## The fix (per-category translation rules) ### Category A — `useAuth` from `auth-context` → `useAuth` from `use-auth` ```js // before import { useAuth } from '../lib/auth-context'; // or auth-context.js // after import { useAuth } from '../lib/use-auth'; // or use-auth.js ``` The destructure pattern (`const { user } = useAuth()` / `const { user, loading } = useAuth()`) stays byte-identical. No call-site changes. ### Category B — `AuthProvider` wrapper in `_app.js` ```jsx // before import { AuthProvider } from '../lib/auth-context.js'; return ( ); // after return ( ); ``` Plus delete the import line. ### Category C — `useIsAdmin` in `pages/card/[id].js` See D2 for the full diff. Three line-ranges touched: the import block, the `useAuth` destructure, and the `useIsAdmin` line block. Usage at line 524 is unchanged. ### Category D — `useAdmin`, `AdminProvider` No call sites. No work to do; these symbols disappear when the file is deleted. ## Verification plan 1. **`rg "lib/auth-context|lib/admin-auth" --type js`** → expect zero hits in `pages/`, `lib/`, `components/`. Achieved. 2. **`npm run lint`** → baseline 128 problems pre-convoy → 125 problems post-convoy (3 fewer errors, since the deleted files contained 3 unused-import / unused-var lints; no new lint surface introduced). No regression. 3. **`npm run test:run`** → 21/21 pass pre- and post-convoy. Layout test confirmed unaffected. 4. **`npm run build`** → succeeds end-to-end. All 26 pages compile (10 dynamic API routes + 16 `pages/**` views including `card/[id]`, `_app`, `decks`, `deck/[id]`, `deck-builder`, `scanner`, `index` — every file modified by the sweep). No SSR-level breakage; importantly no "useAuth must be used within an AuthProvider" runtime error during static generation, which would have indicated the page tried to use the legacy context hook unwrapped. 5. **Manual smoke:** _deferred_ — the build pass + vitest pass + zero-hit grep is the gate for merging; the parent does not have a logged-in admin browser session ready in this conversation. Documenting in As-shipped post-merge once the operator runs `npm run dev` and exercises dashboard / profile / settings / collections / cards / admin/card-editor. ## Risks - **R1 — Shape parity gap breaks runtime auth state.** *Mitigated by D1.* The grep audit confirmed no consumer reads `login` / `register` / any other surface that exists on the legacy hook but not on `use-auth`. `loading` and `user` were preserved with identical semantics. - **R2 — SSR mismatch from removing ``.** *Mitigated.* `lib/use-auth.js` reads `localStorage` inside a `useEffect`, so SSR sees `user === null, loading === true` and never touches the browser-only API on the server — same guarded shape as the legacy provider. `npm run build` confirms no SSR error during static generation. (`auth-context.js`'s `useEffect` had the same guard, so removing the provider didn't change the SSR surface.) - **R3 — Missed importer.** *Mitigated.* Post-delete grep over `--type js` returned zero hits. The deletion would itself surface any missed importer at module-load time during `npm run build` (Node would throw "Cannot find module"); build succeeded. - **R4 — Verify-roundtrip dedup creates a regression where a page never re-verifies.** *Mitigated.* Pre-convoy, three providers each ran their own verify on mount but they did not coordinate state — one provider's success had no effect on another's loading flag. Post-convoy we have a single source of truth. Pages that need to re-verify (e.g. after an action that might have invalidated the token) can call `refreshAuth()` from the same hook; no consumer currently does this, but the surface is preserved for future use. - **R5 — Stale `useAuth` cache across components.** *Out of scope; see Follow-ups.* Each `useAuth()` call site instantiates its own state via `useState`. Two components on the same page that both call `useAuth` will issue two verify roundtrips and hold two independent `user` references. This was true pre-convoy too (the legacy `useIsAdmin` was already a separate verify). Hoisting state into a shared module-level cache or wrapping `useAuth` in a context (the very thing we just removed!) is a separate decision — see "Follow-ups". ## As-shipped Single squash commit `0668b0c` (PR #31, merged 2026-05-27T03:58:08Z UTC / local 2026-05-26). Parent-owned end-to-end per the "Convoy owner" line — no architect, no implementer subagent dispatched. Mirror-the-pattern fix exactly as planned; no mid-execution surprises. **AGENTS.md § 2 Architecture quick reference + § 3 Conventions ("Auth (client)") + `.cursor/rules/auth-and-permissions.mdc` swept to describe the post-convoy single-surface state in the same wave.** **Diff: 15 files, +341 / -263.** 2 file deletions (`lib/auth-context.js`, `lib/admin-auth.js`); 12 file modifications (7 source pages + `.github/CODEOWNERS` + 4 docs / rules / skills); 1 new convoy planning file (`.convoys/single-auth-provider.md`). **The collapse shipped exactly as designed:** 1. **`lib/auth-context.js` + `lib/admin-auth.js` deleted.** No replacement; `lib/use-auth.js`'s hook-only `useAuth()` is the sole client auth surface. 2. **6 importers of `lib/auth-context.js` swept** per the inventory table — path swap `'../lib/auth-context'` → `'../lib/use-auth'` (each file's relative depth preserved). Destructure pattern (`const { user } = useAuth()` / `const { user, loading } = useAuth()`) stays byte-identical. Affected files: `pages/_app.js`, `pages/index.js`, `pages/scanner.js`, `pages/decks.js`, `pages/deck/[id].js`, `pages/deck-builder.js`. 3. **`` wrapper removed from `pages/_app.js`.** Per D3: `useAuth()` from `lib/use-auth.js` is hook-only, no Provider needed. `` stays. `` was never in the tree to begin with (confirmed by reading `_app.js` pre-convoy — only `` + ``). 4. **`useIsAdmin()`'s lone consumer inlined.** `pages/card/[id].js` was the only consumer; replaced `const { isAdmin, loading: adminLoading } = useIsAdmin()` with `const isAdmin = user?.role === 'admin'; const adminLoading = authLoading;` from the existing `useAuth()` call. Rendering condition at line 524 (`{isAdmin && !adminLoading && (...)}`) unchanged byte-for-byte; the `adminLoading` alias is kept rather than substituting `authLoading` directly to keep the diff minimal. 5. **`AdminProvider` and `useAdmin()` had ZERO importers** in the source tree — confirming they were dead exports (only `useIsAdmin` had a live consumer). Deleted together with `lib/admin-auth.js`; no per-file sweep needed for them. 6. **Verify roundtrip count reduced 3 → 1** on `pages/card/[id].js` mount, and 2 → 1 on every other page-load. Pre-convoy worst case was `` verify + `useAuth()` verify + `useIsAdmin()` verify (3 identical `GET /api/auth/verify` requests, serial cost on cold connection). Post-convoy: single `useAuth()` verify per page; admin status computed synchronously from the same `user`. 7. **`.github/CODEOWNERS`** lines for the two deleted files removed. 8. **Doc surface updated atomically** (the 4 docs / rules / skills modifications in the diff stat): `AGENTS.md` § 2 (Auth + DB libs row reframed to the post-convoy single-surface state) + § 3 ("Auth (client)" convention bullet rewritten); `.cursor/rules/auth-and-permissions.mdc` (§ "Legacy" reframed to "deleted by this convoy"; § "Authentication state on the client" rewritten to the post-convoy `useAuth()` shape + the direct-fetch login flow from `pages/login.js` / `pages/signup.js`); `.cursor/rules/no-go-zones.mdc` (auth-refactors bullet trimmed of the deleted files); `.cursor/skills/add-page/SKILL.md` (checklist + anti-pattern row updated to refer to the deletion). **Verification (all gates green at merge):** - `rg "lib/auth-context|lib/admin-auth" --type js` → 0 hits in `pages/`, `lib/`, `components/` post-edit (R3 mitigation — confirms no missed importer; any missed importer would also have surfaced at module-load time during `npm run build` as "Cannot find module", which did not happen). - `npm run lint` → **128 → 125 problems** (3 fewer errors; the deleted files contained 3 unused-import / unused-var lints; no new lint surface introduced). **This is the new lint baseline** for subsequent convoys. - `npm run test:run` → 21/21 pass pre- and post-convoy. The 4 test files don't import any of the deleted modules (`grep`-confirmed pre-convoy); Layout test confirmed unaffected. - `npm run build` → succeeds end-to-end. All 26 pages compile (10 dynamic API routes + 16 `pages/**` views including every file modified by the sweep). **No "useAuth must be used within an AuthProvider" runtime error during static generation**, which confirms `` removal is safe — no page tried to use the legacy context hook unwrapped. - CI on PR #31: Lint ✓ (125 problems baseline) | Vitest 21/21 ✓ | Playwright smoke 3/3 ✓ | `forbidden-endpoints` ✓ | `forbidden-cors-headers` ✓ | Vercel preview deploy ✓ | Aggregate gate ✓ - `Screenshot diff`: triggered (PR #31 touches `pages/**` non-API + some adjacent surface that the visual-diff path filter matches) — `continue-on-error: true` swallow per the documented Decision-4 end state of `adopt-playwright-smoke` (no baseline committed yet). **Manual smoke deferred** per § Verification plan step 5 — the build pass + vitest pass + zero-hit grep is the gate for merging; the parent did not have a logged-in admin browser session ready in this conversation. Optional post-merge operator sequence: `npm run dev` + exercise dashboard / profile / settings / collections / cards / admin/card-editor to confirm no runtime regression. **Cross-validation finding (continues the lineage).** `Playwright smoke` 3/3 PASS on a 15-file sweep confirms the deployed preview is unaffected by the 2-deletion auth-surface collapse. Specifically, smoke test 2 (`'sign-in page renders'`) continues to assert against the `Sign in` CTA introduced by `fix-layout-default-user` (PR #15) — that CTA is unchanged here, and its render path doesn't depend on ``. **Ninth consecutive convoy** where the same 3-test smoke spec defends the auth surface (PR #15 → #19 → #20 → #21 → #25 → #32 → #27 → #30 → this PR). **Operator action required going forward:** **none.** No env vars, no schema, no infra changes. The single client auth surface is `lib/use-auth.js::useAuth()`; the direct-fetch login flow in `pages/login.js` / `pages/signup.js` is preserved verbatim (no client-side credential handling moved). **Spec deviation (documented as the as-shipped reality):** the pre-merge importer estimate was ~30 in `.convoys/ship-readiness.md` P1 #9; actual was 7. This is not a real spec deviation — it's a loose estimate that was correct at the time the estimate was made but became stale once `fix-layout-default-user` (PR #15) migrated most of the tree to `lib/use-auth.js`. The post-flip ship-readiness P1 #9 entry records the actual count. **Cross-convoy follow-up (R5 still open).** § Risks R5 — two components on the same page that both call `useAuth()` will issue two verify roundtrips and hold two independent `user` references — is intentionally out of scope. This was true pre-convoy too (the legacy `useIsAdmin` was already a separate verify). Hoisting state into a shared module-level cache or reintroducing a thin `` that only hoists state is a separate decision; see § Follow-ups in this convoy file. ## Follow-ups (out of scope here) - **Component-level `useAuth` cache audit.** Two components on the same page that both call `useAuth()` will issue two verify roundtrips. This was the original motivation for the legacy context, and was the *one* legitimate thing those providers did right. A future convoy should consider either (a) returning a shared module-level state via a small Zustand-style store, (b) reintroducing a thin `` that *only* hoists state without re-implementing fetch logic, or (c) accepting the duplicate roundtrip as the price of hook-only simplicity. Today's call sites already deduplicate at the page level (one `useAuth` per page is the prevailing pattern), so this is a soft optimisation, not a correctness fix. - **Rate-limit-aware re-auth on 429.** `lib/use-auth.js`'s `checkAuth` does not currently back off if `/api/auth/verify` returns 429 (the rate-limiter from `add-rate-limiting` would only kick in if a single client exceeded 60 verify calls / minute, which is unrealistic in practice but worth a defensive guard). - **Server-side hydration of user.** The page-mount verify roundtrip is unavoidable in this hook-only shape because the token is only readable on the client. Moving to an HTTP-only cookie + Next.js `getServerSideProps` hydration would eliminate the round-trip entirely and is a larger architectural conversation that should not piggyback on a quality convoy. - **Doc-writer cleanup.** Update `.convoys/ship-readiness.md` § P1 → entry 9 with the RESOLVED stamp + squash SHA; trim the "three parallel surfaces" framing from any other doc that still mentions it; refresh the "Auth refactors" no-go-zones bullet if any other files become canonical (none today).