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

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

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

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

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

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

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

Multiple iterations based on Daniel's feedback:

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

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

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

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

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

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

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

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

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

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

90 KiB
Raw Blame History

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_modules for 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:155204 injects ?v=BUILD_ID so 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 = y plus copy-paste discipline. Renaming a button id and forgetting to grep produces a silent dead handler. The static reference linter at scripts/lint-references.js catches 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:

  1. window.X globals. A producer file does window.callAI = ... and a consumer file later in script order calls window.callAI(...).
  2. document.dispatchEvent(new CustomEvent('tabChanged', ...)) — the only system-wide event bus. See public/js/app.js:78 for the producer; every per-tab module subscribes (e.g. app.js:156 for the settings tab).
  3. localStorage / UIState (section 6). Cross-reload state.
  4. BroadcastChannel('pedscribe-auth') — cross-tab logout. See public/js/authFetch.js:22.

Why this pattern was chosen

  • Zero build dependency. The team is one pediatrician. He doesn't want to debug vite dev at 11pm before clinic.
  • <script defer> is enough. Defer guarantees in-order execution after the document parses. As long as app.js precedes per-tab files, every consumer sees its producers' globals.
  • Each file is an island. A bug in peGuide.js cannot crash encounters.js because they don't share a module graph. The IIFE wrapper means a top-level var doesn't leak.
  • Easy to grep. Search for window.X or id="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 as document.addEventListener('click', function(e) { if (e.target.closest('#btn-foo')) ... }) so the handler keeps working after lazy-loaded HTML drops #btn-foo into the DOM later. See public/js/app.js:109116 for the pattern.
  • The classic bug. Rename a button id in components/foo.html and forget to update public/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 at scripts/lint-references.js was 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 = X line at the bottom.
  • Every fetch call uses getAuthHeaders() (public/js/auth.js:260) — on web, this is just Content-Type: application/json and the cookie rides automatically. On native (Capacitor), it adds the Authorization: Bearer <jwt> header from secure storage. See section 7.
  • Any DOM mutation that depends on lazy-loaded markup is gated on the tabChanged event; otherwise the per-tab module would query an empty <section> placeholder.

3. Frontend lifecycle

Page-load order

  1. Browser fetches /.
  2. server.js:198205 hands back index.html with ?v=BUILD_ID appended to every local /js/*.js and /css/*.css reference. The build id is a 7-char git short SHA when running from a checkout (server.js:156170), or a fresh random hex otherwise.
  3. Browser parses the HTML. Each <script defer> is queued.
  4. Once the document is parsed, deferred scripts execute in document order (this is the key contract — deferred scripts preserve ordering, async scripts do not).
  5. The global error trap installs first (public/js/app.js:624) so later boot errors get reported via navigator.sendBeacon to /api/logs/client-error.
  6. DOMContentLoaded fires; app.js:26 registers the tab system.
  7. The first tab (<section class="tab-content active" data-component= "encounter"> per public/index.html) is loaded eagerly via loadComponent at public/js/app.js:6264. That fetches /components/encounter.html, dumps it into the section's innerHTML, sets tabEl.dataset.loaded = '1', dispatches tabChanged with detail.tab = 'encounter'.
  8. 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:3260

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.js exists (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-select and (re)populate it from window._currentModels (set by app.js:290304's call to /api/models).

activateTabpublic/js/app.js:6791

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:

  • activateTab is exposed at window.activateTab = activateTab so auth.js can restore the last tab on login.
  • ped_last_tab in localStorage drives that restore. It is unnamespaced (no ped_ui/ prefix) for backwards compat — Daniel's installation has rows from before ui-state.js existed.
  • The tabChanged event always fires after the component HTML is in the DOM. Subscribers can therefore safely getElementById on 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.js still sets window._EM for 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:167182)
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 to localStorage['ped_sidebar_collapsed']app.js:124149).
  • On mobile (window.innerWidth <= 768), tab clicks auto-close the sidebar (app.js:8689).
  • #btn-menu-toggle, #btn-sidebar-close, #sidebar-overlay are delegated through a single document-level click listener in app.js:109116. 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.js
  • public/js/wellVisit.js
  • public/js/peGuide.js
  • public/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:260273

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_auth httpOnly cookie rides automatically because all fetches are same-origin and fetch()'s default credentials = same-origin sends them.
  • Native (Capacitor) path. Reads the JWT out of secure storage and attaches it as Authorization: Bearer …. There is no cookie on native — the ped_auth cookie is web-only.

The server's authMiddleware (src/middleware/auth.js:2531) 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:

  1. Clears window.AUTH_TOKEN and the SecureStorage / localStorage copies.
  2. Shows a toast.
  3. 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 via getSync).
  • Native. capacitor-secure-storage-plugin → iOS Keychain or Android EncryptedSharedPreferences. Async reads via get, plus a synchronous getSync that returns from a memory cache populated by hydrate(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)

  1. server.js:17require('dotenv').config() reads .env first thing. Then express, cors, helmet, rate-limit, cookie-parser.
  2. server.js:10loggingMiddleware is required (initializing the auditQueues as a side effect).
  3. server.js:1213 — Express app + raw HTTP server (the raw server is kept so SIGTERM can call server.close directly).
  4. server.js:16app.set('trust proxy', 1). Critical: the reverse proxy strips X-Forwarded-For; trust the first hop so req.ip is the real client IP for rate-limit + audit log.
  5. server.js:2154 — 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.net for the transformers worker.
    • huggingface.co + the cdn-lfs subdomains for Whisper model downloads.
    • https://challenges.cloudflare.com for Turnstile.
    • connectSrc extended to clinicaltables.nlm.nih.gov for ICD autocomplete.
    • upgradeInsecureRequests: null so the e2e container can be hit over plain HTTP. Production has Caddy in front, mixed content is a non-issue.
  6. server.js:5980 — 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 neither APP_URL nor CORS_ORIGINS are set, the process refuses to start (server.js:6366). In dev (no APP_URL), all origins are allowed.
  7. server.js:82cookie-parser.
  8. server.js:88express.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 in transcribe.js enforces 25 MB).
  9. server.js:95137 — Rate limiters, layered:
    • Global /api/ — 200 req/min default, overridable via API_RATE_LIMIT_MAX (the e2e container raises to 5000).
    • /api/auth/login — 10 attempts / 15 min, overridable via LOGIN_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.
  10. server.js:140144/.well-known/assetlinks.json for TWA (Trusted Web Activity / Android app verification). Must come before the static handler.
  11. server.js:153196 — Build-id cache busting:
    • Compute BUILD_ID from .git/HEAD (or BUILD_ID file in the Docker image, or random hex).
    • getTemplatedIndex() reads public/index.html, regex-replaces every local /js/...js and /css/...css reference to append ?v=BUILD_ID. The mtime check makes this essentially free after the first read.
  12. server.js:198205GET / and GET /index.html serve the templated HTML. Cache-Control: no-cache, no-store, must-revalidate so browsers always re-fetch (and pick up the new BUILD_ID, which then busts the JS/CSS cache).
  13. server.js:208GET /api/build returns the build id — useful for "what version is the prod box running" debugging.
  14. server.js:210loggingMiddleware. Wraps res.json to emit apiCall log lines for every non-GET /api/* call.
  15. server.js:211226express.static(public/) with per-file Cache-Control:
    • .htmlno-cache, no-store, must-revalidate.
    • .js / .csspublic, max-age=3600 (1 hour) — the ?v=BUILD_ID query param forces a refetch on deploy.
    • /components/* → 1 hour.
  16. server.js:229303 — Route mounts. Order matters:
    • Auth routers first. /api/auth/* from auth.js and oidc.js.
    • Learning Hub admin BEFORE general /api/admin — the learningAdmin router uses moderatorMiddleware (lower privilege than adminMiddleware); mounting it after /api/admin would 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 later authMiddleware mount catches them.
    • Authenticated feature routers (28+) — every one applies authMiddleware internally via router.use(authMiddleware).
  17. server.js:317322 — Fallback GET / and 404 handler.
  18. server.js:329332 — 3-second deferred PROMPTS.loadFromDb(db) call. Lets the DB pool come up first; loads any admin-edited prompt overrides from app_settings rows with key prefix prompt..
  19. server.js:334343 — Listen on process.env.PORT || 3000. Print the banner.
  20. server.js:345387 — 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._cleanupInterval explicitly — the hourly cleanup timer would otherwise keep the event loop alive past process.exit(0) and Docker's 10-second SIGKILL deadline.
    • pool.end() to drain Postgres.
    • 9-second hard deadline to beat Docker's SIGKILL.

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:229303:

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:512518

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:439450

On require, the module:

  1. Logs " PostgreSQL: connected" or fails loudly.
  2. Runs initDatabase() (the idempotent baseline).
  3. Runs node-pg-migrate via runMigrations() from migrate.js — the versioned delta.

initDatabase() does:

  • CREATE EXTENSION IF NOT EXISTS vector — pgvector for embeddings.
  • Collation drift check. Compare pg_database.datcollversion against pg_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 of idx_users_email and idx_sessions_token_hash with COLLATE "C", gated by an app_settings row migration.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 EXISTS for 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 EXISTS migrations for upgrades from older versions (role, disabled, oidc_sub, idempotency_key, etc.).
  • IVFFLAT index on learning_content.embedding once ≥ 10 embeddings exist (lists = sqrt(rows)).
  • Seeds default app_settings rows via INSERT ... ON CONFLICT DO NOTHING (registration_enabled, announcement defaults, feature flags, etc.).

_cleanupIntervaldatabase.js:453471

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 by redact.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 in logger.js:3248 (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:329332) — 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:380399). 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_token
  • saved_encounters.transcript
  • saved_encounters.generated_note
  • saved_encounters.partial_data
  • personal_notes.title
  • personal_notes.body
  • user_memories.name
  • user_memories.content
  • audio_backups.audio_data (gzip → AES-GCM)

What is NOT encrypted

  • All users columns except nextcloud_token (email is indexed; role needs to be readable for admin).
  • audit_log.details — but it goes through redact.js first 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:81105, 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:

  1. 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 === 0 and we 409.
  2. 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:303304):

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

  1. Create the file. npm run migrate:new -- name-of-thing creates migrations/<timestamp>_name-of-thing.js. (Or copy the 1744600000000_example-no-op.js file and rename — the script shells out to node-pg-migrate's CLI, which does this same thing.)
  2. Write up. Use pgm.createTable / addColumn / createIndex / dropTable / sql(...). For raw SQL: pgm.sql('UPDATE ... SET ...').
  3. Write down. Reverse the change. If genuinely irreversible (data destruction), leave it empty with a comment.
  4. Test locally. docker compose -f docker-compose.local.yml up -d then docker exec pediatric-ai-scribe npm run migrate:status to see what's applied. The boot path runs migrations automatically (database.js:441449); you don't need to invoke node-pg-migrate manually.
  5. Commit. Migrations are part of the same commit as the code that uses the new column.

npm scripts

From package.json:617:

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 EXISTS and 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:1341. Records: endpoint, method, status, sizes, model, tokens, cost estimate, duration, IP, error.

Cost is a hardcoded rate table at logger.js:3248 — 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 grep public/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:64116 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:

  • ffmpeg is needed to convert WebM (browser MediaRecorder output) to PCM for AWS Transcribe.
  • curl, jq support 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-deps virtual 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/health every 30 s. Container marked unhealthy after 5 failures.

docker-compose files

Three compose files, layered:

  1. docker-compose.yml — production base. Pediatric-scribe on 127.0.0.1:3552, postgres internal-only, named volumes pgdata + scribe-logs, app depends on postgres healthcheck.
  2. docker-compose.local.yml — dev variant. Binds 3552 publicly (no 127.0.0.1 restriction), uses postgres:16-alpine (no pgvector — for fast iteration without semantic search).
  3. docker-compose.e2e.yml — Playwright test container on 127.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 widens CORS_ORIGINS to include the test hostnames.
  4. docker-compose.monitoring.yml — Loki + Grafana stack (section 19). Overrides the app container to add LOKI_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

  1. 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.

  2. 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, ... }.

  3. Snapshot pre-existing env. The entrypoint records the keys already in env (from docker-compose env_file/environment:) into a temp file.

  4. 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="" from docker-compose.e2e.yml must not be overwritten by OpenBao's real prod value.
    • Otherwise, eval "export $K=$VAL" (with @sh shell-quoted value to avoid injection).
  5. Unset auth material.

    unset OPENBAO_ROLE_ID OPENBAO_SECRET_ID BAO_TOKEN
    

    So the Node process doesn't carry them.

  6. exec "$@" — replace the shell with node 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 at http://loki:3100/loki/api/v1/push.
  • Grafana 11.6.0 at 127.0.0.1:3003. Pre-provisioned Loki datasource + dashboards from monitoring/dashboards/.
  • The app gets LOKI_URL=http://loki:3100 injected; logger.js:1629 fire-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.js SCALES 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:279303:

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

  1. Docker starts container, runs /app/docker-entrypoint.sh.
  2. If OPENBAO_ADDR set: AppRole login, fetch kv/ped-ai/prod, export missing keys to env.
  3. exec node server.js.
  4. dotenv reads .env (any keys not already in env from step 2).
  5. auditQueue.js initializes (3 in-memory queues).
  6. database.js connects pool, runs initDatabase():
    • pgvector extension.
    • Collation drift check + REINDEX if needed.
    • Idempotent baseline DDL.
    • Versioned migrations via migrate.js.
    • Seed default app_settings.
  7. database.js schedules hourly cleanup (_cleanupInterval) + one-shot at +10s.
  8. server.js loads package.json for version banner.
  9. app.set('trust proxy', 1).
  10. helmet (CSP), CORS (/api scope), cookie-parser, json(10mb).
  11. Rate limiters mounted (general /api + auth-specific).
  12. BUILD_ID computed; index.html cache-bust template primed.
  13. /, /api/build defined.
  14. Logging middleware (res.json wrap).
  15. Static (public/) with per-file Cache-Control.
  16. Auth, learning admin, admin routers mounted.
  17. Public endpoints (/api/models, /api/health, /api/health/detailed).
  18. 28+ authenticated feature routers mounted.
  19. 404 handler.
  20. setTimeout(() => PROMPTS.loadFromDb(db), 3000) — loads admin-edited prompt overrides.
  21. server.listen(PORT) — banner printed.
  22. 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.