`docker compose up` starts whatever PED_AI_IMAGE in .env names, and
build-image.sh does not move that pin. A pin left from an earlier deploy
therefore starts the older image while every signal reports success: the build
completes, `up` says the container started, and /api/health returns {ok:true}
from the wrong revision.
This is not hypothetical. The pin here had been sitting on a revision from two
hours before the Modify card was added, so a rebuild-and-restart rolled My
Resources back 31 commits and removed the feature. The missing card was then
reported as a new bug, and three deploys in this session had in fact deployed
nothing.
build-image.sh now compares the pin to the revision it just built and, when they
differ, prints the pin, says that `up` will start it instead, and gives the
command to move it. It does not correct the pin: naming a revision is also how a
deliberate rollback is done, so this is said rather than silently overridden.
Both deployment docs now check /api/build against `git rev-parse HEAD` after
starting, because /api/health passing only proves a container is up, not that it
is the one you built.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
19 KiB
Developer guide
How the codebase is organized, how the main subsystems work, and where to extend them.
Layout
server.js Express entry, middleware stack, route mount
Dockerfile node:24-alpine, argon2 native compile deps
docker-compose.yml app + postgres services
migrations/ node-pg-migrate versioned schema changes
scripts/
maintenance.js REINDEX / drift CLI
release.sh semver bump + tag + push
import-milestones.js one-off seed import
src/
db/
database.js pool, baseline init, query helpers
migrate.js programmatic node-pg-migrate runner
middleware/
auth.js JWT + session-table validation + sliding idle
logging.js request log
utils/
ai.js callAI() multi-provider router + model whitelist
models.js built-in model registry
prompts.js templates (DB-overridable)
promptSafe.js <UNTRUSTED_*> wrapping + INJECTION_GUARD
crypto.js AES-256-GCM (PHI at rest)
passwords.js argon2id + bcrypt fallback + rehash
sessions.js token hash, UA parse, session id
platform.js isMobileClient()
redact.js PHI redactor for audit details
auditQueue.js batched audit/api/access writer
fileType.js magic-byte upload verifier
errors.js generic 500 responder
logger.js audit + api + access + Loki shipper
embeddings.js LiteLLM embeddings
notify.js ntfy push
transcribe.js, tts.js LiteLLM STT / TTS routes
routes/ Express routers for auth, AI workflows, education, logs, and user data
public/
index.html SPA shell, version-stamped asset refs
sw.js cache shell, network-first API
manifest.json PWA
js/ vanilla JS modules (no bundler)
components/ per-tab HTML fragments loaded on demand
css/styles.css
template-guide.md downloadable user template guide
mobile/ Capacitor 6 wrapper (Android + iOS)
.github/workflows/ CI (auto-version, APK, docker)
Backend
Middleware stack (server.js)
request
→ helmet CSP, HSTS, X-Content-Type-Options
→ CORS fail-closed in prod if APP_URL/CORS_ORIGINS missing
→ cookieParser
→ express.json 10 MB cap
→ rate limiters 200/min general; 10/15min login; 20/15min sensitive
→ static public/ with per-filetype Cache-Control; ?v=BUILD_ID cache-busts HTML on deploy
→ route handlers
→ 404 fallback serves index.html for SPA routes
On boot:
APP_VERSIONread frompackage.json, printed + returned by/api/health/detailed.BUILD_ID= full Git HEAD SHA (worktrees and packed refs supported), or the validated image-baked revision. Unversioned development builds reportunknown; no random SHA is invented.JWT_SECRET/DATA_ENCRYPTION_KEYfail-fast if missing in production.initDatabase()→runMigrations()→ collation drift check.- SIGTERM / SIGINT handler drains the audit queue and closes the pool.
DB helpers (src/db/database.js)
await db.get(sql, params); // first row or null
await db.all(sql, params); // array of rows
await db.run(sql, params); // { lastInsertRowid, changes }
await db.query(sql, params); // raw pg result
await db.getSetting(key); // app_settings value (2 min cache)
await db.setSetting(key, v); // writes + invalidates cache
db.pool // pg.Pool instance for transactions
SQL uses ? placeholders (auto-converted to $1, $2, ...) OR native $N.
INSERTs without explicit RETURNING get RETURNING id appended.
Auth middleware (src/middleware/auth.js)
var { authMiddleware, adminMiddleware, moderatorMiddleware } = require('../middleware/auth');
router.post('/thing', authMiddleware, handler); // requires auth
router.post('/admin-thing', authMiddleware, adminMiddleware, handler);
Sets req.user with { id, email, name, role, totp_enabled, disabled } and
req.sessionId.
AI (src/utils/ai.js)
var { callAI } = require('../utils/ai');
var result = await callAI([
{ role: 'system', content: PROMPTS.hpiEncounter + INJECTION_GUARD },
{ role: 'user', content: wrapUserText('transcript', transcript) }
], { model, maxTokens: 4000 });
// result = { content, model, usage }
Throws { code: 'model_not_permitted' } if the requested model isn't in the
active allowlist.
Prompts (src/utils/prompts.js)
Flat PROMPTS object. Any template can be overridden live by writing a
prompt.{name} row in app_settings. Admin Panel's Prompt Editor is the UX
for that.
Settings (src/utils/config.js)
var v = await config.get('feature.read_aloud', 'false'); // key, default
await config.set('registration_enabled', 'true');
2-minute in-memory cache. Writes invalidate immediately.
Logger (src/utils/logger.js)
logger.audit(userId, 'action', 'details', req, { category: 'auth' });
logger.apiCall(userId, endpoint, { model, tokensInput, tokensOutput, duration });
logger.access(userId, 'login', req, true);
logger.error('scope', err.message);
Writes go to the database (batched), the daily JSONL file, and Loki (if configured).
Frontend
No framework, no bundler, no build step. public/index.html is the only
document. Modules communicate through window.* globals and CustomEvent on
document.
Tab system
app.js owns activateTab(name):
- Fetch
/components/{name}.html(browser-cached for 1 h, bust by?v=BUILD_IDthat the server injects). - Inject into
.app-body. - Dispatch
CustomEvent('tabChanged', { detail: { tab: name } }). - Feature modules (
soap.js,encounters.js, etc.) listen for their own tab name and initialize DOM references inside the just-injected fragment.
Auth model (client)
auth.js branches on isNativeApp():
- Web — no localStorage token; relies on the
ped_authhttpOnly cookie set by the server./api/auth/meon boot verifies the session; failed verification shows the login screen. - Mobile — token lives in
capacitor-secure-storage-plugin(iOS Keychain / Android EncryptedSharedPreferences).getAuthHeaders()emitsAuthorization: Bearer <token>.
authFetch.js wraps window.fetch to catch 401 on authenticated /api/*
requests and force re-login. BroadcastChannel('pedscribe-auth') propagates
logout to sibling tabs.
Module load order
Fixed in index.html:
secureStorage → authFetch → auth → (feature modules)
All <script defer>. Dependencies enforced by declaration order.
Adding things
A new AI endpoint
-
Create
src/routes/myFeature.js:var express = require('express'); var router = express.Router(); var { callAI } = require('../utils/ai'); var { authMiddleware } = require('../middleware/auth'); var PROMPTS = require('../utils/prompts'); var { wrapUserText, INJECTION_GUARD } = require('../utils/promptSafe'); var logger = require('../utils/logger'); router.post('/my-feature', authMiddleware, async (req, res) => { try { var { transcript, model } = req.body; var result = await callAI([ { role: 'system', content: PROMPTS.myFeature + INJECTION_GUARD }, { role: 'user', content: wrapUserText('transcript', transcript) } ], { model }); res.json({ success: true, text: result.content }); logger.audit(req.user.id, 'generate_my_feature', 'Generated', req, { category: 'clinical' }); } catch (err) { res.status(500).json({ error: 'Request failed' }); } }); module.exports = router; -
Mount in
server.js:app.use('/api', require('./src/routes/myFeature')); -
Add the template to
src/utils/prompts.js. -
Add a component under
public/components/myfeature.html. -
Add
public/js/myFeature.jswith atabChangedlistener. -
Register the tab button in
public/index.html.
A new table / column
New changes go in a migration file. See docs/migrations.md.
docker exec -w /app pediatric-ai-scribe npm run migrate:new -- add_my_table
# edit the generated file
A new setting
- Optional — add a default in the
defaultsarray insideinitDatabase()(only needed if the app should seed it on fresh installs). - Read at runtime:
await db.getSetting('my_key'). - Admin-editable automatically through
PUT /api/admin/configwhich accepts arbitrary keys.
Physician Templates And Preferences
- Settings saves user templates/preferences through
/api/memoriesintouser_memories. - New rows encrypt
nameandcontentwith the sharedenc1:string format. GET /api/memories/contextdecrypts rows and returns only AI-context categories:physical_exam,ros,encounter_format,family_history,assessment_plan,template_soap,template_hpi,template_wellvisit,template_sickvisit, andtemplate_ed.customrows remain visible in settings but are not included in prompt context.- Legacy
correction_*rows from the removed correction-learning feature are filtered out rather than deleted.
Route reference
| File | Mount | Auth | Purpose |
|---|---|---|---|
auth.js |
/api/auth |
Public | Register, login, 2FA, email verify, password reset, backup codes |
oidc.js |
/api/auth |
Public | OIDC SSO (Authorization Code + PKCE) |
sessions.js |
/api/sessions |
Auth | Active sessions list + revoke |
hpi.js |
/api |
Auth | HPI from encounter or dictation |
soap.js |
/api |
Auth | SOAP generation |
chartReview.js |
/api |
Auth | Chart review / pre-charting |
hospitalCourse.js |
/api |
Auth | Hospital course |
wellVisit.js |
/api |
Auth | Well visit + SSHADESS |
sickVisit.js |
/api |
Auth | Sick visit |
milestones.js |
/api |
Auth | Developmental milestone narratives |
refine.js |
/api |
Auth | Refine / shorten / clarify |
transcribe.js |
/api |
Auth | LiteLLM STT |
tts.js |
/api |
Auth | LiteLLM TTS |
encounters.js |
/api |
Auth | Save / load / optimistic-lock encounters |
memories.js |
/api |
Auth | Templates + prompt preferences |
audioBackups.js |
/api |
Auth | Encrypted audio retry store |
documents.js |
/api |
Auth | S3 documents (magic-byte checked) |
userPreferences.js |
/api |
Auth | Per-user STT/TTS choice |
nextcloud.js |
/api |
Auth | Encrypted WebDAV tokens |
billing.js |
/api |
Auth | ICD-10 / CPT code suggestion |
logs.js |
/api |
Auth | Usage + audit dump (admin-only endpoints gated further) |
admin.js |
/api/admin |
Admin | User management |
adminConfig.js |
/api/admin |
Admin | Settings, prompts, models, SMTP, OIDC |
adminMilestones.js |
/api/admin |
Admin | Milestone data management |
learningHub.js |
/api/learning |
Auth | Content delivery + quizzes |
learningAdmin.js |
/api/admin/learning |
Moderator | CMS CRUD |
learningAI.js |
/api/admin/learning |
Moderator | AI content gen, PPTX export |
clinicalAssistant.js |
/api |
Auth | Grounded clinical answers over MCP retrieval |
myResources.js |
/api |
Auth | Personal teaching material: generate, refine, export |
notes.js |
/api |
Auth | Personal notes |
edEncounters.js |
/api |
Auth | ED encounters: staged notes, consolidate, MDM finalize |
dontMiss.js |
/api |
Auth | Don't-miss diagnosis suggestions |
patientEducation.js |
/api |
Auth | Patient education handouts |
peGuide.js |
/api |
Auth | Physical exam guide |
diagrams.js |
/api |
Auth | Diagram rendering |
generatedImages.js |
/api, /api/admin/learning/image |
Auth | Image generation jobs and their stored output |
extensions.js |
/api |
Auth | Browser-extension integration |
adminDocs.js |
/api/admin/docs |
Admin | In-app rendering of this docs/ tree |
Frontend JS module reference
| File | Purpose |
|---|---|
secureStorage.js |
Platform-branched token storage (Keychain / localStorage) |
authFetch.js |
Global fetch wrapper (401 → logout + reload) |
auth.js |
Login / register / SSO / session management / Turnstile / backup-code modal |
app.js |
Tab navigation, model selector, audio recorder, transcription orchestration |
liveEncounter.js |
Live recording UI + live preview |
soap.js, hpi.js (none — in liveEncounter), sickVisit.js, wellVisit.js, hospitalCourse.js, chartReview.js |
Clinical tabs |
milestones.js + milestonesData.js |
Milestones tab |
shadess.js |
SSHADESS adolescent assessment |
encounters.js |
Save / load / resume with optimistic lock |
memories.js |
Physician templates and prompt preferences UI |
speechRecognition.js |
Explicit opt-in browser Web Speech support |
voicePreferences.js |
Per-user STT/TTS override |
audioBackup.js |
Server + IndexedDB backup retries |
nextcloud.js |
Connect / export |
documents.js |
S3 upload / download |
calculators.js |
Pediatric calculators (BP, BMI, growth, bilirubin, vitals, etc.) |
learningHub.js |
Content browser + CMS editor |
accountBoundary.js |
One verified account owner per JS realm; guards cross-account leakage |
ed-encounters.js |
ED encounter workflow: staged notes, consolidate, MDM finalize |
voiceDictation.js |
Dictation capture and voice-mode call UI |
transcriptionSettings.js |
Transcription provider and model picker |
recordingModules.js |
Shared recorder wiring reused by the clinical tabs |
calc-math.js |
Pure calculator math, kept separate so it can be tested directly |
admin-docs.js |
Documentation viewer inside the Admin panel |
drugs-loader.js, ui-state.js |
Small shared helpers |
e2e-bootstrap.js |
Test-only hook; inert unless the e2e harness sets it up |
admin.js |
Admin panel (users, settings, prompts, models) |
Larger features live in their own directory rather than a single file:
admin/, assistant/, bedside/, calculators/, learningHub/, notes/,
and wellVisit/.
Common tasks
Change default temperature
Edit the default in callAI() in src/utils/ai.js. Per-call options.temperature
overrides.
Override a prompt without deploying
Admin Panel → Prompts → pick the template → edit → save. Takes effect immediately; cache is invalidated on write.
Add a model to the dropdown
Admin Panel → Models → Add Custom Model. Exact provider ID, display name,
cost string, category (free / fast / smart / premium). Appears
immediately for all users.
Trace an AI call
docker compose logs -f pediatric-scribe | grep '\[AI\]'
Or:
SELECT endpoint, model_used, tokens_input, tokens_output, duration_ms, cost_estimate, error
FROM api_log
ORDER BY timestamp DESC
LIMIT 20;
Force a re-index
docker exec -w /app pediatric-ai-scribe npm run maint:reindex
Runs after any Postgres image upgrade if the startup drift check didn't catch it.
Local development
docker compose up -d postgres # just the DB
npm ci
cp .env.example .env # set JWT_SECRET, DATA_ENCRYPTION_KEY, provider credentials
node server.js
App binds http://localhost:3000. Without APP_URL, production-mode guards
relax (open CORS, non-secure cookies) — never deploy like this.
Or run it the way production does, against the built image:
cp .env.example .env
./scripts/build-image.sh
docker compose up -d --no-build
curl -fsS http://127.0.0.1:3552/api/health
Use Node 24 to match the image. Tests run from the repository root with
npm ci && npm test; node --check <file> is a fast syntax gate when you are
touching a backend entrypoint.
Change workflow
- Read the relevant route, utility, frontend module, and tests before editing.
- Make the smallest correct change.
- Add or update a regression test when changing clinical rendering, model routing, auth, settings, or source handling.
- Run focused tests first if available.
- Run
npm testbefore deploy or commit. - Deploy with Docker only after tests pass.
- Verify
/api/healthafter deploy.
Changing the Clinical Assistant
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.
Safe patterns:
el.textContent = userText;
el.innerHTML = escapeHtml(userText).replace(/\n/g, '<br>');
el.innerHTML = sanitizeHtml(renderMarkdown(modelOutput));
Unsafe pattern:
el.innerHTML = modelOutput;
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, check which revision is running, not only that something is:
curl -fsS http://127.0.0.1:3552/api/build # must equal `git rev-parse HEAD`
curl -fsS http://127.0.0.1:3552/api/health
docker compose ps pediatric-scribe
/api/health passing proves a container is up, not that it is the one you
built. docker compose up starts whatever PED_AI_IMAGE in .env names, and
that pin does not move when you build — so a pin left from an earlier deploy
silently starts the older image, and every signal short of /api/build looks
fine. scripts/build-image.sh warns when the pin does not match the revision it
just built; the fix is to update the pin:
sed -i "s|^PED_AI_IMAGE=.*|PED_AI_IMAGE=ped-ai-local:$(git rev-parse HEAD)|" .env
docker compose up -d --no-build
The pin is not updated for you, because naming a revision is also how a deliberate rollback is done.
If the browser still shows old frontend behavior after the revision checks out,
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.