Three things, one subject: making CI say the truth about this repo.
## The red on every run was ours, not the runners'
Every docker-build run came back success, success, failure — the same
shape for weeks. The failing job was `deploy`, and it was failing to
*not run*:
if: ${{ github.event.inputs.deploy == 'true' }}
On a push there is no github.event.inputs at all. This Forgejo does not
treat that as false and skip; it dispatches the job, the runner cannot
resolve it, and the task ends in "Early termination". The runners were
never at fault, and nothing about them needed changing.
The `'runs-on' key not defined` line is a red herring: the `build` job
prints it too and succeeds. It names the job's *needs* target, not the
job, and the old android-apk workflow used `needs:` happily for months.
Deploy is now its own workflow with only workflow_dispatch — no
condition to evaluate, so nothing can be dispatched by mistake. No job
in either file now carries a job-level `if`. The one conditional left is
a *step* (push to registry), and step conditions are evaluated by the
runner once the job is already running, which is why that one has always
worked.
## dev and main
docker-build now runs on `dev` as well. Both branches prove the same two
things — tests pass, image builds — and only `main` publishes the image,
so nothing on `dev` can be mistaken for something deployable. Deploying
stays a person pressing a button after looking at the change.
CONTRIBUTING.md documents the flow.
## Android
Removed: the mobile/ Capacitor project, docs/mobile-build.md, and the
Android bits of scripts/release.sh. All of it is in git history — 4613a278
is the last commit that had it — for when it is rebuilt.
src/utils/platform.js stays. isMobileClient only decides token lifetime,
it is twelve lines, and it is the contract a future app would come back
to; deleting it would be a change to auth for no gain.
.github/workflows/ went too — all five. There is no GitHub remote on
this repository, so none of them has ever run, and two of them wrote
into mobile/ paths that no longer exist.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
18 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
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
.forgejo/workflows/ CI (tests + image on dev/main; manual deploy)
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 |
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 |
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.) |
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/, 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
Deploy with scripts/deploy.sh, never with docker compose up by hand:
./scripts/build-image.sh
REV=$(git rev-parse HEAD)
scripts/deploy.sh "ped-ai-local:$REV" "$REV"
deploy.sh moves the PED_AI_IMAGE pin in .env, waits for health, then reads
/api/build and rolls back if the container came up on a different revision.
Running up by hand does none of that: /api/health passing proves a container
is up, not that it is the one you built, and a pin left from an earlier deploy
will happily start an older image while everything looks fine. See
deployment.md for the full sequence.
To check by hand what is serving:
curl -fsS http://127.0.0.1:3552/api/build # must equal `git rev-parse HEAD`
docker compose ps pediatric-scribe
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.