diff --git a/docs/superpowers/plans/2026-05-27-auth-system.md b/docs/superpowers/plans/2026-05-27-auth-system.md new file mode 100644 index 0000000..b294a3a --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-auth-system.md @@ -0,0 +1,2888 @@ +# Dragonflight Auth System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire a working end-to-end auth system into Dragonflight (mam-api + web-ui) so `AUTH_ENABLED=true` produces a real login that survives reloads, instead of the redirect loop that the prior attempts produced. + +**Architecture:** Hand-rolled `express-session` + `connect-pg-simple` for browser sessions, SHA-256-hashed bearer tokens (existing `api_tokens` table) for the Premiere panel, one `requireAuth` middleware that handles both transports, a single `` React component in the SPA that owns "logged in or not" state. Flat permissions (logged in = full access). First admin via a setup screen on the empty `users` table. + +**Tech Stack:** Node 22 (`express` 4, `express-session` 1.17, `connect-pg-simple` 9, `bcrypt` 5, `pg` 8 — all already in `services/mam-api/package.json`), Node native `node:test` for the test runner (no new deps), React 18 + esbuild-built JSX for the SPA. + +**Source spec:** `docs/superpowers/specs/2026-05-27-auth-system-design.md` + +**Working branch:** `design/auth-system` (already exists on the remote, clone at `/tmp/dragonflight-spec`). Implementer should branch from there: `git checkout -b feat/auth-system origin/design/auth-system`. + +**Test convention:** `node --test test/**/*.test.js` from `services/mam-api/`. Integration tests require a `TEST_DATABASE_URL` env var pointing at a throwaway Postgres (e.g. `postgres://wilddragon:test@localhost:5432/wilddragon_test`); tests that need DB will `skip` with a clear message when the env var is missing rather than fail the suite. + +**Commit cadence:** every task ends with one commit. Frequent commits, not heroic batches. + +--- + +## File map + +**`services/mam-api/` (backend)** + +| Path | Purpose | Task | +|---|---|---| +| `package.json` | Add `"test"` script | 1 | +| `test/helpers/setup-db.js` | Spin up isolated test DB, run migrations, return pool | 1 | +| `test/helpers/test-app.js` | Build an Express app with all middleware wired, listen on ephemeral port, return `{ baseUrl, cleanup }` | 1 | +| `src/db/migrations/023-auth-session-timestamps.sql` | Adds `password_updated_at`, `last_login_at`, seeds dev user | 2 | +| `src/auth/passwords.js` | `hashPassword`, `comparePassword` thin wrappers over `bcrypt` | 3 | +| `src/auth/tokens.js` | `generateToken` (32-byte hex with `dfl_` prefix), `hashToken` (SHA-256 hex), `parseBearer` | 3 | +| `src/middleware/auth.js` | `requireAuth`, `destroyAnd401`, `loadUser`, `DEV_USER` constant | 4 | +| `src/index.js` | Mount session middleware, tighten CORS, mount auth gate, mount new routers | 5, 6, 7, 13, 14 | +| `src/routes/auth.js` | `/auth/setup-required`, `/setup`, `/login`, `/logout`, `/me`, `/password` | 7-12 | +| `src/routes/users.js` | User CRUD (admin reset password lives here too) | 13 | +| `src/routes/tokens.js` | Current-user API token list/create/revoke | 14 | +| `src/auth/rate-limit.js` | Per-IP exponential backoff `Map` for `/auth/login` | 15 | +| `.env.example` | Add `ALLOWED_ORIGINS`, default `AUTH_ENABLED=true` | 18 | + +**`services/web-ui/` (frontend)** + +| Path | Purpose | Task | +|---|---|---| +| `public/auth-gate.jsx` | New: `` orchestration (me → setup-required → render Login / Setup / app) | 16 | +| `public/screens-auth.jsx` | New: ``, `` components, ops-register style | 17 | +| `public/data.jsx` | Replace `apiFetch` 401-bounce with AuthGate dispatch; add `X-Requested-With` header | 16 | +| `public/shell.jsx` | Replace sign-out `/login.html` redirect with AuthGate re-mount | 16 | +| `public/screens-admin.jsx` | Add Account (change password) + API Tokens sections | 17 | +| `public/index.html` | Add ` + + + + + + +... rest unchanged ... + +``` + +- [ ] **Step 5: Update app.jsx to wrap App in AuthGateComponent** + +In `services/web-ui/public/app.jsx`, change the final `root.render` from: + +```jsx +root.render(); +``` + +to: + +```jsx +root.render(); +``` + +- [ ] **Step 6: Build + manual smoke** + +```bash +cd services/web-ui && npm run build:jsx +``` + +Expected: `[build-jsx] compiled N jsx files to /.../dist`, including `auth-gate.js`. (`screens-auth.js` won't exist yet — it's built when Task 16 lands; for now the script tag will produce a 404 in DevTools, which is fine.) + +Start the stack with `AUTH_ENABLED=true` and `DATABASE_URL` set. Open the web UI in a browser. With an empty `users` table, you should see the in-progress AuthGate loading spinner, then a blank screen (because `` doesn't exist yet — Task 16 fills it in). The point of this step is to verify the gate boots, the script loads, and DevTools shows `/api/v1/auth/me → 401` followed by `/api/v1/auth/setup-required → {required: true}`. + +- [ ] **Step 7: Commit** + +```bash +git add services/web-ui/public/auth-gate.jsx services/web-ui/public/data.jsx services/web-ui/public/shell.jsx services/web-ui/public/app.jsx services/web-ui/public/index.html +git commit -m "feat(web-ui): AuthGate orchestration + replace 401 bounce with in-SPA re-render" +``` + +--- + +## Task 16: Frontend — Login and Setup screens (layout B from brainstorm) + +**Files:** +- Create: `services/web-ui/public/screens-auth.jsx` + +- [ ] **Step 1: Build the two screens** + +Create `services/web-ui/public/screens-auth.jsx`: + +```jsx +// LoginScreen + SetupScreen — layout B from the auth brainstorm spec: +// 22px wordmark + "WILD DRAGON BROADCAST" tagline above a --bg-1 card. +// Matches DESIGN.md tokens; no decoration, dense, ops register. + +(function () { + const API_BASE = '/api/v1'; + + async function postJson(path, body) { + return fetch(API_BASE + path, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'dragonflight-ui', + }, + body: JSON.stringify(body), + }); + } + + function Brand() { + return ( +
+
Dragonflight
+
+ Wild Dragon Broadcast +
+
+ ); + } + + function Card({ children }) { + return ( +
{children}
+ ); + } + + function Field({ label, type = 'text', value, onChange, autoComplete, autoFocus }) { + return ( +
+ + onChange(e.target.value)} + style={{ + width: '100%', + background: 'var(--bg-3)', + border: '1px solid var(--border)', + borderRadius: 4, + padding: '8px 10px', + fontSize: 12.5, + color: 'var(--text-1)', + fontFamily: 'var(--font-mono)', + boxSizing: 'border-box', + }} + /> +
+ ); + } + + function Button({ children, disabled, onClick, type = 'button' }) { + return ( + + ); + } + + function ErrorRow({ text }) { + if (!text) return null; + return ( +
{text}
+ ); + } + + function Screen({ children }) { + return ( +
+
e.preventDefault()} style={{ width: 300 }}> + + {children} + +
+ ); + } + + function LoginScreen({ onDone }) { + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [error, setError] = React.useState(''); + const [busy, setBusy] = React.useState(false); + const submit = async () => { + setError(''); setBusy(true); + try { + const r = await postJson('/auth/login', { username, password }); + if (r.status === 200) { onDone(); return; } + const body = await r.json().catch(() => ({})); + setError(body.error || ('Login failed: ' + r.status)); + } catch (e) { setError(e.message || 'Login failed'); } + finally { setBusy(false); } + }; + return ( + + + + + + + ); + } + + function SetupScreen({ onDone }) { + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [confirm, setConfirm] = React.useState(''); + const [error, setError] = React.useState(''); + const [busy, setBusy] = React.useState(false); + const submit = async () => { + setError(''); + if (password !== confirm) { setError('Passwords do not match'); return; } + if (password.length < 12) { setError('Password must be at least 12 characters'); return; } + setBusy(true); + try { + const r = await postJson('/auth/setup', { username, password }); + if (r.status === 200) { onDone(); return; } + const body = await r.json().catch(() => ({})); + setError(body.error || ('Setup failed: ' + r.status)); + } catch (e) { setError(e.message || 'Setup failed'); } + finally { setBusy(false); } + }; + return ( + +
+ First-run setup — create the first admin +
+ + + + + +
+ ); + } + + window.LoginScreen = LoginScreen; + window.SetupScreen = SetupScreen; +})(); +``` + +- [ ] **Step 2: Update auth-gate.jsx to reference the global components** + +In `services/web-ui/public/auth-gate.jsx`, replace the placeholder JSX where it returns Setup/Login with: + +```jsx +if (state.kind === 'setup') return React.createElement(window.SetupScreen, { onDone: () => window.AuthGate.signedIn() }); +if (state.kind === 'login') return React.createElement(window.LoginScreen, { onDone: () => window.AuthGate.signedIn() }); +``` + +(Necessary because the JSX `` references aren't in scope of the IIFE — using `window.SetupScreen` keeps the cross-file linkage explicit.) + +- [ ] **Step 3: Build** + +```bash +cd services/web-ui && npm run build:jsx +``` + +Expected: `screens-auth.js` and updated `auth-gate.js` in `public/dist/`. + +- [ ] **Step 4: Manual smoke — full setup → app → reload → logout → login** + +With `AUTH_ENABLED=true`, empty `users` table, browser open: + +1. **Setup:** see "First-run setup" screen. Enter username `admin`, password ≥12 chars. Submit. App loads. +2. **Reload:** page reloads, no flash to login — app renders again (cookie unlocks `/auth/me`). +3. **Sign out:** click power icon in sidebar. Login screen renders. +4. **Login back in:** enter same creds. App loads. **No infinite loop.** +5. **Wrong password:** enter wrong password. Inline error appears, no redirect. +6. **DevTools network tab:** confirm `/auth/me` returns 200 after login, every page action carries `cookie: dragonflight.sid=...`, every POST carries `X-Requested-With: dragonflight-ui`. + +- [ ] **Step 5: Commit** + +```bash +git add services/web-ui/public/screens-auth.jsx services/web-ui/public/auth-gate.jsx +git commit -m "feat(web-ui): LoginScreen + SetupScreen (layout B from brainstorm)" +``` + +--- + +## Task 17: Frontend — Settings → Account section + API Tokens section + +**Files:** +- Modify: `services/web-ui/public/screens-admin.jsx` + +> This task adds two sections inside the existing Settings screen. `screens-admin.jsx` is large (~103 KB); the changes are scoped and additive. + +- [ ] **Step 1: Locate the Settings tab structure** + +```bash +grep -n "function Settings\|case 'settings'\|export.*Settings" /tmp/dragonflight-spec/services/web-ui/public/screens-admin.jsx | head -20 +``` + +Find the `Settings` component and the tab/section structure it uses. Sections in this codebase typically render as panels with a section label header (per DESIGN.md). Read the surrounding 50 lines to understand the existing pattern. + +- [ ] **Step 2: Add the Account section component** + +Insert (above the `Settings` component in `screens-admin.jsx`): + +```jsx +function AccountSection() { + const [current, setCurrent] = React.useState(''); + const [next, setNext] = React.useState(''); + const [confirm, setConfirm] = React.useState(''); + const [msg, setMsg] = React.useState(null); // { kind: 'ok'|'err', text } + const [busy, setBusy] = React.useState(false); + + const submit = async () => { + setMsg(null); + if (next !== confirm) { setMsg({ kind: 'err', text: 'Passwords do not match' }); return; } + if (next.length < 12) { setMsg({ kind: 'err', text: 'New password must be at least 12 characters' }); return; } + setBusy(true); + try { + const r = await fetch('/api/v1/auth/password', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, + body: JSON.stringify({ current_password: current, new_password: next }), + }); + if (r.status === 204) { + setMsg({ kind: 'ok', text: 'Password updated' }); + setCurrent(''); setNext(''); setConfirm(''); + } else { + const body = await r.json().catch(() => ({})); + setMsg({ kind: 'err', text: body.error || 'Failed (' + r.status + ')' }); + } + } finally { setBusy(false); } + }; + + return ( +
+

Account

+
+ + setCurrent(e.target.value)} /> + + setNext(e.target.value)} /> + + setConfirm(e.target.value)} /> +
+ {msg && ( +
{msg.text}
+ )} + +
+ ); +} +``` + +- [ ] **Step 3: Add the API Tokens section component** + +Insert next to `AccountSection`: + +```jsx +function ApiTokensSection() { + const [tokens, setTokens] = React.useState([]); + const [name, setName] = React.useState(''); + const [justCreated, setJustCreated] = React.useState(null); // { token, prefix, name } + const [busy, setBusy] = React.useState(false); + + const load = React.useCallback(async () => { + const r = await fetch('/api/v1/auth/tokens', { credentials: 'include' }); + if (r.status === 200) setTokens(await r.json()); + }, []); + + React.useEffect(() => { load(); }, [load]); + + const create = async () => { + if (!name.trim()) return; + setBusy(true); + try { + const r = await fetch('/api/v1/auth/tokens', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, + body: JSON.stringify({ name: name.trim() }), + }); + if (r.status === 201) { + const created = await r.json(); + setJustCreated(created); + setName(''); + await load(); + } + } finally { setBusy(false); } + }; + + const revoke = async (id) => { + await fetch('/api/v1/auth/tokens/' + id, { + method: 'DELETE', + credentials: 'include', + headers: { 'X-Requested-With': 'dragonflight-ui' }, + }); + await load(); + }; + + return ( +
+

API Tokens

+ + {justCreated && ( +
+
+ Save this token now — it will not be shown again +
+
{justCreated.token}
+ + +
+ )} + +
+ setName(e.target.value)} style={{ flex: 1 }} /> + +
+ +
+ {tokens.length === 0 &&
No tokens yet.
} + {tokens.map(t => ( +
+
{t.name}
+
{t.prefix}…
+
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : 'never used'}
+ +
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 4: Insert the two sections at the top of the Settings render** + +Inside the `Settings` component's return, add: + +```jsx + + +``` + +…before the existing S3 / AMPP / Transcoding sections. + +- [ ] **Step 5: Build + manual smoke** + +```bash +cd services/web-ui && npm run build:jsx +``` + +In the browser, log in, go to Settings: +1. **Change password:** enter wrong current → inline red error. Enter correct → "Password updated". Sign out, sign back in with new password. +2. **Create token:** name it "smoke", click "New token". Token appears in the accent box. Copy it. +3. **Use token via curl:** + ```bash + curl -H "Authorization: Bearer " http:///api/v1/projects + ``` + Expected: JSON list, not 401. +4. **Revoke:** click Revoke. Same curl now returns 401. + +- [ ] **Step 6: Commit** + +```bash +git add services/web-ui/public/screens-admin.jsx +git commit -m "feat(web-ui): Settings → Account (change password) + API Tokens sections" +``` + +--- + +## Task 18: Cleanup, env defaults, README + +**Files:** +- Modify: `.env.example` +- Modify: `README.md` +- Verify: no `/login.html` file exists; no stray references remain +- Modify: boot log message in `services/mam-api/src/index.js` + +- [ ] **Step 1: Verify no /login.html file exists, and grep for stray references** + +```bash +find services/web-ui -name 'login.html' 2>&1 +grep -rn "login.html" services/ 2>&1 +``` + +Expected: `find` returns nothing. `grep` returns either nothing (good) or only references inside the auth-gate / data.jsx / shell.jsx files that you already replaced; if any remain, finish replacing them now (they should be 0 after Task 15). + +- [ ] **Step 2: Update .env.example** + +In `/tmp/dragonflight-spec/.env.example`, change the AUTH section and add the ALLOWED_ORIGINS line. The block should read: + +``` +# Session Configuration +SESSION_SECRET=changeme + +# MAM API Configuration +MAM_API_URL=http://mam-api:3000 + +# Auth — default to ON in production. Setting to 'false' is a dev-only escape +# hatch that disables all auth checks and attaches a synthetic 'dev' user to +# every request. Never run with AUTH_ENABLED=false on a network you don't control. +AUTH_ENABLED=true + +# CORS allowlist — comma-separated origins that may carry credentials to the API. +# Same-origin requests via the nginx reverse proxy do not need to be listed here. +# Leave empty to allow any origin (DEV ONLY). +ALLOWED_ORIGINS= + +# Reverse-proxy trust — set 'true' when the API sits behind nginx terminating HTTPS, +# so secure-cookie + X-Forwarded-Proto behave correctly. +TRUST_PROXY=false +``` + +- [ ] **Step 3: Update the boot log message in index.js** + +In `services/mam-api/src/index.js`, change: + +```js +const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)'; +``` + +to: + +```js +const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED — dev mode (dev user attached to every request)'; +``` + +- [ ] **Step 4: Add a README section** + +Append to `/tmp/dragonflight-spec/README.md`: + +````markdown +## Authentication + +Dragonflight uses local username/password authentication with two transports: + +- **Browser:** session cookie (`dragonflight.sid`), 8 hour absolute + 1 hour idle timeout. +- **Premiere panel / scripts:** SHA-256-hashed bearer tokens issued from `Settings → API Tokens`. + +### First-run setup + +On a fresh install with `AUTH_ENABLED=true`, navigate to the web UI in a browser. +With no users in the database, the login screen renders a "First-run setup" form +instead — fill it in to create the first admin and you are logged in immediately. + +Subsequent users are created from `Settings → Users` (any signed-in user can +create others — flat access). + +### Dev mode + +Setting `AUTH_ENABLED=false` (default in `.env.example` for local dev) disables +all auth checks; a synthetic `dev` user is attached to every request. **Never +deploy this way.** The dev user row is seeded with a hash that no real password +can match, so flipping `AUTH_ENABLED=true` later does not expose the dev account. + +### Recovering a forgotten admin password + +Any signed-in user can reset another user's password from `Settings → Users`. +If no one can sign in (all admins forgot their passwords), reset directly in +Postgres: + +```sql +-- generate a fresh bcrypt hash with: +-- node -e "import('bcrypt').then(b => b.default.hash(process.argv[1], 12).then(h => console.log(h)))" 'new-passphrase-here' +UPDATE users SET password_hash = '', password_updated_at = NOW() + WHERE username = 'admin'; +``` + +### `AUTH_ENABLED` transition + +When flipping `AUTH_ENABLED=false` → `true` on an existing install: + +1. Ensure `SESSION_SECRET` is set to a stable value (rotating it logs everyone out). +2. Set `ALLOWED_ORIGINS` to the public origin(s) of the web UI. +3. Restart `mam-api`. +4. Visit the UI — first-run setup will appear if no real users exist yet. +```` + +- [ ] **Step 5: Commit** + +```bash +git add .env.example README.md services/mam-api/src/index.js +git commit -m "docs(auth): flip AUTH_ENABLED default + document setup + recovery" +``` + +--- + +## Task 19: Final integration smoke — full system test + +**Files:** +- (None to modify. Manual + curl smoke documented for the PR.) + +- [ ] **Step 1: Bring up the full stack** + +```bash +cd /tmp/dragonflight-spec +docker compose down -v # nuke the DB to exercise first-run +docker compose up --build -d +docker compose logs -f mam-api | grep -E 'Authentication|listening' & +``` + +Expected: `Authentication: ENABLED` (assuming `.env` has `AUTH_ENABLED=true` now) and `MAM API listening on port 3000`. + +- [ ] **Step 2: Run the full test suite once more** + +```bash +cd services/mam-api +TEST_DATABASE_URL=postgres://wilddragon:test@localhost:5432/wilddragon_test npm test +``` + +Expected: every test passes. + +- [ ] **Step 3: Manual end-to-end smoke (browser)** + +1. Open http://localhost:47434 (or whatever the web-ui port is in your compose). See setup screen. +2. Create admin `op1` / 12-char password. App loads. +3. Open DevTools → Application → Cookies — verify `dragonflight.sid` is `HttpOnly` and `SameSite=Lax`. +4. Reload the page. Stays logged in. +5. Settings → API Tokens → create "smoke-bearer". Copy token. +6. `curl -H "Authorization: Bearer " http://localhost:47434/api/v1/projects` — 200 with JSON. +7. Settings → API Tokens → revoke "smoke-bearer". Repeat curl — 401. +8. Settings → Account → change password. Sign out. Sign in with new password. +9. **Idle timeout simulation (optional):** in psql, `UPDATE sessions SET sess = jsonb_set(sess, '{last_seen_at}', to_jsonb((extract(epoch from now()) * 1000 - 3700000)::bigint));`. Reload. Should bounce to login. + +- [ ] **Step 4: Push the branch and open a PR** + +```bash +git push -u origin feat/auth-system +gh pr create --title "feat(auth): wire end-to-end auth — sessions + bearer + first-run setup" \ + --body "$(cat <<'EOF' +## Summary + +Implements `docs/superpowers/specs/2026-05-27-auth-system-design.md`. + +- `express-session` + `connect-pg-simple` actually mounted (was missing — root cause of the prior redirect loop). +- One `requireAuth` middleware handling both cookie and bearer auth. +- Auth router: `/auth/setup`, `/auth/login`, `/auth/logout`, `/auth/me`, `/auth/password`, user CRUD, API token CRUD. +- AuthGate React component owns "logged in or not" state — no more full-page bounces to a non-existent `/login.html`. +- Login + Setup screens matching DESIGN.md tokens (layout B from brainstorm). +- Settings → Account (change own password) + Settings → API Tokens (issue / revoke). +- Per-IP exponential login backoff + X-Requested-With CSRF header. +- Tightened CORS to an `ALLOWED_ORIGINS` allowlist. +- Cluster carve-out preserved for node-agent's existing token binding. + +## Test plan + +- [ ] `npm test` in `services/mam-api` (with TEST_DATABASE_URL) — all green +- [ ] Fresh install setup → create admin → land on dashboard +- [ ] Reload after login — stays signed in +- [ ] Sign out → see login screen +- [ ] Wrong password → inline error, no redirect +- [ ] Repeated wrong passwords → noticeable backoff +- [ ] API token issued in UI authenticates via `Authorization: Bearer` +- [ ] Revoked token returns 401 +- [ ] `AUTH_ENABLED=false` boot still works (dev mode unchanged) + +Spec: `docs/superpowers/specs/2026-05-27-auth-system-design.md` +EOF +)" +``` + +- [ ] **Step 5: Done** + +The branch is ready for review. The original redirect loop is covered by the regression test in `test/routes/auth.test.js`. + +--- + +## Self-review (done at write time) + +**Spec coverage:** + +| Spec section | Task | +|---|---| +| Migration 023 | 2 | +| Session middleware wired | 5 | +| requireAuth middleware (session + bearer + idle + absolute) | 4 | +| Auth gate at /api/v1 with allowlist | 6 | +| Cluster carve-out decision | 6 | +| `/auth/setup-required` | 7 | +| `/auth/setup` | 8 | +| `/auth/login` + `req.session.save()` callback fix | 9 | +| Regression test for redirect loop | 9 | +| `/auth/logout` | 10 | +| `/auth/me`, `/auth/password` | 11 | +| User CRUD + delete-last-user guard | 12 | +| API token CRUD (one-time-show, SHA-256 stored) | 13 | +| Premiere panel bearer flow end-to-end | 13 | +| Rate limiting | 14 | +| CSRF X-Requested-With | 14 | +| AuthGate orchestration | 15 | +| LoginScreen + SetupScreen (layout B) | 16 | +| Settings → Account + API Tokens | 17 | +| CORS tightening | 5 | +| `.env.example` + boot log + README updates | 18 | +| Delete `/login.html` references | 15, 18 (verify) | +| Final integration smoke | 19 | +| Manual test (8h absolute / 1h idle simulation) | 19 | + +**Placeholder scan:** none — every step has explicit code or commands. + +**Type consistency:** `DEV_USER_ID` constant defined in Task 4, referenced unchanged in Tasks 7, 8, 9, 12. `parseBearer`, `hashToken`, `generateToken`, `tokenDisplayPrefix` defined in Task 3, all referenced consistently in Tasks 4, 13. `requireAuth` exported from Task 4, imported in Tasks 6, 11, 12, 13. `ipBackoff` exported from Task 14, imported in Task 14 step 5. `window.AuthGate.bounce(reason)` / `window.AuthGate.signedIn()` signatures defined in Task 15, called the same way from data.jsx, shell.jsx, screens-auth.jsx. + +**Scope decomposition:** This is one feature, one plan. Tasks 15–17 are frontend; they could be split off into a separate "frontend integration" plan if a different engineer is going to do the JS, but the spec is one cohesive unit and the boundaries between backend + frontend are clean within tasks so a single plan is fine.