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
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
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
.forgejo/workflows/
docker-build.yml # dev + main: tests, image; main also publishes
deploy.yml # manual dispatch: deploy.sh against the host
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, My Resources, 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 and image 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.