# Auth, Admin & Learning Hub — application logic

This is a deep walk-through of the three subsystems that own **identity**,
**control surface**, and **content authoring** for ped-ai. They share
infrastructure (the same `users` table, the same `app_settings` key/value
store, the same JWT cookie, the same audit/api/access log pipeline) and they
chain together: **auth** decides whether a request is allowed, **admin**
configures what is allowed at all, and the **Learning Hub** is the single
biggest user of admin-side controls (model allowlist, embeddings, prompts,
moderator role, optional Nextcloud).

The doc cites file paths and line numbers from the current tree at
`/home/danvics/docker/ped-ai`.

---

## 1. Overview

### What each layer owns

| Layer | Decides | Stored where | Consumed by |
|---|---|---|---|
| **Auth** | Who is `req.user`. Is the session still alive? Is local password / 2FA / SSO appropriate? | `users`, `user_sessions` (Postgres). JWT in `ped_auth` httpOnly cookie. | Every router behind `authMiddleware`. |
| **Admin** | What the operator has turned on: model allowlist, prompt overrides, feature flags, SMTP, OIDC config, milestones, registration open/closed. | `app_settings` (key/value text rows), env vars (read at boot), in-memory `PROMPTS` cache. | `callAI`, prompt loader, OIDC initiator, `/api/auth/register`, the entire frontend that gates UI on `feature.X`. |
| **Learning Hub** | What educational content exists; quiz authoring; AI-assisted generation; semantic search. | `learning_content`, `learning_categories`, `learning_questions`, `learning_options`, `learning_progress` plus `learning_content.embedding` (pgvector). | All authenticated users (read + take quiz). Moderators + admins (CMS write). |

### Why they're grouped in one doc

They sit on top of the **same primitives**:

- **`req.user`** is set by `src/middleware/auth.js:22-110` and is the *only*
  way any of these subsystems learn who is talking. There is no parallel
  identity path; the OIDC callback finishes by issuing the same JWT into the
  same cookie via `src/routes/oidc.js:233-241`.
- **`app_settings`** is the universal admin store. Every admin knob — model
  allowlist (`models.disabled`), default model (`models.default`), prompt
  override (`prompt.<key>`), CMS flag (`feature.<name>`), SMTP creds
  (`smtp.host`/`smtp.user`/`smtp.pass`), OIDC config (`oidc.*`),
  announcement (`announcement.*`) — is just a row in this table. Even
  registration_enabled is here.
- **`audit_log`** captures every state transition from any of the three.
  Login? Audit. Admin edits a prompt? Audit. Moderator publishes a quiz?
  No — that one only writes a content row, by design (the row itself *is*
  the audit). But anything that mutates security or system config gets a
  row written via `logger.audit(...)` (src/utils/logger.js:52-78).

Reading order for a brand-new contributor: `src/middleware/auth.js` first
(everything else is gated by it), then `src/routes/auth.js` for the
end-user-facing flows, then `src/routes/admin.js` + `adminConfig.js` for the
operator surface, then the Learning Hub trio
(`learningHub.js` / `learningAdmin.js` / `learningAI.js`).

---

## 2. Auth flow — local accounts

### 2.1 Registration

`POST /api/auth/register` lives at `src/routes/auth.js:177-258`. The order
of the checks is load-bearing:

1. **Registration enabled?** Read from `app_settings` row
   `registration_enabled`. If string `'false'`, returns 403. This row is
   toggled by admins from the panel
   (`src/routes/admin.js:180-191`, key `registration_enabled`).
2. **Required fields + minimum length** (8 chars).
3. **Cloudflare Turnstile** (only if `TURNSTILE_SECRET_KEY` is set;
   `auth.js:189-201`). Token is verified server-side via the
   `challenges.cloudflare.com/turnstile/v0/siteverify` endpoint with
   `remoteip: req.ip`. Failure returns 400 — generic message, no leak.
4. **Email uniqueness** check (`SELECT id FROM users WHERE email = ?`,
   lowercased).
5. **Password hash** via `passwords.hash(password)` —
   `src/utils/passwords.js:28-31`. Argon2id is preferred (memory 19 MiB,
   time 2, parallelism 1 = OWASP 2023 recommendation,
   `passwords.js:18-23`). bcrypt(12) is the fallback if the optional
   `argon2` module isn't installed (`passwords.js:9-15`).
6. **Verification token**: 32 random bytes, hex-encoded, 24 h expiry
   (`auth.js:207-208`).
7. **Auto-admin for first user**: if `COUNT(*) FROM users == 0`, role is
   set to `'admin'` (`auth.js:210-211`). Subsequent users default to
   `'user'`. The third role, `'moderator'`, can only be assigned by an
   existing admin via `POST /api/admin/users/:id/role`
   (`src/routes/admin.js:109-132`).
8. **INSERT user, send verify email** (Nodemailer, see §14).
9. **SMTP-not-configured fallback** (`auth.js:236-251`): if no SMTP host
   is set, the user is auto-verified, a JWT is signed *immediately*, and a
   `user_sessions` row is created. This keeps a fresh dev install usable
   without the operator having to wire SMTP first.

The `audit_log` row is written with action `'register'` and details noting
whether the user was auto-promoted to admin. `notifyNewRegistration` (ntfy,
admin topic) fires fire-and-forget (`src/utils/notify.js:87-92`).

### 2.2 Email verification

Cleared by clicking the link from the verify email. `GET
/api/auth/verify-email?token=...` (`auth.js:263-275`) checks
`verify_token = ? AND verify_expires > ?`. Success page sets
`email_verified = true`, nulls the token columns, writes `email_verified`
audit, returns plain HTML (intentionally — this URL is opened in the user's
default browser, not via the app, so no JSON).

`POST /api/auth/resend-verification` (`auth.js:278-295`) regenerates the
token + email. The response is uniform whether the email exists or not
("If account exists, verification email sent") — partial enumeration
defense.

### 2.3 Password rules

Source of truth is `src/utils/passwords.js`. Three things to know:

1. **Length only** is enforced server-side (>= 8 chars, in
   `auth.js:186`). No complexity rules. Rationale: NIST SP 800-63B no
   longer recommends complexity.
2. **HIBP check** (`auth.js:17-30`) is **advisory**, not blocking. The
   server fetches `https://api.pwnedpasswords.com/range/<sha1-prefix-5>`
   (k-anonymity — only 5 hex chars of SHA-1 leave the box) and returns the
   breach count to the client. `/api/auth/check-password` is a public
   endpoint the registration form hits before submit. On reset/change, if
   the new password is breached, the request still **succeeds** but the
   response includes a `passwordWarning` field
   (`auth.js:599-606`, `auth.js:646-661`).
3. **Hash migration is transparent**. On any successful login against a
   bcrypt hash, `passwords.maybeRehash` returns a fresh argon2id hash, and
   the user row is updated in the background (`auth.js:341-344`). Users
   migrate forward without any action.

### 2.4 Login

`POST /api/auth/login` (`src/routes/auth.js:300-392`).

The order matters because **the goal is timing-equal failure for
unknown-email vs wrong-password vs disabled**:

1. Required fields.
2. Turnstile (if configured).
3. `SELECT * FROM users WHERE email = ?`.
4. **If no user**: still run a `bcrypt.compare(password, DUMMY_HASH)` to
   keep wall-clock latency identical to the password-check branch
   (`auth.js:319-326`). Log to console (server-side only — no email in the
   log message — so an operator watching Grafana sees the lookup-miss rate
   without leaking which addresses exist). Return generic
   `"Invalid credentials"`.
5. **If user exists**: `passwords.verify(password, user.password)`
   handles both argon2 and bcrypt formats (`passwords.js:33-43`). Wrong
   password → audit `login_failed`, access log `login_failed`, return
   `"Invalid credentials"` (same string).
6. **Disabled account** → audit `login_blocked`, return
   `"Invalid credentials"` (same string).
7. **Email not verified** → 403 with `needsVerification: true` — this is
   the **one** non-uniform response, deliberately (UX > perfect
   indistinguishability per `docs/authentication.md:118-122`).
8. **2FA branch** (`auth.js:357-370`) — see §4.
9. **Sign JWT** via `signAuthToken(userId, req)` — 30 d for web, 365 d
   for mobile (`auth.js:57-60`). The mobile token sits in iOS Keychain /
   Android EncryptedSharedPreferences and is never refreshed; idle timeout
   is also disabled for mobile.
10. Insert `user_sessions` row keyed by SHA-256 hash of the JWT
    (`hashToken`, `src/utils/sessions.js:3-5`) — never the raw JWT, so
    even a leaked DB dump can't be turned into login tokens.
11. `notifyNewLogin` ntfy push (`utils/notify.js:63-68`) — fire and
    forget.
12. `setAuthCookie(res, token)` — httpOnly, sameSite=lax, secure when
    `NODE_ENV=production || APP_URL`, 30 d max age (`auth.js:43-52`).
13. JSON response: `{ success, token, sessionId, user: {...} }`.
    - **Web** ignores `token` (cookie does the work) but uses
      `sessionId` for the "current session" highlight in Settings.
    - **Mobile** stores `token` in SecureStorage and uses
      `Authorization: Bearer ...` going forward.

### 2.5 JWT structure

```json
{ "userId": 42, "iat": 1730000000, "exp": 1732592000 }
```

Signed HS256 with `JWT_SECRET` (`src/middleware/auth.js:13-20`). The
secret is **mandatory in production** — if `NODE_ENV=production` or
`APP_URL` is set and `JWT_SECRET` is missing, the process refuses to
start. In dev, a warning is printed and a literal fallback string is used.

Note what's *not* in the JWT: no role, no email, no permissions. The
middleware re-fetches the user row on every authenticated request
(`middleware/auth.js:39`). That means an admin demotion or account disable
takes effect on the very next request — no waiting for the JWT to expire.

### 2.6 Cookie vs Bearer

Set in `src/middleware/auth.js:22-32`:

```js
var authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
  token = authHeader.substring(7) || null;   // empty Bearer → null, fall through
}
if (!token && req.cookies && req.cookies.ped_auth) {
  token = req.cookies.ped_auth;
}
```

Two subtleties:

- **Bearer is checked first**. A native app sending both (cookie from a
  prior browser session in WebView storage + a Bearer header from
  Keychain) will use the Bearer.
- The `|| null` after `substring(7)` is intentional. Some clients
  unconditionally emit `Authorization: Bearer ` with an empty value; that
  shouldn't lock them out of the cookie path.

### 2.7 Rate limits (set in `server.js:95-137`)

| Endpoint | Window | Max | Configurable via |
|---|---|---|---|
| `/api/*` general | 60 s | 200 | `API_RATE_LIMIT_MAX` env |
| `/api/auth/login` | 15 min | 10 | `LOGIN_RATE_LIMIT_MAX` env |
| `/api/auth/register` | 60 min | 5 | hardcoded |
| `/api/auth/forgot-password` | 60 min | 5 | hardcoded |
| `/api/auth/resend-verification` | 15 min | 3 | hardcoded |
| `/api/auth/change-password`, `/setup-2fa`, `/verify-2fa`, `/disable-2fa` | 15 min | 20 | hardcoded |

All keyed by IP (express-rate-limit defaults). `app.set('trust proxy', 1)`
at `server.js:16` is required for these to see the real client IP behind
Caddy/Nginx. The `LOGIN_RATE_LIMIT_MAX` and `API_RATE_LIMIT_MAX` env
overrides exist specifically so the e2e Playwright container can run many
parallel workers without tripping production-grade limits.

---

## 3. Auth flow — OIDC SSO

`src/routes/oidc.js` implements **Authorization Code + PKCE** with
`openid-client` v6. Unlike the registration/login flow, OIDC config is
admin-controlled at runtime (no env vars required, although they're
honoured if present).

### 3.1 The dance, in order

1. **GET `/api/auth/oidc-status`** (`oidc.js:80-93`, public).
   Returns `{ oidcEnabled, disableLocalAuth, buttonLabel }`. Frontend
   `auth.js:88-106` calls this on the login screen to decide whether to
   show the SSO button and whether to hide the email/password fields.

2. **GET `/api/auth/oidc`** (`oidc.js:96-143`, public).
   - Read `oidc.enabled` / `oidc.issuer` / `oidc.client_id` from
     `app_settings`.
   - **`assertSafeIssuer(issuer)`** (`oidc.js:40-47`):
     - Issuer URL must be `https://`.
     - DNS-resolve hostname; **all** resolved addresses must be public
       (not private 10/8, 127/8, 169.254/16 link-local, 172.16/12,
       192.168/16, multicast, IPv6 loopback or unique-local).
     - Defends against an admin (or compromised admin account) pointing
       OIDC at AWS metadata at `http://169.254.169.254/`, container
       sidecars, etc.
   - `oidc.discovery(new URL(issuer), clientId)` fetches the IdP
     metadata.
   - Generate `nonce` (24 random bytes), `codeVerifier` (PKCE), and
     compute `codeChallenge = S256(codeVerifier)`.
   - **State is signed, not stored.** `signState({ n, v, expires })`
     (`oidc.js:50-54`) packs nonce + verifier + 5-min expiry into a base64url
     payload and HMAC-SHA256-signs it with `JWT_SECRET`. This means the
     OIDC flow survives process restarts and works horizontally across
     replicas — there's no per-process map of pending states. Verified
     on the way back via `verifyState` with `crypto.timingSafeEqual`
     (`oidc.js:55-66`).
   - Redirect the user to the IdP's authorization URL.

3. **IdP authenticates the user, redirects back to**
   **`/api/auth/oidc/callback?code=...&state=...`** (`oidc.js:146-252`).
   - `verifyState(state)` — rejects expired or tampered tokens.
   - `assertSafeIssuer` runs again (defense in depth — if the operator
     swapped the issuer between init and callback, the SSRF check still
     blocks).
   - `oidc.authorizationCodeGrant(...)` exchanges the code for tokens,
     verifying nonce + state + PKCE verifier match what we signed.
   - Pull `sub`, `email`, `email_verified`, `name` from claims (or from
     userinfo endpoint as fallback if email isn't in the ID token).

### 3.2 Linking & auto-creation

Three branches in `oidc.js:193-230`:

| Existing `users` row by email | `oidc_sub` set? | What happens |
|---|---|---|
| Yes | matches | Just log them in. |
| Yes | empty | **Auto-link** only if IdP asserts `email_verified === true`. Otherwise redirect with `?error=email_unverified`. Audit `oidc_linked`. |
| Yes | mismatch | Refuse with `?error=sub_mismatch`. Different IdP identity claiming the same email = tell the admin. |
| No | — | **Auto-create** new user row. First-ever user (count == 0) becomes admin. Password column gets 32 random bytes hex (so `hasLocalPassword()` is false → password change / 2FA UI hidden). `email_verified=true`, `oidc_sub` set. |

Disabled accounts are blocked (`?error=disabled`).

### 3.3 Issuing the session

After the user object exists, the callback signs a JWT identical to the
local-login one (30 d web / 365 d mobile), sets the same `ped_auth`
cookie, inserts a `user_sessions` row, writes `login_oidc` audit, and
**redirects back to `/?sso=ok&sid=<sessionId>`**. The frontend
`public/js/auth.js:53-71` notices `?sso=ok` on boot, calls
`/api/auth/me` (cookie sent automatically), and enters the app. The
sessionId is only persisted in mobile SecureStorage (web doesn't need
it — the cookie is enough for auth, and the "current session" highlight
in Settings reads `sessionId` server-side via `req.sessionId`).

### 3.4 SSO-only accounts

A user created via OIDC has a random hex blob in `users.password`, not a
hash. Two server functions detect this and refuse to operate:

- `hasLocalPassword(hash)` (`auth.js:628-630`) — returns true only for
  `$2[aby]$...` (bcrypt) or `$argon2...`.
- `/setup-2fa` (`auth.js:496-509`) and `/change-password`
  (`auth.js:633-663`) both early-return with
  `"This account uses SSO. ... managed by your identity provider."`.

The frontend mirrors this with `canLocalAuth` returned from
`GET /api/auth/me` (`auth.js:666-682`). The 2FA section, password change
section, and active sessions section are hidden in Settings when this
flag is false.

---

## 4. Two-factor authentication

TOTP only (no SMS, no email codes). Implemented with **speakeasy**
(`src/routes/auth.js:7`). 30-second step, ±1-step verification window.

### 4.1 Setup flow

`POST /api/auth/setup-2fa` (`auth.js:496-509`):

1. Block SSO-only accounts (per §3.4).
2. `speakeasy.generateSecret({ name: 'PedScribe (' + email + ')',
   issuer: 'Pediatric AI Scribe' })` → returns `base32` secret +
   `otpauth_url`.
3. **Save the secret** to `users.totp_secret` immediately (so the user
   can come back tomorrow and complete verification with the same
   secret) — but `totp_enabled` stays false until verification succeeds.
4. `QRCode.toDataURL(otpauth_url)` generates a data: URI for inline
   `<img>` display.
5. Response: `{ secret: 'BASE32...', qrCode: 'data:image/png;base64,...' }`.

The frontend (`public/js/auth.js:329-343`) drops the data URI into
`<img id="2fa-qr">` and shows the secret in plain text for users whose
authenticator app doesn't scan QRs (rare, but it happens).

### 4.2 Verify (and first-enable backup-code generation)

`POST /api/auth/verify-2fa` (`auth.js:511-529`):

- `speakeasy.totp.verify({ secret, encoding: 'base32', token: code,
  window: 1 })` — accepts current step or ±1 step.
- **First time enabling**, generate **10 backup codes** (`auth.js:397-409`):
  - Alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` — no `0/O/1/I` to avoid
    "is this a zero or an O" support tickets.
  - Format: `XXXXX-XXXXX` (10 chars + dash for readability).
  - bcrypt-hashed at cost 10 and stored as JSON array in
    `users.totp_backup_codes`.
  - **Returned plaintext exactly once**, in the verify response. Frontend
    pops a modal listing them, and the user must save them.
- Set `totp_enabled = true`.

### 4.3 Backup-code consumption (atomic!)

`tryConsumeBackupCode(userId, submitted)` (`auth.js:419-461`) is the
*only* part of auth that explicitly opens a transaction:

```js
await client.query('BEGIN');
var rowRes = await client.query(
  'SELECT totp_backup_codes FROM users WHERE id = $1 FOR UPDATE',
  [userId]
);
// ...bcrypt.compare each hash; if match, splice it out and UPDATE...
await client.query('COMMIT');
```

Without `FOR UPDATE`, two parallel logins with the same backup code could
both pass the `bcrypt.compare` and both succeed. The row lock funnels
them; the second attempt sees the already-spliced array. Audit
`2fa_backup_code_used` is written on consume.

### 4.4 Backup-code regeneration

`POST /api/auth/2fa/backup-codes` (`auth.js:464-481`) regenerates the
full set. Requires the user's current password to authorize (so a stolen
session cookie alone can't dump fresh codes). Returns plaintext once.
`GET /api/auth/2fa/backup-codes/count` (`auth.js:484-493`) returns just
the remaining count for the Settings UI badge.

### 4.5 Disable

`POST /api/auth/disable-2fa` (`auth.js:531-539`) requires current
password, then nulls `totp_secret`, `totp_backup_codes`, sets
`totp_enabled = false`.

---

## 5. Sessions

### 5.1 Schema

Effective `user_sessions` columns (read from the inserts):

| Column | Source | Used for |
|---|---|---|
| `id` (UUID) | `crypto.randomUUID()` (`utils/sessions.js:38-40`) | Stable session identifier returned to clients |
| `user_id` | FK to users | Lookup, revoke-all-but-this |
| `token_hash` | SHA-256 of JWT (`utils/sessions.js:3-5`) | Look up session from incoming request without storing the JWT itself |
| `ip_address` | `req.ip` | Display in Settings |
| `user_agent` | raw UA (255 char cap) | Audit / forensics |
| `device_label` | `parseUserAgent(ua)` (`utils/sessions.js:7-36`) | Friendly label like "Chrome on macOS" or "PedScribe (Android)" |
| `created_at`, `last_activity` | timestamps | Sliding idle timeout, sort order |

The token is **never stored**. A leaked DB dump cannot be replayed
because `SHA-256(jwt)` is one-way.

### 5.2 The middleware loop, in detail

`src/middleware/auth.js:22-110` does this on every authenticated request:

1. Pick token from Bearer or cookie (per §2.6).
2. `jwt.verify(token, JWT_SECRET)` — invalid/expired → 401
   `"Invalid or expired token"`.
3. `SELECT id, email, name, role, totp_enabled, disabled FROM users
   WHERE id = ?` — sets `req.user`.
4. **Disabled?** 403 `"Account disabled. Contact administrator."`.
5. **Session lookup**: `SELECT id, last_activity FROM user_sessions
   WHERE token_hash = ?`.
   - **If no session and the user has *any* other sessions** → 401
     `"Session revoked"`. (Admin force-logout, password change, or user
     "Revoke" from Settings.)
   - **If no session at all** → fail open. Pre-migration users from before
     the `user_sessions` table existed would be locked out otherwise.
6. **Sliding idle timeout** (web only):
   - `idleMs = NOW() - last_activity`.
   - If `!mobile && idleMs > 24h`: write `session_idle_timeout` audit
     with the minute count, delete the row, clear cookie, return 401
     `{ error: 'Session expired due to inactivity', idleTimeout: true }`.
     The frontend `authFetch.js` interceptor will catch this and bounce
     to login.
7. **Activity refresh** (`middleware/auth.js:85-100`):
   - **Only on POST/PUT/DELETE/PATCH** — GET requests do not extend the
     session. This is intentional. Without it, a polling dashboard or
     `/api/auth/me` heartbeat would keep a session alive forever.
   - **Throttled to once per 10 min** to avoid hammering the DB on
     burst traffic.
   - On refresh, also re-set the cookie with a fresh 30 d maxAge so the
     browser-side cookie slides too.

### 5.3 The Sessions API

`src/routes/sessions.js` (entire file is 53 lines):

| Method | Path | Purpose |
|---|---|---|
| GET | `/api/sessions` | List all sessions for `req.user.id` plus `currentSessionId`. The frontend marks the current row "this device". |
| DELETE | `/api/sessions/:id` | Revoke one specific session. Refuses to revoke `req.sessionId` (the current one) — the user must use logout for that. |
| DELETE | `/api/sessions` | Revoke all *other* sessions (keeps `req.sessionId`). Also fires automatically inside `change-password` (`auth.js:651-653`). |

### 5.4 Logout

`POST /api/auth/logout` (`auth.js:611-623`) deletes the row matching the
incoming token's hash, clears the cookie. The frontend additionally:

- Broadcasts `{ type: 'logout' }` on `BroadcastChannel('pedscribe-auth')`
  so other open tabs immediately drop their UI
  (`public/js/auth.js:213-217`, `public/js/authFetch.js:18-30`).
- Clears every `caches.delete()` entry so the next person on a shared
  device can't read cached pages from the service worker
  (`public/js/auth.js:251-257`).

---

## 6. authMiddleware — the contract

Three functions exported from `src/middleware/auth.js:128`:

```js
module.exports = { authMiddleware, adminMiddleware, moderatorMiddleware, JWT_SECRET };
```

Usage pattern in routers:

| Pattern | Example | Effect |
|---|---|---|
| `router.use(authMiddleware)` at the top | `src/routes/sessions.js:6` | Every route in this file requires a valid session. |
| `router.use(authMiddleware); router.use(adminMiddleware)` | `src/routes/admin.js:13-14` | Admin-only file. |
| Per-route | `router.post('/setup-2fa', authMiddleware, async (req, res) => ...)` | When some routes in the file are public (e.g. `/check-password`, `/forgot-password`). |

Public surface (no `authMiddleware` mounted): `/api/auth/login`,
`/register`, `/forgot-password`, `/reset-password`, `/verify-email`,
`/resend-verification`, `/check-password`, `/registration-status`,
`/oidc-status`, `/oidc`, `/oidc/callback`, `/api/health`,
`/api/build`, `/api/models`. Everything else is gated.

Failure modes (consistent across the codebase):

| Status | Body | When |
|---|---|---|
| 401 | `{ error: 'Authentication required' }` | No token at all |
| 401 | `{ error: 'Invalid or expired token' }` | jwt.verify threw |
| 401 | `{ error: 'User not found' }` | Token decoded, user row missing (deleted while session was alive) |
| 401 | `{ error: 'Session revoked' }` | Token decoded, but no `user_sessions` row and the user has other sessions (so it's not the pre-migration case) |
| 401 | `{ error: 'Session expired due to inactivity', idleTimeout: true }` | Web-only, > 24 h idle |
| 403 | `{ error: 'Account disabled. Contact administrator.' }` | `users.disabled = true` |
| 403 | `{ error: 'Admin access required' }` | adminMiddleware hit |
| 403 | `{ error: 'Moderator or admin access required' }` | moderatorMiddleware hit |

`req.user` after success is `{ id, email, name, role, totp_enabled,
disabled }` — minimal, no PHI, no password hash. `req.sessionId` is set
when a `user_sessions` row matched.

---

## 7. OpenBao secret loading

`docker-entrypoint.sh` is the Dockerfile `ENTRYPOINT`. It runs **before**
Node, with the goal of populating `process.env` from a real secret store
instead of from a `.env` file in the image or on the host.

### 7.1 Behaviour

```sh
if [ -n "${OPENBAO_ADDR:-}" ]; then
  # ... fetch from OpenBao ...
else
  echo "[entrypoint] OPENBAO_ADDR not set — using existing environment (legacy .env path)"
fi
exec "$@"
```

If `OPENBAO_ADDR` is unset (e.g., e2e container, local dev with a
populated `.env`), the script no-ops and `exec`s straight to Node. **This
is the "no production change without explicit opt-in" guarantee.**

### 7.2 The opt-in path (`docker-entrypoint.sh:14-77`)

1. **Require AppRole credentials**. `OPENBAO_ROLE_ID` and
   `OPENBAO_SECRET_ID` are mandatory; missing → fatal exit.
2. **Authenticate** via `bao write -field=token auth/approle/login`.
   The returned client token is exported as `BAO_TOKEN` (the standard
   variable the `bao` CLI reads).
3. **Fetch** `kv/ped-ai/prod` (or whatever `OPENBAO_KV_PATH` points to).
   `bao kv get -format=json | jq -c '.data.data'` produces a flat JSON
   object of `{ KEY: "value", ... }`.
4. **Snapshot the current env** (`env | cut -d= -f1 | sort -u`). Anything
   already set by docker (env_file, environment: block) wins over what
   OpenBao returns. This is critical: the e2e container sets
   `TURNSTILE_SECRET_KEY=""` and `SMTP_HOST=""` to *disable* those
   features in tests, and we don't want OpenBao silently re-enabling them.
5. **Iterate via temp file + `while read`** (not pipe-into-while —
   that runs the loop in a subshell and exports are lost). For each
   key: if not in the snapshot, `eval "export $K=$VAL_QUOTED"`.
   `jq -r 'to_entries[] | "\(.key)\t\(.value | @sh)"'` makes the values
   shell-safe.
6. **Wipe the bootstrap creds**: `unset OPENBAO_ROLE_ID OPENBAO_SECRET_ID
   BAO_TOKEN`. The Node process never sees them — if it gets popped, the
   attacker can't pivot to OpenBao with what's in env.
7. `exec "$@"` hands control to whatever `CMD` the Dockerfile set
   (typically `node server.js`).

### 7.3 Why this matters

- **Production secrets aren't on the host filesystem.** No `.env` to leak
  via a backup, a snapshot, or `docker cp`. The image is pristine; the
  secrets live only in the running process's `/proc/<pid>/environ`.
- **Rotation without rebuilds.** Update the OpenBao value, restart the
  container, done.
- **AppRole > root token.** AppRole policies can be scoped to read-only
  on exactly one path. A compromised container reads one secret bundle
  and nothing else.
- **The OpenBao service itself** lives at `app.danvics.com` (per
  `MEMORY.md` → `project_secret_management.md`). Vaultwarden is for
  human logins; OpenBao is for service secrets.

---

## 8. Admin panel architecture

`public/js/admin.js` is ~1,500 lines, but it's organized as **six
parallel IIFEs**, each owning a distinct admin section. Every IIFE
listens for `tabChanged` to lazy-load and uses event-delegation on
`document.click` to handle its buttons. State is kept module-private,
no global pollution.

### 8.1 The six sections

| Lines | IIFE | What it does |
|---|---|---|
| `1-261` | **Users + global stats** | Fetches `/api/admin/users`, renders the table with verify / disable / role-change / delete / reset-password buttons. Pulls `/api/admin/settings` for `totalUsers`, `totalApiCalls`, `todayApiCalls`. Renders the audit-log viewer (`/api/admin/logs/all`). Shows the registration toggle. |
| `263-660` | **CMS** (announcements, feature flags, prompts, email, SMTP, OIDC) | Renders `/api/admin/config/prompts` for the prompt-editor section, `app_settings` rows starting with `feature.` / `announcement.` / `email.` for the rest. Each input PUTs to `/api/admin/config/:key`. |
| `662-1002` | **Model management** | Calls `/api/admin/config/models` to show the provider's built-in list with checkboxes (toggle calls `/api/admin/config/models/toggle`). The "Discover" button hits `/api/admin/config/models/discover` to query the live provider for new models. The "Test" button posts a 1-token "Reply with only the word: OK" to `/api/admin/config/models/test`. Custom-add form posts to `/api/admin/config/models/custom`. |
| `1004-1186` | **TTS management** | `/api/admin/config/tts` for status + DB-stored voice/model. Discover + Test analogous to model section. Plays returned base64 audio inline. |
| `1188-1376` | **STT management** | `/api/admin/config/stt`. The Test button records mic audio (MediaRecorder), POSTs base64 to `/api/admin/config/stt/test`, displays the transcript and round-trip latency. |
| `1378-1533` | **Embeddings** | `/api/admin/config/embeddings` for the model + dimensions setting. Test endpoint embeds a sample string and returns the first 8 vector values + duration. |

There is no separate admin HTML route — it's a tab inside the main SPA
(`public/index.html`), shown only when `user.role === 'admin'`
(toggle in `public/js/auth.js:178-182`). The tab content lives inline
in `index.html` and is populated by these IIFEs on first activation.

### 8.2 Server-side gating

Every endpoint above is mounted under `/api/admin` and the routers all
do `router.use(authMiddleware); router.use(adminMiddleware)` at the top
(`src/routes/admin.js:13-14`, `adminConfig.js:13` + `:30`,
`adminMilestones.js` uses `adminMiddleware` per-route). The frontend
visibility is purely cosmetic — a non-admin who manually fetches
`/api/admin/users` gets `403 { error: 'Admin access required' }`.

### 8.3 Audit on every mutation

All admin write endpoints write `logger.audit(req.user.id,
'admin_<action>', '<details>', req, { category: 'admin' })`. Examples
from the codebase:

- `admin_verify_user`, `admin_disable_user`, `admin_enable_user`
  (`admin.js:54-104`)
- `admin_change_role`, `admin_delete_user`, `admin_reset_password`
  (`admin.js:109-175`)
- `admin_toggle_registration` (`admin.js:180-191`)
- `admin_config_update`, `admin_config_reset_all`,
  `admin_config_reset` (`adminConfig.js:114, 140, 981`)
- `admin_model_toggle`, `admin_model_default`, `admin_model_add`,
  `admin_model_update`, `admin_model_delete`,
  `admin_models_clear_all`, `admin_model_discover_add`
  (`adminConfig.js:256-329`)
- `admin_smtp_update`, `admin_smtp_clear` (`adminConfig.js:181-194`)

These rows answer the question "who turned off Turnstile last
Wednesday?" in a single SQL query against `audit_log WHERE category =
'admin'`.

### 8.4 Milestones editor

`src/routes/adminMilestones.js` is the simplest of the admin routes —
plain CRUD on `developmental_milestones (age_group, domain,
milestone_text, sort_order)`. The `bulk-import` endpoint accepts an
array of rows + a `clearExisting` flag for re-seeding from the static
data files. There's no audit on these because the table is the audit
(it's small enough to diff). The frontend admin UI for this is in the
"Milestones" sub-tab of the admin panel.

---

## 9. The model allowlist

This is the single most security-relevant admin control because it
gates *spend*. Without it, any authenticated user could POST
`{ model: "openai/o1-pro" }` to any AI route and bill the operator for
$60/M tokens.

### 9.1 Two gates

**Gate 1 (UI):** `/api/models` (`server.js:242-252`, **public** by
design — frontend dropdowns need it before login on the auth screen for
some pages, and the response contains no secrets) returns
`getAvailableModelsWithOverrides(db)`. That function
(`src/utils/models.js:203-223`) starts from the provider's built-in
list, filters out everything in the `models.disabled` array, and
appends anything in the `models.custom` array. So the admin can both
*hide* built-ins they don't want users picking and *add* models the
provider supports but aren't in the hardcoded list (e.g. a fresh
OpenRouter release).

**Gate 2 (server):** `callAI(messages, options)`
(`src/utils/ai.js:401-431`) re-checks the requested model against
`getAllowedModelIds(db)` (`models.js:233-249`, 60 s cache). If a model
ID is provided and isn't in the allowed Set, the call throws
`Error('Model not permitted')` with `code: 'model_not_permitted'`.
**A client cannot bypass the dropdown by hand-crafting a request body** —
even if they snoop the API and find a model ID, the server refuses it.

### 9.2 Cache semantics

The 60 s TTL on `ALLOWED_MODELS_CACHE` (`models.js:229-249`) means an
admin toggle propagates within a minute. There's a manual
`invalidateAllowedModelsCache()` exported but currently nothing in the
admin routes calls it — toggle latency is just the TTL. Acceptable for
an operator-facing change.

### 9.3 The `skipAllowlistCheck` escape hatch

`adminConfig.js:374-395` (`/api/admin/config/models/test`) and a few
other admin test endpoints pass `skipAllowlistCheck: true` so the admin
can verify a model *before* adding it to the allowlist. This option is
**not exposed** to user-facing routes. It is also not in the list of
keys accepted via `/api/models` config wildcards (§10) — only the admin
path can set it, and it's a runtime option object, not a setting.

### 9.4 Default model

`models.default` in `app_settings` overrides `DEFAULT_MODEL` for any
caller that doesn't pass an explicit `model` option. Set via
`PUT /api/admin/config/models/default` (`adminConfig.js:262-270`). The
`/api/models` endpoint includes `defaultModel` in its response so the
frontend can pre-select it in dropdowns.

---

## 10. Prompt overrides

`src/utils/prompts.js` is a single object (`PROMPTS`) holding every
LLM system prompt the app uses (HPI, hospital course, SOAP refine,
chart-review summary, etc.). Hardcoded values live at the top of the
file (~530 lines of prompt text) so a deploy can ship sane defaults.

### 10.1 Override mechanism

At server boot (`server.js:330-332`):

```js
const PROMPTS = require('./src/utils/prompts');
const db = require('./src/db/database');
setTimeout(() => { PROMPTS.loadFromDb(db); }, 3000);
```

The 3 s delay gives the Postgres pool time to come up. `loadFromDb`
(`prompts.js:535-547`) iterates every key in `PROMPTS`, reads
`app_settings` row `prompt.<key>`, and if non-empty, replaces
`PROMPTS[key]` in memory.

Routes use `require('../utils/prompts').hpiEncounter` synchronously —
they never know whether they're getting the hardcoded value or the
DB override. That makes overrides **transparent to route code**. No
async cost per request.

### 10.2 Live edit

When an admin saves a prompt via `PUT /api/admin/config/prompt.<key>`
(falls into the wildcard at `adminConfig.js:957-985`), the handler
both writes to `app_settings` and immediately calls
`PROMPTS.updatePrompt(promptKey, value)` (`prompts.js:549-553`) to
update the in-memory copy. **No restart required.** The next AI call
picks it up on the same request.

### 10.3 Reset to default

`POST /api/admin/config/prompts/:key/reset` (`adminConfig.js:101-117`):

1. DELETE the `prompt.<key>` row from `app_settings`.
2. `delete require.cache[require.resolve('../utils/prompts')]` to
   evict the cached module.
3. `var fresh = require('../utils/prompts')` re-loads the file (which
   contains the hardcoded defaults).
4. Copy `fresh[key]` back into the live `PROMPTS[key]`.

This avoids needing a separate "factory defaults" map.

---

## 11. Per-user preferences

`src/routes/userPreferences.js` is mounted at `/api/user`
(`server.js:302`). Two columns on `users` back this:

- `users.stt_model` — preferred speech-to-text model (string)
- `users.tts_voice` — preferred text-to-speech voice (string)

`webdav_learning_path` and Nextcloud columns are also per-user but
managed elsewhere (`src/routes/nextcloud.js`, plus the
`POST /api/user/webdav-path` shortcut at `server.js:309-314`).

### 11.1 Endpoints

| Method | Path | Returns / accepts |
|---|---|---|
| GET | `/api/user/preferences` | `{ stt_model, tts_voice }` (nullable) |
| POST | `/api/user/preferences` | `{ stt_model, tts_voice }` — both nullable, written verbatim |
| GET | `/api/user/preferences/options` | The full lists of available STT models and TTS voices for the *currently configured* providers (see `userPreferences.js:45-155`). The provider is auto-detected from env vars (Vertex, AWS, LiteLLM, OpenAI, ElevenLabs). |

### 11.2 Where they're consumed

- `src/routes/transcribe.js` reads `users.stt_model` to override the
  default Whisper/Gemini model when the user has set one.
- `src/routes/tts.js` reads `users.tts_voice` to pick the right voice.
- The Settings page (`public/components/settings.html`) pulls
  `/api/user/preferences/options`, renders dropdowns scoped to the
  active provider, and POSTs back on change.

### 11.3 Why per-user, not per-role

A pediatrician may prefer Gemini 2.0 Flash for speed; a colleague
might prefer Whisper-1 for accent robustness. There's no admin policy
restricting this — the admin chose which providers exist, the user
picks within that.

---

## 12. Learning Hub

Three backend files plus a single large frontend module
(`public/js/learningHub.js`, ~1,800 lines). The Learning Hub is the
**only feature in the app where a non-admin user (specifically: a
moderator) gets server-side write access**. That's why it has its own
middleware tier.

### 12.1 Architecture

```
                 ┌───────────────────────────────────┐
                 │    public/js/learningHub.js       │
                 │  ┌────────────┐  ┌─────────────┐  │
                 │  │ Reader UI  │  │  CMS UI     │  │
                 │  │ (any user) │  │ (mod+admin) │  │
                 │  └─────┬──────┘  └──────┬──────┘  │
                 └────────┼─────────────────┼────────┘
                          │                 │
              /api/learning/*       /api/admin/learning/*
                          │                 │
                          ▼                 ▼
       src/routes/learningHub.js   src/routes/learningAdmin.js
                                   src/routes/learningAI.js
                                            │
                              ┌─────────────┼──────────────┐
                              ▼             ▼              ▼
                       callAI() (model    pgvector      Marp / pptxgenjs
                       allowlist)        embeddings     (presentation)
```

### 12.2 Read paths (`src/routes/learningHub.js`)

Mounted at `/api/learning`, gated only by `authMiddleware`
(`learningHub.js:11`). Endpoints:

- `GET /categories` — list `learning_categories` ordered by
  `sort_order, name`.
- `GET /feed?limit&offset` — last N published items, with author + a
  question count subquery.
- `GET /category/:slug` — items within a category.
- `GET /content/:slug` — single item with all questions, all options
  (without `is_correct` so the client can't peek), and the user's last
  5 attempts from `learning_progress`.
- `GET /content/:slug/slides` — for `content_type = 'presentation'`,
  renders the Marp markdown to HTML server-side and returns an array of
  `<section>` elements + the Marp CSS for the slide-viewer modal in the
  reader UI.
- `POST /submit-quiz` — accepts `{ contentId, answers: [{ questionId,
  optionId | optionIds }] }` and grades. Supports both `mcq`
  (single-correct) and `multi` (all-and-only-correct, see
  `learningHub.js:178-193`). Score + total written to
  `learning_progress`.
- `GET /search?q=` — keyword `ILIKE '%q%'` against title/subject/body.
- `GET /search/semantic?q=` — pgvector cosine similarity via
  `searchSimilar` from `src/utils/embeddings.js`.
- `GET /search/hybrid?q=` — keyword + semantic merged, semantic first
  (higher precision), keyword as backfill.

### 12.3 Write paths (`src/routes/learningAdmin.js`)

Mounted at `/api/admin/learning`. **Note the mount order in
`server.js:234-236`** — the Learning Hub admin router is registered
*before* the general `/api/admin` router because moderators need
access to `/api/admin/learning/*` but should be rejected from other
`/api/admin/*` routes. Both routers technically match `/api/admin/...`
on the prefix; Express dispatches to the first one that has a matching
route. The Learning Hub router uses `moderatorMiddleware`, the general
admin router uses `adminMiddleware`.

CRUD endpoints (all `authMiddleware` + `moderatorMiddleware`):

- Categories: `GET /categories`, `POST`, `PUT /:id`, `DELETE /:id`
  (delete sets dependent content to `category_id = NULL` rather than
  cascading).
- Content: `GET /content`, `GET /content/:id`, `POST /content`,
  `PUT /content/:id`, `DELETE /content/:id`. POST and PUT both kick
  off **embedding generation in the background** (don't block the
  response — the editor flow stays snappy and the embedding is
  available within a few seconds).
- Questions: `POST /content/:contentId/questions`, `PUT /questions/:id`,
  `DELETE /questions/:id`. PUT replaces the full options array
  atomically (DELETE all, INSERT each).
- Stats: `GET /stats`.
- Embeddings management: `POST /embeddings/generate` (background batch
  for all unembedded content; auto-creates the `ivfflat` index when ≥10
  embeddings exist) and `GET /embeddings/status`.

### 12.4 AI generation (`src/routes/learningAI.js`)

This is where the Learning Hub gets interesting. Mounted at
`/api/admin/learning` (same mount), **also** moderator-gated. Three
endpoints:

#### `POST /ai-generate`

Accepts either `multipart/form-data` (with `files[]`) or JSON. The
moderator picks a content type — `article`, `pearl`, `quiz`, or
`presentation` — plus a topic, optional refinement instructions,
optional question count, optional word count / slide count, and
optionally a model.

Source material flows in through one of three channels:

1. **Uploaded files** (multer memory storage, 100 MB / file, 10 file
   max). `extractText` (`learningAI.js:48-133`) handles PDF
   (pdf-parse), DOCX (mammoth), PPTX (JSZip → strip XML tags), ODT,
   EPUB, plus plain text/markdown/HTML/CSV/JSON. Multiple files are
   concatenated with `### Source File: <name>` headers.
2. **Nextcloud WebDAV path** (`webdavPath` field). Pulls
   `nextcloud_token` from the user's row, decrypts via
   `cryptoUtil.decryptString` (AES-256-GCM, see authentication.md
   §"Encryption at rest"), HTTP-GETs the file from
   `<nextcloud_url>/remote.php/dav/files/<user>/<path>` with Basic
   auth, runs the same `extractText`.
3. **Topic-only** — no file, just a string. Falls back to a generic
   "Generate educational content on the following topic" prompt.

`buildGeneratePrompt` (`learningAI.js:137-237`) builds the system
prompt. Key constraints:

- Document text is truncated to 50,000 chars before injection (the LLM
  gets the first chunk, embeddings get the first 8,000 — see §12.5).
- For `presentation` with no questions: returns Marp markdown directly,
  no JSON wrapper.
- For `presentation` with questions: returns a JSON object with both
  `marpMarkdown` and `questions[]` so the editor can save both at once.
- For everything else: returns a strict JSON schema with `title`,
  `subject`, `body` (HTML, allowed tags only — `p, h2, h3, ul, ol, li,
  strong, em, blockquote, code`), and `questions[]`.
- Each MCQ must have exactly 4 options, exactly 1 correct.
- `question_type` must be `mcq` or `true_false`.

The call goes through `callAI` (`learningAI.js:303-309`), so the
**model allowlist applies here too**. A moderator who tries to pass a
disallowed model gets `Error('Model not permitted')`.

After the LLM returns, `learningAI.js:311-383` does aggressive
JSON-recovery: strip markdown fences, trim trailing commentary, then
sanitize any literal newlines/tabs that landed inside string values
(models occasionally do this), then retry. After 3 attempts it gives
up with `"AI returned invalid JSON. Try again."` — UX > silent
corruption.

#### `POST /ai-refine`

Takes existing HTML body + free-text instructions, asks the model to
return refined HTML only (no JSON wrapper). Used by the editor's
"Refine with AI" button to make targeted edits.

#### `GET /webdav-browse?path=...`

Issues a `PROPFIND` (Depth: 1) against the user's Nextcloud and
returns the directory listing as `{ path, parentPath, items: [{path,
name, isDir, contentType, size}] }`. The frontend uses this to render
a file picker before generation.

#### `POST /webdav-path`

Saves the user's "default WebDAV folder for learning content" so the
picker remembers where they like to browse from.

#### `POST /generate-pptx`

Takes Marp markdown, **converts to a real .pptx** server-side via
`pptxgenjs` (no headless browser required). The conversion
(`learningAI.js:529-777`) is hand-rolled: it parses each slide's
markdown line by line, classifies into bullet/ordered/heading/
paragraph/table/blockquote/code-fence, and renders each with
appropriate `pptx.addText`/`pptx.addTable`/`pptx.addShape` calls.
Inline `**bold**`, `*italic*`, `***both***`, and `` `code` `` are
parsed via `parseInline` into pptxgenjs text-run objects. Tables get
striped rows. Blockquotes get a blue left bar. Code blocks get a grey
rounded background.

#### `POST /preview-slides`

Server-side Marp render → array of `<section>` HTML strings + CSS, for
the in-browser preview before saving (or before downloading the
.pptx).

### 12.5 Embeddings

`src/utils/embeddings.js` (cited briefly per the brief). `generateEmbedding(text, opts)`:

1. Read `embeddings.model` and `embeddings.dimensions` from
   `app_settings` (admin-controlled), fall back to env vars, then
   built-in defaults (`vertex_ai/text-embedding-005`, 768 dims).
2. Truncate input to 8,000 chars (~2,000 tokens).
3. Try LiteLLM first (`generateEmbeddingLiteLLM`), then Vertex direct,
   then OpenAI. First configured wins.

`generateContentEmbedding({ title, subject, body })` concatenates the
three with separators and embeds the result. Stored as JSON-serialized
array in `learning_content.embedding` (a `vector` column when pgvector
is installed). The `ivfflat` index is created opportunistically
(`learningAdmin.js:373-383`) when ≥10 embeddings exist with
`lists = max(10, sqrt(count))`.

`searchSimilar(query, opts)` embeds the query and runs
`SELECT ... ORDER BY embedding <=> $1::vector LIMIT n` (cosine
distance). The hybrid search merges these with keyword results.

### 12.6 Output formats

| Type | What's saved | What's served |
|---|---|---|
| `article` | HTML in `body` | Renders inline in reader |
| `pearl` | HTML in `body` (shorter, 2-4 paragraphs) | Renders inline |
| `quiz` | HTML intro in `body`, questions in `learning_questions` | Reader shows intro then quiz UI |
| `presentation` | Marp markdown in `body` | Reader has a slide-viewer modal that renders via `marp-core` server-side and paginates `<section>` elements client-side. Optional `.pptx` download via `/generate-pptx`. |

### 12.7 Optional Nextcloud upload

After saving a presentation, the moderator can upload the rendered
`.pptx` to their Nextcloud (`learning-hub.md` documents this; the
upload itself goes through `src/routes/nextcloud.js` which uses the
same encrypted `nextcloud_token`).

---

## 13. Audit log + access log + api log

Three tables, three purposes, one writer (`src/utils/logger.js` →
`src/utils/auditQueue.js`).

### 13.1 The three tables

| Table | Schema (key cols) | What writes | Why |
|---|---|---|---|
| `audit_log` | `user_id, action, category, details, ip_address, user_agent, model_used, tokens_used, duration_ms, status, timestamp` | `logger.audit(...)` from auth, admin, learning admin, encounters | Security/compliance forensic trail |
| `api_log` | `user_id, endpoint, method, status_code, request_size, response_size, model_used, tokens_input, tokens_output, cost_estimate, duration_ms, ip_address, error, timestamp` | `loggingMiddleware` (`src/middleware/logging.js`) wraps `res.json` and writes one row per non-GET `/api/*` response (skips `/api/health`, `/api/models`) | Cost tracking, performance, debugging |
| `access_log` | `user_id, action, ip_address, user_agent, success, timestamp` | `logger.access(...)` from auth flow specifically (`login`, `login_failed`, `login_blocked`) | High-cardinality auth-only feed for SIEM-style queries |

### 13.2 Batched writer

`src/utils/auditQueue.js` is a per-table in-memory ring with **1-second
flush** and **50-row max batch**. Reduces per-request DB load from
"one INSERT per audit call" to "one INSERT per ~50 calls under load".
Tradeoff: up to ~1 s of audit entries lost on crash. The Postgres row
is the primary destination; Loki is shipped fire-and-forget per call
(unbatched — Loki has its own ingestion).

On `SIGTERM`, `server.js:357-364` calls `queues.drainAll()` before
closing the DB pool, so a clean restart doesn't lose buffered entries.

### 13.3 Loki shipping

`logger.js:14-29` pushes every audit/api/access entry to Loki at
`LOKI_URL/loki/api/v1/push` (when configured). Labels include
`{ app: 'pedscribe', type: 'audit'|'api_call'|'access', action,
category, status, endpoint }`. The body is JSON-stringified detail.
Used for the Grafana dashboard checked into `grafana-dashboard.json`.

### 13.4 PHI redaction

Every `audit_log.details` value passes through `src/utils/redact.js`
before insert (`logger.js:53-54`). Patterns: SSN, phone, email, DoB.
500-char cap. Note-body heuristic truncation to keep transcript
fragments out of the audit. Same `redact()` is applied to file logs
(`logger.js:135-142`) so a stray `req.body` log line can't leak
patient data.

### 13.5 Retention

Currently no automated retention/expiry on these tables. Operator can
truncate or partition manually. The README notes future work; for now
the `category` column lets you scope deletes (e.g.,
`DELETE FROM audit_log WHERE category = 'general' AND timestamp <
NOW() - INTERVAL '90 days'`).

---

## 14. Email

`src/routes/auth.js:147-172` defines `getSmtpTransport()` and
`sendEmail()`. The lookup precedence for SMTP config:

1. `app_settings` row (`smtp.host`, `smtp.port`, `smtp.user`,
   `smtp.pass`, `smtp.from`, `smtp.secure`) — admin-controlled via
   `PUT /api/admin/config/smtp` (`adminConfig.js:167-184`).
2. Env vars (`SMTP_HOST`, etc.) as fallback.
3. If neither is present, `sendEmail` logs `"[Email] SMTP not
   configured. Would send to: ..."` and returns `false`. The route
   that called it sees the false and degrades gracefully (e.g.,
   registration auto-verifies the user instead of sending a verify
   link — see §2.1 step 9).

Email templates (`emailWrapper`, `btnHtml`, `linkFallback`) live at
`auth.js:82-144`. Subjects and bodies are also overridable from
`app_settings` (`email.verify.subject`, `email.verify.body`,
`email.reset.subject`, `email.reset.body`) so the operator can rewrite
copy without a deploy.

The actual emails sent:

- **Verify email** (registration + resend) — single button + 24 h
  link (`auth.js:223-229`, `auth.js:286-292`).
- **Password reset** — single button + 1 h link (`auth.js:580-585`).
- **Test email** (admin only) — `POST /api/admin/config/test-email`
  reuses `__sendEmail`/`__emailWrapper`/`__btnHtml` from
  `routes/auth.js` exports (`auth.js:692-697`). Lets the admin send
  themselves a sample to verify SMTP without going through the user
  flow.

`utils/notify.js` is **separate** — it's ntfy push, not email. Sends
on new login, password change, new registration (admin), failed login
attempts (admin), encounter expiring. Uses `NTFY_URL` and
`NTFY_TOKEN` env vars.

---

## 15. Sacred zones

These are paths where changes need extra scrutiny and where "small
refactors" have historically caused outages. Per Daniel's working
preferences (see `MEMORY.md`):

### 15.1 The auth flow itself

**Don't change without a security review.** Specific patterns that
must be preserved:

- **Enumeration-resistant login** (`auth.js:319-326`). The
  `bcrypt.compare(password, DUMMY_HASH)` on the unknown-user branch
  exists to keep wall-clock latency identical to the password-check
  branch. Removing it leaks "is this email registered?" via timing.
- **Atomic backup-code consume** (`auth.js:419-461`). The
  `BEGIN ... SELECT FOR UPDATE ... COMMIT` is what stops two parallel
  logins with the same code from both succeeding. Don't "simplify" it
  to a non-transactional read-modify-write.
- **Timing-safe forgot-password** (`auth.js:548-591`). The response
  is returned **before** the DB lookup and email send — so hit and
  miss take the same wall-clock time and an attacker can't enumerate
  by latency.
- **OIDC SSRF guard** (`oidc.js:19-47`). Don't shortcut the DNS
  resolution check. The whole point is to prevent an admin from
  pointing OIDC at internal infra.
- **JWT not stored** in DB. The session table holds `SHA-256(jwt)`,
  not the JWT. Don't add a "for convenience" plaintext column.
- **Cookie flags** (`auth.js:43-52`). httpOnly + secure (when prod) +
  sameSite=lax. Don't relax these "to make it work in dev" — the
  `secure` flag already gates on `NODE_ENV !== 'production'`.

### 15.2 JWT secret handling

- `JWT_SECRET` is read once at startup (`middleware/auth.js:13-20`).
- **Required** in production — server refuses to start without it.
- Used for two distinct things:
  1. Signing user JWTs.
  2. HMAC-signing OIDC state tokens (`oidc.js:50-66`).
  Rotating `JWT_SECRET` invalidates **both** — every user is logged out
  AND any in-flight OIDC handshake fails. Acceptable for a security
  incident response, but document the impact for routine rotations.

### 15.3 Audit log writes

**Don't drop audit entries to "simplify" code.** The batched queue
already absorbs the per-call cost. The audit table is the *only*
record of who did what — losing entries means you lose forensic
capability you'll only miss when you need it.

Specifically:

- Every `admin_*` write endpoint should call `logger.audit(...)`. If
  you add a new admin action, add the audit call.
- Auth events (`register`, `login`, `login_failed`, `login_blocked`,
  `login_oidc`, `logout`, `email_verified`, `password_changed`,
  `password_reset`, `2fa_enabled`, `2fa_backup_code_used`,
  `2fa_backup_codes_regenerated`, `oidc_linked`,
  `session_idle_timeout`) are all canonical names — don't rename them
  without checking the Grafana dashboard's saved queries.
- `redact()` runs unconditionally on details. Don't bypass it
  ("just this one place needs the raw email") — that's exactly how
  PHI leaks.

### 15.4 Frontend `authFetch.js`

The global fetch interceptor (`public/js/authFetch.js`) is what makes
401-on-any-API-call drop the user back to the login screen. It also
handles cross-tab logout via BroadcastChannel. Don't remove the
"isAuthEndpoint" exclusion list (lines 73-82) — without it, a 401 on
`/api/auth/login` from a wrong-password attempt would bounce the user
out of the login form they were in the middle of.

---

## 16. How to add a new admin setting

Concrete checklist using `feature.foo` as the example. The wildcard
PUT handler at `adminConfig.js:957-985` already accepts any key with
an allowed prefix — `feature.` is in the list — so for a typical flag
the work is minimal.

### 16.1 The four touchpoints

1. **DB key**. Pick a key prefix that's already in the allow-list at
   `adminConfig.js:967`:
   ```js
   var allowed = ['announcement.', 'feature.', 'email.', 'prompt.',
     'registration_enabled', 'site.', 'smtp.', 'models.', 'tts.',
     'stt.', 'embeddings.'];
   ```
   If your key needs a new prefix, add it here. Don't use a non-prefixed
   key unless it's a genuine top-level toggle (registration_enabled is
   the only current example).

2. **Default value** (optional but recommended). Add to the array in
   `POST /api/admin/config/reset-defaults` (`adminConfig.js:122-136`)
   so the "reset to defaults" button restores it. Example:
   ```js
   ['feature.foo', 'true'],
   ```

3. **Server-side reader**. In whatever route consumes the setting,
   read it on demand:
   ```js
   var enabled = await db.getSetting('feature.foo');
   if (enabled !== 'true') return res.status(403).json({ error: '...' });
   ```
   Or, for hot paths, cache it (`feature.foo` reads at startup +
   poll every N minutes).

4. **Admin UI input**. In `public/index.html` (or wherever the admin
   tab content lives), add an input. The CMS section in `admin.js`
   (lines 263-660) already wires generic toggles — for `feature.*`,
   add an `<input type="checkbox" data-cms-key="feature.foo">` and
   the existing event delegation will PUT it on change. For more
   complex inputs, follow the pattern of `models.default` (text
   input → button → fetch).

5. **Frontend reader** (if the setting affects UI). Add a fetch call
   on app boot (or on tab-activation) to `/api/admin/config/...` —
   though this requires admin role. For user-facing flags, expose
   them via a public endpoint similar to `/api/auth/oidc-status` or
   `/api/admin/config/announcement`. The latter is conveniently
   public-with-auth (the only reason the wildcard PUT requires admin
   is the `router.use(adminMiddleware)` at line 30, which comes
   *after* the announcement GET at lines 16-28).

### 16.2 Audit it

Even though the wildcard handler logs `admin_config_update`
automatically (`adminConfig.js:981`), if your setting has unusual
side effects (revokes sessions, clears caches, restarts something),
consider adding a custom audit action like `admin_feature_toggle_foo`
with more specific details.

### 16.3 Test it

Local dev: change the value via the admin UI, hit the affected
endpoint, verify the behaviour change. There's no schema migration —
`app_settings` is just `(key TEXT PK, value TEXT, updated_at
TIMESTAMP)`.

For a feature flag visible in Grafana, query
`audit_log WHERE action LIKE 'admin_config%' AND details LIKE
'%feature.foo%'` to confirm the audit row appeared.

---

## Appendix: file-path quick reference

| File | What |
|---|---|
| `src/middleware/auth.js` | `authMiddleware`, `adminMiddleware`, `moderatorMiddleware`, `JWT_SECRET` |
| `src/middleware/logging.js` | API call audit hook |
| `src/routes/auth.js` | Register, login, 2FA, password reset, logout, /me |
| `src/routes/oidc.js` | OIDC SSO + admin OIDC config |
| `src/routes/sessions.js` | List + revoke sessions |
| `src/routes/admin.js` | User management, basic settings, log viewer |
| `src/routes/adminConfig.js` | CMS flags, prompts, SMTP, model/TTS/STT/embeddings management |
| `src/routes/adminMilestones.js` | Milestone CRUD |
| `src/routes/userPreferences.js` | Per-user STT/TTS pick |
| `src/routes/learningHub.js` | Reader: feed, content, quizzes, search |
| `src/routes/learningAdmin.js` | CMS: categories, content, questions, embeddings backfill |
| `src/routes/learningAI.js` | AI generate, refine, WebDAV browse, PPTX export, slide preview |
| `src/utils/passwords.js` | argon2id + bcrypt unified API + transparent migration |
| `src/utils/sessions.js` | `hashToken`, `parseUserAgent`, `generateSessionId` |
| `src/utils/notify.js` | ntfy push (login alerts, etc.) |
| `src/utils/auditQueue.js` | Batched writer for audit/api/access tables |
| `src/utils/logger.js` | `logger.audit` / `apiCall` / `access` + Loki shipping |
| `src/utils/prompts.js` | Hardcoded prompts + DB override loader |
| `src/utils/models.js` | Model allowlist + cached lookup |
| `src/utils/ai.js` | `callAI` with allowlist enforcement |
| `src/utils/embeddings.js` | Vector generation (LiteLLM/Vertex/OpenAI) |
| `docker-entrypoint.sh` | OpenBao secret bootstrap |
| `server.js` | Mount order, rate limits, CSP, CORS, graceful shutdown |
| `public/js/auth.js` | Login/register/2FA/SSO frontend |
| `public/js/authFetch.js` | Global fetch interceptor (401 → logout, cross-tab sync) |
| `public/js/admin.js` | Six IIFEs covering all admin sections |
| `public/js/learningHub.js` | Reader + CMS frontend (~1,800 lines) |
