diff --git a/.convoys/fix-vercel-deployment-protection-in-ci.md b/.convoys/fix-vercel-deployment-protection-in-ci.md new file mode 100644 index 0000000..2ed0afd --- /dev/null +++ b/.convoys/fix-vercel-deployment-protection-in-ci.md @@ -0,0 +1,239 @@ +--- +name: fix-vercel-deployment-protection-in-ci +classification: convoy +success_metric: | + `Playwright smoke` and `Screenshot diff` workflows reach their actual + smoke / capture step on every PR (no more 401-from-Vercel-SSO 10-min + timeouts). Both workflows complete in < 5 minutes. Failures, when they + occur, are real assertion failures — not auth failures against the + preview URL. +skip: + - role-design-system-auditor # CI infra only + - role-a11y-auditor # no a11y surface + - role-ux-reviewer # no UX surface + - role-ia-architect # no IA surface + - browser-smoke # the convoy IS the smoke pipeline +status: queued +created: 2026-05-24 +parent: ship-readiness +addresses: P0 #7 (CI infrastructure side-effect) +depends_on: + - fix-layout-default-user (shipped — PR #15, ca302a8) + - PR #16 fix(ci) scoped permissions (shipped — 7e97254) +--- + +# Fix Vercel Deployment Protection in CI + +Plumb `VERCEL_AUTOMATION_BYPASS_SECRET` into the `Playwright smoke` and +`Screenshot diff` workflows so anonymous GitHub Actions runners can +actually GET the preview URL without hitting Vercel's SSO 401 challenge. +Without this, both workflows permanently red on every PR — just slower +red than before PR #16. + +## Why now + +PR #16 (`fix(ci): scoped permissions`, squash commit `7e97254`) added +minimal scoped `permissions:` blocks to `.github/workflows/preview-smoke.yml` +and `.github/workflows/visual-diff.yml`. That fixed the 5-second 403 +"Resource not accessible by integration" failure both workflows hit when +trying to call the GitHub deployments API. **However**, with permissions +correct, both workflows now reach the actual deployment check and fail +with a different error: a 10-minute timeout from +`patrickedqvist/wait-for-vercel-preview@v1.3.2`'s subsequent HTTP GET +against the preview URL, which Vercel returns 401 for because Deployment +Protection is on (anonymous GitHub-runner request → Vercel SSO challenge). +Cost: ~10 minutes of runner time per workflow per PR — and zero signal, +since neither workflow ever reaches its smoke step. This blocks PR #15's +recurring follow-up convoys (visual-regression baselines, Playwright smoke +for `adopt-playwright-smoke`) from getting any CI feedback. + +The bypass token already exists locally as `VERCEL_AUTOMATION_BYPASS_SECRET` +in `.env.local` (Protection Bypass for Automation, configured in the +Vercel project). Documented in `AGENTS.md` § 7 — Deployment. The work here +is plumbing it from operator-supplied repo secret → workflow env → +`wait-for-vercel-preview`'s `path:` input + the eventual Playwright +`BASE_URL` so anonymous runner requests bypass the SSO challenge. + +## Operator action required (BEFORE this convoy can run) + +This convoy CANNOT proceed without the operator first seeding the secret +into GitHub Actions. The implementer has nothing to wire up if the +secret isn't visible to the workflows. + +1. **Seed the secret:** + ```bash + gh secret set VERCEL_AUTOMATION_BYPASS_SECRET --body "" + ``` + (The value is whatever `VERCEL_AUTOMATION_BYPASS_SECRET=…` says in + `.env.local`. Do not paste it anywhere logged. Do not echo it from a + workflow step.) +2. **Confirm visibility:** + ```bash + gh secret list + ``` + Expect to see `VERCEL_AUTOMATION_BYPASS_SECRET` listed alongside the + existing repo secrets. Note: `gh secret list` shows names only — never + values — by design. +3. **Notify the next agent** that steps 1 + 2 are done. The convoy file's + frontmatter `status:` should flip from `queued` to `in-progress` only + after this notification. + +This is the same pattern `npm run setup-db`'s `ADMIN_INITIAL_PASSWORD` +established (`drop-public-setup` Brief 1, commit `ff80753`): CI / scripts +that need a secret get an actionable fail-loud error when the secret is +missing, and the operator seeds it once per environment. + +## Decisions to ratify with operator + +Queued; do not pre-decide. + +1. **Bypass via query param vs. request header.** + - **Option A — query param.** Append `?x-vercel-protection-bypass=...&x-vercel-set-bypass-cookie=true` + to the wait-action's `path:` input AND to the Playwright `BASE_URL`. + The first request sets a `_vercel_jwt` cookie on the runner's + ephemeral browser context; subsequent same-origin requests reuse it. + Pro: works with any HTTP client, no custom config in Playwright. + Con: the bypass token shows up in workflow run logs if any step + echoes the URL (mitigation: never `echo` or `cat` a URL containing + the token; log `${{ steps.wait.outputs.url }}` only after stripping + the query string). + - **Option B — request header (`x-vercel-protection-bypass: `).** + Cleaner — the token never appears in any URL. But requires custom + HTTP-client config in `playwright.config.js` (`extraHTTPHeaders`) + AND in `wait-for-vercel-preview` (the action's docs need confirming — + header support may not be exposed via inputs). +2. **CI assertion that bypass actually works.** Should we add a step + that explicitly asserts `200` on the preview URL during the wait- + action's healthcheck phase, before handing off to Playwright / + screenshot capture? This would surface bypass-misconfiguration as a + fast-fail step instead of letting Playwright time out 8 minutes + later on a different error. Cost: ~5 lines of YAML; benefit: clearer + failure signal for the next operator-touch event. +3. **Workflow concurrency cancellation.** The workflows already use + `concurrency:` keyed on `github.ref`. Confirm that the bypass-token + wiring doesn't inadvertently break the cancel-stale behavior (e.g. + if the `secrets.VERCEL_AUTOMATION_BYPASS_SECRET` reference is in a + `concurrency:` expression, that's a syntax error and the implementer + should pull it into a job-level `env:` instead). + +## Scope + +**In scope:** + +- `.github/workflows/preview-smoke.yml` — wire the bypass into the + `wait-for-vercel-preview` step's `path:` input (Option A) OR add the + bypass header via the action's input shape (Option B, pending + confirmation that the action exposes header inputs). +- `.github/workflows/visual-diff.yml` — same treatment as preview-smoke + (the two workflows have similar shapes; whatever pattern works for one + should land in both). +- `playwright.config.js` (when it exists — the `adopt-playwright-smoke` + convoy ships it) — add `use: { extraHTTPHeaders: { 'x-vercel-protection-bypass': process.env.VERCEL_AUTOMATION_BYPASS_SECRET } }` + if Decision #1 picks Option B; OR build the BASE_URL with the query + param (Option A). +- Any test-setup file or helper that constructs the preview URL for + `screenshot-diff`-style workflows. + +**Out of scope:** + +- Writing new Playwright tests. Test authoring lives in + `adopt-playwright-smoke`. This convoy only makes the existing smoke + pipeline reachable. +- Broadening workflow `permissions:` blocks. PR #16 already landed the + minimal scope; this convoy should not need to touch them again. +- Replacing `patrickedqvist/wait-for-vercel-preview` with a different + action. The action retrieves the URL successfully (confirmed in PR #16's + run logs); the failure is the subsequent HTTP GET, which is a + configuration issue, not an action choice. A wholesale action swap is + a deeper rewrite — separate convoy if/when it's needed. +- Authoring new visual-regression baselines. The screenshot diff workflow + has nothing meaningful to compare against today; baseline authoring is + its own convoy. +- Disabling Vercel Deployment Protection on the project. Operator may + prefer to keep protected previews (cheap defense-in-depth against + preview-URL leakage); this fix lets CI work _around_ the protection + without weakening it. + +## Known constraints + +- **`wait-for-vercel-preview@v1.3.2` `path:` input is supported.** PR + #16's run logs confirm the action retrieves the URL successfully — the + subsequent HTTP GET is what fails. The action's `path:` input accepts a + full path including query string, so Option A (`?x-vercel-protection-bypass=...`) + is mechanically straightforward. Whether the action exposes a way to + inject custom request headers (Option B) needs to be confirmed by + reading the action's source / README before the implementer commits to + it. +- **The same secret will need to be plumbed into Playwright's `BASE_URL` + or into a request header in `playwright.config.js`** when the + `adopt-playwright-smoke` convoy ships. Coordinating shape now (this + convoy) vs. shape later (when Playwright lands) saves churn — the + implementer should pick whichever option keeps both call sites + consistent. +- **`npm run setup-db`'s `ADMIN_INITIAL_PASSWORD` is a parallel + precedent** for "CI needs a secret the operator must seed." Same + pattern applies: secret is repo-scoped, fail-loud (or fail-noisy) when + unset, never echoed to logs. See `drop-public-setup` Brief 1 + (commit `ff80753`). +- **Token rotation.** The Vercel bypass token can be rotated from the + Vercel dashboard. If/when that happens, the operator must re-seed the + GitHub secret (`gh secret set ...`). No automation here — this is a + human responsibility per the same pattern as `JWT_SECRET` rotation. + +## Acceptance criteria + +The convoy is shippable when ALL of the following hold: + +1. `Playwright smoke` workflow reaches its actual smoke step on a fresh + PR. It either passes (smoke green) OR fails on a real assertion + (Playwright reports a test failure or a runtime error from the + smoke spec). It does NOT fail with a 10-min timeout from the + `wait-for-vercel-preview` step or with a 401 from the preview URL. +2. `Screenshot diff` workflow reaches its screenshot capture step and + posts the "Visual Diff" comment to the PR (even if the diff itself + is empty / first-run / null-baseline). Same constraint: no 10-min + timeout, no 401. +3. Both workflows complete in < 5 minutes on a typical PR (the + pre-PR-16 baseline was ~30 seconds for the workflow body; adding a + bypass query string or header shouldn't materially affect runtime). +4. The bypass token does not appear in any workflow run log. Verify by + downloading the raw log of a passing run and grepping for the token's + first 8 chars. +5. Workflow YAML still passes basic actionlint review (`actionlint .github/workflows/*.yml` + exits 0). PR #16's permissions blocks remain unchanged. +6. `AGENTS.md` § 7 deployment paragraph (the "Preview protection bypass + for automation" line) still reflects reality after the change. May + need a one-sentence update if the implementer picks Option B + (`x-vercel-protection-bypass` header) vs. Option A (query string). + +## Anything flagged but not acted on (in advance) + +These are real findings that the architect / implementer should NOT try +to solve in this convoy. Each is queued separately if it warrants a fix. + +- **The `wait-for-vercel-preview` action is no longer maintained** (last + release Mar 2024; no v2). Could be replaced with a few lines of + `gh api` + `curl`-loop in the workflow itself. Not in scope here — + this convoy needs to fix the immediate auth failure, not rewrite the + wait logic. Queue as `replace-wait-for-vercel-preview` if the action + ages out further or has a security advisory. +- **Playwright config doesn't exist yet.** `playwright.config.js`, + `tests/smoke/`, and `@playwright/test` all land in + `adopt-playwright-smoke` (P1 #10 step 2 / launch sequence step 10). + Until that convoy ships, the only `Playwright smoke` workflow body is + a no-op. This convoy can pre-wire the bypass infrastructure (env var, + workflow secrets) so `adopt-playwright-smoke` only needs to add the + test files and the Playwright config — but it can't ship a real + smoke-pass without that follow-up. +- **`Screenshot diff` baseline authoring.** Even after this convoy lands, + the visual-diff workflow has nothing to compare against on its first + run. That's expected and orthogonal — baseline authoring is a separate + scope. +- **Operator-rotation hygiene for `VERCEL_AUTOMATION_BYPASS_SECRET`.** + Vercel's bypass tokens don't auto-expire. If the team wants a periodic + rotation policy, that's an ops-runbook concern outside this convoy. +- **`AGENTS.md` § 7 wording.** The current "Smoke/visual-diff workflows + pass this header (`x-vercel-protection-bypass`)" line in § 7 is + aspirational — it describes intent, not what was actually wired. After + this convoy ships, that line becomes accurate. The doc-writer pass at + convoy close should reword to past-tense reality. diff --git a/.convoys/ship-readiness.md b/.convoys/ship-readiness.md index a15792e..b9fee3e 100644 --- a/.convoys/ship-readiness.md +++ b/.convoys/ship-readiness.md @@ -88,11 +88,24 @@ These MUST land before any anonymous traffic touches the production URL. - **As-shipped:** `lib/rate-limit.js` (new) provides `checkAuthRateLimit(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 are `KV_REST_API_URL` / `KV_REST_API_TOKEN` (auto-provisioned by Vercel's Upstash Marketplace integration — note this is a rename from the brief's original `UPSTASH_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 +### 7. Layout default-prop leaks maintainer email — **RESOLVED 2026-05-24** +- **Resolved by:** `fix-layout-default-user` convoy (PR #15, squash commit `ca302a8`). Brief 1 (pre-squash `ddf8fd2`) shipped the Layout default-null + logged-out branch + vitest lock-in; Brief 2 (pre-squash `8c7d127`, rebased to `0f6bfbb` pre-merge) swept the 7 pages that needed page-level fixes. - **File:** `components/Layout.js` line 562: `function Layout({ children, user = { email: 'me@randallstillwell.com', role: 'user' }, ... })`. - **Impact:** Any page that renders Layout without passing a `user` prop displays your real email and impersonates you as the logged-in user. - **Fix:** Default `user = null` and render a logged-out state branch. Verify every page passes `user` explicitly (the graph shows ~13 pages call `Layout`; audit each). +- **As-shipped:** + 1. `components/Layout.js` default prop changed from hardcoded `{ email: 'me@randallstillwell.com', role: 'user' }` to `null`. `UserProfileDropdown` now branches on `user === null` and renders a `Sign in` CTA in place of the avatar + email + dropdown menu (`NavigationContent`'s `authenticatedNavigation` / `myCollectionNavigation` / `adminNavigation` were already null-safe via existing optional chains; no change there). + 2. **7 pages swept** (Brief 2, 11 `` call sites total). `pages/scanner.js` (×1), `pages/decks.js` (×3), `pages/deck-builder.js` (×4), `pages/deck/[id].js` (×3) now pass `user={user}` explicitly. `pages/profile.js` and `pages/settings.js` replaced their leaky `useState({ email: 'me@randallstillwell.com', role: 'admin' })` initializer with `useState(null)` (15 sync `user.*` reads in profile + 1 in settings got null-guards). `pages/card/[id].js` replaced its hardcoded `const user = { email: 'me@…', role: 'user' }` with `const { user } = useAuth()` from `lib/use-auth.js`. + 3. **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. + 4. **Test coverage:** `test/components/Layout.test.js` (new) adds 5 regression-lock assertions — no maintainer email when `user` is `null`/omitted; "Sign in" link present when logged out; supplied email renders when supplied; no accidental `Guest` placeholder. Vitest 21/21 green at merge (16 pre-existing auth tests still green). + 5. **New devDeps:** `jsdom@^29` + `@testing-library/react@^16` (test-only). `vitest.config.js` got a 3-line `esbuild` block to parse JSX in `.js` files (per-file `// @vitest-environment jsdom` directive — no global env change). + 6. **Verification at merge:** `rg 'me@randallstillwell.com' pages/` → 0 hits; anonymous `curl /cards` returned 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 diff` red 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): + 1. 4 pages still import `useAuth` from `lib/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 queued `single-auth-provider` convoy (P1 #9 in this file), not this one. + 2. `components/MobileNavigation.js` still receives a dead `user` prop (it accepts `{ user, onMenuOpen }` but never reads `user.*` — the bottom-bar items are static). Queued as `cleanup-mobile-nav-dead-props` (or fold into `god-component-split` if that lands first). + 3. `pages/card/[id].js` still imports `useIsAdmin` from `lib/admin-auth.js` — third parallel auth surface; same `single-auth-provider` convoy will collapse it. +- **CI infrastructure side-effect (not part of this convoy).** PR #16 (squash commit `7e97254`) landed alongside as a CI permissions fix, adding scoped `permissions:` 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 convoy `fix-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) @@ -259,6 +272,19 @@ Each phase is one Conductor-created convoy. Don't run more than two in parallel 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 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. +- **`cors-tighten`** (priority: P1 quality). Drop the wildcard `Access-Control-Allow-Origin` header from `pages/api/auth/verify.js`. Surfaced in P0 #5 (deferred from `fix-auth-bypass` Brief 4). +- **`add-rate-limiting`** (priority: P1 quality, also listed in launch sequence step 4). Extend `lib/rate-limit.js` to `/api/users/search`, `/api/cards/search`, all `/api/cards/import-*`, and `/api/user/avatar*`. Login + register already wired in `fix-auth-bypass` Brief 4. +- **`purge-weak-creds-from-helpers`** (priority: P2 hygiene). Sweep `scripts/reset-db.js`, `scripts/create-test-users.js`, and `TESTING_GUIDE.md` for the literal `admin@tcgvault.com` / `admin123` references. May fold into `pick-a-name` since the email itself is changing. +- **`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. +- **`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. +- **`fix-vercel-deployment-protection-in-ci`** (priority: P2 CI infra; **operator action required**). PR #16's permissions fix exposed that Vercel Deployment Protection 401s anonymous CI requests, so `Playwright smoke` and `Screenshot diff` now 10-min-timeout instead of 5-second-403. Plumb `VERCEL_AUTOMATION_BYPASS_SECRET` into both workflows + (eventually) Playwright config. Operator must seed the secret into GitHub Actions before the implementer can run. See `.convoys/fix-vercel-deployment-protection-in-ci.md`. Created 2026-05-24. + ## 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/AGENTS.md b/AGENTS.md index 52111e1..a0a4a47 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560 - **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'`. Avoid `lib/auth-context.js` and `lib/admin-auth.js` for new code — they are legacy parallel implementations. +- **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. @@ -53,7 +54,7 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560 - **#5 — `pages/api/setup-database.js` public endpoint. RESOLVED** by `fix-auth-bypass` Brief 3 (commit `fc0dd73`). The file is deleted along with the other three dev endpoints (`/api/simple`, `/api/test-auth`, `/api/test-db`), and `.github/workflows/ci.yml`'s new `forbidden-endpoints` job fails the build if any of them are re-introduced (or if a new `pages/api/test-*.js` file appears). Entry kept (not renumbered) to preserve cross-references. - **#6 — Migrations are bare scripts.** `scripts/add-*.js` and `scripts/fix-*.js` are run-once jobs with no idempotency tracking. Adopt `node-pg-migrate`, `kysely`, or `drizzle-kit` before more schema changes. - **#7 — Dual `is_public` semantics.** Collections and decks both have `is_public` columns; check which controls discovery vs. anonymous read in the relevant route. -- **#8 — Layout has hardcoded default user.** `Layout({ user = { email: 'me@randallstillwell.com', role: 'user' } })`. Anything rendering Layout without passing `user` will impersonate the maintainer. Pass `user` explicitly from every page. +- **#8 — Layout has hardcoded default user. RESOLVED** by `fix-layout-default-user` convoy (PR #15, squash commit `ca302a8`). `components/Layout.js`'s default prop is now `null`; `UserProfileDropdown` renders a `Sign in` CTA when `user === null`. Brief 2 also swept the 7 pages that needed page-level fixes (`scanner` / `decks` / `deck-builder` / `deck/[id]` now pass `user={user}` to Layout; `profile` / `settings` replaced leaky `useState({email:'me@…'})` with `useState(null)` + null-guards on every sync `user.*` read; `card/[id]` swapped a hardcoded `const user = {...}` for `useAuth()` from `lib/use-auth.js`). `test/components/Layout.test.js` adds 5 regression-lock assertions (no maintainer email when user is null/omitted; "Sign in" link present; supplied email renders; no "Guest" placeholder); vitest 21/21 green at merge. New devDeps: `jsdom@^29` + `@testing-library/react@^16`. See `.convoys/fix-layout-default-user.md` and `.convoys/ship-readiness.md` P0 #7. Entry kept (not renumbered) to preserve cross-references. - **#9 — `typescript` is a devDep, but the source is still JavaScript-only.** `package.json` lists `typescript@^5.9.3` purely so `eslint-config-next@16`'s bundled `typescript-eslint` chain can satisfy its hard `require('typescript')` at module load (the `peerDependenciesMeta.typescript.optional: true` flag in `eslint-config-next` only suppresses npm's install-time warning, not the runtime require). There is no `tsconfig.json`, no `.ts`/`.tsx` files, and no `// @ts-check` directives. Do not rename `.js` files to `.ts` or add a `tsconfig.json` without an explicit convoy decision — TypeScript adoption is its own scope. See `.convoys/bump-next-js.md` § Decisions C. - **#10 — ESLint pinned to v9 (maintenance), not v10 (latest).** `devDependencies.eslint` is `^9.39.4` even though `latest` is `10.4.0`. We tried v10 and `npm run lint` crashed with `TypeError: scopeManager.addGlobals is not a function` because `eslint-config-next@16`'s bundled `typescript-eslint@8.x` predates ESLint v10's redesigned global-ingestion path. Reverted to v9 under Decision D. **Do NOT bump ESLint independently** — wait for the queued `bump-eslint-10` follow-up convoy, which is upstream-blocked until `typescript-eslint` ships a v10-tested release that `eslint-config-next` bundles. See `.convoys/bump-next-js.md` § Decisions D + "Follow-up convoys queued". - **#11 — Turbopack is now the default bundler.** `next dev` and `next build` use Turbopack by default in Next.js 16. The fallback per command is `--webpack` (e.g. `next build --webpack`). We have no custom `webpack:` block in `next.config.js`, no custom loaders/aliases, and no Sass tilde imports, so Turbopack should "just work" — but if a build/runtime regression appears, reproduce on both bundlers before deciding whether to revert or pin a script to webpack. Do not pre-emptively switch to `--webpack`.