# 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 `<AuthGate>` 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: `<AuthGate>` orchestration (me → setup-required → render Login / Setup / app) | 16 |
| `public/screens-auth.jsx` | New: `<LoginScreen>`, `<SetupScreen>` 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 `<script>` tags for `dist/auth-gate.js`, `dist/screens-auth.js` (auto-built by `build-jsx.js`) | 17 |

**Cleanup**

| Path | Action | Task |
|---|---|---|
| `services/web-ui/public/dist/login.html` etc. | Verify no `/login.html` file exists; if it does, delete | 18 |
| References to `/login.html` in source | Verify only the references replaced in tasks 16/17 remain | 18 |
| `README.md` | Document auth flow + `AUTH_ENABLED` transition | 18 |

---

## Cluster carve-out decision (locked in for this plan)

The spec leaves the cluster route gating open. **For this plan: carve `/api/v1/cluster/*` out of the `requireAuth` gate**. Rationale: node-agent already uses `019-node-token-binding`; reissuing it an api_token at install time is a larger lift that belongs in a follow-up. The carve-out is one line in `src/index.js` and a comment.

---

## Task 1: Test infrastructure

**Files:**
- Modify: `services/mam-api/package.json`
- Create: `services/mam-api/test/helpers/setup-db.js`
- Create: `services/mam-api/test/helpers/test-app.js`
- Create: `services/mam-api/test/smoke.test.js`

- [ ] **Step 1: Add the test script**

Modify `services/mam-api/package.json` — under `"scripts"`, add the test entry so the block reads:

```json
  "scripts": {
    "start": "node src/index.js",
    "dev": "node --watch src/index.js",
    "test": "node --test test/**/*.test.js"
  },
```

- [ ] **Step 2: Create the test DB helper**

Create `services/mam-api/test/helpers/setup-db.js`:

```js
// Pure helper for integration tests. Requires TEST_DATABASE_URL pointing at
// a throwaway Postgres. Returns a pg Pool with the full schema applied.
// Tests that need this should `skip` (not fail) when TEST_DATABASE_URL is unset.
import { Pool } from 'pg';
import { readdirSync, readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';

const __dirname = dirname(fileURLToPath(import.meta.url));
const migrationsDir = join(__dirname, '..', '..', 'src', 'db', 'migrations');
const schemaFile    = join(__dirname, '..', '..', 'src', 'db', 'schema.sql');
const patches = [
  'schema_patch_ampp.sql',
  'schema_patch_editor.sql',
  'schema_patch_groups_tokens.sql',
];

export function isTestDbConfigured() {
  return !!process.env.TEST_DATABASE_URL;
}

export async function setupTestDb() {
  if (!process.env.TEST_DATABASE_URL) {
    throw new Error('TEST_DATABASE_URL not set');
  }
  const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });

  // Wipe and recreate the public schema so each test run starts clean.
  await pool.query('DROP SCHEMA IF EXISTS public CASCADE');
  await pool.query('CREATE SCHEMA public');
  await pool.query('GRANT ALL ON SCHEMA public TO PUBLIC');

  // Base schema, then patches, then migrations in order.
  await pool.query(readFileSync(schemaFile, 'utf8'));
  for (const p of patches) {
    await pool.query(readFileSync(join(__dirname, '..', '..', 'src', 'db', p), 'utf8'));
  }
  const migrations = readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
  for (const f of migrations) {
    await pool.query(readFileSync(join(migrationsDir, f), 'utf8'));
  }

  return pool;
}
```

- [ ] **Step 3: Create the test app helper**

Create `services/mam-api/test/helpers/test-app.js`:

```js
// Builds an Express app with the production middleware stack pointed at a
// test pool, listens on an ephemeral port, returns { baseUrl, server, pool, cleanup }.
// Tests use fetch() against baseUrl — no supertest dep needed.
import express from 'express';
import cors from 'cors';
import session from 'express-session';
import connectPgSimple from 'connect-pg-simple';
import { setupTestDb } from './setup-db.js';

const PgStore = connectPgSimple(session);

export async function createTestApp({ authEnabled = true } = {}) {
  const pool = await setupTestDb();
  process.env.AUTH_ENABLED = authEnabled ? 'true' : 'false';
  process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'test-secret-' + Math.random();

  const app = express();
  app.use(cors({ origin: true, credentials: true }));
  app.use(express.json({ limit: '5mb' }));
  app.use(session({
    store: new PgStore({ pool, tableName: 'sessions' }),
    secret: process.env.SESSION_SECRET,
    name:   'dragonflight.sid',
    cookie: { httpOnly: true, sameSite: 'lax', secure: false, path: '/', maxAge: 8 * 3600 * 1000 },
    rolling: false,
    resave:  false,
    saveUninitialized: false,
  }));

  app.get('/health', (_req, res) => res.json({ status: 'ok' }));

  // Tests that need additional routes mount them on the returned `app`
  // before calling listen.
  return new Promise(resolve => {
    const server = app.listen(0, '127.0.0.1', () => {
      const port = server.address().port;
      const baseUrl = `http://127.0.0.1:${port}`;
      const cleanup = async () => {
        await new Promise(r => server.close(r));
        await pool.end();
      };
      resolve({ app, server, pool, baseUrl, cleanup });
    });
  });
}
```

- [ ] **Step 4: Write a smoke test that proves the infra works**

Create `services/mam-api/test/smoke.test.js`:

```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isTestDbConfigured } from './helpers/setup-db.js';
import { createTestApp } from './helpers/test-app.js';

test('test infra: /health responds on an ephemeral port', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const { baseUrl, cleanup } = await createTestApp();
  try {
    const res = await fetch(baseUrl + '/health');
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body.status, 'ok');
  } finally {
    await cleanup();
  }
});
```

- [ ] **Step 5: Run the smoke test, with and without DB**

Without `TEST_DATABASE_URL`:

```bash
cd services/mam-api && npm test
```

Expected: the smoke test is reported as **skipped** with message `TEST_DATABASE_URL not set`. Overall exit 0.

With `TEST_DATABASE_URL` set (operator provides their own throwaway DB):

```bash
TEST_DATABASE_URL=postgres://wilddragon:test@localhost:5432/wilddragon_test npm test
```

Expected: smoke test **passes**.

- [ ] **Step 6: Commit**

```bash
cd /tmp/dragonflight-spec
git add services/mam-api/package.json services/mam-api/test/
git commit -m "chore(mam-api): wire node:test runner + test app + DB helper"
```

---

## Task 2: Migration 023 — auth timestamps + dev-user seed

**Files:**
- Create: `services/mam-api/src/db/migrations/023-auth-session-timestamps.sql`

- [ ] **Step 1: Write the migration**

Create `services/mam-api/src/db/migrations/023-auth-session-timestamps.sql`:

```sql
-- Migration 023 — auth-related user timestamps + idempotent dev user.
--
-- See docs/superpowers/specs/2026-05-27-auth-system-design.md
--
-- password_updated_at + last_login_at are operator visibility, no logic depends on them yet.
-- The dev user is seeded with a fixed UUID so FK-bearing routes (api_tokens,
-- future audit fields) keep working when AUTH_ENABLED=false. The seeded
-- password_hash is a placeholder that no bcrypt.compare will accept, so the
-- dev row cannot be used to log in even if AUTH_ENABLED is later flipped on.

ALTER TABLE users ADD COLUMN IF NOT EXISTS password_updated_at TIMESTAMPTZ DEFAULT NOW();
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at       TIMESTAMPTZ;

INSERT INTO users (id, username, password_hash, display_name, role)
VALUES (
  '00000000-0000-4000-8000-000000000dev',
  'dev',
  '!disabled-no-login!',
  'Dev (AUTH_ENABLED=false)',
  'admin'
)
ON CONFLICT (id) DO NOTHING;
```

- [ ] **Step 2: Apply the migration to your local dev DB and verify**

```bash
cd services/mam-api
psql "$DATABASE_URL" -f src/db/migrations/023-auth-session-timestamps.sql
```

Expected: `ALTER TABLE` ×2, `INSERT 0 1` (or `INSERT 0 0` if already seeded).

Verify:

```bash
psql "$DATABASE_URL" -c "SELECT id, username, role FROM users WHERE username='dev'"
```

Expected one row: `00000000-0000-4000-8000-000000000dev | dev | admin`.

- [ ] **Step 3: Run the migration runner integration test (smoke from Task 1)**

```bash
TEST_DATABASE_URL=postgres://wilddragon:test@localhost:5432/wilddragon_test npm test
```

Expected: smoke test still passes (the new migration gets picked up by `setupTestDb`).

- [ ] **Step 4: Commit**

```bash
git add services/mam-api/src/db/migrations/023-auth-session-timestamps.sql
git commit -m "feat(mam-api): migration 023 — auth timestamps + idempotent dev user seed"
```

---

## Task 3: Auth utilities — passwords and tokens

**Files:**
- Create: `services/mam-api/src/auth/passwords.js`
- Create: `services/mam-api/src/auth/tokens.js`
- Create: `services/mam-api/test/auth/passwords.test.js`
- Create: `services/mam-api/test/auth/tokens.test.js`

- [ ] **Step 1: Write the failing password test**

Create `services/mam-api/test/auth/passwords.test.js`:

```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { hashPassword, comparePassword } from '../../src/auth/passwords.js';

test('hashPassword returns a bcrypt string that comparePassword accepts', async () => {
  const hash = await hashPassword('correct-horse-battery-staple');
  assert.match(hash, /^\$2[aby]\$\d{2}\$/);
  assert.equal(await comparePassword('correct-horse-battery-staple', hash), true);
});

test('comparePassword returns false for the wrong password', async () => {
  const hash = await hashPassword('correct-horse-battery-staple');
  assert.equal(await comparePassword('wrong', hash), false);
});

test('comparePassword returns false (not throws) for a malformed hash', async () => {
  assert.equal(await comparePassword('anything', '!disabled-no-login!'), false);
});
```

- [ ] **Step 2: Run, confirm it fails**

```bash
cd services/mam-api && node --test test/auth/passwords.test.js
```

Expected: `Cannot find module '../../src/auth/passwords.js'`.

- [ ] **Step 3: Implement passwords.js**

Create `services/mam-api/src/auth/passwords.js`:

```js
// Thin bcrypt wrapper. Cost 12 per the spec (NIST SP 800-63B-friendly).
// comparePassword must never throw on a malformed hash — that path is hit
// by the seeded dev user's placeholder hash and by any partially-imported
// row. Throwing here would 500 on a wrong-password attempt.
import bcrypt from 'bcrypt';

const COST = 12;

export async function hashPassword(plain) {
  return bcrypt.hash(plain, COST);
}

export async function comparePassword(plain, hash) {
  try {
    return await bcrypt.compare(plain, hash);
  } catch {
    return false;
  }
}
```

- [ ] **Step 4: Run, confirm it passes**

```bash
node --test test/auth/passwords.test.js
```

Expected: all 3 tests pass.

- [ ] **Step 5: Write the failing token test**

Create `services/mam-api/test/auth/tokens.test.js`:

```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { generateToken, hashToken, parseBearer } from '../../src/auth/tokens.js';

test('generateToken returns dfl_-prefixed 32-byte hex (68 chars total)', () => {
  const t = generateToken();
  assert.match(t, /^dfl_[0-9a-f]{64}$/);
});

test('generateToken returns distinct values on each call', () => {
  assert.notEqual(generateToken(), generateToken());
});

test('hashToken returns a stable 64-char hex SHA-256', () => {
  const t = 'dfl_' + 'a'.repeat(64);
  const h = hashToken(t);
  assert.match(h, /^[0-9a-f]{64}$/);
  assert.equal(hashToken(t), h);
});

test('parseBearer returns the token when header is well-formed', () => {
  assert.equal(parseBearer('Bearer dfl_abc'), 'dfl_abc');
  assert.equal(parseBearer('bearer dfl_xyz'), 'dfl_xyz'); // case-insensitive scheme
});

test('parseBearer returns null for missing or malformed headers', () => {
  assert.equal(parseBearer(undefined), null);
  assert.equal(parseBearer(''), null);
  assert.equal(parseBearer('Basic abc'), null);
  assert.equal(parseBearer('Bearer'), null);
});
```

- [ ] **Step 6: Run, confirm it fails**

```bash
node --test test/auth/tokens.test.js
```

Expected: module-not-found error.

- [ ] **Step 7: Implement tokens.js**

Create `services/mam-api/src/auth/tokens.js`:

```js
import { randomBytes, createHash } from 'node:crypto';

const PREFIX = 'dfl_';

export function generateToken() {
  return PREFIX + randomBytes(32).toString('hex');
}

export function hashToken(token) {
  return createHash('sha256').update(token).digest('hex');
}

export function parseBearer(authorizationHeader) {
  if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
  const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);
  return m ? m[1] : null;
}

export const TOKEN_PREFIX_DISPLAY_LEN = 8; // for api_tokens.token_prefix
export function tokenDisplayPrefix(token) {
  return token.slice(0, TOKEN_PREFIX_DISPLAY_LEN);
}
```

- [ ] **Step 8: Run, confirm it passes**

```bash
node --test test/auth/tokens.test.js
```

Expected: all 5 tests pass.

- [ ] **Step 9: Commit**

```bash
git add services/mam-api/src/auth/ services/mam-api/test/auth/
git commit -m "feat(mam-api): auth utilities — password hash/compare + token gen/hash/parse"
```

---

## Task 4: `requireAuth` middleware

**Files:**
- Create: `services/mam-api/src/middleware/auth.js`
- Create: `services/mam-api/test/middleware/auth.test.js`

- [ ] **Step 1: Write the failing test (all seven cases)**

Create `services/mam-api/test/middleware/auth.test.js`:

```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import { requireAuth, DEV_USER_ID } from '../../src/middleware/auth.js';
import { generateToken, hashToken } from '../../src/auth/tokens.js';

function mockRes() {
  const res = {
    statusCode: 200, body: null,
    status(n) { this.statusCode = n; return this; },
    json(o)   { this.body = o; return this; },
  };
  return res;
}

function mockReq({ session = null, authHeader = null } = {}) {
  return {
    session,
    headers: authHeader ? { authorization: authHeader } : {},
  };
}

test('AUTH_ENABLED=false → attaches dev user and calls next', async () => {
  delete process.env.AUTH_ENABLED;
  const req = mockReq(); const res = mockRes(); let called = false;
  await requireAuth(req, res, () => { called = true; });
  assert.equal(called, true);
  assert.equal(req.user.id, DEV_USER_ID);
  assert.equal(req.user.username, 'dev');
});

test('AUTH_ENABLED=true + no session + no bearer → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  process.env.AUTH_ENABLED = 'true';
  try {
    const { requireAuth } = await import('../../src/middleware/auth.js?cache=' + Date.now());
    const req = mockReq(); const res = mockRes();
    await requireAuth(req, res, () => {});
    assert.equal(res.statusCode, 401);
    assert.deepEqual(res.body, { error: 'unauthorized' });
  } finally { await pool.end(); }
});

test('valid session within idle/absolute window → next + req.user populated', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  process.env.AUTH_ENABLED = 'true';
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  // Insert a real user so loadUser can find them.
  const { rows } = await pool.query(
    `INSERT INTO users (username, password_hash, display_name, role)
     VALUES ('alice', 'x', 'Alice', 'admin') RETURNING id`);
  const userId = rows[0].id;
  try {
    const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
    const now = Date.now();
    const req = mockReq({ session: { user_id: userId, first_seen_at: now - 1000, last_seen_at: now - 500 } });
    const res = mockRes(); let called = false;
    await requireAuth(req, res, () => { called = true; });
    assert.equal(called, true, 'next not called; body=' + JSON.stringify(res.body));
    assert.equal(req.user.id, userId);
    assert.equal(req.user.username, 'alice');
  } finally { await pool.end(); }
});

test('idle-expired session (>1h since last_seen_at) → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  process.env.AUTH_ENABLED = 'true';
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  const { rows } = await pool.query(
    `INSERT INTO users (username, password_hash) VALUES ('b', 'x') RETURNING id`);
  try {
    const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
    const now = Date.now();
    const session = { user_id: rows[0].id, first_seen_at: now - 1000, last_seen_at: now - (61 * 60 * 1000), destroy(cb){ cb(); } };
    const req = mockReq({ session }); const res = mockRes();
    await requireAuth(req, res, () => {});
    assert.equal(res.statusCode, 401);
  } finally { await pool.end(); }
});

test('absolute-expired session (>8h since first_seen_at) → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  process.env.AUTH_ENABLED = 'true';
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  const { rows } = await pool.query(
    `INSERT INTO users (username, password_hash) VALUES ('c', 'x') RETURNING id`);
  try {
    const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
    const now = Date.now();
    const session = { user_id: rows[0].id, first_seen_at: now - (9 * 3600 * 1000), last_seen_at: now - 100, destroy(cb){ cb(); } };
    const req = mockReq({ session }); const res = mockRes();
    await requireAuth(req, res, () => {});
    assert.equal(res.statusCode, 401);
  } finally { await pool.end(); }
});

test('valid bearer token → next + req.user populated', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  process.env.AUTH_ENABLED = 'true';
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  const { rows: u } = await pool.query(
    `INSERT INTO users (username, password_hash) VALUES ('d', 'x') RETURNING id`);
  const token = generateToken();
  await pool.query(
    `INSERT INTO api_tokens (user_id, name, token_hash, token_prefix)
     VALUES ($1, 'test', $2, $3)`,
    [u[0].id, hashToken(token), token.slice(0, 8)]);
  try {
    const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
    const req = mockReq({ authHeader: 'Bearer ' + token });
    const res = mockRes(); let called = false;
    await requireAuth(req, res, () => { called = true; });
    assert.equal(called, true, 'next not called; body=' + JSON.stringify(res.body));
    assert.equal(req.user.username, 'd');
  } finally { await pool.end(); }
});

test('invalid bearer token → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  process.env.AUTH_ENABLED = 'true';
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  try {
    const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
    const req = mockReq({ authHeader: 'Bearer dfl_nope' });
    const res = mockRes();
    await requireAuth(req, res, () => {});
    assert.equal(res.statusCode, 401);
  } finally { await pool.end(); }
});

test('bearer token whose user was deleted → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  process.env.AUTH_ENABLED = 'true';
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  const { rows: u } = await pool.query(
    `INSERT INTO users (username, password_hash) VALUES ('e', 'x') RETURNING id`);
  const token = generateToken();
  await pool.query(
    `INSERT INTO api_tokens (user_id, name, token_hash, token_prefix)
     VALUES ($1, 'test', $2, $3)`,
    [u[0].id, hashToken(token), token.slice(0, 8)]);
  // FK ON DELETE CASCADE deletes the token too, so look up by hash returns 0 rows.
  await pool.query(`DELETE FROM users WHERE id = $1`, [u[0].id]);
  try {
    const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
    const req = mockReq({ authHeader: 'Bearer ' + token });
    const res = mockRes();
    await requireAuth(req, res, () => {});
    assert.equal(res.statusCode, 401);
  } finally { await pool.end(); }
});
```

- [ ] **Step 2: Run, confirm it fails**

```bash
node --test test/middleware/auth.test.js
```

Expected: module-not-found for `../../src/middleware/auth.js`.

- [ ] **Step 3: Implement requireAuth**

Create `services/mam-api/src/middleware/auth.js`:

```js
import pool from '../db/pool.js';
import { parseBearer, hashToken } from '../auth/tokens.js';

// Stable UUID matching migration 023's seeded dev user.
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000dev';
export const DEV_USER    = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)' };

const ABSOLUTE_MS = 8 * 3600 * 1000;
const IDLE_MS     = 1 * 3600 * 1000;

async function destroyAnd401(req, res) {
  if (req.session?.destroy) {
    await new Promise(r => req.session.destroy(() => r()));
  }
  return res.status(401).json({ error: 'unauthorized' });
}

async function loadUser(id) {
  const { rows } = await pool.query(
    `SELECT id, username, display_name FROM users WHERE id = $1`, [id]);
  return rows[0] || null;
}

export async function requireAuth(req, res, next) {
  // Dev mode — attach the seeded dev user so FK-bearing routes work.
  if (process.env.AUTH_ENABLED !== 'true') {
    req.user = DEV_USER;
    return next();
  }

  // 1. Session
  if (req.session?.user_id) {
    const now = Date.now();
    const first = req.session.first_seen_at || 0;
    const last  = req.session.last_seen_at  || 0;
    if (now - first > ABSOLUTE_MS) return destroyAnd401(req, res);
    if (now - last  > IDLE_MS)     return destroyAnd401(req, res);
    req.session.last_seen_at = now;
    const u = await loadUser(req.session.user_id);
    if (!u) return destroyAnd401(req, res);
    req.user = u;
    return next();
  }

  // 2. Bearer
  const bearer = parseBearer(req.headers.authorization);
  if (bearer) {
    const hash = hashToken(bearer);
    const { rows } = await pool.query(
      `SELECT t.id AS token_id, t.user_id, t.expires_at, u.username, u.display_name
         FROM api_tokens t JOIN users u ON u.id = t.user_id
        WHERE t.token_hash = $1`, [hash]);
    if (rows.length && (!rows[0].expires_at || rows[0].expires_at > new Date())) {
      pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [rows[0].token_id])
        .catch(err => console.error('[auth] token last_used_at update failed:', err.message));
      req.user = { id: rows[0].user_id, username: rows[0].username, display_name: rows[0].display_name };
      return next();
    }
  }

  // 3. Nothing matched
  return res.status(401).json({ error: 'unauthorized' });
}
```

- [ ] **Step 4: Run, confirm it passes**

```bash
TEST_DATABASE_URL=postgres://wilddragon:test@localhost:5432/wilddragon_test \
  node --test test/middleware/auth.test.js
```

Expected: all 8 tests pass (1 always-runs + 7 DB-dependent).

- [ ] **Step 5: Commit**

```bash
git add services/mam-api/src/middleware/auth.js services/mam-api/test/middleware/auth.test.js
git commit -m "feat(mam-api): requireAuth middleware — session + bearer + idle/absolute timeout"
```

---

## Task 5: Wire session middleware and tighten CORS in index.js

**Files:**
- Modify: `services/mam-api/src/index.js`

- [ ] **Step 1: Read the current top of index.js to anchor the diff**

Read `services/mam-api/src/index.js` lines 1-40 — confirm the current `cors(...)` + `express.json(...)` calls are present and that no `session(...)` is mounted.

- [ ] **Step 2: Add the new imports near the existing imports**

In `services/mam-api/src/index.js`, after the `import cors from 'cors';` line, add:

```js
import session from 'express-session';
import connectPgSimple from 'connect-pg-simple';
const PgStore = connectPgSimple(session);
```

- [ ] **Step 3: Replace the wide-open CORS + add session middleware before any route**

Replace the existing `app.use(cors({ origin: true, credentials: true }));` and the lines immediately after it (`app.use(express.json(...))`) with:

```js
// Tightened CORS — once cookies carry authority, `origin: true` would let
// any site forge requests with the cookie. Drive the allowlist from env.
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
  .split(',').map(s => s.trim()).filter(Boolean);
app.use(cors({
  origin: (origin, cb) => {
    // No Origin header (same-origin or curl) — allow.
    if (!origin) return cb(null, true);
    if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true);
    return cb(new Error('CORS: origin not allowed: ' + origin));
  },
  credentials: true,
}));
app.use(express.json({ limit: '50mb' }));

// Trust the reverse proxy only when explicitly told to (production HTTPS).
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);

// Session — actually wired this time. See specs/2026-05-27-auth-system-design.md.
app.use(session({
  store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
  secret: process.env.SESSION_SECRET,
  name:   'dragonflight.sid',
  cookie: {
    httpOnly: true,
    sameSite: 'lax',
    secure:   process.env.TRUST_PROXY === 'true',
    path:     '/',
    maxAge:   8 * 3600 * 1000,
  },
  rolling: false,         // sliding renewal handled in requireAuth so idle + absolute can be enforced separately
  resave:  false,
  saveUninitialized: false,
}));
```

- [ ] **Step 4: Smoke test — start the server with AUTH_ENABLED=false, verify it still boots**

```bash
cd services/mam-api && AUTH_ENABLED=false SESSION_SECRET=dev DATABASE_URL=$DATABASE_URL node src/index.js &
sleep 2
curl -sS http://localhost:3000/health
kill %1
```

Expected: `{"status":"ok"}` followed by the server shutting down. No errors about missing session store.

- [ ] **Step 5: Commit**

```bash
git add services/mam-api/src/index.js
git commit -m "feat(mam-api): wire express-session + tighten CORS allowlist"
```

---

## Task 6: Mount the auth gate at /api/v1 with allowlist + cluster carve-out

**Files:**
- Modify: `services/mam-api/src/index.js`

- [ ] **Step 1: Add the auth import**

Near the bottom of the import block in `services/mam-api/src/index.js`, add:

```js
import { requireAuth } from './middleware/auth.js';
```

- [ ] **Step 2: Add the gate middleware BEFORE the existing `app.use('/api/v1/...', router)` calls**

In `services/mam-api/src/index.js`, insert this block immediately before the `// ── API Routes ──` block:

```js
// ── Auth gate ─────────────────────────────────────────────────────────────────
// Mount once for everything under /api/v1, with an explicit allowlist for
// the three pre-login auth paths and a carve-out for /cluster/* (node-agent
// uses migration 019's token-binding, not user auth). See spec.
const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']);
app.use('/api/v1', (req, res, next) => {
  if (UNAUTH_PATHS.has(req.path)) return next();
  if (req.path.startsWith('/cluster')) return next();   // node-agent service auth, not user auth
  return requireAuth(req, res, next);
});
```

- [ ] **Step 3: Smoke test — confirm an existing route is now 401-gated when AUTH_ENABLED=true**

```bash
cd services/mam-api
AUTH_ENABLED=true SESSION_SECRET=dev DATABASE_URL=$DATABASE_URL node src/index.js &
sleep 2
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/v1/assets
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/v1/cluster
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3000/health
kill %1
```

Expected: `401`, `200` (cluster carve-out), `200` (health is outside /api/v1).

- [ ] **Step 4: Confirm AUTH_ENABLED=false still lets traffic through**

```bash
AUTH_ENABLED=false SESSION_SECRET=dev DATABASE_URL=$DATABASE_URL node src/index.js &
sleep 2
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/v1/assets
kill %1
```

Expected: `200`.

- [ ] **Step 5: Commit**

```bash
git add services/mam-api/src/index.js
git commit -m "feat(mam-api): mount requireAuth gate at /api/v1 with auth + cluster carve-outs"
```

---

## Task 7: Auth router skeleton + setup-required

**Files:**
- Create: `services/mam-api/src/routes/auth.js`
- Create: `services/mam-api/test/routes/auth.test.js`
- Modify: `services/mam-api/src/index.js`

- [ ] **Step 1: Write the failing test**

Create `services/mam-api/test/routes/auth.test.js`:

```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import express from 'express';
import authRouter from '../../src/routes/auth.js';

async function appWithAuth(pool) {
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  const app = express();
  app.use(express.json());
  app.use('/api/v1/auth', authRouter);
  return new Promise(r => {
    const srv = app.listen(0, '127.0.0.1', () => {
      r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
    });
  });
}

test('GET /auth/setup-required returns { required: true } on empty users (modulo dev seed)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  // setup-required treats the dev seed as a non-user — required iff no real user exists.
  const { baseUrl, close } = await appWithAuth(pool);
  try {
    const res = await fetch(baseUrl + '/api/v1/auth/setup-required');
    assert.equal(res.status, 200);
    assert.deepEqual(await res.json(), { required: true });
  } finally { await close(); await pool.end(); }
});

test('GET /auth/setup-required returns { required: false } once a real user exists', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  await pool.query(`INSERT INTO users (username, password_hash) VALUES ('admin', 'x')`);
  const { baseUrl, close } = await appWithAuth(pool);
  try {
    const res = await fetch(baseUrl + '/api/v1/auth/setup-required');
    assert.deepEqual(await res.json(), { required: false });
  } finally { await close(); await pool.end(); }
});
```

- [ ] **Step 2: Run, confirm it fails**

```bash
node --test test/routes/auth.test.js
```

Expected: module-not-found for `../../src/routes/auth.js`.

- [ ] **Step 3: Implement the skeleton + setup-required**

Create `services/mam-api/src/routes/auth.js`:

```js
import express from 'express';
import pool from '../db/pool.js';
import { DEV_USER_ID } from '../middleware/auth.js';

const router = express.Router();

// Real users = anyone except the seeded dev row.
async function realUserCount() {
  const { rows } = await pool.query(
    `SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]);
  return rows[0].n;
}

// GET /api/v1/auth/setup-required
// Cheap, no auth. Used by AuthGate to decide between Login and Setup screens.
router.get('/setup-required', async (_req, res, next) => {
  try {
    res.json({ required: (await realUserCount()) === 0 });
  } catch (err) { next(err); }
});

export default router;
export { realUserCount };
```

- [ ] **Step 4: Mount the auth router in index.js (BEFORE the auth gate's allowlist matters)**

In `services/mam-api/src/index.js`, in the API Routes block, add:

```js
import authRouter from './routes/auth.js';
// ...
app.use('/api/v1/auth', authRouter);
```

Order matters: this must be registered AFTER the gate (since Express matches in registration order — the gate's allowlist `req.path` check correctly lets `/auth/setup-required` through to the router below it).

- [ ] **Step 5: Run the test, confirm it passes**

```bash
TEST_DATABASE_URL=postgres://wilddragon:test@localhost:5432/wilddragon_test \
  node --test test/routes/auth.test.js
```

Expected: 2 passes.

- [ ] **Step 6: Commit**

```bash
git add services/mam-api/src/routes/auth.js services/mam-api/src/index.js services/mam-api/test/routes/auth.test.js
git commit -m "feat(mam-api): auth router skeleton + setup-required endpoint"
```

---

## Task 8: Setup endpoint (first-run admin creation)

**Files:**
- Modify: `services/mam-api/src/routes/auth.js`
- Modify: `services/mam-api/test/routes/auth.test.js`

- [ ] **Step 1: Add the failing tests**

Append to `services/mam-api/test/routes/auth.test.js`:

```js
import session from 'express-session';

async function appWithSession(pool) {
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  process.env.SESSION_SECRET = 'test';
  process.env.AUTH_ENABLED = 'true';
  const ConnectPg = (await import('connect-pg-simple')).default(session);
  const app = express();
  app.use(express.json());
  app.use(session({
    store: new ConnectPg({ pool, tableName: 'sessions' }),
    secret: 'test', name: 'dragonflight.sid',
    cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
    rolling: false, resave: false, saveUninitialized: false,
  }));
  app.use('/api/v1/auth', authRouter);
  return new Promise(r => {
    const srv = app.listen(0, '127.0.0.1', () => {
      r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
    });
  });
}

test('POST /auth/setup creates the first admin and returns a session cookie', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  const { baseUrl, close } = await appWithSession(pool);
  try {
    const res = await fetch(baseUrl + '/api/v1/auth/setup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }),
    });
    assert.equal(res.status, 200);
    const body = await res.json();
    assert.equal(body.user.username, 'admin');
    assert.match(res.headers.get('set-cookie') || '', /dragonflight\.sid=/);
    const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM users WHERE username='admin'`);
    assert.equal(rows[0].n, 1);
  } finally { await close(); await pool.end(); }
});

test('POST /auth/setup is 409 once a real user exists', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  await pool.query(`INSERT INTO users (username, password_hash) VALUES ('existing', 'x')`);
  const { baseUrl, close } = await appWithSession(pool);
  try {
    const res = await fetch(baseUrl + '/api/v1/auth/setup', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }),
    });
    assert.equal(res.status, 409);
    assert.equal((await res.json()).error, 'setup already complete');
  } finally { await close(); await pool.end(); }
});

test('POST /auth/setup rejects passwords shorter than 12 chars', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  const { baseUrl, close } = await appWithSession(pool);
  try {
    const res = await fetch(baseUrl + '/api/v1/auth/setup', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username: 'admin', password: 'short' }),
    });
    assert.equal(res.status, 400);
  } finally { await close(); await pool.end(); }
});
```

- [ ] **Step 2: Run, confirm the new tests fail**

```bash
TEST_DATABASE_URL=... node --test test/routes/auth.test.js
```

Expected: previous 2 still pass, new 3 fail (`Cannot POST /api/v1/auth/setup` → 404, or `setup is not a function`).

- [ ] **Step 3: Implement setup**

Append to `services/mam-api/src/routes/auth.js` (before `export default router;`):

```js
import { hashPassword } from '../auth/passwords.js';

const MIN_PASSWORD_LEN = 12;

function badRequest(res, msg) { return res.status(400).json({ error: msg }); }

// POST /api/v1/auth/setup — first-run admin creation, locked out forever once a real user exists.
router.post('/setup', async (req, res, next) => {
  try {
    const { username, password } = req.body || {};
    if (!username || typeof username !== 'string') return badRequest(res, 'username required');
    if (!password || typeof password !== 'string') return badRequest(res, 'password required');
    if (password.length < MIN_PASSWORD_LEN)        return badRequest(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' characters');

    if ((await realUserCount()) > 0) {
      return res.status(409).json({ error: 'setup already complete' });
    }

    const hash = await hashPassword(password);
    const { rows } = await pool.query(
      `INSERT INTO users (username, password_hash, display_name, role)
       VALUES ($1, $2, $1, 'admin')
       RETURNING id, username, display_name`,
      [username.trim(), hash]
    );
    const user = rows[0];

    // Immediately log them in.
    req.session.user_id       = user.id;
    req.session.first_seen_at = Date.now();
    req.session.last_seen_at  = Date.now();
    await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));

    res.json({ user });
  } catch (err) {
    if (err.code === '23505') return res.status(409).json({ error: 'username already exists' });
    next(err);
  }
});
```

- [ ] **Step 4: Run, confirm all 5 tests pass**

```bash
TEST_DATABASE_URL=... node --test test/routes/auth.test.js
```

Expected: 5/5 pass.

- [ ] **Step 5: Commit**

```bash
git add services/mam-api/src/routes/auth.js services/mam-api/test/routes/auth.test.js
git commit -m "feat(mam-api): POST /auth/setup — first-run admin creation"
```

---

## Task 9: Login endpoint + the redirect-loop regression test

**Files:**
- Modify: `services/mam-api/src/routes/auth.js`
- Modify: `services/mam-api/test/routes/auth.test.js`

- [ ] **Step 1: Add the failing tests, including the regression test**

Append to `services/mam-api/test/routes/auth.test.js`:

```js
import { hashPassword } from '../../src/auth/passwords.js';
import { requireAuth } from '../../src/middleware/auth.js';

async function appWithSessionAndMe(pool) {
  // Same as appWithSession but also mounts a tiny /me endpoint behind requireAuth
  // so we can exercise the round-trip: login → cookie sent → /me 200.
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  process.env.AUTH_ENABLED = 'true';
  const ConnectPg = (await import('connect-pg-simple')).default(session);
  const app = express();
  app.use(express.json());
  app.use(session({
    store: new ConnectPg({ pool, tableName: 'sessions' }),
    secret: 'test', name: 'dragonflight.sid',
    cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
    rolling: false, resave: false, saveUninitialized: false,
  }));
  app.use('/api/v1/auth', authRouter);
  app.get('/api/v1/protected/me', requireAuth, (req, res) => res.json({ user: req.user }));
  return new Promise(r => {
    const srv = app.listen(0, '127.0.0.1', () => {
      r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
    });
  });
}

test('POST /auth/login with valid creds → 200 + cookie, and the cookie unlocks subsequent requests (regression: redirect loop)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  const hash = await hashPassword('correct-horse-battery');
  await pool.query(`INSERT INTO users (username, password_hash, display_name) VALUES ('alice', $1, 'Alice')`, [hash]);
  const { baseUrl, close } = await appWithSessionAndMe(pool);
  try {
    // 1. Login.
    const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
    });
    assert.equal(loginRes.status, 200);
    const setCookie = loginRes.headers.get('set-cookie');
    assert.match(setCookie || '', /dragonflight\.sid=/, 'expected Set-Cookie with dragonflight.sid');

    // 2. The SAME cookie must unlock the next request. This is the bug that
    //    produced the original redirect loop — login returned 200 but no cookie
    //    was persisted, so the next request 401'd and the SPA bounced.
    const meRes = await fetch(baseUrl + '/api/v1/protected/me', {
      headers: { cookie: setCookie.split(';')[0] },
    });
    assert.equal(meRes.status, 200, 'POST /login returned 200 but the cookie did not unlock /me — this is the regression');
    assert.equal((await meRes.json()).user.username, 'alice');
  } finally { await close(); await pool.end(); }
});

test('POST /auth/login with wrong password → 401 + generic message (no enumeration)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  const hash = await hashPassword('correct-horse-battery');
  await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]);
  const { baseUrl, close } = await appWithSessionAndMe(pool);
  try {
    const r1 = await fetch(baseUrl + '/api/v1/auth/login', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username: 'alice', password: 'wrong' }),
    });
    const r2 = await fetch(baseUrl + '/api/v1/auth/login', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username: 'nobody', password: 'whatever-long-enough' }),
    });
    assert.equal(r1.status, 401);
    assert.equal(r2.status, 401);
    const e1 = (await r1.json()).error, e2 = (await r2.json()).error;
    assert.equal(e1, 'invalid credentials');
    assert.equal(e2, 'invalid credentials');  // identical message — no enumeration
  } finally { await close(); await pool.end(); }
});
```

- [ ] **Step 2: Run, confirm the new tests fail**

Expected: 404 on `/auth/login`.

- [ ] **Step 3: Implement login**

Append to `services/mam-api/src/routes/auth.js` (before `export default router;`):

```js
import { comparePassword } from '../auth/passwords.js';

// POST /api/v1/auth/login
router.post('/login', async (req, res, next) => {
  try {
    const { username, password } = req.body || {};
    if (!username || !password) return res.status(401).json({ error: 'invalid credentials' });

    const { rows } = await pool.query(
      `SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`,
      [username.trim(), DEV_USER_ID]
    );
    if (rows.length === 0) {
      // Still hash the supplied password against a dummy to keep response time uniform.
      await comparePassword(password, '$2b$12$dummyhashthatwillalwaysfailtocomparexxxxxxxxxxxxxxxxxxxx');
      return res.status(401).json({ error: 'invalid credentials' });
    }
    const user = rows[0];
    if (!(await comparePassword(password, user.password_hash))) {
      return res.status(401).json({ error: 'invalid credentials' });
    }

    req.session.user_id       = user.id;
    req.session.first_seen_at = Date.now();
    req.session.last_seen_at  = Date.now();
    // The critical line — wait for the row to land in `sessions` before responding.
    // Without this, the SPA's next request races the store write, hits 401, and
    // the prior bounce-to-login logic produced an infinite loop.
    await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));

    await pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]).catch(() => {});

    res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
  } catch (err) { next(err); }
});
```

- [ ] **Step 4: Run, confirm all login tests pass — especially the regression**

```bash
TEST_DATABASE_URL=... node --test test/routes/auth.test.js
```

Expected: regression test name `(regression: redirect loop)` passes. All ≥7 tests pass overall.

- [ ] **Step 5: Commit**

```bash
git add services/mam-api/src/routes/auth.js services/mam-api/test/routes/auth.test.js
git commit -m "feat(mam-api): POST /auth/login + redirect-loop regression test"
```

---

## Task 10: Logout endpoint

**Files:**
- Modify: `services/mam-api/src/routes/auth.js`
- Modify: `services/mam-api/test/routes/auth.test.js`

- [ ] **Step 1: Add the failing test**

Append to `services/mam-api/test/routes/auth.test.js`:

```js
test('POST /auth/logout destroys the session row and the cookie no longer unlocks /me', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  const hash = await hashPassword('correct-horse-battery');
  await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]);
  const { baseUrl, close } = await appWithSessionAndMe(pool);
  try {
    const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
    });
    const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0];

    const logoutRes = await fetch(baseUrl + '/api/v1/auth/logout', {
      method: 'POST', headers: { cookie },
    });
    assert.equal(logoutRes.status, 204);

    const meRes = await fetch(baseUrl + '/api/v1/protected/me', { headers: { cookie } });
    assert.equal(meRes.status, 401);
  } finally { await close(); await pool.end(); }
});
```

- [ ] **Step 2: Run, confirm it fails (404)**

- [ ] **Step 3: Implement logout**

Append to `services/mam-api/src/routes/auth.js`:

```js
// POST /api/v1/auth/logout — destroys the session and clears the cookie.
router.post('/logout', (req, res) => {
  if (!req.session) return res.status(204).end();
  req.session.destroy(err => {
    if (err) console.error('[auth] session destroy failed:', err.message);
    res.clearCookie('dragonflight.sid', { path: '/' });
    res.status(204).end();
  });
});
```

- [ ] **Step 4: Run, confirm it passes**

- [ ] **Step 5: Commit**

```bash
git add services/mam-api/src/routes/auth.js services/mam-api/test/routes/auth.test.js
git commit -m "feat(mam-api): POST /auth/logout"
```

---

## Task 11: /auth/me and POST /auth/password

**Files:**
- Modify: `services/mam-api/src/routes/auth.js`
- Modify: `services/mam-api/test/routes/auth.test.js`

- [ ] **Step 1: Add failing tests**

Append to `services/mam-api/test/routes/auth.test.js`:

```js
test('GET /auth/me returns the authed user', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  const hash = await hashPassword('correct-horse-battery');
  await pool.query(`INSERT INTO users (username, password_hash, display_name) VALUES ('alice', $1, 'Alice')`, [hash]);
  const { baseUrl, close } = await appWithSessionAndMe(pool);
  try {
    const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
    });
    const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0];
    const me = await fetch(baseUrl + '/api/v1/auth/me', { headers: { cookie } });
    assert.equal(me.status, 200);
    const body = await me.json();
    assert.equal(body.username, 'alice');
    assert.equal(body.display_name, 'Alice');
  } finally { await close(); await pool.end(); }
});

test('POST /auth/password rotates the password when current is correct', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  const hash = await hashPassword('correct-horse-battery');
  await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]);
  const { baseUrl, close } = await appWithSessionAndMe(pool);
  try {
    const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
    });
    const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0];
    const change = await fetch(baseUrl + '/api/v1/auth/password', {
      method: 'POST', headers: { 'Content-Type': 'application/json', cookie },
      body: JSON.stringify({ current_password: 'correct-horse-battery', new_password: 'brand-new-passphrase' }),
    });
    assert.equal(change.status, 204);
    // Wrong current → 400
    const wrong = await fetch(baseUrl + '/api/v1/auth/password', {
      method: 'POST', headers: { 'Content-Type': 'application/json', cookie },
      body: JSON.stringify({ current_password: 'no', new_password: 'another-good-passphrase' }),
    });
    assert.equal(wrong.status, 400);
  } finally { await close(); await pool.end(); }
});
```

The new `/api/v1/auth/me` and `/api/v1/auth/password` routes must be behind `requireAuth`. Since the test app mounts the router directly without the gate, the router itself must apply `requireAuth` to these routes — gate-allowlist or not, the requirement is enforced at the route level for these two.

Wait — that contradicts the architecture (gate at the top, allowlist for the 3 unauth paths). Rationale: the gate decides routing-level access, but the auth router benefits from also calling `requireAuth` inline on `me` and `password` so the route file is **readable in isolation** ("this route requires auth — right there"). Re-running `requireAuth` is cheap; it short-circuits on the dev path or hits the same session lookup.

- [ ] **Step 2: Run, confirm the new tests fail**

- [ ] **Step 3: Implement /me and /password**

Append to `services/mam-api/src/routes/auth.js`:

```js
import { requireAuth } from '../middleware/auth.js';

// GET /api/v1/auth/me
router.get('/me', requireAuth, (req, res) => {
  res.json({ id: req.user.id, username: req.user.username, display_name: req.user.display_name });
});

// POST /api/v1/auth/password { current_password, new_password }
router.post('/password', requireAuth, async (req, res, next) => {
  try {
    const { current_password, new_password } = req.body || {};
    if (!current_password || !new_password) return badRequest(res, 'current_password and new_password required');
    if (new_password.length < MIN_PASSWORD_LEN) return badRequest(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' characters');

    const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
    if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
    if (!(await comparePassword(current_password, rows[0].password_hash))) {
      return badRequest(res, 'current password is incorrect');
    }
    const newHash = await hashPassword(new_password);
    await pool.query(
      `UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2`,
      [newHash, req.user.id]
    );
    res.status(204).end();
  } catch (err) { next(err); }
});
```

- [ ] **Step 4: Run, confirm all auth router tests pass**

- [ ] **Step 5: Commit**

```bash
git add services/mam-api/src/routes/auth.js services/mam-api/test/routes/auth.test.js
git commit -m "feat(mam-api): GET /auth/me + POST /auth/password"
```

---

## Task 12: User CRUD (admin reset password lives here)

**Files:**
- Create: `services/mam-api/src/routes/users.js`
- Create: `services/mam-api/test/routes/users.test.js`
- Modify: `services/mam-api/src/index.js`
- Modify: `services/mam-api/src/routes/groups.js` (verify; should already exist and stay untouched)

> **Note:** A `users.js` route file may not currently exist; the existing `data.jsx` calls `/api/v1/users` and `loadData` includes it. If you find an existing `routes/users.js`, replace it with this implementation only if its responsibilities match — otherwise, add the routes below as new sub-paths under the existing router and reconcile. Check before overwriting.

- [ ] **Step 1: Check the current state of /api/v1/users**

```bash
grep -rn "api/v1/users\|users.js" services/mam-api/src/index.js services/mam-api/src/routes/ 2>&1
```

If a `routes/users.js` exists and is mounted, **read it** and adjust the steps below to extend rather than replace. The spec's behavior — list, create, delete, admin-reset-password — must be present at the end of this task.

- [ ] **Step 2: Write failing tests**

Create `services/mam-api/test/routes/users.test.js`:

```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import session from 'express-session';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import usersRouter from '../../src/routes/users.js';
import authRouter  from '../../src/routes/auth.js';
import { requireAuth } from '../../src/middleware/auth.js';
import { hashPassword } from '../../src/auth/passwords.js';

async function app(pool) {
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  process.env.AUTH_ENABLED = 'true';
  const ConnectPg = (await import('connect-pg-simple')).default(session);
  const a = express();
  a.use(express.json());
  a.use(session({
    store: new ConnectPg({ pool, tableName: 'sessions' }),
    secret: 'test', name: 'dragonflight.sid',
    cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
    rolling: false, resave: false, saveUninitialized: false,
  }));
  a.use('/api/v1/auth', authRouter);
  a.use('/api/v1/auth/users', requireAuth, usersRouter);
  return new Promise(r => {
    const srv = a.listen(0, '127.0.0.1', () => {
      r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
    });
  });
}

async function login(baseUrl, username, password) {
  const r = await fetch(baseUrl + '/api/v1/auth/login', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username, password }),
  });
  assert.equal(r.status, 200, 'login failed: ' + JSON.stringify(await r.json()));
  return (r.headers.get('set-cookie') || '').split(';')[0];
}

test('users: list + create + delete + admin reset password', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  await pool.query(`INSERT INTO users (username, password_hash) VALUES ('admin', $1)`, [await hashPassword('admin-passphrase!!')]);
  const { baseUrl, close } = await app(pool);
  try {
    const cookie = await login(baseUrl, 'admin', 'admin-passphrase!!');

    // List
    const list = await fetch(baseUrl + '/api/v1/auth/users', { headers: { cookie } });
    assert.equal(list.status, 200);
    const users0 = await list.json();
    assert.ok(users0.find(u => u.username === 'admin'));

    // Create
    const created = await fetch(baseUrl + '/api/v1/auth/users', {
      method: 'POST', headers: { 'Content-Type': 'application/json', cookie },
      body: JSON.stringify({ username: 'bob', password: 'bob-passphrase!', display_name: 'Bob' }),
    });
    assert.equal(created.status, 201);
    const bob = await created.json();
    assert.equal(bob.username, 'bob');

    // Admin reset password
    const reset = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id + '/password', {
      method: 'POST', headers: { 'Content-Type': 'application/json', cookie },
      body: JSON.stringify({ new_password: 'a-fresh-passphrase' }),
    });
    assert.equal(reset.status, 204);

    // Delete
    const del = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id, {
      method: 'DELETE', headers: { cookie },
    });
    assert.equal(del.status, 204);
  } finally { await close(); await pool.end(); }
});

test('users: cannot delete the last real user', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  await pool.query(`INSERT INTO users (id, username, password_hash) VALUES (uuid_generate_v4(), 'solo', $1)`, [await hashPassword('only-user-here-12')]);
  const { baseUrl, close } = await app(pool);
  try {
    const cookie = await login(baseUrl, 'solo', 'only-user-here-12');
    const me = await (await fetch(baseUrl + '/api/v1/auth/me', { headers: { cookie } })).json();
    const r = await fetch(baseUrl + '/api/v1/auth/users/' + me.id, { method: 'DELETE', headers: { cookie } });
    assert.equal(r.status, 409);
    assert.equal((await r.json()).error, 'cannot delete last user');
  } finally { await close(); await pool.end(); }
});
```

- [ ] **Step 3: Run, confirm both tests fail (module not found)**

- [ ] **Step 4: Implement users.js**

Create `services/mam-api/src/routes/users.js`:

```js
// User CRUD. Mounted at /api/v1/auth/users by index.js (behind requireAuth via the gate).
// Flat access: any logged-in user can manage other users (spec).
import express from 'express';
import pool from '../db/pool.js';
import { hashPassword } from '../auth/passwords.js';
import { DEV_USER_ID } from '../middleware/auth.js';

const router = express.Router();
const MIN_PASSWORD_LEN = 12;

function bad(res, msg) { return res.status(400).json({ error: msg }); }

// GET / — list users (real ones; dev seed hidden)
router.get('/', async (_req, res, next) => {
  try {
    const { rows } = await pool.query(
      `SELECT id, username, display_name, role, last_login_at, created_at
       FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]);
    res.json(rows);
  } catch (err) { next(err); }
});

// POST / — create user
router.post('/', async (req, res, next) => {
  try {
    const { username, password, display_name, role } = req.body || {};
    if (!username || typeof username !== 'string') return bad(res, 'username required');
    if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
    const hash = await hashPassword(password);
    const { rows } = await pool.query(
      `INSERT INTO users (username, password_hash, display_name, role)
       VALUES ($1, $2, $3, $4)
       RETURNING id, username, display_name, role, created_at`,
      [username.trim(), hash, display_name || username.trim(), role || 'admin']
    );
    res.status(201).json(rows[0]);
  } catch (err) {
    if (err.code === '23505') return res.status(409).json({ error: 'username already exists' });
    next(err);
  }
});

// POST /:id/password — admin reset another user's password
router.post('/:id/password', async (req, res, next) => {
  try {
    const { new_password } = req.body || {};
    if (!new_password || new_password.length < MIN_PASSWORD_LEN) {
      return bad(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' chars');
    }
    const hash = await hashPassword(new_password);
    const { rowCount } = await pool.query(
      `UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2 AND id <> $3`,
      [hash, req.params.id, DEV_USER_ID]);
    if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
    res.status(204).end();
  } catch (err) { next(err); }
});

// DELETE /:id — delete a user, except the last real user
router.delete('/:id', async (req, res, next) => {
  try {
    if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot delete dev user' });
    const { rows } = await pool.query(
      `SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]);
    if (rows[0].n <= 1) return res.status(409).json({ error: 'cannot delete last user' });
    const { rowCount } = await pool.query(`DELETE FROM users WHERE id = $1`, [req.params.id]);
    if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
    res.status(204).end();
  } catch (err) { next(err); }
});

export default router;
```

- [ ] **Step 5: Wire the router in index.js**

In `services/mam-api/src/index.js`, in the API Routes block (next to other router mounts), add:

```js
import usersRouter from './routes/users.js';
// ...
app.use('/api/v1/auth/users', usersRouter);
```

This sits behind the auth gate from Task 6 (the gate sees `/auth/users/...`, which is not in the unauth allowlist, so `requireAuth` runs).

- [ ] **Step 6: Run, confirm tests pass**

```bash
TEST_DATABASE_URL=... node --test test/routes/users.test.js
```

- [ ] **Step 7: Commit**

```bash
git add services/mam-api/src/routes/users.js services/mam-api/src/index.js services/mam-api/test/routes/users.test.js
git commit -m "feat(mam-api): user CRUD + admin password reset + last-user delete guard"
```

---

## Task 13: API token CRUD (Premiere panel transport)

**Files:**
- Create: `services/mam-api/src/routes/tokens.js`
- Create: `services/mam-api/test/routes/tokens.test.js`
- Modify: `services/mam-api/src/index.js`

- [ ] **Step 1: Write failing tests**

Create `services/mam-api/test/routes/tokens.test.js`:

```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import express from 'express';
import session from 'express-session';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import tokensRouter from '../../src/routes/tokens.js';
import authRouter   from '../../src/routes/auth.js';
import { requireAuth } from '../../src/middleware/auth.js';
import { hashPassword } from '../../src/auth/passwords.js';

async function app(pool) {
  process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
  process.env.AUTH_ENABLED = 'true';
  const ConnectPg = (await import('connect-pg-simple')).default(session);
  const a = express();
  a.use(express.json());
  a.use(session({
    store: new ConnectPg({ pool, tableName: 'sessions' }),
    secret: 'test', name: 'dragonflight.sid',
    cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
    rolling: false, resave: false, saveUninitialized: false,
  }));
  a.use('/api/v1/auth', authRouter);
  a.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
  // Tiny protected route to prove the issued token works for bearer auth.
  a.get('/api/v1/protected/ping', requireAuth, (req, res) => res.json({ user: req.user.username }));
  return new Promise(r => {
    const srv = a.listen(0, '127.0.0.1', () => {
      r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
    });
  });
}

async function loginCookie(baseUrl, u, p) {
  const r = await fetch(baseUrl + '/api/v1/auth/login', {
    method: 'POST', headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ username: u, password: p }),
  });
  return (r.headers.get('set-cookie') || '').split(';')[0];
}

test('tokens: create returns the raw token exactly once; bearer of that token works; revoke 401s subsequent calls', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
  const { baseUrl, close } = await app(pool);
  try {
    const cookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery');

    // Create
    const create = await fetch(baseUrl + '/api/v1/auth/tokens', {
      method: 'POST', headers: { 'Content-Type': 'application/json', cookie },
      body: JSON.stringify({ name: 'Premiere panel' }),
    });
    assert.equal(create.status, 201);
    const created = await create.json();
    assert.match(created.token, /^dfl_[0-9a-f]{64}$/);
    assert.equal(created.prefix, created.token.slice(0, 8));

    // List — should NOT include the raw token, only the prefix.
    const list = await fetch(baseUrl + '/api/v1/auth/tokens', { headers: { cookie } });
    const rows = await list.json();
    assert.equal(rows.length, 1);
    assert.equal(rows[0].prefix, created.prefix);
    assert.equal(rows[0].token, undefined);

    // The raw token authenticates as a bearer.
    const ping = await fetch(baseUrl + '/api/v1/protected/ping', {
      headers: { authorization: 'Bearer ' + created.token },
    });
    assert.equal(ping.status, 200);

    // Revoke.
    const rev = await fetch(baseUrl + '/api/v1/auth/tokens/' + created.id, {
      method: 'DELETE', headers: { cookie },
    });
    assert.equal(rev.status, 204);

    // Same bearer now 401s.
    const ping2 = await fetch(baseUrl + '/api/v1/protected/ping', {
      headers: { authorization: 'Bearer ' + created.token },
    });
    assert.equal(ping2.status, 401);
  } finally { await close(); await pool.end(); }
});

test('tokens: cannot revoke another users token', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
  const pool = await setupTestDb();
  await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
  await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob',   $1)`, [await hashPassword('bob-passphrase-12')]);
  const { baseUrl, close } = await app(pool);
  try {
    const aliceCookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery');
    const bobCookie   = await loginCookie(baseUrl, 'bob',   'bob-passphrase-12');
    const aliceTok = await (await fetch(baseUrl + '/api/v1/auth/tokens', {
      method: 'POST', headers: { 'Content-Type': 'application/json', cookie: aliceCookie },
      body: JSON.stringify({ name: 'alice token' }),
    })).json();
    const r = await fetch(baseUrl + '/api/v1/auth/tokens/' + aliceTok.id, {
      method: 'DELETE', headers: { cookie: bobCookie },
    });
    assert.equal(r.status, 404);  // not found from bob's perspective
  } finally { await close(); await pool.end(); }
});
```

- [ ] **Step 2: Run, confirm tests fail**

- [ ] **Step 3: Implement tokens.js**

Create `services/mam-api/src/routes/tokens.js`:

```js
// Current-user API token CRUD. The raw token is returned exactly once at
// creation time; only the SHA-256 hash and an 8-char display prefix are stored.
import express from 'express';
import pool from '../db/pool.js';
import { generateToken, hashToken, tokenDisplayPrefix } from '../auth/tokens.js';

const router = express.Router();

// GET / — list current users tokens (prefix only, never the raw token)
router.get('/', async (req, res, next) => {
  try {
    const { rows } = await pool.query(
      `SELECT id, name, token_prefix AS prefix, last_used_at, expires_at, created_at
       FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC`,
      [req.user.id]);
    res.json(rows);
  } catch (err) { next(err); }
});

// POST / — create a new token
router.post('/', async (req, res, next) => {
  try {
    const { name, expires_at } = req.body || {};
    if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' });
    const raw = generateToken();
    const { rows } = await pool.query(
      `INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at)
       VALUES ($1, $2, $3, $4, $5)
       RETURNING id, name, token_prefix AS prefix, expires_at, created_at`,
      [req.user.id, name.trim(), hashToken(raw), tokenDisplayPrefix(raw), expires_at || null]);
    res.status(201).json({ ...rows[0], token: raw });   // raw token shown exactly once
  } catch (err) { next(err); }
});

// DELETE /:id — revoke; only the owner can revoke
router.delete('/:id', async (req, res, next) => {
  try {
    const { rowCount } = await pool.query(
      `DELETE FROM api_tokens WHERE id = $1 AND user_id = $2`,
      [req.params.id, req.user.id]);
    if (rowCount === 0) return res.status(404).json({ error: 'token not found' });
    res.status(204).end();
  } catch (err) { next(err); }
});

export default router;
```

- [ ] **Step 4: Wire the router in index.js**

In `services/mam-api/src/index.js`:

```js
import tokensRouter from './routes/tokens.js';
// ...
app.use('/api/v1/auth/tokens', tokensRouter);
```

- [ ] **Step 5: Run tests, confirm pass**

- [ ] **Step 6: Commit**

```bash
git add services/mam-api/src/routes/tokens.js services/mam-api/src/index.js services/mam-api/test/routes/tokens.test.js
git commit -m "feat(mam-api): API token CRUD — show raw once, bearer-authenticate via SHA-256 lookup"
```

---

## Task 14: Login rate limit + CSRF X-Requested-With header

**Files:**
- Create: `services/mam-api/src/auth/rate-limit.js`
- Modify: `services/mam-api/src/routes/auth.js`
- Modify: `services/mam-api/src/index.js`
- Create: `services/mam-api/test/auth/rate-limit.test.js`

- [ ] **Step 1: Write the failing rate-limit test**

Create `services/mam-api/test/auth/rate-limit.test.js`:

```js
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ipBackoff } from '../../src/auth/rate-limit.js';

test('first failure → small delay; repeated failures → exponential up to 30s', () => {
  ipBackoff.reset('1.2.3.4');
  assert.equal(ipBackoff.delayMs('1.2.3.4'), 0);  // no delay before first call
  ipBackoff.recordFailure('1.2.3.4');
  assert.equal(ipBackoff.delayMs('1.2.3.4'), 1000);
  ipBackoff.recordFailure('1.2.3.4');
  assert.equal(ipBackoff.delayMs('1.2.3.4'), 2000);
  ipBackoff.recordFailure('1.2.3.4');
  assert.equal(ipBackoff.delayMs('1.2.3.4'), 4000);
  for (let i = 0; i < 10; i++) ipBackoff.recordFailure('1.2.3.4');
  assert.equal(ipBackoff.delayMs('1.2.3.4'), 30000);
});

test('successful login resets the counter', () => {
  ipBackoff.reset('5.6.7.8');
  ipBackoff.recordFailure('5.6.7.8');
  ipBackoff.recordFailure('5.6.7.8');
  ipBackoff.recordSuccess('5.6.7.8');
  assert.equal(ipBackoff.delayMs('5.6.7.8'), 0);
});
```

- [ ] **Step 2: Run, confirm fail**

- [ ] **Step 3: Implement rate-limit.js**

Create `services/mam-api/src/auth/rate-limit.js`:

```js
// Per-IP exponential backoff for /auth/login. Single-instance — fine for
// Dragonflights deployment shape (one mam-api per node). Documented limitation.
const failures = new Map(); // ip -> count

const STEPS = [1000, 2000, 4000, 8000, 16000, 30000];

export const ipBackoff = {
  delayMs(ip) {
    const n = failures.get(ip) || 0;
    if (n === 0) return 0;
    return STEPS[Math.min(n - 1, STEPS.length - 1)];
  },
  recordFailure(ip) {
    failures.set(ip, (failures.get(ip) || 0) + 1);
  },
  recordSuccess(ip) { failures.delete(ip); },
  reset(ip)         { failures.delete(ip); },
};
```

- [ ] **Step 4: Run, confirm rate-limit tests pass**

- [ ] **Step 5: Wire the rate limit into POST /auth/login**

Edit `services/mam-api/src/routes/auth.js` — at the top of the `/login` handler, after `try {`:

```js
import { ipBackoff } from '../auth/rate-limit.js';
// ...
router.post('/login', async (req, res, next) => {
  try {
    const ip = req.ip || req.socket?.remoteAddress || 'unknown';
    const delay = ipBackoff.delayMs(ip);
    if (delay > 0) await new Promise(r => setTimeout(r, delay));
    // ... existing body (lookup user, comparePassword, etc.) ...
    // On failure paths:
    //   ipBackoff.recordFailure(ip); return res.status(401).json({ error: 'invalid credentials' });
    // On success path (before res.json):
    //   ipBackoff.recordSuccess(ip);
```

Update the existing failure returns in `/login` to call `ipBackoff.recordFailure(ip)` before responding 401; add `ipBackoff.recordSuccess(ip)` just before `res.json({ user })`.

- [ ] **Step 6: Add the X-Requested-With CSRF check as a separate middleware**

Append to `services/mam-api/src/middleware/auth.js`:

```js
// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site
// cookie sends, but a custom header that no <form> can produce hardens
// against the edge cases. Applied to mutating verbs only.
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
const REQUIRED_HEADER = 'dragonflight-ui';

export function requireUiHeader(req, res, next) {
  if (!MUTATING.has(req.method)) return next();
  // Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
  // browsers and can't be drive-by'd from another origin.
  if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
  if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next();
  return res.status(403).json({ error: 'missing X-Requested-With header' });
}
```

In `services/mam-api/src/index.js`, import and mount it just inside the `/api/v1` mount, **before** the auth gate:

```js
import { requireAuth, requireUiHeader } from './middleware/auth.js';
// ...
app.use('/api/v1', requireUiHeader);   // CSRF header check on mutating verbs
app.use('/api/v1', (req, res, next) => { /* the auth gate from Task 6 */ });
```

- [ ] **Step 7: Add CSRF test**

Append to `services/mam-api/test/middleware/auth.test.js`:

```js
import { requireUiHeader } from '../../src/middleware/auth.js';

test('requireUiHeader: GET → next (any header)', () => {
  let called = false;
  requireUiHeader({ method: 'GET', headers: {} }, { status: () => ({ json: () => {} }) }, () => { called = true; });
  assert.equal(called, true);
});

test('requireUiHeader: POST without header → 403', () => {
  const res = { status(n) { this.statusCode = n; return this; }, json(o) { this.body = o; } };
  requireUiHeader({ method: 'POST', headers: {} }, res, () => {});
  assert.equal(res.statusCode, 403);
});

test('requireUiHeader: POST with correct header → next', () => {
  let called = false;
  requireUiHeader({ method: 'POST', headers: { 'x-requested-with': 'dragonflight-ui' } }, {}, () => { called = true; });
  assert.equal(called, true);
});

test('requireUiHeader: POST with bearer auth → next (exempt)', () => {
  let called = false;
  requireUiHeader({ method: 'POST', headers: { authorization: 'Bearer dfl_xxx' } }, {}, () => { called = true; });
  assert.equal(called, true);
});
```

- [ ] **Step 8: Run the whole test suite**

```bash
TEST_DATABASE_URL=... npm test
```

Expected: all tests pass, including the new CSRF and rate-limit ones. The existing login test must be updated to send the `X-Requested-With: dragonflight-ui` header on all POSTs from the test helpers; do that now if any test broke.

- [ ] **Step 9: Commit**

```bash
git add services/mam-api/src/auth/rate-limit.js services/mam-api/src/middleware/auth.js services/mam-api/src/routes/auth.js services/mam-api/src/index.js services/mam-api/test/
git commit -m "feat(mam-api): login rate limit + X-Requested-With CSRF header check"
```

---

## Task 15: Frontend — AuthGate orchestration + replace data.jsx 401 bounce

**Files:**
- Create: `services/web-ui/public/auth-gate.jsx`
- Modify: `services/web-ui/public/data.jsx`
- Modify: `services/web-ui/public/shell.jsx`
- Modify: `services/web-ui/public/index.html`

- [ ] **Step 1: Create AuthGate (skeleton; screens come in Task 16)**

Create `services/web-ui/public/auth-gate.jsx`:

```jsx
// auth-gate.jsx — owns the "logged in or not" state.
//
// The SPA boots into <AuthGate>, which calls GET /auth/me. On 401 it then
// calls GET /auth/setup-required and renders <SetupScreen> or <LoginScreen>
// (defined in screens-auth.jsx). On 200 it renders the real <App>.
//
// This component is the SINGLE source of truth for the auth check — no other
// component should redirect to a login page or wipe data on 401. Other code
// surfaces auth failure by calling window.AuthGate.bounce(), which re-mounts
// the gate so the next /auth/me request decides what to do.

(function () {
  const API = '/api/v1';
  const LAST_PATH_KEY = 'df.auth.last_path';

  async function authFetch(path, opts) {
    return fetch(API + path, {
      credentials: 'include',
      ...opts,
      headers: {
        ...(opts && opts.headers),
        'Content-Type': 'application/json',
        ...((opts && opts.method && opts.method !== 'GET') ? { 'X-Requested-With': 'dragonflight-ui' } : {}),
      },
    });
  }

  function AuthGate({ children }) {
    const [state, setState] = React.useState({ kind: 'loading' });

    const check = React.useCallback(async () => {
      setState({ kind: 'loading' });
      try {
        const r = await authFetch('/auth/me');
        if (r.status === 200) {
          const me = await r.json();
          window.ZAMPP_DATA = window.ZAMPP_DATA || {};
          window.ZAMPP_DATA.ME = me;
          setState({ kind: 'authed' });
          return;
        }
      } catch (_) { /* fall through to setup/login decision */ }
      const setup = await authFetch('/auth/setup-required').then(r => r.json()).catch(() => ({ required: false }));
      setState({ kind: setup.required ? 'setup' : 'login' });
    }, []);

    React.useEffect(() => { check(); }, [check]);

    // Global hook: anything else (e.g. data.jsx 401 handler) can re-trigger
    // the gate after auth state changes server-side. Saves current path so
    // the user is restored to where they were after login.
    React.useEffect(() => {
      window.AuthGate = {
        bounce(reason) {
          try { sessionStorage.setItem(LAST_PATH_KEY, location.pathname + location.search); } catch {}
          if (reason) console.warn('[AuthGate] bounce:', reason);
          check();
        },
        signedIn() { check(); },
      };
    }, [check]);

    if (state.kind === 'loading') {
      return (
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: 'var(--bg-0)' }}>
          <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-3)' }}>Loading…</div>
        </div>
      );
    }
    if (state.kind === 'setup') return <SetupScreen onDone={() => window.AuthGate.signedIn()} />;
    if (state.kind === 'login') return <LoginScreen onDone={() => window.AuthGate.signedIn()} />;
    return children;
  }

  window.AuthGate = window.AuthGate || {};
  window.AuthGateComponent = AuthGate;
})();
```

- [ ] **Step 2: Update data.jsx — replace the /login.html bounce with AuthGate dispatch + add X-Requested-With header**

In `services/web-ui/public/data.jsx`, replace the existing `apiFetch` function (currently lines ~64-81) with:

```js
async function apiFetch(path, opts = {}) {
  const method = (opts.method || 'GET').toUpperCase();
  const headers = {
    ...(opts.headers || {}),
    'Content-Type': 'application/json',
  };
  if (method !== 'GET' && method !== 'HEAD') headers['X-Requested-With'] = 'dragonflight-ui';

  const res = await fetch(API + path, {
    credentials: 'include',
    ...opts,
    headers,
  });
  // 401: hand off to AuthGate, which will re-render Login (no full-page reload).
  if (res.status === 401) {
    if (window.AuthGate && typeof window.AuthGate.bounce === 'function') {
      window.AuthGate.bounce('apiFetch saw 401 on ' + path);
    }
    throw new Error('Unauthenticated');
  }
  if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
  return res.json();
}
```

- [ ] **Step 3: Update shell.jsx — sign-out calls AuthGate.bounce instead of /login.html**

In `services/web-ui/public/shell.jsx`, replace the sign-out button's `onClick` (currently around line 215):

```jsx
onClick={async () => {
  try { await window.ZAMPP_API.fetch('/auth/logout', { method: 'POST' }); } catch (_) {}
  window.AuthGate.bounce('user signed out');
}}
```

- [ ] **Step 4: Add the script tag to index.html (AuthGate must load AFTER React, BEFORE app.js)**

In `services/web-ui/public/index.html`, between the `dist/shell.js` line and the screens scripts, add the auth-gate + screens-auth tags. The new order (relevant section):

```html
<script src="dist/data.js"></script>
<script src="dist/icons.js"></script>
<script src="dist/visuals.js"></script>
<script src="dist/shell.js"></script>
<script src="dist/auth-gate.js"></script>       <!-- NEW -->
<script src="dist/screens-auth.js"></script>    <!-- NEW (created in Task 16) -->
<script src="dist/screens-home.js"></script>
... rest unchanged ...
<script src="dist/app.js"></script>
```

- [ ] **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(<App />);
```

to:

```jsx
root.render(<window.AuthGateComponent><App /></window.AuthGateComponent>);
```

- [ ] **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 `<SetupScreen>` 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 (
      <div style={{ textAlign: 'center', marginBottom: 22 }}>
        <div style={{ fontSize: 22, fontWeight: 600, color: 'var(--text-1)', letterSpacing: '-0.01em' }}>Dragonflight</div>
        <div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', marginTop: 6, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
          Wild Dragon Broadcast
        </div>
      </div>
    );
  }

  function Card({ children }) {
    return (
      <div style={{
        background: 'var(--bg-1)',
        border: '1px solid var(--border)',
        borderRadius: 12,
        padding: 22,
      }}>{children}</div>
    );
  }

  function Field({ label, type = 'text', value, onChange, autoComplete, autoFocus }) {
    return (
      <div style={{ marginBottom: 14 }}>
        <label style={{ display: 'block', fontSize: 10.5, fontWeight: 600, color: 'var(--text-2)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 6 }}>
          {label}
        </label>
        <input
          type={type}
          autoComplete={autoComplete}
          autoFocus={autoFocus}
          value={value}
          onChange={e => 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',
          }}
        />
      </div>
    );
  }

  function Button({ children, disabled, onClick, type = 'button' }) {
    return (
      <button
        type={type}
        disabled={disabled}
        onClick={onClick}
        style={{
          width: '100%',
          background: disabled ? 'var(--bg-3)' : 'var(--accent)',
          color: '#fff',
          border: 'none',
          borderRadius: 4,
          padding: '9px',
          fontSize: 13,
          fontWeight: 600,
          fontFamily: 'inherit',
          cursor: disabled ? 'default' : 'pointer',
          opacity: disabled ? 0.6 : 1,
        }}
      >{children}</button>
    );
  }

  function ErrorRow({ text }) {
    if (!text) return null;
    return (
      <div style={{
        fontSize: 11.5,
        color: 'var(--danger)',
        marginBottom: 12,
        padding: '6px 10px',
        background: 'var(--danger-soft)',
        border: '1px solid var(--danger)',
        borderRadius: 4,
      }}>{text}</div>
    );
  }

  function Screen({ children }) {
    return (
      <div style={{ minHeight: '100vh', background: 'var(--bg-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
        <form onSubmit={e => e.preventDefault()} style={{ width: 300 }}>
          <Brand />
          <Card>{children}</Card>
        </form>
      </div>
    );
  }

  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 (
      <Screen>
        <ErrorRow text={error} />
        <Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
        <Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
        <Button type="submit" disabled={busy || !username || !password} onClick={submit}>Sign in</Button>
      </Screen>
    );
  }

  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 (
      <Screen>
        <div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
          First-run setup — create the first admin
        </div>
        <ErrorRow text={error} />
        <Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
        <Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="new-password" />
        <Field label="Confirm password" type="password" value={confirm} onChange={setConfirm} autoComplete="new-password" />
        <Button type="submit" disabled={busy || !username || !password || !confirm} onClick={submit}>Create admin</Button>
      </Screen>
    );
  }

  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 `<SetupScreen ... />` 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 (
    <section className="panel" style={{ padding: 16, marginBottom: 16 }}>
      <h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>Account</h3>
      <div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 10, alignItems: 'center', maxWidth: 480 }}>
        <label>Current password</label>
        <input type="password" autoComplete="current-password" className="field-input" value={current} onChange={e => setCurrent(e.target.value)} />
        <label>New password</label>
        <input type="password" autoComplete="new-password" className="field-input" value={next} onChange={e => setNext(e.target.value)} />
        <label>Confirm new password</label>
        <input type="password" autoComplete="new-password" className="field-input" value={confirm} onChange={e => setConfirm(e.target.value)} />
      </div>
      {msg && (
        <div style={{ marginTop: 10, fontSize: 11.5, color: msg.kind === 'ok' ? 'var(--success)' : 'var(--danger)' }}>{msg.text}</div>
      )}
      <button className="btn primary sm" style={{ marginTop: 12 }} disabled={busy || !current || !next || !confirm} onClick={submit}>
        Change password
      </button>
    </section>
  );
}
```

- [ ] **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 (
    <section className="panel" style={{ padding: 16, marginBottom: 16 }}>
      <h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>API Tokens</h3>

      {justCreated && (
        <div style={{ marginBottom: 12, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
          <div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)', marginBottom: 6 }}>
            Save this token now — it will not be shown again
          </div>
          <div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-1)', wordBreak: 'break-all', marginBottom: 6 }}>{justCreated.token}</div>
          <button className="btn sm" onClick={() => navigator.clipboard.writeText(justCreated.token)}>Copy</button>
          <button className="btn sm" style={{ marginLeft: 8 }} onClick={() => setJustCreated(null)}>Dismiss</button>
        </div>
      )}

      <div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
        <input className="field-input" placeholder="Token name (e.g. Premiere panel)" value={name} onChange={e => setName(e.target.value)} style={{ flex: 1 }} />
        <button className="btn primary sm" disabled={busy || !name.trim()} onClick={create}>New token</button>
      </div>

      <div className="token-list">
        {tokens.length === 0 && <div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>No tokens yet.</div>}
        {tokens.map(t => (
          <div key={t.id} className="token-row" style={{ display: 'grid', gridTemplateColumns: '1fr 120px 140px 80px', gap: 10, alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
            <div>{t.name}</div>
            <div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-2)' }}>{t.prefix}…</div>
            <div style={{ fontSize: 11, color: 'var(--text-3)' }}>{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : 'never used'}</div>
            <button className="btn sm" onClick={() => revoke(t.id)}>Revoke</button>
          </div>
        ))}
      </div>
    </section>
  );
}
```

- [ ] **Step 4: Insert the two sections at the top of the Settings render**

Inside the `Settings` component's return, add:

```jsx
<AccountSection />
<ApiTokensSection />
```

…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 <pasted-token>" http://<host>/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 = '<bcrypt-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 <token>" 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.
