Three pairs of docs described the same thing twice, and the copies had drifted
apart. Merged each into one file, keeping the unique content from both:
- ARCHITECTURE.md -> architecture.md (its operational map: ownership, request
flow, runtime boundaries, source of truth, deployment shape)
- DEVELOPMENT.md -> developer-guide.md (change workflow, Clinical Assistant
high-risk areas, frontend rendering rules, deployment checks)
- transcription-options.md -> speech.md (the clinic setup table, and the list
of browser-Whisper paths that must stay removed)
Then audited what remained against the code and the live database rather than
against the previous docs. Corrected:
- Google Vertex was still documented as a provider across nine files. The SDK
is gone; AI_PROVIDER=vertex now logs an advisory and falls back to
OpenRouter, and Gemini is reached through LiteLLM. Fixed the provider
selection order to match src/utils/ai.js, which starts from LITELLM_API_BASE.
- promptSafe was documented on 8 routes; it is on 13.
- Node 20 -> 24, "24 vanilla JS modules" -> no fixed count, and
transcribe.js/tts.js -> sttProvider.js/ttsProvider.js, which is what exists.
- STT/TTS are LiteLLM-only; README listed direct Google, AWS Transcribe and
ElevenLabs paths that are not in the runtime.
- Learning Hub PPTX export was documented as pptxgenjs, which is not a
dependency. It is pandoc against a reference deck.
- POST /api/admin/milestones/seed does not exist; it is /bulk-import.
- NEXTCLOUD_URL and NTFY_TOPIC are not read anywhere. Nextcloud is per-user in
the users table, and the ntfy topic is derived as pedscribe-{userId}.
- A prose paragraph sat inside the Clinical Assistant settings table, so half
the rows rendered as text.
Filled the gaps the audit exposed:
- database.md was missing 12 of 29 tables, including user_resources,
personal_notes, login_codes, registration_invites and generated_image_jobs.
- developer-guide.md was missing 11 routers and 10 frontend modules.
- api-reference.md detailed 121 of 244 endpoints and said so, but whole
features were absent. Added an endpoint index covering Clinical Assistant,
My Resources, Notes, Diagrams, ED Encounters, invites and sign-in codes.
- configuration.md was missing METRICS_TOKEN, REDIS_URL, API_RATE_LIMIT_MAX,
the LITELLM_* model variables, the DB_* ones maintenance.js reads, and the
per-purpose S3 resolution scheme.
- clinical-assistant.md documented 2 of its 17 environment variables.
- features-explained.md had no entry for My Resources or Clinical Assistant.
Renamed the three remaining SHOUTING filenames to kebab-case, which is what the
docs viewer's prettyName() was working around, and rewrote README's index,
which listed architecture.md twice and omitted nine files.
Noted but not changed: the Turnstile site key is hardcoded in index.html rather
than read from TURNSTILE_SITE_KEY, and /api/health/detailed can report
tts: 'elevenlabs' though no ElevenLabs path exists.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
12 KiB
Architecture
Self-hosted clinical documentation platform. Dockerized Node.js server, PostgreSQL, Redis, and vanilla-JS SPA. No build step on the frontend.
Stack
| Layer | Technology |
|---|---|
| Runtime | Node.js 24 (Alpine, digest-pinned) + Express 4 |
| Database | PostgreSQL 16 via the digest-pinned pgvector/pgvector:pg16 image |
| Cache / state | Redis for operational cache, prompt suggestions, and queue groundwork |
| Frontend | Vanilla JavaScript SPA, service-worker cache |
| Mobile | Capacitor 6 wrapper (Android + iOS) |
| Container | Docker Compose (app + db + Redis) |
| Observability | Prometheus metrics at /metrics; structured app logs in files, Postgres, and optional Loki |
| Reverse proxy | External (Caddy, Nginx, Traefik — any) |
Repository layout
server.js # Express entry
Dockerfile # node:24-alpine base, plus pandoc, python3/python-pptx/python-docx, poppler
docker-compose.yml # app + postgres
migrations/ # node-pg-migrate files (versioned)
scripts/
maintenance.js # REINDEX / collation-drift CLI
release.sh # semver bump + tag + push
src/
db/
database.js # pg pool, idempotent baseline init, 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
models.js # model registry + server-side whitelist
prompts.js # prompt templates (DB-overridable)
crypto.js # AES-256-GCM (PHI at rest)
passwords.js # argon2id with bcrypt fallback + rehash
sessions.js # token hashing, UA parser, session-id gen
platform.js # isMobileClient() detection
redact.js # PHI redactor for audit details
auditQueue.js # batched audit/api/access log writer
fileType.js # magic-byte upload verification
promptSafe.js # <UNTRUSTED_*> LLM prompt wrapper
logger.js # audit/api/access + Loki shipper
errors.js # generic 500 responder
embeddings.js # LiteLLM embeddings
sttProvider.js, ttsProvider.js # speech-to-text and text-to-speech routing
documentExport.js # pptx/docx/pdf export
slideSpec.js, docSpec.js # markdown -> typed spec for the renderers
deckSchema.js, deckBuild.js # the deck a presentation is designed as
deckReview.js # optional vision pass over a rendered deck
routes/ # Express routers (auth, hpi, soap, patient education, …)
public/ # SPA
index.html # shell, loads components on demand
sw.js # service worker (cache shell, network-first API)
js/ # vanilla JS modules, no build step
components/ # per-tab HTML fragments
css/styles.css
mobile/ # Capacitor wrapper
capacitor.config.json # appId com.pedshub.scribe
src/ # launcher (server-URL picker)
android/ # generated AS project + native Java
.forgejo/workflows/
android-apk.yml # signed APK on tag push; optional Play upload
docker-build.yml # Forgejo registry Docker image build
.github/workflows/
auto-version.yml # conventional-commits → semver bump → tag
android-release.yml # legacy GitHub tag APK release path
docker-publish.yml # multi-arch image on tag push
version-bump.yml # manual dispatch override
build-apk.yml # legacy TWA APK
Request pipeline
request
→ helmet (CSP, HSTS, X-Content-Type-Options, …)
→ CORS (APP_URL + CORS_ORIGINS whitelist, fail-closed in prod)
→ cookieParser
→ express.json (10 MB cap)
→ rate limiters (general 200 req/min, per-endpoint tighter on auth)
→ static (public/ with no-cache on HTML, 1h on JS/CSS; ?v=BUILD_ID busts cache per deploy)
→ route (feature routers under /api/*)
→ authMiddleware (on protected routes: JWT, DB session check, 24h idle, last_activity update)
→ handler
→ response
On boot, server.js:
- Validates
JWT_SECRETandDATA_ENCRYPTION_KEY— refuses to start in production without them. - Runs
initDatabase()(idempotent baseline) thennode-pg-migrate(versioned delta). - Checks
pg_databasecollation version; auto-REINDEXes + refreshes on drift. - Reads git HEAD for
BUILD_ID; injects?v=BUILD_IDinto every local/js/*.jsand/css/*.cssreference inindex.html. - Registers SIGTERM/SIGINT handlers that drain the audit queue and close the pool before exit.
Auth model
Hybrid, runtime-selected by User-Agent and X-Client header:
| Client | Token transport | Persistence | Idle policy |
|---|---|---|---|
| Web browser | ped_auth httpOnly cookie, sameSite=lax |
30 d maxAge (sliding) | 24 h from last write request |
Capacitor app (PedScribe-Android / Capacitor UA) |
Authorization: Bearer <jwt> |
iOS Keychain / Android EncryptedSharedPreferences via capacitor-secure-storage-plugin |
No server-side idle check (persistent) |
Sessions are validated against user_sessions.token_hash on every request. Any
logout / password-change / admin-revoke drops the row and the next request gets
401. The service worker clears its caches on logout so a stale shell never
shows PHI on a shared workstation.
Conventional-commits auto-tag workflow can push a new semver tag using a
RELEASE_PAT PAT secret so downstream release workflows fire on the tag push
(the default GITHUB_TOKEN is blocked from triggering other workflows by
design).
Frontend
Single HTML document with #auth-screen and #main-app sections. Tabs are
per-feature HTML fragments under public/components/ fetched on demand. JS
modules talk via window globals and CustomEvent on document — no
bundler, no framework. Loader order is fixed in index.html.
Post-note helpers such as billing suggestions, don't-miss review, and patient education handouts are reusable browser-side actions backed by authenticated JSON APIs. The patient education helper generates a parent-facing plain-text draft from the edited note and keeps the clinician in the review loop before copying or sharing.
authFetch.js installs a global fetch interceptor that treats any 401 on an
authenticated request as a signal to clear local session state and redirect to
login. A BroadcastChannel('pedscribe-auth') pushes that signal to sibling
tabs so logging out in one tab drops UI in every open tab.
Docker topology
| Container | Image | Internal port | External |
|---|---|---|---|
pediatric-ai-scribe |
ped-ai-local:latest (built from repo) |
3000 | 127.0.0.1:3552 |
pedscribe-db |
pgvector/pgvector:pg16 |
5432 | not exposed |
ped-ai-redis |
Redis | 6379 | not exposed |
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
never bound to a public interface directly.
Service worker
sw.js implements two strategies:
- Shell assets (
/,/js/*,/css/*,/components/*) — cache-first. /api/*— network-first with cached fallback. Ensures fresh data online, last-known-good when offline.
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.
Operational map
The sections above describe the code. These describe the running system: who owns what, what crosses each boundary, and where the truth lives when two places disagree.
Ownership
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.
| Area | Owner | Notes |
|---|---|---|
| Web app | Ped-AI | Auth, UI, clinical workflows, admin settings, notes, Learning Hub, bedside tools |
| Database | PostgreSQL | Users, sessions, settings, saved app data, audit/API/access logs |
| Operational cache | Redis | Prompt suggestions, lightweight state, queue groundwork; not clinical answer caching |
| Model gateway | LiteLLM | Text, speech, image, embedding model discovery and routing |
| Clinical retrieval | MCP service | Nextcloud access, indexing, search, rerank, source metadata |
| Reverse proxy | Caddy or equivalent | TLS and public routing |
Request Flow
Normal app request:
browser
-> reverse proxy
-> Express middleware
-> auth/session check when protected
-> route handler
-> PostgreSQL/Redis/provider calls as needed
-> JSON or HTML fragment response
Clinical Assistant request:
browser
-> Ped-AI clinical assistant route
-> MCP semantic search for indexed clinical sources
-> Ped-AI builds grounded answer prompt
-> LiteLLM chat model
-> Ped-AI returns answer plus source metadata
-> browser renders markdown, citations, and source cards
Ped-AI owns the user workflow and rendering. MCP owns retrieval and indexed source metadata. LiteLLM owns model routing.
Runtime Boundaries
| Boundary | Main Risk | Current Direction |
|---|---|---|
| Browser to Ped-AI | XSS, stale shell, session handling | Sanitized rendering, httpOnly cookie for web, cache busting |
| Ped-AI to PostgreSQL | schema drift, slow queries | migrations, maintenance checks, indexes where needed |
| Ped-AI to Redis | unavailable operational state | Redis is useful but should not hold required clinical answers |
| Ped-AI to LiteLLM | provider downtime, wrong model mode | metadata-based model discovery and timeouts |
| Ped-AI to MCP | retrieval latency/failure | explicit MCP client layer and graceful fallback messages |
| MCP to Nextcloud | stale indexed metadata | scanner/indexer updates source metadata over time |
Source Of Truth
| Data | Source Of Truth |
|---|---|
| User accounts and sessions | Ped-AI PostgreSQL |
| Admin app settings | Ped-AI PostgreSQL app_settings |
| Clinical source documents | Nextcloud and MCP index |
| Clinical source title/path shown to users | MCP result metadata, especially indexed file_path |
| Clinical answer text | Generated per request; intentionally not cached |
| Model availability | LiteLLM metadata and configured fallbacks |
Deployment Shape
Production usually runs:
Caddy/TLS
-> pediatric-ai-scribe container
-> pedscribe-db container
-> ped-ai-redis container
-> LiteLLM endpoint
-> MCP endpoint
The app should stay private behind the reverse proxy. Do not expose PostgreSQL, Redis, MCP internals, or provider keys publicly.
Design Principles
- Keep Ped-AI stateless enough to run more than one app container.
- Keep clinical answer generation live and source-grounded; do not cache final clinical answers.
- Prefer model capability metadata over model-name regexes.
- Prefer indexed file names and paths over embedded PDF metadata for source titles.
- Keep renderer fixes narrow and tested because LLM markdown is messy.
- Keep old frontend globals working until the affected feature is intentionally converted to ESM.