release.sh --gh ran `gh release create` against GitHub. This project
publishes releases to Forgejo, and CI does it automatically on a v* tag
push (.forgejo/workflows/android-apk.yml attaches the signed APK for
Obtainium). The flag could not do anything useful here, and having it in
the usage text implied a manual publish step that does not exist.
Drops the flag, its DO_RELEASE branch, and the usage/header references.
--push remains the only option.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The login page linked to
github.com/ifedan-ed/pediatric-ai-scribe-v3/releases/latest, which returns
404 — releases are published to git.danvics.com by the Forgejo APK
workflow. Verified: the Forgejo URL returns 200, the GitHub one 404.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Biometric sign-in has never worked. auth.js drove
window.Capacitor.Plugins.NativeBiometric — the API of
capacitor-native-biometric, which is not a dependency of this project. The
installed plugin is @aparajita/capacitor-biometric-auth, registered as
BiometricAuthNative with an entirely different API and no credential
storage at all. bioPlugin() therefore always returned null, bioAvailable()
always resolved {ok:false}, and the button was never revealed.
Rewritten against what is actually installed, with no new dependency:
BiometricAuthNative (checkBiometry/authenticate) presents the prompt, and
the already-working SecureStoragePlugin — via the window.SecureStorage
wrapper — holds the credentials. Credentials are only read after
authenticate() resolves, so the OS still gates access. biometryType is a
numeric enum in this plugin, so the old FACE_ID/TOUCH_ID string maps are
replaced with a single lookup exposed as typeName.
Secure storage itself was fine and is unchanged: SecureStoragePlugin
matches the name the wrapper looks up and is registered in
capacitor.settings.gradle.
Recording across screen lock: window.nativeKeepAwake() called Capacitor's
KeepAwake plugin, which is also not installed here, so it silently did
nothing and the device slept mid-encounter — taking the WebView's
MediaRecorder with it. keepAwake() is now a method on the existing
NativeRecording JavascriptInterface, which sets FLAG_KEEP_SCREEN_ON, and
is bound to the WebView so it survives the launcher's navigation to the
remote origin. The Capacitor plugin remains a fallback.
If the screen is locked anyway (power button, incoming call), the activity
pauses and Chromium throttles timers for hidden WebViews, starving
MediaRecorder's chunk delivery. MainActivity now calls resumeTimers() on
pause while recording. The foreground service was already correct — it
holds a partial wake lock and declares FOREGROUND_SERVICE_TYPE_MICROPHONE.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
--push ran `git push origin HEAD`, but this repo's only remote is named
forgejo — so the flag failed after the script had already committed and
tagged, leaving the release half-done. Resolve the remote instead:
prefer forgejo, fall back to origin, else use the only remote present,
and fail with a clear message if there is none.
Also corrects the header comment, which claimed the script does not build
the APK and pointed at --gh / gh CLI. Forgejo CI builds the APK on every
branch push and attaches it to a Forgejo release on a v* tag push, which
is what Obtainium tracks. --gh is a leftover from the GitHub era.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Voice lists were a single flat set from LITELLM_TTS_VOICES, so picking a
model could leave an incompatible voice selected and the request would
fail at the gateway. Voices are now resolved per model family (Kokoro,
Kitten, Supertonic, Groq Orpheus EN/AR), with a compatibility check that
falls back through user → admin → env → first valid voice. Groq Orpheus
requests also pin response_format to wav.
Also refreshes the cardiac/respiratory auscultation samples, extends the
well-visit component, and fixes the Android launch theme background
(@null → colorPrimary) so the splash does not flash through.
NOTE: this is in-progress work that was already sitting uncommitted in
the working tree; it is committed here as-is so the tree was clean for
the release bump.
The Turnstile challenge failed reliably inside the Capacitor WebView,
which blocked login and registration from the Android app.
Three separate causes:
1. Android WebView blocks third-party cookies by default. Turnstile runs
in a cross-origin iframe from challenges.cloudflare.com and needs its
own storage, so the widget never emitted a token. MainActivity now
calls setAcceptThirdPartyCookies on the app's own WebView.
2. The register handler read the Turnstile response with an unscoped
document.querySelector, which matched the *login* widget's input (it
comes first in the DOM). Registration therefore submitted the login
widget's token — single-use with a 5 minute expiry, so any prior login
attempt or slow signup made it fail server-side.
3. The register and forgot-password widgets auto-rendered inside forms
that start at display:none, where Turnstile does not reliably complete
a challenge, and nothing re-rendered them when the form was shown.
Widgets are now rendered explicitly when their form first becomes
visible, and tokens are captured from the render callback instead of
being read back out of the injected input — which makes the unscoped
lookup in (2) structurally impossible. Added error/expired/timeout
callbacks so a widget failure surfaces the Cloudflare error code instead
of failing silently behind a generic toast.
Login is no longer gated at all. It is the path mobile users hit
constantly, and it is already covered by a 10-per-15-min per-IP rate
limit, a constant-time credential check, and TOTP 2FA. Registration and
password reset — the endpoints that actually attract bots — stay gated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds opt-in biometric login to the Capacitor app. Replaces the password
step on subsequent sign-ins; the 2FA step (if any) still applies — by
design, defense in depth.
How it works:
- After a successful password sign-in on a Capacitor build, prompt the
user to enroll. If they accept, capacitor-native-biometric.setCredentials
stores the (email, password) pair in the iOS Keychain / Android Keystore
with biometric-protected access. The local flag ped_bio_enabled=1 is
set so the next launch knows to probe.
- On the login form, if isNativeApp() + bioStored() + bioAvailable.ok,
reveal the "Sign in with Face ID / Touch ID / fingerprint" button at
the top. Label is set from the actual biometryType returned by the
plugin so users see what their device supports.
- Tap → verifyIdentity (OS prompt) → getCredentials → fill the email +
password fields → fire the existing form submit so all the regular
flow runs (turnstile, 2FA prompt, error handling, session storage).
- Explicit logout deletes credentials AND clears the local flag,
hiding the button on the next visit. Auto-logout (token expiry,
network) does NOT come through that path, so biometric persists
across silent session resets.
Storage choice — password not JWT:
- JWTs expire and the storage would constantly need refresh.
- Storing the password lets the standard /api/auth/login flow run,
which already handles password-rotation (a stale stored password
just fails 401 → user falls back to typing the new one → re-enrolls).
- The password sits in OS-level secure storage, accessible only after
successful biometric verification — same security posture as a
password manager autofill.
Files:
- mobile/package.json: add capacitor-native-biometric@^5.0.0 (Capacitor 6
compat)
- mobile/android/app/src/main/AndroidManifest.xml: add USE_BIOMETRIC
uses-permission
- mobile/ios/App/App/Info.plist: add NSFaceIDUsageDescription string
- public/js/auth.js: bioPlugin/bioAvailable/bioStored/bioEnroll/
bioRetrieve/bioForget helpers; window.PedBio surface; reveal-on-load;
click handler; post-login enrollment prompt; logout cleanup
- public/index.html: hidden #btn-bio-login + #bio-divider above the
email field on the login form
- public/css/styles.css: themed gradient button + hover lift
- mobile/README.md: feature list updated
Build steps for Daniel:
cd mobile && npm install # picks up capacitor-native-biometric
npx cap sync # ports the plugin into android/ + ios/
# then build APK / IPA as usual
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.
#2 — Don't-miss tooltip (encounters HPI + sick visit, max 5)
- New POST /api/dont-miss returning {points: [{point, why}]} capped at 5
(cap defended both in the prompt and server-side .slice(0,5))
- New dontMissTooltip prompt in prompts.js
- New suggestDontMiss() helper in app.js mirroring suggestBillingCodes;
inserts an orange-bordered card next to the note output, silent on empty
- Wired into liveEncounter.js (encounter HPI) and sickVisit.js. Not added
to wellvisit/soap/hospital/chart per spec.
#1 — Bedside suture selector
- New ES module public/js/bedside/sutures.js (~300L) following the
burns.js pattern: site × age × tension × cosmetic × contamination ×
hours-since-injury → material, size, technique, removal day range,
glue/Steri-strip alternative, warnings, tetanus reminder.
- 15 anatomic sites covered (face, eyelid, lip vermilion, intraoral,
ear, scalp, neck, trunk, upper/lower ext, hand, foot, joint surface,
genitalia, fingertip).
- Bites: cat/human → don't-close-primarily warning; dog bite to hand →
loose-approximation note. Heavy contamination → delayed primary
closure. >12h non-face/scalp → judgment call note.
- Removal days shown as ranges (3–5, 7–10, 10–14) per source norms,
not single midpoints.
- Subungual hematoma trephination guidance corrected: any painful
hematoma with intact nail and no displaced fracture (especially if
25–50% or more), per current UpToDate guidance.
- Inline citation: Roberts & Hedges 7e (2019), Fleisher & Ludwig 8e,
AAP Section on EM, UpToDate (Pope JV).
- Pill registered in sub-nav SECTIONS + bedside/index.js. Persists
active state via existing UIState helper.
All 46 tests pass.
Four changes batched:
1. ED Encounters tab (new) — multi-stage emergency note with don't-miss
tooltips and 2023 E/M MDM finalize. New route /api/ed-encounters
(generate per-stage + finalize MDM), new ed-encounters.js owning all
client logic, new ed-encounter.html component, new template_ed memory
category. Persists draft to localStorage every keystroke and to
saved_encounters on stage advance. encounters.js touched only to
register the new tab in sessionStorage restore + tabMap (save and
idempotency code untouched).
2. Notes model selector — /notes/from-voice now accepts a client-supplied
model (validated by the existing callAI allow-list); falls back to the
admin default. Added <select class="tab-model-select"> to notes.html
so the existing app.js populator handles options + default.
3. Remove AI-learning-from-corrections — deleted correctionTracker.js,
POST /memories/correction, the corrections branch in
/memories/context, the settings UI section, the FAQ entry, and all
dead trackAIOutput/saveCorrection guards in callers. Legacy
correction_* DB rows are filtered (NOT LIKE) rather than dropped, so
no destructive migration.
4. Fix notes AI framing — /notes/from-voice prompt no longer assumes
"physician dictation". Plain notes (shopping lists, reminders,
ideas) now match the dictation tone instead of being forced into
clinical structure.
All 46 tests pass.
Root cause for "note recording, not working nor going into textbox / no
progress etc". The click handler was wired with:
recStop.addEventListener('click', stopRecording);
addEventListener invokes the handler with the click Event as the first
argument. My stopRecording(silent) signature uses that first arg as a
boolean — and a non-null Event is truthy, so every Stop click hit the
silent-cancel branch (mic off, stream closed, UI back to idle, but no
.then-chain fires, no transcribeAudio, no /api/notes/from-voice).
Symptom matched exactly: click Dictate → record → click Stop → mic
indicator vanishes → editor stays empty → no Network activity for
/api/notes/from-voice. Server logs from earlier transcribes were all
from the Encounter / Dictation tabs (untouched flows), never Notes.
Fix: wrap all three voice-button handlers so the Event isn't passed
through. Only stopRecording cared about the first arg, but wrap all
three for symmetry and so future signature changes don't reintroduce
the same class of bug.
recStart → function() { startRecording(); }
recPause → function() { togglePauseRecording(); }
recStop → function() { stopRecording(false); }
SW cache bumped to pedscribe-v12-notes7.
About the model: /api/notes/from-voice already reads `models.default`
from app_settings (whatever you picked in Admin Panel → Models →
Default Model). Confirmed in your DB it's set to
"openrouter-vendor-model-sonnet-4.6". No new admin UI added.
Symptom Daniel reported: "note recording, not working nor going into
textbox". Root cause was on the server side — /api/notes/from-voice
asked the AI for HTML in its prompt, but real-world models return
markdown ~25% of the time. Tiptap's setContent only renders HTML;
markdown comes through as literal text or a partial render, looking
like the textbox didn't fill.
Server (src/routes/notes.js):
• New toHtmlBody() helper. If the AI returned real HTML (any
block tag), pass through. Otherwise run through `marked` so
markdown becomes <p>/<h*>/<strong>/etc.
• Strips ```json / ```html code fences before JSON parsing.
• Stricter JSON-recovery: only accepts {title|body} parsed shape;
falls back to wrapping the AI's full reply via toHtmlBody().
• Final guard: if body would be empty after sanitisation, wrap
the raw transcript so the user can at least edit it manually.
Client (public/js/notes.js):
• applyGeneratedNote prefers _editor.commands.setContent over a
full remount — avoids the toolbar-reattach flicker + the brief
window where the body looked empty.
• Logs to console when the editor target is missing or Tiptap
setContent throws, so a future regression is greppable.
Plus the two infra fixes Daniel approved earlier in the same
session — keeping them in this commit since they're already
deployed and tested:
• src/db/database.js: cleanup interval handle exposed; server
shutdown now clearInterval()s it before pool.end(). Removes
the SIGTERM → 9-second-hang → Docker SIGKILL race.
• src/routes/audioBackups.js: switch multer.memoryStorage() to
diskStorage with cleanup. 10 concurrent 25 MB uploads no
longer pin 250 MB of RAM. Identical user-visible perf since
upload is wire-bound.
New dep: marked@latest (used server-side only in toHtmlBody).
Soft-delete for notes — Daniel asked for "deleted notes go to trash"
so a slip of the finger doesn't lose work.
Schema: migrations/1777090000000_notes-trash.js adds a deleted_at
timestamptz column to personal_notes (NULL = active) plus an index
on (user_id, deleted_at).
Server (src/routes/notes.js):
GET /api/notes now filters deleted_at IS NULL
GET /api/notes/trash new — list trashed items, newest-
deleted first
DELETE /api/notes/:id now soft-deletes (sets deleted_at)
DELETE /api/notes/:id?hard=1 hard-delete, only allowed on items
already in trash (UI bug can't
erase an active note)
POST /api/notes/:id/restore pull a note out of trash
POST /api/notes/trash/empty hard-delete every trashed note for
the user
Frontend (public/components/notes.html + public/js/notes.js +
public/css/styles.css):
• Sidebar gets two tabs — "Notes" / "Trash (n)" with live count
• Trash tab shows deleted-at timestamps, Restore + delete-forever
per row, Empty-trash button at the bottom
• Active list and trash count refresh in parallel after every
save / delete / restore
• Delete button in the editor now says "Move to trash" and uses
the showConfirm helper (no native dialogs)
Sanitizer swap (public/js/notes.js):
Replaced the homegrown allowlist walker with DOMPurify (already
loaded from cdnjs in index.html, used by learningHub.js too).
Custom HTML sanitizers historically have bypasses; DOMPurify is
the right primitive.
Tests (test/notes-sanitize.test.js — node:test + jsdom + dompurify
as new dev deps):
9 contract tests covering script-tag stripping, inline event
handlers, img onerror, iframe/object, style attributes, every
preserved tag in the allowlist, javascript: URI rejection,
null/undefined input, and nested-script-inside-paragraph. Total
test count: 37 → 46 passing.
SW cache bumped to pedscribe-v12-notes5.
1. flushAutosave dropped brand-new unsaved notes.
The guard was `if (_dirty && _activeId != null)` but _activeId is
null until the first POST succeeds. So tap-New-Note → type → tap-
Back went through flushAutosave → skipped → closeToList → lost
the typed content. saveNote() already handles isNew via POST;
the guard only needs to check _dirty.
2. beforeunload sendBeacon used wrong HTTP method.
navigator.sendBeacon is POST-only by spec, but my code used it to
target PUT /api/notes/:id. Beacons silently hit a 404 route and
the note didn't save. For brand-new notes the URL resolved to
/api/notes/undefined. Replaced with fetch({ keepalive: true })
which browsers queue + ship even across unload, supports PUT,
and handles POST for new notes correctly.
3. Mobile: empty-state card stacked below the sidebar in list view.
The mobile media-query hid .notes-reader + .notes-editor when
data-view="list" but didn't include .notes-empty-state, so the
big "new note" card appeared below the list on phones. Added
the missing selector.
4. Delete fallback used window.confirm.
The `else if (window.confirm(...))` branch in deleteActive was
a rule violation even as a fallback — feedback_no_native_dialogs
says no native dialogs anywhere in frontend. Dropped it;
showConfirm is loaded by app.js before any tab activates so the
fallback path is unreachable in practice. Also upgraded the
confirm call to use the danger + confirmText options so the
modal renders red "Delete" button instead of neutral "Confirm".
5. Recorder kept running after switching tabs or notes.
Dictating, then tapping another clinical-tools tab (or opening
another note) left the MediaRecorder active and the mic stream
open — wasted battery, privacy surprise. Added stopRecording
(silent=true) to the non-notes branch of tabChanged, to
startCreate, and to openReader so any in-flight recording
cancels cleanly when the user moves on.
Minor:
• Dead "'Saving…' : 'Saving…'" ternary in saveNote simplified.
SW cache bumped to pedscribe-v12-notes4.
Removed the "your note saves as you type" / "encrypted at rest" /
tip-list filler that telegraphs AI-generated code. UI now reads
the way a real app reads — names where names go, no paragraphs
explaining what a button does.
• Module header: "Notes" — removed the paragraph tagline.
• Empty state: icon + "New note" button only — removed the
"Hello 👋" heading, the description, and the three-tip list.
• Placeholders: "Title" / "Search" — removed the "…" ellipses
and the "Note title" / "Search notes" verbosity.
• Status + meta: emptied the "New note — will save automatically
once you type" meta string and the "This note is empty. Tap
Edit to add content" reader placeholder.
• Empty-list copy trimmed to just "No matches" when the search
filter is active.
SW cache bumped to pedscribe-v12-notes3.
Four changes landing together so the UX flows properly.
1. Fix voice-generation model error.
/api/notes/from-voice was passing `req.body.model` (undefined)
through to callAI, which resolved to LITELLM_DEFAULT_MODEL (empty
for LiteLLM deployments with no explicit default) → `model=""` →
LiteLLM 400 "Invalid model name". Server now reads the admin-
configured `models.default` setting, falls back to the
LITELLM_DEFAULT_MODEL env var, and only passes a model if one
resolved. Empty string never reaches the provider.
2. Read mode as the default post-save.
Opening an existing note or saving a new one now lands on a
clean, read-only rendering of the body (sanitized HTML with an
allowlist — p/h2/h3/strong/em/u/s/a/ul/ol/li/blockquote/code/pre).
Click the "Edit" button to switch to the Tiptap editor. Matches
the mental model of "notes are documents I review, not drafts I'm
always editing."
3. Autosave during edit.
Title-input + Tiptap onUpdate trigger a 1.2-second-debounced save.
In-flight saves coalesce: if the user types more while a save is
in progress, a follow-up fires right after it lands so the last
keystroke never gets stranded. beforeunload uses navigator.
sendBeacon to best-effort flush on tab close. The manual Save
button is kept as a "save + switch to reader" shortcut. Ctrl/
Cmd+S still works in the editor.
4. Mobile layout.
The two-pane layout collapses to a single-pane view below 900px,
driven by a data-view attribute on .notes-layout. Back buttons
appear in the reader + editor heads on mobile to return to the
list. Desktop layout unchanged (sidebar + right pane always
visible).
Also:
• CSS specificity fix — .hidden{display:none} was losing to
.notes-rec-indicator{display:inline-flex} on source order, so
the "Recording" indicator was showing when idle. Added explicit
.notes-rec-indicator.hidden + .notes-voice-bar .btn-sm.hidden
overrides at higher specificity.
• Reader-body sanitizer — allowlist of safe tags + attributes;
<a> links get target=_blank + rel=noopener.
• SW cache bumped to pedscribe-v12-notes2 so clients pick up the
new module / component / CSS.
New "Notes" tab under Clinical Tools — a per-user scratchpad that's
explicitly NOT fed into AI prompts (distinct from user_memories).
Two-pane layout: searchable list on the left, rich-text editor with
title + save/edit/delete on the right.
Backend:
migrations/…_add-personal-notes.js — personal_notes table
(id, user_id → users ON DELETE CASCADE, title, body, created_at,
updated_at) with indexes on user_id + (user_id, updated_at).
src/routes/notes.js — CRUD + one AI endpoint:
GET /api/notes list, newest-updated first
GET /api/notes/:id fetch one
POST /api/notes create (title + body required)
PUT /api/notes/:id update
DELETE /api/notes/:id remove
POST /api/notes/from-voice transcript → { title, body }
via callAI (admin-controlled
provider — never selectable by
the clinician).
Body + title encrypted at rest via the same cryptoUtil used for
user_memories; 500-note per-user cap; 200-char title / 50 KB
body limits.
Frontend:
public/components/notes.html — empty-state card ("Hello 👋"),
sidebar list with search, editor head with voice-bar (Dictate /
Pause / Resume / Stop + live timer + pulse indicator), Tiptap
body, metadata footer. Uses existing .tp-* toolbar classes.
public/js/notes.js — lazy-init on first tab activation; Tiptap
editor built from window.Tiptap (same bundle the Content
Manager uses); delegated list clicks; Ctrl/Cmd+S to save;
uses app.js's AudioRecorder + transcribeAudio so the STT
pipeline is shared. On stop → transcribe → /api/notes/from-
voice → drop the AI-structured title + body into the editor;
clinician reviews then saves.
public/css/styles.css — 70 lines of .notes-* styles (card
layout, warm empty-state with gradient icon + tips, pulse
animation for the recording indicator, focus states, hover
nudges).
public/sw.js — bump cache from pedscribe-v12 → pedscribe-v12-
notes1 so clients pick up the new module/component.
Admin-controlled STT provider: the recorder posts its audio blob
to the existing /api/transcribe (Google / LiteLLM / ElevenLabs /
Browser Whisper — whatever admin wired up in Settings). Users
cannot pick the provider from this UI.
You were right that Playwright has been catching the easy bugs while
the high-signal bugs (lightbox stranded after the Bedside reorg, PVC
clinically wrong, prod not rebuilt) all had to be caught by you as
the user. Adding a static linter so the class of bug that produced
the lightbox regression fails CI next time instead of the app.
scripts/lint-references.js walks public/js and validates every
getElementById('X') and querySelector('#X') resolves to an id that
is defined SOMEWHERE in the repo — either a static HTML attribute,
a .id = 'X' assignment, or an id="X" substring inside a JS template
string. It also walks HTML for asset references (data-img-src,
<img src>, <audio src>, <link href>) and verifies each root-absolute
path maps to a real file on disk.
Running it on the current tree surfaced two real problems:
1. shadess.js:917 reached for #wv-note-transcript when collecting
refine source context. The actual id is #wv-transcript (no -note-
infix — the transcript element is shared across well-visit sub-
panels). The bug meant a generated well-visit note's Refine call
silently missed the original transcript as source context; the AI
still "worked" but with less signal and no error. Fixed.
2. public/js/adminMilestones.js (221 lines) referenced 17 ids that
were removed a year+ ago in commit 3173ce6 ("Remove milestone
admin UI, add CMS content refresh button"). That commit dropped
the HTML but forgot the JS, which has been dead-loaded on every
page view since. All its addEventListener calls are guarded with
optional chaining, so nothing errored — just silent cruft.
Deleted the file and dropped the <script defer> tag from
index.html.
scripts/e2e.sh runs the linter as a preflight before the Playwright
container starts; a broken reference now fails the suite before any
test even boots.
Allowlist is kept small and prefix-based for the handful of id families
that are built dynamically by JS (bedside em-* sections, BP chart
elements, etc.). When a new component is added, its ids get picked up
automatically by the repo-wide collect() pass; the allowlist rarely
needs to grow.
Suite: 294 passed / 0 failed.
session persistence, per-tab model selector
Brings detailed coverage to sections that were previously only smoke-
tested:
- sickvisit-workflow.spec.js: generate → mocked note, refine bar
round-trip, load popover, New button clears demographics + transcript.
- hospitalcourse-workflow.spec.js: fill H&P → generate → mocked
narrative renders via /api/generate-hospital-course, refine updates
text, load popover open/close.
- auth-screen.spec.js: unauthenticated landing visible, login form
structure, forgot-password swap + return, register link is
intentionally display:none on this instance (pinned), register form
DOM still wired correctly if the link is manually unhidden, reg-
password has minlength=8 + type=password.
- session-persistence.spec.js: clear cookie simulates logout (UI login
is gated by Turnstile which can't be completed in the e2e container);
re-login via loginAs restores the last tab + sub-pill via localStorage.
- model-selector.spec.js: tab-model-select dropdowns render with >0
options across 7 tabs.
Fixture corrections:
- /api/sick-visit/note and /api/well-visit/note were not being mocked
at all — the wrong /api/generate-sick-visit pattern was intercepting
nothing, so tests fell through to the real AI backend. Both now have
correct patterns and response shapes (sick-visit returns `note`,
well-visit note returns `note`).
- /api/generate-hospital-course response key updated from `narrative`
to `hospitalCourse` to match what the frontend actually reads.
wellvisit-workflow: the Visit Note test now asserts a concrete
waitForResponse on /api/well-visit/note + text render, instead of the
previous "hit either endpoint" fallback.
Suite: 294 passed / 0 failed in 5m30s.
PVCs are an ECG / rhythm finding, not a routine auscultation sample —
what you actually hear on the stethoscope is an irregular rhythm with a
compensatory pause, which depends on the underlying rate and is not
teachable from a canned audio clip. The card was also backed by a
synthesized sound, not a real recording. Removing both the card and
the pvc.ogg asset.
open from the Bedside tab
The #img-lightbox overlay markup was sitting at the bottom of
calculators.html. Before the reorg it was fine — the calculators tab
was always the only home for bedside, so by the time a user clicked
the seizure or NRP pathway button the lightbox HTML was guaranteed to
be in the DOM. After promoting Bedside to its own tab, a user can
open Bedside -> Seizures without having visited Calculators first;
the lightbox JS's getElementById('img-lightbox') then returns null
and the click silently no-ops.
Moved the overlay markup to the bottom of index.html so it exists
from page load regardless of which tab has been lazy-loaded. The e2e
harness gets a duplicate copy so the existing lightbox smoke test
keeps working.
Added a regression test for NRP (bedside-smoke.spec.js:141) to catch
any future breakage — the prior seizure-only test didn't exercise the
second pathway button and so the neonatal/NRP path had never been
clicked in CI.
Suite: 252 passed / 0 failed.
Tab-level choice (ped_last_tab) already survived sign-out/in via
localStorage, but sub-pill and sub-tab selections inside a loaded tab
lived only in memory — they reset to defaults after a reload or
browser restart. Now the following are persisted under the ped_ui/
namespace:
- Calculators nav pill (BP / BMI / GCS / …)
- Bedside sub-pill (neonatal / airway / …)
- Well Visit sub-tab (byvisit / milestones / shadess / note)
- Physical Exam Guide age group + system
Implementation:
- Added public/js/ui-state.js — a ~30-line window.UIState wrapper
around localStorage with a ped_ui/ prefix and try/catch around both
read and write (Safari private mode + quota errors silently no-op).
- Each tab's click handler now also calls UIState.set; each tab's
init path calls UIState.get and replays the saved value through
the same function a click would call — so there is exactly one
code path for "show this selection", whether it came from the user
or from a restore. For Bedside, the restore additionally listens
for tabChanged so the lazy-loaded HTML is guaranteed to exist by
the time we re-activate the pill.
Tests:
- e2e/tests/ui-state-persistence.spec.js — 5 specs × 2 viewports =
10 tests. Each clicks the feature, reloads the page, and asserts
the same pill / subtab / dropdown value is still active. Catches
any future regression in the persistence wiring.
- e2e/tests/soap-hospital-workflow.spec.js — fills SOAP transcript,
generates via mocked AI, clears, opens/closes load popovers; also
smoke-tests the Hospital Course save-bar.
Suite: 250 passed / 0 failed (+ 20 over the last run).
Bedside is now a top-level tab instead of a sub-pill inside Calculators —
it's the highest-traffic emergency reference in the app and deserves a
one-click entry. Same DOM structure and JS modules; only the container
moved.
Sidebar reorg:
- Notes: Hospital Course, Chart Review, SOAP Note, Well Visit, Sick Visit
(Well Visit + Sick Visit relocated from the old "Pediatric" group —
they're clinical note workflows, not pure reference tools).
- Section rename: "Pediatric" → "Clinical Tools". The section now holds
Vaccine Schedule, Catch-Up Schedule, Physical Exam Guide, Bedside,
Calculators, Pagers & Extensions, Learning Hub, Content Manager — mix
of reference tables + active calculators + utilities, none strictly
pediatric. "Clinical Tools" reads naturally for the combined set.
Layout changes:
- calculators.html: dropped 480 lines of bedside panel + the Bedside
nav-pill. The shared age→weight estimator moved with it.
- bedside.html: new component file, contains the full bedside card +
the age→weight estimator prepended.
- index.html: added bedside-tab section, sidebar restructured.
- e2e-harness.html: renders calculators + bedside side-by-side (not the
old calc-tab→bedside-pill dance) so the bedside smoke suite still
works without auth. e2e-bootstrap fetches both with a cache-buster.
- bedside-smoke.spec.js: removed the now-obsolete calc-nav-pill click
from each test.
Tab persistence is unchanged — ped_last_tab already survives sign-out,
and the lazy-load cache keeps sub-pill state across navigation within a
session. Persistence across browser restart for sub-pills is a separate
follow-up.
8 new spec files covering sections previously only smoke-tested:
- ai-endpoints-contract.spec.js — hits 8 real AI endpoints via request
context and fails if the response leaks TypeError / ReferenceError /
'Cannot read properties of undefined' / 'is not defined' / 'is not a
function'. This is the class of bug that shipped the PE-narrative
regression to prod because every page-level mock prevented the real
handler from running.
- encounter-workflow.spec.js — generate HPI, refine, clear transcript.
- encounter-save-load.spec.js — save draft, load popover, repopulate.
- wellvisit-workflow.spec.js — byvisit, milestones, SSHADESS (12+
reveal), visit note.
- vaxschedule-content.spec.js — schedule + catch-up panels populate
beyond "Loading".
- chart-review-workflow.spec.js — generate + load popover.
- learning-tab.spec.js — search filter, category pills, feed.
- settings-faq-dictation.spec.js — voice/password/2FA/Nextcloud
sections, FAQ expand/collapse, dictation generate flow.
Baseline fixes:
- Added CORS_ORIGINS + API_RATE_LIMIT_MAX env overrides so the e2e
container accepts the browser's Origin header and can absorb the
full suite's API traffic without tripping the 200/min guard.
- Server's /api/ rate limit is now configurable via
API_RATE_LIMIT_MAX (default stays 200).
- extensions-crud: replaced native page.on('dialog') listeners with
#confirm-modal-ok clicks (we moved off native confirm()).
- pe-guide-smoke + extensions-crud: mobile viewport opens the hamburger
before clicking sidebar tabs.
- fixtures.js: /api/refine mock uses 'refined' (real API shape), not
'content'. /api/chart-review replaced with /api/generate-chart-review.
Suite: 230 passed / 0 failed in 4m36s.
drop grunting + dead synth module
Three lung sounds that were Web-Audio syntheses now play real clinical
recordings sourced from the HLS-CMDS manikin dataset (MIT license):
- normal-vesicular.ogg
- rhonchi.ogg
- pleural-rub.ogg
Dropped expiratory grunting from the library entirely — no
openly-licensed clinical recording located across Wikimedia, Freesound
CC0, SPRSound, Pixabay, Internet Archive, or Littmann/EasyAuscultation
(all proprietary). Card is honest by omission rather than hiding a
synth behind a Play-only UI.
All seven remaining entries now use the same native <audio controls>
player (pause, seek, volume). The synth fallback branch in
renderSoundCard, the stopAllExcept synth reset loop, the script tag,
and the entire public/js/respiratorySounds.js (332 lines of Web Audio)
are removed since nothing references them anymore.
The route destructured PROMPTS/INJECTION_GUARD/wrapUserText from
../utils/prompts, but PROMPTS is the module's default export and the
other two live in ../utils/promptSafe. All three resolved to undefined,
so every /api/generate-pe-narrative request crashed with
"Cannot read properties of undefined (reading 'peGuideNarrative')"
and the client surfaced "Request failed" for PE Guide narrative and
summary generation. Split the require into the two-line form that
every other AI route already uses.
User-visible changes:
- Removed the REAL / SYNTH badge from each sound card. User feedback:
"ridiculous". Cards now just show the sound title + description +
player, no distinction beyond the player type (native <audio controls>
for recordings, play/stop/progress bar for synth).
- Removed the "real recordings; synth labelled SYNTH" subtitle from
the sounds library header.
- Single-playback policy across the whole PE Guide: when any sound
starts (audio or synth), every other playing sound stops. Covers:
audio → audio (pause the previous), audio → synth, synth → audio,
synth → synth. Listeners attach on each .pe-audio 'play' event and
on every synth play-button click.
E2E infrastructure fixes (for the test failures we hit):
- auth-gated-smoke.spec.js now imports test + loginAs from the shared
fixtures.js so the token cache is unified across every spec. Without
this, each spec file's module-scoped _tokenCache multiplied logins
and hit the 10/15min rate limit.
- server.js: /api/auth/login rate limit is now configurable via
LOGIN_RATE_LIMIT_MAX env var (default 10, prod unchanged).
- docker-compose.e2e.yml: LOGIN_RATE_LIMIT_MAX="500" so Playwright's
two-project (chromium + mobile-chrome) multi-worker runs can do
their logins without tripping the cap. Prod container unaffected.
- fixtures.js console.error allowlist expanded to suppress known
non-bugs: Cross-Origin-Opener-Policy warnings on http:// e2e
server, transient 401/403/404/503 resource loads, ERR_BLOCKED_BY_CLIENT.
Three user-flagged fixes:
1. Cardiac sounds library expanded from 3 to 7 (from Wikimedia Commons
heart-sounds + heart-murmurs subcategories). All real recordings:
- Normal (61 bpm) [existing]
- Infant heartbeat [new — pediatric reference]
- VSD [existing]
- Mitral valve prolapse [existing]
- Still's murmur in a toddler [new — classic innocent murmur]
- Functional murmur (adult female) [new — benign flow murmur]
- PVCs [new — arrhythmia]
All with native <audio controls> (play/pause/seek/elapsed/total
work on desktop AND mobile for free).
2. APTM diagram is now full-width on every device, no longer sharing
row space with the legend on mobile:
- Image always on its own row, max-width:420px, centered
- Wrapped in an <a href target="_blank"> — tap/click opens the PNG
full-size in a new tab where browser pinch-zoom works natively
- "Tap to open full-size" hint line under the image
- Legend below uses auto-fit minmax(min(100%,260px),1fr) so it stacks
on narrow viewports without cramping
3. Synth sounds (rhonchi, pleural rub, grunting, normal vesicular — all
Web-Audio-API generated, no native controls) now have:
- Play button → kicks off playback and hides itself
- Stop button appears, lets user terminate early
- Progress bar animates over 3.2 s, resets at end
- Only one synth sound plays at a time (clicking another stops the
previous)
Three user-flagged issues fixed together:
1. Mixed real/synth audio was labelled only "synthesised samples" — misleading
since wheeze, stridor, fine+coarse crackles are real Wikimedia recordings.
Now each sound card has a REAL or SYNTH badge and the library header
reads: "real recordings where available; synthesised approximations
labelled SYNTH".
2. No murmur sounds in the CV section. Added a Cardiac sounds library
between the APTM diagram and the innocent-murmur panel:
- Normal heart sounds (S1, S2) — 61 bpm reference
- Ventricular septal defect (VSD) — harsh holosystolic at LLSB
- Mitral valve prolapse (MVP) — mid-systolic click + late systolic
All 3 are real recordings from Wikimedia Commons.
3. No pause / stop / duration controls. Replaced the synth-only play
button with a native <audio controls> element for every real
recording — gives play, pause, seek, elapsed/total time, volume
for free on desktop AND mobile (browser-native, accessibility-
compliant, consistent with platform conventions). Synth sounds
(rhonchi, pleural rub, grunting, normal vesicular) keep the one-
shot play button since they\'re Web-Audio-API generated and don\'t
support seeking.
Mobile layout:
- The APTM diagram + legend was hard-coded 2-col (minmax(280px,1fr) 1fr)
which could overflow narrow screens. Switched to repeat(auto-fit,
minmax(280px,1fr)) — stacks to 1-col below ~580 px viewport.
- Refactored sound-library + APTM render into helpers (renderSoundCard,
renderSoundsLibrary, TWO_COL_GRID constant) to reduce duplication.
The previous entrypoint unconditionally exported every key from
kv/ped-ai/prod. This broke the e2e container, which needs
TURNSTILE_SECRET_KEY="" and SMTP_HOST="" set via docker-compose
environment block so login works without bot challenge and register
auto-verifies. OpenBao's real values were overriding those empties,
re-enabling Turnstile and email on e2e.
Fix: before the OpenBao fetch, snapshot every env var name already
defined (env_file + environment: block). During the export loop,
skip any OpenBao key that's already in the snapshot. Docker-compose
wins, OpenBao fills in the rest.
Impact:
- Prod container: no change (env_file only has OPENBAO_* bootstrap
vars, which aren't in the KV payload anyway)
- E2e container: TURNSTILE_SECRET_KEY="" and SMTP_HOST="" preserved
even when the image is rebuilt from the current source tree
- Any future per-container override via docker-compose environment:
block just works
Log line now reports counts: "applied N secrets; M already set by
docker (kept override)".
Uses the shared fixture so pageerror + console.error fail the test —
would catch any regression of the bug-class that shipped the SSO
ReferenceError. All tests log in as the seeded e2e user and drive the
real UI.
Coverage:
- tab loads with empty-state before age selected
- MSK (default) renders with scales + steps
- switch to Neuro shows MRC scale + teaching pearl
- Respiratory shows 8-sound library with play buttons + RR scale
- Cardiovascular shows APTM image (naturalWidth > 0 — actual network
fetch confirmed), all 5 landmark letters, innocent-murmur panel,
7 "S" criteria footer
- parametrised: every age group × {resp, cv} must NOT show "no data",
must have ≥ 1 step (catches the regression Daniel just flagged)
- step toggle cycles Normal → Abnormal → Skip with visual state change
and note-field show/hide
- grading scales <details> is collapsible and expands on click
Four changes rolled together:
1. REAL AUDIO from Wikimedia Commons (CC BY-SA 3.0, attribution to follow
in privacy policy per Daniel). Embedded in /public/audio/ — synthesis
stays as fallback for sounds not available from Wikimedia.
respiratorySounds.js now tries the real OGG first; falls back to Web
Audio synthesis if file missing.
2. REAL APTM IMAGE from Daniel's Nextcloud share, placed at
/public/images/pe-guide/aptm.png (134 KB PNG). Replaces the inline SVG.
Kept the side legend with A/P/E/T/M colour-coded points.
3. INNOCENT MURMUR REFERENCE PANEL below the APTM diagram with the 5
classic innocent murmurs (Still's, pulmonary flow, venous hum, carotid
bruit, PPS) — age, location, character, confirming maneuver — plus
the 7 "S" criteria summary.
4. ALL-AGE RESP + CV DATA. Before: only adolescent. Now every age group
has age-appropriate resp + cv content (newborn through adolescent).
Newborn: Silverman, pre/postductal sats, duct-dependent lesion screen.
Infant: bronchiolitis, CHF diaphoresis, VSD, early CHD.
Toddler: croup/FB/epiglottitis, innocent murmur peak age.
Preschool + school-age: adult-pattern transition, sports screening.
Fourth PE system added. Adolescent cardiovascular exam at the same
teaching-focused depth as respiratory/neuro: five components with
significance + pearls + detailed step methods + watch-for blocks.
APTM auscultation diagram (new, inline SVG, no image file):
- Stylised anterior chest with sternum, clavicles, ICS level lines,
left mid-clavicular line
- Five colour-coded landmarks:
A Aortic — 2nd ICS right sternal border
P Pulmonic — 2nd ICS left sternal border
E Erb's pt — 3rd ICS left sternal border
T Tricuspid — 4th ICS left sternal border
M Mitral — 5th ICS mid-clavicular (apex)
- Side legend: location + what to listen for at each point
- Patient's-left / patient's-right labels to prevent mirror-image
confusion
CV-specific grading scales (3 new entries in SCALES):
- Murmur grade Levine 1–6
- Pulse amplitude 0–4+
- Capillary refill time thresholds
CV components:
1. Inspection (general appearance, central/peripheral cyanosis,
clubbing with Schamroth sign, precordial bulge, visible apex, JVP)
2. Palpation (apex position + character, parasternal heave, thrills
at all 5 points, peripheral pulses upper + lower, radio-femoral
delay for coarctation)
3. Auscultation — approach (positioning, diaphragm vs bell, systematic
walk through all 5 points, left lateral decub for MS, leaning
forward for AR)
4. Auscultation — heart sounds + murmurs (S1, S2 split, S3/S4 gallops,
murmur characterisation by timing/location/radiation/character,
Levine grading, dynamic maneuvers, innocent-murmur "7 S" pearl)
5. Peripheral vascular (four-limb BP for coarctation, radio-femoral
delay, bounding pulse differential)
UI wiring:
- renderSystem() emits APTM diagram card at the top of cv system,
before scales. Two-column layout: SVG on left, legend on right.
- accentMap/iconMap/labelMap extended with cv = rose accent,
heart-pulse icon, "Cardiovascular" label
- New sub-tab pill in pe-guide.html
Third PE system added. Adolescent respiratory fully fleshed out with
the same teaching-focused depth as neuro: overview, grading scales,
per-component significance + pearls, detailed step methods with HOW
and NORMAL labels, and a watch-for red-flag block.
New respiratorySounds.js uses the Web Audio API to synthesize 8 classic
breath sounds on demand — no network, no audio files, no licensing:
- Normal vesicular
- Wheeze (two-partial + vibrato, filtered sawtooth)
- Stridor (inspiratory, bandpass-filtered sawtooth sweep)
- Fine crackles (dense brief high-freq noise bursts, late inspiration)
- Coarse crackles (sparser, longer, lower-freq bursts)
- Rhonchi (low-pitched warbled sawtooth, expiratory)
- Pleural friction rub (bandpass noise, biphasic)
- Expiratory grunting (square-wave short grunts)
Sounds are synthesised approximations intended to teach the pattern
(what makes a wheeze a wheeze vs a stridor). Labelled as such in the UI.
Controls: one play at a time, auto-stop ~3s.
Respiratory-specific grading scales:
- RR by age (WHO tachypnea cutoffs)
- Pulse ox (SpO2) with hypoxemia thresholds
- Silverman–Andersen (neonatal retractions, 0–10)
- Westley croup severity score
Components in adolescent respiratory:
1. Inspection (observation-first — RR, pattern, WOB, audible sounds,
chest shape, colour, clubbing with Schamroth sign)
2. Palpation (trachea, expansion symmetry, tactile fremitus,
tenderness, subcutaneous emphysema)
3. Percussion (technique + systematic zones + cardiac/hepatic
dullness + diaphragmatic excursion)
4. Auscultation — normal breath sounds (vesicular, bronchovesicular,
bronchial) with systematic side-to-side comparison
5. Auscultation — adventitious sounds with per-sound listen buttons
linking directly to the sounds library
6. Special maneuvers — bronchophony, egophony, whispered pectoriloquy
Older age groups (newborn through school-age) will get their own resp
blocks incrementally — v1 focused on adolescent for the quality bar.
UI: new sub-tab pill "Respiratory" with lung icon, sky-blue accent.
renderSystem refactored to use accent/icon maps instead of per-system
if/else — scales to future systems (cardiovascular coming next).
User feedback: the exam steps were too generic (e.g. "Shoulder abduction
— 5/5 bilaterally" never explained what 5/5 means or HOW to test it).
The guide needs to serve as a teaching tool, not just a checkbox list.
Three changes:
1. GRADING SCALES reference card (new). Collapsible panel at the top of
each system showing the relevant scales:
- Neuro: MRC strength (0-5), DTR (0-4+), Plantar response
- MSK: Scoliometer ATR, Beighton hypermobility score
Each scale shows the grade AND its clinical meaning in a compact
table. No more orphan "5/5 bilaterally" without definition.
2. Per-component SIGNIFICANCE + PEARL fields (optional). Adolescent
neuro components enriched with:
- Significance: one-line clinical relevance (what this component is
actually for — what pathologies it detects)
- Teaching pearl: a Hutchison/Bates/Nelson-style tip that helps the
learner see past the mechanics to the reasoning
Visually distinct — pearl gets a warm amber accent, significance is
a crosshair icon under the name.
3. Method strings REWRITTEN for every adolescent strength step. Before:
"Shoulder abduction — 5/5 bilaterally". After: "Patient abducts both
arms to 90°. Examiner pushes down on each arm just above the elbow
while patient resists. Compare sides. — Holds against full resistance
— MRC 5/5 bilaterally". Same treatment for all 14 strength steps,
all 8 DTR steps, and tone/pronator-drift.
UI redesign:
- Accent bars on cards (cyan for MSK, purple for neuro) for visual
anchor
- Numbered step circles instead of "1." prefix
- HOW / NORMAL label badges on each step
- Watch-for block with red left-border for red-flag grouping
- System-level header with icon (bone for MSK, brain for neuro)
Other age groups (newborn through school-age) keep the old data shape
(steps without pearls) — they still render correctly, just without the
pearl/significance blocks. Enriching them is an incremental follow-up.
Daniel flagged the native browser confirm() dialogs in Extensions as ugly
and incompatible with the app's design. There was also a stray alert()
in calculators.js resus-meds weight validation.
Replaced:
- extensions.js: confirmDelete → showConfirm(..., {confirmText: 'Move to trash'})
- extensions.js: confirmPurge → showConfirm(..., {danger: true, confirmText: 'Delete permanently'})
- calculators.js:2060 alert() → showToast(..., 'error')
All three helpers (showConfirm, showToast) are already defined as globals
in public/js/app.js. The design already had a modal — I should have used
it from the start.
Audit confirmation: `grep -rnE '\b(alert|confirm|prompt)\s*\(' public/`
now returns only comment references and the showConfirm definition
itself. No native dialogs remain anywhere in the frontend.
Playwright test updated to click the in-app modal's #confirm-modal-ok
and #confirm-modal-cancel buttons instead of intercepting page.on('dialog').
Two additions:
1. e2e/fixtures.js — shared test infrastructure
- Custom `test` extending @playwright/test with two auto-fixtures on
every page: page.on('pageerror') and page.on('console') of type
'error'. Any uncaught JS error fails the test. This is the SSO-
bug-class safety net: if we'd had this earlier, the silent
admin.js ReferenceError would have failed CI instead of shipping.
- `authedPage` fixture — logs in via API, injects session cookie,
provides pre-authed page ready to drive.
- `mockAI(page)` helper — intercepts generate-* and transcribe
endpoints with canned JSON responses. Enables fast deterministic
CI runs. Opt-out via E2E_USE_REAL_AI=1 to hit real LiteLLM.
- Console-error allowlist for known noise (favicon 404, lazy-loaded
Whisper models, etc.).
2. e2e/tests/extensions-crud.spec.js — 11 tests covering the new
Pagers & Extensions feature end-to-end:
- empty state, add extension, add pager (correct grouping)
- edit persists, search by location + by number
- soft-delete with confirm → moves to trash
- restore from trash → reappears in active
- purge from trash → permanent
- cancel dialog keeps item, cancel form keeps nothing, validation
on required fields
Known follow-up: the e2e container (pediatric-ai-scribe-e2e) is still
on the pre-entrypoint image from before the OpenBao migration, so it
doesn't have the Extensions routes yet. Rebuilding it needs a small
entrypoint enhancement to honor docker-compose-level env overrides
vs OpenBao-fetched values (e.g. TURNSTILE_SECRET_KEY="" for the e2e
instance). That's separate work — this commit just lays in the tests.
New top-level tab positioned after Physical Exam Guide. Per-user
directory of hospital phone extensions and pagers — grouped by location
then type, searchable, soft-deleted.
Data:
- New table user_phone_extensions (id, user_id, location, name, number,
type CHECK (extension|pager), notes, trashed_at, timestamps).
Partial indexes on active vs trashed rows for fast filtering.
- Not PHI — hospital internal phone directory. Plaintext.
API (all user-scoped, all params validated):
- GET /api/extensions?trash=1&q=text — list active or trash, optional search
- POST /api/extensions — create
- PUT /api/extensions/:id — update (requires all three core fields)
- DELETE /api/extensions/:id — soft-delete (sets trashed_at)
- POST /api/extensions/:id/restore — un-trash
- DELETE /api/extensions/:id/purge — hard-delete (only if trashed)
All :id params parsed + validated (positive integer) before query.
All queries parameterized, every WHERE includes user_id scoping.
UI (public/js/extensions.js + components/extensions.html):
- Search bar with 200ms debounce, server-side LIKE on location/name/number/notes
- Add button expands inline form — location (with datalist of existing
locations for autocomplete), name/dept, number, type, optional notes
- Each entry renders as a card: big monospace number, dept, type badge,
edit + delete inline
- Grouped by location → type (Extensions / Pagers subheaders)
- Trash view: toggle shows trashed items with Restore + Purge actions
- Trash count badge on the Trash button updates after every delete/restore
- Delete requires confirm() dialog, then soft-delete (easy to undo)
- Purge from trash requires a second confirm() ("cannot be undone")
- Esc closes the form; form resets between Add and Edit
Replaces the generic one-line-per-component format with a step-level
checklist. Each exam component now contains 3–13 discrete steps, each
with its own Normal/Abnormal/Skip toggle and optional abnormal note.
Physician ticks the exam off step-by-step; report generation
summarises at the component level but knows exactly which steps were
performed.
Example — previously the adolescent "Cranial nerves (II–XII)" was a
single row: "How to perform: Full formal adult-pattern exam. Expected:
All cranial nerves intact." That's unhelpful. Now it's 14 discrete
steps: CN I, CN II acuity, CN II fields, CN II fundoscopy, CN II/III
pupils, CN III/IV/VI EOM, CN V sensation V1/V2/V3, CN V motor, CN V
corneal, CN VII forehead/eye-close/smile/puff, CN VIII, CN IX/X, CN
XI, CN XII — each with specific method and expected finding. Same
depth for MSK: scoliosis = 5 discrete steps (standing inspection,
Adam forward-bend, rib-hump check, scoliometer, plumb-line), joint
stability = 8 named tests (Lachman, anterior drawer, varus/valgus,
McMurray, apprehension, Neer/Hawkins, anterior drawer ankle, talar
tilt), Beighton = 5 per-joint measurements, etc.
Sources cited in code header: Bates' Guide 13th ed, Nelson Textbook
22nd ed, Hutchison's Clinical Methods 25th ed, Fenichel Clinical
Pediatric Neurology 8th ed.
Backend route accepts the flat step array (grouped by component on
the server), passes structured text to the AI with methods and
expected findings per step. Prompts updated to summarise at the
component level rather than step-by-step, so output is clinically
readable.
Scope: MSK + Neuro × 6 age groups (newborn, infant, toddler, preschool,
school-age, adolescent). More systems follow the same pattern —
append to PE_DATA.
New top-level tab (positioned after Catch-Up Schedule) combining two
functions:
1. Study reference — for each (age group, system) shows OSCE-style
components with technique, expected normal finding, and abnormal-
feature watch-list.
2. Documentation generator — physician marks each component
Normal / Abnormal (with free-text detail) / Skip; AI produces a
two-section report (Technique + Findings), narrative or structured
list format.
Scope v1: MSK + Neuro × 6 age groups (newborn, infant, toddler,
preschool, school-age, adolescent). More systems can be added to the
embedded PE_DATA in peGuide.js without route changes.
Files:
- src/routes/peGuide.js — POST /api/generate-pe-narrative (mirrors
milestone-narrative pattern: AppRole-level
injection guard, clinical audit category,
PHI redaction upstream already in place)
- src/utils/prompts.js — peGuideNarrative + peGuideList prompts,
structured two-section output
- public/components/pe-guide.html — demographics bar + sub-pills + cards
- public/js/peGuide.js — embedded PE_DATA (all clinical content),
render + state + AI call
- public/index.html — tab button, section, script include
- server.js — mount route at /api
No schema change. No PHI stored — findings live in memory only, exported
via existing copy/read-aloud/Nextcloud actions.
The dropdown was labeled just "Risk Factors" with option "None (lower risk)"
— ambiguous because AAP 2022 uses "risk factors" in two distinct senses:
(a) risk factors for developing hyperbilirubinemia (screening-only, do not
change thresholds) and (b) neurotoxicity risk factors (do change thresholds).
Only (b) belongs on the threshold nomogram, and a clinician glancing at the
form could easily pick wrong.
Changes:
- Label: "Neurotoxicity risk factors"
- Options: "Absent" / "Present (any one qualifies)" — removes the misleading
"None (lower risk)" phrasing (no-risk curve actually has HIGHER thresholds)
- Expandable details listing the 6 specific AAP 2022 neurotoxicity risk
factors (isoimmune hemolysis, G6PD, other hemolysis, sepsis, albumin <3.0,
clinical instability <24h) with explicit note that GA <38w is handled by
the per-week curve, not by this checkbox — prevents double-counting.
No data changes. Cite: Kemper et al., Pediatrics 2022;150(3):e2022058859, Box 2.
The previous tables grouped all ≥38-week infants into one table (using
38w values) and 36-37 into another. AAP 2022 actually has separate
phototherapy curves per completed week for 35, 36, 37, 38, 39, 40+.
Worst real-world impact: a 40-week infant at 72h of life got the 38w
threshold (18.8 mg/dL) instead of the correct 40w threshold
(19.8 mg/dL) — a 1.0 mg/dL error at exactly the clinical decision point.
Borderline infants could be started on phototherapy unnecessarily, or
the reverse (miss a true threshold) depending on the direction.
Replaced the block with 18 distinct per-week tables extracted directly
from peditools.org/bili2022 API:
- Phototherapy no-risk: 35, 36, 37, 38, 39, 40+ (all differ)
- Phototherapy with-risk: 35, 36, 37, 38+ (38-41 identical)
- Exchange (both risk states): 35, 36, 37, 38+ (38-41 identical)
Selection logic updated to map gaNum → correct table. HTML dropdown
label clarified to "completed weeks" with a one-line note that days
don't change the curve per AAP 2022.
Validation: exhaustive roundtrip — 7 GA weeks × 85 hours × 2 risk
profiles = 1190 cases, all match peditools within 0.01 mg/dL (zero
mismatches). See commit message footer.
The previous LMS table (L rounded to 2 decimals, ~0 near term) underfit
the skew of Fenton 2013 and drifted ~0.05 z-score units from peditools
and Epic at term. Worst-case this could push borderline infants across
the SGA/AGA cutoff — 10th percentile ≈ z = -1.28, a 0.05-SD drift is
enough to flip the classification.
New LMS: empirically fit against 6 probe weights per week at
peditools.org/fenton2013 (widely-used Fenton 2013 calculator). Validated
across all 21 weeks × both sexes × 5 weights per case (210 cases) —
every one agrees with peditools within 0.01 z-score units, mean
difference 0.002.
Example — 40 5/7 wk male, 3070 g:
BEFORE: z = -1.38 (matched only one of three external sources)
AFTER: z = -1.42 (matches Epic -1.43 and third-source -1.44)
Peditools at integer 40w: z = -1.10 (exact match with new table at
integer-week input)
LMS fit RMSE < 0.005 z-score units per week; see commit message for the
back-solve methodology.
Adds an optional secret-fetch step at container boot. When OPENBAO_ADDR,
OPENBAO_ROLE_ID, and OPENBAO_SECRET_ID are set, the entrypoint
authenticates to OpenBao via AppRole, pulls kv/ped-ai/prod, and exports
each key as a process env var before exec'ing node. When OPENBAO_ADDR
is unset the entrypoint is a no-op — the legacy .env flow continues to
work unchanged (e2e container, local dev, rollback).
Changes:
- docker-entrypoint.sh: new — AppRole login + KV fetch + env inject +
exec. Fails fast on missing/invalid creds; unsets bootstrap vars
before launching node so they don't linger in the process env.
- Dockerfile: multi-stage copy of /bin/bao from openbao/openbao:2.5.3
(multi-arch handled automatically by buildx manifest-list resolution).
Adds jq for JSON parsing. Wires ENTRYPOINT to the script; CMD
remains ["node", "server.js"].
- .env.example: documents the three vault-bootstrap variables at the
top and notes that everything below is vault-sourced when OPENBAO_ADDR
is set.
Rollout is two-phase for safety: rebuild image with unchanged .env
(proves no regression in legacy mode), then add the three OpenBao vars
and restart to cut over to vault-sourced secrets. Rollback at any point
is blanking OPENBAO_ADDR in .env + restart.
Two independent PHI-leak hardenings folded together:
1. forgot-password timing oracle
The hit path previously did SELECT + token gen + UPDATE + SMTP send
before responding; the miss path returned after the SELECT. An
attacker could distinguish registered emails by response latency
(SMTP RTT is hundreds of ms). Response is now sent immediately after
Turnstile, with the DB and email work fired-and-forgotten in a
background async block. Hit and miss take identical wall-clock time.
Also hardened req.body.email to tolerate missing/non-string input
instead of throwing 500.
2. logger.file redaction
logger.info/warn/error wrote straight to /app/data/logs/YYYY-MM-DD.log
without going through redact(). Current callers are metadata-only and
safe, but any future caller writing logger.error('boom', req.body)
would silently drop PHI to disk. Route both message and optional data
through redact() — same helper the audit path already uses. Benign
startup messages pass through unchanged; SSN/phone/email/DOB patterns
are tokenised, long note-body-shaped text is truncated.
Extends the existing crypto helper (already used for audio backups and the
Nextcloud token) to cover every column that can hold PHI:
- saved_encounters.transcript, .generated_note, .partial_data
- user_memories.content (templates + Dragon-style corrections)
- user_memories.name (auto-derived from original snippet on corrections,
so effectively PHI)
Reads decrypt transparently. Legacy plaintext rows continue to work —
decryptString passes non-enc1: values through unchanged — so no migration
is required; rows re-encrypt on their next save.
The encounters list query previously used LEFT(transcript, 200) for a
preview. With ciphertext that slice is meaningless, so the route now
fetches the full columns, decrypts in Node, then slices. At 7-day auto-
delete the row count is bounded and the cost is a handful of GCM
decrypts per list call.
user_memories ORDER BY moved from (category, name) to (category, id)
since SQL can no longer order on encrypted names.
Closes the HHS breach-notification safe-harbor gap on at-rest PHI.
Adds an age-appropriate reflex reference after the Expected Growth /
Feeding block for each well-visit age. Each entry shows the reflex
name, an expected-status chip (Present / Fading / Integrated /
Up-going / Down-going), and a short clinical note covering how to
elicit it, when it should fade, and what abnormal persistence means.
Covers primitive reflexes for newborn-6mo (rooting, sucking, Moro,
palmar/plantar grasp, tonic neck, stepping, Galant, tongue thrust,
Babinski), the transition at 6-18mo (protective extension, parachute,
plantar-response switch), and adult-pattern DTRs/frontal-release
screening from age 2 through 21.
loadOidcConfig() was called from the first IIFE in admin.js but declared
in the second IIFE. Function declarations don't cross IIFE boundaries,
so the call threw ReferenceError and left the SSO form empty with
"Disabled" selected — even when SSO was configured and working. Moved
the call into the tabChanged handler of the IIFE where the function
lives.
Adds a second containerized instance of the app with Turnstile + SMTP
disabled so Playwright can log in without a bot challenge.
- docker-compose.e2e.yml: pediatric-ai-scribe-e2e on port 3553. Shares
postgres + pgdata with main so seeded test users (*@ped-ai.test) persist.
- Test user: e2e-user@ped-ai.test (created once via /api/auth/register
against the e2e container — SMTP is off so register auto-verifies).
- Tests log in once per worker via /api/auth/login (module-scoped token
cache) then inject the ped_auth cookie into each test's browser context.
This avoids the 10-per-15-min login rate-limit.
- Mobile viewport opens the sidebar via #btn-menu-toggle before clicking
tab buttons (which are hidden behind the hamburger <=768px).
Coverage: encounter, wellvisit, chart, vaxschedule, catchup, learning,
dictation, settings, calculators, faq + landing-page-after-login. Each
test clicks the tab, waits for the lazy component to render (>100 chars),
and asserts a known anchor string is present.
Total suite: 128 tests passing (53 desktop + 53 mobile Bedside/top-calcs +
11 desktop + 11 mobile auth-gated).
Adds a second Playwright project so every calculator test runs at both
Desktop Chrome and Pixel 5 (~375 px). Catches mobile layout regressions
automatically. 106 tests passing (53 × 2 viewports).
ES module script tags (<script type="module">) always send an Origin
header on fetch, even for same-origin requests. The global cors()
middleware was rejecting /js/bedside/index.js with 500 in the e2e
harness because the container's internal origin
(http://pediatric-ai-scribe:3000) is not in APP_URL/CORS_ORIGINS.
Production was unaffected (real users hit APP_URL, which is allowed),
but the fix is architecturally correct either way: CORS belongs on
the API boundary, not on static file serving. All protected routes
are under /api/*.
Unblocks the bedside smoke suite — now 26/26 green.
Split the Bedside clinical reference section out of calculators.js
(~1220 lines) into 20 focused ES modules under public/js/bedside/.
Each module owns one clinical topic (cardiac, seizure, sepsis, burns,
etc.) and exports an init() wired up by bedside/index.js. Shared
helpers live in shared.js and also set window._EM for back-compat
with the 4 remaining call sites in calculators.js.
Load order: classic defer calculators.js first, then module script
bedside/index.js. Handlers bind at DOM-ready; runtime _EM lookups
resolve after both are evaluated.
Makes bedside content editable per-section, shrinks calculators.js
from 4111 to 2891 lines, and keeps the existing Playwright smoke
suite (e2e/tests/bedside-smoke.spec.js) as the behavioral contract.
Safety net for upcoming refactors:
- 26 Playwright smoke tests via @playwright/test 1.50.0 in an official
Playwright container (no host Node needed). Covers every Bedside sub-pill,
the age→weight estimator, dose calculators (seizure/sepsis/anaphylaxis/
burns/airway), and interactive widgets (lightbox, vent SVG).
- `npm run e2e` wrapper runs tests inside mcr.microsoft.com/playwright:v1.50.0-noble
on the ped-ai_default Docker network so no host port mapping is needed.
- public/e2e-harness.html + public/js/e2e-bootstrap.js load the calculators
component without the SPA auth wall (scripts external to satisfy CSP).
Server:
- server.js now re-reads public/index.html on mtime change instead of
caching at boot. Fixes the "edit HTML, restart container" friction.
- CSP upgradeInsecureRequests disabled in helmet config; Caddy still
enforces HTTPS at the reverse-proxy layer in production.
Calculators:
- Bedside tab consolidating emergency protocols (Neonatal+Apgar+NRP, Airway/RSI,
Cardiac Arrest/PALS, Respiratory, O2 & Ventilation, Status Epilepticus,
Sepsis & Fever with PECARN/Aronson/Rochester/Step-by-Step, Anaphylaxis,
Procedural Sedation, Agitation, Antiemetics, Antimicrobials, Burns with
Lund-Browder body-parts TBSA + Parkland, Toxicology, Trauma).
- Global age→weight estimator at top of Calculators tab (APLS + Best Guess).
- Pressure-time waveform SVG teaching graphic for Ventilation.
- Algorithm image lightbox (fullscreen, Esc/tap-to-close).
- Every weight-based dose shows mg/kg inline for clinician verification.
- Drug tables wrapped in overflow-x:auto for mobile.
Infrastructure:
- Pure dose math extracted to public/js/calc-math.js (dual-export Node+browser).
- 36 unit tests in test/calc-math.test.js via node:test (zero new deps).
- "npm test" added to package.json.
- Add logger.audit/access calls to auth route (login, login_failed,
login_blocked, register, password_changed, 2fa_backup_code_used,
2fa_backup_codes_regenerated) — these previously only wrote to DB
via raw SQL, bypassing Loki shipper
- Replace logger.info with logger.apiCall in callAI() so every AI call
ships to Loki with model, tokens, cost, duration
- Add device identifier (parsed user agent) to audit and access logs
- Fix TTS voice/model provider mismatch: auto-detect Vertex voices
(Puck, Charon, Kore, etc.) and ElevenLabs voice IDs, override model
to match provider regardless of what model was previously set
- Fix TTS discovery: model IDs saved to tts.voice are detected and
redirected to tts.model (regex for openai-tts, elevenlabs, vertex-tts)
- Fix STT transcription route: add scribe/elevenlabs/transcri to the
isTranscriptionModel regex so ElevenLabs Scribe uses /audio/transcriptions
endpoint instead of chat completions
- Remove OpenObserve/SigNoz code from logger (reverted to Loki-only)
- Add neonatal assessment calculator: GA classification (extremely preterm through
post term), weight-for-GA percentile (AGA/SGA/LGA) using Fenton 2013 LMS data,
birth weight category (ELBW/VLBW/LBW/normal/macrosomia)
- Add DOCX support via mammoth, PPTX/ODT/EPUB via jszip in Learning Hub content
generator file upload
- Add gatewayUrl() helper for consistent API URL construction — handles
LITELLM_API_BASE with or without /v1 suffix, works with any OpenAI-compatible
gateway (LiteLLM, Bifrost, etc.)
- Fix TTS model/voice separation: discovery now tags items as MODEL or VOICE,
auto-detects provider from voice name (Vertex, ElevenLabs, OpenAI)
- Fix STT discovery to include ElevenLabs Scribe and Chirp models
- Fix TTS discovery to include ElevenLabs and Vertex voices alongside models
- Fix admin model test to bypass allowlist check (skipAllowlistCheck) so
discovered models can be tested before adding
- Fix Nextcloud token decryption in learningAI.js WebDAV browse and file import
- Fix admin embedding test to show DB model name instead of hardcoded default
- Fix admin STT test to use correct endpoint for Whisper models
- Add AI gateway migration guide to configuration docs
- Add Grafana dashboard JSON for Loki log visualization
- Drop first/second-person voice; reference-style prose throughout
- Remove stale information; align with current code (argon2id primary, hybrid cookie/Bearer auth, sliding 24h idle, AES-256-GCM PHI at rest, backup codes, node-pg-migrate, collation-drift guard, multi-arch Docker, auto-version pipeline)
- Preserve all technical accuracy and code examples
- Remove any remaining references to separate PedsHub Quiz app
- Keep consistent tone across files (tables + code blocks, imperatives where needed)
- api-reference.md and developer-guide.md route tables expanded to reflect current routes (billing, sessions)
docker-publish.yml:
- Rewrote as matrix build + manifest merge.
- amd64 on ubuntu-latest, arm64 on ubuntu-24.04-arm (free for public
repos). No QEMU — argon2 and every other native dep compile on
their target CPU, no more SIGILL / exit 132.
- Per-arch GHA cache scopes so builds don't thrash each other.
- Final step merges both digests under one tag (vX.Y.Z + latest),
publishing a real multi-arch manifest. `docker pull` from either
arch gets the right variant automatically.
auto-version.yml, version-bump.yml:
- Checkout now uses `secrets.RELEASE_PAT || secrets.GITHUB_TOKEN`.
With RELEASE_PAT set, the tag push this workflow does DOES
trigger downstream (android-release, docker-publish). Without
it, falls back to GITHUB_TOKEN (no downstream trigger, what we
have today).
All workflows (auto-version, version-bump, android-release,
docker-publish):
- Added FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' so actions
still on Node 20 runtime (checkout/cache/setup-*) opt in to
Node 24 early. GitHub makes Node 24 default 2026-06-02 and
removes Node 20 2026-09-16.
To finish the chain (one-time user step): create a fine-grained
PAT with "Contents: Read and write" on this repo and add as
RELEASE_PAT secret. After that `feat:` / `fix:` commits auto-tag
AND auto-build with zero manual intervention.
mobile-build.md:
- Removed "PedsHub Quiz" sections. That app lives in a separate
repo (quiz/mobile/) and has its own build pipeline. Docs here
are PedScribe-only now.
- Reorganized around CI as the primary flow, local build as
fallback. Added explicit secret names, JDK requirement, single-
quote-password caveat, QEMU/argon2 note.
- File-map section at the end so the native sources are
discoverable without grepping.
CONTRIBUTING.md:
- Cut the narrative prose. Dev-facing tables + single-line
commands only. Decision-tree removed (the table suffices).
- Release pipeline and mobile build link out rather than
duplicating content.
Drops a cheat sheet (CONTRIBUTING.md) in repo root so anyone — you,
future maintainers — has the full commit-prefix table one glance
away. Covers which prefixes trigger a release and which don't.
Also adds .gitmessage that you can optionally wire into git as the
default commit template:
git config --local commit.template .gitmessage
Opens the cheat sheet in your editor every time you `git commit`
without -m. Remove it with `git config --local --unset commit.template`.
This commit uses `docs:` prefix so it does NOT trigger a release —
proving the auto-version workflow's filter works.
Adds .github/workflows/auto-version.yml that fires on every push to
main, parses commit messages since the last semver tag, and decides
whether to cut a new release:
feat: → minor bump (new feature, backward-compatible)
fix: → patch bump (bug fix)
feat!: → major bump (breaking change)
BREAKING CHANGE in body → major bump
docs/chore/refactor/style/test/ci → no release
If any commit since the last tag matches feat/fix/BREAKING, the
workflow bumps versions across package.json, mobile/package.json,
mobile/android/app/build.gradle, commits the change as
"Release vX.Y.Z", tags it, and pushes. The tag push then fires the
existing android-release and docker-publish workflows.
You no longer need to remember "what version am I on?" — just commit
with a conventional-commits prefix and push. Docs-only or refactor
commits don't create releases. Add [skip ci] to any commit message
to skip this workflow for that commit.
docker-publish.yml:
- Dropped linux/arm64 from the platforms matrix. The amd64 GitHub-
hosted runner builds arm64 under QEMU emulation, which fails at
native argon2 compile with SIGILL (exit 132). Your production
box is x86, so arm64 isn't needed. Add it back with a native
ARM runner the day you deploy to ARM hardware.
version-bump.yml (new):
- Manual Actions trigger. Click "Run workflow" → pick patch / minor /
major (or type a custom X.Y.Z). The workflow computes the next
semver from the current package.json version, updates all three
version sites (package.json, mobile/package.json, Android
versionName + versionCode), commits "Release vX.Y.Z", tags it,
and pushes. The tag push then fires android-release.yml and
docker-publish.yml automatically — APK + Docker image published
with no local commands required.
Typical flow now:
Actions → "Version bump & release" → Run workflow → patch
↓
Bump + tag in ~5 s
↓
Parallel: android APK build (~2 m), docker image push (~4 m)
↓
Both assets show up on the new release; Obtanium + docker-hub
subscribers see the update automatically.
The mobile/ wrapper had 1700+ node_modules files tracked, plus the
Capacitor-regenerated artifacts that get rewritten on every
`npx cap sync android` (capacitor.build.gradle, capacitor.config.json,
capacitor.plugins.json, capacitor.settings.gradle, the cordova-android-
plugins subtree). Every local dev or CI sync caused noisy drift that
blocked scripts/release.sh from running.
Added mobile/.gitignore covering node_modules, cap-sync outputs,
Android build outputs, .jks/.apk/.aab files, and .DS_Store.
Kept package-lock.json tracked for reproducible npm install.
No logic changes — only stopped tracking files that are always
regenerated.
On every v*.*.* tag push the workflow:
1. Checks out the repo
2. Sets up JDK 17 + Node 20 + Android SDK (cached between runs)
3. Runs npm install + npx cap sync android in mobile/
4. Restores the signing keystore from ANDROID_KEYSTORE_BASE64 secret
5. Builds a signed release APK via gradle
6. Renames to pedscribe-X.Y.Z.apk
7. Creates/updates the matching GitHub release with the APK attached
and make_latest=true so the /releases/latest URL always points to
the newest build (Obtanium and the login-page link pick it up
automatically)
Required repo secrets (set via gh secret set ... or the GitHub UI):
ANDROID_KEYSTORE_BASE64 base64 -w0 of the .jks file
ANDROID_KEYSTORE_PASSWORD keystore password
ANDROID_KEY_ALIAS key alias (pedscribe)
ANDROID_KEY_PASSWORD key password (same as keystore in our setup)
Typical release flow after this lands:
scripts/release.sh 6.1.1 --push (laptop, 5 sec)
── Actions builds APK in ~8-10 min ──
── Release updates automatically with signed APK ──
── Obtanium clients notice on next poll ──
The "Download Android app (APK)" link on the login page is pointless
when the user is already inside the Capacitor app. Wrapped the link
in id="apk-download-link" and added a native-app-only hide pass in
auth.js that runs against a short array of web-only element IDs.
Add more entries to that array as other web-only UI appears, so the
mobile wrapper can diverge cleanly from the web UI without branching
the HTML.
Replaces my best-effort image readings with the pre-digitized
JavaScript data arrays extracted from codingace.net's open
Bhutani calculator (their arrays were embedded in the page
source, apparently digitized from the original Figure 2 at
6-hour granularity through 72 h).
Cross-checked against the AAP 2004 CPG reproduction of the same
Bhutani chart (Southern Health Manitoba clinical policy PDF).
Classifications at several spot-check points (24/36/41/72 h at
varying TSB) match expected zones.
User's reference case (41 h of life, TSB 9.7 mg/dL):
p95 ≈ 13.6, p75 ≈ 11.4, p40 ≈ 9.1
→ Low-Intermediate Zone ✓ (matches clinician expectation)
Data source now properly cited in both the in-code block comment
and the on-card footer text. Tool still documents that AAP 2022
is the correct tab for phototherapy decisions.
The previous bhutaniZones table was introduced in the initial
calculator commit (61cf096, 2026-04-09) without any source citation
and was systematically ~0.5-1 mg/dL below the published Bhutani 1999
curves — borderline patients got pushed into the next-higher zone.
New values read directly from the Stanford Medicine Newborn Nursery
reproduction of Bhutani 1999 Figure 2:
https://med.stanford.edu/newborns/professional-education/jaundice-and-phototherapy/bhutani-nomogram.html
Uncertainty: ±0.3 mg/dL (values eyeballed from a 556 px rendered
graph, not a published table). This is called out explicitly in
both the in-code comment and the Bhutani tab footer, which also
points clinicians to the AAP 2022 tab for actual phototherapy
decisions.
Spot-check: 41h of life, TSB 9.7 mg/dL
Before: p75=9.33 → High-Intermediate Zone (wrong)
After: p75=10.1 → 9.7 below p75, above p40 → Low-Intermediate ✓
No interpolation or decision logic changed — only the lookup data
and its citation.
Vitals — updated source attribution to "Harriet Lane — Johns Hopkins
Children's Center Kids Kard" (was just Kids Kard). Both the card
subtitle and the intro line.
Bhutani — reverted my incorrect "Harriet Lane" citation (the values
in our table were never transcribed from Harriet Lane). Restored the
original attribution to the 1999 paper. Data itself is unchanged from
the pre-session state; treat it as unverified pending a clinician-
supplied source.
Previous table was ~0.5-1.0 mg/dL below the published Bhutani 1999
nomogram at every reference point, which pushed borderline patients
into the next-higher zone. Coarse 12-hour granularity made the
interpolation error worse between reference points.
Corrected to the PediTools-vetted values (same source we use for
AAP 2022) with 6-hour granularity. Source: Bhutani VK et al.,
Pediatrics 1999;103(1):6-14.
Example: 41h of life, TSB 9.7 mg/dL
Before: p75=9.33 → TSB above p75 → "High-Intermediate Zone" (wrong)
After: p75=10.25 → TSB below p75, above p40 → "Low-Intermediate Zone"
(matches the published nomogram)
No code-path changes — only the lookup data.
Replaced "Phototherapy" with the clinical abbreviation "Photo Tx"
(fits in the right margin without cut-off). Exchange label stays.
Bumped the layout right-padding from 40 px to 88 px so even the
longest label ("95th (High-Risk)" on the Bhutani chart) prints
fully inside the canvas on narrow viewports.
Pure visual improvements to renderBiliChart. Interpolation, lookup
tables, and threshold math are all untouched.
AAP chart now has three-zone shading matching BiliTool's convention:
- Faint green tint below the phototherapy curve (safe zone)
- Amber band between phototherapy and exchange (treatment zone)
- Unshaded above the dashed crimson exchange line (danger)
Phototherapy line: solid orange 2.4 px; Exchange: dashed crimson.
renderBiliChart common improvements:
- Right-edge label on each threshold line ("Phototherapy", "Exchange",
or "95th (High-Risk)" for Bhutani) with white halo — identifiable
without a legend, like PediTools
- Legend removed (replaced by the inline labels)
- X-axis auto-ranges to fit the actual data with tick every 12 h
- Y-axis tick every 5 mg/dL for clean BiliTool-style gridlines
- 40 px right padding so labels don't clip
- Patient dot shrunk from r=8 to r=5 with a 1.8 px white ring,
redrawn on top of every label + its TSB value printed next to it
- Bhutani chart inherits all the same improvements without changing
its own zone/fill setup
Reduces label crowding at the chart extremes. Clinical meaning
preserved: 3rd and 97th remain as the abnormal-threshold dashed
outermost lines, 10/25/50/75/90 give the mid trend. The bidirectional
label spread, leader lines, and dot-on-top rendering from the prior
pass all still apply to the 7-line layout.
Fills updated for new indexes (3↔97, 10↔90, 25↔75).
Labels at the top (97/95/90) crowd just as much as the bottom
trio — prior forward-only pass only spread the bottom. Now:
- Forward pass pushes items DOWN when crowded from above
- Backward pass pulls items UP when still crowded from below
- Min gap bumped 13 → 15 px for breathing room
- Labels clamped to stay inside chart top/bottom
- Thin leader line drawn from native curve position to the label
when the two diverge by more than 2 px, so you can still see
which line a nudged label belongs to
- Patient dot redrawn on top of all labels at the very end of
the plugin so a top-region label can never cover the dot
(Chart.js's afterDatasetsDraw fires after dataset rendering,
so the native dot was being painted over).
Label plugin rewritten:
- Collect all percentile-line labels with their native screen Y
- Sort top-to-bottom (97th ... 3rd) so order is always correct
- Walk the sorted list and enforce 13px min vertical gap by
pushing down (never reordering)
- Second-pass pull-back if the stack would clip off the chart
bottom
Prior logic could nudge "10th" past "3rd" because it moved labels
independently without respecting their natural screen order.
Patient dot: radius 8 → 5 (hover 11 → 7). The 8px ring was
dominating the chart; 5px is still clearly visible with the 1.8px
white border but no longer obscures adjacent curves.
Reverted from the 7-line reduced set back to the full clinical set:
3rd, 5th, 10th, 25th, 50th, 75th, 90th, 95th, 97th — matching what
Epic and the printed AAP/CDC/WHO charts display.
Kept the mobile-readability improvements from the prior pass:
- Each percentile is a distinct hue (red / deep-orange / orange /
amber / green / blue / violet / purple / pink)
- Outer 3rd + 97th are dashed (abnormal-threshold convention); 5th
and 95th use a tighter dash; rest solid
- 50th remains bold green to anchor the center
- Label plugin's white halo + vertical nudge keeps 3/5/10 and
90/95/97 label stacks legible
- Patient dot still on top, white-ringed
Fill bands updated for 9 indexes (3↔97, 5↔95, 10↔90, 25↔75).
Reduced reference curves from 9 to 7 (dropped 5th and 95th — they
crowd the 3rd/10th and 90th/97th labels on small screens without
adding clinical value; 3rd and 97th are the US/WHO standard
abnormal thresholds).
Each percentile now gets a distinct hue:
3rd red 50th green (bold) 90th violet
10th orange 75th blue 97th pink
25th amber (3rd/97th dashed)
Label plugin improvements:
- Bolder 11px font (was 10px)
- White halo stroke behind text so labels stay legible when the
line they sit on is also colored
- Vertical nudge when two labels would overlap — keeps adjacent
percentiles readable on mobile aspect ratios
- Solid fill color (strips alpha from borderColor)
Patient dot:
- Moved to order: -1 (drawn on top of everything, including labels
and fill bands)
- White 2.5px border ring so it's visible even when it lands
exactly on a colored curve
- Slightly larger hover radius (11 → was 10)
Adds a Chart.js plugin that draws the percentile label at the right
end of each reference line, matching the convention on printed
WHO/CDC growth charts. Lets clinicians identify lines at a glance
without using the legend. Canvas gets 30px right-padding so the
labels don't get clipped.
1. Build-ID cache busting (server.js):
- Compute a BUILD_ID at boot: git HEAD short hash if available,
else /app/BUILD_ID file, else random-on-boot.
- On first request for /, rewrite every local /js/*.js and
/css/*.css reference in index.html to include ?v=BUILD_ID.
Cached once at startup so subsequent renders are free.
- X-Build-Id response header + GET /api/build expose it for
debugging.
- Eliminates the "works after hard-refresh" class of bugs: every
deploy gets a new build ID, so browsers fetch fresh JS/CSS on
the very next page load.
2. Optimistic encounter locking wired into the client
(public/js/encounters.js):
- On resumeEncounter(): stash enc.version into
window._encounterVersions[id]
- On saveEncounter(): send expected_version in the POST body
when we have one.
- Server returns 409 if another tab/device wrote first → user
sees "Someone else edited this encounter. Reload to see the
latest version." instead of silently clobbering the prior save.
- On success, remember the new server-assigned version for the
next save.
Previous check was strict (canLocalAuth !== true → hide). On a
transient /me hiccup or when the boot cache lagged, a legit local
user saw empty Settings with none of the sections they should see.
Inverted the predicate: hide only when canLocalAuth === false
(explicit SSO-only signal from the server). Undefined/missing now
defaults to show — local-auth users never lose their own UI.
Still hides correctly for the documented SSO-only case because the
/me endpoint sets the flag to false explicitly for those users.
Age parser (src/routes/billing.js):
- Now sums year + month + week + day matches so "4 yr 11 mo"
(59 months) correctly maps to the 5-11y billing bracket instead
of being billed as 1-4y. Added bounds sanity check.
Graceful SIGTERM shutdown (server.js):
- Closes the HTTP listener first, then drains batched audit queues,
then ends the Postgres pool. 9-second hard deadline to beat
Docker's 10-second SIGKILL. Previously an in-flight note save
during a container restart could truncate the write.
Explicit LLM fallback opt-in (src/utils/ai.js):
- The OpenRouter / LiteLLM silent fallback now requires admin
setting `ai.allow_model_fallback = true` (default: false). If
primary fails and fallback is disabled, the error is surfaced
to the caller. Prevents silent spillover from a BAA-covered
primary to a non-covered fallback.
Prompt injection delimiters (src/utils/promptSafe.js):
- Wraps user transcripts, dictations, refine-instructions, and
pasted documents in <UNTRUSTED_*>...</UNTRUSTED_*> tags and
appends an explicit system instruction telling the model to
treat the wrapped content as data rather than commands.
- Applied to soap.js, hpi.js, refine.js. Extend to other AI
routes incrementally.
Cross-tab logout sync (public/js/authFetch.js, auth.js):
- BroadcastChannel('pedscribe-auth') — logout in one tab posts
a message; all sibling tabs clear state and reload, dropping
any PHI-containing UI immediately.
Backup code race-free consumption (src/routes/auth.js):
- tryConsumeBackupCode() now uses a Postgres transaction with
SELECT ... FOR UPDATE so concurrent login attempts using the
same code serialize. First wins, second sees the already-
shortened array.
Optimistic encounter locking (migrations/...add-encounter-version):
- saved_encounters.version INTEGER NOT NULL DEFAULT 1
- POST /api/encounters/saved accepts an expected_version and
rejects with 409 if the row has advanced. Falls back to
last-write-wins if the client doesn't pass one (backward compat).
Audit log batching (src/utils/auditQueue.js):
- Audit / api_log / access_log writes are buffered in memory and
flushed every 1s or every 50 entries via one multi-row INSERT.
Under load this reduces DB pressure by ~50x. On SIGTERM the
shutdown path drains the queue before exiting.
Two findings from review:
1. callAI() previously accepted any model string from the client.
POST /api/hpi with { model: "openai/o1" } would call the reasoning
model regardless of whether the operator enabled it. Added
getAllowedModelIds() in src/utils/models.js (60s TTL DB-backed
cache) and a guard at the top of callAI() that rejects with
"model_not_permitted" when the requested ID isn't in the active
roster. No model supplied → silent fallback to DEFAULT_MODEL.
2. Middleware was updating user_sessions.last_activity on every
request, including GETs. Client-side polling (/api/auth/me
heartbeats, dashboard refreshes, log tail calls) kept sessions
alive indefinitely, defeating the 24h sliding idle policy. Now
only POST/PUT/DELETE/PATCH count as "user activity". GETs are
read-only and often automated — they no longer extend the
session. Idle enforcement still runs on every method, so a
24h-idle user still gets kicked on their next GET.
The server-side revoke was always working — it deletes user_sessions
rows, and middleware correctly returned 401 on the revoked device's
next /api/* request. The bug was entirely client-side: individual
fetch handlers swallowed the 401 (rendering "no sessions found" or
empty data) and nothing redirected to the login screen. So the
revoked device looked like it stayed signed in.
Added public/js/authFetch.js: a global fetch interceptor that
watches every /api/* response. On 401 from a non-auth endpoint
(i.e. not /login, /register, /logout, /me, etc.), it clears any
cached token/user state and reloads the page. The reload's boot
flow lands on /api/auth/me → 401 → login screen as usual.
Guarded against false positives: only triggers when the app believes
the user is currently logged in (AUTH_TOKEN set or main-app visible)
so a pre-login 401 doesn't accidentally flash the screen.
Loaded before auth.js in index.html.
Follows the same pattern as Change Password and 2FA sections —
hidden by default in the HTML, revealed only when canLocalAuth=true.
Why: revoke technically deletes the PedScribe session row and clears
the cookie on that device, but the SSO user can re-auth instantly
because their IdP session is still live. Surfacing a "revoke" button
that the IdP will immediately undo is misleading. SSO users now see
only the SSO-relevant sections of Settings.
Infrastructure only — no existing data or tables modified.
src/db/migrate.js — programmatic runner, fires at boot after
the existing idempotent initDatabase()
migrations/1744600000000... — intentionally empty example, documents
the file shape. Registered in the new
pgmigrations tracking table so it won't
rerun.
.node-pg-migraterc.json — CLI config (migrations-dir, utc naming)
docs/migrations.md — workflow + conventions
package.json — migrate:up/down/new/status npm scripts
(status is a direct pgmigrations query
since node-pg-migrate v7 lacks a status
subcommand)
src/utils/sessions.js:
- parseUserAgent now recognizes the Capacitor wrapper (UA suffix
"PedScribe-Android" / "PedScribe-iOS") and labels sessions
"PedScribe (Android)" instead of "Chrome on Android".
Going forward: schema changes go in /migrations as versioned files
with up() + down(); the inline init in database.js is the implicit
baseline for everything already in production.
After the hybrid auth migration, web users log in but the
setAuthCookie() helper was never actually called in /login or
/register — only in the OIDC callback. Result: local sign-in worked
until the first page reload, then the user appeared logged out. The
Settings page's Active Sessions list came up empty because
/api/sessions received no auth.
Added setAuthCookie(res, token) calls on successful:
- /register (auto-verified first admin path)
- /login (after TOTP / backup code verification)
Mobile is unaffected — it uses Bearer from Keychain and always has.
settings load2FAStatus():
- Explicit credentials: 'same-origin' on the /me fetch (was relying
on fetch defaults, which can behave oddly in some browsers/edges)
- Fall back to window.CURRENT_USER (cached at login) if /me fails,
so local-auth users still see their password/2FA sections after
a transient error. Keeps cache in sync on each successful fetch.
enterApp():
- Cache the logged-in user object on window.CURRENT_USER so modules
that need the canLocalAuth flag don't have to re-fetch /me.
2FA regenerate modal:
- Previous call passed a wrong-shape options object to showConfirm.
Updated to the correct (message, callback, opts) signature with
input:true, inputType:'password', placeholder, required.
OIDC email_verified check:
- Accept boolean true or string 'true' for robustness. Some IdPs
serialize ID-token booleans as strings.
Sections were briefly visible for SSO-only users before load2FAStatus
resolved and hid them. Flipped the default: both sections now carry
style="display:none" in the HTML and are revealed only when the /me
fetch confirms the user has a real password hash.
SSO-only users never see the sections, even for a flash.
Endpoint guards (defense-in-depth over hidden UI):
- POST /api/auth/change-password: 400 with SSO-aware message if
the caller's stored password is not a real bcrypt/argon2 hash.
Prior behaviour was to fail at passwords.verify() with an
ambiguous "current password is incorrect".
- POST /api/auth/setup-2fa: 400 with same SSO-aware message for
SSO-only accounts. Prior behaviour allowed TOTP setup on an
account where it could never actually trigger (user never logs
in locally).
OIDC account-link safety (src/routes/oidc.js):
- Auto-link to an existing local account now requires the IdP to
assert email_verified=true in the ID token (or userinfo). If
absent/false, the callback redirects with ?error=email_unverified.
Prevents an attacker at a misconfigured IdP from taking over a
local account by claiming an email they don't own.
- If an existing user already has oidc_sub set and the incoming
sub is different, refuse with ?error=sub_mismatch. Prior
behaviour silently did nothing, hiding a potential attack.
- Audit 'oidc_linked' written on first successful link.
Frontend:
- Added user-facing messages for the two new SSO error codes.
Middleware:
- Log to console.warn + audit_log when a session is killed for
inactivity. Shows up in Grafana/Loki so you can see how often
users actually get kicked. Audit action: 'session_idle_timeout'
- last_activity throttle bumped 5 min → 10 min — halves DB writes
per active user. Idle precision slop widens to 24h00-24h10;
still invisible in practice.
Per-user local-auth visibility:
- /api/auth/me now returns user.canLocalAuth: true when the stored
password is a real bcrypt / argon2 hash, false for the random
blob OIDC auto-creates for SSO-only users.
- Settings page hides "Change Password" and "Two-Factor
Authentication" sections when canLocalAuth is false — those UIs
are meaningless for users whose sign-in lives at the IdP.
- Password hash is not leaked in the /me payload.
Mobile (restating existing behaviour for clarity): no idle check,
365-day JWT in Keychain/Keystore, never auto-logs-out. Only logout
triggers are: manual logout, password change, admin revoke, JWT hit
365d, or app uninstall.
Session model:
Web — 24h sliding idle timeout enforced server-side via
user_sessions.last_activity. 30-day JWT + cookie are a
safety net; middleware is the real clock. Cookie is
re-set on active use so browsers match the sliding window.
Mobile — 365-day JWT, no idle timeout (stays persistent via Keychain
/ Keystore). Detected via User-Agent ("PedScribe" /
"Capacitor") or X-Client: mobile header.
2FA backup codes:
- 10 single-use codes generated when 2FA is first enabled
- Stored as bcrypt hashes in new users.totp_backup_codes column
- Consumed atomically on successful login fallback (when TOTP fails)
- Regenerate endpoint (POST /api/auth/2fa/backup-codes) requires
current password; invalidates prior codes
- Count endpoint (GET /api/auth/2fa/backup-codes/count) powers a
"N codes remaining" indicator on the 2FA settings card
- Modal shows codes exactly once with Copy + Download .txt actions
- Codes cleared when 2FA is disabled
New files:
src/utils/platform.js — isMobileClient() helper
Schema migration (idempotent):
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_backup_codes TEXT
Shortens both JWT expiresIn and httpOnly cookie maxAge to 24h in
auth.js (local + register + reset flows) and oidc.js (SSO callback).
Rationale: shorter absolute session window for a PHI-adjacent app.
No sliding idle refresh — user re-logs in once a day.
capacitor.config.json:
- webContentsDebuggingEnabled: true → false
(was leaving Chrome DevTools able to attach to released builds)
- allowMixedContent: true → false
(API is HTTPS-only; no need to permit cleartext loads)
- server.allowNavigation: ["*"] → restricted to pedshub.com /
peds.danvics.com origins
(prevents WebView following an attacker-controlled redirect)
AndroidManifest.xml:
- android:allowBackup="false" + data_extraction_rules.xml
(Android system backup would otherwise copy EncryptedSharedPreferences
containing the auth token into Google Cloud backups)
- Removed USE_BIOMETRIC permission (feature removed earlier)
AudioRecordingService.java:
- startForeground(id, notif, TYPE_MICROPHONE) on Android 14+
(without the explicit type Android 14 kills the service with
MissingForegroundServiceTypeException)
- WakeLock cap: 1h → 8h (still bounded, onDestroy releases early)
MainActivity.java:
- Removed dead biometric code path and androidx.biometric imports
mobile/package.json:
- Dropped @aparajita/capacitor-biometric-auth — orphan dependency
Runtime split driven by window.Capacitor.isNativePlatform():
Web browser
- No token in localStorage / sessionStorage — XSS can't read it
- Server-set httpOnly cookie carries the session
- fetch() default credentials='same-origin' sends the cookie
- getAuthHeaders() returns Content-Type only, no Authorization
- Middleware already falls back to cookie when Bearer is absent
Capacitor native (iOS / Android)
- Unchanged — Bearer token lives in Keychain / Keystore via the
capacitor-secure-storage-plugin SecureStorage wrapper
- Bearer header still sent on every request
enterApp() / clearSession() / getAuthHeaders() all now branch on
isNativeApp(). Legacy localStorage entries from the dual-mode era
are wiped on clearSession() for users migrating in.
Rollback: git reset --hard pre-httponly-only-2026-04-14
Adds `npm run maint:check` (health report) and `npm run maint:reindex`
(REINDEX DATABASE + REFRESH COLLATION VERSION + ANALYZE) for post-
upgrade maintenance, modelled after Nextcloud's occ maintenance.
Documented in README.
Also relaxes postgres image from digest pin back to tag-pin
(pgvector/pgvector:pg16) — the auto-REINDEX-on-drift check in
database.js and the COLLATE "C" protection on critical indexes
make the digest pin redundant while blocking ordinary `compose
pull` updates.
idx_users_email and idx_sessions_token_hash now use byte-order
collation so a future ICU library bump cannot silently corrupt the
indexes the way it did this week. The columns themselves retain
their default collation; only the index comparison is C, which is
safe for these because:
- users.email is lowercased ASCII in practice
- user_sessions.token_hash is SHA-256 hex (pure ASCII)
Both are used for equality lookups only, never ORDER BY. Migration
is idempotent, gated on app_settings.migration.text_indexes_c.
Slug indexes on learning_* tables left at default for now — those
are also ASCII in practice but under lighter load; the startup
drift check + auto-REINDEX covers them.
Root cause of recent "invalid credentials on correct password" was
a silent btree index corruption: pgvector/pgvector:pg16 was pulled
with a different ICU library than the one used to build existing
indexes. Queries returned 0 rows even though matching heap rows
existed. Postgres logged nothing (corrupt index → empty result set
is a "successful" query) and the login path never logged unknown-
user attempts (enumeration protection).
Three defenses:
1. Pin postgres image by digest in docker-compose.yml so a
silent pull can't change ICU under our feet.
2. Startup collation-drift check in src/db/database.js:
compares pg_database.datcollversion to the library's actual
version and, on mismatch, runs REINDEX DATABASE + ALTER
DATABASE REFRESH COLLATION VERSION. Logs "Collation versions:
aligned" on clean boot.
3. Server-side console.warn on login lookup-miss (no email, no
audit row — preserves enumeration protection but gives
Grafana/Loki a signal for unusual miss rates).
Root cause for "invalid credentials" on correct password was a
corrupt btree index (idx_users_email) causing user lookups to miss
existing rows. Fixed by REINDEX DATABASE. Keeping a typed catch
around passwords.verify() so any future verify throw is logged
cleanly instead of bubbling as 500.
Percentile displays in growth charts, BMI, and mid-parental height
now show 2 dp (e.g. "37.42th") instead of 1 dp ("37.4th") for more
precision at tail percentiles.
Previously any form of zero total ("0 days", all blank) rejected
with "Enter age". Newborns at birth are a legitimate entry —
distinguish blank-all (error) from explicit-zero (valid).
Replace single text input with three number fields — years, months,
days — that all combine into fractional months. Fill any subset:
leave years blank for a newborn, leave months blank for "2 years",
enter just days for a 10-day-old.
Live hint below ("= 2 yr 5 mo (29 mo total)") still shows the
interpreted total. Parser from prior commit retained on window
for reuse elsewhere.
Replace [years] + [months dropdown] with a single text field that
accepts:
3y / 3 years / 3 yr
29m / 29 months / 29 mo
2y5m / 2 years 5 months / 2 yr 5 mo
3.5 years / 36 (plain number = months)
15 days / 2 weeks / 3y 2m 10d
Enables fractional ages so newborns can be plotted accurately
(WHO/CDC growth curves are continuous — "0 months" means at birth,
not a 0-27 day bucket, so a 15-day-old should plot at ~0.5 months).
Live hint below the field shows how the input was interpreted
("= 2 yr 5 mo (29 mo total)").
argon2 requires node-gyp + python3 + g++ + make to build its C
extension. Added as a virtual .build-deps package so it's compiled
during npm install, then purged to keep the Alpine image slim.
- App-layer AES-256-GCM crypto helper (src/utils/crypto.js)
- Nextcloud tokens encrypted at rest; transparent migration on next use
- Audio backups encrypted at rest (version byte 0x01 envelope); legacy
rows still decrypt as-is until overwritten
- argon2id password hashing via src/utils/passwords.js with bcrypt
fallback; bcrypt hashes rehashed to argon2id on next successful login.
argon2 package is optional — server keeps running with bcrypt only
until npm install adds the native dep
- PHI redactor for audit log details (src/utils/redact.js) — strips SSN,
phone, email, DoB, long IDs; caps at 500 chars; detects note bodies
- DOMPurify (cdnjs, SRI-pinned) replaces custom regex sanitizer in
Learning Hub content rendering
- SRI integrity hashes added for Font Awesome CSS and Chart.js
- Magic-byte file-type verification on document uploads
(src/utils/fileType.js)
- Generic 500 error responses via src/utils/errors.js applied to
nextcloud and audioBackups; full detail still logged server-side
- DATA_ENCRYPTION_KEY env documented in .env.example
Deploy: requires rebuild of the container image to pick up the new
files and `npm install` (adds argon2). Existing users keep working
because bcrypt stays available and crypto helpers pass through
plaintext when the key is not yet set in dev.
- JWT_SECRET fails fast at startup in production
- CORS fails closed if APP_URL + CORS_ORIGINS are both missing
- Explicit HSTS (1y, includeSubDomains, preload)
- Rate limit sensitive auth endpoints (change-password, 2FA)
- /api/health now returns {ok:true}; details gated behind admin auth
- Login enumeration removed — generic 401 + dummy bcrypt on miss
- ReDoS guard: 20KB input cap on /suggest-codes
- showToast uses textContent, no innerHTML
- clearSession() clears service worker caches on logout
- OIDC state is now HMAC-signed and stateless (survives restart)
- SSRF guard on admin-set OIDC issuer (blocks private IPs, requires HTTPS)
Adds docs/mobile-build.md covering APK build, release, git push,
keystore, and troubleshooting for both PedScribe and PedsHub apps.
Web still uses localStorage; Capacitor native app now routes
token/user/session-id through capacitor-secure-storage-plugin
(iOS Keychain, Android EncryptedSharedPreferences / Keystore).
A thin SecureStorage wrapper detects Capacitor at runtime and
falls back to localStorage elsewhere, keeping a single auth.js
codebase for both targets.
To activate on mobile: cd mobile && npm install && npx cap sync android
Loki logs now include:
- User agent string (browser/device identification)
- Session ID (ties actions to specific login session)
- Status field (success/failure)
New logging:
- encounter_load: logged when user opens a saved encounter (with label)
- copy_to_clipboard: logged when user copies note content (PHI access)
- Client event endpoint: POST /api/logs/client-event (auth required)
Encounter save/delete/load all include the encounter label for
patient identification in audit trail.
HIPAA audit trail now covers: who, what, when, from where, which
device, which session, what patient data, success/failure.
Exchange transfusion data (AAP 2022) now covers GA 35, 36, and 38+ weeks
with and without risk factors, hour-by-hour from 12-96h (510 more data
points). Total bilirubin data: 1020 data points (6 photo + 6 exchange
tables x 85 hours each). No interpolation needed for any hour.
Replace interpolated thresholds with exact hour-by-hour values
extracted from PediTools API for every hour from 12-96h:
- 6 phototherapy tables (GA 35/36/38 x with/without risk factors)
- 85 data points per table = 510 total values
- No interpolation needed — exact AAP 2022 nomogram values
- Exchange transfusion thresholds for GA 38 (with/without risk)
Phototherapy thresholds updated with exact values extracted from
PediTools (validated against AAP 2022 nomograms):
- Separate tables for GA 35, 36, and 38+ weeks
- With and without neurotoxicity risk factors
- Hour-specific values at 12, 24, 36, 48, 60, 72, 84, 96, 120h
Previous approximations were 1-3 mg/dL too low (conservative but
inaccurate). New values match the published AAP 2022 curves exactly.
Exchange transfusion thresholds added for GA 38+ weeks (with/without
risk factors). Displayed alongside phototherapy threshold in results.
GA selection expanded: 35, 36, 37, 38, 39, 40+ weeks.
Chart now shows both phototherapy and exchange transfusion lines.
Also: fixed Loki port conflict (3100->3101), added logs.pedshub.com.
GCS Calculator:
- Child/Adult and Infant versions with toggle
- Eye opening (4), Verbal (5), Motor (6) dropdowns
- Auto-calculates total score with severity classification
(Mild 13-15, Moderate 9-12, Severe/Coma 3-8)
- Infant-modified verbal and motor scales per Kids Kard
- Updates on every dropdown change (no button needed)
Equipment Sizing (Johns Hopkins Kids Kard):
- Select age/weight group (premie through 16+)
- Shows: BVM, oral/nasal airway, blade, ETT, LMA, Glidescope,
IV catheter, central line, NGT/OGT, chest tube, Foley
- All values from Johns Hopkins Children's Center Kids Kard
- ETT formulas shown as reference
Replace static vital signs table with interactive age group dropdown.
Each selection shows: HR (awake/sleeping), RR, SBP, DBP, temperature,
SpO2 target, weight range, and age-specific clinical notes.
10 age groups: preterm through 18 years. Values from Harriet Lane
Handbook 23rd Edition. Includes AAP 2017 BP classification thresholds
for ages 13+, ETT sizing formulas, and clinical pearls (orthostatic
testing, febrile tachycardia, athletic bradycardia, etc.).
Full reference table preserved as collapsible "View All Age Groups".
The WebView has its own permission layer separate from Android runtime
permissions. Both must be granted. Now when the web page requests mic
access, the WebChromeClient checks if Android permission exists, grants
the WebView request if yes, or requests Android permission first then
grants the pending WebView request in the callback.
- MainActivity: request RECORD_AUDIO permission at app start via
ActivityCompat (not WebChromeClient override which broke Capacitor bridge)
- Simplify launcher: remove server reachability check (was failing in
WebView), just save URL and navigate directly
- Remove biometric auth from launcher (Capacitor plugins need ES module
bundler, not available in plain HTML). Biometric can be added later
via the web app with proper Capacitor runtime.
- Add webContentsDebuggingEnabled for development
- MainActivity: override WebChromeClient to auto-grant WebView
permission requests (microphone, camera) so the Android runtime
permission dialog shows instead of WebView silently denying
- Add colors.xml with PedScribe blue (#2563eb / #1d4ed8)
- Update styles.xml: set statusBarColor and navigationBarColor to
match app theme (fixes brown/mismatched bar at top)
- Change base theme to NoActionBar (removes action bar)
WebView blocks no-cors fetch and image probes differently than browsers.
Simplified to a normal fetch that treats CORS errors as 'server reachable'
(CORS error = server responded, just blocked the origin).
- Fix AudioRecordingService ACTION_STOP to use com.pedshub.scribe
- Fix deprecated stopForeground(true) to STOP_FOREGROUND_REMOVE
- Fix launcher.js testServer: prevent double callback, fix onerror
always reporting success (now correctly fails on unreachable servers)
- Update service comment from TWA to Capacitor
New mobile/ directory with Capacitor project:
- Configurable server URL launcher (default: app.pedshub.com)
- Android: foreground service + wake lock for background recording
(AudioRecordingService preserved from existing TWA)
- iOS: background audio mode + microphone permission
- App ID: com.pedshub.scribe
- Both platforms initialized and synced
Existing android/ TWA project untouched — this is a separate project.
Build: cd mobile && npx cap open android (or ios)
New feature: after generating any clinical note, the app automatically
suggests relevant billing codes displayed as clickable chips below the output.
Backend (src/routes/billing.js):
- POST /api/suggest-codes endpoint analyzes note text
- Extracts diagnoses from Assessment section via regex
- Looks up ICD-10 codes: local common pediatric map (40+ conditions)
first, then NLM Clinical Tables API for unknown terms
- Suggests CPT E/M codes based on note type, visit complexity,
ROS/PE system counts, and MDM level estimation
- Supports: outpatient (new/established), well visit (age-based),
ED, inpatient (admit/subsequent/discharge)
Frontend (public/js/app.js):
- suggestBillingCodes() renders collapsible card with ICD-10 and CPT chips
- Click any chip to copy the code to clipboard
- Shows E/M level assessment (diagnosis count, ROS, PE, MDM complexity)
- Disclaimer: "Suggestions only. Always verify codes."
Integration: called after note generation in all 6 tabs
(encounter, SOAP, sick visit, well visit, hospital course, chart review)
Calculators tab with 7 tools:
- BP Percentile (AAP 2017) with age/sex/height classification
- BMI Percentile (CDC 2000) with extended obesity classification
(Class 1/2/3 using % of 95th percentile per CDC 2022)
- Growth Charts: weight-for-age, length-for-age, head circumference,
weight-for-length (WHO/CDC LMS), Fenton preterm (22-50 weeks)
- Bilirubin: AAP 2022 phototherapy threshold + Bhutani nomogram
with Nelson Table 137.1 risk factors for severe hyperbilirubinemia
- Vital Signs by Age (Harriet Lane) with quick reference formulas
(estimated weight, min SBP, ETT size, maintenance fluids 4-2-1)
- Body Surface Area (Mosteller formula)
- Weight-Based Dosing with max cap and volume calculation
Fix growth chart sub-tab navigation (pills scoped separately from
top-level nav to prevent panel disappearing)
- Fix: DELETE all other sessions query used empty string fallback when
req.sessionId was undefined, causing id != '' to match ALL rows
(including current session). Now skips deletion if sessionId unknown.
- Fix: Revoke All endpoint returns error if current session not identified
- Fix: var confirm shadowing window.confirm in password change handler
- Replace browser prompt() with inline UI for: 2FA disable (password field),
admin password reset (inline input), admin test email (inline input)
- Remove all password breach warning UI (login, register, settings)
Backend HIBP check endpoint remains but is no longer called from frontend
- Remove model cost display from dropdown and header badge
- Hide empty cost-badge element in header
- Fix model dropdown to flat list (no category grouping)
- Add FAQ tab with accordion sections: Getting Started, AI & Models,
Voice & Transcription, Saving & Export, Privacy & Security,
Well Visit & Sick Visit, Learning Hub, Troubleshooting
- Documents how AI learns from physician edits (correction tracker)
- Fix FAQ accordion (CSP was blocking inline script, moved to app.js)
- Patch all 5 npm vulnerabilities: nodemailer 8.0.5, xmldom, basic-ftp,
path-to-regexp (npm audit now reports 0 vulnerabilities)
- Remove model category grouping from dropdown (flat list, no optgroups)
- Fix model dropdown dark background on options (white bg, dark text)
- Update FAQ model guidance to reflect admin-managed model selection
Security:
- Add session management: users can view/revoke active sessions in Settings
- Add password change in Settings (requires current password, HIBP check)
- Force logout all sessions on password reset
- Fix logout to destroy server-side session (was only clearing cookie)
- Add trust proxy for correct client IP in rate limiting and audit logs
- Add CORS support for multiple domains (CORS_ORIGINS env var)
- Add HIBP breach check endpoint and inline warnings on password fields
Audit logging:
- Add audit logging to all 24 PHI-handling endpoints across 13 route files
- Covers: generation, transcription, TTS, refine, encounters, documents, Nextcloud
- All fire-and-forget (no response delay)
AI improvements:
- Refine now includes original source material (transcript, notes, labs)
so AI can reference the full input when modifying output
- Add correction tracking (trackAIOutput) to sick visit and well visit tabs
- Fix sickvisit missing from encounter save noteIdMap
UI fixes:
- Non-blocking busy bar for transcription and AI generation (replaces full-screen overlay)
- Fix encounter recording: hide record button during recording (was showing two stop buttons)
- Fix ROS/PE "All WNL" stacking duplicate event handlers; add Clear buttons
- Enlarge AI instructions textarea in Learning Hub CMS
Domain:
- Primary domain now app.pedshub.com, with scribe.pedshub.com and peds.danvics.com as CORS origins
- New docs/developer-guide.md: full walkthrough of frontend SPA architecture,
backend middleware stack, database layer, AI integration, settings system,
how to add features/routes/tables, key design decisions, file references
- Expand ai-providers.md: detailed admin model management (add custom models
with ID/name/cost/category, discover from provider, enable/disable, set default)
- Update README docs index
- Add Cloudflare Turnstile to login, register, and password reset forms
- Switch AI provider to LiteLLM, transcription to OpenAI Whisper
- Change domain to scribe.pedshub.com
- Fix PPTX export: add tables, bold/italic, numbered lists, code blocks, blockquotes
- Fix announcement banner close button (CSP was blocking inline onclick)
- Fix auth middleware: empty Bearer token now falls through to cookie auth
- Fix audio backups: only save on transcription failure, stop auto-deleting on success
- Soften AI correction injection to prevent model hallucination from correction history
- Fix LiteLLM TTS model name handling (no incorrect openai/ prefix)
- Expand AI instructions textarea in Learning Hub CMS
- Update README for v6 with all features and providers
- Add comprehensive docs/: architecture, API reference, database schema,
authentication, AI providers, speech, learning hub, configuration, deployment
- Fix model search for all providers: Bedrock now falls back to built-in
list (with live ListFoundationModels attempt), Azure returns built-in list
- Add Test button on every model row (built-in, discovered, custom) that
sends a live prompt and shows response + latency in a toast
- Add TTS management section: search voices from provider API (Google TTS
voices.list, LiteLLM /v1/models, ElevenLabs /v1/voices), Set as Default
writes tts.voice/tts.model to DB, runtime respects DB override
- Add STT management section: search models from provider (Gemini, Whisper,
LiteLLM, OpenAI, local), Set as Default writes stt.model to DB, runtime
respects DB override in transcribe.js
- Add Embedding models section: search from provider (LiteLLM, Vertex,
OpenAI), Set as Default writes embeddings.model+dimensions to DB,
embeddings.js respects DB override
- Add record-and-transcribe STT test (browser MediaRecorder)
- Add TTS synthesize-and-play test (returns base64 audio)
- Add embedding generate test (shows dims + vector sample)
- Expand PUT /config/:key(*) whitelist to include tts., stt., embeddings.
- Add @aws-sdk/client-bedrock as optional dependency for live Bedrock discovery
REMOVED:
- Milestone editing UI from Admin Panel (per user request)
- Milestones will be managed via hardcoded static data only
- Kept backend routes and database support for future use
ADDED:
- Refresh button in Learning Hub CMS content list
- Manual refresh for AI-generated content updates
- Better discoverability of content refresh functionality
FIXES:
- AI learning content now has visible refresh button
- Users can manually refresh content list after AI generation
- Cleaner admin panel without milestone management clutter
NOTE:
- Developmental milestones still work via static fallback
- Edit milestones by modifying public/js/milestonesData.js
- Backend API still supports milestone management if needed later
NEW FEATURES:
- Bulk Import button in Admin Panel → Developmental Milestones section
- "Import Default Milestones Data" button appears when database is empty
- "Re-import All" button to clear and re-import all static data
- Visible notice when no milestones exist with one-click import
IMPROVEMENTS:
- Auto-shows empty state notice when database has no milestones
- Backend bulk-import endpoint now supports clearExisting parameter
- Imports ALL age groups from static data (birth to 11 years)
- Better UX - admin doesn't need CLI to populate milestone data
FIXES:
- Makes milestone admin editing feature discoverable and usable
- No need to manually run import script anymore
FIXES:
- Milestones now show correctly on encounter page (use static fallback if DB empty)
- Static data preserved as MILESTONES_DATA_STATIC for compatibility
- Database-driven milestones still work (admin can edit via CMS)
NEW FEATURES:
- OpenID Connect (OIDC) authentication support (PocketID, Keycloak, Azure AD, etc.)
- Comprehensive setup guide: OPENID_SETUP.md
- Auto-linking existing users by email on SSO login
- Multiple PDF upload support in Learning Hub (up to 10 files)
- 100 MB per file limit (was 20 MB)
- Full PDF content used for AI generation
- Embeddings use first ~8K chars for semantic search
IMPROVEMENTS:
- Updated UI to show multiple file selection with list
- Drag-and-drop supports multiple files
- Better file upload validation and error handling
- Added clarifying comments about embedding truncation
FINAL WORKING SOLUTION:
Previous attempts failed because:
- transformers.js v2.17.2 is ES module-only
- Module workers require complex CSP and external imports
- importScripts() doesn't work with ES modules
Solution:
- Use transformers.js v2.6.2 (has worker-compatible UMD build)
- Bundle library + models, serve entirely from our server
- Classic worker with importScripts() - no CSP issues
What's self-hosted:
- ✅ transformers.min.js (760KB) - at /models/transformers.min.js
- ✅ Whisper models (42MB) - at /models/Xenova/whisper-tiny.en/
Worker loads:
1. importScripts('/models/transformers.min.js') - OUR SERVER
2. Loads models from /models/ - OUR SERVER
3. ZERO external network calls
4. Works in any network (firewalled, air-gapped, etc.)
This is the production-ready, truly offline solution.
Issue: transformers.js is an ES module package and cannot be loaded
with importScripts() in classic workers.
Solution:
- Changed to module worker (type: 'module')
- Import transformers.js from CDN as ES module
- Models (42MB) still served from local server at /models/
Trade-off:
- Library (900KB): Loads from cdn.jsdelivr.net once, cached
- Models (42MB): Self-hosted, served from /models/ (no CDN)
This is necessary because:
1. @xenova/transformers is ES module-only (package.json: "type": "module")
2. ES modules cannot use importScripts()
3. Module workers require HTTPS for imports
4. CDN is HTTPS and cacheable
If CDN is blocked:
- Use Web Speech API (with privacy warnings)
- OR use Server Transcription (Vertex AI/AWS)
Models remain self-hosted as they're 40MB+ and contain the AI.
Provides two transcription options:
1. Browser Whisper (Offline, Batch) - RECOMMENDED
- 100% offline, zero network calls
- HIPAA-compliant, audio never leaves device
- Highest accuracy (Whisper)
- Processes after recording (batch mode)
- Models self-hosted, bundled in v2
2. Web Speech API (Real-time, Streaming) - EXPERIMENTAL
- Real-time transcription (see words as you speak)
- Uses browser's built-in speech recognition
- ⚠️ Sends audio to cloud (Chrome/Edge → Google)
- ⚠️ NOT HIPAA-compliant
- Requires user consent with clear warnings
Features:
- Settings UI for both options
- Clear privacy warnings for Web Speech
- Mutual exclusion (only one active at a time)
- Browser detection shows which provider is used
- Confirmation dialog before enabling Web Speech
Use Cases:
- Clinical/HIPAA: Use Browser Whisper only
- Personal/Non-clinical: Can use Web Speech for real-time feedback
- Maximum privacy: Browser Whisper (offline)
- Maximum speed: Web Speech (if privacy not required)
Implementation:
- speechRecognition.js: Web Speech API wrapper
- transcriptionSettings.js: Settings UI handler
- Privacy info displayed per browser
User can choose based on their privacy vs. speed preference.
Features:
- Admin can add, edit, and delete developmental milestones via dashboard
- Milestones stored in PostgreSQL (developmental_milestones table)
- Client-side loads milestones from API instead of static file
- Import script to migrate existing static data to database
- Organized by age group and domain
- Supports sorting and filtering
Admin UI:
- New section in Admin panel for milestone management
- Filter by age group
- Add/Edit modal with validation
- Delete with confirmation
- Auto-complete for age groups and domains
API Endpoints:
- GET /api/milestones-data - Public endpoint for authenticated users
- GET /api/admin/milestones - List all milestones (admin only)
- GET /api/admin/milestones/meta - Get age groups and domains
- POST /api/admin/milestones - Create milestone
- PUT /api/admin/milestones/:id - Update milestone
- DELETE /api/admin/milestones/:id - Delete milestone
- POST /api/admin/milestones/bulk-import - Bulk import
Usage:
1. Run import script: node scripts/import-milestones.js
2. Access Admin dashboard → Developmental Milestones section
3. Add/Edit/Delete milestones as needed
BREAKING FIX: Browser Whisper now fully self-contained
Previous issue:
- Loaded transformers.js from cdn.jsdelivr.net
- Downloaded models from cdn-lfs.huggingface.co
- Failed in corporate/clinical networks with firewall
- Stuck at "Initializing..." with no progress
Solution:
- Bundle transformers.js library (~876KB)
- Bundle Whisper tiny.en model (~42MB)
- Serve everything from local server
- Works in ANY network environment
Changes:
- whisperWorker.js: Load transformers from /models/ instead of CDN
- Dockerfile: Download models during Docker build
- Add download script for local dev
- Add comprehensive setup documentation
Docker image size: +~42MB (one-time cost, runtime benefit)
Tested: Works on unrestricted and firewalled networks
REALITY CHECK: Browser Whisper CDN loading cannot work in all environments
- Corporate firewalls block cdn.jsdelivr.net
- Network proxies filter JavaScript CDN
- Workers + importScripts + cross-origin = blocked by CSP/CORS
SOLUTION: Graceful degradation
- Clear user-friendly error messages
- Automatic fallback to server transcription
- Warning banner in Settings if CDN blocked
- Comprehensive troubleshooting documentation
Changes:
- browserWhisper.js: Show toast on worker error, fallback gracefully
- app.js: Display CSP warning banner on preload failure
- settings.html: Add warning about network/firewall requirements
- BROWSER_WHISPER_TROUBLESHOOTING.md: Complete guide for users
Key Message:
Browser Whisper is OPTIONAL. Server transcription (Google/AWS/OpenAI)
is the primary method and works everywhere. Browser Whisper is a
privacy-focused bonus feature that requires CDN access.
User Experience:
- If CDN works: Great! Browser Whisper available
- If CDN blocked: No problem! Server transcription works perfectly
- Clear messaging: User knows what to expect
- Allow empty voice value to preview server default
- Display 'server default' in preview text
- Clears user preference (sets to null) when testing default
ROOT CAUSES FOUND AND FIXED:
1. TTS Preview not working: voicePreferences.js listening for wrong event
- Was: 'tab-loaded' (never dispatched)
- Now: 'tabChanged' (correct event name used by app.js)
- Added immediate init if page already loaded
- Added 500ms delay for DOM readiness
2. Browser Whisper CDN blocked: CSP too restrictive
- Added 'unsafe-eval' to scriptSrc (required by transformers.js)
- Added cdn.jsdelivr.net to connectSrc (worker importScripts)
- Added childSrc directive for worker script loading
- Better error messages in worker
3. Worker loading errors: Now logged with specific reasons
- importScripts wrapped in try-catch
- Posts error message to main thread
- Verifies transformers object exists after load
Testing:
- TTS Preview should now work when clicking Settings tab
- Browser Whisper should load from CDN (or show specific error)
- Console logs will show exact init sequence
The button was always returning null because it searched for onclick=speakText
but all output cards use data-action="speak" data-target="id". Now checks
data-action first so the button correctly toggles to Stop during playback.
Emails: white card, clean typography, dark button, no gradients.
Same minimal aesthetic as Linear/Resend/Notion emails.
Verify page responses also updated to match.
LiteLLM /audio/transcriptions gives 'Unmapped provider' for Vertex AI Chirp.
The correct approach: use /v1/chat/completions with a Gemini model and send
audio as base64 input_audio content block — Gemini natively understands audio.
Set LITELLM_STT_MODEL to your Gemini model name (e.g. gemini-2.5-flash).
- Raise express.json limit from 1MB to 10MB — handles large chart reviews
with many notes (50 full clinic notes ≈ 600KB, well within new limit)
- Client-side: warn user if payload >8MB, friendly toast if >30 notes
- Bump to v13.0.0
- Bug 1: When user selected "Outpatient" review type but had any subspecialty
visit cards filled in, the backend ignored the top-level type and switched to
the subspecialty prompt. Fixed: top-level type dropdown is now definitive.
Per-visit note types only control data formatting/labeling, not prompt selection.
- Bug 2: Labs entered in a visit card were silently dropped for outpatient and
subspecialty visits (only ED visit labs were included). Fixed: per-visit labs
now appear immediately after their visit content, labeled with the visit date.
- Improved lab labeling: visit labs are labeled "Labs from this visit (date)"
and the separate labs section is labeled "ADDITIONAL LABS (not tied to a
specific visit)" so the AI clearly distinguishes them.
- TTS response now includes X-TTS-Provider header (google-tts, litellm/model, elevenlabs)
- Frontend reads header and shows actual provider in toast instead of hardcoded "Adam/ElevenLabs"
- CORS exposes X-TTS-Provider header so frontend can access it
- Updated .env.example: clarify that LITELLM_TTS_MODEL and LITELLM_STT_MODEL
can be either the model_name alias OR the full provider/model path depending
on your LiteLLM config (important for BAA compliance routing)
vertex_ai/chirp does not work via LiteLLM's audio transcription proxy.
Changed default LITELLM_STT_MODEL from vertex_ai/chirp to whisper-1.
Updated .env.example documentation to match.
STT: Vertex AI Chirp not supported via LiteLLM proxy (confirmed by docs).
Now uses Gemini directly (transcribeGoogle.js) — auto-detected when
GOOGLE_VERTEX_PROJECT is set, fallback to AWS then OpenAI.
TTS: LiteLLM Vertex TTS DOES work but requires the model_list ALIAS
(tts-1) not the underlying path (vertex_ai/text-to-speech).
Also pass voice param — LiteLLM supports Google Cloud voice names.
Auto-detected when LITELLM_API_BASE is set.
LiteLLM's atranscription has a routing bug with Vertex AI Chirp proxy.
AWS Transcribe is already configured and working. Auto-detect now prefers
AWS over LiteLLM. Use TRANSCRIBE_PROVIDER=litellm to force LiteLLM.
- TTS: switch to axios, drop voice param (configured in LiteLLM per model)
- STT: log full LiteLLM error body so 500s are diagnosable in logs
- TTS: same error detail logging
- Fix 'ElevenLabs unavailable' toast to generic 'TTS unavailable'
- Add red Stop button to encounter recording UI
- TTS: switch from OpenAI SDK to axios (same fix as STT), drop voice
param since it's configured inside LiteLLM per model
- Fix 'ElevenLabs unavailable' toast shown even when provider is LiteLLM
- Add dedicated red Stop button to encounter recording UI
Vertex AI Chirp via LiteLLM rejects/hangs when 'prompt' and
'response_format' are included — these are OpenAI Whisper-only params.
Send only file + model for LiteLLM/Chirp.
Having LITELLM_API_BASE for AI text was auto-routing audio transcription
through LiteLLM even when the proxy has no Whisper model configured,
causing silent hangs. Now LiteLLM STT only activates when LITELLM_STT_MODEL
is explicitly set. Falls back correctly to AWS Transcribe when configured.
OpenAI SDK's audio.transcriptions.create() hangs with LiteLLM
(no timeout, SDK-level incompatibility with multipart handling).
Use axios + form-data directly with 120s timeout — same approach
as ElevenLabs TTS. Handles both {text:"..."} and plain string responses.
Old pedscribe-v11 cache was serving stale admin.js to browsers
even after server updates. New cache name forces old SW to
deactivate and all clients to get fresh JS on next load.
Also switch JS/CSS from stale-while-revalidate to network-first
so code fixes are picked up immediately.
- LITELLM_MODELS = [] — no hardcoded models, global selector now only
shows what admin has actually added via Search API
- getAvailableModelsWithOverrides: for LiteLLM returns only custom list
- Remove toggle safety check — admin can disable any/all models freely
- Admin panel always reloads on tab open (was cached, showing stale data)
- Add 'Clear all models' button for LiteLLM to wipe and start fresh
- Add POST /config/models/clear-all endpoint
- TRANSCRIBE_PROVIDER=litellm routes audio to LiteLLM /audio/transcriptions
- TTS_PROVIDER=litellm routes to LiteLLM /audio/speech
- Both auto-detect when LITELLM_API_BASE is set (no extra config needed)
- LITELLM_STT_MODEL (default: whisper-1), LITELLM_TTS_MODEL (default: tts-1)
- LITELLM_TTS_VOICE (default: alloy) — alloy/echo/fable/onyx/nova/shimmer
- ElevenLabs still works if ELEVENLABS_API_KEY is set and TTS_PROVIDER=elevenlabs
- Health endpoint now reports tts provider
- Root cause: PUT /config/:key(*) wildcard was registered before
/config/models/toggle and /config/models/default, intercepting them
and returning "value is required" (body had modelId not value)
- Fix: move all model-specific PUT routes before the wildcard
- LiteLLM: return empty built-in list with discovery hint (hardcoded
models don't match user's proxy — must use Search API)
- After adding a discovered model: auto-select it in the default dropdown
- GET /config/models now returns defaultModel so dropdown pre-selects it
- Add Vertex AI provider (Gemini models via @google-cloud/vertexai SDK)
- Add LiteLLM proxy support (OpenAI-compatible, routes to any provider)
- Admin panel: model search/discover from provider API, enable/disable, custom models, set default
- New endpoints: /config/models/discover, /config/models/add-discovered, /config/models/default
- Updated models.js with VERTEX_MODELS and LITELLM_MODELS lists
- Updated health endpoint with vertex + litellm status
Root cause: TWA LauncherActivity.onCreate calls Bitmap.createBitmap on the
splash drawable — the XML layer-list with only a color fill had 0x0 intrinsic
dimensions, causing IllegalArgumentException: "width and height must be > 0".
Fixes:
- Replace splash.xml with splash.png (384x384 blue circle with P logo)
- Add proper PNG launcher icons at all 5 density buckets (mdpi through xxxhdpi)
- Change android:icon from @drawable to @mipmap for proper icon resolution
Audio Backups:
- New audio_backups table in PostgreSQL (bytea, gzip compressed)
- POST /api/audio-backups — upload with gzip compression (level 6)
- GET /api/audio-backups — list user's backups
- GET /api/audio-backups/:id/audio — download decompressed audio
- DELETE /api/audio-backups/:id — delete backup
- Auto-cleanup every hour (24h expiry)
- Frontend saves to server first, falls back to IndexedDB
- Settings shows source badge (server/local) per backup
AI Corrections:
- Corrections list is now expandable — click to view original vs corrected
- Shows red "Original" and green "Corrected to" sections
- Click arrow to expand/collapse each correction
- Date shown on each correction
The TWA LauncherActivity crashed with Resources$NotFoundException (0xffffffff)
because android:value with hex color strings is not supported by
androidbrowserhelper — it expects android:resource pointing to color resources.
- Created res/values/colors.xml with all app colors
- Changed AndroidManifest.xml to use android:resource="@color/..."
- Updated styles.xml to reference color resources
The admin model management (enable/disable, custom models, default override)
had persistent rendering issues. Removed the UI panel — models are managed
via the global model selector in the header, which works reliably. Backend
API endpoints for model config are retained for future use.
- Add GET /api/transcribe/status endpoint — returns whether any server
transcription provider (Whisper/AWS/Local) is configured
- Frontend checks status on login via checkTranscribeStatus()
- When no provider configured: recording stops instantly, keeps live
Web Speech API text, shows friendly toast — no error, no upload wait
- Works in encounter, dictation, and SOAP tabs
- App now works fully out-of-the-box with just an AI provider key
Keep 8KB CHUNK_SIZE (proven stable) but replace 10ms setTimeout delay
with a microtask break every 16 chunks. This avoids the AWS SDK
"Deserialization error: inspect {error}.\$response" while still
eliminating the ~1.25s/MB artificial delay from the old 10ms sleep.
- AWS Transcribe: remove 10ms delay between chunks (was adding ~1.25s/MB),
increase chunk size from 8KB to 32KB (AWS max per frame)
- Add detailed timing logs (ffmpeg, streaming, total) for diagnostics
- OpenAI Whisper: use response_format='text' for faster response parsing
- Frontend: show transcription time in toast, request 16kHz sample rate,
increase bitrate to 32kbps Opus (better quality, still small files)
- Return duration in API response for all providers
The r0adkll/sign-android-release@v1 hardcodes build-tools 29.0.3 which
isn't available. Now uses apksigner from the latest installed build-tools
directly with zipalign + sign + verify steps.
The gradlew stub and missing gradle-wrapper.jar caused CI build to fail.
Now uses gradle/actions/setup-gradle@v4 to install Gradle, then generates
wrapper before building. Also renames signed APK and uploads both signed
and unsigned to GitHub Releases.
- APK: Add WAKE_LOCK, BOOT_COMPLETED, ACCESS_NETWORK_STATE permissions
- APK: Disable allowBackup for medical data security
- APK: AudioRecordingService now acquires wake lock, has stop action in notification
- Serve /.well-known/assetlinks.json for TWA domain verification
- Service worker: cache app shell, stale-while-revalidate for assets, network-first for API
- Admin model management: validate model ID format, prevent built-in conflicts, audit toggle actions, prevent disabling all models
- Bump version to v9.0.0, Docker tag to v9
- Full model management UI in admin panel: toggle models on/off, add custom
model IDs (any OpenRouter/Bedrock ID), set admin-configured default model
- /api/models now returns admin-set default model, frontend respects it
- Toggle switch CSS for clean enable/disable UX
- Backend already had the API endpoints, this adds the missing UI
- Add local Whisper (whisper.cpp / faster-whisper) as transcription provider
Set TRANSCRIBE_PROVIDER=local with configurable model size and binary path
- Upgrade all refine/instruction inputs to resizable textareas across
encounter, dictation, hospital course, chart review, well visit, sick visit
- Make AI memory injection flexible: physician preferences and corrections
are now actively applied (not just "formatting reference"), while still
overridable by current prompt instructions
- OIDC callback now passes only ?sso=ok flag, token stays in
httpOnly cookie (prevents token leaking to logs/referrer/history)
- Frontend auth.js uses cookie-based auth for SSO flow
- Add [PHYSICIAN TEMPLATES] boundary markers around physicianMemories
in all 5 generation routes to mitigate prompt injection
- Consistent boundary format across wellVisit, sickVisit, hpi, soap,
hospitalCourse
OIDC/SSO:
- New /api/auth/oidc route with PKCE for secure authorization
- Supports Azure AD, Okta, Keycloak, PocketID, Google, any OIDC provider
- Admin configurable: issuer, client ID/secret, button label
- Option to disable local auth (force SSO only)
- Auto-creates users on first SSO login, links existing by email
- SSO button on login page, hidden until admin enables OIDC
Firefox:
- Show info toast on first recording that live preview requires
Chrome/Edge; server-side transcription still works in all browsers
AWS Transcribe rejects audio event frames over ~16KB with a
cryptic "Deserialization error" / "Your stream is too big" message
hidden inside the SDK error object. Reducing to 8KB per chunk
fixes both Standard and Medical Transcribe streaming.
When Medical Transcribe fails (wrong IAM permissions, region not
supported), automatically falls back to Standard Transcribe instead
of returning an error. Logs the specific failure reason.
- Add try-catch to /nextcloud/disconnect route (was crashing on DB errors)
- Update docker-compose.yml image tag from v7 to v8
- Remove unused SESSION_SECRET from .env.example
Previously only well-visit and sick-visit used saved physician
templates. Now HPI encounter, HPI dictation, SOAP, and hospital
course all fetch getUserMemoryContext() and pass physicianMemories
to the backend so the AI learns from saved templates/preferences.
- Add deduplication logic to prevent Chrome Speech API from repeating
sentences during long recording sessions (all 4 recording modules)
- Enable AWS Transcribe Medical with PRIMARYCARE specialty in .env
- Bump version to 8.0.0
- transcribeAWS.js: convert browser WebM/Opus → PCM 16kHz mono via
ffmpeg before sending to AWS Transcribe — PCM is unambiguous and
most reliable; gracefully falls back to ogg-opus if ffmpeg absent
- Dockerfile: install ffmpeg (apk add ffmpeg) so Docker image works
out of the box with AWS Transcribe
- README: document Amazon Transcribe setup, ffmpeg requirement,
Transcribe Medical specialty options, and env vars reference
- New src/utils/transcribeAWS.js: streams audio directly to AWS
Transcribe without requiring an S3 bucket
- Supports AWS_TRANSCRIBE_MEDICAL=true for Transcribe Medical
(better clinical accuracy: drug names, diagnoses, procedures)
- AWS_TRANSCRIBE_SPECIALTY configures specialty (default PRIMARYCARE)
- transcribe.js auto-selects AWS when AWS_BEDROCK_REGION is set,
or can be forced with TRANSCRIBE_PROVIDER=aws|openai
- Falls back to OpenAI Whisper when AWS is not configured
- Add @aws-sdk/client-transcribe-streaming as optional dependency
- Update .env.example with transcription configuration docs
- Fix word repetition: use sessionFinals pattern so each browser SR
session starts fresh; no overlap when recognition auto-restarts
- Fix HTML injection / '>' parse error: escape < > & in live transcript
innerHTML before inserting speech recognition text
- Add 24 MB blob guard: fall back to live SR transcript if audio file
is too large for Whisper API (long sessions)
- Bump version to 7.0.0, update docker-compose image tag to v7
2026-03-25 20:07:10 +00:00
346 changed files with 46700 additions and 22541 deletions
As of v3, Browser Whisper is **fully self-hosted** with **zero CDN dependencies**. All models and libraries are bundled with the application and served from your own server.
## What Changed
**Before (v2 and earlier):**
- Loaded transformers.js from `cdn.jsdelivr.net`
- Downloaded models from `cdn-lfs.huggingface.co`
- Failed in corporate/clinical networks with firewall restrictions
**Now (v3+):**
- Transformers.js library (v2.6.2) bundled at `/models/transformers.min.js` (760KB)
- Whisper model bundled at `/models/Xenova/whisper-tiny.en/` (42MB)
- Everything served from your own server
- **Works in any network environment** (firewalled, air-gapped, offline)
13. [Adding New Features](#13-adding-new-features)
14. [Resetting Admin Password via Console](#14-resetting-admin-password-via-console)
---
## 1. Project Overview
Pediatric AI Scribe is a clinical documentation platform for pediatric healthcare providers. It uses AI (via OpenRouter, AWS Bedrock, or Azure OpenAI) to generate:
**Key design principle:** Single-page application. All tabs are lazy-loaded HTML components (`/public/components/*.html`). JavaScript modules initialize only when their tab is first activated via the `tabChanged` custom event.
3. **Auth middleware** (`src/middleware/auth.js`) decodes JWT, queries `users` table, attaches `req.user` with `{ id, email, name, role }`
4. **Route handler** processes the request — for AI routes, calls `callAI()` which routes to the configured provider
5. **Database** is accessed via the `pg` driver directly (no ORM). All queries use parameterized placeholders (`$1`, `$2`) to prevent SQL injection
6. **Response** is JSON for API calls, or static files served from `/public`
### AI Providers
Configured via environment variables. The provider is selected at startup in `src/utils/ai.js` using this priority:
1. **AWS Bedrock** — if `AWS_BEDROCK_REGION` is set. HIPAA eligible with BAA. Uses `@aws-sdk/client-bedrock-runtime`. Anthropic models use the native Messages API (`InvokeModel`); all others use the Converse API.
2. **Azure OpenAI** — if `AZURE_OPENAI_ENDPOINT` is set. HIPAA eligible. Uses the OpenAI SDK pointed at your Azure endpoint.
3. **OpenRouter** — default fallback if `OPENROUTER_API_KEY` is set. Routes to 20+ models from various providers. Not HIPAA compliant.
The provider cannot be changed at runtime — it's determined once at startup. To switch providers, update `.env` and restart the container.
### Database Layer
The app uses **raw SQL via the `pg` driver** — no ORM (Sequelize, Prisma, etc.). This is intentional:
- **Simplicity:** Every query is visible and explicit. No magic, no migrations framework, no model definitions to sync.
- **Performance:** No ORM overhead or N+1 query problems.
- **Schema management:**`src/db/database.js` runs `CREATE TABLE IF NOT EXISTS` on startup, plus `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` for migrations. This means the schema is always up-to-date when the app starts.
- **Future ORM migration:** If needed, the queries are standard PostgreSQL and can be wrapped by any ORM. The main work would be defining models and replacing direct `db.get()`/`db.run()` calls.
The `database.js` file exports a helper object (`db`) with convenience methods:
- `db.get(sql, params)` — returns first row or `null`
- `db.all(sql, params)` — returns all rows as array
SMTP_FROM=Pediatric AI Scribe <noreply@example.com>
```
**Note:** If no SMTP is configured, registration auto-verifies and password reset won't work. Configure SMTP or use the console reset method (see Section 14).
---
## 5. Database Schema
All tables are created automatically on first run by `src/db/database.js`. The file runs `CREATE TABLE IF NOT EXISTS` for every table, followed by `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` migrations for upgrades.
### Core Tables
#### `users`
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| email | TEXT UNIQUE | Lowercase |
| password | TEXT | bcrypt hash (cost 12) |
| name | TEXT | Display name |
| role | TEXT | `'user'` \| `'moderator'` \| `'admin'` |
User templates fed into AI generation. `category` is one of: `physical_exam`, `ros`, `encounter_format`, `family_history`, `assessment_plan`, `custom`.
### Learning Hub Tables
#### `learning_categories`
Simple category list with `name`, `slug`, `sort_order`.
Every tab's HTML lives in `/public/components/<tabname>.html`. When a tab button is clicked, `loadComponent()` in `app.js` fetches the HTML, injects it into the tab section, then fires `tabChanged` event.
The body editor in the CMS uses Tiptap 2 (headless, no styling framework). The bundle is pre-built at `/public/vendor/tiptap.bundle.js` and exposes `window.Tiptap = { Editor, StarterKit, Link, Underline, TextStyle, Color }`.
To rebuild the bundle after updating Tiptap packages:
```bash
cat > tiptap-entry.js << 'EOF'
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import Underline from '@tiptap/extension-underline';
import { TextStyle } from '@tiptap/extension-text-style';
import { Color } from '@tiptap/extension-color';
window.Tiptap = { Editor, StarterKit, Link, Underline, TextStyle, Color };
The single function used by all routes. It routes to the correct provider automatically.
```javascript
const { callAI } = require('../utils/ai');
const result = await callAI(
[{ role: 'user', content: 'Generate a note...' }],
{
model: 'google/gemini-2.5-flash', // optional, uses default if omitted
temperature: 0.3, // optional, default 0.3
maxTokens: 4000 // optional, default 4000
}
);
// result = { success: true, content: '...', model: '...', provider: '...', duration: ms }
```
### Bedrock Model Notes
**Inference Profiles:** Most newer models (Anthropic vendor model 4.x, Meta Llama 4, DeepSeek R1, Amazon Nova, Writer) require cross-region inference profiles. These use a `us.` prefix on the model ID (e.g. `us.anthropic.agent-config-sonnet-4-6`). Direct model IDs will return "on-demand throughput not supported" errors.
**Max Output Tokens:** Some models have low output limits (Cohere Command R/R+: 4096, AI21 Jamba: 4096). The `maxOut` field in `models.js` auto-clamps `maxTokens` in `callBedrock()`.
**JSON Sanitization:** Some models (notably vendor model Sonnet 4.6, Opus 4.6) output literal newline characters inside JSON string values. `learningAI.js` includes a `sanitizeJsonString()` function that escapes these before parsing.
### Prompt System
Prompts are defined in `src/utils/prompts.js`. Admins can override any prompt via the Admin panel (`/admin/config/prompts`). Overrides are stored in `app_settings` table and loaded into memory on startup (with 3s grace period for DB readiness).
To add a new prompt:
1. Add a default in `prompts.js`
2. Use `PROMPTS.get('your-prompt-key')` in your route
3. The admin panel will auto-discover it
### AI Generate for Learning Hub
The `src/routes/learningAI.js` file handles all Learning Hub AI generation.
**For presentations:** The AI is prompted to return raw Marp markdown (not JSON). The response is stored in the `body` column. Detection: `content_type === 'presentation'`.
**For articles/quizzes/pearls:** The AI returns JSON:
- `multi` — Multiple select (checkboxes), scoring: all correct chosen AND no incorrect chosen
### PPTX Generation
`POST /admin/learning/generate-pptx` parses Marp markdown (splits on `---`), extracts `#` headings as slide titles, bullet points as content, and uses `pptxgenjs` to create a real `.pptx`. **No Chromium required** — pure Node.js.
### Slide Preview
`POST /admin/learning/preview-slides` uses `@marp-team/marp-core` to render Marp markdown to HTML, then extracts individual `<section>` elements. Returns `{ css, slides[] }`. The frontend renders these one at a time in a full-screen modal with arrow key + swipe navigation.
### Content Display
In the Learning Hub viewer, content `body` is rendered via `sanitizeHtml()` in `learningHub.js`. This function allows a safe subset of HTML tags only (no `<script>`, no `on*` attributes, no `style` attributes except `class`).
# 3. Update production docker-compose.yml to use new tag
```
---
## 12. Known Issues & Security Notes
### Active Known Issues
1. **`nodemailer` HIGH vulnerability** — v6.9.x has an email domain interpretation conflict. Upgrade to `^6.10.0` when available.
2. **`unsafe-inline` in CSP** — `scriptSrc` includes `'unsafe-inline'` to support inline event handlers in HTML components. Should migrate to event listeners and remove this directive.
3. **JWT in localStorage** — Tokens stored in `localStorage` are readable by JavaScript and therefore vulnerable to XSS attacks. A future migration to `httpOnly` cookies would eliminate this risk. See notes in auth.js and Section 6.
4. **`window.prompt()` in `runAiRefineBody`** — Uses browser native prompt, which can be blocked in certain contexts. Should be replaced with an inline input field.
5. **`webdav-learning-path` endpoint** — Sits behind `moderatorMiddleware` but is a user preference that non-moderator users might reasonably need. Consider moving to plain `authMiddleware`.
### Security Hardening Already In Place
- Helmet.js with custom CSP (no external script sources)
- CORS restricted to `APP_URL` in production
- Rate limiting on login (10/15min), register (5/hr), forgot-password (5/hr), resend-verification (3/15min), general API (60/min)
- bcrypt cost 12 for password hashing
- JWT with 7-day expiry
- SQL injection protection: all queries use parameterized `?` / `$1` placeholders
- Dynamic table names validated against an explicit allowlist (`ALLOWED_SLUG_TABLES`)
- User input in HTML contexts goes through `sanitizeHtml()` (tag allowlist, strips `on*` attributes)
- File upload MIME type validated by extension + content type
- Admin/moderator route protection via middleware
---
## 13. Adding New Features
### Adding a New Clinical Tab
1. Create `public/components/mytab.html` with the tab's UI
The Learning Hub AI generator accepts documents via two paths — both produce the same result:
1. **Direct upload** — user selects a file from their computer (up to 20 MB)
2. **Nextcloud WebDAV** — user browses their Nextcloud and picks a file
The flow:
1. `extractText()` in `learningAI.js` detects file type by MIME/extension
2. **PDF:**`pdf-parse` v1.1.1 extracts all text pages into a single string
3. **PPTX/DOCX/TXT:** extracted via appropriate parser or read as UTF-8
4. Text is truncated to **50,000 characters** (~25-30 pages) and sent as context in the AI prompt
5. AI generates structured content (title, HTML body, quiz questions) from the full context
### Supported File Types
| Extension | Handler | Notes |
|-----------|---------|-------|
| `.pdf` | `pdf-parse` | Extracts text only — images, charts, tables are lost |
| `.pptx` | Text extraction from slides | Slide text only |
| `.docx` | Text extraction | Body text only |
| `.txt`, `.md`, `.csv` | Read as UTF-8 | Full content preserved |
### Limits
- **Upload size:** 20 MB (`multer` limit in `learningAI.js`)
- **Context sent to AI:** 50,000 characters (configurable in `buildGeneratePrompt()`)
- **AI response tokens:** 8,000 max (ceiling — model stops when done)
### Why No Vector Embeddings / RAG
Embeddings and RAG (Retrieval Augmented Generation) are unnecessary for this use case:
- **Single document → single generation** — the full text fits in the model's context window
- Most Bedrock models support 100K-200K token inputs — 50,000 chars is well within that
- Embeddings would add complexity (pgvector, chunking, retrieval pipeline) with no benefit
If you later need to **search across hundreds of stored documents** or handle 200+ page PDFs, then consider pgvector + chunked retrieval. For now, the direct approach is correct.
---
## 18. Scalability
### Current Architecture (Single Instance)
The app runs as a single Node.js process. This is fine for a team/department deployment (tens to hundreds of concurrent users).
### What Scales Well Already
- **Stateless JWT auth** — no server-side session store; any instance can validate any token
**The reality about localStorage vs httpOnly cookies:**
> "Unless you're a bank or large enterprise, it doesn't really matter. Focus on preventing XSS, because that's what actually matters... fundamentally, the security benefit of using httpOnly cookies is very minimal. If your site suffers any kind of XSS, it makes it slightly more difficult for an attacker to use the auth token." — Security engineering community consensus
httpOnly cookies prevent token *copying* but not token *use* — an XSS attacker can still make authenticated requests on the user's behalf regardless of where the token is stored.
**If you later want httpOnly cookies:** The infrastructure is already in place (cookie-parser, CORS `credentials:true`). The change is: (1) set cookie on login, (2) remove token from `getAuthHeaders()`, (3) add `/api/auth/logout` to clear cookie. See notes in `auth.js`. This was implemented and reverted in v3.14 — it works but adds CSRF considerations.
**Token lifetime:** Currently 7 days. For higher security, reduce to 1-2 hours and add refresh token rotation.
- `/Medical-Resources` - Opens in Medical Resources folder
- `/Shared/Clinical-Content` - Opens in shared clinical content
- `/` (empty) - Opens at root (default behavior)
**Who Can Use This:**
- Any authenticated user (not just moderators)
- It's a personal preference per user
- Only affects Learning Hub AI file picker
**Why This Exists:**
If you store learning resources in a specific Nextcloud folder, you don't want to navigate there every single time you generate content. Set it once, it remembers.
---
## 🎤 **Browser Whisper Pre-Download**
### Issue You Reported:
"Pre-download models works, stuck at starting download"
### What's Happening:
The download **is actually working** but progress updates are slow because:
1. HuggingFace CDN serves large files (39-244 MB)
2. Progress callbacks are not granular (reported per-file, not per-chunk)
3. Initial ONNX runtime download has no progress tracking
AI-powered clinical documentation platform for pediatric medicine. Generates HPIs, hospital courses, chart reviews, SOAP notes, well/sick visit notes, and developmental milestone assessments from voice recordings or dictation.
Ped-AI is a pediatric clinical documentation, education, and bedside decision-support app. This fork has moved well beyond the original scribe app: it now combines encounter documentation, clinical workflows, Learning Hub CMS, admin controls, MCP-backed clinical assistant integration, Redis-backed operational state, and hardened deployment defaults.
## Features
The app runs as an authenticated Express/Postgres service with a browser frontend and optional integrations for LiteLLM, Vertex/Gemini, AWS, OpenAI-compatible APIs, Nextcloud WebDAV, S3-compatible storage, OpenBao, Redis, OIDC, TOTP, and Cloudflare Turnstile.
## Current Scope
### Clinical Documentation
- **Live Encounter** — record doctor-patient conversations, AI generates structured OLDCARTS HPI
- **Voice Dictation** — dictate narrative, AI cleans and restructures
- **Hospital Course** — paste progress notes, generates prose, day-by-day, organ-system (ICU), or psych format
- **Chart Review / Precharting** — summarize outpatient, subspecialty, and ED notes
- **SOAP Notes** — full SOAP or subjective-only from dictation
- Prompt suggestions backed by Redis operational cache.
- No clinical answer response caching.
- Designed to retrieve from indexed clinical material while keeping provider selection explicit.
### Admin And Security
- Local auth, role-based access, TOTP 2FA, OIDC/SSO, email verification, and optional Turnstile.
- Admin panel for users, settings, prompts, models, logs, and Learning Hub content.
- Audit, API, access, and client-error logs with redaction hardening.
- OpenBao secret loading support at container startup.
- S3-compatible document storage support.
## Removed Browser STT
Browser Whisper has been removed from the runtime. The app should not ship browser Whisper workers, browser-local Whisper model downloads, Transformers.js browser STT, or Browser Whisper setup docs.
Speech-to-text is handled server-side through configured providers such as Google/Gemini, AWS Transcribe, LiteLLM, or OpenAI Whisper. Browser-native Web Speech remains gated behind an explicit user setting when present in the browser.
## Quick Start
### 1. Configure
```bash
cp .env.example .env
docker compose up -d --build
```
Edit `.env` — at minimum set:
The default compose exposes the app on `127.0.0.1:3552` and starts:
```env
AI_PROVIDER=litellm # or openrouter, bedrock, azure, vertex
LITELLM_API_BASE=https://your-litellm.example.com
LITELLM_API_KEY=sk-...
- `pediatric-ai-scribe` for the Node app.
- `pedscribe-db` for Postgres with pgvector.
- `ped-ai-redis` for operational Redis state.
OPENAI_API_KEY=sk-... # for Whisper transcription (if not using LiteLLM STT)
JWT_SECRET=<64-charrandom> # openssl rand -hex 32
DB_PASSWORD=<strongpassword>
APP_URL=https://your-domain.com
```
### 2. Start
Health check:
```bash
docker compose up -d
curl -fsS http://127.0.0.1:3552/api/health
```
App runs on **port 3552**. First user to register becomes admin.
Prometheus metrics are exposed at `GET /metrics` with the `ped_ai_` metric prefix.
### 3. Admin CLI
The first registered user becomes an admin unless registration has already been configured differently.
## Core Environment
Set real values in `.env` before production use.
```env
APP_URL=https://your-domain.example
JWT_SECRET=<64-char-random-secret>
DB_PASSWORD=<strong-database-password>
AI_PROVIDER=litellm
LITELLM_API_BASE=https://your-litellm.example/v1
LITELLM_API_KEY=<key>
TRANSCRIBE_PROVIDER=litellm
LITELLM_STT_MODEL=whisper-1
REDIS_URL=redis://ped-ai-redis:6379
```
Supported text AI providers include LiteLLM, OpenRouter, AWS Bedrock, Azure OpenAI, and Google Vertex AI. Supported STT routing includes Google/Gemini, AWS Transcribe, OpenAI Whisper, and LiteLLM. Supported TTS routing includes Google Cloud TTS, LiteLLM/OpenAI-compatible audio, and ElevenLabs where configured.
Run the reindex command after major Postgres image changes, restoring a dump from another distro, or seeing lookup behavior that suggests collation/index drift.
## Testing
Two layers, both zero-config after the initial setup.
### Unit tests — pure dose math (Node built-in)
Run the Node test suite:
```bash
npm test
```
Runs `node --test test/` against `public/js/calc-math.js` — pure functions for
APLS / Best Guess weight, Parkland, Holliday-Segar 4-2-1, PRAM, Westley,
epi (anaphylaxis vs arrest vs NRP, different concentrations), RSI drugs,
min SBP, ETT sizing, Lund-Browder TBSA. **36 assertions, no dependencies.**
### End-to-end tests — Playwright smoke suite
Runs a headless Chromium against the live app. **128 tests** covering every
calculator tab, every Bedside sub-pill + widget, auth-gated pages (encounter,
well visit, charts, vaccines, catch-up, learning hub, dictation, settings,
FAQ), at **both desktop and mobile (Pixel 5) viewports**.
Run syntax checks for touched files when doing focused backend work:
```bash
# First-time setup: spin up the auth-less test container (port 3553)
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e
node --check server.js
node --check src/routes/transcribe.js
```
# Then run the full suite (runs inside an official Playwright container)
Run the Playwright smoke suite against the e2e compose stack:
```bash
docker compose -f docker-compose.yml -f docker-compose.e2e.yml up -d pediatric-scribe-e2e
npm run e2e
```
The runner script (`scripts/e2e.sh`) uses `mcr.microsoft.com/playwright` so you
don't need Node or browsers on the host.
## Deployment Notes
**Test environment:**
- Put the app behind HTTPS before clinical use.
- Use only AI/STT/TTS providers covered by your BAA and data-processing requirements.
- Configure OIDC/SSO and 2FA for production users.
- Keep `JWT_SECRET`, database credentials, provider keys, S3 keys, SMTP credentials, and OpenBao tokens out of git.
- Treat logs as sensitive operational data even with redaction enabled.
- Use the Caddy/reverse-proxy layer to expose only intended public routes.
- `pediatric-ai-scribe` (port 3552) — your normal app
- `pediatric-ai-scribe-e2e` (port 3553) — identical image, but with
`TURNSTILE_SECRET_KEY=""` and `SMTP_HOST=""` so Playwright can log in
without a bot challenge. Shares the same Postgres + pgdata volume.
- Test user: `e2e-user@ped-ai.test` (auto-verified on first register)
- Harness page: `public/e2e-harness.html` loads the calculators component
without the auth wall for smoke tests that don't need a logged-in session.
- `test-failed-1.png` — screenshot at the point of failure
- `trace.zip` — full action trace (replay with `npx playwright show-trace`)
- `error-context.md` — DOM snapshot and console logs
- `docs/ARCHITECTURE.md` for the current system map and service boundaries.
- `docs/DEVELOPMENT.md` for day-to-day code-change workflow.
- `docs/SCALING.md` for scaling priorities and readiness work.
- `docs/CLINICAL_ASSISTANT.md` for MCP-backed assistant behavior and safety rules.
- `docs/MODULE_CONVENTIONS.md` for CommonJS, ESM, globals, and rendering rules.
- `docs/architecture.md` for high-level architecture.
- `docs/api-reference.md` for API routes.
- `docs/authentication.md` for auth, OIDC, and security configuration.
- `docs/ai-providers.md` for model/provider setup.
- `docs/speech.md` for server-side STT/TTS setup.
- `docs/learning-hub.md` for the CMS and education workflow.
- `docs/configuration.md` for environment variables.
- `docs/deployment.md` for production deployment.
- `docs/mobile-build.md` for the Capacitor wrapper and app-store build notes.
- `docs/logic/README.md` for the deeper code walkthrough.
Everything but the specs and config is gitignored under `e2e/`.
Some deep `docs/logic/` files still describe historical implementation details. Prefer runtime code and tests when documentation conflicts with current behavior.
**Files:**
## Clinical Safety
- `e2e/tests/bedside-smoke.spec.js` — 26 tests for the Bedside module
- `e2e/tests/top-calculators.spec.js` — 27 tests for BP / BMI / Growth /
For auth-gated routes, use the login fixture in `auth-gated-smoke.spec.js`
as a template — it caches the token at module scope so you don't hit the
login rate-limit.
Ped-AI is documentation and education support software. It does not replace clinical judgment, local policy, medication verification, or attending review. Validate generated notes, calculations, and recommendations before use in patient care.
**A:** Browser Whisper and Server (Gemini/Whisper) are equally accurate. Web Speech is slightly less accurate.
### Q: Which is fastest?
**A:** Server transcription (~1s) > Web Speech (real-time) > Browser Whisper (2-10s)
### Q: Which is most private?
**A:** Browser Whisper (100% offline) > Server (with BAA) > Web Speech (not private)
### Q: Can I use multiple at once?
**A:** No. Priority: Web Speech > Browser Whisper > Server (whichever is enabled first)
### Q: What if transcription fails?
**A:** Automatic fallback chain:
1. Browser Whisper (if enabled)
2. Falls back to Server (if configured)
3. Falls back to live transcript (if available)
### Q: Is Browser Whisper really offline?
**A:** Yes! Models are bundled in the Docker image. After the page loads once, transcription works with zero network access.
### Q: Does Web Speech work offline?
**A:** No. It requires internet to send audio to cloud servers.
### Q: Can I train/customize the models?
**A:** No. Browser Whisper uses pre-trained models. Server transcription uses cloud models. No custom training available.
---
## Troubleshooting
### Browser Whisper stuck at "Initializing"
- **Cause:** Models not loaded or network blocked during initial download
- **Fix:** See BROWSER_WHISPER_TROUBLESHOOTING.md
### Server transcription returns "No provider"
- **Cause:** API keys not configured
- **Fix:** Set environment variables in `.env`
### Web Speech says "Not supported"
- **Cause:** Browser doesn't support Web Speech API
- **Fix:** Use Chrome, Edge, or Safari
### Transcription is slow
- **Browser Whisper:** Try switching to "Tiny" model
- **Server:** Check API provider status
- **Web Speech:** Check internet connection
---
## Best Practices
### Clinical Documentation
1. Use Browser Whisper for all patient data
2. Enable audio backups (automatic in v2)
3. Keep recordings under 5 minutes for faster processing
4. Use "Tiny" model for quick notes, "Base" for detailed documentation
### Personal Use
1. Web Speech for quick, informal notes
2. Browser Whisper for anything you want private
3. Server for long recordings
### Performance Optimization
1. Pre-download Browser Whisper model before first use
2. Use shorter clips (30-60 seconds) for fastest results
3. Clear browser cache if models seem corrupted
---
## Summary
| Need | Recommendation |
|------|---------------|
| Clinical/HIPAA | Browser Whisper (offline) |
| Fast transcription | Server (Vertex AI) |
| Real-time feedback | Web Speech (non-clinical only) |
| Maximum privacy | Browser Whisper |
| Zero cost | Browser Whisper |
| Long recordings | Server (faster for 5+ min clips) |
| Offline use | Browser Whisper |
**Default recommendation:** Browser Whisper for 95% of use cases. It's private, accurate, free, and offline. Only use alternatives when you have specific needs for speed or real-time feedback.
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
This document is the current high-level map for Ped-AI. It is intentionally shorter and more operational than the older deep-dive files under `docs/logic/`.
## System Shape
Ped-AI is a self-hosted Express application with a browser frontend, PostgreSQL storage, Redis operational state, LiteLLM model routing, and optional MCP-backed clinical retrieval.
The Clinical Assistant is a retrieval-grounded assistant for pediatric clinical reference questions. It is not the same as the app's note-generation/HPI workflow.
| LiteLLM | model routing and provider abstraction |
## Request Flow
```txt
User asks a question
-> browser posts to Ped-AI
-> Ped-AI calls MCP `nc_semantic_search`
-> MCP returns source excerpts and metadata
-> Ped-AI builds an answer prompt with source constraints
-> LiteLLM model returns answer text
-> browser renders answer and source cards
```
## Source Rules
- Prefer MCP `file_path` basename for displayed source titles when present.
- Do not relabel one source as another requested source.
- If the user names a source and retrieval does not return it, say that before using other sources.
- Use citations only for returned source numbers.
- Unknown citation numbers should remain plain text instead of being guessed.
## Table And Markdown Rendering
LLM output is not guaranteed to be valid markdown. The browser renderer defensively handles common problems:
- adjacent citation clusters,
- missing closing bracket in narrow citation cases,
- smashed bullet lists,
- inline headings,
- malformed pipe tables,
- bare source numbers in source/citation table columns,
- orphan markdown emphasis markers,
- code blocks that must not be modified.
Renderer fixes must be narrow. Do not add broad repairs that turn arbitrary clinical numbers into citations.
## Image Routing
Table lookup requests should stay in retrieval flow.
Examples that should use retrieval:
```txt
show me the table
show me Table 13.1
summarize the developmental table
```
Explicit visual creation/display requests can use image flow.
Examples:
```txt
create an infographic
generate a diagram
show me the image/figure
```
## Caching Policy
Clinical answer response caching is intentionally disabled. Redis can support prompt suggestions and operational metadata, but final answers should be generated from current retrieval context.
## Settings
Important settings include:
| Setting | Purpose |
|---|---|
| `clinical_assistant.chat_model` | Chat model used for answers |
| `clinical_assistant.image_model` | Image model used for explicit image generation |
| `clinical_assistant.search_limit` | Number of MCP results requested |
| `clinical_assistant.context_chars` | Context characters requested from MCP |
| `test/` | Node test suite and frontend module regression tests |
## Change Workflow
1. Read the relevant route, utility, frontend module, and tests before editing.
2. Make the smallest correct change.
3. Add or update a regression test when changing clinical rendering, model routing, auth, settings, or source handling.
4. Run focused tests first if available.
5. Run `npm test` before deploy or commit.
6. Deploy with Docker only after tests pass.
7. Verify `/api/health` after deploy.
## Clinical Assistant Changes
Clinical Assistant changes should usually include tests because small rendering or prompt changes can affect clinical trust.
High-risk areas:
- citation linking,
- table rendering,
- source title cleanup,
- named-source provenance rules,
- image intent detection,
- MCP result normalization,
- provider/model selection.
When a real answer renders badly, save a de-identified example as a fixture or direct test input. Do not make broad global repairs that convert arbitrary numbers into citation links.
## Frontend Rendering Rules
Use `textContent` for plain text. Use `innerHTML` only for static templates, sanitized markdown, or HTML built entirely from escaped values.
If a dynamic value enters an HTML string, escape it at the point of insertion. If it is an attribute value, escape quotes too.
## Deployment Checks
After deployment:
```bash
curl -fsS http://127.0.0.1:3552/api/health
docker compose ps pediatric-scribe
```
If the browser still shows old frontend behavior, force-refresh or check the injected `BUILD_ID` asset query string.
## Documentation Expectations
Keep docs close to operational truth. If a behavior changes, update the most specific doc in the same change. Prefer short, current docs over long historical explanations.
This document describes how Ped-AI should scale without becoming harder to debug or maintain.
## Current Scaling Model
Ped-AI is currently a single app container backed by PostgreSQL and Redis. That is acceptable for self-hosted use, but the code should keep moving toward a shape where multiple app containers can run safely.
```txt
reverse proxy
-> pediatric-ai-scribe replica 1
-> pediatric-ai-scribe replica 2
-> shared PostgreSQL
-> shared Redis
-> LiteLLM
-> MCP
```
## Horizontal Scaling Requirements
| Requirement | Why It Matters |
|---|---|
| Session state in PostgreSQL/Redis | Any app replica can handle the next request |
| No clinical state only in memory | Restarting or scaling containers should not lose required state |
| Shared uploads/storage if files grow | Local container disk does not scale across replicas |
| Idempotent migrations | Deploying more than one app container should not corrupt schema state |
| Request timeouts | Slow providers should not exhaust Node workers |
| Queue for slow jobs | Long work should not block interactive requests |
| Readiness endpoint | Load balancer should only send traffic to ready replicas |
## What Can Stay In Memory
Small process-local caches are acceptable when they are optional and short-lived.
Examples:
- settings cache with short TTL,
- provider model metadata cache,
- static configuration derived at boot.
Do not store required user workflow state only in memory if the action must survive restart or run across replicas.
## Redis Use
Redis is appropriate for:
- prompt suggestion pools,
- rate-limit coordination if needed,
- queues and job status,
- short-lived provider metadata,
- operational locks.
Redis should not be used for final clinical answer response caching. Clinical answers should be generated live from current retrieval context.
## Queue Candidates
Consider moving these to a queue when latency or concurrency becomes a problem:
- long transcription jobs,
- file import/export,
- Learning Hub AI generation from large files,
- image generation,
- bulk document operations,
- provider metadata refresh,
- long-running admin maintenance actions.
BullMQ with Redis is a natural fit if a queue is added.
## Readiness And Health
Keep `/api/health` fast and simple for liveness.
Add a separate readiness endpoint when scaling:
```txt
GET /api/ready
```
It should check:
- PostgreSQL query works,
- Redis ping works if Redis is required for this deployment,
- core settings can be read,
- MCP health is reachable if Clinical Assistant is enabled,
- LiteLLM metadata or configured model endpoint is reachable if AI features are enabled.
## Database Scaling
Priorities:
- confirm indexes on hot user/session/settings/log tables,
- keep migrations explicit and reversible where practical,
- monitor slow queries,
- cap admin log queries with safe limits,
- keep audit/log writes batched where possible,
- avoid long transactions around provider calls.
## Provider Scaling
LiteLLM and MCP can become the bottlenecks before Ped-AI does.
Track:
- LiteLLM request latency,
- LiteLLM error rate by model,
- MCP search latency,
- MCP timeout/error rate,
- queue depth if async jobs are added,
- Postgres connections,
- app container memory and event-loop delay.
## Scaling Order
1. Add request IDs across browser, Ped-AI, MCP, and LiteLLM calls.
2. Add `/api/ready` for dependency readiness.
3. Ensure sessions and settings are not process-local.
4. Add a queue for slow jobs if interactive requests block.
5. Run a second app replica behind the reverse proxy in a staging/test environment.
6. Add metrics and alerts around latency, errors, and resource saturation.
| `pedscribe-db` | `pgvector/pgvector:pg16` | 5432 | not exposed |
| `ped-ai-redis` | Redis | 6379 | not exposed |
Named volumes: `pgdata` (database), `scribe-logs` (filesystem audit logs).
Named volumes: `pgdata` (database), `scribe-logs` (filesystem audit logs), and Redis data if persistence is enabled by compose.
Application health-check polls `GET /api/health`.
A reverse proxy terminates TLS and forwards to `127.0.0.1:3552`. The app is
@ -149,3 +160,11 @@ never bound to a public interface directly.
Precached on install: `index.html`, core JS, main stylesheet, login component.
Cleared on logout (`caches.keys() → caches.delete()`).
## Clinical Assistant And MCP
The clinical assistant can call an external MCP-backed retrieval service. Ped-AI remains responsible for the user workflow, provider selection, prompts, and display. MCP remains responsible for Nextcloud access, indexing, retrieval, and vector search. Clinical answer response caching is intentionally disabled; Redis is used for operational metadata and prompt suggestions, not answer reuse.
## Speech
Browser Whisper and browser-local Whisper model downloads are removed from runtime. Speech-to-text routes through LiteLLM; upstream provider choice belongs in LiteLLM config. Browser-native Web Speech remains available only when explicitly enabled by user settings and browser support.
| `AI_PROVIDER` | `openrouter` / `bedrock` / `azure` / `vertex` / `litellm`. Auto-detected by credential presence if unset. |
| `AI_PROVIDER` | `openrouter` / `bedrock` / `azure` / `vertex` / `litellm`. If unset, the startup loader uses configured credentials and the last initialized provider in Bedrock → Azure → Vertex → LiteLLM order wins; otherwise OpenRouter is the default. |
| `danielonyejesi/pediatric-ai-scribe-v3:latest` | App container. Published by CI on every tag push (multi-arch: `linux/amd64` + `linux/arm64`). Pull directly or build from source. |
| `danielonyejesi/pediatric-ai-scribe-v3:latest` | App container. Published by CI on every tag push where configured. Pull directly or build from source. |
- Disk: Postgres size scales with audit log retention, saved encounters, documents, and Learning Hub content.
- CPU: idle load negligible; AI calls are network-bound on the LLM provider side.
## Production checklist
@ -141,14 +142,15 @@ Container marked unhealthy after 5 failures.
- Turnstile keys set for public-facing deployments
- Reverse proxy serves valid TLS certs
- Postgres dump scheduled off-host
- Log retention and backup policy covers `audit_log`, `api_log`, `access_log`, and filesystem `scribe-logs`
## CI / CD
Four workflows fire on tag push:
On push (and tag push), these workflows run (depending on runner/site):
| Workflow | Output | Runtime |
|---|---|---|
| `android-release.yml` | Signed APK attached to the GitHub release | ~8 min |
| `.forgejo/workflows/android-apk.yml` | Signed APK attached to the Forgejo release, plus optional Google Play internal track upload | ~8 min |
| `docker-publish.yml` | Multi-arch image (amd64 + arm64 via native runners) on Docker Hub | ~4 min |
| `build-apk.yml` | Legacy TWA APK (optional second artifact) | ~2 min |
@ -162,6 +164,7 @@ Triggered by `auto-version.yml` (reads commit messages, bumps + tags via
|---|---|---|
| App | 3000 | 127.0.0.1:3552 |
| Postgres | 5432 | not exposed |
| Redis | 6379 | not exposed |
Change the app's external port by editing the `ports:` mapping in
`docker-compose.yml`.
@ -174,6 +177,8 @@ Change the app's external port by editing the `ports:` mapping in
via `src/utils/auditQueue.js`, drained on SIGTERM.
4. Loki (if `LOKI_URL` set) — pushed fire-and-forget per event.
A central Prometheus/Loki/Grafana stack can also scrape `GET /metrics` and collect Docker logs with Promtail. Keep direct Loki push enabled only for structured application events that are useful for compliance and operations.
The Admin embedding search reads LiteLLM `/model/info` and only shows models with `model_info.mode = "embedding"`. Do not add app-side built-in Vertex/OpenAI embedding lists; configure those choices in LiteLLM.
The local LiteLLM instance currently exposes examples such as `openai-text-embedding-3-large`, `openai-text-embedding-3-small`, and Mistral embedding models. Dimensions are read from LiteLLM metadata when available.
## 🔧 Setup Steps
## Setup Steps
### 1. Database Migration
@ -113,12 +98,12 @@ Response:
"total": 50,
"withEmbeddings": 50,
"missing": 0,
"model": "vertex_ai/text-embedding-005",
"dimensions": 768
"model": "openai-text-embedding-3-large",
"dimensions": 3072
}
```
## 🔍 Using Semantic Search
## Using Semantic Search
### Keyword Search (existing)
```bash
@ -144,12 +129,12 @@ GET /api/learning/search/hybrid?q=fever management
```
Combines keyword + semantic for best results. Automatically deduplicates and ranks by relevance.
## 🔬 How It Works
## How It Works
1. **Content Creation/Update**:
- Text is extracted from `title`, `subject`, and `body` (HTML stripped)
- Sent to embedding model (Vertex AI)
- Returns 768-dimensional vector
- Sent to the configured LiteLLM embedding model
- Returns an embedding vector
- Stored in `learning_content.embedding` column
2. **Semantic Search**:
@ -164,35 +149,23 @@ Combines keyword + semantic for best results. Automatically deduplicates and ran
- Deduplicates by content ID
- Sorts by relevance score
## 💰 Cost Estimate (Vertex AI)
## Cost Estimate
**Titan Text Embeddings (AWS) pricing:**
- ~$0.10 per 1M tokens
- Average article: 2,000 words (~2,700 tokens) = $0.00027
- 1,000 articles: ~**$0.27 one-time**
- Search queries: ~500 tokens = $0.00005 per query
Embedding cost depends on the upstream configured in LiteLLM.
This file is a practical operator-oriented overview of major Ped-AI features. It intentionally describes the current fork, not historical browser Whisper behavior.
## Clinical Documentation
Ped-AI generates pediatric clinical notes from typed input, dictation, or recorded audio. Major workflows include live encounters, dictation cleanup, sick visits, well visits, SOAP notes, hospital courses, chart review, ED documentation, and developmental milestones.
Model selection is available per task where the UI exposes a tab-level selector. Admin defaults provide the baseline model and user/task choices can override that baseline.
Generated notes can expose post-note helper panels. Billing suggestions and don't-miss review are clinician-facing. Patient education handouts are parent-facing drafts generated from the edited note, with optional diagnosis, medication, and preferred-language context. The clinician must verify the handout before sharing it.
## Phone Extensions And Pagers
The bedside tools include a per-user phone extension and pager directory. Entries support active/trash views, search, soft delete/restore, permanent purge, ZIP export, and JSON/ZIP import. Import preview flags exact active duplicates, exact trashed matches that can be restored, and possible duplicates before committing changes.
## Speech
Final transcription is server-side through LiteLLM. Configure upstream STT providers in LiteLLM rather than in Ped-AI.
Browser-native Web Speech is only an explicit opt-in preview path. It is not the final clinical transcript and may use browser-vendor cloud services.
Browser Whisper and browser-local model workers are removed. Do not expect a pre-download model button, public Whisper worker, or bundled Xenova model path.
## Text To Speech
The voice preview button calls LiteLLM TTS and plays the returned audio in the browser. If preview is silent, check that a LiteLLM voice is selected, the gateway is configured, the user is authenticated, and browser autoplay has not blocked playback.
## Learning Hub
Learning Hub is both a learner-facing content area and an admin/moderator CMS.
- Articles and pearls render sanitized content.
- Quizzes support single-answer, multi-select, and true/false questions.
- Presentations use Marp-style markdown with preview and PPTX export.
- AI generation can use topic text, uploaded source files, or connected Nextcloud WebDAV files.
- Categories can organize content without deleting the content when category assignments change.
## Nextcloud WebDAV
Users can connect a Nextcloud account with an app password. Learning Hub AI generation can browse files from the connected WebDAV account, and users can set a default browse path to avoid repeatedly navigating to the same clinical content folder.
## Documents And S3
Document upload is optional and depends on S3-compatible storage configuration. Treat uploaded documents as PHI unless you have a separate deployment reason not to.
## Audio Backups
Audio backups exist to recover failed transcription attempts.
- They are created when transcription fails.
- They are encrypted before persistent storage.
- They expire automatically.
- Users can retry or delete them from Settings.
## Admin Panel
Admins can manage users, roles, registration, security settings, model defaults, prompts, logs, and Learning Hub content. Production deployments should enable SSO/2FA and restrict admin access.
## Feature Status
| Feature | Status | Notes |
|---|---|---|
| Clinical note generation | Active | Provider depends on `AI_PROVIDER`. |
| Server transcription | Active | Google/AWS/LiteLLM/OpenAI paths. |
@ -52,7 +52,7 @@ This is the highest-impact improvement for adoption but also the most complex to
### 5. Offline Mode
**Current state:** The app requires an internet connection for AI generation and cloud-based transcription. Browser Whisper works offline for transcription only.
**Current state:** The app requires configured server-side providers for AI generation and final transcription. Browser Whisper has been removed from the runtime.
**Improvement:** Add a local AI model option (e.g., a small medical LLM running on the device or local server) so the entire workflow — record, transcribe, generate note — can happen without any network calls. This would be valuable for:
- Rural clinics with unreliable internet
@ -74,9 +74,9 @@ Each specialty has unique documentation requirements that could be addressed wit
### 7. Billing Code Suggestions
**Current state:** The well visit tab includes some billing code references.
**Current state:** Post-note billing suggestions are active as clinician-facing helper panels on supported note outputs.
**Improvement:** Automatically suggest ICD-10 and CPT codes based on the generated note content. After the AI generates a note, it could analyze the diagnoses, procedures, and visit complexity to suggest appropriate billing codes. This saves time on coding and reduces missed charges.
**Further improvement:** Improve payer-specific rules, add institution-specific favorites, and add export formats that match common EHR coding workflows.
### 8. Quality Metrics Dashboard
@ -85,7 +85,7 @@ Each specialty has unique documentation requirements that could be addressed wit
**Improvement:** Add a dashboard showing:
- Average note generation time by type
- Most-used AI models and their accuracy (based on how often users edit the output)
- Transcription accuracy metrics (if corrections are tracked)
- Transcription quality metrics from explicit user feedback or retry outcomes
- Usage patterns by time of day and day of week
- Cost tracking across AI providers
@ -93,9 +93,9 @@ This would help administrators optimize model selection and identify training op
### 9. Patient Education Materials
**Current state:** The Learning Hub serves educational content to physicians.
**Current state:** Patient education handouts are active as post-note helpers. Generated notes can open a Handout panel that creates a parent-facing plain-text draft from the clinician note, with optional diagnosis, medication, patient age, and preferred language context. The Learning Hub remains the physician-facing education/CMS area.
**Improvement:** Add a patient-facing education module that generates age-appropriate handouts based on the diagnosis. For example, after generating a note for a child with asthma, the app could produce a parent-friendly handout explaining the diagnosis, medications, and when to seek emergency care — in the parent's preferred language.
**Further improvement:** Add handout templates, saved handout history, institution-approved language libraries, and printable/PDF export.
### 10. Multi-Language Support
@ -140,7 +140,7 @@ This mirrors the real workflow in training institutions and group practices.
### 14. Template Library
**Current state:** Physician memories and corrections provide some personalization.
**Current state:** Physician templates and prompt preferences provide per-user personalization. Legacy correction-learning rows may exist but are no longer active behavior.
**Improvement:** Add a shared template library where physicians can create, share, and browse note templates:
- "My asthma follow-up template"
@ -182,7 +182,7 @@ Compared to existing medical scribes and documentation tools:
- **Pediatric-specific** — prompts, calculators, milestones, and growth charts designed for children, not adapted from adult tools
- **Self-hosted** — runs on your own infrastructure, not a SaaS that holds your data
- **Provider-agnostic** — works with any AI provider (swap between them without changing anything)
- **Overview** — what this part is and why it exists
- **User flow** — what the physician does and sees
- **Data flow** — what HTTP calls happen, what the server does
- **File map** — which files do what
- **Key design decisions** — *why* it works the way it does
- **High-risk zones** — what requires small, tested changes
- **How to extend** — concrete recipes for adding a new X
When a doc mentions a high-risk zone, changes should be small, well-tested, and
directly tied to the requested behavior. Current high-risk areas:
| Zone | Why |
|---|---|
| `public/js/encounters.js` save/load/idempotency | Save/version/idempotency logic has been carefully tuned; refactors keep silently breaking it. |
| Voice/STT plumbing (`audioBackup.js`, `speechRecognition.js`, `voicePreferences.js`, `transcriptionSettings.js`, recorder paths in each clinical tab) | Recording UX has been hardened against many edge cases; refactor only with smallest-diff bug fixes. |
| Validated clinical formulas (BP percentile LMS, Fenton 2013, bilirubin AAP 2022, Bhutani, APLS / Best-Guess weight, PE Guide SCALES) | Validated against peditools / AAP tables; modifying without test vectors risks miscoding patient care. |
| Auth + crypto (`crypto.js`, `passwords.js`, `sessions.js`, `auth.js`, `oidc.js`) | Security; changes without security review are unsafe. |
| MDM rubric in `PROMPTS.edFinalize` | Load-bearing for billing accuracy; trim only with explicit AMA/coding source citation. |
## Cross-cutting topics
A few topics span multiple docs. Use these as your jump-off points:
| Topic | Where to look |
|---|---|
| Frontend globals, ES modules, and lazy tab loading | architecture.md |
This doc summarizes the current AI/STT/TTS pipeline without line-number
citations. For exact behavior, read `src/utils/ai.js`, `src/routes/transcribe.js`,
`src/routes/tts.js`, and the relevant frontend scripts.
## Text Generation
All text-generation routes call `callAI(messages, options)` from
`src/utils/ai.js`.
Supported providers:
- OpenRouter.
- AWS Bedrock.
- Azure OpenAI.
- Google Vertex AI.
- LiteLLM or another OpenAI-compatible gateway.
`AI_PROVIDER` can explicitly choose the provider. If unset, the startup loader
initializes configured clients and the final active provider follows the current
load order described in [`../ai-providers.md`](../ai-providers.md). Route
handlers do not call provider SDKs directly.
## Model Allowlist
`callAI()` rejects model IDs outside the active server-side allowlist unless a
specific admin test path opts out. The allowlist is assembled from built-in
provider models, `models.disabled`, and `models.custom` in `app_settings`.
The default model comes from the configured provider/model settings. Admins can
set defaults and custom models from the Admin Panel.
## Prompt Safety
Clinical routes should build prompts with:
- canonical templates from `src/utils/prompts.js`
- optional DB prompt overrides through `app_settings` keys `prompt.*`
- `INJECTION_GUARD`
- `wrapUserText(label, text)` around user-derived text
User-derived text includes transcripts, dictated notes, pasted chart data,
refine instructions, template preferences, and patient education source notes.
## User Templates
`getUserMemoryContext()` fetches `/api/memories/context` and passes the returned
template/preference context as `physicianMemories`. Server routes wrap that block
as low-priority style/template context. `custom` memories and legacy
`correction_*` rows are not prompt context.
## Speech-To-Text
`POST /api/transcribe` accepts one audio file up to 25 MB. Provider selection:
- explicit `TRANSCRIBE_PROVIDER=litellm`, or
- auto mode when `LITELLM_API_BASE` is configured.
Direct Google, AWS, local Whisper, and OpenAI Whisper branches are not part of the runtime. Browser Whisper/browser-local model downloads are also absent.
## Browser Web Speech
Browser-native Web Speech is an explicit opt-in preview. It may rely on browser
vendor cloud services and must not be treated as the final clinical transcript.
Final transcription should come from the configured server-side STT provider.
## Audio Backup
Failed transcription attempts can create encrypted 24-hour audio backups through
`src/routes/audioBackups.js`. The user can retry or delete backups from
Settings. Browser fallback storage is only for cases where the server cannot
store the failed recording.
## Text-To-Speech
`POST /api/text-to-speech` returns audio from LiteLLM and marks the LiteLLM
model in `X-TTS-Provider`. Voices are LiteLLM-compatible strings configured by
`LITELLM_TTS_VOICES`.
## Post-Note Helpers
Generated note outputs can expose helper panels:
- `refineDocument` for editing/refining/shortening generated text.
- `suggestBillingCodes` for clinician-facing ICD/CPT suggestions.
- `suggestDontMiss` for clinician-facing safety review.
- `attachPatientEducation` for parent-facing handout drafts.
These helpers are authenticated API-backed actions. They should treat the edited
note as the source of truth and keep the clinician in the review loop.
| Core calculators | `public/js/calc-math.js`, `public/js/calculators.js`, `public/js/drugs-loader.js` | `calc-math.js` keeps a dual browser/CommonJS wrapper so tests can `require()` the same formulas used in browser. |
| Drug data | `public/data/drugs.json` | Loaded by `drugs-loader.js`; fallback constants remain in some UI modules for resilience. |
| PE Guide | `public/js/peGuide.js`, `src/routes/peGuide.js`, `public/components/pe-guide.html` | Structured PE reference plus AI narrative endpoint. |
| Well-visit schedule | `public/js/wellVisit/scheduleData.js`, `public/data/well-visit/schedule.json`, `public/js/wellVisit.js` | Schedule JSON is loaded and applied to legacy globals used by the UI. |
| Milestones | `public/js/milestonesData.js`, `public/js/milestones.js`, `src/routes/milestones.js`, `src/routes/adminMilestones.js` | DB-backed milestone data with static fallback. |
## Calculator Accuracy Rule
Do not change clinical formulas or reference data without tests. Add or update
test vectors first, then change data/code, then run `npm test`.
Capacitor 6 wrapper. Android only today; iOS project exists but requires macOS
+ Xcode to produce an `.ipa`.
Capacitor 6 wrapper around the hosted Ped-AI web app. The launcher defaults to `https://app.pedshub.com`, lets the user change the server URL, and stores that URL locally. Android is buildable on Linux. The iOS project exists but requires macOS and Xcode to produce an `.ipa`.
This is not a separate native clinical app. The native shell provides WebView hosting, microphone permission plumbing, secure storage, and mobile packaging for the same authenticated web app.
## One-time setup
@ -25,8 +26,12 @@ npx cap open android
## CI build (preferred)
Tag-triggered. Push any `vX.Y.Z` tag → `.github/workflows/android-release.yml`
builds a signed APK on a GitHub runner and attaches it to the matching release.
Push-triggered. Any push to `main`/feature branches and any `vX.Y.Z` tag push
→ `.forgejo/workflows/android-apk.yml` builds a signed APK on the Forgejo
runner.
Tagged builds additionally publish the artifact to the matching Forgejo release
as `pedscribe-<tag>.apk` so Obtainium can track updates.
Required repo secrets (set once, via Settings → Secrets and variables → Actions
or `gh secret set`):
@ -35,6 +40,14 @@ or `gh secret set`):
- `ANDROID_KEYSTORE_PASSWORD`
- `ANDROID_KEY_ALIAS` — `pedscribe`
- `ANDROID_KEY_PASSWORD`
- `GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_B64` — base64 of your Google Play service
account JSON (optional). If present, the same tag build also runs `bundleRelease`
and uploads the AAB to Play's `internal` track.
Optional Play Store flow:
- Service account must have permissions to edit releases on the app in Play.
- Build task is `bundleRelease`, tracked as `com.pedshub.scribe`.
- Upload lane is `fastlane/android publish_internal` (under `mobile/android/fastlane`).
| **Local Whisper** | Spawns `whisper.cpp` or `faster-whisper` via `WHISPER_BINARY`. Fully offline. Model sizes `tiny`/`base`/`small`/`medium`/`large`. | N/A (nothing leaves host) |
| **OpenAI Whisper** | `whisper-1` via `/v1/audio/transcriptions`. Medical-context prompt prepended: `"Medical patient encounter. Pediatric."` | No |
| **LiteLLM** | Inline audio via LiteLLM's `chat.completions` endpoint (not the `/audio/transcriptions` path). Model from `LITELLM_STT_MODEL`. | Depends on LiteLLM backend |
| LiteLLM | Sends audio through the configured LiteLLM `/audio/transcriptions` backend. | Depends on the selected upstream. |
## Browser Whisper (fully offline)
Browser Whisper and browser-local Whisper workers are not part of the runtime. Do not add browser model downloads or Transformers.js STT back into the public app.
Runs entirely in the browser via WebAssembly. Zero network. Suitable when
no external transcription is acceptable.
## Web Speech Preview
- Runtime: `@xenova/transformers` (WASM).
- Models (bundled in the Docker image, no CDN fetch):
- `whisper-tiny.en` — 39 MB
- `whisper-base.en` — 74 MB
- `whisper-small.en` — 244 MB
- Executes in a dedicated Web Worker; UI thread is never blocked.
- Models cached in IndexedDB after first load.
- Per-user toggle. On browser transcription failure, the client falls back to
server-side transcription without user intervention.
Browser-native Web Speech can show interim text when the user explicitly enables it. It is browser/vendor dependent, may send audio to browser-provider cloud services, and should not be treated as the final clinical transcript.
## Live speech preview
## Text To Speech
Chrome / Edge `webkitSpeechRecognition` streams interim text to the UI during
recording. Used for real-time preview only — **not** for final transcription.
The actual transcript comes from the configured STT provider after recording
header identifies the provider used. 5000-character limit per request. Each
user may override via `users.tts_voice`; admin-wide default via `tts.voice`.
### Providers
`POST /api/text-to-speech` returns audio from LiteLLM `/audio/speech`. The `X-TTS-Provider` response header identifies the LiteLLM model used. Requests are limited to 5000 characters.
Ped-AI currently supports server-side transcription through LiteLLM plus an explicit browser Web Speech preview option. Browser Whisper was removed and should not be offered in settings, documentation, public workers, or model download scripts.
## Recommended Clinical Setup
Route STT through LiteLLM and configure the compliant upstream in LiteLLM.
| Need | Recommended provider |
|---|---|
| Server STT | LiteLLM with a compliant upstream. |
| Real-time draft preview | Browser Web Speech only with explicit user opt-in and privacy warning. |
Auto-detect uses LiteLLM when `LITELLM_API_BASE` is configured. Direct Google, AWS, local Whisper, and OpenAI Whisper branches are not part of the app runtime.
## Configuration
```env
TRANSCRIBE_PROVIDER=litellm
LITELLM_API_BASE=https://your-litellm.example/v1
LITELLM_API_KEY=<key>
LITELLM_STT_MODEL=local-parakeet-v3
```
## Failure Handling
- Server transcription failures can create encrypted audio backups for retry.
- Users can retry or delete failed backups from Settings.
- Web Speech interim text is not a substitute for a server transcription response.
## Removed Paths
These should remain absent unless the project intentionally reintroduces browser-local STT with a new design review:
- `public/js/browserWhisper.js`
- `public/js/whisperWorker.js`
- `public/js/whisperWorkerV2.js`
- `public/models/Xenova/*`
- Browser Whisper setup/troubleshooting docs
- Whisper model download scripts for public browser models
Native mobile wrapper for Pediatric AI Scribe using Capacitor. Provides background audio recording, push notifications, haptic feedback, deep linking, and share intent support on both iOS and Android.
Capacitor mobile wrapper for the hosted Ped-AI web app. The app defaults to `https://app.pedshub.com`, lets users choose a self-hosted server URL, and keeps clinical workflows API-backed through the same Express service as the browser app.
## Features
- Background recording that survives screen lock (foreground service on Android, background audio on iOS)
- Hosted web workflow inside a native WebView; server updates reach mobile clients without app-store releases
- Configurable server URL (supports self-hosted instances)
- Haptic feedback on recording start/stop
- Keep screen awake during recording
- Deep linking (pedscribe:// and https://app.pedshub.com)
- Share intent (receive text/PDFs from other apps)
- Push notification support
- App Store and Play Store ready
- **Biometric sign-in** (Face ID / Touch ID / fingerprint) — credentials
stored in iOS Keychain / Android Keystore, gated by OS biometric.
Enrolled on first password sign-in (opt-in prompt). 2FA still applies
on top — biometric replaces the password step only.
- Android and iOS project scaffolds for store builds