---
convoy: pick-a-name
brief_number: 2
depends_on: []
files:
  - 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 1** — `AGENTS.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 1** — `checkAuthRateLimit(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:**

```js
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:**

```js
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:

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

  Expected: **5**.
- [ ] Zero remaining `tcgvault:` references in the lib body:

  ```bash
  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:**

```json
  "name": "tcg-vault",
```

**Replace with:**

```json
  "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):

  ```bash
  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:
```js
      VALUES (${'admin@tcgvault.com'}, ${hashedPassword}, ${'admin'})
```

Replace with:
```js
      VALUES (${'admin@deckhearth.com'}, ${hashedPassword}, ${'admin'})
```

**Edit 2 — Line 157 (console log):**

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

Replace with:
```js
    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 lines** — `reset-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:
```js
      VALUES (${'admin@tcgvault.com'}, ${hashedPassword}, ${'admin'})
```

Replace with:
```js
      VALUES (${'admin@deckhearth.com'}, ${hashedPassword}, ${'admin'})
```

**Edit 2 — Line 155 (console log):**

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

Replace with:
```js
    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):**
```js
      VALUES ('alice@tcgvault.com', ${alicePassword}, 'user')
```
→
```js
      VALUES ('alice@deckhearth.com', ${alicePassword}, 'user')
```

**Edit 2 — Line 21 (alice console log):**
```js
    console.log('✅ Created Alice (alice@tcgvault.com / alice123)');
```
→
```js
    console.log('✅ Created Alice (alice@deckhearth.com / alice123)');
```

**Edit 3 — Line 27 (bob INSERT):**
```js
      VALUES ('bob@tcgvault.com', ${bobPassword}, 'user')
```
→
```js
      VALUES ('bob@deckhearth.com', ${bobPassword}, 'user')
```

**Edit 4 — Line 30 (bob console log):**
```js
    console.log('✅ Created Bob (bob@tcgvault.com / bob123)');
```
→
```js
    console.log('✅ Created Bob (bob@deckhearth.com / bob123)');
```

**Edit 5 — Line 34 (summary admin line):**
```js
    console.log('   1. admin@tcgvault.com / admin123 (Admin)');
```
→
```js
    console.log('   1. admin@deckhearth.com / admin123 (Admin)');
```

**Edit 6 — Line 35 (summary alice line):**
```js
    console.log('   2. alice@tcgvault.com / alice123 (User)');
```
→
```js
    console.log('   2. alice@deckhearth.com / alice123 (User)');
```

**Edit 7 — Line 36 (summary bob line):**
```js
    console.log('   3. bob@tcgvault.com / bob123 (User)');
```
→
```js
    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-identical** — `purge-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-identical** — `create-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:
```js
                      onClick={() => handleQuickLogin('alice@tcgvault.com', 'alice123')}
```

Replace with:
```js
                      onClick={() => handleQuickLogin('alice@deckhearth.com', 'alice123')}
```

**Edit 2 — Line 184:**

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

Replace with:
```js
                      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 -.

### `test/lib/permission-middleware.test.js` (modified — line 87 negative regression assertion; architect-recommended PRESERVE the historical literal)

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:
```js
  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:
```js
  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:**

```js
#!/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):

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

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

  And:
  ```bash
  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):**

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

  Expected: **at most 1 match** — `test/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.

  ```bash
  rg "@tcgvault\.com" .convoys/
  ```

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

  ```bash
  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):**

  ```bash
  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:
  ```bash
  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):**
  ```bash
  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):**
  ```bash
  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:**
  ```bash
  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 -5` → `Tests  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.
