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.
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.useris set bysrc/middleware/auth.js:22-110and 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 viasrc/routes/oidc.js:233-241.app_settingsis 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_logcaptures 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 vialogger.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:
- Registration enabled? Read from
app_settingsrowregistration_enabled. If string'false', returns 403. This row is toggled by admins from the panel (src/routes/admin.js:180-191, keyregistration_enabled). - Required fields + minimum length (8 chars).
- Cloudflare Turnstile (only if
TURNSTILE_SECRET_KEYis set;auth.js:189-201). Token is verified server-side via thechallenges.cloudflare.com/turnstile/v0/siteverifyendpoint withremoteip: req.ip. Failure returns 400 — generic message, no leak. - Email uniqueness check (
SELECT id FROM users WHERE email = ?, lowercased). - 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 optionalargon2module isn't installed (passwords.js:9-15). - Verification token: 32 random bytes, hex-encoded, 24 h expiry
(
auth.js:207-208). - 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 viaPOST /api/admin/users/:id/role(src/routes/admin.js:109-132). - INSERT user, send verify email (Nodemailer, see §14).
- 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 auser_sessionsrow 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:
- Length only is enforced server-side (>= 8 chars, in
auth.js:186). No complexity rules. Rationale: NIST SP 800-63B no longer recommends complexity. - HIBP check (
auth.js:17-30) is advisory, not blocking. The server fetcheshttps://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-passwordis 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 apasswordWarningfield (auth.js:599-606,auth.js:646-661). - Hash migration is transparent. On any successful login against a
bcrypt hash,
passwords.maybeRehashreturns 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:
- Required fields.
- Turnstile (if configured).
SELECT * FROM users WHERE email = ?.- 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". - If user exists:
passwords.verify(password, user.password)handles both argon2 and bcrypt formats (passwords.js:33-43). Wrong password → auditlogin_failed, access loglogin_failed, return"Invalid credentials"(same string). - Disabled account → audit
login_blocked, return"Invalid credentials"(same string). - Email not verified → 403 with
needsVerification: true— this is the one non-uniform response, deliberately (UX > perfect indistinguishability perdocs/authentication.md:118-122). - 2FA branch (
auth.js:357-370) — see §4. - 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. - Insert
user_sessionsrow 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. notifyNewLoginntfy push (utils/notify.js:63-68) — fire and forget.setAuthCookie(res, token)— httpOnly, sameSite=lax, secure whenNODE_ENV=production || APP_URL, 30 d max age (auth.js:43-52).- JSON response:
{ success, token, sessionId, user: {...} }.- Web ignores
token(cookie does the work) but usessessionIdfor the "current session" highlight in Settings. - Mobile stores
tokenin SecureStorage and usesAuthorization: Bearer ...going forward.
- Web ignores
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.
2.6 Cookie vs Bearer
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
|| nullaftersubstring(7)is intentional. Some clients unconditionally emitAuthorization: Bearerwith 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
-
GET
/api/auth/oidc-status(oidc.js:80-93, public). Returns{ oidcEnabled, disableLocalAuth, buttonLabel }. Frontendauth.js:88-106calls this on the login screen to decide whether to show the SSO button and whether to hide the email/password fields. -
GET
/api/auth/oidc(oidc.js:96-143, public).- Read
oidc.enabled/oidc.issuer/oidc.client_idfromapp_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.
- Issuer URL must be
oidc.discovery(new URL(issuer), clientId)fetches the IdP metadata.- Generate
nonce(24 random bytes),codeVerifier(PKCE), and computecodeChallenge = 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 withJWT_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 viaverifyStatewithcrypto.timingSafeEqual(oidc.js:55-66). - Redirect the user to the IdP's authorization URL.
- Read
-
IdP authenticates the user, redirects back to
/api/auth/oidc/callback?code=...&state=...(oidc.js:146-252).verifyState(state)— rejects expired or tampered tokens.assertSafeIssuerruns 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,namefrom 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):
- Block SSO-only accounts (per §3.4).
speakeasy.generateSecret({ name: 'PedScribe (' + email + ')', issuer: 'Pediatric AI Scribe' })→ returnsbase32secret +otpauth_url.- Save the secret to
users.totp_secretimmediately (so the user can come back tomorrow and complete verification with the same secret) — buttotp_enabledstays false until verification succeeds. QRCode.toDataURL(otpauth_url)generates a data: URI for inline<img>display.- 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— no0/O/1/Ito 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.
- Alphabet
- 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:
- Pick token from Bearer or cookie (per §2.6).
jwt.verify(token, JWT_SECRET)— invalid/expired → 401"Invalid or expired token".SELECT id, email, name, role, totp_enabled, disabled FROM users WHERE id = ?— setsreq.user.- Disabled? 403
"Account disabled. Contact administrator.". - 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_sessionstable existed would be locked out otherwise.
- If no session and the user has any other sessions → 401
- Sliding idle timeout (web only):
idleMs = NOW() - last_activity.- If
!mobile && idleMs > 24h: writesession_idle_timeoutaudit with the minute count, delete the row, clear cookie, return 401{ error: 'Session expired due to inactivity', idleTimeout: true }. The frontendauthFetch.jsinterceptor will catch this and bounce to login.
- 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/meheartbeat 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.
- Only on POST/PUT/DELETE/PATCH — GET requests do not extend the
session. This is intentional. Without it, a polling dashboard or
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' }onBroadcastChannel('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)
- Require AppRole credentials.
OPENBAO_ROLE_IDandOPENBAO_SECRET_IDare mandatory; missing → fatal exit. - Authenticate via
bao write -field=token auth/approle/login. The returned client token is exported asBAO_TOKEN(the standard variable thebaoCLI reads). - Fetch
kv/ped-ai/prod(or whateverOPENBAO_KV_PATHpoints to).bao kv get -format=json | jq -c '.data.data'produces a flat JSON object of{ KEY: "value", ... }. - 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 setsTURNSTILE_SECRET_KEY=""andSMTP_HOST=""to disable those features in tests, and we don't want OpenBao silently re-enabling them. - 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. - 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. exec "$@"hands control to whateverCMDthe Dockerfile set (typicallynode server.js).
7.3 Why this matters
- Production secrets aren't on the host filesystem. No
.envto leak via a backup, a snapshot, ordocker 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(perMEMORY.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):
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):
- DELETE the
prompt.<key>row fromapp_settings. delete require.cache[require.resolve('../utils/prompts')]to evict the cached module.var fresh = require('../utils/prompts')re-loads the file (which contains the hardcoded defaults).- Copy
fresh[key]back into the livePROMPTS[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.jsreadsusers.stt_modelto override the default Whisper/Gemini model when the user has set one.src/routes/tts.jsreadsusers.tts_voiceto 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— listlearning_categoriesordered bysort_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 (withoutis_correctso the client can't peek), and the user's last 5 attempts fromlearning_progress.GET /content/:slug/slides— forcontent_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 bothmcq(single-correct) andmulti(all-and-only-correct, seelearningHub.js:178-193). Score + total written tolearning_progress.GET /search?q=— keywordILIKE '%q%'against title/subject/body.GET /search/semantic?q=— pgvector cosine similarity viasearchSimilarfromsrc/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 tocategory_id = NULLrather 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 theivfflatindex when ≥10 embeddings exist) andGET /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:
- 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. - Nextcloud WebDAV path (
webdavPathfield). Pullsnextcloud_tokenfrom the user's row, decrypts viacryptoUtil.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 sameextractText. - 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
presentationwith no questions: returns Marp markdown directly, no JSON wrapper. - For
presentationwith questions: returns a JSON object with bothmarpMarkdownandquestions[]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), andquestions[]. - Each MCQ must have exactly 4 options, exactly 1 correct.
question_typemust bemcqortrue_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):
- Read
embeddings.modelandembeddings.dimensionsfromapp_settings(admin-controlled), fall back to env vars, then built-in defaults (vertex_ai/text-embedding-005, 768 dims). - Truncate input to 8,000 chars (~2,000 tokens).
- 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:
app_settingsrow (smtp.host,smtp.port,smtp.user,smtp.pass,smtp.from,smtp.secure) — admin-controlled viaPUT /api/admin/config/smtp(adminConfig.js:167-184).- Env vars (
SMTP_HOST, etc.) as fallback. - If neither is present,
sendEmaillogs"[Email] SMTP not configured. Would send to: ..."and returnsfalse. 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-emailreuses__sendEmail/__emailWrapper/__btnHtmlfromroutes/auth.jsexports (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). Thebcrypt.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). TheBEGIN ... SELECT FOR UPDATE ... COMMITis 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" — thesecureflag already gates onNODE_ENV !== 'production'.
15.2 JWT secret handling
JWT_SECRETis read once at startup (middleware/auth.js:13-20).- Required in production — server refuses to start without it.
- Used for two distinct things:
- Signing user JWTs.
- HMAC-signing OIDC state tokens (
oidc.js:50-66). RotatingJWT_SECRETinvalidates 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 calllogger.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
-
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).
-
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'], -
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.fooreads at startup + poll every N minutes). -
Admin UI input. In
public/index.html(or wherever the admin tab content lives), add an input. The CMS section inadmin.js(lines 263-660) already wires generic toggles — forfeature.*, 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 ofmodels.default(text input → button → fetch). -
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-statusor/api/admin/config/announcement. The latter is conveniently public-with-auth (the only reason the wildcard PUT requires admin is therouter.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) |