System architecture of the OpenMedForm platform: services, packages, and how they fit together.
Technical Architecture
Overview
OpenMedForm is a monorepo of applications, demos and shared packages. JSON Forms is the only form engine — see ADR-003 for the platform architecture.
openmedform/
├── apps/api NestJS 10 backend (REST API, auth, scoring, AI conversion)
├── apps/web Next.js 14 frontend (forms, designer, renderer, dashboard)
├── apps/react-demo Standalone React renderer demo
├── apps/angular-demo Standalone Angular renderer demo
├── packages/form-schema-types Data / UI / Print schema contracts + the `omf` vocabulary
├── packages/form-core Framework-independent engine (Ajv validation, scoring, binding)
├── packages/form-design-tokens Shared CSS variables — React/Angular visual parity
├── packages/react-form-renderer React renderer + clinical controls
├── packages/angular-form-renderer Angular renderer + the same controls
└── packages/form-print-engine UI/Print schema → A4 HTML/CSS → PDF
System Architecture
┌─────────────────────────────────┐
│ Browser (Next.js) │
│ ┌───────────┐ ┌─────────────┐ │
│ │ Renderer │ │ AI Designer │ │
│ │ + Designer│ │ Panel │ │
│ │(JSONForms)│ │ │ │
│ └─────┬─────┘ └──────┬──────┘ │
│ │ │ │
└────────┼───────────────┼────────┘
│ │
REST API calls REST API calls
│ │
┌────────┼───────────────┼────────┐
│ ▼ ▼ │
│ ┌───────────┐ ┌─────────────┐ │
│ │ Form │ │ AI Builder │ │
│ │ Module │ │ Module │ │
│ └─────┬─────┘ └──────┬──────┘ │
│ │ │ │
│ ▼ ▼ │
│ ┌──────────┐ ┌──────────────┐ │
│ │ Prisma │ │ LLM Provider │ │
│ │ (PG) │ │ Registry │ │
│ └──────────┘ └──────────────┘ │
│ NestJS Backend │
└──────────────────────────────────┘
Runtime Dependencies (apps/api)
Two native binaries ship in the API image. Both are optional: the feature degrades rather than failing if either is absent.
| Binary | Package | Used for | Without it |
|---|---|---|---|
pdftoppm |
poppler-utils |
Rendering PDF pages to PNG so a vision-capable LLM can read layout during conversion | Falls back to embedded text extraction |
chromium |
chromium |
Executing HTML mock-ups that build their form at runtime, so the generated fields can be read | Falls back to the static markup |
Why Chromium
An AI-generated HTML mock-up routinely builds its whole form from a config
array at load time. The markup is then a heading, an empty <tbody>, and
nothing else — there is literally nothing to convert. Executing the page turns
those config arrays back into real <input>/<select> elements.
Uploaded files are untrusted, so rendering is deliberately not trusting:
- Chromium's OS-level sandbox is the isolation boundary. Uploaded script is
never evaluated in the API process.
--no-sandboxis never passed; jsdom was rejected because it would run untrusted code inside Node. - No network. The context is
offline, all requests are aborted by a catch-all route, and content is injected viasetContentrather than navigated to — the page has no origin to fetch from. - Bounded: 10s cap, downloads refused, pop-ups closed, context torn down.
- Re-sanitised: the rendered DOM goes back through the same extractor as any static upload — scripts stripped, attribute allow-list enforced, hidden content removed.
Implementation:
html-render.ts. Full
rationale and threat model: ADR-003.
Operational notes
- Alpine images use the distro
chromiumpackage (Playwright's bundled build does not support Alpine), located viaCHROMIUM_PATH. HTML_RENDER_DISABLED=1turns rendering off entirely.HTML_PROBE_DISABLED=1keeps rendering but stops the sandbox pressing the page's "Add …" controls (interaction probing).- Allow ≥1 GiB per API instance; Chromium is short-lived but not free.
- Local development and CI need neither binary.
The print/visual-diff loop still expects a separately injected rasterizer. Chromium is present and could serve it, but that wiring is an open task.
Multi-Tenancy
Row-level isolation via tenant_id on all domain tables. The JWT payload carries tenantId, extracted by a TenantGuard and passed to all service methods.
Form Schema Lifecycle
- Form created → FormVersion v1 (draft, empty schema)
- Designer edits → draft version updated via auto-save
- Designer publishes →
published_atset, version becomes immutable - Further edits → new FormVersion v(N+1) created as draft
- Submissions reference the exact
form_version_idthey were filled against
Scoring Architecture
- Client-side: the renderers compute live subtotals and the score summary from
options.omf.pointsvia the sharedform-corescoring module — advisory only - Server-side: the deterministic scoring engine recalculates on submission
completion and is authoritative (no
eval()) - Both sides share one implementation in
form-core, so a score cannot differ between the screen and the stored record