GraphQL API Runtime Contract
This document describes the currently supported GraphQL runtime boundary. The
generated schema in internal/api/graphql/schema.graphql remains the complete
type reference.
Current status
Slice 1.1c exposes one authenticated preview capability. Phase 4 Slice 4.1a adds per-request OIDC human identity to the same bounded transport:
Mapping Studio or GraphQL client
-> authenticated bounded GraphQL POST
-> previewIntegrationMessage
-> server-owned integration binding
-> canonical MessageProcessor
-> raw-free ProcessResult projection
The preview mutation remains side-effect-free and cannot persist a receipt or invoke a destination. Durable production submission and delivery use the separate authenticated ingress and shared processor paths; Integration Session operations retain their own PostgreSQL-backed authoring lifecycle.
The response omits the source bytes, secrets, and executable configuration. Its canonical event payload can still contain PHI and must be handled accordingly.
Startup security boundary
fi-fhir serve fails startup unless its common values and exactly one
authentication mode are valid. Artifact and tenant boundaries come from
deployment configuration, never GraphQL input.
| Variable | Requirement |
|---|---|
FI_FHIR_DEPLOYMENT_TENANT_ID | Canonical deployment security-domain ID |
FI_FHIR_GRAPHQL_AUTH_MODE | static (default compatibility path) or oidc |
FI_FHIR_GRAPHQL_ALLOWED_ORIGINS | Comma-separated exact HTTP(S) origins; no wildcard |
FI_FHIR_INTEGRATION_REGISTRY_PATH | Strict immutable registry JSON for the same tenant |
Static mode additionally requires:
| Variable | Requirement |
|---|---|
FI_FHIR_GRAPHQL_PRINCIPAL_ID | Server-owned compatibility principal ID |
FI_FHIR_GRAPHQL_ROLES | Comma-separated roles; integration:preview is required |
FI_FHIR_GRAPHQL_BEARER_TOKEN | Direct secret of at least 24 canonical bytes |
FI_FHIR_GRAPHQL_BEARER_TOKEN_FILE | Preferred production secret-file path |
Set exactly one token source. The registry is bounded, rejects unknown fields, and verifies the definition, profile, and workflow digests before startup.
Static mode can also accept one separate service credential without changing the existing IDE token or its roles. Set both of these values, or neither:
| Variable | Requirement |
|---|---|
FI_FHIR_GRAPHQL_SERVICE_BEARER_TOKEN_FILE | Mounted single-line secret file; 24 or more canonical token bytes, at most 4096 file bytes |
FI_FHIR_GRAPHQL_SERVICE_PRINCIPAL_ID | Canonical service ID, distinct from the IDE principal |
The service token must differ from the IDE token. It authenticates as a service
principal in FI_FHIR_DEPLOYMENT_TENANT_ID with exactly integration.operator
and integration:preview. There is no service-role or service-tenant override.
It can read the operator control plane and preview a server-bound integration;
it cannot read clinical event/patient queries, recover deliveries, change
deployments, or use the broad graphql:operator grant. The caller must use only
synthetic data when exercising a synthetic preview workflow: the credential
itself does not classify payload contents. Both identities keep separate roles
and attribution, and credential files are read at startup. OIDC mode rejects
these service settings.
For service previews, the adapter supplies the source ID from the selected server-owned registry binding. An existing, different principal source binding is rejected; the shared processor's source validation remains in force.
Operator reads need durable stores independently of authentication. Set
FI_FHIR_OPERATOR_CONTROL_PLANE_ENABLED=true with the existing PostgreSQL
database configuration to initialize the submission/delivery, lifecycle, and
destination schemas under fi-fhir serve. This defaults to false and is rejected by the
preview-only command. It does not enable HTTP/MLLP/batch ingestion, delivery
workers, or Integration Sessions. Startup applies the existing idempotent
migrations, so the database identity needs migration privileges. Existing
deployments that already initialize these stores retain their behavior.
OIDC mode instead requires:
| Variable | Requirement |
|---|---|
FI_FHIR_GRAPHQL_OIDC_ISSUER_URL | Exact HTTPS issuer discovery URL |
FI_FHIR_GRAPHQL_OIDC_AUDIENCE | Exact accepted token audience |
FI_FHIR_GRAPHQL_OIDC_TENANT_CLAIM | Optional claim name; defaults to tenant_id |
FI_FHIR_GRAPHQL_OIDC_ROLES_CLAIM | Optional claim name; defaults to roles |
FI_FHIR_GRAPHQL_OIDC_SIGNING_ALGS | Optional allowlist; defaults to RS256 |
The verifier accepts signed JWT access tokens with the protected header
typ=at+jwt; generic JWT/typeless tokens are rejected to prevent ID-token
substitution. It validates the issuer, one exact audience, signature, allowed
algorithm, expiry, not-before, and subject before accepting exact
deployment-tenant and strict role claims. It maps sub to a human request
principal. This token-class check is not a claim of complete RFC 9068
conformance.
The discovered jwks_uri and all outbound requests must remain HTTPS; redirects
are rejected.
The client has a 10-second maximum request timeout, a 1 MiB response cap for
discovery and JWKS documents, and a 30-second default floor between outbound
unknown-key refreshes. Providers
should publish new keys before issuing tokens that use them; a token for a newly
introduced key can otherwise fail until the refresh floor elapses. OIDC mode
rejects all static principal, roles, bearer-token, token-file, and trusted-CIDR
settings so no compatibility bypass is ambiguous.
Implementation: cmd/fi-fhir/preview_runtime.go,
internal/api/requestsecurity/auth.go, and
internal/integration/registry/static.go.
Cloudflare Access identity (either mode)
When Cloudflare Access fronts the deployment, the edge attaches the signed
application token to every request as Cf-Access-Jwt-Assertion (the browser
holds the same token as the CF_Authorization cookie). Either mode accepts that
identity when all three of these are set; setting one or two fails startup:
| Variable | Requirement |
|---|---|
FI_FHIR_GRAPHQL_ACCESS_TEAM_DOMAIN | https://<team>.cloudflareaccess.com; the issuer and discovery root |
FI_FHIR_GRAPHQL_ACCESS_AUDIENCE | The Access application's AUD tag, one exact value |
FI_FHIR_GRAPHQL_ACCESS_PRINCIPALS | email=role,role;email=role; every entry includes integration:preview |
The verifier discovers the team domain like an OIDC issuer (same HTTPS-only,
size, timeout, and refresh bounds), accepts RS256 with typ=JWT or no typ —
the bearer verifiers' at+jwt class is rejected here and vice versa — and
requires one exact audience, type=app, and an email. It grants only the
roles the map names for that address, compared case-insensitively; an identity
Access admits but the map does not name is rejected, as are service tokens. The
token carries no role or tenant claim, which is why authorization is deployment
configuration and why this is a layer beside the bearer modes rather than a
third FI_FHIR_GRAPHQL_AUTH_MODE.
Precedence: an explicit Authorization header is judged alone, followed by
trusted-network identity and then the Access assertion only when the header is
absent. An invalid, empty, or repeated bearer header returns 401 even on a
trusted network. This intentionally replaces the former stale-token LAN
fallback, preventing a narrow service credential from inheriting IDE roles.
Headerless LAN/Access sessions and valid IDE tokens keep their existing access.
The IDE sends headerless requests after its authenticated status probe.
/api/auth/status reports "authVia":"cloudflare-access" and the verified
email as principal for a verified session, with the roles the map grants it
and what they unlock (see the status contract).
Implementation: internal/api/requestsecurity/cloudflare_access.go.
Authorization
The transitional integration:preview role permits only:
Query.healthMutation.previewIntegrationMessage__typenameon either allowed root operation
Aliases and fragments do not bypass this root-field allowlist. Subscriptions and every legacy query or mutation are forbidden for the preview role.
Per-root-field roles
Since Sprint 4 (Lane S4-E) the transport gate is an enumeration, not a boolean.
Every one of the 131 root fields — 64 Query, 60 Mutation, 7 Subscription —
names the roles a caller must hold, and a root field with no entry is refused.
TestTransportGateRoleMapIsExhaustive compares the map against the schema the
server actually executes, so a new root field cannot ship without a role
decision.
A field's requirement is an AND-set, matching what the service behind it demands
(internal/integration/operator/service.go), so the gate can never be more
permissive than the service. Twenty-six fields have fine-grained requirements:
| Requirement | Root fields |
|---|---|
integration.operator | operatorReceipts, operatorMessageTrace, operatorDeliveryAttempts, operatorDeliveryAttempt, operatorDeadLetters, operatorCircuits, operatorAttemptAudit, operatorDeployments, operatorDeploymentEvents |
integration.operator + integration.delivery.operator | replayDelivery, resubmitMessage, discardDeadLetter |
integration.operator + integration.deployment.operator | pauseIntegrationDeployment, resumeIntegrationDeployment, retireIntegrationDeployment, deployIntegrationRelease |
clinical:read | event, events, patient, patients, patientTimeline, eventStatistics, activeEncounters, activeEncounter, activeEncounterByPatient, projectionStatus |
integration.phi.export is not in the table on purpose. It gates the
includeRawPayload argument of exportIntegrationBundle, not any field, so a
token holding only that grant reaches nothing at the transport gate. The
colon-form integration:submit / integration:mllp / integration:batch
grants are minted by the ingress, MLLP, and batch transports for their own
principals and are never carried by a GraphQL token, so the submit mutations are
not mapped to them either.
The compatibility grant
graphql:operator is a named compatibility grant that expands to all 131
root fields. A token holding it behaves exactly as it did before the narrowing.
It is deprecated, not removed: the remaining 105 root fields — the legacy
workflow catalog, FHIR subscriptions, the integration session workspace,
profiles, LLM, terminology and autoroute review, Temporal, the debugger, and
all seven subscriptions — have no shipped
fine-grained role and are reachable only through it. Each one carries an
explicit entry and a TODO naming its follow-up slice, so the ungoverned
surface is enumerable rather than implicit.
Do not assign graphql:operator to a normal IDE caller, and do not swap it out
of an existing operator token for the fine-grained roles — that keeps the
control plane and loses the whole IDE. serve prints the mapping's shape at
startup so a deployment still relying on the grant says so in its own log.
Implementation: internal/api/graphql/operation_authorization.go and
internal/api/graphql/operation_authorization_roles.go. The narrowing is
defence in depth: every request that clears the gate is authorized again by the
service that answers it.
What an operator's token must carry
graphql:operator gets a request past the transport gate to every root
field, including the operator control plane's. It does not get it past the
control plane: operator.Service re-checks its own roles and answers
operator control-plane action forbidden to a caller that holds only the
grant. The two vocabularies are deliberate — the service roles are the control
plane's defence in depth — so an operator identity is granted both:
integration:preview,graphql:operator,clinical:read,integration.operator,integration.delivery.operator,integration.deployment.operator
Put that list wherever the identity's roles come from:
FI_FHIR_GRAPHQL_ROLES (the static bearer, and the trusted network, which
inherits it) and each operator's entry in FI_FHIR_GRAPHQL_ACCESS_PRINCIPALS.
OIDC tokens carry it in their roles claim. Drop integration.delivery.operator
or integration.deployment.operator for an operator who should read the
control plane without recovering deliveries or changing deployments.
serve logs one warning per configured identity that holds graphql:operator
without integration.operator — transport grant without control-plane role: operator surfaces will be forbidden — naming the identity (principal_id,
mode) and the missing roles (reason). It is never fatal. From 2026-09-05 to
2026-09-25 production ran in exactly that state and nothing said so.
The /api/auth/status contract
GET /api/auth/status needs no credential and grants nothing; GraphQL
re-authenticates and re-authorizes every operation. It resolves the caller the
way /graphql does — an Authorization header is judged alone, then the
trusted network, then the Cloudflare Access assertion — and reports that
identity and what it can reach. With production's 2026-09 grant, from the
trusted network:
{
"authenticated": true,
"authVia": "network",
"principal": "fi-fhir-ide-operator",
"roles": ["integration:preview", "graphql:operator", "clinical:read"],
"capabilities": {
"operatorRead": false,
"operatorDelivery": false,
"operatorDeployment": false,
"clinicalRead": true,
"connectionsRead": false,
"connectionsWrite": false,
"integrationSessions": false,
"streaming": false,
"subscriptions": [],
"llm": {"configured": true},
"controlPlane": false,
"connectionCatalog": false,
"phiExport": false,
"definitionAuthoring": false
},
"missingRoles": {
"operatorRead": ["integration.operator"],
"operatorDelivery": ["integration.operator", "integration.delivery.operator"],
"operatorDeployment": ["integration.operator", "integration.deployment.operator"],
"clinicalRead": [],
"connectionsRead": ["integration.operator"],
"connectionsWrite": ["integration.operator", "integration.deployment.operator"],
"phiExport": ["integration.phi.export"],
"definitionAuthoring": ["integration.operator", "integration.deployment.operator"]
}
}
TestAuthStatusDocumentedExampleMatches fails if this example drifts from what
the handler serves.
| Key | Meaning |
|---|---|
authVia | The identity's authentication method: network, bearer, service-bearer, cloudflare-access, oidc, or oauth2-client-credentials |
principal | The principal ID — FI_FHIR_GRAPHQL_PRINCIPAL_ID, the service principal, the OIDC subject, or the Access email |
roles | The roles the transport gate authorizes this caller with |
capabilities.operatorRead / operatorDelivery / operatorDeployment / clinicalRead | The caller clears both the transport gate and the service behind it for that surface |
missingRoles.<capability> | Exactly the roles that would make that capability true; empty when it already is |
capabilities.integrationSessions | The deployment has the durable Integration Session store |
capabilities.streaming | The deployment serves the session SSE transport (FI_FHIR_INTEGRATION_SESSION_ENABLED) |
capabilities.subscriptions | The subscription roots the stream will accept for this caller; [] when streaming is off |
capabilities.llm.configured | serve built an LLM client at startup; whether it answers is llmCapability's question |
capabilities.phiExport | The caller may export a session bundle with raw sample payloads (exportIntegrationBundle(includeRawPayload: true)): it clears the export field's transport gate and holds integration.phi.export, which the session store re-checks. An export without raw payloads needs neither the grant nor this capability |
capabilities.definitionAuthoring | The deployment composed the definition editor: lifecycle catalog, connection catalog, static registry resolver (.loom/42 E-1). missingRoles.definitionAuthoring names the roles its writes need |
The stream accepts only integrationSessionEvents and sessionRunEvents, so
eventStream, workflowEvents, patientEvents, liveParseStream, and
debugStepEvent never appear in subscriptions: the SSE transport refuses them
even with streaming on, because re-enabling them needs the separate bounded
transport design described under WebSocket.
An unauthenticated caller — including a stale, empty, or repeated bearer —
gets exactly {"authenticated":false}. The body never carries a token, the
tenant, a hostname, or any message-derived value. Role names and principal IDs
are deployment configuration, shown only to a caller already authenticated as
that identity, and they grant nothing by being known. Implementation:
internal/api/graphql/capabilities.go.
Transport policy
HTTP
GET /metadata is an unauthenticated, PHI-free FHIR R4 metadata route. It
returns application/fhir+json with the resources emitted by the US Core
mapper. The CapabilityStatement deliberately declares no read or search
interactions because fi-fhir delivers mapped resources to configured
destinations rather than serving them through a FHIR REST repository.
- Path:
/graphql - Method:
POSTonly;OPTIONSis accepted only for CORS preflight - Content type:
application/json - Maximum complete request body: 1 MiB
- Authentication:
Authorization: Bearer <token>verified by the selected static or OIDC authenticator - Origins: exact allowlist match for browser requests
- Rejected: query-string operations, multipart, compressed bodies, wildcard or reflected origins, and GET queries
- Query limits: depth 10 and complexity 1000 in the default runtime
Non-browser clients may omit Origin, but they still require authentication.
CORS responses never enable credentialed wildcard access.
WebSocket
- Path:
/graphql/wsreturns404 - No WebSocket transport is registered by
fi-fhir serve - The UI subscription adapter fails locally without resolving a credential, opening a socket, or retrying
This containment is deliberate: gqlgen does not provide a bounded pre-authentication frame limit, and query or mutation execution over WebSocket would bypass the POST-only body boundary. Re-enabling subscriptions requires a separate bounded, authorized transport design.
Implementation: internal/api/graphql/server.go and
ui/src/lib/graphql/subscriptions.ts.
Preview mutation
The caller owns only the browser-safe integration key, source bytes, correlation ID, and human reason:
input PreviewIntegrationMessageInput {
integrationId: ID!
data: String!
correlationId: ID!
reason: String!
}
type Mutation {
previewIntegrationMessage(
input: PreviewIntegrationMessageInput!
): IntegrationPreviewResult!
}
The server-owned registry supplies tenant, source, format, classification,
integration revision, profile revision, and workflow revision. The service
requires integration:preview, limits the raw message to 1 MiB, and calls the
same MessageProcessor used by direct kernel tests.
The typed result contains:
- exact integration, source, profile, and workflow provenance;
- canonical events and catalog-safe diagnostics;
- matched/skipped routes and planned action IDs;
- suppressed delivery plans; and
- correlation and trace identifiers.
It has no raw-message field, receipt, persisted run, secret, workflow source, or executable client.
Implementation: internal/integration/preview/service.go,
internal/api/graphql/resolvers/integration_preview.go, and
ui/src/lib/graphql/integrationPreview.graphql.
Mapping Studio credential and PHI handling
The Mapping Studio requires an operator to paste the deployment bearer before
the IDE renders. It validates the credential through health and then holds it
only in the current tab's JavaScript memory. It is not read from a PUBLIC_*
build variable and is never written to localStorage or sessionStorage.
Imported raw HL7 samples and filename-derived source labels also remain only in the current store instance. Clear access or reload the page to discard the credential; reload the page to discard samples and source labels. Browser memory is still PHI-bearing while the tab is open, so use only approved workstations and redact fixtures whenever possible.
On startup, the UI removes the two legacy localStorage keys used by earlier releases for raw samples and recent source labels. Other browser preferences are preserved.
Implementation: ui/src/lib/graphql/GraphQLCredentialGate.svelte,
ui/src/lib/graphql/credentials.ts, and
ui/src/lib/features/hl7/samples/sampleStore.ts.
Legacy containment
The generated schema still describes older authoring and execution operations for compatibility and future migration. They are not part of the preview capability.
The default resolver configuration fails these paths closed:
submitMessage,submitEvent, andsubmitBatch;triggerWorkflow;parsePreviewandparsePreviewWithProfile;- session sample, run, retained-raw export, and live-parse paths; and
- the profile-YAML HTTP endpoints and unauthenticated generic ingest webhook,
which are no longer mounted by
serve. The UI profile-YAML adapter fails locally and the canonical UI proxy exposes no/apifallback.
Containment tests require zero persistence and zero workflow-action calls. Production submit remains unavailable until the durable receipt/idempotency/ outbox boundary ships.
Implementation: internal/api/graphql/resolvers/schema.resolvers.go,
internal/api/graphql/resolvers/legacy_containment_test.go, and
cmd/fi-fhir/main.go.
Verification
Focused local checks for this boundary:
go test ./cmd/fi-fhir
go test ./internal/integration/registry
go test ./internal/api/graphql -run 'TestServerRejects|TestGraphQLHTTPBoundary|TestGraphQLWebSocketTransportIsDisabled|TestAuthenticatedPreviewTransport|TestPreviewTransportCatalog' -v
bash scripts/smoke-test_test.sh
MR, default-branch pipeline, image, and live rollout evidence are pending until the Slice 1.1c release candidate ships.