# 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.