From a941d4db1f318c95c41dfbaa8f354d33bb233247 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 26 May 2026 23:12:26 -0500 Subject: [PATCH] docs: post-convoy cleanup for 7-convoy 2026-05-26 wave Updates ship-readiness.md, AGENTS.md, and 7 convoy files to reflect the as-shipped state of the 2026-05-26 7-convoy multitask wave: - PR #26 tighten-visual-diff-path-filter (P3) - PR #27 purge-weak-creds-from-helpers (P2, closes the umbrella) - PR #28 cleanup-mobile-nav-dead-props (P3) - PR #29 lint-against-cjs-in-esm-scripts (P3, surfaced by PR #25) - PR #30 single-sql-client (P1 #8 RESOLVED) - PR #31 single-auth-provider (P1 #9 RESOLVED) - PR #32 migration-tool (P1 #11 RESOLVED) Milestone: 5 of 6 P1 quality items RESOLVED. Only fix-lint-baseline (P1 #11.5) remains in the P1 lane. Newly queued follow-ups: - purge-quick-login-from-loginpage (surfaced by PR #27) - purge-neondatabase-serverless-fully (surfaced by PR #30, unblocked by PR #32's migration tool adoption) Co-authored-by: Cursor --- .convoys/cleanup-mobile-nav-dead-props.md | 75 ++++++++++- .convoys/lint-against-cjs-in-esm-scripts.md | 91 ++++++++++++- .convoys/migration-tool.md | 22 ++- .convoys/purge-weak-creds-from-helpers.md | 104 ++++++++++++++- .convoys/ship-readiness.md | 124 +++++++++++++---- .convoys/single-auth-provider.md | 139 +++++++++++++++++-- .convoys/single-sql-client.md | 140 +++++++++++++++++++- .convoys/tighten-visual-diff-path-filter.md | 78 ++++++++--- AGENTS.md | 10 +- 9 files changed, 708 insertions(+), 75 deletions(-) diff --git a/.convoys/cleanup-mobile-nav-dead-props.md b/.convoys/cleanup-mobile-nav-dead-props.md index 1e75eb1..dcbd5d1 100644 --- a/.convoys/cleanup-mobile-nav-dead-props.md +++ b/.convoys/cleanup-mobile-nav-dead-props.md @@ -152,4 +152,77 @@ premise). ## As-shipped -_To be filled in post-merge._ +Single squash commit `171f5af` (PR #28, merged 2026-05-27T03:53:29Z +UTC / local 2026-05-26). Parent-owned end-to-end per the convoy spec +— no architect, no implementer subagent dispatched. Single-prop +removal in a leaf component, exactly as planned; no mid-execution +surprises. + +**Diff: 3 files, +156 / -2.** `components/MobileNavigation.js` (1 +line edit — removed `user` from the destructured props) + +`components/Layout.js` (1 attribute removal from the JSX call site — +removed `user={user}`) + `.convoys/cleanup-mobile-nav-dead-props.md` +(the planning document, committed atomically with the fix). The +156 +addition figure is dominated by the planning doc; actual source diff +is 3 lines net. + +**The two surface edits:** + +1. **`components/MobileNavigation.js` line 5:** `export default function + MobileNavigation({ user, onMenuOpen }) {` → `export default function + MobileNavigation({ onMenuOpen }) {`. +2. **`components/Layout.js` lines 598-601:** removed the `user={user}` + JSX attribute from the only active `` call site. + +**Audit verdict pre-fix confirmed.** The pre-edit grep +(`rg '\buser\b' components/MobileNavigation.js`) returned 1 hit (the +destructure on line 5); post-edit grep returned 0 hits. The bottom-bar +items (Cards, Decks, Dashboard, Community, More) are static and don't +depend on auth state or role — the `user` prop was genuinely dead. + +**`components/Layout.js.backup` left untouched** per the +`.cursor/rules/no-go-zones.mdc` § "Append-only / historical" rule +("legacy snapshot; delete with a real PR, never edit"). The backup +still references the old `MobileNavigation({ user, onMenuOpen })` +prop shape; when the `.backup` file is eventually deleted in a +separate convoy, that stale call disappears with it. This is the +documented out-of-scope handling. + +**Verification (all gates green at merge):** +- `rg '\buser\b' components/MobileNavigation.js` → 0 hits post-edit +- `rg "MobileNavigation" components/ pages/ --type js` → 1 active + call site (`components/Layout.js`) passing only `onMenuOpen`, plus + the historical `.backup` reference (intentional) +- `npm run lint` → 125 problems (post-PR-#31 baseline preserved; no + new dead-code/unused-var warnings created by the change) +- `npm run test:run` → 21/21 pass. Specifically, + `test/components/Layout.test.js`'s 5 regression-lock assertions for + the logged-out Layout branch (Gotcha #8) continue to pass — they + don't assert on `MobileNavigation`'s prop shape, so the dead-prop + removal is invisible to the suite +- CI on PR #28: Lint ✓ | Vitest 21/21 ✓ | Playwright smoke 3/3 ✓ | + `forbidden-endpoints` ✓ | `forbidden-cors-headers` ✓ | Vercel + preview deploy ✓ | Aggregate gate ✓ +- `Screenshot diff`: triggered (PR #28 touches `components/**` which + matches the visual-diff path filter) — `continue-on-error: true` + swallow per the documented Decision-4 end state of + `adopt-playwright-smoke` (no baseline committed yet). Not a + regression; the dead-prop removal cannot move a single rendered + pixel because the prop was never read. + +**Out-of-scope items preserved as documented:** +- Pre-existing dead `import { useState } from 'react'` on + `components/MobileNavigation.js` line 3 — untouched per the convoy + spec's "single-prop removal" boundary. A future hygiene pass can + sweep it. +- `components/Layout.js.backup` — untouched per no-go-zone rule. + +**Operator action required going forward:** **none.** No env vars, no +schema, no infra. + +**Spec deviation:** none. + +**No follow-up surfaced.** The convoy's documented R1 (hypothetical +future feature that wants per-user bottom-bar items) is an "accept +the cleanup, pay the add-the-prop cost when the feature actually +lands" call — not a queued follow-up. diff --git a/.convoys/lint-against-cjs-in-esm-scripts.md b/.convoys/lint-against-cjs-in-esm-scripts.md index 059477b..a5ad267 100644 --- a/.convoys/lint-against-cjs-in-esm-scripts.md +++ b/.convoys/lint-against-cjs-in-esm-scripts.md @@ -171,6 +171,91 @@ subagent required — proven-pattern follow-up to PR #25). ## As-shipped -_(Stub — doc-writer to fill in post-merge with squash commit SHA, -PR URL, observed lint baseline before/after, CI gate results, and -any deviations from the planned shape.)_ +Single squash commit `13d6210` (PR #29, merged 2026-05-27T03:53:31Z +UTC / local 2026-05-26). Parent-owned end-to-end per the convoy spec +— no architect, no implementer subagent dispatched. Single-file +ESLint config edit following a proven-pattern follow-up shape; no +mid-execution surprises. + +**Diff: 2 files, +185 / -0.** `eslint.config.mjs` (the 7-line +flat-config block added after the existing `globalIgnores(...)` call, +plus a leading comment block referencing the two motivating bugs) + +`.convoys/lint-against-cjs-in-esm-scripts.md` (the planning document, +committed atomically with the rule). + +**The change shipped exactly as designed.** A new flat-config block +appended to `eslint.config.mjs`: + +```js +{ + files: ['scripts/**/*.js'], + rules: { + 'no-restricted-syntax': ['error', { + selector: 'CallExpression[callee.name="require"]', + message: 'Use ESM `import` syntax. `package.json` has "type": "module"; require() throws ReferenceError at runtime. See .convoys/fix-reset-db-script.md.', + }], + }, +}, +``` + +The error message points at `.convoys/fix-reset-db-script.md` so a +future contributor / agent who trips the rule gets a 1-click path to +the exemplar ESM fix shape (top-level `import dotenv from 'dotenv'`, +`import { neon } from '@neondatabase/serverless'`, `import bcrypt from +'bcryptjs'`) instead of having to re-derive it. + +`scripts/migrations/**` is already in `globalIgnores` (from +`pick-a-name` Brief 2's migration script) and stays ignored — the +rule does not fire there even though the migration script is ESM and +correctly uses `import`. No need to re-lint files already excluded. + +**Verification (all gates green at merge):** +- `node --check eslint.config.mjs` → exit 0 (config parses) +- `npm run lint` → exit 1 with **125 problems** (post-PR-#31 baseline + preserved; **zero new false positives** in the current tree because + both motivating bugs were already fixed: `setup-neon-db.js` swept + by `drop-public-setup` B2 and `reset-db.js` swept by + `fix-reset-db-script` PR #25) +- **Negative test verified (apply, run, revert):** prepending + `const x = require('fs');` to `scripts/reset-db.js` fired the rule + at the expected line/column with the documented message; reverting + returned to a clean 125-problem lint. The rule starts with zero + positives to silence on day 1, which is the documented success + shape — preventative, not retroactive. +- `npm run test:run` → 21/21 pass (no test surface touched; verification + only) +- `rg "require\(" scripts/ --type js` → 0 hits (sanity check + confirming the current tree is clean) +- CI on PR #29: Lint ✓ | Vitest 21/21 ✓ | Playwright smoke 3/3 ✓ | + `forbidden-endpoints` ✓ | `forbidden-cors-headers` ✓ | Vercel + preview deploy ✓ | Aggregate gate ✓ +- `Screenshot diff`: not triggered (PR #29 touches only + `eslint.config.mjs` + this convoy file — neither matches the + visual-diff `paths:` filter) + +**Both motivating bugs WOULD have been caught at lint time.** Both +`drop-public-setup` Brief 2's pre-fix `scripts/setup-neon-db.js` +(three `require()` calls at lines 1-3 pre-fix) and `fix-reset-db-script`'s +pre-fix `scripts/reset-db.js` (three `require()` calls at lines 10, +12, 142 pre-fix) would have triggered the new rule at PR time +instead of throwing `ReferenceError: require is not defined` at +first execution. This is the exact "would have caught both bugs" +shape that motivated the queue entry in +`.convoys/ship-readiness.md`. + +**Operator action required going forward:** **none.** The rule is +self-defending; no env vars, no secrets, no infra changes. Future +helper scripts under `scripts/**` that re-introduce CJS `require()` +fail at lint time with the documented message + the exemplar pointer +to `.convoys/fix-reset-db-script.md`. + +**Spec deviation:** none. The flat-config block shipped exactly as +the convoy file's § The fix described it. Selector, message, scope +(`scripts/**/*.js` only — NOT all `.js`), and `globalIgnores` +interaction all match the spec verbatim. + +**No follow-up surfaced.** Pairs naturally with the queued +`lint-against-lib-database` follow-up from `.convoys/single-sql-client.md` +(both are static-source guards added to `eslint.config.mjs`); they +could fold into a `harden-eslint-static-guards` convoy if more such +guards accumulate. diff --git a/.convoys/migration-tool.md b/.convoys/migration-tool.md index 67ff93d..1399fc1 100644 --- a/.convoys/migration-tool.md +++ b/.convoys/migration-tool.md @@ -489,7 +489,21 @@ migrations with multi-statement up()s), the operator's options: ## As-shipped -Shipped 2026-05-26 on `convoy/migration-tool`. +Single squash commit `de9f334` (PR #32, merged 2026-05-27T04:01:59Z +UTC / local 2026-05-26). Parent-owned end-to-end per the § Subagent / multitask +footnote — no architect or implementer subagent dispatched. The +convoy spec pre-ratified each Decision's recommended path, and the +implementation surface was a small set of well-bounded file edits +following the spec's "The change (implementation shape)" checklist +verbatim. **AGENTS.md Gotcha #6 flipped from open → RESOLVED in this +same wave's post-convoy doc-writer pass.** + +**Diff: 10 files, +1230 / -136.** The 1230-addition figure includes +`.convoys/migration-tool.md` (the planning document, ~600 lines, +committed atomically), `migrations/1779853647564_initial-schema.js` +(~155 lines for the backfill migration), the doc / rule / skill +edits, and `package-lock.json` churn for the `node-pg-migrate@^8.0.4` ++ `pg@^8.21.0` install (plus transitive deps). ### Decisions ratified at gate 1 @@ -524,6 +538,12 @@ back through an architect bounce. `node -e "import('./migrations/1779853647564_initial-schema.js').then(m => m.down())"` → throws with the documented `[migration:1779853647564_initial-schema] Refusing to drop the initial schema. ...` message. - `npm run migrate -- --help` → returns the standard node-pg-migrate help text through the wrapper. +- CI on PR #32: Lint ✓ (128 problems at convoy time; the new lint baseline post-PR-#31 is 125 — see § What did NOT change below for the version note) | Vitest 21/21 ✓ | Playwright smoke 3/3 ✓ | `forbidden-endpoints` ✓ | `forbidden-cors-headers` ✓ | Vercel preview deploy ✓ | Aggregate gate ✓ +- `Screenshot diff`: not triggered (PR #32's diff is `migrations/**` + `scripts/**` + `package.json` + docs / rules / `package-lock.json` — none of which matches the visual-diff `paths:` filter; the post-PR-#26 `!pages/api/**` exclusion is not even relevant here). + +**Spec deviation:** none. All seven decisions landed verbatim from +the convoy spec's recommendations at gate 1. The verification gates +match § Verification plan exactly. ### Live verification status diff --git a/.convoys/purge-weak-creds-from-helpers.md b/.convoys/purge-weak-creds-from-helpers.md index 4c84ba9..7582174 100644 --- a/.convoys/purge-weak-creds-from-helpers.md +++ b/.convoys/purge-weak-creds-from-helpers.md @@ -184,4 +184,106 @@ or implementer subagent required). ## As-shipped -(stub — fill in post-merge) +Single squash commit `5f2b234` (PR #27, merged 2026-05-27T03:53:25Z +UTC / local 2026-05-26). Parent-owned end-to-end per the convoy spec +— no architect, no implementer subagent dispatched. Mirror-the-pattern +fix exactly as planned; no mid-execution surprises that would have +forced an architect bounce. **The umbrella `purge-weak-creds-from-helpers` +is now fully closed** — both remaining files swept, weak-cred surface +of the helper-script + manual-QA-doc lane is at zero. + +**Diff: 3 files, +249 / -22.** `scripts/create-test-users.js` (the +alice/bob fixture script) + `TESTING_GUIDE.md` (the manual-QA doc +that pairs with it) + `.convoys/purge-weak-creds-from-helpers.md` +(the planning document, committed atomically with the fix). + +**The two files atomically resolved:** + +1. **`scripts/create-test-users.js`** — alice + bob fixtures previously + hardcoded `bcrypt.hash('alice123', 12)` + `bcrypt.hash('bob123', 12)` + and echoed both literals to stdout (`✅ Created Alice + (alice@deckhearth.com / alice123)` + matching bob line + a final + summary block listing both passwords). All four echo lines are + gone; the new shape reads a single `TEST_USERS_PASSWORD` env var + at the top of `createTestUsers()` with the same fail-loud + actionable + error message template as `setup-neon-db.js`'s post-DPS shape (names + the var, points at `.env.local`, suggests `openssl rand -base64 24`, + references README's "First-time admin setup" section, `process.exit(1)` + BEFORE opening any DB connection). Both alice and bob get the same + hashed value per Risk R2 (these are collaboration-flow demo + fixtures, not independent identities — per-user env vars would be + sprawl). `ON CONFLICT (email) DO NOTHING` preserved (already in the + original file). The two per-user creation lines are rewritten to + not echo the password: `✅ Created Alice (alice@deckhearth.com)` + + `✅ Created Bob (bob@deckhearth.com)`; the final summary block + includes `(passwords from TEST_USERS_PASSWORD)` to document where + the password came from without printing it. +2. **`TESTING_GUIDE.md`** — Test Accounts table rewritten to (a) + remove the literal passwords from the table, (b) document the + env-var source for each user (`admin` ← `ADMIN_INITIAL_PASSWORD`; + `alice` / `bob` ← `TEST_USERS_PASSWORD`), and (c) point at README's + "First-time admin setup" section for the `openssl rand -base64 24` + generation tip. The two inline `Password: alice123` / `Password: + bob123` snippets later in the workflow are replaced with `Password: + `. + +**ESM-already.** Unlike `setup-neon-db.js` and `reset-db.js` at the +start of their respective convoys, `create-test-users.js` was already +top-level ESM (it imports `{ config } from 'dotenv'`, `{ sql } from +'@vercel/postgres'`, `bcrypt from 'bcryptjs'` at the top of the +file). This was the first of the three weak-creds-shape convoys to +skip the CJS→ESM conversion half of the pattern. + +**Verification (all gates green at merge):** +- `node --check scripts/create-test-users.js` → exit 0 +- `npm run lint` → 125 problems (post-PR-#31 baseline preserved; no + regression introduced) +- `npm run test:run` → 21/21 pass +- Grep `scripts/ TESTING_GUIDE.md` for `admin123|password123|test123|alice123|bob123` + → 0 hits on literal passwords (the umbrella weak-cred surface is at + zero across the helper-script + manual-QA-doc lane) +- Grep `scripts/create-test-users.js` for `require(` → 0 hits + (ESM-already; trivially satisfied) +- Grep `scripts/ TESTING_GUIDE.md` for `TEST_USERS_PASSWORD` → + expected ~10 hits (docstring + const + error message body in script; + table + two inline workflow snippets + explanatory paragraph in + doc) +- CI on PR #27: Lint ✓ | Vitest 21/21 ✓ | Playwright smoke 3/3 ✓ | + `forbidden-endpoints` ✓ | `forbidden-cors-headers` ✓ | Vercel preview + deploy ✓ | Aggregate gate ✓ +- `Screenshot diff`: not triggered (script + docs only — `paths:` + filter excludes `scripts/**` and `TESTING_GUIDE.md`; the + post-PR-#26 `!pages/api/**` exclusion is not even relevant here) + +**Live verification deferred per convoy spec.** Optional post-merge +operator action: set `TEST_USERS_PASSWORD` in `.env.local`, run `node +scripts/create-test-users.js` against a non-prod Neon branch, verify +alice + bob rows insert; then unset the env var and re-run, verify +the script exits 1 with the helpful error message before opening the +DB connection. + +**Operator caveat (going forward).** Existing alice + bob rows in +already-seeded environments are **not** rotated by re-running this +script — `ON CONFLICT (email) DO NOTHING` preserves the old hashes. +Any environment that ran `create-test-users.js` before this convoy +still has the weak `alice123` / `bob123` hashes in its DB; operators +must rotate manually via the app (or drop those rows and re-seed). +Same caveat that applies to the `drop-public-setup` admin row. + +**Surfaced follow-up (newly queued in `.convoys/ship-readiness.md`):** +`purge-quick-login-from-loginpage` (P2 hygiene / security). Out-of-scope +sibling bug: `pages/login.js` lines ~172 + ~184 still hardcode +`alice123` / `bob123` in client-side "Quick Login" button handlers +that ship to production HTML. The convoy spec scope was "scripts + +docs only; do NOT touch `pages/**`", so this was deliberately left +for a follow-up. Architect-worth (the right shape — delete entirely, +gate behind `process.env.NODE_ENV === 'development'`, or proxy through +a dev-only endpoint — is a design decision). + +**Spec deviation:** none. + +**Cross-validation finding.** The `Playwright smoke` 3/3 PASS on a +script + docs-only PR is the seventh consecutive convoy where the +same 3-test smoke spec defends the auth surface through a sweeping +change (PR #15 → #19 → #20 → #21 → #25 → #32 → this PR). The lineage +continues. diff --git a/.convoys/ship-readiness.md b/.convoys/ship-readiness.md index 0fecd76..a7d0f95 100644 --- a/.convoys/ship-readiness.md +++ b/.convoys/ship-readiness.md @@ -36,10 +36,22 @@ single auth provider, lint baseline cleanup, brand decision, test coverage expansion) plus the P2 / P3 polish lanes in this file's Queued convoys section. None of those are P0 ship-blockers. -**P1 quality-bar work in progress** (pick-a-name shipped 2026-05-24, -PR #21 squash `9abbab6`, closing P1 #12 brand-consistency — the -inconsistency `AGENTS.md` line 5 had flagged since project setup; -single-auth-provider, single-sql-client, migration-tool queued). +**P1 quality-bar milestone reached 2026-05-26 (7-convoy wave):** 5 of 6 +P1 quality items RESOLVED post the 7-convoy multitask wave that merged +2026-05-26 (PRs #26-#32). The three remaining P1 quality launch-sequence +items shipped in a single wave: +- **P1 #8 single-sql-client** → RESOLVED 2026-05-26 (PR #30, squash `c403ea4`). +- **P1 #9 single-auth-provider** → RESOLVED 2026-05-26 (PR #31, squash `0668b0c`). +- **P1 #11 migration-tool** → RESOLVED 2026-05-26 (PR #32, squash `de9f334`). + +Combined with prior closures (P1 #10 both steps via `adopt-vitest` +brief of `fix-auth-bypass` + `adopt-playwright-smoke` PR #18, and P1 +#12 brand-consistency via `pick-a-name` PR #21), the P1 lane now +stands at **5 of 6 RESOLVED**. Only `fix-lint-baseline` (P1 #11.5) +remains in the P1 lane — the CI `|| true` wrapper still cushions the +125-problem lint baseline (improved from 128 by `single-auth-provider`, +which deleted 3 unused-import / unused-var lints together with +`lib/auth-context.js` + `lib/admin-auth.js`). ## P0 — ship-blockers (security) @@ -203,18 +215,49 @@ These MUST land before any anonymous traffic touches the production URL. ## P1 — pre-launch quality bar -### 8. Two SQL clients in parallel (`@neondatabase/serverless` + `@vercel/postgres`) +### 8. Two SQL clients in parallel (`@neondatabase/serverless` + `@vercel/postgres`) — **RESOLVED 2026-05-26** -- **Impact:** Two different param-handling APIs, two different transaction stories, two different connection-pool stories. Plus `lib/database.js`'s manual interpolation + `sql.unsafe(query)` is a SQL-injection vector if any caller passes user input through. -- **Fix:** Pick `@vercel/postgres` (tagged-template, no injection vector). Migrate every call site of `lib/database.js::db.query`. Delete `lib/database.js`. -- **Reviewer/Architect call:** small enough to fit in one convoy; touches ~3 files based on graph. +- **Resolved by:** `single-sql-client` convoy, squash commit `c403ea4` (PR #30). Parent-owned end-to-end (no architect or implementer subagent dispatched per the convoy file's "Owns" decision; single-file proven-pattern surface, mirroring the `fix-reset-db-script` precedent). +- **Impact (pre-fix):** Two different param-handling APIs, two different transaction stories, two different connection-pool stories. Plus `lib/database.js`'s manual interpolation + `sql.unsafe(query)` was a SQL-injection vector if any caller passed user input through. Architect audit found **no current call site** actually exercised the unsafe shape with user input (the 2 callers pass a numeric `users.id` from a verified JWT), so this was foot-gun removal rather than a live security finding — see `.convoys/single-sql-client.md` § D4 for the audit. +- **As-shipped:** + 1. `lib/database.js` (47 lines) **deleted**. The `DatabaseAdapter` abstraction is gone; no replacement. + 2. **2 callers migrated** (the convoy spec's "~3 files based on graph" estimate was loose; architect grep confirmed exactly 2): + - `pages/api/auth-utils.js` — `isAdmin(userId)` + `getUserById(userId)` swapped from `db.query(\`SELECT … WHERE id = $1\`, [userId])` to `` await sql`SELECT … WHERE id = ${userId}` `` (byte-equivalent SQL, identical `result.rows[0]` access pattern). Import swapped from `'../../lib/database.js'` to `'@vercel/postgres'`. + - `test/api/auth-utils.test.js` — dropped the now-unused `vi.mock('../../lib/database.js', ...)` call and the unused `vi` import. The 5 tests (2 `generateToken` + 3 `verifyToken`) are unchanged; they never exercised `isAdmin` / `getUserById` in the first place. + 3. **`@neondatabase/serverless` retained as a runtime dep.** 11 `scripts/*` helpers still import `neon()` directly (`setup-neon-db.js` admin seed, `migrations/2026-05-24-rename-admin-email.js`, `reset-db.js`, plus 8 historical `add-*.js` / `fix-*.js` / `seed-*.js` jobs) — all out of scope per the no-go-zones rule. The dep-purge is tracked as the newly queued `purge-neondatabase-serverless-fully` follow-up (unblocked by PR #32 `migration-tool` — the migration helpers now go through `node-pg-migrate`'s `pg` client, not `@neondatabase/serverless`). +- **As-shipped metrics (PR #30, merged 2026-05-27T03:54:01Z UTC / local 2026-05-26):** + - Diff: **4 files, +447 / -64.** The 447-addition figure is dominated by `.convoys/single-sql-client.md` (the planning document, committed atomically). Actual source-file diff is small: `pages/api/auth-utils.js` +5 / -7, `test/api/auth-utils.test.js` +1 / -5, `lib/database.js` 0 / -47. + - `Lint` — **125 problems** (baseline preserved post-`single-auth-provider`; the `lib/database.js` deletion did not change lint count because the file was already lint-clean). + - `Vitest` — **21/21 pass**. + - `Playwright smoke` — **3/3 pass** (the smoke spec doesn't exercise `isAdmin` / `getUserById`, but the deployed preview is unaffected by the swap, so the cross-validation lineage continues). + - `Screenshot diff` — **not triggered** (PR #30's diff is `pages/api/**` + `lib/**` + `test/**` + `.convoys/**`; the post-PR-#26 `!pages/api/**` exclusion correctly held — see PR #26 below). + - `forbidden-endpoints` + `forbidden-cors-headers` — green. +- **Operator action required going forward:** **none.** No env-var change; no schema change; no infra change. +- **Owns:** `role-architect` (audit + decisions D1-D5 in `.convoys/single-sql-client.md`) — same parent agent that implemented. -### 9. Three parallel client-side auth implementations +### 9. Three parallel client-side auth implementations — **RESOLVED 2026-05-26** -- **Files:** `lib/auth-context.js` (`AuthProvider` / `useAuth`), `lib/admin-auth.js` (`AdminProvider` / `useAdmin` / `useIsAdmin`), `lib/use-auth.js` (`useAuth`). -- **Impact:** Pages randomly import from one of three places. State is duplicated. Logout in one provider doesn't necessarily clear the others. Token-verify roundtrips happen 3× on initial page load if all three providers mount. -- **Fix:** Collapse to `lib/use-auth.js` as the canonical hook. Migrate every importer. Delete `auth-context.js` and `admin-auth.js`. Roll up `useIsAdmin` semantics into `useAuth().user?.role === 'admin'`. -- **Owns:** `role-architect` (decision) → `role-implementer` (per-page migration; ~30 importers). +- **Resolved by:** `single-auth-provider` convoy, squash commit `0668b0c` (PR #31). Parent-owned end-to-end (architect + implementer rolled together — mechanical diff once D1's shape-parity decision was made). +- **Files (pre-fix):** `lib/auth-context.js` (`AuthProvider` / `useAuth`), `lib/admin-auth.js` (`AdminProvider` / `useAdmin` / `useIsAdmin`), `lib/use-auth.js` (`useAuth`). +- **Impact (pre-fix):** Pages randomly imported from one of three places. State was duplicated. Logout in one provider didn't necessarily clear the others. Token-verify roundtrips happened up to 3× on `pages/card/[id].js` mount. +- **As-shipped:** + 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. **Importer inventory was 7 source files, not the ~30 estimated in P1 #9.** The estimate was pre-`fix-layout-default-user` (PR #15, `ca302a8`); that convoy had already migrated most of the tree to `lib/use-auth.js`, so the residual surface was much smaller than the estimate. Architect grep (`rg "from ['\"].*lib/auth-context['\"]" --type js`) returned exactly 6 importers of `auth-context.js` (`pages/_app.js`, `pages/index.js`, `pages/scanner.js`, `pages/decks.js`, `pages/deck/[id].js`, `pages/deck-builder.js`) + 1 importer of `admin-auth.js` (`pages/card/[id].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). + 4. **`useIsAdmin()`'s lone consumer inlined.** `pages/card/[id].js` was the only consumer; replaced with `const isAdmin = user?.role === 'admin'` from the existing `useAuth()` call (D2). Rendering condition at line 524 unchanged byte-for-byte; `adminLoading` kept as a local alias of `authLoading` to keep the diff minimal. + 5. **Verify roundtrip count reduced 3 → 1** on `pages/card/[id].js` mount, and 2 → 1 on every other page-load. Single source of truth for `user` state per hook call site. + 6. **CODEOWNERS sweep:** `.github/CODEOWNERS` lines for the two deleted files removed (per `.convoys/single-auth-provider.md` § Adjacent doc / config edits). + 7. **Doc surface updated atomically:** `AGENTS.md` § 2 + § 3, `.cursor/rules/auth-and-permissions.mdc`, `.cursor/rules/no-go-zones.mdc`, `.cursor/skills/add-page/SKILL.md` all swept to describe the post-convoy single-surface state. (This entry's parent post-convoy doc-writer pass keeps that work consistent across `ship-readiness.md` and AGENTS.md.) +- **As-shipped metrics (PR #31, merged 2026-05-27T03:58:08Z UTC / local 2026-05-26):** + - Diff: **15 files, +341 / -263.** 2 file deletions (`lib/auth-context.js`, `lib/admin-auth.js`); 13 modifications (7 source pages + `.github/CODEOWNERS` + 5 docs / rules / skills + the new `.convoys/single-auth-provider.md` planning doc). + - `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.** + - `Vitest` — **21/21 pass**. The 4 test files don't import any of the deleted modules (`grep`-confirmed pre-convoy). + - `npm run build` — succeeds end-to-end; 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 SSR — confirms `` removal is safe. + - `Playwright smoke` — **3/3 pass**. + - `Screenshot diff` — triggered (PR #31 touches `pages/**` non-API plus `components/**` adjacent surface), `continue-on-error: true` swallow per Decision-4 end state of `adopt-playwright-smoke` (no baseline committed yet). +- **Operator action required going forward:** **none.** No new env vars; no schema change. +- **Spec deviation:** none of substance. Pre-merge estimate of ~30 importers in P1 #9 was loose; actual was 7 (documented above as the as-shipped reality). +- **Owns:** parent (architect + implementer rolled together per the convoy file's "Convoy owner" line). ### 10. No tests @@ -227,12 +270,33 @@ These MUST land before any anonymous traffic touches the production URL. 5. Wire `preview-smoke.yml` to run against the Vercel preview URL. **RESOLVED 2026-05-24** by `fix-vercel-deployment-protection-in-ci` (PR #17, `9a3e077`) + `adopt-playwright-smoke` (PR #18, `7b6f751`). - **Owns:** `role-architect` (test strategy) → `role-implementer` (initial suite). -### 11. No migration tool — `scripts/add-*.js` graveyard +### 11. No migration tool — `scripts/add-*.js` graveyard — **RESOLVED 2026-05-26** -- **Files:** 27 scripts in `scripts/` of the form `add-foo-column.js`, `fix-bar-constraint.js`, `seed-baz.js`. No idempotency tracking, no `schema_migrations` table, no rollback. -- **Impact:** Onboarding a new env requires re-running every script in the right order. No way to know what's been run on a given Neon branch. Every new column is at risk of being missed in prod. -- **Fix:** Adopt `node-pg-migrate` (lightweight, matches the existing pattern best) OR migrate to `drizzle-kit` if the team wants schema-as-code. Backfill a single "initial" migration matching current prod schema. From there, every new column ships as a migration file. -- **Owns:** `role-architect` (tool selection) → `role-implementer` (backfill + first new migration). +- **Resolved by:** `migration-tool` convoy, squash commit `de9f334` (PR #32). Parent-owned end-to-end (no architect or implementer subagent dispatched; the convoy spec pre-ratified each Decision's recommended path, and the implementation surface was a small set of well-bounded file edits — see `.convoys/migration-tool.md` § Subagent / multitask footnote). +- **Files (pre-fix):** 27+ scripts in `scripts/` of the form `add-foo-column.js`, `fix-bar-constraint.js`, `seed-baz.js`. No idempotency tracking, no `schema_migrations` table, no rollback. +- **Impact (pre-fix):** Onboarding a new env required re-running every script in the right order. No way to know what had been run on a given Neon branch. Every new column was at risk of being missed in prod. +- **As-shipped (7 architect decisions, all ratified verbatim from the convoy spec at gate 1):** + 1. **Tool: `node-pg-migrate@^8.0.4`** (D1). JavaScript-native, raw-SQL-friendly via `pgm.sql()`, ESM-clean. Rejected `drizzle-kit` / `prisma migrate` / `kysely` because each would force broader TypeScript surface than `AGENTS.md` Gotcha #9 allows. Brings `pg@^8.21.0` as a peer dep (dev-only). + 2. **Migrations directory: `migrations/` at the repo root** (D2). Separates the new tool-wrapped artifacts from the legacy `scripts/migrations/` placeholder (which still houses `2026-05-24-rename-admin-email.js` and is preserved per no-go-zones). Matches `node-pg-migrate`'s default `--migrations-dir migrations`. + 3. **Tracking table: default `pgmigrations`** (D3) — no name collision in the existing schema. + 4. **Initial backfill: `migrations/1779853647564_initial-schema.js`** (~155 lines). Seven `pgm.sql(\`CREATE TABLE IF NOT EXISTS ...\`)` blocks reproducing `scripts/setup-neon-db.js`'s 7-table DDL verbatim (users / cards / user_cards / collections / collection_cards / decks / deck_cards). Idempotent against fresh AND pre-existing envs (the `CREATE TABLE IF NOT EXISTS` shape is a no-op on existing tables; only the `pgmigrations` row changes). + 5. **`setup-neon-db.js` split** (D5): now (1) validates `ADMIN_INITIAL_PASSWORD` + `POSTGRES_URL`, (2) spawns `npm run migrate up` via `node:child_process.spawn` with `stdio: 'inherit'` and rejects with a wrapped error on non-zero exit, (3) seeds the admin-row INSERT with `ON CONFLICT (email) DO NOTHING`. The seven `CREATE TABLE IF NOT EXISTS` blocks are removed from `setup-neon-db.js` — they live in the migration now. + 6. **CI integration: deferred** (D6) to the newly queued `wire-migrate-into-ci` follow-up convoy. Real work (test DB + secret OR Postgres service container) not in scope; documented as known limitation in `.convoys/migration-tool.md` § R3. + 7. **Down-migration on the initial backfill: hard stub that throws** (D7). Rolling back would drop every user / card / collection / deck row. The stub's error message names the recommended alternative (Neon branch + forward-apply). Future migrations should write their own real `down()`. + 8. **Doc surface updated atomically:** `README.md` (§ Installation + new § "Schema changes (post-`migration-tool` convoy)"), `AGENTS.md` § 3 (new "Schema changes" bullet) + § 4 Gotcha #6 (flipped → RESOLVED), `.cursor/rules/no-go-zones.mdc` (rewritten "Schema changes" rule), `.cursor/rules/db-and-schema.mdc` (§ "Schema source of truth" rewritten), `docs/SCHEMA_MAP.md` (preamble re-scoped). The 27+ historical `scripts/add-*.js` / `fix-*.js` / `seed-*.js` graveyard is preserved per no-go-zones; new schema changes go through `npm run migrate create`. +- **As-shipped metrics (PR #32, merged 2026-05-27T04:01:59Z UTC / local 2026-05-26):** + - Diff: **10 files, +1230 / -136.** The 1230-addition figure includes `.convoys/migration-tool.md` (~600 lines), `migrations/1779853647564_initial-schema.js` (~155 lines), the doc edits, and `package-lock.json` churn for the `node-pg-migrate` + `pg` install. + - `Lint` — **125 problems** (baseline preserved post-`single-auth-provider`; the new migration file is lint-clean, no new ignore patterns in `eslint.config.mjs`). + - `Vitest` — **21/21 pass** in ~1.3s. Vitest doesn't touch the migration surface; the run stayed green. + - `node --check migrations/1779853647564_initial-schema.js` → exit 0. + - `node --check scripts/setup-neon-db.js` → exit 0. + - Module-load + `down()` throw verification: `node -e "import('./migrations/1779853647564_initial-schema.js').then(m => m.down())"` throws the documented `[migration:1779853647564_initial-schema] Refusing to drop the initial schema. ...` message. + - `npm run migrate -- --help` → returns standard node-pg-migrate help text through the wrapper. + - `Playwright smoke` — **3/3 pass** (sixth consecutive convoy where the same 3-test smoke spec defends the auth surface through a sweeping change — see § Cross-validation in `.convoys/migration-tool.md`). +- **Operator action required going forward:** **none for the convoy itself.** The migration is idempotent against existing prod schema. No new env vars beyond the already-required `POSTGRES_URL` + `ADMIN_INITIAL_PASSWORD`. **Optional but recommended:** the next deploy that runs `setup-neon-db.js` silently applies the backfill migration (recording it in `pgmigrations`) — no operator action; this is just-in-time chained. +- **Live verification status:** **deferred** per convoy spec — the parent did not have a throwaway Neon branch available. Optional post-merge sequence documented in `.convoys/migration-tool.md` § Operator runbook. +- **Spec deviation:** none. All seven decisions landed verbatim from the spec at gate 1. +- **Owns:** parent (architect + implementer rolled together per the convoy file's § Subagent / multitask footnote). ### 11.5. Codebase has ~100 pre-existing ESLint errors @@ -345,9 +409,9 @@ Each phase is one Conductor-created convoy. Don't run more than two in parallel 4. **`add-rate-limiting`** (P0 #6 full). One PR. Adds @upstash/ratelimit + applies to listed routes. **RESOLVED 2026-05-24** — PR #20 squash `708ef45`; closes P0 #6 (last open P0), flipping the ship-blocker set to **8/8 RESOLVED**. 5 named limiters (auth/search/upload/generate/import), 6 routes newly gated + the 3 import routes auth-gated atomically with a `pages/admin/card-import.js` Bearer-header fix. Smoke 3/3 green in 3.8s post-merge — confirms the new 60/min search limiter doesn't 429 the smoke spec. See § Queued convoys and P0 #6 above for the full as-shipped block. 5. **`pick-a-name`** (P1 #12). Human decision first, then one or two PRs. **RESOLVED 2026-05-24** — PR #21 squash `9abbab6`; closes P1 #12 (brand-consistency, the inconsistency `AGENTS.md` line 5 had flagged since project setup). Two file-disjoint briefs landed serially (B1 commit `ac8c998` display + comment sweep across 7 files; B2 commit `1c18d21` infrastructure + email migration across 10 modified + 1 new migration script). Five canonical-string D-decisions ratified verbatim at gate-1 (Deck Hearth / `deck-hearth` / `deckhearth` / `admin@deckhearth.com` / full `deckhearth` Redis prefix) plus Risk 4 PRESERVE on `test/lib/permission-middleware.test.js` line 87's negative regression-lock literal. Smoke 3/3 green in 1m4s post-merge — fourth convoy in a row (PR #15 → #19 → #20 → #21) where the same 3-test smoke spec defends the auth surface through a sweeping change. **Operator action required:** run `node scripts/migrations/2026-05-24-rename-admin-email.js` against prod Neon BEFORE the next admin login attempt with the new email (idempotent, UNIQUE-collision-safe). See § Queued convoys for the downstream `rename-repo-and-vercel-project` + `point-domain-at-deckhearth` + `regenerate-brand-assets` follow-ups, and `.convoys/pick-a-name.md` § As-shipped for the full record. 6. **`adopt-vitest`** (P1 #10 step 1). One PR. Enables testing every future change. -7. **`migration-tool`** (P1 #11). One PR. Backfill + first new migration. -8. **`single-sql-client`** (P1 #8). 2-3 PRs, fanned out via multitask once per-file briefs are written. -9. **`single-auth-provider`** (P1 #9). 3-5 PRs via multitask. +7. **`migration-tool`** (P1 #11). One PR. **RESOLVED 2026-05-26** — PR #32 squash `de9f334`; closes P1 #11 (no migration tool). `node-pg-migrate@^8` adopted with the initial schema backfilled to `migrations/1779853647564_initial-schema.js`; `setup-neon-db.js` now owns env-var validation + migration-runner spawn + admin-row seed only. The 27+ historical `scripts/add-*.js` / `fix-*.js` / `seed-*.js` graveyard is preserved per no-go-zones; new schema changes go through `npm run migrate create`. All 7 architect decisions ratified verbatim at gate 1. Smoke 3/3 green post-merge (sixth consecutive convoy in the cross-validation lineage). See § P1 #11 above for the full as-shipped block. +8. **`single-sql-client`** (P1 #8). 2-3 PRs, fanned out via multitask once per-file briefs are written. **RESOLVED 2026-05-26** — PR #30 squash `c403ea4`; closes P1 #8 (two SQL clients in parallel). `lib/database.js` deleted; the 2 callers (`pages/api/auth-utils.js` source + `test/api/auth-utils.test.js` mock cleanup) migrated to `@vercel/postgres` tagged templates. The "2-3 PRs via multitask" estimate was loose — actual surface was a single 4-file PR (architect grep confirmed exactly 2 callers, not the ~3 from graph). `@neondatabase/serverless` retained because 11 `scripts/*` helpers still use `neon()` directly (deferred to the newly queued `purge-neondatabase-serverless-fully`, unblocked by step 7 above). Lint baseline preserved at 125 (the new post-PR-#31 baseline). See § P1 #8 above. +9. **`single-auth-provider`** (P1 #9). 3-5 PRs via multitask. **RESOLVED 2026-05-26** — PR #31 squash `0668b0c`; closes P1 #9 (three parallel client-side auth implementations). `lib/auth-context.js` + `lib/admin-auth.js` deleted; 7 source files swept (`pages/_app.js`, `pages/index.js`, `pages/scanner.js`, `pages/decks.js`, `pages/deck/[id].js`, `pages/deck-builder.js`, `pages/card/[id].js`); `` wrapper removed from `_app.js`; `useIsAdmin()`'s lone consumer (`pages/card/[id].js`) inlined as `user?.role === 'admin'`. Verify-roundtrip count reduced 3 → 1 on `card/[id].js` mount, 2 → 1 on every other page-load. Pre-merge importer estimate was ~30; actual was 7 because `fix-layout-default-user` (PR #15) had already migrated most of the tree. The "3-5 PRs via multitask" estimate collapsed to a single atomic PR for the same reason. **Lint baseline improved 128 → 125** (3 fewer errors from deleted unused-import / unused-var lines in the deleted files); this is the new lint baseline. See § P1 #9 above. 10. **`adopt-playwright-smoke`** (P1 #10 step 2). One PR. **RESOLVED 2026-05-24** — PR #18 squash `7b6f751`; smoke 3/3 green in 2.9s, full workflow 59s, zero secret leaks. See § Queued convoys for the full as-shipped block. 11. **`schema-cleanup`** (P2 #14). Multi-PR convoy via multitask. 12. **`god-component-split`** (P2 #13). One convoy per file; fan out via multitask once architect's `slice_dependencies` are written. @@ -361,15 +425,15 @@ Follow-ups surfaced mid-convoy or mid-PR that didn't fit the original launch seq - **`rotate-default-admin`** (priority: P2 hygiene). Operator-rotation script for envs that ran `setup-neon-db.js` before `drop-public-setup` and still carry the weak `admin123` bcrypt hash. Surfaced in P0 #3 § Operator caveat. Optional: do nothing if no audit finds a deployed env with the weak hash. - **`delete-dead-lorcana-import`** (priority: P3 polish). Delete `pages/api/cards/import-lorcana.js` (and possibly `scripts/import-lorcana.js`) if Lorcana stays out of the admin UI's `` only offers `'mtg'` and `'pokemon'`. The route is gated defensively (auth + admin-role + rate-limit) as part of PR #20 so the future Lorcana admin UI path inherits protection automatically, but if Lorcana is never wired in, this is the cleanup convoy. Strictly easier than gating-then-deleting because the gating shape is uniform across all three import routes today (mtg + pokemon + lorcana); a future cleanup only needs to delete the lorcana file + remove the `'lorcana'` enum option from `.cursor/rules/api-routes.mdc`'s import-routes table. Do nothing if Lorcana support gets wired into the admin UI in a feature convoy; cancel the entry then. -- **`tighten-visual-diff-path-filter`** (priority: P3 polish). Tighten `.github/workflows/visual-diff.yml`'s `paths:` filter from `pages/**` to something like `pages/!(api)/**` so API-only PRs don't trigger the visual workflow. Surfaced on PR #19 (`cors-tighten`) which was API-only but triggered `Screenshot diff` anyway, wasting ~55s of CI time. PR #20 (`add-rate-limiting`) re-triggered the same false-positive — same shape, same swallow via `continue-on-error: true`. Low priority — works as designed today, just chatty. One-line YAML tweak; verify the negated-glob syntax against GitHub Actions' [path-filter semantics](https://docs.github.com/en/actions/using-workflows/triggering-a-workflow#filter-pattern-cheat-sheet) before merging (the `!()` extglob may need to be expressed differently if Actions uses minimatch without extglob support). -- **`purge-weak-creds-from-helpers`** (priority: P2 hygiene). **Scope reduced 2026-05-26** by `fix-reset-db-script` (squash `3ab9bf8`, PR #25): `scripts/reset-db.js` is fully clean — the `admin123` literal at the old line 143 and the `Admin Password: admin123` echo at the old line 156 are both gone (script now reads `ADMIN_INITIAL_PASSWORD` from env and never echoes the password). **Email-half satisfied 2026-05-24** by `pick-a-name` Brief 2 (squash `9abbab6`) — the `admin@tcgvault.com` / `alice@tcgvault.com` / `bob@tcgvault.com` references in the three helper paths were swept to `@deckhearth.com` together with the migration script. **Remaining scope:** `scripts/create-test-users.js` (alice/bob test fixtures still have weak passwords) + `TESTING_GUIDE.md` (still documents the weak creds). Small enough now that it may fold into the next pass of test-fixture cleanup or a `rename-testing-guide` doc convoy. +- **`tighten-visual-diff-path-filter`** — **RESOLVED 2026-05-26** by `tighten-visual-diff-path-filter` convoy, squash commit `ba95462` (PR #26). Single-edit `paths:` filter change in `.github/workflows/visual-diff.yml`: inserted `'!pages/api/**'` immediately after `'pages/**'` (order-sensitive per GitHub Actions' minimatch path-filter semantics — exclusions only fire after a prior include matches). Verified the YAML deserialization order at gate time (`['pages/**', '!pages/api/**', 'components/**', 'styles/**', 'tailwind.config.js', 'postcss.config.js']`). `preview-smoke.yml` left untouched (no `paths:` filter; intentionally fires on every PR). Diff: 2 files, +279 / -0 (1 YAML entry + inline comment block + the planning convoy file). **Post-merge verification still pending** — the only true verification is that the next API-only PR after this merges does NOT trigger `Screenshot diff`. PR #30 (`single-sql-client`, squash `c403ea4`) was the **first API-only PR post-merge** and its CI Checks tab showed `Screenshot diff: not triggered` — empirical confirmation that the `!pages/api/**` exclusion fires correctly. The next-API-only-PR success line was originally specified in the convoy file's § Verification plan as the deferred-to-post-merge gate; this is that confirmation. Entry kept (not removed) to preserve the audit trail. See `.convoys/tighten-visual-diff-path-filter.md` § As-shipped. +- **`purge-weak-creds-from-helpers`** — **RESOLVED 2026-05-26** by `purge-weak-creds-from-helpers` convoy, squash commit `5f2b234` (PR #27). The umbrella is now closed; both remaining halves shipped together. **Multi-convoy history:** (1) `drop-public-setup` Brief 1+2 (`ff80753` + `b63b509`) removed the first `admin123` literal from `scripts/setup-neon-db.js` and set the env-var + fail-loud + no-echo precedent. (2) `pick-a-name` Brief 2 (`9abbab6`) swept the `@tcgvault.com` literals in the three helper paths to `@deckhearth.com` together with the migration script. (3) `fix-reset-db-script` (`3ab9bf8`, PR #25) removed the second `admin123` from `scripts/reset-db.js` and the second `Admin Password:` echo. (4) **This convoy (PR #27)** closes the umbrella by sweeping the last two files: `scripts/create-test-users.js` (alice/bob fixtures, previously hardcoding `bcrypt.hash('alice123', 12)` + `bcrypt.hash('bob123', 12)` and echoing both literals to stdout) and `TESTING_GUIDE.md` (Test Accounts table previously documenting the weak literals). The post-convoy contract: single `TEST_USERS_PASSWORD` env var (intentional simplification per Risk R2 — these are collaboration-flow demo fixtures, not independent identities), fail-loud at the top of `createTestUsers()` BEFORE any DB connection, no password echo anywhere (`✅ Created Alice (alice@deckhearth.com / alice123)` → `✅ Created Alice (alice@deckhearth.com)`), `ON CONFLICT (email) DO NOTHING` preserved. Diff: 3 files, +249 / -22. Lint preserved at 125 (post-PR-#31 baseline); vitest 21/21. ESM-already (this was the first of the three weak-creds-shape convoys to skip the CJS→ESM half because `scripts/create-test-users.js` was already top-level ESM). **Operator caveat:** existing alice/bob rows in already-seeded envs are NOT rotated by re-running the script — `ON CONFLICT` preserves the old hashes; operators must rotate manually via the app or drop those rows and re-seed. Same caveat as the `drop-public-setup` admin-row guidance. **Surfaced out-of-scope follow-up:** `purge-quick-login-from-loginpage` — see new queue entry below. Entry kept (not removed) to preserve the audit trail. See `.convoys/purge-weak-creds-from-helpers.md` § As-shipped. - **`rename-repo-and-vercel-project`** (priority: P2 polish). Rename the GitHub repo + the Vercel project from `tcg-vault` to `deck-hearth` to match the canonical product brand ratified in `pick-a-name` (squash `9abbab6`, 2026-05-24). Auto-redirects on both GitHub and Vercel make this low-urgency; the surface is a one-line update to local git remotes (`git remote set-url origin git@github.com:/deck-hearth.git`) + a Vercel project-settings rename + the 8 architect-verified literal-repo references documented in `.convoys/pick-a-name.md` § Full surface inventory § Repo / Vercel project name (out-of-scope) — `README.md` lines 30 + 105, `AGENTS.md` line 1, `.github/workflows/ci.yml` lines 12 + 123, `.github/workflows/visual-diff.yml` line 5, `.agent-context-manifest.yml` source tags. Also re-evaluate the `.agent-context-manifest.yml` `source: "tcg-vault-local"` tag at that point (Risk 5 of `pick-a-name` — renaming the source tag could break the `sync-agent-context` skill's drift tracking; do this convoy with the sync-skill author's input). Surfaced 2026-05-24 as the explicit downstream of `pick-a-name`. - **`point-domain-at-deckhearth`** (priority: P2 polish; **blocked on domain acquisition**). Once the operator buys `deckhearth.com` (or `.app` / `.gg` / other), wire DNS to the Vercel deployment + claim the domain in Vercel's project settings + update the seed admin email's TLD if the purchased TLD is anything other than `.com` (a one-line REPLACE migration mirroring `scripts/migrations/2026-05-24-rename-admin-email.js`). Surfaced 2026-05-24 as the explicit downstream of `pick-a-name` (the convoy seed § *"DNS / domain — out of scope — separate convoy `point-domain-at-deckhearth` (you don't own a `deckhearth.*` domain yet per operator's pre-convoy statement)"*). Until this lands, the seed admin email `admin@deckhearth.com` is a placeholder STRING used as a unique identifier — auth uses email as identity, not as a mail target, so no working mailbox is required for login to function. - **`regenerate-brand-assets`** (priority: P2 polish). Regenerate the favicon (`public/favicon.ico`), Open Graph images, and social share cards with the new Deck Hearth identity. Requires a design pass — out-of-scope for any single agent-driven convoy; queue when an asset-design pass is scheduled. Surfaced 2026-05-22 originally in `.convoys/ship-readiness.md` § Role-design-system-auditor findings ("Then once the name is settled, the 'DH' logo + AnimatedFireLogo need to be unified into one brand mark"); reaffirmed 2026-05-24 in `pick-a-name` Out-of-scope queued follow-ups. - **`convert-reset-db-to-esm`** — **RESOLVED 2026-05-26** by `fix-reset-db-script` (squash `3ab9bf8`, PR #25). `scripts/reset-db.js` now uses ESM top-level imports (`import dotenv`, `import { neon }`, `import bcrypt`) and executes cleanly on Node 22.x. Same fix shape as `setup-neon-db.js` post-`drop-public-setup` B2. As predicted in this entry's prior note, the fold-with-`purge-weak-creds-from-helpers` shape was the right call — both ailments in `scripts/reset-db.js` were fixed atomically with a single 55-line diff. Entry kept (not removed) to preserve the audit trail. See `.convoys/fix-reset-db-script.md` § As-shipped. -- **`lint-against-cjs-in-esm-scripts`** (priority: P3 polish). Add an ESLint `no-restricted-syntax` rule against `CallExpression[callee.name="require"]` (or equivalent) in `scripts/**` so any future helper script that re-introduces CJS `require()` calls in this ESM-since-`bump-next-js` repo fails at lint time instead of at first execution. Would have caught both `drop-public-setup` Brief 2's pre-fix `setup-neon-db.js` AND `fix-reset-db-script`'s pre-fix `scripts/reset-db.js` before the bug shipped. Surfaced 2026-05-26 as the natural follow-up to PR #25. Small surface (one ESLint rule entry in `eslint.config.mjs`); the only design call is whether to scope to `scripts/**` only or to all `.js` files at the repo root (the former matches the actual blast radius; the latter is broader-than-necessary since `pages/api/**` is already correctly using ESM imports throughout). -- **`single-auth-provider`** (priority: P1 quality, also listed as launch sequence step 9). Collapse `lib/auth-context.js` + `lib/admin-auth.js` into `lib/use-auth.js`. Surfaced again as a follow-up in P0 #7 § Flagged-but-deferred (4 pages still import the legacy `useAuth`). -- **`cleanup-mobile-nav-dead-props`** (priority: P3 polish). `components/MobileNavigation.js` accepts a dead `user` prop; remove it. Surfaced in P0 #7 § Flagged-but-deferred. May fold into `god-component-split` (P2 #13) if that lands first. +- **`lint-against-cjs-in-esm-scripts`** — **RESOLVED 2026-05-26** by `lint-against-cjs-in-esm-scripts` convoy, squash commit `13d6210` (PR #29). Single 7-line flat-config block added to `eslint.config.mjs` after the existing `globalIgnores(...)` call: `{ files: ['scripts/**/*.js'], rules: { 'no-restricted-syntax': ['error', { selector: 'CallExpression[callee.name="require"]', message: '...' }] } }`. The error message points at `.convoys/fix-reset-db-script.md` so a future contributor who trips the rule gets a 1-click path to the exemplar ESM fix shape. Scope decision: `scripts/**` only, NOT all `.js` (matches actual blast radius — every observed bug instance has been in a helper script; the config files `postcss.config.js` / `tailwind.config.js` legitimately use CJS-style exports that the next-config base rules already handle correctly). **Would have caught both `drop-public-setup` Brief 2's pre-fix `scripts/setup-neon-db.js` AND `fix-reset-db-script`'s pre-fix `scripts/reset-db.js` at lint time instead of at first execution** — the exact two motivating bugs from the multi-convoy history. Diff: 2 files, +185 / -0. Lint baseline preserved at 125 (post-PR-#31; zero new false positives in the current tree because both motivating bugs were already fixed). Negative test verified: prepending `const x = require('fs');` to `scripts/reset-db.js` fires the rule at the expected line/column with the documented message; reverting returns to a clean lint. `scripts/migrations/**` was already in `globalIgnores` (from `pick-a-name` Brief 2's migration script); the rule does not fire there. Entry kept (not removed) to preserve the audit trail. See `.convoys/lint-against-cjs-in-esm-scripts.md` § As-shipped. +- **`single-auth-provider`** — **RESOLVED 2026-05-26** by `single-auth-provider` convoy, squash commit `0668b0c` (PR #31; also listed as launch sequence step 9 and § P1 #9 above — both flipped to RESOLVED in the same wave). `lib/auth-context.js` + `lib/admin-auth.js` deleted; 7 source files swept; `` wrapper removed from `_app.js`; `useIsAdmin()`'s lone consumer inlined as `user?.role === 'admin'`. Importer inventory was 7, not the ~30 estimated in P1 #9 (most of the tree was already on `lib/use-auth.js` post-`fix-layout-default-user`). Lint improved 128 → 125. Entry kept (not removed) to preserve audit trail. See § P1 #9 + launch sequence step 9 above for the full as-shipped block. +- **`cleanup-mobile-nav-dead-props`** — **RESOLVED 2026-05-26** by `cleanup-mobile-nav-dead-props` convoy, squash commit `171f5af` (PR #28). Two-file, three-line diff: (1) `components/MobileNavigation.js` line 5 — `{ user, onMenuOpen }` → `{ onMenuOpen }`; (2) `components/Layout.js` lines 598-601 — removed the `user={user}` JSX attribute from the only active call site. Audit confirmed `user` was genuinely dead pre-fix (the bottom-bar items are static and don't depend on auth state). `components/Layout.js.backup` left untouched per the `.cursor/rules/no-go-zones.mdc` § "Append-only / historical" rule (its stale `user={user}` call disappears when the `.backup` file is eventually deleted in a separate convoy). Diff: 3 files, +156 / -2 (3 lines source + the planning convoy file). Lint preserved at 125; vitest 21/21 (the `test/components/Layout.test.js` regression-lock assertions for the logged-out Layout branch do not assert on `MobileNavigation`'s prop shape, so the dead-prop removal is invisible to the suite). Pre-existing dead `import { useState } from 'react'` at `MobileNavigation.js` line 3 left untouched per the convoy spec's "single-prop removal" boundary. Did NOT fold into `god-component-split` (P2 #13) — that hasn't landed yet, so this small hygiene convoy shipped first. Entry kept (not removed) to preserve audit trail. See `.convoys/cleanup-mobile-nav-dead-props.md` § As-shipped. - **`bump-eslint-10`** (priority: P2 hygiene; upstream-blocked). Bump ESLint from v9 to v10 once `typescript-eslint` ships a v10-tested release and `eslint-config-next` bundles it. Surfaced in `.convoys/bump-next-js.md` § Decisions D. - **`harden-multipart-parser`** (priority: P2 quality). Surfaced 2026-05-24 in `add-rate-limiting` § Risk list. `pages/api/user/avatar.js`'s `parseMultipartFormData` consumes the 5MB multipart body via `req.on('data')` before any response is sent, so an attacker can still exhaust the 5MB body even on a 429 path from the new `checkUploadRateLimit` gate. Real defense requires moving the parse into a separate edge function or using `read-up-to` semantics. Not a release-blocker — the gate-ordering in PR #20 places the limiter BEFORE the method branches that call `parseMultipartFormData`, so when this hardening lands, the gate ordering is already correct. Surface as P1 only if a real abuse incident occurs. - **`god-function-split` / `refactor-cards-search-sql`** (priority: P2 refactor). Surfaced 2026-05-24 in `add-rate-limiting` § Files explicitly out of scope. `pages/api/cards/search.js` has a 240-line god-function shape with 7+ conditional `SELECT * FROM cards WHERE …` branches; the PR #20 rate-limit gate sits at the top of the handler and leaves the SQL byte-identical. Splitting is its own scope (probably one convoy per branch group with `slice_dependencies:` for safe multitask fan-out). Not security-critical; deferred to the P2 lane. @@ -412,6 +476,14 @@ Follow-ups surfaced mid-convoy or mid-PR that didn't fit the original launch seq 3. `Screenshot diff` baseline authoring — orthogonal scope; the visual-diff workflow has nothing to compare against on its first real run. - **Owns:** `role-architect` (3 Decisions ratified — A query-param, B 120s timeout, D fork-PR skip) → `role-implementer` (Brief 1) + two scope-expansion commits. +- **`purge-quick-login-from-loginpage`** (priority: P2 hygiene / security). Surfaced 2026-05-26 by `purge-weak-creds-from-helpers` (PR #27) as an out-of-scope sibling bug. `pages/login.js` lines ~172 + ~184 still hardcode `alice123` / `bob123` in client-side "Quick Login" button handlers (`handleQuickLogin('alice@deckhearth.com', 'alice123')` / `handleQuickLogin('bob@deckhearth.com', 'bob123')`). These ship to production HTML and reveal the legacy passwords directly to anyone viewing the login page source. The PR #27 convoy spec was "scripts + docs only; do NOT touch `pages/**`", so this was deliberately left for a follow-up. Small surface (one file, two button handlers). Two reasonable shapes: (a) delete the Quick Login section entirely, or (b) gate it behind `process.env.NODE_ENV === 'development'` with a credential source that doesn't ship to prod HTML (likely a `.env.local`-only `NEXT_PUBLIC_DEV_*` convention or a dev-only proxy endpoint). The latter is architect-worth. Should fold into a UI hygiene pass or a `pre-launch-checklist` convoy that strips dev affordances from prod builds. +- **`purge-neondatabase-serverless-fully`** (priority: P3 polish; **unblocked 2026-05-26** by `migration-tool` PR #32). Surfaced 2026-05-26 by `single-sql-client` (PR #30). The `@neondatabase/serverless` dep was retained in `package.json` because 11 `scripts/*` helpers still imported `neon()` directly (`setup-neon-db.js`, `migrations/2026-05-24-rename-admin-email.js`, `reset-db.js`, plus 8 historical `add-*` / `fix-*` / `seed-*`). Post-`migration-tool` (PR #32), the migration helpers all use `node-pg-migrate`'s `pg` client — not `@neondatabase/serverless` — so the only remaining direct `neon()` consumers are `setup-neon-db.js` (admin seed), `reset-db.js`, and the historical graveyard. The graveyard is no-go-zone; `setup-neon-db.js` and `reset-db.js` could be migrated to `@vercel/postgres` in a single small convoy to fully purge the dep. **Estimate:** 2 file edits + 1 `npm uninstall @neondatabase/serverless` + verify scripts still run against a Neon branch. Low priority — dual deps aren't actively harmful, just untidy. Caveat preserved from `.convoys/single-sql-client.md` § Follow-ups: `@vercel/postgres` is tuned for Vercel's edge / serverless runtime; the right answer may be "keep the dep but route all scripts through a single thin helper" rather than "delete the dep entirely". This is its own scope. +- **`wire-migrate-into-ci`** (priority: P2 CI infra). Surfaced 2026-05-26 by `migration-tool` (PR #32) — D6 deferral. Add a CI job that runs `npm run migrate up` against a test DB (either a dedicated Neon branch + `MIGRATE_TEST_DATABASE_URL` secret with branch-reset logic, or a Postgres service container with a ~30s container-start tax). Catches syntactically-invalid migrations + most logical errors at PR time. Currently, the first signal that a new migration is broken is the developer's local `npm run migrate up` against their dev branch (or post-deploy on Vercel). Documented in `.convoys/migration-tool.md` § R3. +- **`reconcile-historical-add-scripts`** (priority: P1 quality — needed for fresh-env onboarding). Surfaced 2026-05-26 by `migration-tool` (PR #32). Fold the effects of the 27 historical `scripts/add-*.js` / `fix-*.js` / `seed-*.js` jobs into the migration history so a brand-new Neon branch can be onboarded by `npm install` → `npm run setup-db` alone (without manually replaying the historical scripts). Multi-PR; ideally one migration per logical change, generated by reading the scripts' SQL and re-shaping into idempotent `pgm.sql(...)` blocks (with `IF NOT EXISTS` / `IF EXISTS` guards so re-application is safe). Documented in `.convoys/migration-tool.md` § R1. +- **`retire-graveyard-scripts-after-audit`** (priority: P3 polish; **blocked on `reconcile-historical-add-scripts`**). Surfaced 2026-05-26 by `migration-tool` (PR #32). Once the migration history captures all historical effects, the legacy `scripts/add-*.js` / `fix-*.js` / `seed-*.js` files can be deleted (or moved to `scripts/historical/`). They remain no-go-zones until that cleanup convoy lands. Documented in `.convoys/migration-tool.md` § Follow-ups. +- **`audit-node-pg-migrate-transitive-deps`** (priority: P3 hygiene). Surfaced 2026-05-26 by `migration-tool` (PR #32) — R5 in the convoy file. `npm audit` reports 11 vulnerabilities (6 moderate, 5 high) coming from `node-pg-migrate@8.0.4`'s `glob@~11.1.0` + `yargs@~17.7.0` transitive deps (older `brace-expansion`, `minimatch`, `picomatch` versions with known advisories). All in dev-only paths; the migration tool runs in scripts/CI, never in the deployed Next.js bundle, and the affected APIs (glob's shell-injection CLI; brace-expansion's ReDoS) are not exercised by node-pg-migrate's call sites. Surface only if a security audit specifically flags this surface, or if `node-pg-migrate` ships a v9 that updates the transitive tree. +- **`add-migration-template`** (priority: P3 DX). Surfaced 2026-05-26 by `migration-tool` (PR #32). Add a custom template via `--template-file-name` so generated migrations include the project's preferred docstring shape + a reminder about `docs/SCHEMA_MAP.md` updates. Surface if migration authoring proves inconsistent. + ## Self-analytics After each convoy, `scripts/log-convoy-event.sh` emits a record to `.convoys/.metrics.jsonl` (gitignored). After 3-5 convoys, run the upstream `agent-pipeline/analytics/` aggregator to see where token spend goes — that data feeds whether to add or remove rules. diff --git a/.convoys/single-auth-provider.md b/.convoys/single-auth-provider.md index a2b0b3d..052790b 100644 --- a/.convoys/single-auth-provider.md +++ b/.convoys/single-auth-provider.md @@ -278,19 +278,134 @@ No call sites. No work to do; these symbols disappear when the file is deleted. ## As-shipped -_Stub for doc-writer post-merge:_ +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.** -- Squash commit: `` -- PR: #`` -- Files changed: 13 (2 deletions: `lib/auth-context.js`, `lib/admin-auth.js`; 11 modifications: - `pages/_app.js`, `pages/index.js`, `pages/scanner.js`, `pages/decks.js`, `pages/deck/[id].js`, - `pages/deck-builder.js`, `pages/card/[id].js`, `.github/CODEOWNERS`, `AGENTS.md`, - `.cursor/rules/auth-and-permissions.mdc`, `.cursor/rules/no-go-zones.mdc`, - `.cursor/skills/add-page/SKILL.md`). -- Verify roundtrip count: documented 3 → 1 on `card/[id].js`, 2 → 1 on every other page-load. -- `.convoys/ship-readiness.md` § P1 entry 9 to be marked RESOLVED with this convoy's squash SHA. -- Lint baseline updated 128 → 125 (no regression; 3 fewer errors from deleted unused-import - lines). +**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) diff --git a/.convoys/single-sql-client.md b/.convoys/single-sql-client.md index c5c97b5..920712b 100644 --- a/.convoys/single-sql-client.md +++ b/.convoys/single-sql-client.md @@ -376,13 +376,139 @@ action, the rollback is a single-commit revert of this convoy. ## As-shipped -*Stub for doc-writer.* Populate after merge with: -- Squash commit SHA + PR number -- Diff stats (file count, line counts) -- CI gate outcomes (lint, vitest, smoke, forbidden-* gates) -- Any operator-action follow-ups -- Cross-validation findings (e.g. smoke continuing to pass) -- Updates needed to AGENTS.md Gotcha #1 + `.cursor/rules/db-and-schema.mdc` § Clients + ship-readiness P1 #8 entry +Single squash commit `c403ea4` (PR #30, merged 2026-05-27T03:54:01Z +UTC / local 2026-05-26). Parent-owned end-to-end per the "Owns" line +— no architect, no implementer subagent dispatched. Mirror-the-pattern +fix exactly as planned; no mid-execution surprises that would have +forced an architect bounce. **AGENTS.md Gotcha #1 flipped from open +→ RESOLVED in the post-convoy doc-writer pass** (see +`.convoys/ship-readiness.md` § P1 #8 entry post-flip and the AGENTS.md +sweep in this same wave). + +**Diff: 4 files, +447 / -64.** The 447-addition figure is dominated +by `.convoys/single-sql-client.md` (~330 lines for the planning +document, committed atomically with the fix). Actual source-file diff +is small: `pages/api/auth-utils.js` +5 / -7 (import swap + 2 query +shape conversions); `test/api/auth-utils.test.js` +1 / -5 (drop the +now-unused `vi.mock` + unused `vi` import); `lib/database.js` 0 / -47 +(deletion). + +**The change shipped exactly as designed:** + +1. **`lib/database.js` deleted.** 47-line file; no replacement. The + `DatabaseAdapter` abstraction is gone. The two callers go straight + to `@vercel/postgres` tagged-template SQL, matching the canonical + pattern used by ~30 other `pages/api/**` handlers already in the + tree. +2. **`pages/api/auth-utils.js`** swept (the only source caller). + - Import: `import { db } from '../../lib/database.js'` → `import + { sql } from '@vercel/postgres'`. + - `isAdmin(userId)`: `await db.query(\`SELECT role FROM users + WHERE id = $1\`, [userId])` → `` await sql`SELECT role FROM + users WHERE id = ${userId}` ``. + - `getUserById(userId)`: `await db.query(\`SELECT id, email, role, + created_at FROM users WHERE id = $1\`, [userId])` → `` await + sql`SELECT id, email, role, created_at FROM users WHERE id = + ${userId}` ``. + - The `try/catch` shape, the `result.rows[0]` access, the + `?.role === 'admin'` check, and the `console.error` + `return + false` / `return null` error paths are all preserved verbatim. + - SQL is byte-equivalent (single-parameter numeric `userId`); + result shape is identical (`{ rows, rowCount }` from + `@vercel/postgres` matches what `DatabaseAdapter.query` was + returning). +3. **`test/api/auth-utils.test.js`** mock cleanup. Dropped + `vi.mock('../../lib/database.js', () => ({ db: { query: vi.fn() } }))` + (no longer needed because `auth-utils.js` no longer imports from + `lib/database.js`) and the unused `vi` import. The 5 test bodies + (2 `generateToken` + 3 `verifyToken`) are unchanged. Test count + stays at 21/21. + +**`@neondatabase/serverless` retained as a runtime dep**, per D3 of +this convoy file. 11 `scripts/*` helpers still import `neon()` +directly (`scripts/setup-neon-db.js`, `scripts/migrations/2026-05-24-rename-admin-email.js`, +`scripts/reset-db.js`, plus 8 historical `add-*` / `fix-*` / `seed-*` +jobs); all out of scope per the no-go-zones rule. The dep-purge is +tracked as the newly queued `purge-neondatabase-serverless-fully` +follow-up (now **unblocked** by PR #32 `migration-tool` — the +migration helpers go through `node-pg-migrate`'s `pg` client, not +`@neondatabase/serverless`, so the only remaining direct `neon()` +consumers post-PR-#32 are `setup-neon-db.js`, `reset-db.js`, and the +historical graveyard). + +**Verification (all gates green at merge):** +- `node --check pages/api/auth-utils.js` → exit 0 +- `npm run lint` → 125 problems (post-PR-#31 baseline preserved; + zero regression). The `lib/database.js` deletion did not change + lint count because the file was already lint-clean. +- `npm run test:run` → 21/21 pass +- `rg "lib/database" --type js -l` → 0 hits anywhere (no `pages/**`, + no `lib/**`, no `scripts/**`, no `test/**`) — confirms no missed + importer (R2 mitigation) +- `rg "@neondatabase/serverless" --type js -l` → still matches + `scripts/setup-neon-db.js`, + `scripts/migrations/2026-05-24-rename-admin-email.js`, + `scripts/reset-db.js`, and the 8 other `scripts/add-*` / `fix-*` / + `seed-*` historical helpers (all out of scope per D3) +- CI on PR #30: Lint ✓ | Vitest 21/21 ✓ | Playwright smoke 3/3 ✓ | + `forbidden-endpoints` ✓ | `forbidden-cors-headers` ✓ | Vercel + preview deploy ✓ | Aggregate gate ✓ +- **`Screenshot diff`: NOT triggered.** PR #30's diff is + `pages/api/**` + `lib/**` + `test/**` + `.convoys/**` — the + post-PR-#26 `!pages/api/**` exclusion correctly held. **This PR is + the first empirical confirmation that the + `tighten-visual-diff-path-filter` (PR #26, `ba95462`) exclusion + fires as documented** — the post-merge success criterion that + PR #26's § Verification plan deferred until "the next API-only PR + after this merges". `Screenshot diff` did NOT appear in PR #30's + Checks tab; the convoy-file as-shipped block of PR #26 now records + this confirmation. + +**Live runtime smoke deferred** per § Verification plan — the two +migrated functions (`isAdmin`, `getUserById`) are only reachable via +`pages/api/admin/index.js` which requires an admin Bearer token + a +populated `users` table. Running a live curl smoke would require +seeding the admin user with a known password (operator-only flow per +`drop-public-setup`'s contract) and minting a token. The +byte-equivalent SQL semantics (D2) plus identical `try/catch` + +`result.rows[0]` access shape gives high confidence the migration is +correct; if a post-merge Vercel preview admin action 500s, rollback +is a single-commit revert. + +**Cross-validation finding (continues the lineage).** The `Playwright +smoke` 3/3 PASS confirms the deployed preview is unaffected by the +lib-database deletion — the smoke spec doesn't exercise `isAdmin` / +`getUserById`, but the auth surface that smoke does exercise +(`/api/health`, sign-in render) is correctly insensitive to the +DatabaseAdapter removal. **Eighth consecutive convoy** where the +same 3-test smoke spec defends the auth surface through a sweeping +change (PR #15 → #19 → #20 → #21 → #25 → #32 → #27 → this PR). + +**Operator action required going forward:** **none.** No env-var +change; no schema change; no infra change. The migration is purely a +client swap; the existing `POSTGRES_URL` contract is preserved +verbatim (both `lib/database.js` and `@vercel/postgres` read the same +`POSTGRES_URL`). + +**Spec deviation:** none of substance. The convoy spec's "~3 files +based on graph" estimate was loose; architect grep (D1) confirmed +exactly 2 source-tree callers (1 source + 1 test mock). No deviation +flag needed; the convoy is even smaller than estimated. The post-flip +ship-readiness P1 #8 entry records this fact explicitly. + +**Doc surface flipped atomically (in the post-convoy doc-writer pass):** +`AGENTS.md` Gotcha #1 → RESOLVED with the as-shipped paragraph +(tool deletion + retained-dep caveat + the 11-script `neon()` direct +consumers list); `AGENTS.md` § 3 "DB access" bullet trimmed (the +warning about `lib/database.js` is gone — the file doesn't exist); +`.cursor/rules/db-and-schema.mdc` § Clients section refreshed to +match (handled in the doc-writer commit alongside this convoy in +the 7-wave cleanup). + +**Surfaced follow-ups (newly queued in `.convoys/ship-readiness.md`):** +- `purge-neondatabase-serverless-fully` (P3 polish; unblocked by PR #32). +- `lint-against-lib-database` (P3 polish; from this convoy's + pre-existing § Follow-ups list — would prevent re-introduction). ## Follow-ups diff --git a/.convoys/tighten-visual-diff-path-filter.md b/.convoys/tighten-visual-diff-path-filter.md index 7c0796e..8c1f037 100644 --- a/.convoys/tighten-visual-diff-path-filter.md +++ b/.convoys/tighten-visual-diff-path-filter.md @@ -243,26 +243,66 @@ architect mid-execution. ## As-shipped -*(stub — populated by post-merge doc-writer pass)* +Single squash commit `ba95462` (PR #26, merged 2026-05-27T03:51:22Z +UTC / local 2026-05-26). Parent-owned end-to-end per the convoy spec +— no architect, no implementer subagent dispatched. Mirror-the-pattern +fix exactly as planned; no mid-execution surprises that would have +forced an architect bounce. -- Squash commit: `` -- PR: `` -- Diff stat: `` (expected: 2 files, +N / -0 — `visual-diff.yml` - +N for the one entry + comment block; this convoy file +M for the - full planning doc) -- Verification at merge: - - YAML parse: paths list includes `!pages/api/**` immediately after - `pages/**` - - Lint: 128 problems (baseline preserved) - - Vitest: 21/21 - - All pre-existing CI gates green at merge -- **Post-merge success criterion** (the deferred verification from - § Verification plan): the next API-only PR after this merges does - NOT show `Screenshot diff` in its Checks tab. Doc-writer to record - that PR's number + the absence of `Screenshot diff` as the - as-shipped success line, mirroring `.convoys/fix-reset-db-script.md`'s - *"Screenshot diff: not triggered (script-only PR — `paths:` filter - excludes `scripts/**`…)"* line. +**Diff: 2 files, +279 / -0.** `.github/workflows/visual-diff.yml` +N +for the one `!pages/api/**` entry + the inline comment block +explaining the ordering rule and naming the empirical PRs that +motivated the fix (#19 `cors-tighten`, #20 `add-rate-limiting`); +`.convoys/tighten-visual-diff-path-filter.md` +M for the full planning +document, committed atomically with the YAML edit. + +**The change shipped exactly as designed.** The `paths:` list now +deserializes to `['pages/**', '!pages/api/**', 'components/**', +'styles/**', 'tailwind.config.js', 'postcss.config.js']` (order-sensitive +— GitHub Actions evaluates `paths:` with minimatch and `!`-prefixed +exclusions only fire after a prior include matches; keeping +`!pages/api/**` immediately after `pages/**` is what makes the +exclusion work). All five existing entries preserved verbatim; +`preview-smoke.yml` intentionally untouched (no `paths:` filter; fires +on every PR by design — see § The fix § preview-smoke.yml left +untouched). + +**Verification at merge (all gates green):** +- YAML parse: `paths:` deserializes to the expected 6-entry list with + `'!pages/api/**'` at index 1 (immediately after `'pages/**'`) +- `npm run lint` → 128 problems (baseline at convoy time; later + improved to 125 by PR #31 `single-auth-provider`) +- `npm run test:run` → 21/21 pass +- All pre-existing CI gates green at merge + +**Post-merge success criterion — CONFIRMED.** The deferred verification +from § Verification plan: the next API-only PR after this merges +should NOT show `Screenshot diff` in its Checks tab. **PR #30 +(`single-sql-client`, squash `c403ea4`, merged 2026-05-27T03:54:01Z +UTC) was the first API-only PR post-merge.** Its diff touched +`pages/api/auth-utils.js` + `test/api/auth-utils.test.js` + +`lib/database.js` (deletion) + `.convoys/single-sql-client.md` — the +`pages/api/**` portion matched `pages/**` but was correctly subtracted +by the new `!pages/api/**` exclusion. **`Screenshot diff` did NOT +appear in PR #30's Checks tab** — the empirical confirmation that the +exclusion fires as documented. This is the as-shipped success line +the convoy file's § Verification plan asked the doc-writer pass to +record, mirroring `.convoys/fix-reset-db-script.md`'s *"Screenshot +diff: not triggered (script-only PR — `paths:` filter excludes +`scripts/**`…)"* line. + +**Operator action required going forward:** **none.** Path-filter +behavior is self-defending; no env vars, no secrets, no infra +changes. + +**Spec deviation:** none. + +**No follow-up surfaced.** The orthogonal `seed-visual-baselines-on-linux` +follow-up was already queued by `adopt-playwright-smoke`; this convoy +didn't add or change anything around it. The `R1 fallback if exclusion +didn't fire → restructure to per-feature path globs` contingency from +the convoy file was NOT exercised because the post-merge verification +on PR #30 confirmed the negated-glob fired correctly. ## Owns diff --git a/AGENTS.md b/AGENTS.md index f3690ce..ca78b54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -17,7 +17,7 @@ Guidance for agents and humans working in this repo. Prefer existing patterns ov A web app for managing trading-card-game collections (Magic, Pokémon, Lorcana). Users authenticate, build collections + decks, scan physical cards via a camera+AI-OCR flow, and share publicly. Admin users curate the card database. - **Framework:** Next.js 16 (Pages router) + React 18, JavaScript (not TypeScript — see Gotcha #9) -- **Data:** Neon Postgres, accessed two different ways — `@neondatabase/serverless` (`lib/database.js`) AND raw `@vercel/postgres` (`pages/api/**`). Pick ONE; see Gotcha #1. +- **Data:** Neon Postgres. The runtime auth surface uses `@vercel/postgres` tagged-template SQL exclusively post-`single-sql-client` (PR #30, `c403ea4`; `lib/database.js` deleted). 11 `scripts/**` helpers (`setup-neon-db.js`, `reset-db.js`, `migrations/2026-05-24-rename-admin-email.js`, plus 8 historical add-*/fix-*/seed-* jobs) still use `@neondatabase/serverless`'s `neon()` directly — out-of-scope per the no-go-zones rule and tracked as the queued `purge-neondatabase-serverless-fully` follow-up. Schema changes ship as `node-pg-migrate` migrations under `migrations/` at the repo root post-`migration-tool` (PR #32, `de9f334`) — see § 3 Conventions § "Schema changes" and Gotcha #6. - **Auth:** Custom JWT (jsonwebtoken + bcryptjs), token stored in `localStorage`, sent as `Authorization: Bearer …`. No NextAuth. The secret + canonical 24h TTL come from `lib/auth-secret.js` (single source of truth; throws at module load if `JWT_SECRET` is unset). `getUserFromRequest` returns `null` for unauthenticated requests — no synthetic admin fallback — and login + register are rate-limited (5 attempts / 15 min via `@upstash/ratelimit`). The seed admin row is created at `admin@deckhearth.com` with a password supplied via the required `ADMIN_INITIAL_PASSWORD` env var (`scripts/setup-neon-db.js` exits with code 1 before touching the DB if the var is unset); no credential ships in the source tree. Operators of envs that pre-date the `drop-public-setup` convoy still have the old `admin123` hash in their DB — rotate manually via the app (see Gotcha #4). - **UI:** Tailwind CSS + custom CSS variables for theming (light/dark via `lib/theme-context.js`) - **Hosting:** Vercel (`vercel.json`, `.vercel/` present) @@ -29,7 +29,7 @@ A web app for managing trading-card-game collections (Magic, Pokémon, Lorcana). | Pages router views | `pages/*.js` | Public + auth views; uses `components/Layout.js` | | API routes | `pages/api/**/*.js` | Express-style `handler(req, res)`. **30+ handlers depend on `lib/permission-middleware.js::getUserFromRequest`** | | Shared UI | `components/*.js` | `Layout`, `CardItem`, `CameraScanner`, modal family | -| Auth + DB libs | `lib/*.js` | `use-auth` (canonical client hook — sole surface post-`single-auth-provider`), `database`, `permission-middleware` | +| Auth + DB libs | `lib/*.js` | `use-auth` (canonical client hook — sole surface post-`single-auth-provider`, PR #31, `0668b0c`), `auth-secret` (single JWT_SECRET + TTL source), `permission-middleware` (server-side `getUserFromRequest` + `withCollectionPermission`), `rate-limit` (5 named limiters — see Gotcha #12). The legacy `lib/database.js` was deleted by `single-sql-client` (PR #30, `c403ea4`); DB access now goes through `@vercel/postgres` tagged templates directly. | | Migration scripts | `scripts/*.js` | 27+ one-off "add column" / "seed" scripts. No formal migration tool | | Card-import jobs | `pages/api/cards/import-*.js`, `scripts/import-*.js` | Scryfall / Lorcana / Pokémon TCG APIs | | Database schema | `scripts/setup-neon-db.js` | Bootstrap SQL DDL — the source of truth until a real migration tool lands | @@ -40,13 +40,13 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560 ## 3. Key conventions - **Auth (server):** `import { getUserFromRequest } from '../../lib/permission-middleware'` → returns `{ userId, email, role }` or `null`. `null` means "send 401" — always early-return when the user is null before doing any work that depends on their identity. -- **Auth (client):** `import { useAuth } from '../lib/use-auth'`. Returns `{ user, loading, logout, refreshAuth }`; `user === null` means logged out, `loading === true` means token verification in flight. There is no client-side admin hook — compute `const isAdmin = user?.role === 'admin'` from the same `useAuth()` call. The legacy `lib/auth-context.js` and `lib/admin-auth.js` were deleted by the `single-auth-provider` convoy; do not reintroduce a `` / `` wrapper in `pages/_app.js`. +- **Auth (client):** `import { useAuth } from '../lib/use-auth'` is the only client auth surface. Returns `{ user, loading, logout, refreshAuth }`; `user === null` means logged out, `loading === true` means token verification in flight. There is no client-side admin hook — compute `const isAdmin = user?.role === 'admin'` from the same `useAuth()` call. The legacy `lib/auth-context.js` + `lib/admin-auth.js` were deleted by `single-auth-provider` (PR #31, `0668b0c`); do not reintroduce a `` / `` wrapper in `pages/_app.js`. The login + signup flow uses direct `fetch('/api/auth/{login,register}')` from `pages/login.js` / `pages/signup.js` — there is no `useAuth().login(...)` / `useAuth().register(...)` method; do not add one. - **Layout `user` prop:** pages should pass `user` from `useAuth()` to ``. Layout's default is `null` and renders a logged-out "Sign in" CTA when no user is supplied — both paths are valid (some surfaces like `pages/invite/{accept,decline}.js` legitimately render Layout for anonymous visitors). Do not reintroduce a hardcoded user object as a default prop. - **JWT secret + TTL:** `import { JWT_SECRET, JWT_TOKEN_TTL } from '../../lib/auth-secret.js'`. This is the only place either value is defined; do not reintroduce literal fallbacks. `JWT_TOKEN_TTL = '24h'` is canonical. - **Auth helper (token mint / verify / password hash):** `import { ... } from '../../pages/api/auth-utils'` (`generateToken`, `verifyToken`, `hashPassword`, `verifyPassword`). Reads the secret + TTL from `lib/auth-secret.js` under the hood. - **Rate limiting:** `import { checkAuthRateLimit } from '../../lib/rate-limit.js'` for any new auth-surface endpoint (`/api/auth/login` + `/api/auth/register` already wired). Returns `{ allowed, remaining, reset }`; on `!allowed` return 429 with a `Retry-After` header. See `.cursor/rules/api-routes.mdc` § "Rate limiting" for the verbatim shape. - **Permission gate for collection routes:** wrap handlers with `withCollectionPermission('viewer' | 'editor' | 'owner')` from `lib/permission-middleware.js`. -- **DB access:** Use **tagged-template** style — `import { sql } from '@vercel/postgres'`. Avoid the legacy `lib/database.js` `db.query(string, params)` API; its parameter interpolation uses `sql.unsafe` and is a SQL-injection vector. +- **DB access:** Use **tagged-template** style — `import { sql } from '@vercel/postgres'`. The legacy `lib/database.js` (`db.query(string, params)` wrapper around `@neondatabase/serverless`, which interpolated params into a string and called `sql.unsafe`) was deleted by `single-sql-client` (PR #30, `c403ea4`); do NOT reintroduce that shape. For `scripts/**` helpers that legitimately need the Neon HTTP driver (e.g. `setup-neon-db.js`, `reset-db.js`), import `{ neon } from '@neondatabase/serverless'` directly and use tagged-template SQL (`await sql\`...\``) — the safe shape, not the wrapper's unsafe shape. - **Activity logging:** `logCollectionActivity(collectionId, userId, action, details)` — call it from any handler that mutates a collection. - **File names:** `kebab-case.js` for libs/scripts; `PascalCase.js` for React components. - **Imports:** No path aliases configured; use relative imports. @@ -56,7 +56,7 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560 ## 4. Common gotchas -- **#1 — Two SQL clients live in parallel.** `@neondatabase/serverless` (used by `lib/database.js`) and `@vercel/postgres` (used by most `pages/api/**` handlers). New code: prefer `@vercel/postgres` tagged templates. Migration to a single client is tracked in `.convoys/`. +- **#1 — Two SQL clients live in parallel. RESOLVED** by `single-sql-client` convoy (PR #30, squash commit `c403ea4`, 2026-05-26). `lib/database.js` is deleted; the 2 callers (`pages/api/auth-utils.js` source + `test/api/auth-utils.test.js` mock) migrated to `@vercel/postgres` tagged templates (byte-equivalent SQL semantics for the two single-parameter SELECT queries). The convoy's architect audit (D4) confirmed no current call site actually exercised the `sql.unsafe` injection vector — the `userId` callers passed a numeric SERIAL from a verified JWT — so this was foot-gun removal rather than a live security finding. **`@neondatabase/serverless` is still in `package.json` as a runtime dep** because 11 `scripts/*` helpers continue to use `neon()` directly (`setup-neon-db.js`, `migrations/2026-05-24-rename-admin-email.js`, `reset-db.js`, plus 8 historical `add-*` / `fix-*` / `seed-*` jobs). Those scripts use the safe tagged-template shape (`await sql\`...\``), not the deleted wrapper's unsafe `db.query(string, params)` shape. Full dep purge is tracked as the queued `purge-neondatabase-serverless-fully` follow-up (now unblocked by `migration-tool` PR #32 — the migration helpers all use `node-pg-migrate`'s `pg` client, not `@neondatabase/serverless`, so the only remaining direct `neon()` consumers are `setup-neon-db.js` (admin seed), `reset-db.js`, and the historical graveyard). Entry kept (not renumbered) to preserve cross-references. - **#2 — `getUserFromRequest` synthetic-admin fallback. RESOLVED** by `fix-auth-bypass` Brief 2 (commit `258e479`). The helper now returns `null` for unauthenticated requests; `pages/api/auth/verify.js` returns 401 on the no-token branch. The 16 unit tests in `test/lib/permission-middleware.test.js` lock in the contract, including a negative regression against the old synthetic-admin shape. Entry kept (not renumbered) to preserve the audit trail and stable cross-references. - **#3 — JWT_SECRET hardcoded across 7 files. RESOLVED** by `fix-auth-bypass` Brief 1 (commit `4a10dce`). `lib/auth-secret.js` is now the single source of truth and throws at module load when `JWT_SECRET` is unset. Canonical TTL is `JWT_TOKEN_TTL = '24h'`. The `'your-secret-key-change-in-production'` literal is gone from all 7 sites; CI lint passes against the post-fix tree. Entry kept (not renumbered) to preserve cross-references. - **#4 — Default admin credentials in the seed. RESOLVED** by `drop-public-setup` Brief 1 (commit `ff80753`) + Brief 2 (commit `b63b509`). `scripts/setup-neon-db.js` no longer hardcodes `admin123`; it reads `ADMIN_INITIAL_PASSWORD` from the environment and exits with code 1 before opening a DB connection if the var is unset. README's "Default Admin Account" section is replaced with "First-time admin setup" copy that documents the env var, `openssl rand -base64 24` generation tip, and CI-secret alternative. Brief 2 converted the script from CJS to ESM so `npm run setup-db` actually runs on Node 22.x (the `bump-next-js` convoy's `"type": "module"` flag had silently broken it). **Operator caveat:** the seed is idempotent (`ON CONFLICT (email) DO NOTHING`); re-running setup-db on an env that already has the admin row does NOT rotate the password. Any deployed env that ran setup before this convoy still has the weak `admin123` hash — operators must rotate manually via the app, or wait for the queued `rotate-default-admin` follow-up convoy. Entry kept (not renumbered) to preserve cross-references. -- 2.45.2