---
phase: 04-server-rebuild-mvp
fixed_at: 2026-05-07T14:50:00Z
review_path: .planning/phases/04-server-rebuild-mvp/04-REVIEW.md
iteration: 1
findings_in_scope: 19
fixed: 19
skipped: 0
status: all_fixed
---

# Phase 4: Code Review Fix Report

**Fixed at:** 2026-05-07
**Source review:** `.planning/phases/04-server-rebuild-mvp/04-REVIEW.md`
**Iteration:** 1

**Summary:**
- Findings in scope: 19 (5 Blocker + 14 Warning)
- Fixed: 19
- Skipped: 0

**Verification approach:**
- Tier 2 (`pnpm --filter @rebno/server typecheck` = `tsc --noEmit`) ran clean after every commit.
- Tier 2 (`node --check`) ran clean on every modified `.mjs`.
- Targeted unit tests run where they directly exercise a fix (`log.test.ts` for WR-13: 2/2 passed; `verify-phase-4.test.mjs` for WR-14: 14 steps in canonical order).
- Pre-existing test failures (`admin-stubs.test.ts`, `persistence.test.ts` better-sqlite3 native-binding errors) are unrelated to these fixes — confirmed by `git diff main --stat` (taken before the cleanup-tail merge) showing zero changes to those source/test files. Those failures existed on the branch's pre-fix state and are out of scope for the fixer.

## Fixed Issues

### BL-01: Auth bypass active when NODE_ENV defaulted (production-deploy footgun)
**Files modified:** `apps/server/src/env.ts`, `apps/server/src/RebnoRoom.ts`, `apps/server/src/index.ts`
**Commit:** `1c36040`
**Applied fix:** Removed `NODE_ENV` and `BETTER_AUTH_SECRET` defaults from `envSchema` so a missing env fails-closed at boot. Tightened the `RebnoRoom.onAuth` dev-bypass branch to require `NODE_ENV === 'test'` (was `'development' || 'test'`). Added a fail-closed boot invariant that refuses to start production with a known dev-placeholder secret. Folded in **WR-02** (Math.random → crypto.randomUUID for the synthetic anon account_id) since it lives in the same code block.

### BL-02: SERVER_DRAINING broadcast races gracefullyShutdown
**Files modified:** `apps/server/src/sigterm.ts`
**Commit:** `ca080b5`
**Applied fix:** Inserted a flush barrier between (a*) broadcast and (b) `gracefullyShutdown` — `setImmediate` yield + 50ms sleep — so the WS transport drains queued frames before the close payload lands. Added `RunGraceOptions.skipDrainSleep` so tests can opt out for speed.

### BL-03: `/health` does not flip to `draining` during SIGTERM grace
**Files modified:** `apps/server/src/sigterm.ts`, `apps/server/src/index.ts`
**Commit:** `6d0fa61`
**Applied fix:** Made `healthState` mutable in `boot()`. Added `SigtermDeps.setDraining` callback fired at the very top of `runGraceShutdown`. `installSigtermHandler` flips status to `'draining'` + `ws_ready: false` so Fly's HTTP probe (≤ 5 s cadence) removes the machine from the LB pool before rooms tear down.

### BL-04: Rev sort breaks at rev 1000
**Files modified:** `apps/server/src/RoomRegistry.ts`, `tools/room-converter/cli.ts`, `tools/scripts/lint-room-layout.mjs`
**Commit:** `6e71fcb`
**Applied fix:** All three sites now `parseInt` the numeric prefix and sort numerically. `RoomRegistry.tryLoadLatest` and `cli.ts` (both `nextRev` and `cmdEdit`) accept any digit-prefixed `.json`. The `lint-room-layout` regex widened from `/^\d{3}$/` to `/^\d{3,}$/` (still rejects unpadded names but accepts ≥4-digit revs).
**Note:** Logic-bearing change (rev-counter math) — a follow-up smoke run at rev ≥ 1000 in a future Phase 7 PAR-03 environment is the conclusive verification. Type/syntax checks pass; no Phase 4 test reaches rev 1000 today (acceptable per ADR 0004 which marks the contract binding through Phase 7).

### BL-05: onLeave-timeout persistence failure silently drops player progress
**Files modified:** `apps/server/src/RebnoRoom.ts`
**Commit:** `a709105`
**Applied fix:** On `persistCharacter` failure during the onLeave-timeout path, drop `authBySession` (no live client to dispatch onMessage to) but KEEP the `PlayerState` in `this.state.players` so the next 30 s checkpoint enumeration / SIGTERM grace flush picks the snapshot up via `snapshotPlayers()`. Success path unchanged.
**Note:** Logic-bearing change (state-eviction semantics). Recommend an integration test that disk-fulls SQLite during onLeave-timeout (the reviewer suggested this) — deferred as it requires a fault-injection harness not present in Phase 4.

### WR-01: `Math.random()` for character row IDs
**Files modified:** `apps/server/src/persistence.ts`
**Commit:** `0dadd73`
**Applied fix:** `makeCharacterId()` now uses `node:crypto.randomUUID()` (CSPRNG-backed) — `'c' + uuid.replace(/-/g, '').slice(0, 23)` for cuid2-shaped IDs.

### WR-02: dev-bypass account_id collision via Math.random
**Files modified:** `apps/server/src/RebnoRoom.ts` (folded into BL-01)
**Commit:** `1c36040` (same commit as BL-01)
**Applied fix:** Synthetic anon account_id now uses `randomUUID().slice(0, 8)` instead of `Math.random().toString(36).slice(2, 10)`. Also gated to `NODE_ENV === 'test'` only (was development|test) per BL-01.

### WR-03: Account enumeration via deterministic legacy email synthesis
**Files modified:** `apps/server/src/legacy-login.ts`, `apps/server/src/index.ts`
**Commit:** `6b8aac4`
**Applied fix:** `legacyEmail()` is now exported and takes a secret argument; synthesises `legacy-<HMAC-SHA256(secret, lower(username))[:16]>@legacy.rebno.local`. Threaded `args.better_auth_secret` through `tryLegacyLogin` and the `index.ts` middleware rewrite path so the email matches what was stored.

### WR-04: Username case-collision creates legacy-account DoS
**Files modified:** `apps/server/src/legacy-login.ts` (combined with WR-03/WR-12)
**Commit:** `6b8aac4`
**Applied fix:** Race-recovery query changed from `WHERE username = ?` to `WHERE LOWER(username) = LOWER(?) OR email = ?` so the case-loser can recover by either case-folded username or the synthesised email.

### WR-05: `c2s` discriminated-union channel is an unrate-limited zod-DoS vector
**Files modified:** `apps/server/src/onMessageHandlers.ts`
**Commit:** `d33c103`
**Applied fix:** Dropped the catchall `c2s` zod parse entirely. The handler now logs a warn and discards the frame; clients must use the typed channels (`input`, `chat_send`, `heartbeat`, `room_join`) which all gate on `rateLimitOrDrop()` before zod.

### WR-06: No CORS / origin gate on Better-Auth — CSRF on /api/auth/sign-in/email
**Files modified:** `apps/server/src/env.ts`, `apps/server/src/auth.ts`, `apps/server/src/index.ts`
**Commit:** `fc42570`
**Applied fix:** Added `ALLOWED_ORIGINS` env var (comma-separated). Wired into `makeAuth()` as `trustedOrigins` (Better-Auth's built-in Origin check) AND a bespoke Express middleware that 403s missing/non-allowed Origin headers BEFORE the legacy-login pre-middleware. OPTIONS preflight returns 204 with `Access-Control-Allow-*` headers when the Origin matches. Production refuses to boot with empty `ALLOWED_ORIGINS`. Dev/test default to permissive.

### WR-07: ARGON2_OPTS duplicated across auth.ts and legacy-login.ts
**Files modified:** `apps/server/src/argon2-opts.ts` (NEW), `apps/server/src/auth.ts`, `apps/server/src/legacy-login.ts`
**Commit:** `bcf0060`
**Applied fix:** New `apps/server/src/argon2-opts.ts` module is the single source of truth. Both `auth.ts` and `legacy-login.ts` import `ARGON2_OPTS` from it. Drift is now structurally impossible.

### WR-08: SIGTERM handler `draining` flag never resets
**Files modified:** `apps/server/src/sigterm.ts`
**Commit:** `6a20957`
**Applied fix:** Replaced `let draining = false` closure with a `SigtermHandlerState` object. `installSigtermHandler` now returns `{ __resetForTests }` so test ergonomics are preserved. The no-op branch logs a warn (was silent) so future debug sessions can see ignored signals.

### WR-09: `bootstrapSchemaIfFresh` exec'd outside a transaction
**Files modified:** `apps/server/src/index.ts`
**Commit:** `94dc44e`
**Applied fix:** Changed `sqlite.exec(baseline)` to `sqlite.exec(\`BEGIN; ${baseline}; COMMIT;\`)` — atomic baseline-load. A mid-statement failure now rolls back the whole batch.

### WR-10: RoomRegistry debounce-Map fill
**Files modified:** `apps/server/src/RoomRegistry.ts`
**Commit:** `f5323e2`
**Applied fix:** Bounded `debounceTimers` Map at 1024 entries. On saturation, drops the watch event with a warn (one log per dropped event, not flooding). Existing room_ids debounce normally — the cap only triggers on NEW room_ids beyond the limit.

### WR-11: Default `BETTER_AUTH_SECRET` in source code is a published secret
**Files modified:** `apps/server/.env.example`
**Commit:** `8d106bd`
**Applied fix:** Replaced the `dev-only-32char-secret-change-me` literal with `GENERATE-WITH-openssl-rand-hex-32` — a placeholder that deliberately fails the `.min(32)` zod check. The BL-01 boot invariant additionally rejects the historical placeholder in production. Also documented the new `ALLOWED_ORIGINS` env var.

### WR-12: `legacy-login.ts` falls through to Better-Auth even after explicit auth failure (timing oracle)
**Files modified:** `apps/server/src/legacy-login.ts` (combined with WR-03/WR-04)
**Commit:** `6b8aac4`
**Applied fix:** When no staging row exists, `tryLegacyLogin` now runs a dummy `bcrypt.compare(password, '$2a$10$' + 'a'.repeat(53))` to equalise timing with the legitimate-row branch. The "tens of ms vs ~zero ms" oracle signal is collapsed.

### WR-13: Redacted log paths miss two- and three-level nesting
**Files modified:** `apps/server/src/log.ts`, `apps/server/test/log.test.ts`
**Commit:** `d33fae5`
**Applied fix:** Extended the redact list with `*.*.password`, `*.*.*.password` (and the same wildcard depth for every sensitive field) plus explicit `req.body.password` / `req.headers.authorization` paths. Extended the unit test to assert depth-2 and depth-3 password values are redacted; the test passes (`✓ 2 passed (2)`).

### WR-14: `verify-phase-4.mjs` runs `pnpm -r test` before any of the lint guards
**Files modified:** `scripts/verify-phase-4.mjs`, `scripts/verify-phase-4.test.mjs`
**Commit:** `f28bb29`
**Applied fix:** Reordered steps: cheap regex lints (protocol-sync, game-logic-purity, better-auth-schema-sync, rate-limit-budgets, no-clipboard-rce, room-layout, ADR 0004 lint) now run BETWEEN typecheck and Drizzle gates; Workspace test moved to second-to-last. First (Phase 3 carry-over) and last (Traceable-reqs check) anchors preserved per CLAUDE.md hard rule. Lockstep updated `EXPECTED_LABELS` in the test; smoke run confirms `verify-phase-4.test: OK (14 steps in canonical order)`.

## Skipped Issues

None — all 19 in-scope findings were fixed.

## Verification Status

- **Build/typecheck:** `pnpm --filter @rebno/server typecheck` PASSES (clean `tsc --noEmit`) after every commit.
- **Targeted tests:** `apps/server/test/log.test.ts` PASSES (2/2). `scripts/verify-phase-4.test.mjs` PASSES (14 steps in canonical order).
- **Out-of-scope test failures:** 7 pre-existing failures in `apps/server/test/admin-stubs.test.ts` (2) and `apps/server/test/persistence.test.ts` (5). The persistence failures are `better-sqlite3` native-binding load errors caused by `pnpm install` declining to run the better-sqlite3 build script in the fresh worktree (warning surfaced at install time: "Ignored build scripts: better-sqlite3@12.9.0"). The admin-stubs failures (`handleExecuteString` / `handleClipboardRun` / `handleModExecute` regex hits inside `// ...` line comments) reproduce on `main` HEAD against pre-fix code — confirmed by `git diff main --stat` showing zero changes to either of those source/test files.
- **Recommended next steps:** verifier phase should (a) run `pnpm install` followed by `pnpm approve-builds better-sqlite3 argon2 esbuild msgpackr-extract` so native bindings build, then re-run the full suite; (b) add an integration test that disk-fulls SQLite during onLeave-timeout (BL-05 follow-up); (c) add a `lint-argon2-opts.mjs` regex gate that asserts no module other than `argon2-opts.ts` declares the constants (WR-07 forcing function); (d) treat BL-04 (rev-1000 boundary) and BL-05 (state-eviction semantics) as logic-bearing fixes that warrant manual confirmation before phase sign-off.

---

_Fixed: 2026-05-07_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
