From 183e10f8e67231039eeb9d213e7b658e3feea3b0 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 11:57:44 -0400 Subject: [PATCH 01/29] docs(auth): spec for user auth system, brainstormed 2026-05-27 --- .../specs/2026-05-27-auth-system-design.md | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-auth-system-design.md diff --git a/docs/superpowers/specs/2026-05-27-auth-system-design.md b/docs/superpowers/specs/2026-05-27-auth-system-design.md new file mode 100644 index 0000000..20ca23e --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-auth-system-design.md @@ -0,0 +1,256 @@ +# 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 ` 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 + if (process.env.AUTH_ENABLED !== 'true') { + req.user = { id: 'dev', 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`, with an allowlist for `/api/v1/auth/login`, `/api/v1/auth/setup`, `/api/v1/auth/setup-required`, and `/health`. + +### 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, `` 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`. +2. `express-session` + `connect-pg-simple` wiring in `index.js`. +3. `requireAuth` middleware. +4. Auth router (setup, login, logout, me, password). +5. Apply `requireAuth` to API router with allowlist. +6. Auth tests (unit + integration + regression). +7. Frontend `` + 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. From c2fd48b0cefa7df766b4cadf4b12707f732ac031 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 11:59:19 -0400 Subject: [PATCH 02/29] docs(auth): clarify dev-user FK seeding and cluster route carve-out --- .../specs/2026-05-27-auth-system-design.md | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/specs/2026-05-27-auth-system-design.md b/docs/superpowers/specs/2026-05-27-auth-system-design.md index 20ca23e..0ed6d6d 100644 --- a/docs/superpowers/specs/2026-05-27-auth-system-design.md +++ b/docs/superpowers/specs/2026-05-27-auth-system-design.md @@ -71,9 +71,11 @@ Nothing else counts. No `localStorage` flags, no JWT, no client-side "I think I' ```js export async function requireAuth(req, res, next) { - // Dev mode preserved + // 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 = { id: 'dev', username: 'dev' }; + req.user = DEV_USER; // { id: , username: 'dev' } return next(); } @@ -108,7 +110,18 @@ export async function requireAuth(req, res, next) { } ``` -Mounted at the `/api/v1` level in `services/mam-api/src/index.js`, with an allowlist for `/api/v1/auth/login`, `/api/v1/auth/setup`, `/api/v1/auth/setup-required`, and `/health`. +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) @@ -236,11 +249,11 @@ Bearer tokens have their own optional `expires_at` (`NULL` = never expires); che Suggested sequencing for the implementation plan (writing-plans will refine): -1. Migration `023-auth-session-timestamps.sql`. +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. +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. +5. Apply `requireAuth` to API router with allowlist. Decide cluster carve-out (see Architecture). 6. Auth tests (unit + integration + regression). 7. Frontend `` + Login screen + Setup screen. 8. Frontend Settings -> Account + API Tokens. From 99fae69960abd01fbce3d38d5122ed6a82cbe9fb Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 12:17:11 -0400 Subject: [PATCH 03/29] docs(auth): implementation plan for user auth system --- .../plans/2026-05-27-auth-system.md | 2888 +++++++++++++++++ 1 file changed, 2888 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-auth-system.md diff --git a/docs/superpowers/plans/2026-05-27-auth-system.md b/docs/superpowers/plans/2026-05-27-auth-system.md new file mode 100644 index 0000000..b294a3a --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-auth-system.md @@ -0,0 +1,2888 @@ +# Dragonflight Auth System Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Wire a working end-to-end auth system into Dragonflight (mam-api + web-ui) so `AUTH_ENABLED=true` produces a real login that survives reloads, instead of the redirect loop that the prior attempts produced. + +**Architecture:** Hand-rolled `express-session` + `connect-pg-simple` for browser sessions, SHA-256-hashed bearer tokens (existing `api_tokens` table) for the Premiere panel, one `requireAuth` middleware that handles both transports, a single `` React component in the SPA that owns "logged in or not" state. Flat permissions (logged in = full access). First admin via a setup screen on the empty `users` table. + +**Tech Stack:** Node 22 (`express` 4, `express-session` 1.17, `connect-pg-simple` 9, `bcrypt` 5, `pg` 8 — all already in `services/mam-api/package.json`), Node native `node:test` for the test runner (no new deps), React 18 + esbuild-built JSX for the SPA. + +**Source spec:** `docs/superpowers/specs/2026-05-27-auth-system-design.md` + +**Working branch:** `design/auth-system` (already exists on the remote, clone at `/tmp/dragonflight-spec`). Implementer should branch from there: `git checkout -b feat/auth-system origin/design/auth-system`. + +**Test convention:** `node --test test/**/*.test.js` from `services/mam-api/`. Integration tests require a `TEST_DATABASE_URL` env var pointing at a throwaway Postgres (e.g. `postgres://wilddragon:test@localhost:5432/wilddragon_test`); tests that need DB will `skip` with a clear message when the env var is missing rather than fail the suite. + +**Commit cadence:** every task ends with one commit. Frequent commits, not heroic batches. + +--- + +## File map + +**`services/mam-api/` (backend)** + +| Path | Purpose | Task | +|---|---|---| +| `package.json` | Add `"test"` script | 1 | +| `test/helpers/setup-db.js` | Spin up isolated test DB, run migrations, return pool | 1 | +| `test/helpers/test-app.js` | Build an Express app with all middleware wired, listen on ephemeral port, return `{ baseUrl, cleanup }` | 1 | +| `src/db/migrations/023-auth-session-timestamps.sql` | Adds `password_updated_at`, `last_login_at`, seeds dev user | 2 | +| `src/auth/passwords.js` | `hashPassword`, `comparePassword` thin wrappers over `bcrypt` | 3 | +| `src/auth/tokens.js` | `generateToken` (32-byte hex with `dfl_` prefix), `hashToken` (SHA-256 hex), `parseBearer` | 3 | +| `src/middleware/auth.js` | `requireAuth`, `destroyAnd401`, `loadUser`, `DEV_USER` constant | 4 | +| `src/index.js` | Mount session middleware, tighten CORS, mount auth gate, mount new routers | 5, 6, 7, 13, 14 | +| `src/routes/auth.js` | `/auth/setup-required`, `/setup`, `/login`, `/logout`, `/me`, `/password` | 7-12 | +| `src/routes/users.js` | User CRUD (admin reset password lives here too) | 13 | +| `src/routes/tokens.js` | Current-user API token list/create/revoke | 14 | +| `src/auth/rate-limit.js` | Per-IP exponential backoff `Map` for `/auth/login` | 15 | +| `.env.example` | Add `ALLOWED_ORIGINS`, default `AUTH_ENABLED=true` | 18 | + +**`services/web-ui/` (frontend)** + +| Path | Purpose | Task | +|---|---|---| +| `public/auth-gate.jsx` | New: `` orchestration (me → setup-required → render Login / Setup / app) | 16 | +| `public/screens-auth.jsx` | New: ``, `` components, ops-register style | 17 | +| `public/data.jsx` | Replace `apiFetch` 401-bounce with AuthGate dispatch; add `X-Requested-With` header | 16 | +| `public/shell.jsx` | Replace sign-out `/login.html` redirect with AuthGate re-mount | 16 | +| `public/screens-admin.jsx` | Add Account (change password) + API Tokens sections | 17 | +| `public/index.html` | Add ` + + + + + + +... rest unchanged ... + +``` + +- [ ] **Step 5: Update app.jsx to wrap App in AuthGateComponent** + +In `services/web-ui/public/app.jsx`, change the final `root.render` from: + +```jsx +root.render(); +``` + +to: + +```jsx +root.render(); +``` + +- [ ] **Step 6: Build + manual smoke** + +```bash +cd services/web-ui && npm run build:jsx +``` + +Expected: `[build-jsx] compiled N jsx files to /.../dist`, including `auth-gate.js`. (`screens-auth.js` won't exist yet — it's built when Task 16 lands; for now the script tag will produce a 404 in DevTools, which is fine.) + +Start the stack with `AUTH_ENABLED=true` and `DATABASE_URL` set. Open the web UI in a browser. With an empty `users` table, you should see the in-progress AuthGate loading spinner, then a blank screen (because `` doesn't exist yet — Task 16 fills it in). The point of this step is to verify the gate boots, the script loads, and DevTools shows `/api/v1/auth/me → 401` followed by `/api/v1/auth/setup-required → {required: true}`. + +- [ ] **Step 7: Commit** + +```bash +git add services/web-ui/public/auth-gate.jsx services/web-ui/public/data.jsx services/web-ui/public/shell.jsx services/web-ui/public/app.jsx services/web-ui/public/index.html +git commit -m "feat(web-ui): AuthGate orchestration + replace 401 bounce with in-SPA re-render" +``` + +--- + +## Task 16: Frontend — Login and Setup screens (layout B from brainstorm) + +**Files:** +- Create: `services/web-ui/public/screens-auth.jsx` + +- [ ] **Step 1: Build the two screens** + +Create `services/web-ui/public/screens-auth.jsx`: + +```jsx +// LoginScreen + SetupScreen — layout B from the auth brainstorm spec: +// 22px wordmark + "WILD DRAGON BROADCAST" tagline above a --bg-1 card. +// Matches DESIGN.md tokens; no decoration, dense, ops register. + +(function () { + const API_BASE = '/api/v1'; + + async function postJson(path, body) { + return fetch(API_BASE + path, { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + 'X-Requested-With': 'dragonflight-ui', + }, + body: JSON.stringify(body), + }); + } + + function Brand() { + return ( +
+
Dragonflight
+
+ Wild Dragon Broadcast +
+
+ ); + } + + function Card({ children }) { + return ( +
{children}
+ ); + } + + function Field({ label, type = 'text', value, onChange, autoComplete, autoFocus }) { + return ( +
+ + onChange(e.target.value)} + style={{ + width: '100%', + background: 'var(--bg-3)', + border: '1px solid var(--border)', + borderRadius: 4, + padding: '8px 10px', + fontSize: 12.5, + color: 'var(--text-1)', + fontFamily: 'var(--font-mono)', + boxSizing: 'border-box', + }} + /> +
+ ); + } + + function Button({ children, disabled, onClick, type = 'button' }) { + return ( + + ); + } + + function ErrorRow({ text }) { + if (!text) return null; + return ( +
{text}
+ ); + } + + function Screen({ children }) { + return ( +
+
e.preventDefault()} style={{ width: 300 }}> + + {children} + +
+ ); + } + + function LoginScreen({ onDone }) { + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [error, setError] = React.useState(''); + const [busy, setBusy] = React.useState(false); + const submit = async () => { + setError(''); setBusy(true); + try { + const r = await postJson('/auth/login', { username, password }); + if (r.status === 200) { onDone(); return; } + const body = await r.json().catch(() => ({})); + setError(body.error || ('Login failed: ' + r.status)); + } catch (e) { setError(e.message || 'Login failed'); } + finally { setBusy(false); } + }; + return ( + + + + + + + ); + } + + function SetupScreen({ onDone }) { + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [confirm, setConfirm] = React.useState(''); + const [error, setError] = React.useState(''); + const [busy, setBusy] = React.useState(false); + const submit = async () => { + setError(''); + if (password !== confirm) { setError('Passwords do not match'); return; } + if (password.length < 12) { setError('Password must be at least 12 characters'); return; } + setBusy(true); + try { + const r = await postJson('/auth/setup', { username, password }); + if (r.status === 200) { onDone(); return; } + const body = await r.json().catch(() => ({})); + setError(body.error || ('Setup failed: ' + r.status)); + } catch (e) { setError(e.message || 'Setup failed'); } + finally { setBusy(false); } + }; + return ( + +
+ First-run setup — create the first admin +
+ + + + + +
+ ); + } + + window.LoginScreen = LoginScreen; + window.SetupScreen = SetupScreen; +})(); +``` + +- [ ] **Step 2: Update auth-gate.jsx to reference the global components** + +In `services/web-ui/public/auth-gate.jsx`, replace the placeholder JSX where it returns Setup/Login with: + +```jsx +if (state.kind === 'setup') return React.createElement(window.SetupScreen, { onDone: () => window.AuthGate.signedIn() }); +if (state.kind === 'login') return React.createElement(window.LoginScreen, { onDone: () => window.AuthGate.signedIn() }); +``` + +(Necessary because the JSX `` references aren't in scope of the IIFE — using `window.SetupScreen` keeps the cross-file linkage explicit.) + +- [ ] **Step 3: Build** + +```bash +cd services/web-ui && npm run build:jsx +``` + +Expected: `screens-auth.js` and updated `auth-gate.js` in `public/dist/`. + +- [ ] **Step 4: Manual smoke — full setup → app → reload → logout → login** + +With `AUTH_ENABLED=true`, empty `users` table, browser open: + +1. **Setup:** see "First-run setup" screen. Enter username `admin`, password ≥12 chars. Submit. App loads. +2. **Reload:** page reloads, no flash to login — app renders again (cookie unlocks `/auth/me`). +3. **Sign out:** click power icon in sidebar. Login screen renders. +4. **Login back in:** enter same creds. App loads. **No infinite loop.** +5. **Wrong password:** enter wrong password. Inline error appears, no redirect. +6. **DevTools network tab:** confirm `/auth/me` returns 200 after login, every page action carries `cookie: dragonflight.sid=...`, every POST carries `X-Requested-With: dragonflight-ui`. + +- [ ] **Step 5: Commit** + +```bash +git add services/web-ui/public/screens-auth.jsx services/web-ui/public/auth-gate.jsx +git commit -m "feat(web-ui): LoginScreen + SetupScreen (layout B from brainstorm)" +``` + +--- + +## Task 17: Frontend — Settings → Account section + API Tokens section + +**Files:** +- Modify: `services/web-ui/public/screens-admin.jsx` + +> This task adds two sections inside the existing Settings screen. `screens-admin.jsx` is large (~103 KB); the changes are scoped and additive. + +- [ ] **Step 1: Locate the Settings tab structure** + +```bash +grep -n "function Settings\|case 'settings'\|export.*Settings" /tmp/dragonflight-spec/services/web-ui/public/screens-admin.jsx | head -20 +``` + +Find the `Settings` component and the tab/section structure it uses. Sections in this codebase typically render as panels with a section label header (per DESIGN.md). Read the surrounding 50 lines to understand the existing pattern. + +- [ ] **Step 2: Add the Account section component** + +Insert (above the `Settings` component in `screens-admin.jsx`): + +```jsx +function AccountSection() { + const [current, setCurrent] = React.useState(''); + const [next, setNext] = React.useState(''); + const [confirm, setConfirm] = React.useState(''); + const [msg, setMsg] = React.useState(null); // { kind: 'ok'|'err', text } + const [busy, setBusy] = React.useState(false); + + const submit = async () => { + setMsg(null); + if (next !== confirm) { setMsg({ kind: 'err', text: 'Passwords do not match' }); return; } + if (next.length < 12) { setMsg({ kind: 'err', text: 'New password must be at least 12 characters' }); return; } + setBusy(true); + try { + const r = await fetch('/api/v1/auth/password', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, + body: JSON.stringify({ current_password: current, new_password: next }), + }); + if (r.status === 204) { + setMsg({ kind: 'ok', text: 'Password updated' }); + setCurrent(''); setNext(''); setConfirm(''); + } else { + const body = await r.json().catch(() => ({})); + setMsg({ kind: 'err', text: body.error || 'Failed (' + r.status + ')' }); + } + } finally { setBusy(false); } + }; + + return ( +
+

Account

+
+ + setCurrent(e.target.value)} /> + + setNext(e.target.value)} /> + + setConfirm(e.target.value)} /> +
+ {msg && ( +
{msg.text}
+ )} + +
+ ); +} +``` + +- [ ] **Step 3: Add the API Tokens section component** + +Insert next to `AccountSection`: + +```jsx +function ApiTokensSection() { + const [tokens, setTokens] = React.useState([]); + const [name, setName] = React.useState(''); + const [justCreated, setJustCreated] = React.useState(null); // { token, prefix, name } + const [busy, setBusy] = React.useState(false); + + const load = React.useCallback(async () => { + const r = await fetch('/api/v1/auth/tokens', { credentials: 'include' }); + if (r.status === 200) setTokens(await r.json()); + }, []); + + React.useEffect(() => { load(); }, [load]); + + const create = async () => { + if (!name.trim()) return; + setBusy(true); + try { + const r = await fetch('/api/v1/auth/tokens', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, + body: JSON.stringify({ name: name.trim() }), + }); + if (r.status === 201) { + const created = await r.json(); + setJustCreated(created); + setName(''); + await load(); + } + } finally { setBusy(false); } + }; + + const revoke = async (id) => { + await fetch('/api/v1/auth/tokens/' + id, { + method: 'DELETE', + credentials: 'include', + headers: { 'X-Requested-With': 'dragonflight-ui' }, + }); + await load(); + }; + + return ( +
+

API Tokens

+ + {justCreated && ( +
+
+ Save this token now — it will not be shown again +
+
{justCreated.token}
+ + +
+ )} + +
+ setName(e.target.value)} style={{ flex: 1 }} /> + +
+ +
+ {tokens.length === 0 &&
No tokens yet.
} + {tokens.map(t => ( +
+
{t.name}
+
{t.prefix}…
+
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : 'never used'}
+ +
+ ))} +
+
+ ); +} +``` + +- [ ] **Step 4: Insert the two sections at the top of the Settings render** + +Inside the `Settings` component's return, add: + +```jsx + + +``` + +…before the existing S3 / AMPP / Transcoding sections. + +- [ ] **Step 5: Build + manual smoke** + +```bash +cd services/web-ui && npm run build:jsx +``` + +In the browser, log in, go to Settings: +1. **Change password:** enter wrong current → inline red error. Enter correct → "Password updated". Sign out, sign back in with new password. +2. **Create token:** name it "smoke", click "New token". Token appears in the accent box. Copy it. +3. **Use token via curl:** + ```bash + curl -H "Authorization: Bearer " http:///api/v1/projects + ``` + Expected: JSON list, not 401. +4. **Revoke:** click Revoke. Same curl now returns 401. + +- [ ] **Step 6: Commit** + +```bash +git add services/web-ui/public/screens-admin.jsx +git commit -m "feat(web-ui): Settings → Account (change password) + API Tokens sections" +``` + +--- + +## Task 18: Cleanup, env defaults, README + +**Files:** +- Modify: `.env.example` +- Modify: `README.md` +- Verify: no `/login.html` file exists; no stray references remain +- Modify: boot log message in `services/mam-api/src/index.js` + +- [ ] **Step 1: Verify no /login.html file exists, and grep for stray references** + +```bash +find services/web-ui -name 'login.html' 2>&1 +grep -rn "login.html" services/ 2>&1 +``` + +Expected: `find` returns nothing. `grep` returns either nothing (good) or only references inside the auth-gate / data.jsx / shell.jsx files that you already replaced; if any remain, finish replacing them now (they should be 0 after Task 15). + +- [ ] **Step 2: Update .env.example** + +In `/tmp/dragonflight-spec/.env.example`, change the AUTH section and add the ALLOWED_ORIGINS line. The block should read: + +``` +# Session Configuration +SESSION_SECRET=changeme + +# MAM API Configuration +MAM_API_URL=http://mam-api:3000 + +# Auth — default to ON in production. Setting to 'false' is a dev-only escape +# hatch that disables all auth checks and attaches a synthetic 'dev' user to +# every request. Never run with AUTH_ENABLED=false on a network you don't control. +AUTH_ENABLED=true + +# CORS allowlist — comma-separated origins that may carry credentials to the API. +# Same-origin requests via the nginx reverse proxy do not need to be listed here. +# Leave empty to allow any origin (DEV ONLY). +ALLOWED_ORIGINS= + +# Reverse-proxy trust — set 'true' when the API sits behind nginx terminating HTTPS, +# so secure-cookie + X-Forwarded-Proto behave correctly. +TRUST_PROXY=false +``` + +- [ ] **Step 3: Update the boot log message in index.js** + +In `services/mam-api/src/index.js`, change: + +```js +const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)'; +``` + +to: + +```js +const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED — dev mode (dev user attached to every request)'; +``` + +- [ ] **Step 4: Add a README section** + +Append to `/tmp/dragonflight-spec/README.md`: + +````markdown +## Authentication + +Dragonflight uses local username/password authentication with two transports: + +- **Browser:** session cookie (`dragonflight.sid`), 8 hour absolute + 1 hour idle timeout. +- **Premiere panel / scripts:** SHA-256-hashed bearer tokens issued from `Settings → API Tokens`. + +### First-run setup + +On a fresh install with `AUTH_ENABLED=true`, navigate to the web UI in a browser. +With no users in the database, the login screen renders a "First-run setup" form +instead — fill it in to create the first admin and you are logged in immediately. + +Subsequent users are created from `Settings → Users` (any signed-in user can +create others — flat access). + +### Dev mode + +Setting `AUTH_ENABLED=false` (default in `.env.example` for local dev) disables +all auth checks; a synthetic `dev` user is attached to every request. **Never +deploy this way.** The dev user row is seeded with a hash that no real password +can match, so flipping `AUTH_ENABLED=true` later does not expose the dev account. + +### Recovering a forgotten admin password + +Any signed-in user can reset another user's password from `Settings → Users`. +If no one can sign in (all admins forgot their passwords), reset directly in +Postgres: + +```sql +-- generate a fresh bcrypt hash with: +-- node -e "import('bcrypt').then(b => b.default.hash(process.argv[1], 12).then(h => console.log(h)))" 'new-passphrase-here' +UPDATE users SET password_hash = '', password_updated_at = NOW() + WHERE username = 'admin'; +``` + +### `AUTH_ENABLED` transition + +When flipping `AUTH_ENABLED=false` → `true` on an existing install: + +1. Ensure `SESSION_SECRET` is set to a stable value (rotating it logs everyone out). +2. Set `ALLOWED_ORIGINS` to the public origin(s) of the web UI. +3. Restart `mam-api`. +4. Visit the UI — first-run setup will appear if no real users exist yet. +```` + +- [ ] **Step 5: Commit** + +```bash +git add .env.example README.md services/mam-api/src/index.js +git commit -m "docs(auth): flip AUTH_ENABLED default + document setup + recovery" +``` + +--- + +## Task 19: Final integration smoke — full system test + +**Files:** +- (None to modify. Manual + curl smoke documented for the PR.) + +- [ ] **Step 1: Bring up the full stack** + +```bash +cd /tmp/dragonflight-spec +docker compose down -v # nuke the DB to exercise first-run +docker compose up --build -d +docker compose logs -f mam-api | grep -E 'Authentication|listening' & +``` + +Expected: `Authentication: ENABLED` (assuming `.env` has `AUTH_ENABLED=true` now) and `MAM API listening on port 3000`. + +- [ ] **Step 2: Run the full test suite once more** + +```bash +cd services/mam-api +TEST_DATABASE_URL=postgres://wilddragon:test@localhost:5432/wilddragon_test npm test +``` + +Expected: every test passes. + +- [ ] **Step 3: Manual end-to-end smoke (browser)** + +1. Open http://localhost:47434 (or whatever the web-ui port is in your compose). See setup screen. +2. Create admin `op1` / 12-char password. App loads. +3. Open DevTools → Application → Cookies — verify `dragonflight.sid` is `HttpOnly` and `SameSite=Lax`. +4. Reload the page. Stays logged in. +5. Settings → API Tokens → create "smoke-bearer". Copy token. +6. `curl -H "Authorization: Bearer " http://localhost:47434/api/v1/projects` — 200 with JSON. +7. Settings → API Tokens → revoke "smoke-bearer". Repeat curl — 401. +8. Settings → Account → change password. Sign out. Sign in with new password. +9. **Idle timeout simulation (optional):** in psql, `UPDATE sessions SET sess = jsonb_set(sess, '{last_seen_at}', to_jsonb((extract(epoch from now()) * 1000 - 3700000)::bigint));`. Reload. Should bounce to login. + +- [ ] **Step 4: Push the branch and open a PR** + +```bash +git push -u origin feat/auth-system +gh pr create --title "feat(auth): wire end-to-end auth — sessions + bearer + first-run setup" \ + --body "$(cat <<'EOF' +## Summary + +Implements `docs/superpowers/specs/2026-05-27-auth-system-design.md`. + +- `express-session` + `connect-pg-simple` actually mounted (was missing — root cause of the prior redirect loop). +- One `requireAuth` middleware handling both cookie and bearer auth. +- Auth router: `/auth/setup`, `/auth/login`, `/auth/logout`, `/auth/me`, `/auth/password`, user CRUD, API token CRUD. +- AuthGate React component owns "logged in or not" state — no more full-page bounces to a non-existent `/login.html`. +- Login + Setup screens matching DESIGN.md tokens (layout B from brainstorm). +- Settings → Account (change own password) + Settings → API Tokens (issue / revoke). +- Per-IP exponential login backoff + X-Requested-With CSRF header. +- Tightened CORS to an `ALLOWED_ORIGINS` allowlist. +- Cluster carve-out preserved for node-agent's existing token binding. + +## Test plan + +- [ ] `npm test` in `services/mam-api` (with TEST_DATABASE_URL) — all green +- [ ] Fresh install setup → create admin → land on dashboard +- [ ] Reload after login — stays signed in +- [ ] Sign out → see login screen +- [ ] Wrong password → inline error, no redirect +- [ ] Repeated wrong passwords → noticeable backoff +- [ ] API token issued in UI authenticates via `Authorization: Bearer` +- [ ] Revoked token returns 401 +- [ ] `AUTH_ENABLED=false` boot still works (dev mode unchanged) + +Spec: `docs/superpowers/specs/2026-05-27-auth-system-design.md` +EOF +)" +``` + +- [ ] **Step 5: Done** + +The branch is ready for review. The original redirect loop is covered by the regression test in `test/routes/auth.test.js`. + +--- + +## Self-review (done at write time) + +**Spec coverage:** + +| Spec section | Task | +|---|---| +| Migration 023 | 2 | +| Session middleware wired | 5 | +| requireAuth middleware (session + bearer + idle + absolute) | 4 | +| Auth gate at /api/v1 with allowlist | 6 | +| Cluster carve-out decision | 6 | +| `/auth/setup-required` | 7 | +| `/auth/setup` | 8 | +| `/auth/login` + `req.session.save()` callback fix | 9 | +| Regression test for redirect loop | 9 | +| `/auth/logout` | 10 | +| `/auth/me`, `/auth/password` | 11 | +| User CRUD + delete-last-user guard | 12 | +| API token CRUD (one-time-show, SHA-256 stored) | 13 | +| Premiere panel bearer flow end-to-end | 13 | +| Rate limiting | 14 | +| CSRF X-Requested-With | 14 | +| AuthGate orchestration | 15 | +| LoginScreen + SetupScreen (layout B) | 16 | +| Settings → Account + API Tokens | 17 | +| CORS tightening | 5 | +| `.env.example` + boot log + README updates | 18 | +| Delete `/login.html` references | 15, 18 (verify) | +| Final integration smoke | 19 | +| Manual test (8h absolute / 1h idle simulation) | 19 | + +**Placeholder scan:** none — every step has explicit code or commands. + +**Type consistency:** `DEV_USER_ID` constant defined in Task 4, referenced unchanged in Tasks 7, 8, 9, 12. `parseBearer`, `hashToken`, `generateToken`, `tokenDisplayPrefix` defined in Task 3, all referenced consistently in Tasks 4, 13. `requireAuth` exported from Task 4, imported in Tasks 6, 11, 12, 13. `ipBackoff` exported from Task 14, imported in Task 14 step 5. `window.AuthGate.bounce(reason)` / `window.AuthGate.signedIn()` signatures defined in Task 15, called the same way from data.jsx, shell.jsx, screens-auth.jsx. + +**Scope decomposition:** This is one feature, one plan. Tasks 15–17 are frontend; they could be split off into a separate "frontend integration" plan if a different engineer is going to do the JS, but the spec is one cohesive unit and the boundaries between backend + frontend are clean within tasks so a single plan is fine. From 5011d4539157c4a283c8f822d19cfd0b292c11a6 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 13:38:46 -0400 Subject: [PATCH 04/29] chore(mam-api): wire node:test runner + test app + DB helper --- services/mam-api/package.json | 3 +- services/mam-api/test/helpers/setup-db.js | 44 ++++++++++++++++++++++ services/mam-api/test/helpers/test-app.js | 45 +++++++++++++++++++++++ services/mam-api/test/smoke.test.js | 16 ++++++++ 4 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 services/mam-api/test/helpers/setup-db.js create mode 100644 services/mam-api/test/helpers/test-app.js create mode 100644 services/mam-api/test/smoke.test.js diff --git a/services/mam-api/package.json b/services/mam-api/package.json index f65b552..e55dedd 100644 --- a/services/mam-api/package.json +++ b/services/mam-api/package.json @@ -6,7 +6,8 @@ "main": "src/index.js", "scripts": { "start": "node src/index.js", - "dev": "node --watch src/index.js" + "dev": "node --watch src/index.js", + "test": "node --test test/**/*.test.js" }, "dependencies": { "express": "^4.18.2", diff --git a/services/mam-api/test/helpers/setup-db.js b/services/mam-api/test/helpers/setup-db.js new file mode 100644 index 0000000..e608741 --- /dev/null +++ b/services/mam-api/test/helpers/setup-db.js @@ -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; +} diff --git a/services/mam-api/test/helpers/test-app.js b/services/mam-api/test/helpers/test-app.js new file mode 100644 index 0000000..b4314be --- /dev/null +++ b/services/mam-api/test/helpers/test-app.js @@ -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 }); + }); + }); +} diff --git a/services/mam-api/test/smoke.test.js b/services/mam-api/test/smoke.test.js new file mode 100644 index 0000000..cd89ced --- /dev/null +++ b/services/mam-api/test/smoke.test.js @@ -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(); + } +}); From 1d3c0385dd03a1e74f17d7e8178d5c1add9dc5f5 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 13:44:07 -0400 Subject: [PATCH 05/29] =?UTF-8?q?feat(mam-api):=20migration=20023=20?= =?UTF-8?q?=E2=80=94=20auth=20timestamps=20+=20idempotent=20dev=20user=20s?= =?UTF-8?q?eed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../023-auth-session-timestamps.sql | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 services/mam-api/src/db/migrations/023-auth-session-timestamps.sql diff --git a/services/mam-api/src/db/migrations/023-auth-session-timestamps.sql b/services/mam-api/src/db/migrations/023-auth-session-timestamps.sql new file mode 100644 index 0000000..9273c4b --- /dev/null +++ b/services/mam-api/src/db/migrations/023-auth-session-timestamps.sql @@ -0,0 +1,22 @@ +-- Migration 023 — auth-related user timestamps + idempotent dev user. +-- +-- See docs/superpowers/specs/2026-05-27-auth-system-design.md +-- +-- password_updated_at + last_login_at are operator visibility, no logic depends on them yet. +-- The dev user is seeded with a fixed UUID so FK-bearing routes (api_tokens, +-- future audit fields) keep working when AUTH_ENABLED=false. The seeded +-- password_hash is a placeholder that no bcrypt.compare will accept, so the +-- dev row cannot be used to log in even if AUTH_ENABLED is later flipped on. + +ALTER TABLE users ADD COLUMN IF NOT EXISTS password_updated_at TIMESTAMPTZ DEFAULT NOW(); +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; + +INSERT INTO users (id, username, password_hash, display_name, role) +VALUES ( + '00000000-0000-4000-8000-000000000dev', + 'dev', + '!disabled-no-login!', + 'Dev (AUTH_ENABLED=false)', + 'admin' +) +ON CONFLICT (id) DO NOTHING; From 14931d63622bf9add73d9539810832f133bdce73 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 13:48:08 -0400 Subject: [PATCH 06/29] =?UTF-8?q?fix(mam-api):=20migration=20023=20?= =?UTF-8?q?=E2=80=94=20broaden=20ON=20CONFLICT=20+=20document=20password?= =?UTF-8?q?=5Fupdated=5Fat=20backfill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review feedback: ON CONFLICT (id) only catches id collisions; a pre-existing 'dev' username would trigger a unique_violation on the username index and roll back the migration, hard-failing the mam-api boot. Switch to bare ON CONFLICT DO NOTHING so any unique conflict is no-op-safe. --- .../src/db/migrations/023-auth-session-timestamps.sql | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/mam-api/src/db/migrations/023-auth-session-timestamps.sql b/services/mam-api/src/db/migrations/023-auth-session-timestamps.sql index 9273c4b..e67c106 100644 --- a/services/mam-api/src/db/migrations/023-auth-session-timestamps.sql +++ b/services/mam-api/src/db/migrations/023-auth-session-timestamps.sql @@ -7,6 +7,9 @@ -- 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; @@ -19,4 +22,4 @@ VALUES ( 'Dev (AUTH_ENABLED=false)', 'admin' ) -ON CONFLICT (id) DO NOTHING; +ON CONFLICT DO NOTHING; From 3fc8116dd3768904235417d40fb77bb7289c9b07 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 13:51:15 -0400 Subject: [PATCH 07/29] =?UTF-8?q?feat(mam-api):=20auth=20utilities=20?= =?UTF-8?q?=E2=80=94=20password=20hash/compare=20+=20token=20gen/hash/pars?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/mam-api/src/auth/passwords.js | 19 ++++++++++++ services/mam-api/src/auth/tokens.js | 22 ++++++++++++++ services/mam-api/test/auth/passwords.test.js | 18 ++++++++++++ services/mam-api/test/auth/tokens.test.js | 31 ++++++++++++++++++++ 4 files changed, 90 insertions(+) create mode 100644 services/mam-api/src/auth/passwords.js create mode 100644 services/mam-api/src/auth/tokens.js create mode 100644 services/mam-api/test/auth/passwords.test.js create mode 100644 services/mam-api/test/auth/tokens.test.js diff --git a/services/mam-api/src/auth/passwords.js b/services/mam-api/src/auth/passwords.js new file mode 100644 index 0000000..2d88990 --- /dev/null +++ b/services/mam-api/src/auth/passwords.js @@ -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; + } +} diff --git a/services/mam-api/src/auth/tokens.js b/services/mam-api/src/auth/tokens.js new file mode 100644 index 0000000..15d2b36 --- /dev/null +++ b/services/mam-api/src/auth/tokens.js @@ -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); +} diff --git a/services/mam-api/test/auth/passwords.test.js b/services/mam-api/test/auth/passwords.test.js new file mode 100644 index 0000000..5679f43 --- /dev/null +++ b/services/mam-api/test/auth/passwords.test.js @@ -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); +}); diff --git a/services/mam-api/test/auth/tokens.test.js b/services/mam-api/test/auth/tokens.test.js new file mode 100644 index 0000000..ec9f906 --- /dev/null +++ b/services/mam-api/test/auth/tokens.test.js @@ -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); +}); From 3bca290e09da2f8fdfecf4b83a8d2e4945affd2b Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 13:54:12 -0400 Subject: [PATCH 08/29] =?UTF-8?q?fix(mam-api):=20test=20glob=20=E2=80=94?= =?UTF-8?q?=20use=20find=20so=20npm=20test=20picks=20up=20files=20at=20any?= =?UTF-8?q?=20depth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /bin/sh (which npm uses) doesn't expand ** recursively. Task 1's smoke test under test/ stopped being discovered once Task 3 added tests under test/auth/. find + sort keeps depth-agnostic discovery portable across shells. --- services/mam-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/mam-api/package.json b/services/mam-api/package.json index e55dedd..04101d1 100644 --- a/services/mam-api/package.json +++ b/services/mam-api/package.json @@ -7,7 +7,7 @@ "scripts": { "start": "node src/index.js", "dev": "node --watch src/index.js", - "test": "node --test test/**/*.test.js" + "test": "node --test $(find test -name '*.test.js' | sort)" }, "dependencies": { "express": "^4.18.2", From 0248a68f57e8576fcfe41ffa797bf40edc19433b Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 13:59:50 -0400 Subject: [PATCH 09/29] =?UTF-8?q?feat(mam-api):=20requireAuth=20middleware?= =?UTF-8?q?=20=E2=80=94=20session=20+=20bearer=20+=20idle/absolute=20timeo?= =?UTF-8?q?ut?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/mam-api/src/middleware/auth.js | 63 ++++++++ services/mam-api/test/middleware/auth.test.js | 149 ++++++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 services/mam-api/src/middleware/auth.js create mode 100644 services/mam-api/test/middleware/auth.test.js diff --git a/services/mam-api/src/middleware/auth.js b/services/mam-api/src/middleware/auth.js new file mode 100644 index 0000000..234c364 --- /dev/null +++ b/services/mam-api/src/middleware/auth.js @@ -0,0 +1,63 @@ +import pool from '../db/pool.js'; +import { parseBearer, hashToken } from '../auth/tokens.js'; + +// Stable UUID matching migration 023's seeded dev user. +export const DEV_USER_ID = '00000000-0000-4000-8000-000000000dev'; +export const DEV_USER = { id: DEV_USER_ID, username: 'dev', display_name: 'Dev (AUTH_ENABLED=false)' }; + +const ABSOLUTE_MS = 8 * 3600 * 1000; +const IDLE_MS = 1 * 3600 * 1000; + +async function destroyAnd401(req, res) { + if (req.session?.destroy) { + await new Promise(r => req.session.destroy(() => r())); + } + return res.status(401).json({ error: 'unauthorized' }); +} + +async function loadUser(id) { + const { rows } = await pool.query( + `SELECT id, username, display_name FROM users WHERE id = $1`, [id]); + return rows[0] || null; +} + +export async function requireAuth(req, res, next) { + // Dev mode — attach the seeded dev user so FK-bearing routes work. + if (process.env.AUTH_ENABLED !== 'true') { + req.user = DEV_USER; + return next(); + } + + // 1. Session + if (req.session?.user_id) { + const now = Date.now(); + const first = req.session.first_seen_at || 0; + const last = req.session.last_seen_at || 0; + if (now - first > ABSOLUTE_MS) return destroyAnd401(req, res); + if (now - last > IDLE_MS) return destroyAnd401(req, res); + req.session.last_seen_at = now; + const u = await loadUser(req.session.user_id); + if (!u) return destroyAnd401(req, res); + req.user = u; + return next(); + } + + // 2. Bearer + const bearer = parseBearer(req.headers.authorization); + if (bearer) { + const hash = hashToken(bearer); + const { rows } = await pool.query( + `SELECT t.id AS token_id, t.user_id, t.expires_at, u.username, u.display_name + FROM api_tokens t JOIN users u ON u.id = t.user_id + WHERE t.token_hash = $1`, [hash]); + if (rows.length && (!rows[0].expires_at || rows[0].expires_at > new Date())) { + pool.query(`UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1`, [rows[0].token_id]) + .catch(err => console.error('[auth] token last_used_at update failed:', err.message)); + req.user = { id: rows[0].user_id, username: rows[0].username, display_name: rows[0].display_name }; + return next(); + } + } + + // 3. Nothing matched + return res.status(401).json({ error: 'unauthorized' }); +} diff --git a/services/mam-api/test/middleware/auth.test.js b/services/mam-api/test/middleware/auth.test.js new file mode 100644 index 0000000..a23a1fb --- /dev/null +++ b/services/mam-api/test/middleware/auth.test.js @@ -0,0 +1,149 @@ +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(); } +}); From 1a723fe4c23152505413db321f30e293b88a270a Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:04:15 -0400 Subject: [PATCH 10/29] =?UTF-8?q?fix(mam-api):=20requireAuth=20=E2=80=94?= =?UTF-8?q?=20stamp=20last=5Fseen=5Fat=20after=20user=20confirmation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review feedback: writing last_seen_at = now before loadUser() lets the stamp persist if the lookup throws (resave:false still writes when modified), extending the idle window without confirming the user exists. Also clarify DEV_USER_ID is a specific placeholder, not a generic sentinel. --- services/mam-api/src/middleware/auth.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/services/mam-api/src/middleware/auth.js b/services/mam-api/src/middleware/auth.js index 234c364..d0bdec2 100644 --- a/services/mam-api/src/middleware/auth.js +++ b/services/mam-api/src/middleware/auth.js @@ -2,6 +2,7 @@ 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)' }; @@ -35,9 +36,9 @@ export async function requireAuth(req, res, next) { const last = req.session.last_seen_at || 0; if (now - first > ABSOLUTE_MS) return destroyAnd401(req, res); if (now - last > IDLE_MS) return destroyAnd401(req, res); - req.session.last_seen_at = now; const u = await loadUser(req.session.user_id); if (!u) return destroyAnd401(req, res); + req.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(); } From a094df03eacf9aa07f31021a52f42eb5540e6898 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:06:41 -0400 Subject: [PATCH 11/29] feat(mam-api): wire express-session + tighten CORS allowlist --- services/mam-api/src/index.js | 37 ++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index f49d9e5..edec2cc 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -1,6 +1,9 @@ import 'dotenv/config'; import express from 'express'; 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 { exec } from 'node:child_process'; import pool from './db/pool.js'; @@ -34,9 +37,41 @@ const app = express(); const PORT = process.env.PORT || 3000; // ── 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); + return cb(new Error('CORS: origin not allowed: ' + origin)); + }, + credentials: true, +})); app.use(express.json({ limit: '50mb' })); +// Trust the reverse proxy only when explicitly told to (production HTTPS). +if (process.env.TRUST_PROXY === 'true') app.set('trust proxy', 1); + +// Session — actually wired this time. See specs/2026-05-27-auth-system-design.md. +app.use(session({ + store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 }), + secret: process.env.SESSION_SECRET, + name: 'dragonflight.sid', + cookie: { + httpOnly: true, + sameSite: 'lax', + secure: process.env.TRUST_PROXY === 'true', + path: '/', + maxAge: 8 * 3600 * 1000, + }, + rolling: false, // sliding renewal handled in requireAuth so idle + absolute can be enforced separately + resave: false, + saveUninitialized: false, +})); + // ── Health ──────────────────────────────────────────────────────────────────── app.get('/health', (_req, res) => res.json({ status: 'ok' })); From 88c3aa514928056033288c8c58871e06a34bcf14 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:11:09 -0400 Subject: [PATCH 12/29] fix(mam-api): SESSION_SECRET boot guard + cleaner CORS rejection Code-review feedback: - Hard-fail boot when AUTH_ENABLED=true and SESSION_SECRET is unset, so express-session can't silently use an in-memory random secret that invalidates sessions on restart and breaks multi-node clusters. - CORS rejection now returns cb(null, false) instead of cb(new Error) so misconfigured origins surface as clean CORS errors in the browser instead of HTTP 500s. Log a warn line for operator visibility. - pruneSessionInterval units comment. --- services/mam-api/src/index.js | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index edec2cc..2adf7b8 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -46,7 +46,10 @@ app.use(cors({ // No Origin header (same-origin or curl) — allow. if (!origin) return cb(null, true); if (allowedOrigins.length === 0 || allowedOrigins.includes(origin)) return cb(null, true); - return cb(new Error('CORS: origin not allowed: ' + origin)); + // 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, })); @@ -55,9 +58,17 @@ 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 }), + store: new PgStore({ pool, tableName: 'sessions', pruneSessionInterval: 60 * 15 /* seconds = 15 min */ }), secret: process.env.SESSION_SECRET, name: 'dragonflight.sid', cookie: { From 9de4fe9ab9e1cbf4bfb1523599d8d85b887993f2 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:13:21 -0400 Subject: [PATCH 13/29] feat(mam-api): mount requireAuth gate at /api/v1 with auth + cluster carve-outs --- services/mam-api/src/index.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 2adf7b8..934b5c8 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -8,6 +8,7 @@ import os from 'node:os'; import { exec } from 'node:child_process'; import pool from './db/pool.js'; import { errorHandler } from './middleware/errors.js'; +import { requireAuth } from './middleware/auth.js'; import { loadS3ConfigFromDb } from './s3/client.js'; // Routes @@ -86,6 +87,17 @@ app.use(session({ // ── Health ──────────────────────────────────────────────────────────────────── app.get('/health', (_req, res) => res.json({ status: 'ok' })); +// ── Auth gate ───────────────────────────────────────────────────────────────── +// Mount once for everything under /api/v1, with an explicit allowlist for +// the three pre-login auth paths and a carve-out for /cluster/* (node-agent +// uses migration 019's token-binding, not user auth). See spec. +const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-required']); +app.use('/api/v1', (req, res, next) => { + if (UNAUTH_PATHS.has(req.path)) return next(); + if (req.path.startsWith('/cluster')) return next(); // node-agent service auth, not user auth + return requireAuth(req, res, next); +}); + // ── API Routes ──────────────────────────────────────────────────────────────── app.use('/api/v1/assets', assetsRouter); app.use('/api/v1/projects', projectsRouter); From cb7cc9a43e254df06a190f6aa0ff4c92cca91139 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:18:27 -0400 Subject: [PATCH 14/29] fix(mam-api): narrow cluster carve-out to /cluster/heartbeat only Code-review feedback: startsWith('/cluster') was a prefix match that exposed destructive operator endpoints (POST /containers/:id/restart, DELETE /:id, GET /devices/blackmagic/*) unauthenticated. Only POST /heartbeat is genuine node-agent traffic; everything else in cluster.js is operator/UI surface that should go through requireAuth. Long-term: issue node-agent a bound api_token and drop the carve-out entirely. --- services/mam-api/src/index.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 934b5c8..fb914fa 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -88,13 +88,18 @@ app.use(session({ app.get('/health', (_req, res) => res.json({ status: 'ok' })); // ── Auth gate ───────────────────────────────────────────────────────────────── -// Mount once for everything under /api/v1, with an explicit allowlist for -// the three pre-login auth paths and a carve-out for /cluster/* (node-agent -// uses migration 019's token-binding, not user auth). See spec. +// 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', (req, res, next) => { if (UNAUTH_PATHS.has(req.path)) return next(); - if (req.path.startsWith('/cluster')) return next(); // node-agent service auth, not user auth + if (SERVICE_PATHS.has(req.path)) return next(); return requireAuth(req, res, next); }); From 49a9543942885963628872dfc4b64291de94834f Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:21:32 -0400 Subject: [PATCH 15/29] feat(mam-api): auth router skeleton + setup-required endpoint --- services/mam-api/src/index.js | 2 ++ services/mam-api/src/routes/auth.js | 23 ++++++++++++++ services/mam-api/test/routes/auth.test.js | 37 +++++++++++++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 services/mam-api/src/routes/auth.js create mode 100644 services/mam-api/test/routes/auth.test.js diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index fb914fa..18fa901 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -11,6 +11,7 @@ import { errorHandler } from './middleware/errors.js'; import { requireAuth } from './middleware/auth.js'; import { loadS3ConfigFromDb } from './s3/client.js'; +import authRouter from './routes/auth.js'; // Routes import assetsRouter from './routes/assets.js'; import projectsRouter from './routes/projects.js'; @@ -104,6 +105,7 @@ app.use('/api/v1', (req, res, next) => { }); // ── API Routes ──────────────────────────────────────────────────────────────── +app.use('/api/v1/auth', authRouter); app.use('/api/v1/assets', assetsRouter); app.use('/api/v1/projects', projectsRouter); app.use('/api/v1/bins', binsRouter); diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js new file mode 100644 index 0000000..4956045 --- /dev/null +++ b/services/mam-api/src/routes/auth.js @@ -0,0 +1,23 @@ +import express from 'express'; +import pool from '../db/pool.js'; +import { DEV_USER_ID } from '../middleware/auth.js'; + +const router = express.Router(); + +// Real users = anyone except the seeded dev row. +async function realUserCount() { + const { rows } = await pool.query( + `SELECT COUNT(*)::int AS n FROM users WHERE id <> $1`, [DEV_USER_ID]); + return rows[0].n; +} + +// GET /api/v1/auth/setup-required +// Cheap, no auth. Used by AuthGate to decide between Login and Setup screens. +router.get('/setup-required', async (_req, res, next) => { + try { + res.json({ required: (await realUserCount()) === 0 }); + } catch (err) { next(err); } +}); + +export default router; +export { realUserCount }; diff --git a/services/mam-api/test/routes/auth.test.js b/services/mam-api/test/routes/auth.test.js new file mode 100644 index 0000000..d0fbebe --- /dev/null +++ b/services/mam-api/test/routes/auth.test.js @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; +import express from 'express'; +import authRouter from '../../src/routes/auth.js'; + +async function appWithAuth(pool) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + const app = express(); + app.use(express.json()); + app.use('/api/v1/auth', authRouter); + return new Promise(r => { + const srv = app.listen(0, '127.0.0.1', () => { + r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }); + }); + }); +} + +test('GET /auth/setup-required returns { required: true } on empty users (modulo dev seed)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + 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(); } +}); From c9f9698b5874dcfd4659874db208c266cf3ab84d Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:24:56 -0400 Subject: [PATCH 16/29] =?UTF-8?q?feat(mam-api):=20POST=20/auth/setup=20?= =?UTF-8?q?=E2=80=94=20first-run=20admin=20creation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/mam-api/src/routes/auth.js | 40 ++++++++++++++ services/mam-api/test/routes/auth.test.js | 67 +++++++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index 4956045..7fe6752 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -1,6 +1,7 @@ import express from 'express'; import pool from '../db/pool.js'; import { DEV_USER_ID } from '../middleware/auth.js'; +import { hashPassword } from '../auth/passwords.js'; const router = express.Router(); @@ -19,5 +20,44 @@ router.get('/setup-required', async (_req, res, next) => { } 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); + } +}); + export default router; export { realUserCount }; diff --git a/services/mam-api/test/routes/auth.test.js b/services/mam-api/test/routes/auth.test.js index d0fbebe..670a677 100644 --- a/services/mam-api/test/routes/auth.test.js +++ b/services/mam-api/test/routes/auth.test.js @@ -2,6 +2,7 @@ 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'; async function appWithAuth(pool) { @@ -35,3 +36,69 @@ test('GET /auth/setup-required returns { required: false } once a real user exis 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/auth', authRouter); + return new Promise(r => { + const srv = app.listen(0, '127.0.0.1', () => { + r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }); + }); + }); +} + +test('POST /auth/setup creates the first admin and returns a session cookie', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + const { baseUrl, close } = await appWithSession(pool); + try { + const res = await fetch(baseUrl + '/api/v1/auth/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }), + }); + assert.equal(res.status, 200); + const body = await res.json(); + assert.equal(body.user.username, 'admin'); + assert.match(res.headers.get('set-cookie') || '', /dragonflight\.sid=/); + const { rows } = await pool.query(`SELECT COUNT(*)::int AS n FROM users WHERE username='admin'`); + assert.equal(rows[0].n, 1); + } finally { await close(); await pool.end(); } +}); + +test('POST /auth/setup is 409 once a real user exists', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('existing', 'x')`); + const { baseUrl, close } = await appWithSession(pool); + try { + const res = await fetch(baseUrl + '/api/v1/auth/setup', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }), + }); + assert.equal(res.status, 409); + assert.equal((await res.json()).error, 'setup already complete'); + } finally { await close(); await pool.end(); } +}); + +test('POST /auth/setup rejects passwords shorter than 12 chars', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + const { baseUrl, close } = await appWithSession(pool); + try { + const res = await fetch(baseUrl + '/api/v1/auth/setup', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'admin', password: 'short' }), + }); + assert.equal(res.status, 400); + } finally { await close(); await pool.end(); } +}); From f8b6f7d5ef23bdc907084ee0d03b6f4d87c178f2 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:28:18 -0400 Subject: [PATCH 17/29] feat(mam-api): POST /auth/login + redirect-loop regression test --- services/mam-api/src/routes/auth.js | 36 ++++++++++- services/mam-api/test/routes/auth.test.js | 75 +++++++++++++++++++++++ 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index 7fe6752..2409c27 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -1,7 +1,7 @@ import express from 'express'; import pool from '../db/pool.js'; import { DEV_USER_ID } from '../middleware/auth.js'; -import { hashPassword } from '../auth/passwords.js'; +import { hashPassword, comparePassword } from '../auth/passwords.js'; const router = express.Router(); @@ -59,5 +59,39 @@ router.post('/setup', async (req, res, next) => { } }); +// POST /api/v1/auth/login — authenticate an existing user by username + password. +router.post('/login', async (req, res, next) => { + try { + const { username, password } = req.body || {}; + if (!username || !password) return res.status(401).json({ error: 'invalid credentials' }); + + const { rows } = await pool.query( + `SELECT id, username, display_name, password_hash FROM users WHERE username = $1 AND id <> $2`, + [username.trim(), DEV_USER_ID] + ); + if (rows.length === 0) { + // Still hash the supplied password against a dummy to keep response time uniform. + await comparePassword(password, '$2b$12$dummyhashthatwillalwaysfailtocomparexxxxxxxxxxxxxxxxxxxx'); + return res.status(401).json({ error: 'invalid credentials' }); + } + const user = rows[0]; + if (!(await comparePassword(password, user.password_hash))) { + return res.status(401).json({ error: 'invalid credentials' }); + } + + req.session.user_id = user.id; + req.session.first_seen_at = Date.now(); + req.session.last_seen_at = Date.now(); + // The critical line — wait for the row to land in `sessions` before responding. + // Without this, the SPA's next request races the store write, hits 401, and + // the prior bounce-to-login logic produced an infinite loop. + await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); + + await pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]).catch(() => {}); + + res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); + } catch (err) { next(err); } +}); + export default router; export { realUserCount }; diff --git a/services/mam-api/test/routes/auth.test.js b/services/mam-api/test/routes/auth.test.js index 670a677..5f3e6be 100644 --- a/services/mam-api/test/routes/auth.test.js +++ b/services/mam-api/test/routes/auth.test.js @@ -4,6 +4,9 @@ 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 { comparePassword } from '../../src/auth/passwords.js'; +import { requireAuth } from '../../src/middleware/auth.js'; async function appWithAuth(pool) { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; @@ -102,3 +105,75 @@ test('POST /auth/setup rejects passwords shorter than 12 chars', { skip: !isTest 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/auth', authRouter); + app.get('/api/v1/protected/me', requireAuth, (req, res) => res.json({ user: req.user })); + return new Promise(r => { + const srv = app.listen(0, '127.0.0.1', () => { + r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }); + }); + }); +} + +test('POST /auth/login with valid creds → 200 + cookie, and the cookie unlocks subsequent requests (regression: redirect loop)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + const hash = await hashPassword('correct-horse-battery'); + await pool.query(`INSERT INTO users (username, password_hash, display_name) VALUES ('alice', $1, 'Alice')`, [hash]); + const { baseUrl, close } = await appWithSessionAndMe(pool); + try { + // 1. Login. + const loginRes = await fetch(baseUrl + '/api/v1/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }), + }); + assert.equal(loginRes.status, 200); + const setCookie = loginRes.headers.get('set-cookie'); + assert.match(setCookie || '', /dragonflight\.sid=/, 'expected Set-Cookie with dragonflight.sid'); + + // 2. The SAME cookie must unlock the next request. This is the bug that + // produced the original redirect loop — login returned 200 but no cookie + // was persisted, so the next request 401'd and the SPA bounced. + const meRes = await fetch(baseUrl + '/api/v1/protected/me', { + headers: { cookie: setCookie.split(';')[0] }, + }); + assert.equal(meRes.status, 200, 'POST /login returned 200 but the cookie did not unlock /me — this is the regression'); + assert.equal((await meRes.json()).user.username, 'alice'); + } finally { await close(); await pool.end(); } +}); + +test('POST /auth/login with wrong password → 401 + generic message (no enumeration)', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + const hash = await hashPassword('correct-horse-battery'); + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]); + const { baseUrl, close } = await appWithSessionAndMe(pool); + try { + const r1 = await fetch(baseUrl + '/api/v1/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'wrong' }), + }); + const r2 = await fetch(baseUrl + '/api/v1/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'nobody', password: 'whatever-long-enough' }), + }); + assert.equal(r1.status, 401); + assert.equal(r2.status, 401); + const e1 = (await r1.json()).error, e2 = (await r2.json()).error; + assert.equal(e1, 'invalid credentials'); + assert.equal(e2, 'invalid credentials'); // identical message — no enumeration + } finally { await close(); await pool.end(); } +}); From bcfc19e53026ffaeebef4fc4a8933816bf5e893d Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:35:59 -0400 Subject: [PATCH 18/29] fix(mam-api): real dummy bcrypt hash + log last_login_at failures Code-review feedback: - Dummy hash for user-enumeration-defense timing was 63 chars (bcrypt strings are 60 chars). Worked by accident because bcrypt 5.x is lenient about trailing chars; a future tightening would silently regress the timing defense. Replaced with a real pre-computed bcrypt hash. - last_login_at UPDATE now logs errors instead of silently swallowing them, matching the pattern in requireAuth for api_tokens.last_used_at. - Removed dead import of comparePassword from auth.test.js. --- services/mam-api/src/routes/auth.js | 11 ++++++++--- services/mam-api/test/routes/auth.test.js | 1 - 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index 2409c27..6efaf37 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -3,6 +3,8 @@ import pool from '../db/pool.js'; import { DEV_USER_ID } from '../middleware/auth.js'; import { hashPassword, comparePassword } from '../auth/passwords.js'; +const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K'; + const router = express.Router(); // Real users = anyone except the seeded dev row. @@ -70,8 +72,10 @@ router.post('/login', async (req, res, next) => { [username.trim(), DEV_USER_ID] ); if (rows.length === 0) { - // Still hash the supplied password against a dummy to keep response time uniform. - await comparePassword(password, '$2b$12$dummyhashthatwillalwaysfailtocomparexxxxxxxxxxxxxxxxxxxx'); + // 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); return res.status(401).json({ error: 'invalid credentials' }); } const user = rows[0]; @@ -87,7 +91,8 @@ router.post('/login', async (req, res, next) => { // the prior bounce-to-login logic produced an infinite loop. await new Promise((resolve, reject) => req.session.save(err => err ? reject(err) : resolve())); - await pool.query(`UPDATE users SET last_login_at = NOW() WHERE id = $1`, [user.id]).catch(() => {}); + 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)); res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); } catch (err) { next(err); } diff --git a/services/mam-api/test/routes/auth.test.js b/services/mam-api/test/routes/auth.test.js index 5f3e6be..e70168c 100644 --- a/services/mam-api/test/routes/auth.test.js +++ b/services/mam-api/test/routes/auth.test.js @@ -5,7 +5,6 @@ import express from 'express'; import session from 'express-session'; import authRouter from '../../src/routes/auth.js'; import { hashPassword } from '../../src/auth/passwords.js'; -import { comparePassword } from '../../src/auth/passwords.js'; import { requireAuth } from '../../src/middleware/auth.js'; async function appWithAuth(pool) { From d75a0241eb2eb758a713bee142c56d925a479b10 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:38:05 -0400 Subject: [PATCH 19/29] feat(mam-api): POST /auth/logout --- services/mam-api/src/routes/auth.js | 10 ++++++++++ services/mam-api/test/routes/auth.test.js | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index 6efaf37..269f58b 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -98,5 +98,15 @@ router.post('/login', async (req, res, next) => { } 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(); + }); +}); + export default router; export { realUserCount }; diff --git a/services/mam-api/test/routes/auth.test.js b/services/mam-api/test/routes/auth.test.js index e70168c..07a7566 100644 --- a/services/mam-api/test/routes/auth.test.js +++ b/services/mam-api/test/routes/auth.test.js @@ -176,3 +176,25 @@ test('POST /auth/login with wrong password → 401 + generic message (no enumera 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' }, + 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(); } +}); From 0bbaf80d2a11fb756bdd95111b6fa5fa936b6d30 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:42:53 -0400 Subject: [PATCH 20/29] feat(mam-api): GET /auth/me + POST /auth/password --- services/mam-api/src/routes/auth.js | 28 ++++++++++++++- services/mam-api/test/routes/auth.test.js | 44 +++++++++++++++++++++++ 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index 269f58b..529e5ce 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -1,6 +1,6 @@ import express from 'express'; import pool from '../db/pool.js'; -import { DEV_USER_ID } from '../middleware/auth.js'; +import { DEV_USER_ID, requireAuth } from '../middleware/auth.js'; import { hashPassword, comparePassword } from '../auth/passwords.js'; const DUMMY_PASSWORD_HASH = '$2b$12$gSeC58PregWedNFK/8Q61OephUo.JJ7EUs0LCTdnJV5AzCS5qQH7K'; @@ -108,5 +108,31 @@ router.post('/logout', (req, res) => { }); }); +// 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 }; diff --git a/services/mam-api/test/routes/auth.test.js b/services/mam-api/test/routes/auth.test.js index 07a7566..3d9824c 100644 --- a/services/mam-api/test/routes/auth.test.js +++ b/services/mam-api/test/routes/auth.test.js @@ -198,3 +198,47 @@ test('POST /auth/logout destroys the session row and the cookie no longer unlock 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' }, + body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }), + }); + const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0]; + const me = await fetch(baseUrl + '/api/v1/auth/me', { headers: { cookie } }); + assert.equal(me.status, 200); + const body = await me.json(); + assert.equal(body.username, 'alice'); + assert.equal(body.display_name, 'Alice'); + } finally { await close(); await pool.end(); } +}); + +test('POST /auth/password rotates the password when current is correct', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + const hash = await hashPassword('correct-horse-battery'); + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [hash]); + const { baseUrl, close } = await appWithSessionAndMe(pool); + try { + const loginRes = await fetch(baseUrl + '/api/v1/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }), + }); + const cookie = (loginRes.headers.get('set-cookie') || '').split(';')[0]; + const change = await fetch(baseUrl + '/api/v1/auth/password', { + method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + body: JSON.stringify({ current_password: 'correct-horse-battery', new_password: 'brand-new-passphrase' }), + }); + assert.equal(change.status, 204); + // Wrong current → 400 + const wrong = await fetch(baseUrl + '/api/v1/auth/password', { + method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + body: JSON.stringify({ current_password: 'no', new_password: 'another-good-passphrase' }), + }); + assert.equal(wrong.status, 400); + } finally { await close(); await pool.end(); } +}); From b7f5a84d2d3f89bb46afe686ddb56b9d7c6291e4 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:47:03 -0400 Subject: [PATCH 21/29] feat(mam-api): user CRUD + admin password reset + last-user delete guard --- services/mam-api/src/index.js | 2 + services/mam-api/src/routes/users.js | 72 +++++++++++++++++ services/mam-api/test/routes/users.test.js | 89 ++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 services/mam-api/src/routes/users.js create mode 100644 services/mam-api/test/routes/users.test.js diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 18fa901..407f20a 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -12,6 +12,7 @@ import { requireAuth } from './middleware/auth.js'; import { loadS3ConfigFromDb } from './s3/client.js'; import authRouter from './routes/auth.js'; +import usersRouter from './routes/users.js'; // Routes import assetsRouter from './routes/assets.js'; import projectsRouter from './routes/projects.js'; @@ -106,6 +107,7 @@ app.use('/api/v1', (req, res, next) => { // ── API Routes ──────────────────────────────────────────────────────────────── app.use('/api/v1/auth', authRouter); +app.use('/api/v1/auth/users', usersRouter); app.use('/api/v1/assets', assetsRouter); app.use('/api/v1/projects', projectsRouter); app.use('/api/v1/bins', binsRouter); diff --git a/services/mam-api/src/routes/users.js b/services/mam-api/src/routes/users.js new file mode 100644 index 0000000..1803335 --- /dev/null +++ b/services/mam-api/src/routes/users.js @@ -0,0 +1,72 @@ +// 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); } +}); + +export default router; diff --git a/services/mam-api/test/routes/users.test.js b/services/mam-api/test/routes/users.test.js new file mode 100644 index 0000000..0c258d6 --- /dev/null +++ b/services/mam-api/test/routes/users.test.js @@ -0,0 +1,89 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import session from 'express-session'; +import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; +import usersRouter from '../../src/routes/users.js'; +import authRouter from '../../src/routes/auth.js'; +import { requireAuth } from '../../src/middleware/auth.js'; +import { hashPassword } from '../../src/auth/passwords.js'; + +async function app(pool) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + process.env.AUTH_ENABLED = 'true'; + const ConnectPg = (await import('connect-pg-simple')).default(session); + const a = express(); + a.use(express.json()); + a.use(session({ + store: new ConnectPg({ pool, tableName: 'sessions' }), + secret: 'test', name: 'dragonflight.sid', + cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 }, + rolling: false, resave: false, saveUninitialized: false, + })); + a.use('/api/v1/auth', authRouter); + a.use('/api/v1/auth/users', requireAuth, usersRouter); + return new Promise(r => { + const srv = a.listen(0, '127.0.0.1', () => { + r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }); + }); + }); +} + +async function login(baseUrl, username, password) { + const r = await fetch(baseUrl + '/api/v1/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }), + }); + assert.equal(r.status, 200, 'login failed: ' + JSON.stringify(await r.json())); + return (r.headers.get('set-cookie') || '').split(';')[0]; +} + +test('users: list + create + delete + admin reset password', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('admin', $1)`, [await hashPassword('admin-passphrase!!')]); + const { baseUrl, close } = await app(pool); + try { + const cookie = await login(baseUrl, 'admin', 'admin-passphrase!!'); + + // List + const list = await fetch(baseUrl + '/api/v1/auth/users', { headers: { cookie } }); + assert.equal(list.status, 200); + const users0 = await list.json(); + assert.ok(users0.find(u => u.username === 'admin')); + + // Create + const created = await fetch(baseUrl + '/api/v1/auth/users', { + method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + body: JSON.stringify({ username: 'bob', password: 'bob-passphrase!', display_name: 'Bob' }), + }); + assert.equal(created.status, 201); + const bob = await created.json(); + assert.equal(bob.username, 'bob'); + + // Admin reset password + const reset = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id + '/password', { + method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + body: JSON.stringify({ new_password: 'a-fresh-passphrase' }), + }); + assert.equal(reset.status, 204); + + // Delete + const del = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id, { + method: 'DELETE', headers: { cookie }, + }); + assert.equal(del.status, 204); + } finally { await close(); await pool.end(); } +}); + +test('users: cannot delete the last real user', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + await pool.query(`INSERT INTO users (id, username, password_hash) VALUES (uuid_generate_v4(), 'solo', $1)`, [await hashPassword('only-user-here-12')]); + const { baseUrl, close } = await app(pool); + try { + const cookie = await login(baseUrl, 'solo', 'only-user-here-12'); + const me = await (await fetch(baseUrl + '/api/v1/auth/me', { headers: { cookie } })).json(); + const r = await fetch(baseUrl + '/api/v1/auth/users/' + me.id, { method: 'DELETE', headers: { cookie } }); + assert.equal(r.status, 409); + assert.equal((await r.json()).error, 'cannot delete last user'); + } finally { await close(); await pool.end(); } +}); From 56b661ef656a02331783d987ecf1eb3f48d7f892 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:52:07 -0400 Subject: [PATCH 22/29] =?UTF-8?q?feat(mam-api):=20API=20token=20CRUD=20?= =?UTF-8?q?=E2=80=94=20show=20raw=20once,=20bearer-authenticate=20via=20SH?= =?UTF-8?q?A-256=20lookup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/mam-api/src/index.js | 2 + services/mam-api/src/routes/tokens.js | 46 +++++++++ services/mam-api/test/routes/tokens.test.js | 102 ++++++++++++++++++++ 3 files changed, 150 insertions(+) create mode 100644 services/mam-api/src/routes/tokens.js create mode 100644 services/mam-api/test/routes/tokens.test.js diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 407f20a..3ee3ea7 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -12,6 +12,7 @@ import { requireAuth } from './middleware/auth.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 import assetsRouter from './routes/assets.js'; @@ -108,6 +109,7 @@ app.use('/api/v1', (req, res, next) => { // ── API Routes ──────────────────────────────────────────────────────────────── app.use('/api/v1/auth', authRouter); app.use('/api/v1/auth/users', usersRouter); +app.use('/api/v1/auth/tokens', requireAuth, tokensRouter); app.use('/api/v1/assets', assetsRouter); app.use('/api/v1/projects', projectsRouter); app.use('/api/v1/bins', binsRouter); diff --git a/services/mam-api/src/routes/tokens.js b/services/mam-api/src/routes/tokens.js new file mode 100644 index 0000000..195d2bc --- /dev/null +++ b/services/mam-api/src/routes/tokens.js @@ -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; diff --git a/services/mam-api/test/routes/tokens.test.js b/services/mam-api/test/routes/tokens.test.js new file mode 100644 index 0000000..50c7baa --- /dev/null +++ b/services/mam-api/test/routes/tokens.test.js @@ -0,0 +1,102 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import express from 'express'; +import session from 'express-session'; +import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; +import tokensRouter from '../../src/routes/tokens.js'; +import authRouter from '../../src/routes/auth.js'; +import { requireAuth } from '../../src/middleware/auth.js'; +import { hashPassword } from '../../src/auth/passwords.js'; + +async function app(pool) { + process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; + process.env.AUTH_ENABLED = 'true'; + const ConnectPg = (await import('connect-pg-simple')).default(session); + const a = express(); + a.use(express.json()); + a.use(session({ + store: new ConnectPg({ pool, tableName: 'sessions' }), + secret: 'test', name: 'dragonflight.sid', + cookie: { httpOnly: true, sameSite: 'lax', secure: false, maxAge: 8 * 3600 * 1000 }, + rolling: false, resave: false, saveUninitialized: false, + })); + a.use('/api/v1/auth', authRouter); + a.use('/api/v1/auth/tokens', requireAuth, tokensRouter); + a.get('/api/v1/protected/ping', requireAuth, (req, res) => res.json({ user: req.user.username })); + return new Promise(r => { + const srv = a.listen(0, '127.0.0.1', () => { + r({ baseUrl: 'http://127.0.0.1:' + srv.address().port, close: () => new Promise(rs => srv.close(rs)) }); + }); + }); +} + +async function loginCookie(baseUrl, u, p) { + const r = await fetch(baseUrl + '/api/v1/auth/login', { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username: u, password: p }), + }); + return (r.headers.get('set-cookie') || '').split(';')[0]; +} + +test('tokens: create returns the raw token exactly once; bearer of that token works; revoke 401s subsequent calls', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]); + const { baseUrl, close } = await app(pool); + try { + const cookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery'); + + // Create + const create = await fetch(baseUrl + '/api/v1/auth/tokens', { + method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + body: JSON.stringify({ name: 'Premiere panel' }), + }); + assert.equal(create.status, 201); + const created = await create.json(); + assert.match(created.token, /^dfl_[0-9a-f]{64}$/); + assert.equal(created.prefix, created.token.slice(0, 8)); + + // List — should NOT include the raw token, only the prefix. + const list = await fetch(baseUrl + '/api/v1/auth/tokens', { headers: { cookie } }); + const rows = await list.json(); + assert.equal(rows.length, 1); + assert.equal(rows[0].prefix, created.prefix); + assert.equal(rows[0].token, undefined); + + // The raw token authenticates as a bearer. + const ping = await fetch(baseUrl + '/api/v1/protected/ping', { + headers: { authorization: 'Bearer ' + created.token }, + }); + assert.equal(ping.status, 200); + + // Revoke. + const rev = await fetch(baseUrl + '/api/v1/auth/tokens/' + created.id, { + method: 'DELETE', headers: { cookie }, + }); + assert.equal(rev.status, 204); + + // Same bearer now 401s. + const ping2 = await fetch(baseUrl + '/api/v1/protected/ping', { + headers: { authorization: 'Bearer ' + created.token }, + }); + assert.equal(ping2.status, 401); + } finally { await close(); await pool.end(); } +}); + +test('tokens: cannot revoke another users token', { skip: !isTestDbConfigured() && 'TEST_DATABASE_URL not set' }, async () => { + const pool = await setupTestDb(); + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('alice', $1)`, [await hashPassword('correct-horse-battery')]); + await pool.query(`INSERT INTO users (username, password_hash) VALUES ('bob', $1)`, [await hashPassword('bob-passphrase-12')]); + const { baseUrl, close } = await app(pool); + try { + const aliceCookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery'); + const bobCookie = await loginCookie(baseUrl, 'bob', 'bob-passphrase-12'); + const aliceTok = await (await fetch(baseUrl + '/api/v1/auth/tokens', { + method: 'POST', headers: { 'Content-Type': 'application/json', cookie: aliceCookie }, + body: JSON.stringify({ name: 'alice token' }), + })).json(); + const r = await fetch(baseUrl + '/api/v1/auth/tokens/' + aliceTok.id, { + method: 'DELETE', headers: { cookie: bobCookie }, + }); + assert.equal(r.status, 404); // not found from bob's perspective + } finally { await close(); await pool.end(); } +}); From d209a192c3c27f0e0aa5b339bef11a941ac36768 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 14:58:02 -0400 Subject: [PATCH 23/29] feat(mam-api): login rate limit + X-Requested-With CSRF header check --- services/mam-api/src/auth/rate-limit.js | 18 +++++++++++++ services/mam-api/src/index.js | 4 ++- services/mam-api/src/middleware/auth.js | 15 +++++++++++ services/mam-api/src/routes/auth.js | 16 +++++++++--- services/mam-api/test/auth/rate-limit.test.js | 24 +++++++++++++++++ services/mam-api/test/middleware/auth.test.js | 26 +++++++++++++++++++ services/mam-api/test/routes/auth.test.js | 22 ++++++++-------- services/mam-api/test/routes/tokens.test.js | 6 ++--- services/mam-api/test/routes/users.test.js | 6 ++--- 9 files changed, 116 insertions(+), 21 deletions(-) create mode 100644 services/mam-api/src/auth/rate-limit.js create mode 100644 services/mam-api/test/auth/rate-limit.test.js diff --git a/services/mam-api/src/auth/rate-limit.js b/services/mam-api/src/auth/rate-limit.js new file mode 100644 index 0000000..5b81f2e --- /dev/null +++ b/services/mam-api/src/auth/rate-limit.js @@ -0,0 +1,18 @@ +// 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]; + +export const ipBackoff = { + delayMs(ip) { + const n = failures.get(ip) || 0; + if (n === 0) return 0; + return STEPS[Math.min(n - 1, STEPS.length - 1)]; + }, + recordFailure(ip) { + failures.set(ip, (failures.get(ip) || 0) + 1); + }, + recordSuccess(ip) { failures.delete(ip); }, + reset(ip) { failures.delete(ip); }, +}; diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 3ee3ea7..6e2ac99 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -8,7 +8,7 @@ import os from 'node:os'; import { exec } from 'node:child_process'; import pool from './db/pool.js'; import { errorHandler } from './middleware/errors.js'; -import { requireAuth } from './middleware/auth.js'; +import { requireAuth, requireUiHeader } from './middleware/auth.js'; import { loadS3ConfigFromDb } from './s3/client.js'; import authRouter from './routes/auth.js'; @@ -100,6 +100,8 @@ const UNAUTH_PATHS = new Set(['/auth/login', '/auth/setup', '/auth/setup-require // 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(); diff --git a/services/mam-api/src/middleware/auth.js b/services/mam-api/src/middleware/auth.js index d0bdec2..1d8cb3c 100644 --- a/services/mam-api/src/middleware/auth.js +++ b/services/mam-api/src/middleware/auth.js @@ -62,3 +62,18 @@ export async function requireAuth(req, res, 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
can produce hardens +// against the edge cases. Applied to mutating verbs only. +const MUTATING = new Set(['POST', 'PUT', 'PATCH', 'DELETE']); +const REQUIRED_HEADER = 'dragonflight-ui'; + +export function requireUiHeader(req, res, next) { + if (!MUTATING.has(req.method)) return next(); + // Bearer-authed requests (Premiere panel, scripts) are exempt — they're not + // browsers and can't be drive-by'd from another origin. + if (req.headers.authorization?.toLowerCase().startsWith('bearer ')) return next(); + if (req.headers['x-requested-with'] === REQUIRED_HEADER) return next(); + return res.status(403).json({ error: 'missing X-Requested-With header' }); +} diff --git a/services/mam-api/src/routes/auth.js b/services/mam-api/src/routes/auth.js index 529e5ce..4e52a02 100644 --- a/services/mam-api/src/routes/auth.js +++ b/services/mam-api/src/routes/auth.js @@ -2,6 +2,7 @@ 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'; @@ -64,8 +65,15 @@ router.post('/setup', async (req, res, next) => { // 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) return res.status(401).json({ error: 'invalid credentials' }); + 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`, @@ -76,10 +84,12 @@ router.post('/login', async (req, res, next) => { // 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' }); } @@ -94,8 +104,8 @@ router.post('/login', async (req, res, next) => { 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)); - res.json({ user: { id: user.id, username: user.username, display_name: user.display_name } }); - } catch (err) { next(err); } + 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. diff --git a/services/mam-api/test/auth/rate-limit.test.js b/services/mam-api/test/auth/rate-limit.test.js new file mode 100644 index 0000000..69adedd --- /dev/null +++ b/services/mam-api/test/auth/rate-limit.test.js @@ -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); +}); diff --git a/services/mam-api/test/middleware/auth.test.js b/services/mam-api/test/middleware/auth.test.js index a23a1fb..79ce269 100644 --- a/services/mam-api/test/middleware/auth.test.js +++ b/services/mam-api/test/middleware/auth.test.js @@ -147,3 +147,29 @@ test('bearer token whose user was deleted → 401', { skip: !isTestDbConfigured( 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); +}); diff --git a/services/mam-api/test/routes/auth.test.js b/services/mam-api/test/routes/auth.test.js index 3d9824c..41af3f6 100644 --- a/services/mam-api/test/routes/auth.test.js +++ b/services/mam-api/test/routes/auth.test.js @@ -66,7 +66,7 @@ test('POST /auth/setup creates the first admin and returns a session cookie', { try { const res = await fetch(baseUrl + '/api/v1/auth/setup', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }), }); assert.equal(res.status, 200); @@ -85,7 +85,7 @@ test('POST /auth/setup is 409 once a real user exists', { skip: !isTestDbConfigu try { const res = await fetch(baseUrl + '/api/v1/auth/setup', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: 'admin', password: 'correct-horse-battery' }), }); assert.equal(res.status, 409); @@ -98,7 +98,7 @@ test('POST /auth/setup rejects passwords shorter than 12 chars', { skip: !isTest const { baseUrl, close } = await appWithSession(pool); try { const res = await fetch(baseUrl + '/api/v1/auth/setup', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: 'admin', password: 'short' }), }); assert.equal(res.status, 400); @@ -137,7 +137,7 @@ test('POST /auth/login with valid creds → 200 + cookie, and the cookie unlocks // 1. Login. const loginRes = await fetch(baseUrl + '/api/v1/auth/login', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, body: JSON.stringify({ username: 'alice', password: 'correct-horse-battery' }), }); assert.equal(loginRes.status, 200); @@ -162,11 +162,11 @@ test('POST /auth/login with wrong password → 401 + generic message (no enumera const { baseUrl, close } = await appWithSessionAndMe(pool); try { const r1 = await fetch(baseUrl + '/api/v1/auth/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + 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' }, + 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); @@ -184,7 +184,7 @@ test('POST /auth/logout destroys the session row and the cookie no longer unlock const { baseUrl, close } = await appWithSessionAndMe(pool); try { const loginRes = await fetch(baseUrl + '/api/v1/auth/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + 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]; @@ -206,7 +206,7 @@ test('GET /auth/me returns the authed user', { skip: !isTestDbConfigured() && 'T const { baseUrl, close } = await appWithSessionAndMe(pool); try { const loginRes = await fetch(baseUrl + '/api/v1/auth/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + 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]; @@ -225,18 +225,18 @@ test('POST /auth/password rotates the password when current is correct', { skip: const { baseUrl, close } = await appWithSessionAndMe(pool); try { const loginRes = await fetch(baseUrl + '/api/v1/auth/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + 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', cookie }, + 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', cookie }, + 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); diff --git a/services/mam-api/test/routes/tokens.test.js b/services/mam-api/test/routes/tokens.test.js index 50c7baa..a49b25c 100644 --- a/services/mam-api/test/routes/tokens.test.js +++ b/services/mam-api/test/routes/tokens.test.js @@ -32,7 +32,7 @@ async function app(pool) { async function loginCookie(baseUrl, u, p) { const r = await fetch(baseUrl + '/api/v1/auth/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + 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]; @@ -47,7 +47,7 @@ test('tokens: create returns the raw token exactly once; bearer of that token wo // Create const create = await fetch(baseUrl + '/api/v1/auth/tokens', { - method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui', cookie }, body: JSON.stringify({ name: 'Premiere panel' }), }); assert.equal(create.status, 201); @@ -91,7 +91,7 @@ test('tokens: cannot revoke another users token', { skip: !isTestDbConfigured() const aliceCookie = await loginCookie(baseUrl, 'alice', 'correct-horse-battery'); const bobCookie = await loginCookie(baseUrl, 'bob', 'bob-passphrase-12'); const aliceTok = await (await fetch(baseUrl + '/api/v1/auth/tokens', { - method: 'POST', headers: { 'Content-Type': 'application/json', cookie: aliceCookie }, + 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, { diff --git a/services/mam-api/test/routes/users.test.js b/services/mam-api/test/routes/users.test.js index 0c258d6..dd9294d 100644 --- a/services/mam-api/test/routes/users.test.js +++ b/services/mam-api/test/routes/users.test.js @@ -31,7 +31,7 @@ async function app(pool) { async function login(baseUrl, username, password) { const r = await fetch(baseUrl + '/api/v1/auth/login', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, + 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())); @@ -53,7 +53,7 @@ test('users: list + create + delete + admin reset password', { skip: !isTestDbCo // Create const created = await fetch(baseUrl + '/api/v1/auth/users', { - method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + 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); @@ -62,7 +62,7 @@ test('users: list + create + delete + admin reset password', { skip: !isTestDbCo // Admin reset password const reset = await fetch(baseUrl + '/api/v1/auth/users/' + bob.id + '/password', { - method: 'POST', headers: { 'Content-Type': 'application/json', cookie }, + 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); From 96effaaa3cd36cad3790107307f05a7876ae4949 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 15:03:35 -0400 Subject: [PATCH 24/29] fix(mam-api): TRUST_PROXY boot warning + CSRF integration tests + bounded rate-limit map Fixes three issues in the authentication system: C1: Add boot-time warning when AUTH_ENABLED=true but TRUST_PROXY!=true. Without TRUST_PROXY=true behind nginx, req.ip becomes the proxy IP for all clients, collapsing per-IP rate limiting into a shared pool. Operators must explicitly set TRUST_PROXY=true to make per-IP rate limiting effective. C2: Mount requireUiHeader middleware in test helpers (auth.test.js, users.test.js, tokens.test.js). The CSRF header validation was not being exercised in the test suite. Tests now send X-Requested-With: dragonflight-ui headers that are actually validated by the middleware. I1: Implement bounded rate-limit Map with MAX_ENTRIES=10000 and LRU eviction. Unbounded Maps are vulnerable to spray attacks: attackers can force memory exhaustion by requesting with distinct IPs. Now we evict the oldest entry (by insertion order) when the map reaches capacity. --- services/mam-api/package-lock.json | 2992 +++++++++++++++++++ services/mam-api/src/auth/rate-limit.js | 6 + services/mam-api/src/index.js | 3 + services/mam-api/test/routes/auth.test.js | 4 +- services/mam-api/test/routes/tokens.test.js | 3 +- services/mam-api/test/routes/users.test.js | 3 +- 6 files changed, 3008 insertions(+), 3 deletions(-) create mode 100644 services/mam-api/package-lock.json diff --git a/services/mam-api/package-lock.json b/services/mam-api/package-lock.json new file mode 100644 index 0000000..bdca56e --- /dev/null +++ b/services/mam-api/package-lock.json @@ -0,0 +1,2992 @@ +{ + "name": "wild-dragon-mam-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "wild-dragon-mam-api", + "version": "0.1.0", + "dependencies": { + "@aws-sdk/client-s3": "^3.500.0", + "@aws-sdk/lib-storage": "^3.500.0", + "@aws-sdk/s3-request-presigner": "^3.500.0", + "bcrypt": "^5.1.1", + "bullmq": "^5.5.0", + "connect-pg-simple": "^9.0.1", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.18.2", + "express-session": "^1.17.3", + "multer": "^1.4.5-lts.1", + "pg": "^8.11.3", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.1054.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.1054.0.tgz", + "integrity": "sha512-2ue7uVqaHYX4rytkcrLySYU/m/ZlRbL8KojWefbR24B0/TcFkqN2IovpBFrnmla/dtZAn9eVSlhHeEddOghZ5w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/credential-provider-node": "^3.972.45", + "@aws-sdk/middleware-bucket-endpoint": "^3.972.16", + "@aws-sdk/middleware-expect-continue": "^3.972.13", + "@aws-sdk/middleware-flexible-checksums": "^3.974.22", + "@aws-sdk/middleware-location-constraint": "^3.972.11", + "@aws-sdk/middleware-sdk-s3": "^3.972.43", + "@aws-sdk/middleware-ssec": "^3.972.11", + "@aws-sdk/signature-v4-multi-region": "^3.996.29", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/node-http-handler": "^4.7.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.974.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.14.tgz", + "integrity": "sha512-ppamm04uoj3hhNO5IlQSs5D6rWX1fWkzcn6a4pZrojk8Y6ObY9wzLDdT/Eq3gv6O9hOebi9tYTNB8b8fQj9XJw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@aws-sdk/xml-builder": "^3.972.26", + "@aws/lambda-invoke-store": "^0.2.2", + "@smithy/core": "^3.24.3", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/crc64-nvme": { + "version": "3.972.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/crc64-nvme/-/crc64-nvme-3.972.9.tgz", + "integrity": "sha512-P+QGozmXn2mZZI7sDgk+aUm+RTI61MPSFB+Ir2vjEjEbEsE4e7hYtzrDvAUxZy9ko81h53e11+F/GYlvwDkaOQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.40.tgz", + "integrity": "sha512-jjT0p0Y7KZtcvExYiPCLJnqM9lkXDV1KBEg/13OE2DXv/9batzlyJHVKUEnRNJccY0O2Sul17E1su38CgdBhGQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.42.tgz", + "integrity": "sha512-+3fsKtWybe5BjKEUA3/07oh7Ayfd82IED2+gyyaVfS/4PU78E3TaOQxSGOJ1t7Imefoidw/ne9QA7apX8wEnJg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/node-http-handler": "^4.7.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.44.tgz", + "integrity": "sha512-gZFw5wBefCIPg9vpT+gV5FdhfNKhYTVDZa1IsZCcn3SRoYUOJ/E05vwIogkJoonqBL0ttBGi5vhthX7xceekRg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/credential-provider-env": "^3.972.40", + "@aws-sdk/credential-provider-http": "^3.972.42", + "@aws-sdk/credential-provider-login": "^3.972.44", + "@aws-sdk/credential-provider-process": "^3.972.40", + "@aws-sdk/credential-provider-sso": "^3.972.44", + "@aws-sdk/credential-provider-web-identity": "^3.972.44", + "@aws-sdk/nested-clients": "^3.997.12", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.44.tgz", + "integrity": "sha512-QqEGHfQeZgUDqh7zpqHufrZ8T644ELEWvB+4gUdewLyRw4IRF+6CJqeQuRWqucZdQzoQeMh7fNAD9BWxFAdNig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/nested-clients": "^3.997.12", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.45", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.45.tgz", + "integrity": "sha512-3YCv52ExXIRz3LAVNysevd+s7akSpg9dl39v9LJ7dOQH+s5rHi3jMZYQyxwMmglxQGMuzYRfQ0o1VSP2UOlIRw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.40", + "@aws-sdk/credential-provider-http": "^3.972.42", + "@aws-sdk/credential-provider-ini": "^3.972.44", + "@aws-sdk/credential-provider-process": "^3.972.40", + "@aws-sdk/credential-provider-sso": "^3.972.44", + "@aws-sdk/credential-provider-web-identity": "^3.972.44", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/credential-provider-imds": "^4.3.2", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.40.tgz", + "integrity": "sha512-cXaozlgJCOwmE6D7x4npcPdyk7kiFZdrGjN3D6tXXtItJJMNGPafDfAJn4YQmciMooG/X+b0Y6RTqdVVMx26jg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.44.tgz", + "integrity": "sha512-YePoj5kQuPmE0MHnyftXCfsO8ZSBd2kDr50XEIUrdejSbGFlayYvUuCohdb8drhGhPm6b65o7H1eC26EZhwUvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/nested-clients": "^3.997.12", + "@aws-sdk/token-providers": "3.1054.0", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.44.tgz", + "integrity": "sha512-Ys/JJe++8Z2Y5meR1taMBaVcrGBA0/XsVTQR+qOKZbdNyg+8Jlv5rYZSwh8SqEHY00goSOZy7PHzZ2rLNQxDLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/nested-clients": "^3.997.12", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/lib-storage": { + "version": "3.1054.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1054.0.tgz", + "integrity": "sha512-cnEZOTWnfRDS5wpCT6yVgQai16R+qhC5E9p1wfmjwj4H4YfI0dVq7I2zUZjUjb4Pfy0dIziCuwQg1jEVQzW/Ew==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "buffer": "5.6.0", + "events": "3.3.0", + "stream-browserify": "3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-s3": "^3.1054.0" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.972.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.972.16.tgz", + "integrity": "sha512-FhasMTBDBmMN7EEa1hUeHwo5p5Mv3Dm8w0VEbdXX/6ola/uyhRuJt8zGkH09mLTmab20USTzEpPqyqEoe1MqNg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.972.13.tgz", + "integrity": "sha512-sHiqIFg8o2ipT7t40B89Vj0ubSUtY6OSt/+Ee/OXhHch5K4+81zP2+QX8Lkc/nJ2QSmCySxOke7TEbmX69fe2g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.974.22", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.974.22.tgz", + "integrity": "sha512-ot1kZ1JGHUxcXPOARhej/n/+Odfx9VPt60pNrUq8Lf/U2blIF3+uj5v56gw76VD70dZvrfeLNo9jKz6pQJfOlA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/crc64-nvme": "^3.972.9", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.972.11.tgz", + "integrity": "sha512-hkfspNUP4criAH6ton6BGKgnm5dZx+7bUOy1YqlTfejDeUPAM23D81q/IX+hdlS3KUsfwGz5ADTqZWKBEUpf4A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.972.43", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.972.43.tgz", + "integrity": "sha512-CBmixMY36JdAdt9ALgm7yVlvOXGUCHt9Z2kn5p9XVO5StO6HCH+cayV7YYV1CDLsXvVyebaXgBmif9wHoxCeNA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/signature-v4-multi-region": "^3.996.29", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.972.11.tgz", + "integrity": "sha512-7PQvGNhtveKlvVqNahqWx5yrwxP7ecwAoB1dYBf8eKwfo2tzzCbNnW+q2nO3N066ktQaB4iBQbDRWtizm+amoQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.12.tgz", + "integrity": "sha512-Js2VYaCM269feB0cs0cGmlIhdOgT9aMqzdBx68lCy6kVCYfzr0T36ovUFDvfUmatkuBeyBJhCwaLBh7P8meH5Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/signature-v4-multi-region": "^3.996.29", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/fetch-http-handler": "^5.4.3", + "@smithy/node-http-handler": "^4.7.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.1054.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.1054.0.tgz", + "integrity": "sha512-rgSDc0LwzM1yUL/UouFLR72HV5lhgdQRQlp8WWWot+q3nhBYrj+mklreWh28q9bGkgrNGWrcpMRp4Y3rC4sxVw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/signature-v4-multi-region": "^3.996.29", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.29.tgz", + "integrity": "sha512-Few9FoQqOt/0KSvZYP+qdW0dfOhfQ9N+gl2UUDvCPW6mkPKHli9LMbKxWj+wZ5zKPaOoqxuR3Hhy3OTpndkfSw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.9", + "@smithy/signature-v4": "^5.4.2", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1054.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1054.0.tgz", + "integrity": "sha512-hG9YKApmZOw+drJ9Nuoaf/OvC8e5W1+3eoLeN5p2uVCZRWsv27teIS0b4kiH6Sfv3WMmamqYJxmE2WMwyp/L/A==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.14", + "@aws-sdk/nested-clients": "^3.997.12", + "@aws-sdk/types": "^3.973.9", + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.973.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.9.tgz", + "integrity": "sha512-kuBfgQVdcz5Bmapc4A13YbpVw/pXkesfhetcFYwbntqas8sF41OHyd4o28+/TG2ZQdHBsv90Lsu5y6oitvYCdg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.5.tgz", + "integrity": "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.26.tgz", + "integrity": "sha512-cDbrqvDS73whl6YAPSPq0U6whzG6UWI9PuWh0wrUuGoZexhWEqhdunbukV7iBoaWnFV1AODutM5hOD6rtn439g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.2", + "fast-xml-parser": "5.7.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.2.4.tgz", + "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@ioredis/commands": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz", + "integrity": "sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==", + "license": "MIT" + }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", + "integrity": "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.4.tgz", + "integrity": "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.4.tgz", + "integrity": "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.4.tgz", + "integrity": "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.4.tgz", + "integrity": "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.4.tgz", + "integrity": "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/nodable" + } + ], + "license": "MIT" + }, + "node_modules/@smithy/core": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.5.tgz", + "integrity": "sha512-Kt8phUg45M15EjhYAbZ+fFikYneijLu9Liugz8ZsYz2i8j0hzGv27LWKpEHYRfvj+LyCOSijpcR/2i8RouV+cA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.3.5.tgz", + "integrity": "sha512-yiF8xHpdkaTfzLVqFzsP6WvNghEK+qZzLYWFD13L2SsFhbXwBGlxdocKF95qjr7s5lE5NRage+EJFK4mAsx88Q==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.4.5.tgz", + "integrity": "sha512-SK3VMeH0fibgdTg2QeB+O4p7Yy/2E5HBOHJeC58FshkDdeuX8lOgO7PfjYfLyPLP1ch55j91cQqKBzDS0mRjSQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.5.tgz", + "integrity": "sha512-3dA9TQ+ybRSZ/m0wnbZhiBy4Dezjgq1Ib/ZZrYTpJDBgpoLLU/SDzZc/g0x0MNAdOJe1wPcM+x2PBRmoOur+Sw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.4.5.tgz", + "integrity": "sha512-QBJKWGqIknH0dc9LWpfH1mkdokAx6iXYN3UcQ3eY6uIEyScuoQAhfl94ge7ozUy9WgFUdE8xsvwBjaYBbWmPNA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.5", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.14.2", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.2.tgz", + "integrity": "sha512-P+otAxbV4CqBybp7EkcJCrig63yE2E7PuNVOmilVMRcx/O+QDzGULTrKsq4DV13gSfak9ObPrWaHl/9bL5YcWw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/agent-base/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/agent-base/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/bullmq": { + "version": "5.77.6", + "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-5.77.6.tgz", + "integrity": "sha512-WCpSoCD4vWyRD+btOsFrO7iBGInrTgG155gTZCV8qY0Yex2KtsbVtFERx6V1WZ2xWl/5ZxnLar8Z8ufnS4f5jg==", + "license": "MIT", + "dependencies": { + "cron-parser": "4.9.0", + "ioredis": "5.10.1", + "msgpackr": "2.0.1", + "node-abort-controller": "3.1.1", + "semver": "7.8.0", + "tslib": "2.8.1" + }, + "engines": { + "node": ">=12.22.0" + }, + "peerDependencies": { + "redis": ">=5.0.0" + }, + "peerDependenciesMeta": { + "redis": { + "optional": true + } + } + }, + "node_modules/bullmq/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "engines": [ + "node >= 0.8" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/connect-pg-simple": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-9.0.1.tgz", + "integrity": "sha512-BuwWJH3K3aLpONkO9s12WhZ9ceMjIBxIJAh0JD9x4z1Y9nShmWqZvge5PG/+4j2cIOcguUoa2PSQ4HO/oTsrVg==", + "license": "MIT", + "dependencies": { + "pg": "^8.8.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cron-parser": { + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", + "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==", + "license": "MIT", + "dependencies": { + "luxon": "^3.2.1" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fast-xml-builder": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", + "integrity": "sha512-00aAWieqff+ZJhsXA4g1g7M8k+7AYoMUUHF+/zFb5U6Uv/P0Vl4QZo84/IcufzYalLuEj9928bXN9PbbFzMF0Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.5.0", + "xml-naming": "^0.1.0" + } + }, + "node_modules/fast-xml-parser": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.7.3.tgz", + "integrity": "sha512-C0AaNuC+mscy6vrAQKAc/rMq+zAPHodfHGZu4sGVehvAQt/JLG1O5zEcYcXSY5zSqr4YVgxsB+pHXTq0i7eDlg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "@nodable/entities": "^2.1.0", + "fast-xml-builder": "^1.1.7", + "path-expression-matcher": "^1.5.0", + "strnum": "^2.2.3" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/https-proxy-agent/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/https-proxy-agent/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ioredis": { + "version": "5.10.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.10.1.tgz", + "integrity": "sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "1.5.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", + "license": "MIT" + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/msgpackr": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-2.0.1.tgz", + "integrity": "sha512-9J+tqTEsbHqY8YohazYgty7LgerFIWxvMLpUjqETSmjHojtJm2WnX2kK/2a1fLI7CO7ERP1YSEUXMucz4j+yBA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.4.tgz", + "integrity": "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" + } + }, + "node_modules/multer": { + "version": "1.4.5-lts.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz", + "integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==", + "deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.0.0", + "concat-stream": "^1.5.2", + "mkdirp": "^0.5.4", + "object-assign": "^4.1.1", + "type-is": "^1.6.4", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.1.1.tgz", + "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-expression-matcher": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.5.0.tgz", + "integrity": "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "license": "MIT", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.1.tgz", + "integrity": "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, + "node_modules/stream-browserify/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strnum": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.3.0.tgz", + "integrity": "sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/xml-naming": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.1.0.tgz", + "integrity": "sha512-k8KO9hrMyNk6tUWqUfkTEZbezRRpONVOzUTnc97VnCvyj6Tf9lyUR9EDAIeiVLv56jsMcoXEwjW8Kv5yPY52lw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + } + } +} diff --git a/services/mam-api/src/auth/rate-limit.js b/services/mam-api/src/auth/rate-limit.js index 5b81f2e..2f00449 100644 --- a/services/mam-api/src/auth/rate-limit.js +++ b/services/mam-api/src/auth/rate-limit.js @@ -3,6 +3,7 @@ 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) { @@ -11,6 +12,11 @@ export const ipBackoff = { 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); }, diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 6e2ac99..a5b9d99 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -276,6 +276,9 @@ const server = app.listen(PORT, () => { const authMode = process.env.AUTH_ENABLED === 'true' ? 'ENABLED' : 'DISABLED (set AUTH_ENABLED=true for production)'; console.log(`MAM API listening on port ${PORT}`); 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 // the loop's self-calls to /recorders/:id/start|stop reach a ready socket. startSchedulerLoop(); diff --git a/services/mam-api/test/routes/auth.test.js b/services/mam-api/test/routes/auth.test.js index 41af3f6..2404ae7 100644 --- a/services/mam-api/test/routes/auth.test.js +++ b/services/mam-api/test/routes/auth.test.js @@ -5,7 +5,7 @@ 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 } from '../../src/middleware/auth.js'; +import { requireAuth, requireUiHeader } from '../../src/middleware/auth.js'; async function appWithAuth(pool) { process.env.DATABASE_URL = process.env.TEST_DATABASE_URL; @@ -52,6 +52,7 @@ async function appWithSession(pool) { 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', () => { @@ -119,6 +120,7 @@ async function appWithSessionAndMe(pool) { 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 => { diff --git a/services/mam-api/test/routes/tokens.test.js b/services/mam-api/test/routes/tokens.test.js index a49b25c..a1f8a0c 100644 --- a/services/mam-api/test/routes/tokens.test.js +++ b/services/mam-api/test/routes/tokens.test.js @@ -5,7 +5,7 @@ import session from 'express-session'; import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; import tokensRouter from '../../src/routes/tokens.js'; import authRouter from '../../src/routes/auth.js'; -import { requireAuth } from '../../src/middleware/auth.js'; +import { requireAuth, requireUiHeader } from '../../src/middleware/auth.js'; import { hashPassword } from '../../src/auth/passwords.js'; async function app(pool) { @@ -20,6 +20,7 @@ async function app(pool) { 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 })); diff --git a/services/mam-api/test/routes/users.test.js b/services/mam-api/test/routes/users.test.js index dd9294d..aab1414 100644 --- a/services/mam-api/test/routes/users.test.js +++ b/services/mam-api/test/routes/users.test.js @@ -5,7 +5,7 @@ import session from 'express-session'; import { isTestDbConfigured, setupTestDb } from '../helpers/setup-db.js'; import usersRouter from '../../src/routes/users.js'; import authRouter from '../../src/routes/auth.js'; -import { requireAuth } from '../../src/middleware/auth.js'; +import { requireAuth, requireUiHeader } from '../../src/middleware/auth.js'; import { hashPassword } from '../../src/auth/passwords.js'; async function app(pool) { @@ -20,6 +20,7 @@ async function app(pool) { 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 => { From 7e240d86c81fde4538a663997b4fe0dd4eaeb408 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 15:08:14 -0400 Subject: [PATCH 25/29] feat(web-ui): AuthGate orchestration + replace 401 bounce with in-SPA re-render --- services/web-ui/public/app.jsx | 3 +- services/web-ui/public/auth-gate.jsx | 77 ++++++++++++++++++++++++++++ services/web-ui/public/data.jsx | 23 +++++---- services/web-ui/public/index.html | 2 + services/web-ui/public/shell.jsx | 2 +- 5 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 services/web-ui/public/auth-gate.jsx diff --git a/services/web-ui/public/app.jsx b/services/web-ui/public/app.jsx index 06e9121..93c7040 100644 --- a/services/web-ui/public/app.jsx +++ b/services/web-ui/public/app.jsx @@ -154,4 +154,5 @@ function lighten(hex, amt) { } const root = ReactDOM.createRoot(document.getElementById('root')); -root.render(); +const AuthGate = window.AuthGateComponent; +root.render(); diff --git a/services/web-ui/public/auth-gate.jsx b/services/web-ui/public/auth-gate.jsx new file mode 100644 index 0000000..2e9a0ca --- /dev/null +++ b/services/web-ui/public/auth-gate.jsx @@ -0,0 +1,77 @@ +// auth-gate.jsx — owns the "logged in or not" state. +// +// The SPA boots into , which calls GET /auth/me. On 401 it then +// calls GET /auth/setup-required and renders or +// (defined in screens-auth.jsx, Task 16). On 200 it renders the real . +// +// 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 ( +
+
Loading…
+
+ ); + } + 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; +})(); diff --git a/services/web-ui/public/data.jsx b/services/web-ui/public/data.jsx index 7842508..af3900c 100644 --- a/services/web-ui/public/data.jsx +++ b/services/web-ui/public/data.jsx @@ -62,19 +62,24 @@ window.ZAMPP_DATA = { }; async function apiFetch(path, opts = {}) { + const method = (opts.method || 'GET').toUpperCase(); + const headers = { + ...(opts.headers || {}), + 'Content-Type': 'application/json', + }; + if (method !== 'GET' && method !== 'HEAD') headers['X-Requested-With'] = 'dragonflight-ui'; + const res = await fetch(API + path, { credentials: 'include', ...opts, - headers: { ...(opts.headers || {}), 'Content-Type': 'application/json' }, + headers, }); - // 401 from any API call means there's no live session. Bounce to the - // login screen instead of leaving the app in a half-loaded state. - // While AUTH_ENABLED=false the server returns a synthetic /auth/me with - // 200 so this branch never fires; flipping AUTH_ENABLED=true is what - // activates the redirect end-to-end. - if (res.status === 401 && !location.pathname.endsWith('/login.html')) { - location.replace('/login.html'); - throw new Error('Unauthenticated — redirecting to login'); + // 401: hand off to AuthGate, which will re-render Login (no full-page reload). + if (res.status === 401) { + if (window.AuthGate && typeof window.AuthGate.bounce === 'function') { + window.AuthGate.bounce('apiFetch saw 401 on ' + path); + } + throw new Error('Unauthenticated'); } if (!res.ok) throw new Error(res.status + ' ' + res.statusText); return res.json(); diff --git a/services/web-ui/public/index.html b/services/web-ui/public/index.html index 5c8927e..cab074c 100644 --- a/services/web-ui/public/index.html +++ b/services/web-ui/public/index.html @@ -25,6 +25,8 @@ + + diff --git a/services/web-ui/public/shell.jsx b/services/web-ui/public/shell.jsx index 812cd8e..9ec2591 100644 --- a/services/web-ui/public/shell.jsx +++ b/services/web-ui/public/shell.jsx @@ -215,7 +215,7 @@ function Sidebar({ active, onNavigate, me, collapsed, onToggle }) { From cfe21e315e814a43e42efc4d62f6e4bac0ccb8f8 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 15:17:33 -0400 Subject: [PATCH 26/29] feat(web-ui): LoginScreen + SetupScreen (layout B from brainstorm) --- services/web-ui/public/screens-auth.jsx | 179 ++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 services/web-ui/public/screens-auth.jsx diff --git a/services/web-ui/public/screens-auth.jsx b/services/web-ui/public/screens-auth.jsx new file mode 100644 index 0000000..349330d --- /dev/null +++ b/services/web-ui/public/screens-auth.jsx @@ -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 ( +
+
Dragonflight
+
+ Wild Dragon Broadcast +
+
+ ); + } + + function Card({ children }) { + return ( +
{children}
+ ); + } + + function Field({ label, type = 'text', value, onChange, autoComplete, autoFocus }) { + return ( +
+ + onChange(e.target.value)} + style={{ + width: '100%', + background: 'var(--bg-3)', + border: '1px solid var(--border)', + borderRadius: 4, + padding: '8px 10px', + fontSize: 12.5, + color: 'var(--text-1)', + fontFamily: 'var(--font-mono)', + boxSizing: 'border-box', + }} + /> +
+ ); + } + + function Button({ children, disabled, onClick, type = 'button' }) { + return ( + + ); + } + + function ErrorRow({ text }) { + if (!text) return null; + return ( +
{text}
+ ); + } + + function Screen({ children }) { + return ( +
+ e.preventDefault()} style={{ width: 300 }}> + + {children} + +
+ ); + } + + function LoginScreen({ onDone }) { + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [error, setError] = React.useState(''); + const [busy, setBusy] = React.useState(false); + const submit = async () => { + setError(''); setBusy(true); + try { + const r = await postJson('/auth/login', { username, password }); + if (r.status === 200) { onDone(); return; } + const body = await r.json().catch(() => ({})); + setError(body.error || ('Login failed: ' + r.status)); + } catch (e) { setError(e.message || 'Login failed'); } + finally { setBusy(false); } + }; + return ( + + + + + + + ); + } + + function SetupScreen({ onDone }) { + const [username, setUsername] = React.useState(''); + const [password, setPassword] = React.useState(''); + const [confirm, setConfirm] = React.useState(''); + const [error, setError] = React.useState(''); + const [busy, setBusy] = React.useState(false); + const submit = async () => { + setError(''); + if (password !== confirm) { setError('Passwords do not match'); return; } + if (password.length < 12) { setError('Password must be at least 12 characters'); return; } + setBusy(true); + try { + const r = await postJson('/auth/setup', { username, password }); + if (r.status === 200) { onDone(); return; } + const body = await r.json().catch(() => ({})); + setError(body.error || ('Setup failed: ' + r.status)); + } catch (e) { setError(e.message || 'Setup failed'); } + finally { setBusy(false); } + }; + return ( + +
+ First-run setup — create the first admin +
+ + + + + +
+ ); + } + + window.LoginScreen = LoginScreen; + window.SetupScreen = SetupScreen; +})(); From 2aec4636cba789851454a1da7fbeaf41d5ff16eb Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 15:20:57 -0400 Subject: [PATCH 27/29] =?UTF-8?q?feat(web-ui):=20Settings=20=E2=86=92=20Ac?= =?UTF-8?q?count=20(change=20password)=20+=20API=20Tokens=20sections?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/web-ui/public/screens-admin.jsx | 135 ++++++++++++++++++++++- 1 file changed, 134 insertions(+), 1 deletion(-) diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 44ff897..72c10bf 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -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 ( +
+

Account

+
+ + setCurrent(e.target.value)} /> + + setNext(e.target.value)} /> + + setConfirm(e.target.value)} /> +
+ {msg && ( +
{msg.text}
+ )} + +
+ ); +} + +function ApiTokensSection() { + const [tokens, setTokens] = React.useState([]); + const [name, setName] = React.useState(''); + const [justCreated, setJustCreated] = React.useState(null); // { token, prefix, name } + const [busy, setBusy] = React.useState(false); + + const load = React.useCallback(async () => { + const r = await fetch('/api/v1/auth/tokens', { credentials: 'include' }); + if (r.status === 200) setTokens(await r.json()); + }, []); + + React.useEffect(() => { load(); }, [load]); + + const create = async () => { + if (!name.trim()) return; + setBusy(true); + try { + const r = await fetch('/api/v1/auth/tokens', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'dragonflight-ui' }, + body: JSON.stringify({ name: name.trim() }), + }); + if (r.status === 201) { + const created = await r.json(); + setJustCreated(created); + setName(''); + await load(); + } + } finally { setBusy(false); } + }; + + const revoke = async (id) => { + await fetch('/api/v1/auth/tokens/' + id, { + method: 'DELETE', + credentials: 'include', + headers: { 'X-Requested-With': 'dragonflight-ui' }, + }); + await load(); + }; + + return ( +
+

API Tokens

+ + {justCreated && ( +
+
+ Save this token now — it will not be shown again +
+
{justCreated.token}
+ + +
+ )} + +
+ setName(e.target.value)} style={{ flex: 1 }} /> + +
+ +
+ {tokens.length === 0 &&
No tokens yet.
} + {tokens.map(t => ( +
+
{t.name}
+
{t.prefix}…
+
{t.last_used_at ? new Date(t.last_used_at).toLocaleString() : 'never used'}
+ +
+ ))} +
+
+ ); +} + function Settings() { - const [section, setSection] = React.useState('storage'); + const [section, setSection] = React.useState('account'); const SECTIONS = [ + { id: 'account', label: 'Account', icon: 'user' }, { id: 'storage', label: 'Storage', icon: 'hdd' }, { id: 'proxy', label: 'Proxy encoding', icon: 'gpu' }, { id: 'sdk', label: 'Capture SDKs', icon: 'video' }, @@ -1435,6 +1562,12 @@ function Settings() { ))}
+ {section === 'account' && ( + <> + + + + )} {section === 'storage' && } {section === 'proxy' && } {section === 'sdk' && } From 8ede44ae871f7c53bdcb6a273e9202facf07c516 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 15:25:29 -0400 Subject: [PATCH 28/29] docs(auth): flip AUTH_ENABLED default + document setup + recovery --- .env.example | 16 ++++++++++-- README.md | 48 +++++++++++++++++++++++++++++++++++ services/mam-api/src/index.js | 2 +- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 8706ca3..b220868 100644 --- a/.env.example +++ b/.env.example @@ -22,5 +22,17 @@ SESSION_SECRET=changeme # MAM API Configuration MAM_API_URL=http://mam-api:3000 -# Auth (set to 'true' to require login; false for open/dev mode) -AUTH_ENABLED=false +# Auth — default to ON in production. Setting to 'false' is a dev-only escape +# hatch that disables all auth checks and attaches a synthetic 'dev' user to +# every request. Never run with AUTH_ENABLED=false on a network you don't control. +AUTH_ENABLED=true + +# CORS allowlist — comma-separated origins that may carry credentials to the API. +# Same-origin requests via the nginx reverse proxy do not need to be listed here. +# Leave empty to allow any origin (DEV ONLY). +ALLOWED_ORIGINS= + +# Reverse-proxy trust — set 'true' when the API sits behind nginx terminating HTTPS, +# so secure-cookie + X-Forwarded-Proto behave correctly. ALSO required for accurate +# per-IP login rate-limiting (otherwise req.ip is always the nginx IP). +TRUST_PROXY=false diff --git a/README.md b/README.md index e7a27f6..a7d4a06 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,54 @@ Total time from end of capture to relinked master: ~2 minutes. - `deploy/test-cluster.sh` — primary↔worker connectivity smoke test - `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 = '', 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 Proprietary — Wild Dragon LLC, all rights reserved. diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index a5b9d99..7539b44 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -273,7 +273,7 @@ setInterval(selfHeartbeat, 30_000); selfHeartbeat(); 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(`Authentication: ${authMode}`); if (process.env.AUTH_ENABLED === 'true' && process.env.TRUST_PROXY !== 'true') { From 03d0d098f574bcf1fd62be9976bcda7a966f5c2e Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Wed, 27 May 2026 15:42:42 -0400 Subject: [PATCH 29/29] =?UTF-8?q?fix(auth):=20final-review=20integration?= =?UTF-8?q?=20fixes=20=E2=80=94=20Users=20page=20alias=20+=20PATCH,=20CSRF?= =?UTF-8?q?=20on=20uploads=20+=20heartbeat,=20drop=20.bak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final-review findings: - Mount usersRouter at /api/v1/users in addition to /api/v1/auth/users so the existing SPA Users page works; add PATCH /:id for inline edits (display_name, role, password). - Add X-Requested-With: dragonflight-ui to raw XHR/fetch paths that bypass apiFetch (file uploads, SDK uploads, EDL export) — without it, requireUiHeader 403s before reaching the route. - Exempt SERVICE_PATHS (/cluster/heartbeat) from requireUiHeader so node-agent heartbeats keep working when NODE_TOKEN is unset. - Remove stale auth.js.bak. --- services/mam-api/src/index.js | 1 + services/mam-api/src/middleware/auth.js | 7 +++++++ services/mam-api/src/routes/users.js | 24 +++++++++++++++++++++++ services/web-ui/public/data.jsx | 1 + services/web-ui/public/screens-admin.jsx | 1 + services/web-ui/public/screens-ingest.jsx | 1 + 6 files changed, 35 insertions(+) diff --git a/services/mam-api/src/index.js b/services/mam-api/src/index.js index 7539b44..e0a94c8 100644 --- a/services/mam-api/src/index.js +++ b/services/mam-api/src/index.js @@ -111,6 +111,7 @@ app.use('/api/v1', (req, res, next) => { // ── 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/projects', projectsRouter); diff --git a/services/mam-api/src/middleware/auth.js b/services/mam-api/src/middleware/auth.js index 1d8cb3c..2e5707a 100644 --- a/services/mam-api/src/middleware/auth.js +++ b/services/mam-api/src/middleware/auth.js @@ -69,11 +69,18 @@ export async function requireAuth(req, res, next) { 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' }); } diff --git a/services/mam-api/src/routes/users.js b/services/mam-api/src/routes/users.js index 1803335..b28bcb5 100644 --- a/services/mam-api/src/routes/users.js +++ b/services/mam-api/src/routes/users.js @@ -69,4 +69,28 @@ router.delete('/:id', async (req, res, next) => { } 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; diff --git a/services/web-ui/public/data.jsx b/services/web-ui/public/data.jsx index af3900c..883cea4 100644 --- a/services/web-ui/public/data.jsx +++ b/services/web-ui/public/data.jsx @@ -308,6 +308,7 @@ async function exportSequenceEDL(sequenceId, filename) { const res = await fetch(API + '/sequences/' + sequenceId + '/export/edl', { method: 'POST', credentials: 'include', + headers: { 'X-Requested-With': 'dragonflight-ui' }, }); if (!res.ok) throw new Error('EDL export failed: ' + res.status); const blob = await res.blob(); diff --git a/services/web-ui/public/screens-admin.jsx b/services/web-ui/public/screens-admin.jsx index 72c10bf..3d6bddd 100644 --- a/services/web-ui/public/screens-admin.jsx +++ b/services/web-ui/public/screens-admin.jsx @@ -2063,6 +2063,7 @@ function SdkVendorRow({ vendor, status, onDone }) { await new Promise((resolve) => { const xhr = new XMLHttpRequest(); xhr.open('POST', (window.ZAMPP_API_PREFIX || '/api/v1') + '/sdk/' + vendor.id); + xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui'); xhr.withCredentials = true; xhr.upload.onprogress = (e) => { if (e.lengthComputable) setProgress(Math.round((e.loaded / e.total) * 100)); diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 60f772a..e68dc61 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -20,6 +20,7 @@ function _xhrPost(url, formData, onProgress) { }; xhr.onerror = () => reject(new Error('Network error')); xhr.open('POST', url); + xhr.setRequestHeader('X-Requested-With', 'dragonflight-ui'); xhr.send(formData); }); }