dragonflight/docs/superpowers/plans/2026-05-27-auth-system.md

109 KiB
Raw Blame History

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:

  "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:

// 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:

// 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:

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:

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):

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

Expected: smoke test passes.

  • Step 6: Commit
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:

-- 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
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:

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)
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
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:

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
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:

// 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
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:

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
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:

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
node --test test/auth/tokens.test.js

Expected: all 5 tests pass.

  • Step 9: Commit
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:

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
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:

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
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
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:

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:

// 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
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
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:

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:

// ── 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
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
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
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:

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
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:

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:

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
TEST_DATABASE_URL=postgres://wilddragon:test@localhost:5432/wilddragon_test \
  node --test test/routes/auth.test.js

Expected: 2 passes.

  • Step 6: Commit
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:

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
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;):

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
TEST_DATABASE_URL=... node --test test/routes/auth.test.js

Expected: 5/5 pass.

  • Step 5: Commit
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:

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;):

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
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
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:

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:

// 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

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:

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:

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

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
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:

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:

// 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:

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
TEST_DATABASE_URL=... node --test test/routes/users.test.js
  • Step 7: Commit
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:

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:

// 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:

import tokensRouter from './routes/tokens.js';
// ...
app.use('/api/v1/auth/tokens', tokensRouter);
  • Step 5: Run tests, confirm pass

  • Step 6: Commit

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:

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:

// 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 {:

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:

// 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:

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:

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
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
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:

// 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:

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):

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):

<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:

root.render(<App />);

to:

root.render(<window.AuthGateComponent><App /></window.AuthGateComponent>);
  • Step 6: Build + manual smoke
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
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:

// 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:

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
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
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
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):

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:

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:

<AccountSection />
<ApiTokensSection />

…before the existing S3 / AMPP / Transcoding sections.

  • Step 5: Build + manual smoke
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:
    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
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

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:

const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';

to:

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:

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

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