pediatric-ai-scribe-v3/docs/logic/auth-admin-learning.md
Daniel 503f5afaad feat: ED multi-stage UX, extensions polish, docs viewer + application-logic docs
Three concurrent themes from this session:

═══════════════════════════════════════════════════════════════════
ED ENCOUNTERS — per-stage cards + consolidate→MDM finalize
═══════════════════════════════════════════════════════════════════

UX redesign per Daniel's feedback ("every stage note should be shown,
if AI is told to modify that particular note then the modified version
is used in final mdm"):

- Each generated stage stays on screen as its own editable card with
  its own embedded "Don't Miss" panel. No more single rolling note
  element that gets replaced on each generation.
- gatherCurrentNotes() reads contenteditable text from each stage card
  before any operation (advance, finalize, persist) so inline edits
  flow into the next AI call and the final consolidate.
- Stage badge is now state-accurate. "Stage N (recording)" with yellow
  background after Add-more before generation; "Stage N" with gray
  after generation. Fixes the bug where the badge flipped to Stage 2
  the moment Add-more was clicked.
- Save & Done now runs TWO server-side AI calls in /finalize:
  1. edConsolidate (new prompt) → polished single final note that
     integrates every stage chronologically (HPI / ROS / PE / ED Course /
     A&P with disposition).
  2. edFinalize (rewritten with full inline 2023 AMA E/M element
     rubric — problems / data / risk definitions, level mapping with
     concrete examples) → MDM JSON.
- Two new cards render after finalize: blue-bordered Final Consolidated
  Note + green-bordered MDM. Stage cards become read-only.
- partial_data on the saved row now stores {stages, finalNote, mdm,
  finalized} so resume re-renders the full state.

Why two-call finalize: a single combined prompt makes the model cut
corners on one task. Two focused calls cost ~2× latency at the very end
of an encounter — acceptable since finalize is a one-time terminal
action, not a per-stage hot path.

Files: public/components/ed-encounter.html, public/js/ed-encounters.js,
src/routes/edEncounters.js, src/utils/prompts.js (edConsolidate added,
edFinalize rewritten).

═══════════════════════════════════════════════════════════════════
EXTENSIONS / PAGERS — visual polish
═══════════════════════════════════════════════════════════════════

Multiple iterations based on Daniel's feedback:

- Layout: align-items:flex-start so action buttons stay pinned top-right
  when long numbers wrap (was align-items:center → buttons drifted into
  the text area, causing visible overlap).
- Number: word-break:break-all + min-width:0 + font-feature-settings:tnum
  so long numbers wrap within their column instead of pushing under the
  buttons. Click-to-copy with a 0.55s green flash + ✓ copied badge.
- Phone/pager Font Awesome icon next to the number in the type color —
  at-a-glance type signal (replacing an earlier 3px left stripe that
  Daniel found visually bulky).
- Name: font-weight 700, font-size 14.5px, color g900, letter-spacing
  -0.012em — scan-target headline typography for long lists.
- Alternating subtle backgrounds by index (white vs #fafbfc) so a long
  list reads as distinct rows.
- Hover: card lifts 1px with a soft shadow; action buttons fade from
  55% to 100% opacity. Cubic-bezier transition on transform.
- Entrance: staggered fade-up animation per card (35ms × index, capped
  at 12). prefers-reduced-motion media query disables motion.
- Empty state: 48px FA icon + heading instead of plain gray text.

Files: public/js/extensions.js, public/css/styles.css.

═══════════════════════════════════════════════════════════════════
DOCS REORGANIZATION + APPLICATION-LOGIC DOCS + ADMIN VIEWER
═══════════════════════════════════════════════════════════════════

Document moves (preserving git history via git mv):
  BROWSER_WHISPER_SETUP.md          → docs/browser-whisper-setup.md
  BROWSER_WHISPER_TROUBLESHOOTING.md → docs/browser-whisper-troubleshooting.md
  DEVELOPER_GUIDE.md                → docs/developer-guide-extended.md
  EMBEDDINGS_SETUP.md               → docs/embeddings-setup.md
  FEATURES_EXPLAINED.md             → docs/features-explained.md
  IMPROVEMENTS.md                   → docs/improvements.md
  OPENID_SETUP.md                   → docs/openid-setup.md
  TRANSCRIPTION_OPTIONS.md          → docs/transcription-options.md
README.md updated with the new paths + a Documentation section that
links to docs/logic/ at the top.

New application-logic doc series (~8,300 lines total) at docs/logic/.
Built with 5 parallel doc-writing agents per Daniel's "use multiple
agents" directive. Each doc explains how a part of the app actually
works — application logic, data flow, design decisions, sacred zones,
how-to-extend recipes — at a depth that lets a new dev (or an AI
assistant) modify the code confidently.

  docs/logic/README.md                — index + recommended reading order
  docs/logic/architecture.md (2166 L) — frontend IIFE pattern, lazy tab
                                         load, backend route convention,
                                         schema, encryption, deployment
  docs/logic/clinical-notes.md (1546L) — every note tab + helper trio
  docs/logic/bedside-and-calculators.md (1373L) — bedside ES module
                                         pocket + calculators + PE Guide
                                         + suture selector
  docs/logic/auth-admin-learning.md (1281L) — auth (local+OIDC+2FA) +
                                         admin panel + Learning Hub
                                         (Quiz engine logic at sub-detail
                                         only — TODO follow-up)
  docs/logic/ai-and-voice.md (1128 L) — callAI 5-provider routing,
                                         prompts, voice/STT, helper trio
  docs/logic/ed-encounters.md (821 L) — multi-stage ED + MDM (this
                                         session's worked example)

Admin-only docs viewer:
- New route /api/admin/docs/{tree,file}: recursively walks docs/, returns
  the tree as JSON; /file?path=X validates path stays inside docs/ and
  renders markdown via marked. Both gated by req.user.role==='admin'.
- New tab "Docs" (book icon) in the sidebar, hidden by default and
  revealed in auth.js when user.role==='admin' (same pattern as the
  existing Admin and CMS tabs).
- New component public/components/admin-docs.html: split-pane layout
  with a tree sidebar + filter input + a markdown reader pane.
- New module public/js/admin-docs.js: lazy-loads the tree on first tab
  activation, renders collapsible folders, persists expanded state and
  last-opened path via UIState. Server-rendered HTML so no client
  markdown parser needed.
- CSS for the viewer (responsive split-pane, code-block styling, table
  scrolling, etc.).
- Mounted at /api/admin/docs (NOT /api) — important: mounting a router
  with router.use(authMiddleware) at /api accidentally 401s every other
  /api/* path (caught and fixed during testing — /api/health was 401'ing).

Files: docs/* (moved + new), README.md, public/components/admin-docs.html
(new), public/js/admin-docs.js (new), src/routes/adminDocs.js (new),
public/index.html (tab + section + script), public/js/auth.js (admin
gate + logout cleanup), public/css/styles.css (viewer styles), server.js
(mount).

═══════════════════════════════════════════════════════════════════
KNOWN GAPS (TODO follow-ups)
═══════════════════════════════════════════════════════════════════

- Learning Hub quiz engine (MCQ / multi-select / T-F scoring + attempt
  tracking + progress dashboard) is covered at the architectural level
  in docs/logic/auth-admin-learning.md but not drilled into the quiz
  data model and scoring flow. Worth a focused follow-up doc.
- ED finalize: if MDM step JSON parse fails, server returns 502 with
  the consolidated finalNote in the error payload, but client doesn't
  surface the partial result. Add a "MDM failed, retry" affordance.
- No e2e Playwright coverage for ED encounters or the new docs viewer.
2026-04-28 03:09:38 +02:00

1281 lines
59 KiB
Markdown

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