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.
1281 lines
59 KiB
Markdown
1281 lines
59 KiB
Markdown
# Auth, Admin & Learning Hub — application logic
|
|
|
|
This is a deep walk-through of the three subsystems that own **identity**,
|
|
**control surface**, and **content authoring** for ped-ai. They share
|
|
infrastructure (the same `users` table, the same `app_settings` key/value
|
|
store, the same JWT cookie, the same audit/api/access log pipeline) and they
|
|
chain together: **auth** decides whether a request is allowed, **admin**
|
|
configures what is allowed at all, and the **Learning Hub** is the single
|
|
biggest user of admin-side controls (model allowlist, embeddings, prompts,
|
|
moderator role, optional Nextcloud).
|
|
|
|
The doc cites file paths and line numbers from the current tree at
|
|
`/home/danvics/docker/ped-ai`.
|
|
|
|
---
|
|
|
|
## 1. Overview
|
|
|
|
### What each layer owns
|
|
|
|
| Layer | Decides | Stored where | Consumed by |
|
|
|---|---|---|---|
|
|
| **Auth** | Who is `req.user`. Is the session still alive? Is local password / 2FA / SSO appropriate? | `users`, `user_sessions` (Postgres). JWT in `ped_auth` httpOnly cookie. | Every router behind `authMiddleware`. |
|
|
| **Admin** | What the operator has turned on: model allowlist, prompt overrides, feature flags, SMTP, OIDC config, milestones, registration open/closed. | `app_settings` (key/value text rows), env vars (read at boot), in-memory `PROMPTS` cache. | `callAI`, prompt loader, OIDC initiator, `/api/auth/register`, the entire frontend that gates UI on `feature.X`. |
|
|
| **Learning Hub** | What educational content exists; quiz authoring; AI-assisted generation; semantic search. | `learning_content`, `learning_categories`, `learning_questions`, `learning_options`, `learning_progress` plus `learning_content.embedding` (pgvector). | All authenticated users (read + take quiz). Moderators + admins (CMS write). |
|
|
|
|
### Why they're grouped in one doc
|
|
|
|
They sit on top of the **same primitives**:
|
|
|
|
- **`req.user`** is set by `src/middleware/auth.js:22-110` and is the *only*
|
|
way any of these subsystems learn who is talking. There is no parallel
|
|
identity path; the OIDC callback finishes by issuing the same JWT into the
|
|
same cookie via `src/routes/oidc.js:233-241`.
|
|
- **`app_settings`** is the universal admin store. Every admin knob — model
|
|
allowlist (`models.disabled`), default model (`models.default`), prompt
|
|
override (`prompt.<key>`), CMS flag (`feature.<name>`), SMTP creds
|
|
(`smtp.host`/`smtp.user`/`smtp.pass`), OIDC config (`oidc.*`),
|
|
announcement (`announcement.*`) — is just a row in this table. Even
|
|
registration_enabled is here.
|
|
- **`audit_log`** captures every state transition from any of the three.
|
|
Login? Audit. Admin edits a prompt? Audit. Moderator publishes a quiz?
|
|
No — that one only writes a content row, by design (the row itself *is*
|
|
the audit). But anything that mutates security or system config gets a
|
|
row written via `logger.audit(...)` (src/utils/logger.js:52-78).
|
|
|
|
Reading order for a brand-new contributor: `src/middleware/auth.js` first
|
|
(everything else is gated by it), then `src/routes/auth.js` for the
|
|
end-user-facing flows, then `src/routes/admin.js` + `adminConfig.js` for the
|
|
operator surface, then the Learning Hub trio
|
|
(`learningHub.js` / `learningAdmin.js` / `learningAI.js`).
|
|
|
|
---
|
|
|
|
## 2. Auth flow — local accounts
|
|
|
|
### 2.1 Registration
|
|
|
|
`POST /api/auth/register` lives at `src/routes/auth.js:177-258`. The order
|
|
of the checks is load-bearing:
|
|
|
|
1. **Registration enabled?** Read from `app_settings` row
|
|
`registration_enabled`. If string `'false'`, returns 403. This row is
|
|
toggled by admins from the panel
|
|
(`src/routes/admin.js:180-191`, key `registration_enabled`).
|
|
2. **Required fields + minimum length** (8 chars).
|
|
3. **Cloudflare Turnstile** (only if `TURNSTILE_SECRET_KEY` is set;
|
|
`auth.js:189-201`). Token is verified server-side via the
|
|
`challenges.cloudflare.com/turnstile/v0/siteverify` endpoint with
|
|
`remoteip: req.ip`. Failure returns 400 — generic message, no leak.
|
|
4. **Email uniqueness** check (`SELECT id FROM users WHERE email = ?`,
|
|
lowercased).
|
|
5. **Password hash** via `passwords.hash(password)` —
|
|
`src/utils/passwords.js:28-31`. Argon2id is preferred (memory 19 MiB,
|
|
time 2, parallelism 1 = OWASP 2023 recommendation,
|
|
`passwords.js:18-23`). bcrypt(12) is the fallback if the optional
|
|
`argon2` module isn't installed (`passwords.js:9-15`).
|
|
6. **Verification token**: 32 random bytes, hex-encoded, 24 h expiry
|
|
(`auth.js:207-208`).
|
|
7. **Auto-admin for first user**: if `COUNT(*) FROM users == 0`, role is
|
|
set to `'admin'` (`auth.js:210-211`). Subsequent users default to
|
|
`'user'`. The third role, `'moderator'`, can only be assigned by an
|
|
existing admin via `POST /api/admin/users/:id/role`
|
|
(`src/routes/admin.js:109-132`).
|
|
8. **INSERT user, send verify email** (Nodemailer, see §14).
|
|
9. **SMTP-not-configured fallback** (`auth.js:236-251`): if no SMTP host
|
|
is set, the user is auto-verified, a JWT is signed *immediately*, and a
|
|
`user_sessions` row is created. This keeps a fresh dev install usable
|
|
without the operator having to wire SMTP first.
|
|
|
|
The `audit_log` row is written with action `'register'` and details noting
|
|
whether the user was auto-promoted to admin. `notifyNewRegistration` (ntfy,
|
|
admin topic) fires fire-and-forget (`src/utils/notify.js:87-92`).
|
|
|
|
### 2.2 Email verification
|
|
|
|
Cleared by clicking the link from the verify email. `GET
|
|
/api/auth/verify-email?token=...` (`auth.js:263-275`) checks
|
|
`verify_token = ? AND verify_expires > ?`. Success page sets
|
|
`email_verified = true`, nulls the token columns, writes `email_verified`
|
|
audit, returns plain HTML (intentionally — this URL is opened in the user's
|
|
default browser, not via the app, so no JSON).
|
|
|
|
`POST /api/auth/resend-verification` (`auth.js:278-295`) regenerates the
|
|
token + email. The response is uniform whether the email exists or not
|
|
("If account exists, verification email sent") — partial enumeration
|
|
defense.
|
|
|
|
### 2.3 Password rules
|
|
|
|
Source of truth is `src/utils/passwords.js`. Three things to know:
|
|
|
|
1. **Length only** is enforced server-side (>= 8 chars, in
|
|
`auth.js:186`). No complexity rules. Rationale: NIST SP 800-63B no
|
|
longer recommends complexity.
|
|
2. **HIBP check** (`auth.js:17-30`) is **advisory**, not blocking. The
|
|
server fetches `https://api.pwnedpasswords.com/range/<sha1-prefix-5>`
|
|
(k-anonymity — only 5 hex chars of SHA-1 leave the box) and returns the
|
|
breach count to the client. `/api/auth/check-password` is a public
|
|
endpoint the registration form hits before submit. On reset/change, if
|
|
the new password is breached, the request still **succeeds** but the
|
|
response includes a `passwordWarning` field
|
|
(`auth.js:599-606`, `auth.js:646-661`).
|
|
3. **Hash migration is transparent**. On any successful login against a
|
|
bcrypt hash, `passwords.maybeRehash` returns a fresh argon2id hash, and
|
|
the user row is updated in the background (`auth.js:341-344`). Users
|
|
migrate forward without any action.
|
|
|
|
### 2.4 Login
|
|
|
|
`POST /api/auth/login` (`src/routes/auth.js:300-392`).
|
|
|
|
The order matters because **the goal is timing-equal failure for
|
|
unknown-email vs wrong-password vs disabled**:
|
|
|
|
1. Required fields.
|
|
2. Turnstile (if configured).
|
|
3. `SELECT * FROM users WHERE email = ?`.
|
|
4. **If no user**: still run a `bcrypt.compare(password, DUMMY_HASH)` to
|
|
keep wall-clock latency identical to the password-check branch
|
|
(`auth.js:319-326`). Log to console (server-side only — no email in the
|
|
log message — so an operator watching Grafana sees the lookup-miss rate
|
|
without leaking which addresses exist). Return generic
|
|
`"Invalid credentials"`.
|
|
5. **If user exists**: `passwords.verify(password, user.password)`
|
|
handles both argon2 and bcrypt formats (`passwords.js:33-43`). Wrong
|
|
password → audit `login_failed`, access log `login_failed`, return
|
|
`"Invalid credentials"` (same string).
|
|
6. **Disabled account** → audit `login_blocked`, return
|
|
`"Invalid credentials"` (same string).
|
|
7. **Email not verified** → 403 with `needsVerification: true` — this is
|
|
the **one** non-uniform response, deliberately (UX > perfect
|
|
indistinguishability per `docs/authentication.md:118-122`).
|
|
8. **2FA branch** (`auth.js:357-370`) — see §4.
|
|
9. **Sign JWT** via `signAuthToken(userId, req)` — 30 d for web, 365 d
|
|
for mobile (`auth.js:57-60`). The mobile token sits in iOS Keychain /
|
|
Android EncryptedSharedPreferences and is never refreshed; idle timeout
|
|
is also disabled for mobile.
|
|
10. Insert `user_sessions` row keyed by SHA-256 hash of the JWT
|
|
(`hashToken`, `src/utils/sessions.js:3-5`) — never the raw JWT, so
|
|
even a leaked DB dump can't be turned into login tokens.
|
|
11. `notifyNewLogin` ntfy push (`utils/notify.js:63-68`) — fire and
|
|
forget.
|
|
12. `setAuthCookie(res, token)` — httpOnly, sameSite=lax, secure when
|
|
`NODE_ENV=production || APP_URL`, 30 d max age (`auth.js:43-52`).
|
|
13. JSON response: `{ success, token, sessionId, user: {...} }`.
|
|
- **Web** ignores `token` (cookie does the work) but uses
|
|
`sessionId` for the "current session" highlight in Settings.
|
|
- **Mobile** stores `token` in SecureStorage and uses
|
|
`Authorization: Bearer ...` going forward.
|
|
|
|
### 2.5 JWT structure
|
|
|
|
```json
|
|
{ "userId": 42, "iat": 1730000000, "exp": 1732592000 }
|
|
```
|
|
|
|
Signed HS256 with `JWT_SECRET` (`src/middleware/auth.js:13-20`). The
|
|
secret is **mandatory in production** — if `NODE_ENV=production` or
|
|
`APP_URL` is set and `JWT_SECRET` is missing, the process refuses to
|
|
start. In dev, a warning is printed and a literal fallback string is used.
|
|
|
|
Note what's *not* in the JWT: no role, no email, no permissions. The
|
|
middleware re-fetches the user row on every authenticated request
|
|
(`middleware/auth.js:39`). That means an admin demotion or account disable
|
|
takes effect on the very next request — no waiting for the JWT to expire.
|
|
|
|
### 2.6 Cookie vs Bearer
|
|
|
|
Set in `src/middleware/auth.js:22-32`:
|
|
|
|
```js
|
|
var authHeader = req.headers.authorization;
|
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
|
token = authHeader.substring(7) || null; // empty Bearer → null, fall through
|
|
}
|
|
if (!token && req.cookies && req.cookies.ped_auth) {
|
|
token = req.cookies.ped_auth;
|
|
}
|
|
```
|
|
|
|
Two subtleties:
|
|
|
|
- **Bearer is checked first**. A native app sending both (cookie from a
|
|
prior browser session in WebView storage + a Bearer header from
|
|
Keychain) will use the Bearer.
|
|
- The `|| null` after `substring(7)` is intentional. Some clients
|
|
unconditionally emit `Authorization: Bearer ` with an empty value; that
|
|
shouldn't lock them out of the cookie path.
|
|
|
|
### 2.7 Rate limits (set in `server.js:95-137`)
|
|
|
|
| Endpoint | Window | Max | Configurable via |
|
|
|---|---|---|---|
|
|
| `/api/*` general | 60 s | 200 | `API_RATE_LIMIT_MAX` env |
|
|
| `/api/auth/login` | 15 min | 10 | `LOGIN_RATE_LIMIT_MAX` env |
|
|
| `/api/auth/register` | 60 min | 5 | hardcoded |
|
|
| `/api/auth/forgot-password` | 60 min | 5 | hardcoded |
|
|
| `/api/auth/resend-verification` | 15 min | 3 | hardcoded |
|
|
| `/api/auth/change-password`, `/setup-2fa`, `/verify-2fa`, `/disable-2fa` | 15 min | 20 | hardcoded |
|
|
|
|
All keyed by IP (express-rate-limit defaults). `app.set('trust proxy', 1)`
|
|
at `server.js:16` is required for these to see the real client IP behind
|
|
Caddy/Nginx. The `LOGIN_RATE_LIMIT_MAX` and `API_RATE_LIMIT_MAX` env
|
|
overrides exist specifically so the e2e Playwright container can run many
|
|
parallel workers without tripping production-grade limits.
|
|
|
|
---
|
|
|
|
## 3. Auth flow — OIDC SSO
|
|
|
|
`src/routes/oidc.js` implements **Authorization Code + PKCE** with
|
|
`openid-client` v6. Unlike the registration/login flow, OIDC config is
|
|
admin-controlled at runtime (no env vars required, although they're
|
|
honoured if present).
|
|
|
|
### 3.1 The dance, in order
|
|
|
|
1. **GET `/api/auth/oidc-status`** (`oidc.js:80-93`, public).
|
|
Returns `{ oidcEnabled, disableLocalAuth, buttonLabel }`. Frontend
|
|
`auth.js:88-106` calls this on the login screen to decide whether to
|
|
show the SSO button and whether to hide the email/password fields.
|
|
|
|
2. **GET `/api/auth/oidc`** (`oidc.js:96-143`, public).
|
|
- Read `oidc.enabled` / `oidc.issuer` / `oidc.client_id` from
|
|
`app_settings`.
|
|
- **`assertSafeIssuer(issuer)`** (`oidc.js:40-47`):
|
|
- Issuer URL must be `https://`.
|
|
- DNS-resolve hostname; **all** resolved addresses must be public
|
|
(not private 10/8, 127/8, 169.254/16 link-local, 172.16/12,
|
|
192.168/16, multicast, IPv6 loopback or unique-local).
|
|
- Defends against an admin (or compromised admin account) pointing
|
|
OIDC at AWS metadata at `http://169.254.169.254/`, container
|
|
sidecars, etc.
|
|
- `oidc.discovery(new URL(issuer), clientId)` fetches the IdP
|
|
metadata.
|
|
- Generate `nonce` (24 random bytes), `codeVerifier` (PKCE), and
|
|
compute `codeChallenge = S256(codeVerifier)`.
|
|
- **State is signed, not stored.** `signState({ n, v, expires })`
|
|
(`oidc.js:50-54`) packs nonce + verifier + 5-min expiry into a base64url
|
|
payload and HMAC-SHA256-signs it with `JWT_SECRET`. This means the
|
|
OIDC flow survives process restarts and works horizontally across
|
|
replicas — there's no per-process map of pending states. Verified
|
|
on the way back via `verifyState` with `crypto.timingSafeEqual`
|
|
(`oidc.js:55-66`).
|
|
- Redirect the user to the IdP's authorization URL.
|
|
|
|
3. **IdP authenticates the user, redirects back to**
|
|
**`/api/auth/oidc/callback?code=...&state=...`** (`oidc.js:146-252`).
|
|
- `verifyState(state)` — rejects expired or tampered tokens.
|
|
- `assertSafeIssuer` runs again (defense in depth — if the operator
|
|
swapped the issuer between init and callback, the SSRF check still
|
|
blocks).
|
|
- `oidc.authorizationCodeGrant(...)` exchanges the code for tokens,
|
|
verifying nonce + state + PKCE verifier match what we signed.
|
|
- Pull `sub`, `email`, `email_verified`, `name` from claims (or from
|
|
userinfo endpoint as fallback if email isn't in the ID token).
|
|
|
|
### 3.2 Linking & auto-creation
|
|
|
|
Three branches in `oidc.js:193-230`:
|
|
|
|
| Existing `users` row by email | `oidc_sub` set? | What happens |
|
|
|---|---|---|
|
|
| Yes | matches | Just log them in. |
|
|
| Yes | empty | **Auto-link** only if IdP asserts `email_verified === true`. Otherwise redirect with `?error=email_unverified`. Audit `oidc_linked`. |
|
|
| Yes | mismatch | Refuse with `?error=sub_mismatch`. Different IdP identity claiming the same email = tell the admin. |
|
|
| No | — | **Auto-create** new user row. First-ever user (count == 0) becomes admin. Password column gets 32 random bytes hex (so `hasLocalPassword()` is false → password change / 2FA UI hidden). `email_verified=true`, `oidc_sub` set. |
|
|
|
|
Disabled accounts are blocked (`?error=disabled`).
|
|
|
|
### 3.3 Issuing the session
|
|
|
|
After the user object exists, the callback signs a JWT identical to the
|
|
local-login one (30 d web / 365 d mobile), sets the same `ped_auth`
|
|
cookie, inserts a `user_sessions` row, writes `login_oidc` audit, and
|
|
**redirects back to `/?sso=ok&sid=<sessionId>`**. The frontend
|
|
`public/js/auth.js:53-71` notices `?sso=ok` on boot, calls
|
|
`/api/auth/me` (cookie sent automatically), and enters the app. The
|
|
sessionId is only persisted in mobile SecureStorage (web doesn't need
|
|
it — the cookie is enough for auth, and the "current session" highlight
|
|
in Settings reads `sessionId` server-side via `req.sessionId`).
|
|
|
|
### 3.4 SSO-only accounts
|
|
|
|
A user created via OIDC has a random hex blob in `users.password`, not a
|
|
hash. Two server functions detect this and refuse to operate:
|
|
|
|
- `hasLocalPassword(hash)` (`auth.js:628-630`) — returns true only for
|
|
`$2[aby]$...` (bcrypt) or `$argon2...`.
|
|
- `/setup-2fa` (`auth.js:496-509`) and `/change-password`
|
|
(`auth.js:633-663`) both early-return with
|
|
`"This account uses SSO. ... managed by your identity provider."`.
|
|
|
|
The frontend mirrors this with `canLocalAuth` returned from
|
|
`GET /api/auth/me` (`auth.js:666-682`). The 2FA section, password change
|
|
section, and active sessions section are hidden in Settings when this
|
|
flag is false.
|
|
|
|
---
|
|
|
|
## 4. Two-factor authentication
|
|
|
|
TOTP only (no SMS, no email codes). Implemented with **speakeasy**
|
|
(`src/routes/auth.js:7`). 30-second step, ±1-step verification window.
|
|
|
|
### 4.1 Setup flow
|
|
|
|
`POST /api/auth/setup-2fa` (`auth.js:496-509`):
|
|
|
|
1. Block SSO-only accounts (per §3.4).
|
|
2. `speakeasy.generateSecret({ name: 'PedScribe (' + email + ')',
|
|
issuer: 'Pediatric AI Scribe' })` → returns `base32` secret +
|
|
`otpauth_url`.
|
|
3. **Save the secret** to `users.totp_secret` immediately (so the user
|
|
can come back tomorrow and complete verification with the same
|
|
secret) — but `totp_enabled` stays false until verification succeeds.
|
|
4. `QRCode.toDataURL(otpauth_url)` generates a data: URI for inline
|
|
`<img>` display.
|
|
5. Response: `{ secret: 'BASE32...', qrCode: 'data:image/png;base64,...' }`.
|
|
|
|
The frontend (`public/js/auth.js:329-343`) drops the data URI into
|
|
`<img id="2fa-qr">` and shows the secret in plain text for users whose
|
|
authenticator app doesn't scan QRs (rare, but it happens).
|
|
|
|
### 4.2 Verify (and first-enable backup-code generation)
|
|
|
|
`POST /api/auth/verify-2fa` (`auth.js:511-529`):
|
|
|
|
- `speakeasy.totp.verify({ secret, encoding: 'base32', token: code,
|
|
window: 1 })` — accepts current step or ±1 step.
|
|
- **First time enabling**, generate **10 backup codes** (`auth.js:397-409`):
|
|
- Alphabet `ABCDEFGHJKLMNPQRSTUVWXYZ23456789` — no `0/O/1/I` to avoid
|
|
"is this a zero or an O" support tickets.
|
|
- Format: `XXXXX-XXXXX` (10 chars + dash for readability).
|
|
- bcrypt-hashed at cost 10 and stored as JSON array in
|
|
`users.totp_backup_codes`.
|
|
- **Returned plaintext exactly once**, in the verify response. Frontend
|
|
pops a modal listing them, and the user must save them.
|
|
- Set `totp_enabled = true`.
|
|
|
|
### 4.3 Backup-code consumption (atomic!)
|
|
|
|
`tryConsumeBackupCode(userId, submitted)` (`auth.js:419-461`) is the
|
|
*only* part of auth that explicitly opens a transaction:
|
|
|
|
```js
|
|
await client.query('BEGIN');
|
|
var rowRes = await client.query(
|
|
'SELECT totp_backup_codes FROM users WHERE id = $1 FOR UPDATE',
|
|
[userId]
|
|
);
|
|
// ...bcrypt.compare each hash; if match, splice it out and UPDATE...
|
|
await client.query('COMMIT');
|
|
```
|
|
|
|
Without `FOR UPDATE`, two parallel logins with the same backup code could
|
|
both pass the `bcrypt.compare` and both succeed. The row lock funnels
|
|
them; the second attempt sees the already-spliced array. Audit
|
|
`2fa_backup_code_used` is written on consume.
|
|
|
|
### 4.4 Backup-code regeneration
|
|
|
|
`POST /api/auth/2fa/backup-codes` (`auth.js:464-481`) regenerates the
|
|
full set. Requires the user's current password to authorize (so a stolen
|
|
session cookie alone can't dump fresh codes). Returns plaintext once.
|
|
`GET /api/auth/2fa/backup-codes/count` (`auth.js:484-493`) returns just
|
|
the remaining count for the Settings UI badge.
|
|
|
|
### 4.5 Disable
|
|
|
|
`POST /api/auth/disable-2fa` (`auth.js:531-539`) requires current
|
|
password, then nulls `totp_secret`, `totp_backup_codes`, sets
|
|
`totp_enabled = false`.
|
|
|
|
---
|
|
|
|
## 5. Sessions
|
|
|
|
### 5.1 Schema
|
|
|
|
Effective `user_sessions` columns (read from the inserts):
|
|
|
|
| Column | Source | Used for |
|
|
|---|---|---|
|
|
| `id` (UUID) | `crypto.randomUUID()` (`utils/sessions.js:38-40`) | Stable session identifier returned to clients |
|
|
| `user_id` | FK to users | Lookup, revoke-all-but-this |
|
|
| `token_hash` | SHA-256 of JWT (`utils/sessions.js:3-5`) | Look up session from incoming request without storing the JWT itself |
|
|
| `ip_address` | `req.ip` | Display in Settings |
|
|
| `user_agent` | raw UA (255 char cap) | Audit / forensics |
|
|
| `device_label` | `parseUserAgent(ua)` (`utils/sessions.js:7-36`) | Friendly label like "Chrome on macOS" or "PedScribe (Android)" |
|
|
| `created_at`, `last_activity` | timestamps | Sliding idle timeout, sort order |
|
|
|
|
The token is **never stored**. A leaked DB dump cannot be replayed
|
|
because `SHA-256(jwt)` is one-way.
|
|
|
|
### 5.2 The middleware loop, in detail
|
|
|
|
`src/middleware/auth.js:22-110` does this on every authenticated request:
|
|
|
|
1. Pick token from Bearer or cookie (per §2.6).
|
|
2. `jwt.verify(token, JWT_SECRET)` — invalid/expired → 401
|
|
`"Invalid or expired token"`.
|
|
3. `SELECT id, email, name, role, totp_enabled, disabled FROM users
|
|
WHERE id = ?` — sets `req.user`.
|
|
4. **Disabled?** 403 `"Account disabled. Contact administrator."`.
|
|
5. **Session lookup**: `SELECT id, last_activity FROM user_sessions
|
|
WHERE token_hash = ?`.
|
|
- **If no session and the user has *any* other sessions** → 401
|
|
`"Session revoked"`. (Admin force-logout, password change, or user
|
|
"Revoke" from Settings.)
|
|
- **If no session at all** → fail open. Pre-migration users from before
|
|
the `user_sessions` table existed would be locked out otherwise.
|
|
6. **Sliding idle timeout** (web only):
|
|
- `idleMs = NOW() - last_activity`.
|
|
- If `!mobile && idleMs > 24h`: write `session_idle_timeout` audit
|
|
with the minute count, delete the row, clear cookie, return 401
|
|
`{ error: 'Session expired due to inactivity', idleTimeout: true }`.
|
|
The frontend `authFetch.js` interceptor will catch this and bounce
|
|
to login.
|
|
7. **Activity refresh** (`middleware/auth.js:85-100`):
|
|
- **Only on POST/PUT/DELETE/PATCH** — GET requests do not extend the
|
|
session. This is intentional. Without it, a polling dashboard or
|
|
`/api/auth/me` heartbeat would keep a session alive forever.
|
|
- **Throttled to once per 10 min** to avoid hammering the DB on
|
|
burst traffic.
|
|
- On refresh, also re-set the cookie with a fresh 30 d maxAge so the
|
|
browser-side cookie slides too.
|
|
|
|
### 5.3 The Sessions API
|
|
|
|
`src/routes/sessions.js` (entire file is 53 lines):
|
|
|
|
| Method | Path | Purpose |
|
|
|---|---|---|
|
|
| GET | `/api/sessions` | List all sessions for `req.user.id` plus `currentSessionId`. The frontend marks the current row "this device". |
|
|
| DELETE | `/api/sessions/:id` | Revoke one specific session. Refuses to revoke `req.sessionId` (the current one) — the user must use logout for that. |
|
|
| DELETE | `/api/sessions` | Revoke all *other* sessions (keeps `req.sessionId`). Also fires automatically inside `change-password` (`auth.js:651-653`). |
|
|
|
|
### 5.4 Logout
|
|
|
|
`POST /api/auth/logout` (`auth.js:611-623`) deletes the row matching the
|
|
incoming token's hash, clears the cookie. The frontend additionally:
|
|
|
|
- Broadcasts `{ type: 'logout' }` on `BroadcastChannel('pedscribe-auth')`
|
|
so other open tabs immediately drop their UI
|
|
(`public/js/auth.js:213-217`, `public/js/authFetch.js:18-30`).
|
|
- Clears every `caches.delete()` entry so the next person on a shared
|
|
device can't read cached pages from the service worker
|
|
(`public/js/auth.js:251-257`).
|
|
|
|
---
|
|
|
|
## 6. authMiddleware — the contract
|
|
|
|
Three functions exported from `src/middleware/auth.js:128`:
|
|
|
|
```js
|
|
module.exports = { authMiddleware, adminMiddleware, moderatorMiddleware, JWT_SECRET };
|
|
```
|
|
|
|
Usage pattern in routers:
|
|
|
|
| Pattern | Example | Effect |
|
|
|---|---|---|
|
|
| `router.use(authMiddleware)` at the top | `src/routes/sessions.js:6` | Every route in this file requires a valid session. |
|
|
| `router.use(authMiddleware); router.use(adminMiddleware)` | `src/routes/admin.js:13-14` | Admin-only file. |
|
|
| Per-route | `router.post('/setup-2fa', authMiddleware, async (req, res) => ...)` | When some routes in the file are public (e.g. `/check-password`, `/forgot-password`). |
|
|
|
|
Public surface (no `authMiddleware` mounted): `/api/auth/login`,
|
|
`/register`, `/forgot-password`, `/reset-password`, `/verify-email`,
|
|
`/resend-verification`, `/check-password`, `/registration-status`,
|
|
`/oidc-status`, `/oidc`, `/oidc/callback`, `/api/health`,
|
|
`/api/build`, `/api/models`. Everything else is gated.
|
|
|
|
Failure modes (consistent across the codebase):
|
|
|
|
| Status | Body | When |
|
|
|---|---|---|
|
|
| 401 | `{ error: 'Authentication required' }` | No token at all |
|
|
| 401 | `{ error: 'Invalid or expired token' }` | jwt.verify threw |
|
|
| 401 | `{ error: 'User not found' }` | Token decoded, user row missing (deleted while session was alive) |
|
|
| 401 | `{ error: 'Session revoked' }` | Token decoded, but no `user_sessions` row and the user has other sessions (so it's not the pre-migration case) |
|
|
| 401 | `{ error: 'Session expired due to inactivity', idleTimeout: true }` | Web-only, > 24 h idle |
|
|
| 403 | `{ error: 'Account disabled. Contact administrator.' }` | `users.disabled = true` |
|
|
| 403 | `{ error: 'Admin access required' }` | adminMiddleware hit |
|
|
| 403 | `{ error: 'Moderator or admin access required' }` | moderatorMiddleware hit |
|
|
|
|
`req.user` after success is `{ id, email, name, role, totp_enabled,
|
|
disabled }` — minimal, no PHI, no password hash. `req.sessionId` is set
|
|
when a `user_sessions` row matched.
|
|
|
|
---
|
|
|
|
## 7. OpenBao secret loading
|
|
|
|
`docker-entrypoint.sh` is the Dockerfile `ENTRYPOINT`. It runs **before**
|
|
Node, with the goal of populating `process.env` from a real secret store
|
|
instead of from a `.env` file in the image or on the host.
|
|
|
|
### 7.1 Behaviour
|
|
|
|
```sh
|
|
if [ -n "${OPENBAO_ADDR:-}" ]; then
|
|
# ... fetch from OpenBao ...
|
|
else
|
|
echo "[entrypoint] OPENBAO_ADDR not set — using existing environment (legacy .env path)"
|
|
fi
|
|
exec "$@"
|
|
```
|
|
|
|
If `OPENBAO_ADDR` is unset (e.g., e2e container, local dev with a
|
|
populated `.env`), the script no-ops and `exec`s straight to Node. **This
|
|
is the "no production change without explicit opt-in" guarantee.**
|
|
|
|
### 7.2 The opt-in path (`docker-entrypoint.sh:14-77`)
|
|
|
|
1. **Require AppRole credentials**. `OPENBAO_ROLE_ID` and
|
|
`OPENBAO_SECRET_ID` are mandatory; missing → fatal exit.
|
|
2. **Authenticate** via `bao write -field=token auth/approle/login`.
|
|
The returned client token is exported as `BAO_TOKEN` (the standard
|
|
variable the `bao` CLI reads).
|
|
3. **Fetch** `kv/ped-ai/prod` (or whatever `OPENBAO_KV_PATH` points to).
|
|
`bao kv get -format=json | jq -c '.data.data'` produces a flat JSON
|
|
object of `{ KEY: "value", ... }`.
|
|
4. **Snapshot the current env** (`env | cut -d= -f1 | sort -u`). Anything
|
|
already set by docker (env_file, environment: block) wins over what
|
|
OpenBao returns. This is critical: the e2e container sets
|
|
`TURNSTILE_SECRET_KEY=""` and `SMTP_HOST=""` to *disable* those
|
|
features in tests, and we don't want OpenBao silently re-enabling them.
|
|
5. **Iterate via temp file + `while read`** (not pipe-into-while —
|
|
that runs the loop in a subshell and exports are lost). For each
|
|
key: if not in the snapshot, `eval "export $K=$VAL_QUOTED"`.
|
|
`jq -r 'to_entries[] | "\(.key)\t\(.value | @sh)"'` makes the values
|
|
shell-safe.
|
|
6. **Wipe the bootstrap creds**: `unset OPENBAO_ROLE_ID OPENBAO_SECRET_ID
|
|
BAO_TOKEN`. The Node process never sees them — if it gets popped, the
|
|
attacker can't pivot to OpenBao with what's in env.
|
|
7. `exec "$@"` hands control to whatever `CMD` the Dockerfile set
|
|
(typically `node server.js`).
|
|
|
|
### 7.3 Why this matters
|
|
|
|
- **Production secrets aren't on the host filesystem.** No `.env` to leak
|
|
via a backup, a snapshot, or `docker cp`. The image is pristine; the
|
|
secrets live only in the running process's `/proc/<pid>/environ`.
|
|
- **Rotation without rebuilds.** Update the OpenBao value, restart the
|
|
container, done.
|
|
- **AppRole > root token.** AppRole policies can be scoped to read-only
|
|
on exactly one path. A compromised container reads one secret bundle
|
|
and nothing else.
|
|
- **The OpenBao service itself** lives at `app.danvics.com` (per
|
|
`MEMORY.md` → `project_secret_management.md`). Vaultwarden is for
|
|
human logins; OpenBao is for service secrets.
|
|
|
|
---
|
|
|
|
## 8. Admin panel architecture
|
|
|
|
`public/js/admin.js` is ~1,500 lines, but it's organized as **six
|
|
parallel IIFEs**, each owning a distinct admin section. Every IIFE
|
|
listens for `tabChanged` to lazy-load and uses event-delegation on
|
|
`document.click` to handle its buttons. State is kept module-private,
|
|
no global pollution.
|
|
|
|
### 8.1 The six sections
|
|
|
|
| Lines | IIFE | What it does |
|
|
|---|---|---|
|
|
| `1-261` | **Users + global stats** | Fetches `/api/admin/users`, renders the table with verify / disable / role-change / delete / reset-password buttons. Pulls `/api/admin/settings` for `totalUsers`, `totalApiCalls`, `todayApiCalls`. Renders the audit-log viewer (`/api/admin/logs/all`). Shows the registration toggle. |
|
|
| `263-660` | **CMS** (announcements, feature flags, prompts, email, SMTP, OIDC) | Renders `/api/admin/config/prompts` for the prompt-editor section, `app_settings` rows starting with `feature.` / `announcement.` / `email.` for the rest. Each input PUTs to `/api/admin/config/:key`. |
|
|
| `662-1002` | **Model management** | Calls `/api/admin/config/models` to show the provider's built-in list with checkboxes (toggle calls `/api/admin/config/models/toggle`). The "Discover" button hits `/api/admin/config/models/discover` to query the live provider for new models. The "Test" button posts a 1-token "Reply with only the word: OK" to `/api/admin/config/models/test`. Custom-add form posts to `/api/admin/config/models/custom`. |
|
|
| `1004-1186` | **TTS management** | `/api/admin/config/tts` for status + DB-stored voice/model. Discover + Test analogous to model section. Plays returned base64 audio inline. |
|
|
| `1188-1376` | **STT management** | `/api/admin/config/stt`. The Test button records mic audio (MediaRecorder), POSTs base64 to `/api/admin/config/stt/test`, displays the transcript and round-trip latency. |
|
|
| `1378-1533` | **Embeddings** | `/api/admin/config/embeddings` for the model + dimensions setting. Test endpoint embeds a sample string and returns the first 8 vector values + duration. |
|
|
|
|
There is no separate admin HTML route — it's a tab inside the main SPA
|
|
(`public/index.html`), shown only when `user.role === 'admin'`
|
|
(toggle in `public/js/auth.js:178-182`). The tab content lives inline
|
|
in `index.html` and is populated by these IIFEs on first activation.
|
|
|
|
### 8.2 Server-side gating
|
|
|
|
Every endpoint above is mounted under `/api/admin` and the routers all
|
|
do `router.use(authMiddleware); router.use(adminMiddleware)` at the top
|
|
(`src/routes/admin.js:13-14`, `adminConfig.js:13` + `:30`,
|
|
`adminMilestones.js` uses `adminMiddleware` per-route). The frontend
|
|
visibility is purely cosmetic — a non-admin who manually fetches
|
|
`/api/admin/users` gets `403 { error: 'Admin access required' }`.
|
|
|
|
### 8.3 Audit on every mutation
|
|
|
|
All admin write endpoints write `logger.audit(req.user.id,
|
|
'admin_<action>', '<details>', req, { category: 'admin' })`. Examples
|
|
from the codebase:
|
|
|
|
- `admin_verify_user`, `admin_disable_user`, `admin_enable_user`
|
|
(`admin.js:54-104`)
|
|
- `admin_change_role`, `admin_delete_user`, `admin_reset_password`
|
|
(`admin.js:109-175`)
|
|
- `admin_toggle_registration` (`admin.js:180-191`)
|
|
- `admin_config_update`, `admin_config_reset_all`,
|
|
`admin_config_reset` (`adminConfig.js:114, 140, 981`)
|
|
- `admin_model_toggle`, `admin_model_default`, `admin_model_add`,
|
|
`admin_model_update`, `admin_model_delete`,
|
|
`admin_models_clear_all`, `admin_model_discover_add`
|
|
(`adminConfig.js:256-329`)
|
|
- `admin_smtp_update`, `admin_smtp_clear` (`adminConfig.js:181-194`)
|
|
|
|
These rows answer the question "who turned off Turnstile last
|
|
Wednesday?" in a single SQL query against `audit_log WHERE category =
|
|
'admin'`.
|
|
|
|
### 8.4 Milestones editor
|
|
|
|
`src/routes/adminMilestones.js` is the simplest of the admin routes —
|
|
plain CRUD on `developmental_milestones (age_group, domain,
|
|
milestone_text, sort_order)`. The `bulk-import` endpoint accepts an
|
|
array of rows + a `clearExisting` flag for re-seeding from the static
|
|
data files. There's no audit on these because the table is the audit
|
|
(it's small enough to diff). The frontend admin UI for this is in the
|
|
"Milestones" sub-tab of the admin panel.
|
|
|
|
---
|
|
|
|
## 9. The model allowlist
|
|
|
|
This is the single most security-relevant admin control because it
|
|
gates *spend*. Without it, any authenticated user could POST
|
|
`{ model: "openai/o1-pro" }` to any AI route and bill the operator for
|
|
$60/M tokens.
|
|
|
|
### 9.1 Two gates
|
|
|
|
**Gate 1 (UI):** `/api/models` (`server.js:242-252`, **public** by
|
|
design — frontend dropdowns need it before login on the auth screen for
|
|
some pages, and the response contains no secrets) returns
|
|
`getAvailableModelsWithOverrides(db)`. That function
|
|
(`src/utils/models.js:203-223`) starts from the provider's built-in
|
|
list, filters out everything in the `models.disabled` array, and
|
|
appends anything in the `models.custom` array. So the admin can both
|
|
*hide* built-ins they don't want users picking and *add* models the
|
|
provider supports but aren't in the hardcoded list (e.g. a fresh
|
|
OpenRouter release).
|
|
|
|
**Gate 2 (server):** `callAI(messages, options)`
|
|
(`src/utils/ai.js:401-431`) re-checks the requested model against
|
|
`getAllowedModelIds(db)` (`models.js:233-249`, 60 s cache). If a model
|
|
ID is provided and isn't in the allowed Set, the call throws
|
|
`Error('Model not permitted')` with `code: 'model_not_permitted'`.
|
|
**A client cannot bypass the dropdown by hand-crafting a request body** —
|
|
even if they snoop the API and find a model ID, the server refuses it.
|
|
|
|
### 9.2 Cache semantics
|
|
|
|
The 60 s TTL on `ALLOWED_MODELS_CACHE` (`models.js:229-249`) means an
|
|
admin toggle propagates within a minute. There's a manual
|
|
`invalidateAllowedModelsCache()` exported but currently nothing in the
|
|
admin routes calls it — toggle latency is just the TTL. Acceptable for
|
|
an operator-facing change.
|
|
|
|
### 9.3 The `skipAllowlistCheck` escape hatch
|
|
|
|
`adminConfig.js:374-395` (`/api/admin/config/models/test`) and a few
|
|
other admin test endpoints pass `skipAllowlistCheck: true` so the admin
|
|
can verify a model *before* adding it to the allowlist. This option is
|
|
**not exposed** to user-facing routes. It is also not in the list of
|
|
keys accepted via `/api/models` config wildcards (§10) — only the admin
|
|
path can set it, and it's a runtime option object, not a setting.
|
|
|
|
### 9.4 Default model
|
|
|
|
`models.default` in `app_settings` overrides `DEFAULT_MODEL` for any
|
|
caller that doesn't pass an explicit `model` option. Set via
|
|
`PUT /api/admin/config/models/default` (`adminConfig.js:262-270`). The
|
|
`/api/models` endpoint includes `defaultModel` in its response so the
|
|
frontend can pre-select it in dropdowns.
|
|
|
|
---
|
|
|
|
## 10. Prompt overrides
|
|
|
|
`src/utils/prompts.js` is a single object (`PROMPTS`) holding every
|
|
LLM system prompt the app uses (HPI, hospital course, SOAP refine,
|
|
chart-review summary, etc.). Hardcoded values live at the top of the
|
|
file (~530 lines of prompt text) so a deploy can ship sane defaults.
|
|
|
|
### 10.1 Override mechanism
|
|
|
|
At server boot (`server.js:330-332`):
|
|
|
|
```js
|
|
const PROMPTS = require('./src/utils/prompts');
|
|
const db = require('./src/db/database');
|
|
setTimeout(() => { PROMPTS.loadFromDb(db); }, 3000);
|
|
```
|
|
|
|
The 3 s delay gives the Postgres pool time to come up. `loadFromDb`
|
|
(`prompts.js:535-547`) iterates every key in `PROMPTS`, reads
|
|
`app_settings` row `prompt.<key>`, and if non-empty, replaces
|
|
`PROMPTS[key]` in memory.
|
|
|
|
Routes use `require('../utils/prompts').hpiEncounter` synchronously —
|
|
they never know whether they're getting the hardcoded value or the
|
|
DB override. That makes overrides **transparent to route code**. No
|
|
async cost per request.
|
|
|
|
### 10.2 Live edit
|
|
|
|
When an admin saves a prompt via `PUT /api/admin/config/prompt.<key>`
|
|
(falls into the wildcard at `adminConfig.js:957-985`), the handler
|
|
both writes to `app_settings` and immediately calls
|
|
`PROMPTS.updatePrompt(promptKey, value)` (`prompts.js:549-553`) to
|
|
update the in-memory copy. **No restart required.** The next AI call
|
|
picks it up on the same request.
|
|
|
|
### 10.3 Reset to default
|
|
|
|
`POST /api/admin/config/prompts/:key/reset` (`adminConfig.js:101-117`):
|
|
|
|
1. DELETE the `prompt.<key>` row from `app_settings`.
|
|
2. `delete require.cache[require.resolve('../utils/prompts')]` to
|
|
evict the cached module.
|
|
3. `var fresh = require('../utils/prompts')` re-loads the file (which
|
|
contains the hardcoded defaults).
|
|
4. Copy `fresh[key]` back into the live `PROMPTS[key]`.
|
|
|
|
This avoids needing a separate "factory defaults" map.
|
|
|
|
---
|
|
|
|
## 11. Per-user preferences
|
|
|
|
`src/routes/userPreferences.js` is mounted at `/api/user`
|
|
(`server.js:302`). Two columns on `users` back this:
|
|
|
|
- `users.stt_model` — preferred speech-to-text model (string)
|
|
- `users.tts_voice` — preferred text-to-speech voice (string)
|
|
|
|
`webdav_learning_path` and Nextcloud columns are also per-user but
|
|
managed elsewhere (`src/routes/nextcloud.js`, plus the
|
|
`POST /api/user/webdav-path` shortcut at `server.js:309-314`).
|
|
|
|
### 11.1 Endpoints
|
|
|
|
| Method | Path | Returns / accepts |
|
|
|---|---|---|
|
|
| GET | `/api/user/preferences` | `{ stt_model, tts_voice }` (nullable) |
|
|
| POST | `/api/user/preferences` | `{ stt_model, tts_voice }` — both nullable, written verbatim |
|
|
| GET | `/api/user/preferences/options` | The full lists of available STT models and TTS voices for the *currently configured* providers (see `userPreferences.js:45-155`). The provider is auto-detected from env vars (Vertex, AWS, LiteLLM, OpenAI, ElevenLabs). |
|
|
|
|
### 11.2 Where they're consumed
|
|
|
|
- `src/routes/transcribe.js` reads `users.stt_model` to override the
|
|
default Whisper/Gemini model when the user has set one.
|
|
- `src/routes/tts.js` reads `users.tts_voice` to pick the right voice.
|
|
- The Settings page (`public/components/settings.html`) pulls
|
|
`/api/user/preferences/options`, renders dropdowns scoped to the
|
|
active provider, and POSTs back on change.
|
|
|
|
### 11.3 Why per-user, not per-role
|
|
|
|
A pediatrician may prefer Gemini 2.0 Flash for speed; a colleague
|
|
might prefer Whisper-1 for accent robustness. There's no admin policy
|
|
restricting this — the admin chose which providers exist, the user
|
|
picks within that.
|
|
|
|
---
|
|
|
|
## 12. Learning Hub
|
|
|
|
Three backend files plus a single large frontend module
|
|
(`public/js/learningHub.js`, ~1,800 lines). The Learning Hub is the
|
|
**only feature in the app where a non-admin user (specifically: a
|
|
moderator) gets server-side write access**. That's why it has its own
|
|
middleware tier.
|
|
|
|
### 12.1 Architecture
|
|
|
|
```
|
|
┌───────────────────────────────────┐
|
|
│ public/js/learningHub.js │
|
|
│ ┌────────────┐ ┌─────────────┐ │
|
|
│ │ Reader UI │ │ CMS UI │ │
|
|
│ │ (any user) │ │ (mod+admin) │ │
|
|
│ └─────┬──────┘ └──────┬──────┘ │
|
|
└────────┼─────────────────┼────────┘
|
|
│ │
|
|
/api/learning/* /api/admin/learning/*
|
|
│ │
|
|
▼ ▼
|
|
src/routes/learningHub.js src/routes/learningAdmin.js
|
|
src/routes/learningAI.js
|
|
│
|
|
┌─────────────┼──────────────┐
|
|
▼ ▼ ▼
|
|
callAI() (model pgvector Marp / pptxgenjs
|
|
allowlist) embeddings (presentation)
|
|
```
|
|
|
|
### 12.2 Read paths (`src/routes/learningHub.js`)
|
|
|
|
Mounted at `/api/learning`, gated only by `authMiddleware`
|
|
(`learningHub.js:11`). Endpoints:
|
|
|
|
- `GET /categories` — list `learning_categories` ordered by
|
|
`sort_order, name`.
|
|
- `GET /feed?limit&offset` — last N published items, with author + a
|
|
question count subquery.
|
|
- `GET /category/:slug` — items within a category.
|
|
- `GET /content/:slug` — single item with all questions, all options
|
|
(without `is_correct` so the client can't peek), and the user's last
|
|
5 attempts from `learning_progress`.
|
|
- `GET /content/:slug/slides` — for `content_type = 'presentation'`,
|
|
renders the Marp markdown to HTML server-side and returns an array of
|
|
`<section>` elements + the Marp CSS for the slide-viewer modal in the
|
|
reader UI.
|
|
- `POST /submit-quiz` — accepts `{ contentId, answers: [{ questionId,
|
|
optionId | optionIds }] }` and grades. Supports both `mcq`
|
|
(single-correct) and `multi` (all-and-only-correct, see
|
|
`learningHub.js:178-193`). Score + total written to
|
|
`learning_progress`.
|
|
- `GET /search?q=` — keyword `ILIKE '%q%'` against title/subject/body.
|
|
- `GET /search/semantic?q=` — pgvector cosine similarity via
|
|
`searchSimilar` from `src/utils/embeddings.js`.
|
|
- `GET /search/hybrid?q=` — keyword + semantic merged, semantic first
|
|
(higher precision), keyword as backfill.
|
|
|
|
### 12.3 Write paths (`src/routes/learningAdmin.js`)
|
|
|
|
Mounted at `/api/admin/learning`. **Note the mount order in
|
|
`server.js:234-236`** — the Learning Hub admin router is registered
|
|
*before* the general `/api/admin` router because moderators need
|
|
access to `/api/admin/learning/*` but should be rejected from other
|
|
`/api/admin/*` routes. Both routers technically match `/api/admin/...`
|
|
on the prefix; Express dispatches to the first one that has a matching
|
|
route. The Learning Hub router uses `moderatorMiddleware`, the general
|
|
admin router uses `adminMiddleware`.
|
|
|
|
CRUD endpoints (all `authMiddleware` + `moderatorMiddleware`):
|
|
|
|
- Categories: `GET /categories`, `POST`, `PUT /:id`, `DELETE /:id`
|
|
(delete sets dependent content to `category_id = NULL` rather than
|
|
cascading).
|
|
- Content: `GET /content`, `GET /content/:id`, `POST /content`,
|
|
`PUT /content/:id`, `DELETE /content/:id`. POST and PUT both kick
|
|
off **embedding generation in the background** (don't block the
|
|
response — the editor flow stays snappy and the embedding is
|
|
available within a few seconds).
|
|
- Questions: `POST /content/:contentId/questions`, `PUT /questions/:id`,
|
|
`DELETE /questions/:id`. PUT replaces the full options array
|
|
atomically (DELETE all, INSERT each).
|
|
- Stats: `GET /stats`.
|
|
- Embeddings management: `POST /embeddings/generate` (background batch
|
|
for all unembedded content; auto-creates the `ivfflat` index when ≥10
|
|
embeddings exist) and `GET /embeddings/status`.
|
|
|
|
### 12.4 AI generation (`src/routes/learningAI.js`)
|
|
|
|
This is where the Learning Hub gets interesting. Mounted at
|
|
`/api/admin/learning` (same mount), **also** moderator-gated. Three
|
|
endpoints:
|
|
|
|
#### `POST /ai-generate`
|
|
|
|
Accepts either `multipart/form-data` (with `files[]`) or JSON. The
|
|
moderator picks a content type — `article`, `pearl`, `quiz`, or
|
|
`presentation` — plus a topic, optional refinement instructions,
|
|
optional question count, optional word count / slide count, and
|
|
optionally a model.
|
|
|
|
Source material flows in through one of three channels:
|
|
|
|
1. **Uploaded files** (multer memory storage, 100 MB / file, 10 file
|
|
max). `extractText` (`learningAI.js:48-133`) handles PDF
|
|
(pdf-parse), DOCX (mammoth), PPTX (JSZip → strip XML tags), ODT,
|
|
EPUB, plus plain text/markdown/HTML/CSV/JSON. Multiple files are
|
|
concatenated with `### Source File: <name>` headers.
|
|
2. **Nextcloud WebDAV path** (`webdavPath` field). Pulls
|
|
`nextcloud_token` from the user's row, decrypts via
|
|
`cryptoUtil.decryptString` (AES-256-GCM, see authentication.md
|
|
§"Encryption at rest"), HTTP-GETs the file from
|
|
`<nextcloud_url>/remote.php/dav/files/<user>/<path>` with Basic
|
|
auth, runs the same `extractText`.
|
|
3. **Topic-only** — no file, just a string. Falls back to a generic
|
|
"Generate educational content on the following topic" prompt.
|
|
|
|
`buildGeneratePrompt` (`learningAI.js:137-237`) builds the system
|
|
prompt. Key constraints:
|
|
|
|
- Document text is truncated to 50,000 chars before injection (the LLM
|
|
gets the first chunk, embeddings get the first 8,000 — see §12.5).
|
|
- For `presentation` with no questions: returns Marp markdown directly,
|
|
no JSON wrapper.
|
|
- For `presentation` with questions: returns a JSON object with both
|
|
`marpMarkdown` and `questions[]` so the editor can save both at once.
|
|
- For everything else: returns a strict JSON schema with `title`,
|
|
`subject`, `body` (HTML, allowed tags only — `p, h2, h3, ul, ol, li,
|
|
strong, em, blockquote, code`), and `questions[]`.
|
|
- Each MCQ must have exactly 4 options, exactly 1 correct.
|
|
- `question_type` must be `mcq` or `true_false`.
|
|
|
|
The call goes through `callAI` (`learningAI.js:303-309`), so the
|
|
**model allowlist applies here too**. A moderator who tries to pass a
|
|
disallowed model gets `Error('Model not permitted')`.
|
|
|
|
After the LLM returns, `learningAI.js:311-383` does aggressive
|
|
JSON-recovery: strip markdown fences, trim trailing commentary, then
|
|
sanitize any literal newlines/tabs that landed inside string values
|
|
(models occasionally do this), then retry. After 3 attempts it gives
|
|
up with `"AI returned invalid JSON. Try again."` — UX > silent
|
|
corruption.
|
|
|
|
#### `POST /ai-refine`
|
|
|
|
Takes existing HTML body + free-text instructions, asks the model to
|
|
return refined HTML only (no JSON wrapper). Used by the editor's
|
|
"Refine with AI" button to make targeted edits.
|
|
|
|
#### `GET /webdav-browse?path=...`
|
|
|
|
Issues a `PROPFIND` (Depth: 1) against the user's Nextcloud and
|
|
returns the directory listing as `{ path, parentPath, items: [{path,
|
|
name, isDir, contentType, size}] }`. The frontend uses this to render
|
|
a file picker before generation.
|
|
|
|
#### `POST /webdav-path`
|
|
|
|
Saves the user's "default WebDAV folder for learning content" so the
|
|
picker remembers where they like to browse from.
|
|
|
|
#### `POST /generate-pptx`
|
|
|
|
Takes Marp markdown, **converts to a real .pptx** server-side via
|
|
`pptxgenjs` (no headless browser required). The conversion
|
|
(`learningAI.js:529-777`) is hand-rolled: it parses each slide's
|
|
markdown line by line, classifies into bullet/ordered/heading/
|
|
paragraph/table/blockquote/code-fence, and renders each with
|
|
appropriate `pptx.addText`/`pptx.addTable`/`pptx.addShape` calls.
|
|
Inline `**bold**`, `*italic*`, `***both***`, and `` `code` `` are
|
|
parsed via `parseInline` into pptxgenjs text-run objects. Tables get
|
|
striped rows. Blockquotes get a blue left bar. Code blocks get a grey
|
|
rounded background.
|
|
|
|
#### `POST /preview-slides`
|
|
|
|
Server-side Marp render → array of `<section>` HTML strings + CSS, for
|
|
the in-browser preview before saving (or before downloading the
|
|
.pptx).
|
|
|
|
### 12.5 Embeddings
|
|
|
|
`src/utils/embeddings.js` (cited briefly per the brief). `generateEmbedding(text, opts)`:
|
|
|
|
1. Read `embeddings.model` and `embeddings.dimensions` from
|
|
`app_settings` (admin-controlled), fall back to env vars, then
|
|
built-in defaults (`vertex_ai/text-embedding-005`, 768 dims).
|
|
2. Truncate input to 8,000 chars (~2,000 tokens).
|
|
3. Try LiteLLM first (`generateEmbeddingLiteLLM`), then Vertex direct,
|
|
then OpenAI. First configured wins.
|
|
|
|
`generateContentEmbedding({ title, subject, body })` concatenates the
|
|
three with separators and embeds the result. Stored as JSON-serialized
|
|
array in `learning_content.embedding` (a `vector` column when pgvector
|
|
is installed). The `ivfflat` index is created opportunistically
|
|
(`learningAdmin.js:373-383`) when ≥10 embeddings exist with
|
|
`lists = max(10, sqrt(count))`.
|
|
|
|
`searchSimilar(query, opts)` embeds the query and runs
|
|
`SELECT ... ORDER BY embedding <=> $1::vector LIMIT n` (cosine
|
|
distance). The hybrid search merges these with keyword results.
|
|
|
|
### 12.6 Output formats
|
|
|
|
| Type | What's saved | What's served |
|
|
|---|---|---|
|
|
| `article` | HTML in `body` | Renders inline in reader |
|
|
| `pearl` | HTML in `body` (shorter, 2-4 paragraphs) | Renders inline |
|
|
| `quiz` | HTML intro in `body`, questions in `learning_questions` | Reader shows intro then quiz UI |
|
|
| `presentation` | Marp markdown in `body` | Reader has a slide-viewer modal that renders via `marp-core` server-side and paginates `<section>` elements client-side. Optional `.pptx` download via `/generate-pptx`. |
|
|
|
|
### 12.7 Optional Nextcloud upload
|
|
|
|
After saving a presentation, the moderator can upload the rendered
|
|
`.pptx` to their Nextcloud (`learning-hub.md` documents this; the
|
|
upload itself goes through `src/routes/nextcloud.js` which uses the
|
|
same encrypted `nextcloud_token`).
|
|
|
|
---
|
|
|
|
## 13. Audit log + access log + api log
|
|
|
|
Three tables, three purposes, one writer (`src/utils/logger.js` →
|
|
`src/utils/auditQueue.js`).
|
|
|
|
### 13.1 The three tables
|
|
|
|
| Table | Schema (key cols) | What writes | Why |
|
|
|---|---|---|---|
|
|
| `audit_log` | `user_id, action, category, details, ip_address, user_agent, model_used, tokens_used, duration_ms, status, timestamp` | `logger.audit(...)` from auth, admin, learning admin, encounters | Security/compliance forensic trail |
|
|
| `api_log` | `user_id, endpoint, method, status_code, request_size, response_size, model_used, tokens_input, tokens_output, cost_estimate, duration_ms, ip_address, error, timestamp` | `loggingMiddleware` (`src/middleware/logging.js`) wraps `res.json` and writes one row per non-GET `/api/*` response (skips `/api/health`, `/api/models`) | Cost tracking, performance, debugging |
|
|
| `access_log` | `user_id, action, ip_address, user_agent, success, timestamp` | `logger.access(...)` from auth flow specifically (`login`, `login_failed`, `login_blocked`) | High-cardinality auth-only feed for SIEM-style queries |
|
|
|
|
### 13.2 Batched writer
|
|
|
|
`src/utils/auditQueue.js` is a per-table in-memory ring with **1-second
|
|
flush** and **50-row max batch**. Reduces per-request DB load from
|
|
"one INSERT per audit call" to "one INSERT per ~50 calls under load".
|
|
Tradeoff: up to ~1 s of audit entries lost on crash. The Postgres row
|
|
is the primary destination; Loki is shipped fire-and-forget per call
|
|
(unbatched — Loki has its own ingestion).
|
|
|
|
On `SIGTERM`, `server.js:357-364` calls `queues.drainAll()` before
|
|
closing the DB pool, so a clean restart doesn't lose buffered entries.
|
|
|
|
### 13.3 Loki shipping
|
|
|
|
`logger.js:14-29` pushes every audit/api/access entry to Loki at
|
|
`LOKI_URL/loki/api/v1/push` (when configured). Labels include
|
|
`{ app: 'pedscribe', type: 'audit'|'api_call'|'access', action,
|
|
category, status, endpoint }`. The body is JSON-stringified detail.
|
|
Used for the Grafana dashboard checked into `grafana-dashboard.json`.
|
|
|
|
### 13.4 PHI redaction
|
|
|
|
Every `audit_log.details` value passes through `src/utils/redact.js`
|
|
before insert (`logger.js:53-54`). Patterns: SSN, phone, email, DoB.
|
|
500-char cap. Note-body heuristic truncation to keep transcript
|
|
fragments out of the audit. Same `redact()` is applied to file logs
|
|
(`logger.js:135-142`) so a stray `req.body` log line can't leak
|
|
patient data.
|
|
|
|
### 13.5 Retention
|
|
|
|
Currently no automated retention/expiry on these tables. Operator can
|
|
truncate or partition manually. The README notes future work; for now
|
|
the `category` column lets you scope deletes (e.g.,
|
|
`DELETE FROM audit_log WHERE category = 'general' AND timestamp <
|
|
NOW() - INTERVAL '90 days'`).
|
|
|
|
---
|
|
|
|
## 14. Email
|
|
|
|
`src/routes/auth.js:147-172` defines `getSmtpTransport()` and
|
|
`sendEmail()`. The lookup precedence for SMTP config:
|
|
|
|
1. `app_settings` row (`smtp.host`, `smtp.port`, `smtp.user`,
|
|
`smtp.pass`, `smtp.from`, `smtp.secure`) — admin-controlled via
|
|
`PUT /api/admin/config/smtp` (`adminConfig.js:167-184`).
|
|
2. Env vars (`SMTP_HOST`, etc.) as fallback.
|
|
3. If neither is present, `sendEmail` logs `"[Email] SMTP not
|
|
configured. Would send to: ..."` and returns `false`. The route
|
|
that called it sees the false and degrades gracefully (e.g.,
|
|
registration auto-verifies the user instead of sending a verify
|
|
link — see §2.1 step 9).
|
|
|
|
Email templates (`emailWrapper`, `btnHtml`, `linkFallback`) live at
|
|
`auth.js:82-144`. Subjects and bodies are also overridable from
|
|
`app_settings` (`email.verify.subject`, `email.verify.body`,
|
|
`email.reset.subject`, `email.reset.body`) so the operator can rewrite
|
|
copy without a deploy.
|
|
|
|
The actual emails sent:
|
|
|
|
- **Verify email** (registration + resend) — single button + 24 h
|
|
link (`auth.js:223-229`, `auth.js:286-292`).
|
|
- **Password reset** — single button + 1 h link (`auth.js:580-585`).
|
|
- **Test email** (admin only) — `POST /api/admin/config/test-email`
|
|
reuses `__sendEmail`/`__emailWrapper`/`__btnHtml` from
|
|
`routes/auth.js` exports (`auth.js:692-697`). Lets the admin send
|
|
themselves a sample to verify SMTP without going through the user
|
|
flow.
|
|
|
|
`utils/notify.js` is **separate** — it's ntfy push, not email. Sends
|
|
on new login, password change, new registration (admin), failed login
|
|
attempts (admin), encounter expiring. Uses `NTFY_URL` and
|
|
`NTFY_TOKEN` env vars.
|
|
|
|
---
|
|
|
|
## 15. Sacred zones
|
|
|
|
These are paths where changes need extra scrutiny and where "small
|
|
refactors" have historically caused outages. Per Daniel's working
|
|
preferences (see `MEMORY.md`):
|
|
|
|
### 15.1 The auth flow itself
|
|
|
|
**Don't change without a security review.** Specific patterns that
|
|
must be preserved:
|
|
|
|
- **Enumeration-resistant login** (`auth.js:319-326`). The
|
|
`bcrypt.compare(password, DUMMY_HASH)` on the unknown-user branch
|
|
exists to keep wall-clock latency identical to the password-check
|
|
branch. Removing it leaks "is this email registered?" via timing.
|
|
- **Atomic backup-code consume** (`auth.js:419-461`). The
|
|
`BEGIN ... SELECT FOR UPDATE ... COMMIT` is what stops two parallel
|
|
logins with the same code from both succeeding. Don't "simplify" it
|
|
to a non-transactional read-modify-write.
|
|
- **Timing-safe forgot-password** (`auth.js:548-591`). The response
|
|
is returned **before** the DB lookup and email send — so hit and
|
|
miss take the same wall-clock time and an attacker can't enumerate
|
|
by latency.
|
|
- **OIDC SSRF guard** (`oidc.js:19-47`). Don't shortcut the DNS
|
|
resolution check. The whole point is to prevent an admin from
|
|
pointing OIDC at internal infra.
|
|
- **JWT not stored** in DB. The session table holds `SHA-256(jwt)`,
|
|
not the JWT. Don't add a "for convenience" plaintext column.
|
|
- **Cookie flags** (`auth.js:43-52`). httpOnly + secure (when prod) +
|
|
sameSite=lax. Don't relax these "to make it work in dev" — the
|
|
`secure` flag already gates on `NODE_ENV !== 'production'`.
|
|
|
|
### 15.2 JWT secret handling
|
|
|
|
- `JWT_SECRET` is read once at startup (`middleware/auth.js:13-20`).
|
|
- **Required** in production — server refuses to start without it.
|
|
- Used for two distinct things:
|
|
1. Signing user JWTs.
|
|
2. HMAC-signing OIDC state tokens (`oidc.js:50-66`).
|
|
Rotating `JWT_SECRET` invalidates **both** — every user is logged out
|
|
AND any in-flight OIDC handshake fails. Acceptable for a security
|
|
incident response, but document the impact for routine rotations.
|
|
|
|
### 15.3 Audit log writes
|
|
|
|
**Don't drop audit entries to "simplify" code.** The batched queue
|
|
already absorbs the per-call cost. The audit table is the *only*
|
|
record of who did what — losing entries means you lose forensic
|
|
capability you'll only miss when you need it.
|
|
|
|
Specifically:
|
|
|
|
- Every `admin_*` write endpoint should call `logger.audit(...)`. If
|
|
you add a new admin action, add the audit call.
|
|
- Auth events (`register`, `login`, `login_failed`, `login_blocked`,
|
|
`login_oidc`, `logout`, `email_verified`, `password_changed`,
|
|
`password_reset`, `2fa_enabled`, `2fa_backup_code_used`,
|
|
`2fa_backup_codes_regenerated`, `oidc_linked`,
|
|
`session_idle_timeout`) are all canonical names — don't rename them
|
|
without checking the Grafana dashboard's saved queries.
|
|
- `redact()` runs unconditionally on details. Don't bypass it
|
|
("just this one place needs the raw email") — that's exactly how
|
|
PHI leaks.
|
|
|
|
### 15.4 Frontend `authFetch.js`
|
|
|
|
The global fetch interceptor (`public/js/authFetch.js`) is what makes
|
|
401-on-any-API-call drop the user back to the login screen. It also
|
|
handles cross-tab logout via BroadcastChannel. Don't remove the
|
|
"isAuthEndpoint" exclusion list (lines 73-82) — without it, a 401 on
|
|
`/api/auth/login` from a wrong-password attempt would bounce the user
|
|
out of the login form they were in the middle of.
|
|
|
|
---
|
|
|
|
## 16. How to add a new admin setting
|
|
|
|
Concrete checklist using `feature.foo` as the example. The wildcard
|
|
PUT handler at `adminConfig.js:957-985` already accepts any key with
|
|
an allowed prefix — `feature.` is in the list — so for a typical flag
|
|
the work is minimal.
|
|
|
|
### 16.1 The four touchpoints
|
|
|
|
1. **DB key**. Pick a key prefix that's already in the allow-list at
|
|
`adminConfig.js:967`:
|
|
```js
|
|
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.',
|
|
'registration_enabled', 'site.', 'smtp.', 'models.', 'tts.',
|
|
'stt.', 'embeddings.'];
|
|
```
|
|
If your key needs a new prefix, add it here. Don't use a non-prefixed
|
|
key unless it's a genuine top-level toggle (registration_enabled is
|
|
the only current example).
|
|
|
|
2. **Default value** (optional but recommended). Add to the array in
|
|
`POST /api/admin/config/reset-defaults` (`adminConfig.js:122-136`)
|
|
so the "reset to defaults" button restores it. Example:
|
|
```js
|
|
['feature.foo', 'true'],
|
|
```
|
|
|
|
3. **Server-side reader**. In whatever route consumes the setting,
|
|
read it on demand:
|
|
```js
|
|
var enabled = await db.getSetting('feature.foo');
|
|
if (enabled !== 'true') return res.status(403).json({ error: '...' });
|
|
```
|
|
Or, for hot paths, cache it (`feature.foo` reads at startup +
|
|
poll every N minutes).
|
|
|
|
4. **Admin UI input**. In `public/index.html` (or wherever the admin
|
|
tab content lives), add an input. The CMS section in `admin.js`
|
|
(lines 263-660) already wires generic toggles — for `feature.*`,
|
|
add an `<input type="checkbox" data-cms-key="feature.foo">` and
|
|
the existing event delegation will PUT it on change. For more
|
|
complex inputs, follow the pattern of `models.default` (text
|
|
input → button → fetch).
|
|
|
|
5. **Frontend reader** (if the setting affects UI). Add a fetch call
|
|
on app boot (or on tab-activation) to `/api/admin/config/...` —
|
|
though this requires admin role. For user-facing flags, expose
|
|
them via a public endpoint similar to `/api/auth/oidc-status` or
|
|
`/api/admin/config/announcement`. The latter is conveniently
|
|
public-with-auth (the only reason the wildcard PUT requires admin
|
|
is the `router.use(adminMiddleware)` at line 30, which comes
|
|
*after* the announcement GET at lines 16-28).
|
|
|
|
### 16.2 Audit it
|
|
|
|
Even though the wildcard handler logs `admin_config_update`
|
|
automatically (`adminConfig.js:981`), if your setting has unusual
|
|
side effects (revokes sessions, clears caches, restarts something),
|
|
consider adding a custom audit action like `admin_feature_toggle_foo`
|
|
with more specific details.
|
|
|
|
### 16.3 Test it
|
|
|
|
Local dev: change the value via the admin UI, hit the affected
|
|
endpoint, verify the behaviour change. There's no schema migration —
|
|
`app_settings` is just `(key TEXT PK, value TEXT, updated_at
|
|
TIMESTAMP)`.
|
|
|
|
For a feature flag visible in Grafana, query
|
|
`audit_log WHERE action LIKE 'admin_config%' AND details LIKE
|
|
'%feature.foo%'` to confirm the audit row appeared.
|
|
|
|
---
|
|
|
|
## Appendix: file-path quick reference
|
|
|
|
| File | What |
|
|
|---|---|
|
|
| `src/middleware/auth.js` | `authMiddleware`, `adminMiddleware`, `moderatorMiddleware`, `JWT_SECRET` |
|
|
| `src/middleware/logging.js` | API call audit hook |
|
|
| `src/routes/auth.js` | Register, login, 2FA, password reset, logout, /me |
|
|
| `src/routes/oidc.js` | OIDC SSO + admin OIDC config |
|
|
| `src/routes/sessions.js` | List + revoke sessions |
|
|
| `src/routes/admin.js` | User management, basic settings, log viewer |
|
|
| `src/routes/adminConfig.js` | CMS flags, prompts, SMTP, model/TTS/STT/embeddings management |
|
|
| `src/routes/adminMilestones.js` | Milestone CRUD |
|
|
| `src/routes/userPreferences.js` | Per-user STT/TTS pick |
|
|
| `src/routes/learningHub.js` | Reader: feed, content, quizzes, search |
|
|
| `src/routes/learningAdmin.js` | CMS: categories, content, questions, embeddings backfill |
|
|
| `src/routes/learningAI.js` | AI generate, refine, WebDAV browse, PPTX export, slide preview |
|
|
| `src/utils/passwords.js` | argon2id + bcrypt unified API + transparent migration |
|
|
| `src/utils/sessions.js` | `hashToken`, `parseUserAgent`, `generateSessionId` |
|
|
| `src/utils/notify.js` | ntfy push (login alerts, etc.) |
|
|
| `src/utils/auditQueue.js` | Batched writer for audit/api/access tables |
|
|
| `src/utils/logger.js` | `logger.audit` / `apiCall` / `access` + Loki shipping |
|
|
| `src/utils/prompts.js` | Hardcoded prompts + DB override loader |
|
|
| `src/utils/models.js` | Model allowlist + cached lookup |
|
|
| `src/utils/ai.js` | `callAI` with allowlist enforcement |
|
|
| `src/utils/embeddings.js` | Vector generation (LiteLLM/Vertex/OpenAI) |
|
|
| `docker-entrypoint.sh` | OpenBao secret bootstrap |
|
|
| `server.js` | Mount order, rate limits, CSP, CORS, graceful shutdown |
|
|
| `public/js/auth.js` | Login/register/2FA/SSO frontend |
|
|
| `public/js/authFetch.js` | Global fetch interceptor (401 → logout, cross-tab sync) |
|
|
| `public/js/admin.js` | Six IIFEs covering all admin sections |
|
|
| `public/js/learningHub.js` | Reader + CMS frontend (~1,800 lines) |
|