pediatric-ai-scribe-v3/docs/logic/auth-admin-learning.md
Daniel b53aa34248 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

59 KiB

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

{ "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.

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

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:

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:

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

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 execs 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.mdproject_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):

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.jssrc/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:

    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:

    ['feature.foo', 'true'],
    
  3. Server-side reader. In whatever route consumes the setting, read it on demand:

    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)