deckhearth/.convoys/pick-a-name/brief-2-infrastructure-and-email-migration.md
varutasu 9abbab6c21
feat(brand): unify on Deck Hearth across in-repo strings + infra (P1 brand decision)
Resolves the launch-blocking 'TCG Vault vs Deck Hearth' inconsistency called out in AGENTS.md line 5 since project setup. Operator gate-0 decision: Deck Hearth wins. Two briefs applied serially. B1 (mechanical): 7-file display + comment sweep. B2 (infrastructure): Redis prefix rename in lib/rate-limit.js (5 prefixes, accept one-time counter reset), package.json + lockfile regen (STOP-on-churn confirmed only name lines changed), admin/alice/bob email rename in seed scripts + login pre-fill + NEW idempotent migration script scripts/migrations/2026-05-24-rename-admin-email.js. Risk 4 PRESERVE applied: test/lib/permission-middleware.test.js retains admin@tcgvault.com literal with 7-line architect-authored why comment (documents pre-fix-auth-bypass bug shape; preserves historical truth per project's gotcha-documentation convention). All 5 D-decisions ratified at gate-1 (Deck Hearth / deck-hearth / deckhearth / admin@deckhearth.com / full deckhearth Redis prefix). Local: lint 128 baseline (B1 + B2), vitest 21/21 (B1 + B2). CI all green: Playwright smoke 3/3 against rebranded preview in 1m4s, forbidden-cors-headers pass, forbidden-endpoints pass, Screenshot diff pass, Vercel deployment complete. Cross-validation lineage: 4th convoy where the same 3-test smoke spec defends auth surface through sweeping change (after PR #15 Layout default-user, PR #19 CORS, PR #20 rate-limit, now this PR #21 brand rename). OPERATOR POST-MERGE ACTION REQUIRED: run 'node scripts/migrations/2026-05-24-rename-admin-email.js' against prod Neon DB before next admin login (ordering: migration FIRST, then any subsequent setup-db invocation). Migration is ESM, idempotent, UNIQUE-collision-safe. PR #21 architect-commit 50ce9ab, B1 ac8c998, B2 1c18d21.
2026-05-25 02:28:29 -05:00

49 KiB

convoy brief_number depends_on files
pick-a-name 2
lib/rate-limit.js
package.json
package-lock.json
README.md
TESTING_GUIDE.md
scripts/setup-neon-db.js
scripts/reset-db.js
scripts/create-test-users.js
pages/login.js
test/lib/permission-middleware.test.js
scripts/migrations/2026-05-24-rename-admin-email.js

Brief 2: Infrastructure + email migration (Redis prefix rename, package.json + lockfile regen, admin/alice/bob email rename in seed scripts + login fixtures + migration script + regression test)

Goal (1 sentence)

Sweep the eleven files that own brand-bound INFRASTRUCTURE identity — five Redis key prefixes in lib/rate-limit.js (operator accepts the one-time counter reset), the package.json name field + package-lock.json regen, the admin@tcgvault.com / alice@tcgvault.com / bob@tcgvault.com literals across the three seed/reset/test-user scripts + the README admin-email mention + the TESTING_GUIDE account table + the pages/login.js quick-login fixtures + the test/lib/permission-middleware.test.js negative regression assertion — AND ship a new dated migration script scripts/migrations/2026-05-24-rename-admin-email.js that UPDATEs every existing @tcgvault.com user row to @deckhearth.com (idempotent), in a single atomic PR per the operator-ratified D3 + D4 + D5 decisions.

Files in scope (do not edit anything else)

The 10 modified files + 1 new migration script listed in files: above.

Files explicitly out of scope (do not touch even if it seems related):

  • All 7 files owned by Brief 1AGENTS.md, .cursor/rules/ui-and-theming.mdc, .cursor/rules/auth-and-permissions.mdc, scripts/README.md, pages/api/cards/import-lorcana.js, pages/api/cards/import-pokemon.js, scripts/import-lorcana.js. Disjoint file sets enable B1 + B2 to run in parallel via /multitask.
  • components/Layout.js, components/AnimatedFireLogo.js, components/ShareModal.js, pages/index.js, pages/login.js welcome copy (lines 76 + 158), pages/signup.js, styles/globals.css — already render Deck Hearth correctly. For pages/login.js, ONLY lines 172 + 184 (the quick-login fixture emails) are in scope; lines 76, 158, and every other line stay byte-identical.
  • scripts/seed-collections-with-cards.js, scripts/seed-collections-alice-bob.js, scripts/add-*.js, scripts/fix-*.js — historical seed / migration scripts per .cursor/rules/no-go-zones.mdc § Append-only / historical. Do NOT touch. The migration script in B2 handles the in-DB email rename for existing rows; if any of these scripts are re-run post-migration, their WHERE email IN ('alice@tcgvault.com', ...) clauses match zero rows (harmless no-op).
  • components/Layout.js.backup — legacy snapshot per no-go-zones.
  • .convoys/** — append-only history per no-go-zones. Doc-writer pass at convoy close ADDS an "As-shipped" section to .convoys/pick-a-name.md only.
  • AGENTS.md Gotcha #4 (line 53) + Gotcha #12 (lines 61-77 — rate-limit gotcha): the doc-writer pass at convoy close updates Gotcha #4 to add the post-migration admin-email caveat and updates Gotcha #12 to reflect the Redis prefix rename. Do NOT preempt those edits in B2.
  • AGENTS.md line 1 + line 5 + line 13 — B1 + doc-writer scope (line 5 = B1; lines 1 + 13 = doc-writer).
  • scripts/reset-db.js line 143's bcrypt.hash('admin123', 12) literal — out of scope per the queued convert-reset-db-to-esm + purge-weak-creds-from-helpers follow-ups. Only edit lines 147 (INSERT email) and 155 (console-log). Do NOT touch line 142 (const bcrypt = require('bcryptjs') CJS-in-ESM bug) or line 143 (weak password literal) or line 156 (Admin Password: admin123 console log) — those are separate convoys. (Note: line 156 IS a related concern but its fix collides with the password-rotation convoy, not the email rename; leaving it preserves the existing scope boundary.)
  • .env.local — secrets file per no-go-zones; the operator independently updates their local env after the migration runs.
  • tests/smoke/**, tests/visual/** — smoke spec is brand-agnostic (architect-verified); visual baseline doesn't exist yet. Do NOT touch.
  • Any new vitest spec — deferred to queued fill-vitest-handler-coverage per the add-rate-limiting Decision 6 + cors-tighten Decision D4 precedent.

Conventions to follow

Decisions from the convoy file (cite when implementing)

  • D1 (operator-ratified at gate-1): Display string is "Deck Hearth" — used in README + TESTING_GUIDE titles + the migration script's stdout messages.
  • D2 (operator-ratified at gate-1): Kebab is deck-hearth — used for package.json name.
  • D3 (operator-ratified at gate-1): Camel/lower for IDs is deckhearth — used in the Redis prefix.
  • D4 (operator-ratified at gate-1): Admin email is admin@deckhearth.com (placeholder TLD pending the queued point-domain-at-deckhearth convoy). Test users follow the same TLD: alice@deckhearth.com, bob@deckhearth.com.
  • D5 (operator-ratified at gate-1): Redis prefix is the full deckhearth (NOT abbreviated dh). Pattern: deckhearth:auth, deckhearth:search, deckhearth:upload, deckhearth:generate, deckhearth:import.

Repo conventions (cite + match)

  • .cursor/rules/no-go-zones.mdc § "Editing rules of thumb / Schema changes" — INSERT-semantics changes (changing what gets inserted into users.email) require a NEW dated migration script under scripts/migrations/YYYY-MM-DD-<slug>.js. That folder doesn't exist yet; B2 creates it with the first migration file. The seed scripts' in-place INSERT literal updates ARE allowed per the operational-precedent line in the same rule (the setup-neon-db.js precedent from drop-public-setup Brief 1 — the rule says "if the change touches DDL strings or INSERT semantics, file a migration" which we do AND "if it only touches Node-module behavior or pre-flight validation, edit in place" — INSERT-literal updates fall in the migration-required category, and the migration is shipped to satisfy this).
  • @upstash/ratelimit per-class prefix isolation (architect-cited from add-rate-limiting Brief 1 Finding 11) — each limiter class needs a distinct Redis prefix. After the rename, the five distinct prefixes are deckhearth:auth, deckhearth:search, deckhearth:upload, deckhearth:generate, deckhearth:import. The five-prefixes-must-be-distinct invariant is preserved.
  • JWT auth contract from fix-auth-bypass Brief 4 + add-rate-limiting Brief 1checkAuthRateLimit(req) returns { allowed, remaining, reset } and that contract is byte-identical post-B2 (only the Redis key prefix changes; the public API surface of lib/rate-limit.js is unchanged).
  • @vercel/postgres tagged-templates only — the migration script uses @neondatabase/serverless (matching scripts/setup-neon-db.js's shape, since this is a one-off script, not an API route). Tagged-template SQL is the rule for API handlers; setup / migration scripts use the existing neon(POSTGRES_URL) adapter for parity with the codebase's other scripts.
  • README First-time admin setup § "Operators of envs that pre-date this change" callout (lines 135-141) — the same pattern is reused for the post-pick-a-name operator caveat: "if your environment had @tcgvault.com rows, run node scripts/migrations/2026-05-24-rename-admin-email.js once after deploy." B2 adds this caveat alongside the email literal update on line 132.

Acceptance criteria

lib/rate-limit.js (modified — Redis prefix rename ONLY)

Replace the five prefix: values in LIMITER_CONFIG (lines 10-14) from tcgvault:* to deckhearth:*. EVERY OTHER LINE in the 131-line module stays byte-identical, including the comment block above LIMITER_CONFIG (which uses tcgvault as an example of WHY distinct prefixes matter — that comment is conceptually about prefix isolation, NOT brand identity; rewrite the comment example along with the values).

Current lines 9-15:

const LIMITER_CONFIG = {
  auth:     { limit: 5,  window: '15 m', prefix: 'tcgvault:auth' },
  search:   { limit: 60, window: '1 m',  prefix: 'tcgvault:search' },
  upload:   { limit: 10, window: '1 h',  prefix: 'tcgvault:upload' },
  generate: { limit: 5,  window: '1 h',  prefix: 'tcgvault:generate' },
  import:   { limit: 5,  window: '1 h',  prefix: 'tcgvault:import' },
};

Replace with:

const LIMITER_CONFIG = {
  auth:     { limit: 5,  window: '15 m', prefix: 'deckhearth:auth' },
  search:   { limit: 60, window: '1 m',  prefix: 'deckhearth:search' },
  upload:   { limit: 10, window: '1 h',  prefix: 'deckhearth:upload' },
  generate: { limit: 5,  window: '1 h',  prefix: 'deckhearth:generate' },
  import:   { limit: 5,  window: '1 h',  prefix: 'deckhearth:import' },
};

Acceptance:

  • Lines 10-14 each replace tcgvault: with deckhearth: — five edits, one per line, preserving the alignment / spacing inside the { limit: N, window: '…', prefix: '…' } shape.

  • checkAuthRateLimit(req) return shape is byte-identical{ allowed: boolean, remaining: number, reset: number }. The five exported functions (checkAuthRateLimit, checkSearchRateLimit, checkUploadRateLimit, checkGenerateRateLimit, checkImportRateLimit) keep their byte-identical signatures and return shapes from the add-rate-limiting Brief 1 contract. No call site needs editing.

  • Lines 1-9, 15-131 stay byte-identical (imports, cached = null declaration, init() body, extractIpIdentifier, extractUserIdentifier, check() helper, the five export async function blocks, comments).

  • In particular: the comment block lines 4-8 (which uses tcgvault:auth as the example for why distinct prefixes matter — "a search-class hit would consume the auth-class budget for the same identifier") is architecturally about prefix isolation, not brand identity. The comment stays byte-identical — it cites the pre-rename example, which still correctly explains the invariant. (Updating the comment to reference deckhearth:auth would be defensible but creates extra diff; architect recommendation: leave comment as-is for minimum-diff.) If the implementer prefers to update the comment for self-consistency, that's allowed — but it's not required.

  • Five distinct prefixes post-rename, no duplicates:

    rg "deckhearth:" lib/rate-limit.js | sort -u | wc -l
    

    Expected: 5.

  • Zero remaining tcgvault: references in the lib body:

    rg "tcgvault:" lib/rate-limit.js
    

    Expected: zero matches if the comment is also updated; OR exactly the comment lines if the implementer left the comment as-is. Both outcomes are acceptable.

package.json (modified — name field rename)

Replace line 2 — the "name" field — from tcg-vault to deck-hearth per D2.

Current line 2:

  "name": "tcg-vault",

Replace with:

  "name": "deck-hearth",

Acceptance:

  • Line 2 matches the verbatim replacement above (preserves leading 2-space indent + trailing comma).
  • Every other line in package.json stays byte-identical (version, scripts, dependencies, devDependencies, "type": "module", etc.).
  • Net diff: 1 line + / 1 line -.

package-lock.json (regenerated — opaque, npm-managed)

Run npm install AFTER the package.json name change to regenerate the lockfile. The diff SHOULD only touch the top-level name field (line 2) + the packages."" entry's nested name (line 8) — both should become deck-hearth.

Expected diff (architect-verified pre-edit grep — exactly two matches for tcg-vault in package-lock.json):

-  "name": "tcg-vault",      // line 2 — top-level lockfileVersion 3 root
+  "name": "deck-hearth",
...
-      "name": "tcg-vault",  // line 8 — packages[""] entry
+      "name": "deck-hearth",

Acceptance:

  • After npm install, package-lock.json shows ONLY the two name field updates — NO opportunistic dependency churn (no version bumps, no new integrity hashes for packages that didn't change, no shifted ordering of unrelated entries).

  • If npm install produces unrelated churn (e.g., npm pulls fresh metadata for one or more deps), STOP and flag for a separate bump-deps-cleanup convoy. Do NOT commit accidental dependency updates inside the pick-a-name PR.

  • Verification command (run BEFORE commit):

    git diff package-lock.json | grep -E '^[+-]' | grep -v -E '^[+-]{3}|^[+-]\s*"name":' | head -20
    

    Expected: zero output (every diff line is either the file header +++ / --- or a "name": change). If output appears, investigate before commit.

  • npm ci (NOT npm install) should succeed cleanly with the regenerated lockfile — verifies the lockfile is internally consistent and matches package.json.

README.md (modified — title + admin-email mention + operator caveat addition)

Two edits in two distinct locations + an additive operator caveat alongside the existing one.

Edit 1 — Line 1 (title):

Current:

# TCG Vault

Replace with:

# Deck Hearth

Edit 2 — Line 132 (admin email):

Current:

- **Admin email:** the seed creates `admin@tcgvault.com`. Change the password

Replace with:

- **Admin email:** the seed creates `admin@deckhearth.com`. Change the password

Edit 3 — Append a new operator caveat as a SIBLING blockquote to the existing one (the existing blockquote is lines 135-141, the > **Operators of envs that pre-date this change:** paragraph). Insert the new blockquote IMMEDIATELY AFTER the existing one (a blank line, then the new blockquote, then a blank line, then the existing ## 🤝 Contributing section heading).

Insert (between current lines 141 and 143):

> **Operators of envs that pre-date the `pick-a-name` convoy (2026-05-24):**
> the admin row was renamed from `admin@tcgvault.com` to
> `admin@deckhearth.com`. Run
> `node scripts/migrations/2026-05-24-rename-admin-email.js` once after
> deploy to UPDATE any existing `@tcgvault.com` user rows (the admin row,
> plus alice/bob if `npm run create-test-users` was ever run). Re-running
> the migration after the first run is idempotent and prints "Nothing to
> migrate." Verify post-migration with
> `psql $POSTGRES_URL -c "SELECT email FROM users WHERE email LIKE '%@tcgvault.com'"`
> — expect zero rows.

Acceptance:

  • Line 1 matches the verbatim replacement above.
  • Line 132 matches the verbatim replacement above (preserves leading - **Admin email:** markdown structure + trailing Change the password).
  • Lines 30 (cd tcg-vault) and 105 (tcg-vault/ directory tree) stay byte-identical — repo-name references, deferred to rename-repo-and-vercel-project convoy.
  • The new operator-caveat blockquote is inserted after the existing 2026-05-22-era > **Operators of envs that pre-date this change:** blockquote (which talks about the admin123 weak hash), formatted as another > blockquote with the verbatim 9-line content above.
  • No other line in README.md changes.
  • Net diff: 2 lines changed (lines 1, 132) + ~10 lines added (new caveat blockquote with surrounding whitespace).

TESTING_GUIDE.md (modified — title + 6 email references)

Update the title and every @tcgvault.com email reference to @deckhearth.com.

Edit 1 — Line 1 (title):

Current:

# 🎯 TCG Vault Collaboration Testing Guide

Replace with:

# 🎯 Deck Hearth Collaboration Testing Guide

Edit 2 — Lines 7-9 (account table):

Current:

| Admin | `admin@tcgvault.com` | `admin123` | Admin |
| Alice | `alice@tcgvault.com` | `alice123` | User |
| Bob | `bob@tcgvault.com` | `bob123` | User |

Replace with:

| Admin | `admin@deckhearth.com` | `admin123` | Admin |
| Alice | `alice@deckhearth.com` | `alice123` | User |
| Bob | `bob@deckhearth.com` | `bob123` | User |

Edit 3 — Line 24:

Current:

Email: alice@tcgvault.com

Replace with:

Email: alice@deckhearth.com

Edit 4 — Line 46:

Current:

- Enter: `bob@tcgvault.com`

Replace with:

- Enter: `bob@deckhearth.com`

Edit 5 — Line 53:

Current:

- Email: `bob@tcgvault.com`

Replace with:

- Email: `bob@deckhearth.com`

Acceptance:

  • Six lines updated (1, 7, 8, 9, 24, 46, 53 — counting line 1 as the title).
  • The admin123 / alice123 / bob123 password literals stay — that's the queued purge-weak-creds-from-helpers convoy's scope, not pick-a-name. The TESTING_GUIDE is a manual testing reference; the dev test users having weak passwords is the documented state pre-purge-weak-creds-from-helpers.
  • The instructional flow (sections 1-6) text is byte-identical apart from the email-literal edits above. Don't rewrite copy.
  • After the edits, rg "tcgvault" TESTING_GUIDE.md returns zero matches and rg "@tcgvault\.com" TESTING_GUIDE.md returns zero matches.

scripts/setup-neon-db.js (modified — admin email INSERT + console log)

Two edits, both on the admin email literal.

Edit 1 — Line 148 (admin INSERT):

Current:

      VALUES (${'admin@tcgvault.com'}, ${hashedPassword}, ${'admin'})

Replace with:

      VALUES (${'admin@deckhearth.com'}, ${hashedPassword}, ${'admin'})

Edit 2 — Line 157 (console log):

Current:

    console.log('   Admin user ready (email: admin@tcgvault.com)');

Replace with:

    console.log('   Admin user ready (email: admin@deckhearth.com)');

Acceptance:

  • Lines 148 + 157 match the verbatim replacements above.
  • Every other line stays byte-identical — including the ADMIN_INITIAL_PASSWORD env-var gate (lines 18-28, locked in by drop-public-setup Brief 1), the ESM imports (lines 11, 14, 15, locked in by drop-public-setup Brief 2), the seven CREATE TABLE IF NOT EXISTS blocks, the ON CONFLICT (email) DO NOTHING clause, and the troubleshooting copy.
  • Re-running npm run setup-db after this convoy lands seeds new deploys with admin@deckhearth.com. For existing deploys with admin@tcgvault.com, the seed's ON CONFLICT (email) DO NOTHING doesn't match (the existing email is different) so a NEW admin row would be created with admin@deckhearth.com — creating a TWO-ADMIN row collision. The operator MUST run the migration script FIRST, then setup-db (the migration sweeps the row to @deckhearth.com, and a subsequent setup-db sees the row exists with the new email and no-ops). This ordering is documented in the README operator caveat and in the migration script's stdout.
  • Net diff: 2 lines + / 2 lines -.

scripts/reset-db.js (modified — admin email INSERT + console log ONLY)

Two edits, both on the admin email literal. Do NOT touch other linesreset-db.js has the CJS require('bcryptjs') bug (line 142) and weak password literal (line 143 + the password-echo on line 156); those are queued for separate convoys (convert-reset-db-to-esm + purge-weak-creds-from-helpers). pick-a-name is scoped to the email rename only.

Edit 1 — Line 147 (admin INSERT):

Current:

      VALUES (${'admin@tcgvault.com'}, ${hashedPassword}, ${'admin'})

Replace with:

      VALUES (${'admin@deckhearth.com'}, ${hashedPassword}, ${'admin'})

Edit 2 — Line 155 (console log):

Current:

    console.log('   Admin User: admin@tcgvault.com');

Replace with:

    console.log('   Admin User: admin@deckhearth.com');

Acceptance:

  • Lines 147 + 155 match the verbatim replacements above.
  • Line 142 (const bcrypt = require('bcryptjs');) stays byte-identical — out of scope per convert-reset-db-to-esm follow-up. (Note: this line is broken on Node 22.x with "type": "module" in package.json. The bug means npm run reset-db doesn't actually work today. That's out of pick-a-name's scope — reset-db.js is a developer utility that nobody runs in CI; fixing the ESM-conversion bug is its own convoy. The email rename here is preparatory: when convert-reset-db-to-esm ships, the email will already be correct.)
  • Line 143 (const hashedPassword = await bcrypt.hash('admin123', 12);) stays byte-identical — weak password literal is purge-weak-creds-from-helpers scope.
  • Line 156 (console.log(' Admin Password: admin123');) stays byte-identical — same.
  • Net diff: 2 lines + / 2 lines -.

scripts/create-test-users.js (modified — alice/bob/admin emails across 6 lines)

Six edits across the alice INSERT, bob INSERT, and the three-account summary console log block.

Edit 1 — Line 18 (alice INSERT):

      VALUES ('alice@tcgvault.com', ${alicePassword}, 'user')

      VALUES ('alice@deckhearth.com', ${alicePassword}, 'user')

Edit 2 — Line 21 (alice console log):

    console.log('✅ Created Alice (alice@tcgvault.com / alice123)');

    console.log('✅ Created Alice (alice@deckhearth.com / alice123)');

Edit 3 — Line 27 (bob INSERT):

      VALUES ('bob@tcgvault.com', ${bobPassword}, 'user')

      VALUES ('bob@deckhearth.com', ${bobPassword}, 'user')

Edit 4 — Line 30 (bob console log):

    console.log('✅ Created Bob (bob@tcgvault.com / bob123)');

    console.log('✅ Created Bob (bob@deckhearth.com / bob123)');

Edit 5 — Line 34 (summary admin line):

    console.log('   1. admin@tcgvault.com / admin123 (Admin)');

    console.log('   1. admin@deckhearth.com / admin123 (Admin)');

Edit 6 — Line 35 (summary alice line):

    console.log('   2. alice@tcgvault.com / alice123 (User)');

    console.log('   2. alice@deckhearth.com / alice123 (User)');

Edit 7 — Line 36 (summary bob line):

    console.log('   3. bob@tcgvault.com / bob123 (User)');

    console.log('   3. bob@deckhearth.com / bob123 (User)');

Acceptance:

  • Lines 18, 21, 27, 30, 34, 35, 36 each match the verbatim replacements above (preserves leading indent, surrounding emoji/punctuation, password / role literals).
  • Password literals (alice123, bob123, admin123) stay byte-identicalpurge-weak-creds-from-helpers scope.
  • ON CONFLICT (email) DO NOTHING clauses stay byte-identical — preserves the script's idempotency.
  • The ESM imports (lines 3-5, dotenv / @vercel/postgres / bcryptjs) stay byte-identicalcreate-test-users.js is already ESM-correct.
  • After this edit, re-running node scripts/create-test-users.js against a fresh DB creates alice/bob with the new emails. Against an existing DB with the OLD emails, the ON CONFLICT clause keys off the email (UNIQUE on email column), so a re-run would INSERT NEW rows under the new emails alongside the old ones (two alices, two bobs). Operator should run the migration script FIRST to rename existing rows.
  • Net diff: 7 lines + / 7 lines -.

pages/login.js (modified — quick-login fixture emails ONLY, lines 172 + 184)

The "Quick Login for Testing" section has two button onClick handlers that pre-fill the email field with alice / bob fixture emails. These MUST match the post-migration emails in the DB for the quick-login to work.

Edit 1 — Line 172:

Current:

                      onClick={() => handleQuickLogin('alice@tcgvault.com', 'alice123')}

Replace with:

                      onClick={() => handleQuickLogin('alice@deckhearth.com', 'alice123')}

Edit 2 — Line 184:

Current:

                      onClick={() => handleQuickLogin('bob@tcgvault.com', 'bob123')}

Replace with:

                      onClick={() => handleQuickLogin('bob@deckhearth.com', 'bob123')}

Acceptance:

  • Lines 172 + 184 match the verbatim replacements above (preserves the surrounding 22-space indent + the closing parenthesis structure).
  • EVERY OTHER LINE in pages/login.js stays byte-identical — specifically:
    • Lines 76, 158 (Welcome to Deck Hearth, Sign in to Deck Hearth) — already correct, DO NOT touch.
    • Lines 1-15 (imports, component declaration, state) — DO NOT touch.
    • Lines 200-205 (signup link copy) — DO NOT touch.
    • The 30-line <AuthLayout> shell, the form fields, the handleSubmit body — DO NOT touch.
  • After this edit, clicking the "Alice" / "Bob" quick-login buttons pre-fills the email field with <name>@deckhearth.com. The button's effect is purely UI state (sets formData); the actual login fires when the user clicks "Sign in to Deck Hearth", which POSTs to /api/auth/login. If the DB has been migrated, login succeeds; if not, login 401s.
  • Warning to implementer: if the DB migration has NOT been run when these fixtures are tested locally, login will 401. That's the expected pre-migration failure mode, NOT a bug in the edit. Document the testing order in the PR description.
  • Net diff: 2 lines + / 2 lines -.

This is the negative regression assertion locking in that getUserFromRequest does NOT return the OLD synthetic-admin shape — { userId: 1, email: 'admin@tcgvault.com', role: 'admin' }. Architect-recommended approach: PRESERVE the historical literal admin@tcgvault.com because:

  1. The .toBeNull() assertion on the next line (line 90) is the strong contract — it asserts the helper returns null, which is what matters.
  2. The .not.toEqual({...}) assertion is a soft historical-shape check — it specifically locks in that the helper does NOT return the EXACT pre-fix-auth-bypass synthetic shape. Updating the email literal weakens this — it would only catch a future synthetic-admin path that used the NEW email, not one that resurrected the OLD shape.
  3. Git history shows the bug was committed with admin@tcgvault.com; the assertion is a historical artifact that documents the bug shape.

Edit — Line 83-91 region (preserve email literal; add explanatory comment ABOVE the .not.toEqual({...}) call):

Current:

  it('does NOT return the synthetic admin shape when no Authorization header is present (Brief 2 regression lock)', async () => {
    const user = await getUserFromRequest({ headers: {} });
    expect(user).not.toEqual({
      userId: 1,
      email: 'admin@tcgvault.com',
      role: 'admin',
    });
    expect(user).toBeNull();
  });

Replace with:

  it('does NOT return the synthetic admin shape when no Authorization header is present (Brief 2 regression lock)', async () => {
    const user = await getUserFromRequest({ headers: {} });
    // Email literal is the OLD `admin@tcgvault.com` (pre-`pick-a-name`
    // convoy, 2026-05-24) — preserved as the exact pre-fix-auth-bypass
    // synthetic-admin shape this assertion locks against. The
    // `.toBeNull()` check below is the strong contract; this soft check
    // documents the historical bug. Do NOT update to
    // `admin@deckhearth.com` — that would weaken the regression-lock to
    // a shape that never actually existed.
    expect(user).not.toEqual({
      userId: 1,
      email: 'admin@tcgvault.com',
      role: 'admin',
    });
    expect(user).toBeNull();
  });

Acceptance:

  • The 7-line comment block is inserted ABOVE the expect(user).not.toEqual({...}) call (between the const user = await getUserFromRequest({ headers: {} }); line and the expect(user).not.toEqual({...}) line). Preserves the existing 4-space indent.
  • The email: 'admin@tcgvault.com' literal stays UNCHANGED (line 87 in the pre-edit file).
  • The expect(user).toBeNull(); assertion (line 90 pre-edit) stays byte-identical.
  • Every other line in the file stays byte-identical.
  • After the edit, npm run test:run still passes 21/21.
  • Implementer alternative (if preferred): if you'd rather update the email literal to admin@deckhearth.com for self-consistency, do so AND change the comment to explain "this assertion's literal email tracks the current admin email; the historical pre-fix-auth-bypass bug returned admin@tcgvault.com, see commit 258e479 for the deleted shape." Either path is acceptable; the architect recommendation is PRESERVE for stronger historical regression coverage.

scripts/migrations/2026-05-24-rename-admin-email.js (NEW — the email migration script)

Create the new scripts/migrations/ folder + the first dated migration script. Idempotent. Operator-invoked once after deploy.

Verbatim new file:

#!/usr/bin/env node

/**
 * Migration: 2026-05-24 — Rename @tcgvault.com user emails to @deckhearth.com
 *
 * Part of the `pick-a-name` convoy. Renames every `users.email` row matching
 * `%@tcgvault.com` to the `@deckhearth.com` equivalent (admin + alice + bob,
 * plus any other accidentally-`@tcgvault.com` users if they exist).
 *
 * Idempotent: re-running after the first run prints "Nothing to migrate."
 *
 * Usage:
 *   node scripts/migrations/2026-05-24-rename-admin-email.js
 *
 * Required env: POSTGRES_URL (read from .env.local).
 *
 * Safety: the UPDATE uses REPLACE() so emails like `admin@tcgvault.com`
 * become `admin@deckhearth.com`. The `users.email` UNIQUE constraint will
 * fail loudly if a row with the target email already exists — which is the
 * correct behavior (do NOT silently overwrite). If you see the constraint
 * violation, inspect the DB manually before retrying.
 */

import dotenv from 'dotenv';
dotenv.config({ path: '.env.local' });

import { neon } from '@neondatabase/serverless';

async function main() {
  if (!process.env.POSTGRES_URL) {
    console.error('❌ POSTGRES_URL is not set. Set it in .env.local before running this migration.');
    process.exit(1);
  }

  const sql = neon(process.env.POSTGRES_URL);

  const { rows: before } = await sql`
    SELECT id, email, role
    FROM users
    WHERE email LIKE '%@tcgvault.com'
    ORDER BY id
  `;

  if (before.length === 0) {
    console.log('✅ Nothing to migrate. No users with @tcgvault.com emails found.');
    return;
  }

  console.log(`Found ${before.length} user(s) with @tcgvault.com emails:`);
  for (const r of before) {
    console.log(`  id=${r.id}  role=${r.role}  email=${r.email}`);
  }

  await sql`
    UPDATE users
    SET email = REPLACE(email, '@tcgvault.com', '@deckhearth.com'),
        updated_at = CURRENT_TIMESTAMP
    WHERE email LIKE '%@tcgvault.com'
  `;

  const { rows: after } = await sql`
    SELECT id, email, role
    FROM users
    WHERE email LIKE '%@deckhearth.com'
    ORDER BY id
  `;

  console.log(`✅ Migrated ${before.length} user(s). Post-migration @deckhearth.com rows:`);
  for (const r of after) {
    console.log(`  id=${r.id}  role=${r.role}  email=${r.email}`);
  }

  const { rows: stragglers } = await sql`
    SELECT COUNT(*)::int AS count FROM users WHERE email LIKE '%@tcgvault.com'
  `;
  if (stragglers[0].count !== 0) {
    console.warn(`⚠️  ${stragglers[0].count} @tcgvault.com row(s) still present after migration — investigate.`);
    process.exit(1);
  }
}

main().catch((err) => {
  console.error('❌ Migration failed:', err);
  process.exit(1);
});

Acceptance:

  • File ends up at scripts/migrations/2026-05-24-rename-admin-email.js (folder created automatically by the file write).
  • File content is the verbatim shape above. 2-space indent. ESM (matches scripts/setup-neon-db.js's post-drop-public-setup-Brief-2 shape).
  • Idempotency: running the script twice should: (a) first run prints the before-count + UPDATEs + post-count; (b) second run prints Nothing to migrate. and exits 0. Verified by re-running locally against a staging DB.
  • Safety: UNIQUE-constraint violation handling. If the target email already exists (e.g. operator ran the migration, then created a duplicate admin via setup-neon-db.js, then tried to re-migrate a regressed row), the UPDATE fails with the Postgres UNIQUE constraint error and the script exits non-zero — that's the correct fail-loud behavior. Do NOT add a ON CONFLICT clause to the UPDATE (which doesn't exist in standard SQL for UPDATE statements anyway — it's an INSERT-only clause).
  • Straggler check: after the UPDATE, the script SELECTs to confirm zero remaining @tcgvault.com rows. If any remain (unexpected), it warns + exits 1.
  • No new dependencies. Uses dotenv + @neondatabase/serverless — both already in package.json dependencies.
  • POSTGRES_URL env guard at startup — same defensive pattern as setup-neon-db.js for ADMIN_INITIAL_PASSWORD (drop-public-setup Brief 1 precedent).
  • The script does NOT touch any other column. It only updates users.email (and users.updated_at to reflect the change). No password, role, or any other field changes.
  • NOT added to package.json scripts. It's a one-off migration; the operator invokes it directly via node scripts/migrations/2026-05-24-rename-admin-email.js. No "migrate-admin-email" npm script is added. (Adding a migration npm script per migration is wrong — when a real migration tool lands per AGENTS.md Gotcha #6, that tool's CLI handles invocation.)

Cross-file checks

  • npm run lint exit code unchanged. Per lib/rate-limit.js Brief 1's precedent: lib refactors that change a config-literal don't add ESLint surface. New migration script: pure ESM Node, should pass without warnings (matches scripts/setup-neon-db.js's shape).

  • npm run test:run (vitest) passes 21/21. Only test edit is the comment-addition in test/lib/permission-middleware.test.js; assertion semantics are byte-identical, so all 21 tests still pass.

  • npm run build exit 0. Turbopack should compile cleanly. The lib/rate-limit.js edits are string literals; pages/login.js edits are string literals. No new imports.

  • npm run test:smoke against the Vercel preview passes 3/3 (smoke spec is brand-agnostic; verified at architect time).

  • Per-class Redis prefix uniqueness check (the post-rename equivalent of add-rate-limiting Brief 1's check):

    rg "deckhearth:" lib/rate-limit.js | sort -u
    

    Expected: 5 distinct lines (deckhearth:auth, deckhearth:search, deckhearth:upload, deckhearth:generate, deckhearth:import).

    And:

    rg "tcgvault:" lib/rate-limit.js
    

    Expected: zero matches in active code (the architect-recommended preserve-comment path may leave the comment block on lines 4-8 referencing tcgvault:auth as the example; if so, that's the only acceptable match).

  • Repo-wide grep (post-B2, with B1 also landed):

    rg "@tcgvault\.com" --type-not md
    

    Expected: at most 1 matchtest/lib/permission-middleware.test.js line 87's preserved historical regression literal (if the implementer took the recommended preserve path). Zero matches if the implementer chose the update-to-new-email path.

    rg "@tcgvault\.com" .convoys/
    

    Expected: same count as pre-B2 (historical convoy artifacts).

    rg "@tcgvault\.com" --type-not md --type-not js
    

    Expected: zero matches (no @tcgvault.com left outside markdown + js source).

  • In-DB migration verification (against staging Neon, post-deploy):

    node scripts/migrations/2026-05-24-rename-admin-email.js
    

    Expected first-run output (against an env that had the old emails):

    Found 3 user(s) with @tcgvault.com emails:
      id=1  role=admin  email=admin@tcgvault.com
      id=2  role=user   email=alice@tcgvault.com
      id=3  role=user   email=bob@tcgvault.com
    ✅ Migrated 3 user(s). Post-migration @deckhearth.com rows:
      id=1  role=admin  email=admin@deckhearth.com
      id=2  role=user   email=alice@deckhearth.com
      id=3  role=user   email=bob@deckhearth.com
    

    Expected second-run output (idempotency check):

    ✅ Nothing to migrate. No users with @tcgvault.com emails found.
    
  • Diff hygiene. git diff main..HEAD --stat (after B2 commit only) should show:

    • lib/rate-limit.js: 5 lines + / 5 lines -.
    • package.json: 1 line + / 1 line -.
    • package-lock.json: 2 lines + / 2 lines - (assuming clean regen — see acceptance criteria above).
    • README.md: ~12 lines + / 2 lines - (2 edits + new caveat blockquote).
    • TESTING_GUIDE.md: 6 lines + / 6 lines -.
    • scripts/setup-neon-db.js: 2 lines + / 2 lines -.
    • scripts/reset-db.js: 2 lines + / 2 lines -.
    • scripts/create-test-users.js: 7 lines + / 7 lines -.
    • pages/login.js: 2 lines + / 2 lines -.
    • test/lib/permission-middleware.test.js: 7 lines + / 0 lines - (comment block addition).
    • scripts/migrations/2026-05-24-rename-admin-email.js: NEW, ~85 lines.
    • No whitespace-only changes elsewhere.

Manual verification (in addition to CI on push)

Run these in order against a staging environment with Upstash + Neon configured. DO NOT run against production.

  • Lockfile regen sanity. Before commit:

    npm install        # regenerate package-lock.json
    git diff package-lock.json | head -40
    

    Expected: a 4-line diff (two -name lines, two +name lines). If ANYTHING else changes, STOP and flag.

  • Local dev boot. npm run dev. Expected: clean boot, no [rate-limit] warn-spam at startup (lazy-init; warn only on first request if Upstash isn't configured). The Redis prefix change is transparent at boot time.

  • Auth limiter regression check (Brief 4 + add-rate-limiting contract preserved):

    for i in 1 2 3 4 5 6; do
      curl -sS -o /dev/null -w "POST /api/auth/login attempt $i: %{http_code}\n" \
        -X POST -H "Content-Type: application/json" \
        -d '{"email":"nobody@example.com","password":"wrong"}' \
        http://localhost:3000/api/auth/login
    done
    

    Expected (only meaningful with KV_REST_API_* set against the staging Upstash):

    • Attempts 1-5: 401
    • Attempt 6: 429 with Retry-After header

    The behavior is byte-identical to pre-B2 — only the Redis key namespace changes from tcgvault:auth:* to deckhearth:auth:*. The counter for nobody@example.com-from-this-IP starts fresh at zero post-deploy because the old tcgvault:auth:* keys are orphaned. This is the acknowledged Redis counter reset.

  • Migration script smoke (against staging only):

    node scripts/migrations/2026-05-24-rename-admin-email.js
    

    Run twice. First run reports the count + UPDATEs; second run reports "Nothing to migrate."

  • Post-migration login smoke:

    curl -sX POST http://localhost:3000/api/auth/login \
      -H 'content-type: application/json' \
      -d '{"email":"admin@deckhearth.com","password":"<your-admin-password>"}'
    

    Expected: 200 + JWT in response body.

  • Quick-login button smoke: in the browser, visit /login, click "Alice", confirm the email field shows alice@deckhearth.com, click "Sign in to Deck Hearth", confirm redirect to /dashboard. Repeat for "Bob". Both require the migration to have run; if migration skipped, both 401 (expected pre-migration failure mode).

  • Visit /admin/card-import as admin to confirm the post-add-rate-limiting admin-UI Bearer-token wiring still works against the renamed admin row. Type a small set code (e.g. neo) and click Import. Expected: 200 from the API (or whatever Scryfall returns).

  • Vitest pass count unchanged. npm run test:run 2>&1 | tail -5Tests 21 passed (21).

Boot-the-brief findings (preempted by the architect; do not re-investigate)

Finding 1 — Lockfile regen is mostly opaque but architect-verified to be minimal

Architect ran rg "tcg-vault" package-lock.json at brief time → exactly 2 matches (lines 2 + 8 — top-level name field and the packages[""] entry's nested name). Standard npm behavior: npm install after a package.json name change updates ONLY those two fields. However, npm sometimes opportunistically refreshes stale dependency metadata (integrity hashes, peer-dep resolutions). The brief includes a verification command to confirm zero unrelated churn before commit.

If unrelated churn appears, the implementer must STOP — the convoy is a brand rename, not a dependency update. Quarantine the unrelated diff for a separate bump-deps-cleanup convoy.

Finding 2 — Redis counter reset is per-window, not permanent

Architect cross-checked against @upstash/ratelimit@2.0.8's slidingWindow semantics + the five window durations in LIMITER_CONFIG. Maximum window: 1 hour (upload / generate / import). Minimum: 1 minute (search). After the prefix rename ships, all five windows reset to zero — within an hour, the system is back to nominal counter state for all five classes. No persistent damage. The legitimate user who was at 4/5 auth-attempts pre-deploy now has 5/5 fresh — same as the attacker. Net defensively neutral.

The convoy seed explicitly accepts this. No additional mitigation needed.

Finding 3 — scripts/reset-db.js has a CJS-in-ESM bug that is OUT OF SCOPE here

Line 142 (const bcrypt = require('bcryptjs');) and line 5 (#!/usr/bin/env node shebang without any import statement) indicate reset-db.js was never converted to ESM after package.json got "type": "module" (the bump-next-js convoy's flag). The script is broken on Node 22.x. Out of scope for pick-a-name — track in queued convert-reset-db-to-esm (which can also fold the weak-password literal cleanup from purge-weak-creds-from-helpers). B2 only changes the email-literal strings on lines 147 + 155; do NOT attempt to fix the CJS bug.

Finding 4 — The migration script's UPDATE will fail loudly on UNIQUE-constraint violation, which is correct

If the operator runs setup-neon-db.js BEFORE the migration (after B2 deploys), there'd be TWO admin rows: one with admin@tcgvault.com (pre-existing) + one with admin@deckhearth.com (newly seeded). The migration's UPDATE … SET email = REPLACE(...) would try to update the old row to admin@deckhearth.com, hitting the UNIQUE constraint on users.email. Postgres throws duplicate key value violates unique constraint "users_email_key". The script exits non-zero. The operator sees the error, manually deletes one of the two duplicate admin rows, then re-runs the migration. This is the correct fail-loud behavior. Do NOT add silent conflict handling.

The README operator caveat documents the recommended order: migration FIRST, then setup-db (which then no-ops because the row exists with the new email).

Finding 5 — pages/login.js quick-login buttons are pre-fill, NOT direct-login

Architect read lines 168-194 of pages/login.js. The handleQuickLogin(email, password) function sets formData state — it pre-fills the email + password fields. The user still has to click "Sign in to Deck Hearth" to actually submit. So the quick-login emails are tested AGAINST the database when the user clicks Sign In, not when they click the Quick-Login button. Pre-migration, clicking Alice + then Sign-In will 401. Post-migration, it succeeds.

This is the correct UX — the implementer doesn't need to add any pre-submit validation.

Finding 6 — The test/lib/permission-middleware.test.js regression-lock email is architecturally a HISTORICAL marker

Architect read lines 83-91 of the test file. The assertion expect(user).not.toEqual({ userId: 1, email: 'admin@tcgvault.com', role: 'admin' }) is checking against the EXACT pre-fix-auth-bypass synthetic-admin return shape. The .toBeNull() immediately after is the strong contract — that's the assertion that fails the test if the bug regresses. The .not.toEqual() is a soft historical-shape check that documents the bug's exact return value at the time it was a bug.

Architect recommendation: PRESERVE the historical literal admin@tcgvault.com + add a comment explaining why. The alternative (updating to admin@deckhearth.com) is defensible but creates a weaker assertion (only catches a future synthetic-admin path that uses the NEW email). The brief documents both options; implementer chooses.

Finding 7 — .cursor/rules/no-go-zones.mdc allows the migration script + permits the in-place email edits

Re-read the rule's "Editing rules of thumb" subsection:

Schema changes: until a proper migration tool lands, document the change in a new dated script under scripts/migrations/YYYY-MM-DD-<slug>.js (folder TBD). Do NOT edit scripts/setup-neon-db.js in place for any DDL change ... Operational changes are allowed (env-var gating, error-message hardening, module-system fixes) — drop-public-setup set this precedent ... The distinction: if the change touches DDL strings or INSERT semantics, file a migration; if it only touches Node-module behavior or pre-flight validation, edit in place and document why in the convoy.

The admin-email rename:

  • IS an INSERT-semantics change (different literal value going into INSERT INTO users (email, …)).
  • Therefore requires a NEW dated migration script. B2 ships one.
  • AND requires the in-place edits to setup-neon-db.js + reset-db.js + create-test-users.js to keep new-deploy seeding correct. The rule explicitly allows operational changes in-place when accompanied by a migration; B2 satisfies both halves.

The folder scripts/migrations/ is created automatically with the first file write.

Finding 8 — No existing convoy has touched scripts/reset-db.js (architect cross-check)

Architect ran rg "scripts/reset-db.js" .convoys/ at brief time. The only matches are passing references (e.g. ship-readiness.md line 360's purge-weak-creds-from-helpers queued entry mentioning the file). No prior convoy has edited reset-db.js. B2's edit (email literals only) is the file's first convoy-driven change. Coordination risk: zero.

Finding 9 — The pages/login.js quick-login section is bounded; no other email references

Architect ran rg "@tcgvault" pages/login.js → exactly 2 matches (lines 172, 184). Outside the quick-login <button> onClick handlers, there are no other email literals in pages/login.js. The handleSubmit body: JSON.stringify(formData) sends whatever the user typed — no other email-bound shapes to update.

Finding 10 — Brief 2 has the larger blast but lower decision-density than B1

B1 has 7 trivial edits + 6 boot-the-brief findings. B2 has ~30 edits + 1 new file + 10+ boot-the-brief findings. The complexity is in the cross-file ordering and the migration script's idempotency contract — NOT in any single edit, all of which are mechanical literal replacements. The implementer should land B2 against a fresh checkout, sequence the edits in the order listed in Acceptance criteria, and run the boot-the-brief verification commands before commit.

Out of scope (do not do these)

  • Do NOT edit any file in B1's files: list.
  • Do NOT touch .convoys/** historical artifacts.
  • Do NOT touch components/Layout.js.backup, scripts/seed-collections-*.js, scripts/add-*.js, or scripts/fix-*.js (historical per no-go-zones).
  • Do NOT touch repo-name references (README cd / tree, AGENTS.md line 1, workflow YAML comments, .agent-context-manifest.yml source tags) — that's rename-repo-and-vercel-project.
  • Do NOT touch AGENTS.md (any line) — B1 owns line 5; doc-writer owns lines 1, 13, 53, and Gotcha #12.
  • Do NOT preempt AGENTS.md Gotcha #4 / #12 updates — doc-writer pass at convoy close.
  • Do NOT fix scripts/reset-db.js's CJS-in-ESM bug (line 142) or weak-password literals (lines 143, 156) — convert-reset-db-to-esm + purge-weak-creds-from-helpers scope.
  • Do NOT touch TESTING_GUIDE.md's weak password literals (admin123, alice123, bob123) — purge-weak-creds-from-helpers scope.
  • Do NOT add KV_REST_API_* env vars to test/setup.js. The warn-and-noop branch is the correct test behavior. The Redis prefix rename doesn't change this.
  • Do NOT bump @upstash/ratelimit or @upstash/redis versions. Pins stay at ^2.0.8 and ^1.38.0.
  • Do NOT add Lorcana to the <select> in pages/admin/card-import.js (that's the add-rate-limiting Brief 1's explicit out-of-scope; this convoy doesn't touch admin/card-import.js at all).
  • Do NOT add a "migrate-admin-email" npm script to package.json. The migration is invoked directly via node scripts/migrations/.... When a real migration tool lands (per AGENTS.md Gotcha #6), its CLI handles invocation.
  • Do NOT add visual baselines or update the smoke spec — the smoke spec is brand-agnostic; baselines are seeded by the queued seed-visual-baselines-on-linux convoy AFTER pick-a-name lands.
  • Do NOT preempt the doc-writer convoy-close work. The "As-shipped" section of .convoys/pick-a-name.md, the AGENTS.md gotcha edits, and the ship-readiness.md milestone entry are all post-merge doc-writer scope.

Rationale (≤3 sentences)

B2 is the higher-blast slice of the convoy: 11 files + 1 new migration script, ~30 net edits, with one externally-visible behavior change (Redis counter reset) and one operator-action-required step (run the migration script post-deploy). Splitting the email migration from B1's mechanical sweep keeps both briefs file-disjoint so the conductor can dispatch them in parallel via /multitask, AND co-locates every email + infrastructure ID change in one PR so the migration script + the seed-script email updates + the login.js quick-login fixtures ship atomically (preventing a deploy window where any of them is wrong). The migration script's idempotent UNIQUE-constraint-aware design + the README operator caveat together make the post-deploy operator action survivable even if executed in the wrong order.