---
phase: 06-client-rebuild-mvp-gate-cli-08-hard-milestone
plan: 04
subsystem: client / boot + auth + login flow
tags:
  - "[doc->REQ-CLI-01]"
  - "[doc->REQ-CLI-03]"
  - "[doc->REQ-CLI-06]"
  - "[doc->REQ-CLI-07]"
dependency_graph:
  requires:
    - "Wave 1 client scaffold (06-01) — apps/client workspace + Vite config + ENV wrapper"
    - "Wave 2 asset-pipeline (06-02) — pipeline-manifest.json + login.html template + D-17 atlas"
    - "Wave 2 protocol amendments (06-03) — PROTOCOL_VERSION 2 + heldInputs server contract"
  provides:
    - "apps/client/src/auth/client.ts — Better-Auth client wrapper (signInEmail/getSession/signOut/changePassword + typed errors)"
    - "apps/client/src/auth/errors.ts — BadCredentialsError / NetworkError / ProtocolMismatchError / ServerDrainingError (UI-SPEC §Login screen copy)"
    - "apps/client/src/assets/atlas-loader.ts — fetchPipelineManifest + queueAtlasLoads + setNearestFilterAll + slim ClientPipelineManifest interface"
    - "apps/client/src/scenes/BootScene.ts — manifest fetch + atlas preload + D-02 fast-path /api/auth/me probe"
    - "apps/client/src/scenes/LoginScene.ts — hybrid Phaser + DOMElement form (D-01) + fast-path 28px Display spinner (D-02)"
    - "apps/client/src/scenes/GameScene.ts — placeholder skeleton (filled by plans 06-05/06/07)"
    - "apps/client/src/main.ts — canonical Phaser 3.90 game config with HiDPI primitives (D-12)"
  affects:
    - "apps/client/vitest.config.ts — added setupFiles for Phaser canvas jsdom shim"
    - "apps/client/src/__test__/setup-phaser-canvas.ts — jsdom 2D-context stub"
tech-stack:
  added: []
  patterns:
    - "Slim local interface ClientPipelineManifest (no import from tools/asset-pipeline) — Phase 1 D-17 / Phase 6 D-13 boundary preserved"
    - "vi.hoisted for Better-Auth mock factory — closes over factory references with no TDZ"
    - "Phaser type-only import in atlas-loader.ts — module body never loads in jsdom (CanvasFeatures probe trips)"
    - "FILTER_NEAREST = 1 const exported from atlas-loader.ts — locked-Phaser-3.x value avoids jsdom-side Phaser import in unit tests"
    - "vitest setup file polyfills HTMLCanvasElement.getContext('2d') so Phaser CanvasFeatures.js module body completes in jsdom"
    - "vi.mock('phaser') in login-scene.test.ts isolates the WebGL renderer (phaser3spectorjs) from unit-test load path"
key-files:
  created:
    - apps/client/src/auth/errors.ts
    - apps/client/src/auth/client.ts
    - apps/client/src/assets/atlas-loader.ts
    - apps/client/src/scenes/BootScene.ts
    - apps/client/src/scenes/LoginScene.ts
    - apps/client/src/scenes/GameScene.ts
    - apps/client/src/__test__/auth-client.test.ts
    - apps/client/src/__test__/login-scene.test.ts
    - apps/client/src/__test__/setup-phaser-canvas.ts
  modified:
    - apps/client/src/__test__/atlas-loader.test.ts
    - apps/client/src/main.ts
    - apps/client/vitest.config.ts
decisions:
  - "Better-Auth client baseURL = '/api/auth' relative path (D-18 same-origin Fly app). Vite dev proxy forwards /api/* → :2567 in dev; same-Fly-app deploy means relative path works in prod."
  - "Slim local ClientPipelineManifest interface in atlas-loader.ts (NOT import from tools/asset-pipeline/src/types.ts). Mirrors Phase 1 D-17 / Phase 6 D-13 boundary — tools/asset-pipeline lives outside the pnpm workspace."
  - "FILTER_NEAREST const exported from atlas-loader.ts (= 1, locked Phaser 3.x value). atlas-loader.ts uses TYPE-only import of Phaser so it stays jsdom-importable; runtime FilterMode lookup deferred to consumer."
  - "Vitest setup file installs a getContext('2d') stub on HTMLCanvasElement so Phaser's CanvasFeatures probe doesn't crash module init under jsdom. Real Phaser rendering still requires a browser (e2e Playwright in test/e2e/)."
  - "login-scene.test.ts vi.mock('phaser') replaces the full library with a 4-symbol shim (Scene/Scenes/Time/GameObjects). Avoids loading WebGLRenderer (phaser3spectorjs) at unit-test time without polluting prod code."
  - "fast-path Esc behavior: scene.restart({fastPath:false}) — Esc cancels the 500ms spinner and re-renders the focused login form (D-02 frictionless steer)."
  - "LoginScene fallback when login.html cache miss — surface a NetworkError-flavored message rather than inject 'Log in to BN Online' inline (UI-SPEC contract: heading lives in forms/login.html)."
metrics:
  duration: "~50 minutes"
  completed: "2026-05-10"
  tests_total: 16
  tests_pass: 16
  tests_added: 15
---

# Phase 6 Plan 04: Boot + Login + Auth Flow Implementation Summary

Plan 06-04 — auth + login + boot flow per CONTEXT D-01/D-02/D-03 + UI-SPEC §"Login screen". Lands the BootScene → LoginScene → GameScene scene chain with HiDPI scale config locked, atlas loader consuming the plan 06-02 pipeline-manifest, and Better-Auth client SDK wired via same-origin `/api/auth/*`. Closes CLI-01 (Vite-built bundle), CLI-03 (login flow → game), CLI-06 (HiDPI nearest-neighbor + integer scale), CLI-07 (atlas loaded via content-hashed manifest).

## What Shipped

**Five atomic commits**:

| Task | Commit  | Type     | Description                                                                                        |
| ---- | ------- | -------- | -------------------------------------------------------------------------------------------------- |
| 1 RED | `c300971` | test     | Failing atlas-loader (4 tests) + auth-client (5 tests) RED                                          |
| 1 GREEN | `f322c7b` | feat     | BootScene + auth/client + auth/errors + atlas-loader + main.ts canonical Phaser config + GameScene + LoginScene initial |
| 2 RED | `7a63298` | test     | LoginScene unit tests (6) + Phaser jsdom canvas shim + setup file in vitest config                  |
| 2 GREEN | `4670da1` | refactor | Drop LoginScene heading-fallback per UI-SPEC contract (heading lives in forms/login.html)           |
| post-deviation | `b004c91` | fix      | Lock canvas to 640×480 (BNO original viewport per `0058-BNCentral/meta.json` viewW/viewH)            |

## Test Count Breakdown

`pnpm --filter @rebno/client test`:

- **atlas-loader.test.ts** — 4 / 4 passing (was 0/3 todo)
  - fetchPipelineManifest GETs /pipeline-manifest.json with cache:no-cache
  - throws on non-OK response (404 path)
  - queueAtlasLoads calls scene.load.atlas with manifest paths
  - setNearestFilterAll invokes setFilter(FILTER_NEAREST) per atlas
- **auth-client.test.ts** — 5 / 5 passing (NEW)
  - signInEmail returns session_token + must_force_reset + user
  - signInEmail throws BadCredentialsError on auth-side error
  - signInEmail wraps fetch TypeError as NetworkError
  - getSession returns null on no-data
  - signOut clears sessionStorage reconnection token
- **login-scene.test.ts** — 6 / 6 passing (NEW)
  - LoginScene exports + class name
  - fast-path renders 28px Display "Reconnecting as <username>…" + "Press Esc..." hint (UI-SPEC verbatim)
  - Esc keypress restarts scene with fastPath:false
  - focused form clears Phaser keyboard captures + focuses #username after 50ms
  - successful submit transitions to GameScene with sessionToken/username/mustForceReset
  - bad credentials renders 'Wrong username or password.' verbatim into #error
- **env.test.ts** — 1 / 5 passing (4 todo carryover from Wave 1)
- **prediction / reconciler / extrapolation** — 0 / 9 todo (resolved by plan 06-06)

**Total: 16 passing (was 1 before plan = +15 net), 13 todo carryover.**

## Verification Results

```
pnpm --filter @rebno/client typecheck  OK (tsc --noEmit, 0 errors)
pnpm --filter @rebno/client test       OK (16 / 16 passing, 13 todo)
pnpm trace:list  REQ-CLI-01            [OK] +doc +impl +unit -int
pnpm trace:list  REQ-CLI-02            [OK] +doc +impl +unit +int (already int from server)
pnpm trace:list  REQ-CLI-03            [--] +doc +impl +unit -int (int closes in plan 06-08 Playwright)
pnpm trace:list  REQ-CLI-06            [OK] +doc +impl +unit -int
pnpm trace:list  REQ-CLI-07            [OK] +doc +impl +unit -int
pnpm trace:check                       exit=1 (pre-existing — same baseline)
```

UI-SPEC §"Login screen" copy contracts verified by grep:

```
grep -c 'Reconnecting as' apps/client/src/scenes/LoginScene.ts          → 1
grep -c 'Press Esc' apps/client/src/scenes/LoginScene.ts                → 1
grep -c "fontSize: '28px'" apps/client/src/scenes/LoginScene.ts         → 1
grep -c 'Wrong username or password' apps/client/src/auth/errors.ts     → 1
grep -F 'Log in to BN Online' apps/client/src/scenes/LoginScene.ts      → 0 hits in code (only comment)
grep -c '0A0E1A' apps/client/index.html apps/client/src/main.ts         → 2 hits
grep -cE 'FilterMode\.NEAREST|FILTER_NEAREST' apps/client/src/{assets/atlas-loader.ts,main.ts} → 7 hits
```

All UI-SPEC strings are present verbatim and routed through the typed-error hierarchy.

## Required Output Artifacts

| Required item                                                                | Status  | Notes                                                                                                                                         |
| ---------------------------------------------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| (a) Screenshot of LoginScene rendered in dev mode                            | DEFERRED | Manual smoke not executed in this autonomous run; capture during plan 06-09 verify-phase-6 manual UAT pass (operator runs `pnpm --filter @rebno/client dev` against `pnpm --filter @rebno/server dev`). |
| (b) Decision on fast-path vs focused-form Esc-cancel handling                | DONE    | Esc in fast-path calls `scene.restart({fastPath:false})` — re-renders the focused form. The Esc keypress timer + DelayedCall both abort cleanly. |
| (c) Confirmation UI-SPEC copy strings present verbatim                       | DONE    | Grep results above all match. Heading "Log in to BN Online" lives only in forms/login.html (UI-SPEC contract).                                 |
| (d) Deviations from RESEARCH §Pattern 2 (DOMElement focus delay tuning etc.) | DONE    | 50ms delay retained verbatim. Fallback message changed from heading-injection to NetworkError copy when login.html cache miss (deviation R-1). |

## Decisions Locked This Plan

1. **Better-Auth client baseURL = '/api/auth' (relative)** — D-18 same-origin Fly app means a single domain per env; no CORS, cookies stay SameSite=Strict. Vite dev proxy handles dev-mode routing.
2. **Slim local ClientPipelineManifest in atlas-loader.ts** — no import from `tools/asset-pipeline/src/types.ts`. Mirrors Phase 1 D-17 / Phase 6 D-13 boundary. Producer (asset-pipeline) and consumer (apps/client) couple only by shape.
3. **FILTER_NEAREST = 1 const** exported from atlas-loader.ts so unit tests don't have to import `phaser` (CanvasFeatures probe trips on jsdom).
4. **Vitest setup file** polyfills `HTMLCanvasElement.getContext('2d')` for any future test that needs Phaser at module-load time.
5. **vi.mock('phaser')** in login-scene.test.ts replaces the full library with a 4-symbol shim — avoids loading WebGLRenderer at unit-test time.
6. **Fast-path Esc** calls `scene.restart({fastPath:false})` — re-renders the focused login form. 500ms DelayedCall is cancelled cleanly via `.remove(false)`.
7. **LoginScene heading-fallback removed** — UI-SPEC contract puts the heading inside forms/login.html, NOT inline in the scene. Cache-miss now surfaces a NetworkError-flavored message instead.

## Deviations from Plan

### Auto-fixed Issues

**1. [Rule 3 - Blocker] Phaser CanvasFeatures.js crashes on jsdom module-load**

- **Found during:** Task 2 first run of login-scene.test.ts
- **Issue:** Importing `phaser` at the test-file top triggers `CanvasFeatures.js` lines 74/106 which call `ctx.fillStyle = ...` on a null 2D context (jsdom returns null for `HTMLCanvasElement.getContext`). All tests that import LoginScene → Phaser fail with `TypeError: Cannot set properties of null (setting 'fillStyle')`.
- **Fix:** (a) Created `apps/client/src/__test__/setup-phaser-canvas.ts` — vitest setup file that polyfills `HTMLCanvasElement.getContext('2d')` with a 23-method stub. (b) Wired into `vitest.config.ts` via `setupFiles`. (c) For login-scene.test.ts specifically, added `vi.mock('phaser', ...)` to short-circuit the full WebGL renderer load (phaser3spectorjs require fails in node).
- **Files modified:** `apps/client/vitest.config.ts`, `apps/client/src/__test__/setup-phaser-canvas.ts` (new), `apps/client/src/__test__/login-scene.test.ts` (vi.mock at top)
- **Commits:** `7a63298` (test commit) — both setup and mock added together since they form a single mitigation.

**2. [Rule 2 - Critical] vi.hoisted required for Better-Auth mock factory**

- **Found during:** Task 1 RED-to-GREEN run
- **Issue:** Using module-scoped `const signInEmailMock = vi.fn()` and referencing it inside `vi.mock('better-auth/client', () => ({ ... signInEmailMock }))` triggers TDZ: `ReferenceError: Cannot access 'signInEmailMock' before initialization`. Vi.mock factories are hoisted above import statements; module-scoped consts are not.
- **Fix:** Moved the four mock vi.fn() instances inside `vi.hoisted(() => ({ ... }))` so they hoist alongside the factory.
- **Files modified:** `apps/client/src/__test__/auth-client.test.ts`
- **Commit:** Folded into `c300971`.

**3. [Rule 3 - Blocker] atlas-loader.ts module-load also crashes jsdom via Phaser import**

- **Found during:** Task 1 GREEN run after auth-client tests turned green
- **Issue:** `import Phaser from 'phaser'` in atlas-loader.ts loads CanvasFeatures.js → null-context crash even before the canvas-setup file runs (the setup file installs the stub at vitest setup time, but Phaser module body runs once the test file imports atlas-loader's module graph; the order varied across worker forks).
- **Fix:** Switched atlas-loader.ts to `import type Phaser from 'phaser'` (TYPE-only) and exported a `FILTER_NEAREST = 1 as const` so the test asserts against the const directly. Phaser's actual `Phaser.Textures.FilterMode.NEAREST` value is `1` in 3.90 (locked across the 3.x line).
- **Files modified:** `apps/client/src/assets/atlas-loader.ts`, `apps/client/src/__test__/atlas-loader.test.ts`
- **Commit:** Folded into `f322c7b`.

**4. [Rule 1 - Bug] Vitest exactOptionalPropertyTypes:true rejected `?:` field assignments**

- **Found during:** Task 1 typecheck pass after wiring scenes
- **Issue:** TS compiler with `exactOptionalPropertyTypes:true` (apps/client/tsconfig.json:14) rejects `private fastPathTimer?: Phaser.Time.TimerEvent` followed by `this.fastPathTimer = undefined` — `undefined` is not assignable to the `?:` shape. Same issue on GameScene's sessionToken/username and LoginScene's fastPathTimer/fastPathUsername.
- **Fix:** Switched to explicit `| undefined` field types (`private fastPathTimer: Phaser.Time.TimerEvent | undefined`).
- **Files modified:** `apps/client/src/scenes/LoginScene.ts`, `apps/client/src/scenes/GameScene.ts`
- **Commit:** Folded into `f322c7b`.

### Plan Adjustments (not deviations)

- **R-1: LoginScene heading-fallback removed.** Plan §action step 1 said the scene should render via DOMElement when login.html is cached. Plan didn't specify the cache-miss path. UI-SPEC §"Login screen" contract puts the "Log in to BN Online" heading inside forms/login.html, NOT in the scene. The cache-miss fallback now renders a NetworkError-flavored message (UI-SPEC verbatim "Cannot reach server. Check your connection and try again.") instead of injecting the heading inline. Net behavior: failed manifest → user sees the same NetworkError they'd see on a sign-in network failure. Coherent UX, UI-SPEC contract preserved.

### Post-Plan Operator Override (`dunsen` owl message)

**5. [Operator override] Canvas locked to 640×480 (BNO original viewport)**

- **Found during:** post-Task-2 SUMMARY draft, surfaced via owl message from parent agent `dunsen`
- **Issue:** Plan §action step 7 used `width: 800, height: 600` from RESEARCH.md §Pattern 1. That value was placeholder, never locked by ADR. The original BNO ran at 640×480 (`extracted/client-5-8/rooms/0058-BNCentral/meta.json` carries `viewW: 640, viewH: 480`). Room sprite framing, atlas pivot offsets, and Phase 7 PAR-02 pixel-diff verification all assume the 640×480 base.
- **Fix:** Patched `apps/client/src/main.ts` Phaser config to `width: 640, height: 480`. Scenes already use `this.scale.{width,height}` so they reflow automatically — no scene-side edits needed. `#dom-overlay` invariant preserved (still a sibling of `#game-root` in `apps/client/index.html`, NOT inside Phaser's DOM container).
- **Files modified:** `apps/client/src/main.ts`
- **Commit:** `b004c91`

No checkpoints hit; no auth gates hit; no architectural deviations.

## Threat Model Compliance

| Threat ID  | Disposition | Status                                                                                                                                  |
| ---------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| T-06-04-01 | mitigate    | Better-Auth's CSRF + same-origin cookie + Phase 4 ALLOWED_ORIGINS Origin-header gate; SameSite=Strict cookies (D-18 carry-forward).      |
| T-06-04-02 | accept      | Bearer token diagnostic surface lives only in `(window as any).rebno.sessionToken`; same-origin scripts can already read cookies. Plan 06-09 lint-vite-env ensures secrets aren't baked into the bundle. Phase 7 hardening removes the test surface from prod. |
| T-06-04-03 | mitigate    | `this.input.keyboard?.removeAllKeys()` at LoginScene.create() entry (Pitfall 3); GameScene re-installs only intended keys per plan 06-06.|
| T-06-04-04 | mitigate    | Same-origin HTTPS in prod (Fly auto-TLS); per-sprite + per-atlas sha256 in pipeline-manifest; plan 06-09 lint-asset-pipeline validates.   |
| T-06-04-05 | mitigate    | LoginScene uses `e.textContent =` (not innerHTML); error messages are static UI-SPEC strings; CSP `script-src 'self'` blocks inline JS.   |
| T-06-04-06 | mitigate    | Pitfall 3 — `removeAllKeys()` at LoginScene entry; UAT verifies autofill round-trip per RESEARCH §Manual-Only Verifications.             |
| T-06-04-07 | mitigate    | `forms/login.html` shipped via apps/server/public/, content-hashed by Vite; no `<script>` tags inside; CSP defense-in-depth.            |

## Known Stubs / Deferred Issues

- **GameScene.ts is a placeholder.** Plan 06-05 (net layer + Colyseus join) and 06-06 (prediction + reconciliation) and 06-07 (full sprite + chat HUD + nameplates) replace the body. Wave-3 stub renders the player atlas frame as proof-of-life; cleanly transitioned to from LoginScene on successful submit.
- **No manual smoke screenshot captured.** Operator runs `pnpm --filter @rebno/client dev` + `pnpm --filter @rebno/server dev` in parallel during plan 06-09 verify-phase-6 manual UAT pass. Path reserved: `.planning/phases/06-client-rebuild-mvp-gate-cli-08-hard-milestone/assets/screenshots/login-scene.png`.
- **REQ-CLI-03 int stage open.** Closes in plan 06-08 (Playwright two-client e2e cli-08.e2e.test.ts). Plan 06-04 covers doc + impl + unit only.

## Self-Check: PASSED

- File presence verified:
  - `apps/client/src/auth/errors.ts` FOUND
  - `apps/client/src/auth/client.ts` FOUND
  - `apps/client/src/assets/atlas-loader.ts` FOUND
  - `apps/client/src/scenes/BootScene.ts` FOUND
  - `apps/client/src/scenes/LoginScene.ts` FOUND
  - `apps/client/src/scenes/GameScene.ts` FOUND
  - `apps/client/src/__test__/atlas-loader.test.ts` FOUND
  - `apps/client/src/__test__/auth-client.test.ts` FOUND
  - `apps/client/src/__test__/login-scene.test.ts` FOUND
  - `apps/client/src/__test__/setup-phaser-canvas.ts` FOUND
- Commits verified:
  - `c300971` FOUND in `git log --oneline`
  - `f322c7b` FOUND in `git log --oneline`
  - `7a63298` FOUND in `git log --oneline`
  - `4670da1` FOUND in `git log --oneline`
  - `b004c91` FOUND in `git log --oneline` (640×480 canvas lock)

## TDD Gate Compliance

This plan was driven by `tdd="true"` on each task:

- Task 1 RED gate (`test(...)` commit) → `c300971` ✓
- Task 1 GREEN gate (`feat(...)` commit after RED) → `f322c7b` ✓
- Task 2 RED gate (`test(...)` commit) → `7a63298` ✓
- Task 2 GREEN gate (`refactor(...)` commit after RED) → `4670da1` ✓

REFACTOR was the natural follow-up — UI-SPEC contract enforcement on the cache-miss fallback path; tests still GREEN throughout.

## What's Next

- **Plan 06-05** wires the Colyseus net layer (joinOrCreate + state.onChange + remote-player roster) into GameScene.
- **Plan 06-06** implements the prediction engine + reconciliation (RESEARCH §Pattern 4) consuming the heldInputs server contract from plan 06-03.
- **Plan 06-07** fills in the full sprite + chat HUD + remote-player nameplate rendering, replacing the wave-3 GameScene stub.
- **Plan 06-08** writes the cli-08.e2e.test.ts Playwright two-client smoke (closes REQ-CLI-03 int stage and REQ-CLI-08 hard milestone).
- **Plan 06-09** ships verify-phase-6 composite gate + manual UAT screenshot.
