-
Notifications
You must be signed in to change notification settings - Fork 1
API Service
This page lists what the API serves today, endpoint by endpoint, and what it does not. It is for anyone writing a client or picking up an unimplemented aggregate.
For the conventions behind these endpoints, see API design.
pnpm --filter api dev # tsx watch, port 4000
pnpm --filter api test # vitest run --coverage
pnpm --filter api build # tsc
pnpm --filter api start # node dist/index.jspnpm start with NODE_ENV=production throws at construction by design, because index.ts builds the app with in-memory defaults and the production wiring assertion refuses them. See Security model.
apps/api/src/
app.ts createApp(): wires the chain, the routers, one error boundary
index.ts process entry: parseEnv, serve
env.ts the environment schema
context.ts typed Hono context variables
errors.ts ApiError and the kind-to-status map
middleware/ the chain, in the order the design fixes
routes/ internal REST, one router per aggregate
fhir/ the FHIR R4 boundary: registry, metadata, search, bundles
repositories/ data access behind an interface; in-memory and Prisma
audit/ request-scoped collector, in-transaction sink
policy/ the permission catalogue and role bundles
schemas/ request and response contracts, in Zod
openapi/ the spec, generated from those same Zod contracts
http/ the two error representations and validation helpers
| Method | Path | Returns |
|---|---|---|
| GET | /healthz |
{ "status": "ok", "service": "openrunic-api" } |
| GET | /openapi.json |
OpenAPI 3.1 document for /bff/v0
|
| GET | /fhir/metadata |
FHIR CapabilityStatement |
| Method | Path | Permission | Notes |
|---|---|---|---|
| GET | /fhir/Patient |
patient.read |
searchset Bundle. Unsupported parameters are rejected, not ignored. |
| GET | /fhir/Patient/{id} |
patient.read |
A cross-tenant miss is reported as 404, never 403. |
| POST | /fhir/Patient |
patient.write |
201 with an absolute Location header. |
| ALL | /fhir/* |
chain only | Catch-all 404 as an OperationOutcome. |
Patient is the only routed FHIR resource. There is no update or delete interaction. The declared interactions are read, search-type, and create, and the CapabilityStatement is generated from the same registry that validates search parameters, so it cannot advertise something the server will refuse.
Twenty-three mapper pairs exist in packages/fhir covering the rest of the clinical, scheduling, and financial resources. They are tested but not yet reachable over HTTP. See FHIR boundary.
Base path /bff/v0. Explicitly unstable; it changes with the screens it serves.
Patients
| Method | Path | Operation | Permission |
|---|---|---|---|
| GET | /bff/v0/patients |
listPatients |
patient.read |
| GET | /bff/v0/patients/{id} |
readPatient |
patient.read |
| POST | /bff/v0/patients |
createPatient |
patient.write |
| PATCH | /bff/v0/patients/{id} |
updatePatient |
patient.write |
Search accepts a free-text q across given, family, preferred name, and MRN, plus mrn, family, given, birthDate, and active. Sorts by familyName, birthDate, or createdAt.
Appointments
| Method | Path | Operation | Permission |
|---|---|---|---|
| GET | /bff/v0/appointments |
listAppointments |
appointment.read |
| GET | /bff/v0/appointments/{id} |
readAppointment |
appointment.read |
| POST | /bff/v0/appointments |
createAppointment |
appointment.write |
| PATCH | /bff/v0/appointments/{id} |
updateAppointment |
appointment.write |
Every appointment route additionally calls assertFacilityAccess. On create, the facility check runs before the write, so an unauthorised booking never reaches the database. On read and update it runs against the row that was found.
Range filtering is from inclusive and to exclusive. Sorts by start or createdAt, defaulting to start.
Seven aggregates are mounted, authenticated, authorised, and then answer 501: encounters, orders, results, claims, payments, tasks, and forms. Each mounts four routes, so 28 routes exist in this state.
The ordering is the interesting part and is asserted by tests. An anonymous caller gets 401. A caller with the wrong role gets 403 and an audit record of the denial. Only a caller who would otherwise have been allowed sees the 501, whose detail names the workstream that owns the aggregate.
That means the status code never tells someone who could not have used a feature whether it exists.
No route and no stub: practitioners, facilities and organisations, users, coverage, documents, medications, allergies, conditions, immunizations, observations, schedules and slots, and any authentication or token endpoint.
The static resolver accepts four synthetic tokens across two synthetic tenants:
| Token | Role | Tenant |
|---|---|---|
dev-clinician-a |
clinician | A |
dev-frontdesk-a |
front desk | A |
dev-biller-a |
biller | A |
dev-clinician-b |
clinician | B |
Having a principal in a second tenant makes cross-tenant behaviour testable by hand:
# Read a patient in tenant A
curl -H 'Authorization: Bearer dev-clinician-a' \
http://localhost:4000/bff/v0/patients/<id>
# The same id with tenant B's token returns 404, not 403
curl -H 'Authorization: Bearer dev-clinician-b' \
http://localhost:4000/bff/v0/patients/<id>This resolver performs no signature verification and is a development fixture. See Security model.
Data access sits behind interfaces in apps/api/src/repositories/types.ts, with two implementations. The in-memory one backs the tests. The Prisma one runs at runtime.
The interfaces take no tenant parameter. RepositoryRegistry.forRequest({ tenantId, audit }) binds a tenant and an audit collector per request, so a handler has no way to name another organisation.
Two Prisma constraints follow from the tenant client extension and are documented in db-port.ts: reads use findFirst rather than findUnique, and writes use updateMany rather than update. The extension rewrites where into a compound AND predicate, which is not a legal filter for the unique-argument variants.
Two variables, validated by a Zod schema in apps/api/src/env.ts:
| Variable | Type | Default |
|---|---|---|
PORT |
integer, 1 to 65535 | 4000 |
NODE_ENV |
development, test, or production
|
development |
Validation failure throws a message naming the offending variables and never echoing their values.
NODE_ENV is additionally read directly in app.ts for two purposes: gating the production wiring assertion, and skipping request logging under test.
There is no .env loader in apps/api. DATABASE_URL is read by @openrunic/database, not by the API.
Sixteen suites under apps/api/src/__tests__/, covering the app, audit, environment, errors, FHIR, middleware, OpenAPI, policy, both repository implementations, the patient and appointment routers, the stub behaviour, schemas, and tenant isolation.
Everything drives the real application through app.request(...) against the in-memory repositories. No database, no port binding, no HTTP server. That is what makes the suite fast enough to shard.
Coverage uses the istanbul provider with src/index.ts excluded as entry wiring. There are no thresholds in the vitest config; floors are enforced by CI on the merged coverage map.
openrunic is an open-source operating system for human health. Pre-alpha: do not run it in production, and never put real patient data into it.
Repository · Licence (AGPL-3.0-only) · Security policy · Contributing · Code of conduct
Where this wiki and the repository disagree, the repository is right.