# 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              | Server-side providers such as LiteLLM/OpenAI-compatible transcription, Google/Gemini, AWS Transcribe, or local server STT where configured |
| 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:155–204`
  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:

```js
(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:109–116` 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:198–205` 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:156–170`), 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:6–24`) 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:62–64`. 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:32–60`

```js
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:290–304`'s call to `/api/models`).

### `activateTab` — `public/js/app.js:67–91`

```js
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:

```js
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:

```html
<!-- public/index.html -->
<script type="module" src="/js/bedside/index.js"></script>
```

Inside, it uses real `import`:

```js
// 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: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 to `localStorage['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-overlay` are
  delegated through a single document-level click listener in
  `app.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:

```js
(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:260–273`

```js
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:25–31`) reads the
Bearer header first, then falls back to the cookie:

```js
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:1–7`** — `require('dotenv').config()` reads `.env`
   first thing. Then express, cors, helmet, rate-limit, cookie-parser.
2. **`server.js:10`** — `loggingMiddleware` is required (initializing
   the `auditQueue`s as a side effect).
3. **`server.js:12–13`** — Express app + raw HTTP server (the raw
   server is kept so SIGTERM can call `server.close` directly).
4. **`server.js:16`** — `app.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:21–54`** — Helmet with a hand-crafted CSP. CSP allows:
   - `'self'` everywhere by default.
   - CDN origins used by the current frontend vendor assets.
   - `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.

Browser Whisper, Transformers.js browser STT, public Whisper workers, and
browser-local model downloads are intentionally not part of the runtime or CSP.
6. **`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 neither
   `APP_URL` nor `CORS_ORIGINS` are set, the process refuses to
   start (`server.js:63–66`). In dev (no `APP_URL`), all origins
   are allowed.
7. **`server.js:82`** — `cookie-parser`.
8. **`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 in `transcribe.js` enforces 25 MB).
9. **`server.js:95–137`** — 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:140–144`** — `/.well-known/assetlinks.json` for TWA
    (Trusted Web Activity / Android app verification). Must come
    **before** the static handler.
11. **`server.js:153–196`** — 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:198–205`** — `GET /` 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:208`** — `GET /api/build` returns the build id —
    useful for "what version is the prod box running" debugging.
14. **`server.js:210`** — `loggingMiddleware`. Wraps `res.json` to
    emit `apiCall` log lines for every non-GET `/api/*` call.
15. **`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_ID` query param forces a refetch on deploy.
    - `/components/*` → 1 hour.
16. **`server.js:229–303`** — 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:317–322`** — Fallback `GET /` and 404 handler.
18. **`server.js:329–332`** — 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:334–343`** — Listen on `process.env.PORT || 3000`.
    Print the banner.
20. **`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._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:

```js
// ============================================================
// 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, ZIP export, JSON/ZIP import, duplicate preview for `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/patientEducation.js`    | `/api`                    | Parent-facing education handouts generated from clinician notes                                 |
| `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:

```js
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`

```js
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

```js
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:

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

### `_cleanupInterval` — `database.js:453–471`

```js
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: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

```sql
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

```js
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 ).

```js
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`):

```js
exports.up = (pgm) => {
  pgm.addColumn('saved_encounters', { version: { type: 'integer', notNull: true, default: 1 } });
};
```

Server logic (`routes/encounters.js:81–105`, simplified):

```js
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:303–304`):

```sql
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

```js
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:441–449`); 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: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 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: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 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: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:

```Dockerfile
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
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.
- **No browser Whisper download step.** Browser-local Whisper assets and
  Transformers.js browser STT are intentionally absent from the runtime image.
- **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:

```bash
# 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:16–29`
  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/voiceDictation.js`, `public/js/liveEncounter.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, server-side transcription, and 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

```js
// 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:

```html
<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:

```html
<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`:

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

```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):

```html
<script defer src="/js/mytab.js"></script>
```

### 5. Server route

Create `src/routes/mytab.js`:

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

```js
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`:

```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

```bash
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

```bash
docker compose restart pediatric-scribe
```

Open the app, click the new tab, click the button, see the JSON
echo. Look at `audit_log`:

```sql
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,
      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)

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.
