Merge branch 'feat/auth-system'

This commit is contained in:
Zac Gaetano 2026-05-27 15:47:56 -04:00
commit 14ece1a160
33 changed files with 7960 additions and 17 deletions

View file

@ -22,5 +22,17 @@ SESSION_SECRET=changeme
# MAM API Configuration # MAM API Configuration
MAM_API_URL=http://mam-api:3000 MAM_API_URL=http://mam-api:3000
# Auth (set to 'true' to require login; false for open/dev mode) # Auth — default to ON in production. Setting to 'false' is a dev-only escape
AUTH_ENABLED=false # 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. ALSO required for accurate
# per-IP login rate-limiting (otherwise req.ip is always the nginx IP).
TRUST_PROXY=false

View file

@ -205,6 +205,54 @@ Total time from end of capture to relinked master: ~2 minutes.
- `deploy/test-cluster.sh` — primary↔worker connectivity smoke test - `deploy/test-cluster.sh` — primary↔worker connectivity smoke test
- `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + growing-file flow - `docs/GROWING_FILES_QUICKSTART.md` — Premiere CEP panel install + growing-file flow
## 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` 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. Set `TRUST_PROXY=true` when behind nginx (required for rate-limit accuracy).
4. Restart `mam-api`.
5. Visit the UI — first-run setup will appear if no real users exist yet.
---
## License ## License
Proprietary — Wild Dragon LLC, all rights reserved. Proprietary — Wild Dragon LLC, all rights reserved.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,269 @@
# Dragonflight User Authentication — Design
**Status:** Approved, ready for implementation planning
**Date:** 2026-05-27
**Brainstormed with:** Zac
## Problem
Dragonflight has the skeleton of an auth system spread across the codebase:
- `users` table (`id`, `username`, `password_hash`, `display_name`, `role`)
- `sessions` table (`sid`, `sess`, `expire`) for `connect-pg-simple`
- `groups`, `user_groups`, `api_tokens` tables
- `SESSION_SECRET` env var
- `AUTH_ENABLED` env flag with boot-log toggle
- PR #26 frontend handler that bounces to `/login.html` on 401
- Issue #94 "session security fixes" deployed 2026-05-26 (commit `3ebe5d6`)
But the actual `express-session` middleware was never mounted in `services/mam-api/src/index.js`. There is no `/api/v1/auth/*` router. There is no `requireAuth` middleware. As a result, when `AUTH_ENABLED=true` was tried:
1. User submits login, server returns 200 OK from a stub endpoint.
2. No `Set-Cookie` is ever sent (no session middleware mounted).
3. The next request to a protected route returns 401.
4. Frontend bounces to `/login.html`.
5. **Infinite redirect loop.**
The prior attempts failed because auth was being built reactively in pieces, with no single source of truth for what "logged in" means.
## Goals
- One coherent, readable auth code path.
- Web UI logins survive page reloads and container restarts.
- Premiere panel can authenticate via long-lived bearer tokens.
- First-run setup works on a fresh install with no env var or CLI gymnastics.
- The whole auth flow can be exercised by automated tests, including a regression test for the redirect-loop failure mode.
## Non-goals (v1)
- MFA / TOTP.
- OAuth / OIDC delegation (Forgejo, Google, etc.).
- Per-project or per-recorder permissions. Flat access: logged in = full access.
- Email-based "forgot password" (no SMTP assumed; admin-reset only).
- Audit log of who-did-what (the `last_login_at` column is the minimum).
- Service-to-service auth for `node-agent` — keeps existing `019-node-token-binding` mechanism.
## Decisions
| Decision | Choice | Reasoning |
|---|---|---|
| Client surface | Web UI + Premiere panel | Two transports (cookies + bearer), one identity backend |
| Permission model | Flat (logged in = full access) | Small homogeneous operator population. `groups` / `user_groups` schemas stay inert. |
| Identity provider | Local username/password | On-prem broadcast operators won't tolerate OIDC roundtrips. Matches existing schema. |
| First-user bootstrap | First-run setup page | Hardest to mis-configure. No env vars to leak. No CLI to remember. |
| Session lifetime | 8h absolute + 1h sliding idle | Operator security posture, tighter than typical SaaS. |
| Auth library | Hand-rolled (`express-session` + `connect-pg-simple`) | Explicit, debuggable. Rejected JWT and Passport for this codebase. |
## Architecture
### Single source of truth
"Logged in" means exactly one of two things:
1. The request carries a valid `dragonflight.sid` cookie whose row in `sessions` hasn't expired and isn't past its 1h-idle or 8h-absolute window, OR
2. The request carries `Authorization: Bearer <token>` whose SHA-256 matches an `api_tokens` row that hasn't been revoked or expired.
Nothing else counts. No `localStorage` flags, no JWT, no client-side "I think I'm logged in" hints.
### One middleware, one check
`services/mam-api/src/middleware/auth.js` exposes a single `requireAuth` function:
```js
export async function requireAuth(req, res, next) {
// Dev mode preserved. The 'dev' user is a real row in `users` seeded at
// boot when AUTH_ENABLED !== 'true', so FK-bearing routes (api_tokens,
// future comments, audit fields) keep working without conditional logic.
if (process.env.AUTH_ENABLED !== 'true') {
req.user = DEV_USER; // { id: <UUID of seeded 'dev' user>, username: 'dev' }
return next();
}
// 1. Session check
if (req.session?.user_id) {
const now = Date.now();
if (now - req.session.first_seen_at > 8 * 3600 * 1000) return destroyAnd401(req, res);
if (now - req.session.last_seen_at > 1 * 3600 * 1000) return destroyAnd401(req, res);
req.session.last_seen_at = now;
req.user = await loadUser(req.session.user_id);
if (!req.user) return destroyAnd401(req, res);
return next();
}
// 2. Bearer check
const bearer = parseBearer(req.headers.authorization);
if (bearer) {
const hash = sha256hex(bearer);
const row = await pool.query(
`SELECT t.id, t.user_id, t.expires_at, u.username
FROM api_tokens t JOIN users u ON u.id = t.user_id
WHERE t.token_hash = $1`, [hash]);
if (row.rows.length && (!row.rows[0].expires_at || row.rows[0].expires_at > new Date())) {
pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [row.rows[0].id]).catch(() => {});
req.user = { id: row.rows[0].user_id, username: row.rows[0].username };
return next();
}
}
// 3. Otherwise
return res.status(401).json({ error: 'unauthorized' });
}
```
Mounted at the `/api/v1` level in `services/mam-api/src/index.js`, **before** the individual route mounts, with an allowlist for the three pre-login auth paths:
```js
app.use('/api/v1', (req, res, next) => {
const unauth = ['/auth/login', '/auth/setup', '/auth/setup-required'];
if (unauth.some(p => req.path === p)) return next();
return requireAuth(req, res, next);
});
// then: app.use('/api/v1/assets', assetsRouter), etc.
```
`/health` lives at the root, outside the `/api/v1` mount, so it's naturally unaffected. `/api/v1/cluster/*` keeps its existing `019-node-token-binding` service-auth path: requireAuth runs first, fails with 401 for an unauthenticated request, **but** the cluster routes themselves do their own token check on request bodies, so node-agent traffic must include a valid user session OR an api_token (which is the change — node-agent will need to be issued an api_token at install time). Alternative: carve `/api/v1/cluster/*` out of the requireAuth gate too, and keep node-agent on its existing binding token alone. Implementer should pick — flagged in the implementation order.
### Session middleware (actually wired this time)
In `services/mam-api/src/index.js`, **before any route**:
```js
import session from 'express-session';
import connectPgSimple from 'connect-pg-simple';
const PgStore = connectPgSimple(session);
if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1);
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 we can enforce idle + absolute separately
resave: false,
saveUninitialized: false,
}));
```
### Auth router
`services/mam-api/src/routes/auth.js`:
| Method | Path | Auth | Description |
|---|---|---|---|
| `GET` | `/api/v1/auth/setup-required` | none | `{ required: bool }`. Cheap, no auth. |
| `POST` | `/api/v1/auth/setup` | none | Only succeeds if `users` is empty. Creates first user, logs them in. |
| `POST` | `/api/v1/auth/login` | none | `{ username, password }` -> 200 + cookie or 401 |
| `POST` | `/api/v1/auth/logout` | required | Destroys session row, clears cookie |
| `GET` | `/api/v1/auth/me` | required | `{ id, username, display_name }` |
| `POST` | `/api/v1/auth/password` | required | Change own password (requires current) |
| `GET/POST/DELETE` | `/api/v1/auth/users[/:id]` | required | User CRUD |
| `GET/POST/DELETE` | `/api/v1/auth/tokens[/:id]` | required | Current user's API tokens |
### Data model
Existing schema is almost right. One small migration:
```sql
-- services/mam-api/src/db/migrations/023-auth-session-timestamps.sql
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;
-- idle / absolute timestamps live inside session.sess JSONB; no schema change needed
```
`groups` and `user_groups` stay as-is, unused for v1. `api_tokens` is already correctly shaped.
## Flows
### Browser login (the one that broke last time)
1. SPA boots, `<AuthGate>` calls `GET /api/v1/auth/me`.
2. `requireAuth` returns 401.
3. AuthGate calls `GET /api/v1/auth/setup-required`. If `true`, render Setup screen. Otherwise, render Login screen.
4. User submits `POST /api/v1/auth/login`. Server `bcrypt.compare`s, sets `req.session.user_id`, `first_seen_at`, `last_seen_at`. **Critical:** `await new Promise(r => req.session.save(r))` before responding, so the cookie is persisted to Postgres before the next request can arrive.
5. AuthGate re-calls `/api/v1/auth/me`, gets 200, renders the app.
**Why this doesn't loop:** the explicit `req.session.save()` callback before response guarantees the cookie row exists before the SPA can fire its next request. `requireAuth` returns a clean 401 (not a redirect) so the SPA decides what to render. The static `/login.html` is deleted; there is no HTML bounce.
### Premiere panel bearer
1. Web UI -> Settings -> API Tokens -> "New token" named "Premiere panel".
2. `POST /api/v1/auth/tokens` returns `{ token: 'dfl_<32 hex>', prefix: 'dfl_a3f2', id }` **exactly once**.
3. Premiere panel sends `Authorization: Bearer dfl_<...>` on every request. `requireAuth` SHA-256s it, looks up `api_tokens.token_hash`, updates `last_used_at`.
### Idle + absolute timeout (inside `requireAuth`)
```
if session present:
if now - session.first_seen_at > 8h -> destroy session, 401
if now - session.last_seen_at > 1h -> destroy session, 401
session.last_seen_at = now
req.user = lookup(session.user_id)
next()
```
Bearer tokens have their own optional `expires_at` (`NULL` = never expires); checked the same way.
## Frontend
- **`services/web-ui/src/auth-gate.jsx`** — new component that wraps the SPA. On mount: `GET /me`. On 401: check `setup-required`, render either Setup or Login. On 200: render the app shell.
- **Login screen** — layout B from brainstorm: 22px wordmark over "WILD DRAGON BROADCAST" tagline above a `--bg-1` card containing username, password, "Sign in" button. Matches DESIGN.md tokens.
- **Setup screen** — same chrome; fields = username, password, confirm password; button = "Create admin".
- **Settings -> Account section** — change password.
- **Settings -> API Tokens section** — list / create / revoke. New token shown exactly once with a copy affordance.
- **Fetch wrapper** — the central `ZAMPP_API.fetch` (already exists) gains a 401 handler that re-mounts AuthGate's Login state with the current path saved as `last_path`, restored after re-auth.
### Removed
- The static `/login.html` page (PR #26's bounce target) is deleted. SPA handles login internally; no full-page reload.
## Error handling
| Case | Behavior |
|---|---|
| Wrong username or password | `401 { error: 'invalid credentials' }`. Same message either way, no user enumeration. |
| Login rate limiting | Per-IP exponential backoff (1s, 2s, 4s, 8s, max 30s). In-memory `Map`. Single-instance limitation documented. |
| Idle / absolute expiry | 401 -> AuthGate Login. Last path saved, restored on re-auth. |
| Setup after first user exists | `409 { error: 'setup already complete' }`. Permanently disabled. |
| Token revoke | `DELETE /api/v1/auth/tokens/:id` — only owner can revoke. Subsequent bearer requests 401. |
| Delete-self when only user | `409 { error: 'cannot delete last user' }`. |
| Forgot password | No self-serve. Any logged-in user can reset another via `POST /api/v1/auth/users/:id/password`. Documented as the recovery path. |
| Password rules | Min 12 chars, no max, no character class requirements (NIST SP 800-63B). `bcrypt` cost 12. |
| CSRF | `SameSite=Lax` + same origin + required `X-Requested-With: dragonflight-ui` header on mutating requests (belt-and-suspenders). |
| Session table growth | `connect-pg-simple` `pruneSessionInterval: 60 * 15` (every 15 min). |
## Testing
- **Unit — `services/mam-api/test/middleware/auth.test.js`**: requireAuth with (a) no creds, (b) valid session, (c) idle-expired session, (d) absolute-expired session, (e) valid bearer, (f) invalid bearer, (g) bearer matching a deleted user.
- **Integration — `services/mam-api/test/auth.integration.test.js`**: spin up Express + test Postgres. Walks: setup -> login -> /me -> mutating call -> logout -> /me 401. Second pass: idle timeout simulated by mutating `last_seen_at` in DB. Third pass: bearer issue -> use -> revoke -> 401.
- **Regression test for the redirect-loop bug:** explicit test that after `POST /auth/login` returns 200, a subsequent `GET /auth/me` with the returned cookie returns 200 in the same test client. This is the test that would have caught the original failure.
- **Manual smoke (documented in PR):** fresh install -> setup -> create admin -> land on dashboard -> reload (stays logged in) -> wait 1h idle -> reload -> bounce to login.
## Implementation order
Suggested sequencing for the implementation plan (writing-plans will refine):
1. Migration `023-auth-session-timestamps.sql`. Add idempotent seed of the dev user (`INSERT ... ON CONFLICT DO NOTHING` with a fixed UUID) so dev mode FK-bearing routes work out of the box.
2. `express-session` + `connect-pg-simple` wiring in `index.js`.
3. `requireAuth` middleware (with `DEV_USER` constant resolved from the seeded row).
4. Auth router (setup, login, logout, me, password).
5. Apply `requireAuth` to API router with allowlist. Decide cluster carve-out (see Architecture).
6. Auth tests (unit + integration + regression).
7. Frontend `<AuthGate>` + Login screen + Setup screen.
8. Frontend Settings -> Account + API Tokens.
9. Delete `/login.html`.
10. User CRUD + token CRUD routes.
11. Rate limiting + CSRF header.
12. Documentation: README updates, `AUTH_ENABLED` transition notes.
## Out-of-band notes for the implementer
- The current `cors({ origin: true, credentials: true })` in `index.js` is too permissive once cookies start carrying authority. Tighten to a specific origin list (driven by an `ALLOWED_ORIGINS` env var) at the same time as wiring the session middleware — otherwise we're undoing the `SameSite=Lax` protection from the other side.
- node-agent -> mam-api traffic on `/api/v1/cluster/*` must keep working. Add a route-level carve-out comment that this path uses the existing `019-node-token-binding` token, not the user-auth path.
- The boot log currently says `Authentication: ENABLED` / `DISABLED (set AUTH_ENABLED=true for production)`. Once this lands, the recommended default flips: `AUTH_ENABLED=true` becomes the documented default in `.env.example` and the README, and `AUTH_ENABLED=false` is documented as a dev-only escape hatch.

2992
services/mam-api/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -6,7 +6,8 @@
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "node --watch src/index.js" "dev": "node --watch src/index.js",
"test": "node --test $(find test -name '*.test.js' | sort)"
}, },
"dependencies": { "dependencies": {
"express": "^4.18.2", "express": "^4.18.2",

View file

@ -0,0 +1,19 @@
// 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;
}
}

View file

@ -0,0 +1,24 @@
// Per-IP exponential backoff for /auth/login. Single-instance — fine for
// Dragonflight's deployment shape (one mam-api per node). Documented limitation.
const failures = new Map(); // ip -> count
const STEPS = [1000, 2000, 4000, 8000, 16000, 30000];
const MAX_ENTRIES = 10000; // bounded to prevent unbounded growth under spray attacks
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) {
// Evict the oldest entry if we're at the cap. Map preserves insertion order,
// so .keys().next().value is the oldest.
if (failures.size >= MAX_ENTRIES && !failures.has(ip)) {
failures.delete(failures.keys().next().value);
}
failures.set(ip, (failures.get(ip) || 0) + 1);
},
recordSuccess(ip) { failures.delete(ip); },
reset(ip) { failures.delete(ip); },
};

View file

@ -0,0 +1,22 @@
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);
}

View file

@ -0,0 +1,25 @@
-- 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.
--
-- password_updated_at is backfilled with NOW() for existing rows at migration time;
-- treat values from before this deploy as approximate.
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 DO NOTHING;

View file

@ -1,12 +1,19 @@
import 'dotenv/config'; import 'dotenv/config';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
import session from 'express-session';
import connectPgSimple from 'connect-pg-simple';
const PgStore = connectPgSimple(session);
import os from 'node:os'; import os from 'node:os';
import { exec } from 'node:child_process'; import { exec } from 'node:child_process';
import pool from './db/pool.js'; import pool from './db/pool.js';
import { errorHandler } from './middleware/errors.js'; import { errorHandler } from './middleware/errors.js';
import { requireAuth, requireUiHeader } from './middleware/auth.js';
import { loadS3ConfigFromDb } from './s3/client.js'; import { loadS3ConfigFromDb } from './s3/client.js';
import authRouter from './routes/auth.js';
import tokensRouter from './routes/tokens.js';
import usersRouter from './routes/users.js';
// Routes // Routes
import assetsRouter from './routes/assets.js'; import assetsRouter from './routes/assets.js';
import projectsRouter from './routes/projects.js'; import projectsRouter from './routes/projects.js';
@ -34,13 +41,78 @@ const app = express();
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
// ── Middleware ──────────────────────────────────────────────────────────────── // ── Middleware ────────────────────────────────────────────────────────────────
app.use(cors({ origin: true, credentials: true })); // 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);
// Reject cleanly: omit the Allow-Origin header so the browser surfaces
// a real CORS error instead of a 500 from a thrown Error in the callback.
console.warn('[cors] rejected origin:', origin);
return cb(null, false);
},
credentials: true,
}));
app.use(express.json({ limit: '50mb' })); 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);
// Hard-fail when production-mode auth has no stable session secret. Without
// this, express-session falls back to an in-memory random secret which
// invalidates every session on restart and breaks multi-node deployments.
if (process.env.AUTH_ENABLED === 'true' && !process.env.SESSION_SECRET) {
console.error('[fatal] SESSION_SECRET is required when AUTH_ENABLED=true');
process.exit(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 /* seconds = 15 min */ }),
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,
}));
// ── Health ──────────────────────────────────────────────────────────────────── // ── Health ────────────────────────────────────────────────────────────────────
app.get('/health', (_req, res) => res.json({ status: 'ok' })); app.get('/health', (_req, res) => res.json({ status: 'ok' }));
// ── Auth gate ─────────────────────────────────────────────────────────────────
// req.path is relative to the /api/v1 mount, so /auth/login NOT /api/v1/auth/login.
const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']);
// Service-auth carve-outs: node-agent uses migration 019's bound-hostname
// api_token mechanism, not user auth. Today only /cluster/heartbeat is
// reached without a user session — operator/UI endpoints in cluster.js
// (containers restart, DELETE /:id, blackmagic device queries) ARE expected
// to require auth. If node-agent grows another endpoint, add it here.
// TODO: long-term, issue node-agent a real bound api_token and drop this carve-out.
const SERVICE_PATHS = new Set(['/cluster/heartbeat']);
app.use('/api/v1', requireUiHeader);
// then the existing gate:
app.use('/api/v1', (req, res, next) => {
if (UNAUTH_PATHS.has(req.path)) return next();
if (SERVICE_PATHS.has(req.path)) return next();
return requireAuth(req, res, next);
});
// ── API Routes ──────────────────────────────────────────────────────────────── // ── API Routes ────────────────────────────────────────────────────────────────
app.use('/api/v1/auth', authRouter);
app.use('/api/v1/auth/users', usersRouter);
app.use('/api/v1/users', usersRouter); // alias for the existing SPA Users page that calls /api/v1/users; keeps the same auth gate
app.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
app.use('/api/v1/assets', assetsRouter); app.use('/api/v1/assets', assetsRouter);
app.use('/api/v1/projects', projectsRouter); app.use('/api/v1/projects', projectsRouter);
app.use('/api/v1/bins', binsRouter); app.use('/api/v1/bins', binsRouter);
@ -202,9 +274,12 @@ setInterval(selfHeartbeat, 30_000);
selfHeartbeat(); selfHeartbeat();
const server = app.listen(PORT, () => { const server = app.listen(PORT, () => {
const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)'; const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED — dev mode (dev user attached to every request)';
console.log(`MAM API listening on port ${PORT}`); console.log(`MAM API listening on port ${PORT}`);
console.log(`Authentication: ${authMode}`); console.log(`Authentication: ${authMode}`);
if (process.env.AUTH_ENABLED === 'true' && process.env.TRUST_PROXY !== 'true') {
console.warn('[auth] WARNING: AUTH_ENABLED=true but TRUST_PROXY=false — req.ip will be the proxy IP, login rate-limit will throttle all clients together. Set TRUST_PROXY=true when behind nginx/HTTPS.');
}
// Boot the recorder scheduler tick loop after the HTTP server is live so // Boot the recorder scheduler tick loop after the HTTP server is live so
// the loop's self-calls to /recorders/:id/start|stop reach a ready socket. // the loop's self-calls to /recorders/:id/start|stop reach a ready socket.
startSchedulerLoop(); startSchedulerLoop();

View file

@ -0,0 +1,86 @@
import pool from '../db/pool.js';
import { parseBearer, hashToken } from '../auth/tokens.js';
// Stable UUID matching migration 023's seeded dev user.
/** UUID of the seeded dev-mode placeholder. NOT a sentinel for "any unauthenticated 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);
const u = await loadUser(req.session.user_id);
if (!u) return destroyAnd401(req, res);
req.session.last_seen_at = now; // stamp only after user is confirmed; avoids extending idle window if loadUser throws or the user was deleted
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' });
}
// 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';
// Paths exempt from the CSRF header check. Must match the SERVICE_PATHS set
// in index.js — these are non-browser service-to-service calls (node-agent
// heartbeat) where the CSRF protection doesn't apply.
const CSRF_EXEMPT_PATHS = new Set(['/cluster/heartbeat']);
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();
// Service path carve-outs (e.g. node-agent heartbeat — not a browser).
if (CSRF_EXEMPT_PATHS.has(req.path)) return next();
if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next();
return res.status(403).json({ error: 'missing X-Requested-With header' });
}

View file

@ -0,0 +1,148 @@
import express from 'express';
import pool from '../db/pool.js';
import { DEV_USER_ID, requireAuth } from '../middleware/auth.js';
import { hashPassword, comparePassword } from '../auth/passwords.js';
import { ipBackoff } from '../auth/rate-limit.js';
const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K';
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); }
});
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);
}
});
// POST /api/v1/auth/login — authenticate an existing user by username + password.
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));
const { username, password } = req.body || {};
if (!username || !password) {
ipBackoff.recordFailure(ip);
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) {
// Pre-computed bcrypt hash of a value that no real password input will match.
// Used to keep the user-not-found response time uniform with the wrong-password
// path (~180ms at cost 12) so user enumeration via timing isn't possible.
await comparePassword(password, DUMMY_PASSWORD_HASH);
ipBackoff.recordFailure(ip);
return res.status(401).json({ error: 'invalid credentials' });
}
const user = rows[0];
if (!(await comparePassword(password, user.password_hash))) {
ipBackoff.recordFailure(ip);
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()));
pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id])
.catch(err => console.error('[auth] last_login_at update failed:', err.message));
ipBackoff.recordSuccess(ip);
res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); }
});
// 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();
});
});
// 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); }
});
export default router;
export { realUserCount };

View file

@ -0,0 +1,46 @@
// 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 user's 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;

View file

@ -0,0 +1,96 @@
// User CRUD. Mounted at /api/v1/auth/users by index.js (behind the auth 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); }
});
// PATCH /:id { display_name?, role?, password? } — generic update.
// password update goes through hashPassword; other fields are passed through.
router.patch('/:id', async (req, res, next) => {
try {
if (req.params.id === DEV_USER_ID) return res.status(400).json({ error: 'cannot edit dev user' });
const sets = []; const vals = [];
if (typeof req.body?.display_name === 'string') { sets.push('display_name = $' + (sets.length + 1)); vals.push(req.body.display_name); }
if (typeof req.body?.role === 'string') { sets.push('role = $' + (sets.length + 1)); vals.push(req.body.role); }
if (typeof req.body?.password === 'string') {
if (req.body.password.length < MIN_PASSWORD_LEN) return bad(res, 'password must be at least ' + MIN_PASSWORD_LEN + ' chars');
sets.push('password_hash = $' + (sets.length + 1) + ', password_updated_at = NOW()');
vals.push(await hashPassword(req.body.password));
}
if (sets.length === 0) return bad(res, 'nothing to update');
vals.push(req.params.id);
const { rows } = await pool.query(
`UPDATE users SET ${sets.join(', ')} WHERE id = $${vals.length} RETURNING id, username, display_name, role`,
vals
);
if (rows.length === 0) return res.status(404).json({ error: 'user not found' });
res.json(rows[0]);
} catch (err) { next(err); }
});
export default router;

View file

@ -0,0 +1,18 @@
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);
});

View file

@ -0,0 +1,24 @@
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);
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);
});

View file

@ -0,0 +1,31 @@
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);
});

View file

@ -0,0 +1,44 @@
// 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;
}

View file

@ -0,0 +1,45 @@
// 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 });
});
});
}

View file

@ -0,0 +1,175 @@
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;
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)]);
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(); }
});
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);
});

View file

@ -0,0 +1,246 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js';
import express from 'express';
import session from 'express-session';
import authRouter from '../../src/routes/auth.js';
import { hashPassword } from '../../src/auth/passwords.js';
import { requireAuth, requireUiHeader } from '../../src/middleware/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();
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(); }
});
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', requireUiHeader);
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', 'X-Requested-With': 'dragonflight-ui' },
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', 'X-Requested-With': 'dragonflight-ui' },
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', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ username: 'admin', password: 'short' }),
});
assert.equal(res.status, 400);
} finally { await close(); await pool.end(); }
});
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', requireUiHeader);
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', 'X-Requested-With': 'dragonflight-ui' },
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', 'X-Requested-With': 'dragonflight-ui' },
body: JSON.stringify({ username: 'alice', password: 'wrong' }),
});
const r2 = await fetch(baseUrl + '/api/v1/auth/login', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' },
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(); }
});
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', 'X-Requested-With': 'dragonflight-ui' },
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(); }
});
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', 'X-Requested-With': 'dragonflight-ui' },
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', 'X-Requested-With': 'dragonflight-ui' },
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', 'X-Requested-With': 'dragonflight-ui', 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', 'X-Requested-With': 'dragonflight-ui', cookie },
body: JSON.stringify({ current_password: 'no', new_password: 'another-good-passphrase' }),
});
assert.equal(wrong.status, 400);
} finally { await close(); await pool.end(); }
});

View file

@ -0,0 +1,103 @@
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, requireUiHeader } 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', requireUiHeader);
a.use('/api/v1/auth', authRouter);
a.use('/api/v1/auth/tokens', requireAuth, tokensRouter);
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', 'X-Requested-With': 'dragonflight-ui' },
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', 'X-Requested-With': 'dragonflight-ui', 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', 'X-Requested-With': 'dragonflight-ui', 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(); }
});

View file

@ -0,0 +1,90 @@
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, requireUiHeader } 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', requireUiHeader);
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', 'X-Requested-With': 'dragonflight-ui' },
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', 'X-Requested-With': 'dragonflight-ui', 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', 'X-Requested-With': 'dragonflight-ui', 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(); }
});

View file

@ -0,0 +1,16 @@
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();
}
});

View file

@ -154,4 +154,5 @@ function lighten(hex, amt) {
} }
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />); const AuthGate = window.AuthGateComponent;
root.render(<AuthGate><App /></AuthGate>);

View file

@ -0,0 +1,77 @@
// 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, Task 16). 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 React.createElement(window.SetupScreen, { onDone: () => window.AuthGate.signedIn() });
if (state.kind === 'login') return React.createElement(window.LoginScreen, { onDone: () => window.AuthGate.signedIn() });
return children;
}
window.AuthGate = window.AuthGate || {};
window.AuthGateComponent = AuthGate;
})();

View file

@ -62,19 +62,24 @@ window.ZAMPP_DATA = {
}; };
async function apiFetch(path, opts = {}) { 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, { const res = await fetch(API + path, {
credentials: 'include', credentials: 'include',
...opts, ...opts,
headers: { ...(opts.headers || {}), 'Content-Type': 'application/json' }, headers,
}); });
// 401 from any API call means there's no live session. Bounce to the // 401: hand off to AuthGate, which will re-render Login (no full-page reload).
// login screen instead of leaving the app in a half-loaded state. if (res.status === 401) {
// While AUTH_ENABLED=false the server returns a synthetic /auth/me with if (window.AuthGate && typeof window.AuthGate.bounce === 'function') {
// 200 so this branch never fires; flipping AUTH_ENABLED=true is what window.AuthGate.bounce('apiFetch saw 401 on ' + path);
// activates the redirect end-to-end. }
if (res.status === 401 && !location.pathname.endsWith('/login.html')) { throw new Error('Unauthenticated');
location.replace('/login.html');
throw new Error('Unauthenticated — redirecting to login');
} }
if (!res.ok) throw new Error(res.status + ' ' + res.statusText); if (!res.ok) throw new Error(res.status + ' ' + res.statusText);
return res.json(); return res.json();
@ -303,6 +308,7 @@ async function exportSequenceEDL(sequenceId, filename) {
const res = await fetch(API + '/sequences/' + sequenceId + '/export/edl', { const res = await fetch(API + '/sequences/' + sequenceId + '/export/edl', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
headers: { 'X-Requested-With': 'dragonflight-ui' },
}); });
if (!res.ok) throw new Error('EDL export failed: ' + res.status); if (!res.ok) throw new Error('EDL export failed: ' + res.status);
const blob = await res.blob(); const blob = await res.blob();

View file

@ -25,6 +25,8 @@
<script src="dist/icons.js"></script> <script src="dist/icons.js"></script>
<script src="dist/visuals.js"></script> <script src="dist/visuals.js"></script>
<script src="dist/shell.js"></script> <script src="dist/shell.js"></script>
<script src="dist/auth-gate.js"></script>
<script src="dist/screens-auth.js"></script>
<script src="dist/screens-home.js"></script> <script src="dist/screens-home.js"></script>
<script src="dist/screens-library.js"></script> <script src="dist/screens-library.js"></script>
<script src="dist/screens-asset.js"></script> <script src="dist/screens-asset.js"></script>

View file

@ -1406,10 +1406,137 @@ function DetailRow({ k, v, mono }) {
); );
} }
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>
);
}
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>
);
}
function Settings() { function Settings() {
const [section, setSection] = React.useState('storage'); const [section, setSection] = React.useState('account');
const SECTIONS = [ const SECTIONS = [
{ id: 'account', label: 'Account', icon: 'user' },
{ id: 'storage', label: 'Storage', icon: 'hdd' }, { id: 'storage', label: 'Storage', icon: 'hdd' },
{ id: 'proxy', label: 'Proxy encoding', icon: 'gpu' }, { id: 'proxy', label: 'Proxy encoding', icon: 'gpu' },
{ id: 'sdk', label: 'Capture SDKs', icon: 'video' }, { id: 'sdk', label: 'Capture SDKs', icon: 'video' },
@ -1435,6 +1562,12 @@ function Settings() {
))} ))}
</nav> </nav>
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
{section === 'account' && (
<>
<AccountSection />
<ApiTokensSection />
</>
)}
{section === 'storage' && <StorageSection />} {section === 'storage' && <StorageSection />}
{section === 'proxy' && <GpuSettingsCard />} {section === 'proxy' && <GpuSettingsCard />}
{section === 'sdk' && <SdkSettingsCard />} {section === 'sdk' && <SdkSettingsCard />}
@ -1930,6 +2063,7 @@ function SdkVendorRow({ vendor, status, onDone }) {
await new Promise((resolve) => { await new Promise((resolve) => {
const xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id); xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id);
xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui');
xhr.withCredentials = true; xhr.withCredentials = true;
xhr.upload.onprogress = (e) => { xhr.upload.onprogress = (e) => {
if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100)); if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100));

View file

@ -0,0 +1,179 @@
// 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;
})();

View file

@ -20,6 +20,7 @@ function _xhrPost(url, formData, onProgress) {
}; };
xhr.onerror = () => reject(new Error('Network error')); xhr.onerror = () => reject(new Error('Network error'));
xhr.open('POST', url); xhr.open('POST', url);
xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui');
xhr.send(formData); xhr.send(formData);
}); });
} }

View file

@ -215,7 +215,7 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) {
<button className="icon-btn" aria-label="Sign out" data-tip="Sign out" title="Sign out" <button className="icon-btn" aria-label="Sign out" data-tip="Sign out" title="Sign out"
onClick={async () => { onClick={async () => {
try { await window.ZAMPP_API.fetch('/auth/logout', { method: 'POST' }); } catch (_) {} try { await window.ZAMPP_API.fetch('/auth/logout', { method: 'POST' }); } catch (_) {}
window.location.replace('/login.html'); window.AuthGate.bounce('user signed out');
}}> }}>
<Icon name="power" /> <Icon name="power" />
</button> </button>