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.
90 KiB
ped-ai — Application Architecture (Deep Dive)
Audience. Engineers working in this repo (and Daniel six months from now). Assumes familiarity with Node, Express, Postgres, and vanilla DOM. No framework knowledge required — there isn't one.
Scope. Top-to-bottom mechanical description of how the app is wired: the IIFE frontend, the Express composition root, the schema, encryption at rest, migrations, deployment, and the sacred zones you must not casually edit.
Companion docs:
docs/architecture.md(one-page overview Daniel maintains),docs/database.md,docs/deployment.md,docs/configuration.md. This file is the long-form version that pulls the threads together with file:line citations.
1. Overview
What this is
ped-ai (PedScribe / Pediatric AI Scribe) is a self-hosted, single-tenant
clinical documentation platform for pediatricians. It runs as two Docker
containers — an Express app and a PostgreSQL database — behind a
user-supplied reverse proxy. The audience is one practice (often one
clinician), not a multi-tenant SaaS. There is no per-tenant isolation
because there is no concept of a tenant: every authenticated user shares
the same Postgres schema with row-level user_id scoping.
Top-level shape
┌────────────────────────────────────────┐
│ Reverse proxy (Caddy / Nginx / etc.) │
│ TLS termination + host routing │
└────────────────────┬───────────────────┘
│
127.0.0.1:3552
│
┌────────────────────────────────────┴─────────────────────────────────┐
│ pediatric-ai-scribe (node:20-alpine, Express 4) │
│ ┌────────────────────────────────────────────────────────────────┐ │
│ │ static (public/) → vanilla JS SPA shell │ │
│ │ /api/* → 28+ Express routers │ │
│ │ /api/health → Docker healthcheck │ │
│ └────────────────────────────────────────────────────────────────┘ │
│ │ │ │
│ │ TCP 5432 │ outbound HTTPS │
│ ▼ ▼ │
│ ┌──────────────┐ ┌────────────────────────┐ │
│ │ PostgreSQL │ │ AI provider │ │
│ │ pgvector │ │ Bedrock / Vertex / │ │
│ │ pg16 │ │ Azure / LiteLLM / │ │
│ │ │ │ OpenRouter │ │
│ └──────────────┘ └────────────────────────┘ │
│ │
│ Optional siblings: Loki (logs), Grafana (metrics), │
│ OpenBao (secrets), Nextcloud (WebDAV upload), S3 (documents) │
└──────────────────────────────────────────────────────────────────────┘
Tech stack
| Layer | Choice |
|---|---|
| Runtime | Node.js 20 (alpine) |
| HTTP | Express 4 + helmet + cors + cookie-parser + express-rate-limit |
| DB | PostgreSQL 16 with pgvector extension |
| DB driver | pg (pool, max 20) |
| Migrations | node-pg-migrate (programmatic) |
| Auth | JWT (HS256) signed locally + DB-backed user_sessions table |
| Secrets | argon2id (current), bcrypt (legacy / rehash on next login) |
| AI | Provider-agnostic via src/utils/ai.js → OpenAI SDK shape |
| STT | OpenAI Whisper API / Google Gemini / AWS Transcribe / local whisper.cpp / browser Whisper (transformers.js) |
| TTS | Google Cloud TTS / ElevenLabs / LiteLLM-routed |
| Frontend | Vanilla JS, no framework, no bundler. Plain <script defer> tags + IIFE modules + lazy HTML components |
| Mobile | Capacitor 6 wrapper (Android shipping, iOS scaffolded) |
| Container | Docker + docker-compose |
How it diverges from a "modern" stack
This repo deliberately rejects the React/Vite/Tailwind default. The frontend
is 38 <script defer> tags in public/index.html that load IIFE
modules into window globals; the only ES-module pocket is
public/js/bedside/ (one <script type="module"> line). There is no
build step. There is no Webpack, no Vite, no Babel, no Tailwind, no
JSX, no TypeScript on the frontend. Every JS file is what hits the
browser.
The trade-off is intentional:
- No build = no bundler-induced bugs, no CI pipeline for the
frontend, no source-maps to wrangle, and a smaller image (no
node_modulesfor the browser). - Every change is one Ctrl-S away from being deployed — refresh the
browser and you see it. The cache-bust at
server.js:155–204injects?v=BUILD_IDso the browser always pulls the latest JS/CSS. - The cost is a real one: no dependency graph, no tree-shaking, no
type checking. Cross-file references are
window.x = yplus copy-paste discipline. Renaming a button id and forgetting to grep produces a silent dead handler. The static reference linter atscripts/lint-references.jscatches the easiest version of that bug but cannot model control flow. See section 16.
There is a planned migration to Node + TypeScript + Fastify on the
backend, and React + TS + Vite + Tailwind + shadcn on the frontend
(projects/migration-checkpoint). This document describes the current
state as of v6.53.x.
2. Frontend architecture: the IIFE pattern
The pattern
Every file under public/js/ (except the bedside pocket) wraps its body
in a self-invoking function:
(function () {
// private state, helpers
function doThing() { ... }
// surface public API on window
window.doThing = doThing;
})();
There are no import / export statements. Files communicate through:
window.Xglobals. A producer file doeswindow.callAI = ...and a consumer file later in script order callswindow.callAI(...).document.dispatchEvent(new CustomEvent('tabChanged', ...))— the only system-wide event bus. Seepublic/js/app.js:78for the producer; every per-tab module subscribes (e.g.app.js:156for the settings tab).localStorage/UIState(section 6). Cross-reload state.BroadcastChannel('pedscribe-auth')— cross-tab logout. Seepublic/js/authFetch.js:22.
Why this pattern was chosen
- Zero build dependency. The team is one pediatrician. He doesn't
want to debug
vite devat 11pm before clinic. <script defer>is enough. Defer guarantees in-order execution after the document parses. As long asapp.jsprecedes per-tab files, every consumer sees its producers' globals.- Each file is an island. A bug in
peGuide.jscannot crashencounters.jsbecause they don't share a module graph. The IIFE wrapper means a top-levelvardoesn't leak. - Easy to grep. Search for
window.Xorid="X"and you find every reference.
The cost
- No dependency graph. Removing a function from one file requires
manual
grep -r 'window.functionName'to know who calls it. - No tree-shaking. The whole file ships even if you use one helper.
- Manual event delegation against
document. Most click handlers are written asdocument.addEventListener('click', function(e) { if (e.target.closest('#btn-foo')) ... })so the handler keeps working after lazy-loaded HTML drops#btn-foointo the DOM later. Seepublic/js/app.js:109–116for the pattern. - The classic bug. Rename a button id in
components/foo.htmland forget to updatepublic/js/foo.js— the handler silently no-ops. The lightbox bug for Bedside pathway images was exactly this (the id moved between components and the handler stopped finding it). The linter atscripts/lint-references.jswas added in response. See section 16.
Conventions you'll see throughout public/js/
- Top-of-file comment block with
============================================================. - Helpers are file-private; only the few functions other files call get
a
window.X = Xline at the bottom. - Every fetch call uses
getAuthHeaders()(public/js/auth.js:260) — on web, this is justContent-Type: application/jsonand the cookie rides automatically. On native (Capacitor), it adds theAuthorization: Bearer <jwt>header from secure storage. See section 7. - Any DOM mutation that depends on lazy-loaded markup is gated on the
tabChangedevent; otherwise the per-tab module would query an empty<section>placeholder.
3. Frontend lifecycle
Page-load order
- Browser fetches
/. server.js:198–205hands backindex.htmlwith?v=BUILD_IDappended to every local/js/*.jsand/css/*.cssreference. The build id is a 7-char git short SHA when running from a checkout (server.js:156–170), or a fresh random hex otherwise.- Browser parses the HTML. Each
<script defer>is queued. - Once the document is parsed, deferred scripts execute in document order (this is the key contract — deferred scripts preserve ordering, async scripts do not).
- The global error trap installs first (
public/js/app.js:6–24) so later boot errors get reported vianavigator.sendBeaconto/api/logs/client-error. DOMContentLoadedfires;app.js:26registers the tab system.- The first tab (
<section class="tab-content active" data-component= "encounter">perpublic/index.html) is loaded eagerly vialoadComponentatpublic/js/app.js:62–64. That fetches/components/encounter.html, dumps it into the section'sinnerHTML, setstabEl.dataset.loaded = '1', dispatchestabChangedwithdetail.tab = 'encounter'. - Per-tab modules listen for that event and initialize themselves only when their tab activates. This keeps cold-start cost down — the bedside calculator code does not pre-build pathway DOM if you never click Bedside.
loadComponent walkthrough — public/js/app.js:32–60
function loadComponent(tabEl) {
var component = tabEl.getAttribute('data-component');
if (!component || tabEl.dataset.loaded) return Promise.resolve();
if (_componentCache[component]) {
tabEl.innerHTML = _componentCache[component];
tabEl.dataset.loaded = '1';
return Promise.resolve();
}
if (_componentLoading[component]) return _componentLoading[component];
_componentLoading[component] = fetch('/components/' + component + '.html')
.then(function (r) { ...; return r.text(); })
.then(function (html) {
_componentCache[component] = html;
tabEl.innerHTML = html;
tabEl.dataset.loaded = '1';
// Re-attach per-tab model selectors
tabEl.querySelectorAll('.tab-model-select').forEach(function (sel) {
if (typeof window._buildModelOptions === 'function') window._buildModelOptions(sel);
});
delete _componentLoading[component];
})
.catch(...);
}
Three things matter:
- In-flight de-dup.
_componentLoading[component]returns the same promise to two near-simultaneous calls — important if the user rapidly clicks two tabs. - Component cache is in-memory only. A page reload clears it,
which is why
ui-state.jsexists (section 6) — the per-tab DOM state needs to survive the reset. - Side effect on insert. After the HTML is in the DOM, we hunt for
.tab-model-selectand (re)populate it fromwindow._currentModels(set byapp.js:290–304's call to/api/models).
activateTab — public/js/app.js:67–91
function activateTab(tabName) {
var btn = document.querySelector('.tab-btn[data-tab="' + tabName + '"]');
if (!btn || btn.classList.contains('hidden')) return false;
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
btn.classList.add('active');
var tabEl = document.getElementById(tabName + '-tab');
if (tabEl) {
tabEl.classList.add('active');
loadComponent(tabEl).then(function () {
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
});
} else {
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
}
try { localStorage.setItem('ped_last_tab', tabName); } catch (e) {}
...
}
Notes:
activateTabis exposed atwindow.activateTab = activateTabsoauth.jscan restore the last tab on login.ped_last_tabin localStorage drives that restore. It is unnamespaced (noped_ui/prefix) for backwards compat — Daniel's installation has rows from beforeui-state.jsexisted.- The
tabChangedevent always fires after the component HTML is in the DOM. Subscribers can therefore safelygetElementByIdon ids that live inside the lazy-loaded fragment.
tabChanged consumer pattern
Every per-tab module looks like this:
document.addEventListener('tabChanged', function (e) {
if (!e.detail || e.detail.tab !== 'mytab') return;
// bootstrap UI, fetch initial data, restore UIState picks
});
Some modules also listen for events fired immediately on script load
(non-tab-bound features like the auth fetch interceptor). The
distinction: anything that touches lazy-loaded DOM must wait for
tabChanged.
4. The bedside ES-module exception
public/js/bedside/index.js is the only file in the codebase loaded
as a real ES module:
<!-- public/index.html -->
<script type="module" src="/js/bedside/index.js"></script>
Inside, it uses real import:
// public/js/bedside/index.js
import './shared.js'; // side-effect: sets window._EM for back-compat
import * as ageWeight from './age-weight.js';
import * as subNav from './sub-nav.js';
// ... ~18 modules
[ ageWeight, subNav, ..., sutures ].forEach(function (m) {
if (m && typeof m.init === 'function') m.init();
});
This pocket exists because:
- Bedside has 20 sibling files (one per pathway: airway, sepsis, NRP,
etc.). Loading them all as IIFEs would mean 20 more
<script defer>lines plus careful ordering; an ES module graph is genuinely cleaner here. - The 20 modules expose a
init()function that gets called once at load. Each module knows how to lazy-attach to its DOM section. shared.jsstill setswindow._EMfor back-compat — older bedside code that hasn't been ported looks for the global, so the ESM module emits the global as a side effect during import.
This is the migration target shape for the rest of the frontend under Daniel's revised plan: keep vanilla JS but switch IIFE → ESM, add TypeScript, retain plain DOM (no React for now).
The CSP allows it: scriptSrc includes 'self' plus the ES module
loader doesn't need any new directive.
5. Tab list + sidebar
The sidebar lives inside public/index.html and is not lazy-loaded.
Every .tab-btn[data-tab="X"] has a paired
<section id="X-tab" data-component="X" class="tab-content">. The data
binding from button → section is purely by string suffix:
activateTab('foo') looks for #foo-tab.
Current tab roster
From public/index.html:
Sidebar button (data-tab) |
Section id | data-component |
Component HTML | Notes |
|---|---|---|---|---|
encounter |
#encounter-tab |
encounter |
components/encounter.html |
Default active tab |
dictation |
#dictation-tab |
dictation |
components/dictation.html |
Voice → AI note |
ed |
#ed-tab |
ed-encounter |
components/ed-encounter.html |
ED encounter form |
hospital |
#hospital-tab |
hospital |
components/hospital.html |
Hospital course |
chart |
#chart-tab |
chart |
components/chart.html |
Chart review |
soap |
#soap-tab |
soap |
components/soap.html |
SOAP note |
wellvisit |
#wellvisit-tab |
wellvisit |
components/wellvisit.html |
Well visit |
sickvisit |
#sickvisit-tab |
sickvisit |
components/sickvisit.html |
Sick visit |
vaxschedule |
#vaxschedule-tab |
vaxschedule |
components/vaxschedule.html |
AAP schedule |
catchup |
#catchup-tab |
catchup |
components/catchup.html |
Catch-up immunization |
peguide |
#peguide-tab |
pe-guide |
components/pe-guide.html |
Physical exam guide (with audio) |
bedside |
#bedside-tab |
bedside |
components/bedside.html |
Bedside pathways (ES-module pocket) |
calculators |
#calculators-tab |
calculators |
components/calculators.html |
Pediatric calculators |
extensions |
#extensions-tab |
extensions |
components/extensions.html |
Phone extensions / pagers |
notes |
#notes-tab |
notes |
components/notes.html |
Personal notes (rich text) |
learning |
#learning-tab |
learning |
components/learning.html |
Learning Hub (CMS-driven) |
cms (hidden until role) |
#cms-tab |
cms |
components/cms.html |
Learning CMS (moderator/admin) |
settings |
#settings-tab |
settings |
components/settings.html |
User settings |
faq |
#faq-tab |
faq |
components/faq.html |
FAQ accordion (wired in app.js:167–182) |
admin (hidden until role) |
#admin-tab |
admin |
components/admin.html |
Admin panel |
A few sections (encounter, dictation etc.) do not have inline
markup in index.html — they're empty placeholders and rely entirely
on the lazy fetch. Some legacy pieces have inline markup and will
load instantly without a fetch. This isn't documented anywhere; both
patterns coexist.
Sidebar behavior
- The whole left column (
#sidebar) collapses on desktop (state persisted tolocalStorage['ped_sidebar_collapsed']—app.js:124–149). - On mobile (
window.innerWidth <= 768), tab clicks auto-close the sidebar (app.js:86–89). #btn-menu-toggle,#btn-sidebar-close,#sidebar-overlayare delegated through a single document-level click listener inapp.js:109–116. This is the canonical pattern — never attach click handlers directly to lazy-loaded elements; delegate from document.
Hidden tabs
cms and admin start with class="hidden". After login, auth.js
checks req.user.role (returned by /api/auth/me) and toggles the
hidden class so the appropriate buttons appear.
6. ui-state.js — sub-pill persistence
Why this exists
_componentCache (in app.js:29) is in-memory and dies on every page
reload. So does the live DOM. If a user picks "Cardiac" sub-section
inside Bedside, then reloads, both the inserted HTML and any
in-DOM "active" classes are gone. Vanilla localStorage works, but
without a namespace it's hard to enumerate, and every consumer has to
hand-roll the try/catch (Safari private mode throws on
localStorage.setItem).
public/js/ui-state.js is the namespaced shim:
(function () {
var PREFIX = 'ped_ui/';
function get(key) { try { return localStorage.getItem(PREFIX + key); } catch (e) { return null; } }
function set(key, value) { try { localStorage.setItem(PREFIX + key, value); } catch (e) {} }
function del(key) { try { localStorage.removeItem(PREFIX + key); } catch (e) {} }
window.UIState = { get: get, set: set, del: del };
})();
Consumers (current grep)
public/js/calculators.jspublic/js/wellVisit.jspublic/js/peGuide.jspublic/js/bedside/sub-nav.js
Every key lives under ped_ui/. Examples in the wild:
ped_ui/peGuide.lastSection, ped_ui/bedside.subTab,
ped_ui/wv.activePill. The convention is module.dotted.path after
the prefix.
Why not just dump everything into one JSON blob
Multiple tabs writing to one shared blob would race. Per-key entries are atomic from each consumer's perspective.
Versus ped_last_tab
ped_last_tab (set in app.js:84) predates UIState and is an
unnamespaced top-level key. It is the macro state — which tab to
show. UIState keys are the micro state — which sub-pill, which
sub-tab, which collapsed/expanded section.
7. getAuthHeaders, authFetch, secureStorage
The frontend has three pieces of auth wiring:
getAuthHeaders() — public/js/auth.js:260–273
window.getAuthHeaders = function () {
if (!isNativeApp()) {
return { 'Content-Type': 'application/json' };
}
var token = window.AUTH_TOKEN || window.SecureStorage.getSync(TOKEN_KEY) || '';
return {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + token
};
};
- Web browser path. Returns just the content-type header. The
ped_authhttpOnly cookie rides automatically because all fetches are same-origin andfetch()'s defaultcredentials = same-originsends them. - Native (Capacitor) path. Reads the JWT out of secure storage and
attaches it as
Authorization: Bearer …. There is no cookie on native — theped_authcookie is web-only.
The server's authMiddleware (src/middleware/auth.js:25–31) reads the
Bearer header first, then falls back to the cookie:
var authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
token = authHeader.substring(7) || null;
}
if (!token && req.cookies && req.cookies.ped_auth) {
token = req.cookies.ped_auth;
}
This means getAuthHeaders() works identically on both clients —
the caller doesn't need to know which platform it's on.
authFetch.js — global 401 handler
public/js/authFetch.js monkey-patches window.fetch. On any 401 from
an /api/* endpoint that isn't an auth endpoint, it:
- Clears
window.AUTH_TOKENand the SecureStorage / localStorage copies. - Shows a toast.
- Reloads after 800ms — the boot flow then hits
/api/auth/me, gets another 401 (no cookie / no token), and shows the login screen.
It also opens a BroadcastChannel('pedscribe-auth'). When any tab
posts { type: 'logout' } on it, every other open tab receives the
message and runs the same logout flow. This solves the
shared-workstation case — log out in one tab, every other open tab
drops PHI within milliseconds instead of waiting for its next failed
fetch.
The interceptor self-installs once via window.__fetchAuthIntercepted
guard so it survives a re-import / re-load script (re-injection
during dev).
secureStorage.js — Capacitor wrapper
A shim around two backends:
- Web. Plain
localStorage(synchronous reads viagetSync). - Native.
capacitor-secure-storage-plugin→ iOS Keychain or Android EncryptedSharedPreferences. Async reads viaget, plus a synchronousgetSyncthat returns from a memory cache populated byhydrate(keys)at boot.
The keys it stores: ped_scribe_token (the JWT), ped_scribe_user
(JSON cache of the current user), ped_session_id. None are stored
on web — the cookie is the source of truth for the JWT, and user
info is pulled from /api/auth/me.
8. Backend architecture: server.js as composition root
server.js is the only file that wires the application together. Read
it top-to-bottom and you have the whole boot sequence.
Boot order (line by line)
server.js:1–7—require('dotenv').config()reads.envfirst thing. Then express, cors, helmet, rate-limit, cookie-parser.server.js:10—loggingMiddlewareis required (initializing theauditQueues as a side effect).server.js:12–13— Express app + raw HTTP server (the raw server is kept so SIGTERM can callserver.closedirectly).server.js:16—app.set('trust proxy', 1). Critical: the reverse proxy strips X-Forwarded-For; trust the first hop soreq.ipis the real client IP for rate-limit + audit log.server.js:21–54— Helmet with a hand-crafted CSP. CSP allows:'self'everywhere by default.'wasm-unsafe-eval'and'unsafe-eval'because in-browser Whisper (@xenova/transformers) needs both.cdn.jsdelivr.netfor the transformers worker.huggingface.co+ the cdn-lfs subdomains for Whisper model downloads.https://challenges.cloudflare.comfor Turnstile.connectSrcextended toclinicaltables.nlm.nih.govfor ICD autocomplete.upgradeInsecureRequests: nullso the e2e container can be hit over plain HTTP. Production has Caddy in front, mixed content is a non-issue.
server.js:59–80— CORS, scoped to/api/*only. Static assets must not be CORS-checked because ES-module scripts always send an Origin header. In production, fail-closed: if neitherAPP_URLnorCORS_ORIGINSare set, the process refuses to start (server.js:63–66). In dev (noAPP_URL), all origins are allowed.server.js:82—cookie-parser.server.js:88—express.json({ limit: '10mb' }). The 10 MB cap exists because chart-review payloads with many notes can exceed Express's 100 KB default. Audio uploads use multipart and bypass this cap (multer intranscribe.jsenforces 25 MB).server.js:95–137— Rate limiters, layered:- Global
/api/— 200 req/min default, overridable viaAPI_RATE_LIMIT_MAX(the e2e container raises to 5000). /api/auth/login— 10 attempts / 15 min, overridable viaLOGIN_RATE_LIMIT_MAX(e2e raises to 500 for multi-worker Playwright)./api/auth/register— 5 / hour./api/auth/forgot-password— 5 / hour./api/auth/resend-verification— 3 / 15 min.- A "sensitiveAuthLimiter" (20 / 15 min) on
change-password,setup-2fa,verify-2fa,disable-2fa. This blocks brute-forcing TOTP via a stolen cookie; the 10⁶ space is way out of reach at 20/15min.
- Global
server.js:140–144—/.well-known/assetlinks.jsonfor TWA (Trusted Web Activity / Android app verification). Must come before the static handler.server.js:153–196— Build-id cache busting:- Compute
BUILD_IDfrom.git/HEAD(orBUILD_IDfile in the Docker image, or random hex). getTemplatedIndex()readspublic/index.html, regex-replaces every local/js/...jsand/css/...cssreference to append?v=BUILD_ID. The mtime check makes this essentially free after the first read.
- Compute
server.js:198–205—GET /andGET /index.htmlserve the templated HTML.Cache-Control: no-cache, no-store, must-revalidateso browsers always re-fetch (and pick up the new BUILD_ID, which then busts the JS/CSS cache).server.js:208—GET /api/buildreturns the build id — useful for "what version is the prod box running" debugging.server.js:210—loggingMiddleware. Wrapsres.jsonto emitapiCalllog lines for every non-GET/api/*call.server.js:211–226—express.static(public/)with per-file Cache-Control:.html→no-cache, no-store, must-revalidate..js/.css→public, max-age=3600(1 hour) — the?v=BUILD_IDquery param forces a refetch on deploy./components/*→ 1 hour.
server.js:229–303— Route mounts. Order matters:- Auth routers first.
/api/auth/*fromauth.jsandoidc.js. - Learning Hub admin BEFORE general
/api/admin— thelearningAdminrouter usesmoderatorMiddleware(lower privilege thanadminMiddleware); mounting it after/api/adminwould attach the strict adminMiddleware first and block moderators. - General admin routers (
admin.js,adminConfig.js,adminMilestones.js). - Public endpoints (
/api/models,/api/health,/api/health/detailed) — explicitly defined so no laterauthMiddlewaremount catches them. - Authenticated feature routers (28+) — every one applies
authMiddlewareinternally viarouter.use(authMiddleware).
- Auth routers first.
server.js:317–322— FallbackGET /and 404 handler.server.js:329–332— 3-second deferredPROMPTS.loadFromDb(db)call. Lets the DB pool come up first; loads any admin-edited prompt overrides fromapp_settingsrows with key prefixprompt..server.js:334–343— Listen onprocess.env.PORT || 3000. Print the banner.server.js:345–387— SIGTERM / SIGINT graceful shutdown:- Stop accepting new connections (
server.close). - Drain
auditQueue.drainAll()— flushes any pending audit / api / access log batches (src/utils/auditQueue.js). - Clear
db._cleanupIntervalexplicitly — the hourly cleanup timer would otherwise keep the event loop alive pastprocess.exit(0)and Docker's 10-second SIGKILL deadline. pool.end()to drain Postgres.- 9-second hard deadline to beat Docker's SIGKILL.
- Stop accepting new connections (
Why this is a "composition root" pattern
Every dependency is wired here. There is no DI container, no service
locator. To know what middleware runs, look at server.js. To know
what routes exist, look at server.js. To know the rate-limit policy,
look at server.js. It's a 388-line file that tells you everything.
Compare the alternative — a magic auto-loader that scans src/routes/
and mounts everything under /api/. That would be tighter, but the
mount order matters in two specific cases (learning admin vs admin,
and public endpoints vs authenticated routers), so explicit wiring
wins.
9. Route convention
Every file in src/routes/ follows the same shape:
// ============================================================
// FOO ROUTES — One-line description
// ============================================================
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
var cryptoUtil = require('../utils/crypto'); // if it touches PHI
var { callAI } = require('../utils/ai'); // if it does AI work
router.use(authMiddleware); // protect everything below
router.get('/foo/:id', async function (req, res) {
try {
// 1. Validate input
if (!req.params.id) return res.status(400).json({ error: 'id required' });
// 2. Fetch user-scoped data
var row = await db.get(
'SELECT * FROM foo WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!row) return res.status(404).json({ error: 'Not found' });
// 3. Decrypt as needed
try { row.body = cryptoUtil.decryptString(row.body); } catch (e) {}
// 4. Optionally call AI
// var result = await callAI(prompt, model);
// 5. Respond
res.json({ success: true, foo: row });
// 6. Audit (after the response — the queue absorbs the cost)
logger.audit(req.user.id, 'foo_load', 'Loaded foo:' + row.id, req, { category: 'clinical' });
} catch (e) {
logger.error('GET /foo/:id', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;
Six fixed beats: validate → fetch user-scoped → decrypt → AI →
respond → audit. The rare router that needs admin gates a sub-route
with adminMiddleware after authMiddleware.
One-line purpose for every router
From server.js:229–303:
| File | Mount path | Purpose |
|---|---|---|
routes/auth.js |
/api/auth |
Local + email/password login, register, verify, reset, 2FA setup/verify/disable, change-password, /me |
routes/oidc.js |
/api/auth |
OIDC SSO start/callback, IdP-linked account creation |
routes/learningAdmin.js |
/api/admin/learning |
Learning Hub CMS — moderators + admins; categories, content, questions |
routes/admin.js |
/api/admin |
User mgmt: list, verify, disable, delete, promote |
routes/adminConfig.js |
/api/admin |
app_settings editor: SMTP, OIDC, models, prompts, announcements, feature flags |
routes/adminMilestones.js |
/api/admin |
Editor for developmental_milestones reference table |
routes/learningHub.js |
/api/learning |
User-facing Learning Hub: list categories/content, take quizzes, record progress |
routes/transcribe.js |
/api |
/api/transcribe (multipart audio → text) and /api/transcribe/status |
routes/hpi.js |
/api |
HPI generation from transcript |
routes/hospitalCourse.js |
/api |
Hospital course note generation |
routes/chartReview.js |
/api |
Chart review summarization (multi-note input) |
routes/milestones.js |
/api |
Read milestones, run developmental check |
routes/peGuide.js |
/api |
PE guide AI prompts (audio findings → narrative) |
routes/extensions.js |
/api |
CRUD on user_phone_extensions |
routes/soap.js |
/api |
SOAP note generation |
routes/tts.js |
/api |
Text-to-speech proxy (Google / ElevenLabs / LiteLLM) |
routes/nextcloud.js |
/api |
WebDAV upload of finished notes |
routes/refine.js |
/api |
Take a draft note + correction prompt, return refined note |
routes/logs.js |
/api |
Client-side error log ingest (/api/logs/client-error) |
routes/encounters.js |
/api |
Sacred. Save / load / list / delete saved_encounters. Optimistic lock + idempotency. See section 13 |
routes/memories.js |
/api |
CRUD on user_memories (style hints, templates) |
routes/notes.js |
/api |
CRUD on personal_notes + voice-to-AI-note + trash/restore |
routes/documents.js |
/api |
S3 doc upload / list / download / delete (metadata in user_documents) |
routes/audioBackups.js |
/api |
Server-side audio retry store (encrypted gzip, 24h TTL) |
routes/billing.js |
/api |
Encounter coding suggestions (E&M codes) |
routes/sessions.js |
/api/sessions |
Active sessions list + admin-revoke |
routes/wellVisit.js |
/api |
Well-visit AI note pipeline |
routes/sickVisit.js |
/api |
Sick-visit AI note pipeline |
routes/edEncounters.js |
/api |
ED encounter pipeline |
routes/dontMiss.js |
/api |
"Don't miss" differential / red-flag prompt |
routes/userPreferences.js |
/api/user |
User-level preferences (STT model, TTS voice, etc.) |
routes/learningAI.js |
/api/admin/learning |
AI-assisted Learning Hub authoring (generate quiz from content, embed) |
32 routers plus inline routes for /api/health, /api/health/detailed,
/api/models, /api/build, /api/user/webdav-path. Every authenticated
router calls router.use(authMiddleware) at the top — there is no global
app.use(authMiddleware) for /api. Always-on auth would clobber
/api/health, /api/models, the OIDC callbacks, and the public auth
endpoints.
10. Database wrapper (src/db/database.js)
A thin promise-returning shim over pg.Pool that mimics the SQLite
better-sqlite3 API used in earlier versions of this app. The shape:
var db = {
get: async function (sql, params) { ... return row || null; },
all: async function (sql, params) { ... return rows; },
run: async function (sql, params) {
// Auto-appends RETURNING id on INSERTs without one
// Returns { lastInsertRowid, changes }
},
query: async function (sql, params) { ... return pool.result; },
getSetting: async function (key) { ... },
setSetting: async function (key, value) { ... },
pool: pool,
_cleanupInterval: <Timeout>
};
Placeholder normalization — database.js:512–518
function convertPlaceholders(sql) {
var index = 0;
return sql.replace(/\?/g, function () { index++; return '$' + index; });
}
The wrapper accepts ? SQLite-style placeholders and $1/$2
Postgres-style. It rewrites ? → $N before passing to pg. This
is residual from the SQLite-era API and lets old route code keep
working unchanged.
db.run quirk — auto-RETURNING
if (sql.trim().toUpperCase().startsWith('INSERT') &&
pgSql.toUpperCase().indexOf('RETURNING') === -1) {
pgSql += ' RETURNING id';
}
INSERT statements without RETURNING get one appended so
.lastInsertRowid is populated. Tables without an id column will
break here — every table in the schema does have one, so this is
fine in practice.
Boot — database.js:439–450
On require, the module:
- Logs "✅ PostgreSQL: connected" or fails loudly.
- Runs
initDatabase()(the idempotent baseline). - Runs
node-pg-migrateviarunMigrations()frommigrate.js— the versioned delta.
initDatabase() does:
CREATE EXTENSION IF NOT EXISTS vector— pgvector for embeddings.- Collation drift check. Compare
pg_database.datcollversionagainstpg_database_collation_actual_version(). On mismatch (i.e., the libc / ICU version in the postgres image changed),REINDEX DATABASE+ALTER DATABASE ... REFRESH COLLATION VERSION. This prevents silent index corruption — the symptom would be "user can't log in despite correct password" because the email index gives wrong rows. COLLATE "C"migration. One-time DROP/CREATE ofidx_users_emailandidx_sessions_token_hashwithCOLLATE "C", gated by anapp_settingsrowmigration.text_indexes_c. C collation is byte-exact and immune to ICU library upgrades. This protects the auth-critical lookup paths.CREATE TABLE IF NOT EXISTSfor the baseline schema (users, app_settings, audit_log, api_log, access_log, saved_encounters, user_memories, user_phone_extensions, learning_*, see section 11).- A pile of
ALTER TABLE ADD COLUMN IF NOT EXISTSmigrations for upgrades from older versions (role,disabled,oidc_sub,idempotency_key, etc.). - IVFFLAT index on
learning_content.embeddingonce ≥ 10 embeddings exist (lists = sqrt(rows)). - Seeds default
app_settingsrows viaINSERT ... ON CONFLICT DO NOTHING(registration_enabled, announcement defaults, feature flags, etc.).
_cleanupInterval — database.js:453–471
async function cleanupExpired() {
await pool.query('DELETE FROM saved_encounters WHERE expires_at < NOW()');
await pool.query('DELETE FROM audio_backups WHERE expires_at < NOW()');
await pool.query("DELETE FROM user_sessions WHERE created_at < NOW() - INTERVAL '7 days'");
}
var _cleanupInterval = setInterval(cleanupExpired, 60 * 60 * 1000); // hourly
setTimeout(cleanupExpired, 10000); // also at +10s
db._cleanupInterval = _cleanupInterval;
The interval handle is exposed because of the SIGTERM problem: Node
considers an unref'd timer enough to keep the event loop alive. Without
explicitly clearing it, the process won't exit cleanly within the
9-second graceful-shutdown window, and Docker SIGKILLs us at 10s.
server.js:371 does clearInterval(dbMod._cleanupInterval) at
shutdown.
11. Schema
PostgreSQL 16 + pgvector. Two-layer management: baseline (idempotent
CREATE TABLE IF NOT EXISTS on every boot, in database.js) plus
versioned migrations (node-pg-migrate, in migrations/).
users — local accounts + OIDC + per-user prefs
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
email |
TEXT UNIQUE NOT NULL | Index uses COLLATE "C" for ICU-drift immunity |
password |
TEXT NOT NULL | argon2id (current) or bcrypt (rehashed on next login). For OIDC-auto-created accounts, random hex (unverifiable). |
name |
TEXT | |
role |
TEXT default 'user' |
user / admin / moderator |
email_verified |
BOOLEAN default false | |
verify_token, verify_expires |
TEXT, BIGINT | Email verification |
totp_secret, totp_enabled |
TEXT, BOOLEAN | TOTP 2FA |
totp_backup_codes |
TEXT | JSON array of bcrypt-hashed 10-char recovery codes; consumed atomically on login |
passkey_credentials |
TEXT | Reserved for future WebAuthn |
oidc_sub |
TEXT | IdP subject identifier (when linked) |
disabled |
BOOLEAN default false | Soft disable; auth middleware returns 403 |
nextcloud_url, nextcloud_user, nextcloud_token, nextcloud_folder |
TEXT | WebDAV creds. nextcloud_token is encrypted (enc1: prefix) |
reset_token, reset_expires |
TEXT, BIGINT | Password reset |
stt_model, tts_voice |
TEXT | Per-user STT/TTS overrides |
webdav_learning_path |
TEXT | Learning Hub WebDAV browser root |
created_at, updated_at |
TIMESTAMPTZ |
Encryption. Only nextcloud_token is encrypted at rest.
Email/name/role are deliberately plaintext (needed for indexed lookups,
admin display).
Who writes. routes/auth.js (register, password change, profile),
routes/oidc.js (auto-create on IdP first-login), routes/admin.js
(promote/disable/delete), routes/userPreferences.js (stt_model /
tts_voice), routes/nextcloud.js (webdav creds).
Who reads. authMiddleware on every request (SELECT id, email, name, role, totp_enabled, disabled FROM users WHERE id = ?).
user_sessions — authoritative session registry
| Column | Type | Notes |
|---|---|---|
id |
TEXT PK | UUID |
user_id |
INTEGER FK users.id (CASCADE) | |
token_hash |
TEXT NOT NULL | SHA-256 of the JWT. Index uses COLLATE "C" |
ip_address, user_agent |
TEXT | |
device_label |
TEXT | Parsed UA — "Chrome on Android", "PedScribe (Android)", etc. |
created_at, last_activity |
TIMESTAMPTZ | last_activity updated on POST/PUT/DELETE/PATCH only, throttled to ≤1 / 10 min |
The flow. Login mints a JWT, hashes it (SHA-256), inserts a row.
Every subsequent request hits authMiddleware, which looks up the
session by token_hash. If the row is gone (logout, password change,
admin revoke), the request returns 401 even if the JWT is otherwise
valid. This is what makes JWTs revocable in this app.
Encryption. None — IPs and UAs are operational metadata, not PHI.
Retention. Sliding 24h idle timeout (web only) — auth.js:69 checks
now - last_activity > 24h and deletes the row + clears the cookie.
Mobile clients have no idle check (persistent JWT in Keychain). The
hourly cleanup job sweeps anything > 7 days old created_at as a
belt-and-braces backstop.
saved_encounters — draft/complete encounter workspace
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
user_id |
INTEGER FK users.id (CASCADE) | |
label |
TEXT NOT NULL DEFAULT 'Untitled' | Unique per user within active rows |
enc_type |
TEXT NOT NULL DEFAULT 'encounter' | encounter, dictation, soap, sickvisit, wellvisit, hospital, chart, milestones, ed, … |
transcript |
TEXT | Encrypted (enc1:) |
generated_note |
TEXT | Encrypted (enc1:) |
partial_data |
TEXT | JSON of in-progress form state. Encrypted (enc1:) |
status |
TEXT DEFAULT 'active' | |
version |
INT NOT NULL DEFAULT 1 | Optimistic lock — see section 13 |
idempotency_key |
TEXT | Prevents duplicate creates on double-submit |
created_at, updated_at |
TIMESTAMPTZ | |
expires_at |
TIMESTAMPTZ | Default NOW() + 7 days, configurable via site.auto_delete_days |
Who writes. routes/encounters.js (sacred). The wellVisit /
sickVisit / ed / hospital / soap routes all funnel through the same
POST /api/encounters/saved endpoint when persisting.
Retention. Hourly cleanup deletes rows where expires_at < NOW().
The 7-day default is a deliberate ephemerality choice — this is a
clinical scratchpad, not a chart. Notes that need to survive go to
Nextcloud or are exported.
personal_notes — clinician scratchpad
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
user_id |
INTEGER FK (CASCADE) | |
title |
TEXT NOT NULL | Encrypted (enc1:) |
body |
TEXT NOT NULL | Encrypted (enc1:) — Tiptap HTML |
created_at, updated_at |
TIMESTAMPTZ | |
deleted_at |
TIMESTAMPTZ NULL | Soft delete — NULL = active, timestamp = trashed |
Created via migration 1777003849000_add-personal-notes.js, soft-delete
added via 1777090000000_notes-trash.js.
Distinct from user_memories. Memories are AI prompt fuel
(templates / style hints) — they get injected into LLM calls. Notes
are pure clinician text that never goes to an AI. Both are encrypted.
user_memories — AI prompt fuel
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
user_id |
INTEGER FK (CASCADE) | |
category |
TEXT NOT NULL DEFAULT 'custom' | physical_exam, ros, encounter_format, family_history, assessment_plan, custom, template_soap, template_hpi, template_wellvisit, template_sickvisit, template_ed, legacy correction_* (filtered out) |
name |
TEXT NOT NULL | Encrypted |
content |
TEXT NOT NULL | Encrypted |
created_at, updated_at |
TIMESTAMPTZ |
routes/memories.js filters correction_* rows out of the API
response — these are dead-feature artifacts from a Dragon-style
learning module that was removed. They're left in place rather than
deleted to avoid losing user content irreversibly.
audit_log, api_log, access_log — three log writers
All three written via src/utils/auditQueue.js (batched, 1 s flush).
| Table | Purpose | Writer | Source of records |
|---|---|---|---|
audit_log |
Human-readable security/action audit | logger.audit(...) |
Login attempts, encounter saves/loads, password change, 2FA events, admin actions, AI calls (with model + tokens) |
api_log |
Per-request AI telemetry (cost, tokens) | logger.apiCall(...) (auto-fired by loggingMiddleware on every non-GET /api/*) |
All AI-bearing requests with usage in the response body |
access_log |
Auth-only event stream | logger.access(...) |
login, logout, failed_login |
Schema details.
audit_log:
user_id,action,category(auth/clinical/integration/export/documents/phi_access/general),details(PHI-redacted byredact.js, capped at 500 chars),ip_address,user_agent,model_used,tokens_used,duration_ms,status(success/failure).
api_log:
user_id,endpoint,method,status_code,request_size,response_size,model_used,tokens_input,tokens_output,cost_estimate(USD),duration_ms,ip_address,error. Costs use a hardcoded rate table inlogger.js:32–48(OpenRouter would use live pricing in a future revision).
access_log:
user_id,action,ip_address,user_agent,success.
Encryption. None. The redactor (src/utils/redact.js) strips SSN
(\d{3}-\d{2}-\d{4}), US phones, emails, dates of birth, long ID
runs, and aggressively truncates anything that smells like a clinical
note body (>4 newlines or >1000 chars → keep first 120 chars +
[TRUNCATED:possible-note-body]). The 500-char cap is the second
guardrail.
Retention. None — explicit. These tables grow forever. Disk should be sized accordingly. A future migration is on the roadmap.
audio_backups — failed-transcription retry store
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
user_id |
INTEGER FK (CASCADE) | |
module |
TEXT default 'encounter' |
encounter, dictation, etc. |
mime_type |
TEXT default 'audio/webm' |
|
size_bytes, compressed_bytes |
INTEGER | |
audio_data |
BYTEA NOT NULL | gzip → AES-256-GCM, version-byte 0x01. Legacy 0x1F-prefixed rows (raw gzip without enc wrapper) pass through decryptBuffer transparently |
created_at, expires_at |
TIMESTAMPTZ | 24h default |
Who writes. routes/audioBackups.js from the frontend
audioBackup.js lifecycle when a transcription call fails — the
client uploads the gzipped audio so the user can retry without
re-recording.
Retention. 24 hours via hourly cleanup. Audio is the most sensitive PHI in the system; short retention is intentional.
user_documents — S3 doc metadata
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
user_id |
INTEGER FK (CASCADE) | |
s3_key |
TEXT NOT NULL | Bucket-relative key (prefixed with user id) |
filename, mime_type |
TEXT | |
size_bytes |
INTEGER | |
description |
TEXT | |
created_at |
TIMESTAMPTZ |
File bytes live in S3 (or any S3-compatible store: MinIO, Backblaze
B2 — set S3_FORCE_PATH_STYLE=true for non-AWS). The DB row is
metadata only.
user_phone_extensions — personal directory
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
user_id |
INTEGER FK (CASCADE) | |
location |
TEXT | |
name, number |
TEXT | |
type |
TEXT CHECK (extension or pager) |
|
notes |
TEXT default '' |
|
trashed_at |
TIMESTAMPTZ NULL | Soft delete |
created_at, updated_at |
TIMESTAMPTZ |
Indexes split on WHERE trashed_at IS NULL vs IS NOT NULL so
active-row queries don't scan trash.
app_settings — live runtime config
CREATE TABLE app_settings (
key TEXT PRIMARY KEY, -- COLLATE "C"
value TEXT NOT NULL,
updated_at TIMESTAMPTZ,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL
);
Read via config.get(key, default) (src/utils/config.js) with a
2-minute in-memory cache. Writes invalidate the cache. Examples of
keys: registration_enabled, site.auto_delete_days, oidc.enabled,
models.default, prompt.<name>, feature.read_aloud,
smtp.host, email.verify.subject, migration.text_indexes_c.
The Admin Panel can edit any of these. Prompt templates in
src/utils/prompts.js are loaded from the DB on boot
(server.js:329–332) — admins can hot-edit a prompt without redeploy.
Learning Hub tables
learning_categories, learning_content, learning_questions,
learning_options, learning_progress. CMS-style content tree.
learning_content.embedding is VECTOR(768) for semantic search,
indexed via IVFFLAT once ≥ 10 embeddings exist (database.js:380–399).
Default model is text-embedding-005 (Vertex). See
docs/learning-hub.md for the deep dive.
developmental_milestones — AAP reference data
| Column | Type | Notes |
|---|---|---|
id |
SERIAL PK | |
age_group |
TEXT | 2 months, 4 months, 1 year, … |
domain |
TEXT | motor / language / social / cognitive |
milestone_text |
TEXT | |
sort_order |
INTEGER | |
created_at, updated_at |
TIMESTAMPTZ |
Editable from the Admin Panel via routes/adminMilestones.js.
Read-only from the user-facing milestones tab.
pgmigrations
Created and managed by node-pg-migrate. Records applied migration
filenames + run time. Never edit by hand.
12. Encryption at rest
src/utils/crypto.js. AES-256-GCM via Node's built-in crypto. No
external KMS — symmetric key from a single env var.
Key handling
var raw = process.env.DATA_ENCRYPTION_KEY;
if (raw) {
if (raw.length === 64 && /^[0-9a-fA-F]+$/.test(raw)) {
KEY = Buffer.from(raw, 'hex'); // preferred: 32 raw bytes
} else if (raw.length >= 32) {
KEY = crypto.createHash('sha256').update(raw).digest(); // fallback: SHA-256 derive
console.warn('[crypto] DATA_ENCRYPTION_KEY is not 64 hex chars; derived via SHA-256.');
}
}
if (!KEY && (process.env.NODE_ENV === 'production' || process.env.APP_URL)) {
process.exit(1); // hard refuse to run in prod without a key
}
The fallback exists so a humans-typed passphrase still works in dev.
Production must pass a real 64-hex key (openssl rand -hex 32).
Format
Strings. enc1: prefix + base64( iv(12) || authTag(16) ||
ciphertext ).
function encryptString(plaintext) {
if (plaintext == null) return plaintext;
if (!KEY) return plaintext; // dev passthrough
var iv = crypto.randomBytes(12);
var cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
var ct = Buffer.concat([cipher.update(String(plaintext), 'utf8'), cipher.final()]);
var tag = cipher.getAuthTag();
return PREFIX + Buffer.concat([iv, tag, ct]).toString('base64');
}
Buffers. Single 0x01 version byte prefix + iv(12) + tag(16) + ciphertext. The 0x01 byte is the discriminator from legacy raw-gzip buffers (which start with 0x1F, the gzip magic).
What is encrypted
users.nextcloud_tokensaved_encounters.transcriptsaved_encounters.generated_notesaved_encounters.partial_datapersonal_notes.titlepersonal_notes.bodyuser_memories.nameuser_memories.contentaudio_backups.audio_data(gzip → AES-GCM)
What is NOT encrypted
- All
userscolumns exceptnextcloud_token(email is indexed; role needs to be readable for admin). audit_log.details— but it goes throughredact.jsfirst and is capped at 500 chars.api_log.*,access_log.*— operational telemetry only, no PHI.app_settings.value.learning_content.body— public content (admin-curated).developmental_milestones.milestone_text— reference data.user_documents.*— metadata only; the file bytes live in S3 with whatever encryption that bucket has. Recommend SSE-S3 or KMS at the bucket level.
Legacy plaintext rows
Both decryptString and decryptBuffer pass through values without
the encryption prefix. This means a row written before encryption was
introduced reads back as plaintext, and a re-write encrypts it. No
explicit migration was run. Over time, rows naturally rotate to
encrypted as users edit them.
Why GCM, not CBC
Authenticated encryption — the auth tag detects ciphertext tampering, which a stolen-DB-but-no-key attacker might attempt to use to inject controlled content. CBC + HMAC would work too but GCM is one construct.
Why one app-wide key
The threat model is "Postgres dump leaks." If the attacker has the running container, they have the key, and there is nothing application-layer encryption can do. KMS / per-user keys would help the latter but not the former; the cost (KMS setup, key rotation, multi-tenant complexity) was deemed not worth it for a single-tenant self-hosted tool.
13. Optimistic locking + idempotency
Two related guards on saved_encounters, both enforced by
routes/encounters.js. Sacred file — do not refactor.
Optimistic locking via version
Schema (added by migrations/1744650000000_add-encounter-version.js):
exports.up = (pgm) => {
pgm.addColumn('saved_encounters', { version: { type: 'integer', notNull: true, default: 1 } });
};
Server logic (routes/encounters.js:81–105, simplified):
var existing = await db.get('SELECT id, version FROM saved_encounters WHERE id = $1 AND user_id = $2', [id, req.user.id]);
if (!existing) return res.status(404).json({ error: 'Not found' });
var expected = req.body.expected_version;
if (expected != null && Number(expected) !== Number(existing.version || 1)) {
return res.status(409).json({ error: '...modified in another tab...' });
}
var newVersion = (Number(existing.version) || 1) + 1;
var upd = await db.run(
'UPDATE saved_encounters SET ..., version=$6, ... WHERE id=$7 AND user_id=$8 AND (version = $9 OR $9::int IS NULL)',
[..., newVersion, id, req.user.id, expected != null ? Number(expected) : null]
);
if (upd.changes === 0) return res.status(409).json({ error: 'changed under you. Reload and retry.' });
res.json({ success: true, id: id, version: newVersion });
Two layers:
- Compare-and-set in the WHERE clause — the UPDATE only runs if
the row's version still matches the expected version. If it
doesn't,
upd.changes === 0and we 409. - Read-then-compare guard — even before the UPDATE, we do a SELECT and compare; this gives a cleaner 409 message with both versions reported back to the client.
Backwards compat: if the client doesn't send expected_version, the
guard degrades to last-write-wins. Older mobile builds rely on this.
Idempotency keys
Schema (in database.js:303–304):
ALTER TABLE saved_encounters ADD COLUMN IF NOT EXISTS idempotency_key TEXT;
CREATE UNIQUE INDEX idx_saved_enc_idemp ON saved_encounters(user_id, idempotency_key) WHERE idempotency_key IS NOT NULL;
Frontend generates a UUID and posts it with the create. If a
double-click sends two POSTs, the second one finds an existing row
with the same (user_id, idempotency_key) and the route returns the
existing id rather than creating a duplicate. The partial unique index
keeps the constraint from blocking nulls.
14. Migrations
node-pg-migrate, run programmatically from src/db/migrate.js after
the inline initDatabase() baseline finishes.
File layout
migrations/
1744600000000_example-no-op.js
1744650000000_add-encounter-version.js
1777003849000_add-personal-notes.js
1777090000000_notes-trash.js
Filename prefix is a millisecond timestamp; node-pg-migrate sorts and
runs them in order, skipping anything in the pgmigrations table.
Convention
exports.up = (pgm) => {
pgm.addColumn('saved_encounters', {
version: { type: 'integer', notNull: true, default: 1 }
});
};
exports.down = (pgm) => {
pgm.dropColumn('saved_encounters', 'version');
};
pgm is the migration helper. Full API: salsita.github.io/node-pg-migrate.
Always write a down (even if it's a no-op // not safely reversible)
so rollback is possible during testing.
Adding a new migration
- Create the file.
npm run migrate:new -- name-of-thingcreatesmigrations/<timestamp>_name-of-thing.js. (Or copy the1744600000000_example-no-op.jsfile and rename — the script shells out to node-pg-migrate's CLI, which does this same thing.) - Write
up. Usepgm.createTable / addColumn / createIndex / dropTable / sql(...). For raw SQL:pgm.sql('UPDATE ... SET ...'). - Write
down. Reverse the change. If genuinely irreversible (data destruction), leave it empty with a comment. - Test locally.
docker compose -f docker-compose.local.yml up -dthendocker exec pediatric-ai-scribe npm run migrate:statusto see what's applied. The boot path runs migrations automatically (database.js:441–449); you don't need to invoke node-pg-migrate manually. - Commit. Migrations are part of the same commit as the code that uses the new column.
npm scripts
From package.json:6–17:
migrate → node-pg-migrate (raw CLI)
migrate:up → run all pending up
migrate:down → roll back one
migrate:status → list applied migrations + run timestamps (uses pg directly)
migrate:new → scaffold a new migration file
The implicit baseline
database.js's initDatabase() runs on every boot, before
migrations. It contains every table that existed before node-pg-migrate
was adopted, expressed as CREATE TABLE IF NOT EXISTS. This means:
- Fresh DBs get the full baseline as one big DDL block, then migrations layer deltas on top.
- Existing DBs see all those
IF NOT EXISTSand skip them. - Adding a new table now: always do it as a migration, never edit the inline baseline. The baseline is frozen in time as the pre-tooling cutover snapshot.
15. Logging
Three writers, three tables, all batched via src/utils/auditQueue.js.
logger.audit(userId, action, details, req, extra)
Writes to audit_log. Used for security and action events:
login, login_failed, session_idle_timeout, password_changed,
generate_soap, 2fa_backup_code_used, encounter_save,
encounter_load, webdav_upload, admin_user_promote, etc.
details is run through redact.js and capped at 500 chars.
extra can carry { category, model, tokens, duration, status }.
Also fire-and-forget shipped to Loki if LOKI_URL is set.
logger.apiCall(userId, endpoint, data)
Writes to api_log. Auto-fired by loggingMiddleware
(src/middleware/logging.js) on every non-GET /api/* request — see
the res.json wrap at logging.js:13–41. Records:
endpoint, method, status, sizes, model, tokens, cost estimate,
duration, IP, error.
Cost is a hardcoded rate table at logger.js:32–48 — not
authoritative, just informational. OpenRouter calls would have live
pricing in the response; a future improvement is to read from the
usage.cost field when present.
logger.access(userId, action, req, success)
Writes to access_log. Auth-only stream: login, logout,
failed_login. Used by routes/auth.js and routes/oidc.js.
logger.error / logger.warn / logger.info — file logger
Writes JSONL to data/logs/YYYY-MM-DD.log. PHI-redacted via
redact.js. Stored on the scribe-logs named volume so logs
survive container rebuilds.
auditQueue.js batching
Each of the three tables has its own queue. Inserts buffer in memory, flushing on either:
- 50-entry batch limit reached (
setImmediate(flush)). - 1-second timer interval (
setInterval). - Process shutdown (
drainAll()from the SIGTERM handler).
Build the SQL with multi-row INSERT ((?, ?, ?), (?, ?, ?), ...) so
under load, hundreds of audit lines coalesce into one round-trip.
Trade-off. A process crash between flushes loses up to 1 second of audit entries. The PostgreSQL row is the primary destination; Loki is separately pushed fire-and-forget per call (no batching there — Loki's own ingestion handles batching). Acceptable for an audit trail in a single-tenant clinical tool. Not acceptable for, say, financial txn logs — but that's not what this is.
16. Static reference linter (scripts/lint-references.js)
Why it exists
In a vanilla-JS + window-globals + lazy-HTML codebase, the compiler catches nothing. The class of bug you get is:
- Rename
<button id="btn-foo-old">to<button id="btn-foo-new">and forget to greppublic/js/foo.js. - The old handler
document.getElementById('btn-foo-old').addEventListener(...)is no longer wired up. - Page loads fine, no error in the console — the button is just dead.
The Bedside lightbox bug was the canonical example. The
<img id="img-lightbox"> element used to live in calculators.html,
loaded as part of the calculators tab. After a reorg, Bedside got its
own component but the lightbox markup didn't move. bedside/image-lightbox.js
kept calling getElementById('img-lightbox') and silently no-op'd.
What the linter does
Reads every file under public/js/. Builds a set of every id
defined anywhere in the repo — both static HTML attributes
(id="X") and dynamic JS (element.id = "X"). Then scans JS for
getElementById('X'), querySelector('#X'), etc. and flags
references whose id appears nowhere in the source tree.
Also scans <img|audio|video|source|link> src= and data-img-src
attributes — verifies the file exists on disk under public/.
What it doesn't do
Does not model control flow. An id defined in components/foo.html
satisfies a JS reference even if foo.html never gets loaded into
the DOM at runtime. To catch that you'd need a build-time component
graph, which is exactly the framework the codebase has decided to
not adopt.
Allowlist
DYNAMIC_ID_PREFIXES at lint-references.js:64–116 is the manual
allowlist for ids constructed at runtime — em-, shadess-, wv-panel-,
the bedside pathway prefixes (nrp-, seizure-, sepsis-, airway-,
cardiac-, anaph-, burn-, vent-, tox-, trauma-, neo-,
resp-, sed-, agit-, antiemetic-, antibio-), the calculator
prefixes (bili-, bmi-, bp-, growth-, dose-, bsa-, equip-,
resus-, gcs-, vitals-), the auth UI prefixes (totp-, 2fa-,
fpa-, rsp-), etc.
When you add a new feature that builds ids dynamically, you'll likely
need a one-line addition here. Keep prefixes specific (ending in -)
to avoid swallowing real bugs.
Running it
node scripts/lint-references.js
Exit code is non-zero on any unresolved reference or broken asset — suitable for CI. Currently invoked manually as part of pre-release discipline, not yet wired into a hook.
17. Deployment
Dockerfile — Dockerfile
Two-stage build. The first stage pulls the OpenBao CLI binary out of
openbao/openbao:2.5.3 (see section 18). The second stage is the
runtime image:
FROM node:20-alpine
WORKDIR /app
RUN apk add --no-cache ffmpeg curl jq
COPY --from=bao-src /bin/bao /usr/local/bin/bao
COPY package.json ./
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
&& npm install --omit=dev \
&& apk del .build-deps
COPY . .
RUN chmod +x /app/docker-entrypoint.sh
RUN mkdir -p /app/data/logs
RUN mkdir -p /app/public/models/Xenova/whisper-tiny.en/onnx && \
cd /app/public/models && \
curl -sL -o transformers.min.js https://cdn.jsdelivr.net/npm/@xenova/transformers@2.0.0/dist/transformers.min.js && \
cd Xenova/whisper-tiny.en && \
curl -sL -o config.json https://huggingface.co/Xenova/whisper-tiny.en/resolve/main/config.json && \
...
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "server.js"]
Notes:
ffmpegis needed to convert WebM (browser MediaRecorder output) to PCM for AWS Transcribe.curl,jqsupport the OpenBao secret-fetch step (jq) and miscellaneous downloads (curl).python3 make g++only at build time — needed by argon2's node-gyp build, then dropped via the.build-depsvirtual package. Final image stays slim (~220 MB).npm install --omit=dev— devDependencies (jsdom, dompurify for tests) are not in the runtime image.- Browser Whisper bundle. Pulls the @xenova/transformers worker build + the whisper-tiny.en model files at image-build time so the running container has zero external dependencies for browser- side transcription. ~42 MB of model files baked in.
- Healthcheck. Polls
/api/healthevery 30 s. Container marked unhealthy after 5 failures.
docker-compose files
Three compose files, layered:
docker-compose.yml— production base. Pediatric-scribe on127.0.0.1:3552, postgres internal-only, named volumespgdata+scribe-logs, app depends on postgres healthcheck.docker-compose.local.yml— dev variant. Binds 3552 publicly (no127.0.0.1restriction), usespostgres:16-alpine(no pgvector — for fast iteration without semantic search).docker-compose.e2e.yml— Playwright test container on127.0.0.1:3553, sharing the same postgres + pgdata volume. Disables Turnstile (TURNSTILE_SECRET_KEY="",TURNSTILE_SITE_KEY=""), disables SMTP (SMTP_HOST=""so register auto-verifies and returns a session), raises rate limits (LOGIN_RATE_LIMIT_MAX=500,API_RATE_LIMIT_MAX=5000), and widensCORS_ORIGINSto include the test hostnames.docker-compose.monitoring.yml— Loki + Grafana stack (section 19). Overrides the app container to addLOKI_URL=http://loki:3100.
Layered usage:
# Production
docker compose up -d
# Dev (no localhost-restricted port)
docker compose -f docker-compose.local.yml up -d
# Full stack with monitoring
docker compose -f docker-compose.yml -f docker-compose.monitoring.yml up -d
# E2E test instance alongside prod
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e
Volumes
| Volume | Purpose |
|---|---|
pgdata |
All Postgres data — users, encounters, memories, audit logs, settings, embeddings. Critical for backup. |
scribe-logs |
Filesystem JSONL log files (data/logs/YYYY-MM-DD.log). Low priority — Postgres has the same data in audit_log. |
loki-data |
Loki chunk storage (when monitoring stack is up). |
grafana-data |
Grafana settings, saved dashboards. |
Reverse proxy
App binds 127.0.0.1:3552, never to a public interface. Caddy / Nginx /
Traefik terminates TLS and forwards to it. app.set('trust proxy', 1)
in server.js:16 makes req.ip the original client IP from
X-Forwarded-For.
docker-entrypoint.sh
The container's ENTRYPOINT. Optional OpenBao path described in
section 18. Falls through to exec "$@" (i.e., node server.js)
when OpenBao isn't configured. Backwards compatible — legacy
.env-only deployments work unchanged.
18. OpenBao secret loading
docker-entrypoint.sh is the integration point. OpenBao is the open-
source fork of HashiCorp Vault that Daniel runs at app.danvics.com
for service secrets (Vaultwarden is for human logins; OpenBao is for
machine-to-machine).
Trigger
The entrypoint checks OPENBAO_ADDR. If unset, skip OpenBao entirely
and start the Node process with whatever's already in env (the legacy
.env path). If set, OPENBAO_ROLE_ID and OPENBAO_SECRET_ID are
required (FATAL otherwise).
Flow
-
AppRole login.
bao write -field=token auth/approle/login \ role_id="${OPENBAO_ROLE_ID}" \ secret_id="${OPENBAO_SECRET_ID}"Captures the resulting token into
BAO_TOKEN. -
Fetch the KV.
bao kv get -format=json "${OPENBAO_KV_PATH:-kv/ped-ai/prod}" | jq -c '.data.data'The path defaults to
kv/ped-ai/prod. The response is a single JSON object of{ KEY: VALUE, ... }. -
Snapshot pre-existing env. The entrypoint records the keys already in env (from docker-compose
env_file/environment:) into a temp file. -
Per-key apply. For every key returned from OpenBao:
- If the key is already in env (set by docker), skip — the
docker-compose override wins. This is critical for the e2e
container:
TURNSTILE_SECRET_KEY=""fromdocker-compose.e2e.ymlmust not be overwritten by OpenBao's real prod value. - Otherwise,
eval "export $K=$VAL"(with@shshell-quoted value to avoid injection).
- If the key is already in env (set by docker), skip — the
docker-compose override wins. This is critical for the e2e
container:
-
Unset auth material.
unset OPENBAO_ROLE_ID OPENBAO_SECRET_ID BAO_TOKENSo the Node process doesn't carry them.
-
exec "$@"— replace the shell withnode server.js.
Why
Production secrets (JWT_SECRET, DATA_ENCRYPTION_KEY, AI provider
keys, SMTP creds, etc.) shouldn't sit in .env files on disk. OpenBao
holds them centrally; only OPENBAO_ROLE_ID + OPENBAO_SECRET_ID
need to be on the host (and AppRole credentials are themselves
rotatable / revocable / auditable).
19. Optional services
Loki + Grafana — docker-compose.monitoring.yml
- Loki 3.4.2 at
127.0.0.1:3101. Internal-only ingest athttp://loki:3100/loki/api/v1/push. - Grafana 11.6.0 at
127.0.0.1:3003. Pre-provisioned Loki datasource + dashboards frommonitoring/dashboards/. - The app gets
LOKI_URL=http://loki:3100injected;logger.js:16–29fire-and-forgets every audit/api/access record to Loki in addition to the Postgres write.
Stack: streams labeled { app: 'pedscribe', type: 'audit' | 'api_call' | 'access', ... }. Full message body is JSON.
Nextcloud — WebDAV upload
Per-user creds stored in users.nextcloud_url, nextcloud_user,
nextcloud_token (encrypted), nextcloud_folder. Routes:
routes/nextcloud.js — upload finished notes as .txt / .md files
via WebDAV PUT.
There's also a Learning Hub WebDAV file browser keyed off
users.webdav_learning_path.
S3 — document storage
Config: S3_BUCKET, S3_REGION, S3_PREFIX, S3_ENDPOINT,
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_FORCE_PATH_STYLE.
The path-style flag is needed for MinIO, Backblaze B2, and most
non-AWS providers (AWS itself supports virtual-hosted style).
routes/documents.js does presigned PUT/GET via the AWS SDK. File
bytes never touch the app's filesystem — direct upload from browser to
S3 in a future revision.
pgvector — embeddings
Already installed via pgvector/pgvector:pg16 image. The
learning_content.embedding column is VECTOR(768), indexed via
IVFFLAT once ≥ 10 rows have embeddings. Default model:
text-embedding-005 (Vertex). Switching to a different-dimension
model requires an ALTER COLUMN embedding TYPE vector(N) migration
plus re-embedding all content.
ntfy — push notifications
Optional — set NTFY_URL (and NTFY_TOKEN if needed). src/utils/notify.js
posts to pedscribe-user-${userId} topics for new-login alerts and
password-change confirmations. Admin notifications go to
pedscribe-admin.
Cloudflare Turnstile
TURNSTILE_SITE_KEY + TURNSTILE_SECRET_KEY. When unset, server-side
verification is a no-op and the frontend skips the iframe entirely.
Required for any deployment with public-facing registration.
20. Sacred zones
These files / behaviors must not be casually edited. Per Daniel's explicit instruction set in MEMORY:
public/js/encounters.js — save / idempotency
Every change requires per-change approval. Even a pre-approved change
gets rejected if it refactors the save path or idempotency logic.
The reason: Daniel has lost notes mid-clinic to encounter-save bugs
twice. The current shape works — defensive try/catch, optimistic
locking, idempotency keys, label-uniqueness check before insert.
Refactoring "for clarity" is forbidden unless an existing bug is
named first.
Voice / STT plumbing
public/js/audioBackup.js, public/js/browserWhisper.js,
public/js/voiceDictation.js, public/js/liveEncounter.js,
public/js/whisperWorker.js, public/js/whisperWorkerV2.js,
public/js/transcriptionSettings.js, public/js/voicePreferences.js,
public/js/speechRecognition.js, plus the server-side
src/routes/transcribe.js and src/utils/transcribe*.js.
Don't refactor recorder / transcribe plumbing. Fix only named bugs in the smallest possible diff. The recorder lifecycle has been tuned over many cycles to handle: tab backgrounding, OS audio interruption, MediaRecorder mime negotiation across Chrome/Firefox/ Safari, browser Whisper fallback when network fails, server-side audio backup on transcription failure. Touching anything outside the named bug is how the silent-cancel branch became always-taken in v6.53.0 (fixed in 66f319e).
Clinical formulas
public/js/calculators.js— pediatric calculators (Bili, BMI, BP percentiles, GCS, BSA, dosing). Specifically the formula constants and citation-anchored values.public/js/peGuide.jsSCALES section — heart sound timing, respiratory exam findings, etc.public/js/bedside/age-weight.js— Broselow / weight-by-age estimators, drug dose tables.
These are clinical references with citations. Changes require peer-reviewed source; no guessing.
Auth flow
src/routes/auth.js, src/routes/oidc.js, src/middleware/auth.js,
src/utils/sessions.js, src/utils/passwords.js, public/js/auth.js,
public/js/authFetch.js. Mature, hardened code with subtle
interactions (sliding idle, BroadcastChannel sync, mobile vs web
transport, OIDC auto-link). Touch with care.
21. Anti-patterns to avoid
Pulled directly from the patterns visible in the codebase:
1. Editing public/js/encounters.js without per-change approval
It's sacred. See above. If a feature needs to write to
saved_encounters, route it through the existing POST /api/encounters/saved
path — don't bypass the wrapper.
2. Renaming a button id without grepping handlers
The lint-references.js linter catches the easiest version (id
literally exists nowhere). It does not catch ids that exist in
some other component which never loads on the affected tab. Before
renaming any id="...", do grep -r 'btn-foo' public/.
3. Adding a new feature inside a 1500-line file
src/routes/learningAI.js (806 lines), src/routes/adminConfig.js
(987 lines), and public/js/calculators.js are red-flag sized.
New features should be their own file. Carve out subroutes:
src/routes/learning-quiz.js, src/routes/learning-embed.js, etc.
The improvement roadmap explicitly calls out "split calculators.js"
as a prioritized refactor.
4. Using bare fetch() instead of the standard helpers
// WRONG
fetch('/api/foo', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(...) })
// RIGHT
fetch('/api/foo', { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(...) })
The getAuthHeaders() helper handles the web/native split. Bare
fetch() works on web (the cookie rides automatically) but breaks
silently on native (no Bearer token attached, request 401s).
5. Native alert / confirm / prompt
Banned. Use showToast(msg, type) and showConfirm(msg) from
app.js. The native dialogs block the JS thread, look terrible on
mobile WebView, and are inconsistent across browsers.
6. Adding "explanatory UI copy"
Banned by Daniel's MEMORY directive
(feedback_no_explanatory_ui_copy.md). Skip "your note saves as you
type" / tip-lists / "encrypted at rest" filler. Reads as
AI-generated.
7. Suggesting "wipe and reset" as a fix
Banned by Daniel's MEMORY directive
(feedback_destructive_actions.md). Even when "nothing important is
stored", work forward from existing state. Don't propose
pgdata rebuild, npm install from scratch, branch deletion, etc.
as a first move.
8. Citation comment blocks added silently
Banned by Daniel's MEMORY directive
(feedback_no_code_citations.md). Don't dump
/* eslint-disable */ // Adapted from https://... blocks into files
without proposing first. Inline references for load-bearing data
(Fenton growth charts, peditools formulas) are OK without asking.
9. Mounting a router globally instead of per-feature
Don't app.use('/api', authMiddleware). Each router uses
router.use(authMiddleware) at the top. Globally mounting auth
breaks /api/health, /api/models, OIDC callbacks, and the public
auth endpoints — none of which should require an authenticated user.
10. Direct db.pool.query from a route
Use db.get / db.all / db.run / db.query from src/db/database.js.
The wrapper handles the SQLite-style placeholder conversion and the
auto-RETURNING for inserts. Bypassing it leaks the legacy ? syntax
detail and makes future driver swaps painful.
22. How to add a new tab end-to-end
Concrete checklist. Use this as a template.
1. Sidebar button
Edit public/index.html. Add inside the <nav id="sidebar-nav">
section:
<button class="tab-btn" data-tab="mytab">
<span class="tab-icon">🧪</span>
<span class="tab-label">My Tab</span>
</button>
(If the tab is admin/moderator-only, add class="tab-btn hidden"
and an id="mytab-tab-btn" so auth.js can toggle visibility.)
2. Tab content placeholder
Edit public/index.html. Add inside the <main> section:
<section id="mytab-tab" class="tab-content" data-component="mytab"></section>
The id must be ${tabName}-tab and data-component is the
filename (without .html) under public/components/.
3. Component HTML
Create public/components/mytab.html:
<div class="container">
<h1>My Tab</h1>
<button id="mytab-do-thing">Do Thing</button>
<pre id="mytab-output"></pre>
</div>
No <script> tags here — JS lives in public/js/.
4. Per-tab JS module
Create public/js/mytab.js:
// ============================================================
// MYTAB.JS — Demo per-tab module
// ============================================================
(function () {
function init() {
var btn = document.getElementById('mytab-do-thing');
if (!btn || btn._wired) return;
btn._wired = true;
btn.addEventListener('click', async function () {
var resp = await fetch('/api/mytab', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ hello: 'world' })
});
var data = await resp.json();
document.getElementById('mytab-output').textContent = JSON.stringify(data, null, 2);
});
}
document.addEventListener('tabChanged', function (e) {
if (e.detail && e.detail.tab === 'mytab') init();
});
})();
Then add the script tag to public/index.html (in the deferred-script
list, alphabetically per existing convention):
<script defer src="/js/mytab.js"></script>
5. Server route
Create src/routes/mytab.js:
var express = require('express');
var router = express.Router();
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
router.use(authMiddleware);
router.post('/mytab', async function (req, res) {
try {
var hello = (req.body || {}).hello;
if (!hello) return res.status(400).json({ error: 'hello required' });
res.json({ success: true, echoed: hello, user: req.user.email });
logger.audit(req.user.id, 'mytab_call', 'mytab call: ' + hello, req, { category: 'general' });
} catch (e) {
logger.error('POST /mytab', e.message);
res.status(500).json({ error: 'Request failed' });
}
});
module.exports = router;
6. Mount the route in server.js
Add to the authenticated feature router list near
server.js:279–303:
app.use('/api', require('./src/routes/mytab'));
7. Prompt entry (only if you're calling AI)
If your route calls callAI, add the prompt template to
src/utils/prompts.js:
PROMPTS.mytab = function (input) {
return `You are a pediatric assistant. ${input}`;
};
Live-editable from the Admin Panel via app_settings key
prompt.mytab.
8. Encounter API integration (only if it persists like an encounter)
If your tab needs save/resume, don't create a new persistence
table. Use POST /api/encounters/saved with a new enc_type value
(e.g. enc_type: 'mytab'). The tab list at section 11 shows
existing values. Add your new type to whichever client code lists
encounter types (public/js/encounters.js's rendering of saved
encounters).
This one piece touches sacred code — get per-change approval.
9. Lint check
node scripts/lint-references.js
Should print ✓ All JS id references resolve to an HTML id. and
✓ All asset paths resolve to a file on disk.
10. Restart and test
docker compose restart pediatric-scribe
Open the app, click the new tab, click the button, see the JSON
echo. Look at audit_log:
SELECT * FROM audit_log WHERE action = 'mytab_call' ORDER BY timestamp DESC LIMIT 5;
11. Commit
feat(mytab): add demo tab end-to-end
- Sidebar button + tab placeholder
- public/components/mytab.html
- public/js/mytab.js
- src/routes/mytab.js mounted at /api/mytab
- Audit category: general
If the new tab is admin/moderator-only, add a role check before the
sidebar button toggle in auth.js (look for the existing pattern
around cms-tab-btn and admin-tab-btn).
Appendix A — File map (compressed)
server.js # composition root (388 lines)
docker-entrypoint.sh, Dockerfile, docker-compose{,.local,.e2e,.monitoring}.yml
src/
db/ database.js, migrate.js
middleware/ auth.js, logging.js
routes/ 32 Express routers — see section 9 table
utils/ config, crypto, auditQueue, redact, logger, errors, fileType,
platform, notify, sessions, passwords, promptSafe, prompts,
models, ai, embeddings, transcribe*, tts*
migrations/ # node-pg-migrate, timestamp-prefixed
public/
index.html # ~38 <script> tags, sidebar + placeholders
sw.js # service worker
js/
app.js, auth.js, authFetch.js, secureStorage.js, ui-state.js
encounters.js, audioBackup.js, liveEncounter.js, voiceDictation.js,
browserWhisper.js, whisperWorker*.js, speechRecognition.js,
transcriptionSettings.js, voicePreferences.js # ALL SACRED — STT
calculators.js, calc-math.js, drugs-loader.js, peGuide.js # SACRED — formulas
bedside/index.js # ES-MODULE POCKET (script type="module")
bedside/{age-weight,sub-nav,airway,cardiac,sepsis,...}.js
soap, hospitalCourse, chartReview, milestones, sickVisit, wellVisit,
ed-encounters, shadess, extensions, notes, memories, documents,
nextcloud, learningHub, admin
pediatricScheduleData.js, milestonesData.js, e2e-bootstrap.js
components/ # one HTML fragment per tab (~20 files)
models/ # bundled transformers.js + Whisper-tiny.en
scripts/
lint-references.js, maintenance.js, release.sh, e2e.sh,
download-whisper-models.sh, import-milestones.js
monitoring/ # Loki + Grafana provisioning + dashboards
mobile/ # Capacitor wrapper (Android shipping)
Appendix B — Boot sequence one-pager
- Docker starts container, runs
/app/docker-entrypoint.sh. - If
OPENBAO_ADDRset: AppRole login, fetchkv/ped-ai/prod, export missing keys to env. exec node server.js.dotenvreads.env(any keys not already in env from step 2).auditQueue.jsinitializes (3 in-memory queues).database.jsconnects pool, runsinitDatabase():- pgvector extension.
- Collation drift check + REINDEX if needed.
- Idempotent baseline DDL.
- Versioned migrations via
migrate.js. - Seed default
app_settings.
database.jsschedules hourly cleanup (_cleanupInterval) + one-shot at +10s.server.jsloadspackage.jsonfor version banner.app.set('trust proxy', 1).- helmet (CSP), CORS (/api scope), cookie-parser, json(10mb).
- Rate limiters mounted (general /api + auth-specific).
- BUILD_ID computed; index.html cache-bust template primed.
/,/api/builddefined.- Logging middleware (
res.jsonwrap). - Static (
public/) with per-file Cache-Control. - Auth, learning admin, admin routers mounted.
- Public endpoints (
/api/models,/api/health,/api/health/detailed). - 28+ authenticated feature routers mounted.
- 404 handler.
setTimeout(() => PROMPTS.loadFromDb(db), 3000)— loads admin-edited prompt overrides.server.listen(PORT)— banner printed.- SIGTERM/SIGINT handlers registered (audit drain → DB pool end → 9-second hard exit).
Healthy and serving.
Appendix C — Request lifecycle (compressed)
Browser → reverse proxy → 127.0.0.1:3552 → Express
helmet → cors(/api) → cookieParser → json(10mb)
→ rate limiter(general 200/min)
→ rate limiter(endpoint-specific, if any)
→ static OR route mount
└─ router.use(authMiddleware): verify JWT → lookup user →
check disabled → lookup session by token_hash → check idle
(web only) → update last_activity (write methods) →
attach req.user
└─ handler: validate → db.get/all (decrypt) →
callAI? → db.run (encrypt) → res.json
└─ loggingMiddleware fires apiCall log on res.json
└─ handler may also fire audit log
◀ response
↳ auditQueue flushes (1s interval or 50-row batch)
↳ Loki push fire-and-forget (if LOKI_URL set)
End of document.