Reflects the merged adopt-playwright-smoke convoy (PR #18, squash commit7b6f751) in repo documentation. Closes the test-infrastructure side of P1 #10 step 2 (launch sequence step 10). One commit in the convoy: Brief 1, with two small lint-baseline-preserving deviations from the brief's verbatim shape that the implementer report flagged. .convoys/adopt-playwright-smoke.md: - frontmatter status: in-progress -> shipped (added shipped: 2026-05-24) - new ## As-shipped section: operator-ratified Decisions (D1 keep .ts, D4 defer baselines, D6 simple scripts); two implementer deviations (removed unused eslint-disable-no-console directive that would have regressed lint 128 -> 129; placed @playwright/test first in devDeps for alphabetical correctness); cross-validation that smoke test 2 ("sign-in page renders") locks in PR #15's logged-out CTA work in components/Layout.js; empirical metrics from post-merge run 26376162598 (59s workflow, 3/3 in 2.9s, 0-leak); operator-action-required note pointing at the queued seed-visual-baselines-on-linux follow-up; What did NOT change audit trail. .convoys/ship-readiness.md: - Queued convoys: new entry seed-visual-baselines-on-linux (Linux- Docker baseline generation per Decision 4 + Boot-the-brief Finding 7; Mac-generated baselines would silently overwrite Linux CI baselines because the custom snapshotPathTemplate has no {platform} token). - Queued convoys: new RESOLVED block for adopt-playwright-smoke (PR #18,7b6f751) — as-shipped surface, implementer deviations, empirical metrics (59s workflow / 3/3 in 2.9s / 0 secret leaks), the PR #15 cross-validation finding, operator-action-required going forward (the seed-visual-baselines-on-linux follow-up), flagged-but-deferred items, and ownership trail (3 architect- self-ratified decisions + 3 operator-ratified). - Launch sequence step 10: marked RESOLVED 2026-05-24 with the commit + metrics inline. - P1 #10 No tests Fix sequence: step 2 marked RESOLVED with the convoy + metrics ref; step 3 (re-enable test: job in ci.yml) called out as the next remaining task; step 5 (wire preview-smoke.yml to the Vercel preview URL) marked RESOLVED across PR #17 + PR #18 since both contributed. AGENTS.md: - Section 6 Testing: rewritten end-to-end. Was "E2E/smoke runner still pending"; is now "@playwright/test@^1.60.0 wired, two projects (smoke + visual), npm run test:smoke / test:visual / test:visual:update". Documents the local-run convention (boot next dev separately, then BASE_URL=... npm run test:smoke); the one-time npx playwright install --with-deps chromium step; the no-baselines-yet state + the Linux-Docker seed command + the cross-platform mismatch reason (no {platform} token in snapshotPathTemplate); the CI behavior split (vitest blocking, smoke on every PR with pipeline:skip-smoke escape hatch, Screenshot diff path-filtered with the first-red-on-missing- baseline state documented). Updates vitest coverage count 16 -> 21 (the +5 Layout regression-lock tests from PR #15). Notes TESTING_GUIDE.md is being eclipsed and will be renamed to docs/MANUAL_QA.md in a future cleanup convoy. - Section 7 Deployment: rewrites the Vercel-bypass paragraph from a single "query param now / header reserved for future" bullet into a two-shape audit ((1) query param on the wait-action's path: input per PR #17; (2) HTTP header in playwright.config.js's use.extraHTTPHeaders per PR #18). Documents the Decision-2 fail-loud-in-CI / warn-in-dev predicate and references Gotcha #12 as the established precedent (lib/rate-limit.js). Picked Section 7 over a new Gotcha because the bypass plumbing is operationally a deployment concern, not an app pitfall. No changes to: package.json, package-lock.json, playwright.config.js, eslint.config.mjs, lib/**, pages/**, components/**, scripts/**, .github/workflows/**, .cursor/rules/**, README.md, tests/visual/homepage.spec.ts (JSDoc is already neutral-tense, no future-tense references to clean up). Co-authored-by: Cursor <cursoragent@cursor.com>
47 KiB
| name | classification | success_metric | skip | status | created |
|---|---|---|---|---|---|
| ship-readiness | epic | tcg-vault is safe to expose to anonymous internet traffic with a documented launch checklist green | open | 2026-05-22 |
Ship-readiness convoy
Umbrella convoy capturing the full agent-pipeline review of tcg-vault as of 2026-05-22. Findings are grouped by L2 role lens (Reviewer / Architect / Design-system / A11y / IA / Doc-writer) and severity. Each item points to the convoy that will execute the fix.
Code graph: 122 files, 628 nodes, 5602 edges, 11 communities. Indexed by user-code-review-graph MCP.
P0 — ship-blockers (security)
These MUST land before any anonymous traffic touches the production URL.
1. getUserFromRequest returns a hardcoded admin when no Bearer token is present — RESOLVED 2026-05-23
- Resolved by:
fix-auth-bypassBrief 2, commit258e479(PR #8). Follow-up Brief 6 hotfix1fca3aaadded explicit 401 guards to the cards-collection POST/PUT/DELETE branches that previously masked the bug as 500s. - File:
lib/permission-middleware.jslines 13-17. - Impact: Every API route that calls
getUserFromRequest(30+ handlers — seeuser-code-review-graphcross-community edges fromapi-handler→lib-admin) accepts unauthenticated requests as admin user 1. - Repro:
curl https://<host>/api/collectionswith noAuthorizationheader returns admin's collections. - Fix: Delete lines 13-17. Return
nullwhen no Bearer token. Update every caller to handlenullproperly (most already do; the broken fallback was masking the right path). - As-shipped: The helper now returns
nullfor any unauthenticated request. 16 unit tests intest/lib/permission-middleware.test.jslock in the contract (including a negative regression against the old synthetic-admin shape).pages/api/auth/verify.jsreturns 401 on the no-token branch instead of fetching the seed admin row. - Owns:
role-architect+role-implementer(one PR; small surface area in the helper, callers already check!user).
2. JWT_SECRET hardcoded fallback in 7 files — RESOLVED 2026-05-23
- Resolved by:
fix-auth-bypassBrief 1, commit4a10dce(PR #7). - Files:
pages/api/auth-utils.js('your-secret-key')pages/api/auth/login.js,pages/api/auth/register.js,pages/api/auth/verify.jspages/api/favorites.js,pages/api/users/search.jslib/permission-middleware.js
- Impact: If
JWT_SECRETenv var is unset (e.g. preview/staging misconfig), tokens are signed with'your-secret-key-change-in-production'— an attacker can sign their own admin token in 5 seconds. - Fix: Centralize JWT_SECRET access in one helper that
throws at module load ifprocess.env.JWT_SECRETis unset. Every other file imports from there. - Bonus: Token expiry is inconsistent (
/api/auth/login.jsuses 24h,pages/api/auth-utils.jsuses 7d). Pick one. - As-shipped:
lib/auth-secret.jsis the single source of truth and throws at module load ifJWT_SECRETis unset. Canonical TTL isJWT_TOKEN_TTL = '24h'. All 7 literal fallback sites are converted to import-and-throw.test/lib/auth-secret.test.js(3 tests) covers the fail-loud path. - Owns:
role-architect+role-implementer.
3. Default admin credentials in seed + README — RESOLVED 2026-05-23
- Resolved by:
drop-public-setupBrief 1 (commitff80753) + Brief 2 (commitb63b509). PR #13. - Files:
scripts/setup-neon-db.jslines 130-138 — createsadmin@tcgvault.com/admin123README.mddocuments the credentialspages/api/setup-database.js— duplicates the setup AND is an UNAUTHENTICATED public POST endpoint withAccess-Control-Allow-Origin: *
- Impact: Anyone who hits
/api/setup-databasecan re-trigger DDL. Theadmin123password is one Google away from public knowledge. - Fix:
- Delete
pages/api/setup-database.js. Schema setup is a one-time job; it should not be a route. - Change
setup-neon-db.jsto require aADMIN_INITIAL_PASSWORDenv var (no default). - Strip the admin password from README — replace with "run
npm run setup-dband follow the prompt".
- Delete
- As-shipped:
pages/api/setup-database.jsalready deleted byfix-auth-bypassBrief 3 (commitfc0dd73); theforbidden-endpointsCI job blocks re-introduction.scripts/setup-neon-db.jsnow readsADMIN_INITIAL_PASSWORDfromprocess.env; if unset or empty, the script writes an actionable error (names the env var, points at.env.local, suggestsopenssl rand -base64 24, mentions CI-secret alternative, references README) and exits with code 1 before opening any DB connection. The bcrypt input is the env-var value, not the literaladmin123. The twoconsole.loglines that previously echoedAdmin User: admin@tcgvault.com+Admin Password: admin123are deleted (R3 — stdout-leak prevention into CI logs); replaced with a singleAdmin user ready (email: admin@tcgvault.com)line that does NOT echo the password.README.md's "Default Admin Account" section replaced with "First-time admin setup" copy that documents the env-var requirement, theopenssl rand -base64 24generation tip, the CI-secret alternative, and an operator-rotation note for envs that pre-date this convoy.- Bonus (Decision D, Brief 2):
scripts/setup-neon-db.jsconverted from CommonJS to ESM sonpm run setup-dbactually executes on Node 22.x. Thebump-next-jsconvoy added"type": "module"topackage.jsonfor ESLint v9 flat config; the seed script'srequire()calls were silently broken since that landed. Without Brief 2, Brief 1's env-var gate would have been theatrical (script throwsReferenceErrorbefore reaching the gate).
- Operator caveat (R1, Decision A — going-forward only): the seed is idempotent (
ON CONFLICT (email) DO NOTHING); re-runningnpm run setup-dbon 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 weakadmin123hash in its DB — operators must rotate manually via the app's profile settings, or wait for the queuedrotate-default-adminfollow-up convoy. Documented inAGENTS.mdGotcha #4 and the README's First-time admin setup blockquote. - Sibling weak-cred references deferred:
scripts/reset-db.js,scripts/create-test-users.js, andTESTING_GUIDE.mdstill hardcodeadmin@tcgvault.com/admin123— out of scope here per the no-go-zones rule (historical scripts) and the convoy spec. Queued forpurge-weak-creds-from-helpersfollow-up (or fold intopick-a-namesince the email is also changing). - Owns:
role-implementer.
4. Dev-only test endpoints shipped to production — RESOLVED 2026-05-23
- Resolved by:
fix-auth-bypassBrief 3, commitfc0dd73(PR #6). - Files:
pages/api/simple.js,pages/api/test-auth.js,pages/api/test-db.js,pages/api/setup-database.js. - Impact: Unknown — depends on what they expose.
/api/test-dblikely returns the DB connection string;/api/test-authmay leak token-handling details. - Fix: Delete all four. Add a CI grep that fails the build if any file matching
pages/api/(test-|simple|setup-)*.jsexists. - As-shipped: All four files deleted.
.github/workflows/ci.ymlhas a newforbidden-endpointsjob (blocking) that fails the build if any of the four paths reappear OR if a newpages/api/test-*.jsfile is added. Local simulation in the implementer PR confirmed clean → OK, withtest-fake.js→ FAIL, post-cleanup → OK. - Owns:
role-implementer.
5. CORS Access-Control-Allow-Origin: * on auth endpoints — PARTIAL 2026-05-23
- Partially resolved by:
fix-auth-bypassBrief 4, commit297afca(PR #9). Login + register only;pages/api/auth/verify.jsis deferred to the queuedcors-tightenfollow-up convoy. - Files: at minimum
pages/api/auth/login.js,pages/api/auth/register.js,pages/api/setup-database.js(verify others). - Impact: Any origin can submit credentials. Combined with the no-rate-limit problem below, credential stuffing is wide open.
- Fix: Set
Access-Control-Allow-Originto the literal frontend origin (https://tcgvault.com/ preview domain), or remove the header entirely if the API and the frontend are same-origin (they are, on Vercel). - As-shipped:
pages/api/auth/login.jsandpages/api/auth/register.jsdrop the foursetHeadercalls + the OPTIONS preflight handler.pages/api/setup-database.jswas deleted entirely by Brief 3.pages/api/auth/verify.jsstill has the wildcard header — see follow-up convoycors-tighten. - Owns:
role-implementer.
6. No rate limiting anywhere — PARTIAL 2026-05-23
- Partially resolved by:
fix-auth-bypassBrief 4, commit297afca(PR #9). Login + register only; the rest of the listed endpoints are deferred to the queuedadd-rate-limitingconvoy. - Impact: Login endpoint accepts unlimited attempts; card-search endpoint can be hammered; image upload endpoints can be exhausted. The
pages/api/cards/import-*.jsendpoints externally hit Scryfall/Pokémon APIs with no caller throttling. - Fix: Adopt
@upstash/ratelimit(free tier covers a small launch) or Vercel's built-in middleware-based rate limiting. Apply to:/api/auth/login,/api/auth/register,/api/users/search,/api/cards/search, all/api/cards/import-*, and/api/user/avatar*(upload). - As-shipped:
lib/rate-limit.js(new) providescheckAuthRateLimit(req)via@upstash/ratelimit@^2.0.8+@upstash/redis@^1.38.0(5 attempts / 15-min sliding window per IP). Wired into login + register. Env vars areKV_REST_API_URL/KV_REST_API_TOKEN(auto-provisioned by Vercel's Upstash Marketplace integration — note this is a rename from the brief's originalUPSTASH_REDIS_REST_*spec; see.convoys/fix-auth-bypass/brief-4-tighten-auth-surface.md§ Post-merge addendum). Fails closed in prod when env vars are unset; warn-and-no-ops in dev. Search / import / avatar endpoints are unchanged. - Owns:
role-architect(pattern) →role-implementer(per-route).
7. Layout default-prop leaks maintainer email — RESOLVED 2026-05-24
- Resolved by:
fix-layout-default-userconvoy (PR #15, squash commitca302a8). Brief 1 (pre-squashddf8fd2) shipped the Layout default-null + logged-out branch + vitest lock-in; Brief 2 (pre-squash8c7d127, rebased to0f6bfbbpre-merge) swept the 7 pages that needed page-level fixes. - File:
components/Layout.jsline 562:function Layout({ children, user = { email: 'me@randallstillwell.com', role: 'user' }, ... }). - Impact: Any page that renders Layout without passing a
userprop displays your real email and impersonates you as the logged-in user. - Fix: Default
user = nulland render a logged-out state branch. Verify every page passesuserexplicitly (the graph shows ~13 pages callLayout; audit each). - As-shipped:
components/Layout.jsdefault prop changed from hardcoded{ email: 'me@randallstillwell.com', role: 'user' }tonull.UserProfileDropdownnow branches onuser === nulland renders a<Link href="/login">Sign in</Link>CTA in place of the avatar + email + dropdown menu (NavigationContent'sauthenticatedNavigation/myCollectionNavigation/adminNavigationwere already null-safe via existing optional chains; no change there).- 7 pages swept (Brief 2, 11
<Layout>call sites total).pages/scanner.js(×1),pages/decks.js(×3),pages/deck-builder.js(×4),pages/deck/[id].js(×3) now passuser={user}explicitly.pages/profile.jsandpages/settings.jsreplaced their leakyuseState({ email: 'me@randallstillwell.com', role: 'admin' })initializer withuseState(null)(15 syncuser.*reads in profile + 1 in settings got null-guards).pages/card/[id].jsreplaced its hardcodedconst user = { email: 'me@…', role: 'user' }withconst { user } = useAuth()fromlib/use-auth.js. - 10 pages already correct (architect's per-page audit, Decision B in
.convoys/fix-layout-default-user.md):dashboard,my-cards,cards,collections,collection/[identifier],community/collections,admin/card-import,admin/card-editor,invite/accept,invite/decline. No changes there. - Test coverage:
test/components/Layout.test.js(new) adds 5 regression-lock assertions — no maintainer email whenuserisnull/omitted; "Sign in" link present when logged out; supplied email renders when supplied; no accidentalGuestplaceholder. Vitest 21/21 green at merge (16 pre-existing auth tests still green). - New devDeps:
jsdom@^29+@testing-library/react@^16(test-only).vitest.config.jsgot a 3-lineesbuildblock to parse JSX in.jsfiles (per-file// @vitest-environment jsdomdirective — no global env change). - Verification at merge:
rg 'me@randallstillwell.com' pages/→ 0 hits; anonymouscurl /cardsreturned HTTP 200 with no maintainer email; lint baseline match (128 problems, unchanged); CI Aggregate gate / Lint / Vitest / Vercel preview / forbidden-endpoints all green.Playwright smoke+Screenshot diffred but for an unrelated CI-infra reason — see CI infrastructure side-effect note below.
- Flagged-but-deferred (deliberately out of scope per the convoy spec):
- 4 pages still import
useAuthfromlib/auth-context.js(pages/scanner.js,pages/decks.js,pages/deck-builder.js,pages/deck/[id].js) — collapsing the three parallel client-side auth surfaces is the queuedsingle-auth-providerconvoy (P1 #9 in this file), not this one. components/MobileNavigation.jsstill receives a deaduserprop (it accepts{ user, onMenuOpen }but never readsuser.*— the bottom-bar items are static). Queued ascleanup-mobile-nav-dead-props(or fold intogod-component-splitif that lands first).pages/card/[id].jsstill importsuseIsAdminfromlib/admin-auth.js— third parallel auth surface; samesingle-auth-providerconvoy will collapse it.
- 4 pages still import
- CI infrastructure side-effect (not part of this convoy). PR #16 (squash commit
7e97254) landed alongside as a CI permissions fix, adding scopedpermissions:blocks to.github/workflows/preview-smoke.yml+.github/workflows/visual-diff.yml. That fixed the 5-second 403 "Resource not accessible by integration" failure on both workflows but exposed a second issue: with permissions correct, both now reach the actual deployment check and 10-min-timeout against Vercel Deployment Protection's 401 SSO challenge (anonymous GitHub runner GETs the preview URL). New queued convoyfix-vercel-deployment-protection-in-ci(.convoys/fix-vercel-deployment-protection-in-ci.md) tracks that follow-up. - Owns:
role-implementer.
8. Next.js 15.4.3 — Vercel platform blocks deploys (vulnerable version) — RESOLVED 2026-05-23
- Resolved by:
bump-next-jsconvoy, single-brief PR commite57ea17("bump: next 15.4.3 -> 16.2.6, ESLint flat config (v9 fallback), typescript devDep"). The Vercel platform gate cleared with the first successful deploy on the same date; every subsequent PR (fix-auth-bypass,drop-public-setup,fix-layout-default-user, the CI permissions fix) has had a green Vercel preview. - Discovered: 2026-05-22 during the bootstrap PR CI run. Vercel build completes successfully (~29s) but the deployment exits with status
Errorand"Vulnerable version of Next.js detected, please update immediately". - Files:
package.jsonline 22 ("next": "^15.4.2"→ locked at15.4.3),package-lock.json. - Impact: Vercel will not deploy any branch — including
main— until Next.js is bumped. Preview URLs are unavailable, which meanspreview-smoke.ymlandvisual-diff.ymlcan't fire. The last successful deploy onmainwas 2025-08-01; production may already be running an outdated build. - CVE context: Next.js shipped a middleware auth-bypass advisory (CVE-2025-29927) patched in 15.2.3, plus subsequent advisories. The exact CVE Vercel is flagging on 15.4.3 needs confirmation via
npm auditand the Next.js security advisory page. - Fix: Bump
nextto the latest secure 15.x (npm install next@^15.5and run smoke tests) OR the latest 16.x (next@^16.2.6— major bump; review breaking changes in Next.js 16 release notes). - As-shipped (Decision A in
.convoys/bump-next-js.md— leapfrog to 16):next:^15.4.2→^16.2.6(resolves to16.2.6).eslint-config-next:15.4.2→^16.2.6. Config migrated from.eslintrc.jsontoeslint.config.mjs(eslint-config-next@16 is flat-config-only).eslint:^8→^9.39.4(Decision D fallback — v10 surfaced Risk R15 empirically because@typescript-eslint/scope-manager@8.59.4bundled byeslint-config-next@16doesn't implement v10's newaddGlobalsAPI; v10 adoption deferred to a separatebump-eslint-10convoy, upstream-blocked on typescript-eslint).typescript: newly added at^5.9.3as a devDep (Decision C — required by the typescript-eslint chain regardless of ESLint major; no project source migration to TS).scripts.lint:"next lint"→"eslint ."(next lint removed in 16). Lint baseline grew from ~100 to 128 problems (81 errors, 47 warnings) due toeslint-plugin-react-hooks@7.1.1+@next/eslint-plugin-next@16.2.6rule additions; CI tolerates this via the|| truewrapper in.github/workflows/ci.ymlper P1 #11.5 (fix-lint-baseline).next.config.js:images.domains→images.remotePatterns(deprecated and removed in 16; preserves Scryfall, Pokémon TCG, Lorcana API hosts for eventualnext/imageadoption).- Verification at merge:
npm installclean (no ERESOLVE),npm run buildexit 0 with Turbopack (~1.4s compile, 23 static pages + 47 API routes), first green Vercel deploy onmainsince 2025-08-01.
- Side-effects (deliberately deferred, not part of this convoy):
bump-react(React 18 → 19) — held until 18.x EOL or until a feature needs it.- App Router migration — multi-month effort; queued indefinitely.
adopt-vitest✅ shipped asfix-auth-bypassBrief 5;adopt-playwright-smokepartially shipped via the Vercel-bound workflows (CI infra now blocked byfix-vercel-deployment-protection-in-ci).fix-lint-baseline(P1 #11.5) — drop the CI|| truewrapper once the 128-problem baseline is cleared.bump-eslint-10+bump-typescript-6— upstream-blocked on typescript-eslint shipping v10-tested releases.
- Doc drift note: this resolution was applied as part of the
fix-layout-default-userpost-convoy cleanup (commit reflectingb7ddd08's sibling) — thebump-next-jsconvoy never ran a dedicated doc-writer pass, so this RESOLVED entry was added ~24h after the fix actually shipped. - Owns:
role-architect(pick target version + assess breaking changes) →role-implementer(bump + verify dev/build/start + smoke). - Convoy:
bump-next-js— ran beforefix-auth-bypass. Without this convoy, every L3 gate that depends on a Vercel preview was non-functional.
P1 — pre-launch quality bar
8. Two SQL clients in parallel (@neondatabase/serverless + @vercel/postgres)
- 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 oflib/database.js::db.query. Deletelib/database.js. - Reviewer/Architect call: small enough to fit in one convoy; touches ~3 files based on graph.
9. Three parallel client-side auth implementations
- 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.jsas the canonical hook. Migrate every importer. Deleteauth-context.jsandadmin-auth.js. Roll upuseIsAdminsemantics intouseAuth().user?.role === 'admin'. - Owns:
role-architect(decision) →role-implementer(per-page migration; ~30 importers).
10. No tests
- Impact: The first agent-driven refactor of
getUserFromRequest(P0 #1) is high-blast-radius with no safety net. - Fix sequence:
- Install
vitest. Addnpm run test:runscript. RESOLVED byfix-auth-bypassBrief 5, commit1629afb. - Install
@playwright/test. Wire uptests/smoke/app.smoke.spec.ts(already drafted; needsplaywright.config.ts). RESOLVED 2026-05-24 byadopt-playwright-smoke, PR #18 squash7b6f751— 3/3 smoke tests pass in 2.9s, full workflow 59s, zero secret leaks. See § Queued convoys and.convoys/adopt-playwright-smoke.md§ As-shipped. - Re-enable the
test:job in.github/workflows/ci.yml(commented out at install time). Next remaining step in this fix sequence. - Add unit tests for
lib/permission-middleware.js,lib/slug-utils.js,pages/api/auth-utils.js. - Wire
preview-smoke.ymlto run against the Vercel preview URL. RESOLVED 2026-05-24 byfix-vercel-deployment-protection-in-ci(PR #17,9a3e077) +adopt-playwright-smoke(PR #18,7b6f751).
- Install
- Owns:
role-architect(test strategy) →role-implementer(initial suite).
11. No migration tool — scripts/add-*.js graveyard
- Files: 27 scripts in
scripts/of the formadd-foo-column.js,fix-bar-constraint.js,seed-baz.js. No idempotency tracking, noschema_migrationstable, 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 todrizzle-kitif 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).
11.5. Codebase has ~100 pre-existing ESLint errors
- Discovered: 2026-05-22 during the bootstrap PR. The repo had
"lint": "next lint"inpackage.jsonbut no.eslintrc.json— meaning lint was never run. Bootstrap added the config; lint now surfaces ~100 errors. - Most serious:
react-hooks/rules-of-hooksviolations (hooks called conditionally) in several components. These are real bugs — React's hook ordering is undefined when hooks are called after early returns. They likely manifest as state-loss / stale-closure bugs in edge cases. - Less serious:
react/no-unescaped-entities(cosmetic),react-hooks/exhaustive-deps(warnings about missing useEffect deps),@next/next/no-img-element(cosmetic). - Impact: The L3 CI lint job is currently
continue-on-error: true(see.github/workflows/ci.yml) so it doesn't block PRs. Lint output is visible in logs but PRs merge regardless of lint state until this is cleaned up. - Fix: Triage each error. The rules-of-hooks ones need genuine code restructuring (move hooks before any early returns). The unescaped-entities are mechanical (
'→'). After cleanup, removecontinue-on-error: true. - Convoy:
fix-lint-baseline— run afterfix-auth-bypassanddrop-public-setup. Multitask-safe: split into briefs by file group. - Owns:
role-architect(group strategy) →role-implementer(per-group fan-out).
12. Branding mismatch — "TCG Vault" vs. "Deck Hearth"
- Files: README,
package.json, seed data say "TCG Vault" /admin@tcgvault.com.components/Layout.jslines 596 + 689 render "Deck Hearth" + "DH" logo. The.env.localtemplate,vercel.json, and Vercel project name should also be audited. - Impact: Confusing for users. Confusing for marketing. Confusing for analytics. Pick one.
- Fix: Brand workshop → final name → global replace → update README, package.json
"name", every UI string, Vercel project name, email sender, support pages. Schedule a redirect from the old domain. - Owns:
role-ia-architect(which name? — needs human decision) →role-implementer.
P2 — refactor priorities
13. God components (10 files over 500 lines)
| File | Lines | Notes |
|---|---|---|
pages/cards.js |
1499 | AuthenticatedCards (886) + Card3D (502) live in one file. Split into pages/cards/index.js + components/Card3D.js. |
pages/collection/[identifier].js |
1044 | CollectionView is one mega-component. Extract: header, card-grid, share-modal-wrapper, edit-form. |
pages/collections.js |
989 | Similar structure to collection/[identifier]. Possibly share extracted pieces. |
pages/card/[id].js |
913 | CardDetail — split into header, owned-badge, add-to-collection-flow. |
pages/deck-builder.js |
823 | DeckBuilder — extract card-search, deck-list, mana-curve panels. |
components/CameraScanner.js |
817 | Camera + AI-OCR + detection-loop — extract the detection loop into a hook. |
pages/admin/card-editor.js |
778 | Form heavy. Use a useFormState pattern + separate the search-results subview. |
pages/scanner.js |
776 | Mirror of CameraScanner concerns plus queue management. |
pages/settings.js |
669 | One screen per settings section is the usual fix. |
pages/profile.js |
625 | Avatar generation logic alone is ~150 lines — extract useGeneratedAvatar hook. |
Each is one convoy of its own. Use the architect role's slice_dependencies: to fan out implementers safely.
14. Schema-design smells (documented in docs/SCHEMA_MAP.md)
usershas two avatar columns (profile_image_url+avatar_url). Reconcile.collectionshas two visibility flags (is_public BOOLEAN+visibility VARCHAR). Reconcile.cards.quantity+cards.favoritedare unused (they belong onuser_cards/user_favorites). Drop.user_settingstable duplicates severaluserscolumns. Reconcile.- All enum-shaped VARCHARs (
role,condition,theme,game,visibility) should be CHECK-constrained or proper Postgres ENUMs. collections.tagsisTEXT(comma-separated). Migrate toJSONBor a join table.
15. Component coupling warning from graph
user-code-review-graph flagged:
- High coupling (44 edges) between
components-handleandpages-handle(largelyLayout,CardItem,ManaCost— expected for a shared UI surface). - High coupling (34 edges) between
lib-adminandapi-handler— almost all viagetUserFromRequest. After P0 #1 is fixed, this number stays high because the auth check is genuinely shared — that's fine.
16. Lots of inline SVG and emoji
The getIcon registry in Layout.js and MobileNavigation.js redefines the same SVG paths. Extract to components/icons/ with named exports. Then audit the codebase for inline SVG that should be a named import. Bonus: lazy-load the larger icon families.
P3 — UX, IA, design-system
Role-ia-architect findings
- URL structure — solid.
/cards,/collections,/collection/[slug],/deck-builder,/community/collections. Coherent. One quirk:/card/[id](singular) for detail vs./cards(plural) for index — typical Next.js shape but worth a redirect rule so/cards/[id]also resolves. - Logged-out homepage — current
pages/index.jsis 316 lines; needs an editorial pass. What's the value prop in one sentence? Right now it's mostly "we have cards". - Onboarding — signup → profile setup → first collection → scan-or-import card. Currently each step is a separate page. Consider a multi-step wizard at
/onboardingto keep the new user in flow. - Discoverability —
/community/decksand/community/forumsare in the nav but flagged as placeholders. Either ship the MVP for each before launch (forums likely too big) or hide the nav items until they exist.
Role-ux-reviewer findings
- Loading states — most data fetches set
loading: truethen re-render; very few show skeletons. Card grids should use shimmer placeholders; modals should disable submit while in flight. - Error states — error messages bubble to
console.errorand toast nothing. Add a global toast system (e.g.sonner) and wire every catch block. - Empty states —
/my-cardsand/collectionswhen empty drop to "no cards yet". Replace with first-time CTA: "Scan your first card" or "Browse popular sets". - Mobile drawer —
MobileNavigationis solid (recent commit442e906). One thing: the bottom-bar's active state contrast looks low in light mode; verify against AA. - Camera scanner UX — 817 lines of detection loop. Add a one-line "scanning…" status under the viewfinder and a single "captured N cards" badge. The current toolbar is busy.
Role-design-system-auditor findings
- Two visual languages mixing — Tailwind classes AND CSS variables on the same elements. This is documented in
.cursor/rules/ui-and-theming.mdc; the cleanup is to define which property goes where and enforce. - Hardcoded hex colors — grep for
bg-\[#andstyle={{ backgroundColor: '#. There are still a handful; convert to theme tokens. - Logo + brand — see P1 #12. Then once the name is settled, the "DH" logo + AnimatedFireLogo need to be unified into one brand mark.
- Modal patterns —
CollectionSelectionModal,ShareModal,UploadImageModaleach have their own backdrop + focus-trap implementation. Extract<Modal>primitive. Useheadlessuiorradix-ui's Dialog to get focus management for free. - Card grid spacing + density —
pages/cards.js(the 1499-line monster) does responsive grid math inline. Extract a<CardGrid>component that handles density (compact / comfortable / spacious) + sort + filter chrome.
Role-a11y-auditor findings
- Focus traps in modals — none of the modals trap focus. Tab through
ShareModaland you leave to the background. Critical for keyboard users + screen readers. - ESC to close modals — inconsistent. Some have it, some don't.
- Skip-to-content — no
<a href="#main" class="sr-only focus:not-sr-only">. Add to_app.js. - Image alts — card images use
alt={card.name}(good); avatar images sometimes have empty alts. Audit. - Color contrast — verify the muted text colors (
var(--text-secondary)) hit AA on both themes. The mobile bottom-bar inactive state is a likely fail. - Form errors — login/signup form errors are visually red but not connected to inputs via
aria-describedby. Screen readers don't know which field failed. - Keyboard ops on non-button elements — most clickable
<div>s already haveonKeyDownbut a few don't (audit withrg "onClick" components pages | rg -v "<button").
Role-doc-writer findings
- README — needs a public-facing rewrite. Currently mixes user docs + dev setup + admin credentials. Split into
README.md(project landing) +docs/DEVELOPMENT.md(dev setup) + delete the admin credentials section entirely. docs/SCHEMA_MAP.md— installed at bootstrap (this convoy). Keep it fresh on every schema change.- CHANGELOG — none yet. Adopt Keep-a-Changelog format. Backfill
[0.1.0] — initial private alphacovering everything to date. TESTING_GUIDE.md— currently the only test doc; rename todocs/MANUAL_QA.mdoncevitest+playwrightland.docs/API_REFERENCE.md— would help. Could be auto-generated by walkingpages/api/**/*.jsand extracting JSDoc; or hand-curated to start.- Privacy policy / Terms of service — required before public launch. Use a template (Termly / Iubenda) and customize.
Proposed launch sequence
Each phase is one Conductor-created convoy. Don't run more than two in parallel until tests exist.
bump-next-js(P0 #8). One PR. MUST land first — Vercel is currently blocking all deployments, which makes every other PR's preview-smoke / visual-diff gate non-functional. Trivial bump; risk is breaking changes if going to 16.x.fix-auth-bypass(P0 #1, #2, #4, #5, #6 partial). One PR. Highest risk; needs human review.drop-public-setup(P0 #3, #4). One PR. Trivial; do as a hotfix.fix-layout-default-user(P0 #7). One PR. Trivial. 3.5.fix-lint-baseline(P1 #11.5). 2-4 PRs via multitask. Closes the lint gate (dropscontinue-on-error).add-rate-limiting(P0 #6 full). One PR. Adds @upstash/ratelimit + applies to listed routes.pick-a-name(P1 #12). Human decision first, then one or two PRs.adopt-vitest(P1 #10 step 1). One PR. Enables testing every future change.migration-tool(P1 #11). One PR. Backfill + first new migration.single-sql-client(P1 #8). 2-3 PRs, fanned out via multitask once per-file briefs are written.single-auth-provider(P1 #9). 3-5 PRs via multitask.adopt-playwright-smoke(P1 #10 step 2). One PR. RESOLVED 2026-05-24 — PR #18 squash7b6f751; smoke 3/3 green in 2.9s, full workflow 59s, zero secret leaks. See § Queued convoys for the full as-shipped block.schema-cleanup(P2 #14). Multi-PR convoy via multitask.god-component-split(P2 #13). One convoy per file; fan out via multitask once architect'sslice_dependenciesare written.launch-polish(P3). UX/IA/a11y/docs convoy.
Total: ~14 convoys to get from current state to public-launch-ready. Estimate 4-8 weeks at one human-in-the-loop reviewer per convoy. Multitask + Cursor 3.2 worktrees compress steps 8-12 substantially.
Queued convoys
Follow-ups surfaced mid-convoy or mid-PR that didn't fit the original launch sequence but need to land before public traffic. Listed in priority order; not all will be P0/P1 — most are CI / DX / hygiene polish.
rotate-default-admin(priority: P2 hygiene). Operator-rotation script for envs that ransetup-neon-db.jsbeforedrop-public-setupand still carry the weakadmin123bcrypt hash. Surfaced in P0 #3 § Operator caveat. Optional: do nothing if no audit finds a deployed env with the weak hash.cors-tighten(priority: P1 quality). Drop the wildcardAccess-Control-Allow-Originheader frompages/api/auth/verify.js. Surfaced in P0 #5 (deferred fromfix-auth-bypassBrief 4).add-rate-limiting(priority: P1 quality, also listed in launch sequence step 4). Extendlib/rate-limit.jsto/api/users/search,/api/cards/search, all/api/cards/import-*, and/api/user/avatar*. Login + register already wired infix-auth-bypassBrief 4.purge-weak-creds-from-helpers(priority: P2 hygiene). Sweepscripts/reset-db.js,scripts/create-test-users.js, andTESTING_GUIDE.mdfor the literaladmin@tcgvault.com/admin123references. May fold intopick-a-namesince the email itself is changing.single-auth-provider(priority: P1 quality, also listed as launch sequence step 9). Collapselib/auth-context.js+lib/admin-auth.jsintolib/use-auth.js. Surfaced again as a follow-up in P0 #7 § Flagged-but-deferred (4 pages still import the legacyuseAuth).cleanup-mobile-nav-dead-props(priority: P3 polish).components/MobileNavigation.jsaccepts a deaduserprop; remove it. Surfaced in P0 #7 § Flagged-but-deferred. May fold intogod-component-split(P2 #13) if that lands first.bump-eslint-10(priority: P2 hygiene; upstream-blocked). Bump ESLint from v9 to v10 oncetypescript-eslintships a v10-tested release andeslint-config-nextbundles it. Surfaced in.convoys/bump-next-js.md§ Decisions D.seed-visual-baselines-on-linux(priority: P2 CI infra; operator action required). Generate Linux baselines fortests/visual/__screenshots__/in themcr.microsoft.com/playwright:v1.60.0-nobleDocker image and commit them in a small follow-up PR. Mac-generated baselines would silently overwrite Linux CI baselines becauseplaywright.config.js's customsnapshotPathTemplatehas no{platform}token (Risk R3 + Boot-the-brief Finding 7 in.convoys/adopt-playwright-smoke.md). Until this PR lands, everyScreenshot diffrun on a PR touchingpages/**/components/**/styles/**/ Tailwind/PostCSS config fails at the test step and posts a "Visual Diff — view run" comment with empty artifacts — that's the documented Decision-4 end state ofadopt-playwright-smoke, not a regression. One small PR with just the PNG baseline(s). Surfaced 2026-05-24 as the follow-up toadopt-playwright-smoke(PR #18).adopt-playwright-smoke(priority: P1 quality, also listed as launch sequence step 10 / P1 #10 step 2) — RESOLVED 2026-05-24.- Resolved by: squash commit
7b6f751(PR #18, architect-commit3ac527e, implementer-commitc72d006). Brief 1 shipped as planned with two small lint-baseline-preserving deviations from the brief's verbatim shape (documented in the convoy file's § As-shipped). - As-shipped surface:
@playwright/test@^1.60.0added todevDependencies; newplaywright.config.jsat repo root (ESM, two projects partitioned bytestMatch—smoke+visual, CI-fail-loud / dev-warn predicate onVERCEL_AUTOMATION_BYPASS_SECRETper Decision 2,snapshotPathTemplate: 'tests/visual/__screenshots__/{arg}{ext}'aligned withvisual-diff.yml's artifact upload path); newtests/visual/homepage.spec.ts(1 test, no baseline committed per Decision 4); three newpackage.jsonscripts (test:smoke,test:visual,test:visual:update); three new.gitignoreentries (/playwright-report/,/test-results/,/.playwright/). Noeslint.config.mjschange (Decision 5 + Finding 2 verified clean empirically). No source touched underpages/**/components/**/lib/**. - Implementer deviations (both behavior-neutral, both lint-baseline-preserving):
- Removed Brief 1's
// eslint-disable-next-line no-consoledirective onplaywright.config.js'sconsole.warnbranch — the current ESLint config does not flagconsole.warnat all, so the disable directive itself would have regressed lint from 128 → 129 as an "Unused eslint-disable directive" error. - Placed
@playwright/testfirst indevDependenciesfor strict alphabetical correctness — the brief's prose was internally inconsistent on neighbors (@playwrightsorts lexically before@testing-library/react).
- Removed Brief 1's
- As-shipped metrics (from post-merge
Playwright smokerun 26376162598 onmain):Playwright smokeworkflow total runtime: 59 seconds, exit 0 (was: fast-fail at "playwright not installed" / "no config" before this convoy).Run smoke testsstep: 3/3 tests pass in 2.9s against the Vercel preview withx-vercel-protection-bypassheader applied —home redirects or renders without 5xx✓ 683ms /sign-in page renders✓ 459ms /public health endpoint responds✓ 571ms.Screenshot diffworkflow: not triggered on PR #18 itself because itspaths:filter excludes test-infra-only changes; first real trigger fires on the next PR touchingpages/**/components/**/styles/**/tailwind.config.js/postcss.config.js. At that point the documented Decision-4 end state runs live (test fails on missing baseline →continue-on-error: trueswallows → comment-on-PR step posts run link with empty artifacts).- Bypass secret leak check: 0 matches in the raw workflow log. GitHub Actions auto-masks registered secrets; our Decision-2 branches name the env var but never interpolate the value into any string.
- Cross-validation finding (not a planned AC; surfaced organically from CI green): smoke test 2 (
'sign-in page renders') assertsawait expect(page.getByRole('button', { name: /sign in/i })).toBeVisible()against/login, which only passes becausecomponents/Layout.jsrenders the<Link href="/login">Sign in</Link>CTA on the logged-out branch that PR #15 (fix-layout-default-user,ca302a8) introduced. P0 #7's resolved state is now defended by a live CI signal — if a future PR reverts to a hardcoded default user or breaks the CTA wording, smoke fails the PR (in addition to the 5 vitest assertions intest/components/Layout.test.js). - Operator action required going forward:
seed-visual-baselines-on-linux(above) is the follow-up. Until it lands,Screenshot diffruns post a "Visual Diff — view run" comment with empty artifacts on every UI-touching PR — that is the Decision-4 end state, not a regression. No operator action is required to keepPlaywright smokegreen. - Flagged-but-deferred (deliberately out of scope per the convoy file, restated here for the audit trail):
seed-visual-baselines-on-linux— see above.adopt-test-smoke-local(possible follow-up) — atest:smoke:localwrapper that auto-bootsnext dev. Explicitly rejected by Decision 6; queue only if dev friction proves out.- Deeper E2E coverage beyond the 3 existing smoke checks — per-feature work in feature convoys, not a test-infra concern.
- Owns:
role-architect(3 of 6 decisions self-ratified — D2 CI predicate, D3 two-project shape, D5 no-eslint-change; 3 of 6 operator-ratified — D1 keep.ts, D4 defer baselines, D6 simple scripts) →role-implementer(Brief 1, plus the two deviations above).
- Resolved by: squash commit
fix-vercel-deployment-protection-in-ci(priority: P2 CI infra) — RESOLVED 2026-05-24.- Resolved by: squash commit
9a3e077(PR #17), comprising three commits, not one. Operator prereq seeded 2026-05-24T20:03:31Z (gh secret set VERCEL_AUTOMATION_BYPASS_SECRET; confirmed viagh secret list); the implementer dispatch waited on that visibility per the convoy file's "Operator action required" gate. - Three-commit reality (Brief 1 + two scope expansions found during CI validation):
365e9f0Brief 1 — bypass plumbing per spec. Both.github/workflows/preview-smoke.ymland.github/workflows/visual-diff.ymlgot the same shape change:wait-for-vercel-preview@v1.3.2'spath:input now carries/?x-vercel-protection-bypass=${{ secrets.VERCEL_AUTOMATION_BYPASS_SECRET }}&x-vercel-set-bypass-cookie=true(Decision A's original cookie-variant shape — later corrected in commit 3);max_timeout: 600 → 120(Decision B); thegate:job's Decide step short-circuits ongithub.event.pull_request.head.repo.fork == truewith a::notice::annotation, before the existing PR-body skip directive runs (Decision D); and the Playwright/screenshot step exportsVERCEL_AUTOMATION_BYPASS_SECRETasenv:for forward-compat withadopt-playwright-smoke.b6f8688shell-injection hardening (latent pre-existing bug surfaced by PR #17's own CI validation). Decision D's gate step inlined${{ github.event.pull_request.body }}directly into bash, which broke when the PR body contained shell metacharacters like(or backticks. PR #17's own description bit this with"unexpected token \('"because of phrasing like *"(was: 10-minute timeout)"*. Fix is the standard GitHub Actions hardening pattern (their official "Security hardening" guide flags inline${{ }}in shell as both a syntax-error risk and a shell-injection vector): route the body and the fork flag through the step'senv:block asPR_BODYandPR_IS_FORK, then quote them as"$PR_BODY"/"$PR_IS_FORK"` in the shell condition. Same change in both workflows; ~9 LOC each. Documented in commit message as technically beyond Brief 1's scope but bundled into the convoy because the bug actively blocked Brief 1's success criterion from being validated.043a6eedrop&x-vercel-set-bypass-cookie=truefrom the wait-actionpath:— corrects Decision A's exact shape. With the cookie variant, Vercel responds 307 + Set-Cookie, and axios in Node has no cookie jar — it follows the redirect to the bare URL without the cookie, which then 401s. Empirically confirmed by operator's local curl: bare?x-vercel-protection-bypass=X→ HTTP/2 200, while?x-vercel-protection-bypass=X&x-vercel-set-bypass-cookie=true→ HTTP/2 307 (the broken path). For a one-shot healthcheck the per-request bypass query is enough. The cookie variant stays reserved for the future Playwright config (adopt-playwright-smoke) where a real browser cookie jar exists. An inline comment inpreview-smoke.ymlexplains this so the next agent doesn't accidentally re-add the cookie param.
- As-shipped metrics (from PR #17's CI run, post-validation):
Wait for Vercel Preview deploymentstep elapsed: 194 milliseconds (was: 10-minute timeout before this convoy).Playwright smokeworkflow total runtime: 59 seconds (was: 10+ minutes).- Step breakdown:
Wait for Vercel Preview deployment→ success in 194ms;npm ci,setup-node,playwright install→ success;Run smoke tests→ failure (expected — see next bullet). Screenshot diffworkflow: not triggered on PR #17 itself because its path filter excludes workflow-only changes; will fire on the next PR touchingpages/**/components/**/styles/**/ Tailwind/PostCSS config.
- Documented expected red:
Playwright smokenow reachesnpx playwright testand fast-fails becauseplaywright.config.jsdoesn't exist in the tree yet. That isadopt-playwright-smoke's scope (P1 #10 step 2 / launch sequence step 10), not this convoy's. Per the convoy file's Test plan § and Brief 1 acceptance criterion #1, a real downstream failure with the wait-action reachingReceived success status codefirst counts as success for this convoy — the failure mode shifted from "401 timeout in the wait step" to "playwright not installed", which is precisely the target state. - Operator-rotation caveat (R6 in the convoy file). The Vercel bypass token does not auto-expire. If/when it's rotated from the Vercel dashboard, the operator must re-seed the GitHub secret via
gh secret set VERCEL_AUTOMATION_BYPASS_SECRET --body "<new value>". Same human-responsibility pattern asJWT_SECRETrotation; not preventable from workflow YAML. No automation here. - Flagged-but-deferred (from the convoy file's "Anything flagged but not acted on" section, unchanged at merge):
replace-wait-for-vercel-preview— the wait-action's last release was Mar 2024; could be replaced with a few lines ofgh api+curl-loop. Out of scope for this convoy; queue if the action ages out further or gets a security advisory.adopt-playwright-smoke— owns the actualplaywright.config.js,tests/smoke/, and@playwright/testdep. The bypass plumbing here is forward-compat for that convoy (env var available on the smoke step). Listed in P1 #10 step 2 / launch sequence step 10 above.Screenshot diffbaseline 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.
- Resolved by: squash commit
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.
How to start
Per .cursor/agents/role-conductor.md, start the next convoy with:
"Run role-conductor: start a new convoy
fix-auth-bypassto address P0 #1, #2, #4, #5, #6 partial in.convoys/ship-readiness.md. Success =getUserFromRequestreturns null for missing tokens; no API route accepts unauthenticated requests; CI green."
The Conductor will set classification, skip flags, and hand off to subsequent roles.