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
253 lines
12 KiB
Markdown
253 lines
12 KiB
Markdown
# 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_SECRET` and `DATA_ENCRYPTION_KEY` — refuses to start in production without them.
|
|
- Runs `initDatabase()` (idempotent baseline) then `node-pg-migrate` (versioned delta).
|
|
- Checks `pg_database` collation version; auto-REINDEXes + refreshes on drift.
|
|
- Reads git HEAD for `BUILD_ID`; injects `?v=BUILD_ID` into every local `/js/*.js` and `/css/*.css` reference in `index.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:
|
|
|
|
```txt
|
|
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:
|
|
|
|
```txt
|
|
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:
|
|
|
|
```txt
|
|
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.
|