2888 lines
109 KiB
Markdown
2888 lines
109 KiB
Markdown
# Dragonflight Auth System Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Wire a working end-to-end auth system into Dragonflight (mam-api + web-ui) so `AUTH_ENABLED=true` produces a real login that survives reloads, instead of the redirect loop that the prior attempts produced.
|
||
|
||
**Architecture:** Hand-rolled `express-session` + `connect-pg-simple` for browser sessions, SHA-256-hashed bearer tokens (existing `api_tokens` table) for the Premiere panel, one `requireAuth` middleware that handles both transports, a single `<AuthGate>` React component in the SPA that owns "logged in or not" state. Flat permissions (logged in = full access). First admin via a setup screen on the empty `users` table.
|
||
|
||
**Tech Stack:** Node 22 (`express` 4, `express-session` 1.17, `connect-pg-simple` 9, `bcrypt` 5, `pg` 8 — all already in `services/mam-api/package.json`), Node native `node:test` for the test runner (no new deps), React 18 + esbuild-built JSX for the SPA.
|
||
|
||
**Source spec:** `docs/superpowers/specs/2026-05-27-auth-system-design.md`
|
||
|
||
**Working branch:** `design/auth-system` (already exists on the remote, clone at `/tmp/dragonflight-spec`). Implementer should branch from there: `git checkout -b feat/auth-system origin/design/auth-system`.
|
||
|
||
**Test convention:** `node --test test/**/*.test.js` from `services/mam-api/`. Integration tests require a `TEST_DATABASE_URL` env var pointing at a throwaway Postgres (e.g. `postgres://wilddragon:test@localhost:5432/wilddragon_test`); tests that need DB will `skip` with a clear message when the env var is missing rather than fail the suite.
|
||
|
||
**Commit cadence:** every task ends with one commit. Frequent commits, not heroic batches.
|
||
|
||
---
|
||
|
||
## File map
|
||
|
||
**`services/mam-api/` (backend)**
|
||
|
||
| Path | Purpose | Task |
|
||
|---|---|---|
|
||
| `package.json` | Add `"test"` script | 1 |
|
||
| `test/helpers/setup-db.js` | Spin up isolated test DB, run migrations, return pool | 1 |
|
||
| `test/helpers/test-app.js` | Build an Express app with all middleware wired, listen on ephemeral port, return `{ baseUrl, cleanup }` | 1 |
|
||
| `src/db/migrations/023-auth-session-timestamps.sql` | Adds `password_updated_at`, `last_login_at`, seeds dev user | 2 |
|
||
| `src/auth/passwords.js` | `hashPassword`, `comparePassword` thin wrappers over `bcrypt` | 3 |
|
||
| `src/auth/tokens.js` | `generateToken` (32-byte hex with `dfl_` prefix), `hashToken` (SHA-256 hex), `parseBearer` | 3 |
|
||
| `src/middleware/auth.js` | `requireAuth`, `destroyAnd401`, `loadUser`, `DEV_USER` constant | 4 |
|
||
| `src/index.js` | Mount session middleware, tighten CORS, mount auth gate, mount new routers | 5, 6, 7, 13, 14 |
|
||
| `src/routes/auth.js` | `/auth/setup-required`, `/setup`, `/login`, `/logout`, `/me`, `/password` | 7-12 |
|
||
| `src/routes/users.js` | User CRUD (admin reset password lives here too) | 13 |
|
||
| `src/routes/tokens.js` | Current-user API token list/create/revoke | 14 |
|
||
| `src/auth/rate-limit.js` | Per-IP exponential backoff `Map` for `/auth/login` | 15 |
|
||
| `.env.example` | Add `ALLOWED_ORIGINS`, default `AUTH_ENABLED=true` | 18 |
|
||
|
||
**`services/web-ui/` (frontend)**
|
||
|
||
| Path | Purpose | Task |
|
||
|---|---|---|
|
||
| `public/auth-gate.jsx` | New: `<AuthGate>` orchestration (me → setup-required → render Login / Setup / app) | 16 |
|
||
| `public/screens-auth.jsx` | New: `<LoginScreen>`, `<SetupScreen>` components, ops-register style | 17 |
|
||
| `public/data.jsx` | Replace `apiFetch` 401-bounce with AuthGate dispatch; add `X-Requested-With` header | 16 |
|
||
| `public/shell.jsx` | Replace sign-out `/login.html` redirect with AuthGate re-mount | 16 |
|
||
| `public/screens-admin.jsx` | Add Account (change password) + API Tokens sections | 17 |
|
||
| `public/index.html` | Add `<script>` tags for `dist/auth-gate.js`, `dist/screens-auth.js` (auto-built by `build-jsx.js`) | 17 |
|
||
|
||
**Cleanup**
|
||
|
||
| Path | Action | Task |
|
||
|---|---|---|
|
||
| `services/web-ui/public/dist/login.html` etc. | Verify no `/login.html` file exists; if it does, delete | 18 |
|
||
| References to `/login.html` in source | Verify only the references replaced in tasks 16/17 remain | 18 |
|
||
| `README.md` | Document auth flow + `AUTH_ENABLED` transition | 18 |
|
||
|
||
---
|
||
|
||
## Cluster carve-out decision (locked in for this plan)
|
||
|
||
The spec leaves the cluster route gating open. **For this plan: carve `/api/v1/cluster/*` out of the `requireAuth` gate**. Rationale: node-agent already uses `019-node-token-binding`; reissuing it an api_token at install time is a larger lift that belongs in a follow-up. The carve-out is one line in `src/index.js` and a comment.
|
||
|
||
---
|
||
|
||
## Task 1: Test infrastructure
|
||
|
||
**Files:**
|
||
- Modify: `services/mam-api/package.json`
|
||
- Create: `services/mam-api/test/helpers/setup-db.js`
|
||
- Create: `services/mam-api/test/helpers/test-app.js`
|
||
- Create: `services/mam-api/test/smoke.test.js`
|
||
|
||
- [ ] **Step 1: Add the test script**
|
||
|
||
Modify `services/mam-api/package.json` — under `"scripts"`, add the test entry so the block reads:
|
||
|
||
```json
|
||
"scripts": {
|
||
"start": "node src/index.js",
|
||
"dev": "node --watch src/index.js",
|
||
"test": "node --test test/**/*.test.js"
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 2: Create the test DB helper**
|
||
|
||
Create `services/mam-api/test/helpers/setup-db.js`:
|
||
|
||
```js
|
||
// Pure helper for integration tests. Requires TEST_DATABASE_URL pointing at
|
||
// a throwaway Postgres. Returns a pg Pool with the full schema applied.
|
||
// Tests that need this should `skip` (not fail) when TEST_DATABASE_URL is unset.
|
||
import { Pool } from 'pg';
|
||
import { readdirSync, readFileSync } from 'node:fs';
|
||
import { fileURLToPath } from 'node:url';
|
||
import { dirname, join } from 'node:path';
|
||
|
||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||
const migrationsDir = join(__dirname, '..', '..', 'src', 'db', 'migrations');
|
||
const schemaFile = join(__dirname, '..', '..', 'src', 'db', 'schema.sql');
|
||
const patches = [
|
||
'schema_patch_ampp.sql',
|
||
'schema_patch_editor.sql',
|
||
'schema_patch_groups_tokens.sql',
|
||
];
|
||
|
||
export function isTestDbConfigured() {
|
||
return !!process.env.TEST_DATABASE_URL;
|
||
}
|
||
|
||
export async function setupTestDb() {
|
||
if (!process.env.TEST_DATABASE_URL) {
|
||
throw new Error('TEST_DATABASE_URL not set');
|
||
}
|
||
const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL });
|
||
|
||
// Wipe and recreate the public schema so each test run starts clean.
|
||
await pool.query('DROP SCHEMA IF EXISTS public CASCADE');
|
||
await pool.query('CREATE SCHEMA public');
|
||
await pool.query('GRANT ALL ON SCHEMA public TO PUBLIC');
|
||
|
||
// Base schema, then patches, then migrations in order.
|
||
await pool.query(readFileSync(schemaFile, 'utf8'));
|
||
for (const p of patches) {
|
||
await pool.query(readFileSync(join(__dirname, '..', '..', 'src', 'db', p), 'utf8'));
|
||
}
|
||
const migrations = readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
||
for (const f of migrations) {
|
||
await pool.query(readFileSync(join(migrationsDir, f), 'utf8'));
|
||
}
|
||
|
||
return pool;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Create the test app helper**
|
||
|
||
Create `services/mam-api/test/helpers/test-app.js`:
|
||
|
||
```js
|
||
// Builds an Express app with the production middleware stack pointed at a
|
||
// test pool, listens on an ephemeral port, returns { baseUrl, server, pool, cleanup }.
|
||
// Tests use fetch() against baseUrl — no supertest dep needed.
|
||
import express from 'express';
|
||
import cors from 'cors';
|
||
import session from 'express-session';
|
||
import connectPgSimple from 'connect-pg-simple';
|
||
import { setupTestDb } from './setup-db.js';
|
||
|
||
const PgStore = connectPgSimple(session);
|
||
|
||
export async function createTestApp({ authEnabled = true } = {}) {
|
||
const pool = await setupTestDb();
|
||
process.env.AUTH_ENABLED = authEnabled ? 'true' : 'false';
|
||
process.env.SESSION_SECRET = process.env.SESSION_SECRET || 'test-secret-' + Math.random();
|
||
|
||
const app = express();
|
||
app.use(cors({ origin: true, credentials: true }));
|
||
app.use(express.json({ limit: '5mb' }));
|
||
app.use(session({
|
||
store: new PgStore({ pool, tableName: 'sessions' }),
|
||
secret: process.env.SESSION_SECRET,
|
||
name: 'dragonflight.sid',
|
||
cookie: { httpOnly: true, sameSite: 'lax', secure: false, path: '/', maxAge: 8 * 3600 * 1000 },
|
||
rolling: false,
|
||
resave: false,
|
||
saveUninitialized: false,
|
||
}));
|
||
|
||
app.get('/health', (_req, res) => res.json({ status: 'ok' }));
|
||
|
||
// Tests that need additional routes mount them on the returned `app`
|
||
// before calling listen.
|
||
return new Promise(resolve => {
|
||
const server = app.listen(0, '127.0.0.1', () => {
|
||
const port = server.address().port;
|
||
const baseUrl = `http://127.0.0.1:${port}`;
|
||
const cleanup = async () => {
|
||
await new Promise(r => server.close(r));
|
||
await pool.end();
|
||
};
|
||
resolve({ app, server, pool, baseUrl, cleanup });
|
||
});
|
||
});
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Write a smoke test that proves the infra works**
|
||
|
||
Create `services/mam-api/test/smoke.test.js`:
|
||
|
||
```js
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { isTestDbConfigured } from './helpers/setup-db.js';
|
||
import { createTestApp } from './helpers/test-app.js';
|
||
|
||
test('test infra: /health responds on an ephemeral port', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const { baseUrl, cleanup } = await createTestApp();
|
||
try {
|
||
const res = await fetch(baseUrl + '/health');
|
||
assert.equal(res.status, 200);
|
||
const body = await res.json();
|
||
assert.equal(body.status, 'ok');
|
||
} finally {
|
||
await cleanup();
|
||
}
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 5: Run the smoke test, with and without DB**
|
||
|
||
Without `TEST_DATABASE_URL`:
|
||
|
||
```bash
|
||
cd services/mam-api && npm test
|
||
```
|
||
|
||
Expected: the smoke test is reported as **skipped** with message `TEST_DATABASE_URL not set`. Overall exit 0.
|
||
|
||
With `TEST_DATABASE_URL` set (operator provides their own throwaway DB):
|
||
|
||
```bash
|
||
TEST_DATABASE_URL=postgres://wilddragon:test@localhost:5432/wilddragon_test npm test
|
||
```
|
||
|
||
Expected: smoke test **passes**.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
cd /tmp/dragonflight-spec
|
||
git add services/mam-api/package.json services/mam-api/test/
|
||
git commit -m "chore(mam-api): wire node:test runner + test app + DB helper"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Migration 023 — auth timestamps + dev-user seed
|
||
|
||
**Files:**
|
||
- Create: `services/mam-api/src/db/migrations/023-auth-session-timestamps.sql`
|
||
|
||
- [ ] **Step 1: Write the migration**
|
||
|
||
Create `services/mam-api/src/db/migrations/023-auth-session-timestamps.sql`:
|
||
|
||
```sql
|
||
-- Migration 023 — auth-related user timestamps + idempotent dev user.
|
||
--
|
||
-- See docs/superpowers/specs/2026-05-27-auth-system-design.md
|
||
--
|
||
-- password_updated_at + last_login_at are operator visibility, no logic depends on them yet.
|
||
-- The dev user is seeded with a fixed UUID so FK-bearing routes (api_tokens,
|
||
-- future audit fields) keep working when AUTH_ENABLED=false. The seeded
|
||
-- password_hash is a placeholder that no bcrypt.compare will accept, so the
|
||
-- dev row cannot be used to log in even if AUTH_ENABLED is later flipped on.
|
||
|
||
ALTER TABLE users ADD COLUMN IF NOT EXISTS password_updated_at TIMESTAMPTZ DEFAULT NOW();
|
||
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ;
|
||
|
||
INSERT INTO users (id, username, password_hash, display_name, role)
|
||
VALUES (
|
||
'00000000-0000-4000-8000-000000000dev',
|
||
'dev',
|
||
'!disabled-no-login!',
|
||
'Dev (AUTH_ENABLED=false)',
|
||
'admin'
|
||
)
|
||
ON CONFLICT (id) DO NOTHING;
|
||
```
|
||
|
||
- [ ] **Step 2: Apply the migration to your local dev DB and verify**
|
||
|
||
```bash
|
||
cd services/mam-api
|
||
psql "$DATABASE_URL" -f src/db/migrations/023-auth-session-timestamps.sql
|
||
```
|
||
|
||
Expected: `ALTER TABLE` ×2, `INSERT 0 1` (or `INSERT 0 0` if already seeded).
|
||
|
||
Verify:
|
||
|
||
```bash
|
||
psql "$DATABASE_URL" -c "SELECT id, username, role FROM users WHERE username='dev'"
|
||
```
|
||
|
||
Expected one row: `00000000-0000-4000-8000-000000000dev | dev | admin`.
|
||
|
||
- [ ] **Step 3: Run the migration runner integration test (smoke from Task 1)**
|
||
|
||
```bash
|
||
TEST_DATABASE_URL=postgres://wilddragon:test@localhost:5432/wilddragon_test npm test
|
||
```
|
||
|
||
Expected: smoke test still passes (the new migration gets picked up by `setupTestDb`).
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add services/mam-api/src/db/migrations/023-auth-session-timestamps.sql
|
||
git commit -m "feat(mam-api): migration 023 — auth timestamps + idempotent dev user seed"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Auth utilities — passwords and tokens
|
||
|
||
**Files:**
|
||
- Create: `services/mam-api/src/auth/passwords.js`
|
||
- Create: `services/mam-api/src/auth/tokens.js`
|
||
- Create: `services/mam-api/test/auth/passwords.test.js`
|
||
- Create: `services/mam-api/test/auth/tokens.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing password test**
|
||
|
||
Create `services/mam-api/test/auth/passwords.test.js`:
|
||
|
||
```js
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { hashPassword, comparePassword } from '../../src/auth/passwords.js';
|
||
|
||
test('hashPassword returns a bcrypt string that comparePassword accepts', async () => {
|
||
const hash = await hashPassword('correct-horse-battery-staple');
|
||
assert.match(hash, /^\$2[aby]\$\d{2}\$/);
|
||
assert.equal(await comparePassword('correct-horse-battery-staple', hash), true);
|
||
});
|
||
|
||
test('comparePassword returns false for the wrong password', async () => {
|
||
const hash = await hashPassword('correct-horse-battery-staple');
|
||
assert.equal(await comparePassword('wrong', hash), false);
|
||
});
|
||
|
||
test('comparePassword returns false (not throws) for a malformed hash', async () => {
|
||
assert.equal(await comparePassword('anything', '!disabled-no-login!'), false);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm it fails**
|
||
|
||
```bash
|
||
cd services/mam-api && node --test test/auth/passwords.test.js
|
||
```
|
||
|
||
Expected: `Cannot find module '../../src/auth/passwords.js'`.
|
||
|
||
- [ ] **Step 3: Implement passwords.js**
|
||
|
||
Create `services/mam-api/src/auth/passwords.js`:
|
||
|
||
```js
|
||
// Thin bcrypt wrapper. Cost 12 per the spec (NIST SP 800-63B-friendly).
|
||
// comparePassword must never throw on a malformed hash — that path is hit
|
||
// by the seeded dev user's placeholder hash and by any partially-imported
|
||
// row. Throwing here would 500 on a wrong-password attempt.
|
||
import bcrypt from 'bcrypt';
|
||
|
||
const COST = 12;
|
||
|
||
export async function hashPassword(plain) {
|
||
return bcrypt.hash(plain, COST);
|
||
}
|
||
|
||
export async function comparePassword(plain, hash) {
|
||
try {
|
||
return await bcrypt.compare(plain, hash);
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm it passes**
|
||
|
||
```bash
|
||
node --test test/auth/passwords.test.js
|
||
```
|
||
|
||
Expected: all 3 tests pass.
|
||
|
||
- [ ] **Step 5: Write the failing token test**
|
||
|
||
Create `services/mam-api/test/auth/tokens.test.js`:
|
||
|
||
```js
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { generateToken, hashToken, parseBearer } from '../../src/auth/tokens.js';
|
||
|
||
test('generateToken returns dfl_-prefixed 32-byte hex (68 chars total)', () => {
|
||
const t = generateToken();
|
||
assert.match(t, /^dfl_[0-9a-f]{64}$/);
|
||
});
|
||
|
||
test('generateToken returns distinct values on each call', () => {
|
||
assert.notEqual(generateToken(), generateToken());
|
||
});
|
||
|
||
test('hashToken returns a stable 64-char hex SHA-256', () => {
|
||
const t = 'dfl_' + 'a'.repeat(64);
|
||
const h = hashToken(t);
|
||
assert.match(h, /^[0-9a-f]{64}$/);
|
||
assert.equal(hashToken(t), h);
|
||
});
|
||
|
||
test('parseBearer returns the token when header is well-formed', () => {
|
||
assert.equal(parseBearer('Bearer dfl_abc'), 'dfl_abc');
|
||
assert.equal(parseBearer('bearer dfl_xyz'), 'dfl_xyz'); // case-insensitive scheme
|
||
});
|
||
|
||
test('parseBearer returns null for missing or malformed headers', () => {
|
||
assert.equal(parseBearer(undefined), null);
|
||
assert.equal(parseBearer(''), null);
|
||
assert.equal(parseBearer('Basic abc'), null);
|
||
assert.equal(parseBearer('Bearer'), null);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 6: Run, confirm it fails**
|
||
|
||
```bash
|
||
node --test test/auth/tokens.test.js
|
||
```
|
||
|
||
Expected: module-not-found error.
|
||
|
||
- [ ] **Step 7: Implement tokens.js**
|
||
|
||
Create `services/mam-api/src/auth/tokens.js`:
|
||
|
||
```js
|
||
import { randomBytes, createHash } from 'node:crypto';
|
||
|
||
const PREFIX = 'dfl_';
|
||
|
||
export function generateToken() {
|
||
return PREFIX + randomBytes(32).toString('hex');
|
||
}
|
||
|
||
export function hashToken(token) {
|
||
return createHash('sha256').update(token).digest('hex');
|
||
}
|
||
|
||
export function parseBearer(authorizationHeader) {
|
||
if (!authorizationHeader || typeof authorizationHeader !== 'string') return null;
|
||
const m = authorizationHeader.match(/^Bearer\s+(\S+)$/i);
|
||
return m ? m[1] : null;
|
||
}
|
||
|
||
export const TOKEN_PREFIX_DISPLAY_LEN = 8; // for api_tokens.token_prefix
|
||
export function tokenDisplayPrefix(token) {
|
||
return token.slice(0, TOKEN_PREFIX_DISPLAY_LEN);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 8: Run, confirm it passes**
|
||
|
||
```bash
|
||
node --test test/auth/tokens.test.js
|
||
```
|
||
|
||
Expected: all 5 tests pass.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add services/mam-api/src/auth/ services/mam-api/test/auth/
|
||
git commit -m "feat(mam-api): auth utilities — password hash/compare + token gen/hash/parse"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: `requireAuth` middleware
|
||
|
||
**Files:**
|
||
- Create: `services/mam-api/src/middleware/auth.js`
|
||
- Create: `services/mam-api/test/middleware/auth.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing test (all seven cases)**
|
||
|
||
Create `services/mam-api/test/middleware/auth.test.js`:
|
||
|
||
```js
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||
import { requireAuth, DEV_USER_ID } from '../../src/middleware/auth.js';
|
||
import { generateToken, hashToken } from '../../src/auth/tokens.js';
|
||
|
||
function mockRes() {
|
||
const res = {
|
||
statusCode: 200, body: null,
|
||
status(n) { this.statusCode = n; return this; },
|
||
json(o) { this.body = o; return this; },
|
||
};
|
||
return res;
|
||
}
|
||
|
||
function mockReq({ session = null, authHeader = null } = {}) {
|
||
return {
|
||
session,
|
||
headers: authHeader ? { authorization: authHeader } : {},
|
||
};
|
||
}
|
||
|
||
test('AUTH_ENABLED=false → attaches dev user and calls next', async () => {
|
||
delete process.env.AUTH_ENABLED;
|
||
const req = mockReq(); const res = mockRes(); let called = false;
|
||
await requireAuth(req, res, () => { called = true; });
|
||
assert.equal(called, true);
|
||
assert.equal(req.user.id, DEV_USER_ID);
|
||
assert.equal(req.user.username, 'dev');
|
||
});
|
||
|
||
test('AUTH_ENABLED=true + no session + no bearer → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
process.env.AUTH_ENABLED = 'true';
|
||
try {
|
||
const { requireAuth } = await import('../../src/middleware/auth.js?cache=' + Date.now());
|
||
const req = mockReq(); const res = mockRes();
|
||
await requireAuth(req, res, () => {});
|
||
assert.equal(res.statusCode, 401);
|
||
assert.deepEqual(res.body, { error: 'unauthorized' });
|
||
} finally { await pool.end(); }
|
||
});
|
||
|
||
test('valid session within idle/absolute window → next + req.user populated', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
process.env.AUTH_ENABLED = 'true';
|
||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||
// Insert a real user so loadUser can find them.
|
||
const { rows } = await pool.query(
|
||
`INSERT INTO users (username, password_hash, display_name, role)
|
||
VALUES ('alice', 'x', 'Alice', 'admin') RETURNING id`);
|
||
const userId = rows[0].id;
|
||
try {
|
||
const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
|
||
const now = Date.now();
|
||
const req = mockReq({ session: { user_id: userId, first_seen_at: now - 1000, last_seen_at: now - 500 } });
|
||
const res = mockRes(); let called = false;
|
||
await requireAuth(req, res, () => { called = true; });
|
||
assert.equal(called, true, 'next not called; body=' + JSON.stringify(res.body));
|
||
assert.equal(req.user.id, userId);
|
||
assert.equal(req.user.username, 'alice');
|
||
} finally { await pool.end(); }
|
||
});
|
||
|
||
test('idle-expired session (>1h since last_seen_at) → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
process.env.AUTH_ENABLED = 'true';
|
||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||
const { rows } = await pool.query(
|
||
`INSERT INTO users (username, password_hash) VALUES ('b', 'x') RETURNING id`);
|
||
try {
|
||
const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
|
||
const now = Date.now();
|
||
const session = { user_id: rows[0].id, first_seen_at: now - 1000, last_seen_at: now - (61 * 60 * 1000), destroy(cb){ cb(); } };
|
||
const req = mockReq({ session }); const res = mockRes();
|
||
await requireAuth(req, res, () => {});
|
||
assert.equal(res.statusCode, 401);
|
||
} finally { await pool.end(); }
|
||
});
|
||
|
||
test('absolute-expired session (>8h since first_seen_at) → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
process.env.AUTH_ENABLED = 'true';
|
||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||
const { rows } = await pool.query(
|
||
`INSERT INTO users (username, password_hash) VALUES ('c', 'x') RETURNING id`);
|
||
try {
|
||
const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
|
||
const now = Date.now();
|
||
const session = { user_id: rows[0].id, first_seen_at: now - (9 * 3600 * 1000), last_seen_at: now - 100, destroy(cb){ cb(); } };
|
||
const req = mockReq({ session }); const res = mockRes();
|
||
await requireAuth(req, res, () => {});
|
||
assert.equal(res.statusCode, 401);
|
||
} finally { await pool.end(); }
|
||
});
|
||
|
||
test('valid bearer token → next + req.user populated', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
process.env.AUTH_ENABLED = 'true';
|
||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||
const { rows: u } = await pool.query(
|
||
`INSERT INTO users (username, password_hash) VALUES ('d', 'x') RETURNING id`);
|
||
const token = generateToken();
|
||
await pool.query(
|
||
`INSERT INTO api_tokens (user_id, name, token_hash, token_prefix)
|
||
VALUES ($1, 'test', $2, $3)`,
|
||
[u[0].id, hashToken(token), token.slice(0, 8)]);
|
||
try {
|
||
const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
|
||
const req = mockReq({ authHeader: 'Bearer ' + token });
|
||
const res = mockRes(); let called = false;
|
||
await requireAuth(req, res, () => { called = true; });
|
||
assert.equal(called, true, 'next not called; body=' + JSON.stringify(res.body));
|
||
assert.equal(req.user.username, 'd');
|
||
} finally { await pool.end(); }
|
||
});
|
||
|
||
test('invalid bearer token → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
process.env.AUTH_ENABLED = 'true';
|
||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||
try {
|
||
const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
|
||
const req = mockReq({ authHeader: 'Bearer dfl_nope' });
|
||
const res = mockRes();
|
||
await requireAuth(req, res, () => {});
|
||
assert.equal(res.statusCode, 401);
|
||
} finally { await pool.end(); }
|
||
});
|
||
|
||
test('bearer token whose user was deleted → 401', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
process.env.AUTH_ENABLED = 'true';
|
||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||
const { rows: u } = await pool.query(
|
||
`INSERT INTO users (username, password_hash) VALUES ('e', 'x') RETURNING id`);
|
||
const token = generateToken();
|
||
await pool.query(
|
||
`INSERT INTO api_tokens (user_id, name, token_hash, token_prefix)
|
||
VALUES ($1, 'test', $2, $3)`,
|
||
[u[0].id, hashToken(token), token.slice(0, 8)]);
|
||
// FK ON DELETE CASCADE deletes the token too, so look up by hash returns 0 rows.
|
||
await pool.query(`DELETE FROM users WHERE id = $1`, [u[0].id]);
|
||
try {
|
||
const { requireAuth } = await import('../../src/middleware/auth.js?v=' + Date.now());
|
||
const req = mockReq({ authHeader: 'Bearer ' + token });
|
||
const res = mockRes();
|
||
await requireAuth(req, res, () => {});
|
||
assert.equal(res.statusCode, 401);
|
||
} finally { await pool.end(); }
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm it fails**
|
||
|
||
```bash
|
||
node --test test/middleware/auth.test.js
|
||
```
|
||
|
||
Expected: module-not-found for `../../src/middleware/auth.js`.
|
||
|
||
- [ ] **Step 3: Implement requireAuth**
|
||
|
||
Create `services/mam-api/src/middleware/auth.js`:
|
||
|
||
```js
|
||
import pool from '../db/pool.js';
|
||
import { parseBearer, hashToken } from '../auth/tokens.js';
|
||
|
||
// Stable UUID matching migration 023's seeded dev user.
|
||
export const DEV_USER_ID = '00000000-0000-4000-8000-000000000dev';
|
||
export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)' };
|
||
|
||
const ABSOLUTE_MS = 8 * 3600 * 1000;
|
||
const IDLE_MS = 1 * 3600 * 1000;
|
||
|
||
async function destroyAnd401(req, res) {
|
||
if (req.session?.destroy) {
|
||
await new Promise(r => req.session.destroy(() => r()));
|
||
}
|
||
return res.status(401).json({ error: 'unauthorized' });
|
||
}
|
||
|
||
async function loadUser(id) {
|
||
const { rows } = await pool.query(
|
||
`SELECT id, username, display_name FROM users WHERE id = $1`, [id]);
|
||
return rows[0] || null;
|
||
}
|
||
|
||
export async function requireAuth(req, res, next) {
|
||
// Dev mode — attach the seeded dev user so FK-bearing routes work.
|
||
if (process.env.AUTH_ENABLED !== 'true') {
|
||
req.user = DEV_USER;
|
||
return next();
|
||
}
|
||
|
||
// 1. Session
|
||
if (req.session?.user_id) {
|
||
const now = Date.now();
|
||
const first = req.session.first_seen_at || 0;
|
||
const last = req.session.last_seen_at || 0;
|
||
if (now - first > ABSOLUTE_MS) return destroyAnd401(req, res);
|
||
if (now - last > IDLE_MS) return destroyAnd401(req, res);
|
||
req.session.last_seen_at = now;
|
||
const u = await loadUser(req.session.user_id);
|
||
if (!u) return destroyAnd401(req, res);
|
||
req.user = u;
|
||
return next();
|
||
}
|
||
|
||
// 2. Bearer
|
||
const bearer = parseBearer(req.headers.authorization);
|
||
if (bearer) {
|
||
const hash = hashToken(bearer);
|
||
const { rows } = await pool.query(
|
||
`SELECT t.id AS token_id, t.user_id, t.expires_at, u.username, u.display_name
|
||
FROM api_tokens t JOIN users u ON u.id = t.user_id
|
||
WHERE t.token_hash = $1`, [hash]);
|
||
if (rows.length && (!rows[0].expires_at || rows[0].expires_at > new Date())) {
|
||
pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [rows[0].token_id])
|
||
.catch(err => console.error('[auth] token last_used_at update failed:', err.message));
|
||
req.user = { id: rows[0].user_id, username: rows[0].username, display_name: rows[0].display_name };
|
||
return next();
|
||
}
|
||
}
|
||
|
||
// 3. Nothing matched
|
||
return res.status(401).json({ error: 'unauthorized' });
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm it passes**
|
||
|
||
```bash
|
||
TEST_DATABASE_URL=postgres://wilddragon:test@localhost:5432/wilddragon_test \
|
||
node --test test/middleware/auth.test.js
|
||
```
|
||
|
||
Expected: all 8 tests pass (1 always-runs + 7 DB-dependent).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add services/mam-api/src/middleware/auth.js services/mam-api/test/middleware/auth.test.js
|
||
git commit -m "feat(mam-api): requireAuth middleware — session + bearer + idle/absolute timeout"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Wire session middleware and tighten CORS in index.js
|
||
|
||
**Files:**
|
||
- Modify: `services/mam-api/src/index.js`
|
||
|
||
- [ ] **Step 1: Read the current top of index.js to anchor the diff**
|
||
|
||
Read `services/mam-api/src/index.js` lines 1-40 — confirm the current `cors(...)` + `express.json(...)` calls are present and that no `session(...)` is mounted.
|
||
|
||
- [ ] **Step 2: Add the new imports near the existing imports**
|
||
|
||
In `services/mam-api/src/index.js`, after the `import cors from 'cors';` line, add:
|
||
|
||
```js
|
||
import session from 'express-session';
|
||
import connectPgSimple from 'connect-pg-simple';
|
||
const PgStore = connectPgSimple(session);
|
||
```
|
||
|
||
- [ ] **Step 3: Replace the wide-open CORS + add session middleware before any route**
|
||
|
||
Replace the existing `app.use(cors({ origin: true, credentials: true }));` and the lines immediately after it (`app.use(express.json(...))`) with:
|
||
|
||
```js
|
||
// Tightened CORS — once cookies carry authority, `origin: true` would let
|
||
// any site forge requests with the cookie. Drive the allowlist from env.
|
||
const allowedOrigins = (process.env.ALLOWED_ORIGINS || '')
|
||
.split(',').map(s => s.trim()).filter(Boolean);
|
||
app.use(cors({
|
||
origin: (origin, cb) => {
|
||
// No Origin header (same-origin or curl) — allow.
|
||
if (!origin) return cb(null, true);
|
||
if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true);
|
||
return cb(new Error('CORS: origin not allowed: ' + origin));
|
||
},
|
||
credentials: true,
|
||
}));
|
||
app.use(express.json({ limit: '50mb' }));
|
||
|
||
// Trust the reverse proxy only when explicitly told to (production HTTPS).
|
||
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
|
||
|
||
// Session — actually wired this time. See specs/2026-05-27-auth-system-design.md.
|
||
app.use(session({
|
||
store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }),
|
||
secret: process.env.SESSION_SECRET,
|
||
name: 'dragonflight.sid',
|
||
cookie: {
|
||
httpOnly: true,
|
||
sameSite: 'lax',
|
||
secure: process.env.TRUST_PROXY === 'true',
|
||
path: '/',
|
||
maxAge: 8 * 3600 * 1000,
|
||
},
|
||
rolling: false, // sliding renewal handled in requireAuth so idle + absolute can be enforced separately
|
||
resave: false,
|
||
saveUninitialized: false,
|
||
}));
|
||
```
|
||
|
||
- [ ] **Step 4: Smoke test — start the server with AUTH_ENABLED=false, verify it still boots**
|
||
|
||
```bash
|
||
cd services/mam-api && AUTH_ENABLED=false SESSION_SECRET=dev DATABASE_URL=$DATABASE_URL node src/index.js &
|
||
sleep 2
|
||
curl -sS http://localhost:3000/health
|
||
kill %1
|
||
```
|
||
|
||
Expected: `{"status":"ok"}` followed by the server shutting down. No errors about missing session store.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add services/mam-api/src/index.js
|
||
git commit -m "feat(mam-api): wire express-session + tighten CORS allowlist"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Mount the auth gate at /api/v1 with allowlist + cluster carve-out
|
||
|
||
**Files:**
|
||
- Modify: `services/mam-api/src/index.js`
|
||
|
||
- [ ] **Step 1: Add the auth import**
|
||
|
||
Near the bottom of the import block in `services/mam-api/src/index.js`, add:
|
||
|
||
```js
|
||
import { requireAuth } from './middleware/auth.js';
|
||
```
|
||
|
||
- [ ] **Step 2: Add the gate middleware BEFORE the existing `app.use('/api/v1/...', router)` calls**
|
||
|
||
In `services/mam-api/src/index.js`, insert this block immediately before the `// ── API Routes ──` block:
|
||
|
||
```js
|
||
// ── Auth gate ─────────────────────────────────────────────────────────────────
|
||
// Mount once for everything under /api/v1, with an explicit allowlist for
|
||
// the three pre-login auth paths and a carve-out for /cluster/* (node-agent
|
||
// uses migration 019's token-binding, not user auth). See spec.
|
||
const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']);
|
||
app.use('/api/v1', (req, res, next) => {
|
||
if (UNAUTH_PATHS.has(req.path)) return next();
|
||
if (req.path.startsWith('/cluster')) return next(); // node-agent service auth, not user auth
|
||
return requireAuth(req, res, next);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Smoke test — confirm an existing route is now 401-gated when AUTH_ENABLED=true**
|
||
|
||
```bash
|
||
cd services/mam-api
|
||
AUTH_ENABLED=true SESSION_SECRET=dev DATABASE_URL=$DATABASE_URL node src/index.js &
|
||
sleep 2
|
||
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/v1/assets
|
||
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/v1/cluster
|
||
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3000/health
|
||
kill %1
|
||
```
|
||
|
||
Expected: `401`, `200` (cluster carve-out), `200` (health is outside /api/v1).
|
||
|
||
- [ ] **Step 4: Confirm AUTH_ENABLED=false still lets traffic through**
|
||
|
||
```bash
|
||
AUTH_ENABLED=false SESSION_SECRET=dev DATABASE_URL=$DATABASE_URL node src/index.js &
|
||
sleep 2
|
||
curl -sS -o /dev/null -w "%{http_code}\n" http://localhost:3000/api/v1/assets
|
||
kill %1
|
||
```
|
||
|
||
Expected: `200`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add services/mam-api/src/index.js
|
||
git commit -m "feat(mam-api): mount requireAuth gate at /api/v1 with auth + cluster carve-outs"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 7: Auth router skeleton + setup-required
|
||
|
||
**Files:**
|
||
- Create: `services/mam-api/src/routes/auth.js`
|
||
- Create: `services/mam-api/test/routes/auth.test.js`
|
||
- Modify: `services/mam-api/src/index.js`
|
||
|
||
- [ ] **Step 1: Write the failing test**
|
||
|
||
Create `services/mam-api/test/routes/auth.test.js`:
|
||
|
||
```js
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||
import express from 'express';
|
||
import authRouter from '../../src/routes/auth.js';
|
||
|
||
async function appWithAuth(pool) {
|
||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use('/api/v1/auth', authRouter);
|
||
return new Promise(r => {
|
||
const srv = app.listen(0, '127.0.0.1', () => {
|
||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
|
||
});
|
||
});
|
||
}
|
||
|
||
test('GET /auth/setup-required returns { required: true } on empty users (modulo dev seed)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
// setup-required treats the dev seed as a non-user — required iff no real user exists.
|
||
const { baseUrl, close } = await appWithAuth(pool);
|
||
try {
|
||
const res = await fetch(baseUrl + '/api/v1/auth/setup-required');
|
||
assert.equal(res.status, 200);
|
||
assert.deepEqual(await res.json(), { required: true });
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
|
||
test('GET /auth/setup-required returns { required: false } once a real user exists', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('admin', 'x')`);
|
||
const { baseUrl, close } = await appWithAuth(pool);
|
||
try {
|
||
const res = await fetch(baseUrl + '/api/v1/auth/setup-required');
|
||
assert.deepEqual(await res.json(), { required: false });
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm it fails**
|
||
|
||
```bash
|
||
node --test test/routes/auth.test.js
|
||
```
|
||
|
||
Expected: module-not-found for `../../src/routes/auth.js`.
|
||
|
||
- [ ] **Step 3: Implement the skeleton + setup-required**
|
||
|
||
Create `services/mam-api/src/routes/auth.js`:
|
||
|
||
```js
|
||
import express from 'express';
|
||
import pool from '../db/pool.js';
|
||
import { DEV_USER_ID } from '../middleware/auth.js';
|
||
|
||
const router = express.Router();
|
||
|
||
// Real users = anyone except the seeded dev row.
|
||
async function realUserCount() {
|
||
const { rows } = await pool.query(
|
||
`SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]);
|
||
return rows[0].n;
|
||
}
|
||
|
||
// GET /api/v1/auth/setup-required
|
||
// Cheap, no auth. Used by AuthGate to decide between Login and Setup screens.
|
||
router.get('/setup-required', async (_req, res, next) => {
|
||
try {
|
||
res.json({ required: (await realUserCount()) === 0 });
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
export default router;
|
||
export { realUserCount };
|
||
```
|
||
|
||
- [ ] **Step 4: Mount the auth router in index.js (BEFORE the auth gate's allowlist matters)**
|
||
|
||
In `services/mam-api/src/index.js`, in the API Routes block, add:
|
||
|
||
```js
|
||
import authRouter from './routes/auth.js';
|
||
// ...
|
||
app.use('/api/v1/auth', authRouter);
|
||
```
|
||
|
||
Order matters: this must be registered AFTER the gate (since Express matches in registration order — the gate's allowlist `req.path` check correctly lets `/auth/setup-required` through to the router below it).
|
||
|
||
- [ ] **Step 5: Run the test, confirm it passes**
|
||
|
||
```bash
|
||
TEST_DATABASE_URL=postgres://wilddragon:test@localhost:5432/wilddragon_test \
|
||
node --test test/routes/auth.test.js
|
||
```
|
||
|
||
Expected: 2 passes.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add services/mam-api/src/routes/auth.js services/mam-api/src/index.js services/mam-api/test/routes/auth.test.js
|
||
git commit -m "feat(mam-api): auth router skeleton + setup-required endpoint"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 8: Setup endpoint (first-run admin creation)
|
||
|
||
**Files:**
|
||
- Modify: `services/mam-api/src/routes/auth.js`
|
||
- Modify: `services/mam-api/test/routes/auth.test.js`
|
||
|
||
- [ ] **Step 1: Add the failing tests**
|
||
|
||
Append to `services/mam-api/test/routes/auth.test.js`:
|
||
|
||
```js
|
||
import session from 'express-session';
|
||
|
||
async function appWithSession(pool) {
|
||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||
process.env.SESSION_SECRET = 'test';
|
||
process.env.AUTH_ENABLED = 'true';
|
||
const ConnectPg = (await import('connect-pg-simple')).default(session);
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use(session({
|
||
store: new ConnectPg({ pool, tableName: 'sessions' }),
|
||
secret: 'test', name: 'dragonflight.sid',
|
||
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
||
rolling: false, resave: false, saveUninitialized: false,
|
||
}));
|
||
app.use('/api/v1/auth', authRouter);
|
||
return new Promise(r => {
|
||
const srv = app.listen(0, '127.0.0.1', () => {
|
||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
|
||
});
|
||
});
|
||
}
|
||
|
||
test('POST /auth/setup creates the first admin and returns a session cookie', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
const { baseUrl, close } = await appWithSession(pool);
|
||
try {
|
||
const res = await fetch(baseUrl + '/api/v1/auth/setup', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }),
|
||
});
|
||
assert.equal(res.status, 200);
|
||
const body = await res.json();
|
||
assert.equal(body.user.username, 'admin');
|
||
assert.match(res.headers.get('set-cookie') || '', /dragonflight\.sid=/);
|
||
const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM users WHERE username='admin'`);
|
||
assert.equal(rows[0].n, 1);
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
|
||
test('POST /auth/setup is 409 once a real user exists', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('existing', 'x')`);
|
||
const { baseUrl, close } = await appWithSession(pool);
|
||
try {
|
||
const res = await fetch(baseUrl + '/api/v1/auth/setup', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }),
|
||
});
|
||
assert.equal(res.status, 409);
|
||
assert.equal((await res.json()).error, 'setup already complete');
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
|
||
test('POST /auth/setup rejects passwords shorter than 12 chars', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
const { baseUrl, close } = await appWithSession(pool);
|
||
try {
|
||
const res = await fetch(baseUrl + '/api/v1/auth/setup', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: 'admin', password: 'short' }),
|
||
});
|
||
assert.equal(res.status, 400);
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm the new tests fail**
|
||
|
||
```bash
|
||
TEST_DATABASE_URL=... node --test test/routes/auth.test.js
|
||
```
|
||
|
||
Expected: previous 2 still pass, new 3 fail (`Cannot POST /api/v1/auth/setup` → 404, or `setup is not a function`).
|
||
|
||
- [ ] **Step 3: Implement setup**
|
||
|
||
Append to `services/mam-api/src/routes/auth.js` (before `export default router;`):
|
||
|
||
```js
|
||
import { hashPassword } from '../auth/passwords.js';
|
||
|
||
const MIN_PASSWORD_LEN = 12;
|
||
|
||
function badRequest(res, msg) { return res.status(400).json({ error: msg }); }
|
||
|
||
// POST /api/v1/auth/setup — first-run admin creation, locked out forever once a real user exists.
|
||
router.post('/setup', async (req, res, next) => {
|
||
try {
|
||
const { username, password } = req.body || {};
|
||
if (!username || typeof username !== 'string') return badRequest(res, 'username required');
|
||
if (!password || typeof password !== 'string') return badRequest(res, 'password required');
|
||
if (password.length < MIN_PASSWORD_LEN) return badRequest(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' characters');
|
||
|
||
if ((await realUserCount()) > 0) {
|
||
return res.status(409).json({ error: 'setup already complete' });
|
||
}
|
||
|
||
const hash = await hashPassword(password);
|
||
const { rows } = await pool.query(
|
||
`INSERT INTO users (username, password_hash, display_name, role)
|
||
VALUES ($1, $2, $1, 'admin')
|
||
RETURNING id, username, display_name`,
|
||
[username.trim(), hash]
|
||
);
|
||
const user = rows[0];
|
||
|
||
// Immediately log them in.
|
||
req.session.user_id = user.id;
|
||
req.session.first_seen_at = Date.now();
|
||
req.session.last_seen_at = Date.now();
|
||
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||
|
||
res.json({ user });
|
||
} catch (err) {
|
||
if (err.code === '23505') return res.status(409).json({ error: 'username already exists' });
|
||
next(err);
|
||
}
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm all 5 tests pass**
|
||
|
||
```bash
|
||
TEST_DATABASE_URL=... node --test test/routes/auth.test.js
|
||
```
|
||
|
||
Expected: 5/5 pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add services/mam-api/src/routes/auth.js services/mam-api/test/routes/auth.test.js
|
||
git commit -m "feat(mam-api): POST /auth/setup — first-run admin creation"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 9: Login endpoint + the redirect-loop regression test
|
||
|
||
**Files:**
|
||
- Modify: `services/mam-api/src/routes/auth.js`
|
||
- Modify: `services/mam-api/test/routes/auth.test.js`
|
||
|
||
- [ ] **Step 1: Add the failing tests, including the regression test**
|
||
|
||
Append to `services/mam-api/test/routes/auth.test.js`:
|
||
|
||
```js
|
||
import { hashPassword } from '../../src/auth/passwords.js';
|
||
import { requireAuth } from '../../src/middleware/auth.js';
|
||
|
||
async function appWithSessionAndMe(pool) {
|
||
// Same as appWithSession but also mounts a tiny /me endpoint behind requireAuth
|
||
// so we can exercise the round-trip: login → cookie sent → /me 200.
|
||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||
process.env.AUTH_ENABLED = 'true';
|
||
const ConnectPg = (await import('connect-pg-simple')).default(session);
|
||
const app = express();
|
||
app.use(express.json());
|
||
app.use(session({
|
||
store: new ConnectPg({ pool, tableName: 'sessions' }),
|
||
secret: 'test', name: 'dragonflight.sid',
|
||
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
||
rolling: false, resave: false, saveUninitialized: false,
|
||
}));
|
||
app.use('/api/v1/auth', authRouter);
|
||
app.get('/api/v1/protected/me', requireAuth, (req, res) => res.json({ user: req.user }));
|
||
return new Promise(r => {
|
||
const srv = app.listen(0, '127.0.0.1', () => {
|
||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
|
||
});
|
||
});
|
||
}
|
||
|
||
test('POST /auth/login with valid creds → 200 + cookie, and the cookie unlocks subsequent requests (regression: redirect loop)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
const hash = await hashPassword('correct-horse-battery');
|
||
await pool.query(`INSERT INTO users (username, password_hash, display_name) VALUES ('alice', $1, 'Alice')`, [hash]);
|
||
const { baseUrl, close } = await appWithSessionAndMe(pool);
|
||
try {
|
||
// 1. Login.
|
||
const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
|
||
});
|
||
assert.equal(loginRes.status, 200);
|
||
const setCookie = loginRes.headers.get('set-cookie');
|
||
assert.match(setCookie || '', /dragonflight\.sid=/, 'expected Set-Cookie with dragonflight.sid');
|
||
|
||
// 2. The SAME cookie must unlock the next request. This is the bug that
|
||
// produced the original redirect loop — login returned 200 but no cookie
|
||
// was persisted, so the next request 401'd and the SPA bounced.
|
||
const meRes = await fetch(baseUrl + '/api/v1/protected/me', {
|
||
headers: { cookie: setCookie.split(';')[0] },
|
||
});
|
||
assert.equal(meRes.status, 200, 'POST /login returned 200 but the cookie did not unlock /me — this is the regression');
|
||
assert.equal((await meRes.json()).user.username, 'alice');
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
|
||
test('POST /auth/login with wrong password → 401 + generic message (no enumeration)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
const hash = await hashPassword('correct-horse-battery');
|
||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]);
|
||
const { baseUrl, close } = await appWithSessionAndMe(pool);
|
||
try {
|
||
const r1 = await fetch(baseUrl + '/api/v1/auth/login', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: 'alice', password: 'wrong' }),
|
||
});
|
||
const r2 = await fetch(baseUrl + '/api/v1/auth/login', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: 'nobody', password: 'whatever-long-enough' }),
|
||
});
|
||
assert.equal(r1.status, 401);
|
||
assert.equal(r2.status, 401);
|
||
const e1 = (await r1.json()).error, e2 = (await r2.json()).error;
|
||
assert.equal(e1, 'invalid credentials');
|
||
assert.equal(e2, 'invalid credentials'); // identical message — no enumeration
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm the new tests fail**
|
||
|
||
Expected: 404 on `/auth/login`.
|
||
|
||
- [ ] **Step 3: Implement login**
|
||
|
||
Append to `services/mam-api/src/routes/auth.js` (before `export default router;`):
|
||
|
||
```js
|
||
import { comparePassword } from '../auth/passwords.js';
|
||
|
||
// POST /api/v1/auth/login
|
||
router.post('/login', async (req, res, next) => {
|
||
try {
|
||
const { username, password } = req.body || {};
|
||
if (!username || !password) return res.status(401).json({ error: 'invalid credentials' });
|
||
|
||
const { rows } = await pool.query(
|
||
`SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`,
|
||
[username.trim(), DEV_USER_ID]
|
||
);
|
||
if (rows.length === 0) {
|
||
// Still hash the supplied password against a dummy to keep response time uniform.
|
||
await comparePassword(password, '$2b$12$dummyhashthatwillalwaysfailtocomparexxxxxxxxxxxxxxxxxxxx');
|
||
return res.status(401).json({ error: 'invalid credentials' });
|
||
}
|
||
const user = rows[0];
|
||
if (!(await comparePassword(password, user.password_hash))) {
|
||
return res.status(401).json({ error: 'invalid credentials' });
|
||
}
|
||
|
||
req.session.user_id = user.id;
|
||
req.session.first_seen_at = Date.now();
|
||
req.session.last_seen_at = Date.now();
|
||
// The critical line — wait for the row to land in `sessions` before responding.
|
||
// Without this, the SPA's next request races the store write, hits 401, and
|
||
// the prior bounce-to-login logic produced an infinite loop.
|
||
await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve()));
|
||
|
||
await pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]).catch(() => {});
|
||
|
||
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } });
|
||
} catch (err) { next(err); }
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm all login tests pass — especially the regression**
|
||
|
||
```bash
|
||
TEST_DATABASE_URL=... node --test test/routes/auth.test.js
|
||
```
|
||
|
||
Expected: regression test name `(regression: redirect loop)` passes. All ≥7 tests pass overall.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add services/mam-api/src/routes/auth.js services/mam-api/test/routes/auth.test.js
|
||
git commit -m "feat(mam-api): POST /auth/login + redirect-loop regression test"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 10: Logout endpoint
|
||
|
||
**Files:**
|
||
- Modify: `services/mam-api/src/routes/auth.js`
|
||
- Modify: `services/mam-api/test/routes/auth.test.js`
|
||
|
||
- [ ] **Step 1: Add the failing test**
|
||
|
||
Append to `services/mam-api/test/routes/auth.test.js`:
|
||
|
||
```js
|
||
test('POST /auth/logout destroys the session row and the cookie no longer unlocks /me', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
const hash = await hashPassword('correct-horse-battery');
|
||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]);
|
||
const { baseUrl, close } = await appWithSessionAndMe(pool);
|
||
try {
|
||
const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
|
||
});
|
||
const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0];
|
||
|
||
const logoutRes = await fetch(baseUrl + '/api/v1/auth/logout', {
|
||
method: 'POST', headers: { cookie },
|
||
});
|
||
assert.equal(logoutRes.status, 204);
|
||
|
||
const meRes = await fetch(baseUrl + '/api/v1/protected/me', { headers: { cookie } });
|
||
assert.equal(meRes.status, 401);
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm it fails (404)**
|
||
|
||
- [ ] **Step 3: Implement logout**
|
||
|
||
Append to `services/mam-api/src/routes/auth.js`:
|
||
|
||
```js
|
||
// POST /api/v1/auth/logout — destroys the session and clears the cookie.
|
||
router.post('/logout', (req, res) => {
|
||
if (!req.session) return res.status(204).end();
|
||
req.session.destroy(err => {
|
||
if (err) console.error('[auth] session destroy failed:', err.message);
|
||
res.clearCookie('dragonflight.sid', { path: '/' });
|
||
res.status(204).end();
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm it passes**
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add services/mam-api/src/routes/auth.js services/mam-api/test/routes/auth.test.js
|
||
git commit -m "feat(mam-api): POST /auth/logout"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 11: /auth/me and POST /auth/password
|
||
|
||
**Files:**
|
||
- Modify: `services/mam-api/src/routes/auth.js`
|
||
- Modify: `services/mam-api/test/routes/auth.test.js`
|
||
|
||
- [ ] **Step 1: Add failing tests**
|
||
|
||
Append to `services/mam-api/test/routes/auth.test.js`:
|
||
|
||
```js
|
||
test('GET /auth/me returns the authed user', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
const hash = await hashPassword('correct-horse-battery');
|
||
await pool.query(`INSERT INTO users (username, password_hash, display_name) VALUES ('alice', $1, 'Alice')`, [hash]);
|
||
const { baseUrl, close } = await appWithSessionAndMe(pool);
|
||
try {
|
||
const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
|
||
});
|
||
const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0];
|
||
const me = await fetch(baseUrl + '/api/v1/auth/me', { headers: { cookie } });
|
||
assert.equal(me.status, 200);
|
||
const body = await me.json();
|
||
assert.equal(body.username, 'alice');
|
||
assert.equal(body.display_name, 'Alice');
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
|
||
test('POST /auth/password rotates the password when current is correct', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
const hash = await hashPassword('correct-horse-battery');
|
||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]);
|
||
const { baseUrl, close } = await appWithSessionAndMe(pool);
|
||
try {
|
||
const loginRes = await fetch(baseUrl + '/api/v1/auth/login', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }),
|
||
});
|
||
const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0];
|
||
const change = await fetch(baseUrl + '/api/v1/auth/password', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json', cookie },
|
||
body: JSON.stringify({ current_password: 'correct-horse-battery', new_password: 'brand-new-passphrase' }),
|
||
});
|
||
assert.equal(change.status, 204);
|
||
// Wrong current → 400
|
||
const wrong = await fetch(baseUrl + '/api/v1/auth/password', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json', cookie },
|
||
body: JSON.stringify({ current_password: 'no', new_password: 'another-good-passphrase' }),
|
||
});
|
||
assert.equal(wrong.status, 400);
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
```
|
||
|
||
The new `/api/v1/auth/me` and `/api/v1/auth/password` routes must be behind `requireAuth`. Since the test app mounts the router directly without the gate, the router itself must apply `requireAuth` to these routes — gate-allowlist or not, the requirement is enforced at the route level for these two.
|
||
|
||
Wait — that contradicts the architecture (gate at the top, allowlist for the 3 unauth paths). Rationale: the gate decides routing-level access, but the auth router benefits from also calling `requireAuth` inline on `me` and `password` so the route file is **readable in isolation** ("this route requires auth — right there"). Re-running `requireAuth` is cheap; it short-circuits on the dev path or hits the same session lookup.
|
||
|
||
- [ ] **Step 2: Run, confirm the new tests fail**
|
||
|
||
- [ ] **Step 3: Implement /me and /password**
|
||
|
||
Append to `services/mam-api/src/routes/auth.js`:
|
||
|
||
```js
|
||
import { requireAuth } from '../middleware/auth.js';
|
||
|
||
// GET /api/v1/auth/me
|
||
router.get('/me', requireAuth, (req, res) => {
|
||
res.json({ id: req.user.id, username: req.user.username, display_name: req.user.display_name });
|
||
});
|
||
|
||
// POST /api/v1/auth/password { current_password, new_password }
|
||
router.post('/password', requireAuth, async (req, res, next) => {
|
||
try {
|
||
const { current_password, new_password } = req.body || {};
|
||
if (!current_password || !new_password) return badRequest(res, 'current_password and new_password required');
|
||
if (new_password.length < MIN_PASSWORD_LEN) return badRequest(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' characters');
|
||
|
||
const { rows } = await pool.query(`SELECT password_hash FROM users WHERE id = $1`, [req.user.id]);
|
||
if (!rows.length) return res.status(401).json({ error: 'unauthorized' });
|
||
if (!(await comparePassword(current_password, rows[0].password_hash))) {
|
||
return badRequest(res, 'current password is incorrect');
|
||
}
|
||
const newHash = await hashPassword(new_password);
|
||
await pool.query(
|
||
`UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2`,
|
||
[newHash, req.user.id]
|
||
);
|
||
res.status(204).end();
|
||
} catch (err) { next(err); }
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm all auth router tests pass**
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add services/mam-api/src/routes/auth.js services/mam-api/test/routes/auth.test.js
|
||
git commit -m "feat(mam-api): GET /auth/me + POST /auth/password"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 12: User CRUD (admin reset password lives here)
|
||
|
||
**Files:**
|
||
- Create: `services/mam-api/src/routes/users.js`
|
||
- Create: `services/mam-api/test/routes/users.test.js`
|
||
- Modify: `services/mam-api/src/index.js`
|
||
- Modify: `services/mam-api/src/routes/groups.js` (verify; should already exist and stay untouched)
|
||
|
||
> **Note:** A `users.js` route file may not currently exist; the existing `data.jsx` calls `/api/v1/users` and `loadData` includes it. If you find an existing `routes/users.js`, replace it with this implementation only if its responsibilities match — otherwise, add the routes below as new sub-paths under the existing router and reconcile. Check before overwriting.
|
||
|
||
- [ ] **Step 1: Check the current state of /api/v1/users**
|
||
|
||
```bash
|
||
grep -rn "api/v1/users\|users.js" services/mam-api/src/index.js services/mam-api/src/routes/ 2>&1
|
||
```
|
||
|
||
If a `routes/users.js` exists and is mounted, **read it** and adjust the steps below to extend rather than replace. The spec's behavior — list, create, delete, admin-reset-password — must be present at the end of this task.
|
||
|
||
- [ ] **Step 2: Write failing tests**
|
||
|
||
Create `services/mam-api/test/routes/users.test.js`:
|
||
|
||
```js
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import express from 'express';
|
||
import session from 'express-session';
|
||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||
import usersRouter from '../../src/routes/users.js';
|
||
import authRouter from '../../src/routes/auth.js';
|
||
import { requireAuth } from '../../src/middleware/auth.js';
|
||
import { hashPassword } from '../../src/auth/passwords.js';
|
||
|
||
async function app(pool) {
|
||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||
process.env.AUTH_ENABLED = 'true';
|
||
const ConnectPg = (await import('connect-pg-simple')).default(session);
|
||
const a = express();
|
||
a.use(express.json());
|
||
a.use(session({
|
||
store: new ConnectPg({ pool, tableName: 'sessions' }),
|
||
secret: 'test', name: 'dragonflight.sid',
|
||
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
||
rolling: false, resave: false, saveUninitialized: false,
|
||
}));
|
||
a.use('/api/v1/auth', authRouter);
|
||
a.use('/api/v1/auth/users', requireAuth, usersRouter);
|
||
return new Promise(r => {
|
||
const srv = a.listen(0, '127.0.0.1', () => {
|
||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
|
||
});
|
||
});
|
||
}
|
||
|
||
async function login(baseUrl, username, password) {
|
||
const r = await fetch(baseUrl + '/api/v1/auth/login', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username, password }),
|
||
});
|
||
assert.equal(r.status, 200, 'login failed: ' + JSON.stringify(await r.json()));
|
||
return (r.headers.get('set-cookie') || '').split(';')[0];
|
||
}
|
||
|
||
test('users: list + create + delete + admin reset password', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('admin', $1)`, [await hashPassword('admin-passphrase!!')]);
|
||
const { baseUrl, close } = await app(pool);
|
||
try {
|
||
const cookie = await login(baseUrl, 'admin', 'admin-passphrase!!');
|
||
|
||
// List
|
||
const list = await fetch(baseUrl + '/api/v1/auth/users', { headers: { cookie } });
|
||
assert.equal(list.status, 200);
|
||
const users0 = await list.json();
|
||
assert.ok(users0.find(u => u.username === 'admin'));
|
||
|
||
// Create
|
||
const created = await fetch(baseUrl + '/api/v1/auth/users', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json', cookie },
|
||
body: JSON.stringify({ username: 'bob', password: 'bob-passphrase!', display_name: 'Bob' }),
|
||
});
|
||
assert.equal(created.status, 201);
|
||
const bob = await created.json();
|
||
assert.equal(bob.username, 'bob');
|
||
|
||
// Admin reset password
|
||
const reset = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id + '/password', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json', cookie },
|
||
body: JSON.stringify({ new_password: 'a-fresh-passphrase' }),
|
||
});
|
||
assert.equal(reset.status, 204);
|
||
|
||
// Delete
|
||
const del = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id, {
|
||
method: 'DELETE', headers: { cookie },
|
||
});
|
||
assert.equal(del.status, 204);
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
|
||
test('users: cannot delete the last real user', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
await pool.query(`INSERT INTO users (id, username, password_hash) VALUES (uuid_generate_v4(), 'solo', $1)`, [await hashPassword('only-user-here-12')]);
|
||
const { baseUrl, close } = await app(pool);
|
||
try {
|
||
const cookie = await login(baseUrl, 'solo', 'only-user-here-12');
|
||
const me = await (await fetch(baseUrl + '/api/v1/auth/me', { headers: { cookie } })).json();
|
||
const r = await fetch(baseUrl + '/api/v1/auth/users/' + me.id, { method: 'DELETE', headers: { cookie } });
|
||
assert.equal(r.status, 409);
|
||
assert.equal((await r.json()).error, 'cannot delete last user');
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Run, confirm both tests fail (module not found)**
|
||
|
||
- [ ] **Step 4: Implement users.js**
|
||
|
||
Create `services/mam-api/src/routes/users.js`:
|
||
|
||
```js
|
||
// User CRUD. Mounted at /api/v1/auth/users by index.js (behind requireAuth via the gate).
|
||
// Flat access: any logged-in user can manage other users (spec).
|
||
import express from 'express';
|
||
import pool from '../db/pool.js';
|
||
import { hashPassword } from '../auth/passwords.js';
|
||
import { DEV_USER_ID } from '../middleware/auth.js';
|
||
|
||
const router = express.Router();
|
||
const MIN_PASSWORD_LEN = 12;
|
||
|
||
function bad(res, msg) { return res.status(400).json({ error: msg }); }
|
||
|
||
// GET / — list users (real ones; dev seed hidden)
|
||
router.get('/', async (_req, res, next) => {
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT id, username, display_name, role, last_login_at, created_at
|
||
FROM users WHERE id <> $1 ORDER BY username`, [DEV_USER_ID]);
|
||
res.json(rows);
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
// POST / — create user
|
||
router.post('/', async (req, res, next) => {
|
||
try {
|
||
const { username, password, display_name, role } = req.body || {};
|
||
if (!username || typeof username !== 'string') return bad(res, 'username required');
|
||
if (!password || password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
||
const hash = await hashPassword(password);
|
||
const { rows } = await pool.query(
|
||
`INSERT INTO users (username, password_hash, display_name, role)
|
||
VALUES ($1, $2, $3, $4)
|
||
RETURNING id, username, display_name, role, created_at`,
|
||
[username.trim(), hash, display_name || username.trim(), role || 'admin']
|
||
);
|
||
res.status(201).json(rows[0]);
|
||
} catch (err) {
|
||
if (err.code === '23505') return res.status(409).json({ error: 'username already exists' });
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
// POST /:id/password — admin reset another user's password
|
||
router.post('/:id/password', async (req, res, next) => {
|
||
try {
|
||
const { new_password } = req.body || {};
|
||
if (!new_password || new_password.length < MIN_PASSWORD_LEN) {
|
||
return bad(res, 'new password must be at least ' + MIN_PASSWORD_LEN + ' chars');
|
||
}
|
||
const hash = await hashPassword(new_password);
|
||
const { rowCount } = await pool.query(
|
||
`UPDATE users SET password_hash = $1, password_updated_at = NOW() WHERE id = $2 AND id <> $3`,
|
||
[hash, req.params.id, DEV_USER_ID]);
|
||
if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
|
||
res.status(204).end();
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
// DELETE /:id — delete a user, except the last real user
|
||
router.delete('/:id', async (req, res, next) => {
|
||
try {
|
||
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot delete dev user' });
|
||
const { rows } = await pool.query(
|
||
`SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]);
|
||
if (rows[0].n <= 1) return res.status(409).json({ error: 'cannot delete last user' });
|
||
const { rowCount } = await pool.query(`DELETE FROM users WHERE id = $1`, [req.params.id]);
|
||
if (rowCount === 0) return res.status(404).json({ error: 'user not found' });
|
||
res.status(204).end();
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
export default router;
|
||
```
|
||
|
||
- [ ] **Step 5: Wire the router in index.js**
|
||
|
||
In `services/mam-api/src/index.js`, in the API Routes block (next to other router mounts), add:
|
||
|
||
```js
|
||
import usersRouter from './routes/users.js';
|
||
// ...
|
||
app.use('/api/v1/auth/users', usersRouter);
|
||
```
|
||
|
||
This sits behind the auth gate from Task 6 (the gate sees `/auth/users/...`, which is not in the unauth allowlist, so `requireAuth` runs).
|
||
|
||
- [ ] **Step 6: Run, confirm tests pass**
|
||
|
||
```bash
|
||
TEST_DATABASE_URL=... node --test test/routes/users.test.js
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add services/mam-api/src/routes/users.js services/mam-api/src/index.js services/mam-api/test/routes/users.test.js
|
||
git commit -m "feat(mam-api): user CRUD + admin password reset + last-user delete guard"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 13: API token CRUD (Premiere panel transport)
|
||
|
||
**Files:**
|
||
- Create: `services/mam-api/src/routes/tokens.js`
|
||
- Create: `services/mam-api/test/routes/tokens.test.js`
|
||
- Modify: `services/mam-api/src/index.js`
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `services/mam-api/test/routes/tokens.test.js`:
|
||
|
||
```js
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import express from 'express';
|
||
import session from 'express-session';
|
||
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
|
||
import tokensRouter from '../../src/routes/tokens.js';
|
||
import authRouter from '../../src/routes/auth.js';
|
||
import { requireAuth } from '../../src/middleware/auth.js';
|
||
import { hashPassword } from '../../src/auth/passwords.js';
|
||
|
||
async function app(pool) {
|
||
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL;
|
||
process.env.AUTH_ENABLED = 'true';
|
||
const ConnectPg = (await import('connect-pg-simple')).default(session);
|
||
const a = express();
|
||
a.use(express.json());
|
||
a.use(session({
|
||
store: new ConnectPg({ pool, tableName: 'sessions' }),
|
||
secret: 'test', name: 'dragonflight.sid',
|
||
cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 },
|
||
rolling: false, resave: false, saveUninitialized: false,
|
||
}));
|
||
a.use('/api/v1/auth', authRouter);
|
||
a.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
|
||
// Tiny protected route to prove the issued token works for bearer auth.
|
||
a.get('/api/v1/protected/ping', requireAuth, (req, res) => res.json({ user: req.user.username }));
|
||
return new Promise(r => {
|
||
const srv = a.listen(0, '127.0.0.1', () => {
|
||
r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) });
|
||
});
|
||
});
|
||
}
|
||
|
||
async function loginCookie(baseUrl, u, p) {
|
||
const r = await fetch(baseUrl + '/api/v1/auth/login', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ username: u, password: p }),
|
||
});
|
||
return (r.headers.get('set-cookie') || '').split(';')[0];
|
||
}
|
||
|
||
test('tokens: create returns the raw token exactly once; bearer of that token works; revoke 401s subsequent calls', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
|
||
const { baseUrl, close } = await app(pool);
|
||
try {
|
||
const cookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery');
|
||
|
||
// Create
|
||
const create = await fetch(baseUrl + '/api/v1/auth/tokens', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json', cookie },
|
||
body: JSON.stringify({ name: 'Premiere panel' }),
|
||
});
|
||
assert.equal(create.status, 201);
|
||
const created = await create.json();
|
||
assert.match(created.token, /^dfl_[0-9a-f]{64}$/);
|
||
assert.equal(created.prefix, created.token.slice(0, 8));
|
||
|
||
// List — should NOT include the raw token, only the prefix.
|
||
const list = await fetch(baseUrl + '/api/v1/auth/tokens', { headers: { cookie } });
|
||
const rows = await list.json();
|
||
assert.equal(rows.length, 1);
|
||
assert.equal(rows[0].prefix, created.prefix);
|
||
assert.equal(rows[0].token, undefined);
|
||
|
||
// The raw token authenticates as a bearer.
|
||
const ping = await fetch(baseUrl + '/api/v1/protected/ping', {
|
||
headers: { authorization: 'Bearer ' + created.token },
|
||
});
|
||
assert.equal(ping.status, 200);
|
||
|
||
// Revoke.
|
||
const rev = await fetch(baseUrl + '/api/v1/auth/tokens/' + created.id, {
|
||
method: 'DELETE', headers: { cookie },
|
||
});
|
||
assert.equal(rev.status, 204);
|
||
|
||
// Same bearer now 401s.
|
||
const ping2 = await fetch(baseUrl + '/api/v1/protected/ping', {
|
||
headers: { authorization: 'Bearer ' + created.token },
|
||
});
|
||
assert.equal(ping2.status, 401);
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
|
||
test('tokens: cannot revoke another users token', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => {
|
||
const pool = await setupTestDb();
|
||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]);
|
||
await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('bob-passphrase-12')]);
|
||
const { baseUrl, close } = await app(pool);
|
||
try {
|
||
const aliceCookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery');
|
||
const bobCookie = await loginCookie(baseUrl, 'bob', 'bob-passphrase-12');
|
||
const aliceTok = await (await fetch(baseUrl + '/api/v1/auth/tokens', {
|
||
method: 'POST', headers: { 'Content-Type': 'application/json', cookie: aliceCookie },
|
||
body: JSON.stringify({ name: 'alice token' }),
|
||
})).json();
|
||
const r = await fetch(baseUrl + '/api/v1/auth/tokens/' + aliceTok.id, {
|
||
method: 'DELETE', headers: { cookie: bobCookie },
|
||
});
|
||
assert.equal(r.status, 404); // not found from bob's perspective
|
||
} finally { await close(); await pool.end(); }
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm tests fail**
|
||
|
||
- [ ] **Step 3: Implement tokens.js**
|
||
|
||
Create `services/mam-api/src/routes/tokens.js`:
|
||
|
||
```js
|
||
// Current-user API token CRUD. The raw token is returned exactly once at
|
||
// creation time; only the SHA-256 hash and an 8-char display prefix are stored.
|
||
import express from 'express';
|
||
import pool from '../db/pool.js';
|
||
import { generateToken, hashToken, tokenDisplayPrefix } from '../auth/tokens.js';
|
||
|
||
const router = express.Router();
|
||
|
||
// GET / — list current users tokens (prefix only, never the raw token)
|
||
router.get('/', async (req, res, next) => {
|
||
try {
|
||
const { rows } = await pool.query(
|
||
`SELECT id, name, token_prefix AS prefix, last_used_at, expires_at, created_at
|
||
FROM api_tokens WHERE user_id = $1 ORDER BY created_at DESC`,
|
||
[req.user.id]);
|
||
res.json(rows);
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
// POST / — create a new token
|
||
router.post('/', async (req, res, next) => {
|
||
try {
|
||
const { name, expires_at } = req.body || {};
|
||
if (!name || typeof name !== 'string') return res.status(400).json({ error: 'name required' });
|
||
const raw = generateToken();
|
||
const { rows } = await pool.query(
|
||
`INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, expires_at)
|
||
VALUES ($1, $2, $3, $4, $5)
|
||
RETURNING id, name, token_prefix AS prefix, expires_at, created_at`,
|
||
[req.user.id, name.trim(), hashToken(raw), tokenDisplayPrefix(raw), expires_at || null]);
|
||
res.status(201).json({ ...rows[0], token: raw }); // raw token shown exactly once
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
// DELETE /:id — revoke; only the owner can revoke
|
||
router.delete('/:id', async (req, res, next) => {
|
||
try {
|
||
const { rowCount } = await pool.query(
|
||
`DELETE FROM api_tokens WHERE id = $1 AND user_id = $2`,
|
||
[req.params.id, req.user.id]);
|
||
if (rowCount === 0) return res.status(404).json({ error: 'token not found' });
|
||
res.status(204).end();
|
||
} catch (err) { next(err); }
|
||
});
|
||
|
||
export default router;
|
||
```
|
||
|
||
- [ ] **Step 4: Wire the router in index.js**
|
||
|
||
In `services/mam-api/src/index.js`:
|
||
|
||
```js
|
||
import tokensRouter from './routes/tokens.js';
|
||
// ...
|
||
app.use('/api/v1/auth/tokens', tokensRouter);
|
||
```
|
||
|
||
- [ ] **Step 5: Run tests, confirm pass**
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add services/mam-api/src/routes/tokens.js services/mam-api/src/index.js services/mam-api/test/routes/tokens.test.js
|
||
git commit -m "feat(mam-api): API token CRUD — show raw once, bearer-authenticate via SHA-256 lookup"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 14: Login rate limit + CSRF X-Requested-With header
|
||
|
||
**Files:**
|
||
- Create: `services/mam-api/src/auth/rate-limit.js`
|
||
- Modify: `services/mam-api/src/routes/auth.js`
|
||
- Modify: `services/mam-api/src/index.js`
|
||
- Create: `services/mam-api/test/auth/rate-limit.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing rate-limit test**
|
||
|
||
Create `services/mam-api/test/auth/rate-limit.test.js`:
|
||
|
||
```js
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { ipBackoff } from '../../src/auth/rate-limit.js';
|
||
|
||
test('first failure → small delay; repeated failures → exponential up to 30s', () => {
|
||
ipBackoff.reset('1.2.3.4');
|
||
assert.equal(ipBackoff.delayMs('1.2.3.4'), 0); // no delay before first call
|
||
ipBackoff.recordFailure('1.2.3.4');
|
||
assert.equal(ipBackoff.delayMs('1.2.3.4'), 1000);
|
||
ipBackoff.recordFailure('1.2.3.4');
|
||
assert.equal(ipBackoff.delayMs('1.2.3.4'), 2000);
|
||
ipBackoff.recordFailure('1.2.3.4');
|
||
assert.equal(ipBackoff.delayMs('1.2.3.4'), 4000);
|
||
for (let i = 0; i < 10; i++) ipBackoff.recordFailure('1.2.3.4');
|
||
assert.equal(ipBackoff.delayMs('1.2.3.4'), 30000);
|
||
});
|
||
|
||
test('successful login resets the counter', () => {
|
||
ipBackoff.reset('5.6.7.8');
|
||
ipBackoff.recordFailure('5.6.7.8');
|
||
ipBackoff.recordFailure('5.6.7.8');
|
||
ipBackoff.recordSuccess('5.6.7.8');
|
||
assert.equal(ipBackoff.delayMs('5.6.7.8'), 0);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run, confirm fail**
|
||
|
||
- [ ] **Step 3: Implement rate-limit.js**
|
||
|
||
Create `services/mam-api/src/auth/rate-limit.js`:
|
||
|
||
```js
|
||
// Per-IP exponential backoff for /auth/login. Single-instance — fine for
|
||
// Dragonflights deployment shape (one mam-api per node). Documented limitation.
|
||
const failures = new Map(); // ip -> count
|
||
|
||
const STEPS = [1000, 2000, 4000, 8000, 16000, 30000];
|
||
|
||
export const ipBackoff = {
|
||
delayMs(ip) {
|
||
const n = failures.get(ip) || 0;
|
||
if (n === 0) return 0;
|
||
return STEPS[Math.min(n - 1, STEPS.length - 1)];
|
||
},
|
||
recordFailure(ip) {
|
||
failures.set(ip, (failures.get(ip) || 0) + 1);
|
||
},
|
||
recordSuccess(ip) { failures.delete(ip); },
|
||
reset(ip) { failures.delete(ip); },
|
||
};
|
||
```
|
||
|
||
- [ ] **Step 4: Run, confirm rate-limit tests pass**
|
||
|
||
- [ ] **Step 5: Wire the rate limit into POST /auth/login**
|
||
|
||
Edit `services/mam-api/src/routes/auth.js` — at the top of the `/login` handler, after `try {`:
|
||
|
||
```js
|
||
import { ipBackoff } from '../auth/rate-limit.js';
|
||
// ...
|
||
router.post('/login', async (req, res, next) => {
|
||
try {
|
||
const ip = req.ip || req.socket?.remoteAddress || 'unknown';
|
||
const delay = ipBackoff.delayMs(ip);
|
||
if (delay > 0) await new Promise(r => setTimeout(r, delay));
|
||
// ... existing body (lookup user, comparePassword, etc.) ...
|
||
// On failure paths:
|
||
// ipBackoff.recordFailure(ip); return res.status(401).json({ error: 'invalid credentials' });
|
||
// On success path (before res.json):
|
||
// ipBackoff.recordSuccess(ip);
|
||
```
|
||
|
||
Update the existing failure returns in `/login` to call `ipBackoff.recordFailure(ip)` before responding 401; add `ipBackoff.recordSuccess(ip)` just before `res.json({ user })`.
|
||
|
||
- [ ] **Step 6: Add the X-Requested-With CSRF check as a separate middleware**
|
||
|
||
Append to `services/mam-api/src/middleware/auth.js`:
|
||
|
||
```js
|
||
// Belt-and-suspenders CSRF: SameSite=Lax already blocks most cross-site
|
||
// cookie sends, but a custom header that no <form> can produce hardens
|
||
// against the edge cases. Applied to mutating verbs only.
|
||
const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
||
const REQUIRED_HEADER = 'dragonflight-ui';
|
||
|
||
export function requireUiHeader(req, res, next) {
|
||
if (!MUTATING.has(req.method)) return next();
|
||
// Bearer-authed requests (Premiere panel, scripts) are exempt — they're not
|
||
// browsers and can't be drive-by'd from another origin.
|
||
if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next();
|
||
if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next();
|
||
return res.status(403).json({ error: 'missing X-Requested-With header' });
|
||
}
|
||
```
|
||
|
||
In `services/mam-api/src/index.js`, import and mount it just inside the `/api/v1` mount, **before** the auth gate:
|
||
|
||
```js
|
||
import { requireAuth, requireUiHeader } from './middleware/auth.js';
|
||
// ...
|
||
app.use('/api/v1', requireUiHeader); // CSRF header check on mutating verbs
|
||
app.use('/api/v1', (req, res, next) => { /* the auth gate from Task 6 */ });
|
||
```
|
||
|
||
- [ ] **Step 7: Add CSRF test**
|
||
|
||
Append to `services/mam-api/test/middleware/auth.test.js`:
|
||
|
||
```js
|
||
import { requireUiHeader } from '../../src/middleware/auth.js';
|
||
|
||
test('requireUiHeader: GET → next (any header)', () => {
|
||
let called = false;
|
||
requireUiHeader({ method: 'GET', headers: {} }, { status: () => ({ json: () => {} }) }, () => { called = true; });
|
||
assert.equal(called, true);
|
||
});
|
||
|
||
test('requireUiHeader: POST without header → 403', () => {
|
||
const res = { status(n) { this.statusCode = n; return this; }, json(o) { this.body = o; } };
|
||
requireUiHeader({ method: 'POST', headers: {} }, res, () => {});
|
||
assert.equal(res.statusCode, 403);
|
||
});
|
||
|
||
test('requireUiHeader: POST with correct header → next', () => {
|
||
let called = false;
|
||
requireUiHeader({ method: 'POST', headers: { 'x-requested-with': 'dragonflight-ui' } }, {}, () => { called = true; });
|
||
assert.equal(called, true);
|
||
});
|
||
|
||
test('requireUiHeader: POST with bearer auth → next (exempt)', () => {
|
||
let called = false;
|
||
requireUiHeader({ method: 'POST', headers: { authorization: 'Bearer dfl_xxx' } }, {}, () => { called = true; });
|
||
assert.equal(called, true);
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 8: Run the whole test suite**
|
||
|
||
```bash
|
||
TEST_DATABASE_URL=... npm test
|
||
```
|
||
|
||
Expected: all tests pass, including the new CSRF and rate-limit ones. The existing login test must be updated to send the `X-Requested-With: dragonflight-ui` header on all POSTs from the test helpers; do that now if any test broke.
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add services/mam-api/src/auth/rate-limit.js services/mam-api/src/middleware/auth.js services/mam-api/src/routes/auth.js services/mam-api/src/index.js services/mam-api/test/
|
||
git commit -m "feat(mam-api): login rate limit + X-Requested-With CSRF header check"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 15: Frontend — AuthGate orchestration + replace data.jsx 401 bounce
|
||
|
||
**Files:**
|
||
- Create: `services/web-ui/public/auth-gate.jsx`
|
||
- Modify: `services/web-ui/public/data.jsx`
|
||
- Modify: `services/web-ui/public/shell.jsx`
|
||
- Modify: `services/web-ui/public/index.html`
|
||
|
||
- [ ] **Step 1: Create AuthGate (skeleton; screens come in Task 16)**
|
||
|
||
Create `services/web-ui/public/auth-gate.jsx`:
|
||
|
||
```jsx
|
||
// auth-gate.jsx — owns the "logged in or not" state.
|
||
//
|
||
// The SPA boots into <AuthGate>, which calls GET /auth/me. On 401 it then
|
||
// calls GET /auth/setup-required and renders <SetupScreen> or <LoginScreen>
|
||
// (defined in screens-auth.jsx). On 200 it renders the real <App>.
|
||
//
|
||
// This component is the SINGLE source of truth for the auth check — no other
|
||
// component should redirect to a login page or wipe data on 401. Other code
|
||
// surfaces auth failure by calling window.AuthGate.bounce(), which re-mounts
|
||
// the gate so the next /auth/me request decides what to do.
|
||
|
||
(function () {
|
||
const API = '/api/v1';
|
||
const LAST_PATH_KEY = 'df.auth.last_path';
|
||
|
||
async function authFetch(path, opts) {
|
||
return fetch(API + path, {
|
||
credentials: 'include',
|
||
...opts,
|
||
headers: {
|
||
...(opts && opts.headers),
|
||
'Content-Type': 'application/json',
|
||
...((opts && opts.method && opts.method !== 'GET') ? { 'X-Requested-With': 'dragonflight-ui' } : {}),
|
||
},
|
||
});
|
||
}
|
||
|
||
function AuthGate({ children }) {
|
||
const [state, setState] = React.useState({ kind: 'loading' });
|
||
|
||
const check = React.useCallback(async () => {
|
||
setState({ kind: 'loading' });
|
||
try {
|
||
const r = await authFetch('/auth/me');
|
||
if (r.status === 200) {
|
||
const me = await r.json();
|
||
window.ZAMPP_DATA = window.ZAMPP_DATA || {};
|
||
window.ZAMPP_DATA.ME = me;
|
||
setState({ kind: 'authed' });
|
||
return;
|
||
}
|
||
} catch (_) { /* fall through to setup/login decision */ }
|
||
const setup = await authFetch('/auth/setup-required').then(r => r.json()).catch(() => ({ required: false }));
|
||
setState({ kind: setup.required ? 'setup' : 'login' });
|
||
}, []);
|
||
|
||
React.useEffect(() => { check(); }, [check]);
|
||
|
||
// Global hook: anything else (e.g. data.jsx 401 handler) can re-trigger
|
||
// the gate after auth state changes server-side. Saves current path so
|
||
// the user is restored to where they were after login.
|
||
React.useEffect(() => {
|
||
window.AuthGate = {
|
||
bounce(reason) {
|
||
try { sessionStorage.setItem(LAST_PATH_KEY, location.pathname + location.search); } catch {}
|
||
if (reason) console.warn('[AuthGate] bounce:', reason);
|
||
check();
|
||
},
|
||
signedIn() { check(); },
|
||
};
|
||
}, [check]);
|
||
|
||
if (state.kind === 'loading') {
|
||
return (
|
||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: 'var(--bg-0)' }}>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-3)' }}>Loading…</div>
|
||
</div>
|
||
);
|
||
}
|
||
if (state.kind === 'setup') return <SetupScreen onDone={() => window.AuthGate.signedIn()} />;
|
||
if (state.kind === 'login') return <LoginScreen onDone={() => window.AuthGate.signedIn()} />;
|
||
return children;
|
||
}
|
||
|
||
window.AuthGate = window.AuthGate || {};
|
||
window.AuthGateComponent = AuthGate;
|
||
})();
|
||
```
|
||
|
||
- [ ] **Step 2: Update data.jsx — replace the /login.html bounce with AuthGate dispatch + add X-Requested-With header**
|
||
|
||
In `services/web-ui/public/data.jsx`, replace the existing `apiFetch` function (currently lines ~64-81) with:
|
||
|
||
```js
|
||
async function apiFetch(path, opts = {}) {
|
||
const method = (opts.method || 'GET').toUpperCase();
|
||
const headers = {
|
||
...(opts.headers || {}),
|
||
'Content-Type': 'application/json',
|
||
};
|
||
if (method !== 'GET' && method !== 'HEAD') headers['X-Requested-With'] = 'dragonflight-ui';
|
||
|
||
const res = await fetch(API + path, {
|
||
credentials: 'include',
|
||
...opts,
|
||
headers,
|
||
});
|
||
// 401: hand off to AuthGate, which will re-render Login (no full-page reload).
|
||
if (res.status === 401) {
|
||
if (window.AuthGate && typeof window.AuthGate.bounce === 'function') {
|
||
window.AuthGate.bounce('apiFetch saw 401 on ' + path);
|
||
}
|
||
throw new Error('Unauthenticated');
|
||
}
|
||
if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
|
||
return res.json();
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Update shell.jsx — sign-out calls AuthGate.bounce instead of /login.html**
|
||
|
||
In `services/web-ui/public/shell.jsx`, replace the sign-out button's `onClick` (currently around line 215):
|
||
|
||
```jsx
|
||
onClick={async () => {
|
||
try { await window.ZAMPP_API.fetch('/auth/logout', { method: 'POST' }); } catch (_) {}
|
||
window.AuthGate.bounce('user signed out');
|
||
}}
|
||
```
|
||
|
||
- [ ] **Step 4: Add the script tag to index.html (AuthGate must load AFTER React, BEFORE app.js)**
|
||
|
||
In `services/web-ui/public/index.html`, between the `dist/shell.js` line and the screens scripts, add the auth-gate + screens-auth tags. The new order (relevant section):
|
||
|
||
```html
|
||
<script src="dist/data.js"></script>
|
||
<script src="dist/icons.js"></script>
|
||
<script src="dist/visuals.js"></script>
|
||
<script src="dist/shell.js"></script>
|
||
<script src="dist/auth-gate.js"></script> <!-- NEW -->
|
||
<script src="dist/screens-auth.js"></script> <!-- NEW (created in Task 16) -->
|
||
<script src="dist/screens-home.js"></script>
|
||
... rest unchanged ...
|
||
<script src="dist/app.js"></script>
|
||
```
|
||
|
||
- [ ] **Step 5: Update app.jsx to wrap App in AuthGateComponent**
|
||
|
||
In `services/web-ui/public/app.jsx`, change the final `root.render` from:
|
||
|
||
```jsx
|
||
root.render(<App />);
|
||
```
|
||
|
||
to:
|
||
|
||
```jsx
|
||
root.render(<window.AuthGateComponent><App /></window.AuthGateComponent>);
|
||
```
|
||
|
||
- [ ] **Step 6: Build + manual smoke**
|
||
|
||
```bash
|
||
cd services/web-ui && npm run build:jsx
|
||
```
|
||
|
||
Expected: `[build-jsx] compiled N jsx files to /.../dist`, including `auth-gate.js`. (`screens-auth.js` won't exist yet — it's built when Task 16 lands; for now the script tag will produce a 404 in DevTools, which is fine.)
|
||
|
||
Start the stack with `AUTH_ENABLED=true` and `DATABASE_URL` set. Open the web UI in a browser. With an empty `users` table, you should see the in-progress AuthGate loading spinner, then a blank screen (because `<SetupScreen>` doesn't exist yet — Task 16 fills it in). The point of this step is to verify the gate boots, the script loads, and DevTools shows `/api/v1/auth/me → 401` followed by `/api/v1/auth/setup-required → {required: true}`.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add services/web-ui/public/auth-gate.jsx services/web-ui/public/data.jsx services/web-ui/public/shell.jsx services/web-ui/public/app.jsx services/web-ui/public/index.html
|
||
git commit -m "feat(web-ui): AuthGate orchestration + replace 401 bounce with in-SPA re-render"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 16: Frontend — Login and Setup screens (layout B from brainstorm)
|
||
|
||
**Files:**
|
||
- Create: `services/web-ui/public/screens-auth.jsx`
|
||
|
||
- [ ] **Step 1: Build the two screens**
|
||
|
||
Create `services/web-ui/public/screens-auth.jsx`:
|
||
|
||
```jsx
|
||
// LoginScreen + SetupScreen — layout B from the auth brainstorm spec:
|
||
// 22px wordmark + "WILD DRAGON BROADCAST" tagline above a --bg-1 card.
|
||
// Matches DESIGN.md tokens; no decoration, dense, ops register.
|
||
|
||
(function () {
|
||
const API_BASE = '/api/v1';
|
||
|
||
async function postJson(path, body) {
|
||
return fetch(API_BASE + path, {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-Requested-With': 'dragonflight-ui',
|
||
},
|
||
body: JSON.stringify(body),
|
||
});
|
||
}
|
||
|
||
function Brand() {
|
||
return (
|
||
<div style={{ textAlign: 'center', marginBottom: 22 }}>
|
||
<div style={{ fontSize: 22, fontWeight: 600, color: 'var(--text-1)', letterSpacing: '-0.01em' }}>Dragonflight</div>
|
||
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', marginTop: 6, letterSpacing: '0.08em', textTransform: 'uppercase' }}>
|
||
Wild Dragon Broadcast
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Card({ children }) {
|
||
return (
|
||
<div style={{
|
||
background: 'var(--bg-1)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 12,
|
||
padding: 22,
|
||
}}>{children}</div>
|
||
);
|
||
}
|
||
|
||
function Field({ label, type = 'text', value, onChange, autoComplete, autoFocus }) {
|
||
return (
|
||
<div style={{ marginBottom: 14 }}>
|
||
<label style={{ display: 'block', fontSize: 10.5, fontWeight: 600, color: 'var(--text-2)', letterSpacing: '0.06em', textTransform: 'uppercase', marginBottom: 6 }}>
|
||
{label}
|
||
</label>
|
||
<input
|
||
type={type}
|
||
autoComplete={autoComplete}
|
||
autoFocus={autoFocus}
|
||
value={value}
|
||
onChange={e => onChange(e.target.value)}
|
||
style={{
|
||
width: '100%',
|
||
background: 'var(--bg-3)',
|
||
border: '1px solid var(--border)',
|
||
borderRadius: 4,
|
||
padding: '8px 10px',
|
||
fontSize: 12.5,
|
||
color: 'var(--text-1)',
|
||
fontFamily: 'var(--font-mono)',
|
||
boxSizing: 'border-box',
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Button({ children, disabled, onClick, type = 'button' }) {
|
||
return (
|
||
<button
|
||
type={type}
|
||
disabled={disabled}
|
||
onClick={onClick}
|
||
style={{
|
||
width: '100%',
|
||
background: disabled ? 'var(--bg-3)' : 'var(--accent)',
|
||
color: '#fff',
|
||
border: 'none',
|
||
borderRadius: 4,
|
||
padding: '9px',
|
||
fontSize: 13,
|
||
fontWeight: 600,
|
||
fontFamily: 'inherit',
|
||
cursor: disabled ? 'default' : 'pointer',
|
||
opacity: disabled ? 0.6 : 1,
|
||
}}
|
||
>{children}</button>
|
||
);
|
||
}
|
||
|
||
function ErrorRow({ text }) {
|
||
if (!text) return null;
|
||
return (
|
||
<div style={{
|
||
fontSize: 11.5,
|
||
color: 'var(--danger)',
|
||
marginBottom: 12,
|
||
padding: '6px 10px',
|
||
background: 'var(--danger-soft)',
|
||
border: '1px solid var(--danger)',
|
||
borderRadius: 4,
|
||
}}>{text}</div>
|
||
);
|
||
}
|
||
|
||
function Screen({ children }) {
|
||
return (
|
||
<div style={{ minHeight: '100vh', background: 'var(--bg-0)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||
<form onSubmit={e => e.preventDefault()} style={{ width: 300 }}>
|
||
<Brand />
|
||
<Card>{children}</Card>
|
||
</form>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LoginScreen({ onDone }) {
|
||
const [username, setUsername] = React.useState('');
|
||
const [password, setPassword] = React.useState('');
|
||
const [error, setError] = React.useState('');
|
||
const [busy, setBusy] = React.useState(false);
|
||
const submit = async () => {
|
||
setError(''); setBusy(true);
|
||
try {
|
||
const r = await postJson('/auth/login', { username, password });
|
||
if (r.status === 200) { onDone(); return; }
|
||
const body = await r.json().catch(() => ({}));
|
||
setError(body.error || ('Login failed: ' + r.status));
|
||
} catch (e) { setError(e.message || 'Login failed'); }
|
||
finally { setBusy(false); }
|
||
};
|
||
return (
|
||
<Screen>
|
||
<ErrorRow text={error} />
|
||
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
|
||
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
|
||
<Button type="submit" disabled={busy || !username || !password} onClick={submit}>Sign in</Button>
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
function SetupScreen({ onDone }) {
|
||
const [username, setUsername] = React.useState('');
|
||
const [password, setPassword] = React.useState('');
|
||
const [confirm, setConfirm] = React.useState('');
|
||
const [error, setError] = React.useState('');
|
||
const [busy, setBusy] = React.useState(false);
|
||
const submit = async () => {
|
||
setError('');
|
||
if (password !== confirm) { setError('Passwords do not match'); return; }
|
||
if (password.length < 12) { setError('Password must be at least 12 characters'); return; }
|
||
setBusy(true);
|
||
try {
|
||
const r = await postJson('/auth/setup', { username, password });
|
||
if (r.status === 200) { onDone(); return; }
|
||
const body = await r.json().catch(() => ({}));
|
||
setError(body.error || ('Setup failed: ' + r.status));
|
||
} catch (e) { setError(e.message || 'Setup failed'); }
|
||
finally { setBusy(false); }
|
||
};
|
||
return (
|
||
<Screen>
|
||
<div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 14 }}>
|
||
First-run setup — create the first admin
|
||
</div>
|
||
<ErrorRow text={error} />
|
||
<Field label="Username" value={username} onChange={setUsername} autoComplete="username" autoFocus />
|
||
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="new-password" />
|
||
<Field label="Confirm password" type="password" value={confirm} onChange={setConfirm} autoComplete="new-password" />
|
||
<Button type="submit" disabled={busy || !username || !password || !confirm} onClick={submit}>Create admin</Button>
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
window.LoginScreen = LoginScreen;
|
||
window.SetupScreen = SetupScreen;
|
||
})();
|
||
```
|
||
|
||
- [ ] **Step 2: Update auth-gate.jsx to reference the global components**
|
||
|
||
In `services/web-ui/public/auth-gate.jsx`, replace the placeholder JSX where it returns Setup/Login with:
|
||
|
||
```jsx
|
||
if (state.kind === 'setup') return React.createElement(window.SetupScreen, { onDone: () => window.AuthGate.signedIn() });
|
||
if (state.kind === 'login') return React.createElement(window.LoginScreen, { onDone: () => window.AuthGate.signedIn() });
|
||
```
|
||
|
||
(Necessary because the JSX `<SetupScreen ... />` references aren't in scope of the IIFE — using `window.SetupScreen` keeps the cross-file linkage explicit.)
|
||
|
||
- [ ] **Step 3: Build**
|
||
|
||
```bash
|
||
cd services/web-ui && npm run build:jsx
|
||
```
|
||
|
||
Expected: `screens-auth.js` and updated `auth-gate.js` in `public/dist/`.
|
||
|
||
- [ ] **Step 4: Manual smoke — full setup → app → reload → logout → login**
|
||
|
||
With `AUTH_ENABLED=true`, empty `users` table, browser open:
|
||
|
||
1. **Setup:** see "First-run setup" screen. Enter username `admin`, password ≥12 chars. Submit. App loads.
|
||
2. **Reload:** page reloads, no flash to login — app renders again (cookie unlocks `/auth/me`).
|
||
3. **Sign out:** click power icon in sidebar. Login screen renders.
|
||
4. **Login back in:** enter same creds. App loads. **No infinite loop.**
|
||
5. **Wrong password:** enter wrong password. Inline error appears, no redirect.
|
||
6. **DevTools network tab:** confirm `/auth/me` returns 200 after login, every page action carries `cookie: dragonflight.sid=...`, every POST carries `X-Requested-With: dragonflight-ui`.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add services/web-ui/public/screens-auth.jsx services/web-ui/public/auth-gate.jsx
|
||
git commit -m "feat(web-ui): LoginScreen + SetupScreen (layout B from brainstorm)"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 17: Frontend — Settings → Account section + API Tokens section
|
||
|
||
**Files:**
|
||
- Modify: `services/web-ui/public/screens-admin.jsx`
|
||
|
||
> This task adds two sections inside the existing Settings screen. `screens-admin.jsx` is large (~103 KB); the changes are scoped and additive.
|
||
|
||
- [ ] **Step 1: Locate the Settings tab structure**
|
||
|
||
```bash
|
||
grep -n "function Settings\|case 'settings'\|export.*Settings" /tmp/dragonflight-spec/services/web-ui/public/screens-admin.jsx | head -20
|
||
```
|
||
|
||
Find the `Settings` component and the tab/section structure it uses. Sections in this codebase typically render as panels with a section label header (per DESIGN.md). Read the surrounding 50 lines to understand the existing pattern.
|
||
|
||
- [ ] **Step 2: Add the Account section component**
|
||
|
||
Insert (above the `Settings` component in `screens-admin.jsx`):
|
||
|
||
```jsx
|
||
function AccountSection() {
|
||
const [current, setCurrent] = React.useState('');
|
||
const [next, setNext] = React.useState('');
|
||
const [confirm, setConfirm] = React.useState('');
|
||
const [msg, setMsg] = React.useState(null); // { kind: 'ok'|'err', text }
|
||
const [busy, setBusy] = React.useState(false);
|
||
|
||
const submit = async () => {
|
||
setMsg(null);
|
||
if (next !== confirm) { setMsg({ kind: 'err', text: 'Passwords do not match' }); return; }
|
||
if (next.length < 12) { setMsg({ kind: 'err', text: 'New password must be at least 12 characters' }); return; }
|
||
setBusy(true);
|
||
try {
|
||
const r = await fetch('/api/v1/auth/password', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
||
body: JSON.stringify({ current_password: current, new_password: next }),
|
||
});
|
||
if (r.status === 204) {
|
||
setMsg({ kind: 'ok', text: 'Password updated' });
|
||
setCurrent(''); setNext(''); setConfirm('');
|
||
} else {
|
||
const body = await r.json().catch(() => ({}));
|
||
setMsg({ kind: 'err', text: body.error || 'Failed (' + r.status + ')' });
|
||
}
|
||
} finally { setBusy(false); }
|
||
};
|
||
|
||
return (
|
||
<section className="panel" style={{ padding: 16, marginBottom: 16 }}>
|
||
<h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>Account</h3>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '160px 1fr', gap: 10, alignItems: 'center', maxWidth: 480 }}>
|
||
<label>Current password</label>
|
||
<input type="password" autoComplete="current-password" className="field-input" value={current} onChange={e => setCurrent(e.target.value)} />
|
||
<label>New password</label>
|
||
<input type="password" autoComplete="new-password" className="field-input" value={next} onChange={e => setNext(e.target.value)} />
|
||
<label>Confirm new password</label>
|
||
<input type="password" autoComplete="new-password" className="field-input" value={confirm} onChange={e => setConfirm(e.target.value)} />
|
||
</div>
|
||
{msg && (
|
||
<div style={{ marginTop: 10, fontSize: 11.5, color: msg.kind === 'ok' ? 'var(--success)' : 'var(--danger)' }}>{msg.text}</div>
|
||
)}
|
||
<button className="btn primary sm" style={{ marginTop: 12 }} disabled={busy || !current || !next || !confirm} onClick={submit}>
|
||
Change password
|
||
</button>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Add the API Tokens section component**
|
||
|
||
Insert next to `AccountSection`:
|
||
|
||
```jsx
|
||
function ApiTokensSection() {
|
||
const [tokens, setTokens] = React.useState([]);
|
||
const [name, setName] = React.useState('');
|
||
const [justCreated, setJustCreated] = React.useState(null); // { token, prefix, name }
|
||
const [busy, setBusy] = React.useState(false);
|
||
|
||
const load = React.useCallback(async () => {
|
||
const r = await fetch('/api/v1/auth/tokens', { credentials: 'include' });
|
||
if (r.status === 200) setTokens(await r.json());
|
||
}, []);
|
||
|
||
React.useEffect(() => { load(); }, [load]);
|
||
|
||
const create = async () => {
|
||
if (!name.trim()) return;
|
||
setBusy(true);
|
||
try {
|
||
const r = await fetch('/api/v1/auth/tokens', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
|
||
body: JSON.stringify({ name: name.trim() }),
|
||
});
|
||
if (r.status === 201) {
|
||
const created = await r.json();
|
||
setJustCreated(created);
|
||
setName('');
|
||
await load();
|
||
}
|
||
} finally { setBusy(false); }
|
||
};
|
||
|
||
const revoke = async (id) => {
|
||
await fetch('/api/v1/auth/tokens/' + id, {
|
||
method: 'DELETE',
|
||
credentials: 'include',
|
||
headers: { 'X-Requested-With': 'dragonflight-ui' },
|
||
});
|
||
await load();
|
||
};
|
||
|
||
return (
|
||
<section className="panel" style={{ padding: 16, marginBottom: 16 }}>
|
||
<h3 style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--text-3)', letterSpacing: '0.08em', textTransform: 'uppercase', marginBottom: 12 }}>API Tokens</h3>
|
||
|
||
{justCreated && (
|
||
<div style={{ marginBottom: 12, padding: 10, border: '1px solid var(--accent)', background: 'var(--accent-soft)', borderRadius: 6 }}>
|
||
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--accent-text)', marginBottom: 6 }}>
|
||
Save this token now — it will not be shown again
|
||
</div>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 12, color: 'var(--text-1)', wordBreak: 'break-all', marginBottom: 6 }}>{justCreated.token}</div>
|
||
<button className="btn sm" onClick={() => navigator.clipboard.writeText(justCreated.token)}>Copy</button>
|
||
<button className="btn sm" style={{ marginLeft: 8 }} onClick={() => setJustCreated(null)}>Dismiss</button>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', gap: 8, marginBottom: 12 }}>
|
||
<input className="field-input" placeholder="Token name (e.g. Premiere panel)" value={name} onChange={e => setName(e.target.value)} style={{ flex: 1 }} />
|
||
<button className="btn primary sm" disabled={busy || !name.trim()} onClick={create}>New token</button>
|
||
</div>
|
||
|
||
<div className="token-list">
|
||
{tokens.length === 0 && <div style={{ fontSize: 11.5, color: 'var(--text-3)' }}>No tokens yet.</div>}
|
||
{tokens.map(t => (
|
||
<div key={t.id} className="token-row" style={{ display: 'grid', gridTemplateColumns: '1fr 120px 140px 80px', gap: 10, alignItems: 'center', padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
|
||
<div>{t.name}</div>
|
||
<div style={{ fontFamily: 'var(--font-mono)', fontSize: 11, color: 'var(--text-2)' }}>{t.prefix}…</div>
|
||
<div style={{ fontSize: 11, color: 'var(--text-3)' }}>{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : 'never used'}</div>
|
||
<button className="btn sm" onClick={() => revoke(t.id)}>Revoke</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Insert the two sections at the top of the Settings render**
|
||
|
||
Inside the `Settings` component's return, add:
|
||
|
||
```jsx
|
||
<AccountSection />
|
||
<ApiTokensSection />
|
||
```
|
||
|
||
…before the existing S3 / AMPP / Transcoding sections.
|
||
|
||
- [ ] **Step 5: Build + manual smoke**
|
||
|
||
```bash
|
||
cd services/web-ui && npm run build:jsx
|
||
```
|
||
|
||
In the browser, log in, go to Settings:
|
||
1. **Change password:** enter wrong current → inline red error. Enter correct → "Password updated". Sign out, sign back in with new password.
|
||
2. **Create token:** name it "smoke", click "New token". Token appears in the accent box. Copy it.
|
||
3. **Use token via curl:**
|
||
```bash
|
||
curl -H "Authorization: Bearer <pasted-token>" http://<host>/api/v1/projects
|
||
```
|
||
Expected: JSON list, not 401.
|
||
4. **Revoke:** click Revoke. Same curl now returns 401.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add services/web-ui/public/screens-admin.jsx
|
||
git commit -m "feat(web-ui): Settings → Account (change password) + API Tokens sections"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 18: Cleanup, env defaults, README
|
||
|
||
**Files:**
|
||
- Modify: `.env.example`
|
||
- Modify: `README.md`
|
||
- Verify: no `/login.html` file exists; no stray references remain
|
||
- Modify: boot log message in `services/mam-api/src/index.js`
|
||
|
||
- [ ] **Step 1: Verify no /login.html file exists, and grep for stray references**
|
||
|
||
```bash
|
||
find services/web-ui -name 'login.html' 2>&1
|
||
grep -rn "login.html" services/ 2>&1
|
||
```
|
||
|
||
Expected: `find` returns nothing. `grep` returns either nothing (good) or only references inside the auth-gate / data.jsx / shell.jsx files that you already replaced; if any remain, finish replacing them now (they should be 0 after Task 15).
|
||
|
||
- [ ] **Step 2: Update .env.example**
|
||
|
||
In `/tmp/dragonflight-spec/.env.example`, change the AUTH section and add the ALLOWED_ORIGINS line. The block should read:
|
||
|
||
```
|
||
# Session Configuration
|
||
SESSION_SECRET=changeme
|
||
|
||
# MAM API Configuration
|
||
MAM_API_URL=http://mam-api:3000
|
||
|
||
# Auth — default to ON in production. Setting to 'false' is a dev-only escape
|
||
# hatch that disables all auth checks and attaches a synthetic 'dev' user to
|
||
# every request. Never run with AUTH_ENABLED=false on a network you don't control.
|
||
AUTH_ENABLED=true
|
||
|
||
# CORS allowlist — comma-separated origins that may carry credentials to the API.
|
||
# Same-origin requests via the nginx reverse proxy do not need to be listed here.
|
||
# Leave empty to allow any origin (DEV ONLY).
|
||
ALLOWED_ORIGINS=
|
||
|
||
# Reverse-proxy trust — set 'true' when the API sits behind nginx terminating HTTPS,
|
||
# so secure-cookie + X-Forwarded-Proto behave correctly.
|
||
TRUST_PROXY=false
|
||
```
|
||
|
||
- [ ] **Step 3: Update the boot log message in index.js**
|
||
|
||
In `services/mam-api/src/index.js`, change:
|
||
|
||
```js
|
||
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)';
|
||
```
|
||
|
||
to:
|
||
|
||
```js
|
||
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED — dev mode (dev user attached to every request)';
|
||
```
|
||
|
||
- [ ] **Step 4: Add a README section**
|
||
|
||
Append to `/tmp/dragonflight-spec/README.md`:
|
||
|
||
````markdown
|
||
## Authentication
|
||
|
||
Dragonflight uses local username/password authentication with two transports:
|
||
|
||
- **Browser:** session cookie (`dragonflight.sid`), 8 hour absolute + 1 hour idle timeout.
|
||
- **Premiere panel / scripts:** SHA-256-hashed bearer tokens issued from `Settings → API Tokens`.
|
||
|
||
### First-run setup
|
||
|
||
On a fresh install with `AUTH_ENABLED=true`, navigate to the web UI in a browser.
|
||
With no users in the database, the login screen renders a "First-run setup" form
|
||
instead — fill it in to create the first admin and you are logged in immediately.
|
||
|
||
Subsequent users are created from `Settings → Users` (any signed-in user can
|
||
create others — flat access).
|
||
|
||
### Dev mode
|
||
|
||
Setting `AUTH_ENABLED=false` (default in `.env.example` for local dev) disables
|
||
all auth checks; a synthetic `dev` user is attached to every request. **Never
|
||
deploy this way.** The dev user row is seeded with a hash that no real password
|
||
can match, so flipping `AUTH_ENABLED=true` later does not expose the dev account.
|
||
|
||
### Recovering a forgotten admin password
|
||
|
||
Any signed-in user can reset another user's password from `Settings → Users`.
|
||
If no one can sign in (all admins forgot their passwords), reset directly in
|
||
Postgres:
|
||
|
||
```sql
|
||
-- generate a fresh bcrypt hash with:
|
||
-- node -e "import('bcrypt').then(b => b.default.hash(process.argv[1], 12).then(h => console.log(h)))" 'new-passphrase-here'
|
||
UPDATE users SET password_hash = '<bcrypt-hash>', password_updated_at = NOW()
|
||
WHERE username = 'admin';
|
||
```
|
||
|
||
### `AUTH_ENABLED` transition
|
||
|
||
When flipping `AUTH_ENABLED=false` → `true` on an existing install:
|
||
|
||
1. Ensure `SESSION_SECRET` is set to a stable value (rotating it logs everyone out).
|
||
2. Set `ALLOWED_ORIGINS` to the public origin(s) of the web UI.
|
||
3. Restart `mam-api`.
|
||
4. Visit the UI — first-run setup will appear if no real users exist yet.
|
||
````
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add .env.example README.md services/mam-api/src/index.js
|
||
git commit -m "docs(auth): flip AUTH_ENABLED default + document setup + recovery"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 19: Final integration smoke — full system test
|
||
|
||
**Files:**
|
||
- (None to modify. Manual + curl smoke documented for the PR.)
|
||
|
||
- [ ] **Step 1: Bring up the full stack**
|
||
|
||
```bash
|
||
cd /tmp/dragonflight-spec
|
||
docker compose down -v # nuke the DB to exercise first-run
|
||
docker compose up --build -d
|
||
docker compose logs -f mam-api | grep -E 'Authentication|listening' &
|
||
```
|
||
|
||
Expected: `Authentication: ENABLED` (assuming `.env` has `AUTH_ENABLED=true` now) and `MAM API listening on port 3000`.
|
||
|
||
- [ ] **Step 2: Run the full test suite once more**
|
||
|
||
```bash
|
||
cd services/mam-api
|
||
TEST_DATABASE_URL=postgres://wilddragon:test@localhost:5432/wilddragon_test npm test
|
||
```
|
||
|
||
Expected: every test passes.
|
||
|
||
- [ ] **Step 3: Manual end-to-end smoke (browser)**
|
||
|
||
1. Open http://localhost:47434 (or whatever the web-ui port is in your compose). See setup screen.
|
||
2. Create admin `op1` / 12-char password. App loads.
|
||
3. Open DevTools → Application → Cookies — verify `dragonflight.sid` is `HttpOnly` and `SameSite=Lax`.
|
||
4. Reload the page. Stays logged in.
|
||
5. Settings → API Tokens → create "smoke-bearer". Copy token.
|
||
6. `curl -H "Authorization: Bearer <token>" http://localhost:47434/api/v1/projects` — 200 with JSON.
|
||
7. Settings → API Tokens → revoke "smoke-bearer". Repeat curl — 401.
|
||
8. Settings → Account → change password. Sign out. Sign in with new password.
|
||
9. **Idle timeout simulation (optional):** in psql, `UPDATE sessions SET sess = jsonb_set(sess, '{last_seen_at}', to_jsonb((extract(epoch from now()) * 1000 - 3700000)::bigint));`. Reload. Should bounce to login.
|
||
|
||
- [ ] **Step 4: Push the branch and open a PR**
|
||
|
||
```bash
|
||
git push -u origin feat/auth-system
|
||
gh pr create --title "feat(auth): wire end-to-end auth — sessions + bearer + first-run setup" \
|
||
--body "$(cat <<'EOF'
|
||
## Summary
|
||
|
||
Implements `docs/superpowers/specs/2026-05-27-auth-system-design.md`.
|
||
|
||
- `express-session` + `connect-pg-simple` actually mounted (was missing — root cause of the prior redirect loop).
|
||
- One `requireAuth` middleware handling both cookie and bearer auth.
|
||
- Auth router: `/auth/setup`, `/auth/login`, `/auth/logout`, `/auth/me`, `/auth/password`, user CRUD, API token CRUD.
|
||
- AuthGate React component owns "logged in or not" state — no more full-page bounces to a non-existent `/login.html`.
|
||
- Login + Setup screens matching DESIGN.md tokens (layout B from brainstorm).
|
||
- Settings → Account (change own password) + Settings → API Tokens (issue / revoke).
|
||
- Per-IP exponential login backoff + X-Requested-With CSRF header.
|
||
- Tightened CORS to an `ALLOWED_ORIGINS` allowlist.
|
||
- Cluster carve-out preserved for node-agent's existing token binding.
|
||
|
||
## Test plan
|
||
|
||
- [ ] `npm test` in `services/mam-api` (with TEST_DATABASE_URL) — all green
|
||
- [ ] Fresh install setup → create admin → land on dashboard
|
||
- [ ] Reload after login — stays signed in
|
||
- [ ] Sign out → see login screen
|
||
- [ ] Wrong password → inline error, no redirect
|
||
- [ ] Repeated wrong passwords → noticeable backoff
|
||
- [ ] API token issued in UI authenticates via `Authorization: Bearer`
|
||
- [ ] Revoked token returns 401
|
||
- [ ] `AUTH_ENABLED=false` boot still works (dev mode unchanged)
|
||
|
||
Spec: `docs/superpowers/specs/2026-05-27-auth-system-design.md`
|
||
EOF
|
||
)"
|
||
```
|
||
|
||
- [ ] **Step 5: Done**
|
||
|
||
The branch is ready for review. The original redirect loop is covered by the regression test in `test/routes/auth.test.js`.
|
||
|
||
---
|
||
|
||
## Self-review (done at write time)
|
||
|
||
**Spec coverage:**
|
||
|
||
| Spec section | Task |
|
||
|---|---|
|
||
| Migration 023 | 2 |
|
||
| Session middleware wired | 5 |
|
||
| requireAuth middleware (session + bearer + idle + absolute) | 4 |
|
||
| Auth gate at /api/v1 with allowlist | 6 |
|
||
| Cluster carve-out decision | 6 |
|
||
| `/auth/setup-required` | 7 |
|
||
| `/auth/setup` | 8 |
|
||
| `/auth/login` + `req.session.save()` callback fix | 9 |
|
||
| Regression test for redirect loop | 9 |
|
||
| `/auth/logout` | 10 |
|
||
| `/auth/me`, `/auth/password` | 11 |
|
||
| User CRUD + delete-last-user guard | 12 |
|
||
| API token CRUD (one-time-show, SHA-256 stored) | 13 |
|
||
| Premiere panel bearer flow end-to-end | 13 |
|
||
| Rate limiting | 14 |
|
||
| CSRF X-Requested-With | 14 |
|
||
| AuthGate orchestration | 15 |
|
||
| LoginScreen + SetupScreen (layout B) | 16 |
|
||
| Settings → Account + API Tokens | 17 |
|
||
| CORS tightening | 5 |
|
||
| `.env.example` + boot log + README updates | 18 |
|
||
| Delete `/login.html` references | 15, 18 (verify) |
|
||
| Final integration smoke | 19 |
|
||
| Manual test (8h absolute / 1h idle simulation) | 19 |
|
||
|
||
**Placeholder scan:** none — every step has explicit code or commands.
|
||
|
||
**Type consistency:** `DEV_USER_ID` constant defined in Task 4, referenced unchanged in Tasks 7, 8, 9, 12. `parseBearer`, `hashToken`, `generateToken`, `tokenDisplayPrefix` defined in Task 3, all referenced consistently in Tasks 4, 13. `requireAuth` exported from Task 4, imported in Tasks 6, 11, 12, 13. `ipBackoff` exported from Task 14, imported in Task 14 step 5. `window.AuthGate.bounce(reason)` / `window.AuthGate.signedIn()` signatures defined in Task 15, called the same way from data.jsx, shell.jsx, screens-auth.jsx.
|
||
|
||
**Scope decomposition:** This is one feature, one plan. Tasks 15–17 are frontend; they could be split off into a separate "frontend integration" plan if a different engineer is going to do the JS, but the spec is one cohesive unit and the boundaries between backend + frontend are clean within tasks so a single plan is fine.
|