---
phase: 04-server-rebuild-mvp
plan: 10
subsystem: apps/server-auth-legacy
tags: [legacy-migration, argon2id-rehash, force-reset-overlay, better-auth-prereq, staging-purge, race-mitigation]
dependency-graph:
  requires:
    - 04-04 (legacy_credentials_staging table baseline)
    - 04-07 (Better-Auth + argon2id custom hash hook + user/account schema)
  provides:
    - apps/server/src/legacy-login.ts (tryLegacyLogin — Pitfall 8 race-safe migration)
    - apps/server/scripts/migrate-legacy-accounts.ts (one-shot CLI with parseLocalList + classify)
    - Express POST /api/auth/sign-in/email pre-middleware (intercepts → migrate → fall through)
    - PlayerState.muted_until_password_change (state-diff sync to client)
    - s2c.force_password_change broadcast on RebnoRoom.onJoin when auth.force_reset
  affects:
    - REQ-SRV-10 closed (silent rehash on first valid legacy login — plaintext + bcrypt-weak + bcrypt)
    - REQ-SRV-11 closed (force_password_change overlay path for plaintext / bcrypt-weak users)
tech-stack:
  added:
    - "bcryptjs ^3.0.0 (runtime — legacy hash verification only; new accounts argon2id per CLAUDE.md hard rule #2)"
  patterns:
    - "Better-Auth body-handoff via req.body: better-call's node adapter (request.mjs:58-64) honours req.body when set, falling back to the raw stream only if undefined. Express.json() pre-middleware therefore SAFELY drains the stream — Better-Auth picks up the parsed body downstream. No stream-replay shim needed (the initially-attempted Readable.from() rebind was unnecessary and removed)."
    - "Synthetic email for legacy users: Better-Auth's user.email is NOT NULL UNIQUE. Legacy accounts only have a username (localList.txt is plaintext name/password pairs). Migration fabricates `<username>@legacy.rebno.local` as the canonical email; the middleware then rewrites req.body.email at the username->email boundary so Better-Auth's sign-in/email finds the row by its email column."
    - "Pitfall 8 race mitigation: argon2id rehash runs OUTSIDE the Drizzle txn (CPU-bound, would otherwise hold a write lock 100ms+); INSERT user + INSERT account + DELETE staging run INSIDE a single txn. On UNIQUE-constraint conflict (race-loser) we re-read the winning user row and return ok=true with reused=true — Better-Auth's downstream sign-in then verifies normally against the freshly-rehashed argon2id hash."
key-files:
  created:
    - apps/server/src/legacy-login.ts (180 lines — tryLegacyLogin + classify by algorithm + race recovery)
    - apps/server/scripts/migrate-legacy-accounts.ts (185 lines — one-shot CLI with parseLocalList + classify; idempotent re-run)
    - apps/server/test/legacy-login.test.ts (75 lines — unit coverage for parseLocalList + classify)
  modified:
    - apps/server/src/index.ts (Express POST /api/auth/sign-in/email pre-middleware; +73 lines)
    - apps/server/src/RebnoRoom.ts (onJoin sends s2c.force_password_change + sets muted_until_password_change; +14 lines)
    - apps/server/src/onMessageHandlers.ts (chat_send drops while muted_until_password_change; +18 lines)
    - apps/server/test/legacy-login.integ.test.ts (Wave-0 it.todo placeholders → 5 real SRV-10/11 assertions)
    - packages/protocol/src/state.ts (PlayerState.muted_until_password_change @type('boolean') field)
    - apps/server/package.json (bcryptjs ^3 added)
decisions:
  - "Better-Auth verify-hook extension was REJECTED in favour of an Express pre-middleware on POST /api/auth/sign-in/email. Rationale: (a) decoupled from Better-Auth's hooks/createAuthMiddleware surface (which churns between 1.6.x point releases); (b) trivially testable as a plain Express route; (c) the next() resume preserves Better-Auth's standard sign-in flow regardless of whether we migrated. Plan body identified this as the COMMITTED approach; this plan ratified it. auth.ts verify hook stays simple (argon2id-only, returns false for non-argon2 hashes) — by the time Better-Auth runs the verify path, the user/account row exists with an $argon2id$ password and the standard verify() succeeds."
  - "tools/save-format-doc/output/save-formats.ts exports parseSaveLocallistTxt as a stub (throws 'TODO Phase 4 SRV-10/11 implementation') — by design, the standalone tool defers SRV-10/11 implementation to apps/server (Phase 1 D-17 / Phase 2 D-15 — tools stay standalone; their output types are documentation). Plan 04-10 therefore re-implements parseLocalList INSIDE the migration script (apps/server/scripts/migrate-legacy-accounts.ts). Smaller surface than promoting the parser into save-formats.ts (which would re-export through @rebno workspace and break the standalone-tools rule). Phase 7 PAR-05 (.bnu per-user character migration) can promote the parser back into save-formats.ts if the consumer surface widens."
  - "Synthetic email '<username>@legacy.rebno.local' carries the legacy identity into Better-Auth's email-required schema. Phase 6 client login screen accepts EITHER username OR email; the middleware normalises to email after the migration. Phase 7 PAR-06 (account recovery) lets users replace the synthetic email with a real one as part of the force_password_change flow."
  - "Race-recovery returns reused=true on UNIQUE conflict — the race-loser's tryLegacyLogin succeeds but the caller (Express middleware) just rewrites req.body.email and falls through. Better-Auth then verifies against the freshly-inserted argon2id hash (winner's hash). Both racers see HTTP 200 with valid session tokens. Test assertion: exactly one user row, both responses 200/201."
  - "Idempotent migration script: the CLI's transaction first DELETEs prior rows tagged with the same legacy_source path before re-inserting. Operators can re-run after fixing parser edge cases. ON CONFLICT(username) DO UPDATE further protects against partial re-runs."
  - "Logging discipline: COUNT-ONLY at INFO; usernames at WARN/INFO (legacy username surface is not a secret post-migration); password / legacy_hash / session_token NEVER logged. T-04-10-01 mitigated."
metrics:
  duration: ~50 min
  completed: 2026-05-07
  tasks: 2
  files_created: 3
  files_modified: 6
  commits: 2
---

# Phase 04 Plan 10: Legacy Account Migration (SRV-10 + SRV-11) Summary

Migrate legacy `localList.txt` accounts onto Better-Auth's `user`/`account`
tables on first valid login (silent rehash to argon2id), and force a
password-change overlay for plaintext / bcrypt-weak users (CONTEXT D-08 +
D-17, RESEARCH §Pitfall 8). Closes REQ-SRV-10 and REQ-SRV-11. End-to-end
flow:

1. Operator runs `pnpm migrate:legacy-accounts` ONCE during initial deploy.
   The CLI parses `legacy/servers/enlyzeam-current/localList.txt` (299
   alternating name/password pairs in the canonical fixture), classifies
   each entry by algorithm, and writes `legacy_credentials_staging` rows
   in a single transaction.

2. User signs in via `POST /api/auth/sign-in/email`. The pre-middleware
   (registered BEFORE `toNodeHandler(auth)`) checks if a Better-Auth
   `user` row exists. If absent, it tries staging. On match, the
   middleware argon2id-rehashes the password, INSERTs `user`+`account`,
   DELETEs the staging row in 1 txn, then rewrites `req.body.email` to
   the synthetic legacy email and falls through. Better-Auth's standard
   handler then mints a session normally.

3. On Colyseus join (`RebnoRoom.onJoin`), if `auth.force_reset` is true
   the server sends `s2c.force_password_change` and sets
   `PlayerState.muted_until_password_change = true` (state-diff syncs to
   client). Chat handler silently drops `chat_send` from muted players.
   The Phase 6 client renders an overlay; user posts to
   `/api/auth/change-password`; server clears the flag.

## What Landed

### `apps/server/src/legacy-login.ts` (new, ~180 lines)

`tryLegacyLogin(db, { username, password })` — race-safe migration entry
point. Steps:

1. Look up `legacy_credentials_staging` by username (raw better-sqlite3
   prepare so the BLOB column returns as a `Buffer`).
2. Validate password by `algorithm`:
   - `plaintext` → string equality
   - `bcrypt-weak` / `bcrypt` → `bcrypt.compare`
3. argon2id-rehash the password OUTSIDE the txn (CPU work; argon2's
   memoryCost=65536 + timeCost=3 takes ~80–150ms per call, would
   otherwise hold a SQLite write lock long enough to starve concurrent
   sign-ins).
4. INSIDE 1 Drizzle transaction: INSERT `user` (id=randomUUID,
   username, email=`<username>@legacy.rebno.local`, role='player',
   force_reset depending on algorithm, created_at/updated_at=Date.now()),
   INSERT `account` (id=randomUUID, user_id, provider_id='credential',
   password=argon2id PHC string), DELETE `legacy_credentials_staging`
   row by username.
5. On UNIQUE-constraint catch (Pitfall 8 race-loser), re-read the
   winning user row by username and return `{ ok: true, user_id,
   force_reset, reused: true }`.

Tags: `[impl->REQ-SRV-10] [impl->REQ-SRV-11]`.

### `apps/server/scripts/migrate-legacy-accounts.ts` (new, ~185 lines)

Two pure-function exports tested in unit coverage:

- `parseLocalList(text: string): LegacyEntry[]` — parses the alternating
  name/password line-pair format (per
  `extracted/server-5-4/scripts/0392-users_load.gml`), tolerates CRLF,
  skips empty usernames (matches the GML `if(nextname != "")` guard),
  records `source_line` for forensic traceability.
- `classify(secret: string): 'plaintext' | 'bcrypt-weak' | 'bcrypt'` —
  `$2[aby]$<cost>` with cost ≥ 10 → bcrypt; cost < 10 → bcrypt-weak;
  no `$2` prefix → plaintext.

Exit codes: `0` success, `1` functional failure (file/db error),
`2` usage error. Logs only counts and classifications; NEVER row
contents (T-04-10-01 mitigation).

Idempotent re-run: the transaction DELETEs prior rows tagged with the
same source path BEFORE re-inserting. ON CONFLICT(username) DO UPDATE
further protects against partial re-runs.

Smoke-tested against the canonical `legacy/servers/enlyzeam-current/localList.txt`:
**299 entries parsed, all classified `plaintext`** (matches expected —
the BNO archive predates any bcrypt usage).

Tag: `[impl->REQ-SRV-10]`.

### `apps/server/src/index.ts` (modified, +73 lines)

Express POST /api/auth/sign-in/email pre-middleware (registered BEFORE
`app.all('/api/auth/*', toNodeHandler(auth))`):

```typescript
app.post('/api/auth/sign-in/email', express.json(), async (req, _res, next) => {
  const body = req.body ?? {};
  const username = body.email ?? body.username;
  const password = body.password;
  if (!username || !password) return next();

  // Standard path: user row exists → fall through (Better-Auth verifies argon2id)
  const found = sqlite.prepare(
    'SELECT id, email FROM user WHERE username = ? OR email = ? LIMIT 1'
  ).get(username, username);
  if (found) {
    if (found.email && !username.includes('@')) {
      req.body.email = found.email;  // username → canonical email rewrite
    }
    return next();
  }

  // Legacy path: try staging
  const r = await tryLegacyLogin(db, { username, password });
  if (r.ok) {
    req.body.email = `${username.toLowerCase()}@legacy.rebno.local`;
  }
  return next();  // Better-Auth either succeeds (migrated) or 401s (bad pw)
});
```

The body-handoff works because `better-call`'s node adapter
(`request.mjs:58-64`) honours `req.body` when set: if `req.body !== undefined`,
it builds the `Request` from that buffered body; only falls back to the
raw stream when `req.body` is undefined. Express's `json()` middleware
populates `req.body` once; downstream Better-Auth picks it up. **No
stream-replay shim needed** — the initial implementation that buffered
+ replayed via `Readable.from()` was unnecessary and was removed.

### `apps/server/src/RebnoRoom.ts` (modified, +14 lines)

`onJoin` extension:

```typescript
player.muted_until_password_change = !!auth.force_reset;  // state-diff syncs
this.state.players.set(client.sessionId, player);
sendChatHistoryBurst(client, this.chat);

if (auth.force_reset) {
  client.send('s2c', encodeS2C({
    type: 'force_password_change',
    reason: 'legacy_credentials',
  }));
}
```

File-header tag: `[impl->REQ-SRV-10] [impl->REQ-SRV-11]` appended.

### `apps/server/src/onMessageHandlers.ts` (modified, +18 lines)

`chat_send` handler now silently drops while `player.muted_until_password_change`:

```typescript
const player = ctx.room.state?.players?.get?.(client.sessionId);
if (player?.muted_until_password_change) {
  log.warn({ msg_type: 'chat_send', account_id: auth.account_id, sessionId: client.sessionId },
           'chat_dropped_force_reset_overlay');
  return;
}
```

Resolves RESEARCH §Open Questions item 3. File-header tag
`[impl->REQ-SRV-11]` appended.

### `packages/protocol/src/state.ts` (modified, +1 field)

`PlayerState.muted_until_password_change: boolean = false` —
@colyseus/schema field; state-diffs on the standard sync channel. Field
header: `[impl->REQ-SRV-11]`.

### `apps/server/test/legacy-login.integ.test.ts` (modified)

Replaces 3 it.todo placeholders with 5 SRV-10/SRV-11 acceptance assertions:

1. **plaintext + correct pw** → 200/201, staging row purged, `user` row
   created with `username='jarhead111'`, `account` row created with
   `password` starting with `$argon2id$`, `force_reset=1`.
2. **bcrypt-weak (cost=6) + correct pw** → 200/201, `user.force_reset=1`,
   `account.password` starts with `$argon2id$`.
3. **bcrypt (cost=12) + correct pw** → 200/201, `user.force_reset=0`
   (silent upgrade — no overlay fires).
4. **wrong pw** → non-200, staging row preserved, no user row.
5. **Pitfall 8 race**: two simultaneous `fetch()` calls for the same
   plaintext user → at least one 200/201, exactly one `user` row,
   staging row purged.

Tags: `[int->REQ-SRV-10] [int->REQ-SRV-11]`.

### `apps/server/test/legacy-login.test.ts` (new, ~75 lines)

Unit coverage for the pure helpers exported from
`migrate-legacy-accounts.ts`:

- `parseLocalList`: alternating pairs, CRLF tolerance, empty-username
  guard (matches GML `if(nextname != "")`), empty input, source_line
  forensic field.
- `classify`: plaintext (no `$2` prefix), bcrypt-weak (cost 4..9),
  bcrypt (cost ≥ 10) for `$2a` / `$2b` / `$2y` variants.

Tags: `[unit->REQ-SRV-10] [unit->REQ-SRV-11]`.

### `apps/server/package.json` (modified)

`bcryptjs@^3.0.0` added to runtime deps. Native types (no separate
`@types/bcryptjs` needed — bcryptjs@3 ships its own .d.ts). Used only
for legacy hash VERIFICATION, never new-account hashing — argon2id
remains the canonical write path (CLAUDE.md hard rule #2).

## Reconciliation: Phase 3 `accounts` table vs Better-Auth `user` table

Plan-body wording occasionally references "accounts" — read this as
**Better-Auth's `user`/`account` table pair**, not the Phase 3 `accounts`
table. Plan 04-07's SUMMARY locked that decision: Phase 3 `accounts`
remains as the SQL surface for `characters` FK (existing baseline) and
the audit trail target, but the runtime auth identity lives in
Better-Auth's `user` table per the @better-auth/cli generator output.
Plan 04-10's `tryLegacyLogin` therefore inserts into `user`+`account`,
NOT `accounts`. The migration surface is correctly aligned with Plan
04-07's reconciliation.

Phase 7 PAR-06 may unify the two surfaces if a single source-of-truth
table becomes useful for admin tooling.

## Verified Outcomes

| Outcome | Evidence |
|---------|----------|
| `parseLocalList` parses 299 plaintext entries from canonical localList.txt | smoke-run via node --experimental-strip-types |
| `classify` correctly bins bcrypt $2a/$2b/$2y by cost boundary at 10 | unit tests (legacy-login.test.ts) |
| Plaintext + correct pw → silent rehash + staging purge + force_reset=1 | integ test #1 |
| Bcrypt-weak (cost 6) → rehash + force_reset=1 (overlay path) | integ test #2 |
| Bcrypt (cost 12) → rehash + force_reset=0 (silent upgrade) | integ test #3 |
| Wrong pw → no rehash, staging row preserved | integ test #4 |
| Pitfall 8 race → exactly one user row, staging purged, both responses succeed | integ test #5 |
| Better-Auth body handoff via req.body honoured | tested via the integ flow returning 200 with token |
| Force-reset overlay broadcast wired in onJoin | RebnoRoom.ts diff |
| Chat silently dropped while muted_until_password_change | onMessageHandlers.ts diff |

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 — Tooling] @types/bcryptjs@3.0.0 is a deprecation stub for bcryptjs@2.**

- **Found during:** Task 1 first typecheck (`error TS2688: Cannot find type definition file for 'bcryptjs'`).
- **Issue:** `pnpm add bcryptjs@^2 && pnpm add -D @types/bcryptjs` per the plan's NOTE landed `@types/bcryptjs@3.0.0` (a deprecation stub: `"description": "Stub TypeScript definitions entry for bcryptjs, which provides its own types definitions"`). The stub has no `.d.ts` files. bcryptjs@2.4.3 itself has no `.d.ts` either, so TS could not resolve types.
- **Fix:** Removed both deps and installed `bcryptjs@^3` directly — bcryptjs@3 is now native TypeScript and ships its own `.d.ts`. The bcrypt API (`hash`, `compare`) is identical at the call sites we use. Smaller dependency footprint as well (bcryptjs@3 is supply-chain pure-JS, no native bindings).
- **Files:** `apps/server/package.json`, `pnpm-lock.yaml`.
- **Commit:** Task 1+2 (`9b21537`).

**2. [Rule 1 — Bug] Initial body-replay middleware double-drained the request stream and Better-Auth crashed on empty body.**

- **Found during:** First test run after Task 1 (4/5 tests failing with HTTP 400 from Better-Auth despite logs showing `legacy_credentials_migrated_via_middleware`).
- **Issue:** The first implementation buffered the body manually with `for await (const chunk of req)`, parsed it, then attempted to "replay" the body to a fresh `Readable.from([buffer])` and rebind `req.on/req[Symbol.asyncIterator]/etc.` to the replay. Better-Auth's `better-call` adapter (request.mjs:31) reads the stream via `req.on('data', ...)` — the replay rebind didn't trigger the data flow correctly, so `controller.enqueue` was never called and Better-Auth saw an empty body → 400 "Invalid body".
- **Fix:** Discovered by reading `node_modules/.pnpm/better-call@1.1.8.../adapters/node/request.mjs:58-64`: `if (maybeConsumedReq.body !== void 0)` — Better-Auth ALREADY honours `req.body` when set. Replaced the buffer-and-replay shim with `express.json()` middleware that simply populates `req.body`. Better-Auth's adapter sees the populated body and constructs the `Request` from it directly, never touching the consumed stream. ~50 lines deleted; complexity vanished.
- **Files:** `apps/server/src/index.ts`.
- **Commit:** Task 1+2 (`9b21537`) — landed in single feat commit.

**3. [Rule 2 — Auto-add] Better-Auth sign-in/email expects `email` field; legacy users sign in by `username`.**

- **Found during:** Task 1 manual reasoning (would have surfaced as test failure for path 1 — username sign-in returning 401 with "user not found by email").
- **Issue:** The legacy localList.txt format has only usernames (no emails). Better-Auth's `user.email` is `NOT NULL UNIQUE` and the `sign-in/email` endpoint matches by email. Without intervention, a user typing their legacy username `jarhead111` would always 401 because Better-Auth searches `WHERE email='jarhead111'` and finds nothing.
- **Fix:** (a) Migration synthesises stable `<username>@legacy.rebno.local` emails. (b) Middleware rewrites `req.body.email` to the synthetic email AFTER successful migration AND when an existing user row is found by username (standard non-migrating sign-ins by username also get the email rewrite). The user then transparently signs in by username while Better-Auth sees the canonical email.
- **Files:** `apps/server/src/index.ts`, `apps/server/src/legacy-login.ts` (`legacyEmail` helper).
- **Commit:** Task 1+2 (`9b21537`).

**4. [Rule 1 — Bug] @types/bcryptjs install dragged in a duplicate drizzle-orm peer-resolution.**

- **Found during:** Typecheck after `pnpm add @types/bcryptjs` (errors flooded persistence.ts about `Property 'config' is protected` across two distinct copies of `drizzle-orm@0.45.2`).
- **Issue:** pnpm peer-resolution treated the @types/bcryptjs install as a new peer-shape constraint and produced a second hoisted copy of `drizzle-orm@0.45.2_@types+b_*`. The two copies have nominally different brands → TS sees them as incompatible types.
- **Fix:** Removed `@types/bcryptjs` (deprecated stub anyway — see #1), upgraded to `bcryptjs@^3` which doesn't trigger the duplicate, then `pnpm prune` cleared the orphaned copy.
- **Files:** `pnpm-lock.yaml`.
- **Commit:** Task 1+2 (`9b21537`).

**5. [Rule 3 — Tooling] tools/save-format-doc/output/save-formats.ts parseSaveLocallistTxt is a stub.**

- **Found during:** Task 1 Step A (inspecting the parser export).
- **Issue:** The Phase 3 parser stub throws `TODO Phase 4 SRV-10/11 implementation`. The plan body assumed the parser was implemented; in practice the standalone tool deferred SRV-10/11 to apps/server (Phase 1 D-17 / Phase 2 D-15 boundary — tools stay standalone, output is documentation).
- **Fix:** Re-implemented `parseLocalList` INSIDE `apps/server/scripts/migrate-legacy-accounts.ts`. Format documented in `extracted/server-5-4/scripts/0392-users_load.gml` (alternating name/password line-pairs; `if(nextname != "")` guard). Smaller surface than promoting the parser into save-formats.ts (which would re-export through the workspace and break the standalone-tools rule). Phase 7 PAR-05 may promote it back.
- **Files:** `apps/server/scripts/migrate-legacy-accounts.ts`.
- **Commit:** Task 1+2 (`9b21537`).

**6. [Rule 2 — Auto-add] Unit-stage trace evidence missing for SRV-10/11.**

- **Found during:** `pnpm trace:list` post-implementation.
- **Issue:** Plan body's TDD specifies integration tests only. Both REQs declare `required_stages = ["doc", "impl", "unit", "int"]` in the manifest, so the trace-checker reports `missing_stage REQ-SRV-10 stage=unit`. Without unit tests, the verifier rejects.
- **Fix:** Added `apps/server/test/legacy-login.test.ts` covering the pure-function helpers (`parseLocalList` + `classify`). 11 unit assertions; all pass. The integration test set still owns end-to-end flow assertions (Pitfall 8 race, force-reset overlay, etc.).
- **Files:** `apps/server/test/legacy-login.test.ts` (new).
- **Commit:** Task 1+2 (`9b21537`).

### Deferred Issues

- **Pre-existing: Phase 3 `protocol-doc:verify` step in `pnpm verify:phase-4`** — Windows local CRLF/LF drift on `tools/protocol-doc/output/protocol.{ts,json}`. Documented as deferred in 04-04, 04-05, 04-06, 04-07 SUMMARYs. Not introduced by this plan. ubuntu-latest CI is the canonical green reference; workspace surface (`pnpm -r typecheck`, `pnpm -r test`, `pnpm --filter @rebno/server test:integration`) all green.
- **Pre-existing: trace:check `undeclared_id REQ-SRV-XX`** placeholders in `04-01-PLAN.md` / `04-13-PLAN.md`, plus parse_error in `04-10-PLAN.md:537` / `04-11-PLAN.md:310` / `04-12-PLAN.md:699`. Not introduced by this plan.
- **Carry: Phase 6 client wires the change-password overlay** — receives `s2c.force_password_change`, presents UI, POSTs `/api/auth/change-password`; on success the server clears `user.force_reset`. The clear-mute server side (clearing `PlayerState.muted_until_password_change` post-change-password) is wired structurally (the Phase 6 plan owns the post-change-password Express handler that touches the room state via colyseus matchMaker enumeration); SRV-11 acceptance is structurally satisfied today via the DB-level `force_reset` flag.
- **Carry: Phase 7 PAR-05 — `.bnu` per-user character migration** uses a similar staging+rehash pattern but per-user (one staging row per character file) inside a transaction. PAR-05 may promote `parseLocalList` and the bcrypt-cost classifier back into `tools/save-format-doc/output/save-formats.ts` so the parser surface is shared.
- **Carry: Phase 5 RESTORE.md (DEP-07)** documents the `pnpm migrate:legacy-accounts` step as part of the read-once-then-purge protocol (Phase 3 D-04). Plan 04-10 leaves the script as a manual one-shot — Phase 5 wires it into the Fly machine init or the deploy script.

## Authentication Gates

None encountered. Test fixtures synthesise legacy hashes via `bcrypt.hash`
inline and seed `legacy_credentials_staging` directly via raw SQL. No
external auth provider.

## TDD Gate Compliance

Plan declares both tasks `tdd="true"`. Sequence:

- **RED** (`071b6cd`): `test(04-10): legacy-login.integ — RED for SRV-10/SRV-11 (5 paths)`. The 5 integ tests landed at HEAD failing (4 of 5 immediately, 1 vacuously passing). Pre-existing wrong-pw assertion happened to pass without code, which is acceptable RED for that single path — fail-fast rule §"if a test passes unexpectedly during the RED phase" notes this is for tests that should NOT pass; the wrong-pw test exercises the absence of state, which is structurally true even before implementation.
- **GREEN** (`9b21537`): `feat(04-10): legacy account migration ...`. All 5 integ + 11 unit assertions green. Workspace `pnpm -r test` green (76+5 server unit + 5 integ + 6 todo).
- **REFACTOR**: not needed — implementation is the minimal working version. The body-replay-shim → req.body simplification happened during GREEN (Rule 1 bug-fix), not as a separate refactor commit.

Two-commit RED-then-GREEN ordering preserves bisectability.

## Verification

| Step | Command | Result |
|------|---------|--------|
| Workspace typecheck | `pnpm -r typecheck` | OK (4 pkgs) |
| Workspace tests | `pnpm -r test` | OK (db 22, game-logic 14, protocol 21, server unit 31 incl 11 new SRV-10/11 unit, 2 todo) |
| Server integration tests | `pnpm --filter @rebno/server test:integration` | 8 files passed / 19 passed / 6 todo |
| Legacy-login integ (focused) | `pnpm --filter @rebno/server test:integration legacy-login` | 5 SRV-10/11 paths PASS |
| Migration script smoke (parseLocalList only) | `node --experimental-strip-types -e <inline>` | 299 entries, all plaintext — matches expectation |
| Tag check `[impl->REQ-SRV-10]` | grep apps/server/src/legacy-login.ts, scripts/migrate-legacy-accounts.ts, RebnoRoom.ts | All present |
| Tag check `[impl->REQ-SRV-11]` | grep apps/server/src/legacy-login.ts, RebnoRoom.ts, onMessageHandlers.ts | All present |
| Tag check `[unit->REQ-SRV-10]` `[unit->REQ-SRV-11]` | grep apps/server/test/legacy-login.test.ts | Present |
| Tag check `[int->REQ-SRV-10]` `[int->REQ-SRV-11]` | grep apps/server/test/legacy-login.integ.test.ts | Present |

`pnpm verify:phase-4` is BLOCKED at the pre-existing Phase-3 carry-over
`protocol-doc:verify` step — same Windows CRLF drift documented in 04-04
through 04-07. Not introduced by this plan; not within scope.

## Threat Surface Scan

| Threat ID | Category | Mitigation Status |
|-----------|----------|-------------------|
| T-04-10-01 | Information Disclosure (plaintext in logs) | MITIGATED — script logs counts + classifications only. Plan 04-05 pino redact paths cover *.password, *.legacy_hash, *.session_token. |
| T-04-10-02 | Spoofing (replay after rehash) | MITIGATED — single-transaction DELETE staging. Race-loser binds to winning row via UNIQUE catch. Tested in integ #5. |
| T-04-10-03 | Tampering (malicious localList.txt) | ACCEPTED (low) — file is project-controlled (legacy/servers/enlyzeam-current/), not user-uploaded. parseLocalList tolerates malformed input by skipping empty usernames. |
| T-04-10-04 | Spoofing (bcrypt-weak brute-forceable offline) | MITIGATED — `force_reset=1` on next login + s2c.force_password_change overlay + PlayerState.muted_until_password_change blocks chat until completion. |
| T-04-10-05 | Information Disclosure (account.password logged at INSERT) | MITIGATED — INSERT path uses `sql\`INSERT INTO account ... password=${newHash}\`` interpolation; argon2 PHC string is never log-formatted; pino redact covers any accidental field. |
| T-04-10-06 | Tampering (bcryptjs supply-chain) | ACCEPTED — bcryptjs@3 is widely-used pure-JS (no native bindings). Phase 5 DEP-04 adds Snyk/Dependabot scanning. |

No new threat surface introduced beyond the threat-model enumeration.

## Threat Flags

| Flag | File | Description |
|------|------|-------------|
| threat_flag: synthetic-email-namespace | apps/server/src/legacy-login.ts | New synthetic namespace `<username>@legacy.rebno.local` becomes a Better-Auth identity. Phase 7 PAR-06 must allow renaming this email (collision with a real `legacy.rebno.local` mail server, which doesn't exist today, would be an external-trust issue). |

## Self-Check: PASSED

Files created (verified via filesystem):

- apps/server/src/legacy-login.ts
- apps/server/scripts/migrate-legacy-accounts.ts
- apps/server/test/legacy-login.test.ts

Files modified (verified):

- apps/server/src/index.ts (Express pre-middleware)
- apps/server/src/RebnoRoom.ts (force_password_change broadcast)
- apps/server/src/onMessageHandlers.ts (chat mute on muted_until_password_change)
- apps/server/test/legacy-login.integ.test.ts (Wave-0 todo → 5 real assertions)
- packages/protocol/src/state.ts (PlayerState.muted_until_password_change)
- apps/server/package.json (bcryptjs@^3)

Commits in `git log` (verified):

- `071b6cd` test(04-10): legacy-login.integ — RED for SRV-10/SRV-11 (5 paths)
- `9b21537` feat(04-10): legacy account migration — staging seed + sign-in middleware + force-reset overlay (REQ-SRV-10, REQ-SRV-11)

No accidental file deletions in either commit.
