From 4ff0b95f998028934f980f992e39b6052c0423db Mon Sep 17 00:00:00 2001 From: Salih Yilboga Date: Thu, 3 Sep 2026 09:54:57 +0300 Subject: [PATCH] Add FHIR R4, SMART on FHIR, HL7 v2, eval/drift monitoring, and clinical MCP control plane Implements the full clinical-capability backlog plus its UI wiring: - FHIR R4 read-only facade (Patient/ImagingStudy/DiagnosticReport/DocumentReference) - SMART on FHIR: resource-server discovery/scope enforcement, and a full client-role launch flow (PKCE, trusted-issuer allowlist, token storage) - gated behind an already-authenticated ModelForge session by design - HL7 v2: ER7 parser/builder, ORU^R01 outbound, inbound ORU/ADT parsing, ACK/NACK, an MLLP TCP listener, and a match/apply/review ingestion pipeline with the same "ambiguous match always needs human review" rule imaging's own DICOM patient matching uses - Evidence provenance citations for scalar case fields, prompt versioning, online eval/drift monitoring, and quality-aware multi-model routing - Electron UI: External EHR page (trusted-issuer admin, SMART launch trigger, session management) and an HL7 Inbox review queue, both wired through new IPC handlers and preload bridges - Managed clinical MCP integration: short-lived context grants, exact-operation approval tickets, and structured operation provenance for modelforge-clinical-mcp Each capability's scope and disclosed gaps are documented in docs/{FHIR_INTEGRATION,SMART_LAUNCH,HL7_V2_INTEGRATION,CLINICAL_AI_EVALUATION, CLINICAL_MCP_INTEGRATION}.md. Full server/app/frontend suites passing, 0 Semgrep findings across touched files. Co-Authored-By: Claude Sonnet 5 --- app/src/clinical-mcp-broker.test.ts | 67 ++++ app/src/clinical-mcp-broker.ts | 84 +++++ app/src/hl7-client.ts | 49 +++ app/src/ipc/agent-handlers.ts | 11 +- app/src/ipc/mcp-handlers.ts | 5 + app/src/ipc/shared-backend-handlers.ts | 46 +++ app/src/managed-mcp-policy.test.ts | 10 + app/src/managed-mcp-policy.ts | 44 +++ app/src/mcp-client.ts | 33 +- app/src/mcp-oauth.test.ts | 6 + app/src/mcp-oauth.ts | 8 +- app/src/preload.ts | 27 +- app/src/providers/types.ts | 3 + app/src/schemas.ts | 1 + app/src/shared-backend-client.ts | 51 +++ app/src/smart-launch-client.ts | 102 ++++++ app/src/smart-launch-flow.ts | 72 ++++ docs/CLINICAL_AI_EVALUATION.md | 44 ++- docs/CLINICAL_AI_GATEWAY.md | 33 +- docs/CLINICAL_MCP_INTEGRATION.md | 169 ++++++++++ docs/FHIR_INTEGRATION.md | 109 ++++++ docs/HL7_V2_INTEGRATION.md | 144 ++++++++ docs/SMART_LAUNCH.md | 148 ++++++++ frontend/src/App.tsx | 4 + frontend/src/components/layout.tsx | 4 + frontend/src/lib/translations.ts | 6 + frontend/src/pages/Chat.tsx | 32 +- frontend/src/pages/ExternalEhr.tsx | 227 +++++++++++++ frontend/src/pages/Hl7Inbox.tsx | 174 ++++++++++ frontend/src/pages/Settings.tsx | 18 + frontend/src/types/electron.d.ts | 37 +- infra/imaging-cdk/package-lock.json | 2 +- infra/imaging-cdk/package.json | 2 +- packages/contracts/src/ai-gateway.ts | 7 + packages/contracts/src/fhir.ts | 276 +++++++++++++++ packages/contracts/src/hl7.ts | 78 +++++ packages/contracts/src/index.ts | 16 + packages/contracts/src/mcp-clinical.ts | 87 +++++ packages/contracts/src/smart-launch.ts | 89 +++++ .../023_ai_output_prompt_version.sql | 233 +++++++++++++ server/migrations/024_hl7_ingestion.sql | 190 +++++++++++ .../025_mcp_clinical_control_plane.sql | 61 ++++ server/migrations/026_smart_launch.sql | 211 ++++++++++++ .../src/ai-gateway/data-minimization.test.ts | 17 + server/src/ai-gateway/data-minimization.ts | 29 +- server/src/ai-gateway/gateway.test.ts | 180 +++++++++- server/src/ai-gateway/gateway.ts | 149 ++++++-- server/src/ai-gateway/model-router.test.ts | 166 +++++++++ server/src/ai-gateway/model-router.ts | 149 ++++++++ server/src/ai-gateway/prompt-registry.test.ts | 29 ++ server/src/ai-gateway/prompt-registry.ts | 61 ++++ server/src/app.ts | 52 ++- server/src/auth/oidc-verifier.test.ts | 65 +++- server/src/auth/oidc-verifier.ts | 71 +++- server/src/config.test.ts | 34 ++ server/src/config.ts | 56 +++ server/src/domain/action-catalog.ts | 22 ++ server/src/domain/types.ts | 6 + .../eval-harness/production-monitor.test.ts | 130 +++++++ server/src/eval-harness/production-monitor.ts | 160 +++++++++ server/src/fhir/capability-statement.ts | 32 ++ server/src/fhir/mappers.test.ts | 135 ++++++++ server/src/fhir/mappers.ts | 133 ++++++++ server/src/fhir/smart-configuration.ts | 24 ++ server/src/fhir/smart-scopes.test.ts | 38 +++ server/src/fhir/smart-scopes.ts | 49 +++ server/src/hl7/ack-builder.test.ts | 50 +++ server/src/hl7/ack-builder.ts | 66 ++++ server/src/hl7/adt-parser.test.ts | 34 ++ server/src/hl7/adt-parser.ts | 46 +++ server/src/hl7/inbound-parser.test.ts | 77 +++++ server/src/hl7/inbound-parser.ts | 88 +++++ server/src/hl7/ingestion.test.ts | 137 ++++++++ server/src/hl7/ingestion.ts | 188 +++++++++++ server/src/hl7/message.test.ts | 176 ++++++++++ server/src/hl7/message.ts | 241 +++++++++++++ server/src/hl7/mllp-handler.test.ts | 101 ++++++ server/src/hl7/mllp-handler.ts | 63 ++++ server/src/hl7/mllp-server.test.ts | 141 ++++++++ server/src/hl7/mllp-server.ts | 167 +++++++++ server/src/hl7/oru-builder.test.ts | 134 ++++++++ server/src/hl7/oru-builder.ts | 129 +++++++ server/src/index.ts | 92 ++++- server/src/mcp-approval-issuer.test.ts | 31 ++ server/src/mcp-approval-issuer.ts | 49 +++ .../src/routes/ai-gateway.integration.test.ts | 53 +++ server/src/routes/ai-gateway.ts | 47 ++- server/src/routes/deps.ts | 29 +- server/src/routes/fhir.integration.test.ts | 195 +++++++++++ server/src/routes/fhir.ts | 145 ++++++++ server/src/routes/hl7.integration.test.ts | 278 +++++++++++++++ server/src/routes/hl7.ts | 144 ++++++++ .../routes/mcp-clinical.integration.test.ts | 149 ++++++++ server/src/routes/mcp-clinical.ts | 171 ++++++++++ server/src/routes/mcp-registry.ts | 40 ++- server/src/routes/params.ts | 18 + .../routes/smart-launch.integration.test.ts | 319 ++++++++++++++++++ server/src/routes/smart-launch.ts | 153 +++++++++ server/src/smart-launch/discovery.test.ts | 66 ++++ server/src/smart-launch/discovery.ts | 57 ++++ server/src/smart-launch/pkce.test.ts | 40 +++ server/src/smart-launch/pkce.ts | 35 ++ server/src/smart-launch/service.test.ts | 187 ++++++++++ server/src/smart-launch/service.ts | 201 +++++++++++ server/src/smart-launch/token-crypto.test.ts | 44 +++ server/src/smart-launch/token-crypto.ts | 51 +++ server/src/store/ai-gateway-store.ts | 9 + server/src/store/hl7-ingestion-store.ts | 27 ++ .../store/in-memory-ai-gateway-store.test.ts | 8 +- .../src/store/in-memory-ai-gateway-store.ts | 7 +- .../store/in-memory-hl7-ingestion-store.ts | 67 ++++ .../src/store/in-memory-smart-launch-store.ts | 122 +++++++ server/src/store/mcp-clinical-store.test.ts | 27 ++ server/src/store/mcp-clinical-store.ts | 201 +++++++++++ server/src/store/mcp-registry-store.ts | 29 +- server/src/store/postgres-ai-gateway-store.ts | 14 +- .../postgres-hl7-ingestion-store.test.ts | 48 +++ .../src/store/postgres-hl7-ingestion-store.ts | 107 ++++++ .../store/postgres-smart-launch-store.test.ts | 45 +++ .../src/store/postgres-smart-launch-store.ts | 137 ++++++++ server/src/store/smart-launch-store.ts | 90 +++++ 121 files changed, 10079 insertions(+), 98 deletions(-) create mode 100644 app/src/clinical-mcp-broker.test.ts create mode 100644 app/src/clinical-mcp-broker.ts create mode 100644 app/src/hl7-client.ts create mode 100644 app/src/smart-launch-client.ts create mode 100644 app/src/smart-launch-flow.ts create mode 100644 docs/CLINICAL_MCP_INTEGRATION.md create mode 100644 docs/FHIR_INTEGRATION.md create mode 100644 docs/HL7_V2_INTEGRATION.md create mode 100644 docs/SMART_LAUNCH.md create mode 100644 frontend/src/pages/ExternalEhr.tsx create mode 100644 frontend/src/pages/Hl7Inbox.tsx create mode 100644 packages/contracts/src/fhir.ts create mode 100644 packages/contracts/src/hl7.ts create mode 100644 packages/contracts/src/mcp-clinical.ts create mode 100644 packages/contracts/src/smart-launch.ts create mode 100644 server/migrations/023_ai_output_prompt_version.sql create mode 100644 server/migrations/024_hl7_ingestion.sql create mode 100644 server/migrations/025_mcp_clinical_control_plane.sql create mode 100644 server/migrations/026_smart_launch.sql create mode 100644 server/src/ai-gateway/model-router.test.ts create mode 100644 server/src/ai-gateway/model-router.ts create mode 100644 server/src/ai-gateway/prompt-registry.test.ts create mode 100644 server/src/ai-gateway/prompt-registry.ts create mode 100644 server/src/eval-harness/production-monitor.test.ts create mode 100644 server/src/eval-harness/production-monitor.ts create mode 100644 server/src/fhir/capability-statement.ts create mode 100644 server/src/fhir/mappers.test.ts create mode 100644 server/src/fhir/mappers.ts create mode 100644 server/src/fhir/smart-configuration.ts create mode 100644 server/src/fhir/smart-scopes.test.ts create mode 100644 server/src/fhir/smart-scopes.ts create mode 100644 server/src/hl7/ack-builder.test.ts create mode 100644 server/src/hl7/ack-builder.ts create mode 100644 server/src/hl7/adt-parser.test.ts create mode 100644 server/src/hl7/adt-parser.ts create mode 100644 server/src/hl7/inbound-parser.test.ts create mode 100644 server/src/hl7/inbound-parser.ts create mode 100644 server/src/hl7/ingestion.test.ts create mode 100644 server/src/hl7/ingestion.ts create mode 100644 server/src/hl7/message.test.ts create mode 100644 server/src/hl7/message.ts create mode 100644 server/src/hl7/mllp-handler.test.ts create mode 100644 server/src/hl7/mllp-handler.ts create mode 100644 server/src/hl7/mllp-server.test.ts create mode 100644 server/src/hl7/mllp-server.ts create mode 100644 server/src/hl7/oru-builder.test.ts create mode 100644 server/src/hl7/oru-builder.ts create mode 100644 server/src/mcp-approval-issuer.test.ts create mode 100644 server/src/mcp-approval-issuer.ts create mode 100644 server/src/routes/fhir.integration.test.ts create mode 100644 server/src/routes/fhir.ts create mode 100644 server/src/routes/hl7.integration.test.ts create mode 100644 server/src/routes/hl7.ts create mode 100644 server/src/routes/mcp-clinical.integration.test.ts create mode 100644 server/src/routes/mcp-clinical.ts create mode 100644 server/src/routes/smart-launch.integration.test.ts create mode 100644 server/src/routes/smart-launch.ts create mode 100644 server/src/smart-launch/discovery.test.ts create mode 100644 server/src/smart-launch/discovery.ts create mode 100644 server/src/smart-launch/pkce.test.ts create mode 100644 server/src/smart-launch/pkce.ts create mode 100644 server/src/smart-launch/service.test.ts create mode 100644 server/src/smart-launch/service.ts create mode 100644 server/src/smart-launch/token-crypto.test.ts create mode 100644 server/src/smart-launch/token-crypto.ts create mode 100644 server/src/store/hl7-ingestion-store.ts create mode 100644 server/src/store/in-memory-hl7-ingestion-store.ts create mode 100644 server/src/store/in-memory-smart-launch-store.ts create mode 100644 server/src/store/mcp-clinical-store.test.ts create mode 100644 server/src/store/mcp-clinical-store.ts create mode 100644 server/src/store/postgres-hl7-ingestion-store.test.ts create mode 100644 server/src/store/postgres-hl7-ingestion-store.ts create mode 100644 server/src/store/postgres-smart-launch-store.test.ts create mode 100644 server/src/store/postgres-smart-launch-store.ts create mode 100644 server/src/store/smart-launch-store.ts diff --git a/app/src/clinical-mcp-broker.test.ts b/app/src/clinical-mcp-broker.test.ts new file mode 100644 index 0000000..84b5c40 --- /dev/null +++ b/app/src/clinical-mcp-broker.test.ts @@ -0,0 +1,67 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { modelVisibleClinicalSchema, prepareClinicalMcpArguments } from "./clinical-mcp-broker"; +import * as patientCases from "./patient-cases-store"; +import * as backend from "./shared-backend-client"; + +vi.mock("./patient-cases-store"); +vi.mock("./shared-backend-client"); + +const policy = { entryId: "10000000-0000-4000-8000-000000000001", organizationId: "10000000-0000-4000-8000-000000000002", allowedTools: "*" as const, dataEgressPolicy: "unrestricted" as const, integrationProfile: "modelforge-clinical" as const }; + +describe("clinical MCP broker", () => { + beforeEach(() => vi.clearAllMocks()); + + it("removes infrastructure-only fields from model-visible tool schemas", () => { + expect(modelVisibleClinicalSchema({ type: "object", properties: { contextGrantId: {}, approvalTicket: {}, idempotencyKey: {}, rationale: {} }, required: ["contextGrantId", "approvalTicket", "idempotencyKey", "rationale"] })).toEqual({ type: "object", properties: { rationale: {} }, required: ["rationale"] }); + }); + + it("hides authoritative medication fields without mutating the wire schema", () => { + const schema = { type: "object", additionalProperties: false, properties: { medications: {}, allergies: {}, contextGrantId: {} }, required: ["medications", "allergies", "contextGrantId"] }; + expect(modelVisibleClinicalSchema(schema, "clinical.medication_conflict_check")).toEqual({ type: "object", additionalProperties: false, properties: {}, required: [] }); + expect(schema.required).toEqual(["medications", "allergies", "contextGrantId"]); + expect(Object.keys(schema.properties)).toEqual(["medications", "allergies", "contextGrantId"]); + expect(modelVisibleClinicalSchema(undefined, "clinical.medication_conflict_check")).toBeUndefined(); + }); + + it("does not remove similarly named domain fields from other tools", () => { + const schema = { properties: { medications: {} }, required: ["medications"] }; + expect(modelVisibleClinicalSchema(schema, "clinical.response_contract_check")).toEqual(schema); + }); + + it("rejects missing or excluded case data before requesting a grant", async () => { + await expect(prepareClinicalMcpArguments(policy, "clinical.medication_conflict_check", {})).rejects.toThrow(/Attach a patient case/); + vi.mocked(patientCases.getCase).mockResolvedValue(null); + await expect(prepareClinicalMcpArguments(policy, "clinical.medication_conflict_check", {}, { patientCaseId: "case-1" })).rejects.toThrow(/no longer available/); + vi.mocked(patientCases.getCase).mockResolvedValue({ medications: { includeInContext: false, value: [] }, allergies: { includeInContext: true, value: [] } } as never); + await expect(prepareClinicalMcpArguments(policy, "clinical.medication_conflict_check", {}, { patientCaseId: "case-1" })).rejects.toThrow(/Include both/); + expect(backend.createMcpContextGrant).not.toHaveBeenCalled(); + }); + + it("uses medications and allergies from the attached case and injects only the grant handle", async () => { + vi.mocked(patientCases.getCase).mockResolvedValue({ medications: { includeInContext: true, value: ["warfarin"] }, allergies: { includeInContext: true, value: ["aspirin"] } } as never); + vi.mocked(backend.createMcpContextGrant).mockResolvedValue({ id: "grant-1" } as never); + await expect(prepareClinicalMcpArguments(policy, "clinical.medication_conflict_check", { medications: ["untrusted"] }, { patientCaseId: "case-1" })).resolves.toEqual({ medications: ["warfarin"], allergies: ["aspirin"], contextGrantId: "grant-1" }); + expect(backend.createMcpContextGrant).toHaveBeenCalledWith(expect.objectContaining({ caseId: "case-1", requestedFields: ["allergies", "medications"] })); + }); + + it("requires a human-approved review and injects an operation-bound ticket and idempotency key", async () => { + vi.mocked(backend.createMcpContextGrant).mockResolvedValue({ id: "grant-2" } as never); + vi.mocked(backend.prepareMcpApproval).mockResolvedValue({ approvalRequest: { id: "10000000-0000-4000-8000-000000000003" }, challenge: {} } as never); + vi.mocked(backend.confirmMcpApproval).mockResolvedValue({ approvalRequest: {}, approvalTicket: "ticket-1" } as never); + const args = { reviewedOperationId: "10000000-0000-4000-8000-000000000004", decision: "approved", rationale: "Checked." }; + await expect(prepareClinicalMcpArguments(policy, "clinical.record_review_decision", args, { patientCaseId: "case-1" })).rejects.toThrow(/explicit approval/); + expect(backend.createMcpContextGrant).not.toHaveBeenCalled(); + expect(backend.prepareMcpApproval).not.toHaveBeenCalled(); + expect(backend.confirmMcpApproval).not.toHaveBeenCalled(); + const result = await prepareClinicalMcpArguments(policy, "clinical.record_review_decision", args, { patientCaseId: "case-1", humanApproved: true }); + expect(result).toMatchObject({ ...args, contextGrantId: "grant-2", approvalTicket: "ticket-1" }); + expect(result.idempotencyKey).toEqual(expect.any(String)); + }); + + it("strips model-provided infrastructure credentials and leaves generic tools unchanged", async () => { + vi.mocked(backend.createMcpContextGrant).mockResolvedValue({ id: "trusted-grant" } as never); + const args = { assistantResponse: "draft", contextGrantId: "model-grant", approvalTicket: "model-ticket", idempotencyKey: "model-key" }; + await expect(prepareClinicalMcpArguments(policy, "clinical.response_contract_check", args, { patientCaseId: "case-1" })).resolves.toEqual({ assistantResponse: "draft", contextGrantId: "trusted-grant" }); + await expect(prepareClinicalMcpArguments({ ...policy, integrationProfile: "generic" }, "generic.tool", args)).resolves.toBe(args); + }); +}); diff --git a/app/src/clinical-mcp-broker.ts b/app/src/clinical-mcp-broker.ts new file mode 100644 index 0000000..0fbc726 --- /dev/null +++ b/app/src/clinical-mcp-broker.ts @@ -0,0 +1,84 @@ +import { randomUUID } from "node:crypto"; +import type { ManagedMcpPolicy } from "./managed-mcp-policy"; +import * as patientCases from "./patient-cases-store"; +import { confirmMcpApproval, createMcpContextGrant, prepareMcpApproval } from "./shared-backend-client"; + +export interface ClinicalMcpExecutionContext { + patientCaseId?: string; + humanApproved?: boolean; +} + +const INFRASTRUCTURE_FIELDS = new Set(["contextGrantId", "approvalTicket", "idempotencyKey"]); +const TOOL_FIELDS: Record = { + "clinical.medication_conflict_check": ["allergies", "medications"], + "clinical.response_contract_check": ["assistantResponse"], + "clinical.response_contract_check_batch": ["items"], + "clinical.record_review_decision": ["rationale"], +}; +const PURPOSE = { + "clinical.medication_conflict_check": "medication-review", + "clinical.response_contract_check": "documentation-assist", + "clinical.response_contract_check_batch": "documentation-assist", + "clinical.record_review_decision": "documentation-assist", +} as const; + +export function modelVisibleClinicalSchema(schema: Record | undefined, toolName?: string): Record | undefined { + if (!schema) return schema; + const brokerFields = new Set(INFRASTRUCTURE_FIELDS); + if (toolName === "clinical.medication_conflict_check") { + brokerFields.add("medications"); + brokerFields.add("allergies"); + } + const properties = { ...((schema.properties ?? {}) as Record) }; + for (const field of brokerFields) delete properties[field]; + const required = Array.isArray(schema.required) ? schema.required.filter((field) => typeof field !== "string" || !brokerFields.has(field)) : undefined; + return { ...schema, properties, ...(required ? { required } : {}) }; +} + +function stripInfrastructureArgs(args: Record): Record { + const clean = { ...args }; + for (const field of INFRASTRUCTURE_FIELDS) delete clean[field]; + return clean; +} + +async function authoritativeArguments(toolName: string, args: Record, caseId?: string): Promise> { + const clean = stripInfrastructureArgs(args); + if (toolName !== "clinical.medication_conflict_check") return clean; + if (!caseId) throw new Error("Attach a patient case before running a clinical medication check."); + const patientCase = await patientCases.getCase(caseId); + if (!patientCase) throw new Error("The attached patient case is no longer available."); + if (!patientCase.medications.includeInContext || !patientCase.allergies.includeInContext) { + throw new Error("Include both medications and allergies in the attached case context before running this check."); + } + return { medications: patientCase.medications.value, allergies: patientCase.allergies.value }; +} + +export async function prepareClinicalMcpArguments( + policy: ManagedMcpPolicy, + toolName: string, + args: Record, + context: ClinicalMcpExecutionContext = {} +): Promise> { + if (policy.integrationProfile !== "modelforge-clinical") return args; + if (toolName === "clinical.submit_compute_request") throw new Error("Governed compute submission is not enabled in this clinical-review release."); + if (toolName === "clinical.record_review_decision" && !context.humanApproved) { + throw new Error("This controlled clinical write requires explicit approval for this call."); + } + const domainArguments = await authoritativeArguments(toolName, args, context.patientCaseId); + const fields = TOOL_FIELDS[toolName] ?? []; + let contextGrantId: string | undefined; + if (fields.length > 0) { + if (!context.patientCaseId) throw new Error(`Attach a patient case before running "${toolName}".`); + const purpose = PURPOSE[toolName as keyof typeof PURPOSE] ?? "documentation-assist"; + const grant = await createMcpContextGrant({ registryEntryId: policy.entryId, caseId: context.patientCaseId, purpose, toolNames: [toolName], requestedFields: fields }); + contextGrantId = grant.id; + } + const injected: Record = { ...domainArguments, ...(contextGrantId ? { contextGrantId } : {}) }; + if (toolName === "clinical.record_review_decision") { + const prepared = await prepareMcpApproval({ registryEntryId: policy.entryId, toolName, arguments: domainArguments, contextGrantId, caseId: context.patientCaseId }); + const confirmed = await confirmMcpApproval(prepared.approvalRequest.id); + injected.approvalTicket = confirmed.approvalTicket; + injected.idempotencyKey = randomUUID(); + } + return injected; +} diff --git a/app/src/hl7-client.ts b/app/src/hl7-client.ts new file mode 100644 index 0000000..94d3c90 --- /dev/null +++ b/app/src/hl7-client.ts @@ -0,0 +1,49 @@ +import { hl7IngestionJobSchema, type Hl7IngestionJob } from "@modelforge/contracts"; +import { z } from "zod"; +import { authorizedRequest, SharedBackendClientError } from "./shared-backend-client"; +import { getSharedBackendConfig } from "./shared-backend-config-store"; + +// REST glue for server/src/routes/hl7.ts's inbound-ingestion review queue — +// GET .../inbound/jobs and POST .../inbound/jobs/:jobId/resolve. Outbound +// ORU^R01 generation and the raw /parse endpoint have no UI need yet (no +// clinician-facing use for either) so aren't wired here. + +export type Hl7ResolveDecision = { action: "apply"; caseId: string } | { action: "reject"; reason: string }; + +function organizationId(): string { + const id = getSharedBackendConfig()?.organizationId; + if (!id) throw new SharedBackendClientError("Select a shared-backend organization before using HL7 ingestion review."); + return id; +} + +async function expectJson(response: Response, action: string): Promise { + if (!response.ok) { + let detail = `HTTP ${response.status}`; + try { + const body = (await response.json()) as { message?: string; error?: string }; + detail = body.message ?? body.error ?? detail; + } catch { /* response was not JSON */ } + throw new SharedBackendClientError(`${action} failed: ${detail}`); + } + return response.json() as Promise; +} + +export async function listHl7IngestionJobs(status?: Hl7IngestionJob["status"]): Promise { + const org = organizationId(); + const query = status ? `?status=${encodeURIComponent(status)}` : ""; + const body = await expectJson<{ jobs: unknown[] }>( + await authorizedRequest(`/organizations/${encodeURIComponent(org)}/hl7/v2/inbound/jobs${query}`), + "Loading HL7 ingestion queue" + ); + return z.array(hl7IngestionJobSchema).parse(body.jobs); +} + +export async function resolveHl7IngestionJob(jobId: string, decision: Hl7ResolveDecision): Promise { + const org = organizationId(); + return hl7IngestionJobSchema.parse( + await expectJson( + await authorizedRequest(`/organizations/${encodeURIComponent(org)}/hl7/v2/inbound/jobs/${encodeURIComponent(jobId)}/resolve`, { method: "POST", body: JSON.stringify(decision) }), + "Resolving HL7 ingestion job" + ) + ); +} diff --git a/app/src/ipc/agent-handlers.ts b/app/src/ipc/agent-handlers.ts index 0d13091..7338374 100644 --- a/app/src/ipc/agent-handlers.ts +++ b/app/src/ipc/agent-handlers.ts @@ -20,7 +20,13 @@ export function registerAgentIpc(): void { "tools:execute", async ( event: IpcMainInvokeEvent, - { workspaceRoot, name, args, requestId }: { workspaceRoot: string; name: string; args: unknown; requestId?: string } + { workspaceRoot, name, args, requestId, clinicalContext }: { + workspaceRoot: string; + name: string; + args: unknown; + requestId?: string; + clinicalContext?: { patientCaseId?: string; humanApproved?: boolean }; + } ) => { requireString(workspaceRoot, "workspace root"); requireString(name, "tool name"); @@ -46,9 +52,10 @@ export function registerAgentIpc(): void { const controller = requestId ? new AbortController() : undefined; if (requestId && controller) activeMcpToolRequests.set(requestId, controller); try { - result = await mcpClient.callMcpTool(name, validatedArgs, { + result = await mcpClient.callMcpToolStructured(name, validatedArgs, { signal: controller?.signal, onProgress: requestId ? (p) => event.sender.send(`mcp:toolProgress:${requestId}`, p) : undefined, + clinicalContext, }); } finally { if (requestId) activeMcpToolRequests.delete(requestId); diff --git a/app/src/ipc/mcp-handlers.ts b/app/src/ipc/mcp-handlers.ts index 45707b0..e6187d4 100644 --- a/app/src/ipc/mcp-handlers.ts +++ b/app/src/ipc/mcp-handlers.ts @@ -5,8 +5,13 @@ import * as mcpOAuth from "../mcp-oauth"; import { mcpServerConfigSchema, parseOrThrow } from "../schemas"; import { requireString, getMainWindow, activeMcpToolRequests } from "../app-state"; import { buildMastervaultServerConfig, isMastervaultBuiltinAvailable } from "../mastervault-builtin"; +import { listManagedClinicalMcpServers } from "../managed-mcp-policy"; export function registerMcpIpc(): void { + ipcMain.handle("mcp:listManagedClinicalServers", async () => { + try { return { servers: await listManagedClinicalMcpServers() }; } + catch (error) { return { error: (error as Error).message }; } + }); ipcMain.handle("mcp:isMastervaultBuiltinAvailable", () => isMastervaultBuiltinAvailable()); // Convenience one-click add for the built-in MasterVault server: prompts diff --git a/app/src/ipc/shared-backend-handlers.ts b/app/src/ipc/shared-backend-handlers.ts index 1966dba..80524f9 100644 --- a/app/src/ipc/shared-backend-handlers.ts +++ b/app/src/ipc/shared-backend-handlers.ts @@ -8,6 +8,9 @@ import { requireString } from "../app-state"; import * as caseMigration from "../case-migration"; import * as imagingClient from "../imaging-client"; import * as clinicalAiClient from "../clinical-ai-client"; +import * as hl7Client from "../hl7-client"; +import * as smartLaunchClient from "../smart-launch-client"; +import { runSmartLaunch } from "../smart-launch-flow"; import { closeOhifLaunch, createOhifLaunch } from "../ohif-viewer"; // IPC surface for enterprise-mode shared-backend connection management @@ -142,4 +145,47 @@ export function registerSharedBackendIpc(): void { ipcMain.handle("clinicalAi:submit", (_event, input: { caseId: string; request: clinicalAiClient.ClinicalAiSubmitInput }) => { requireString(input?.caseId,"case id");return clinicalAiClient.submitClinicalAiRequest(input.caseId,input.request); }); ipcMain.handle("clinicalAi:listActivity", (_event, caseId: string) => { requireString(caseId,"case id");return clinicalAiClient.listClinicalAiActivity(caseId); }); ipcMain.handle("clinicalAi:review", (_event, input: { outputId: string; review: { decision: "accepted"|"rejected"|"corrected"|"escalated"; correctedText?: string; escalationReason?: string } }) => { requireString(input?.outputId,"output id");return clinicalAiClient.reviewClinicalAiOutput(input.outputId,input.review); }); + + ipcMain.handle("hl7:listJobs", (_event: IpcMainInvokeEvent, status?: "pending-review" | "applied" | "rejected") => hl7Client.listHl7IngestionJobs(status)); + ipcMain.handle("hl7:resolveJob", (_event: IpcMainInvokeEvent, input: { jobId: string; decision: hl7Client.Hl7ResolveDecision }) => { + requireString(input?.jobId, "ingestion job id"); + if (input?.decision?.action !== "apply" && input?.decision?.action !== "reject") throw new Error('Resolution decision must have action "apply" or "reject".'); + if (input.decision.action === "apply") requireString(input.decision.caseId, "case id"); + else requireString(input.decision.reason, "rejection reason"); + return hl7Client.resolveHl7IngestionJob(input.jobId, input.decision); + }); + + ipcMain.handle("smartLaunch:listTrustedIssuers", () => smartLaunchClient.listTrustedIssuers()); + ipcMain.handle("smartLaunch:upsertTrustedIssuer", (_event: IpcMainInvokeEvent, input: { issuer: string; clientId: string; redirectUris: string[] }) => { + requireString(input?.issuer, "issuer"); + requireString(input?.clientId, "client id"); + if (!Array.isArray(input?.redirectUris) || input.redirectUris.length === 0) throw new Error("At least one redirect URI is required."); + return smartLaunchClient.upsertTrustedIssuer(input); + }); + ipcMain.handle("smartLaunch:deleteTrustedIssuer", (_event: IpcMainInvokeEvent, issuer: string) => { + requireString(issuer, "issuer"); + return smartLaunchClient.deleteTrustedIssuer(issuer); + }); + ipcMain.handle("smartLaunch:listSessions", () => smartLaunchClient.listLaunchSessions()); + ipcMain.handle("smartLaunch:revokeSession", (_event: IpcMainInvokeEvent, sessionId: string) => { + requireString(sessionId, "session id"); + return smartLaunchClient.revokeLaunchSession(sessionId); + }); + // Opens the system browser and waits on user interaction at the EHR — + // same long-running-external-flow shape as sharedBackend:connect/ + // mcp:startOAuthFlow above, so it catches and returns {error} rather + // than rejecting, letting the renderer show an inline error instead of + // an unhandled-IPC-rejection for an ordinary "user closed the tab" or + // "5-minute timeout" outcome. + ipcMain.handle("smartLaunch:start", async (_event: IpcMainInvokeEvent, issuer: string) => { + try { + requireString(issuer, "issuer"); + const token = await runSmartLaunch(issuer); + return { token }; + } catch (err) { + const error = err as Error; + logger.error(`SMART launch failed: ${error.message}`); + return { error: error.message }; + } + }); } diff --git a/app/src/managed-mcp-policy.test.ts b/app/src/managed-mcp-policy.test.ts index 0f37e76..50994ee 100755 --- a/app/src/managed-mcp-policy.test.ts +++ b/app/src/managed-mcp-policy.test.ts @@ -51,9 +51,19 @@ describe("managed MCP policy", () => { organizationId, allowedTools: ["lookup"], dataEgressPolicy: "unrestricted", + integrationProfile: "generic", + oauthClientId: undefined, + catalogVersionConstraint: undefined, + approvalChallengeEndpoint: undefined, }); }); + it("requires an exact institutional OAuth client binding for the clinical profile", () => { + const clinical = entry({ integrationProfile: "modelforge-clinical", oauthClientId: "desktop-client", approvalChallengeEndpoint: "https://mcp.example.test/approval-challenges" }); + expect(() => selectManagedMcpPolicy(httpConfig(), organizationId, [clinical])).toThrow(/not bound/); + expect(selectManagedMcpPolicy(httpConfig({ oauthClientId: "desktop-client" }), organizationId, [clinical]).oauthClientId).toBe("desktop-client"); + }); + it("fails closed for missing, disabled, cross-tenant, ambiguous, or malformed entries", () => { expect(() => selectManagedMcpPolicy(httpConfig(), organizationId, [])).toThrow(/not an active entry/); expect(() => selectManagedMcpPolicy(httpConfig(), organizationId, [entry({ status: "disabled" })])).toThrow(/not an active entry/); diff --git a/app/src/managed-mcp-policy.ts b/app/src/managed-mcp-policy.ts index f8fbd16..350b241 100755 --- a/app/src/managed-mcp-policy.ts +++ b/app/src/managed-mcp-policy.ts @@ -11,6 +11,10 @@ const registryEntrySchema = z.object({ endpoint: z.string().min(1), allowedTools: z.union([z.literal("*"), z.array(z.string().min(1))]), dataEgressPolicy: z.enum(["none", "metadata-only", "unrestricted"]), + integrationProfile: z.enum(["generic", "modelforge-clinical"]).default("generic"), + oauthClientId: z.string().min(1).max(512).optional(), + catalogVersionConstraint: z.string().optional(), + approvalChallengeEndpoint: z.string().url().optional(), status: z.enum(["active", "disabled"]), }).passthrough(); @@ -21,6 +25,10 @@ export interface ManagedMcpPolicy { organizationId: string; allowedTools: "*" | string[]; dataEgressPolicy: "none" | "metadata-only" | "unrestricted"; + integrationProfile: "generic" | "modelforge-clinical"; + oauthClientId?: string; + catalogVersionConstraint?: string; + approvalChallengeEndpoint?: string; } export class ManagedMcpPolicyError extends Error { @@ -86,11 +94,21 @@ export function selectManagedMcpPolicy( ); } const entry = matches[0]; + if (entry.integrationProfile === "modelforge-clinical") { + if (!entry.oauthClientId) throw new ManagedMcpPolicyError(`Clinical MCP server "${config.name}" has no institutional OAuth client ID.`); + if (config.oauthClientId !== entry.oauthClientId) { + throw new ManagedMcpPolicyError(`Clinical MCP server "${config.name}" is not bound to the registry's OAuth client ID. Re-import it from institutional settings.`); + } + } return { entryId: entry.id, organizationId: entry.organizationId, allowedTools: entry.allowedTools, dataEgressPolicy: entry.dataEgressPolicy, + integrationProfile: entry.integrationProfile, + oauthClientId: entry.oauthClientId, + catalogVersionConstraint: entry.catalogVersionConstraint, + approvalChallengeEndpoint: entry.approvalChallengeEndpoint, }; } @@ -108,6 +126,32 @@ export async function resolveManagedMcpPolicy(config: McpServerConfig): Promise< return selectManagedMcpPolicy(config, organizationId, await response.json()); } +export async function listManagedClinicalMcpServers(): Promise { + const backendConfig = getSharedBackendConfig(); + const organizationId = backendConfig?.organizationId; + if (!organizationId) throw new ManagedMcpPolicyError("Connect to the shared backend and select an organization first."); + const response = await authorizedRequest(`/organizations/${encodeURIComponent(organizationId)}/mcp-registry?status=active`); + if (!response.ok) throw new ManagedMcpPolicyError(`Could not load institutional MCP servers: HTTP ${response.status}.`); + const parsed = z.array(registryEntrySchema).safeParse(await response.json()); + if (!parsed.success) throw new ManagedMcpPolicyError("The institutional MCP registry returned an invalid response."); + return parsed.data + .filter((entry) => entry.organizationId === organizationId && entry.status === "active" && entry.transport === "http" && entry.integrationProfile === "modelforge-clinical") + .map((entry) => { + if (!entry.oauthClientId) throw new ManagedMcpPolicyError(`Clinical MCP registry entry "${entry.name}" has no OAuth client ID.`); + if (entry.oauthClientId !== backendConfig.clientId) throw new ManagedMcpPolicyError(`Clinical MCP registry entry "${entry.name}" uses a different OAuth client than this desktop connection.`); + return ({ + id: `managed-${entry.id}`, + name: entry.name, + transport: "http" as const, + enabled: true, + url: entry.endpoint, + auth: { type: "oauth2" as const }, + oauthClientId: entry.oauthClientId, + warningBanner: entry.catalogVersionConstraint ? `Institutional clinical MCP · catalog policy ${entry.catalogVersionConstraint} (metadata only)` : "Institutional clinical MCP", + }); + }); +} + export function filterManagedMcpTools(policy: ManagedMcpPolicy | null, tools: T[]): T[] { if (!policy || policy.allowedTools === "*") return tools; const allowed = new Set(policy.allowedTools); diff --git a/app/src/mcp-client.ts b/app/src/mcp-client.ts index bce5611..dfd94d2 100644 --- a/app/src/mcp-client.ts +++ b/app/src/mcp-client.ts @@ -14,6 +14,8 @@ import { resolveManagedMcpPolicy, type ManagedMcpPolicy, } from "./managed-mcp-policy"; +import { modelVisibleClinicalSchema, prepareClinicalMcpArguments, type ClinicalMcpExecutionContext } from "./clinical-mcp-broker"; +import { mcpOperationResponseSchema, type McpOperationResponse } from "@modelforge/contracts"; export interface McpServerConfig { id: string; @@ -39,6 +41,10 @@ export interface McpServerConfig { // pre-filled here, since RFC 9728/8414 discovery means this app doesn't // need to know the authorization server URL up front. auth?: { type: "none" | "oauth2" }; + /** Static institutional public-client identifier. Clinical managed + * servers use the same client as the shared backend so context grants + * and approval tickets stay bound to one OAuth azp/client_id. */ + oauthClientId?: string; // A hard denylist enforced in code (filtered out of both the tool list // and callMcpTool itself, not just hidden in the UI) — for servers like // DICOM MCP whose upstream tool catalog includes operations this app @@ -71,6 +77,7 @@ interface Connection { client: Client; transport: Transport; tools: McpToolInfo[]; + managedPolicy: ManagedMcpPolicy | null; // Populated for the HTTP transport, which exposes the version the SDK // negotiated during connect(); the stdio transport validates the same // negotiation internally (Client.connect() throws if the server's @@ -140,7 +147,7 @@ async function connectStdio(config: McpServerConfig, managedPolicy: ManagedMcpPo const list = await client.listTools(); const tools = filterManagedMcpTools(managedPolicy, filterBlockedTools(config, list.tools as McpToolInfo[])); for (const tool of tools) precompileToolSchema(config.id, tool.name, tool.inputSchema); - return { config, client, transport, tools, resourceLeaseId: lease.leaseId }; + return { config, client, transport, tools, managedPolicy, resourceLeaseId: lease.leaseId }; } catch (err) { mainResourceOrchestrator.release(lease.leaseId); throw err; @@ -181,6 +188,7 @@ async function connectHttp(config: McpServerConfig, managedPolicy: ManagedMcpPol client, transport, tools, + managedPolicy, protocolVersion: transport.protocolVersion, }; } @@ -235,7 +243,9 @@ export function getConnectedTools(): ToolDefinition[] { result.push({ name: qualifiedName(conn.config.id, tool.name), description: `[MCP: ${conn.config.name}] ${tool.description ?? tool.name}`, - parameters: (tool.inputSchema as unknown as ToolDefinition["parameters"]) ?? { + parameters: ((conn.managedPolicy?.integrationProfile === "modelforge-clinical" + ? modelVisibleClinicalSchema(tool.inputSchema, tool.name) + : tool.inputSchema) as unknown as ToolDefinition["parameters"]) ?? { type: "object", properties: {}, }, @@ -271,7 +281,8 @@ export interface McpStructuredToolResult { /** Which server/tool/when produced this — so a caller (audit logging, a * future UI) doesn't have to re-derive provenance the qualified tool * name already implies but doesn't timestamp. */ - provenance: { serverId: string; serverName: string; toolName: string; timestamp: string }; + provenance: { serverId: string; serverName: string; toolName: string; timestamp: string; registryEntryId?: string }; + clinicalOperation?: McpOperationResponse; } // MCP tool results are `{ content: [...], structuredContent?, isError? }`. @@ -313,12 +324,14 @@ function buildStructuredResult( } } const flatText = content.length > 0 ? textParts.join("\n") : JSON.stringify(result ?? null, null, 2); + const clinicalOperation = mcpOperationResponseSchema.safeParse(r?.structuredContent); return { text: r?.isError ? `Error: ${flatText}` : flatText, structuredContent: r?.structuredContent, resourceLinks: resourceLinks.length > 0 ? resourceLinks : undefined, isError: r?.isError ?? false, provenance: { serverId, serverName, toolName, timestamp: new Date().toISOString() }, + clinicalOperation: clinicalOperation.success ? clinicalOperation.data : undefined, }; } @@ -348,6 +361,7 @@ export interface McpToolCallOptions { /** Requires the server to actually send progress notifications; most * won't for a fast call, so this may simply never fire. */ onProgress?: (progress: McpToolCallProgress) => void; + clinicalContext?: ClinicalMcpExecutionContext; } /** Full structured result — used where structuredContent/resource links/ @@ -366,7 +380,6 @@ export async function callMcpToolStructured( // central allowlist must fail closed for an already-open connection. const managedPolicy = await resolveManagedMcpPolicy(conn.config); enforceManagedMcpToolCall(managedPolicy, toolName, args); - // Defense in depth: filterBlockedTools() already keeps a blocked name out // of conn.tools (so it's never offered to the model or shown in the // approval card), but this call site is checked independently rather @@ -376,18 +389,24 @@ export async function callMcpToolStructured( throw new Error(`"${toolName}" is blocked on server "${conn.config.name}" and cannot be called.`); } - const problems = validateArgs(serverId, toolName, args); + const callArgs = managedPolicy?.integrationProfile === "modelforge-clinical" + ? await prepareClinicalMcpArguments(managedPolicy, toolName, args, options?.clinicalContext) + : args; + + const problems = validateArgs(serverId, toolName, callArgs); if (problems.length > 0) { throw new Error(`Invalid arguments for "${toolName}": ${problems.join("; ")}`); } - const result = await conn.client.callTool({ name: toolName, arguments: args }, undefined, { + const result = await conn.client.callTool({ name: toolName, arguments: callArgs }, undefined, { signal: options?.signal, onprogress: options?.onProgress ? (p) => options.onProgress!({ progress: p.progress, total: p.total, message: p.message }) : undefined, }); - return buildStructuredResult(serverId, conn.config.name, toolName, result); + const structured = buildStructuredResult(serverId, conn.config.name, toolName, result); + if (managedPolicy?.integrationProfile === "modelforge-clinical") structured.provenance.registryEntryId = managedPolicy.entryId; + return structured; } export async function callMcpTool(qualified: string, args: Record, options?: McpToolCallOptions): Promise { diff --git a/app/src/mcp-oauth.test.ts b/app/src/mcp-oauth.test.ts index 3307785..b24fdcb 100644 --- a/app/src/mcp-oauth.test.ts +++ b/app/src/mcp-oauth.test.ts @@ -77,6 +77,12 @@ describe("mcp-oauth", () => { expect((await provider.clientInformation())?.client_id).toBe("client-abc"); }); + it("uses the institutional static client id instead of dynamic registration state", async () => { + const provider = getOAuthProvider({ ...oauthConfig("oauth-1"), oauthClientId: "institutional-desktop" })!; + await provider.saveClientInformation!({ client_id: "stale-dynamic-client", redirect_uris: [String(provider.redirectUrl)] }); + expect((await provider.clientInformation())?.client_id).toBe("institutional-desktop"); + }); + it("invalidateCredentials('all') clears tokens, verifier, and client info together", async () => { const provider = getOAuthProvider(oauthConfig("oauth-1"))!; await provider.saveTokens({ access_token: "t", token_type: "Bearer" }); diff --git a/app/src/mcp-oauth.ts b/app/src/mcp-oauth.ts index 5032991..d75d23e 100644 --- a/app/src/mcp-oauth.ts +++ b/app/src/mcp-oauth.ts @@ -32,7 +32,7 @@ function clientInfoKey(serverId: string): string { // a token to one specific server in the first place — a mixed-up token // would defeat that even if the resource indicator itself were correct). class ModelForgeOAuthProvider implements OAuthClientProvider { - constructor(private serverId: string) {} + constructor(private serverId: string, private preferredClientId?: string) {} get redirectUrl(): string { return REDIRECT_URI; @@ -49,6 +49,7 @@ class ModelForgeOAuthProvider implements OAuthClientProvider { } clientInformation(): OAuthClientInformationMixed | undefined { + if (this.preferredClientId) return { client_id: this.preferredClientId } as OAuthClientInformationMixed; const raw = secretsStore.getSecret(clientInfoKey(this.serverId)); return raw ? (JSON.parse(raw) as OAuthClientInformationMixed) : undefined; } @@ -89,7 +90,7 @@ class ModelForgeOAuthProvider implements OAuthClientProvider { export function getOAuthProvider(config: McpServerConfig): OAuthClientProvider | undefined { if (config.auth?.type !== "oauth2") return undefined; - return new ModelForgeOAuthProvider(config.id); + return new ModelForgeOAuthProvider(config.id, config.oauthClientId); } export function hasStoredOAuthTokens(serverId: string): boolean { @@ -144,7 +145,8 @@ function waitForRedirectCode(): Promise { */ export async function startOAuthFlow(config: McpServerConfig): Promise { if (!config.url) throw new Error("This server has no URL configured."); - const provider = new ModelForgeOAuthProvider(config.id); + const provider = getOAuthProvider(config); + if (!provider) throw new Error("This server is not configured for OAuth."); const first = await auth(provider, { serverUrl: config.url }); if (first === "AUTHORIZED") return; const code = await waitForRedirectCode(); diff --git a/app/src/preload.ts b/app/src/preload.ts index 5509067..93ce51d 100644 --- a/app/src/preload.ts +++ b/app/src/preload.ts @@ -29,6 +29,8 @@ import type { CreateImagingShareInput, ImagingStudyDetail } from "./imaging-clie import type { ImagingIngestionJob, ImagingShareGrant, ImagingStudy } from "@modelforge/contracts"; import type { AiConsent, AiReview } from "@modelforge/contracts"; import type { ClinicalAiImagingOption, ClinicalAiModelOption, ClinicalAiRequestDetail, ClinicalAiSubmitInput } from "./clinical-ai-client"; +import type { Hl7ResolveDecision } from "./hl7-client"; +import type { Hl7IngestionJob, SmartLaunchToken, SmartTrustedIssuer } from "@modelforge/contracts"; export interface ToolExecuteResult { result?: unknown; @@ -379,8 +381,8 @@ export const api = { agent: { pickWorkspace: (): Promise => ipcRenderer.invoke("agent:pickWorkspace"), - executeTool: (workspaceRoot: string, name: string, args: Record): Promise => - ipcRenderer.invoke("tools:execute", { workspaceRoot, name, args }), + executeTool: (workspaceRoot: string, name: string, args: Record, clinicalContext?: { patientCaseId?: string; humanApproved?: boolean }): Promise => + ipcRenderer.invoke("tools:execute", { workspaceRoot, name, args, clinicalContext }), // Only meaningfully different from executeTool for MCP tools — a // requestId lets main thread progress notifications back on a push // channel and lets the caller cancel mid-call. Built-in tools ignore @@ -391,14 +393,15 @@ export const api = { workspaceRoot: string, name: string, args: Record, - onProgress: (progress: { progress: number; total?: number; message?: string }) => void + onProgress: (progress: { progress: number; total?: number; message?: string }) => void, + clinicalContext?: { patientCaseId?: string; humanApproved?: boolean } ): { requestId: string; promise: Promise } => { const requestId = randomId(); const channel = `mcp:toolProgress:${requestId}`; const listener = (_event: unknown, progress: { progress: number; total?: number; message?: string }) => onProgress(progress); ipcRenderer.on(channel, listener); const promise = ipcRenderer - .invoke("tools:execute", { workspaceRoot, name, args, requestId }) + .invoke("tools:execute", { workspaceRoot, name, args, requestId, clinicalContext }) .finally(() => ipcRenderer.removeListener(channel, listener)); return { requestId, promise }; }, @@ -604,6 +607,7 @@ export const api = { }, mcp: { + listManagedClinicalServers: (): Promise<{ servers?: McpServerConfig[]; error?: string }> => ipcRenderer.invoke("mcp:listManagedClinicalServers"), connect: (config: McpServerConfig): Promise => ipcRenderer.invoke("mcp:connect", config), disconnect: (id: string): Promise => ipcRenderer.invoke("mcp:disconnect", id), status: (): Promise> => ipcRenderer.invoke("mcp:status"), @@ -616,6 +620,21 @@ export const api = { clearOAuthCredentials: (serverId: string): Promise => ipcRenderer.invoke("mcp:clearOAuthCredentials", serverId), }, + hl7: { + listJobs: (status?: Hl7IngestionJob["status"]): Promise => ipcRenderer.invoke("hl7:listJobs", status), + resolveJob: (jobId: string, decision: Hl7ResolveDecision): Promise => ipcRenderer.invoke("hl7:resolveJob", { jobId, decision }), + }, + + smartLaunch: { + listTrustedIssuers: (): Promise => ipcRenderer.invoke("smartLaunch:listTrustedIssuers"), + upsertTrustedIssuer: (input: { issuer: string; clientId: string; redirectUris: string[] }): Promise => + ipcRenderer.invoke("smartLaunch:upsertTrustedIssuer", input), + deleteTrustedIssuer: (issuer: string): Promise => ipcRenderer.invoke("smartLaunch:deleteTrustedIssuer", issuer), + listSessions: (): Promise => ipcRenderer.invoke("smartLaunch:listSessions"), + revokeSession: (sessionId: string): Promise => ipcRenderer.invoke("smartLaunch:revokeSession", sessionId), + start: (issuer: string): Promise<{ token?: SmartLaunchToken; error?: string }> => ipcRenderer.invoke("smartLaunch:start", issuer), + }, + screen: { listSources: (): Promise => ipcRenderer.invoke("screen:listSources"), capture: (sourceId: string): Promise => ipcRenderer.invoke("screen:capture", sourceId), diff --git a/app/src/providers/types.ts b/app/src/providers/types.ts index 83efe2a..2fd8f50 100644 --- a/app/src/providers/types.ts +++ b/app/src/providers/types.ts @@ -1,3 +1,5 @@ +import type { McpOperationProvenance } from "@modelforge/contracts"; + export interface UsageInfo { promptTokens?: number; completionTokens?: number; @@ -53,6 +55,7 @@ export interface ChatMessage { // Set on the synthetic message the verification loop appends — a UI // affordance like `pinned`, never sent to a provider. isVerification?: boolean; + mcpOperation?: McpOperationProvenance; } export interface ChatChunk { diff --git a/app/src/schemas.ts b/app/src/schemas.ts index a4d0040..939d38f 100644 --- a/app/src/schemas.ts +++ b/app/src/schemas.ts @@ -53,6 +53,7 @@ export const mcpServerConfigSchema = z.object({ headers: z.record(z.string(), z.string()).optional(), trustProfile: z.object({ autoApprovedTools: z.array(z.string()) }).optional(), auth: z.object({ type: z.enum(["none", "oauth2"]) }).optional(), + oauthClientId: z.string().min(1).max(512).optional(), blockedTools: z.array(z.string()).optional(), warningBanner: z.string().optional(), }); diff --git a/app/src/shared-backend-client.ts b/app/src/shared-backend-client.ts index dd9d917..8f9f01d 100644 --- a/app/src/shared-backend-client.ts +++ b/app/src/shared-backend-client.ts @@ -1,6 +1,15 @@ import { getValidAccessToken, isAllowedRemoteUrl } from "./shared-backend-auth"; import { getSharedBackendConfig, setSharedBackendConfig } from "./shared-backend-config-store"; import { migrationPreviewSchema, migrationSessionSchema, type MigrationPreview, type MigrationSession } from "@modelforge/contracts"; +import { + mcpApprovalChallengeSchema, + mcpApprovalRequestSchema, + mcpContextGrantSchema, + type McpApprovalChallenge, + type McpApprovalRequest, + type McpContextGrant, +} from "@modelforge/contracts"; +import { z } from "zod"; // General-purpose client for the shared backend's non-case-data endpoints // (GET /me, POST /organizations) — the pieces a Settings UI needs to let a @@ -104,6 +113,48 @@ function selectedOrganizationId(): string { return id; } +async function clinicalMcpRequest(path: string, init: RequestInit): Promise { + const response = await authorizedRequest(path, init); + if (!response.ok) { + let detail = `HTTP ${response.status}`; + try { + const body = (await response.json()) as { error?: string; message?: string }; + detail = body.message ?? body.error ?? detail; + } catch { /* non-JSON response */ } + throw new SharedBackendClientError(`Clinical MCP control-plane request failed: ${detail}`); + } + return response.json(); +} + +export async function createMcpContextGrant(input: { + registryEntryId: string; + caseId: string; + purpose: "diagnostic-support" | "summarization" | "medication-review" | "documentation-assist" | "research" | "teaching" | "quality-improvement"; + toolNames: string[]; + requestedFields: string[]; +}): Promise { + const organizationId = selectedOrganizationId(); + return mcpContextGrantSchema.parse(await clinicalMcpRequest(`/organizations/${organizationId}/mcp-context-grants`, { method: "POST", body: JSON.stringify(input) })); +} + +const preparedApprovalSchema = z.object({ approvalRequest: mcpApprovalRequestSchema, challenge: mcpApprovalChallengeSchema }).strict(); +export async function prepareMcpApproval(input: { + registryEntryId: string; + toolName: string; + arguments: Record; + contextGrantId?: string; + caseId?: string; +}): Promise<{ approvalRequest: McpApprovalRequest; challenge: McpApprovalChallenge }> { + const organizationId = selectedOrganizationId(); + return preparedApprovalSchema.parse(await clinicalMcpRequest(`/organizations/${organizationId}/mcp-approvals/prepare`, { method: "POST", body: JSON.stringify(input) })); +} + +export async function confirmMcpApproval(approvalRequestId: string): Promise<{ approvalRequest: McpApprovalRequest; approvalTicket: string }> { + const organizationId = selectedOrganizationId(); + const schema = z.object({ approvalRequest: mcpApprovalRequestSchema, approvalTicket: z.string().min(1) }).strict(); + return schema.parse(await clinicalMcpRequest(`/organizations/${organizationId}/mcp-approvals/${encodeURIComponent(approvalRequestId)}/confirm`, { method: "POST" })); +} + async function migrationRequest(path: string, init?: RequestInit): Promise { const response = await authorizedRequest(path, init); if (!response.ok) { diff --git a/app/src/smart-launch-client.ts b/app/src/smart-launch-client.ts new file mode 100644 index 0000000..bd4c98b --- /dev/null +++ b/app/src/smart-launch-client.ts @@ -0,0 +1,102 @@ +import { smartLaunchSessionSchema, smartLaunchTokenSchema, smartTrustedIssuerSchema, type SmartLaunchSession, type SmartLaunchToken, type SmartTrustedIssuer } from "@modelforge/contracts"; +import { z } from "zod"; +import { authorizedRequest, SharedBackendClientError } from "./shared-backend-client"; +import { getSharedBackendConfig } from "./shared-backend-config-store"; + +// REST glue for server/src/routes/smart-launch.ts. The actual +// authorization-code + PKCE dance (opening a browser, catching the +// redirect) lives in smart-launch-flow.ts, which calls startLaunchSession/ +// completeLaunchCallback below — kept separate the same way +// shared-backend-client.ts's own request helpers are separate from any one +// flow that uses them. + +function organizationId(): string { + const id = getSharedBackendConfig()?.organizationId; + if (!id) throw new SharedBackendClientError("Select a shared-backend organization before using SMART launch."); + return id; +} + +async function expectJson(response: Response, action: string): Promise { + if (!response.ok) { + let detail = `HTTP ${response.status}`; + try { + const body = (await response.json()) as { message?: string; error?: string }; + detail = body.message ?? body.error ?? detail; + } catch { /* response was not JSON */ } + throw new SharedBackendClientError(`${action} failed: ${detail}`); + } + return response.json() as Promise; +} + +export async function listTrustedIssuers(): Promise { + const org = organizationId(); + const body = await expectJson<{ trustedIssuers: unknown[] }>( + await authorizedRequest(`/organizations/${encodeURIComponent(org)}/smart/trusted-issuers`), + "Listing trusted EHR issuers" + ); + return z.array(smartTrustedIssuerSchema).parse(body.trustedIssuers); +} + +export async function upsertTrustedIssuer(input: { issuer: string; clientId: string; redirectUris: string[] }): Promise { + const org = organizationId(); + return smartTrustedIssuerSchema.parse( + await expectJson( + await authorizedRequest(`/organizations/${encodeURIComponent(org)}/smart/trusted-issuers`, { method: "PUT", body: JSON.stringify(input) }), + "Registering trusted EHR issuer" + ) + ); +} + +export async function deleteTrustedIssuer(issuer: string): Promise { + const org = organizationId(); + const response = await authorizedRequest(`/organizations/${encodeURIComponent(org)}/smart/trusted-issuers/delete`, { method: "POST", body: JSON.stringify({ issuer }) }); + if (!response.ok && response.status !== 404) { + throw new SharedBackendClientError(`Removing trusted EHR issuer failed: HTTP ${response.status}`); + } +} + +export async function listLaunchSessions(): Promise { + const org = organizationId(); + const body = await expectJson<{ sessions: unknown[] }>( + await authorizedRequest(`/organizations/${encodeURIComponent(org)}/smart/sessions`), + "Listing SMART launch sessions" + ); + return z.array(smartLaunchTokenSchema).parse(body.sessions); +} + +export async function revokeLaunchSession(sessionId: string): Promise { + const org = organizationId(); + const response = await authorizedRequest(`/organizations/${encodeURIComponent(org)}/smart/sessions/${encodeURIComponent(sessionId)}/revoke`, { method: "POST" }); + if (!response.ok && response.status !== 404) { + throw new SharedBackendClientError(`Revoking SMART launch session failed: HTTP ${response.status}`); + } +} + +/** Starts a launch: the server validates `issuer` against this org's + * trusted-issuer allowlist and `redirectUri` against that issuer's own + * allowlist (exact match — see smart-launch/service.ts), discovers the + * EHR's authorization endpoint, and returns a URL to send the user to. + * Internal to smart-launch-flow.ts — not exposed over IPC directly, since + * completing the flow also requires catching the redirect. */ +export async function startLaunchSession(issuer: string, redirectUri: string): Promise<{ session: SmartLaunchSession; authorizationUrl: string }> { + const org = organizationId(); + const schema = z.object({ session: smartLaunchSessionSchema, authorizationUrl: z.string().url() }); + return schema.parse( + await expectJson( + await authorizedRequest(`/organizations/${encodeURIComponent(org)}/smart/launch-sessions`, { method: "POST", body: JSON.stringify({ issuer, redirectUri }) }), + "Starting SMART launch" + ) + ); +} + +/** Completes a launch: exchanges the authorization code the EHR redirected + * back with. Single-use — a second call with the same `state` fails. */ +export async function completeLaunchCallback(state: string, code: string): Promise { + const org = organizationId(); + return smartLaunchTokenSchema.parse( + await expectJson( + await authorizedRequest(`/organizations/${encodeURIComponent(org)}/smart/launch-sessions/${encodeURIComponent(state)}/callback`, { method: "POST", body: JSON.stringify({ code }) }), + "Completing SMART launch" + ) + ); +} diff --git a/app/src/smart-launch-flow.ts b/app/src/smart-launch-flow.ts new file mode 100644 index 0000000..4733642 --- /dev/null +++ b/app/src/smart-launch-flow.ts @@ -0,0 +1,72 @@ +import * as http from "node:http"; +import { shell } from "electron"; +import type { SmartLaunchToken } from "@modelforge/contracts"; +import { completeLaunchCallback, startLaunchSession } from "./smart-launch-client"; + +// Same fixed-loopback-redirect approach as mcp-oauth.ts (a CLI-OAuth-style +// redirect URI, no OS custom-protocol registration needed) — deliberately a +// different port so the two flows can never collide if somehow triggered +// at once. Only bound while a launch is actually in progress. +const REDIRECT_PORT = 51824; +const REDIRECT_URI = `http://127.0.0.1:${REDIRECT_PORT}/smart/callback`; + +export class SmartLaunchFlowError extends Error { + constructor(message: string) { + super(message); + this.name = "SmartLaunchFlowError"; + } +} + +// Briefly opens a loopback HTTP server just long enough to catch the single +// redirect the EHR's authorization server sends back with +// `?code=...&state=...`, then closes it. `expectedState` is checked here +// too (not only server-side by completeLaunchCallback) as defense in depth +// against a stray request reaching this transient listener while it's up. +function waitForSmartRedirect(expectedState: string): Promise { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + const url = new URL(req.url ?? "/", REDIRECT_URI); + const code = url.searchParams.get("code"); + const state = url.searchParams.get("state"); + const error = url.searchParams.get("error"); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end( + error + ? "EHR authorization failed. You can close this tab and return to ModelForge Medical." + : "Authorization complete — you can close this tab and return to ModelForge Medical." + ); + server.close(); + if (error) reject(new SmartLaunchFlowError(`EHR authorization was denied or failed: ${error}`)); + else if (!code) reject(new SmartLaunchFlowError("No authorization code was returned.")); + else if (state !== expectedState) reject(new SmartLaunchFlowError("The authorization response's state did not match this launch — rejecting to avoid a mixed-up session.")); + else resolve(code); + }); + server.on("error", (err) => reject(new SmartLaunchFlowError(`Could not start the local SMART launch redirect listener: ${err.message}`))); + server.listen(REDIRECT_PORT, "127.0.0.1"); + const timeout = setTimeout( + () => { + server.close(); + reject(new SmartLaunchFlowError("Timed out waiting for EHR authorization — no response after 5 minutes.")); + }, + 5 * 60_000 + ); + timeout.unref(); + }); +} + +/** + * Runs a full SMART App Launch (client role) end to end for one trusted + * issuer: asks the server to start a launch session (server validates the + * issuer/redirectUri allowlist and discovers the EHR's authorization + * endpoint), opens that URL in the system browser, catches the redirect, + * and exchanges the code — all via server/src/smart-launch/service.ts, + * which does the actual PKCE + token exchange server-side. This process + * never sees the EHR's access/refresh token: completeLaunchCallback + * returns only the public SmartLaunchToken shape (metadata, no secrets). + */ +export async function runSmartLaunch(issuer: string): Promise { + const { session, authorizationUrl } = await startLaunchSession(issuer, REDIRECT_URI); + await shell.openExternal(authorizationUrl); + const code = await waitForSmartRedirect(session.id); + return completeLaunchCallback(session.id, code); +} diff --git a/docs/CLINICAL_AI_EVALUATION.md b/docs/CLINICAL_AI_EVALUATION.md index 8ea476d..d00bde4 100644 --- a/docs/CLINICAL_AI_EVALUATION.md +++ b/docs/CLINICAL_AI_EVALUATION.md @@ -1,4 +1,15 @@ -# Clinical AI evaluation harness +# Clinical AI evaluation + +Two complementary pieces, deliberately kept separate: an **offline** harness +(this file's original scope, below) that gates a candidate model/prompt against +a fixed synthetic suite before it serves any traffic, and an **online** +production quality monitor (`server/src/eval-harness/production-monitor.ts`) +that observes how an already-deployed model is actually behaving, from data +the gateway itself already records. Neither implements live shadow traffic, +canary routing, or automatic promotion/rollback — see each section's own +"not implemented" note for why. + +## Offline harness (promotion gate) The clinical evaluation harness is an offline promotion gate for candidate models. It uses versioned, explicitly synthetic fixtures only; it does not read @@ -34,3 +45,34 @@ approved validation protocol before using any model in a real clinical role. The harness does not implement live shadow traffic, canary routing, or automatic promotion. A human or CI system may consume the exit code/report, but changing a model's catalog validation state remains an explicit administrative action. + +## Online production quality monitor + +`GET /organizations/:organizationId/ai-provider-models/:modelId/quality-monitor` +(optional `?since=`) reports aggregate metrics for one provider +model computed from real production `AiOutput` rows already recorded by the +gateway — no golden answers, no synthetic cases: output volume, abstention +rate (of all outputs), and reviewed/acceptance/rejection/correction/escalation +rates (of *reviewed* outputs only, so a review backlog can never masquerade as +a quality change — see `production-monitor.ts`'s own doc comment). Gated by +the same `aiGateway:viewAuditTrail` permission as every other model-level +catalog read; only aggregate rates cross this route, never patient-identifying +data. + +`GET .../quality-drift?splitAt=&baselineSince=` compares two +disjoint time windows for the same model (`baselineSince` to `splitAt`, and +`splitAt` onward) and flags a real behavioral shift — abstention rate up, +acceptance rate down, rejection/escalation rate up — beyond configurable +thresholds (`DEFAULT_DRIFT_THRESHOLDS` in `production-monitor.ts`). Reports +`sufficientData: false` (never a false alarm or false reassurance) when either +window has fewer than `minimumOutputCount` (default 20) outputs. + +**Not implemented**: this only observes and reports — it never changes what +traffic a model receives. No shadow/canary routing, no automatic rollback, no +alerting integration (Slack/PagerDuty/email) — a caller (human, cron, or an +external monitoring system) is expected to poll this route and act on what it +returns. No statistical significance testing beyond the raw count floor above +(a real deployment wanting p-values/confidence intervals on the rate deltas +would need to add that on top of this). See +[docs/CLINICAL_AI_GATEWAY.md](CLINICAL_AI_GATEWAY.md)'s "Remaining work" for +the still-open shadow/canary/rollback piece this deliberately does not cover. diff --git a/docs/CLINICAL_AI_GATEWAY.md b/docs/CLINICAL_AI_GATEWAY.md index 2d822a1..ccbbecd 100644 --- a/docs/CLINICAL_AI_GATEWAY.md +++ b/docs/CLINICAL_AI_GATEWAY.md @@ -43,6 +43,34 @@ matrix" below. `reasoning`/`chainOfThought` field anywhere. A model that ignores the format doesn't get its free-form answer discarded — it becomes the whole `summary`, with `formatCompliant: false` recorded as a trust signal, never a fabricated structure. +- **Prompt versioning, alongside model versioning.** `AiOutput.modelVersion` recorded which + provider model produced an output from day one; `AiOutput.promptVersion` + (`ai-gateway/prompt-registry.ts`) now does the same for the gateway's own system-prompt text — + an append-only, immutable-per-version registry, so "rollback" is just pinning an older version, + never editing shipped prompt text in place. +- **Multi-model routing is opt-in, additive, and never widens authorization.** `providerModelId` + on a submit/preview request is now optional. Supplying it is unchanged from before this existed: + exactly that one model, no fallback. Omitting it runs `ai-gateway/model-router.ts`'s + `rankEligibleProviderModels` (validated-before-canary, quality-before-hosting, local/on-premises- + before-cloud, lower-cost first, deterministic tie-break) over every enabled candidate, then + `submitRequest` tries each in ranked order — falling back to the next on a retryable outcome + (admission rejection, provider failure, or an authorization denial, since eligibility filtering + is only a pre-check of what `evaluateGatewayAuthorization` will actually decide), stopping + immediately on a non-retryable one (content-blocked, case-not-found). Each attempt is its own + real, immutable `AiRequestEnvelope` — a failed attempt is never deleted or hidden. Ranking also + factors in real production quality now: a candidate's recent clinician-acceptance rate + (`eval-harness/production-monitor.ts`, fetched per candidate in `gatherRoutingCandidates`) + outranks hosting/cost preference but never overrides validation status, bucketed + (good/fair/poor) rather than by raw rate so statistical noise never reorders two models with + indistinguishable real performance, and neutral (never penalized) below `MIN_QUALITY_SAMPLE_SIZE` + (20) reviewed outputs so a newly-approved model isn't starved of traffic for lacking history yet. + Not implemented: true concurrent-load balancing (this ranks a static snapshot, not current load) + and latency-based ranking (no latency telemetry exists anywhere in this codebase yet). +- **Every included case field is cited, not only clinical notes.** `data-minimization.ts`'s + `resourceRefs` originally only produced citations for individually-identified resources + (clinical notes); scalar fields (labResults, vitalSigns, etc. — most of most prompts) produced + none. A synthetic `patientCaseField` citation now covers those too, with a matching read-time + re-authorization branch in `routes/ai-gateway.ts`. ## Where the code lives @@ -263,7 +291,10 @@ tests remain a deployment gate rather than an implied verification. - Pixel-aware multimodal imaging inference and validated DICOM SR/SEG output artifacts. - Tenant-safe retrieval/RAG, if a vector index is ever added to this codebase. -- Automated shadow/canary rollout, production drift monitoring, and incident-response workflow. +- Automated shadow/canary rollout and incident-response workflow. (Production quality/drift + *monitoring*, as distinct from automated rollout/rollback, now exists — see + `server/src/eval-harness/production-monitor.ts` and docs/CLINICAL_AI_EVALUATION.md's "Online + production quality monitor" section.) - Domain-level idempotency keys and automatic retry/backoff. - A genuinely separate platform-admin authentication path for the global provider catalog. - Formal risk-management, usability-engineering, and QMS documentation for any use case that ends diff --git a/docs/CLINICAL_MCP_INTEGRATION.md b/docs/CLINICAL_MCP_INTEGRATION.md new file mode 100644 index 0000000..f5754a1 --- /dev/null +++ b/docs/CLINICAL_MCP_INTEGRATION.md @@ -0,0 +1,169 @@ +# Managed clinical MCP integration + +This slice connects the ModelForge desktop application and shared backend to +`modelforge-clinical-mcp`. It provides short-lived context grants, exact-operation +approval tickets, institutional onboarding, review persistence, and structured +operation provenance. It is not a clinical validation or production-readiness +certification. + +## Implemented behavior + +- Settings can import HTTP servers whose institutional registry entry has + `integrationProfile: "modelforge-clinical"`. Generic MCP integrations retain + their existing behavior. +- The desktop uses the registry's static public OAuth client ID. It must match + the shared-backend desktop client; a dynamically registered, different client + cannot use grants or approvals bound to the institutional client. +- Clinical tool schemas hide `contextGrantId`, `approvalTicket`, and + `idempotencyKey` from the model. The medication-check schema also hides + medications and allergies: the main-process broker loads them from the + attached case, only when both fields have `includeInContext` enabled. +- Grant issuance checks current case access, MCP permissions, active registry + status, tool allowlist, egress policy, case consent, and purpose-specific AI + consent. Grants contain field names and identity/case bindings, not clinical + field values, and live for at most 300 seconds. +- Review writes require explicit approval for that call. Auto-approval does + not satisfy the broker's human-approval check. The backend requests a digest + challenge from the gateway, persists the pending approval without arguments, + then confirms it and signs an RS256 ticket. The gateway verifies that ticket + against the actual operation digest before execution. +- Tool messages preserve operation ID, digest, policy versions, registry/server/ + tool identity, and optional review ID/decision in `mcpOperation`. Ordinary tool + results can contain clinical data; the provenance object and audit details do + not copy arguments, response text, tickets, or rationale. +- PostgreSQL stores grants, pending/confirmed approvals, and review records with + tenant row-level policies. Mutations and their audit entries share a database + transaction. Review recording deduplicates by organization and reviewed + operation ID. + +## Backend endpoints + +| Endpoint | Caller | Purpose | +| --- | --- | --- | +| `POST /organizations/:org/mcp-context-grants` | Organization member with case access and `mcpClinical:use` | Issue a field/tool/purpose-bound grant | +| `POST /organizations/:org/mcp-approvals/prepare` | Organization principal with `mcpClinical:approve` | Request the gateway's exact-operation challenge | +| `POST /organizations/:org/mcp-approvals/:id/confirm` | Original subject and OAuth client with `mcpClinical:approve` | Confirm once and issue an approval ticket | +| `POST /internal/mcp/context-grants/introspect` | Registered tenant service principal with `mcpClinical:introspect` | Resolve an unexpired grant | +| `POST /internal/mcp/reviews` | Registered tenant service principal with `mcpClinical:recordReview` | Persist a review decision | + +Grant creation requires active case scopes `ai-assistance` and +`remote-model-use`, plus an active AI consent for the mapped purpose. Medication +review maps to treatment consent and requires the `medications` and `allergies` +data categories. Response-contract and review operations also require active AI +consent; their derived fields are not separate consent data categories. + +## Deployment prerequisites + +1. Use the PostgreSQL backend for durable operation. Apply + `server/migrations/025_mcp_clinical_control_plane.sql` through the backend's + migration runner. Migrations are tracked by full filename, not numeric prefix. + Validate migrations/RLS using disposable infrastructure before production. +2. Register a public PKCE desktop OAuth client at the institution's issuer. Its + registered redirect URIs must support both the shared-backend desktop flow + and MCP's `http://127.0.0.1:51823/oauth/callback`. Set the same client ID in the + desktop shared-backend configuration, clinical registry entry, and gateway + `MODELFORGE_MCP_ALLOWED_CLIENT_IDS`. +3. Configure tokens deliberately: the current prepare route forwards the + shared-backend bearer token to the registry's trusted challenge endpoint. + That token must also be intended for the MCP audience and contain its required + scopes/organization claims. The separately obtained MCP token must resolve to + the same issuer/subject/client/organization. Same client ID alone is not + sufficient. Audience-specific token exchange is not implemented; a deployment + whose identity provider cannot issue an appropriately scoped token needs that + integration before enabling review approval. Do not disable audience checks + to make this work. +4. Register the gateway workload identity as an active service principal in + each authorized organization. Give it only the internal MCP permissions and + resource scopes it needs. Its token must be accepted by the shared backend; + its lifetime/rotation remain deployment responsibilities. +5. Configure the backend signer with `MCP_APPROVAL_PRIVATE_KEY_PEM` (PEM contents, + not a filename), `MCP_APPROVAL_ISSUER`, and `MCP_APPROVAL_AUDIENCE`. Keep the + private key in the deployment secret manager. Configure the gateway with the + matching public-key **file path** in `MODELFORGE_MCP_APPROVAL_PUBLIC_KEY_PEM` + and matching issuer/audience values. If signing is unconfigured, confirmation + returns 503 and does not issue a ticket; a new approval is needed after setup. +6. Configure authenticated HTTPS service base URLs on the gateway: + + ```text + MODELFORGE_MCP_GRANT_SERVICE_URL=https://backend.example.test/internal/mcp/context-grants + MODELFORGE_MCP_REVIEW_SERVICE_URL=https://backend.example.test/internal/mcp + ``` + + The grant adapter appends `/introspect`; the review adapter appends `/reviews`. + Configure `MODELFORGE_MCP_WORKLOAD_TOKEN_FILE` and the gateway's separate shared + state database, tenant policy, OIDC verification, and deployment profile as + described in its enterprise deployment guide. +7. Create an active organization registry entry, for example: + + ```json + { + "name": "Institutional clinical gateway", + "transport": "http", + "endpoint": "https://mcp.example.test/mcp", + "integrationProfile": "modelforge-clinical", + "oauthClientId": "institutional-desktop", + "approvalChallengeEndpoint": "https://mcp.example.test/approval-challenges", + "allowedTools": [ + "modelforge.capabilities", + "clinical.response_contract_check", + "clinical.response_contract_check_batch", + "clinical.record_review_decision" + ], + "dataEgressPolicy": "unrestricted" + } + ``` + + `unrestricted` is the existing coarse registry egress category needed for + nonempty tool payloads; it does not bypass gateway grants or tenant policy. + Only institution-approved, trusted destinations belong in this registry. +8. In the built desktop application, connect to the shared backend, select the + organization, choose **Add institutional clinical MCP**, sign in to the + imported server, and connect. Attach a consented synthetic case for the pilot. + A source build does not update an already-installed desktop binary. + +## Verification and remaining gates + +Run from the ModelForge repository: + +```bash +npm --prefix packages/contracts run build +npm --prefix server run typecheck +npm --prefix app run build +npm --prefix frontend run build +npm --prefix server test +npm --prefix app test +npm --prefix frontend test +``` + +`server/src/routes/mcp-clinical.integration.test.ts` exercises actual backend +routes, OIDC verification, tenant permissions, consent checks, review replay, +and RS256 ticket verification using synthetic data. It mocks the remote MCP +challenge response. The gateway's Rust HTTP tests separately verify challenge +authentication and digest generation. These are not a live, cross-process +desktop/identity-provider/backend/gateway acceptance test. + +Before a production pilot, complete that cross-process acceptance test and the +disposable PostgreSQL/Redis integration tests (some tests skip without their +explicit infrastructure configuration). Verify wrong-audience/client denial, +revoked consent, disabled registry entries, expired/replayed tickets, restart +durability, and cross-replica replay using synthetic data. + +Current limits: + +- `catalogVersionConstraint` is registry metadata, not a negotiated compatibility + gate. Version compatibility still needs deployment acceptance testing. +- Existing grants are expiry-bound snapshots. There is no grant-revocation API + or revalidation of current consent at introspection; allow for the maximum + five-minute outstanding-grant window when designing revocation procedures. +- A stored review references an operation UUID; the backend does not yet own an + operation ledger that independently verifies that operation's case provenance. +- This slice does not enable governed compute in the desktop broker. The + gateway's built-in medication seed checker remains development-only, not a + clinically validated enterprise medication service. +- A per-call desktop approval is not independent human-attestation or a second + reviewer workflow. Production approval/consent policy remains an institution + decision. + +No live infrastructure, identity-provider settings, database migrations, or +installed application binaries are changed by the source/test work described +here. diff --git a/docs/FHIR_INTEGRATION.md b/docs/FHIR_INTEGRATION.md new file mode 100644 index 0000000..397f284 --- /dev/null +++ b/docs/FHIR_INTEGRATION.md @@ -0,0 +1,109 @@ +# FHIR R4 Integration + +`routes/fhir.ts` exposes a **read-only FHIR R4 facade** over data this system already stores and +protects — clinical cases (`patient_cases`) and clinical imaging (`docs/IMAGING.md`). It is not a +general-purpose FHIR resource server, not a new persistence layer, and not a certified/validated +FHIR implementation of any kind. This document says plainly what it is and is not, so a future +session (or an integration partner) never has to infer scope from route names alone. + +## What this is + +Four FHIR R4 resource types, each mapped from an existing internal shape close enough to map +faithfully without inventing data: + +| FHIR resource | Mapped from | Interactions | +|---------------------|-------------------------------------------|------------------------| +| `Patient` | `PatientCase` (`packages/contracts/src/index.ts`) | `read` | +| `ImagingStudy` | `ImagingStudy` + its series (`imaging.ts`) | `read` | +| `DiagnosticReport` | `DiagnosticReport` (`imaging.ts`) | `read` | +| `DocumentReference` | `DocumentReference` (`imaging.ts`) | `search-type` only (see below) | + +- `GET /organizations/:organizationId/fhir/r4/metadata` — a `CapabilityStatement` that advertises + exactly these interactions and nothing more (`server/src/fhir/capability-statement.ts`). +- `GET /organizations/:organizationId/fhir/r4/Patient/:caseId` +- `GET /organizations/:organizationId/fhir/r4/ImagingStudy/:studyId` +- `GET /organizations/:organizationId/fhir/r4/DiagnosticReport/:reportId` +- `GET /organizations/:organizationId/fhir/r4/DocumentReference?studyId=...` — a `searchset` + `Bundle`, not a by-id read: the underlying store (`store/imaging-store.ts`) has no + `getDocumentReference(id)`, only `listDocumentReferencesForStudy`, so that is the honest surface + to expose rather than inventing a lookup path the data layer doesn't have. + +Every response is `application/fhir+json`. Mapping logic lives in `server/src/fhir/mappers.ts` and +is pure/unit-tested (`mappers.test.ts`) independently of the routes; route wiring and authorization +enforcement are covered by `routes/fhir.integration.test.ts`. + +## The architecture decisions this was built against + +- **Reuse the existing IAM authorization, don't invent a parallel FHIR permission model.** A FHIR + resource is just a different JSON *shape* of data this server already protects — never a + different trust boundary. `GET .../Patient/:caseId` enforces `patientCase:view` with the exact + same `conditionContext` `routes/cases.ts` uses; `GET .../ImagingStudy/:studyId` and + `.../DiagnosticReport/:reportId` enforce `imagingStudy:view`/`diagnosticReport:view` the same way + `routes/imaging-studies.ts`/`routes/imaging-reports.ts` do. No new IAM actions were added. +- **Identical response for absent and unauthorized**, matching every other resource in this API: a + case/study/report that doesn't exist and one that exists but the caller can't see both return a + 404 `OperationOutcome`. The `DocumentReference` search endpoint follows the same principle in its + own idiom — an empty `Bundle` either way, since a search endpoint returning 404 isn't meaningful + and a non-empty-vs-empty status-code difference would itself leak existence. +- **No new persistence.** Every mapper reads from the store interfaces that already exist + (`CaseStore`, `ImagingStore`) at request time. There is no FHIR-shaped database table, no sync + job, no cache — a `Patient` resource is exactly as fresh as the `PatientCase` it was read from a + moment before. + +## What is deliberately NOT implemented (disclosed gaps) + +- **No write API.** `POST`/`PUT`/`PATCH`/`DELETE` on any FHIR resource do not exist. Every write + still goes through the native routes (`POST /cases`, `POST .../imaging/studies/:id/reports`, + etc.) — FHIR is a read projection on top of them, not an alternate write path. +- **No other R4 resource types.** No `Observation`, `Condition`, `MedicationStatement`, + `Encounter`, `Practitioner`, `Organization`, `Consent`, `AllergyIntolerance`, or anything else — + this system has no internal model for most of these yet, and mapping to them would mean + fabricating clinical data that was never actually captured, which is worse than not exposing the + resource at all. +- **`Patient` has no `name` or `birthDate`.** This system has no structured field for either + anywhere in its domain model — `PatientCase.demographics.age` is a free-text string, not a + `birthDate`. Omitting these FHIR fields was chosen deliberately over fabricating a + probably-wrong value; see `mappers.ts`'s own doc comment. +- **`Patient.gender` is a heuristic best-effort mapping**, not a validated coded value — sourced + from `demographics.sex`, a free-text field this system never constrained at entry time. See + `mapSexToFhirGender` in `mappers.ts` for the exact (small, disclosed) mapping table. +- **No search beyond the one `DocumentReference?studyId=` case.** No `_include`, `_revinclude`, + chained search, `Patient?identifier=`, or any other FHIR search parameter grammar. Every other + resource is by-id `read` only. +- **No resource versioning / `vread` / `_history`.** `Meta.lastUpdated` is populated; `Meta.versionId` + is not, and there is no `/History` interaction. +- **SMART App Launch is partially implemented — discovery + scope/launch-context enforcement + only, no launch redirect flow.** `GET .../fhir/r4/.well-known/smart-configuration` + (`server/src/fhir/smart-configuration.ts`) republishes the external IdP's own + `authorization_endpoint`/`token_endpoint` (resolved once at startup via + `resolveAuthorizationServerMetadata` in `auth/oidc-verifier.ts` — this server is a SMART + *resource server*, never its own authorization server, matching the standing "delegate auth + entirely to an external IdP" decision). When a verified bearer token carries a `patient/*.read` + -shaped SMART scope **and** a `patient` launch-context claim, every FHIR read route + (`server/src/fhir/smart-scopes.ts`'s `resolveSmartLaunchContext`/`deniedBySmartLaunchContext`) + additionally confines that caller to the one patient named by the claim — denied identically + (404) to absent/unauthorized. A plain OIDC token with no SMART scope is completely unaffected; + existing IAM authorization remains the only gate for it, unchanged. **Not implemented**: this + server has no EHR-launch redirect endpoint (`GET .../launch?iss=&launch=`), no standalone-launch + initiation, no PKCE enforcement (it issues no tokens itself), no dynamic client registration, and + does not itself validate that a scope was actually granted by the IdP for this specific + client/purpose beyond trusting the token's signature — same trust already placed in every other + claim on an accepted token. **This is the resource-server side only.** The complementary + client-role capability — this server launching *into* an external EHR's own FHIR API as a SMART + client (authorization_code + PKCE, token storage, no redirect-flow initiation gap) — is a + separate, independent feature; see [docs/SMART_LAUNCH.md](SMART_LAUNCH.md). +- **No terminology validation.** `Coding.system`/`code` values (e.g. the DICOM modality codes on + `ImagingStudy`) are passed through from what this system already stores; nothing here validates + them against an actual DICOM/SNOMED/LOINC code system. +- **Not validated against a FHIR conformance test suite** (e.g. Touchstone, Inferno) and makes no + claim of US Core, IPS, or any other FHIR implementation guide conformance — it is a plain R4 + JSON shape only. +## Extending this + +Adding a new mapped resource type means: a schema in `packages/contracts/src/fhir.ts`, a pure +`toFhirX` mapper in `server/src/fhir/mappers.ts` with its own unit tests, a route in +`routes/fhir.ts` that reuses the *existing* IAM action for that underlying resource (never a new +one), an entry in `capability-statement.ts`'s advertised interactions, and an integration test. +Resist adding a resource type for data this system doesn't actually have a real field for yet — +follow the `Patient.name`/`birthDate` precedent above and simply omit the field, or don't add the +resource until the underlying data exists. diff --git a/docs/HL7_V2_INTEGRATION.md b/docs/HL7_V2_INTEGRATION.md new file mode 100644 index 0000000..dba0e49 --- /dev/null +++ b/docs/HL7_V2_INTEGRATION.md @@ -0,0 +1,144 @@ +# HL7 v2 Integration + +`server/src/hl7/` implements a from-scratch HL7 v2.x message parser/builder and one concrete +outbound message type (ORU^R01), exposed at +`GET /organizations/:organizationId/hl7/v2/DiagnosticReport/:reportId/oru-r01` +(`server/src/routes/hl7.ts`). This document says plainly what that is and is not, matching +[docs/FHIR_INTEGRATION.md](FHIR_INTEGRATION.md)'s own convention for this codebase's other +external-standard facade. + +## What this is + +- **`server/src/hl7/message.ts`** — a generic HL7 v2 ER7 ("pipe-and-hat") parser and builder: + `parseHl7Message`/`buildHl7Message` (segments and fields; MSH's own encoding-character + declaration — component `^`, repetition `~`, escape `\`, subcomponent `&` — is read from the + message itself, never hardcoded), `getField`/`buildSegment` (1-indexed field access matching + HL7's own numbering, e.g. `getField(msh, 9)` for MSH-9), `splitComponents`/`splitRepetitions`/ + `splitSubcomponents` (decompose a field further only where a caller needs to), and + `escapeHl7Text`/`unescapeHl7Text` (the standard `\F\`/`\S\`/`\T\`/`\R\`/`\E\` delimiter-escaping + mechanism, so real data containing `|`/`^`/`&`/`~`/`\` never corrupts message structure). +- **`server/src/hl7/oru-builder.ts`** — `buildOruR01`, mapping this system's own + `DiagnosticReport`/`ImagingStudy` to a real, well-formed HL7 v2.5.1 ORU^R01 (unsolicited + observation result) message: MSH (header) / PID (patient identification) / OBR (the report) / + OBX (conclusion, conclusion code, critical flag). This is the direct HL7 v2 analog of + `server/src/fhir/mappers.ts`'s `toFhirDiagnosticReport` — same source data, same disclosed + limitations (see below), different wire format. +- **`server/src/hl7/inbound-parser.ts`** — `parseOruR01`: parses a raw inbound ORU^R01 message into + `{messageControlId, patientIdentifier?, observations}`, where each observation is a real + `LabResult`-shaped object (name/value/unit/referenceRange/observedAt, read from OBX-3/5/6/7/14). +- **`server/src/hl7/adt-parser.ts`** — `parseAdtMessage`: parses any ADT trigger event (A01/A04/A08/ + A28/...) into `{messageControlId, triggerEvent, patientIdentifier?}` uniformly, since every trigger + event shares the same PID-based identity payload this codebase actually uses. +- **`server/src/hl7/ack-builder.ts`** — `buildAck`: the standard HL7 v2 general-acknowledgment + response (MSH + MSA) a real receiver sends back for every message — used by `mllp-server.ts` below, + and available to any other transport. +- **`server/src/hl7/ingestion.ts`** — `ingestInboundMessage`/`resolveIngestionJob`: the shared match/ + apply pipeline both the HTTP route and the MLLP listener call into. Detects ORU vs. ADT via MSH-9, + matches the patient by exact equality against a case's own `patientId ?? id` (no fuzzy matching — + the same "ambiguous or absent match always requires human review, never a guess" discipline + imaging's own DICOM patient matching uses, `docs/IMAGING.md`), and only for an **unambiguous single + match** applies it: an ORU's observations merge into that case's `labResults`; an ADT has no case + field of its own to update once matched, so "applying" it just records the job — the audit trail of + "this visit event was received and recognized." Every other outcome (no match, 2+ matches, or an + applied-ORU hitting a concurrency conflict twice) creates a `pending-review` `Hl7IngestionJob` row + (`packages/contracts/src/hl7.ts`) and touches no case data; `resolveIngestionJob` lets a reviewer + apply it to a specific case (which, for an ambiguous job, must be one of the job's own recorded + candidates — never an arbitrary id) or reject it with a reason. +- Five HTTP routes in `routes/hl7.ts`. The outbound one (`GET .../DiagnosticReport/:reportId/oru-r01`) + reuses this codebase's *existing* IAM authorization exactly the way `routes/fhir.ts` does — + `imagingStudy:view`/`diagnosticReport:view`, no new HL7-specific permission, identical 404 for + absent and unauthorized. The inbound ones (`POST .../inbound/oru-r01/parse`, `POST + .../inbound/ingest`, `GET .../inbound/jobs`, `POST .../inbound/jobs/:jobId/resolve`) have no single + case/patient resource to reuse an existing action from — parsing is a stateless format conversion, + ingestion matches across every case in the tenant — so they're gated by new `hl7:parseInbound`/ + `hl7:ingest`/`hl7:reviewIngestion` actions instead (403, not a disclosing 404, since there's no one + resource to hide the existence of). A structurally invalid message returns 422, never a 500. +- **`server/src/hl7/mllp-server.ts`** — an opt-in MLLP (Minimal Lower Layer Protocol) TCP listener + bound to exactly one pre-configured organization, feeding every received message into the same + `ingestInboundMessage` pipeline above and replying with a real ACK/NACK. See "MLLP transport" + below for its trust model and bounds. + +## MLLP transport + +`server/src/hl7/mllp-server.ts` is a real MLLP (Minimal Lower Layer Protocol) TCP listener — +`message` framing, the transport almost every real HL7 v2 sender (a lab system, an +EHR interface engine) speaks. It is **off by default** and only starts when every one of +`HL7_MLLP_PORT`/`HL7_MLLP_ORGANIZATION_ID` is explicitly configured (`config.ts`'s `hl7Mllp`); +`HL7_MLLP_HOST` defaults to `127.0.0.1` (loopback-only) and must be explicitly widened. + +**Trust model — read this before enabling it.** A raw TCP connection carries no bearer token, no +OIDC identity, nothing this codebase's IAM layer can check — HL7 v2/MLLP predates OAuth and is +conventionally trusted at the network layer instead (a private network segment, a VPN, an IP +allowlist, or mutual TLS the deployment's own infrastructure terminates — this module speaks plain +TCP with no TLS of its own). Consequently: **one listener serves exactly one pre-configured +organization** (`HL7_MLLP_ORGANIZATION_ID`) — there is no per-message routing to a tenant, since +there is no per-message identity to route by. A deployment integrating with more than one lab feed +needs more than one listener (a future generalization, not attempted here). Every message this +listener processes is attributed to a synthetic `system:hl7-mllp` audit actor, the same +`"system:"` convention already used elsewhere in this codebase for automated action. + +Every received message is fed into the same `ingestInboundMessage` pipeline the HTTP `POST +.../inbound/ingest` route uses, and replied to with a real ACK (`AA`) or NACK (`AE`/`AR`, via +`ack-builder.ts`) — `hl7/mllp-handler.ts` is the specific wiring, and is careful to never let a raw +internal error's text (which could carry connection strings or stack detail) reach the wire; only +an `Hl7ParseError`'s own already-safe message is ever quoted back. + +DoS-conscious by construction: a bounded per-connection buffer (`maxMessageBytes`, default 1 MiB — +a sender that never completes a frame is disconnected, not accumulated forever), a per-connection +idle timeout (default 30s), and a cap on concurrent connections (default 50) — all overridable, none +unbounded by default. + +**Not implemented**: TLS (the deployment's own network/proxy layer is expected to provide it if +needed — see the trust-model paragraph above), retry/redelivery semantics beyond MLLP's own +one-ACK-per-message, and multi-tenant routing (one listener, one organization, by design). + +## What is deliberately NOT implemented (disclosed gaps) + +- **Only ORU^R01 and ADT (any trigger event) are recognized.** No ORM (order), no other HL7 v2 + message type, inbound or outbound. Adding an outbound one means a new `*-builder.ts` file + following `oru-builder.ts`'s own pattern; an inbound one means a new `*-parser.ts` file following + `adt-parser.ts`'s, plus teaching `ingestion.ts`'s message-type detection about it. +- **Ingestion patient matching has no issuer concept.** Exact string equality against + `PatientCase.patientId ?? id` only — `PatientCase` has no `(issuer, value)` pair the way imaging's + `ImagingPatientIdentifier` does, so PID-3's own assigning-authority component (component 4) is + read and stored on the job record for a reviewer's reference, but never used to disambiguate a + match. A real, disclosed limitation of this system's domain model, not an oversight. +- **PID has no name or birth date** — same reasoning and same gap as `fhir/mappers.ts`'s + `toFhirPatient`: this system's domain model has no structured field for either anywhere, so PID-5 + and PID-7 are left empty rather than fabricated. +- **OBR-4 (universal service identifier) is a local, uncoded text description** + (`DX-REPORT^Diagnostic imaging report`), not a LOINC/CPT/local code — this system has no real + terminology binding to draw one from, matching `fhir/mappers.ts`'s own `code.text`-only + `CodeableConcept` for the same report. +- **Not validated against any receiving system's implementation guide, and not conformance-tested + against a real HL7 v2 interface engine or EHR.** Real HL7 v2 integrations are conformance-tested + per trading partner (every EHR vendor's HL7 v2 interface has its own quirks/extensions on top of + the base standard) — this produces a spec-well-formed message, not a claim that any specific + partner will accept it as-is. +- **`RESULT_STATUS`'s mapping from this system's `DiagnosticReport.status` to HL7 v2's result- + status table (0085) is a best-effort nearest match**, not a one-to-one standard mapping — most + notably `entered-in-error` maps to `W` ("wrong patient/wrong test," the nearest documented + withdrawal reason in that table), since HL7 v2 has no generic "entered in error" code. See + `oru-builder.ts`'s own `RESULT_STATUS` map for the complete, exact mapping. + +## UI + +`app/src/hl7-client.ts` (REST glue) + IPC handlers `hl7:listJobs`/`hl7:resolveJob` +(`ipc/shared-backend-handlers.ts`, exposed via `preload.ts`) back +`frontend/src/pages/Hl7Inbox.tsx` (nav: "HL7 Inbox") — an org-wide queue of ingestion jobs, filtered +by status (defaults to `pending-review`). Each job shows its message type, patient identifier, raw +message (collapsible), and match/apply status; a pending-review job gets resolve controls shaped by +its own `matchStatus`: an **ambiguous** match offers one button per `candidateCaseIds` entry (never +a free-text field — the same "must be one of the job's own recorded candidates" constraint +`ingestion.ts` enforces server-side is mirrored here, not just relied on as a backstop); a +**no-match** job needs a case id typed in, since there is nothing to choose from; **reject** always +requires a short reason, recorded on the job for the audit trail via `rejectionReason`. + +## Extending this + +A new outbound message type follows `oru-builder.ts`'s own shape: build each segment with +`buildSegment(id, {fieldNumber: value})`, escape any free-text field value with `escapeHl7Text` +before placing it, assemble the segment array in the message's own defined order, and serialize +with `buildHl7Message`. Give it its own test file proving round-trip parseability and correct field +placement, following `oru-builder.test.ts`'s own pattern — never assume a hand-built segment is +correct without parsing the built output back and checking specific field values. diff --git a/docs/SMART_LAUNCH.md b/docs/SMART_LAUNCH.md new file mode 100644 index 0000000..1722c99 --- /dev/null +++ b/docs/SMART_LAUNCH.md @@ -0,0 +1,148 @@ +# SMART App Launch (client role) + +This is the complement to `docs/FHIR_INTEGRATION.md`'s SMART section. That document covers this +server acting as a SMART **resource server** (an external app presenting a SMART-scoped token to +*this* server's own FHIR facade). This document covers the opposite direction: this server acting +as a SMART **client**, launched from — or reaching out to — an *external* EHR's FHIR server to +obtain a patient-scoped access token. The two are independent capabilities that happen to share the +SMART App Launch spec; neither depends on the other. + +## What this is + +A standard SMART App Launch **public client**, authorization_code + PKCE (S256) only — never a +confidential client, never `client_secret`. Four pieces: + +- `server/src/smart-launch/discovery.ts` — resolves an EHR's own + `{fhirBaseUrl}/.well-known/smart-configuration` (distinct from the OIDC discovery + `auth/oidc-verifier.ts` uses for ModelForge's own IdP) to find its `authorization_endpoint`/ + `token_endpoint`. +- `server/src/smart-launch/pkce.ts` — S256 code verifier/challenge and CSRF `state`, one pair per + launch session. +- `server/src/smart-launch/service.ts` — `createLaunchSession` (validates the target issuer and + `redirectUri` against an org-configured allowlist, builds the authorization URL) and + `completeLaunchCallback` (single-use: exchanges the code for a token, encrypts it, marks the + session completed). +- `server/src/smart-launch/token-crypto.ts` — AES-256-GCM at rest, the exact envelope format + (`iv || authTag || ciphertext`) already used by `imaging/object-store.ts`. Keyed by + `SMART_LAUNCH_ENCRYPTION_KEY` (32 bytes, base64); the token-exchange route 503s if it isn't set, + rather than ever encrypting with no real key (`routes/deps.ts`'s own doc comment on + `smartLaunchEncryptionKey`). + +Routes (`server/src/routes/smart-launch.ts`), all org-scoped under +`/organizations/:organizationId/smart/...`: + +| Route | Gate | Purpose | +|---|---|---| +| `PUT .../trusted-issuers` | `smartLaunch:manage` | Register an EHR issuer + its `client_id` + allowed `redirectUris` | +| `GET .../trusted-issuers` | `smartLaunch:manage` | List them | +| `POST .../trusted-issuers/delete` | `smartLaunch:manage` | Remove one | +| `POST .../launch-sessions` | `smartLaunch:use` | Start a launch: discover, validate, return an authorization URL | +| `POST .../launch-sessions/:state/callback` | `smartLaunch:use` | Exchange the code, store the encrypted token | +| `GET .../sessions` | `smartLaunch:use` | List the caller's own completed launches | +| `POST .../sessions/:sessionId/revoke` | `smartLaunch:use` | Delete a caller's own stored token | + +Persistence: `store/smart-launch-store.ts` (interface), `in-memory-smart-launch-store.ts`, +`postgres-smart-launch-store.ts` (`smart_trusted_issuers`/`smart_launch_sessions`/ +`smart_launch_tokens` tables, migration `026_smart_launch.sql`, provisioned per-tenant the same +way every other clinical domain is). A public API response never carries a secret — internal +session/token shapes (with `codeVerifier`/`encryptedAccessToken`/etc.) are stripped down to +`publicLaunchSession`/`publicToken` before ever reaching a route handler's `reply.send`. + +## The design decision this was built against + +**Every SMART launch route requires an already-authenticated ModelForge session first.** This was +an explicit choice (not a default): a launch never creates or auto-provisions a ModelForge +identity, and there is no unauthenticated redirect entry point anywhere in this flow — every route +sits behind the exact same bearer-token `authPreHandler` every other route in this API uses. A +launch — EHR-initiated (`launch` token present) or standalone — only *attaches* a patient-scoped +external token to a clinician's existing session; it never establishes who that clinician is. This +avoids a second, harder trust problem (verifying an EHR-asserted identity claim and mapping it to a +ModelForge principal) that a from-scratch SSO/auto-provisioning design would require, and keeps the +blast radius of a compromised or misconfigured trusted-issuer entry to "an authenticated clinician +can fetch data from an EHR they already had reason to talk to," not "an unauthenticated caller can +reach ModelForge at all." + +Other decisions: + +- **Exact-match `redirectUri` allowlisting per trusted issuer**, not a prefix or pattern match — + the open-redirect/code-theft guard `service.test.ts` exercises directly. +- **Single-use `state`.** A launch session can only ever be completed once; a second callback with + the same `state` is rejected (`session_not_pending`), matching the OAuth spec's replay guidance. +- **10-minute session TTL** (`SESSION_TTL_MS` in `service.ts`) between starting a launch and + completing its callback. +- **A non-2xx token endpoint response is wrapped, never passed through.** `completeLaunchCallback` + raises `SmartLaunchCallbackError` with a fixed `token_exchange_failed` message — an EHR's raw + error body (which can carry internal detail) is never included in any response or thrown message, + per `service.test.ts`'s own "never leaking the raw response body" test. +- **`smartLaunch:manage` vs `smartLaunch:use` are separate actions**, mirroring every other + admin-config-vs-use split in `domain/action-catalog.ts` (e.g. `aiGateway:manageProviders` vs + `aiGateway:invoke`): configuring which EHRs are trusted is a materially different privilege from + using an already-trusted one. + +## What is deliberately NOT implemented (disclosed gaps) + +- **No FHIR proxy.** Once a token is stored, nothing in this server uses it to actually fetch data + from the EHR's FHIR API. `GET .../sessions` returns the token's metadata (`patientId`, `scope`, + `hasRefreshToken`, expiry) for a caller to use with their own tooling — this server does not act + as a pass-through proxy to the EHR's endpoints. Building that would mean re-deriving this + server's own FHIR-read authorization logic (`docs/FHIR_INTEGRATION.md`) for a completely + different, externally-hosted, non-ModelForge-shaped dataset — out of scope here. +- **No automatic token refresh**, despite storing `refresh_token` (encrypted, when the EHR returns + one). `hasRefreshToken` is exposed so a caller knows one exists; nothing currently uses it. A + launch session simply expires when the EHR's own `expires_in` elapses, requiring a fresh launch. +- **Public client only.** No `client_secret` / confidential-client support. This avoids a second + secrets-management problem (client secret storage, rotation, per-issuer scoping) in this pass; + every trusted issuer is assumed to support public-client PKCE, which most modern EHR sandboxes + (Epic, Cerner/Oracle Health, SMART Health IT reference server) do. +- **No EHR-initiated launch's `iss`/`launch` query-param redirect endpoint.** An EHR's own "launch + this app" click normally lands on a fixed redirect URL carrying `?iss=...&launch=...`; this + server has no such unauthenticated landing route (per the design decision above — there is no + unauthenticated entry point at all). Consuming an EHR-initiated launch today means: the + ModelForge UI captures the `launch` token from wherever it lands, and calls + `POST .../launch-sessions` with it as an already-authenticated clinician, exactly like a + standalone launch. There is no server-side handling of the EHR's actual `iss=`/`launch=` redirect + itself. +- **No `aud` validation beyond echoing it.** The authorization URL includes `aud=` per the + SMART spec's confused-deputy guidance, but this server does not independently verify the EHR + actually enforced it — that enforcement lives entirely on the EHR side. +- **Not validated against a SMART/Inferno conformance test suite.** Built to the spec's + authorization_code + PKCE flow as documented, not run against Da Vinci/Inferno or any EHR + sandbox's own certification suite. + +## UI + +`app/src/smart-launch-client.ts` (REST glue, same pattern as `imaging-client.ts`) and +`app/src/smart-launch-flow.ts` (`runSmartLaunch`) drive the flow from the Electron main process — +the latter mirrors `mcp-oauth.ts`'s own established loopback-redirect pattern exactly: a transient +`http://127.0.0.1:51824/smart/callback` listener (a different port from MCP OAuth's own 51823, so +the two can never collide), `shell.openExternal` to hand the user to the EHR's login in their +system browser, then a single-use capture of `?code=&state=` before the listener closes itself. +`state` is checked against the session this process itself started (defense in depth — the server +enforces it independently) before ever calling back. The IPC surface +(`smartLaunch:listTrustedIssuers`/`upsertTrustedIssuer`/`deleteTrustedIssuer`/`listSessions`/ +`revokeSession`/`start`) is registered in `ipc/shared-backend-handlers.ts` alongside imaging/ +clinicalAi's own handlers, exposed via `preload.ts`. `smartLaunch:start` follows the same +catch-and-return-`{error}` shape as `mcp:startOAuthFlow`/`sharedBackend:connect` (both also +long-running, user-interaction-gated flows), rather than rejecting the IPC call, so a timeout or a +closed browser tab surfaces as an ordinary inline error, not an unhandled-rejection crash. + +The renderer page is `frontend/src/pages/ExternalEhr.tsx` (nav: "External EHR"): trusted-issuer +admin (register/remove — the API's own `smartLaunch:manage` gate is the actual enforcement; the +form itself isn't hidden from anyone, matching every other admin-shaped control already in this +app, e.g. `imaging-panel.tsx`'s `ShareDialog`), a launch trigger (pick a trusted issuer, click +Launch), and the caller's own active-session list with revoke. `GET .../smart/trusted-issuers` was +relaxed server-side to accept `smartLaunch:use` as well as `smartLaunch:manage` specifically to make +this page's launch dropdown possible — a clinician has to see which EHRs are configured to pick one, +and none of that data (issuer URL, public `client_id`, redirect URIs) is secret in a PKCE-only, +no-`client_secret` design. Only the trusted-issuer PUT/delete routes stay `smartLaunch:manage`-only. + +## Extending this + +Adding a FHIR proxy on top of a stored token would need: a new route that decrypts the token +(`token-crypto.ts`), makes the request server-side, and re-applies this server's own IAM + +data-minimization discipline to whatever comes back — never just relaying the EHR's raw response, +since that would bypass every governance layer `docs/CLINICAL_AI_GATEWAY.md` and +`FHIR_INTEGRATION.md` already established. Adding refresh-token use means a background or +on-demand refresh path in `smart-launch/service.ts`, re-encrypting the new access token and +updating `expiresAt` — the store schema already has everything needed (`encryptedRefreshToken`); +only the refresh call itself is unbuilt. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 59dbad3..724109b 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,8 @@ import PatientCaseDetail from "./pages/PatientCaseDetail"; import EvidenceLibrary from "./pages/EvidenceLibrary"; import KnowledgeGraph from "./pages/KnowledgeGraph"; import AuditPrivacy from "./pages/AuditPrivacy"; +import Hl7Inbox from "./pages/Hl7Inbox"; +import ExternalEhr from "./pages/ExternalEhr"; import { ThemeProvider } from "@/components/theme-provider"; import { ToastProvider } from "@/components/toast"; import { SessionsProvider } from "@/lib/sessions-context"; @@ -33,6 +35,8 @@ const router = createHashRouter([ { path: "evidence", element: }, { path: "knowledge-graph", element: }, { path: "audit", element: }, + { path: "hl7-inbox", element: }, + { path: "external-ehr", element: }, ], }, ]); diff --git a/frontend/src/components/layout.tsx b/frontend/src/components/layout.tsx index 3a8843f..34871c6 100644 --- a/frontend/src/components/layout.tsx +++ b/frontend/src/components/layout.tsx @@ -29,6 +29,8 @@ import { BookOpen, Share2, ShieldCheck, + Inbox, + ExternalLink, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -881,6 +883,8 @@ export default function Layout() { } label={t.evidenceLibrary} collapsed={collapsed} disabled={!hasApi} /> } label={t.knowledgeGraph} collapsed={collapsed} disabled={!hasApi} /> } label={t.auditPrivacy} collapsed={collapsed} disabled={!hasApi} /> + } label={t.hl7Inbox} collapsed={collapsed} disabled={!hasApi} /> + } label={t.externalEhr} collapsed={collapsed} disabled={!hasApi} /> {!collapsed && } } label={t.compareModels} collapsed={collapsed} disabled={!hasApi} /> diff --git a/frontend/src/lib/translations.ts b/frontend/src/lib/translations.ts index 7ce3283..4a63c58 100644 --- a/frontend/src/lib/translations.ts +++ b/frontend/src/lib/translations.ts @@ -253,6 +253,8 @@ export interface Dictionary { evidenceLibrary: string; knowledgeGraph: string; auditPrivacy: string; + hl7Inbox: string; + externalEhr: string; navClinicalGroup: string; localProcessing: string; remoteProcessing: string; @@ -695,6 +697,8 @@ export const en: Dictionary = { evidenceLibrary: "Evidence Library", knowledgeGraph: "Knowledge Graph", auditPrivacy: "Audit & Privacy", + hl7Inbox: "HL7 Inbox", + externalEhr: "External EHR", navClinicalGroup: "Clinical", localProcessing: "Local — stays on this device", remoteProcessing: "Remote — sent to a cloud provider", @@ -1148,6 +1152,8 @@ export const tr: Dictionary = { evidenceLibrary: "Kanıt Kütüphanesi", knowledgeGraph: "Bilgi Grafiği", auditPrivacy: "Denetim ve Gizlilik", + hl7Inbox: "HL7 Gelen Kutusu", + externalEhr: "Harici EHR", navClinicalGroup: "Klinik", localProcessing: "Yerel — bu cihazda kalır", remoteProcessing: "Uzak — bir bulut sağlayıcısına gönderilir", diff --git a/frontend/src/pages/Chat.tsx b/frontend/src/pages/Chat.tsx index 56235ba..6db4fe4 100644 --- a/frontend/src/pages/Chat.tsx +++ b/frontend/src/pages/Chat.tsx @@ -1409,13 +1409,13 @@ export default function Chat() { // ever touching the workspace bridge): records the result and, once // nothing else from this turn is still pending, hands control back to // the model. - function resolveToolCall(call: ToolCall, resultText: string) { + function resolveToolCall(call: ToolCall, resultText: string, mcpOperation?: ChatMessage["mcpOperation"]) { setPendingToolCalls((prev) => { const remaining = prev.filter((c) => c.id !== call.id); setMessages((m) => { const next: ChatMessage[] = [ ...m, - { role: "tool", content: resultText, toolCallId: call.id, toolName: call.name }, + { role: "tool", content: resultText, toolCallId: call.id, toolName: call.name, mcpOperation }, ]; if (remaining.length === 0) continueAfterTools(next); return next; @@ -1442,6 +1442,7 @@ export default function Chat() { const startedAt = Date.now(); let resultText: string; + let mcpOperation: ChatMessage["mcpOperation"]; if (!approve) { resultText = "The user denied this tool call."; } else if (!agentWorkspace) { @@ -1454,18 +1455,39 @@ export default function Chat() { agentWorkspace, call.name, call.arguments, - (progress) => setExecutingCall((cur) => (cur?.callId === call.id ? { ...cur, progress } : cur)) + (progress) => setExecutingCall((cur) => (cur?.callId === call.id ? { ...cur, progress } : cur)), + { patientCaseId: attachedCaseId ?? undefined, humanApproved: approve && !autoApproved } ); setExecutingCall({ callId: call.id, requestId }); const res = await promise; setExecutingCall((cur) => (cur?.callId === call.id ? null : cur)); + const structured = res.result as { + clinicalOperation?: { operationId: string; operationDigest: string; policySnapshot: NonNullable["policySnapshot"]; result?: unknown }; + provenance?: { registryEntryId?: string; serverName?: string; toolName?: string }; + } | undefined; + if (structured?.clinicalOperation && structured.provenance?.registryEntryId && structured.provenance.serverName && structured.provenance.toolName) { + const review = structured.clinicalOperation.result as { reviewId?: unknown; decision?: unknown } | undefined; + mcpOperation = { + registryEntryId: structured.provenance.registryEntryId, + serverName: structured.provenance.serverName, + toolName: structured.provenance.toolName, + operationId: structured.clinicalOperation.operationId, + operationDigest: structured.clinicalOperation.operationDigest, + policySnapshot: structured.clinicalOperation.policySnapshot, + reviewId: typeof review?.reviewId === "string" ? review.reviewId : undefined, + reviewDecision: review?.decision === "approved" || review?.decision === "rejected" || review?.decision === "needs_revision" ? review.decision : undefined, + }; + } resultText = res.error ? `Error: ${res.error}` : typeof res.result === "string" ? res.result : JSON.stringify(res.result, null, 2); } else { - const res = await window.api.agent.executeTool(agentWorkspace, call.name, call.arguments); + const res = await window.api.agent.executeTool(agentWorkspace, call.name, call.arguments, { + patientCaseId: attachedCaseId ?? undefined, + humanApproved: approve && !autoApproved, + }); resultText = res.error ? `Error: ${res.error}` : typeof res.result === "string" @@ -1490,7 +1512,7 @@ export default function Chat() { }); } - resolveToolCall(call, resultText); + resolveToolCall(call, resultText, mcpOperation); } function cancelExecutingTool() { diff --git a/frontend/src/pages/ExternalEhr.tsx b/frontend/src/pages/ExternalEhr.tsx new file mode 100644 index 0000000..407f290 --- /dev/null +++ b/frontend/src/pages/ExternalEhr.tsx @@ -0,0 +1,227 @@ +import { useCallback, useEffect, useState } from "react"; +import { ExternalLink, Plus, RefreshCw, Trash2, Zap } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { EmptyState, InlineNotice } from "@/components/ds"; +import { useToast } from "@/components/toast"; +import { useI18n } from "@/lib/i18n"; +import { formatRelativeTime } from "@/lib/format-time"; +import type { SmartLaunchToken, SmartTrustedIssuer } from "@/types/electron"; + +/** Registers a new trusted EHR — org-admin only server-side + * (smartLaunch:manage); this form is shown to everyone (the API itself is + * the enforcement point, same as every other admin-shaped action in this + * app — see imaging-panel.tsx's ShareDialog for the same convention), so a + * clinician without rights sees a normal toast error rather than a hidden + * control that mysteriously isn't there. */ +function AddTrustedIssuerForm({ onAdded }: { onAdded: () => void }) { + const toast = useToast(); + const [issuer, setIssuer] = useState(""); + const [clientId, setClientId] = useState(""); + const [redirectUris, setRedirectUris] = useState(""); + const [submitting, setSubmitting] = useState(false); + + async function submit() { + setSubmitting(true); + try { + const uris = redirectUris.split(",").map((u) => u.trim()).filter(Boolean); + await window.api.smartLaunch.upsertTrustedIssuer({ issuer: issuer.trim(), clientId: clientId.trim(), redirectUris: uris }); + setIssuer(""); setClientId(""); setRedirectUris(""); + toast.success("Trusted EHR issuer saved."); + onAdded(); + } catch (err) { + toast.error((err as Error).message); + } finally { + setSubmitting(false); + } + } + + return ( +
+

Register a trusted EHR

+
+ + + +
+
+ +
+
+ ); +} + +export default function ExternalEhr() { + const { t } = useI18n(); + const toast = useToast(); + const hasApi = typeof window !== "undefined" && !!window.api; + const [issuers, setIssuers] = useState([]); + const [sessions, setSessions] = useState([]); + const [selectedIssuer, setSelectedIssuer] = useState(""); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [launching, setLaunching] = useState(false); + const [busyId, setBusyId] = useState(null); + + const refresh = useCallback((): Promise => { + if (!hasApi) return Promise.resolve(); + return Promise.all([window.api.smartLaunch.listTrustedIssuers(), window.api.smartLaunch.listSessions()]) + .then(([nextIssuers, nextSessions]) => { + setIssuers(nextIssuers); + setSessions(nextSessions); + setSelectedIssuer((current) => current || nextIssuers[0]?.issuer || ""); + setError(null); + }) + .catch((err: unknown) => setError((err as Error).message)) + .finally(() => setLoading(false)); + }, [hasApi]); + + useEffect(() => { void refresh(); }, [refresh]); + + async function launch() { + if (!selectedIssuer) return; + setLaunching(true); + try { + const result = await window.api.smartLaunch.start(selectedIssuer); + if (result.error) { + toast.error(result.error); + } else { + toast.success(`Connected — patient ${result.token?.patientId ?? "context"} available.`); + await refresh(); + } + } catch (err) { + toast.error((err as Error).message); + } finally { + setLaunching(false); + } + } + + async function removeIssuer(issuer: string) { + if (!confirm(`Remove trusted EHR "${issuer}"? Existing sessions from it are unaffected, but no new launch can start against it.`)) return; + setBusyId(issuer); + try { + await window.api.smartLaunch.deleteTrustedIssuer(issuer); + toast.success("Trusted EHR removed."); + await refresh(); + } catch (err) { + toast.error((err as Error).message); + } finally { + setBusyId(null); + } + } + + async function revokeSession(session: SmartLaunchToken) { + setBusyId(session.id); + try { + await window.api.smartLaunch.revokeSession(session.id); + toast.success("Session revoked."); + await refresh(); + } catch (err) { + toast.error((err as Error).message); + } finally { + setBusyId(null); + } + } + + if (!hasApi) { + return ( +
+ External EHR is only available when running inside the Electron app. +
+ ); + } + + return ( +
+
+ + {t.externalEhr} + +
+ + +
+ + Connects this app to an external EHR's own FHIR data for a patient-scoped session — it does not + automatically attach anything to a case. See docs/SMART_LAUNCH.md for the full flow and its disclosed + gaps (no automatic data pull, public-client PKCE only). + + {error && {error}} + +
+

Start a launch

+ {issuers.length === 0 ? ( +

No trusted EHRs are registered yet — add one below.

+ ) : ( +
+ + +
+ )} + {launching &&

A browser window opened for you to sign in at the EHR. Complete authorization there, then return here.

} +
+ +
+

Active sessions

+ {sessions.length === 0 ? ( + + ) : ( +
+ {sessions.map((session) => ( +
+
+

{session.issuer}

+

+ {session.patientId ? `Patient ${session.patientId} · ` : ""} + Connected {formatRelativeTime(session.createdAt)} · expires {formatRelativeTime(session.expiresAt)} +

+
+ +
+ ))} +
+ )} +
+ + void refresh()} /> + + {issuers.length > 0 && ( +
+

Trusted EHRs

+
+ {issuers.map((iss) => ( +
+
+

{iss.issuer}

+

client_id {iss.clientId} · {iss.redirectUris.length} redirect URI(s)

+
+ +
+ ))} +
+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/pages/Hl7Inbox.tsx b/frontend/src/pages/Hl7Inbox.tsx new file mode 100644 index 0000000..93c7f9a --- /dev/null +++ b/frontend/src/pages/Hl7Inbox.tsx @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useState } from "react"; +import { AlertTriangle, Inbox, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { EmptyState, InlineNotice } from "@/components/ds"; +import { StatusBadge, type StatusTone } from "@/components/ds/status-badge"; +import { useToast } from "@/components/toast"; +import { useI18n } from "@/lib/i18n"; +import { formatRelativeTime } from "@/lib/format-time"; +import type { Hl7IngestionJob } from "@/types/electron"; + +type StatusFilter = "pending-review" | "applied" | "rejected" | "all"; + +const STATUS_TONE: Record = { + "pending-review": "warning", + applied: "success", + rejected: "neutral", +}; + +const MATCH_TONE: Record = { + matched: "success", + ambiguous: "warning", + "no-match": "error", +}; + +/** One pending-review job's resolve controls. Ambiguous matches offer a + * button per candidate case (never an arbitrary id — resolveHl7IngestionJob + * enforces the same server-side, this just avoids offering a choice the + * server would reject); a "matched" job that's still pending (a retry after + * a concurrency conflict — see hl7/ingestion.ts) offers its one matched + * case; a genuine no-match needs a case id typed in, since there is nothing + * to pick from. Rejecting always requires a short reason, recorded on the + * job for the audit trail. */ +function ResolveRow({ job, resolving, onResolve }: { job: Hl7IngestionJob; resolving: boolean; onResolve: (decision: { action: "apply"; caseId: string } | { action: "reject"; reason: string }) => void }) { + const [manualCaseId, setManualCaseId] = useState(""); + const [rejectReason, setRejectReason] = useState(""); + + const applyTargets = job.matchStatus === "ambiguous" ? (job.candidateCaseIds ?? []) : job.matchedCaseId ? [job.matchedCaseId] : []; + + return ( +
+ {applyTargets.map((caseId) => ( + + ))} + {applyTargets.length === 0 && ( +
+ setManualCaseId(e.target.value)} /> + +
+ )} +
+ setRejectReason(e.target.value)} /> + +
+
+ ); +} + +function JobCard({ job, resolving, onResolve }: { job: Hl7IngestionJob; resolving: boolean; onResolve: (decision: { action: "apply"; caseId: string } | { action: "reject"; reason: string }) => void }) { + return ( +
+
+
+

+ {job.messageType} · {job.patientIdentifierValue ?? "no patient identifier"} + {job.patientIdentifierIssuer ? ` (${job.patientIdentifierIssuer})` : ""} +

+

+ Received {formatRelativeTime(job.receivedAt)} · control id {job.messageControlId || "—"} + {job.observationsAdded ? ` · ${job.observationsAdded} observation(s) merged` : ""} +

+
+
+ {job.matchStatus} + {job.status} +
+
+ {job.rejectionReason &&

Rejected: {job.rejectionReason}

} +
+ Raw message +
{job.rawMessage}
+
+ {job.status === "pending-review" && } +
+ ); +} + +export default function Hl7Inbox() { + const { t } = useI18n(); + const toast = useToast(); + const hasApi = typeof window !== "undefined" && !!window.api; + const [filter, setFilter] = useState("pending-review"); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [resolvingJobId, setResolvingJobId] = useState(null); + + const refresh = useCallback((): Promise => { + if (!hasApi) return Promise.resolve(); + return window.api.hl7 + .listJobs(filter === "all" ? undefined : filter) + .then((next) => { setJobs(next); setError(null); }) + .catch((err: unknown) => setError((err as Error).message)) + .finally(() => setLoading(false)); + }, [hasApi, filter]); + + useEffect(() => { void refresh(); }, [refresh]); + + async function resolve(job: Hl7IngestionJob, decision: { action: "apply"; caseId: string } | { action: "reject"; reason: string }) { + setResolvingJobId(job.id); + try { + await window.api.hl7.resolveJob(job.id, decision); + toast.success(decision.action === "apply" ? `Applied to case ${decision.caseId}.` : "Message rejected."); + await refresh(); + } catch (err) { + toast.error((err as Error).message); + } finally { + setResolvingJobId(null); + } + } + + if (!hasApi) { + return ( +
+ HL7 Inbox is only available when running inside the Electron app. +
+ ); + } + + return ( +
+
+ + {t.hl7Inbox} +
+ {(["pending-review", "applied", "rejected", "all"] as const).map((f) => ( + + ))} + +
+
+ + +
+ + Every inbound ORU/ADT message that didn't match exactly one existing case by patient identifier lands here for + human review — an ambiguous or absent match is never guessed automatically. See docs/HL7_V2_INTEGRATION.md. + + + {error && {error}} + {!error && !loading && jobs.length === 0 && ( + } title="Nothing here" description={`No ${filter === "all" ? "" : filter + " "}HL7 ingestion jobs.`} /> + )} +
+ {jobs.map((job) => ( + void resolve(job, decision)} /> + ))} +
+
+
+
+ ); +} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 93fed2f..14093fd 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -975,6 +975,21 @@ export default function Settings() { } } + async function importManagedClinicalServers() { + if (!settings) return; + const result = await window.api.mcp.listManagedClinicalServers(); + if (result.error) { + toast.error(result.error); + return; + } + const discovered = result.servers ?? []; + const discoveredIds = new Set(discovered.map((server) => server.id)); + const next = [...(settings.mcpServers ?? []).filter((server) => !discoveredIds.has(server.id)), ...discovered]; + const updated = await window.api.settings.save({ mcpServers: next }); + setSettings(updated); + toast.success(discovered.length > 0 ? `${discovered.length} institutional clinical MCP server(s) added.` : "No active institutional clinical MCP servers were found."); + } + async function removeMcpServer(id: string) { if (!settings) return; await window.api.mcp.disconnect(id); @@ -2275,6 +2290,9 @@ export default function Settings() { > {t.addMcpServer} +
{MCP_SERVER_PRESETS.map((preset) => (
diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index 658adf5..c2d1831 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -47,6 +47,16 @@ export interface ChatMessage { // distinguishes it from a real tool result so the UI can render it as a // pass/fail checklist card instead of a generic tool-output box. isVerification?: boolean; + mcpOperation?: { + registryEntryId: string; + serverName: string; + toolName: string; + operationId: string; + operationDigest: string; + policySnapshot: { registryVersion: string; rbacVersion: string; egressPolicyVersion: string; killSwitchVersion: string; toolPolicyVersion: string }; + reviewId?: string; + reviewDecision?: "approved" | "rejected" | "needs_revision"; + }; // RAG chunks that were retrieved and folded into this message's prompt // content, kept separately so the UI can render them as source citations // instead of just the flattened text that went to the model. @@ -203,6 +213,7 @@ export interface McpServerConfig { headers?: Record; trustProfile?: { autoApprovedTools: string[] }; auth?: { type: "none" | "oauth2" }; + oauthClientId?: string; blockedTools?: string[]; warningBanner?: string; } @@ -885,6 +896,10 @@ export type ImagingStudy = SharedImagingStudy; export type ImagingIngestionJob = SharedImagingIngestionJob; export type ImagingShareGrant = SharedImagingShareGrant; export type ViewerSession = SharedViewerSession; +export type Hl7IngestionJob = SharedHl7IngestionJob; +export type Hl7ResolveDecision = { action: "apply"; caseId: string } | { action: "reject"; reason: string }; +export type SmartTrustedIssuer = SharedSmartTrustedIssuer; +export type SmartLaunchToken = SharedSmartLaunchToken; export interface CreateImagingShareInput { mode: "internal" | "cross-organization" | "external-portal"; @@ -1230,13 +1245,15 @@ export interface ElectronApi { executeTool: ( workspaceRoot: string, name: string, - args: Record + args: Record, + clinicalContext?: { patientCaseId?: string; humanApproved?: boolean } ) => Promise<{ result?: unknown; error?: string }>; executeToolWithProgress: ( workspaceRoot: string, name: string, args: Record, - onProgress: (progress: { progress: number; total?: number; message?: string }) => void + onProgress: (progress: { progress: number; total?: number; message?: string }) => void, + clinicalContext?: { patientCaseId?: string; humanApproved?: boolean } ) => { requestId: string; promise: Promise<{ result?: unknown; error?: string }> }; rollbackLastWrite: (workspaceRoot: string) => Promise; detectScripts: (workspaceRoot: string) => Promise; @@ -1373,6 +1390,7 @@ export interface ElectronApi { review: (outputId: string, review: { decision: SharedAiReview["decision"]; correctedText?: string; escalationReason?: string }) => Promise; }; mcp: { + listManagedClinicalServers: () => Promise<{ servers?: McpServerConfig[]; error?: string }>; connect: ( config: McpServerConfig ) => Promise<{ tools?: { name: string; description?: string; inputSchema?: Record }[]; error?: string }>; @@ -1385,6 +1403,18 @@ export interface ElectronApi { hasOAuthTokens: (serverId: string) => Promise; clearOAuthCredentials: (serverId: string) => Promise; }; + hl7: { + listJobs: (status?: Hl7IngestionJob["status"]) => Promise; + resolveJob: (jobId: string, decision: Hl7ResolveDecision) => Promise; + }; + smartLaunch: { + listTrustedIssuers: () => Promise; + upsertTrustedIssuer: (input: { issuer: string; clientId: string; redirectUris: string[] }) => Promise; + deleteTrustedIssuer: (issuer: string) => Promise; + listSessions: () => Promise; + revokeSession: (sessionId: string) => Promise; + start: (issuer: string) => Promise<{ token?: SmartLaunchToken; error?: string }>; + }; screen: { listSources: () => Promise; capture: (sourceId: string) => Promise; @@ -1434,4 +1464,7 @@ import type { AiCitation as SharedAiCitation, AiReview as SharedAiReview, DeidentificationJob as SharedDeidentificationJob, + Hl7IngestionJob as SharedHl7IngestionJob, + SmartTrustedIssuer as SharedSmartTrustedIssuer, + SmartLaunchToken as SharedSmartLaunchToken, } from "@modelforge/contracts"; diff --git a/infra/imaging-cdk/package-lock.json b/infra/imaging-cdk/package-lock.json index c6e48a7..e2f9136 100644 --- a/infra/imaging-cdk/package-lock.json +++ b/infra/imaging-cdk/package-lock.json @@ -12,7 +12,7 @@ "constructs": "^10.8.1" }, "devDependencies": { - "@types/node": "^22.10.2", + "@types/node": "^22.20.1", "aws-cdk": "^2.1139.0", "tsx": "^4.19.2", "typescript": "^5.7.2" diff --git a/infra/imaging-cdk/package.json b/infra/imaging-cdk/package.json index 1bfaa69..9da46e5 100644 --- a/infra/imaging-cdk/package.json +++ b/infra/imaging-cdk/package.json @@ -14,7 +14,7 @@ "deploy": "cdk deploy" }, "devDependencies": { - "@types/node": "^22.10.2", + "@types/node": "^22.20.1", "aws-cdk": "^2.1139.0", "tsx": "^4.19.2", "typescript": "^5.7.2" diff --git a/packages/contracts/src/ai-gateway.ts b/packages/contracts/src/ai-gateway.ts index ded8e2d..e493d8c 100644 --- a/packages/contracts/src/ai-gateway.ts +++ b/packages/contracts/src/ai-gateway.ts @@ -347,6 +347,13 @@ export const aiOutputSchema = z modelVersion: z.string().min(1).max(100), generatedAt: timestampSchema, summary: z.string().min(1).max(20_000), + // The gateway's own system-prompt version (server/src/ai-gateway/ + // prompt-registry.ts), NOT the provider's modelVersion above — two + // independent axes of "what produced this output." Required, not + // optional: every output has always been generated from some + // prompt text, this just makes which one an explicit, queryable + // fact instead of an unrecorded implementation detail. + promptVersion: z.string().min(1).max(100), evidence: z.array(z.string().max(2_000)).default([]), uncertainty: z.string().max(4_000).optional(), followUp: z.array(z.string().max(2_000)).default([]), diff --git a/packages/contracts/src/fhir.ts b/packages/contracts/src/fhir.ts new file mode 100644 index 0000000..44ca04f --- /dev/null +++ b/packages/contracts/src/fhir.ts @@ -0,0 +1,276 @@ +import { z } from "zod"; + +/** + * FHIR R4 resource shapes exposed by server/src/routes/fhir.ts. + * + * Scope, deliberately: this is a **read-only FHIR R4 facade** over data that + * already lives in this system's own domain stores (patient_cases, clinical + * imaging) — not a general-purpose FHIR resource server, not a new + * persistence layer, and not a validator for arbitrary inbound FHIR + * resources. Four resource types are mapped (Patient, DiagnosticReport, + * ImagingStudy, DocumentReference), chosen because this codebase already has + * an internal shape close enough to map faithfully — see + * server/src/fhir/mappers.ts for the mapping and its own disclosed + * approximations (most notably: Patient has no structured name/birthDate + * anywhere in this system, so those FHIR fields are simply absent rather + * than fabricated). docs/FHIR_INTEGRATION.md has the full scope statement, + * what's NOT implemented (write API, most other R4 resource types, terminology + * validation, `_include`/chained search, versioned history), and why. + * + * Every schema below is `.strict()` the same way the rest of this package is + * — safe here because these resources are only ever server-constructed + * (mappers.ts), never parsed from untrusted client input; there is no FHIR + * write API yet for a real client to send us one of these. + */ + +const fhirId = z.string().min(1).max(200); +const fhirInstant = z.string().datetime({ offset: true }); +const fhirDate = z.string().regex(/^\d{4}(-\d{2}(-\d{2})?)?$/, "must be a FHIR date (YYYY, YYYY-MM, or YYYY-MM-DD)"); + +export const fhirIdentifierSchema = z + .object({ + system: z.string().max(500).optional(), + value: z.string().min(1).max(500), + }) + .strict(); +export type FhirIdentifier = z.infer; + +export const fhirCodingSchema = z + .object({ + system: z.string().max(500).optional(), + code: z.string().max(200).optional(), + display: z.string().max(500).optional(), + }) + .strict(); + +export const fhirCodeableConceptSchema = z + .object({ + coding: z.array(fhirCodingSchema).max(50).optional(), + text: z.string().max(2_000).optional(), + }) + .strict(); +export type FhirCodeableConcept = z.infer; + +export const fhirReferenceSchema = z + .object({ + reference: z.string().max(1_000).optional(), + display: z.string().max(500).optional(), + }) + .strict(); +export type FhirReference = z.infer; + +export const fhirExtensionSchema = z + .object({ + url: z.string().min(1).max(500), + valueString: z.string().max(2_000).optional(), + }) + .strict(); + +export const fhirMetaSchema = z + .object({ + lastUpdated: fhirInstant.optional(), + }) + .strict(); + +// --- Patient --- +// Administrative gender only — a coded FHIR field this system's free-text +// `sex` case field is heuristically mapped onto (see mappers.ts). Never a +// clinical/biological-sex assertion. +export const fhirAdministrativeGenderSchema = z.enum(["male", "female", "other", "unknown"]); + +export const fhirPatientSchema = z + .object({ + resourceType: z.literal("Patient"), + id: fhirId, + meta: fhirMetaSchema.optional(), + active: z.boolean().optional(), + identifier: z.array(fhirIdentifierSchema).max(50).optional(), + gender: fhirAdministrativeGenderSchema.optional(), + // No `name`/`birthDate`: this system has no structured field for + // either anywhere in its domain model (patientCaseSchema's + // `demographics.age` is free text, not a birthDate). Fabricating + // either here would be worse than omitting them. + extension: z.array(fhirExtensionSchema).max(20).optional(), + }) + .strict(); +export type FhirPatient = z.infer; + +// --- DiagnosticReport --- +export const fhirDiagnosticReportStatusSchema = z.enum(["preliminary", "final", "amended", "corrected", "cancelled", "entered-in-error"]); + +export const fhirDiagnosticReportSchema = z + .object({ + resourceType: z.literal("DiagnosticReport"), + id: fhirId, + meta: fhirMetaSchema.optional(), + status: fhirDiagnosticReportStatusSchema, + code: fhirCodeableConceptSchema, + subject: fhirReferenceSchema.optional(), + issued: fhirInstant.optional(), + effectiveDateTime: fhirInstant.optional(), + conclusion: z.string().max(100_000).optional(), + conclusionCode: z.array(fhirCodeableConceptSchema).max(20).optional(), + imagingStudy: z.array(fhirReferenceSchema).max(20).optional(), + extension: z.array(fhirExtensionSchema).max(20).optional(), + }) + .strict(); +export type FhirDiagnosticReport = z.infer; + +// --- ImagingStudy --- +export const fhirImagingStudyStatusSchema = z.enum(["registered", "available", "cancelled", "entered-in-error", "unknown"]); + +export const fhirImagingStudySeriesSchema = z + .object({ + uid: z.string().min(1).max(200), + number: z.number().int().nonnegative().optional(), + modality: fhirCodingSchema, + description: z.string().max(2_000).optional(), + numberOfInstances: z.number().int().nonnegative().optional(), + }) + .strict(); + +export const fhirImagingStudySchema = z + .object({ + resourceType: z.literal("ImagingStudy"), + id: fhirId, + meta: fhirMetaSchema.optional(), + status: fhirImagingStudyStatusSchema, + identifier: z.array(fhirIdentifierSchema).max(10).optional(), + modality: z.array(fhirCodingSchema).max(50).optional(), + subject: fhirReferenceSchema.optional(), + started: fhirInstant.optional(), + numberOfSeries: z.number().int().nonnegative().optional(), + numberOfInstances: z.number().int().nonnegative().optional(), + description: z.string().max(2_000).optional(), + series: z.array(fhirImagingStudySeriesSchema).max(1_000).optional(), + }) + .strict(); +export type FhirImagingStudy = z.infer; + +// --- DocumentReference --- +// Always "current": this system's internal DocumentReference (imaging.ts) +// tracks no superseded/entered-in-error lifecycle state, so those FHIR +// status values are never emitted (not the same claim as "never happens" — +// see mappers.ts's doc comment on this specific gap). +export const fhirDocumentReferenceStatusSchema = z.enum(["current"]); + +export const fhirAttachmentSchema = z + .object({ + contentType: z.string().max(200).optional(), + size: z.number().int().nonnegative().optional(), + hash: z.string().max(200).optional(), + title: z.string().max(2_000).optional(), + }) + .strict(); + +export const fhirDocumentReferenceSchema = z + .object({ + resourceType: z.literal("DocumentReference"), + id: fhirId, + status: fhirDocumentReferenceStatusSchema, + type: fhirCodeableConceptSchema.optional(), + subject: fhirReferenceSchema.optional(), + date: fhirInstant.optional(), + content: z.array(z.object({ attachment: fhirAttachmentSchema }).strict()).min(1).max(1), + }) + .strict(); +export type FhirDocumentReference = z.infer; + +// --- OperationOutcome --- +export const fhirIssueSeveritySchema = z.enum(["fatal", "error", "warning", "information"]); +export const fhirIssueCodeSchema = z.enum(["not-found", "forbidden", "invalid", "processing"]); + +export const fhirOperationOutcomeSchema = z + .object({ + resourceType: z.literal("OperationOutcome"), + issue: z + .array( + z + .object({ + severity: fhirIssueSeveritySchema, + code: fhirIssueCodeSchema, + diagnostics: z.string().max(2_000).optional(), + }) + .strict() + ) + .min(1), + }) + .strict(); +export type FhirOperationOutcome = z.infer; + +// --- Bundle (searchset only — this facade has no other Bundle use yet) --- +export const fhirBundleEntrySchema = z + .object({ + fullUrl: z.string().max(2_000).optional(), + resource: z.unknown(), + }) + .strict(); + +export const fhirBundleSchema = z + .object({ + resourceType: z.literal("Bundle"), + type: z.literal("searchset"), + total: z.number().int().nonnegative(), + entry: z.array(fhirBundleEntrySchema), + }) + .strict(); +export type FhirBundle = z.infer; + +// --- CapabilityStatement --- +export const fhirCapabilityStatementSchema = z + .object({ + resourceType: z.literal("CapabilityStatement"), + status: z.literal("active"), + date: fhirInstant, + kind: z.literal("instance"), + fhirVersion: z.literal("4.0.1"), + format: z.array(z.literal("json")), + rest: z.array( + z + .object({ + mode: z.literal("server"), + resource: z.array( + z + .object({ + type: z.string().min(1).max(100), + interaction: z.array(z.object({ code: z.enum(["read", "search-type"]) }).strict()), + }) + .strict() + ), + }) + .strict() + ), + }) + .strict(); +export type FhirCapabilityStatement = z.infer; + +// --- SMART on FHIR discovery (`.well-known/smart-configuration`) --- +// +// This server is a SMART *resource server*, never its own authorization +// server — actual OAuth authorization/token issuance is delegated entirely +// to whichever external OIDC IdP is configured (auth/oidc-verifier.ts's own +// top doc comment; the same standing architecture decision this reuses +// rather than overrides). `authorization_endpoint`/`token_endpoint` here are +// therefore always the *external IdP's* endpoints, discovered from its own +// `.well-known/openid-configuration` — see server/src/fhir/smart- +// configuration.ts and docs/FHIR_INTEGRATION.md's SMART section for what +// this does and, just as importantly, does not implement (no dynamic client +// registration, no PKCE enforcement by this server since it issues no +// tokens itself, no EHR-launch redirect endpoint). +export const fhirSmartConfigurationSchema = z + .object({ + issuer: z.string().min(1).max(2_000), + authorization_endpoint: z.string().min(1).max(2_000), + token_endpoint: z.string().min(1).max(2_000), + capabilities: z.array(z.string().min(1).max(200)), + code_challenge_methods_supported: z.array(z.string().min(1).max(50)), + grant_types_supported: z.array(z.string().min(1).max(50)), + scopes_supported: z.array(z.string().min(1).max(200)), + }) + .strict(); +export type FhirSmartConfiguration = z.infer; + +// fhirDate is exported only for reuse by mappers.ts's own input validation +// of `studyDate`-shaped strings; it is not part of any schema's public +// surface above (ImagingStudy.started is a full instant, not a bare date). +export { fhirDate }; diff --git a/packages/contracts/src/hl7.ts b/packages/contracts/src/hl7.ts new file mode 100644 index 0000000..f80574d --- /dev/null +++ b/packages/contracts/src/hl7.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; + +/** + * Inbound HL7 v2 ingestion — tracking what happened when an inbound + * message (ORU^R01 lab result, or an ADT admit/update event) was matched + * against this tenant's patient cases. See server/src/hl7/ingestion.ts for + * the actual match/apply logic and docs/HL7_V2_INTEGRATION.md for the full + * architecture and disclosed scope. + * + * Same "ambiguous match requires human review, never a guess" discipline + * as clinical imaging's own DICOM patient matching (packages/contracts's + * imaging.ts, `ImagingIngestionJob`) — this schema is deliberately + * structured the same way: a job row persists regardless of outcome, + * `matchStatus` records what patient-matching found, `status` records + * what (if anything) was actually applied to a case as a result. + */ +const identifierSchema = z.string().min(1).max(200); +const timestampSchema = z.string().datetime({ offset: true }); + +export const hl7IngestionMatchStatusSchema = z.enum(["matched", "ambiguous", "no-match"]); +export const hl7IngestionStatusSchema = z.enum(["pending-review", "applied", "rejected"]); + +export const hl7IngestionJobSchema = z + .object({ + id: identifierSchema, + /** e.g. "ORU^R01", "ADT^A01", "ADT^A08" — free text, not a closed + * enum, since HL7 v2 trigger events are inherently open-ended (see + * server/src/hl7/adt-parser.ts's own doc comment on accepting any + * ADT trigger event uniformly). */ + messageType: z.string().min(1).max(20), + messageControlId: z.string().max(200), + /** The raw inbound message text — kept for review (a reviewer + * resolving an ambiguous/no-match job needs to see what the + * message actually said), same "clinical text lives directly in a + * tenant-schema row" pattern as AiOutput.summary/PatientCase's own + * clinicalNotes, not a separate blob store. */ + rawMessage: z.string().min(1).max(50_000), + receivedAt: timestampSchema, + patientIdentifierValue: z.string().max(200).optional(), + patientIdentifierIssuer: z.string().max(200).optional(), + matchStatus: hl7IngestionMatchStatusSchema, + matchedCaseId: identifierSchema.optional(), + /** Populated only when matchStatus is "ambiguous" — the actual + * candidate case ids a reviewer must choose between, never a + * silent pick-one. */ + candidateCaseIds: z.array(identifierSchema).max(200).optional(), + status: hl7IngestionStatusSchema, + /** Set only for an applied ORU message — how many observations + * were merged into the matched case's labResults. Always 0 for an + * applied ADT message (see ingestion.ts: ADT has no case field of + * its own to update once the patient is matched — the job record + * itself is the audit trail of "this visit event was received and + * recognized," not a data mutation). */ + observationsAdded: z.number().int().nonnegative().optional(), + reviewedByUserId: identifierSchema.optional(), + reviewedAt: timestampSchema.optional(), + rejectionReason: z.string().max(2_000).optional(), + createdAt: timestampSchema, + updatedAt: timestampSchema, + }) + .strict() + .refine((v) => v.matchStatus !== "ambiguous" || (v.candidateCaseIds !== undefined && v.candidateCaseIds.length > 1), { + message: "an ambiguous match must list its candidate case ids", + path: ["candidateCaseIds"], + }) + .refine((v) => v.matchStatus !== "matched" || v.status === "pending-review" || v.matchedCaseId !== undefined, { + message: "an applied/rejected job with matchStatus matched must record matchedCaseId", + path: ["matchedCaseId"], + }) + .refine((v) => (v.status === "rejected") === (v.rejectionReason !== undefined), { + message: "rejectionReason is required for (and only for) a rejected job", + path: ["rejectionReason"], + }) + .refine((v) => (v.reviewedByUserId !== undefined) === (v.reviewedAt !== undefined), { + message: "reviewedByUserId and reviewedAt must be set together", + path: ["reviewedAt"], + }); +export type Hl7IngestionJob = z.infer; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 18d3d56..afa6ae6 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { mcpOperationProvenanceSchema } from "./mcp-clinical.js"; // This package is the only runtime source of truth for clinical payloads // crossing the Electron/server boundary. Keep storage-only metadata in @@ -255,6 +256,7 @@ export const chatMessageSchema = z toolName: z.string().max(500).optional(), pinned: z.boolean().optional(), isVerification: z.boolean().optional(), + mcpOperation: mcpOperationProvenanceSchema.optional(), }) .strict(); export type ChatMessage = z.infer; @@ -345,7 +347,21 @@ export * from "./imaging.js"; // AiConsent is "exactly which purpose, which data categories, since when, // until when." export * from "./ai-gateway.js"; +export * from "./mcp-clinical.js"; // Enterprise CPU/GPU control plane — PHI-free inventory, policy, request, // and lease contracts shared by the server scheduler and managed node agent. export * from "./compute.js"; + +// FHIR R4 read facade (server/src/routes/fhir.ts) — a dedicated domain, kept +// in its own module. See fhir.ts's own top doc comment for scope. +export * from "./fhir.js"; + +// HL7 v2 inbound ingestion (server/src/hl7/) — a dedicated domain, kept in +// its own module. See hl7.ts's own top doc comment for scope. +export * from "./hl7.js"; + +// SMART App Launch client role (server/src/smart-launch/) — a dedicated +// domain, kept in its own module. See smart-launch.ts's own top doc +// comment for scope. +export * from "./smart-launch.js"; diff --git a/packages/contracts/src/mcp-clinical.ts b/packages/contracts/src/mcp-clinical.ts new file mode 100644 index 0000000..5a8d027 --- /dev/null +++ b/packages/contracts/src/mcp-clinical.ts @@ -0,0 +1,87 @@ +import { z } from "zod"; + +const identifier = z.string().min(1).max(512); +const timestamp = z.string().datetime({ offset: true }); + +export const mcpDestinationClassSchema = z.enum(["local_model_forge", "managed_model_forge", "approved_third_party"]); +export type McpDestinationClass = z.infer; +export const mcpRiskClassSchema = z.enum(["read_only", "controlled_write", "prohibited"]); +export const mcpEgressClassSchema = z.enum(["none", "local_only", "approved_remote"]); + +export const mcpCatalogEntrySchema = z.object({ + name: identifier, + description: z.string().min(1).max(4_000), + risk: mcpRiskClassSchema, + egress: mcpEgressClassSchema, + phiFields: z.array(z.string().min(1).max(100)).max(100), + idempotencyRequired: z.boolean(), +}).strict(); +export type McpCatalogEntry = z.infer; + +export const mcpPolicySnapshotSchema = z.object({ + registryVersion: identifier, + rbacVersion: identifier, + egressPolicyVersion: identifier, + killSwitchVersion: identifier, + toolPolicyVersion: identifier, +}).strict(); +export type McpPolicySnapshot = z.infer; + +export const mcpOperationResponseSchema = z.object({ + operationId: z.string().uuid(), + operationDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/), + policySnapshot: mcpPolicySnapshotSchema, + result: z.unknown(), +}).strict(); +export type McpOperationResponse = z.infer; + +export const mcpContextGrantSchema = z.object({ + id: identifier, + subjectId: identifier, + clientId: identifier, + organizationId: z.string().uuid(), + caseId: identifier, + allowedTools: z.array(identifier).min(1).max(100), + allowedFields: z.array(z.string().min(1).max(100)).min(1).max(100), + purpose: z.string().min(1).max(100), + destination: mcpDestinationClassSchema, + expiresAtEpochSeconds: z.number().int().positive(), + version: z.number().int().positive(), +}).strict(); +export type McpContextGrant = z.infer; + +export const mcpApprovalChallengeSchema = z.object({ + challengeId: identifier, + operationDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/), + policySnapshot: mcpPolicySnapshotSchema, + expiresAtEpochSeconds: z.number().int().positive(), +}).strict(); +export type McpApprovalChallenge = z.infer; + +export const mcpApprovalRequestSchema = z.object({ + id: z.string().uuid(), + organizationId: z.string().uuid(), + registryEntryId: z.string().uuid(), + subjectId: identifier, + clientId: identifier, + toolName: identifier, + operationDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/), + caseId: identifier.optional(), + status: z.enum(["pending", "confirmed", "expired"]), + expiresAt: timestamp, + createdAt: timestamp, + confirmedAt: timestamp.optional(), +}).strict(); +export type McpApprovalRequest = z.infer; + +export const mcpOperationProvenanceSchema = z.object({ + registryEntryId: z.string().uuid(), + serverName: z.string().min(1).max(500), + toolName: identifier, + operationId: z.string().uuid(), + operationDigest: z.string().regex(/^sha256:[a-f0-9]{64}$/), + policySnapshot: mcpPolicySnapshotSchema, + reviewId: z.string().uuid().optional(), + reviewDecision: z.enum(["approved", "rejected", "needs_revision"]).optional(), +}).strict(); +export type McpOperationProvenance = z.infer; diff --git a/packages/contracts/src/smart-launch.ts b/packages/contracts/src/smart-launch.ts new file mode 100644 index 0000000..ca226dd --- /dev/null +++ b/packages/contracts/src/smart-launch.ts @@ -0,0 +1,89 @@ +import { z } from "zod"; + +/** + * SMART App Launch — the client role: ModelForge acting as a SMART app + * embedded in (or launched from) an external EHR, requesting access to + * THAT EHR's own FHIR data. The mirror image of `fhir.ts`'s facade, which + * is this server acting as the FHIR *resource server* for its own data. + * See server/src/smart-launch/ and docs/SMART_LAUNCH.md for the full flow + * and disclosed scope. + * + * Load-bearing design decision, per explicit product direction (not this + * codebase's own default assumption): **a launch requires an already- + * authenticated ModelForge session.** There is no unauthenticated redirect + * entry point anywhere in this flow — every route below sits behind the + * same `deps.authPreHandler` bearer-token check every other route in this + * API does. This sidesteps the much larger, separate problem of trusting + * an external EHR's identity claims to establish a *new* ModelForge + * session (auto-provisioning/SSO) — deliberately out of scope here. + * + * Public-client (PKCE, no client_secret) only — see + * server/src/smart-launch/token-crypto.ts's own doc comment on why a + * confidential-client (client_secret) flow isn't implemented either. + */ +const identifierSchema = z.string().min(1).max(200); +const timestampSchema = z.string().datetime({ offset: true }); + +/** An organization's own allowlist of EHRs it trusts enough to launch a + * SMART session against — configured by an admin ahead of time + * (`smartLaunch:manage`), never inferred from an unauthenticated launch + * request. `issuer` doubles as the FHIR base URL, per SMART App Launch's + * own convention (the `iss` a launch names IS the FHIR server to talk to; + * its `.well-known/smart-configuration` describes where to authorize). */ +export const smartTrustedIssuerSchema = z + .object({ + id: identifierSchema, + issuer: z.string().url().max(2_000), + clientId: z.string().min(1).max(500), + /** Exact-match allowlist — a launch-session request's own + * `redirectUri` must equal one of these verbatim. Never a prefix/ + * pattern match (an open-redirect-shaped mistake this schema + * makes structurally harder to make). */ + redirectUris: z.array(z.string().url().max(2_000)).min(1).max(20), + addedByUserId: identifierSchema, + createdAt: timestampSchema, + }) + .strict(); +export type SmartTrustedIssuer = z.infer; + +export const smartLaunchSessionStatusSchema = z.enum(["pending", "completed", "expired"]); + +/** The pending, single-use authorization-request record between "redirect + * the user to the EHR" and "the EHR redirected back with a code" — never + * contains the PKCE code_verifier or anything else secret in any API + * response shape (see server/src/smart-launch/store.ts for where that + * actually lives, store-internal only). */ +export const smartLaunchSessionSchema = z + .object({ + id: identifierSchema, + issuer: z.string().url().max(2_000), + requestedByUserId: identifierSchema, + scope: z.string().min(1).max(1_000), + status: smartLaunchSessionStatusSchema, + createdAt: timestampSchema, + expiresAt: timestampSchema, + }) + .strict(); +export type SmartLaunchSession = z.infer; + +/** A completed launch — what the token exchange produced, minus the + * secrets themselves (access_token/refresh_token live only in the store's + * own internal, encrypted-at-rest representation; see + * server/src/store/smart-launch-store.ts). */ +export const smartLaunchTokenSchema = z + .object({ + id: identifierSchema, + issuer: z.string().url().max(2_000), + requestedByUserId: identifierSchema, + scope: z.string().min(1).max(1_000), + /** SMART launch context — which patient this token is scoped to, + * when the EHR's token response included one (SMART's own + * `patient` response parameter). Absent for a launch that didn't + * request/receive patient context. */ + patientId: z.string().max(200).optional(), + hasRefreshToken: z.boolean(), + expiresAt: timestampSchema, + createdAt: timestampSchema, + }) + .strict(); +export type SmartLaunchToken = z.infer; diff --git a/server/migrations/023_ai_output_prompt_version.sql b/server/migrations/023_ai_output_prompt_version.sql new file mode 100644 index 0000000..605fc03 --- /dev/null +++ b/server/migrations/023_ai_output_prompt_version.sql @@ -0,0 +1,233 @@ +-- Model/prompt versioning (docs/CLINICAL_AI_GATEWAY.md's own disclosed gap: +-- "the system prompt is a single hardcoded SYSTEM_PROMPT constant in +-- gateway.ts with no version/hash tracked per request"). Adds +-- ai_outputs.prompt_version, populated going forward by +-- server/src/ai-gateway/prompt-registry.ts's getCurrentSystemPrompt(). +-- +-- Same two-part structure as migrations 017/018's own precedent, but +-- lighter: rather than replacing the whole provision_tenant_ai_gateway_tables +-- function body just to add one column to its CREATE TABLE statement, this +-- adds a single idempotent `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` call +-- inside that function (via CREATE OR REPLACE, since PL/pgSQL functions +-- have no ALTER-in-place). That one statement is correct for BOTH cases at +-- once: a brand new tenant (the CREATE TABLE just above it in the same +-- function still runs first and creates the table without this column; the +-- ALTER TABLE immediately adds it) and an already-provisioned tenant (the +-- CREATE TABLE is a no-op since the table exists; the ALTER TABLE adds the +-- missing column, backfilling every existing row with the DEFAULT below). +-- Part 2 re-runs the existing backfill loop, which already calls this same +-- function for every tenant schema — nothing new to add there. +-- +-- Every other line of the function body below is copied verbatim from +-- migration 018's own version of this function. + +CREATE OR REPLACE FUNCTION provision_tenant_ai_gateway_tables(schema_name TEXT) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $aigateway$ +BEGIN + -- Per-tenant opt-in/override on top of the global catalog. A model + -- existing globally with phi_permitted=true does NOT by itself let any + -- tenant send it PHI — the *effective* permission the gateway enforces + -- is "global.phi_permitted AND tenant.phi_allowed," always the AND of + -- both, never either alone (see server/src/ai-gateway/provider-registry.ts). + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.ai_provider_tenant_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider_model_id UUID NOT NULL REFERENCES public.ai_provider_models(id), + enabled BOOLEAN NOT NULL DEFAULT FALSE, + phi_allowed BOOLEAN NOT NULL DEFAULT FALSE, + allowed_roles TEXT[] NOT NULL DEFAULT ''{}'', + approved_by_user_id UUID NOT NULL, + approved_at TIMESTAMPTZ NOT NULL DEFAULT now(), + notes TEXT, + UNIQUE (provider_model_id) + )', schema_name + ); + + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.ai_consents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + patient_case_id TEXT NOT NULL, + version INTEGER NOT NULL, + purpose TEXT NOT NULL CHECK (purpose IN (''treatment'',''research'',''teaching'',''quality-improvement'')), + data_categories TEXT[] NOT NULL, + status TEXT NOT NULL CHECK (status IN (''active'',''revoked'',''expired'')) DEFAULT ''active'', + granted_by_user_id UUID NOT NULL, + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ, + revoked_by_user_id UUID, + revoked_at TIMESTAMPTZ, + revoked_reason TEXT, + UNIQUE (patient_case_id, version) + )', schema_name + ); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.ai_consents (patient_case_id, status)', 'idx_' || schema_name || '_ai_consents_case', schema_name); + + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.ai_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + patient_case_id TEXT NOT NULL, + requested_by_user_id UUID NOT NULL, + provider_model_id UUID NOT NULL REFERENCES public.ai_provider_models(id), + purpose_of_use TEXT NOT NULL, + consent_id UUID NOT NULL REFERENCES %I.ai_consents(id), + policy_snapshot_hash TEXT NOT NULL, + data_scope JSONB NOT NULL, + deidentification_applied BOOLEAN NOT NULL DEFAULT FALSE, + status TEXT NOT NULL CHECK (status IN ( + ''draft'',''pending-authorization'',''scanning'',''queued'',''running'', + ''awaiting-review'',''accepted'',''rejected'',''corrected'',''escalated'', + ''failed'',''cancelled'',''expired'' + )) DEFAULT ''draft'', + rejection_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ + )', schema_name, schema_name + ); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.ai_requests (patient_case_id, created_at DESC)', 'idx_' || schema_name || '_ai_requests_case', schema_name); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.ai_requests (status) WHERE status NOT IN (''accepted'',''rejected'',''failed'',''cancelled'',''expired'')', 'idx_' || schema_name || '_ai_requests_open', schema_name); + + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.ai_request_inputs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + request_id UUID NOT NULL REFERENCES %I.ai_requests(id) ON DELETE CASCADE, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + resource_version_hash TEXT, + included_in_prompt BOOLEAN NOT NULL DEFAULT TRUE + )', schema_name, schema_name + ); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.ai_request_inputs (request_id)', 'idx_' || schema_name || '_ai_request_inputs_req', schema_name); + + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.ai_data_transformations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + request_id UUID NOT NULL REFERENCES %I.ai_requests(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK (kind IN (''minimization'',''redaction'',''deidentification'',''pseudonymization'',''content-scan'')), + applied_at TIMESTAMPTZ NOT NULL DEFAULT now(), + details JSONB + )', schema_name, schema_name + ); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.ai_data_transformations (request_id)', 'idx_' || schema_name || '_ai_transformations_req', schema_name); + + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.ai_outputs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + request_id UUID NOT NULL REFERENCES %I.ai_requests(id) ON DELETE CASCADE, + provider_model_id UUID NOT NULL REFERENCES public.ai_provider_models(id), + model_version TEXT NOT NULL, + generated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + summary TEXT NOT NULL, + evidence TEXT[] NOT NULL DEFAULT ''{}'', + uncertainty TEXT, + follow_up TEXT[] NOT NULL DEFAULT ''{}'', + abstained BOOLEAN NOT NULL DEFAULT FALSE, + abstain_reason TEXT, + confidence REAL, + output_hash TEXT NOT NULL, + review_status TEXT NOT NULL CHECK (review_status IN (''unreviewed'',''accepted'',''rejected'',''corrected'',''escalated'')) DEFAULT ''unreviewed'' + )', schema_name, schema_name + ); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.ai_outputs (request_id)', 'idx_' || schema_name || '_ai_outputs_req', schema_name); + -- New this migration: see this file's own top comment for why a single + -- idempotent ALTER TABLE here (rather than adding the column to the + -- CREATE TABLE statement above) correctly covers both new and existing + -- tenants with one statement. + EXECUTE format('ALTER TABLE %I.ai_outputs ADD COLUMN IF NOT EXISTS prompt_version TEXT NOT NULL DEFAULT ''clinical-gateway-prompt-v1''', schema_name); + + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.ai_citations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + output_id UUID NOT NULL REFERENCES %I.ai_outputs(id) ON DELETE CASCADE, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + resource_version_hash TEXT, + locator TEXT + )', schema_name, schema_name + ); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.ai_citations (output_id)', 'idx_' || schema_name || '_ai_citations_output', schema_name); + + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.ai_reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + output_id UUID NOT NULL REFERENCES %I.ai_outputs(id) ON DELETE CASCADE, + reviewed_by_user_id UUID NOT NULL, + decision TEXT NOT NULL CHECK (decision IN (''accepted'',''rejected'',''corrected'',''escalated'')), + corrected_text TEXT, + escalation_reason TEXT, + reviewed_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (output_id) + )', schema_name, schema_name + ); + + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.ai_safety_events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + request_id UUID, + kind TEXT NOT NULL CHECK (kind IN ( + ''prompt-injection-detected'',''secret-detected'',''unsupported-content-detected'', + ''dlp-block'',''abstained'',''provider-failure'',''consent-violation-blocked'', + ''quota-exceeded'',''kill-switch-blocked'' + )), + severity TEXT NOT NULL CHECK (severity IN (''info'',''warning'',''critical'')), + details TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )', schema_name + ); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.ai_safety_events (created_at DESC)', 'idx_' || schema_name || '_ai_safety_events_time', schema_name); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.ai_safety_events (severity) WHERE severity = ''critical''', 'idx_' || schema_name || '_ai_safety_events_critical', schema_name); + + -- Metadata-only change feed (never prompt/output text) — same shape as + -- imaging_changes / case_changes for a future sync client. + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.ai_gateway_change_counter ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + next_sequence BIGINT NOT NULL DEFAULT 1 + )', schema_name + ); + EXECUTE format('INSERT INTO %I.ai_gateway_change_counter (singleton, next_sequence) VALUES (TRUE, 1) ON CONFLICT DO NOTHING', schema_name); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.ai_gateway_changes ( + sequence BIGINT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN (''upsert'', ''delete'')), + resource_type TEXT NOT NULL CHECK (resource_type IN (''request'',''output'',''review'',''consent'')), + resource_id TEXT NOT NULL, + resource JSONB NOT NULL, + changed_at TIMESTAMPTZ NOT NULL + )', schema_name + ); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.ai_gateway_changes (resource_type, sequence DESC)', 'idx_' || schema_name || '_ai_gateway_changes_type', schema_name); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'modelforge_runtime') THEN + EXECUTE format( + 'GRANT SELECT, INSERT, UPDATE, DELETE ON + %I.ai_provider_tenant_settings, %I.ai_consents, %I.ai_requests, %I.ai_request_inputs, + %I.ai_data_transformations, %I.ai_outputs, %I.ai_citations, %I.ai_reviews, + %I.ai_safety_events, %I.ai_gateway_change_counter, %I.ai_gateway_changes + TO modelforge_runtime', + schema_name, schema_name, schema_name, schema_name, schema_name, schema_name, + schema_name, schema_name, schema_name, schema_name, schema_name + ); + END IF; +END +$aigateway$; + +REVOKE ALL ON FUNCTION provision_tenant_ai_gateway_tables(TEXT) FROM PUBLIC; + +-- Backfill: re-run the (now-updated) function for every already-provisioned +-- tenant schema — same loop as migration 018's own Part 2, safe to re-run +-- since every statement in the function is idempotent (IF NOT EXISTS / +-- ADD COLUMN IF NOT EXISTS). +DO $backfill$ +DECLARE + org RECORD; +BEGIN + FOR org IN SELECT tenant_schema FROM organizations WHERE tenant_schema IS NOT NULL LOOP + PERFORM provision_tenant_ai_gateway_tables(org.tenant_schema); + END LOOP; +END +$backfill$; diff --git a/server/migrations/024_hl7_ingestion.sql b/server/migrations/024_hl7_ingestion.sql new file mode 100644 index 0000000..6dd682d --- /dev/null +++ b/server/migrations/024_hl7_ingestion.sql @@ -0,0 +1,190 @@ +-- HL7 v2 inbound ingestion job tracking (server/src/hl7/ingestion.ts, +-- routes/hl7.ts's POST .../inbound/ingest). See docs/HL7_V2_INTEGRATION.md. +-- +-- Same three-part structure as every migration since 015/017/018/022: a +-- dedicated `provision_tenant_hl7_tables` sub-function (Part 1, this +-- domain's own tables, same split-into-its-own-function reason +-- provision_tenant_imaging_tables/provision_tenant_ai_gateway_tables were); +-- provision_tenant_clinical_schema replaced to call it for every *future* +-- organization (Part 2, copied verbatim from migration 022's version of +-- this function except the one new PERFORM line at the end); and a +-- backfill for every already-provisioned tenant schema (Part 3). + +-- --- Part 1: this domain's own tables --------------------------------------- + +CREATE OR REPLACE FUNCTION provision_tenant_hl7_tables(schema_name TEXT) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $hl7$ +BEGIN + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.hl7_ingestion_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + message_type TEXT NOT NULL, + message_control_id TEXT NOT NULL, + raw_message TEXT NOT NULL, + received_at TIMESTAMPTZ NOT NULL, + patient_identifier_value TEXT, + patient_identifier_issuer TEXT, + match_status TEXT NOT NULL CHECK (match_status IN (''matched'',''ambiguous'',''no-match'')), + matched_case_id TEXT, + candidate_case_ids TEXT[], + status TEXT NOT NULL CHECK (status IN (''pending-review'',''applied'',''rejected'')), + observations_added INTEGER, + reviewed_by_user_id UUID, + reviewed_at TIMESTAMPTZ, + rejection_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + )', schema_name + ); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.hl7_ingestion_jobs (status, created_at DESC)', 'idx_' || schema_name || '_hl7_ingestion_jobs_status', schema_name); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.hl7_ingestion_jobs (patient_identifier_value)', 'idx_' || schema_name || '_hl7_ingestion_jobs_patient', schema_name); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'modelforge_runtime') THEN + EXECUTE format('GRANT SELECT, INSERT, UPDATE, DELETE ON %I.hl7_ingestion_jobs TO modelforge_runtime', schema_name); + END IF; +END +$hl7$; + +REVOKE ALL ON FUNCTION provision_tenant_hl7_tables(TEXT) FROM PUBLIC; + +-- --- Part 2: extend provision_tenant_clinical_schema (verbatim copy of +-- migration 022's version, plus one new PERFORM line before RETURN) ------- + +CREATE OR REPLACE FUNCTION provision_tenant_clinical_schema(target_org UUID) +RETURNS TEXT +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $provision$ +DECLARE + schema_name TEXT := 'tenant_' || replace(target_org::text, '-', ''); +BEGIN + IF NOT EXISTS (SELECT 1 FROM public.organizations WHERE id = target_org) THEN + RAISE EXCEPTION 'Unknown organization'; + END IF; + UPDATE public.organizations SET tenant_schema = schema_name WHERE id = target_org; + EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I', schema_name); + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'modelforge_runtime') THEN + EXECUTE format('GRANT USAGE ON SCHEMA %I TO modelforge_runtime', schema_name); + EXECUTE format( + 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO modelforge_runtime', + CURRENT_USER, schema_name + ); + END IF; + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.patient_cases ( + case_id TEXT PRIMARY KEY, + version BIGINT NOT NULL, + data JSONB NOT NULL, + patient_id TEXT NOT NULL, + owner_user_id UUID NOT NULL, + workspace_id TEXT, + department_id TEXT, + assigned_user_ids UUID[] NOT NULL DEFAULT ''{}'', + active_consent_scopes TEXT[] NOT NULL DEFAULT ''{}'', + staged_migration_id UUID, + active BOOLEAN NOT NULL DEFAULT TRUE, + updated_at TIMESTAMPTZ NOT NULL + )', schema_name + ); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.case_change_counter ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + next_sequence BIGINT NOT NULL DEFAULT 1 + )', schema_name + ); + EXECUTE format('INSERT INTO %I.case_change_counter (singleton, next_sequence) VALUES (TRUE, 1) ON CONFLICT DO NOTHING', schema_name); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.case_changes ( + sequence BIGINT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN (''upsert'', ''delete'')), + case_id TEXT NOT NULL, + version BIGINT NOT NULL, + patient_case JSONB, + resource JSONB NOT NULL, + changed_at TIMESTAMPTZ NOT NULL + )', schema_name + ); + EXECUTE format('ALTER TABLE %I.case_changes ADD COLUMN IF NOT EXISTS resource JSONB', schema_name); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.case_changes (case_id, sequence DESC)', 'idx_' || schema_name || '_case_changes_case', schema_name); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.case_migrations ( + id UUID PRIMARY KEY, + status TEXT NOT NULL CHECK (status IN (''staging'', ''validated'', ''active'', ''rolled-back'')), + source_fingerprint TEXT NOT NULL, + total_items INTEGER NOT NULL, + accepted_items INTEGER NOT NULL DEFAULT 0, + preview JSONB, + created_by UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE (source_fingerprint, created_by) + )', schema_name + ); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.case_migration_items ( + migration_id UUID NOT NULL REFERENCES %I.case_migrations(id) ON DELETE CASCADE, + item_key TEXT NOT NULL, + case_id TEXT NOT NULL, + data JSONB NOT NULL, + data_hash TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN (''pending'', ''accepted'', ''invalid'', ''collision'')), + errors JSONB NOT NULL DEFAULT ''[]'', + PRIMARY KEY (migration_id, item_key) + )', schema_name, schema_name + ); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.chat_sessions ( + id TEXT PRIMARY KEY, + version BIGINT NOT NULL, + data JSONB NOT NULL, + owner_user_id UUID NOT NULL, + assigned_user_ids UUID[] NOT NULL DEFAULT ''{}'', + updated_at TIMESTAMPTZ NOT NULL + )', schema_name + ); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.chat_session_change_counter ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + next_sequence BIGINT NOT NULL DEFAULT 1 + )', schema_name + ); + EXECUTE format('INSERT INTO %I.chat_session_change_counter (singleton, next_sequence) VALUES (TRUE, 1) ON CONFLICT DO NOTHING', schema_name); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.chat_session_changes ( + sequence BIGINT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN (''upsert'', ''delete'')), + session_id TEXT NOT NULL, + version BIGINT NOT NULL, + session_data JSONB, + resource JSONB NOT NULL, + changed_at TIMESTAMPTZ NOT NULL + )', schema_name + ); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.chat_session_changes (session_id, sequence DESC)', 'idx_' || schema_name || '_chat_session_changes_session', schema_name); + + PERFORM provision_tenant_imaging_tables(schema_name); + PERFORM provision_tenant_ai_gateway_tables(schema_name); + -- New this migration. + PERFORM provision_tenant_hl7_tables(schema_name); + + RETURN schema_name; +END +$provision$; + +REVOKE ALL ON FUNCTION provision_tenant_clinical_schema(UUID) FROM PUBLIC; + +-- --- Part 3: backfill every already-provisioned tenant schema -------------- +DO $backfill$ +DECLARE + org RECORD; +BEGIN + FOR org IN SELECT tenant_schema FROM organizations WHERE tenant_schema IS NOT NULL LOOP + PERFORM provision_tenant_hl7_tables(org.tenant_schema); + END LOOP; +END +$backfill$; diff --git a/server/migrations/025_mcp_clinical_control_plane.sql b/server/migrations/025_mcp_clinical_control_plane.sql new file mode 100644 index 0000000..7982d63 --- /dev/null +++ b/server/migrations/025_mcp_clinical_control_plane.sql @@ -0,0 +1,61 @@ +ALTER TABLE mcp_registry_entries + ADD COLUMN IF NOT EXISTS integration_profile TEXT NOT NULL DEFAULT 'generic' + CHECK (integration_profile IN ('generic', 'modelforge-clinical')), + ADD COLUMN IF NOT EXISTS oauth_client_id TEXT, + ADD COLUMN IF NOT EXISTS catalog_version_constraint TEXT, + ADD COLUMN IF NOT EXISTS approval_challenge_endpoint TEXT; + +CREATE TABLE IF NOT EXISTS mcp_context_grants ( + id TEXT PRIMARY KEY, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + subject_id TEXT NOT NULL, + client_id TEXT NOT NULL, + case_id TEXT NOT NULL, + allowed_tools TEXT[] NOT NULL, + allowed_fields TEXT[] NOT NULL, + purpose TEXT NOT NULL, + destination TEXT NOT NULL CHECK (destination IN ('local_model_forge','managed_model_forge','approved_third_party')), + expires_at TIMESTAMPTZ NOT NULL, + version BIGINT NOT NULL CHECK (version > 0), + revoked_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS idx_mcp_context_grants_org_expiry ON mcp_context_grants(organization_id, expires_at); + +CREATE TABLE IF NOT EXISTS mcp_approval_requests ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + registry_entry_id UUID NOT NULL REFERENCES mcp_registry_entries(id), + subject_id TEXT NOT NULL, + client_id TEXT NOT NULL, + tool_name TEXT NOT NULL, + operation_digest TEXT NOT NULL, + case_id TEXT, + status TEXT NOT NULL CHECK (status IN ('pending','confirmed')), + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + confirmed_at TIMESTAMPTZ +); +CREATE INDEX IF NOT EXISTS idx_mcp_approval_requests_org_expiry ON mcp_approval_requests(organization_id, expires_at); + +CREATE TABLE IF NOT EXISTS mcp_operation_reviews ( + id UUID PRIMARY KEY, + organization_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + case_id TEXT NOT NULL, + reviewer_subject_id TEXT NOT NULL, + reviewed_operation_id UUID NOT NULL, + decision TEXT NOT NULL CHECK (decision IN ('approved','rejected','needs_revision')), + rationale TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (organization_id, reviewed_operation_id) +); + +ALTER TABLE mcp_context_grants ENABLE ROW LEVEL SECURITY; +ALTER TABLE mcp_approval_requests ENABLE ROW LEVEL SECURITY; +ALTER TABLE mcp_operation_reviews ENABLE ROW LEVEL SECURITY; +DROP POLICY IF EXISTS tenant_isolation ON mcp_context_grants; +DROP POLICY IF EXISTS tenant_isolation ON mcp_approval_requests; +DROP POLICY IF EXISTS tenant_isolation ON mcp_operation_reviews; +CREATE POLICY tenant_isolation ON mcp_context_grants USING (organization_id = nullif(current_setting('app.tenant_id', true), '')::uuid) WITH CHECK (organization_id = nullif(current_setting('app.tenant_id', true), '')::uuid); +CREATE POLICY tenant_isolation ON mcp_approval_requests USING (organization_id = nullif(current_setting('app.tenant_id', true), '')::uuid) WITH CHECK (organization_id = nullif(current_setting('app.tenant_id', true), '')::uuid); +CREATE POLICY tenant_isolation ON mcp_operation_reviews USING (organization_id = nullif(current_setting('app.tenant_id', true), '')::uuid) WITH CHECK (organization_id = nullif(current_setting('app.tenant_id', true), '')::uuid); diff --git a/server/migrations/026_smart_launch.sql b/server/migrations/026_smart_launch.sql new file mode 100644 index 0000000..cd8da88 --- /dev/null +++ b/server/migrations/026_smart_launch.sql @@ -0,0 +1,211 @@ +-- SMART App Launch client-role state (server/src/smart-launch/, +-- routes/smart-launch.ts). See docs/SMART_LAUNCH.md. +-- +-- Same three-part structure as migration 024 (HL7 ingestion) and every +-- migration since 015/017/018/022 before it: a dedicated +-- `provision_tenant_smart_launch_tables` sub-function (Part 1); +-- provision_tenant_clinical_schema replaced to call it for every *future* +-- organization (Part 2, copied verbatim from migration 024's version of +-- this function except the one new PERFORM line); a backfill for every +-- already-provisioned tenant schema (Part 3). + +-- --- Part 1: this domain's own tables --------------------------------------- + +CREATE OR REPLACE FUNCTION provision_tenant_smart_launch_tables(schema_name TEXT) +RETURNS VOID +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $smart$ +BEGIN + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.smart_trusted_issuers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issuer TEXT NOT NULL UNIQUE, + client_id TEXT NOT NULL, + redirect_uris TEXT[] NOT NULL, + added_by_user_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + )', schema_name + ); + + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.smart_launch_sessions ( + state TEXT PRIMARY KEY, + issuer TEXT NOT NULL, + requested_by_user_id UUID NOT NULL, + scope TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN (''pending'',''completed'',''expired'')), + code_verifier TEXT NOT NULL, + redirect_uri TEXT NOT NULL, + launch TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL + )', schema_name + ); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.smart_launch_sessions (requested_by_user_id)', 'idx_' || schema_name || '_smart_launch_sessions_user', schema_name); + + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.smart_launch_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + issuer TEXT NOT NULL, + requested_by_user_id UUID NOT NULL, + scope TEXT NOT NULL, + patient_id TEXT, + encrypted_access_token TEXT NOT NULL, + encrypted_refresh_token TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ NOT NULL + )', schema_name + ); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.smart_launch_tokens (requested_by_user_id)', 'idx_' || schema_name || '_smart_launch_tokens_user', schema_name); + + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'modelforge_runtime') THEN + EXECUTE format( + 'GRANT SELECT, INSERT, UPDATE, DELETE ON %I.smart_trusted_issuers, %I.smart_launch_sessions, %I.smart_launch_tokens TO modelforge_runtime', + schema_name, schema_name, schema_name + ); + END IF; +END +$smart$; + +REVOKE ALL ON FUNCTION provision_tenant_smart_launch_tables(TEXT) FROM PUBLIC; + +-- --- Part 2: extend provision_tenant_clinical_schema (verbatim copy of +-- migration 024's version, plus one new PERFORM line before RETURN) ------- + +CREATE OR REPLACE FUNCTION provision_tenant_clinical_schema(target_org UUID) +RETURNS TEXT +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = pg_catalog, public +AS $provision$ +DECLARE + schema_name TEXT := 'tenant_' || replace(target_org::text, '-', ''); +BEGIN + IF NOT EXISTS (SELECT 1 FROM public.organizations WHERE id = target_org) THEN + RAISE EXCEPTION 'Unknown organization'; + END IF; + UPDATE public.organizations SET tenant_schema = schema_name WHERE id = target_org; + EXECUTE format('CREATE SCHEMA IF NOT EXISTS %I', schema_name); + IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'modelforge_runtime') THEN + EXECUTE format('GRANT USAGE ON SCHEMA %I TO modelforge_runtime', schema_name); + EXECUTE format( + 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA %I GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO modelforge_runtime', + CURRENT_USER, schema_name + ); + END IF; + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.patient_cases ( + case_id TEXT PRIMARY KEY, + version BIGINT NOT NULL, + data JSONB NOT NULL, + patient_id TEXT NOT NULL, + owner_user_id UUID NOT NULL, + workspace_id TEXT, + department_id TEXT, + assigned_user_ids UUID[] NOT NULL DEFAULT ''{}'', + active_consent_scopes TEXT[] NOT NULL DEFAULT ''{}'', + staged_migration_id UUID, + active BOOLEAN NOT NULL DEFAULT TRUE, + updated_at TIMESTAMPTZ NOT NULL + )', schema_name + ); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.case_change_counter ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + next_sequence BIGINT NOT NULL DEFAULT 1 + )', schema_name + ); + EXECUTE format('INSERT INTO %I.case_change_counter (singleton, next_sequence) VALUES (TRUE, 1) ON CONFLICT DO NOTHING', schema_name); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.case_changes ( + sequence BIGINT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN (''upsert'', ''delete'')), + case_id TEXT NOT NULL, + version BIGINT NOT NULL, + patient_case JSONB, + resource JSONB NOT NULL, + changed_at TIMESTAMPTZ NOT NULL + )', schema_name + ); + EXECUTE format('ALTER TABLE %I.case_changes ADD COLUMN IF NOT EXISTS resource JSONB', schema_name); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.case_changes (case_id, sequence DESC)', 'idx_' || schema_name || '_case_changes_case', schema_name); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.case_migrations ( + id UUID PRIMARY KEY, + status TEXT NOT NULL CHECK (status IN (''staging'', ''validated'', ''active'', ''rolled-back'')), + source_fingerprint TEXT NOT NULL, + total_items INTEGER NOT NULL, + accepted_items INTEGER NOT NULL DEFAULT 0, + preview JSONB, + created_by UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE (source_fingerprint, created_by) + )', schema_name + ); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.case_migration_items ( + migration_id UUID NOT NULL REFERENCES %I.case_migrations(id) ON DELETE CASCADE, + item_key TEXT NOT NULL, + case_id TEXT NOT NULL, + data JSONB NOT NULL, + data_hash TEXT NOT NULL, + status TEXT NOT NULL CHECK (status IN (''pending'', ''accepted'', ''invalid'', ''collision'')), + errors JSONB NOT NULL DEFAULT ''[]'', + PRIMARY KEY (migration_id, item_key) + )', schema_name, schema_name + ); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.chat_sessions ( + id TEXT PRIMARY KEY, + version BIGINT NOT NULL, + data JSONB NOT NULL, + owner_user_id UUID NOT NULL, + assigned_user_ids UUID[] NOT NULL DEFAULT ''{}'', + updated_at TIMESTAMPTZ NOT NULL + )', schema_name + ); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.chat_session_change_counter ( + singleton BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (singleton), + next_sequence BIGINT NOT NULL DEFAULT 1 + )', schema_name + ); + EXECUTE format('INSERT INTO %I.chat_session_change_counter (singleton, next_sequence) VALUES (TRUE, 1) ON CONFLICT DO NOTHING', schema_name); + EXECUTE format( + 'CREATE TABLE IF NOT EXISTS %I.chat_session_changes ( + sequence BIGINT PRIMARY KEY, + kind TEXT NOT NULL CHECK (kind IN (''upsert'', ''delete'')), + session_id TEXT NOT NULL, + version BIGINT NOT NULL, + session_data JSONB, + resource JSONB NOT NULL, + changed_at TIMESTAMPTZ NOT NULL + )', schema_name + ); + EXECUTE format('CREATE INDEX IF NOT EXISTS %I ON %I.chat_session_changes (session_id, sequence DESC)', 'idx_' || schema_name || '_chat_session_changes_session', schema_name); + + PERFORM provision_tenant_imaging_tables(schema_name); + PERFORM provision_tenant_ai_gateway_tables(schema_name); + PERFORM provision_tenant_hl7_tables(schema_name); + -- New this migration. + PERFORM provision_tenant_smart_launch_tables(schema_name); + + RETURN schema_name; +END +$provision$; + +REVOKE ALL ON FUNCTION provision_tenant_clinical_schema(UUID) FROM PUBLIC; + +-- --- Part 3: backfill every already-provisioned tenant schema -------------- +DO $backfill$ +DECLARE + org RECORD; +BEGIN + FOR org IN SELECT tenant_schema FROM organizations WHERE tenant_schema IS NOT NULL LOOP + PERFORM provision_tenant_smart_launch_tables(org.tenant_schema); + END LOOP; +END +$backfill$; diff --git a/server/src/ai-gateway/data-minimization.test.ts b/server/src/ai-gateway/data-minimization.test.ts index 591e0bb..f99d8d8 100644 --- a/server/src/ai-gateway/data-minimization.test.ts +++ b/server/src/ai-gateway/data-minimization.test.ts @@ -57,6 +57,23 @@ describe("minimizeForTask", () => { expect(result.sections.find((s) => s.text.includes("Follow-up"))!.text).toContain("[REDACTED_PHONE]"); }); + it("cites every included scalar field, not only clinical notes — evidence provenance must cover what actually reached the model", () => { + const patientCase = patientCaseFixture("case-42", { + presentingComplaint: { value: "Chest pain", includeInContext: true }, + labResults: { value: [{ id: "l1", name: "Troponin", value: "0.01" }], includeInContext: true }, + vitalSigns: { value: "BP 140/90", includeInContext: true }, + }); + const result = minimizeForTask(patientCase, "diagnostic-support", ["presentingComplaint", "labResults", "vitalSigns"]); + expect(result.resourceRefs).toEqual( + expect.arrayContaining([ + { resourceType: "patientCaseField", resourceId: "presentingComplaint:case-42" }, + { resourceType: "patientCaseField", resourceId: "labResults:case-42" }, + { resourceType: "patientCaseField", resourceId: "vitalSigns:case-42" }, + ]) + ); + expect(result.resourceRefs).toHaveLength(3); + }); + it("an unrecognized purposeOfUse resolves to an empty allowlist rather than falling back to 'everything'", () => { const patientCase = patientCaseFixture("case-1", { medications: { value: ["x"], includeInContext: true } }); const result = minimizeForTask(patientCase, "not-a-real-purpose", ["medications"]); diff --git a/server/src/ai-gateway/data-minimization.ts b/server/src/ai-gateway/data-minimization.ts index 96f6dde..834e6cb 100644 --- a/server/src/ai-gateway/data-minimization.ts +++ b/server/src/ai-gateway/data-minimization.ts @@ -24,6 +24,14 @@ export const TASK_DATA_CATEGORIES: Record = { "quality-improvement": ["conditions", "medications", "labResults"], } satisfies Record; +/** The scalar (non-array-of-resources) case fields minimizeForTask can + * include — the complete set of `resourceType: "patientCaseField"` citation + * suffixes routes/ai-gateway.ts's citation re-authorization must recognize. + * Exported so that re-verification never drifts from what this function + * actually cites; a category added here without a matching read-time check + * would silently make its citations undisplayable, not silently unsafe. */ +export const SCALAR_CASE_FIELD_CATEGORIES = ["presentingComplaint", "symptomsTimeline", "vitalSigns", "conditions", "allergies", "medications", "labResults", "imagingAndReports"] as const; + export interface MinimizedSelection { /** Plain-text sections, one per included category, ready to compose * into a prompt — never the whole PatientCase JSON. */ @@ -33,9 +41,23 @@ export interface MinimizedSelection { * flag) — what the request envelope's dataScope and the audit trail * both record. */ includedCategories: string[]; - /** Resource refs for citation purposes — one per clinical note - * actually included, since notes (unlike scalar fields) are individual - * cited resources, not a single blob. */ + /** Resource refs for citation purposes (ai-gateway/gateway.ts's + * createOutput turns each of these into an AiCitation) — one per + * clinical note actually included (real resource ids), PLUS one + * synthetic `patientCaseField` ref per included scalar field category + * (`resourceId: ":"`). The synthetic ones exist + * specifically so evidence provenance covers *every* piece of content + * that reached the model, not only clinical notes — before this, a + * diagnostic-support request's labResults/vitalSigns/imagingAndReports + * sections (the majority of most prompts) produced zero citations at + * all, a real provenance gap this closes. Category comes first in + * `resourceId`, deliberately, so extracting the fixed, colon-free + * category back out at read time is unambiguous even if a caseId + * itself happens to contain a colon (case ids are arbitrary caller- + * supplied TEXT, not a format this system controls — see + * routes/params.ts's own doc comment on that). See + * routes/ai-gateway.ts's citation re-authorization loop for the + * matching read-time check. */ resourceRefs: Array<{ resourceType: string; resourceId: string }>; } @@ -88,6 +110,7 @@ export function minimizeForTask(patientCase: PatientCase, purposeOfUse: string, if (text) { sections.push({ category, text: redactIdentifiers(text).text }); includedCategories.push(category); + resourceRefs.push({ resourceType: "patientCaseField", resourceId: `${category}:${patientCase.id}` }); } } diff --git a/server/src/ai-gateway/gateway.test.ts b/server/src/ai-gateway/gateway.test.ts index 2aee16d..035c946 100644 --- a/server/src/ai-gateway/gateway.test.ts +++ b/server/src/ai-gateway/gateway.test.ts @@ -112,11 +112,21 @@ describe("ClinicalAiGateway", () => { expect(result.output.reviewStatus).toBe("unreviewed"); expect(result.output.summary).toContain("No interactions found"); expect(result.output.evidence.length).toBeGreaterThan(0); - // data-minimization.ts only produces individually-citable - // resourceRefs for clinicalNotes — medications/allergies are - // scalar case fields with no per-resource identity to cite, so - // an output built purely from them legitimately has none. - expect(result.citations).toHaveLength(0); + // Model/prompt versioning: every output records which + // prompt-registry.ts version generated it, defaulting to + // CURRENT_PROMPT_VERSION when the caller doesn't pin one. + expect(result.output.promptVersion).toBe("clinical-gateway-prompt-v1"); + expect(client.lastRequest?.systemPrompt).toContain("ABSTAIN"); + // data-minimization.ts cites every included scalar field too + // (not only clinicalNotes) via a synthetic patientCaseField + // ref — evidence provenance must cover everything that + // actually reached the model, not just individually-identified + // resources. See data-minimization.test.ts's own coverage of + // this. + expect(result.citations.map((c) => ({ resourceType: c.resourceType, resourceId: c.resourceId, locator: c.locator })).sort((a, b) => a.resourceId.localeCompare(b.resourceId))).toEqual([ + { resourceType: "patientCaseField", resourceId: "allergies:case-1", locator: "allergies" }, + { resourceType: "patientCaseField", resourceId: "medications:case-1", locator: "medications" }, + ]); // The provider client only ever saw already-minimized sections, // never a live handle to the patient case. @@ -126,6 +136,21 @@ describe("ClinicalAiGateway", () => { expect(transformations.map((t) => t.kind).sort()).toEqual(["content-scan", "minimization", "redaction"]); }); + it("pinning an explicit promptVersion uses that prompt's text and records it on the output — the rollback mechanism", async () => { + const { gateway, input, client } = await setup(); + const result = await gateway.submitRequest({ ...input, promptVersion: "clinical-gateway-prompt-v1" }, actor()); + expect(result.outcome).toBe("completed"); + if (result.outcome !== "completed") return; + expect(result.output.promptVersion).toBe("clinical-gateway-prompt-v1"); + expect(client.lastRequest?.systemPrompt).toBeTruthy(); + }); + + it("an unknown pinned promptVersion fails before ever calling the provider", async () => { + const { gateway, input, client } = await setup(); + await expect(gateway.submitRequest({ ...input, promptVersion: "does-not-exist" }, actor())).rejects.toThrow(/Unknown prompt version/); + expect(client.lastRequest).toBeNull(); + }); + it("produces real citations pointing at the exact clinical note when the purpose of use pulls in clinicalNotes", async () => { // documentation-assist's own task allowlist only covers // "clinicalNotes" (see data-minimization.ts's @@ -157,6 +182,151 @@ describe("ClinicalAiGateway", () => { }); }); + describe("submitRequest — auto-routing (no providerModelId)", () => { + /** Two approved, eligible provider models under one tenant — a + * cheap/preferred one and an expensive/fallback one — so ranking + * and fallback-on-failure are both actually exercised, unlike the + * shared top-level setup() which only ever creates one model. */ + async function setupTwoModels(options: { preferredFails?: boolean } = {}) { + const ctx = tenantContext(); + const caseStore = new InMemoryCaseStore(); + const gatewayStore = new InMemoryAiGatewayStore(); + const registry = new InMemoryAiProviderRegistryStore(); + const admission = new AiInferenceAdmission({ cpuThreads: 8, ramMB: 16_000, vramBudgetMB: 0 }); + + const now = new Date().toISOString(); + const patientCase = patientCaseFixture("case-1", { + consentRecords: [{ id: "consent-scope-1", scope: "ai-assistance", grantedAt: now, method: "in-person" }], + medications: { value: ["Lisinopril 10mg daily"], includeInContext: true }, + allergies: { value: ["Penicillin"], includeInContext: true }, + }); + await caseStore.forTenant(ctx).writeOne(patientCase, null, actor(), resourceAttrs(ctx, "case-1")); + + const provider = await registry.createProvider({ name: "Local inference", kind: "local" }, actor()); + const preferred = await registry.createProviderModel( + { providerId: provider.id, modelId: "llama3-cheap", modelVersion: "1.0", intendedUse: "medication review", supportedDataTypes: ["text"], maxContextTokens: 8192, hostingRegion: "local", processingLocation: "local", phiPermitted: true, validationStatus: "validated", costPerInputTokenUsd: 0, costPerOutputTokenUsd: 0 }, + actor() + ); + const fallback = await registry.createProviderModel( + { providerId: provider.id, modelId: "llama3-expensive", modelVersion: "1.0", intendedUse: "medication review", supportedDataTypes: ["text"], maxContextTokens: 8192, hostingRegion: "local", processingLocation: "local", phiPermitted: true, validationStatus: "canary", costPerInputTokenUsd: 1, costPerOutputTokenUsd: 1 }, + actor() + ); + + const gatewayRepo = gatewayStore.forTenant(ctx); + await gatewayRepo.upsertProviderTenantSettings({ providerModelId: preferred.id, enabled: true, phiAllowed: true, allowedRoles: [], approvedByUserId: "admin-1" }, actor()); + await gatewayRepo.upsertProviderTenantSettings({ providerModelId: fallback.id, enabled: true, phiAllowed: true, allowedRoles: [], approvedByUserId: "admin-1" }, actor()); + await gatewayRepo.createConsent({ patientCaseId: "case-1", purpose: "treatment", dataCategories: ["medications", "allergies"], grantedByUserId: "admin-1" }, actor()); + + const preferredClient = new TestAiProviderClient( + options.preferredFails + ? () => { throw new Error("simulated provider outage"); } + : { rawText: "SUMMARY: From the preferred model.\nEVIDENCE:\n- x.\nFOLLOWUP:\n- y.", modelVersion: "1.0" } + ); + const fallbackClient = new TestAiProviderClient({ rawText: "SUMMARY: From the fallback model.\nEVIDENCE:\n- x.\nFOLLOWUP:\n- y.", modelVersion: "1.0" }); + + const caseRepo = caseStore.forTenant(ctx); + const gateway = new ClinicalAiGateway({ + caseRepo, + gatewayRepo, + registry, + admission, + resolveProviderClient: (_provider, model) => (model.id === preferred.id ? preferredClient : fallbackClient), + }); + + const input: SubmitAiRequestInput = { + patientCaseId: "case-1", + requestedByUserId: "clinician-1", + callerRoles: ["clinician"], + purposeOfUse: "medication-review", + requestedCategories: ["medications", "allergies"], + }; + return { gateway, gatewayRepo, preferred, fallback, preferredClient, fallbackClient, input }; + } + + it("ranks and auto-selects the cheaper eligible model when providerModelId is omitted", async () => { + const { gateway, preferred, input } = await setupTwoModels(); + const result = await gateway.submitRequest(input, actor()); + expect(result.outcome).toBe("completed"); + if (result.outcome !== "completed") return; + expect(result.output.summary).toContain("preferred model"); + expect(result.output.providerModelId).toBe(preferred.id); + }); + + it("falls back to the next-ranked candidate when the top-ranked one fails, recording BOTH attempts as separate real request envelopes", async () => { + const { gateway, gatewayRepo, fallback, input } = await setupTwoModels({ preferredFails: true }); + const result = await gateway.submitRequest(input, actor()); + expect(result.outcome).toBe("completed"); + if (result.outcome !== "completed") return; + expect(result.output.summary).toContain("fallback model"); + expect(result.output.providerModelId).toBe(fallback.id); + + const requests = await gatewayRepo.listRequestsForCase("case-1"); + expect(requests).toHaveLength(2); + expect(requests.map((r) => r.status).sort()).toEqual(["awaiting-review", "failed"]); + }); + + it("reports no-eligible-provider-model, never a crash, when nothing is enabled — auto-routing only, never returned when a caller pins an explicit id", async () => { + const { gateway, gatewayRepo, preferred, fallback, input } = await setupTwoModels(); + // Disable both approved models after they were created. + await gatewayRepo.upsertProviderTenantSettings({ providerModelId: preferred.id, enabled: false, phiAllowed: true, allowedRoles: [], approvedByUserId: "admin-1" }, actor()); + await gatewayRepo.upsertProviderTenantSettings({ providerModelId: fallback.id, enabled: false, phiAllowed: true, allowedRoles: [], approvedByUserId: "admin-1" }, actor()); + const result = await gateway.submitRequest(input, actor()); + expect(result).toMatchObject({ outcome: "no-eligible-provider-model" }); + expect(await gatewayRepo.listRequestsForCase("case-1")).toHaveLength(0); + }); + + it("previewRequest shows what auto-routing would currently pick, without creating anything", async () => { + const { gateway, gatewayRepo, preferred, input } = await setupTwoModels(); + const preview = await gateway.previewRequest(input); + expect(preview?.model?.id).toBe(preferred.id); + expect(await gatewayRepo.listRequestsForCase("case-1")).toHaveLength(0); + }); + + it("real production quality history sways auto-routing between two otherwise-tied candidates", async () => { + // Two candidates identical in every ranking dimension model- + // router.ts considers BEFORE quality (validation status, + // hosting kind, cost) — isolating quality as the only thing + // that can explain a preference between them. A separate, + // minimal setup rather than reusing setupTwoModels, which + // deliberately gives its two models different validation tiers + // for its own fallback test — that would dominate quality here. + const ctx = tenantContext(); + const caseStore = new InMemoryCaseStore(); + const gatewayStore = new InMemoryAiGatewayStore(); + const registry = new InMemoryAiProviderRegistryStore(); + const now = new Date().toISOString(); + const patientCase = patientCaseFixture("case-1", { + consentRecords: [{ id: "consent-scope-1", scope: "ai-assistance", grantedAt: now, method: "in-person" }], + medications: { value: ["Lisinopril 10mg daily"], includeInContext: true }, + }); + await caseStore.forTenant(ctx).writeOne(patientCase, null, actor(), resourceAttrs(ctx, "case-1")); + const provider = await registry.createProvider({ name: "Local inference", kind: "local" }, actor()); + const modelSpec = { providerId: provider.id, modelVersion: "1.0", intendedUse: "medication review", supportedDataTypes: ["text" as const], maxContextTokens: 8192, hostingRegion: "local", processingLocation: "local", phiPermitted: true, validationStatus: "validated" as const }; + const tarnished = await registry.createProviderModel({ ...modelSpec, modelId: "llama3-tarnished" }, actor()); + const clean = await registry.createProviderModel({ ...modelSpec, modelId: "llama3-clean" }, actor()); + + const gatewayRepo = gatewayStore.forTenant(ctx); + for (const id of [tarnished.id, clean.id]) { + await gatewayRepo.upsertProviderTenantSettings({ providerModelId: id, enabled: true, phiAllowed: true, allowedRoles: [], approvedByUserId: "admin-1" }, actor()); + } + await gatewayRepo.createConsent({ patientCaseId: "case-1", purpose: "treatment", dataCategories: ["medications"], grantedByUserId: "admin-1" }, actor()); + + // Seed a real, well-sampled bad track record for `tarnished` — + // enough rejected reviews to clear MIN_QUALITY_SAMPLE_SIZE. + for (let i = 0; i < 20; i++) { + const { output } = await gatewayRepo.createOutput( + { requestId: "seed-request", providerModelId: tarnished.id, modelVersion: "1.0", promptVersion: "clinical-gateway-prompt-v1", summary: "seed", evidence: [], followUp: [], abstained: false, outputHash: `${"a".repeat(63)}${i}`, citations: [] }, + actor() + ); + await gatewayRepo.createReview({ outputId: output.id, reviewedByUserId: "clinician-1", decision: "rejected" }, actor()); + } + + const gateway = new ClinicalAiGateway({ caseRepo: caseStore.forTenant(ctx), gatewayRepo, registry, admission: new AiInferenceAdmission({ cpuThreads: 8, ramMB: 16_000, vramBudgetMB: 0 }), resolveProviderClient: () => new TestAiProviderClient() }); + const preview = await gateway.previewRequest({ patientCaseId: "case-1", requestedByUserId: "clinician-1", callerRoles: ["clinician"], purposeOfUse: "medication-review", requestedCategories: ["medications"] }); + expect(preview?.model?.id).toBe(clean.id); + }); + }); + describe("submitRequest — authorization gates", () => { it("denies when there is no active consent for this purpose", async () => { const { gateway, input } = await setup({ skipConsent: true }); diff --git a/server/src/ai-gateway/gateway.ts b/server/src/ai-gateway/gateway.ts index 0ef7ad5..359369b 100644 --- a/server/src/ai-gateway/gateway.ts +++ b/server/src/ai-gateway/gateway.ts @@ -17,6 +17,9 @@ import type { AiProviderRegistryStore } from "../store/ai-provider-registry-stor import { evaluateGatewayAuthorization, type GatewayAuthorizationDenialReason } from "./policy.js"; import { scanForUnsafeContent, type ContentScanFinding } from "./content-scanner.js"; import { minimizeForTask } from "./data-minimization.js"; +import { getSystemPrompt } from "./prompt-registry.js"; +import { rankEligibleProviderModels, type RoutingCandidate } from "./model-router.js"; +import { computeProductionQualitySnapshot } from "../eval-harness/production-monitor.js"; import { AiAdmissionError, type AiAdmissionDecisionStatus, type AiAdmissionPriority, type AiInferenceAdmission } from "./admission.js"; import type { AiProviderClient } from "./provider-client.js"; import { validateModelResponse } from "./response-validation.js"; @@ -80,19 +83,6 @@ import { validateModelResponse } from "./response-validation.js"; * architecture survey for this phase). */ -const SYSTEM_PROMPT = `You are a clinical decision-support assistant. You are NOT a diagnostic device and your output is never a final medical decision. Every response you produce will be shown to a licensed clinician as an unsigned draft for their review — it must never be presented to a patient directly, and it never modifies, signs, or submits any medical record on its own. - -Respond using EXACTLY this structure, with no other section headers: -SUMMARY: -EVIDENCE: -- -UNCERTAINTY: -FOLLOWUP: -- -ABSTAIN: - -Never include your reasoning process, chain-of-thought, or private deliberation — only the concise sections above. Never invent facts not present in the clinical data provided. If the data is insufficient, contradictory, or you are not confident, use the ABSTAIN section rather than guessing.`; - /** Default admission-queue priority per clinical purpose of use — item: * "priority for active clinician workflows... preemption of background * jobs." A caller may override via `SubmitAiRequestInput.admissionPriority` @@ -139,7 +129,15 @@ export interface SubmitAiRequestInput { patientCaseId: string; requestedByUserId: string; callerRoles: string[]; - providerModelId: string; + /** Omit to auto-route (model-router.ts) across every enabled, eligible + * provider model for this tenant, ranked by validation status/hosting + * kind/cost, with automatic fallback to the next-ranked candidate on a + * retryable failure (admission rejection, provider failure, or an + * authorization denial — since eligibility filtering is a pre-check, + * not a guarantee, of what evaluateGatewayAuthorization will actually + * decide). Supplying an explicit id is unchanged from before this + * existed: exactly that one model, no fallback. */ + providerModelId?: string; purposeOfUse: AiRequestEnvelope["purposeOfUse"]; /** The user's own explicit selection from the pre-flight sharing UI — * see data-minimization.ts's own doc comment on why this can only ever @@ -156,6 +154,11 @@ export interface SubmitAiRequestInput { }>; admissionPriority?: AiAdmissionPriority; maxTokens?: number; + /** Pins a specific prompt-registry.ts version instead of + * CURRENT_PROMPT_VERSION — the rollback mechanism that version's own + * doc comment describes. Unknown values throw (UnknownPromptVersionError), + * never silently fall back to current. */ + promptVersion?: string; } export interface RequestPreview { @@ -177,6 +180,12 @@ export type GatewaySubmitResult = | { outcome: "content-blocked"; findings: ContentScanFinding[] } | { outcome: "admission-rejected"; status: AiAdmissionDecisionStatus; reasons: string[] } | { outcome: "provider-failed"; message: string } + /** Auto-routing only (no providerModelId supplied): no enabled, + * validated, safety-nominal provider model was eligible at all — never + * returned when the caller pins an explicit providerModelId, which + * instead runs the normal authorization-denied/provider-failed paths + * against that one model. */ + | { outcome: "no-eligible-provider-model"; message: string } | { outcome: "completed"; request: AiRequestEnvelope; output: AiOutput; citations: AiCitation[] }; export interface ClinicalAiGatewayDeps { @@ -200,16 +209,56 @@ export class ClinicalAiGateway { this.requestTtlMs = deps.requestTtlMs ?? 15 * 60 * 1_000; } + /** Every enabled provider model this tenant has approved settings for, + * joined with its global catalog provider row and its real production + * quality signal (eval-harness/production-monitor.ts) — the candidate + * pool model-router.ts ranks/filters. A model missing either its + * provider row (shouldn't happen — providerId is a real FK) or tenant + * settings (very possible — most catalog models are never approved by + * most tenants) is simply excluded, not an error. Quality is fetched + * per candidate (one query each — candidate counts are small, a + * handful of approved models per tenant, not worth a batched store + * method yet); a model with no output history yet naturally gets an + * all-zero snapshot, which model-router.ts's own qualityTier already + * treats as neutral, never as ineligible. */ + private async gatherRoutingCandidates(): Promise { + const [providers, models, settingsList] = await Promise.all([ + this.deps.registry.listProviders(), + this.deps.registry.listProviderModels(), + this.deps.gatewayRepo.listProviderTenantSettings(), + ]); + const providerById = new Map(providers.map((p) => [p.id, p])); + const settingsByModelId = new Map(settingsList.map((s) => [s.providerModelId, s])); + const candidates: RoutingCandidate[] = []; + for (const model of models) { + const provider = providerById.get(model.providerId); + const settings = settingsByModelId.get(model.id); + if (!provider || !settings) continue; + const snapshot = await computeProductionQualitySnapshot(this.deps.gatewayRepo, model.id); + candidates.push({ provider, model, settings, quality: { acceptanceRate: snapshot.acceptanceRate, reviewedCount: snapshot.outputCount - snapshot.unreviewedCount } }); + } + return candidates; + } + /** Lifecycle steps 1-2: read-only, no consent/policy/admission side * effects at all — exactly what a pre-flight sharing-confirmation UI - * calls before the user clicks "share." */ + * calls before the user clicks "share." When `input.providerModelId` + * is omitted, shows what auto-routing would currently pick (the top- + * ranked eligible candidate) — informational only; submitRequest re- + * ranks independently at submission time and is the only thing that + * actually commits to a choice. */ async previewRequest(input: SubmitAiRequestInput): Promise { const caseRecord = await this.deps.caseRepo.getOne(input.patientCaseId); if (!caseRecord) return null; - const providerModel = await this.deps.registry.getProviderModel(input.providerModelId); - const provider = providerModel ? await this.deps.registry.getProvider(providerModel.providerId) : null; const minimized = minimizeForTask(caseRecord.patientCase, input.purposeOfUse, input.requestedCategories); + let providerModelId = input.providerModelId; + if (!providerModelId) { + const ranked = rankEligibleProviderModels(await this.gatherRoutingCandidates(), { requiresPhi: minimized.sections.length > 0, callerRoles: input.callerRoles }); + providerModelId = ranked[0]?.model.id; + } + const providerModel = providerModelId ? await this.deps.registry.getProviderModel(providerModelId) : null; + const provider = providerModel ? await this.deps.registry.getProvider(providerModel.providerId) : null; const imaging = input.requestedCategories.includes("imagingStudies") && IMAGING_ALLOWED_PURPOSES.has(input.purposeOfUse) ? input.imagingSelections ?? [] : []; return { @@ -225,10 +274,50 @@ export class ClinicalAiGateway { }; } - /** Lifecycle steps 3-14: the real, side-effecting request lifecycle. - * Every early return corresponds to one governance or safety gate - * failing closed — none of them proceed to invoke a provider. */ + /** + * Lifecycle steps 3-14, the public entry point. With an explicit + * `input.providerModelId`, this is exactly one attempt against exactly + * that model — unchanged from before auto-routing existed. Omitting it + * ranks every eligible model (model-router.ts) and tries each in + * ranked order, falling back to the next candidate on a retryable + * outcome (`admission-rejected`, `provider-failed`, or + * `authorization-denied` — eligibility filtering is a pre-check, not a + * guarantee of what the real authorization gate decides) and stopping + * immediately on a non-retryable one (`content-blocked`, + * `case-not-found` — neither depends on which model was chosen, so + * trying another would just fail identically). Each attempt creates + * its own real, immutable `AiRequestEnvelope` (a failed attempt is not + * deleted or hidden — it is exactly as auditable as a successful one, + * matching this codebase's "never mutate, always a new row" discipline + * everywhere else); a caller inspecting request history for a case + * will see one row per attempt, not just the winner. + */ async submitRequest(input: SubmitAiRequestInput, actor: AuditActor): Promise { + if (input.providerModelId) return this.attemptSubmit({ ...input, providerModelId: input.providerModelId }, actor); + + const caseRecord = await this.deps.caseRepo.getOne(input.patientCaseId); + if (!caseRecord) return { outcome: "case-not-found" }; + const minimized = minimizeForTask(caseRecord.patientCase, input.purposeOfUse, input.requestedCategories); + const ranked = rankEligibleProviderModels(await this.gatherRoutingCandidates(), { requiresPhi: minimized.sections.length > 0, callerRoles: input.callerRoles }); + if (ranked.length === 0) { + return { outcome: "no-eligible-provider-model", message: "No enabled, validated, safety-nominal provider model is eligible for this request's purpose/data/role — check ai-provider-tenant-settings and each candidate model's validationStatus/safetyStatus." }; + } + + let lastResult: GatewaySubmitResult = { outcome: "no-eligible-provider-model", message: "Unreachable — ranked.length > 0 was just checked." }; + for (const candidate of ranked) { + const result = await this.attemptSubmit({ ...input, providerModelId: candidate.model.id }, actor); + if (result.outcome === "completed") return result; + lastResult = result; + if (result.outcome === "content-blocked" || result.outcome === "case-not-found") return result; + } + return lastResult; + } + + /** The single-model attempt every submitRequest call ultimately runs — + * see submitRequest's own doc comment for the auto-routing loop around + * this. Every early return corresponds to one governance or safety + * gate failing closed — none of them proceed to invoke a provider. */ + private async attemptSubmit(input: SubmitAiRequestInput & { providerModelId: string }, actor: AuditActor): Promise { const caseRecord = await this.deps.caseRepo.getOne(input.patientCaseId); if (!caseRecord) return { outcome: "case-not-found" }; const patientCase = caseRecord.patientCase; @@ -347,7 +436,11 @@ export class ClinicalAiGateway { await this.deps.gatewayRepo.updateRequestStatus(request.id, "queued", undefined, actor); // Step 9: schedule the actual inference call under tenant-aware - // admission control. + // admission control. Resolved before the lease so an unknown + // pinned promptVersion (prompt-registry.ts's UnknownPromptVersionError) + // fails fast, before ever acquiring admission capacity or calling a + // provider. + const prompt = getSystemPrompt(input.promptVersion); const priority = input.admissionPriority ?? DEFAULT_ADMISSION_PRIORITY[input.purposeOfUse] ?? "background-summary"; let invocation; try { @@ -361,7 +454,7 @@ export class ClinicalAiGateway { async () => { await this.deps.gatewayRepo.updateRequestStatus(request.id, "running", undefined, actor); const client = await this.deps.resolveProviderClient(authz.provider, authz.providerModel); - return client.invoke({ systemPrompt: SYSTEM_PROMPT, sections: minimized.sections, purposeOfUse: input.purposeOfUse, maxTokens: input.maxTokens }); + return client.invoke({ systemPrompt: prompt.text, sections: minimized.sections, purposeOfUse: input.purposeOfUse, maxTokens: input.maxTokens }); } ); } catch (err) { @@ -394,6 +487,7 @@ export class ClinicalAiGateway { requestId: request.id, providerModelId: authz.providerModel.id, modelVersion: invocation.modelVersion, + promptVersion: prompt.version, summary: validated.summary, evidence: validated.evidence, uncertainty: validated.uncertainty, @@ -401,7 +495,16 @@ export class ClinicalAiGateway { abstained: validated.abstained, abstainReason: validated.abstainReason, outputHash: validated.outputHash, - citations: dataScope.resourceRefs.map((ref) => ({ resourceType: ref.resourceType, resourceId: ref.resourceId })), + // `locator` for a patientCaseField ref is the category name + // encoded in its own resourceId (data-minimization.ts's + // `":"`) — a real pointer into the source + // ("this citation is the labResults field"), not a re-copied + // excerpt, per aiCitationSchema's own doc comment. + citations: dataScope.resourceRefs.map((ref) => ({ + resourceType: ref.resourceType, + resourceId: ref.resourceId, + locator: ref.resourceType === "patientCaseField" ? ref.resourceId.split(":")[0] : undefined, + })), }, actor ); diff --git a/server/src/ai-gateway/model-router.test.ts b/server/src/ai-gateway/model-router.test.ts new file mode 100644 index 0000000..e872d24 --- /dev/null +++ b/server/src/ai-gateway/model-router.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; +import type { AiProvider, AiProviderModel, AiProviderTenantSettings } from "@modelforge/contracts"; +import { MIN_QUALITY_SAMPLE_SIZE, rankEligibleProviderModels, type RoutingCandidate } from "./model-router.js"; + +const NOW = "2026-01-01T00:00:00.000Z"; + +function provider(overrides: Partial = {}): AiProvider { + return { id: "provider-1", name: "Local inference", kind: "local", killSwitchEngaged: false, operationalStatus: "active", createdAt: NOW, updatedAt: NOW, ...overrides }; +} + +function model(overrides: Partial = {}): AiProviderModel { + return { + id: "model-1", providerId: "provider-1", modelId: "llama3", modelVersion: "3.1", intendedUse: "general", + supportedDataTypes: ["text"], maxContextTokens: 8192, hostingRegion: "local", processingLocation: "local", + phiPermitted: true, retainsPrompts: false, retainsOutputs: false, trainingUseAllowed: false, zeroRetentionSupport: true, + approvals: { baaSigned: false, dpaSigned: false, contractualApproval: false, securityReviewApproval: false }, + encryptionInTransit: true, encryptionAtRest: true, validationStatus: "validated", safetyStatus: "nominal", + approvedRoles: [], effectiveAt: NOW, createdAt: NOW, updatedAt: NOW, + ...overrides, + }; +} + +function settings(overrides: Partial = {}): AiProviderTenantSettings { + return { id: "settings-1", providerModelId: "model-1", enabled: true, phiAllowed: true, allowedRoles: [], approvedByUserId: "admin-1", approvedAt: NOW, ...overrides }; +} + +function candidate(overrides: { provider?: Partial; model?: Partial; settings?: Partial; quality?: RoutingCandidate["quality"] } = {}): RoutingCandidate { + const m = model(overrides.model); + return { provider: provider({ id: m.providerId, ...overrides.provider }), model: m, settings: settings({ providerModelId: m.id, ...overrides.settings }), quality: overrides.quality }; +} + +describe("rankEligibleProviderModels — eligibility filtering", () => { + const cases: Array<[string, RoutingCandidate]> = [ + ["a kill-switched provider", candidate({ provider: { killSwitchEngaged: true } })], + ["a suspended provider", candidate({ provider: { operationalStatus: "suspended" } })], + ["a tenant-disabled model", candidate({ settings: { enabled: false } })], + ["a retired model", candidate({ model: { retiredAt: "2020-01-01T00:00:00.000Z" } })], + ["an unvalidated model", candidate({ model: { validationStatus: "unvalidated" } })], + ["a deprecated model", candidate({ model: { validationStatus: "deprecated" } })], + ["a shadow model", candidate({ model: { validationStatus: "shadow" } })], + ["a safety-disabled model", candidate({ model: { safetyStatus: "disabled" } })], + ["a safety-restricted model", candidate({ model: { safetyStatus: "restricted" } })], + ["a model that doesn't support text at all", candidate({ model: { supportedDataTypes: ["image"] } })], + ]; + it.each(cases)("excludes %s", (_label, one) => { + expect(rankEligibleProviderModels([one], { requiresPhi: false, callerRoles: [], now: NOW })).toEqual([]); + }); + + it("excludes a model whose catalog phiPermitted is false when the request requires PHI, even if tenant settings allow it", () => { + const c = candidate({ model: { phiPermitted: false }, settings: { phiAllowed: true } }); + expect(rankEligibleProviderModels([c], { requiresPhi: true, callerRoles: [], now: NOW })).toEqual([]); + }); + + it("excludes a model whose tenant settings disallow PHI, even if the catalog permits it — effective permission is the AND of both", () => { + const c = candidate({ model: { phiPermitted: true }, settings: { phiAllowed: false } }); + expect(rankEligibleProviderModels([c], { requiresPhi: true, callerRoles: [], now: NOW })).toEqual([]); + }); + + it("does NOT require PHI permission when the request itself carries no identifiers", () => { + const c = candidate({ model: { phiPermitted: false }, settings: { phiAllowed: false } }); + expect(rankEligibleProviderModels([c], { requiresPhi: false, callerRoles: [], now: NOW })).toHaveLength(1); + }); + + it("excludes a model restricted to specific roles when the caller has none of them, but includes it when the caller does", () => { + const c = candidate({ settings: { allowedRoles: ["radiology-group"] } }); + expect(rankEligibleProviderModels([c], { requiresPhi: false, callerRoles: ["primary-care-group"], now: NOW })).toEqual([]); + expect(rankEligibleProviderModels([c], { requiresPhi: false, callerRoles: ["radiology-group"], now: NOW })).toHaveLength(1); + }); + + it("includes a model with no allowedRoles restriction regardless of caller roles", () => { + const c = candidate({ settings: { allowedRoles: [] } }); + expect(rankEligibleProviderModels([c], { requiresPhi: false, callerRoles: [], now: NOW })).toHaveLength(1); + }); +}); + +describe("rankEligibleProviderModels — ranking", () => { + it("ranks a validated model ahead of an equally-eligible canary model", () => { + const validated = candidate({ model: { id: "m-validated", validationStatus: "validated" }, settings: { providerModelId: "m-validated" } }); + const canary = candidate({ model: { id: "m-canary", validationStatus: "canary" }, settings: { providerModelId: "m-canary" } }); + const ranked = rankEligibleProviderModels([canary, validated], { requiresPhi: false, callerRoles: [], now: NOW }); + expect(ranked.map((r) => r.model.id)).toEqual(["m-validated", "m-canary"]); + }); + + it("ranks local/on-premises hosting ahead of tenant-managed/cloud, all else equal", () => { + const cloud = candidate({ provider: { id: "p-cloud", kind: "cloud" }, model: { id: "m-cloud", providerId: "p-cloud" }, settings: { providerModelId: "m-cloud" } }); + const local = candidate({ provider: { id: "p-local", kind: "local" }, model: { id: "m-local", providerId: "p-local" }, settings: { providerModelId: "m-local" } }); + const ranked = rankEligibleProviderModels([cloud, local], { requiresPhi: false, callerRoles: [], now: NOW }); + expect(ranked.map((r) => r.model.id)).toEqual(["m-local", "m-cloud"]); + }); + + it("ranks lower combined cost-per-token ahead of higher, among equally-eligible cloud models", () => { + const expensive = candidate({ provider: { id: "p-cloud", kind: "cloud" }, model: { id: "m-expensive", providerId: "p-cloud", costPerInputTokenUsd: 0.01, costPerOutputTokenUsd: 0.03 }, settings: { providerModelId: "m-expensive" } }); + const cheap = candidate({ provider: { id: "p-cloud", kind: "cloud" }, model: { id: "m-cheap", providerId: "p-cloud", costPerInputTokenUsd: 0.001, costPerOutputTokenUsd: 0.002 }, settings: { providerModelId: "m-cheap" } }); + const ranked = rankEligibleProviderModels([expensive, cheap], { requiresPhi: false, callerRoles: [], now: NOW }); + expect(ranked.map((r) => r.model.id)).toEqual(["m-cheap", "m-expensive"]); + }); + + it("treats missing cost fields as 0 (the common 'this is free because it's ours' local-model case), not as unknown/worst-case", () => { + const untracked = candidate({ model: { id: "m-untracked" }, settings: { providerModelId: "m-untracked" } }); // no cost fields set + const pricedCheap = candidate({ model: { id: "m-priced", costPerInputTokenUsd: 0.5, costPerOutputTokenUsd: 0.5 }, settings: { providerModelId: "m-priced" } }); + const ranked = rankEligibleProviderModels([pricedCheap, untracked], { requiresPhi: false, callerRoles: [], now: NOW }); + expect(ranked.map((r) => r.model.id)).toEqual(["m-untracked", "m-priced"]); + }); + + it("breaks a total tie deterministically by model id", () => { + const b = candidate({ model: { id: "model-b" }, settings: { providerModelId: "model-b" } }); + const a = candidate({ model: { id: "model-a" }, settings: { providerModelId: "model-a" } }); + expect(rankEligibleProviderModels([b, a], { requiresPhi: false, callerRoles: [], now: NOW }).map((r) => r.model.id)).toEqual(["model-a", "model-b"]); + }); + + it("returns an empty array, never throws, when nothing is eligible", () => { + const c = candidate({ settings: { enabled: false } }); + expect(rankEligibleProviderModels([c], { requiresPhi: false, callerRoles: [], now: NOW })).toEqual([]); + }); +}); + +describe("rankEligibleProviderModels — quality-aware ranking (production telemetry)", () => { + function withQuality(id: string, quality: RoutingCandidate["quality"]): RoutingCandidate { + return { ...candidate({ model: { id }, settings: { providerModelId: id } }), quality }; + } + + it("ranks a model with a good recent acceptance rate ahead of one with a poor rate, both well-sampled", () => { + const good = withQuality("m-good", { acceptanceRate: 0.95, reviewedCount: MIN_QUALITY_SAMPLE_SIZE }); + const poor = withQuality("m-poor", { acceptanceRate: 0.2, reviewedCount: MIN_QUALITY_SAMPLE_SIZE }); + expect(rankEligibleProviderModels([poor, good], { requiresPhi: false, callerRoles: [] }).map((r) => r.model.id)).toEqual(["m-good", "m-poor"]); + }); + + it("ranks a fair acceptance rate between good and poor", () => { + const good = withQuality("m-good", { acceptanceRate: 0.95, reviewedCount: MIN_QUALITY_SAMPLE_SIZE }); + const fair = withQuality("m-fair", { acceptanceRate: 0.65, reviewedCount: MIN_QUALITY_SAMPLE_SIZE }); + const poor = withQuality("m-poor", { acceptanceRate: 0.2, reviewedCount: MIN_QUALITY_SAMPLE_SIZE }); + expect(rankEligibleProviderModels([poor, good, fair], { requiresPhi: false, callerRoles: [] }).map((r) => r.model.id)).toEqual(["m-good", "m-fair", "m-poor"]); + }); + + it("treats a poor rate with too little sample data as neutral, never penalizing a newly-approved model for lacking history", () => { + const untested = withQuality("m-untested", { acceptanceRate: 0.1, reviewedCount: MIN_QUALITY_SAMPLE_SIZE - 1 }); + const good = withQuality("m-good", { acceptanceRate: 0.95, reviewedCount: MIN_QUALITY_SAMPLE_SIZE }); + // Both rank as "tier 0" (neutral/good) — tie-broken by model id, not by the untested one's (statistically meaningless) low raw rate. + expect(rankEligibleProviderModels([good, untested], { requiresPhi: false, callerRoles: [] }).map((r) => r.model.id)).toEqual(["m-good", "m-untested"]); + }); + + it("treats a candidate with no quality signal at all (undefined) the same as neutral", () => { + const noSignal = candidate({ model: { id: "m-no-signal" }, settings: { providerModelId: "m-no-signal" } }); + const poor = withQuality("m-poor", { acceptanceRate: 0.1, reviewedCount: MIN_QUALITY_SAMPLE_SIZE }); + expect(rankEligibleProviderModels([poor, noSignal], { requiresPhi: false, callerRoles: [] }).map((r) => r.model.id)).toEqual(["m-no-signal", "m-poor"]); + }); + + it("quality outranks hosting/cost preference — a well-sampled poor local model ranks behind a well-sampled good cloud model", () => { + const poorLocal = candidate({ model: { id: "m-poor-local" }, settings: { providerModelId: "m-poor-local" }, quality: { acceptanceRate: 0.1, reviewedCount: MIN_QUALITY_SAMPLE_SIZE } }); + const goodCloud = candidate({ + provider: { id: "p-cloud", kind: "cloud" }, + model: { id: "m-good-cloud", providerId: "p-cloud" }, + settings: { providerModelId: "m-good-cloud" }, + quality: { acceptanceRate: 0.95, reviewedCount: MIN_QUALITY_SAMPLE_SIZE }, + }); + const ranked = rankEligibleProviderModels([poorLocal, goodCloud], { requiresPhi: false, callerRoles: [] }); + expect(ranked.map((r) => r.model.id)).toEqual(["m-good-cloud", "m-poor-local"]); + }); + + it("quality never overrides validation status — a well-sampled good canary model still ranks behind a validated one with no data", () => { + const validatedNoData = candidate({ model: { id: "m-validated", validationStatus: "validated" }, settings: { providerModelId: "m-validated" } }); + const goodCanary = withQuality("m-canary", { acceptanceRate: 1, reviewedCount: MIN_QUALITY_SAMPLE_SIZE }); + const canaryCandidate = { ...goodCanary, model: { ...goodCanary.model, validationStatus: "canary" as const } }; + expect(rankEligibleProviderModels([canaryCandidate, validatedNoData], { requiresPhi: false, callerRoles: [] }).map((r) => r.model.id)).toEqual(["m-validated", "m-canary"]); + }); +}); diff --git a/server/src/ai-gateway/model-router.ts b/server/src/ai-gateway/model-router.ts new file mode 100644 index 0000000..5d49c35 --- /dev/null +++ b/server/src/ai-gateway/model-router.ts @@ -0,0 +1,149 @@ +import type { AiProvider, AiProviderModel, AiProviderTenantSettings } from "@modelforge/contracts"; + +/** + * Multi-model routing — closes the gap the ClinicalAiGateway audit found: + * "routing is caller-selected: the request supplies providerModelId + * explicitly; there's no runtime load-balancing, fallback, or cost/latency- + * based auto-routing across providers." This module is the pure + * filter+rank half of that; gateway.ts's submitRequest owns the actual + * fallback-on-failure loop (trying ranked candidates in order until one + * succeeds), since only it has the request lifecycle to retry. + * + * `rankEligibleProviderModels` never widens what evaluateGatewayAuthorization + * (policy.ts) itself enforces — it is a pre-filter to avoid wasting an + * attempt on an obviously-ineligible model, not a replacement for that real + * authorization check, which gateway.ts still runs per attempt regardless + * of how a candidate was chosen. + * + * Deliberately NOT implemented: true request-time load balancing across + * concurrent traffic (this only ranks a static snapshot of catalog/settings + * state per call, with no notion of current load), and latency-based + * ranking (no latency telemetry is tracked anywhere in this codebase yet). + * + * Quality-aware ranking (`QualitySignal` below) DOES factor in + * eval-harness/production-monitor.ts's real acceptance-rate telemetry — a + * candidate with a poor recent clinician acceptance rate ranks behind an + * otherwise-equal one with a good rate, ahead of hosting/cost preference + * (a safety signal outranks a cost preference), but never ahead of + * validation status (an unvalidated/deprecated model was already filtered + * out by `isEligible` regardless of how well it happens to score). A + * candidate with too little review history to trust (below + * `MIN_QUALITY_SAMPLE_SIZE`) is treated as neutral, never penalized — a + * newly-approved model isn't unfairly starved of traffic just for lacking + * a track record yet. + */ + +export interface RoutingCandidate { + provider: AiProvider; + model: AiProviderModel; + settings: AiProviderTenantSettings; + /** Real production telemetry for this model (gateway.ts's + * gatherRoutingCandidates populates this from + * eval-harness/production-monitor.ts's computeProductionQualitySnapshot). + * Undefined — never fetched, e.g. a caller that doesn't have a + * TenantAiGatewayRepository handy — is treated exactly like "too + * little data," never as ineligible. */ + quality?: QualitySignal; +} + +export interface QualitySignal { + /** Fraction of REVIEWED outputs accepted — meaningless (and ignored) + * below `MIN_QUALITY_SAMPLE_SIZE` reviews, matching production- + * monitor.ts's own "acceptanceRate is of reviewed outputs only" design. */ + acceptanceRate: number; + /** How many reviewed outputs `acceptanceRate` is actually computed + * over — production-monitor.ts's `outputCount - unreviewedCount`, NOT + * its raw `outputCount` (a model with 500 outputs and 2 reviews has 2 + * data points, not 500). */ + reviewedCount: number; +} + +export interface RoutingCriteria { + /** True when the request's minimized data includes identifiers + * (gateway.ts's own `includesIdentifiers`) — gates on the same + * `model.phiPermitted && settings.phiAllowed` AND policy.ts's real + * evaluateGatewayAuthorization enforces, just pre-filtered here. */ + requiresPhi: boolean; + callerRoles: string[]; + /** ISO timestamp to evaluate `retiredAt`/expiry-shaped fields against — + * defaults to `new Date().toISOString()`; overridable only for + * deterministic tests. */ + now?: string; +} + +export interface RankedCandidate extends RoutingCandidate { + /** Lower ranks first. Exposed for test introspection and logging, not + * meant to be interpreted as a normalized score across calls. */ + rank: readonly [validationTier: number, qualityTier: number, hostingTier: number, costUsdPerThousandTokens: number, modelId: string]; +} + +const VALIDATION_TIER: Partial> = { validated: 0, canary: 1 }; +const HOSTING_TIER: Record = { local: 0, "on-premises": 1, "tenant-managed": 2, cloud: 3 }; + +/** Below this many reviewed outputs, a model's acceptanceRate is treated as + * statistically unreliable — the same floor eval-harness/production- + * monitor.ts's own drift detection uses (DEFAULT_DRIFT_THRESHOLDS. + * minimumOutputCount) for the identical reason: a couple of data points + * proves nothing about a model's real behavior. */ +export const MIN_QUALITY_SAMPLE_SIZE = 20; +const POOR_ACCEPTANCE_THRESHOLD = 0.5; +const FAIR_ACCEPTANCE_THRESHOLD = 0.8; + +/** 0 = good or unknown (neutral — never penalize missing data), 1 = fair, + * 2 = poor. Bucketed rather than a raw float specifically so two models + * with statistically indistinguishable acceptance rates (e.g. 91% vs 89%) + * don't get reordered on noise — only a real, sizeable quality difference + * changes rank. */ +function qualityTier(quality: QualitySignal | undefined): number { + if (!quality || quality.reviewedCount < MIN_QUALITY_SAMPLE_SIZE) return 0; + if (quality.acceptanceRate >= FAIR_ACCEPTANCE_THRESHOLD) return 0; + if (quality.acceptanceRate >= POOR_ACCEPTANCE_THRESHOLD) return 1; + return 2; +} + +function isEligible(candidate: RoutingCandidate, criteria: RoutingCriteria, now: string): boolean { + const { provider, model, settings } = candidate; + if (provider.killSwitchEngaged) return false; + if (provider.operationalStatus !== "active") return false; + if (!settings.enabled) return false; + if (model.retiredAt && model.retiredAt <= now) return false; + if (!(model.validationStatus in VALIDATION_TIER)) return false; // excludes unvalidated/deprecated + if (model.safetyStatus !== "nominal") return false; + if (!model.supportedDataTypes.includes("text")) return false; // this gateway only ever sends text sections (data-minimization.ts) + if (criteria.requiresPhi && !(model.phiPermitted && settings.phiAllowed)) return false; + if (settings.allowedRoles.length > 0 && !criteria.callerRoles.some((role) => settings.allowedRoles.includes(role))) return false; + return true; +} + +/** Cost per 1,000 combined input+output tokens — a single comparable unit + * across models with different input/output pricing. Missing pricing + * (typical for a `local`/`on-premises` model, which usually has no + * per-token billing at all) is treated as 0, not as unknown/worst-case — + * the common real case is "this is free because it's ours," not "we don't + * know the price of this cloud model." A cloud model that genuinely hasn't + * had pricing entered yet will rank ahead of priced peers until an + * operator fills it in; that is a data-completeness problem for the + * catalog to fix, not something this function should paper over by + * guessing a "worst case" price. */ +function costPerThousandTokens(model: AiProviderModel): number { + return ((model.costPerInputTokenUsd ?? 0) + (model.costPerOutputTokenUsd ?? 0)) * 1_000; +} + +/** + * Filters to eligible candidates (see `isEligible`) and ranks them, in + * order of precedence: validated before canary; a good/unknown recent + * clinician-acceptance rate before a merely fair one before a poor one + * (see `qualityTier`); local/on-premises before tenant-managed/cloud + * (prefer keeping data closest to home, all else equal); lower combined + * cost first; deterministic model-id tie-break last. Returns an empty + * array — never throws — when nothing is eligible; the caller decides what + * that means (gateway.ts reports it as a distinct `no-eligible-provider- + * model` outcome, never silently falls back to an ineligible model). + */ +export function rankEligibleProviderModels(candidates: RoutingCandidate[], criteria: RoutingCriteria): RankedCandidate[] { + const now = criteria.now ?? new Date().toISOString(); + return candidates + .filter((c) => isEligible(c, criteria, now)) + .map((c) => ({ ...c, rank: [VALIDATION_TIER[c.model.validationStatus]!, qualityTier(c.quality), HOSTING_TIER[c.provider.kind], costPerThousandTokens(c.model), c.model.id] as const })) + .sort((a, b) => a.rank[0] - b.rank[0] || a.rank[1] - b.rank[1] || a.rank[2] - b.rank[2] || a.rank[3] - b.rank[3] || a.rank[4].localeCompare(b.rank[4])); +} diff --git a/server/src/ai-gateway/prompt-registry.test.ts b/server/src/ai-gateway/prompt-registry.test.ts new file mode 100644 index 0000000..f99fd6d --- /dev/null +++ b/server/src/ai-gateway/prompt-registry.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { CURRENT_PROMPT_VERSION, getSystemPrompt, PROMPT_VERSIONS, UnknownPromptVersionError } from "./prompt-registry.js"; + +describe("prompt-registry", () => { + it("CURRENT_PROMPT_VERSION always resolves to a real, non-empty entry in PROMPT_VERSIONS", () => { + expect(PROMPT_VERSIONS[CURRENT_PROMPT_VERSION]).toBeTruthy(); + }); + + it("getSystemPrompt() with no argument resolves the current version", () => { + const prompt = getSystemPrompt(); + expect(prompt.version).toBe(CURRENT_PROMPT_VERSION); + expect(prompt.text).toBe(PROMPT_VERSIONS[CURRENT_PROMPT_VERSION]); + }); + + it("getSystemPrompt(version) pins an explicit known version — the rollback mechanism", () => { + const prompt = getSystemPrompt("clinical-gateway-prompt-v1"); + expect(prompt.version).toBe("clinical-gateway-prompt-v1"); + expect(prompt.text).toContain("ABSTAIN"); + }); + + it("throws UnknownPromptVersionError for an unrecognized version, rather than silently falling back to current", () => { + expect(() => getSystemPrompt("does-not-exist")).toThrow(UnknownPromptVersionError); + expect(() => getSystemPrompt("does-not-exist")).toThrow(/Unknown prompt version/); + }); + + it("the registry is frozen — no accidental mutation of a shipped version's text", () => { + expect(Object.isFrozen(PROMPT_VERSIONS)).toBe(true); + }); +}); diff --git a/server/src/ai-gateway/prompt-registry.ts b/server/src/ai-gateway/prompt-registry.ts new file mode 100644 index 0000000..1e8a4ed --- /dev/null +++ b/server/src/ai-gateway/prompt-registry.ts @@ -0,0 +1,61 @@ +/** + * Model/prompt versioning for the ClinicalAiGateway's own system prompt — + * closes the gap that used to exist here: a single hardcoded prompt string + * inline in gateway.ts, with no version recorded per request and no way to + * pin or roll back to an older wording. `AiOutput.promptVersion` + * (packages/contracts/src/ai-gateway.ts) now records exactly which entry + * below produced each output, forever — never mutated after the fact, + * mirroring `AiOutput.modelVersion`'s own "what produced this" role for the + * provider model itself. + * + * Versions are append-only and immutable: once `PROMPT_VERSIONS` ships a + * version, its text must never change (a wording fix is a NEW version, not + * an edit) — an already-generated AiOutput's `promptVersion` must always be + * able to resolve back to the exact prompt text that produced it, for as + * long as this codebase cares to keep the entry around. "Rollback" is + * simply pointing `CURRENT_PROMPT_VERSION` at an older key; nothing about + * older outputs' records needs to change for that to be safe. + */ + +const V1 = `You are a clinical decision-support assistant. You are NOT a diagnostic device and your output is never a final medical decision. Every response you produce will be shown to a licensed clinician as an unsigned draft for their review — it must never be presented to a patient directly, and it never modifies, signs, or submits any medical record on its own. + +Respond using EXACTLY this structure, with no other section headers: +SUMMARY: +EVIDENCE: +- +UNCERTAINTY: +FOLLOWUP: +- +ABSTAIN: + +Never include your reasoning process, chain-of-thought, or private deliberation — only the concise sections above. Never invent facts not present in the clinical data provided. If the data is insufficient, contradictory, or you are not confident, use the ABSTAIN section rather than guessing.`; + +export const PROMPT_VERSIONS: Readonly> = Object.freeze({ + "clinical-gateway-prompt-v1": V1, +}); + +/** The version every new request uses unless a caller pins an older one + * (submitRequest's own optional `promptVersion` input) — a real rollback + * mechanism: an operator who finds a new prompt version is producing worse + * outputs (see eval-harness/production-monitor.ts's drift detection) can + * pin requests back to the prior version without a code deploy, by passing + * it explicitly, while a fix is prepared. */ +export const CURRENT_PROMPT_VERSION = "clinical-gateway-prompt-v1"; + +export class UnknownPromptVersionError extends Error { + constructor(version: string) { + super(`Unknown prompt version "${version}" — known versions: ${Object.keys(PROMPT_VERSIONS).join(", ")}.`); + this.name = "UnknownPromptVersionError"; + } +} + +/** Resolves a prompt version to its text. `version` defaults to + * `CURRENT_PROMPT_VERSION`; an explicitly-passed unknown version throws + * rather than silently falling back to current — a caller pinning a + * specific version (e.g. for a reproducibility/rollback reason) must never + * silently get a different one. */ +export function getSystemPrompt(version: string = CURRENT_PROMPT_VERSION): { version: string; text: string } { + const text = PROMPT_VERSIONS[version]; + if (!text) throw new UnknownPromptVersionError(version); + return { version, text }; +} diff --git a/server/src/app.ts b/server/src/app.ts index 70bc364..052e967 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -19,6 +19,7 @@ import { registerCaseMigrationRoutes } from "./routes/case-migrations.js"; import { registerGroupRoutes } from "./routes/groups.js"; import { registerInvitationRoutes } from "./routes/invitations.js"; import { registerMcpRegistryRoutes } from "./routes/mcp-registry.js"; +import { registerMcpClinicalRoutes } from "./routes/mcp-clinical.js"; import { registerComputeControlRoutes } from "./routes/compute-control.js"; import { registerMeRoutes } from "./routes/me.js"; import { registerOrganizationRoutes } from "./routes/organizations.js"; @@ -32,6 +33,9 @@ import { registerImagingViewerSessionRoutes } from "./routes/imaging-viewer-sess import { registerImagingIntegrationRoutes } from "./routes/imaging-integrations.js"; import { registerImagingDeidentificationRoutes } from "./routes/imaging-deidentification.js"; import { registerAiGatewayRoutes } from "./routes/ai-gateway.js"; +import { registerFhirRoutes } from "./routes/fhir.js"; +import { registerHl7Routes } from "./routes/hl7.js"; +import { registerSmartLaunchRoutes } from "./routes/smart-launch.js"; import { registerPolicyVersionRoutes } from "./routes/policy-versions.js"; import { registerScimRoutes } from "./routes/scim.js"; import { registerScimTokenRoutes } from "./routes/scim-tokens.js"; @@ -51,6 +55,10 @@ import type { IamStore } from "./store/iam-store.js"; import type { IdempotencyStore } from "./store/idempotency-store.js"; import type { McpRegistryStore } from "./store/mcp-registry-store.js"; import { InMemoryMcpRegistryStore } from "./store/mcp-registry-store.js"; +import type { McpClinicalStore } from "./store/mcp-clinical-store.js"; +import { InMemoryMcpClinicalStore } from "./store/mcp-clinical-store.js"; +import type { McpApprovalTicketIssuer } from "./mcp-approval-issuer.js"; +import { UnconfiguredMcpApprovalTicketIssuer } from "./mcp-approval-issuer.js"; import type { ComputeControlStore } from "./store/compute-control-store.js"; import { InMemoryComputeControlStore } from "./store/compute-control-store.js"; import { ComputeControlPlane } from "./compute/control-plane.js"; @@ -66,8 +74,12 @@ import { OriginStreamContentDelivery } from "./imaging/content-delivery.js"; import type { ImagingObjectStore } from "./imaging/object-store.js"; import { LocalFilesystemImagingObjectStore } from "./imaging/object-store.js"; import type { ImagingStore } from "./store/imaging-store.js"; +import type { Hl7IngestionStore } from "./store/hl7-ingestion-store.js"; +import type { SmartLaunchStore } from "./store/smart-launch-store.js"; +import { InMemorySmartLaunchStore } from "./store/in-memory-smart-launch-store.js"; import { InMemoryImagingStore } from "./store/in-memory-imaging-store.js"; -import type { AiProvider, AiProviderModel } from "@modelforge/contracts"; +import { InMemoryHl7IngestionStore } from "./store/in-memory-hl7-ingestion-store.js"; +import type { AiProvider, AiProviderModel, FhirSmartConfiguration } from "@modelforge/contracts"; import type { AiGatewayStore } from "./store/ai-gateway-store.js"; import { InMemoryAiGatewayStore } from "./store/in-memory-ai-gateway-store.js"; import type { AiProviderRegistryStore } from "./store/ai-provider-registry-store.js"; @@ -120,6 +132,8 @@ export interface BuildAppOptions { accessGovernanceStore?: AccessGovernanceStore; scimTokenStore?: ScimTokenStore; mcpRegistryStore?: McpRegistryStore; + mcpClinicalStore?: McpClinicalStore; + mcpApprovalTicketIssuer?: McpApprovalTicketIssuer; computeControlStore?: ComputeControlStore; /** Ed25519 SPKI PEM used to verify organization-bound compute policy * payloads. Omission fails policy creation closed with HTTP 503. */ @@ -130,6 +144,13 @@ export interface BuildAppOptions { * intentionally not accepted by default. */ resolveComputeAgentCertificateFingerprint?: (request: FastifyRequest) => string | undefined; imagingStore?: ImagingStore; + hl7IngestionStore?: Hl7IngestionStore; + smartLaunchStore?: SmartLaunchStore; + /** SMART_LAUNCH_ENCRYPTION_KEY, already-decoded. Omitted (every test + * in this package) means routes/smart-launch.ts's token-exchange + * route 503s rather than encrypting with no real key — see + * RouteDeps's own doc comment. */ + smartLaunchEncryptionKey?: Buffer; imagingObjectStore?: ImagingObjectStore; createDicomwebAdapter?: (organizationId: string) => DicomwebAdapter; imagingStorageMode?: "local-filesystem" | "s3"; @@ -209,6 +230,13 @@ export interface BuildAppOptions { * other header, which is the correct default for an API not meant to * be called directly from an arbitrary web page. */ adminConsoleOrigin?: string; + /** routes/fhir.ts's `.well-known/smart-configuration` document. + * Omitted (every test in this package) means that one route responds + * 503 rather than making a live OIDC discovery call — see RouteDeps's + * own doc comment. index.ts resolves this once at startup via + * auth/oidc-verifier.ts's resolveAuthorizationServerMetadata + + * fhir/smart-configuration.ts's buildSmartConfiguration. */ + smartConfiguration?: FhirSmartConfiguration; } /** @@ -339,6 +367,8 @@ export function buildApp(options: BuildAppOptions): FastifyInstance { accessGovernanceStore: options.accessGovernanceStore ?? new InMemoryAccessGovernanceStore(options.auditStore), scimTokenStore: options.scimTokenStore ?? new InMemoryScimTokenStore(options.auditStore), mcpRegistryStore: options.mcpRegistryStore ?? new InMemoryMcpRegistryStore(options.auditStore), + mcpClinicalStore: options.mcpClinicalStore ?? new InMemoryMcpClinicalStore(options.auditStore), + mcpApprovalTicketIssuer: options.mcpApprovalTicketIssuer ?? new UnconfiguredMcpApprovalTicketIssuer(), computeControlStore, computeControlPlane: new ComputeControlPlane(computeControlStore), verifyComputePolicySignature: createComputePolicySignatureVerifier(options.computePolicyPublicKeyPem), @@ -348,6 +378,9 @@ export function buildApp(options: BuildAppOptions): FastifyInstance { return socket.getPeerCertificate()?.fingerprint256; }), imagingStore: options.imagingStore ?? new InMemoryImagingStore(options.auditStore), + hl7IngestionStore: options.hl7IngestionStore ?? new InMemoryHl7IngestionStore(options.auditStore), + smartLaunchStore: options.smartLaunchStore ?? new InMemorySmartLaunchStore(options.auditStore), + smartLaunchEncryptionKey: options.smartLaunchEncryptionKey, imagingObjectStore: options.imagingObjectStore ?? getDefaultImagingObjectStore(), createDicomwebAdapter: options.createDicomwebAdapter ?? @@ -364,6 +397,7 @@ export function buildApp(options: BuildAppOptions): FastifyInstance { breakGlassGrantDurationMs: options.breakGlassGrantDurationMs ?? DEFAULT_BREAK_GLASS_GRANT_DURATION_MS, tenantDirectory: options.tenantDirectory ?? new StoreTenantDirectory(options.store), authPreHandler, + smartConfiguration: options.smartConfiguration, }; // Raw-binary content types for DICOM upload (routes/imaging-ingestion.ts). @@ -381,6 +415,18 @@ export function buildApp(options: BuildAppOptions): FastifyInstance { done(null, payload); }); + // Raw-text content type for an inbound HL7 v2 message + // (routes/hl7.ts's POST .../inbound/oru-r01/parse) — HL7 v2 is + // plain text (ER7/"pipe-and-hat"), never JSON, so Fastify's default + // application/json parser doesn't apply; this hands the route the + // raw string exactly as received, same "no opinion on size, the + // route sets its own bodyLimit if it needs one" posture as the + // DICOM parser above (a real HL7 v2 message is always small, so + // this route doesn't override the app-wide 1 MiB default). + instance.addContentTypeParser("application/hl7-v2", { parseAs: "string" }, (_request, payload, done) => { + done(null, payload); + }); + instance.get("/health", async (_request, reply) => { if (!options.healthCheck) return { status: "ok" }; const healthy = await options.healthCheck(); @@ -461,6 +507,7 @@ export function buildApp(options: BuildAppOptions): FastifyInstance { registerSessionRoutes(instance, deps); registerScimTokenRoutes(instance, deps); registerMcpRegistryRoutes(instance, deps); + registerMcpClinicalRoutes(instance, deps); registerComputeControlRoutes(instance, deps); registerImagingStudyRoutes(instance, deps); registerImagingReportRoutes(instance, deps); @@ -470,6 +517,9 @@ export function buildApp(options: BuildAppOptions): FastifyInstance { registerImagingDeidentificationRoutes(instance, deps); registerImagingIngestionRoutes(instance, deps); registerAiGatewayRoutes(instance, deps); + registerFhirRoutes(instance, deps); + registerHl7Routes(instance, deps); + registerSmartLaunchRoutes(instance, deps); // DICOMweb routes are bearer-token (viewer-session) authenticated, // not OIDC — see that file's own requireViewerSession, the same // "not deps.authPreHandler" pattern routes/scim.ts already diff --git a/server/src/auth/oidc-verifier.test.ts b/server/src/auth/oidc-verifier.test.ts index c52b6dd..5ea0e8c 100644 --- a/server/src/auth/oidc-verifier.test.ts +++ b/server/src/auth/oidc-verifier.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeAll, afterEach, vi } from "vitest"; import { SignJWT, exportJWK, generateKeyPair, createLocalJWKSet, type JWTVerifyGetKey, type CryptoKey, type JWK } from "jose"; -import { verifyAccessToken, resolveJwks, decodeUnverifiedIssuer, TokenVerificationError } from "./oidc-verifier.js"; +import { verifyAccessToken, resolveJwks, resolveAuthorizationServerMetadata, decodeUnverifiedIssuer, TokenVerificationError } from "./oidc-verifier.js"; const ISSUER = "https://idp.example-hospital.test/realms/clinical"; const AUDIENCE = "modelforge-iam-server"; @@ -228,4 +228,67 @@ describe("oidc-verifier", () => { await expect(resolveJwks({ issuer: ISSUER })).resolves.toBeDefined(); }); }); + + describe("resolveAuthorizationServerMetadata (SMART on FHIR discovery)", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("resolves the external IdP's authorization_endpoint/token_endpoint from its discovery document", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => + new Response( + JSON.stringify({ + issuer: ISSUER, + authorization_endpoint: "https://idp.example-hospital.test/protocol/openid-connect/auth", + token_endpoint: "https://idp.example-hospital.test/protocol/openid-connect/token", + }), + { status: 200 } + ) + ) + ); + + await expect(resolveAuthorizationServerMetadata({ issuer: ISSUER })).resolves.toEqual({ + issuer: ISSUER, + authorizationEndpoint: "https://idp.example-hospital.test/protocol/openid-connect/auth", + tokenEndpoint: "https://idp.example-hospital.test/protocol/openid-connect/token", + }); + }); + + it("rejects with a clear error when the discovery document has no authorization_endpoint", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ token_endpoint: "https://idp.example-hospital.test/token" }), { status: 200 })) + ); + + await expect(resolveAuthorizationServerMetadata({ issuer: ISSUER })).rejects.toThrow(/no usable "authorization_endpoint"/); + }); + + it("rejects with a clear error when the discovery document has no token_endpoint", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response(JSON.stringify({ authorization_endpoint: "https://idp.example-hospital.test/auth" }), { status: 200 })) + ); + + await expect(resolveAuthorizationServerMetadata({ issuer: ISSUER })).rejects.toThrow(/no usable "token_endpoint"/); + }); + + it("rejects with a clear error, not a hang, on discovery timeout — same posture as resolveJwks", async () => { + vi.stubGlobal( + "fetch", + vi.fn((_url: string, init?: { signal?: AbortSignal }) => { + return new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + const err = new Error("This operation was aborted"); + err.name = "TimeoutError"; + reject(err); + }); + }); + }) + ); + + await expect(resolveAuthorizationServerMetadata({ issuer: ISSUER }, 50)).rejects.toThrow(/timed out after 50ms/); + }); + }); }); diff --git a/server/src/auth/oidc-verifier.ts b/server/src/auth/oidc-verifier.ts index a3726e1..bb1696a 100644 --- a/server/src/auth/oidc-verifier.ts +++ b/server/src/auth/oidc-verifier.ts @@ -129,30 +129,73 @@ export function decodeUnverifiedIssuer(token: string): string | undefined { * Fetched and cached once per process by `createRemoteJWKSet` internally — * not re-fetched on every request. */ -export async function resolveJwks( - config: { issuer: string; jwksUri?: string }, - // Overridable only so oidc-verifier.test.ts can exercise the timeout - // path in milliseconds instead of the real 10s — production code never - // passes this. - discoveryTimeoutMs: number = DISCOVERY_TIMEOUT_MS -): Promise { - if (config.jwksUri) return createRemoteJWKSet(new URL(config.jwksUri)); - - const base = config.issuer.endsWith("/") ? config.issuer.slice(0, -1) : config.issuer; +/** + * Fetches `{issuer}/.well-known/openid-configuration` — the one piece of + * discovery-document plumbing resolveJwks() and + * resolveAuthorizationServerMetadata() (below) both need, factored out so + * there is exactly one place that owns the URL construction, timeout, and + * error-message shape for "OIDC discovery failed." + */ +async function fetchOidcDiscoveryDocument(issuer: string, discoveryTimeoutMs: number): Promise> { + const base = issuer.endsWith("/") ? issuer.slice(0, -1) : issuer; const discoveryUrl = `${base}/.well-known/openid-configuration`; let response: Response; try { response = await fetch(discoveryUrl, { signal: AbortSignal.timeout(discoveryTimeoutMs) }); } catch (err) { const reason = err instanceof Error && err.name === "TimeoutError" ? `timed out after ${discoveryTimeoutMs}ms` : String(err); - throw new Error(`OIDC discovery failed for issuer "${config.issuer}" (${discoveryUrl}): ${reason}`); + throw new Error(`OIDC discovery failed for issuer "${issuer}" (${discoveryUrl}): ${reason}`); } if (!response.ok) { - throw new Error(`OIDC discovery failed for issuer "${config.issuer}" (${discoveryUrl}): HTTP ${response.status} ${response.statusText}`); + throw new Error(`OIDC discovery failed for issuer "${issuer}" (${discoveryUrl}): HTTP ${response.status} ${response.statusText}`); } - const discovery = (await response.json()) as { jwks_uri?: unknown }; + return (await response.json()) as Record; +} + +export async function resolveJwks( + config: { issuer: string; jwksUri?: string }, + // Overridable only so oidc-verifier.test.ts can exercise the timeout + // path in milliseconds instead of the real 10s — production code never + // passes this. + discoveryTimeoutMs: number = DISCOVERY_TIMEOUT_MS +): Promise { + if (config.jwksUri) return createRemoteJWKSet(new URL(config.jwksUri)); + + const discovery = await fetchOidcDiscoveryDocument(config.issuer, discoveryTimeoutMs); if (typeof discovery.jwks_uri !== "string" || discovery.jwks_uri.length === 0) { - throw new Error(`OIDC discovery document at ${discoveryUrl} has no usable "jwks_uri".`); + throw new Error(`OIDC discovery document for issuer "${config.issuer}" has no usable "jwks_uri".`); } return createRemoteJWKSet(new URL(discovery.jwks_uri)); } + +export interface AuthorizationServerMetadata { + issuer: string; + authorizationEndpoint: string; + tokenEndpoint: string; +} + +/** + * Resolves the external IdP's own `authorization_endpoint`/`token_endpoint` + * — used only to populate this server's `.well-known/smart-configuration` + * (server/src/fhir/smart-configuration.ts). This server never issues + * tokens itself (see this file's own top doc comment); it only ever + * *republishes* the real authorization server's endpoints so a SMART + * client knows where to actually send a user to authorize. Resolved once + * at startup (index.ts), same "fail loudly if unreachable" posture as + * resolveJwks — an IdP a SMART launch depends on but this process can't + * reach at boot should be a startup failure, not a 500 on first request. + */ +export async function resolveAuthorizationServerMetadata( + config: { issuer: string }, + discoveryTimeoutMs: number = DISCOVERY_TIMEOUT_MS +): Promise { + const discovery = await fetchOidcDiscoveryDocument(config.issuer, discoveryTimeoutMs); + const { authorization_endpoint: authorizationEndpoint, token_endpoint: tokenEndpoint } = discovery; + if (typeof authorizationEndpoint !== "string" || authorizationEndpoint.length === 0) { + throw new Error(`OIDC discovery document for issuer "${config.issuer}" has no usable "authorization_endpoint".`); + } + if (typeof tokenEndpoint !== "string" || tokenEndpoint.length === 0) { + throw new Error(`OIDC discovery document for issuer "${config.issuer}" has no usable "token_endpoint".`); + } + return { issuer: config.issuer, authorizationEndpoint, tokenEndpoint }; +} diff --git a/server/src/config.test.ts b/server/src/config.test.ts index 61b358f..2e39d0b 100644 --- a/server/src/config.test.ts +++ b/server/src/config.test.ts @@ -392,4 +392,38 @@ describe("loadConfig", () => { ).toThrow(/base64-encoded PEM/); }); }); + + describe("HL7_MLLP_*", () => { + const ORG_ID = "11111111-1111-4111-8111-111111111111"; + + it("is undefined when unset — no MLLP listener starts by default", () => { + expect(loadConfig(baseEnv()).hl7Mllp).toBeUndefined(); + }); + + it("rejects HL7_MLLP_PORT without HL7_MLLP_ORGANIZATION_ID", () => { + expect(() => loadConfig(baseEnv({ HL7_MLLP_PORT: "2575" }))).toThrow(/must be configured together/); + }); + + it("rejects HL7_MLLP_ORGANIZATION_ID without HL7_MLLP_PORT", () => { + expect(() => loadConfig(baseEnv({ HL7_MLLP_ORGANIZATION_ID: ORG_ID }))).toThrow(/must be configured together/); + }); + + it("rejects a non-UUID organization id", () => { + expect(() => loadConfig(baseEnv({ HL7_MLLP_PORT: "2575", HL7_MLLP_ORGANIZATION_ID: "not-a-uuid" }))).toThrow(/must be the organization's UUID/); + }); + + it("accepts a valid configuration, defaulting host to loopback", () => { + const config = loadConfig(baseEnv({ HL7_MLLP_PORT: "2575", HL7_MLLP_ORGANIZATION_ID: ORG_ID })); + expect(config.hl7Mllp).toEqual({ port: 2575, host: "127.0.0.1", organizationId: ORG_ID }); + }); + + it("honors an explicit HL7_MLLP_HOST override", () => { + const config = loadConfig(baseEnv({ HL7_MLLP_PORT: "2575", HL7_MLLP_ORGANIZATION_ID: ORG_ID, HL7_MLLP_HOST: "0.0.0.0" })); + expect(config.hl7Mllp?.host).toBe("0.0.0.0"); + }); + + it("rejects a port outside the valid TCP range", () => { + expect(() => loadConfig(baseEnv({ HL7_MLLP_PORT: "70000", HL7_MLLP_ORGANIZATION_ID: ORG_ID }))).toThrow(ConfigError); + }); + }); }); diff --git a/server/src/config.ts b/server/src/config.ts index ed2b74e..219a961 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -75,6 +75,16 @@ export interface AppConfig { * /health's own posture: both are deliberately metadata-only/PHI-free * (see metrics.ts and docs/OBSERVABILITY.md). */ metricsToken?: string; + /** SMART_LAUNCH_ENCRYPTION_KEY, optional — base64 for exactly 32 + * bytes, same shape as IMAGING_ENCRYPTION_KEY below. Encrypts an + * external EHR's access/refresh token at rest + * (smart-launch/token-crypto.ts) before routes/smart-launch.ts's + * token-exchange route ever writes one to the store. Unset means that + * one route 503s rather than encrypting with no real key — every + * other SMART launch route (configuring trusted issuers, listing + * one's own sessions) works regardless, since none of them touch a + * secret. */ + smartLaunchEncryptionKeyBase64?: string; imaging: { localRoot?: string; encryptionKeyBase64?: string; @@ -180,6 +190,33 @@ export interface AppConfig { * that ownership before relying on this in production. */ grantDurationMs: number; }; + /** Opt-in MLLP (HL7 v2 TCP transport) listener — see + * hl7/mllp-server.ts's own top doc comment for the full trust-model + * reasoning this config exists to enforce. undefined (the default — + * every environment before this existed) means no MLLP listener starts + * at all; index.ts never binds a socket unless every one of these is + * explicitly set. */ + hl7Mllp?: { + /** HL7_MLLP_PORT. Required together with organizationId below. */ + port: number; + /** HL7_MLLP_HOST, default "127.0.0.1". A non-loopback bind is + * accepted (this config layer doesn't forbid it — some deployments + * genuinely run this process inside an already-isolated private + * network segment where loopback-only would be wrong) but is + * never the default: an operator must type a real, intentional + * value to widen it, matching TRUST_PROXY/ADMIN_CONSOLE_ORIGIN's + * own "never guess at the deployment topology" posture above. */ + host: string; + /** HL7_MLLP_ORGANIZATION_ID. This listener serves exactly one + * tenant — see mllp-server.ts's own doc comment on why MLLP has no + * per-message identity to route by. A deployment integrating with + * more than one hospital's lab feed needs more than one listener + * (a future generalization, not attempted here since no such + * deployment exists yet — same "not generalized past what's + * actually needed" reasoning as adminConsoleOrigin's single-origin + * limitation above). */ + organizationId: string; + }; } // CACHE_TTL_MS bounds: below 1s, caching stops meaningfully reducing store @@ -413,6 +450,10 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { const decoded = Buffer.from(env.IMAGING_ENCRYPTION_KEY, "base64"); if (decoded.length !== 32) throw new ConfigError("IMAGING_ENCRYPTION_KEY must be base64 for exactly 32 bytes."); } + if (env.SMART_LAUNCH_ENCRYPTION_KEY) { + const decoded = Buffer.from(env.SMART_LAUNCH_ENCRYPTION_KEY, "base64"); + if (decoded.length !== 32) throw new ConfigError("SMART_LAUNCH_ENCRYPTION_KEY must be base64 for exactly 32 bytes."); + } const cloudFrontValues = [env.IMAGING_CLOUDFRONT_DOMAIN, env.IMAGING_CLOUDFRONT_KEY_PAIR_ID, env.IMAGING_CLOUDFRONT_PRIVATE_KEY]; if (cloudFrontValues.some(Boolean)) { if (!cloudFrontValues.every(Boolean)) { @@ -432,6 +473,13 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { throw new ConfigError("IMAGING_CLOUDFRONT_PRIVATE_KEY must be a base64-encoded PEM private key."); } } + const mllpValues = [env.HL7_MLLP_PORT, env.HL7_MLLP_ORGANIZATION_ID]; + if (mllpValues.some(Boolean) && !mllpValues.every(Boolean)) { + throw new ConfigError("HL7_MLLP_PORT and HL7_MLLP_ORGANIZATION_ID must be configured together."); + } + if (env.HL7_MLLP_ORGANIZATION_ID && !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(env.HL7_MLLP_ORGANIZATION_ID)) { + throw new ConfigError("HL7_MLLP_ORGANIZATION_ID must be the organization's UUID."); + } return { port: parseBoundedIntEnv("PORT", env.PORT, { default: 4000, min: 1, max: 65_535 }), @@ -445,6 +493,7 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { runtimeDatabaseUrl: env.RUNTIME_DATABASE_URL, adminConsoleOrigin: env.ADMIN_CONSOLE_ORIGIN, metricsToken: env.METRICS_TOKEN, + smartLaunchEncryptionKeyBase64: env.SMART_LAUNCH_ENCRYPTION_KEY, imaging: { localRoot: env.IMAGING_LOCAL_ROOT, encryptionKeyBase64: env.IMAGING_ENCRYPTION_KEY, @@ -490,5 +539,12 @@ export function loadConfig(env: NodeJS.ProcessEnv = process.env): AppConfig { max: 86_400_000, // 24 hours }), }, + hl7Mllp: env.HL7_MLLP_PORT + ? { + port: parseBoundedIntEnv("HL7_MLLP_PORT", env.HL7_MLLP_PORT, { default: 2575, min: 1, max: 65_535 }), + host: env.HL7_MLLP_HOST || "127.0.0.1", + organizationId: env.HL7_MLLP_ORGANIZATION_ID!, + } + : undefined, }; } diff --git a/server/src/domain/action-catalog.ts b/server/src/domain/action-catalog.ts index 3d3eae3..1171554 100644 --- a/server/src/domain/action-catalog.ts +++ b/server/src/domain/action-catalog.ts @@ -137,6 +137,10 @@ export const ACTION_CATALOG: readonly ActionCatalogEntry[] = [ // doc comment for the enforcement scope boundary. { action: "mcpRegistry:list", description: "List the organization's registered MCP servers and their allowlisted tools/egress policy." }, { action: "mcpRegistry:manage", description: "Create, update, or enable/disable an entry in the organization's MCP server registry." }, + { action: "mcpClinical:use", description: "Issue a short-lived patient-context grant and invoke approved clinical MCP tools." }, + { action: "mcpClinical:approve", description: "Confirm an operation-bound approval for a controlled clinical MCP write." }, + { action: "mcpClinical:introspect", description: "Workload-only permission to resolve short-lived clinical MCP context grants." }, + { action: "mcpClinical:recordReview", description: "Workload-only permission to persist a clinician's MCP review decision." }, // Hybrid CPU/GPU control plane (routes/compute-control.ts). { action: "compute:list", description: "Read compute nodes, pools, policies, requests, leases, and PHI-free capacity summaries." }, @@ -146,6 +150,24 @@ export const ACTION_CATALOG: readonly ActionCatalogEntry[] = [ { action: "compute:manageCritical", description: "Perform high-impact compute operations including quarantine and hard quota changes; intended for step-up or break-glass policies." }, { action: "compute:submit", description: "Submit and cancel organization compute workloads." }, { action: "compute:agent", description: "Send node heartbeats and acknowledge, renew, or release fenced leases from an enrolled node agent." }, + + // HL7 v2 (routes/hl7.ts, server/src/hl7/). See docs/HL7_V2_INTEGRATION.md. + // Outbound generation (ORU^R01) reuses imagingStudy:view/diagnosticReport:view, + // the same data it's a wire-format re-shaping of — this action gates only + // inbound parsing, a stateless format conversion with no case/patient + // resource of its own to reuse an existing action from. + { action: "hl7:parseInbound", description: "Parse an inbound HL7 v2 message into structured data. Parsing only — no case or patient record is looked up, matched, or written." }, + { action: "hl7:ingest", description: "Ingest an inbound HL7 v2 message: match it against patient cases and, for an unambiguous match, apply it (merge ORU observations, record an ADT visit event)." }, + { action: "hl7:reviewIngestion", description: "List HL7 v2 ingestion jobs and resolve one flagged pending-review (ambiguous or no patient match) by choosing a case or rejecting it." }, + + // SMART App Launch client role (routes/smart-launch.ts, server/src/smart-launch/). + // See docs/SMART_LAUNCH.md. A launch always requires an already- + // authenticated caller — these actions gate the two distinct concerns: + // configuring which EHRs this org trusts at all (an admin action) vs. + // any org member actually using that trust to launch/hold a session + // for themselves. + { action: "smartLaunch:manage", description: "Add, list, or remove this organization's trusted EHR issuers for SMART App Launch (client_id, allowed redirect URIs)." }, + { action: "smartLaunch:use", description: "Start a SMART App Launch against a trusted issuer, complete its callback, and list/revoke one's own resulting launch sessions." }, ] as const; const ACTION_STRINGS = new Set(ACTION_CATALOG.map((entry) => entry.action)); diff --git a/server/src/domain/types.ts b/server/src/domain/types.ts index 01fa407..6fd7165 100644 --- a/server/src/domain/types.ts +++ b/server/src/domain/types.ts @@ -383,6 +383,8 @@ export const mcpDataEgressPolicySchema = z.enum(["none", "metadata-only", "unres export type McpDataEgressPolicy = z.infer; export const mcpRegistryStatusSchema = z.enum(["active", "disabled"]); export type McpRegistryStatus = z.infer; +export const mcpIntegrationProfileSchema = z.enum(["generic", "modelforge-clinical"]); +export type McpIntegrationProfile = z.infer; export const mcpAllowedToolsSchema = z.union([z.literal("*"), z.array(z.string().min(1))]); export type McpAllowedTools = z.infer; export const mcpRegistryEntrySchema = z.object({ @@ -393,6 +395,10 @@ export const mcpRegistryEntrySchema = z.object({ endpoint: z.string().min(1), allowedTools: mcpAllowedToolsSchema, dataEgressPolicy: mcpDataEgressPolicySchema, + integrationProfile: mcpIntegrationProfileSchema, + oauthClientId: z.string().min(1).max(512).optional(), + catalogVersionConstraint: z.string().min(1).max(200).optional(), + approvalChallengeEndpoint: z.string().url().optional(), status: mcpRegistryStatusSchema, description: z.string().optional(), createdByUserId: z.string().uuid(), diff --git a/server/src/eval-harness/production-monitor.test.ts b/server/src/eval-harness/production-monitor.test.ts new file mode 100644 index 0000000..4f268bb --- /dev/null +++ b/server/src/eval-harness/production-monitor.test.ts @@ -0,0 +1,130 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InMemoryAiGatewayStore } from "../store/in-memory-ai-gateway-store.js"; +import type { TenantAiGatewayRepository } from "../store/ai-gateway-store.js"; +import type { TenantContext } from "../tenant-context.js"; +import { computeProductionQualitySnapshot, DEFAULT_DRIFT_THRESHOLDS, detectProductionQualityDrift } from "./production-monitor.js"; + +const actor = () => ({ externalSubject: "idp|clinician", userId: "user-1", organizationId: undefined as unknown as string }); + +function tenantContext(organizationId: string): TenantContext { + return { organizationId, schemaName: `tenant_${organizationId.replaceAll("-", "")}`, issuer: "test", subject: "test" }; +} + +async function makeOutput(repo: TenantAiGatewayRepository, providerModelId: string, opts: { abstained?: boolean; review?: "accepted" | "rejected" | "corrected" | "escalated" }) { + const { output } = await repo.createOutput( + { + requestId: "req-1", + providerModelId, + modelVersion: "v1", + promptVersion: "clinical-gateway-prompt-v1", + summary: opts.abstained ? "Abstained." : "No interactions found.", + evidence: [], + followUp: [], + abstained: opts.abstained ?? false, + abstainReason: opts.abstained ? "insufficient evidence" : undefined, + outputHash: "a".repeat(64), + citations: [], + }, + actor() + ); + if (opts.review) { + await repo.createReview( + { + outputId: output.id, + reviewedByUserId: "clinician-1", + decision: opts.review, + correctedText: opts.review === "corrected" ? "corrected text" : undefined, + escalationReason: opts.review === "escalated" ? "needs specialist" : undefined, + }, + actor() + ); + } + return output; +} + +describe("computeProductionQualitySnapshot", () => { + it("reports all-zero rates for a model with no outputs yet, never NaN/divide-by-zero", async () => { + const repo = new InMemoryAiGatewayStore().forTenant(tenantContext("org-1")); + const snapshot = await computeProductionQualitySnapshot(repo, "model-1"); + expect(snapshot).toMatchObject({ outputCount: 0, abstentionRate: 0, reviewedRate: 0, acceptanceRate: 0, rejectionRate: 0, correctionRate: 0, escalationRate: 0, unreviewedCount: 0 }); + }); + + it("computes abstention rate over ALL outputs but decision rates only over REVIEWED outputs, so a review backlog doesn't dilute the acceptance rate", async () => { + const repo = new InMemoryAiGatewayStore().forTenant(tenantContext("org-1")); + await makeOutput(repo, "model-1", { review: "accepted" }); + await makeOutput(repo, "model-1", { review: "rejected" }); + await makeOutput(repo, "model-1", { abstained: true }); // unreviewed + await makeOutput(repo, "model-1", {}); // unreviewed + + const snapshot = await computeProductionQualitySnapshot(repo, "model-1"); + expect(snapshot.outputCount).toBe(4); + expect(snapshot.abstentionRate).toBeCloseTo(0.25, 5); // 1 of 4 abstained + expect(snapshot.reviewedRate).toBeCloseTo(0.5, 5); // 2 of 4 reviewed + expect(snapshot.unreviewedCount).toBe(2); + expect(snapshot.acceptanceRate).toBeCloseTo(0.5, 5); // 1 of 2 REVIEWED + expect(snapshot.rejectionRate).toBeCloseTo(0.5, 5); + }); + + it("only counts outputs for the requested provider model", async () => { + const repo = new InMemoryAiGatewayStore().forTenant(tenantContext("org-1")); + await makeOutput(repo, "model-1", { review: "accepted" }); + await makeOutput(repo, "model-2", { review: "rejected" }); + expect((await computeProductionQualitySnapshot(repo, "model-1")).outputCount).toBe(1); + expect((await computeProductionQualitySnapshot(repo, "model-2")).outputCount).toBe(1); + }); +}); + +describe("detectProductionQualityDrift", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("reports insufficientData and never alerts when either window is below minimumOutputCount", async () => { + const repo = new InMemoryAiGatewayStore().forTenant(tenantContext("org-1")); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + await makeOutput(repo, "model-1", { review: "rejected" }); + const splitAt = new Date("2026-01-02T00:00:00Z").toISOString(); + vi.setSystemTime(new Date("2026-01-03T00:00:00Z")); + await makeOutput(repo, "model-1", { review: "rejected" }); + + const report = await detectProductionQualityDrift(repo, "model-1", undefined, splitAt); + expect(report.sufficientData).toBe(false); + expect(report.drifted).toBe(false); + expect(report.alerts).toEqual([]); + }); + + it("flags a real rejection-rate spike between two well-populated windows, and never on a stable model", async () => { + const repo = new InMemoryAiGatewayStore().forTenant(tenantContext("org-1")); + const n = DEFAULT_DRIFT_THRESHOLDS.minimumOutputCount; + + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + for (let i = 0; i < n; i++) await makeOutput(repo, "model-1", { review: "accepted" }); // baseline: 100% accepted + const splitAt = new Date("2026-01-02T00:00:00Z").toISOString(); + + vi.setSystemTime(new Date("2026-01-03T00:00:00Z")); + for (let i = 0; i < n; i++) await makeOutput(repo, "model-1", { review: "rejected" }); // current: 100% rejected + + const report = await detectProductionQualityDrift(repo, "model-1", undefined, splitAt); + expect(report.sufficientData).toBe(true); + expect(report.baseline.acceptanceRate).toBeCloseTo(1, 5); + expect(report.current.rejectionRate).toBeCloseTo(1, 5); + expect(report.drifted).toBe(true); + expect(report.alerts.some((a) => a.includes("acceptance rate dropped"))).toBe(true); + expect(report.alerts.some((a) => a.includes("rejection rate rose"))).toBe(true); + }); + + it("does not flag drift when a model behaves identically across both windows", async () => { + const repo = new InMemoryAiGatewayStore().forTenant(tenantContext("org-1")); + const n = DEFAULT_DRIFT_THRESHOLDS.minimumOutputCount; + + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + for (let i = 0; i < n; i++) await makeOutput(repo, "model-1", { review: "accepted" }); + const splitAt = new Date("2026-01-02T00:00:00Z").toISOString(); + vi.setSystemTime(new Date("2026-01-03T00:00:00Z")); + for (let i = 0; i < n; i++) await makeOutput(repo, "model-1", { review: "accepted" }); + + const report = await detectProductionQualityDrift(repo, "model-1", undefined, splitAt); + expect(report.sufficientData).toBe(true); + expect(report.drifted).toBe(false); + expect(report.alerts).toEqual([]); + }); +}); diff --git a/server/src/eval-harness/production-monitor.ts b/server/src/eval-harness/production-monitor.ts new file mode 100644 index 0000000..fad0c7c --- /dev/null +++ b/server/src/eval-harness/production-monitor.ts @@ -0,0 +1,160 @@ +import type { AiOutput } from "@modelforge/contracts"; +import type { TenantAiGatewayRepository } from "../store/ai-gateway-store.js"; + +/** + * The "online"/production half of the clinical AI evaluation framework — + * distinct from and complementary to runner.ts's offline golden-dataset + * harness. runner.ts answers "does a candidate model/prompt pass a fixed + * synthetic test suite before we let it serve traffic"; this module answers + * "how is a model actually behaving in production, against real clinician + * decisions" — no golden answers, no synthetic cases, just aggregated + * signal already captured by the gateway itself: `AiOutput.abstained` and + * `AiOutput.reviewStatus` (kept in sync with each output's `AiReview` by + * the store — see ai-gateway-store.ts's own doc comment on that field). + * + * Deliberately NOT built: shadow traffic, canary rollout, or automatic + * promotion/rollback — those need a request-routing layer this codebase + * doesn't have (ai-gateway/provider-client.ts's own doc comment on + * production-shaped-but-untested inference clients is the relevant + * precedent for why that's a separate, larger piece of work, not bundled + * in here). This module only observes and reports; it never changes what + * traffic a model receives. See docs/CLINICAL_AI_EVALUATION.md. + */ + +export interface ProductionQualitySnapshot { + providerModelId: string; + /** Open lower bound this snapshot's outputs were selected after + * (exclusive), or undefined for "since the beginning of this tenant's + * history with this model." */ + windowStart?: string; + /** Open upper bound (inclusive `<=`), or undefined for "through now." */ + windowEnd?: string; + outputCount: number; + /** Of all outputs in the window — not just reviewed ones. A model that + * abstains constantly is a real operational signal even before any + * clinician has reviewed anything. */ + abstentionRate: number; + /** Fraction of outputs that have received ANY clinician decision yet — + * low values on an old-enough window are themselves a signal (a + * review backlog, or a workflow nobody is actually using). */ + reviewedRate: number; + unreviewedCount: number; + /** The four rates below are of REVIEWED outputs only (denominator is + * `outputCount - unreviewedCount`), since "what fraction of decided + * cases were accepted" is the meaningful question — diluting it by + * not-yet-reviewed outputs would make a simple review backlog look + * like a quality drop. All four are 0 when nothing has been reviewed + * yet, never a divide-by-zero NaN. */ + acceptanceRate: number; + rejectionRate: number; + correctionRate: number; + escalationRate: number; +} + +function safeRatio(numerator: number, denominator: number): number { + return denominator === 0 ? 0 : numerator / denominator; +} + +function snapshotFrom(providerModelId: string, outputs: AiOutput[], windowStart: string | undefined, windowEnd: string | undefined): ProductionQualitySnapshot { + const outputCount = outputs.length; + const abstained = outputs.filter((o) => o.abstained).length; + const reviewed = outputs.filter((o) => o.reviewStatus !== "unreviewed"); + const decisionCount = (decision: AiOutput["reviewStatus"]) => outputs.filter((o) => o.reviewStatus === decision).length; + return { + providerModelId, + windowStart, + windowEnd, + outputCount, + abstentionRate: safeRatio(abstained, outputCount), + reviewedRate: safeRatio(reviewed.length, outputCount), + unreviewedCount: outputCount - reviewed.length, + acceptanceRate: safeRatio(decisionCount("accepted"), reviewed.length), + rejectionRate: safeRatio(decisionCount("rejected"), reviewed.length), + correctionRate: safeRatio(decisionCount("corrected"), reviewed.length), + escalationRate: safeRatio(decisionCount("escalated"), reviewed.length), + }; +} + +/** + * A single window's snapshot — `since` is an open lower bound + * (`generatedAt > since`), matching listOutputsForProviderModel's own + * convention. Undefined means the whole tenant history for this model. + */ +export async function computeProductionQualitySnapshot(repo: TenantAiGatewayRepository, providerModelId: string, since?: string): Promise { + const outputs = await repo.listOutputsForProviderModel(providerModelId, since); + return snapshotFrom(providerModelId, outputs, since, undefined); +} + +export interface DriftThresholds { + /** A rise in abstentionRate greater than this (current − baseline) alerts. */ + maxAbstentionRateIncrease: number; + /** A drop in acceptanceRate greater than this (baseline − current) alerts. */ + maxAcceptanceRateDrop: number; + /** A rise in rejectionRate greater than this alerts. */ + maxRejectionRateIncrease: number; + /** A rise in escalationRate greater than this alerts — clinicians + * escalating more often is one of the more safety-relevant signals + * this can catch. */ + maxEscalationRateIncrease: number; + /** Neither snapshot's outputCount may fall below this for a comparison + * to be considered statistically meaningful at all — below it, drift + * is reported as `insufficientData`, never a false alarm (or false + * reassurance) from a handful of outputs. */ + minimumOutputCount: number; +} + +export const DEFAULT_DRIFT_THRESHOLDS: DriftThresholds = { + maxAbstentionRateIncrease: 0.15, + maxAcceptanceRateDrop: 0.15, + maxRejectionRateIncrease: 0.15, + maxEscalationRateIncrease: 0.1, + minimumOutputCount: 20, +}; + +export interface DriftReport { + baseline: ProductionQualitySnapshot; + current: ProductionQualitySnapshot; + /** True only when both windows clear `minimumOutputCount` — a + * necessary condition for `drifted` to mean anything. */ + sufficientData: boolean; + drifted: boolean; + alerts: string[]; +} + +/** + * Compares two disjoint time windows for the same provider model — + * `baselineSince` is the older window's open lower bound, `splitAt` is + * where "baseline" ends and "current" begins (baseline: `generatedAt` in + * `(baselineSince, splitAt]`; current: `generatedAt > splitAt`). One store + * call (from `baselineSince` onward), partitioned client-side at `splitAt` + * — avoids needing a second bounded-range store method for what is, so + * far, this module's only caller of that shape. + */ +export async function detectProductionQualityDrift( + repo: TenantAiGatewayRepository, + providerModelId: string, + baselineSince: string | undefined, + splitAt: string, + thresholds: DriftThresholds = DEFAULT_DRIFT_THRESHOLDS +): Promise { + const outputs = await repo.listOutputsForProviderModel(providerModelId, baselineSince); + const baselineOutputs = outputs.filter((o) => o.generatedAt <= splitAt); + const currentOutputs = outputs.filter((o) => o.generatedAt > splitAt); + const baseline = snapshotFrom(providerModelId, baselineOutputs, baselineSince, splitAt); + const current = snapshotFrom(providerModelId, currentOutputs, splitAt, undefined); + + const sufficientData = baseline.outputCount >= thresholds.minimumOutputCount && current.outputCount >= thresholds.minimumOutputCount; + const alerts: string[] = []; + if (sufficientData) { + const abstentionRise = current.abstentionRate - baseline.abstentionRate; + if (abstentionRise > thresholds.maxAbstentionRateIncrease) alerts.push(`abstention rate rose ${(abstentionRise * 100).toFixed(1)} points (${(baseline.abstentionRate * 100).toFixed(1)}% -> ${(current.abstentionRate * 100).toFixed(1)}%)`); + const acceptanceDrop = baseline.acceptanceRate - current.acceptanceRate; + if (acceptanceDrop > thresholds.maxAcceptanceRateDrop) alerts.push(`acceptance rate dropped ${(acceptanceDrop * 100).toFixed(1)} points (${(baseline.acceptanceRate * 100).toFixed(1)}% -> ${(current.acceptanceRate * 100).toFixed(1)}%)`); + const rejectionRise = current.rejectionRate - baseline.rejectionRate; + if (rejectionRise > thresholds.maxRejectionRateIncrease) alerts.push(`rejection rate rose ${(rejectionRise * 100).toFixed(1)} points (${(baseline.rejectionRate * 100).toFixed(1)}% -> ${(current.rejectionRate * 100).toFixed(1)}%)`); + const escalationRise = current.escalationRate - baseline.escalationRate; + if (escalationRise > thresholds.maxEscalationRateIncrease) alerts.push(`escalation rate rose ${(escalationRise * 100).toFixed(1)} points (${(baseline.escalationRate * 100).toFixed(1)}% -> ${(current.escalationRate * 100).toFixed(1)}%)`); + } + + return { baseline, current, sufficientData, drifted: sufficientData && alerts.length > 0, alerts }; +} diff --git a/server/src/fhir/capability-statement.ts b/server/src/fhir/capability-statement.ts new file mode 100644 index 0000000..7492acf --- /dev/null +++ b/server/src/fhir/capability-statement.ts @@ -0,0 +1,32 @@ +import type { FhirCapabilityStatement } from "@modelforge/contracts"; +import { fhirCapabilityStatementSchema } from "@modelforge/contracts"; + +/** + * Advertises exactly the interactions routes/fhir.ts actually implements — + * `read` on Patient/DiagnosticReport/ImagingStudy, `search-type` on + * DocumentReference only (its route has no by-id read, see that file). A + * real SMART-on-FHIR client is expected to call this before doing anything + * else; keeping it honest (never advertising an interaction that 404s) is + * the whole point of publishing it at all. + */ +export function buildCapabilityStatement(): FhirCapabilityStatement { + return fhirCapabilityStatementSchema.parse({ + resourceType: "CapabilityStatement", + status: "active", + date: new Date().toISOString(), + kind: "instance", + fhirVersion: "4.0.1", + format: ["json"], + rest: [ + { + mode: "server", + resource: [ + { type: "Patient", interaction: [{ code: "read" }] }, + { type: "DiagnosticReport", interaction: [{ code: "read" }] }, + { type: "ImagingStudy", interaction: [{ code: "read" }] }, + { type: "DocumentReference", interaction: [{ code: "search-type" }] }, + ], + }, + ], + } satisfies FhirCapabilityStatement); +} diff --git a/server/src/fhir/mappers.test.ts b/server/src/fhir/mappers.test.ts new file mode 100644 index 0000000..700c4cc --- /dev/null +++ b/server/src/fhir/mappers.test.ts @@ -0,0 +1,135 @@ +import { diagnosticReportSchema, documentReferenceSchema, imagingStudySchema } from "@modelforge/contracts"; +import { describe, expect, it } from "vitest"; +import type { ImagingSeriesRecord } from "../store/imaging-store.js"; +import { patientCaseFixture } from "../test/patient-case-fixture.js"; +import { fhirBundle, fhirNotFound, mapSexToFhirGender, toFhirDiagnosticReport, toFhirDocumentReference, toFhirImagingStudy, toFhirPatient } from "./mappers.js"; + +const SHA256_HEX = "a".repeat(64); + +describe("fhir mappers", () => { + describe("mapSexToFhirGender", () => { + it.each([ + ["male", "male"], + ["Male", "male"], + ["m", "male"], + ["female", "female"], + ["F", "female"], + [undefined, "unknown"], + ["", "unknown"], + ["nonbinary", "other"], + ] as const)("maps %s to %s", (input, expected) => { + expect(mapSexToFhirGender(input)).toBe(expected); + }); + }); + + it("toFhirPatient carries identifier, gender, and a reported-age extension, but never fabricates name/birthDate", () => { + const patientCase = patientCaseFixture("case-1", { demographics: { value: { age: "42", sex: "female" }, includeInContext: false } }); + const patient = toFhirPatient(patientCase, "MRN-001"); + expect(patient).toEqual({ + resourceType: "Patient", + id: "MRN-001", + meta: { lastUpdated: patientCase.updatedAt }, + active: true, + identifier: [{ system: "urn:modelforge:patientId", value: "MRN-001" }], + gender: "female", + extension: [{ url: "urn:modelforge:extension:reportedAge", valueString: "42" }], + }); + expect(patient).not.toHaveProperty("name"); + expect(patient).not.toHaveProperty("birthDate"); + }); + + it("toFhirPatient omits the extension entirely when no age was recorded", () => { + const patientCase = patientCaseFixture("case-2"); + const patient = toFhirPatient(patientCase, "MRN-002"); + expect(patient.extension).toBeUndefined(); + }); + + it("toFhirDiagnosticReport maps status/conclusion and references the source ImagingStudy and Patient", () => { + const study = imagingStudySchema.parse({ + id: "study-1", + studyInstanceUid: "1.2.3.4", + patientIdentifier: { value: "MRN-001", issuer: "TEST-HOSPITAL" }, + modalities: ["CT"], + numberOfSeries: 1, + numberOfInstances: 1, + status: "available", + sensitivity: "normal", + ingestionStatus: "published", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }); + const report = diagnosticReportSchema.parse({ + id: "report-1", + studyId: "study-1", + status: "final", + conclusion: "No acute findings.", + authorUserId: "user-1", + authoredAt: "2026-01-02T00:00:00.000Z", + signedByUserId: "user-1", + signedAt: "2026-01-02T01:00:00.000Z", + isCritical: false, + createdAt: "2026-01-02T00:00:00.000Z", + updatedAt: "2026-01-02T01:00:00.000Z", + }); + const fhirReport = toFhirDiagnosticReport(report, study); + expect(fhirReport.status).toBe("final"); + expect(fhirReport.conclusion).toBe("No acute findings."); + expect(fhirReport.subject).toEqual({ reference: "Patient/MRN-001" }); + expect(fhirReport.imagingStudy).toEqual([{ reference: "ImagingStudy/study-1" }]); + expect(fhirReport.issued).toBe("2026-01-02T01:00:00.000Z"); + }); + + it("toFhirImagingStudy maps modalities to DICOM codings and embeds series", () => { + const study = imagingStudySchema.parse({ + id: "study-2", + studyInstanceUid: "1.2.3.5", + patientIdentifier: { value: "MRN-002", issuer: "TEST-HOSPITAL" }, + modalities: ["MR"], + description: "Brain MRI", + studyDate: "2026-02-01", + numberOfSeries: 1, + numberOfInstances: 3, + status: "available", + sensitivity: "normal", + ingestionStatus: "published", + createdAt: "2026-02-01T00:00:00.000Z", + updatedAt: "2026-02-01T00:00:00.000Z", + }); + const series: ImagingSeriesRecord[] = [ + { id: "series-1", studyId: "study-2", seriesInstanceUid: "1.2.3.5.1", seriesNumber: "1", modality: "MR", numberOfInstances: 3, createdAt: study.createdAt, updatedAt: study.updatedAt }, + ]; + const fhirStudy = toFhirImagingStudy(study, series); + expect(fhirStudy.status).toBe("available"); + expect(fhirStudy.modality).toEqual([{ system: "urn:oid:1.2.840.10008.2.16.4", code: "MR" }]); + expect(fhirStudy.series).toEqual([{ uid: "1.2.3.5.1", number: 1, modality: { system: "urn:oid:1.2.840.10008.2.16.4", code: "MR" }, description: undefined, numberOfInstances: 3 }]); + expect(fhirStudy.started).toBe("2026-02-01T00:00:00Z"); + }); + + it("toFhirDocumentReference is always status current, since this system tracks no other document lifecycle state", () => { + const doc = documentReferenceSchema.parse({ + id: "doc-1", + title: "Referral letter", + contentType: "application/pdf", + sizeBytes: 1024, + checksumSha256: SHA256_HEX, + authorUserId: "user-1", + createdAt: "2026-01-01T00:00:00.000Z", + }); + const fhirDoc = toFhirDocumentReference(doc); + expect(fhirDoc.status).toBe("current"); + expect(fhirDoc.content).toEqual([{ attachment: { contentType: "application/pdf", size: 1024, hash: SHA256_HEX, title: "Referral letter" } }]); + }); + + it("fhirNotFound and fhirBundle produce spec-shaped envelopes", () => { + expect(fhirNotFound("Patient", "abc")).toEqual({ + resourceType: "OperationOutcome", + issue: [{ severity: "error", code: "not-found", diagnostics: "Patient/abc was not found or is not accessible." }], + }); + expect(fhirBundle([{ resourceType: "DocumentReference" }])).toEqual({ + resourceType: "Bundle", + type: "searchset", + total: 1, + entry: [{ resource: { resourceType: "DocumentReference" } }], + }); + }); +}); diff --git a/server/src/fhir/mappers.ts b/server/src/fhir/mappers.ts new file mode 100644 index 0000000..7de6adc --- /dev/null +++ b/server/src/fhir/mappers.ts @@ -0,0 +1,133 @@ +import type { + DiagnosticReport, + DocumentReference, + FhirBundle, + FhirDiagnosticReport, + FhirDocumentReference, + FhirImagingStudy, + FhirOperationOutcome, + FhirPatient, + ImagingStudy, + PatientCase, +} from "@modelforge/contracts"; +import { fhirBundleSchema, fhirDiagnosticReportSchema, fhirDocumentReferenceSchema, fhirImagingStudySchema, fhirOperationOutcomeSchema, fhirPatientSchema } from "@modelforge/contracts"; +import type { ImagingSeriesRecord } from "../store/imaging-store.js"; + +/** + * Pure PatientCase/ImagingStudy/DiagnosticReport/DocumentReference -> FHIR R4 + * JSON mappers. No I/O, no authorization — routes/fhir.ts owns both of + * those; this module only ever transforms already-authorized, already- + * fetched domain objects. See @modelforge/contracts's fhir.ts for the + * schemas and this file's overall scope statement. + * + * Every `toFhir*` function ends with `.parse(...)` against the matching + * schema — deliberately, so a future field added to a FHIR schema without a + * matching mapper update fails loudly in tests (a missing required field) + * rather than silently shipping an incomplete resource. + */ + +const DICOM_MODALITY_SYSTEM = "urn:oid:1.2.840.10008.2.16.4" as const; // DICOM Ontology (DCM) CID 29 code system + +type FhirAdministrativeGender = "male" | "female" | "other" | "unknown"; + +/** + * Best-effort mapping from this system's free-text `demographics.sex` case + * field onto FHIR's coded `Patient.gender` (administrative gender, not a + * clinical assertion). A value this doesn't recognize maps to "unknown" + * rather than being guessed at or omitted-as-error — FHIR's own definition + * of "unknown" is exactly "The gender is not known" ,which a free-text field + * this system never validated at entry time genuinely can be. + */ +export function mapSexToFhirGender(sex: string | undefined): FhirAdministrativeGender { + if (!sex) return "unknown"; + const normalized = sex.trim().toLowerCase(); + if (["male", "m"].includes(normalized)) return "male"; + if (["female", "f"].includes(normalized)) return "female"; + if (normalized.length === 0) return "unknown"; + return "other"; +} + +export function toFhirPatient(patientCase: PatientCase, resourcePatientId: string): FhirPatient { + const age = patientCase.demographics.value.age; + return fhirPatientSchema.parse({ + resourceType: "Patient", + id: resourcePatientId, + meta: { lastUpdated: patientCase.updatedAt }, + active: true, + identifier: [{ system: "urn:modelforge:patientId", value: resourcePatientId }], + gender: mapSexToFhirGender(patientCase.demographics.value.sex), + extension: age ? [{ url: "urn:modelforge:extension:reportedAge", valueString: age }] : undefined, + } satisfies FhirPatient); +} + +const DIAGNOSTIC_REPORT_CODE_TEXT = "Diagnostic imaging report"; + +export function toFhirDiagnosticReport(report: DiagnosticReport, study: ImagingStudy): FhirDiagnosticReport { + return fhirDiagnosticReportSchema.parse({ + resourceType: "DiagnosticReport", + id: report.id, + meta: { lastUpdated: report.updatedAt }, + status: report.status, + code: { text: DIAGNOSTIC_REPORT_CODE_TEXT }, + subject: { reference: `Patient/${study.patientIdentifier.value}` }, + issued: report.signedAt ?? report.authoredAt, + effectiveDateTime: report.authoredAt, + conclusion: report.conclusion, + conclusionCode: report.conclusionCode ? [{ text: report.conclusionCode }] : undefined, + imagingStudy: [{ reference: `ImagingStudy/${study.id}` }], + } satisfies FhirDiagnosticReport); +} + +export function toFhirImagingStudy(study: ImagingStudy, series: ImagingSeriesRecord[]): FhirImagingStudy { + return fhirImagingStudySchema.parse({ + resourceType: "ImagingStudy", + id: study.id, + meta: { lastUpdated: study.updatedAt }, + status: study.status, + identifier: [{ system: "urn:dicom:uid", value: `urn:oid:${study.studyInstanceUid}` }], + modality: study.modalities.map((code) => ({ system: DICOM_MODALITY_SYSTEM, code })), + subject: { reference: `Patient/${study.patientIdentifier.value}` }, + started: study.studyDate ? `${study.studyDate}T${(study.studyTime ?? "00:00:00").padEnd(8, "0").slice(0, 8)}Z` : undefined, + numberOfSeries: study.numberOfSeries, + numberOfInstances: study.numberOfInstances, + description: study.description, + series: series.map((s) => ({ + uid: s.seriesInstanceUid, + number: s.seriesNumber ? Number(s.seriesNumber) : undefined, + modality: { system: DICOM_MODALITY_SYSTEM, code: s.modality }, + description: s.description, + numberOfInstances: s.numberOfInstances, + })), + } satisfies FhirImagingStudy); +} + +/** + * Always `status: "current"` — see @modelforge/contracts's fhir.ts doc + * comment on fhirDocumentReferenceStatusSchema for why this system has no + * superseded/entered-in-error lifecycle to map from. + */ +export function toFhirDocumentReference(doc: DocumentReference): FhirDocumentReference { + return fhirDocumentReferenceSchema.parse({ + resourceType: "DocumentReference", + id: doc.id, + status: "current", + date: doc.createdAt, + content: [{ attachment: { contentType: doc.contentType, size: doc.sizeBytes, hash: doc.checksumSha256, title: doc.title } }], + } satisfies FhirDocumentReference); +} + +export function fhirNotFound(resourceType: string, id: string): FhirOperationOutcome { + return fhirOperationOutcomeSchema.parse({ + resourceType: "OperationOutcome", + issue: [{ severity: "error", code: "not-found", diagnostics: `${resourceType}/${id} was not found or is not accessible.` }], + } satisfies FhirOperationOutcome); +} + +export function fhirBundle(resources: unknown[]): FhirBundle { + return fhirBundleSchema.parse({ + resourceType: "Bundle", + type: "searchset", + total: resources.length, + entry: resources.map((resource) => ({ resource })), + } satisfies FhirBundle); +} diff --git a/server/src/fhir/smart-configuration.ts b/server/src/fhir/smart-configuration.ts new file mode 100644 index 0000000..a7ed11e --- /dev/null +++ b/server/src/fhir/smart-configuration.ts @@ -0,0 +1,24 @@ +import type { FhirSmartConfiguration } from "@modelforge/contracts"; +import { fhirSmartConfigurationSchema } from "@modelforge/contracts"; +import type { AuthorizationServerMetadata } from "../auth/oidc-verifier.js"; + +/** + * Builds this server's `.well-known/smart-configuration` document from the + * external IdP's own discovered endpoints (auth/oidc-verifier.ts's + * resolveAuthorizationServerMetadata). `capabilities` only lists what this + * resource server actually enforces — see routes/fhir.ts's + * enforceSmartLaunchContext for what `context-ehr-patient` and + * `permission-patient` mean here concretely (a token carrying a `patient` + * launch-context claim is confined to that patient's data). + */ +export function buildSmartConfiguration(metadata: AuthorizationServerMetadata): FhirSmartConfiguration { + return fhirSmartConfigurationSchema.parse({ + issuer: metadata.issuer, + authorization_endpoint: metadata.authorizationEndpoint, + token_endpoint: metadata.tokenEndpoint, + capabilities: ["launch-ehr", "launch-standalone", "client-public", "client-confidential-symmetric", "sso-openid-connect", "context-ehr-patient", "permission-patient", "permission-v2"], + code_challenge_methods_supported: ["S256"], + grant_types_supported: ["authorization_code"], + scopes_supported: ["openid", "fhirUser", "launch", "launch/patient", "patient/*.read", "offline_access"], + } satisfies FhirSmartConfiguration); +} diff --git a/server/src/fhir/smart-scopes.test.ts b/server/src/fhir/smart-scopes.test.ts new file mode 100644 index 0000000..e21105b --- /dev/null +++ b/server/src/fhir/smart-scopes.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { deniedBySmartLaunchContext, resolveSmartLaunchContext } from "./smart-scopes.js"; + +describe("resolveSmartLaunchContext", () => { + it("returns undefined for a plain OIDC token with no scope claim at all — existing IAM auth is unaffected", () => { + expect(resolveSmartLaunchContext({ sub: "idp|clinician-1" })).toBeUndefined(); + }); + + it("returns undefined when scope has no patient/-prefixed grant, even with a patient claim present", () => { + expect(resolveSmartLaunchContext({ scope: "openid fhirUser", patient: "MRN-001" })).toBeUndefined(); + }); + + it("returns undefined when a patient-scoped grant is present but there is no patient launch-context claim", () => { + expect(resolveSmartLaunchContext({ scope: "patient/*.read" })).toBeUndefined(); + }); + + it("returns a launch context confined to the patient claim when both a patient-scoped grant and a patient claim are present", () => { + expect(resolveSmartLaunchContext({ scope: "openid launch patient/*.read", patient: "MRN-001" })).toEqual({ confinedToPatientId: "MRN-001" }); + }); + + it("recognizes a resource-specific patient scope (patient/ImagingStudy.read), not just the wildcard", () => { + expect(resolveSmartLaunchContext({ scope: "patient/ImagingStudy.read", patient: "MRN-002" })).toEqual({ confinedToPatientId: "MRN-002" }); + }); +}); + +describe("deniedBySmartLaunchContext", () => { + it("is never denied when there is no launch context (plain OIDC token)", () => { + expect(deniedBySmartLaunchContext(undefined, "MRN-001")).toBe(false); + }); + + it("is not denied when the resource's patient matches the launch context", () => { + expect(deniedBySmartLaunchContext({ confinedToPatientId: "MRN-001" }, "MRN-001")).toBe(false); + }); + + it("is denied when the resource's patient does not match the launch context", () => { + expect(deniedBySmartLaunchContext({ confinedToPatientId: "MRN-001" }, "MRN-999")).toBe(true); + }); +}); diff --git a/server/src/fhir/smart-scopes.ts b/server/src/fhir/smart-scopes.ts new file mode 100644 index 0000000..1d6c771 --- /dev/null +++ b/server/src/fhir/smart-scopes.ts @@ -0,0 +1,49 @@ +import type { JWTPayload } from "jose"; + +/** + * SMART App Launch scope/launch-context handling for the resource-server + * side (routes/fhir.ts) — the piece of "SMART on FHIR OAuth" this server is + * actually responsible for, since it delegates real token issuance to the + * external IdP (see auth/oidc-verifier.ts's top doc comment). A SMART + * launch conveys two things on the access token this server already + * verifies: a space-delimited `scope` claim (OAuth2 standard) that may + * include `patient/*.read`-shaped SMART clinical scopes, and (per SMART's + * launch context convention, which several IdPs — Cerner/Oracle Health, + * Epic — put directly on the access token rather than a separate response + * field) a `patient` claim naming which patient the launch was scoped to. + * + * This is deliberately conservative: it only activates when *both* signals + * are present. A plain OIDC bearer token with no SMART scope (the only kind + * this API issued before this file existed) is completely unaffected — + * existing IAM authorization (routes/guards.ts) remains the only gate, same + * as every other route in this API. See docs/FHIR_INTEGRATION.md's SMART + * section for what this does not implement (this server does not itself + * validate a scope against what the IdP was actually authorized to grant — + * that trust is already placed in the IdP by virtue of accepting its + * signed token at all, same as every claim on it). + */ +export interface SmartLaunchContext { + /** The `patient` claim value — every FHIR read this request makes must + * resolve to this same patient, or be denied. */ + readonly confinedToPatientId: string; +} + +const PATIENT_SCOPE_PREFIX = "patient/"; + +export function resolveSmartLaunchContext(claims: JWTPayload): SmartLaunchContext | undefined { + const scopeClaim = claims.scope; + if (typeof scopeClaim !== "string") return undefined; + const hasPatientScopedGrant = scopeClaim.split(/\s+/).some((scope) => scope.startsWith(PATIENT_SCOPE_PREFIX)); + if (!hasPatientScopedGrant) return undefined; + + const patientClaim = claims.patient; + if (typeof patientClaim !== "string" || patientClaim.length === 0) return undefined; + return { confinedToPatientId: patientClaim }; +} + +/** True if a launch context is active and confines the caller to a + * *different* patient than `patientId` — the one thing every FHIR read + * route needs to check after its normal IAM authorization passes. */ +export function deniedBySmartLaunchContext(launchContext: SmartLaunchContext | undefined, patientId: string): boolean { + return launchContext !== undefined && launchContext.confinedToPatientId !== patientId; +} diff --git a/server/src/hl7/ack-builder.test.ts b/server/src/hl7/ack-builder.test.ts new file mode 100644 index 0000000..24cf5d2 --- /dev/null +++ b/server/src/hl7/ack-builder.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { buildAck } from "./ack-builder.js"; +import { getField, parseHl7Message } from "./message.js"; + +const ORIGINAL = parseHl7Message(["MSH|^~\\&|LAB|HOSPITAL|EHR|HOSPITAL|20260315120000||ORU^R01|MSG00001|P|2.5.1", "PID|1||MRN-001"].join("\r")); + +describe("buildAck", () => { + it("builds a parseable ACK naming the original message's control id in MSA-2", () => { + const raw = buildAck(ORIGINAL, "AA", { sendingApplication: "ModelForge", sendingFacility: "Example Health System", messageControlId: "ACK001", now: new Date("2026-03-15T12:00:05Z") }); + const ack = parseHl7Message(raw); + expect(ack.segments.map((s) => s.id)).toEqual(["MSH", "MSA"]); + const msh = ack.segments[0]; + expect(getField(msh, 9)).toBe("ACK"); + expect(getField(msh, 10)).toBe("ACK001"); + const msa = ack.segments[1]; + expect(getField(msa, 1)).toBe("AA"); + expect(getField(msa, 2)).toBe("MSG00001"); + }); + + it("swaps sending/receiving application-facility relative to the original message", () => { + const raw = buildAck(ORIGINAL, "AA", { sendingApplication: "ModelForge", sendingFacility: "Example Health System" }); + const msh = parseHl7Message(raw).segments[0]; + expect(getField(msh, 3)).toBe("ModelForge"); + expect(getField(msh, 4)).toBe("Example Health System"); + // The ACK is addressed back to whoever sent the original. + expect(getField(msh, 5)).toBe("LAB"); + expect(getField(msh, 6)).toBe("HOSPITAL"); + }); + + it("includes MSA-3 details only when supplied", () => { + const withDetails = parseHl7Message(buildAck(ORIGINAL, "AE", { sendingApplication: "ModelForge", sendingFacility: "Example" }, "no matching patient")); + expect(getField(withDetails.segments[1], 3)).toBe("no matching patient"); + + const withoutDetails = parseHl7Message(buildAck(ORIGINAL, "AA", { sendingApplication: "ModelForge", sendingFacility: "Example" })); + expect(getField(withoutDetails.segments[1], 3)).toBe(""); + }); + + it("generates a random, non-empty messageControlId when none is supplied", () => { + const raw = buildAck(ORIGINAL, "AA", { sendingApplication: "ModelForge", sendingFacility: "Example" }); + expect(getField(parseHl7Message(raw).segments[0], 10).length).toBeGreaterThan(0); + }); + + it("handles an original message with no MSH gracefully (AR — reject — is exactly this case)", () => { + const malformed = { segments: [], encoding: { field: "|", component: "^", repetition: "~", escape: "\\", subcomponent: "&" } }; + const raw = buildAck(malformed, "AR", { sendingApplication: "ModelForge", sendingFacility: "Example" }, "malformed message"); + const ack = parseHl7Message(raw); + expect(getField(ack.segments[1], 1)).toBe("AR"); + expect(getField(ack.segments[1], 2)).toBe(""); + }); +}); diff --git a/server/src/hl7/ack-builder.ts b/server/src/hl7/ack-builder.ts new file mode 100644 index 0000000..90c58a5 --- /dev/null +++ b/server/src/hl7/ack-builder.ts @@ -0,0 +1,66 @@ +import { buildHl7Message, buildSegment, getField, type Hl7Message, type Hl7EncodingCharacters, DEFAULT_ENCODING_CHARACTERS } from "./message.js"; + +/** + * HL7 v2's own general acknowledgment (ACK) message — every real HL7 v2 + * receiver (this codebase's mllp-server.ts included) is expected to send + * one back for every message it receives, per the standard's own + * "original mode acknowledgment" pattern: an MSH (mirroring the sender's + * own encoding characters and swapping sending/receiving application- + * facility) plus an MSA segment naming the acknowledgment code and the + * original message's control id. + */ +export type Hl7AckCode = "AA" | "AE" | "AR"; // Application Accept / Error / Reject — HL7 v2 table 0008 + +export interface AckContext { + sendingApplication: string; + sendingFacility: string; + /** Defaults to a freshly-generated control id — pass an explicit one + * only for deterministic tests. */ + messageControlId?: string; + now?: Date; +} + +function hl7Timestamp(date: Date): string { + const pad = (n: number, width = 2) => String(n).padStart(width, "0"); + return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`; +} + +function randomMessageControlId(): string { + return `MF${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`; +} + +/** + * Builds an ACK in response to `original` — `code` "AA" (accepted), "AE" + * (error — the message was understood but couldn't be processed, e.g. a + * failed patient match), or "AR" (reject — the message itself is + * malformed/unsupported). `details` becomes MSA-3 (a short human-readable + * reason), never a stack trace or anything PHI-bearing — same "no + * PHI-bearing failure detail" discipline as imaging/ingestion.ts's own + * `failureCategory` (a closed category, never free text derived from + * message content). + */ +export function buildAck(original: Hl7Message, code: Hl7AckCode, context: AckContext, details?: string): string { + const originalMsh = original.segments.find((s) => s.id === "MSH"); + const encoding: Hl7EncodingCharacters = original.encoding ?? DEFAULT_ENCODING_CHARACTERS; + const originalControlId = originalMsh ? getField(originalMsh, 10) : ""; + const now = context.now ?? new Date(); + + const msh = buildSegment("MSH", { + 1: encoding.field, + 2: `${encoding.component}${encoding.repetition}${encoding.escape}${encoding.subcomponent}`, + 3: context.sendingApplication, + 4: context.sendingFacility, + // Reply to whoever sent the original — MSH-3/4 of the inbound + // message become MSH-5/6 of this ACK. + 5: originalMsh ? getField(originalMsh, 3) : "", + 6: originalMsh ? getField(originalMsh, 4) : "", + 7: hl7Timestamp(now), + 9: "ACK", + 10: context.messageControlId ?? randomMessageControlId(), + 11: "P", + 12: "2.5.1", + }); + const msa = buildSegment("MSA", details ? { 1: code, 2: originalControlId, 3: details } : { 1: code, 2: originalControlId }); + + return buildHl7Message({ encoding, segments: [msh, msa] }); +} diff --git a/server/src/hl7/adt-parser.test.ts b/server/src/hl7/adt-parser.test.ts new file mode 100644 index 0000000..3839e26 --- /dev/null +++ b/server/src/hl7/adt-parser.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { parseAdtMessage } from "./adt-parser.js"; +import { Hl7ParseError } from "./message.js"; + +const SAMPLE_ADT_A01 = [ + "MSH|^~\\&|EHR|HOSPITAL|MODELFORGE|MODELFORGE|20260315120000||ADT^A01|MSG00001|P|2.5.1", + "PID|1||MRN-001^^^TEST-HOSPITAL||", + "PV1|1|I", +].join("\r"); + +describe("parseAdtMessage", () => { + it("parses message control id, trigger event, and patient identifier", () => { + const parsed = parseAdtMessage(SAMPLE_ADT_A01); + expect(parsed.messageControlId).toBe("MSG00001"); + expect(parsed.triggerEvent).toBe("A01"); + expect(parsed.patientIdentifier).toEqual({ value: "MRN-001", issuer: "TEST-HOSPITAL" }); + }); + + it("accepts any ADT trigger event uniformly, not just A01", () => { + expect(parseAdtMessage(SAMPLE_ADT_A01.replace("ADT^A01", "ADT^A08")).triggerEvent).toBe("A08"); + expect(parseAdtMessage(SAMPLE_ADT_A01.replace("ADT^A01", "ADT^A28")).triggerEvent).toBe("A28"); + }); + + it("returns undefined patientIdentifier for a message with no PID segment, rather than throwing", () => { + const noPid = SAMPLE_ADT_A01.split("\r").filter((line) => !line.startsWith("PID")).join("\r"); + expect(parseAdtMessage(noPid).patientIdentifier).toBeUndefined(); + }); + + it("rejects a non-ADT message type with Hl7ParseError, never silently parsing it as one", () => { + const oru = SAMPLE_ADT_A01.replace("ADT^A01", "ORU^R01"); + expect(() => parseAdtMessage(oru)).toThrow(Hl7ParseError); + expect(() => parseAdtMessage(oru)).toThrow(/Expected an ADT message type/); + }); +}); diff --git a/server/src/hl7/adt-parser.ts b/server/src/hl7/adt-parser.ts new file mode 100644 index 0000000..2818d6c --- /dev/null +++ b/server/src/hl7/adt-parser.ts @@ -0,0 +1,46 @@ +import { getField, Hl7ParseError, parseHl7Message, splitComponents, unescapeHl7Text } from "./message.js"; + +/** + * Inbound ADT (admit/discharge/transfer) parsing — the second HL7 v2 + * message type this codebase understands, alongside oru-builder.ts/ + * inbound-parser.ts's ORU^R01. ADT is the standard way an EHR notifies + * downstream systems of patient admit/register/transfer/update events + * (A01/A04/A08/A28/... — HL7 v2 table 0003's full trigger-event list; this + * parser accepts any of them uniformly rather than special-casing each, + * since every trigger event shares the same PID-based identity payload + * this codebase actually uses). + * + * Same scope discipline as inbound-parser.ts's parseOruR01: parsing only. + * This never looks up, matches, or writes a PatientCase — see + * docs/HL7_V2_INTEGRATION.md and hl7/ingestion.ts (the shared match/persist + * pipeline both ORU and ADT feed into) for where that actually happens, + * deliberately kept separate from parsing itself. + */ +export interface ParsedAdtMessage { + messageControlId: string; + /** The trigger event, e.g. "A01" (admit), "A08" (update) — read from + * MSH-9.2, not validated against table 0003's closed list (an EHR + * sending a trigger event this parser doesn't specifically know about + * is still parsed the same way; only the message TYPE, MSH-9.1, must + * be "ADT"). */ + triggerEvent: string; + patientIdentifier?: { value: string; issuer: string }; +} + +export function parseAdtMessage(raw: string): ParsedAdtMessage { + const message = parseHl7Message(raw); + const msh = message.segments.find((s) => s.id === "MSH"); + if (!msh) throw new Hl7ParseError("Message has no MSH segment."); + const messageTypeField = getField(msh, 9); + const [messageType, triggerEvent = ""] = splitComponents(messageTypeField, message.encoding); + if (messageType !== "ADT") throw new Hl7ParseError(`Expected an ADT message type, got "${messageTypeField || "(empty)"}".`); + + const pid = message.segments.find((s) => s.id === "PID"); + const pid3 = pid ? getField(pid, 3) : ""; + const pid3Components = pid3 ? splitComponents(pid3, message.encoding) : []; + const value = pid3Components[0] ? unescapeHl7Text(pid3Components[0], message.encoding) : ""; + const issuer = pid3Components[3] ? unescapeHl7Text(pid3Components[3], message.encoding) : ""; + const patientIdentifier = value ? { value, issuer } : undefined; + + return { messageControlId: getField(msh, 10), triggerEvent, patientIdentifier }; +} diff --git a/server/src/hl7/inbound-parser.test.ts b/server/src/hl7/inbound-parser.test.ts new file mode 100644 index 0000000..f3350c2 --- /dev/null +++ b/server/src/hl7/inbound-parser.test.ts @@ -0,0 +1,77 @@ +import { diagnosticReportSchema, imagingStudySchema } from "@modelforge/contracts"; +import { describe, expect, it } from "vitest"; +import { Hl7ParseError } from "./message.js"; +import { parseOruR01 } from "./inbound-parser.js"; +import { buildOruR01 } from "./oru-builder.js"; + +const SAMPLE_ORU = [ + "MSH|^~\\&|LAB|HOSPITAL|EHR|HOSPITAL|20260315120000||ORU^R01|MSG00001|P|2.5.1", + "PID|1||MRN-001^^^TEST-HOSPITAL||", + "OBR|1|||CBC^Complete Blood Count|||20260315110000", + "OBX|1|NM|2345-7^Glucose^LN||95|mg/dL|70-99|N|||F|||20260315113000", + "OBX|2|NM|718-7^Hemoglobin^LN||14.2|g/dL|13.5-17.5|N|||F|||20260315113000", +].join("\r"); + +describe("parseOruR01", () => { + it("parses message control id, patient identifier, and every OBX as an observation", () => { + const parsed = parseOruR01(SAMPLE_ORU); + expect(parsed.messageControlId).toBe("MSG00001"); + expect(parsed.patientIdentifier).toEqual({ value: "MRN-001", issuer: "TEST-HOSPITAL" }); + expect(parsed.observations).toHaveLength(2); + }); + + it("prefers the human-readable text component of OBX-3 over the raw code", () => { + const parsed = parseOruR01(SAMPLE_ORU); + expect(parsed.observations[0].name).toBe("Glucose"); + expect(parsed.observations[1].name).toBe("Hemoglobin"); + }); + + it("extracts value/unit/referenceRange from the correct OBX fields", () => { + const parsed = parseOruR01(SAMPLE_ORU); + expect(parsed.observations[0]).toMatchObject({ value: "95", unit: "mg/dL", referenceRange: "70-99" }); + }); + + it("parses OBX-14 into an ISO observedAt timestamp", () => { + const parsed = parseOruR01(SAMPLE_ORU); + expect(parsed.observations[0].observedAt).toBe("2026-03-15T11:30:00.000Z"); + }); + + it("every observation gets a fresh synthetic id — never the same id across two parses of the same message", () => { + const first = parseOruR01(SAMPLE_ORU); + const second = parseOruR01(SAMPLE_ORU); + expect(first.observations[0].id).not.toBe(second.observations[0].id); + }); + + it("returns undefined patientIdentifier for a message with no PID segment, rather than throwing", () => { + const noPid = SAMPLE_ORU.split("\r").filter((line) => !line.startsWith("PID")).join("\r"); + expect(parseOruR01(noPid).patientIdentifier).toBeUndefined(); + }); + + it("returns an empty observations array for a message with no OBX segments", () => { + const noObx = SAMPLE_ORU.split("\r").filter((line) => !line.startsWith("OBX")).join("\r"); + expect(parseOruR01(noObx).observations).toEqual([]); + }); + + it("rejects a non-ORU message type with Hl7ParseError, never silently parsing it as one", () => { + const adt = SAMPLE_ORU.replace("ORU^R01", "ADT^A01"); + expect(() => parseOruR01(adt)).toThrow(Hl7ParseError); + expect(() => parseOruR01(adt)).toThrow(/Expected an ORU message type/); + }); + + it("round-trips through buildOruR01: parsing what this codebase itself built recovers the same patient identifier and conclusion", () => { + const report = diagnosticReportSchema.parse({ + id: "report-1", studyId: "study-1", status: "final", conclusion: "No acute findings.", + authorUserId: "user-1", authoredAt: "2026-03-15T10:00:00.000Z", isCritical: false, + createdAt: "2026-03-15T10:00:00.000Z", updatedAt: "2026-03-15T10:00:00.000Z", + }); + const study = imagingStudySchema.parse({ + id: "study-1", studyInstanceUid: "1.2.3.4", patientIdentifier: { value: "MRN-999", issuer: "ROUND-TRIP-HOSPITAL" }, + modalities: ["CT"], numberOfSeries: 1, numberOfInstances: 1, status: "available", sensitivity: "normal", + ingestionStatus: "published", createdAt: "2026-03-15T00:00:00.000Z", updatedAt: "2026-03-15T00:00:00.000Z", + }); + const raw = buildOruR01(report, study, { sendingApplication: "ModelForge", sendingFacility: "Example", receivingApplication: "EHR", receivingFacility: "Example" }); + const parsed = parseOruR01(raw); + expect(parsed.patientIdentifier).toEqual({ value: "MRN-999", issuer: "ROUND-TRIP-HOSPITAL" }); + expect(parsed.observations[0].value).toBe("No acute findings."); + }); +}); diff --git a/server/src/hl7/inbound-parser.ts b/server/src/hl7/inbound-parser.ts new file mode 100644 index 0000000..1126b87 --- /dev/null +++ b/server/src/hl7/inbound-parser.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto"; +import { labResultSchema, type LabResult } from "@modelforge/contracts"; +import { getField, Hl7ParseError, parseHl7Message, parseHl7Timestamp, splitComponents, unescapeHl7Text } from "./message.js"; + +/** + * Inbound HL7 v2 parsing — the receiving-side counterpart to + * oru-builder.ts's outbound generation, for the one message type this + * codebase currently understands (ORU^R01, "unsolicited observation + * result" — the common shape a lab system sends result data in). + * + * Deliberately, and by design, this ONLY parses and returns structured + * data — it never looks up, matches, or writes to a PatientCase. Turning a + * parsed message into a stored, patient-matched lab result is a separate, + * NOT-yet-built step that would need the same kind of deliberate ambiguous- + * match-requires-review workflow imaging ingestion already has for DICOM + * patient matching (see docs/IMAGING.md) — building that same rigor for + * HL7 inbound intake, without the review workflow, would be a real safety + * regression, so it was left undone rather than done carelessly. See + * docs/HL7_V2_INTEGRATION.md. + */ + +export interface ParsedInboundObservation extends LabResult { + /** `id` is synthetic (randomUUID) — HL7 v2's OBX segment has no + * concept of a persistent, stable identifier across a system boundary + * the way this codebase's own LabResult.id does. A caller that + * eventually persists this must not treat it as a dedup key across + * repeated deliveries of the same message. */ + id: string; +} + +export interface ParsedOruMessage { + messageControlId: string; + /** Undefined when the message has no PID segment, or PID-3 is empty — + * a message with no recognizable patient identifier is not itself a + * parse error (some ORU messages are genuinely unsolicited/QC results + * with no patient), but a caller intending to act on this must handle + * that case, not assume it's always present. */ + patientIdentifier?: { value: string; issuer: string }; + observations: ParsedInboundObservation[]; +} + +/** + * Parses an inbound ORU^R01 message into `{messageControlId, + * patientIdentifier?, observations}`. Throws `Hl7ParseError` for anything + * that isn't a well-formed HL7 v2 message at all (see message.ts's own + * parseHl7Message), or whose MSH-9 message type isn't ORU-shaped — never + * for a message that parses fine but is merely missing optional content + * (a missing PID, an OBX with no OBX-6 units, etc.), which this function + * represents as absent fields, not an error. + */ +export function parseOruR01(raw: string): ParsedOruMessage { + const message = parseHl7Message(raw); + const msh = message.segments.find((s) => s.id === "MSH"); + if (!msh) throw new Hl7ParseError("Message has no MSH segment."); + const messageType = getField(msh, 9); + if (!messageType.startsWith("ORU")) throw new Hl7ParseError(`Expected an ORU message type, got "${messageType || "(empty)"}".`); + + const pid = message.segments.find((s) => s.id === "PID"); + const pid3 = pid ? getField(pid, 3) : ""; + const pid3Components = pid3 ? splitComponents(pid3, message.encoding) : []; + const patientIdentifierValue = pid3Components[0] ? unescapeHl7Text(pid3Components[0], message.encoding) : ""; + // PID-3 component 4 (assigning authority) is the conventional + // "^^^issuer" position this codebase's own oru-builder.ts writes to + // (`^^^`) — read back symmetrically. + const patientIdentifierIssuer = pid3Components[3] ? unescapeHl7Text(pid3Components[3], message.encoding) : ""; + const patientIdentifier = patientIdentifierValue ? { value: patientIdentifierValue, issuer: patientIdentifierIssuer } : undefined; + + const observations = message.segments + .filter((s) => s.id === "OBX") + .map((obx) => { + const idComponents = splitComponents(getField(obx, 3), message.encoding); + // Prefer the human-readable text component (OBX-3.2) over the + // raw code (OBX-3.1) when both are present, matching how a + // clinician would actually want to see this; fall back to + // whichever one exists. + const name = unescapeHl7Text(idComponents[1] || idComponents[0] || "", message.encoding) || "Unknown observation"; + return labResultSchema.parse({ + id: randomUUID(), + name, + value: unescapeHl7Text(getField(obx, 5), message.encoding), + unit: getField(obx, 6) || undefined, + referenceRange: getField(obx, 7) || undefined, + observedAt: parseHl7Timestamp(getField(obx, 14)), + } satisfies ParsedInboundObservation); + }); + + return { messageControlId: getField(msh, 10), patientIdentifier, observations }; +} diff --git a/server/src/hl7/ingestion.test.ts b/server/src/hl7/ingestion.test.ts new file mode 100644 index 0000000..55d30b8 --- /dev/null +++ b/server/src/hl7/ingestion.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from "vitest"; +import { InMemoryCaseStore } from "../store/in-memory-case-store.js"; +import { InMemoryHl7IngestionStore } from "../store/in-memory-hl7-ingestion-store.js"; +import type { TenantContext } from "../tenant-context.js"; +import { patientCaseFixture } from "../test/patient-case-fixture.js"; +import { Hl7IngestionResolutionError, ingestInboundMessage, resolveIngestionJob } from "./ingestion.js"; +import { Hl7ParseError } from "./message.js"; + +const actor = () => ({ externalSubject: "idp|system", userId: "user-1", organizationId: undefined as unknown as string }); + +function tenantContext(): TenantContext { + return { organizationId: "org-1", schemaName: "tenant_" + "0".repeat(32), issuer: "test", subject: "test" }; +} + +const SAMPLE_ORU = (mrn: string) => [ + "MSH|^~\\&|LAB|HOSPITAL|EHR|HOSPITAL|20260315120000||ORU^R01|MSG00001|P|2.5.1", + `PID|1||${mrn}||`, + "OBX|1|NM|2345-7^Glucose^LN||95|mg/dL|70-99|N|||F", +].join("\r"); + +const SAMPLE_ADT = (mrn: string) => [ + "MSH|^~\\&|EHR|HOSPITAL|MODELFORGE|MODELFORGE|20260315120000||ADT^A01|MSG00002|P|2.5.1", + `PID|1||${mrn}||`, +].join("\r"); + +async function setup() { + const ctx = tenantContext(); + const caseStore = new InMemoryCaseStore(); + const ingestionStore = new InMemoryHl7IngestionStore(); + const caseRepo = caseStore.forTenant(ctx); + const ingestionRepo = ingestionStore.forTenant(ctx); + return { caseRepo, ingestionRepo }; +} + +describe("ingestInboundMessage", () => { + it("applies an ORU message to the single unambiguously-matched case, appending its observations to labResults", async () => { + const { caseRepo, ingestionRepo } = await setup(); + await caseRepo.writeOne(patientCaseFixture("case-1", { patientId: "MRN-001" }), null, actor(), { organizationId: "org-1", caseId: "case-1", patientId: "MRN-001", ownerUserId: "user-1", assignedUserIds: [], activeConsentScopes: [] }); + + const { job } = await ingestInboundMessage(caseRepo, ingestionRepo, SAMPLE_ORU("MRN-001"), actor()); + expect(job).toMatchObject({ messageType: "ORU^R01", matchStatus: "matched", matchedCaseId: "case-1", status: "applied", observationsAdded: 1 }); + + const updated = await caseRepo.getOne("case-1"); + expect(updated?.patientCase.labResults.value).toHaveLength(1); + expect(updated?.patientCase.labResults.value[0]).toMatchObject({ name: "Glucose", value: "95" }); + }); + + it("applies an ADT message to the matched case with zero observations added — no case field to update once matched", async () => { + const { caseRepo, ingestionRepo } = await setup(); + await caseRepo.writeOne(patientCaseFixture("case-1", { patientId: "MRN-001" }), null, actor(), { organizationId: "org-1", caseId: "case-1", patientId: "MRN-001", ownerUserId: "user-1", assignedUserIds: [], activeConsentScopes: [] }); + + const { job } = await ingestInboundMessage(caseRepo, ingestionRepo, SAMPLE_ADT("MRN-001"), actor()); + expect(job).toMatchObject({ messageType: "ADT^A01", matchStatus: "matched", matchedCaseId: "case-1", status: "applied", observationsAdded: 0 }); + + const updated = await caseRepo.getOne("case-1"); + expect(updated?.patientCase.labResults.value).toEqual([]); + }); + + it("records a pending-review no-match job, and never touches any case, when no case has that patientId", async () => { + const { caseRepo, ingestionRepo } = await setup(); + const { job } = await ingestInboundMessage(caseRepo, ingestionRepo, SAMPLE_ORU("MRN-DOES-NOT-EXIST"), actor()); + expect(job).toMatchObject({ matchStatus: "no-match", status: "pending-review" }); + expect(job.matchedCaseId).toBeUndefined(); + expect(await caseRepo.readAll()).toEqual([]); + }); + + it("records a pending-review ambiguous job listing every candidate, and never guesses which case to apply to", async () => { + const { caseRepo, ingestionRepo } = await setup(); + await caseRepo.writeOne(patientCaseFixture("case-1", { patientId: "MRN-SHARED" }), null, actor(), { organizationId: "org-1", caseId: "case-1", patientId: "MRN-SHARED", ownerUserId: "user-1", assignedUserIds: [], activeConsentScopes: [] }); + await caseRepo.writeOne(patientCaseFixture("case-2", { patientId: "MRN-SHARED" }), null, actor(), { organizationId: "org-1", caseId: "case-2", patientId: "MRN-SHARED", ownerUserId: "user-1", assignedUserIds: [], activeConsentScopes: [] }); + + const { job } = await ingestInboundMessage(caseRepo, ingestionRepo, SAMPLE_ORU("MRN-SHARED"), actor()); + expect(job.matchStatus).toBe("ambiguous"); + expect(job.status).toBe("pending-review"); + expect(job.candidateCaseIds?.sort()).toEqual(["case-1", "case-2"]); + expect((await caseRepo.getOne("case-1"))?.patientCase.labResults.value).toEqual([]); + expect((await caseRepo.getOne("case-2"))?.patientCase.labResults.value).toEqual([]); + }); + + it("throws Hl7ParseError for an unsupported message type, and creates no job at all", async () => { + const { caseRepo, ingestionRepo } = await setup(); + const oul = "MSH|^~\\&|LAB|HOSPITAL|EHR|HOSPITAL|20260315120000||OUL^R21|MSG00003|P|2.5.1\rPID|1||MRN-001"; + await expect(ingestInboundMessage(caseRepo, ingestionRepo, oul, actor())).rejects.toThrow(Hl7ParseError); + expect(await ingestionRepo.listJobs()).toEqual([]); + }); + + it("persists the raw message on the job record even for a no-match, so a reviewer can see what was actually sent", async () => { + const { caseRepo, ingestionRepo } = await setup(); + const raw = SAMPLE_ORU("MRN-DOES-NOT-EXIST"); + const { job } = await ingestInboundMessage(caseRepo, ingestionRepo, raw, actor()); + expect(job.rawMessage).toBe(raw); + }); +}); + +describe("resolveIngestionJob", () => { + it("applies an ambiguous job to a reviewer-chosen candidate case", async () => { + const { caseRepo, ingestionRepo } = await setup(); + await caseRepo.writeOne(patientCaseFixture("case-1", { patientId: "MRN-SHARED" }), null, actor(), { organizationId: "org-1", caseId: "case-1", patientId: "MRN-SHARED", ownerUserId: "user-1", assignedUserIds: [], activeConsentScopes: [] }); + await caseRepo.writeOne(patientCaseFixture("case-2", { patientId: "MRN-SHARED" }), null, actor(), { organizationId: "org-1", caseId: "case-2", patientId: "MRN-SHARED", ownerUserId: "user-1", assignedUserIds: [], activeConsentScopes: [] }); + const { job } = await ingestInboundMessage(caseRepo, ingestionRepo, SAMPLE_ORU("MRN-SHARED"), actor()); + + const resolved = await resolveIngestionJob(caseRepo, ingestionRepo, job.id, { action: "apply", caseId: "case-2" }, "reviewer-1", actor()); + expect(resolved).toMatchObject({ status: "applied", matchedCaseId: "case-2", observationsAdded: 1, reviewedByUserId: "reviewer-1" }); + expect((await caseRepo.getOne("case-2"))?.patientCase.labResults.value).toHaveLength(1); + expect((await caseRepo.getOne("case-1"))?.patientCase.labResults.value).toEqual([]); + }); + + it("refuses to apply an ambiguous job to a case that wasn't among its own candidates", async () => { + const { caseRepo, ingestionRepo } = await setup(); + await caseRepo.writeOne(patientCaseFixture("case-1", { patientId: "MRN-SHARED" }), null, actor(), { organizationId: "org-1", caseId: "case-1", patientId: "MRN-SHARED", ownerUserId: "user-1", assignedUserIds: [], activeConsentScopes: [] }); + await caseRepo.writeOne(patientCaseFixture("case-2", { patientId: "MRN-SHARED" }), null, actor(), { organizationId: "org-1", caseId: "case-2", patientId: "MRN-SHARED", ownerUserId: "user-1", assignedUserIds: [], activeConsentScopes: [] }); + await caseRepo.writeOne(patientCaseFixture("case-unrelated"), null, actor(), { organizationId: "org-1", caseId: "case-unrelated", patientId: "MRN-OTHER", ownerUserId: "user-1", assignedUserIds: [], activeConsentScopes: [] }); + const { job } = await ingestInboundMessage(caseRepo, ingestionRepo, SAMPLE_ORU("MRN-SHARED"), actor()); + + await expect(resolveIngestionJob(caseRepo, ingestionRepo, job.id, { action: "apply", caseId: "case-unrelated" }, "reviewer-1", actor())).rejects.toThrow(Hl7IngestionResolutionError); + }); + + it("rejects a job with a recorded reason, touching no case data", async () => { + const { caseRepo, ingestionRepo } = await setup(); + const { job } = await ingestInboundMessage(caseRepo, ingestionRepo, SAMPLE_ORU("MRN-DOES-NOT-EXIST"), actor()); + const resolved = await resolveIngestionJob(caseRepo, ingestionRepo, job.id, { action: "reject", reason: "duplicate delivery" }, "reviewer-1", actor()); + expect(resolved).toMatchObject({ status: "rejected", rejectionReason: "duplicate delivery" }); + }); + + it("refuses to resolve a job that isn't pending-review anymore", async () => { + const { caseRepo, ingestionRepo } = await setup(); + await caseRepo.writeOne(patientCaseFixture("case-1", { patientId: "MRN-001" }), null, actor(), { organizationId: "org-1", caseId: "case-1", patientId: "MRN-001", ownerUserId: "user-1", assignedUserIds: [], activeConsentScopes: [] }); + const { job } = await ingestInboundMessage(caseRepo, ingestionRepo, SAMPLE_ORU("MRN-001"), actor()); + expect(job.status).toBe("applied"); + await expect(resolveIngestionJob(caseRepo, ingestionRepo, job.id, { action: "reject", reason: "too late" }, "reviewer-1", actor())).rejects.toThrow(Hl7IngestionResolutionError); + }); + + it("returns null for a job id that doesn't exist", async () => { + const { caseRepo, ingestionRepo } = await setup(); + expect(await resolveIngestionJob(caseRepo, ingestionRepo, "does-not-exist", { action: "reject", reason: "x" }, "reviewer-1", actor())).toBeNull(); + }); +}); diff --git a/server/src/hl7/ingestion.ts b/server/src/hl7/ingestion.ts new file mode 100644 index 0000000..9dce101 --- /dev/null +++ b/server/src/hl7/ingestion.ts @@ -0,0 +1,188 @@ +import type { Hl7IngestionJob, LabResult } from "@modelforge/contracts"; +import type { TenantCaseRepository } from "../store/case-store.js"; +import type { TenantHl7IngestionRepository } from "../store/hl7-ingestion-store.js"; +import type { AuditActor } from "../store/audit-store.js"; +import { getField, Hl7ParseError, parseHl7Message, splitComponents } from "./message.js"; +import { parseOruR01 } from "./inbound-parser.js"; +import { parseAdtMessage } from "./adt-parser.js"; + +/** + * The shared match/apply pipeline both the HTTP ingestion route + * (routes/hl7.ts) and, in principle, an MLLP listener (mllp-server.ts) — + * or any other future inbound transport — call into. Kept separate from + * both so the actual clinical-safety logic (patient matching, what "apply" + * means per message type) lives in exactly one place regardless of how a + * message arrived. + * + * Patient matching: exact string equality against a case's own effective + * patientId (`patientCase.patientId ?? patientCase.id`, the same fallback + * routes/cases.ts's own resourceForCreate uses) — no fuzzy matching, no + * partial matching. Zero matches or more than one are BOTH treated as + * "cannot safely auto-apply," never a guess — the same discipline + * imaging's own DICOM patient-matching algorithm uses (docs/IMAGING.md). + * This is deliberately less sophisticated than imaging's own matching + * (which also considers the identifier's issuer) because PatientCase.patientId + * is a bare string with no issuer concept anywhere in this system's domain + * model — a real, disclosed limitation, not an oversight. + */ + +export interface IngestOutcome { + job: Hl7IngestionJob; +} + +async function findMatchingCases(caseRepo: TenantCaseRepository, patientIdentifierValue: string): Promise { + const cases = await caseRepo.readAll(); + return cases.filter((c) => (c.patientId ?? c.id) === patientIdentifierValue).map((c) => c.id); +} + +/** Appends `newResults` to `caseId`'s labResults field, retrying once on an + * optimistic-concurrency conflict (a clinician editing the case at the same + * moment ingestion runs — rare, but not impossible, and worth one retry + * rather than either silently losing the clinician's concurrent edit or + * silently dropping the inbound results). A second conflict gives up rather + * than looping — the caller treats that as "could not apply," never as a + * a fabricated success. */ +async function appendLabResults(caseRepo: TenantCaseRepository, caseId: string, newResults: LabResult[], actor: AuditActor): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + const current = await caseRepo.getOne(caseId); + if (!current) return false; + const updated = { + ...current.patientCase, + labResults: { ...current.patientCase.labResults, value: [...current.patientCase.labResults.value, ...newResults] }, + updatedAt: new Date().toISOString(), + }; + const result = await caseRepo.writeOne(updated, current.patientCase.version ?? null, actor, current.resource); + if (!("conflict" in result)) return true; + } + return false; +} + +/** + * Parses and ingests a raw inbound HL7 v2 message: detects ORU^R01 vs. ADT + * by MSH-9, matches the patient, and — only for an unambiguous single + * match — applies it (an ORU's observations merge into the matched case's + * labResults; an ADT has no case field of its own to update once matched, + * so "applying" it just records the job as applied with zero observations, + * the audit trail of "this visit event was received and recognized"). + * Anything else (no PID, zero matches, multiple matches, an unsupported + * message type) creates a `pending-review` job and touches no case data. + * Throws Hl7ParseError only for a message that isn't well-formed HL7 at + * all, or isn't ORU/ADT — never for a message that parses fine but simply + * can't be matched, which is a normal, expected outcome recorded on the + * job, not an error. + */ +export async function ingestInboundMessage(caseRepo: TenantCaseRepository, ingestionRepo: TenantHl7IngestionRepository, rawMessage: string, actor: AuditActor): Promise { + // Parse once, just to read MSH-9's own message-type component (the + // real, spec-correct way to determine message type — never a + // hand-rolled fixed-offset string peek, which broke on this exact + // input the first time this was written: MSH-2's own encoding + // characters aren't a fixed width relative to MSH-9's position without + // going through the real field-splitting logic). parseOruR01/ + // parseAdtMessage each re-parse below — cheap for a message this + // small, and keeps each parser fully self-contained. + const probe = parseHl7Message(rawMessage); + const probeMsh = probe.segments.find((s) => s.id === "MSH"); + if (!probeMsh) throw new Hl7ParseError("Message has no MSH segment."); + const [detectedType] = splitComponents(getField(probeMsh, 9), probe.encoding); + + let messageType: string; + let messageControlId: string; + let patientIdentifier: { value: string; issuer: string } | undefined; + let observations: LabResult[] = []; + + if (detectedType === "ORU") { + const parsed = parseOruR01(rawMessage); + messageType = "ORU^R01"; + messageControlId = parsed.messageControlId; + patientIdentifier = parsed.patientIdentifier; + observations = parsed.observations; + } else if (detectedType === "ADT") { + const parsed = parseAdtMessage(rawMessage); + messageType = `ADT^${parsed.triggerEvent || "?"}`; + messageControlId = parsed.messageControlId; + patientIdentifier = parsed.patientIdentifier; + } else { + throw new Hl7ParseError(`Unsupported inbound message type${detectedType ? ` "${detectedType}"` : ""} — only ORU and ADT are ingested.`); + } + + const candidateCaseIds = patientIdentifier ? await findMatchingCases(caseRepo, patientIdentifier.value) : []; + const receivedAt = new Date().toISOString(); + const baseInput = { + messageType, + messageControlId, + rawMessage, + receivedAt, + patientIdentifierValue: patientIdentifier?.value, + patientIdentifierIssuer: patientIdentifier?.issuer, + }; + + if (candidateCaseIds.length === 1) { + const caseId = candidateCaseIds[0]; + if (messageType === "ORU^R01") { + const applied = await appendLabResults(caseRepo, caseId, observations, actor); + if (applied) { + const job = await ingestionRepo.createJob({ ...baseInput, matchStatus: "matched", matchedCaseId: caseId, status: "applied", observationsAdded: observations.length }, actor); + return { job }; + } + // The one case that matched vanished or hit a concurrency + // conflict twice in a row between matching and writing — + // record it for a human to sort out rather than silently + // dropping the results. + const job = await ingestionRepo.createJob({ ...baseInput, matchStatus: "matched", matchedCaseId: caseId, status: "pending-review" }, actor); + return { job }; + } + // ADT: the patient is matched, there is no further case field to + // update — the job record itself is the outcome. + const job = await ingestionRepo.createJob({ ...baseInput, matchStatus: "matched", matchedCaseId: caseId, status: "applied", observationsAdded: 0 }, actor); + return { job }; + } + + const matchStatus = candidateCaseIds.length === 0 ? "no-match" : "ambiguous"; + const job = await ingestionRepo.createJob( + { ...baseInput, matchStatus, candidateCaseIds: candidateCaseIds.length > 1 ? candidateCaseIds : undefined, status: "pending-review" }, + actor + ); + return { job }; +} + +export type ResolveDecision = { action: "apply"; caseId: string } | { action: "reject"; reason: string }; + +export class Hl7IngestionResolutionError extends Error {} + +/** + * Resolves a `pending-review` job: "apply" merges the job's already- + * parsed content into a reviewer-chosen case (must be one of the job's own + * `candidateCaseIds` when the job was ambiguous — a reviewer picks among + * what was actually found, never an arbitrary case id, keeping this + * consistent with imaging's own "reject citations/matches the requesting + * scope didn't actually produce" discipline); "reject" just records why, + * touching no case data. Re-parses `job.rawMessage` rather than trusting + * any cached observations, so resolution always reflects the message + * exactly as received. + */ +export async function resolveIngestionJob(caseRepo: TenantCaseRepository, ingestionRepo: TenantHl7IngestionRepository, jobId: string, decision: ResolveDecision, reviewerUserId: string, actor: AuditActor): Promise { + const job = await ingestionRepo.getJob(jobId); + if (!job) return null; + if (job.status !== "pending-review") throw new Hl7IngestionResolutionError(`Job ${jobId} is already "${job.status}" — only a pending-review job can be resolved.`); + + const reviewedAt = new Date().toISOString(); + if (decision.action === "reject") { + return ingestionRepo.updateJob(jobId, { status: "rejected", rejectionReason: decision.reason, reviewedByUserId: reviewerUserId, reviewedAt }, actor); + } + + if (job.matchStatus === "ambiguous" && !(job.candidateCaseIds ?? []).includes(decision.caseId)) { + throw new Hl7IngestionResolutionError(`Case ${decision.caseId} was not among this job's own candidate matches.`); + } + const targetCase = await caseRepo.getOne(decision.caseId); + if (!targetCase) throw new Hl7IngestionResolutionError(`Case ${decision.caseId} does not exist in this tenant.`); + + let observationsAdded = 0; + if (job.messageType === "ORU^R01") { + const parsed = parseOruR01(job.rawMessage); + const applied = await appendLabResults(caseRepo, decision.caseId, parsed.observations, actor); + if (!applied) throw new Hl7IngestionResolutionError(`Could not apply to case ${decision.caseId} — a concurrent edit conflict occurred twice; retry.`); + observationsAdded = parsed.observations.length; + } + + return ingestionRepo.updateJob(jobId, { status: "applied", matchedCaseId: decision.caseId, observationsAdded, reviewedByUserId: reviewerUserId, reviewedAt }, actor); +} diff --git a/server/src/hl7/message.test.ts b/server/src/hl7/message.test.ts new file mode 100644 index 0000000..a65ad91 --- /dev/null +++ b/server/src/hl7/message.test.ts @@ -0,0 +1,176 @@ +import { describe, expect, it } from "vitest"; +import { + buildHl7Message, + buildSegment, + DEFAULT_ENCODING_CHARACTERS, + escapeHl7Text, + getField, + Hl7ParseError, + parseHl7Message, + parseHl7Timestamp, + splitComponents, + splitRepetitions, + splitSubcomponents, + unescapeHl7Text, + type Hl7Message, +} from "./message.js"; + +const SAMPLE_ORU = [ + "MSH|^~\\&|LAB|HOSPITAL|EHR|HOSPITAL|20260101120000||ORU^R01|MSG00001|P|2.5.1", + "PID|1||MRN12345||||", + "OBR|1|||CBC^Complete Blood Count|||20260101110000", + "OBX|1|TX|SUMMARY||No acute findings.||||||F", +].join("\r"); + +describe("parseHl7Message", () => { + it("parses a well-formed ORU^R01 into segments with correctly split fields", () => { + const message = parseHl7Message(SAMPLE_ORU); + expect(message.segments.map((s) => s.id)).toEqual(["MSH", "PID", "OBR", "OBX"]); + expect(message.encoding).toEqual(DEFAULT_ENCODING_CHARACTERS); + }); + + it("extracts MSH-2 encoding characters correctly, not hardcoded", () => { + const message = parseHl7Message(SAMPLE_ORU); + const msh = message.segments[0]; + expect(getField(msh, 1)).toBe("|"); + expect(getField(msh, 2)).toBe("^~\\&"); + expect(getField(msh, 9)).toBe("ORU^R01"); + }); + + it("getField is 1-indexed and matches conventional HL7 field numbering (MSH-9 is the message type)", () => { + const message = parseHl7Message(SAMPLE_ORU); + const obx = message.segments[3]; + expect(getField(obx, 1)).toBe("1"); + expect(getField(obx, 2)).toBe("TX"); + expect(getField(obx, 3)).toBe("SUMMARY"); + expect(getField(obx, 11)).toBe("F"); + }); + + it("returns '' rather than throwing for a field beyond what the segment actually has", () => { + const message = parseHl7Message(SAMPLE_ORU); + const pid = message.segments[1]; + expect(getField(pid, 50)).toBe(""); + }); + + it("splitComponents decomposes a component-delimited field (OBR-4, code^text)", () => { + const message = parseHl7Message(SAMPLE_ORU); + const obr = message.segments[2]; + expect(splitComponents(getField(obr, 4), message.encoding)).toEqual(["CBC", "Complete Blood Count"]); + }); + + it("splitComponents/splitRepetitions/splitSubcomponents use DEFAULT_ENCODING_CHARACTERS when not passed explicitly", () => { + expect(splitComponents("a^b^c")).toEqual(["a", "b", "c"]); + expect(splitRepetitions("a~b")).toEqual(["a", "b"]); + expect(splitSubcomponents("a&b")).toEqual(["a", "b"]); + }); + + it("tolerates LF and CRLF segment terminators, not only the spec's own CR", () => { + const lfVersion = SAMPLE_ORU.replaceAll("\r", "\n"); + const crlfVersion = SAMPLE_ORU.replaceAll("\r", "\r\n"); + expect(parseHl7Message(lfVersion).segments.map((s) => s.id)).toEqual(["MSH", "PID", "OBR", "OBX"]); + expect(parseHl7Message(crlfVersion).segments.map((s) => s.id)).toEqual(["MSH", "PID", "OBR", "OBX"]); + }); + + it("throws Hl7ParseError, not a generic error, for input that isn't a message at all", () => { + expect(() => parseHl7Message("")).toThrow(Hl7ParseError); + expect(() => parseHl7Message("PID|1||MRN\r")).toThrow(Hl7ParseError); + expect(() => parseHl7Message("MSH|^~")).toThrow(Hl7ParseError); + }); +}); + +describe("buildHl7Message / buildSegment", () => { + it("round-trips a hand-built message through build -> parse with the same field values", () => { + const message: Hl7Message = { + encoding: DEFAULT_ENCODING_CHARACTERS, + segments: [ + buildSegment("MSH", { 1: "|", 2: "^~\\&", 3: "LAB", 9: "ORU^R01", 10: "MSG00001", 11: "P", 12: "2.5.1" }), + buildSegment("PID", { 1: "1", 3: "MRN12345" }), + buildSegment("OBX", { 1: "1", 2: "TX", 3: "SUMMARY", 5: "No acute findings.", 11: "F" }), + ], + }; + const raw = buildHl7Message(message); + expect(raw.endsWith("\r")).toBe(true); + const reparsed = parseHl7Message(raw); + expect(reparsed.segments.map((s) => s.id)).toEqual(["MSH", "PID", "OBX"]); + const msh = reparsed.segments[0]; + expect(getField(msh, 9)).toBe("ORU^R01"); + const obx = reparsed.segments[2]; + expect(getField(obx, 5)).toBe("No acute findings."); + }); + + it("buildSegment fills gaps between set fields with '' so later field positions stay correct", () => { + const segment = buildSegment("OBX", { 1: "1", 5: "value-only-field-5" }); + expect(segment.fields).toEqual(["1", "", "", "", "value-only-field-5"]); + }); + + it("build -> parse -> build is stable (idempotent re-serialization)", () => { + const once = buildHl7Message(parseHl7Message(SAMPLE_ORU)); + const twice = buildHl7Message(parseHl7Message(once)); + expect(twice).toBe(once); + }); +}); + +describe("escapeHl7Text / unescapeHl7Text", () => { + it("escapes every one of the five delimiter characters so they never corrupt message structure", () => { + const raw = "field|comp^sub&rep~esc\\end"; + const escaped = escapeHl7Text(raw); + expect(escaped).not.toContain("|"); + // The unescaped `^`/`&`/`~` only ever appear as part of an escape + // sequence's own literal "\S\"/"\T\"/"\R\" text, never as raw + // delimiter characters — the round trip below is the real proof. + expect(unescapeHl7Text(escaped)).toBe(raw); + }); + + it("a value with no special characters is returned unchanged by both directions", () => { + expect(escapeHl7Text("No acute findings.")).toBe("No acute findings."); + expect(unescapeHl7Text("No acute findings.")).toBe("No acute findings."); + }); + + it("leaves an unrecognized escape code untouched, verbatim, rather than guessing", () => { + expect(unescapeHl7Text("text \\H\\highlighted\\N\\ text")).toBe("text \\H\\highlighted\\N\\ text"); + }); + + it("escaping a value and embedding it as a field, then parsing the message back, recovers the exact original text", () => { + const conclusionWithDelimiters = "Impression: mild finding & no other concern (ratio 3|4, class^A~B)"; + const message: Hl7Message = { + encoding: DEFAULT_ENCODING_CHARACTERS, + segments: [ + buildSegment("MSH", { 1: "|", 2: "^~\\&", 9: "ORU^R01" }), + buildSegment("OBX", { 1: "1", 5: escapeHl7Text(conclusionWithDelimiters) }), + ], + }; + const reparsed = parseHl7Message(buildHl7Message(message)); + const obx = reparsed.segments[1]; + expect(unescapeHl7Text(getField(obx, 5))).toBe(conclusionWithDelimiters); + }); +}); + +describe("parseHl7Timestamp", () => { + it("parses a full-precision timestamp (YYYYMMDDHHMMSS) as UTC when no timezone is given", () => { + expect(parseHl7Timestamp("20260315143045")).toBe("2026-03-15T14:30:45.000Z"); + }); + + it("parses a date-only value (YYYYMMDD) as midnight UTC — the minimum precision this function accepts", () => { + expect(parseHl7Timestamp("20260315")).toBe("2026-03-15T00:00:00.000Z"); + }); + + it("honors an explicit timezone offset instead of assuming UTC", () => { + expect(parseHl7Timestamp("20260315143045+0500")).toBe("2026-03-15T09:30:45.000Z"); + expect(parseHl7Timestamp("20260315143045-0500")).toBe("2026-03-15T19:30:45.000Z"); + }); + + it("tolerates a fractional-second component", () => { + expect(parseHl7Timestamp("20260315143045.123")).toBe("2026-03-15T14:30:45.000Z"); + }); + + it("returns undefined, never throws, for anything less precise than YYYYMMDD", () => { + expect(parseHl7Timestamp("2026")).toBeUndefined(); + expect(parseHl7Timestamp("202603")).toBeUndefined(); + expect(parseHl7Timestamp("")).toBeUndefined(); + }); + + it("returns undefined for garbage input rather than an Invalid Date", () => { + expect(parseHl7Timestamp("not-a-timestamp")).toBeUndefined(); + expect(parseHl7Timestamp("99999999")).toBeUndefined(); + }); +}); diff --git a/server/src/hl7/message.ts b/server/src/hl7/message.ts new file mode 100644 index 0000000..af26ac7 --- /dev/null +++ b/server/src/hl7/message.ts @@ -0,0 +1,241 @@ +/** + * A minimal, from-scratch HL7 v2.x (ER7/"pipe-and-hat") message parser and + * builder. Scope, deliberately: this is protocol-level plumbing — parsing + * and constructing well-formed HL7 v2 messages — not an interface engine. + * It has no MLLP (the TCP framing real HL7 v2 transport uses), no + * persistence, and no inbound HTTP listener; see docs/HL7_V2_INTEGRATION.md + * for the full scope statement and how a real deployment would wire actual + * network transport on top of this. server/src/hl7/oru-builder.ts is the + * one concrete message type this codebase currently builds (ORU^R01, + * mapping this system's own DiagnosticReport/ImagingStudy/PatientCase). + * + * HL7 v2's own field-composition rules (per its own spec, not a rule this + * code invented): a message is CR ("\r", 0x0D)-terminated segments; each + * segment is a segment-id followed by fields separated by the field + * separator (declared in MSH-1, always `|` in practice — HL7 v2 itself + * defines no other legal value, but this parser still reads it from the + * message rather than hardcoding it, matching the spec's own model); + * MSH-2 declares the four "encoding characters" (component `^`, repetition + * `~`, escape `\`, subcomponent `&`, in that fixed order) used inside every + * OTHER segment's fields. This parser stops at the FIELD level by default + * (a segment's fields as raw, still-delimited strings) — `splitComponents`/ + * `splitRepetitions`/`splitSubcomponents` below decompose a field further + * only where a caller actually needs to (most fields, especially simple + * ones like a timestamp or a status code, are used whole). + */ + +export interface Hl7EncodingCharacters { + field: string; // MSH-1, e.g. "|" + component: string; // MSH-2 char 1, e.g. "^" + repetition: string; // MSH-2 char 2, e.g. "~" + escape: string; // MSH-2 char 3, e.g. "\" + subcomponent: string; // MSH-2 char 4, e.g. "&" +} + +export const DEFAULT_ENCODING_CHARACTERS: Hl7EncodingCharacters = { field: "|", component: "^", repetition: "~", escape: "\\", subcomponent: "&" }; + +export interface Hl7Segment { + /** The segment id (e.g. "MSH", "PID", "OBX") — NOT included in `fields`. */ + id: string; + /** Raw, still-delimited field strings, 1-indexed in the conventional + * HL7 sense via `getField` below (fields[0] here is field 1, since the + * segment id itself is field 0 in the spec's own numbering but is + * already split out as `id`) — EXCEPT for MSH, where field 1 (the + * field separator itself) is never a normal delimited value and field + * 2 (encoding characters) is stored as a plain literal string; both + * are still present in `fields` at their spec-numbered positions so + * `getField(seg, 1)`/`getField(seg, 2)` work uniformly across every + * segment type, MSH included. */ + fields: string[]; +} + +export interface Hl7Message { + segments: Hl7Segment[]; + encoding: Hl7EncodingCharacters; +} + +export class Hl7ParseError extends Error { + constructor(message: string) { + super(message); + this.name = "Hl7ParseError"; + } +} + +/** 1-indexed field access, matching HL7's own field-numbering convention + * (MSH-9, PID-3, etc.) — `getField(segment, 9)` for MSH-9, not + * `segment.fields[9]`. Returns "" (never undefined/throws) for a field + * beyond what the segment actually has — a short/truncated segment is + * common and every value here is optional by construction, not a parse + * error. */ +export function getField(segment: Hl7Segment, oneIndexedField: number): string { + return segment.fields[oneIndexedField - 1] ?? ""; +} + +export function splitComponents(field: string, encoding: Hl7EncodingCharacters = DEFAULT_ENCODING_CHARACTERS): string[] { + return field.split(encoding.component); +} + +export function splitRepetitions(field: string, encoding: Hl7EncodingCharacters = DEFAULT_ENCODING_CHARACTERS): string[] { + return field.split(encoding.repetition); +} + +export function splitSubcomponents(component: string, encoding: Hl7EncodingCharacters = DEFAULT_ENCODING_CHARACTERS): string[] { + return component.split(encoding.subcomponent); +} + +/** + * Escapes a value that will become ONE field/component/subcomponent so any + * of the message's own delimiter characters appearing literally in real + * data (e.g. a conclusion string that happens to contain "&", or a name + * with a "^") do not corrupt the message's structure — HL7 v2's own + * escape-sequence mechanism (`\F\`, `\S\`, `\T\`, `\R\`, `\E\` for + * field/component/subcomponent/repetition/escape respectively), applied in + * a single left-to-right pass so an already-escaped backslash is never + * re-escaped. + */ +export function escapeHl7Text(value: string, encoding: Hl7EncodingCharacters = DEFAULT_ENCODING_CHARACTERS): string { + let result = ""; + for (const char of value) { + if (char === encoding.escape) result += `${encoding.escape}E${encoding.escape}`; + else if (char === encoding.field) result += `${encoding.escape}F${encoding.escape}`; + else if (char === encoding.component) result += `${encoding.escape}S${encoding.escape}`; + else if (char === encoding.subcomponent) result += `${encoding.escape}T${encoding.escape}`; + else if (char === encoding.repetition) result += `${encoding.escape}R${encoding.escape}`; + else result += char; + } + return result; +} + +const UNESCAPE_MAP: Record = { E: "escape", F: "field", S: "component", T: "subcomponent", R: "repetition" }; + +/** Inverse of escapeHl7Text — decodes `\F\`/`\S\`/`\T\`/`\R\`/`\E\` escape + * sequences back to literal delimiter characters. An unrecognized escape + * code (anything HL7 v2 also reserves for locally-defined or highlighting + * escapes, e.g. `\H\`/`\N\`/`\Zxxx\`) is left untouched, verbatim — this + * function only ever decodes the five delimiter escapes it itself + * produces, never guesses at ones it doesn't know. */ +export function unescapeHl7Text(value: string, encoding: Hl7EncodingCharacters = DEFAULT_ENCODING_CHARACTERS): string { + const esc = encoding.escape; + if (!value.includes(esc)) return value; + let result = ""; + let i = 0; + while (i < value.length) { + if (value[i] === esc) { + const close = value.indexOf(esc, i + 1); + const code = close > i ? value.slice(i + 1, close) : ""; + const key = UNESCAPE_MAP[code]; + if (close > i && key) { + result += encoding[key]; + i = close + 1; + continue; + } + } + result += value[i]; + i++; + } + return result; +} + +/** + * Parses a raw HL7 v2 message. Segments split on CR (`\r`) primarily, per + * spec — but a bare `\n` or `\r\n` (common from systems/tools that don't + * preserve the exact wire terminator, e.g. a message pasted from a text + * editor) is tolerated too, since rejecting an otherwise well-formed + * message over terminator style would be pedantry with no safety benefit + * here (this parser is never fed anything but this codebase's own + * already-built messages and, in the future, whatever a real MLLP + * transport layer hands it — see this file's own top doc comment on why + * that transport doesn't exist yet). + */ +export function parseHl7Message(raw: string): Hl7Message { + const lines = raw.split(/\r\n|\r|\n/).filter((line) => line.length > 0); + if (lines.length === 0) throw new Hl7ParseError("Empty message."); + const mshLine = lines[0]; + if (!mshLine.startsWith("MSH")) throw new Hl7ParseError("Message must start with an MSH segment."); + if (mshLine.length < 8) throw new Hl7ParseError("MSH segment too short to contain a field separator and encoding characters."); + + const fieldSep = mshLine[3]; + const encodingCharsField = mshLine.slice(4, 8); + if (encodingCharsField.length !== 4) throw new Hl7ParseError("MSH-2 (encoding characters) must be exactly 4 characters."); + const encoding: Hl7EncodingCharacters = { field: fieldSep, component: encodingCharsField[0], repetition: encodingCharsField[1], escape: encodingCharsField[2], subcomponent: encodingCharsField[3] }; + + const segments: Hl7Segment[] = lines.map((line) => { + const id = line.slice(0, 3); + if (id === "MSH") { + // MSH-1 is the field separator itself (never split out of the + // raw text the way other segments' field 1 is). Index 8 is the + // single separator character between MSH-2 and MSH-3 — sliced + // past (not included) so the rest splits on fieldSep exactly + // like every other segment's fields, starting at MSH-3. + const rest = line.slice(9); + const restFields = rest.length > 0 || line.length > 8 ? rest.split(fieldSep) : []; + return { id, fields: [fieldSep, encodingCharsField, ...restFields] }; + } + // Plain startsWith/slice, not a dynamically-built RegExp — fieldSep + // is taken from the message itself (MSH-1), so constructing a + // RegExp from it is both a ReDoS smell and a correctness risk (a + // fieldSep that happens to be a regex metacharacter would silently + // match something other than its literal self). + const afterId = line.slice(3); + const body = afterId.startsWith(fieldSep) ? afterId.slice(fieldSep.length) : afterId; + return { id, fields: body.length > 0 || line.length > 3 ? body.split(fieldSep) : [] }; + }); + + return { segments, encoding }; +} + +/** Serializes a Hl7Message back to CR-terminated wire format — the inverse + * of parseHl7Message for a message this codebase itself constructed (see + * MSH's own special-cased fields[0]/fields[1] handling, mirroring the + * parser's own). Every segment ends with the terminator; the whole message + * does too, matching how real HL7 v2 senders terminate the final segment + * the same as every other one. */ +export function buildHl7Message(message: Hl7Message): string { + return message.segments + .map((segment) => { + if (segment.id === "MSH") { + const fieldSep = segment.fields[0] ?? message.encoding.field; + const encodingChars = segment.fields[1] ?? `${message.encoding.component}${message.encoding.repetition}${message.encoding.escape}${message.encoding.subcomponent}`; + const rest = segment.fields.slice(2); + return `MSH${fieldSep}${encodingChars}${rest.length > 0 ? fieldSep + rest.join(fieldSep) : ""}`; + } + return `${segment.id}${segment.fields.length > 0 ? message.encoding.field + segment.fields.join(message.encoding.field) : ""}`; + }) + .join("\r") + "\r"; +} + +/** Builds one non-MSH segment from 1-indexed field values (a sparse + * `{fieldNumber: value}` map, since most segments only set a handful of + * fields out of dozens the spec defines) — the construction-side + * counterpart to `getField`. Gaps are filled with "" so a later field's + * position is always correct even when earlier ones are unset. */ +export function buildSegment(id: string, oneIndexedFields: Record): Hl7Segment { + const maxField = Math.max(0, ...Object.keys(oneIndexedFields).map(Number)); + const fields: string[] = []; + for (let i = 1; i <= maxField; i++) fields.push(oneIndexedFields[i] ?? ""); + return { id, fields }; +} + +const TS_PATTERN = /^(\d{4})(\d{2})?(\d{2})?(\d{2})?(\d{2})?(\d{2})?(?:\.\d+)?([+-]\d{4})?$/; + +/** + * Parses an HL7 v2 TS (timestamp) field value — `YYYYMMDDHHMMSS`, or any + * shorter prefix per the standard's own "trailing components may be + * omitted" rule, optionally followed by a fractional-second and/or a + * `+HHMM`/`-HHMM` timezone offset — into an ISO 8601 string. Requires at + * least `YYYYMMDD` (year+month+day); anything less precise is treated as + * unusably imprecise for this codebase's purposes and returns undefined, + * never throws. When no timezone offset is present, UTC is assumed — a + * real, disclosed ambiguity: HL7 v2 itself has no required-timezone rule, + * and this parser has no way to know a sending system's local convention + * when the message itself doesn't say. + */ +export function parseHl7Timestamp(value: string): string | undefined { + const match = TS_PATTERN.exec(value); + if (!match || !match[2] || !match[3]) return undefined; + const [, year, month, day, hour = "00", minute = "00", second = "00", tz] = match; + const offset = tz ? `${tz.slice(0, 3)}:${tz.slice(3)}` : "Z"; + const iso = `${year}-${month}-${day}T${hour}:${minute}:${second}${offset}`; + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} diff --git a/server/src/hl7/mllp-handler.test.ts b/server/src/hl7/mllp-handler.test.ts new file mode 100644 index 0000000..8d60efc --- /dev/null +++ b/server/src/hl7/mllp-handler.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { InMemoryCaseStore } from "../store/in-memory-case-store.js"; +import { InMemoryHl7IngestionStore } from "../store/in-memory-hl7-ingestion-store.js"; +import type { TenantContext } from "../tenant-context.js"; +import { patientCaseFixture } from "../test/patient-case-fixture.js"; +import { createMllpIngestionHandler } from "./mllp-handler.js"; +import { getField, parseHl7Message } from "./message.js"; + +const actor = () => ({ externalSubject: "idp|system", userId: "user-1", organizationId: undefined as unknown as string }); + +function tenantContext(): TenantContext { + return { organizationId: "org-1", schemaName: "tenant_" + "0".repeat(32), issuer: "test", subject: "test" }; +} + +const SAMPLE_ORU = (mrn: string) => [ + "MSH|^~\\&|LAB|HOSPITAL|EHR|HOSPITAL|20260315120000||ORU^R01|MSG00001|P|2.5.1", + `PID|1||${mrn}||`, + "OBX|1|NM|2345-7^Glucose^LN||95|mg/dL|70-99|N|||F", +].join("\r"); + +async function setup() { + const ctx = tenantContext(); + const caseStore = new InMemoryCaseStore(); + const ingestionStore = new InMemoryHl7IngestionStore(); + const caseRepo = caseStore.forTenant(ctx); + const ingestionRepo = ingestionStore.forTenant(ctx); + const handler = createMllpIngestionHandler({ + organizationId: "org-1", + caseRepo, + ingestionRepo, + ackContext: { sendingApplication: "ModelForge", sendingFacility: "Example Health System" }, + }); + return { caseRepo, ingestionRepo, handler }; +} + +describe("createMllpIngestionHandler", () => { + it("returns an AA ack referencing the original message control id for a matched, applied message", async () => { + const { caseRepo, handler } = await setup(); + await caseRepo.writeOne(patientCaseFixture("case-1", { patientId: "MRN-001" }), null, actor(), { organizationId: "org-1", caseId: "case-1", patientId: "MRN-001", ownerUserId: "user-1", assignedUserIds: [], activeConsentScopes: [] }); + + const ackRaw = await handler(SAMPLE_ORU("MRN-001")); + const ack = parseHl7Message(ackRaw); + expect(getField(ack.segments[0], 9)).toBe("ACK"); + const msa = ack.segments[1]; + expect(getField(msa, 1)).toBe("AA"); + expect(getField(msa, 2)).toBe("MSG00001"); + expect(getField(msa, 3)).toContain("applied"); + + expect((await caseRepo.getOne("case-1"))?.patientCase.labResults.value).toHaveLength(1); + }); + + it("still returns AA (queued for review) for an ambiguous/no-match — a real receiving system, matching or not, is not an error", async () => { + const { handler } = await setup(); + const ackRaw = await handler(SAMPLE_ORU("MRN-DOES-NOT-EXIST")); + const msa = parseHl7Message(ackRaw).segments[1]; + expect(getField(msa, 1)).toBe("AA"); + expect(getField(msa, 3)).toContain("no matching patient"); + }); + + it("returns AR for a structurally invalid message, quoting the real parse error safely", async () => { + const { handler } = await setup(); + const ackRaw = await handler("this is not HL7 at all"); + const msa = parseHl7Message(ackRaw).segments[1]; + expect(getField(msa, 1)).toBe("AR"); + }); + + it("returns AR for a message MSH parses but whose type isn't ORU/ADT, without leaking internals", async () => { + const { handler } = await setup(); + const oul = "MSH|^~\\&|LAB|HOSPITAL|EHR|HOSPITAL|20260315120000||OUL^R21|MSG00003|P|2.5.1\rPID|1||MRN-001"; + const ackRaw = await handler(oul); + const ack = parseHl7Message(ackRaw); + const msa = ack.segments[1]; + expect(getField(msa, 1)).toBe("AR"); + expect(getField(msa, 2)).toBe("MSG00003"); + }); + + it("returns AE and never echoes the raw error text for an unexpected (non-parse) failure", async () => { + const ctx = tenantContext(); + const caseStore = new InMemoryCaseStore(); + const ingestionStore = new InMemoryHl7IngestionStore(); + const caseRepo = caseStore.forTenant(ctx); + // A repo whose readAll() throws, simulating an unexpected backend failure. + const brokenCaseRepo = { ...caseRepo, readAll: vi.fn(async () => { throw new Error("connection string: postgres://secret@internal-host/db"); }) }; + const onError = vi.fn(); + const handler = createMllpIngestionHandler({ + organizationId: "org-1", + caseRepo: brokenCaseRepo, + ingestionRepo: ingestionStore.forTenant(ctx), + ackContext: { sendingApplication: "ModelForge", sendingFacility: "Example" }, + onError, + }); + + const ackRaw = await handler(SAMPLE_ORU("MRN-001")); + const msa = parseHl7Message(ackRaw).segments[1]; + expect(getField(msa, 1)).toBe("AE"); + expect(getField(msa, 3)).not.toContain("postgres://"); + expect(getField(msa, 3)).not.toContain("secret"); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0].message).toContain("postgres://"); + }); +}); diff --git a/server/src/hl7/mllp-handler.ts b/server/src/hl7/mllp-handler.ts new file mode 100644 index 0000000..6c810f2 --- /dev/null +++ b/server/src/hl7/mllp-handler.ts @@ -0,0 +1,63 @@ +import type { Hl7IngestionJob } from "@modelforge/contracts"; +import type { TenantCaseRepository } from "../store/case-store.js"; +import type { TenantHl7IngestionRepository } from "../store/hl7-ingestion-store.js"; +import { buildAck, type AckContext } from "./ack-builder.js"; +import { ingestInboundMessage } from "./ingestion.js"; +import { DEFAULT_ENCODING_CHARACTERS, Hl7ParseError, parseHl7Message, type Hl7Message } from "./message.js"; + +/** + * Wires hl7/ingestion.ts's `ingestInboundMessage` into an MLLP-shaped + * `(rawMessage) => Promise` handler for mllp-server.ts — the only + * place in this codebase an inbound HL7 v2 message reaches the ingestion + * pipeline with no bearer token/IAM check of its own (see mllp-server.ts's + * own doc comment on why: MLLP's trust model is network-level, not + * per-message). The synthetic audit actor below (`system:hl7-mllp`) + * mirrors the same `"system:"` convention this codebase already + * uses for other automated, non-human-initiated actions. + * + * Never lets a raw error message (which could carry internal detail — a + * database error, a stack frame) reach the wire in an ACK/NACK: an + * `Hl7ParseError` reports its own (already user-safe) message, anything + * else reports a fixed generic string, with the real error only ever + * logged server-side via `onError`. + */ +export interface MllpIngestionHandlerOptions { + organizationId: string; + caseRepo: TenantCaseRepository; + ingestionRepo: TenantHl7IngestionRepository; + ackContext: AckContext; + onIngested?: (job: Hl7IngestionJob) => void; + onError?: (err: Error) => void; +} + +const SYSTEM_ACTOR_SUBJECT = "system:hl7-mllp"; + +function summarize(job: Hl7IngestionJob): string { + if (job.status === "applied") return job.messageType === "ORU^R01" ? `applied — ${job.observationsAdded ?? 0} observation(s) added` : "applied — visit event recorded"; + if (job.matchStatus === "ambiguous") return "ambiguous patient match — queued for review"; + if (job.matchStatus === "no-match") return "no matching patient — queued for review"; + return "queued for review"; +} + +export function createMllpIngestionHandler(options: MllpIngestionHandlerOptions): (rawMessage: string) => Promise { + return async (rawMessage: string): Promise => { + let original: Hl7Message; + try { + original = parseHl7Message(rawMessage); + } catch { + // Can't even extract MSH-10 to reference in MSA-2 — build the + // most minimal reject possible against an empty stand-in. + return buildAck({ segments: [], encoding: DEFAULT_ENCODING_CHARACTERS }, "AR", options.ackContext, "malformed message: could not parse an MSH segment"); + } + + try { + const { job } = await ingestInboundMessage(options.caseRepo, options.ingestionRepo, rawMessage, { externalSubject: SYSTEM_ACTOR_SUBJECT, organizationId: options.organizationId }); + options.onIngested?.(job); + return buildAck(original, "AA", options.ackContext, summarize(job)); + } catch (err) { + if (err instanceof Hl7ParseError) return buildAck(original, "AR", options.ackContext, err.message); + options.onError?.(err instanceof Error ? err : new Error(String(err))); + return buildAck(original, "AE", options.ackContext, "internal processing error"); + } + }; +} diff --git a/server/src/hl7/mllp-server.test.ts b/server/src/hl7/mllp-server.test.ts new file mode 100644 index 0000000..80fb1d3 --- /dev/null +++ b/server/src/hl7/mllp-server.test.ts @@ -0,0 +1,141 @@ +import net from "node:net"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMllpServer, startMllpServer, type MllpServerOptions } from "./mllp-server.js"; + +const VT = 0x0b; +const FS = 0x1c; +const CR = 0x0d; + +function frame(message: string): Buffer { + return Buffer.concat([Buffer.from([VT]), Buffer.from(message, "utf8"), Buffer.from([FS, CR])]); +} + +/** Collects complete MLLP-framed replies from a socket's data stream. */ +function replyCollector(socket: net.Socket): { next: () => Promise } { + let buffer = Buffer.alloc(0); + const pending: string[] = []; + const waiters: Array<(value: string) => void> = []; + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + for (;;) { + const start = buffer.indexOf(VT); + if (start === -1) return; + const end = buffer.indexOf(Buffer.from([FS, CR]), start + 1); + if (end === -1) return; + const text = buffer.subarray(start + 1, end).toString("utf8"); + buffer = buffer.subarray(end + 2); + const waiter = waiters.shift(); + if (waiter) waiter(text); + else pending.push(text); + } + }); + return { + next: () => new Promise((resolve) => { + const value = pending.shift(); + if (value !== undefined) resolve(value); + else waiters.push(resolve); + }), + }; +} + +async function connect(port: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.connect(port, "127.0.0.1"); + socket.once("connect", () => resolve(socket)); + socket.once("error", reject); + }); +} + +describe("MLLP server", () => { + let cleanup: (() => Promise) | undefined; + + afterEach(async () => { + await cleanup?.(); + cleanup = undefined; + }); + + async function start(overrides: Partial = {}): Promise<{ port: number; handler: ReturnType }> { + const handler = vi.fn(async (raw: string) => `ACK-FOR:${raw}`); + const { close, server } = await startMllpServer({ handler, port: 0, host: "127.0.0.1", ...overrides }); + cleanup = close; + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected a TCP address"); + return { port: address.port, handler }; + } + + it("frames a single message, calls the handler with the unframed text, and returns a correctly-framed reply", async () => { + const { port, handler } = await start(); + const socket = await connect(port); + const replies = replyCollector(socket); + socket.write(frame("MSH|test-message-1")); + expect(await replies.next()).toBe("ACK-FOR:MSH|test-message-1"); + expect(handler).toHaveBeenCalledWith("MSH|test-message-1"); + socket.destroy(); + }); + + it("handles two messages sent back to back in the same write, replying to each in order", async () => { + const { port } = await start(); + const socket = await connect(port); + const replies = replyCollector(socket); + socket.write(Buffer.concat([frame("first"), frame("second")])); + expect(await replies.next()).toBe("ACK-FOR:first"); + expect(await replies.next()).toBe("ACK-FOR:second"); + socket.destroy(); + }); + + it("reassembles a message split across multiple TCP writes (a frame arriving in chunks)", async () => { + const { port } = await start(); + const socket = await connect(port); + const replies = replyCollector(socket); + const whole = frame("split-message"); + socket.write(whole.subarray(0, 5)); + await new Promise((r) => setTimeout(r, 20)); + socket.write(whole.subarray(5)); + expect(await replies.next()).toBe("ACK-FOR:split-message"); + socket.destroy(); + }); + + it("preserves ACK order even when the handler resolves out of order across messages", async () => { + const order = ["slow", "fast"]; + const handler = vi.fn(async (raw: string) => { + if (raw === "slow") await new Promise((r) => setTimeout(r, 30)); + return `ACK:${raw}`; + }); + const { close, server } = await startMllpServer({ handler, port: 0, host: "127.0.0.1" }); + cleanup = close; + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected a TCP address"); + const socket = await connect(address.port); + const replies = replyCollector(socket); + socket.write(Buffer.concat([frame(order[0]), frame(order[1])])); + expect(await replies.next()).toBe("ACK:slow"); + expect(await replies.next()).toBe("ACK:fast"); + socket.destroy(); + }); + + it("drops a connection that exceeds maxMessageBytes without ever completing a frame", async () => { + const onError = vi.fn(); + const { port } = await start({ maxMessageBytes: 100, onError }); + const socket = await connect(port); + const closed = new Promise((resolve) => socket.once("close", resolve)); + // Never send FS/CR — an unterminated, oversized frame. + socket.write(Buffer.concat([Buffer.from([VT]), Buffer.alloc(200, 0x41)])); + await closed; + expect(onError).toHaveBeenCalled(); + }); + + it("refuses a new connection once maxConcurrentConnections is reached", async () => { + const { port } = await start({ maxConcurrentConnections: 1 }); + const first = await connect(port); + const second = await connect(port); + const secondClosed = new Promise((resolve) => second.once("close", resolve)); + await secondClosed; + first.destroy(); + }); + + it("createMllpServer builds a server without starting it (caller controls listen)", () => { + const server = createMllpServer({ handler: async () => "x", port: 0 }); + expect(server.listening).toBe(false); + server.close(); + }); +}); diff --git a/server/src/hl7/mllp-server.ts b/server/src/hl7/mllp-server.ts new file mode 100644 index 0000000..f776b40 --- /dev/null +++ b/server/src/hl7/mllp-server.ts @@ -0,0 +1,167 @@ +import net from "node:net"; + +/** + * MLLP (Minimal Lower Layer Protocol) — the TCP framing real HL7 v2 + * transport almost always uses: `message`, `VT`=0x0B (start + * block), `FS`=0x1C, `CR`=0x0D (end block, two bytes together). This + * module is transport/framing only — no HL7 parsing, no patient matching, + * no IAM, no knowledge of ORU/ADT/anything — `options.handler` (supplied + * by the caller, see hl7/mllp-handler.ts for the one this codebase wires + * up) owns everything about what a message means and what to reply. + * + * Trust model, stated plainly because it differs from every other route in + * this codebase: **a raw TCP connection carries no bearer token, no OIDC + * identity, nothing IAM can check.** Real HL7 v2/MLLP predates OAuth and is + * conventionally trusted at the network layer instead — a private network, + * a VPN, an IP allowlist, or mutual TLS the deployment's own infrastructure + * enforces (not this module, which speaks plain TCP with no TLS of its + * own). This is why `host` defaults to loopback-only (`127.0.0.1`) and + * requires an explicit opt-in override to bind anywhere reachable from + * outside the host — see index.ts's own config-gating of this module for + * the full posture (off unless explicitly configured, one specific + * organization per listener, never a public bind by default). + * + * DoS-conscious by construction, not just by convention: bounded + * accumulated-buffer size (a sender that never completes a frame, or + * streams garbage, gets disconnected rather than growing memory + * unboundedly), a per-connection idle timeout, and a cap on concurrent + * connections. + */ + +const VT = 0x0b; +const FS = 0x1c; +const CR = 0x0d; +const FRAME_END = Buffer.from([FS, CR]); + +export interface MllpServerOptions { + /** Called once per received message (already stripped of MLLP framing) + * — must resolve to the raw HL7 v2 ACK/NACK message text to send back, + * and must not itself throw (a thrown error is caught and logged via + * `onError`, and the connection that triggered it is dropped without a + * reply, rather than risking an unhandled rejection crashing the + * process or an error's raw text — which could carry internal detail + * never meant to cross this trust boundary — being sent over the + * wire). */ + handler: (rawMessage: string) => Promise; + /** Defaults to "127.0.0.1" — see this file's own top doc comment on + * why a wider bind is never the default. */ + host?: string; + port: number; + /** Defaults to 1 MiB. A real HL7 v2 message is always small (KBs); + * anything accumulating past this on one connection without completing + * a frame is treated as abusive or broken, and the connection is + * dropped. */ + maxMessageBytes?: number; + /** Defaults to 30s. An idle connection (no data, no frame completed) + * past this is closed — bounds how many connections can sit open + * doing nothing. */ + connectionIdleTimeoutMs?: number; + /** Defaults to 50. A new connection past this count while at capacity + * is refused immediately. */ + maxConcurrentConnections?: number; + onError?: (err: Error) => void; + onConnection?: (remoteAddress: string | undefined) => void; +} + +/** Builds (but does not start listening — call `.listen(port, host)` or + * use the `port`/`host` already on `options`, done for you by + * `startMllpServer` below) an MLLP TCP server. */ +export function createMllpServer(options: MllpServerOptions): net.Server { + const maxMessageBytes = options.maxMessageBytes ?? 1_048_576; + const idleTimeoutMs = options.connectionIdleTimeoutMs ?? 30_000; + const maxConnections = options.maxConcurrentConnections ?? 50; + let activeConnections = 0; + + const server = net.createServer((socket) => { + if (activeConnections >= maxConnections) { + socket.destroy(); + return; + } + activeConnections++; + options.onConnection?.(socket.remoteAddress); + socket.setTimeout(idleTimeoutMs); + + let buffer = Buffer.alloc(0); + // Serializes ACK replies in the order their messages were framed, + // even though `options.handler` is async — without this, two + // frames arriving in the same TCP chunk could resolve out of + // order and confuse a sender expecting one ACK per message in + // sequence. + let queue: Promise = Promise.resolve(); + + function reply(ack: string): void { + if (socket.writable) socket.write(Buffer.concat([Buffer.from([VT]), Buffer.from(ack, "utf8"), FRAME_END])); + } + + function enqueue(rawMessage: string): void { + queue = queue.then(async () => { + try { + const ack = await options.handler(rawMessage); + reply(ack); + } catch (err) { + // The handler contract says it shouldn't throw — this + // is a last-resort guard, not the normal error path + // (hl7/mllp-handler.ts's own handler always catches + // its own errors and returns a NACK string instead). + // Never echo the error's own message back over the + // wire; just drop this reply and log server-side. + options.onError?.(err instanceof Error ? err : new Error(String(err))); + } + }); + } + + socket.on("data", (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + if (buffer.length > maxMessageBytes) { + options.onError?.(new Error(`MLLP connection exceeded ${maxMessageBytes} bytes without completing a frame — dropping connection.`)); + socket.destroy(); + return; + } + for (;;) { + const start = buffer.indexOf(VT); + if (start === -1) { + buffer = Buffer.alloc(0); + return; + } + const end = buffer.indexOf(FRAME_END, start + 1); + if (end === -1) { + // Incomplete frame — keep from the start marker + // onward (discarding any garbage that preceded it) + // and wait for more data. + buffer = start > 0 ? buffer.subarray(start) : buffer; + return; + } + enqueue(buffer.subarray(start + 1, end).toString("utf8")); + buffer = buffer.subarray(end + FRAME_END.length); + } + }); + + socket.on("timeout", () => socket.destroy()); + socket.on("error", (err) => options.onError?.(err)); + socket.on("close", () => { + activeConnections--; + }); + }); + + server.on("error", (err) => options.onError?.(err)); + return server; +} + +/** Convenience wrapper: builds and starts listening, defaulting `host` to + * loopback. Returns the server plus a `close()` that resolves once fully + * stopped (existing connections included) — index.ts uses this for a + * clean shutdown. */ +export async function startMllpServer(options: MllpServerOptions): Promise<{ server: net.Server; close: () => Promise }> { + const server = createMllpServer(options); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(options.port, options.host ?? "127.0.0.1", () => { + server.removeListener("error", reject); + resolve(); + }); + }); + return { + server, + close: () => new Promise((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))), + }; +} diff --git a/server/src/hl7/oru-builder.test.ts b/server/src/hl7/oru-builder.test.ts new file mode 100644 index 0000000..d49a8b3 --- /dev/null +++ b/server/src/hl7/oru-builder.test.ts @@ -0,0 +1,134 @@ +import { diagnosticReportSchema, imagingStudySchema } from "@modelforge/contracts"; +import { describe, expect, it } from "vitest"; +import { getField, parseHl7Message, unescapeHl7Text } from "./message.js"; +import { buildOruR01 } from "./oru-builder.js"; + +const CONTEXT = { sendingApplication: "ModelForge", sendingFacility: "Example Health System", receivingApplication: "EHR", receivingFacility: "Example Health System", messageControlId: "MSG-TEST-001", now: new Date("2026-03-15T14:30:00Z") }; + +function study(overrides: Partial[0]> = {}) { + return imagingStudySchema.parse({ + id: "study-1", + studyInstanceUid: "1.2.3.4", + patientIdentifier: { value: "MRN-001", issuer: "TEST-HOSPITAL" }, + modalities: ["CT"], + numberOfSeries: 1, + numberOfInstances: 1, + status: "available", + sensitivity: "normal", + ingestionStatus: "published", + createdAt: "2026-03-15T00:00:00.000Z", + updatedAt: "2026-03-15T00:00:00.000Z", + ...overrides, + }); +} + +function report(overrides: Partial[0]> = {}) { + return diagnosticReportSchema.parse({ + id: "report-1", + studyId: "study-1", + status: "final", + conclusion: "No acute findings.", + authorUserId: "user-1", + authoredAt: "2026-03-15T10:00:00.000Z", + signedByUserId: "user-1", + signedAt: "2026-03-15T10:30:00.000Z", + isCritical: false, + createdAt: "2026-03-15T10:00:00.000Z", + updatedAt: "2026-03-15T10:30:00.000Z", + ...overrides, + }); +} + +describe("buildOruR01", () => { + it("produces a parseable ORU^R01 message with correct header fields", () => { + const raw = buildOruR01(report(), study(), CONTEXT); + const message = parseHl7Message(raw); + const msh = message.segments[0]; + expect(getField(msh, 9)).toBe("ORU^R01"); + expect(getField(msh, 10)).toBe("MSG-TEST-001"); + expect(getField(msh, 11)).toBe("P"); + expect(getField(msh, 12)).toBe("2.5.1"); + expect(getField(msh, 3)).toBe("ModelForge"); + expect(getField(msh, 4)).toBe("Example Health System"); + expect(getField(msh, 7)).toBe("20260315143000"); + }); + + it("PID-3 encodes the patient identifier as id^^^issuer", () => { + const raw = buildOruR01(report(), study(), CONTEXT); + const message = parseHl7Message(raw); + const pid = message.segments.find((s) => s.id === "PID")!; + expect(getField(pid, 3)).toBe("MRN-001^^^TEST-HOSPITAL"); + }); + + it("never fabricates a patient name or birth date — same disclosed gap as the FHIR Patient mapper", () => { + const raw = buildOruR01(report(), study(), CONTEXT); + const message = parseHl7Message(raw); + const pid = message.segments.find((s) => s.id === "PID")!; + expect(getField(pid, 5)).toBe(""); + expect(getField(pid, 7)).toBe(""); + }); + + it("OBR-4 carries a local, uncoded service identifier rather than a fabricated LOINC/CPT code", () => { + const raw = buildOruR01(report(), study(), CONTEXT); + const message = parseHl7Message(raw); + const obr = message.segments.find((s) => s.id === "OBR")!; + expect(getField(obr, 4)).toBe("DX-REPORT^Diagnostic imaging report"); + }); + + it.each([ + ["preliminary", "P"], + ["final", "F"], + ["amended", "C"], + ["corrected", "C"], + ["cancelled", "X"], + ] as const)("maps DiagnosticReport.status %s to HL7 v2 result status %s", (status, expected) => { + const r = status === "amended" || status === "corrected" + ? report({ status, previousVersionId: "report-0", amendmentReason: "correction" }) + : report({ status }); + const raw = buildOruR01(r, study(), CONTEXT); + const message = parseHl7Message(raw); + const obr = message.segments.find((s) => s.id === "OBR")!; + expect(getField(obr, 25)).toBe(expected); + }); + + it("emits exactly one CONCLUSION OBX for a report with no conclusionCode and not critical", () => { + const raw = buildOruR01(report(), study(), CONTEXT); + const message = parseHl7Message(raw); + const obxSegments = message.segments.filter((s) => s.id === "OBX"); + expect(obxSegments).toHaveLength(1); + expect(unescapeHl7Text(getField(obxSegments[0], 5))).toBe("No acute findings."); + }); + + it("adds a second OBX for conclusionCode when present", () => { + const raw = buildOruR01(report({ conclusionCode: "R91.8" }), study(), CONTEXT); + const message = parseHl7Message(raw); + const obxSegments = message.segments.filter((s) => s.id === "OBX"); + expect(obxSegments).toHaveLength(2); + expect(getField(obxSegments[1], 5)).toBe("R91.8"); + }); + + it("adds a critical-flag OBX when the report is marked critical, after any conclusionCode OBX", () => { + const raw = buildOruR01(report({ conclusionCode: "R91.8", isCritical: true }), study(), CONTEXT); + const message = parseHl7Message(raw); + const obxSegments = message.segments.filter((s) => s.id === "OBX"); + expect(obxSegments).toHaveLength(3); + expect(getField(obxSegments[2], 3)).toContain("CRITICAL-FLAG"); + expect(getField(obxSegments[2], 1)).toBe("3"); + }); + + it("escapes delimiter characters in the conclusion text and recovers the exact original after parsing", () => { + const tricky = "Impression: mild finding & concern (ratio 3|4, class^A~B)"; + const raw = buildOruR01(report({ conclusion: tricky }), study(), CONTEXT); + // The raw message must still be well-formed HL7 (parseable at all, + // i.e. the embedded "|" never created a spurious extra field). + const message = parseHl7Message(raw); + const obx = message.segments.find((s) => s.id === "OBX")!; + expect(unescapeHl7Text(getField(obx, 5))).toBe(tricky); + }); + + it("generates a random, non-empty messageControlId when none is supplied", () => { + const raw = buildOruR01(report(), study(), { ...CONTEXT, messageControlId: undefined }); + const message = parseHl7Message(raw); + expect(getField(message.segments[0], 10).length).toBeGreaterThan(0); + }); +}); diff --git a/server/src/hl7/oru-builder.ts b/server/src/hl7/oru-builder.ts new file mode 100644 index 0000000..9728f82 --- /dev/null +++ b/server/src/hl7/oru-builder.ts @@ -0,0 +1,129 @@ +import type { DiagnosticReport, ImagingStudy } from "@modelforge/contracts"; +import { buildHl7Message, buildSegment, DEFAULT_ENCODING_CHARACTERS, escapeHl7Text, type Hl7Message } from "./message.js"; + +/** + * Builds an HL7 v2.5.1 ORU^R01 (unsolicited observation result) message + * from this system's own DiagnosticReport/ImagingStudy/PatientCase — the + * outbound half of "HL7 v2 support," mirroring exactly what + * server/src/fhir/mappers.ts's `toFhirDiagnosticReport` does for FHIR: a + * pure, from-scratch mapping over data this codebase already has, not a + * new persistence layer or a claim of conformance to any specific + * receiving system's implementation guide (real HL7 v2 integrations are + * always conformance-tested per trading partner — see + * docs/HL7_V2_INTEGRATION.md). + * + * Segment structure: MSH (message header) / PID (patient identification, + * built entirely from `ImagingStudy.patientIdentifier` — same disclosed + * limitation as FHIR's own Patient mapping — no structured name or birth + * date exists anywhere in this system's domain model, so PID-5/PID-7 are + * left empty rather than fabricated, and there is no separate PatientCase + * parameter here at all since nothing in this mapping reads one) / OBR + * (the report itself) / one OBX per report field this maps (conclusion, + * conclusion code, critical flag) — OBX-2 "TX" (text), the correct HL7 v2 + * value type for free-text narrative content, never a coded value this + * system has no real terminology binding for. + */ + +export interface OruMessageContext { + sendingApplication: string; + sendingFacility: string; + receivingApplication: string; + receivingFacility: string; + /** Defaults to a freshly-generated UUID — pass an explicit one only for + * deterministic tests. */ + messageControlId?: string; + /** Defaults to "P" (production) — "T" (test)/"D" (debug) per HL7 v2's + * own MSH-11 processing-id values, for a non-production environment + * that still wants a realistic message shape. */ + processingId?: "P" | "T" | "D"; + /** Defaults to `new Date()` — overridable only for deterministic tests. */ + now?: Date; +} + +function hl7Timestamp(date: Date): string { + // HL7 v2's own TS data type, to-the-second precision (YYYYMMDDHHMMSS) — + // this codebase has no sub-second-meaningful clinical event here to + // justify finer precision, and to-the-second is the most common real- + // world granularity for this field. + const pad = (n: number, width = 2) => String(n).padStart(width, "0"); + return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}`; +} + +/** HL7 v2's OBR-25/OBX-11 "result status" — the closest standard code set + * to this system's own DiagnosticReport.status; "final"/"corrected" map + * cleanly, the rest fall back to the nearest honest equivalent rather than + * a fabricated one-to-one that doesn't exist in the HL7 v2 table. */ +const RESULT_STATUS: Record = { + preliminary: "P", + final: "F", + amended: "C", // "corrected", HL7 v2's own closest match to an amendment + corrected: "C", + cancelled: "X", + "entered-in-error": "W", // "wrong patient" - table 0085 has no generic "entered in error"; nearest documented reason a result would be withdrawn +}; + +function randomMessageControlId(): string { + return `MF${Date.now().toString(36).toUpperCase()}${Math.random().toString(36).slice(2, 8).toUpperCase()}`; +} + +export function buildOruR01(report: DiagnosticReport, study: ImagingStudy, context: OruMessageContext): string { + const now = context.now ?? new Date(); + const encoding = DEFAULT_ENCODING_CHARACTERS; + const esc = (value: string) => escapeHl7Text(value, encoding); + + const msh = buildSegment("MSH", { + 1: encoding.field, + 2: `${encoding.component}${encoding.repetition}${encoding.escape}${encoding.subcomponent}`, + 3: esc(context.sendingApplication), + 4: esc(context.sendingFacility), + 5: esc(context.receivingApplication), + 6: esc(context.receivingFacility), + 7: hl7Timestamp(now), + 9: "ORU^R01", + 10: context.messageControlId ?? randomMessageControlId(), + 11: context.processingId ?? "P", + 12: "2.5.1", + }); + + // PID-3 (patient identifier list): this system's ImagingStudy.patientIdentifier + // is an {issuer, value} pair — HL7 v2's own PID-3 repeated-field shape + // for exactly that: ^^^. No name (PID-5) or birth date + // (PID-7): same disclosed gap as fhir/mappers.ts's toFhirPatient — this + // system's domain model has neither field anywhere to map from. + const pid = buildSegment("PID", { + 1: "1", + 3: `${esc(study.patientIdentifier.value)}${encoding.component}${encoding.component}${encoding.component}${esc(study.patientIdentifier.issuer)}`, + }); + + // OBR-4 (universal service identifier): a local, un-coded text + // identifier — "diagnostic imaging report," this codebase's own + // fixed description — rather than a fabricated LOINC/CPT code this + // system has no real terminology binding for (same reasoning + // fhir/mappers.ts's toFhirDiagnosticReport documents for its own + // `code.text`-only CodeableConcept). + const obr = buildSegment("OBR", { + 1: "1", + 4: `DX-REPORT${encoding.component}Diagnostic imaging report`, + 7: hl7Timestamp(new Date(report.authoredAt)), + 22: hl7Timestamp(new Date(report.signedAt ?? report.authoredAt)), + 25: RESULT_STATUS[report.status], + }); + + // DiagnosticReport itself carries only conclusion/conclusionCode (the + // structured evidence/uncertainty/followUp breakdown lives on AiOutput, + // a different resource this builder doesn't map) — one OBX for the + // conclusion, one more when conclusionCode is present, one more when + // the report is flagged critical. + const obxSegments = [ + buildSegment("OBX", { 1: "1", 2: "TX", 3: `CONCLUSION${encoding.component}Conclusion`, 5: esc(report.conclusion), 11: RESULT_STATUS[report.status] }), + ...(report.conclusionCode + ? [buildSegment("OBX", { 1: "2", 2: "TX", 3: `CONCLUSION-CODE${encoding.component}Conclusion code`, 5: esc(report.conclusionCode), 11: RESULT_STATUS[report.status] })] + : []), + ...(report.isCritical + ? [buildSegment("OBX", { 1: String((report.conclusionCode ? 3 : 2)), 2: "TX", 3: `CRITICAL-FLAG${encoding.component}Critical result flag`, 5: "Y - critical result, requires acknowledgement", 11: "F" })] + : []), + ]; + + const message: Hl7Message = { encoding, segments: [msh, pid, obr, ...obxSegments] }; + return buildHl7Message(message); +} diff --git a/server/src/index.ts b/server/src/index.ts index 5c7903b..45b4790 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,6 +1,7 @@ import { Pool } from "pg"; import { buildApp } from "./app.js"; -import { resolveJwks } from "./auth/oidc-verifier.js"; +import { resolveAuthorizationServerMetadata, resolveJwks } from "./auth/oidc-verifier.js"; +import { buildSmartConfiguration } from "./fhir/smart-configuration.js"; import { createCacheFactory } from "./cache/create-cache.js"; import { loadConfig, type AppConfig } from "./config.js"; import type { AccessGovernanceStore } from "./store/access-governance-store.js"; @@ -22,6 +23,9 @@ import { InMemoryIdempotencyStore } from "./store/in-memory-idempotency-store.js import { InMemoryPrincipalStore } from "./store/in-memory-principal-store.js"; import type { McpRegistryStore } from "./store/mcp-registry-store.js"; import { InMemoryMcpRegistryStore, PostgresMcpRegistryStore } from "./store/mcp-registry-store.js"; +import type { McpClinicalStore } from "./store/mcp-clinical-store.js"; +import { InMemoryMcpClinicalStore, PostgresMcpClinicalStore } from "./store/mcp-clinical-store.js"; +import { Rs256McpApprovalTicketIssuer } from "./mcp-approval-issuer.js"; import { runMigrations } from "./store/migrate.js"; import { PostgresCaseStore } from "./store/postgres-case-store.js"; import { PostgresCaseMigrationStore } from "./store/postgres-case-migration-store.js"; @@ -39,7 +43,9 @@ import { InMemorySessionStore } from "./store/in-memory-session-store.js"; import { PostgresSessionStore } from "./store/postgres-session-store.js"; import type { TenantBackupStore } from "./store/tenant-backup-store.js"; import { InMemoryTenantBackupStore, PostgresTenantBackupStore } from "./store/tenant-backup-store.js"; -import { PostgresTenantDirectory, StoreTenantDirectory, type TenantDirectory } from "./tenant-context.js"; +import { PostgresTenantDirectory, StoreTenantDirectory, schemaNameForTenant, type TenantDirectory } from "./tenant-context.js"; +import { startMllpServer } from "./hl7/mllp-server.js"; +import { createMllpIngestionHandler } from "./hl7/mllp-handler.js"; import { randomBytes } from "node:crypto"; import * as os from "node:os"; import * as path from "node:path"; @@ -56,6 +62,13 @@ import { AiInferenceAdmission } from "./ai-gateway/admission.js"; import type { ComputeControlStore } from "./store/compute-control-store.js"; import { InMemoryComputeControlStore } from "./store/compute-control-store.js"; import { PostgresComputeControlStore } from "./store/postgres-compute-control-store.js"; +import type { Hl7IngestionStore } from "./store/hl7-ingestion-store.js"; +import { InMemoryHl7IngestionStore } from "./store/in-memory-hl7-ingestion-store.js"; +import { PostgresHl7IngestionStore } from "./store/postgres-hl7-ingestion-store.js"; +import type { SmartLaunchStore } from "./store/smart-launch-store.js"; +import { InMemorySmartLaunchStore } from "./store/in-memory-smart-launch-store.js"; +import { PostgresSmartLaunchStore } from "./store/postgres-smart-launch-store.js"; +import { loadTokenEncryptionKey } from "./smart-launch/token-crypto.js"; /** How long GET /health waits on its own DB probe before reporting * degraded — independent of the pool's own connectionTimeoutMillis/ @@ -83,9 +96,12 @@ interface BuiltStores { accessGovernanceStore: AccessGovernanceStore; scimTokenStore: ScimTokenStore; imagingStore: ImagingStore; + hl7IngestionStore: Hl7IngestionStore; + smartLaunchStore: SmartLaunchStore; aiGatewayStore: AiGatewayStore; aiProviderRegistryStore: AiProviderRegistryStore; mcpRegistryStore: McpRegistryStore; + mcpClinicalStore: McpClinicalStore; computeControlStore: ComputeControlStore; tenantDirectory: TenantDirectory; closeCache: () => Promise; @@ -134,9 +150,12 @@ async function buildStores(config: AppConfig): Promise { let accessGovernanceStore: AccessGovernanceStore; let scimTokenStore: ScimTokenStore; let imagingStore: ImagingStore; + let hl7IngestionStore: Hl7IngestionStore; + let smartLaunchStore: SmartLaunchStore; let aiGatewayStore: AiGatewayStore; let aiProviderRegistryStore: AiProviderRegistryStore; let mcpRegistryStore: McpRegistryStore; + let mcpClinicalStore: McpClinicalStore; let computeControlStore: ComputeControlStore; let tenantDirectory: TenantDirectory; let pool: Pool | undefined; @@ -162,9 +181,12 @@ async function buildStores(config: AppConfig): Promise { accessGovernanceStore = new InMemoryAccessGovernanceStore(sharedAuditStore); scimTokenStore = new InMemoryScimTokenStore(sharedAuditStore); imagingStore = new InMemoryImagingStore(sharedAuditStore); + hl7IngestionStore = new InMemoryHl7IngestionStore(sharedAuditStore); + smartLaunchStore = new InMemorySmartLaunchStore(sharedAuditStore); aiGatewayStore = new InMemoryAiGatewayStore(sharedAuditStore); aiProviderRegistryStore = new InMemoryAiProviderRegistryStore(sharedAuditStore); mcpRegistryStore = new InMemoryMcpRegistryStore(sharedAuditStore); + mcpClinicalStore = new InMemoryMcpClinicalStore(sharedAuditStore); computeControlStore = new InMemoryComputeControlStore(sharedAuditStore); tenantDirectory = new StoreTenantDirectory(store); caseStore = new InMemoryCaseStore(sharedAuditStore); @@ -230,16 +252,19 @@ async function buildStores(config: AppConfig): Promise { sessionStore = new PostgresSessionStore(pool); scimTokenStore = new PostgresScimTokenStore(pool); imagingStore = new PostgresImagingStore(pool); + hl7IngestionStore = new PostgresHl7IngestionStore(pool); + smartLaunchStore = new PostgresSmartLaunchStore(pool); aiGatewayStore = new PostgresAiGatewayStore(pool); aiProviderRegistryStore = new PostgresAiProviderRegistryStore(pool); mcpRegistryStore = new PostgresMcpRegistryStore(pool); + mcpClinicalStore = new PostgresMcpClinicalStore(pool); computeControlStore = new PostgresComputeControlStore(pool); } if (!config.cache.enabled) { return { store, caseStore, caseMigrationStore, idempotencyStore, auditStore, auditLegalHoldStore, tenantBackupStore, sessionStore, - principalStore, accessGovernanceStore, scimTokenStore, imagingStore, aiGatewayStore, aiProviderRegistryStore, mcpRegistryStore, computeControlStore, tenantDirectory, closeCache: async () => {}, pool, + principalStore, accessGovernanceStore, scimTokenStore, imagingStore, hl7IngestionStore, smartLaunchStore, aiGatewayStore, aiProviderRegistryStore, mcpRegistryStore, mcpClinicalStore, computeControlStore, tenantDirectory, closeCache: async () => {}, pool, }; } @@ -252,7 +277,7 @@ async function buildStores(config: AppConfig): Promise { const cachingStore = new CachingIamStore(store, factory, { negativeCacheTtlMs: config.cache.negativeTtlMs }); return { store: cachingStore, caseStore, caseMigrationStore, idempotencyStore, auditStore, auditLegalHoldStore, tenantBackupStore, sessionStore, - principalStore, accessGovernanceStore, scimTokenStore, imagingStore, aiGatewayStore, aiProviderRegistryStore, mcpRegistryStore, computeControlStore, tenantDirectory, closeCache: close, pool, cachingStore, + principalStore, accessGovernanceStore, scimTokenStore, imagingStore, hl7IngestionStore, smartLaunchStore, aiGatewayStore, aiProviderRegistryStore, mcpRegistryStore, mcpClinicalStore, computeControlStore, tenantDirectory, closeCache: close, pool, cachingStore, }; } @@ -292,9 +317,23 @@ async function main(): Promise { jwks: await resolveJwks(additional), })) ); + // Best-effort, unlike resolveJwks above: SMART on FHIR launch + // (routes/fhir.ts's .well-known/smart-configuration) is an additive + // capability, not something every existing deployment's auth depends + // on — an IdP whose discovery document happens to omit + // authorization_endpoint/token_endpoint (or an OIDC_JWKS_URI override + // deployment that skips discovery entirely for JWKS) must not be a + // startup failure for the whole server. That one route degrades to a + // 503 instead — see RouteDeps's own doc comment. + let smartConfiguration: ReturnType | undefined; + try { + smartConfiguration = buildSmartConfiguration(await resolveAuthorizationServerMetadata(config.oidc)); + } catch (err) { + console.warn("SMART on FHIR discovery unavailable — GET /organizations/:id/fhir/r4/.well-known/smart-configuration will 503:", err); + } const { store, caseStore, caseMigrationStore, idempotencyStore, auditStore, auditLegalHoldStore, tenantBackupStore, sessionStore, - principalStore, accessGovernanceStore, scimTokenStore, imagingStore, aiGatewayStore, aiProviderRegistryStore, mcpRegistryStore, computeControlStore, tenantDirectory, closeCache, pool, cachingStore, + principalStore, accessGovernanceStore, scimTokenStore, imagingStore, hl7IngestionStore, smartLaunchStore, aiGatewayStore, aiProviderRegistryStore, mcpRegistryStore, mcpClinicalStore, computeControlStore, tenantDirectory, closeCache, pool, cachingStore, } = await buildStores(config); const stopCacheStatsLogging = cachingStore ? logCacheStatsPeriodically(cachingStore) : undefined; let imagingObjectStore: ImagingObjectStore; @@ -344,9 +383,16 @@ async function main(): Promise { imagingStorageMode, dicomwebMode: config.imaging.pacs ? "pacs-proxy" : "local", imagingContentDelivery, + hl7IngestionStore, + smartLaunchStore, + smartLaunchEncryptionKey: config.smartLaunchEncryptionKeyBase64 ? loadTokenEncryptionKey(config.smartLaunchEncryptionKeyBase64) : undefined, aiGatewayStore, aiProviderRegistryStore, mcpRegistryStore, + mcpClinicalStore, + mcpApprovalTicketIssuer: process.env.MCP_APPROVAL_PRIVATE_KEY_PEM && process.env.MCP_APPROVAL_ISSUER && process.env.MCP_APPROVAL_AUDIENCE + ? new Rs256McpApprovalTicketIssuer(process.env.MCP_APPROVAL_PRIVATE_KEY_PEM.replace(/\\n/g, "\n"), process.env.MCP_APPROVAL_ISSUER, process.env.MCP_APPROVAL_AUDIENCE) + : undefined, computeControlStore, computePolicyPublicKeyPem: process.env.COMPUTE_POLICY_PUBLIC_KEY_PEM, aiAdmission, @@ -362,9 +408,44 @@ async function main(): Promise { rateLimit: config.rateLimit, trustProxy: config.trustProxy, adminConsoleOrigin: config.adminConsoleOrigin, + smartConfiguration, }); await app.listen({ port: config.port, host: "0.0.0.0" }); + // Opt-in MLLP (HL7 v2 TCP transport) listener — see + // config.ts's AppConfig.hl7Mllp and hl7/mllp-server.ts's own doc + // comments for the full trust-model reasoning. undefined unless every + // one of HL7_MLLP_PORT/HL7_MLLP_ORGANIZATION_ID is explicitly set — + // this process never opens an extra TCP listener by accident. + let closeMllpServer: (() => Promise) | undefined; + if (config.hl7Mllp) { + const organization = await tenantDirectory.resolve(config.hl7Mllp.organizationId); + if (!organization) { + throw new Error(`HL7_MLLP_ORGANIZATION_ID "${config.hl7Mllp.organizationId}" does not resolve to an existing organization.`); + } + const mllpTenantContext = Object.freeze({ + organizationId: organization.id, + schemaName: organization.tenantSchema ?? schemaNameForTenant(organization.id), + issuer: "system:hl7-mllp", + subject: "system:hl7-mllp", + }); + const handler = createMllpIngestionHandler({ + organizationId: organization.id, + caseRepo: caseStore.forTenant(mllpTenantContext), + ingestionRepo: hl7IngestionStore.forTenant(mllpTenantContext), + ackContext: { sendingApplication: "ModelForge", sendingFacility: organization.name }, + onError: (err) => console.error("HL7 MLLP handler error:", err), + }); + const mllp = await startMllpServer({ + handler, + host: config.hl7Mllp.host, + port: config.hl7Mllp.port, + onError: (err) => console.error("HL7 MLLP server error:", err), + }); + closeMllpServer = mllp.close; + console.log(`HL7 MLLP listener started on ${config.hl7Mllp.host}:${config.hl7Mllp.port} for organization ${organization.id}.`); + } + // Crash-safety net for AiInferenceAdmission (server/src/ai-gateway/ // admission.ts): reclaims any lease whose holder crashed mid-inference // without releasing it, across every tenant (the admission instance is @@ -407,6 +488,7 @@ async function main(): Promise { clearInterval(aiAdmissionSweepTimer); clearInterval(computeSweepTimer); stopCacheStatsLogging?.(); + await closeMllpServer?.(); await app.close(); await closeCache(); if (pool) await pool.end(); diff --git a/server/src/mcp-approval-issuer.test.ts b/server/src/mcp-approval-issuer.test.ts new file mode 100644 index 0000000..55c0e70 --- /dev/null +++ b/server/src/mcp-approval-issuer.test.ts @@ -0,0 +1,31 @@ +import { generateKeyPairSync, verify } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import type { McpApprovalRequest } from "@modelforge/contracts"; +import { Rs256McpApprovalTicketIssuer } from "./mcp-approval-issuer.js"; + +describe("Rs256McpApprovalTicketIssuer", () => { + it("binds a short-lived ticket to subject, client, tool, and operation digest", async () => { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const issuer = new Rs256McpApprovalTicketIssuer(privateKey.export({ type: "pkcs8", format: "pem" }).toString(), "https://backend.test", "clinical-mcp"); + const request: McpApprovalRequest = { + id: "10000000-0000-4000-8000-000000000001", + organizationId: "10000000-0000-4000-8000-000000000002", + registryEntryId: "10000000-0000-4000-8000-000000000003", + subjectId: "clinician-1", + clientId: "desktop-1", + toolName: "clinical.record_review_decision", + operationDigest: `sha256:${"a".repeat(64)}`, + status: "confirmed", + createdAt: new Date(1_000_000).toISOString(), + confirmedAt: new Date(1_001_000).toISOString(), + expiresAt: new Date(1_300_000).toISOString(), + }; + const token = await issuer.issue(request, 1_000); + const [header, payload, signature] = token.split("."); + expect(verify("RSA-SHA256", Buffer.from(`${header}.${payload}`), publicKey, Buffer.from(signature, "base64url"))).toBe(true); + expect(JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))).toMatchObject({ + iss: "https://backend.test", aud: "clinical-mcp", sub: "clinician-1", azp: "desktop-1", + tool: "clinical.record_review_decision", digest: request.operationDigest, iat: 1_000, exp: 1_300, + }); + }); +}); diff --git a/server/src/mcp-approval-issuer.ts b/server/src/mcp-approval-issuer.ts new file mode 100644 index 0000000..8a0a630 --- /dev/null +++ b/server/src/mcp-approval-issuer.ts @@ -0,0 +1,49 @@ +import { createPrivateKey, randomUUID, sign } from "node:crypto"; +import type { McpApprovalRequest } from "@modelforge/contracts"; + +export interface McpApprovalTicketIssuer { + issue(request: McpApprovalRequest, nowEpochSeconds?: number): Promise; +} + +export class McpApprovalIssuerUnavailableError extends Error {} + +export class UnconfiguredMcpApprovalTicketIssuer implements McpApprovalTicketIssuer { + async issue(): Promise { + throw new McpApprovalIssuerUnavailableError("MCP approval signing is not configured."); + } +} + +function encode(value: unknown): string { + return Buffer.from(JSON.stringify(value), "utf8").toString("base64url"); +} + +export class Rs256McpApprovalTicketIssuer implements McpApprovalTicketIssuer { + private readonly key; + + constructor(privateKeyPem: string, private readonly issuer: string, private readonly audience: string) { + if (!issuer || !audience) throw new Error("MCP approval issuer and audience are required."); + this.key = createPrivateKey(privateKeyPem); + } + + async issue(request: McpApprovalRequest, nowEpochSeconds = Math.floor(Date.now() / 1000)): Promise { + if (request.status !== "confirmed") throw new Error("Only confirmed MCP approval requests can be signed."); + const requestedExpiry = Math.floor(new Date(request.expiresAt).getTime() / 1000); + const exp = Math.min(requestedExpiry, nowEpochSeconds + 300); + if (exp <= nowEpochSeconds) throw new Error("The MCP approval request has expired."); + const header = encode({ alg: "RS256", typ: "JWT" }); + const payload = encode({ + iss: this.issuer, + aud: this.audience, + sub: request.subjectId, + azp: request.clientId, + tool: request.toolName, + digest: request.operationDigest, + jti: randomUUID(), + iat: nowEpochSeconds, + exp, + }); + const signingInput = `${header}.${payload}`; + const signature = sign("RSA-SHA256", Buffer.from(signingInput, "utf8"), this.key).toString("base64url"); + return `${signingInput}.${signature}`; + } +} diff --git a/server/src/routes/ai-gateway.integration.test.ts b/server/src/routes/ai-gateway.integration.test.ts index d719212..26f6d04 100644 --- a/server/src/routes/ai-gateway.integration.test.ts +++ b/server/src/routes/ai-gateway.integration.test.ts @@ -146,6 +146,18 @@ describe("ClinicalAiGateway: end-to-end route security", () => { const getResponse = await app.inject({ method: "GET", url: `/organizations/${orgId}/ai-requests/${submitBody.request.id}`, headers }); expect(getResponse.statusCode).toBe(200); expect(getResponse.json().outputs).toHaveLength(1); + // Evidence provenance: both requested categories are scalar case + // fields (not clinical notes), so before data-minimization.ts's + // synthetic patientCaseField refs this would have had zero + // citations despite both fields having actually reached the model. + const citations = getResponse.json().outputs[0].citations; + expect(citations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ resourceType: "patientCaseField", resourceId: `medications:${caseId}`, locator: "medications" }), + expect.objectContaining({ resourceType: "patientCaseField", resourceId: `allergies:${caseId}`, locator: "allergies" }), + ]) + ); + expect(citations).toHaveLength(2); const reviewResponse = await app.inject({ method: "POST", @@ -159,6 +171,47 @@ describe("ClinicalAiGateway: end-to-end route security", () => { expect(secondReview.statusCode).toBe(409); }); + it("quality-monitor and quality-drift report real aggregate metrics from completed requests, gated by aiGateway:viewAuditTrail", async () => { + const { orgId, headers, caseId, modelId } = await fullySetUp("quality-monitor"); + const payload = { providerModelId: modelId, purposeOfUse: "medication-review", requestedCategories: ["medications", "allergies"] }; + const submit = await app.inject({ method: "POST", url: `/organizations/${orgId}/cases/${caseId}/ai-requests`, headers, payload }); + expect(submit.statusCode).toBe(201); + + const snapshot = await app.inject({ method: "GET", url: `/organizations/${orgId}/ai-provider-models/${modelId}/quality-monitor`, headers }); + expect(snapshot.statusCode).toBe(200); + expect(snapshot.json()).toMatchObject({ providerModelId: modelId, outputCount: 1, unreviewedCount: 1, reviewedRate: 0 }); + + const drift = await app.inject({ method: "GET", url: `/organizations/${orgId}/ai-provider-models/${modelId}/quality-drift?splitAt=${encodeURIComponent(new Date(0).toISOString())}`, headers }); + expect(drift.statusCode).toBe(200); + expect(drift.json()).toMatchObject({ sufficientData: false, drifted: false, alerts: [] }); + + await app.inject({ method: "POST", url: `/organizations/${orgId}/users`, headers, payload: { externalSubject: "idp|no-audit-rights", displayName: "No Rights" } }); + const unprivilegedToken = await tokenFor("idp|no-audit-rights"); + const forbidden = await app.inject({ method: "GET", url: `/organizations/${orgId}/ai-provider-models/${modelId}/quality-monitor`, headers: { authorization: `Bearer ${unprivilegedToken}` } }); + expect(forbidden.statusCode).toBe(403); + }); + + it("omitting providerModelId auto-routes to the tenant's one eligible model — the route-level wiring for model-router.ts", async () => { + const { orgId, headers, caseId } = await fullySetUp("auto-route"); + const submitResponse = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/cases/${caseId}/ai-requests`, + headers, + payload: { purposeOfUse: "medication-review", requestedCategories: ["medications", "allergies"] }, + }); + expect(submitResponse.statusCode).toBe(201); + expect(submitResponse.json().outcome).toBe("completed"); + + const previewResponse = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/cases/${caseId}/ai-requests/preview`, + headers, + payload: { purposeOfUse: "medication-review", requestedCategories: ["medications", "allergies"] }, + }); + expect(previewResponse.statusCode).toBe(200); + expect(previewResponse.json().model?.modelId).toBe("llama3"); + }); + it("registers immutable llama.cpp artifacts and keeps deployments disabled until verification", async () => { const { orgId, headers, modelId } = await fullySetUp("inference-registry"); const sha256 = "a".repeat(64); diff --git a/server/src/routes/ai-gateway.ts b/server/src/routes/ai-gateway.ts index 09d17bb..dd531e8 100644 --- a/server/src/routes/ai-gateway.ts +++ b/server/src/routes/ai-gateway.ts @@ -17,6 +17,8 @@ import { } from "./params.js"; import { withIdempotencyKey } from "./idempotency.js"; import { ClinicalAiGateway } from "../ai-gateway/gateway.js"; +import { SCALAR_CASE_FIELD_CATEGORIES } from "../ai-gateway/data-minimization.js"; +import { computeProductionQualitySnapshot, detectProductionQualityDrift } from "../eval-harness/production-monitor.js"; import { clientForDeployment } from "../ai-gateway/provider-client.js"; /** @@ -54,7 +56,10 @@ const globalCatalogResourceName = (organizationId: string): string => `organizat const submitRequestBodySchema = z .object({ - providerModelId: z.string().min(1), + // Omit to auto-route across every enabled, eligible provider model + // for this tenant — see ai-gateway/model-router.ts and + // ClinicalAiGateway.submitRequest's own doc comment. + providerModelId: z.string().min(1).optional(), purposeOfUse: aiPurposeOfUseSchema, requestedCategories: z.array(z.string().min(1).max(100)).min(1).max(50), selectedDeidentificationJobIds: z.array(z.string().min(1).max(200)).max(10).default([]), @@ -237,6 +242,8 @@ function statusForDeniedOutcome(outcome: string): number { return 503; case "provider-failed": return 502; + case "no-eligible-provider-model": + return 503; default: return 500; } @@ -352,6 +359,20 @@ export function registerAiGatewayRoutes(fastify: FastifyInstance, deps: RouteDep const stored = await deps.imagingStore.forTenant(caller.tenantContext).getStudy(citation.resourceId); if (!stored || stored.study.caseId !== resolved.requestEnvelope.patientCaseId) continue; if (await isPermissionAllowed(deps.store, caller, "imagingStudy:view", `organization:${organizationId}/imagingStudy:${stored.study.id}`)) citations.push(citation); + continue; + } + // data-minimization.ts's synthetic per-field citation + // (`":"`) — no separate permission check + // needed beyond the case-level access resolveRequestForCase + // already required above (unlike imagingStudy, a case field + // is not a separate authorization domain), just re-verified + // existence: a known category, still belonging to this same + // request's case. Same "re-checked only against the live + // resource, not trusted from generation time" posture as + // every other branch here. + if (citation.resourceType === "patientCaseField") { + const [category, caseId] = [citation.resourceId.split(":")[0], citation.resourceId.slice(citation.resourceId.indexOf(":") + 1)]; + if (caseId === resolved.requestEnvelope.patientCaseId && (SCALAR_CASE_FIELD_CATEGORIES as readonly string[]).includes(category)) citations.push(citation); } } return { output: item, citations, review: await resolved.gatewayRepo.getReviewForOutput(item.id) }; @@ -508,6 +529,30 @@ export function registerAiGatewayRoutes(fastify: FastifyInstance, deps: RouteDep reply.send({ artifacts: await deps.aiProviderRegistryStore.listModelArtifacts({ providerModelId: modelId }) }); }); + // --- Online production quality monitoring (eval-harness/production- + // monitor.ts) — the "online" half of the clinical AI evaluation + // framework, complementary to the offline golden-dataset harness + // (eval-harness/runner.ts, driven by its own CLI, not an HTTP route). + // Aggregate rates only, gated the same as every other model-level + // catalog read here — no patient-identifying data crosses this route. + fastify.get("/organizations/:organizationId/ai-provider-models/:modelId/quality-monitor", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId, modelId } = organizationAiProviderModelParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + await requirePermission(deps.store, caller, "aiGateway:viewAuditTrail", globalCatalogResourceName(organizationId)); + const { since } = z.object({ since: z.string().datetime({ offset: true }).optional() }).parse(request.query); + const repo = deps.aiGatewayStore.forTenant(caller.tenantContext); + reply.send(await computeProductionQualitySnapshot(repo, modelId, since)); + }); + + fastify.get("/organizations/:organizationId/ai-provider-models/:modelId/quality-drift", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId, modelId } = organizationAiProviderModelParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + await requirePermission(deps.store, caller, "aiGateway:viewAuditTrail", globalCatalogResourceName(organizationId)); + const { baselineSince, splitAt } = z.object({ baselineSince: z.string().datetime({ offset: true }).optional(), splitAt: z.string().datetime({ offset: true }) }).parse(request.query); + const repo = deps.aiGatewayStore.forTenant(caller.tenantContext); + reply.send(await detectProductionQualityDrift(repo, modelId, baselineSince, splitAt)); + }); + fastify.post("/organizations/:organizationId/ai-provider-models/:modelId/artifacts", { preHandler: deps.authPreHandler }, async (request, reply) => { const { organizationId, modelId } = organizationAiProviderModelParamsSchema.parse(request.params); const caller = await requireOrgUser(deps, request, organizationId); diff --git a/server/src/routes/deps.ts b/server/src/routes/deps.ts index a815095..0f0ad0d 100644 --- a/server/src/routes/deps.ts +++ b/server/src/routes/deps.ts @@ -1,5 +1,5 @@ import type { FastifyReply, FastifyRequest } from "fastify"; -import type { AiProvider, AiProviderModel } from "@modelforge/contracts"; +import type { AiProvider, AiProviderModel, FhirSmartConfiguration } from "@modelforge/contracts"; import type { AiInferenceAdmission } from "../ai-gateway/admission.js"; import type { AiProviderClient } from "../ai-gateway/provider-client.js"; import type { AccessGovernanceStore } from "../store/access-governance-store.js"; @@ -12,10 +12,14 @@ import type { CaseMigrationStore } from "../store/case-migration-store.js"; import type { IamStore } from "../store/iam-store.js"; import type { IdempotencyStore } from "../store/idempotency-store.js"; import type { McpRegistryStore } from "../store/mcp-registry-store.js"; +import type { McpClinicalStore } from "../store/mcp-clinical-store.js"; +import type { McpApprovalTicketIssuer } from "../mcp-approval-issuer.js"; import type { DicomwebAdapter } from "../imaging/dicomweb-adapter.js"; import type { ImagingContentDelivery } from "../imaging/content-delivery.js"; import type { ImagingObjectStore } from "../imaging/object-store.js"; import type { ImagingStore } from "../store/imaging-store.js"; +import type { Hl7IngestionStore } from "../store/hl7-ingestion-store.js"; +import type { SmartLaunchStore } from "../store/smart-launch-store.js"; import type { PrincipalStore } from "../store/principal-store.js"; import type { ScimTokenStore } from "../store/scim-token-store.js"; import type { SessionStore } from "../store/session-store.js"; @@ -42,6 +46,8 @@ export interface RouteDeps { aiGatewayStore: AiGatewayStore; aiProviderRegistryStore: AiProviderRegistryStore; mcpRegistryStore: McpRegistryStore; + mcpClinicalStore: McpClinicalStore; + mcpApprovalTicketIssuer: McpApprovalTicketIssuer; computeControlStore: ComputeControlStore; computeControlPlane: ComputeControlPlane; verifyComputePolicySignature: ComputePolicySignatureVerifier; @@ -70,4 +76,25 @@ export interface RouteDeps { breakGlassGrantDurationMs: number; tenantDirectory: TenantDirectory; authPreHandler: (request: FastifyRequest, reply: FastifyReply) => Promise; + /** routes/fhir.ts's `.well-known/smart-configuration` document — + * resolved once at startup (index.ts) via OIDC discovery against the + * configured issuer (auth/oidc-verifier.ts's + * resolveAuthorizationServerMetadata). Undefined in test/dev builds + * that construct RouteDeps without live discovery (e.g. every + * app.test.ts-style app.inject() suite, which uses a synthetic + * non-resolvable issuer) — the route itself handles that case with a + * 503, never a crash. */ + smartConfiguration: FhirSmartConfiguration | undefined; + hl7IngestionStore: Hl7IngestionStore; + smartLaunchStore: SmartLaunchStore; + /** SMART_LAUNCH_ENCRYPTION_KEY, decoded — undefined when unset, in + * which case routes/smart-launch.ts's own token-exchange route fails + * closed with 503 rather than encrypting with no real key. Unlike + * hl7Mllp (a whole listener that simply doesn't start), the SMART + * launch routes are always registered — this flag is checked per + * request instead, so configuring/listing trusted issuers and viewing + * one's own session list still works even before an operator sets + * this, and only the step that would actually need to encrypt a new + * token is blocked. */ + smartLaunchEncryptionKey: Buffer | undefined; } diff --git a/server/src/routes/fhir.integration.test.ts b/server/src/routes/fhir.integration.test.ts new file mode 100644 index 0000000..7a35b70 --- /dev/null +++ b/server/src/routes/fhir.integration.test.ts @@ -0,0 +1,195 @@ +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import { SignJWT, exportJWK, generateKeyPair, createLocalJWKSet, type JWTVerifyGetKey, type CryptoKey } from "jose"; +import type { FastifyInstance } from "fastify"; +import { buildApp } from "../app.js"; +import { InMemoryAuditStore } from "../store/audit-store.js"; +import { InMemoryCaseStore } from "../store/in-memory-case-store.js"; +import { InMemoryIamStore } from "../store/in-memory-iam-store.js"; +import { InMemoryIdempotencyStore } from "../store/in-memory-idempotency-store.js"; +import { buildMinimalDicomFile } from "../imaging/test-fixtures.js"; +import { patientCaseFixture } from "../test/patient-case-fixture.js"; + +/** + * HTTP-level integration tests for the FHIR R4 read facade + * (routes/fhir.ts). Mirrors imaging.integration.test.ts's own setup and + * rationale exactly: unit coverage of the mapping logic itself already + * lives in fhir/mappers.test.ts, so this file is specifically about what + * only a real app.inject() request can prove — route wiring, that the + * existing IAM authorization is actually applied to the FHIR routes (not + * just the native ones), and the `application/fhir+json` content type. + */ +const ISSUER = "https://idp.example-hospital.test/realms/clinical"; +const AUDIENCE = "modelforge-iam-server"; +const KID = "test-key"; + +describe("FHIR R4 read facade: end-to-end route security", () => { + let privateKey: CryptoKey; + let jwks: JWTVerifyGetKey; + let app: FastifyInstance; + + beforeAll(async () => { + const pair = await generateKeyPair("RS256"); + privateKey = pair.privateKey; + const publicJwk = await exportJWK(pair.publicKey); + publicJwk.kid = KID; + publicJwk.alg = "RS256"; + jwks = createLocalJWKSet({ keys: [publicJwk] }); + }); + + beforeEach(() => { + const auditStore = new InMemoryAuditStore(); + app = buildApp({ + store: new InMemoryIamStore(auditStore), + caseStore: new InMemoryCaseStore(auditStore), + idempotencyStore: new InMemoryIdempotencyStore(), + auditStore, + jwks, + oidc: { issuer: ISSUER, audience: AUDIENCE }, + }); + }); + + async function tokenFor(subject: string, extra?: Record): Promise { + return new SignJWT({ sub: subject, ...extra }) + .setProtectedHeader({ alg: "RS256", kid: KID }) + .setIssuedAt() + .setIssuer(ISSUER) + .setAudience(AUDIENCE) + .setExpirationTime("1h") + .sign(privateKey); + } + + async function createOrg(adminSubject: string): Promise<{ orgId: string; adminToken: string }> { + const adminToken = await tokenFor(adminSubject, { name: "Dr. Admin" }); + const response = await app.inject({ method: "POST", url: "/organizations", headers: { authorization: `Bearer ${adminToken}` }, payload: { name: "Example Health System" } }); + expect(response.statusCode).toBe(201); + return { orgId: response.json().organization.id, adminToken }; + } + + async function addUnprivilegedUser(orgId: string, adminToken: string, subject: string): Promise { + await app.inject({ + method: "POST", + url: `/organizations/${orgId}/users`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { externalSubject: subject, displayName: "No Rights" }, + }); + return tokenFor(subject); + } + + it("GET metadata returns a CapabilityStatement advertising only implemented interactions", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + const response = await app.inject({ method: "GET", url: `/organizations/${orgId}/fhir/r4/metadata`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toBe("application/fhir+json; charset=utf-8"); + const body = response.json(); + expect(body.resourceType).toBe("CapabilityStatement"); + expect(body.fhirVersion).toBe("4.0.1"); + const resourceTypes = body.rest[0].resource.map((r: { type: string }) => r.type); + expect(resourceTypes).toEqual(["Patient", "DiagnosticReport", "ImagingStudy", "DocumentReference"]); + }); + + it("GET Patient/:caseId maps a case to a FHIR Patient for an authorized caller, and 404s identically for an unauthorized one", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + const created = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/cases`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: patientCaseFixture("case-1", { patientId: "MRN-777", demographics: { value: { age: "35", sex: "male" }, includeInContext: false } }), + }); + expect(created.statusCode).toBe(201); + + const authorized = await app.inject({ method: "GET", url: `/organizations/${orgId}/fhir/r4/Patient/case-1`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(authorized.statusCode).toBe(200); + expect(authorized.headers["content-type"]).toBe("application/fhir+json; charset=utf-8"); + const patient = authorized.json(); + expect(patient).toMatchObject({ resourceType: "Patient", id: "MRN-777", gender: "male", identifier: [{ system: "urn:modelforge:patientId", value: "MRN-777" }] }); + + const strangerToken = await addUnprivilegedUser(orgId, adminToken, "idp|no-rights"); + const unauthorized = await app.inject({ method: "GET", url: `/organizations/${orgId}/fhir/r4/Patient/case-1`, headers: { authorization: `Bearer ${strangerToken}` } }); + expect(unauthorized.statusCode).toBe(404); + expect(unauthorized.json().resourceType).toBe("OperationOutcome"); + + const missing = await app.inject({ method: "GET", url: `/organizations/${orgId}/fhir/r4/Patient/does-not-exist`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(missing.statusCode).toBe(404); + expect(missing.json().resourceType).toBe("OperationOutcome"); + }); + + it("GET ImagingStudy/:studyId and DiagnosticReport/:reportId map ingested imaging data, and 404 identically for an unauthorized caller", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + const dicomBytes = buildMinimalDicomFile({ patientId: "MRN-001", issuerOfPatientId: "TEST-HOSPITAL" }); + const ingestResult = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/imaging/ingestion`, + headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/dicom" }, + payload: dicomBytes, + }); + expect(ingestResult.statusCode).toBe(201); + const studyId = ingestResult.json().studyId; + + const studyResponse = await app.inject({ method: "GET", url: `/organizations/${orgId}/fhir/r4/ImagingStudy/${studyId}`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(studyResponse.statusCode).toBe(200); + const fhirStudy = studyResponse.json(); + expect(fhirStudy).toMatchObject({ resourceType: "ImagingStudy", id: studyId, status: "available", subject: { reference: "Patient/MRN-001" } }); + + const reportResult = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/imaging/studies/${studyId}/reports`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { conclusion: "No acute findings.", status: "final" }, + }); + expect(reportResult.statusCode).toBe(201); + const reportId = reportResult.json().id; + + const reportResponse = await app.inject({ method: "GET", url: `/organizations/${orgId}/fhir/r4/DiagnosticReport/${reportId}`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(reportResponse.statusCode).toBe(200); + expect(reportResponse.json()).toMatchObject({ + resourceType: "DiagnosticReport", + id: reportId, + status: "final", + conclusion: "No acute findings.", + imagingStudy: [{ reference: `ImagingStudy/${studyId}` }], + }); + + const strangerToken = await addUnprivilegedUser(orgId, adminToken, "idp|no-imaging-rights"); + const unauthorizedStudy = await app.inject({ method: "GET", url: `/organizations/${orgId}/fhir/r4/ImagingStudy/${studyId}`, headers: { authorization: `Bearer ${strangerToken}` } }); + expect(unauthorizedStudy.statusCode).toBe(404); + const unauthorizedReport = await app.inject({ method: "GET", url: `/organizations/${orgId}/fhir/r4/DiagnosticReport/${reportId}`, headers: { authorization: `Bearer ${strangerToken}` } }); + expect(unauthorizedReport.statusCode).toBe(404); + }); + + it("GET .well-known/smart-configuration 503s when the app was built without live OIDC discovery (every test in this suite)", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + const response = await app.inject({ method: "GET", url: `/organizations/${orgId}/fhir/r4/.well-known/smart-configuration`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(response.statusCode).toBe(503); + expect(response.json().error).toBe("smart_configuration_unavailable"); + }); + + it("a SMART launch context (patient-scoped token) confines FHIR reads to that one patient, denying identically for a mismatched patient as for unauthorized/absent", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + await app.inject({ + method: "POST", + url: `/organizations/${orgId}/cases`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: patientCaseFixture("case-launch", { patientId: "MRN-LAUNCH" }), + }); + // Same admin identity, but this particular bearer token carries a + // SMART launch context confined to a DIFFERENT patient — proves the + // launch-context check is independent of (and additional to) the + // normal IAM permission check, which this caller clearly passes. + const wrongPatientLaunchToken = await tokenFor("idp|dr-admin", { scope: "openid launch patient/*.read", patient: "MRN-SOMEONE-ELSE" }); + const denied = await app.inject({ method: "GET", url: `/organizations/${orgId}/fhir/r4/Patient/case-launch`, headers: { authorization: `Bearer ${wrongPatientLaunchToken}` } }); + expect(denied.statusCode).toBe(404); + expect(denied.json().resourceType).toBe("OperationOutcome"); + + const matchingPatientLaunchToken = await tokenFor("idp|dr-admin", { scope: "openid launch patient/*.read", patient: "MRN-LAUNCH" }); + const allowed = await app.inject({ method: "GET", url: `/organizations/${orgId}/fhir/r4/Patient/case-launch`, headers: { authorization: `Bearer ${matchingPatientLaunchToken}` } }); + expect(allowed.statusCode).toBe(200); + expect(allowed.json().id).toBe("MRN-LAUNCH"); + }); + + it("GET DocumentReference?studyId= returns an empty search bundle (never a 403/404) for both a missing study and an unauthorized one — no existence disclosure via status code", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + const response = await app.inject({ method: "GET", url: `/organizations/${orgId}/fhir/r4/DocumentReference?studyId=does-not-exist`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ resourceType: "Bundle", type: "searchset", total: 0, entry: [] }); + }); +}); diff --git a/server/src/routes/fhir.ts b/server/src/routes/fhir.ts new file mode 100644 index 0000000..73b4cf0 --- /dev/null +++ b/server/src/routes/fhir.ts @@ -0,0 +1,145 @@ +import type { CaseResourceAttributes, ImagingResourceAttributes } from "@modelforge/contracts"; +import type { FastifyInstance, FastifyReply } from "fastify"; +import { z } from "zod"; +import { buildCapabilityStatement } from "../fhir/capability-statement.js"; +import { fhirBundle, fhirNotFound, toFhirDiagnosticReport, toFhirDocumentReference, toFhirImagingStudy, toFhirPatient } from "../fhir/mappers.js"; +import { deniedBySmartLaunchContext, resolveSmartLaunchContext } from "../fhir/smart-scopes.js"; +import type { RouteDeps } from "./deps.js"; +import { isPermissionAllowed, requireOrgUser, type ResolvedPrincipal } from "./guards.js"; +import { organizationFhirCaseParamsSchema, organizationFhirReportParamsSchema, organizationFhirStudyParamsSchema, organizationParamsSchema } from "./params.js"; + +/** + * FHIR R4 read facade — see @modelforge/contracts's fhir.ts for the full + * scope statement and docs/FHIR_INTEGRATION.md for the architecture. Every + * route here re-uses this codebase's *existing* IAM authorization (the same + * patientCase:view/imagingStudy:view/diagnosticReport:view actions and + * conditionContext shape routes/cases.ts and routes/imaging-*.ts already + * enforce) rather than inventing a parallel FHIR-specific permission model — + * a FHIR resource is only ever a different JSON *shape* of data this server + * already protects, never a different trust boundary. Same "identical 404 + * for absent and unauthorized" discipline as the rest of this API. + * + * All responses use `application/fhir+json`, per the FHIR HTTP spec. + */ +const FHIR_CONTENT_TYPE = "application/fhir+json; charset=utf-8"; + +function sendFhir(reply: FastifyReply, statusCode: number, body: unknown): void { + reply.code(statusCode).header("content-type", FHIR_CONTENT_TYPE).send(body); +} + +function caseConditionContext(resource: CaseResourceAttributes, caller: ResolvedPrincipal): Record { + return { + "resource:patientId": resource.patientId, + "resource:ownerUserId": resource.ownerUserId, + "resource:workspaceId": resource.workspaceId ?? "", + "resource:departmentId": resource.departmentId ?? "", + "resource:isOwner": String(resource.ownerUserId === caller.id), + "resource:isAssigned": String(resource.assignedUserIds.includes(caller.id)), + "resource:activeConsentScopes": [...resource.activeConsentScopes].sort().join(","), + }; +} + +function studyConditionContext(resource: ImagingResourceAttributes, caller: ResolvedPrincipal): Record { + return { + "resource:ownerUserId": resource.ownerUserId, + "resource:isOwner": String(resource.ownerUserId === caller.id), + "resource:isAssigned": String(resource.assignedUserIds.includes(caller.id)), + "resource:sensitivity": resource.sensitivity, + "resource:workspaceId": resource.workspaceId ?? "", + "resource:departmentId": resource.departmentId ?? "", + "resource:caseId": resource.caseId ?? "", + }; +} + +export function registerFhirRoutes(fastify: FastifyInstance, deps: RouteDeps): void { + fastify.get("/organizations/:organizationId/fhir/r4/metadata", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId } = organizationParamsSchema.parse(request.params); + await requireOrgUser(deps, request, organizationId); + sendFhir(reply, 200, buildCapabilityStatement()); + }); + + // Plain OAuth discovery JSON per the SMART App Launch spec — not itself + // a FHIR resource, so no application/fhir+json, no OperationOutcome + // envelope, and (unlike every other route here) no per-caller IAM check: + // a discovery document describing where to authorize is not + // patient/organization data. Requires deps.smartConfiguration to have + // been resolved (index.ts, at startup, against the configured OIDC + // issuer) — see RouteDeps's own doc comment on why this can be + // undefined (test/dev builds that skip live OIDC discovery). + fastify.get("/organizations/:organizationId/fhir/r4/.well-known/smart-configuration", async (_request, reply) => { + if (!deps.smartConfiguration) { + return reply.code(503).send({ error: "smart_configuration_unavailable", message: "This server was not started with OIDC discovery resolved; SMART on FHIR launch is unavailable." }); + } + reply.code(200).header("content-type", "application/json; charset=utf-8").send(deps.smartConfiguration); + }); + + fastify.get("/organizations/:organizationId/fhir/r4/Patient/:caseId", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId, caseId } = organizationFhirCaseParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + const current = await deps.caseStore.forTenant(caller.tenantContext).getOne(caseId); + if (!current || !(await isPermissionAllowed(deps.store, caller, "patientCase:view", `organization:${organizationId}/patientCase:${caseId}`, caseConditionContext(current.resource, caller)))) { + return sendFhir(reply, 404, fhirNotFound("Patient", caseId)); + } + const launchContext = resolveSmartLaunchContext(request.auth!.claims); + if (deniedBySmartLaunchContext(launchContext, current.resource.patientId)) { + return sendFhir(reply, 404, fhirNotFound("Patient", caseId)); + } + sendFhir(reply, 200, toFhirPatient(current.patientCase, current.resource.patientId)); + }); + + fastify.get("/organizations/:organizationId/fhir/r4/ImagingStudy/:studyId", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId, studyId } = organizationFhirStudyParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + const repo = deps.imagingStore.forTenant(caller.tenantContext); + const current = await repo.getStudy(studyId); + if (!current || !(await isPermissionAllowed(deps.store, caller, "imagingStudy:view", `organization:${organizationId}/imagingStudy:${studyId}`, studyConditionContext(current.resource, caller)))) { + return sendFhir(reply, 404, fhirNotFound("ImagingStudy", studyId)); + } + const launchContext = resolveSmartLaunchContext(request.auth!.claims); + if (deniedBySmartLaunchContext(launchContext, current.study.patientIdentifier.value)) { + return sendFhir(reply, 404, fhirNotFound("ImagingStudy", studyId)); + } + const series = await repo.listSeriesForStudy(studyId); + sendFhir(reply, 200, toFhirImagingStudy(current.study, series)); + }); + + fastify.get("/organizations/:organizationId/fhir/r4/DiagnosticReport/:reportId", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId, reportId } = organizationFhirReportParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + const repo = deps.imagingStore.forTenant(caller.tenantContext); + const report = await repo.getReport(reportId); + if (!report) return sendFhir(reply, 404, fhirNotFound("DiagnosticReport", reportId)); + const study = await repo.getStudy(report.studyId); + if (!study || !(await isPermissionAllowed(deps.store, caller, "imagingStudy:view", `organization:${organizationId}/imagingStudy:${report.studyId}`, studyConditionContext(study.resource, caller)))) { + return sendFhir(reply, 404, fhirNotFound("DiagnosticReport", reportId)); + } + if (!(await isPermissionAllowed(deps.store, caller, "diagnosticReport:view", `organization:${organizationId}/imagingStudy:${report.studyId}`))) { + return sendFhir(reply, 404, fhirNotFound("DiagnosticReport", reportId)); + } + const launchContext = resolveSmartLaunchContext(request.auth!.claims); + if (deniedBySmartLaunchContext(launchContext, study.study.patientIdentifier.value)) { + return sendFhir(reply, 404, fhirNotFound("DiagnosticReport", reportId)); + } + sendFhir(reply, 200, toFhirDiagnosticReport(report, study.study)); + }); + + // Search-type only (no by-id read route) — matches this system's own + // store interface, which has no getDocumentReference(id), only + // listDocumentReferencesForStudy. See capability-statement.ts. + fastify.get("/organizations/:organizationId/fhir/r4/DocumentReference", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId } = organizationParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + const { studyId } = z.object({ studyId: z.string().min(1) }).parse(request.query); + const repo = deps.imagingStore.forTenant(caller.tenantContext); + const study = await repo.getStudy(studyId); + if (!study || !(await isPermissionAllowed(deps.store, caller, "imagingStudy:view", `organization:${organizationId}/imagingStudy:${studyId}`, studyConditionContext(study.resource, caller)))) { + return sendFhir(reply, 200, fhirBundle([])); + } + const launchContext = resolveSmartLaunchContext(request.auth!.claims); + if (deniedBySmartLaunchContext(launchContext, study.study.patientIdentifier.value)) { + return sendFhir(reply, 200, fhirBundle([])); + } + const documents = await repo.listDocumentReferencesForStudy(studyId); + sendFhir(reply, 200, fhirBundle(documents.map(toFhirDocumentReference))); + }); +} diff --git a/server/src/routes/hl7.integration.test.ts b/server/src/routes/hl7.integration.test.ts new file mode 100644 index 0000000..34ac1d5 --- /dev/null +++ b/server/src/routes/hl7.integration.test.ts @@ -0,0 +1,278 @@ +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; +import { SignJWT, exportJWK, generateKeyPair, createLocalJWKSet, type JWTVerifyGetKey, type CryptoKey } from "jose"; +import type { FastifyInstance } from "fastify"; +import { buildApp } from "../app.js"; +import { InMemoryAuditStore } from "../store/audit-store.js"; +import { InMemoryCaseStore } from "../store/in-memory-case-store.js"; +import { InMemoryIamStore } from "../store/in-memory-iam-store.js"; +import { InMemoryIdempotencyStore } from "../store/in-memory-idempotency-store.js"; +import { buildMinimalDicomFile } from "../imaging/test-fixtures.js"; +import { parseHl7Message, getField } from "../hl7/message.js"; + +/** + * HTTP-level integration tests for routes/hl7.ts — mirrors + * fhir.integration.test.ts's own setup/rationale: unit coverage of the + * mapping logic lives in hl7/oru-builder.test.ts, so this file is + * specifically about route wiring, IAM enforcement being actually applied, + * and the `application/hl7-v2` content type. + */ +const ISSUER = "https://idp.example-hospital.test/realms/clinical"; +const AUDIENCE = "modelforge-iam-server"; +const KID = "test-key"; + +describe("HL7 v2 ORU^R01 generation: end-to-end route security", () => { + let privateKey: CryptoKey; + let jwks: JWTVerifyGetKey; + let app: FastifyInstance; + + beforeAll(async () => { + const pair = await generateKeyPair("RS256"); + privateKey = pair.privateKey; + const publicJwk = await exportJWK(pair.publicKey); + publicJwk.kid = KID; + publicJwk.alg = "RS256"; + jwks = createLocalJWKSet({ keys: [publicJwk] }); + }); + + beforeEach(() => { + const auditStore = new InMemoryAuditStore(); + app = buildApp({ + store: new InMemoryIamStore(auditStore), + caseStore: new InMemoryCaseStore(auditStore), + idempotencyStore: new InMemoryIdempotencyStore(), + auditStore, + jwks, + oidc: { issuer: ISSUER, audience: AUDIENCE }, + }); + }); + + async function tokenFor(subject: string, extra?: Record): Promise { + return new SignJWT({ sub: subject, ...extra }).setProtectedHeader({ alg: "RS256", kid: KID }).setIssuedAt().setIssuer(ISSUER).setAudience(AUDIENCE).setExpirationTime("1h").sign(privateKey); + } + + async function createOrg(adminSubject: string): Promise<{ orgId: string; adminToken: string }> { + const adminToken = await tokenFor(adminSubject, { name: "Dr. Admin" }); + const response = await app.inject({ method: "POST", url: "/organizations", headers: { authorization: `Bearer ${adminToken}` }, payload: { name: "Example Health System" } }); + expect(response.statusCode).toBe(201); + return { orgId: response.json().organization.id, adminToken }; + } + + function caseFixturePayload(id: string, patientId: string) { + const now = new Date().toISOString(); + return { + id, title: "Synthetic case", patientId, + demographics: { value: {}, includeInContext: false }, + presentingComplaint: { value: "", includeInContext: false }, + symptomsTimeline: { value: "", includeInContext: false }, + vitalSigns: { value: "", includeInContext: false }, + conditions: { value: [], includeInContext: false }, + allergies: { value: [], includeInContext: false }, + medications: { value: [], includeInContext: false }, + labResults: { value: [], includeInContext: false }, + imagingAndReports: { value: "", includeInContext: false }, + clinicalNotes: [], attachments: [], consentRecords: [], + createdAt: now, updatedAt: now, + }; + } + + async function createCase(orgId: string, adminToken: string, caseId: string, patientId: string): Promise { + const response = await app.inject({ method: "POST", url: `/organizations/${orgId}/cases`, headers: { authorization: `Bearer ${adminToken}` }, payload: caseFixturePayload(caseId, patientId) }); + expect(response.statusCode).toBe(201); + } + + it("GET oru-r01 returns a well-formed ORU^R01 message for an authorized caller, and 404s identically for an unauthorized one", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + const dicomBytes = buildMinimalDicomFile({ patientId: "MRN-001", issuerOfPatientId: "TEST-HOSPITAL" }); + const ingestResult = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/imaging/ingestion`, + headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/dicom" }, + payload: dicomBytes, + }); + expect(ingestResult.statusCode).toBe(201); + const studyId = ingestResult.json().studyId; + + const reportResult = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/imaging/studies/${studyId}/reports`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { conclusion: "No acute findings.", status: "final" }, + }); + expect(reportResult.statusCode).toBe(201); + const reportId = reportResult.json().id; + + const url = `/organizations/${orgId}/hl7/v2/DiagnosticReport/${reportId}/oru-r01?receivingApplication=EHR&receivingFacility=Example%20Health%20System`; + const response = await app.inject({ method: "GET", url, headers: { authorization: `Bearer ${adminToken}` } }); + expect(response.statusCode).toBe(200); + expect(response.headers["content-type"]).toBe("application/hl7-v2; charset=utf-8"); + const message = parseHl7Message(response.body); + expect(message.segments.map((s) => s.id)).toEqual(["MSH", "PID", "OBR", "OBX"]); + expect(getField(message.segments[0], 9)).toBe("ORU^R01"); + expect(getField(message.segments[1], 3)).toBe("MRN-001^^^TEST-HOSPITAL"); + + await app.inject({ method: "POST", url: `/organizations/${orgId}/users`, headers: { authorization: `Bearer ${adminToken}` }, payload: { externalSubject: "idp|no-imaging-rights", displayName: "No Rights" } }); + const strangerToken = await tokenFor("idp|no-imaging-rights"); + const unauthorized = await app.inject({ method: "GET", url, headers: { authorization: `Bearer ${strangerToken}` } }); + expect(unauthorized.statusCode).toBe(404); + }); + + it("requires receivingApplication/receivingFacility query params", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + const response = await app.inject({ method: "GET", url: `/organizations/${orgId}/hl7/v2/DiagnosticReport/does-not-exist/oru-r01`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(response.statusCode).toBe(400); + }); + + describe("POST inbound/oru-r01/parse", () => { + const SAMPLE_ORU = [ + "MSH|^~\\&|LAB|HOSPITAL|EHR|HOSPITAL|20260315120000||ORU^R01|MSG00001|P|2.5.1", + "PID|1||MRN-001^^^TEST-HOSPITAL||", + "OBX|1|NM|2345-7^Glucose^LN||95|mg/dL|70-99|N|||F", + ].join("\r"); + + it("parses a well-formed inbound ORU^R01 for an authorized caller", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + const response = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/hl7/v2/inbound/oru-r01/parse`, + headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/hl7-v2" }, + payload: SAMPLE_ORU, + }); + expect(response.statusCode).toBe(200); + const body = response.json(); + expect(body.messageControlId).toBe("MSG00001"); + expect(body.patientIdentifier).toEqual({ value: "MRN-001", issuer: "TEST-HOSPITAL" }); + expect(body.observations).toHaveLength(1); + expect(body.observations[0]).toMatchObject({ name: "Glucose", value: "95", unit: "mg/dL" }); + }); + + it("returns 422 (not 500) for structurally invalid HL7, and 400 for an empty body", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + const invalid = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/hl7/v2/inbound/oru-r01/parse`, + headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/hl7-v2" }, + payload: "this is not HL7 at all", + }); + expect(invalid.statusCode).toBe(422); + + const empty = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/hl7/v2/inbound/oru-r01/parse`, + headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/hl7-v2" }, + payload: "", + }); + expect(empty.statusCode).toBe(400); + }); + + it("rejects a caller without hl7:parseInbound with 403", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + await app.inject({ method: "POST", url: `/organizations/${orgId}/users`, headers: { authorization: `Bearer ${adminToken}` }, payload: { externalSubject: "idp|no-hl7-rights", displayName: "No Rights" } }); + const strangerToken = await tokenFor("idp|no-hl7-rights"); + const response = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/hl7/v2/inbound/oru-r01/parse`, + headers: { authorization: `Bearer ${strangerToken}`, "content-type": "application/hl7-v2" }, + payload: SAMPLE_ORU, + }); + expect(response.statusCode).toBe(403); + }); + }); + + describe("POST inbound/ingest, GET jobs, POST jobs/:jobId/resolve", () => { + const SAMPLE_ORU = (mrn: string) => [ + "MSH|^~\\&|LAB|HOSPITAL|EHR|HOSPITAL|20260315120000||ORU^R01|MSG00001|P|2.5.1", + `PID|1||${mrn}||`, + "OBX|1|NM|2345-7^Glucose^LN||95|mg/dL|70-99|N|||F", + ].join("\r"); + + it("ingests an ORU matching exactly one case and merges the observation into it", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + await createCase(orgId, adminToken, "case-1", "MRN-001"); + + const ingest = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/hl7/v2/inbound/ingest`, + headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/hl7-v2" }, + payload: SAMPLE_ORU("MRN-001"), + }); + expect(ingest.statusCode).toBe(201); + expect(ingest.json()).toMatchObject({ matchStatus: "matched", matchedCaseId: "case-1", status: "applied", observationsAdded: 1 }); + + const updatedCase = await app.inject({ method: "GET", url: `/organizations/${orgId}/cases/case-1`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(updatedCase.json().labResults.value).toHaveLength(1); + }); + + it("an ambiguous match creates a pending-review job listing candidates, resolvable by picking one of them", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + await createCase(orgId, adminToken, "case-1", "MRN-SHARED"); + await createCase(orgId, adminToken, "case-2", "MRN-SHARED"); + + const ingest = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/hl7/v2/inbound/ingest`, + headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/hl7-v2" }, + payload: SAMPLE_ORU("MRN-SHARED"), + }); + expect(ingest.statusCode).toBe(201); + const job = ingest.json(); + expect(job.matchStatus).toBe("ambiguous"); + expect(job.status).toBe("pending-review"); + + const jobsList = await app.inject({ method: "GET", url: `/organizations/${orgId}/hl7/v2/inbound/jobs?status=pending-review`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(jobsList.statusCode).toBe(200); + expect(jobsList.json().jobs).toHaveLength(1); + + const resolve = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/hl7/v2/inbound/jobs/${job.id}/resolve`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { action: "apply", caseId: "case-2" }, + }); + expect(resolve.statusCode).toBe(200); + expect(resolve.json()).toMatchObject({ status: "applied", matchedCaseId: "case-2" }); + + const case2 = await app.inject({ method: "GET", url: `/organizations/${orgId}/cases/case-2`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(case2.json().labResults.value).toHaveLength(1); + }); + + it("refuses resolving a job to a case outside its own candidates with 409, not a silent apply", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + await createCase(orgId, adminToken, "case-1", "MRN-SHARED"); + await createCase(orgId, adminToken, "case-2", "MRN-SHARED"); + await createCase(orgId, adminToken, "case-unrelated", "MRN-OTHER"); + + const ingest = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/hl7/v2/inbound/ingest`, + headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/hl7-v2" }, + payload: SAMPLE_ORU("MRN-SHARED"), + }); + const job = ingest.json(); + + const resolve = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/hl7/v2/inbound/jobs/${job.id}/resolve`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { action: "apply", caseId: "case-unrelated" }, + }); + expect(resolve.statusCode).toBe(409); + }); + + it("rejects a caller without hl7:ingest/hl7:reviewIngestion with 403", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + await app.inject({ method: "POST", url: `/organizations/${orgId}/users`, headers: { authorization: `Bearer ${adminToken}` }, payload: { externalSubject: "idp|no-hl7-rights", displayName: "No Rights" } }); + const strangerToken = await tokenFor("idp|no-hl7-rights"); + + const ingest = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/hl7/v2/inbound/ingest`, + headers: { authorization: `Bearer ${strangerToken}`, "content-type": "application/hl7-v2" }, + payload: SAMPLE_ORU("MRN-001"), + }); + expect(ingest.statusCode).toBe(403); + + const jobs = await app.inject({ method: "GET", url: `/organizations/${orgId}/hl7/v2/inbound/jobs`, headers: { authorization: `Bearer ${strangerToken}` } }); + expect(jobs.statusCode).toBe(403); + }); + }); +}); diff --git a/server/src/routes/hl7.ts b/server/src/routes/hl7.ts new file mode 100644 index 0000000..f2da527 --- /dev/null +++ b/server/src/routes/hl7.ts @@ -0,0 +1,144 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { Hl7ParseError } from "../hl7/message.js"; +import { parseOruR01 } from "../hl7/inbound-parser.js"; +import { buildOruR01 } from "../hl7/oru-builder.js"; +import { Hl7IngestionResolutionError, ingestInboundMessage, resolveIngestionJob } from "../hl7/ingestion.js"; +import type { RouteDeps } from "./deps.js"; +import { isPermissionAllowed, requireOrgUser, requirePermission } from "./guards.js"; +import { actorFrom } from "../store/audit-store.js"; +import { organizationFhirReportParamsSchema, organizationHl7JobParamsSchema, organizationParamsSchema } from "./params.js"; + +/** + * HL7 v2 outbound generation, inbound parsing, and inbound ingestion — see + * docs/HL7_V2_INTEGRATION.md for the full architecture. Outbound reuses + * hl7/oru-builder.ts's mapping over the same DiagnosticReport/ImagingStudy + * data routes/fhir.ts's DiagnosticReport route already exposes as FHIR, + * and the exact same authorization reuse principle: an HL7-shaped + * representation of data this server already protects is not a different + * trust boundary, so it enforces the same imagingStudy:view/ + * diagnosticReport:view checks, not a new permission. Inbound parsing/ + * ingestion have no case/patient resource of their own to reuse an + * existing action from (parsing is a stateless format conversion; ingestion + * matches across every case in the tenant, not one specific one) — gated + * by new `hl7:parseInbound`/`hl7:ingest`/`hl7:reviewIngestion` actions + * instead. Parsing never looks up/matches/writes anything; ingestion does, + * through hl7/ingestion.ts's own deliberately conservative "ambiguous or + * no match always requires human review, never a guess" pipeline — see + * that file's own doc comment. + */ +const oruQuerySchema = z + .object({ + receivingApplication: z.string().min(1).max(200), + receivingFacility: z.string().min(1).max(200), + sendingApplication: z.string().min(1).max(200).default("ModelForge"), + sendingFacility: z.string().min(1).max(200).optional(), + }) + .strict(); + +export function registerHl7Routes(fastify: FastifyInstance, deps: RouteDeps): void { + fastify.get("/organizations/:organizationId/hl7/v2/DiagnosticReport/:reportId/oru-r01", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId, reportId } = organizationFhirReportParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + const query = oruQuerySchema.parse(request.query); + const repo = deps.imagingStore.forTenant(caller.tenantContext); + const report = await repo.getReport(reportId); + if (!report) return reply.code(404).send({ error: "not_found" }); + const study = await repo.getStudy(report.studyId); + if (!study) return reply.code(404).send({ error: "not_found" }); + const resource = study.resource; + const canView = await isPermissionAllowed(deps.store, caller, "imagingStudy:view", `organization:${organizationId}/imagingStudy:${report.studyId}`, { + "resource:ownerUserId": resource.ownerUserId, + "resource:isOwner": String(resource.ownerUserId === caller.id), + "resource:isAssigned": String(resource.assignedUserIds.includes(caller.id)), + "resource:sensitivity": resource.sensitivity, + "resource:workspaceId": resource.workspaceId ?? "", + "resource:departmentId": resource.departmentId ?? "", + "resource:caseId": resource.caseId ?? "", + }); + if (!canView || !(await isPermissionAllowed(deps.store, caller, "diagnosticReport:view", `organization:${organizationId}/imagingStudy:${report.studyId}`))) { + return reply.code(404).send({ error: "not_found" }); + } + + const raw = buildOruR01(report, study.study, { + sendingApplication: query.sendingApplication, + sendingFacility: query.sendingFacility ?? organizationId, + receivingApplication: query.receivingApplication, + receivingFacility: query.receivingFacility, + }); + reply.code(200).header("content-type", "application/hl7-v2; charset=utf-8").send(raw); + }); + + // Parse-only — see this file's own top doc comment. request.body is the + // raw HL7 v2 text handed through verbatim by app.ts's + // application/hl7-v2 content-type parser. + fastify.post("/organizations/:organizationId/hl7/v2/inbound/oru-r01/parse", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId } = organizationParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + await requirePermission(deps.store, caller, "hl7:parseInbound", `organization:${organizationId}/hl7Inbound`); + const raw = request.body; + if (typeof raw !== "string" || raw.length === 0) { + return reply.code(400).send({ error: "invalid_body", message: "Request body must be a non-empty raw HL7 v2 message (content-type: application/hl7-v2)." }); + } + try { + reply.code(200).send(parseOruR01(raw)); + } catch (err) { + if (err instanceof Hl7ParseError) return reply.code(422).send({ error: "hl7_parse_error", message: err.message }); + throw err; + } + }); + + // Ingest — see hl7/ingestion.ts's own doc comment for the match/apply + // pipeline. Unlike /parse above, this DOES touch case data (only for + // an unambiguous single patient match), so it is gated by a separate, + // stronger action. + fastify.post("/organizations/:organizationId/hl7/v2/inbound/ingest", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId } = organizationParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + await requirePermission(deps.store, caller, "hl7:ingest", `organization:${organizationId}/hl7Inbound`); + const raw = request.body; + if (typeof raw !== "string" || raw.length === 0) { + return reply.code(400).send({ error: "invalid_body", message: "Request body must be a non-empty raw HL7 v2 message (content-type: application/hl7-v2)." }); + } + const caseRepo = deps.caseStore.forTenant(caller.tenantContext); + const ingestionRepo = deps.hl7IngestionStore.forTenant(caller.tenantContext); + try { + const { job } = await ingestInboundMessage(caseRepo, ingestionRepo, raw, actorFrom(caller)); + reply.code(201).send(job); + } catch (err) { + if (err instanceof Hl7ParseError) return reply.code(422).send({ error: "hl7_parse_error", message: err.message }); + throw err; + } + }); + + fastify.get("/organizations/:organizationId/hl7/v2/inbound/jobs", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId } = organizationParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + await requirePermission(deps.store, caller, "hl7:reviewIngestion", `organization:${organizationId}/hl7Inbound`); + const { status } = z.object({ status: z.enum(["pending-review", "applied", "rejected"]).optional() }).parse(request.query); + const jobs = await deps.hl7IngestionStore.forTenant(caller.tenantContext).listJobs(status ? { status } : undefined); + reply.send({ jobs }); + }); + + const resolveBodySchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("apply"), caseId: z.string().min(1) }).strict(), + z.object({ action: z.literal("reject"), reason: z.string().min(1).max(2_000) }).strict(), + ]); + + fastify.post("/organizations/:organizationId/hl7/v2/inbound/jobs/:jobId/resolve", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId, jobId } = organizationHl7JobParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + await requirePermission(deps.store, caller, "hl7:reviewIngestion", `organization:${organizationId}/hl7Inbound`); + const decision = resolveBodySchema.parse(request.body); + const caseRepo = deps.caseStore.forTenant(caller.tenantContext); + const ingestionRepo = deps.hl7IngestionStore.forTenant(caller.tenantContext); + try { + const resolved = await resolveIngestionJob(caseRepo, ingestionRepo, jobId, decision, caller.id, actorFrom(caller)); + if (!resolved) return reply.code(404).send({ error: "not_found" }); + reply.send(resolved); + } catch (err) { + if (err instanceof Hl7IngestionResolutionError) return reply.code(409).send({ error: "resolution_conflict", message: err.message }); + throw err; + } + }); +} diff --git a/server/src/routes/mcp-clinical.integration.test.ts b/server/src/routes/mcp-clinical.integration.test.ts new file mode 100644 index 0000000..7f8a62d --- /dev/null +++ b/server/src/routes/mcp-clinical.integration.test.ts @@ -0,0 +1,149 @@ +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { createLocalJWKSet, exportJWK, exportPKCS8, generateKeyPair, jwtVerify, SignJWT, type CryptoKey, type JWTVerifyGetKey } from "jose"; +import type { FastifyInstance } from "fastify"; +import { mcpContextGrantSchema } from "@modelforge/contracts"; +import { buildApp } from "../app.js"; +import { Rs256McpApprovalTicketIssuer } from "../mcp-approval-issuer.js"; +import { InMemoryAuditStore } from "../store/audit-store.js"; +import { InMemoryCaseStore } from "../store/in-memory-case-store.js"; +import { InMemoryIamStore } from "../store/in-memory-iam-store.js"; +import { InMemoryIdempotencyStore } from "../store/in-memory-idempotency-store.js"; + +const ISSUER = "https://identity.example.test"; +const AUDIENCE = "modelforge-integration-test"; +const CLIENT = "institutional-desktop"; +const CASE = "synthetic-case"; +const REVIEW = "clinical.record_review_decision"; +const DIGEST = `sha256:${"b".repeat(64)}`; +const SNAPSHOT = { registryVersion: "1", rbacVersion: "1", egressPolicyVersion: "1", killSwitchVersion: "1", toolPolicyVersion: "1" }; + +// Real HTTP route/IAM/store/RS256 wiring with synthetic records. The MCP +// challenge service is mocked; Rust HTTP tests cover its digest generation. +describe("clinical MCP control-plane HTTP integration", () => { + let privateKey: CryptoKey; + let publicKey: CryptoKey; + let jwks: JWTVerifyGetKey; + let privatePem: string; + let app: FastifyInstance; + let audit: InMemoryAuditStore; + let organizationId: string; + let registryEntryId: string; + let adminToken: string; + let headers: { authorization: string }; + + beforeAll(async () => { + ({ privateKey, publicKey } = await generateKeyPair("RS256", { extractable: true })); + const jwk = await exportJWK(publicKey); + jwks = createLocalJWKSet({ keys: [{ ...jwk, kid: "test", alg: "RS256" }] }); + privatePem = await exportPKCS8(privateKey); + }); + + async function token(subject = "clinician", client: string | null = CLIENT) { + return new SignJWT({ sub: subject, ...(client ? { azp: client } : {}) }) + .setProtectedHeader({ alg: "RS256", kid: "test" }).setIssuer(ISSUER) + .setAudience(AUDIENCE).setIssuedAt().setExpirationTime("5m").sign(privateKey); + } + + const path = (suffix: string) => `/organizations/${organizationId}/${suffix}`; + const registry = () => ({ name: "Institutional clinical gateway", transport: "http", endpoint: "https://mcp.example.test/mcp", allowedTools: [REVIEW, "clinical.medication_conflict_check"], dataEgressPolicy: "unrestricted", integrationProfile: "modelforge-clinical", oauthClientId: CLIENT, approvalChallengeEndpoint: "https://mcp.example.test/approval-challenges" }); + const grantBody = () => ({ registryEntryId, caseId: CASE, purpose: "medication-review", toolNames: ["clinical.medication_conflict_check"], requestedFields: ["medications", "allergies"] }); + + beforeEach(async () => { + audit = new InMemoryAuditStore(); + app = buildApp({ store: new InMemoryIamStore(audit), caseStore: new InMemoryCaseStore(audit), idempotencyStore: new InMemoryIdempotencyStore(), auditStore: audit, jwks, oidc: { issuer: ISSUER, audience: AUDIENCE }, mcpApprovalTicketIssuer: new Rs256McpApprovalTicketIssuer(privatePem, ISSUER, "clinical-approval") }); + adminToken = await token(); + headers = { authorization: `Bearer ${adminToken}` }; + const org = await app.inject({ method: "POST", url: "/organizations", headers, payload: { name: "Synthetic hospital" } }); + expect(org.statusCode).toBe(201); + organizationId = org.json().organization.id; + const now = new Date().toISOString(); + const createdCase = await app.inject({ method: "POST", url: path("cases"), headers, payload: { + id: CASE, title: "Synthetic case", demographics: { value: {}, includeInContext: false }, + presentingComplaint: { value: "", includeInContext: false }, symptomsTimeline: { value: "", includeInContext: false }, + vitalSigns: { value: "", includeInContext: false }, conditions: { value: [], includeInContext: false }, + allergies: { value: ["synthetic allergy"], includeInContext: true }, medications: { value: ["synthetic medication"], includeInContext: true }, + labResults: { value: [], includeInContext: false }, imagingAndReports: { value: "", includeInContext: false }, + clinicalNotes: [], attachments: [], createdAt: now, updatedAt: now, + consentRecords: ["ai-assistance", "remote-model-use"].map((scope) => ({ id: scope, scope, grantedAt: now, method: "in-person" })), + } }); + expect(createdCase.statusCode).toBe(201); + const consent = await app.inject({ method: "POST", url: path(`cases/${CASE}/ai-consents`), headers, payload: { purpose: "treatment", dataCategories: ["medications", "allergies"] } }); + expect(consent.statusCode).toBe(201); + const entry = await app.inject({ method: "POST", url: path("mcp-registry"), headers, payload: registry() }); + expect(entry.statusCode).toBe(201); + registryEntryId = entry.json().id; + }); + + afterEach(async () => { vi.unstubAllGlobals(); await app.close(); }); + + it("rejects incomplete clinical registry entries while retaining generic compatibility", async () => { + const { oauthClientId: _client, ...incomplete } = registry(); + expect((await app.inject({ method: "POST", url: path("mcp-registry"), headers, payload: incomplete })).statusCode).toBe(400); + expect((await app.inject({ method: "POST", url: path("mcp-registry"), headers, payload: { name: "Generic", transport: "http", endpoint: "https://generic.example.test/mcp", allowedTools: "*", dataEgressPolicy: "none" } })).statusCode).toBe(201); + }); + + it("issues field-bound grants with no clinical values and requires the matching OAuth client", async () => { + const response = await app.inject({ method: "POST", url: path("mcp-context-grants"), headers, payload: grantBody() }); + expect(response.statusCode).toBe(201); + const grant = mcpContextGrantSchema.parse(response.json()); + expect(grant).toMatchObject({ subjectId: "clinician", clientId: CLIENT, organizationId, caseId: CASE, allowedFields: ["allergies", "medications"] }); + expect(grant.expiresAtEpochSeconds - Math.floor(Date.now() / 1000)).toBeLessThanOrEqual(300); + expect(response.body).not.toContain("synthetic medication"); + const otherClient = { authorization: `Bearer ${await token("clinician", "wrong-client")}` }; + expect((await app.inject({ method: "POST", url: path("mcp-context-grants"), headers: otherClient, payload: grantBody() })).statusCode).toBe(403); + expect((await app.inject({ method: "POST", url: path("mcp-context-grants"), payload: grantBody() })).statusCode).toBe(401); + }); + + it("denies unknown cases, tools, uncovered fields, revoked consent, and disabled entries", async () => { + const issue = (overrides: Record) => app.inject({ method: "POST", url: path("mcp-context-grants"), headers, payload: { ...grantBody(), ...overrides } }); + expect((await issue({ caseId: "unknown" })).statusCode).toBe(404); + expect((await issue({ toolNames: ["clinical.unknown"] })).statusCode).toBe(403); + expect((await issue({ requestedFields: ["demographics"] })).statusCode).toBe(403); + const consents = await app.inject({ method: "GET", url: path(`cases/${CASE}/ai-consents`), headers }); + const revoked = await app.inject({ method: "POST", url: path(`cases/${CASE}/ai-consents/${consents.json().consents[0].id}/revoke`), headers, payload: { reason: "Synthetic revocation" } }); + expect(revoked.statusCode).toBe(200); + expect((await issue({})).statusCode).toBe(403); + await app.inject({ method: "POST", url: path(`mcp-registry/${registryEntryId}/status`), headers, payload: { status: "disabled" } }); + expect((await issue({})).statusCode).toBe(404); + }); + + it("requires a registered tenant workload for introspection and review recording", async () => { + const issued = await app.inject({ method: "POST", url: path("mcp-context-grants"), headers, payload: grantBody() }); + const grantId = issued.json().id; + expect((await app.inject({ method: "POST", url: "/internal/mcp/context-grants/introspect", headers, payload: { grantId } })).statusCode).toBe(403); + const policy = await app.inject({ method: "POST", url: path("policies"), headers, payload: { name: "MCP workload", document: { version: "2026-01-01", statements: [{ effect: "Allow", actions: ["mcpClinical:introspect", "mcpClinical:recordReview"], resources: [`organization:${organizationId}`, `organization:${organizationId}/patientCase:*`] }] } } }); + expect(policy.statusCode).toBe(201); + const service = await app.inject({ method: "POST", url: path("service-principals"), headers, payload: { issuer: ISSUER, externalSubject: "mcp-workload", displayName: "MCP", policyIds: [policy.json().id] } }); + expect(service.statusCode).toBe(201); + const workload = { authorization: `Bearer ${await token("mcp-workload", "workload-client")}` }; + const introspected = await app.inject({ method: "POST", url: "/internal/mcp/context-grants/introspect", headers: workload, payload: { grantId } }); + expect(introspected.statusCode).toBe(200); + expect(introspected.json()).toEqual(issued.json()); + const payload = { organizationId, caseId: CASE, reviewerSubjectId: "clinician", reviewedOperationId: "10000000-0000-4000-8000-000000000001", decision: "approved", rationale: "Synthetic review text" }; + expect((await app.inject({ method: "POST", url: "/internal/mcp/reviews", headers, payload })).statusCode).toBe(403); + const first = await app.inject({ method: "POST", url: "/internal/mcp/reviews", headers: workload, payload }); + const replay = await app.inject({ method: "POST", url: "/internal/mcp/reviews", headers: workload, payload }); + expect(first.statusCode).toBe(201); + expect(replay.json()).toEqual(first.json()); + const log = JSON.stringify(await audit.listByOrganization(organizationId)); + expect(log).not.toContain(payload.rationale); + }); + + it("signs the challenged digest for the original identity and confirms only once", async () => { + const fetchChallenge = vi.fn().mockResolvedValue(new Response(JSON.stringify({ challengeId: "challenge-1", operationDigest: DIGEST, policySnapshot: SNAPSHOT, expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + 300 }), { status: 200 })); + vi.stubGlobal("fetch", fetchChallenge); + const prepared = await app.inject({ method: "POST", url: path("mcp-approvals/prepare"), headers, payload: { registryEntryId, toolName: REVIEW, caseId: CASE, arguments: { rationale: "Synthetic sensitive rationale" }, contextGrantId: "synthetic-grant" } }); + expect(prepared.statusCode).toBe(201); + expect(fetchChallenge).toHaveBeenCalledWith(registry().approvalChallengeEndpoint, expect.objectContaining({ redirect: "error", headers: expect.objectContaining({ authorization: headers.authorization }) })); + const confirmPath = path(`mcp-approvals/${prepared.json().approvalRequest.id}/confirm`); + const wrongClient = { authorization: `Bearer ${await token("clinician", "wrong-client")}` }; + expect((await app.inject({ method: "POST", url: confirmPath, headers: wrongClient })).statusCode).toBe(409); + const confirmed = await app.inject({ method: "POST", url: confirmPath, headers }); + expect(confirmed.statusCode).toBe(200); + const { payload } = await jwtVerify(confirmed.json().approvalTicket, publicKey, { issuer: ISSUER, audience: "clinical-approval", algorithms: ["RS256"] }); + expect(payload).toMatchObject({ sub: "clinician", azp: CLIENT, tool: REVIEW, digest: DIGEST }); + expect(payload.exp! - payload.iat!).toBeLessThanOrEqual(300); + expect((await app.inject({ method: "POST", url: confirmPath, headers })).statusCode).toBe(409); + expect(JSON.stringify(await audit.listByOrganization(organizationId))).not.toContain("Synthetic sensitive rationale"); + }); +}); diff --git a/server/src/routes/mcp-clinical.ts b/server/src/routes/mcp-clinical.ts new file mode 100644 index 0000000..b863f3a --- /dev/null +++ b/server/src/routes/mcp-clinical.ts @@ -0,0 +1,171 @@ +import type { CaseResourceAttributes } from "@modelforge/contracts"; +import { aiPurposeOfUseSchema, mcpApprovalChallengeSchema, mcpDestinationClassSchema } from "@modelforge/contracts"; +import type { FastifyInstance, FastifyRequest } from "fastify"; +import { z } from "zod"; +import { McpApprovalIssuerUnavailableError } from "../mcp-approval-issuer.js"; +import { actorFrom } from "../store/audit-store.js"; +import type { RouteDeps } from "./deps.js"; +import { isPermissionAllowed, requireOrgUser, requirePermission, type ResolvedPrincipal } from "./guards.js"; +import { organizationMcpApprovalParamsSchema, organizationParamsSchema } from "./params.js"; + +const grantBodySchema = z.object({ + registryEntryId: z.string().uuid(), + caseId: z.string().min(1).max(512), + purpose: aiPurposeOfUseSchema, + toolNames: z.array(z.string().min(1).max(512)).min(1).max(100), + requestedFields: z.array(z.string().min(1).max(100)).min(1).max(100), + destination: mcpDestinationClassSchema.default("managed_model_forge"), + ttlSeconds: z.number().int().min(30).max(300).default(300), +}).strict(); + +const prepareBodySchema = z.object({ + registryEntryId: z.string().uuid(), + toolName: z.string().min(1).max(512), + arguments: z.record(z.string(), z.unknown()), + contextGrantId: z.string().min(1).max(512).optional(), + caseId: z.string().min(1).max(512).optional(), +}).strict(); + +const introspectBodySchema = z.object({ grantId: z.string().min(1).max(512) }).strict(); +const reviewBodySchema = z.object({ + organizationId: z.string().uuid(), + caseId: z.string().min(1).max(512), + reviewerSubjectId: z.string().min(1).max(512), + reviewedOperationId: z.string().uuid(), + decision: z.enum(["approved", "rejected", "needs_revision"]), + rationale: z.string().trim().min(1).max(2_000), +}).strict(); + +const PURPOSE_TO_CONSENT = { + "diagnostic-support": "treatment", + "medication-review": "treatment", + "documentation-assist": "treatment", + summarization: "treatment", + research: "research", + teaching: "teaching", + "quality-improvement": "quality-improvement", +} as const; +const DERIVED_FIELDS = new Set(["assistantResponse", "items", "rationale"]); + +function clientId(request: FastifyRequest): string { + const azp = request.auth?.claims.azp; + const client = request.auth?.claims.client_id; + if (typeof azp === "string" && azp.length > 0) return azp; + if (typeof client === "string" && client.length > 0) return client; + throw Object.assign(new Error("The verified token is missing azp/client_id."), { statusCode: 401 }); +} + +function caseResourceName(organizationId: string, caseId: string): string { + return `organization:${organizationId}/patientCase:${caseId}`; +} + +function caseConditions(resource: CaseResourceAttributes, caller: ResolvedPrincipal): Record { + return { + "resource:patientId": resource.patientId, + "resource:ownerUserId": resource.ownerUserId, + "resource:workspaceId": resource.workspaceId ?? "", + "resource:departmentId": resource.departmentId ?? "", + "resource:isOwner": String(resource.ownerUserId === caller.id), + "resource:isAssigned": String(resource.assignedUserIds.includes(caller.id)), + "resource:activeConsentScopes": [...resource.activeConsentScopes].sort().join(","), + }; +} + +function endpointAllowed(value: string): boolean { + try { + const url = new URL(value); + return url.protocol === "https:" || (url.protocol === "http:" && ["127.0.0.1", "localhost", "::1"].includes(url.hostname)); + } catch { return false; } +} + +async function requestApprovalChallenge(endpoint: string, authorization: string, body: z.infer) { + if (!endpointAllowed(endpoint)) throw Object.assign(new Error("The registry approval challenge endpoint is not trusted."), { statusCode: 503 }); + const response = await fetch(endpoint, { + method: "POST", + redirect: "error", + signal: AbortSignal.timeout(5_000), + headers: { authorization, "content-type": "application/json" }, + body: JSON.stringify({ toolName: body.toolName, arguments: body.arguments, contextGrantId: body.contextGrantId }), + }); + if (!response.ok) throw Object.assign(new Error(`Clinical MCP approval challenge failed with HTTP ${response.status}.`), { statusCode: response.status >= 500 ? 503 : 409 }); + return mcpApprovalChallengeSchema.parse(await response.json()); +} + +export function registerMcpClinicalRoutes(fastify: FastifyInstance, deps: RouteDeps): void { + fastify.post("/organizations/:organizationId/mcp-context-grants", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId } = organizationParamsSchema.parse(request.params); + const body = grantBodySchema.parse(request.body); + const caller = await requireOrgUser(deps, request, organizationId); + const current = await deps.caseStore.forTenant(caller.tenantContext).getOne(body.caseId); + if (!current || !(await isPermissionAllowed(deps.store, caller, "patientCase:view", caseResourceName(organizationId, body.caseId), caseConditions(current.resource, caller)))) return reply.code(404).send({ error: "not_found" }); + await requirePermission(deps.store, caller, "mcpClinical:use", caseResourceName(organizationId, body.caseId), caseConditions(current.resource, caller)); + + const registry = await deps.mcpRegistryStore.getById(organizationId, body.registryEntryId); + if (!registry || registry.status !== "active" || registry.integrationProfile !== "modelforge-clinical") return reply.code(404).send({ error: "not_found" }); + if (!registry.oauthClientId || registry.oauthClientId !== clientId(request)) return reply.code(403).send({ error: "oauth_client_not_allowed" }); + if (registry.allowedTools !== "*" && body.toolNames.some((tool) => !registry.allowedTools.includes(tool))) return reply.code(403).send({ error: "tool_not_allowed" }); + if (body.destination !== "managed_model_forge") return reply.code(403).send({ error: "destination_not_allowed" }); + if (registry.dataEgressPolicy !== "unrestricted" && body.requestedFields.length > 0) return reply.code(403).send({ error: "data_egress_denied" }); + + const hasCaseConsent = current.patientCase.consentRecords.some((record) => record.scope === "ai-assistance" && record.revokedAt === undefined); + const hasRemoteConsent = current.patientCase.consentRecords.some((record) => record.scope === "remote-model-use" && record.revokedAt === undefined); + if (!hasCaseConsent || !hasRemoteConsent) return reply.code(403).send({ error: "case_consent_required" }); + const gatewayRepo = deps.aiGatewayStore.forTenant(caller.tenantContext); + await gatewayRepo.expireStaleConsents(new Date().toISOString()); + const consent = await gatewayRepo.getActiveConsent(body.caseId, PURPOSE_TO_CONSENT[body.purpose]); + const consentFields = new Set(consent?.dataCategories ?? []); + if (!consent || body.requestedFields.some((field) => !consentFields.has(field) && !DERIVED_FIELDS.has(field))) return reply.code(403).send({ error: "consent_scope_insufficient" }); + + const grant = await deps.mcpClinicalStore.createGrant({ organizationId, subjectId: request.auth!.subject, clientId: clientId(request), caseId: body.caseId, allowedTools: body.toolNames, allowedFields: body.requestedFields, purpose: body.purpose, destination: body.destination, expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + body.ttlSeconds }, actorFrom(caller)); + reply.code(201).send(grant); + }); + + fastify.post("/organizations/:organizationId/mcp-approvals/prepare", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId } = organizationParamsSchema.parse(request.params); + const body = prepareBodySchema.parse(request.body); + const caller = await requireOrgUser(deps, request, organizationId); + await requirePermission(deps.store, caller, "mcpClinical:approve", body.caseId ? caseResourceName(organizationId, body.caseId) : `organization:${organizationId}`); + const registry = await deps.mcpRegistryStore.getById(organizationId, body.registryEntryId); + if (!registry || registry.status !== "active" || registry.integrationProfile !== "modelforge-clinical" || !registry.approvalChallengeEndpoint) return reply.code(404).send({ error: "not_found" }); + if (!registry.oauthClientId || registry.oauthClientId !== clientId(request)) return reply.code(403).send({ error: "oauth_client_not_allowed" }); + if (registry.allowedTools !== "*" && !registry.allowedTools.includes(body.toolName)) return reply.code(403).send({ error: "tool_not_allowed" }); + const challenge = await requestApprovalChallenge(registry.approvalChallengeEndpoint, request.headers.authorization!, body); + const expiresAt = new Date(Math.min(challenge.expiresAtEpochSeconds, Math.floor(Date.now() / 1000) + 300) * 1_000).toISOString(); + const approval = await deps.mcpClinicalStore.createApprovalRequest({ organizationId, registryEntryId: body.registryEntryId, subjectId: request.auth!.subject, clientId: clientId(request), toolName: body.toolName, operationDigest: challenge.operationDigest, caseId: body.caseId, expiresAt }, actorFrom(caller)); + reply.code(201).send({ approvalRequest: approval, challenge }); + }); + + fastify.post("/organizations/:organizationId/mcp-approvals/:approvalRequestId/confirm", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId, approvalRequestId } = organizationMcpApprovalParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + const pending = await deps.mcpClinicalStore.getApprovalRequest(organizationId, approvalRequestId); + if (!pending) return reply.code(404).send({ error: "not_found" }); + await requirePermission(deps.store, caller, "mcpClinical:approve", pending.caseId ? caseResourceName(organizationId, pending.caseId) : `organization:${organizationId}`); + const confirmed = await deps.mcpClinicalStore.confirmApprovalRequest(organizationId, approvalRequestId, request.auth!.subject, clientId(request), actorFrom(caller)); + if (!confirmed) return reply.code(409).send({ error: "approval_not_pending" }); + try { return reply.send({ approvalRequest: confirmed, approvalTicket: await deps.mcpApprovalTicketIssuer.issue(confirmed) }); } + catch (error) { + if (error instanceof McpApprovalIssuerUnavailableError) return reply.code(503).send({ error: "approval_issuer_unavailable" }); + throw error; + } + }); + + fastify.post("/internal/mcp/context-grants/introspect", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { grantId } = introspectBodySchema.parse(request.body); + const organizationId = grantId.slice(0, 36); + const caller = await requireOrgUser(deps, request, organizationId); + if (caller.principalType !== "service") return reply.code(403).send({ error: "service_principal_required" }); + await requirePermission(deps.store, caller, "mcpClinical:introspect", `organization:${organizationId}`); + const grant = await deps.mcpClinicalStore.introspectGrant(grantId); + if (!grant) return reply.code(404).send({ error: "not_found" }); + reply.send(grant); + }); + + fastify.post("/internal/mcp/reviews", { preHandler: deps.authPreHandler }, async (request, reply) => { + const body = reviewBodySchema.parse(request.body); + const caller = await requireOrgUser(deps, request, body.organizationId); + if (caller.principalType !== "service") return reply.code(403).send({ error: "service_principal_required" }); + await requirePermission(deps.store, caller, "mcpClinical:recordReview", caseResourceName(body.organizationId, body.caseId)); + reply.code(201).send(await deps.mcpClinicalStore.recordReview(body, actorFrom(caller))); + }); +} diff --git a/server/src/routes/mcp-registry.ts b/server/src/routes/mcp-registry.ts index 2b9f500..e50a386 100644 --- a/server/src/routes/mcp-registry.ts +++ b/server/src/routes/mcp-registry.ts @@ -1,6 +1,6 @@ import type { FastifyInstance } from "fastify"; import { z } from "zod"; -import { mcpAllowedToolsSchema, mcpDataEgressPolicySchema, mcpTransportSchema } from "../domain/types.js"; +import { mcpAllowedToolsSchema, mcpDataEgressPolicySchema, mcpIntegrationProfileSchema, mcpTransportSchema } from "../domain/types.js"; import { actorFrom } from "../store/audit-store.js"; import type { RouteDeps } from "./deps.js"; import { requireOrgUser, requirePermission } from "./guards.js"; @@ -13,11 +13,37 @@ const createEntryBodySchema = z endpoint: z.string().min(1), allowedTools: mcpAllowedToolsSchema, dataEgressPolicy: mcpDataEgressPolicySchema, + integrationProfile: mcpIntegrationProfileSchema.default("generic"), + oauthClientId: z.string().min(1).max(512).optional(), + catalogVersionConstraint: z.string().min(1).max(200).optional(), + approvalChallengeEndpoint: z.string().url().optional(), description: z.string().optional(), }) - .strict(); + .strict() + .superRefine((value, context) => { + if (value.integrationProfile === "modelforge-clinical" && value.transport !== "http") { + context.addIssue({ code: "custom", path: ["transport"], message: "The clinical integration profile requires HTTP transport." }); + } + if (value.integrationProfile === "modelforge-clinical" && !value.oauthClientId) { + context.addIssue({ code: "custom", path: ["oauthClientId"], message: "The clinical integration profile requires an OAuth client ID." }); + } + if (value.integrationProfile === "modelforge-clinical" && !value.approvalChallengeEndpoint) { + context.addIssue({ code: "custom", path: ["approvalChallengeEndpoint"], message: "The clinical integration profile requires an approval challenge endpoint." }); + } + }); -const updateEntryBodySchema = createEntryBodySchema.partial().strict(); +const updateEntryBodySchema = z.object({ + name: z.string().min(1).optional(), + transport: mcpTransportSchema.optional(), + endpoint: z.string().min(1).optional(), + allowedTools: mcpAllowedToolsSchema.optional(), + dataEgressPolicy: mcpDataEgressPolicySchema.optional(), + integrationProfile: mcpIntegrationProfileSchema.optional(), + oauthClientId: z.string().min(1).max(512).optional(), + catalogVersionConstraint: z.string().min(1).max(200).optional(), + approvalChallengeEndpoint: z.string().url().optional(), + description: z.string().optional(), +}).strict(); const setStatusBodySchema = z.object({ status: z.enum(["active", "disabled"]) }).strict(); @@ -68,8 +94,14 @@ export function registerMcpRegistryRoutes(fastify: FastifyInstance, deps: RouteD const caller = await requireOrgUser(deps, request, organizationId); await requirePermission(deps.store, caller, "mcpRegistry:manage", `organization:${organizationId}`); const body = updateEntryBodySchema.parse(request.body); + const current = await deps.mcpRegistryStore.getById(organizationId, entryId); + if (!current) return reply.code(404).send({ error: "not_found" }); + const next = { ...current, ...body }; + if (next.integrationProfile === "modelforge-clinical" && (next.transport !== "http" || !next.oauthClientId || !next.approvalChallengeEndpoint)) { + return reply.code(400).send({ error: "invalid_clinical_registry_entry" }); + } const updated = await deps.mcpRegistryStore.update(organizationId, entryId, body, caller.id, actorFrom(caller)); - if (!updated) return reply.code(404).send({ error: "not_found" }); + if (!updated) return reply.code(409).send({ error: "registry_entry_changed" }); reply.send(updated); }); diff --git a/server/src/routes/params.ts b/server/src/routes/params.ts index 7360db1..599bb0a 100644 --- a/server/src/routes/params.ts +++ b/server/src/routes/params.ts @@ -54,3 +54,21 @@ export const organizationAiInferenceDeploymentParamsSchema = z.object({ organiza // mcp_registry_entries.id is a real Postgres UUID column (migrations/020_mcp_registry.sql). export const organizationMcpRegistryEntryParamsSchema = z.object({ organizationId: uuid, entryId: uuid }); +export const organizationMcpApprovalParamsSchema = z.object({ organizationId: uuid, approvalRequestId: uuid }); +export const organizationHl7JobParamsSchema = z.object({ organizationId: uuid, jobId: z.string().min(1) }); + +// SMART App Launch (routes/smart-launch.ts): `state` is a server-generated +// random token (smart-launch/pkce.ts's generateState) that also doubles as +// the launch session's own store id; `sessionId` is a completed token's +// randomUUID() id. Neither is a UUID-format check at this boundary — same +// "server-generated TEXT id" reasoning as every other non-UUID id above. +export const organizationSmartLaunchStateParamsSchema = z.object({ organizationId: uuid, state: z.string().min(1) }); +export const organizationSmartLaunchSessionParamsSchema = z.object({ organizationId: uuid, sessionId: z.string().min(1) }); + +// FHIR R4 read facade (routes/fhir.ts). caseId/studyId/reportId: same +// "server-generated TEXT id, not enforced as UUID at this boundary" +// reasoning as their non-FHIR counterparts above — these params.parse the +// same underlying ids, just reached via a different URL shape. +export const organizationFhirCaseParamsSchema = z.object({ organizationId: uuid, caseId: z.string().min(1) }); +export const organizationFhirStudyParamsSchema = z.object({ organizationId: uuid, studyId: z.string().min(1) }); +export const organizationFhirReportParamsSchema = z.object({ organizationId: uuid, reportId: z.string().min(1) }); diff --git a/server/src/routes/smart-launch.integration.test.ts b/server/src/routes/smart-launch.integration.test.ts new file mode 100644 index 0000000..e80155a --- /dev/null +++ b/server/src/routes/smart-launch.integration.test.ts @@ -0,0 +1,319 @@ +import { randomBytes } from "node:crypto"; +import { afterEach, describe, it, expect, beforeAll, beforeEach, vi } from "vitest"; +import { SignJWT, exportJWK, generateKeyPair, createLocalJWKSet, type JWTVerifyGetKey, type CryptoKey } from "jose"; +import type { FastifyInstance } from "fastify"; +import { buildApp } from "../app.js"; +import { InMemoryAuditStore } from "../store/audit-store.js"; +import { InMemoryCaseStore } from "../store/in-memory-case-store.js"; +import { InMemoryIamStore } from "../store/in-memory-iam-store.js"; +import { InMemoryIdempotencyStore } from "../store/in-memory-idempotency-store.js"; + +/** + * HTTP-level integration tests for routes/smart-launch.ts — mirrors + * hl7.integration.test.ts's own setup/rationale: unit coverage of the PKCE/ + * discovery/token-exchange logic lives in smart-launch/service.test.ts, so + * this file is specifically about route wiring, IAM enforcement, the + * "requires an existing ModelForge session first" design (every route sits + * behind the same authPreHandler as everything else), and that a completed + * token's secrets never appear in any response body. + */ +const ISSUER = "https://idp.example-hospital.test/realms/clinical"; +const AUDIENCE = "modelforge-iam-server"; +const KID = "test-key"; +const EHR_ISSUER = "https://ehr.example-hospital.test/fhir"; +const REDIRECT_URI = "https://modelforge.example.test/smart/callback"; +const ENCRYPTION_KEY = randomBytes(32); + +describe("SMART App Launch (client role): end-to-end route security", () => { + let privateKey: CryptoKey; + let jwks: JWTVerifyGetKey; + let app: FastifyInstance; + + beforeAll(async () => { + const pair = await generateKeyPair("RS256"); + privateKey = pair.privateKey; + const publicJwk = await exportJWK(pair.publicKey); + publicJwk.kid = KID; + publicJwk.alg = "RS256"; + jwks = createLocalJWKSet({ keys: [publicJwk] }); + }); + + beforeEach(() => { + const auditStore = new InMemoryAuditStore(); + app = buildApp({ + store: new InMemoryIamStore(auditStore), + caseStore: new InMemoryCaseStore(auditStore), + idempotencyStore: new InMemoryIdempotencyStore(), + auditStore, + jwks, + oidc: { issuer: ISSUER, audience: AUDIENCE }, + smartLaunchEncryptionKey: ENCRYPTION_KEY, + }); + }); + + afterEach(() => vi.unstubAllGlobals()); + + async function tokenFor(subject: string, extra?: Record): Promise { + return new SignJWT({ sub: subject, ...extra }).setProtectedHeader({ alg: "RS256", kid: KID }).setIssuedAt().setIssuer(ISSUER).setAudience(AUDIENCE).setExpirationTime("1h").sign(privateKey); + } + + async function createOrg(adminSubject: string): Promise<{ orgId: string; adminToken: string }> { + const adminToken = await tokenFor(adminSubject, { name: "Dr. Admin" }); + const response = await app.inject({ method: "POST", url: "/organizations", headers: { authorization: `Bearer ${adminToken}` }, payload: { name: "Example Health System" } }); + expect(response.statusCode).toBe(201); + return { orgId: response.json().organization.id, adminToken }; + } + + async function createUserWithSmartLaunchUse(orgId: string, adminToken: string, externalSubject: string): Promise { + const policy = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/policies`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { name: "smart-launch-use", document: { version: "2026-01-01", statements: [{ effect: "Allow", actions: ["smartLaunch:use"], resources: ["*"] }] } }, + }); + expect(policy.statusCode).toBe(201); + const user = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/users`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { externalSubject, displayName: "Dr. Other", policyIds: [policy.json().id] }, + }); + expect(user.statusCode).toBe(201); + return tokenFor(externalSubject); + } + + async function addTrustedIssuer(orgId: string, adminToken: string, redirectUris: string[] = [REDIRECT_URI]) { + const response = await app.inject({ + method: "PUT", + url: `/organizations/${orgId}/smart/trusted-issuers`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { issuer: EHR_ISSUER, clientId: "modelforge-client", redirectUris }, + }); + expect(response.statusCode).toBe(200); + return response.json(); + } + + function stubDiscovery() { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url.endsWith("/.well-known/smart-configuration")) { + return new Response(JSON.stringify({ authorization_endpoint: "https://ehr.example-hospital.test/auth", token_endpoint: "https://ehr.example-hospital.test/token" }), { status: 200 }); + } + throw new Error(`unexpected fetch to ${url}`); + }) + ); + } + + function stubDiscoveryAndTokenExchange(payload: Record, status = 200) { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: { method?: string }) => { + if (url.endsWith("/.well-known/smart-configuration")) { + return new Response(JSON.stringify({ authorization_endpoint: "https://ehr.example-hospital.test/auth", token_endpoint: "https://ehr.example-hospital.test/token" }), { status: 200 }); + } + if (url === "https://ehr.example-hospital.test/token" && init?.method === "POST") { + return new Response(JSON.stringify(payload), { status }); + } + throw new Error(`unexpected fetch to ${url}`); + }) + ); + } + + describe("trusted issuer administration", () => { + it("lets an authorized admin upsert and list a trusted issuer, and reject one lacking smartLaunch:manage", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + const trusted = await addTrustedIssuer(orgId, adminToken); + expect(trusted).toMatchObject({ issuer: EHR_ISSUER, clientId: "modelforge-client", redirectUris: [REDIRECT_URI] }); + + const list = await app.inject({ method: "GET", url: `/organizations/${orgId}/smart/trusted-issuers`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(list.statusCode).toBe(200); + expect(list.json().trustedIssuers).toHaveLength(1); + + await app.inject({ method: "POST", url: `/organizations/${orgId}/users`, headers: { authorization: `Bearer ${adminToken}` }, payload: { externalSubject: "idp|no-rights", displayName: "No Rights" } }); + const strangerToken = await tokenFor("idp|no-rights"); + const forbidden = await app.inject({ method: "PUT", url: `/organizations/${orgId}/smart/trusted-issuers`, headers: { authorization: `Bearer ${strangerToken}` }, payload: { issuer: EHR_ISSUER, clientId: "x", redirectUris: [REDIRECT_URI] } }); + expect(forbidden.statusCode).toBe(403); + + const listForbidden = await app.inject({ method: "GET", url: `/organizations/${orgId}/smart/trusted-issuers`, headers: { authorization: `Bearer ${strangerToken}` } }); + expect(listForbidden.statusCode).toBe(403); + }); + + it("lets a smartLaunch:use-only caller (no manage rights) read the trusted-issuer list — needed to pick one to launch against — but not write it", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + await addTrustedIssuer(orgId, adminToken); + const clinicianToken = await createUserWithSmartLaunchUse(orgId, adminToken, "idp|clinician"); + + const list = await app.inject({ method: "GET", url: `/organizations/${orgId}/smart/trusted-issuers`, headers: { authorization: `Bearer ${clinicianToken}` } }); + expect(list.statusCode).toBe(200); + expect(list.json().trustedIssuers).toHaveLength(1); + + const forbiddenWrite = await app.inject({ method: "PUT", url: `/organizations/${orgId}/smart/trusted-issuers`, headers: { authorization: `Bearer ${clinicianToken}` }, payload: { issuer: EHR_ISSUER, clientId: "x", redirectUris: [REDIRECT_URI] } }); + expect(forbiddenWrite.statusCode).toBe(403); + }); + + it("deletes a trusted issuer, 404ing identically on a second delete", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + await addTrustedIssuer(orgId, adminToken); + const del = await app.inject({ method: "POST", url: `/organizations/${orgId}/smart/trusted-issuers/delete`, headers: { authorization: `Bearer ${adminToken}` }, payload: { issuer: EHR_ISSUER } }); + expect(del.statusCode).toBe(204); + const delAgain = await app.inject({ method: "POST", url: `/organizations/${orgId}/smart/trusted-issuers/delete`, headers: { authorization: `Bearer ${adminToken}` }, payload: { issuer: EHR_ISSUER } }); + expect(delAgain.statusCode).toBe(404); + }); + }); + + describe("launch flow", () => { + it("requires the caller to already hold a ModelForge session — no unauthenticated entry point", async () => { + const { orgId } = await createOrg("idp|dr-admin"); + const response = await app.inject({ method: "POST", url: `/organizations/${orgId}/smart/launch-sessions`, payload: { issuer: EHR_ISSUER, redirectUri: REDIRECT_URI } }); + expect(response.statusCode).toBe(401); + }); + + it("starts a launch session against a trusted issuer and returns an authorization URL with PKCE + state", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + await addTrustedIssuer(orgId, adminToken); + stubDiscovery(); + + const response = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/smart/launch-sessions`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { issuer: EHR_ISSUER, redirectUri: REDIRECT_URI }, + }); + expect(response.statusCode).toBe(201); + const body = response.json(); + expect(body.session).toMatchObject({ issuer: EHR_ISSUER, status: "pending" }); + const url = new URL(body.authorizationUrl); + expect(url.searchParams.get("state")).toBe(body.session.id); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + }); + + it("rejects a launch against an issuer not on this organization's trusted allowlist with 422", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + const response = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/smart/launch-sessions`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { issuer: EHR_ISSUER, redirectUri: REDIRECT_URI }, + }); + expect(response.statusCode).toBe(422); + expect(response.json()).toMatchObject({ error: "untrusted_issuer" }); + }); + + it("completes the callback, exchanging the code and never returning secrets in the response body", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + await addTrustedIssuer(orgId, adminToken); + stubDiscovery(); + const start = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/smart/launch-sessions`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { issuer: EHR_ISSUER, redirectUri: REDIRECT_URI }, + }); + const state = start.json().session.id; + + stubDiscoveryAndTokenExchange({ access_token: "real-access-token", refresh_token: "real-refresh-token", expires_in: 3600, patient: "epic-patient-123", scope: "patient/*.read launch" }); + const callback = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/smart/launch-sessions/${state}/callback`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { code: "auth-code-xyz" }, + }); + expect(callback.statusCode).toBe(201); + const token = callback.json(); + expect(token).toMatchObject({ issuer: EHR_ISSUER, patientId: "epic-patient-123", hasRefreshToken: true }); + expect(JSON.stringify(token)).not.toContain("real-access-token"); + expect(JSON.stringify(token)).not.toContain("real-refresh-token"); + + const sessions = await app.inject({ method: "GET", url: `/organizations/${orgId}/smart/sessions`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(sessions.statusCode).toBe(200); + expect(sessions.json().sessions).toHaveLength(1); + expect(JSON.stringify(sessions.json())).not.toContain("real-access-token"); + }); + + it("refuses to complete a launch for a different user than the one who started it", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + await addTrustedIssuer(orgId, adminToken); + stubDiscovery(); + const start = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/smart/launch-sessions`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { issuer: EHR_ISSUER, redirectUri: REDIRECT_URI }, + }); + const state = start.json().session.id; + + const otherToken = await createUserWithSmartLaunchUse(orgId, adminToken, "idp|other-clinician"); + const callback = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/smart/launch-sessions/${state}/callback`, + headers: { authorization: `Bearer ${otherToken}` }, + payload: { code: "auth-code-xyz" }, + }); + expect(callback.statusCode).toBe(403); + }); + + it("503s the callback when SMART_LAUNCH_ENCRYPTION_KEY is not configured, rather than encrypting with no real key", async () => { + const auditStore = new InMemoryAuditStore(); + const unkeyedApp = buildApp({ + store: new InMemoryIamStore(auditStore), + caseStore: new InMemoryCaseStore(auditStore), + idempotencyStore: new InMemoryIdempotencyStore(), + auditStore, + jwks, + oidc: { issuer: ISSUER, audience: AUDIENCE }, + }); + const adminToken = await tokenFor("idp|dr-admin", { name: "Dr. Admin" }); + const orgResponse = await unkeyedApp.inject({ method: "POST", url: "/organizations", headers: { authorization: `Bearer ${adminToken}` }, payload: { name: "Example Health System" } }); + const orgId = orgResponse.json().organization.id; + const response = await unkeyedApp.inject({ + method: "POST", + url: `/organizations/${orgId}/smart/launch-sessions/some-state/callback`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { code: "auth-code-xyz" }, + }); + expect(response.statusCode).toBe(503); + }); + }); + + describe("session management", () => { + it("lets a caller revoke their own session, 404ing identically for one they don't own", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + await addTrustedIssuer(orgId, adminToken); + stubDiscovery(); + const start = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/smart/launch-sessions`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { issuer: EHR_ISSUER, redirectUri: REDIRECT_URI }, + }); + const state = start.json().session.id; + stubDiscoveryAndTokenExchange({ access_token: "real-access-token", expires_in: 3600, patient: "epic-patient-123" }); + const callback = await app.inject({ + method: "POST", + url: `/organizations/${orgId}/smart/launch-sessions/${state}/callback`, + headers: { authorization: `Bearer ${adminToken}` }, + payload: { code: "auth-code-xyz" }, + }); + const sessionId = callback.json().id; + + const otherToken = await createUserWithSmartLaunchUse(orgId, adminToken, "idp|other-clinician"); + const notOwner = await app.inject({ method: "POST", url: `/organizations/${orgId}/smart/sessions/${sessionId}/revoke`, headers: { authorization: `Bearer ${otherToken}` } }); + expect(notOwner.statusCode).toBe(404); + + const revoke = await app.inject({ method: "POST", url: `/organizations/${orgId}/smart/sessions/${sessionId}/revoke`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(revoke.statusCode).toBe(204); + + const revokeAgain = await app.inject({ method: "POST", url: `/organizations/${orgId}/smart/sessions/${sessionId}/revoke`, headers: { authorization: `Bearer ${adminToken}` } }); + expect(revokeAgain.statusCode).toBe(404); + }); + + it("rejects a caller without smartLaunch:use with 403", async () => { + const { orgId, adminToken } = await createOrg("idp|dr-admin"); + await app.inject({ method: "POST", url: `/organizations/${orgId}/users`, headers: { authorization: `Bearer ${adminToken}` }, payload: { externalSubject: "idp|no-rights", displayName: "No Rights" } }); + const strangerToken = await tokenFor("idp|no-rights"); + const response = await app.inject({ method: "GET", url: `/organizations/${orgId}/smart/sessions`, headers: { authorization: `Bearer ${strangerToken}` } }); + expect(response.statusCode).toBe(403); + }); + }); +}); diff --git a/server/src/routes/smart-launch.ts b/server/src/routes/smart-launch.ts new file mode 100644 index 0000000..3db59fc --- /dev/null +++ b/server/src/routes/smart-launch.ts @@ -0,0 +1,153 @@ +import type { FastifyInstance } from "fastify"; +import { z } from "zod"; +import { completeLaunchCallback, createLaunchSession, SmartLaunchCallbackError, SmartLaunchError } from "../smart-launch/service.js"; +import { actorFrom } from "../store/audit-store.js"; +import type { RouteDeps } from "./deps.js"; +import { isPermissionAllowed, requireOrgUser, requirePermission } from "./guards.js"; +import { organizationParamsSchema, organizationSmartLaunchSessionParamsSchema, organizationSmartLaunchStateParamsSchema } from "./params.js"; + +/** + * SMART App Launch, client role — see packages/contracts/src/smart-launch.ts + * and docs/SMART_LAUNCH.md for the full flow and its standing design + * decision: every route here sits behind the same bearer-token + * `deps.authPreHandler` every other route in this API does. There is no + * unauthenticated redirect entry point — a launch always starts from an + * already-authenticated ModelForge caller, who is handed an authorization + * URL to navigate to (a client-side redirect this server does not itself + * perform), not the other way around. + * + * `smartLaunch:manage` gates the admin-configured trusted-issuer allowlist + * (client_id, allowed redirect URIs — the two things that make the + * exact-match validation in smart-launch/service.ts meaningful); + * `smartLaunch:use` gates any org member actually starting/completing a + * launch or managing their own resulting sessions. A completed launch's + * token is never returned in any response body — see + * store/smart-launch-store.ts's own publicToken/publicLaunchSession. + */ +// issuer identifies the trusted-issuer row within the request body, not +// the URL path — a full URL is an awkward path segment, and PUT/DELETE +// both need one either way. +const upsertTrustedIssuerBodySchema = z + .object({ + issuer: z.string().url().max(2_000), + clientId: z.string().min(1).max(500), + redirectUris: z.array(z.string().url().max(2_000)).min(1).max(20), + }) + .strict(); + +const deleteTrustedIssuerBodySchema = z.object({ issuer: z.string().url().max(2_000) }).strict(); + +const createLaunchSessionBodySchema = z + .object({ + issuer: z.string().url().max(2_000), + redirectUri: z.string().url().max(2_000), + scopes: z.array(z.string().min(1).max(200)).max(20).optional(), + launch: z.string().max(500).optional(), + }) + .strict(); + +const callbackBodySchema = z.object({ code: z.string().min(1).max(2_000) }).strict(); + +export function registerSmartLaunchRoutes(fastify: FastifyInstance, deps: RouteDeps): void { + // --- Trusted issuer administration --------------------------------- + + fastify.put("/organizations/:organizationId/smart/trusted-issuers", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId } = organizationParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + await requirePermission(deps.store, caller, "smartLaunch:manage", `organization:${organizationId}/smartLaunchTrustedIssuers`); + const body = upsertTrustedIssuerBodySchema.parse(request.body); + const repo = deps.smartLaunchStore.forTenant(caller.tenantContext); + const trusted = await repo.upsertTrustedIssuer({ issuer: body.issuer, clientId: body.clientId, redirectUris: body.redirectUris, addedByUserId: caller.id }, actorFrom(caller)); + reply.code(200).send(trusted); + }); + + // Readable by smartLaunch:use as well as smartLaunch:manage — a + // clinician has to know which EHRs are configured (issuer, clientId, + // redirectUris) to actually start a launch, and none of that is + // secret (this is a public PKCE client; there is no client_secret to + // protect here, see smart-launch/service.ts's own doc comment). Only + // PUT/delete below stay manage-only. + fastify.get("/organizations/:organizationId/smart/trusted-issuers", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId } = organizationParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + const resource = `organization:${organizationId}/smartLaunchTrustedIssuers`; + const canManage = await isPermissionAllowed(deps.store, caller, "smartLaunch:manage", resource); + const canUse = canManage || (await isPermissionAllowed(deps.store, caller, "smartLaunch:use", `organization:${organizationId}/smartLaunchSessions`)); + if (!canUse) return reply.code(403).send({ error: "forbidden", message: 'Not authorized to perform "smartLaunch:use" or "smartLaunch:manage" on this organization.' }); + const repo = deps.smartLaunchStore.forTenant(caller.tenantContext); + reply.send({ trustedIssuers: await repo.listTrustedIssuers() }); + }); + + fastify.post("/organizations/:organizationId/smart/trusted-issuers/delete", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId } = organizationParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + await requirePermission(deps.store, caller, "smartLaunch:manage", `organization:${organizationId}/smartLaunchTrustedIssuers`); + const body = deleteTrustedIssuerBodySchema.parse(request.body); + const repo = deps.smartLaunchStore.forTenant(caller.tenantContext); + const deleted = await repo.deleteTrustedIssuer(body.issuer, actorFrom(caller)); + if (!deleted) return reply.code(404).send({ error: "not_found" }); + reply.code(204).send(); + }); + + // --- Launch flow ----------------------------------------------------- + + fastify.post("/organizations/:organizationId/smart/launch-sessions", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId } = organizationParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + await requirePermission(deps.store, caller, "smartLaunch:use", `organization:${organizationId}/smartLaunchSessions`); + const body = createLaunchSessionBodySchema.parse(request.body); + const repo = deps.smartLaunchStore.forTenant(caller.tenantContext); + try { + const result = await createLaunchSession({ repo, requestedByUserId: caller.id, issuer: body.issuer, redirectUri: body.redirectUri, scopes: body.scopes, launch: body.launch, actor: actorFrom(caller) }); + reply.code(201).send(result); + } catch (err) { + if (err instanceof SmartLaunchError) return reply.code(422).send({ error: err.code, message: err.message }); + throw err; + } + }); + + fastify.post("/organizations/:organizationId/smart/launch-sessions/:state/callback", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId, state } = organizationSmartLaunchStateParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + await requirePermission(deps.store, caller, "smartLaunch:use", `organization:${organizationId}/smartLaunchSessions`); + if (!deps.smartLaunchEncryptionKey) { + return reply.code(503).send({ error: "smart_launch_unavailable", message: "SMART_LAUNCH_ENCRYPTION_KEY is not configured on this server; no launch token can be safely stored." }); + } + const body = callbackBodySchema.parse(request.body); + const repo = deps.smartLaunchStore.forTenant(caller.tenantContext); + try { + const token = await completeLaunchCallback({ repo, state, code: body.code, callerId: caller.id, encryptionKey: deps.smartLaunchEncryptionKey, actor: actorFrom(caller) }); + reply.code(201).send(token); + } catch (err) { + if (err instanceof SmartLaunchCallbackError) { + const status = err.code === "session_not_found" ? 404 : err.code === "forbidden" ? 403 : 409; + return reply.code(status).send({ error: err.code, message: err.message }); + } + throw err; + } + }); + + // --- Session management (a caller's own sessions only) -------------- + + fastify.get("/organizations/:organizationId/smart/sessions", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId } = organizationParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + await requirePermission(deps.store, caller, "smartLaunch:use", `organization:${organizationId}/smartLaunchSessions`); + const repo = deps.smartLaunchStore.forTenant(caller.tenantContext); + reply.send({ sessions: await repo.listTokensForUser(caller.id) }); + }); + + fastify.post("/organizations/:organizationId/smart/sessions/:sessionId/revoke", { preHandler: deps.authPreHandler }, async (request, reply) => { + const { organizationId, sessionId } = organizationSmartLaunchSessionParamsSchema.parse(request.params); + const caller = await requireOrgUser(deps, request, organizationId); + await requirePermission(deps.store, caller, "smartLaunch:use", `organization:${organizationId}/smartLaunchSessions`); + const repo = deps.smartLaunchStore.forTenant(caller.tenantContext); + const existing = await repo.getToken(sessionId); + // Identical 404 for absent and "exists but belongs to someone + // else" — same nondisclosure discipline as every other resource + // route in this API. + if (!existing || existing.requestedByUserId !== caller.id) return reply.code(404).send({ error: "not_found" }); + await repo.deleteToken(sessionId, actorFrom(caller)); + reply.code(204).send(); + }); +} diff --git a/server/src/smart-launch/discovery.test.ts b/server/src/smart-launch/discovery.test.ts new file mode 100644 index 0000000..da3a6c7 --- /dev/null +++ b/server/src/smart-launch/discovery.test.ts @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveSmartConfiguration, SmartDiscoveryError } from "./discovery.js"; + +const FHIR_BASE = "https://ehr.example-hospital.test/fhir"; + +describe("resolveSmartConfiguration", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("resolves authorization/token endpoints from a well-formed smart-configuration document", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + expect(url).toBe(`${FHIR_BASE}/.well-known/smart-configuration`); + return new Response(JSON.stringify({ authorization_endpoint: "https://ehr.example-hospital.test/auth", token_endpoint: "https://ehr.example-hospital.test/token" }), { status: 200 }); + }) + ); + await expect(resolveSmartConfiguration(FHIR_BASE)).resolves.toEqual({ authorizationEndpoint: "https://ehr.example-hospital.test/auth", tokenEndpoint: "https://ehr.example-hospital.test/token" }); + }); + + it("strips a trailing slash from the FHIR base before appending the discovery path", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + expect(url).toBe(`${FHIR_BASE}/.well-known/smart-configuration`); + return new Response(JSON.stringify({ authorization_endpoint: "a", token_endpoint: "b" }), { status: 200 }); + }) + ); + await resolveSmartConfiguration(`${FHIR_BASE}/`); + }); + + it("rejects with SmartDiscoveryError on a non-2xx response", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("not found", { status: 404 }))); + await expect(resolveSmartConfiguration(FHIR_BASE)).rejects.toBeInstanceOf(SmartDiscoveryError); + }); + + it("rejects with SmartDiscoveryError on malformed JSON", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response("not json", { status: 200 }))); + await expect(resolveSmartConfiguration(FHIR_BASE)).rejects.toBeInstanceOf(SmartDiscoveryError); + }); + + it("rejects with SmartDiscoveryError when authorization_endpoint is missing", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ token_endpoint: "b" }), { status: 200 }))); + await expect(resolveSmartConfiguration(FHIR_BASE)).rejects.toThrow(/authorization_endpoint/); + }); + + it("rejects with SmartDiscoveryError when token_endpoint is missing", async () => { + vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ authorization_endpoint: "a" }), { status: 200 }))); + await expect(resolveSmartConfiguration(FHIR_BASE)).rejects.toThrow(/token_endpoint/); + }); + + it("rejects with a clear error, not a hang, on discovery timeout", async () => { + vi.stubGlobal( + "fetch", + vi.fn((_url: string, init?: { signal?: AbortSignal }) => new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => { + const err = new Error("This operation was aborted"); + err.name = "TimeoutError"; + reject(err); + }); + })) + ); + await expect(resolveSmartConfiguration(FHIR_BASE, 50)).rejects.toThrow(/timed out after 50ms/); + }); +}); diff --git a/server/src/smart-launch/discovery.ts b/server/src/smart-launch/discovery.ts new file mode 100644 index 0000000..a7d255d --- /dev/null +++ b/server/src/smart-launch/discovery.ts @@ -0,0 +1,57 @@ +/** + * SMART App Launch discovery against an EXTERNAL EHR — the client-role + * counterpart to auth/oidc-verifier.ts's resolveJwks/ + * resolveAuthorizationServerMetadata (which discover THIS server's own + * trusted OIDC issuer for verifying inbound tokens). This fetches a + * *different* server's `.well-known/smart-configuration` — the SMART App + * Launch spec's own discovery document, distinct from plain OIDC discovery + * (`.well-known/openid-configuration`) — to learn where to send a user to + * authorize and where to exchange a code for a token. + */ +const DISCOVERY_TIMEOUT_MS = 10_000; + +export interface SmartAuthorizationServerMetadata { + authorizationEndpoint: string; + tokenEndpoint: string; +} + +export class SmartDiscoveryError extends Error {} + +/** + * `fhirBaseUrl` is the `iss` a launch names — SMART's own convention that + * the FHIR base URL and the identifier used for its `.well-known/smart- + * configuration` discovery are the same URL. Never called with a caller- + * supplied URL that hasn't already been checked against this + * organization's own trusted-issuer allowlist (see routes/smart-launch.ts) + * — this function itself does no allowlisting, it only speaks HTTP to + * whatever URL it's given, and letting an unauthenticated/unvalidated URL + * reach it would be a real SSRF risk. + */ +export async function resolveSmartConfiguration(fhirBaseUrl: string, discoveryTimeoutMs: number = DISCOVERY_TIMEOUT_MS): Promise { + const base = fhirBaseUrl.endsWith("/") ? fhirBaseUrl.slice(0, -1) : fhirBaseUrl; + const discoveryUrl = `${base}/.well-known/smart-configuration`; + let response: Response; + try { + response = await fetch(discoveryUrl, { signal: AbortSignal.timeout(discoveryTimeoutMs) }); + } catch (err) { + const reason = err instanceof Error && err.name === "TimeoutError" ? `timed out after ${discoveryTimeoutMs}ms` : String(err); + throw new SmartDiscoveryError(`SMART discovery failed for "${fhirBaseUrl}" (${discoveryUrl}): ${reason}`); + } + if (!response.ok) { + throw new SmartDiscoveryError(`SMART discovery failed for "${fhirBaseUrl}" (${discoveryUrl}): HTTP ${response.status} ${response.statusText}`); + } + let document: Record; + try { + document = (await response.json()) as Record; + } catch { + throw new SmartDiscoveryError(`SMART discovery document at ${discoveryUrl} was not valid JSON.`); + } + const { authorization_endpoint: authorizationEndpoint, token_endpoint: tokenEndpoint } = document; + if (typeof authorizationEndpoint !== "string" || authorizationEndpoint.length === 0) { + throw new SmartDiscoveryError(`SMART discovery document at ${discoveryUrl} has no usable "authorization_endpoint".`); + } + if (typeof tokenEndpoint !== "string" || tokenEndpoint.length === 0) { + throw new SmartDiscoveryError(`SMART discovery document at ${discoveryUrl} has no usable "token_endpoint".`); + } + return { authorizationEndpoint, tokenEndpoint }; +} diff --git a/server/src/smart-launch/pkce.test.ts b/server/src/smart-launch/pkce.test.ts new file mode 100644 index 0000000..ff624bd --- /dev/null +++ b/server/src/smart-launch/pkce.test.ts @@ -0,0 +1,40 @@ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { generatePkcePair, generateState } from "./pkce.js"; + +describe("generatePkcePair", () => { + it("produces a code_challenge that is the SHA-256(codeVerifier), base64url encoded", () => { + const { codeVerifier, codeChallenge } = generatePkcePair(); + const expected = createHash("sha256").update(codeVerifier).digest("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); + expect(codeChallenge).toBe(expected); + }); + + it("codeVerifier is within RFC 7636's required 43-128 character range", () => { + const { codeVerifier } = generatePkcePair(); + expect(codeVerifier.length).toBeGreaterThanOrEqual(43); + expect(codeVerifier.length).toBeLessThanOrEqual(128); + }); + + it("is URL-safe (no +, /, or = padding)", () => { + const { codeVerifier, codeChallenge } = generatePkcePair(); + expect(codeVerifier).not.toMatch(/[+/=]/); + expect(codeChallenge).not.toMatch(/[+/=]/); + }); + + it("generates a different pair every call", () => { + const a = generatePkcePair(); + const b = generatePkcePair(); + expect(a.codeVerifier).not.toBe(b.codeVerifier); + expect(a.codeChallenge).not.toBe(b.codeChallenge); + }); +}); + +describe("generateState", () => { + it("is URL-safe and generates a different value every call", () => { + const a = generateState(); + const b = generateState(); + expect(a).not.toBe(b); + expect(a).not.toMatch(/[+/=]/); + expect(a.length).toBeGreaterThan(20); + }); +}); diff --git a/server/src/smart-launch/pkce.ts b/server/src/smart-launch/pkce.ts new file mode 100644 index 0000000..abd4f47 --- /dev/null +++ b/server/src/smart-launch/pkce.ts @@ -0,0 +1,35 @@ +import { createHash, randomBytes } from "node:crypto"; + +/** + * PKCE (RFC 7636), S256 only — required for this flow since it is a public + * client (no client_secret; see token-crypto.ts's own doc comment on why). + * `codeVerifier` is a high-entropy random string never sent to the + * authorization endpoint; `codeChallenge` (its SHA-256, base64url-encoded) + * is sent instead, and the verifier is presented only at the token + * exchange — the mechanism that lets a public client prove it, not an + * attacker who merely intercepted the authorization code, is the one + * completing the exchange. + */ +export interface PkcePair { + codeVerifier: string; + codeChallenge: string; +} + +function base64UrlEncode(input: Buffer): string { + return input.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** 32 random bytes, base64url-encoded — 43 characters, comfortably within + * RFC 7636's required 43-128 character range for a code_verifier. */ +export function generatePkcePair(): PkcePair { + const codeVerifier = base64UrlEncode(randomBytes(32)); + const codeChallenge = base64UrlEncode(createHash("sha256").update(codeVerifier).digest()); + return { codeVerifier, codeChallenge }; +} + +/** A cryptographically random, URL-safe `state` value (CSRF protection — + * see smart-launch/service.ts's own doc comment on how it's used). Same + * generation shape as a PKCE verifier but a distinct, unrelated value. */ +export function generateState(): string { + return base64UrlEncode(randomBytes(24)); +} diff --git a/server/src/smart-launch/service.test.ts b/server/src/smart-launch/service.test.ts new file mode 100644 index 0000000..d630a2c --- /dev/null +++ b/server/src/smart-launch/service.test.ts @@ -0,0 +1,187 @@ +import { randomBytes } from "node:crypto"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { InMemorySmartLaunchStore } from "../store/in-memory-smart-launch-store.js"; +import type { TenantContext } from "../tenant-context.js"; +import { decryptToken } from "./token-crypto.js"; +import { completeLaunchCallback, createLaunchSession, SmartLaunchCallbackError, SmartLaunchError } from "./service.js"; + +const actor = () => ({ externalSubject: "idp|clinician", userId: "user-1", organizationId: "org-1" }); + +function tenantContext(): TenantContext { + return { organizationId: "org-1", schemaName: "tenant_" + "0".repeat(32), issuer: "test", subject: "test" }; +} + +const ISSUER = "https://ehr.example-hospital.test/fhir"; +const REDIRECT_URI = "https://modelforge.example.test/smart/callback"; +const KEY = randomBytes(32); + +async function setupTrustedIssuer(overrides: { redirectUris?: string[] } = {}) { + const store = new InMemorySmartLaunchStore(); + const repo = store.forTenant(tenantContext()); + await repo.upsertTrustedIssuer({ issuer: ISSUER, clientId: "modelforge-client", redirectUris: overrides.redirectUris ?? [REDIRECT_URI], addedByUserId: "admin-1" }, actor()); + return repo; +} + +function stubDiscovery() { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (url.endsWith("/.well-known/smart-configuration")) { + return new Response(JSON.stringify({ authorization_endpoint: "https://ehr.example-hospital.test/auth", token_endpoint: "https://ehr.example-hospital.test/token" }), { status: 200 }); + } + throw new Error(`unexpected fetch to ${url}`); + }) + ); +} + +describe("createLaunchSession", () => { + afterEach(() => vi.unstubAllGlobals()); + + it("builds a correct authorization URL with PKCE, state, and default scopes", async () => { + const repo = await setupTrustedIssuer(); + stubDiscovery(); + const { session, authorizationUrl } = await createLaunchSession({ repo, requestedByUserId: "user-1", issuer: ISSUER, redirectUri: REDIRECT_URI, actor: actor() }); + + expect(session.status).toBe("pending"); + expect(session.issuer).toBe(ISSUER); + const url = new URL(authorizationUrl); + expect(url.origin + url.pathname).toBe("https://ehr.example-hospital.test/auth"); + expect(url.searchParams.get("response_type")).toBe("code"); + expect(url.searchParams.get("client_id")).toBe("modelforge-client"); + expect(url.searchParams.get("redirect_uri")).toBe(REDIRECT_URI); + expect(url.searchParams.get("state")).toBe(session.id); + expect(url.searchParams.get("aud")).toBe(ISSUER); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("code_challenge")).toBeTruthy(); + expect(url.searchParams.get("scope")).toBe("launch patient/*.read openid fhirUser"); + }); + + it("includes the launch param only when an EHR launch token is supplied", async () => { + const repo = await setupTrustedIssuer(); + stubDiscovery(); + const withLaunch = await createLaunchSession({ repo, requestedByUserId: "user-1", issuer: ISSUER, redirectUri: REDIRECT_URI, launch: "abc123", actor: actor() }); + expect(new URL(withLaunch.authorizationUrl).searchParams.get("launch")).toBe("abc123"); + + const withoutLaunch = await createLaunchSession({ repo, requestedByUserId: "user-1", issuer: ISSUER, redirectUri: REDIRECT_URI, actor: actor() }); + expect(new URL(withoutLaunch.authorizationUrl).searchParams.has("launch")).toBe(false); + }); + + it("rejects an issuer that isn't in this organization's trusted allowlist", async () => { + const store = new InMemorySmartLaunchStore(); + const repo = store.forTenant(tenantContext()); + const failure = await createLaunchSession({ repo, requestedByUserId: "user-1", issuer: "https://untrusted.test/fhir", redirectUri: REDIRECT_URI, actor: actor() }).catch((e) => e); + expect(failure).toBeInstanceOf(SmartLaunchError); + expect(failure).toMatchObject({ code: "untrusted_issuer" }); + }); + + it("rejects a redirectUri that isn't on the trusted issuer's own allowlist — the open-redirect/code-theft guard", async () => { + const repo = await setupTrustedIssuer(); + await expect(createLaunchSession({ repo, requestedByUserId: "user-1", issuer: ISSUER, redirectUri: "https://attacker.test/steal", actor: actor() })) + .rejects.toMatchObject({ code: "invalid_redirect_uri" }); + }); + + it("wraps a discovery failure as SmartLaunchError with code discovery_failed", async () => { + const repo = await setupTrustedIssuer(); + vi.stubGlobal("fetch", vi.fn(async () => new Response("not found", { status: 404 }))); + await expect(createLaunchSession({ repo, requestedByUserId: "user-1", issuer: ISSUER, redirectUri: REDIRECT_URI, actor: actor() })) + .rejects.toMatchObject({ code: "discovery_failed" }); + }); + + it("respects an explicit scopes list instead of the default", async () => { + const repo = await setupTrustedIssuer(); + stubDiscovery(); + const { authorizationUrl } = await createLaunchSession({ repo, requestedByUserId: "user-1", issuer: ISSUER, redirectUri: REDIRECT_URI, scopes: ["patient/Observation.read"], actor: actor() }); + expect(new URL(authorizationUrl).searchParams.get("scope")).toBe("patient/Observation.read"); + }); +}); + +describe("completeLaunchCallback", () => { + afterEach(() => vi.unstubAllGlobals()); + + async function launch(repo: Awaited>) { + stubDiscovery(); + const { session } = await createLaunchSession({ repo, requestedByUserId: "user-1", issuer: ISSUER, redirectUri: REDIRECT_URI, actor: actor() }); + return session; + } + + function stubTokenExchange(payload: Record, status = 200) { + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: { method?: string }) => { + if (url.endsWith("/.well-known/smart-configuration")) { + return new Response(JSON.stringify({ authorization_endpoint: "https://ehr.example-hospital.test/auth", token_endpoint: "https://ehr.example-hospital.test/token" }), { status: 200 }); + } + if (url === "https://ehr.example-hospital.test/token" && init?.method === "POST") { + return new Response(JSON.stringify(payload), { status }); + } + throw new Error(`unexpected fetch to ${url}`); + }) + ); + } + + it("exchanges the code, encrypts and stores the token, and marks the session completed", async () => { + const repo = await setupTrustedIssuer(); + const session = await launch(repo); + stubTokenExchange({ access_token: "real-access-token", refresh_token: "real-refresh-token", expires_in: 3600, patient: "epic-patient-123", scope: "patient/*.read launch" }); + + const token = await completeLaunchCallback({ repo, state: session.id, code: "auth-code-xyz", callerId: "user-1", encryptionKey: KEY, actor: actor() }); + expect(token).toMatchObject({ issuer: ISSUER, patientId: "epic-patient-123", hasRefreshToken: true, scope: "patient/*.read launch" }); + expect(token).not.toHaveProperty("encryptedAccessToken"); + + const stored = await repo.getToken(token.id); + expect(decryptToken(stored!.encryptedAccessToken, KEY)).toBe("real-access-token"); + expect(decryptToken(stored!.encryptedRefreshToken!, KEY)).toBe("real-refresh-token"); + + const completedSession = await repo.getLaunchSession(session.id); + expect(completedSession?.status).toBe("completed"); + }); + + it("rejects a state with no matching session", async () => { + const repo = await setupTrustedIssuer(); + await expect(completeLaunchCallback({ repo, state: "does-not-exist", code: "x", callerId: "user-1", encryptionKey: KEY, actor: actor() })) + .rejects.toMatchObject({ code: "session_not_found" }); + }); + + it("refuses to complete a session for a DIFFERENT user than the one who created it", async () => { + const repo = await setupTrustedIssuer(); + const session = await launch(repo); + await expect(completeLaunchCallback({ repo, state: session.id, code: "x", callerId: "someone-else", encryptionKey: KEY, actor: actor() })) + .rejects.toMatchObject({ code: "forbidden" }); + }); + + it("refuses to complete the same session twice (single-use state)", async () => { + const repo = await setupTrustedIssuer(); + const session = await launch(repo); + stubTokenExchange({ access_token: "token-1", expires_in: 3600 }); + await completeLaunchCallback({ repo, state: session.id, code: "code-1", callerId: "user-1", encryptionKey: KEY, actor: actor() }); + + await expect(completeLaunchCallback({ repo, state: session.id, code: "code-2", callerId: "user-1", encryptionKey: KEY, actor: actor() })) + .rejects.toMatchObject({ code: "session_not_pending" }); + }); + + it("rejects an expired session", async () => { + const repo = await setupTrustedIssuer(); + stubDiscovery(); + const past = new Date("2020-01-01T00:00:00Z"); + const session = await createLaunchSession({ repo, requestedByUserId: "user-1", issuer: ISSUER, redirectUri: REDIRECT_URI, actor: actor(), now: past }).then((r) => r.session); + await expect(completeLaunchCallback({ repo, state: session.id, code: "x", callerId: "user-1", encryptionKey: KEY, actor: actor(), now: new Date() })) + .rejects.toMatchObject({ code: "session_expired" }); + }); + + it("wraps a non-2xx token endpoint response as token_exchange_failed, never leaking the raw response body", async () => { + const repo = await setupTrustedIssuer(); + const session = await launch(repo); + stubTokenExchange({ error: "invalid_grant", error_description: "internal-secret-detail" }, 400); + const failure = await completeLaunchCallback({ repo, state: session.id, code: "x", callerId: "user-1", encryptionKey: KEY, actor: actor() }).catch((e) => e); + expect(failure).toBeInstanceOf(SmartLaunchCallbackError); + expect(failure.message).not.toContain("internal-secret-detail"); + }); + + it("rejects a token response with no access_token", async () => { + const repo = await setupTrustedIssuer(); + const session = await launch(repo); + stubTokenExchange({ token_type: "Bearer" }); + await expect(completeLaunchCallback({ repo, state: session.id, code: "x", callerId: "user-1", encryptionKey: KEY, actor: actor() })) + .rejects.toMatchObject({ code: "token_exchange_failed" }); + }); +}); diff --git a/server/src/smart-launch/service.ts b/server/src/smart-launch/service.ts new file mode 100644 index 0000000..1dc4308 --- /dev/null +++ b/server/src/smart-launch/service.ts @@ -0,0 +1,201 @@ +import type { SmartLaunchSession, SmartLaunchToken } from "@modelforge/contracts"; +import type { AuditActor } from "../store/audit-store.js"; +import { publicLaunchSession, publicToken, type TenantSmartLaunchRepository } from "../store/smart-launch-store.js"; +import { resolveSmartConfiguration } from "./discovery.js"; +import { generatePkcePair, generateState } from "./pkce.js"; +import { encryptToken } from "./token-crypto.js"; + +/** + * The actual SMART App Launch client-role flow — createLaunchSession + * (redirect a user to an EHR to authorize) and completeLaunchCallback + * (exchange the resulting code for a token). Reused as-is by + * routes/smart-launch.ts; kept here, not inline in the route, so the + * security-critical parts (state/PKCE generation, redirect_uri + * allowlisting, the token exchange itself) have their own dedicated, + * directly-testable surface independent of HTTP/IAM plumbing. + * + * See packages/contracts/src/smart-launch.ts's own doc comment for the + * standing design decision this whole flow sits inside: a launch always + * requires an already-authenticated ModelForge caller (`actor`/ + * `requestedByUserId` below always come from an already-verified bearer + * token — this module trusts them, never re-derives them), and every + * token this flow produces is encrypted at rest and never returned to a + * caller in its own request/response cycle. + */ + +const DEFAULT_SCOPES = ["launch", "patient/*.read", "openid", "fhirUser"]; +const SESSION_TTL_MS = 10 * 60 * 1_000; // 10 minutes — an authorization_code's own real-world lifetime is usually similar or shorter; this just bounds how long a stale, never-completed launch attempt lingers. +const TOKEN_EXCHANGE_TIMEOUT_MS = 10_000; +const DEFAULT_TOKEN_LIFETIME_S = 3_600; + +export type SmartLaunchErrorCode = "untrusted_issuer" | "invalid_redirect_uri" | "discovery_failed"; +export class SmartLaunchError extends Error { + constructor( + message: string, + public readonly code: SmartLaunchErrorCode + ) { + super(message); + this.name = "SmartLaunchError"; + } +} + +export type SmartLaunchCallbackErrorCode = "session_not_found" | "session_expired" | "session_not_pending" | "forbidden" | "token_exchange_failed"; +export class SmartLaunchCallbackError extends Error { + constructor( + message: string, + public readonly code: SmartLaunchCallbackErrorCode + ) { + super(message); + this.name = "SmartLaunchCallbackError"; + } +} + +export interface CreateLaunchSessionOptions { + repo: TenantSmartLaunchRepository; + requestedByUserId: string; + issuer: string; + redirectUri: string; + scopes?: string[]; + launch?: string; + actor: AuditActor; + now?: Date; +} + +export async function createLaunchSession(options: CreateLaunchSessionOptions): Promise<{ session: SmartLaunchSession; authorizationUrl: string }> { + const trusted = await options.repo.getTrustedIssuer(options.issuer); + if (!trusted) throw new SmartLaunchError(`"${options.issuer}" is not a trusted issuer for this organization.`, "untrusted_issuer"); + // Exact match against the admin-configured allowlist — never a prefix + // or pattern match. A caller-controlled redirect_uri that doesn't + // match verbatim is exactly the open-redirect/code-theft shape this + // check exists to close off. + if (!trusted.redirectUris.includes(options.redirectUri)) { + throw new SmartLaunchError(`"${options.redirectUri}" is not an allowed redirect URI for this issuer.`, "invalid_redirect_uri"); + } + + let metadata; + try { + metadata = await resolveSmartConfiguration(options.issuer); + } catch (err) { + throw new SmartLaunchError(`SMART discovery failed for "${options.issuer}": ${err instanceof Error ? err.message : String(err)}`, "discovery_failed"); + } + + const { codeVerifier, codeChallenge } = generatePkcePair(); + const state = generateState(); + const scope = (options.scopes && options.scopes.length > 0 ? options.scopes : DEFAULT_SCOPES).join(" "); + const now = options.now ?? new Date(); + const expiresAt = new Date(now.getTime() + SESSION_TTL_MS).toISOString(); + + const internal = await options.repo.createLaunchSession( + state, + { issuer: options.issuer, requestedByUserId: options.requestedByUserId, scope, codeVerifier, redirectUri: options.redirectUri, launch: options.launch, expiresAt }, + options.actor + ); + + const authorizationUrl = new URL(metadata.authorizationEndpoint); + authorizationUrl.searchParams.set("response_type", "code"); + authorizationUrl.searchParams.set("client_id", trusted.clientId); + authorizationUrl.searchParams.set("redirect_uri", options.redirectUri); + authorizationUrl.searchParams.set("scope", scope); + authorizationUrl.searchParams.set("state", state); + authorizationUrl.searchParams.set("aud", options.issuer); + authorizationUrl.searchParams.set("code_challenge", codeChallenge); + authorizationUrl.searchParams.set("code_challenge_method", "S256"); + if (options.launch) authorizationUrl.searchParams.set("launch", options.launch); + + return { session: publicLaunchSession(internal), authorizationUrl: authorizationUrl.toString() }; +} + +export interface CompleteLaunchCallbackOptions { + repo: TenantSmartLaunchRepository; + state: string; + code: string; + callerId: string; + encryptionKey: Buffer; + actor: AuditActor; + now?: Date; +} + +export async function completeLaunchCallback(options: CompleteLaunchCallbackOptions): Promise { + const session = await options.repo.getLaunchSession(options.state); + if (!session) throw new SmartLaunchCallbackError("No pending launch session for this state.", "session_not_found"); + // A different ModelForge user attempting to complete someone else's + // pending launch — this can only happen if `state` leaked somewhere + // (e.g. a shared browser); refusing it outright is the correct + // response, not merging or transferring ownership. + if (session.requestedByUserId !== options.callerId) throw new SmartLaunchCallbackError("This launch session belongs to a different user.", "forbidden"); + if (session.status !== "pending") throw new SmartLaunchCallbackError(`This launch session is already "${session.status}".`, "session_not_pending"); + const now = options.now ?? new Date(); + if (session.expiresAt <= now.toISOString()) throw new SmartLaunchCallbackError("This launch session has expired.", "session_expired"); + + const trusted = await options.repo.getTrustedIssuer(session.issuer); + if (!trusted) throw new SmartLaunchCallbackError("The trusted issuer configuration for this session no longer exists.", "session_not_found"); + let metadata; + try { + metadata = await resolveSmartConfiguration(session.issuer); + } catch (err) { + throw new SmartLaunchCallbackError(`SMART discovery failed during token exchange: ${err instanceof Error ? err.message : String(err)}`, "token_exchange_failed"); + } + + const body = new URLSearchParams({ + grant_type: "authorization_code", + code: options.code, + redirect_uri: session.redirectUri, + client_id: trusted.clientId, + code_verifier: session.codeVerifier, + }); + let response: Response; + try { + response = await fetch(metadata.tokenEndpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: body.toString(), signal: AbortSignal.timeout(TOKEN_EXCHANGE_TIMEOUT_MS) }); + } catch (err) { + throw new SmartLaunchCallbackError(`Token exchange request failed: ${err instanceof Error ? err.message : String(err)}`, "token_exchange_failed"); + } + if (!response.ok) { + // Never echo the response body verbatim — an EHR's error response + // could (rarely, but possibly) itself carry sensitive detail, and + // this codebase's own convention is a fixed, safe error shape. + throw new SmartLaunchCallbackError(`Token exchange failed: HTTP ${response.status}`, "token_exchange_failed"); + } + let payload: Record; + try { + payload = (await response.json()) as Record; + } catch { + throw new SmartLaunchCallbackError("Token endpoint did not return valid JSON.", "token_exchange_failed"); + } + + const accessToken = payload.access_token; + if (typeof accessToken !== "string" || accessToken.length === 0) { + throw new SmartLaunchCallbackError("Token response had no access_token.", "token_exchange_failed"); + } + const refreshToken = typeof payload.refresh_token === "string" && payload.refresh_token.length > 0 ? payload.refresh_token : undefined; + const expiresInSeconds = typeof payload.expires_in === "number" && payload.expires_in > 0 ? payload.expires_in : DEFAULT_TOKEN_LIFETIME_S; + const patientId = typeof payload.patient === "string" && payload.patient.length > 0 ? payload.patient : undefined; + const scope = typeof payload.scope === "string" && payload.scope.length > 0 ? payload.scope : session.scope; + + const token = await options.repo.createToken( + { + issuer: session.issuer, + requestedByUserId: session.requestedByUserId, + scope, + patientId, + encryptedAccessToken: encryptToken(accessToken, options.encryptionKey), + encryptedRefreshToken: refreshToken ? encryptToken(refreshToken, options.encryptionKey) : undefined, + expiresAt: new Date(now.getTime() + expiresInSeconds * 1_000).toISOString(), + }, + options.actor + ); + + // Single-use: mark the session completed only after a successful + // exchange (so a transient network failure during exchange doesn't + // burn the one-time state, letting a legitimate retry with a fresh + // `code` — the EHR's own authorization server invalidates `code` + // after one use regardless — still succeed against the same session). + // completeLaunchSession itself refuses a non-"pending" session, so a + // racing double-submit can never produce two tokens sharing one + // session's ownership check; the (harmless, rare) residual case is a + // genuine concurrent double-submit each independently exchanging a + // still-valid code before the other's write lands, which is bounded + // by the EHR's own code-is-single-use enforcement, not this store's. + await options.repo.completeLaunchSession(options.state, options.actor); + + return publicToken(token); +} diff --git a/server/src/smart-launch/token-crypto.test.ts b/server/src/smart-launch/token-crypto.test.ts new file mode 100644 index 0000000..1036c75 --- /dev/null +++ b/server/src/smart-launch/token-crypto.test.ts @@ -0,0 +1,44 @@ +import { randomBytes } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { decryptToken, encryptToken, loadTokenEncryptionKey, SmartLaunchEncryptionKeyError } from "./token-crypto.js"; + +const KEY = randomBytes(32); + +describe("encryptToken / decryptToken", () => { + it("round-trips a token exactly", () => { + const token = "opaque-ehr-access-token-example-value"; + expect(decryptToken(encryptToken(token, KEY), KEY)).toBe(token); + }); + + it("produces a different ciphertext each time (random IV), even for the same plaintext", () => { + const token = "same-token"; + expect(encryptToken(token, KEY)).not.toBe(encryptToken(token, KEY)); + }); + + it("fails to decrypt with the wrong key — authenticated encryption, not just confidentiality", () => { + const encrypted = encryptToken("secret", KEY); + const wrongKey = randomBytes(32); + expect(() => decryptToken(encrypted, wrongKey)).toThrow(); + }); + + it("fails to decrypt tampered ciphertext", () => { + const encrypted = encryptToken("secret-value", KEY); + const buf = Buffer.from(encrypted, "base64"); + buf[buf.length - 1] ^= 0xff; + expect(() => decryptToken(buf.toString("base64"), KEY)).toThrow(); + }); +}); + +describe("loadTokenEncryptionKey", () => { + it("decodes a valid 32-byte base64 key", () => { + expect(loadTokenEncryptionKey(KEY.toString("base64"))).toEqual(KEY); + }); + + it("throws SmartLaunchEncryptionKeyError when unset", () => { + expect(() => loadTokenEncryptionKey(undefined)).toThrow(SmartLaunchEncryptionKeyError); + }); + + it("throws SmartLaunchEncryptionKeyError for a key that isn't exactly 32 bytes", () => { + expect(() => loadTokenEncryptionKey(randomBytes(16).toString("base64"))).toThrow(SmartLaunchEncryptionKeyError); + }); +}); diff --git a/server/src/smart-launch/token-crypto.ts b/server/src/smart-launch/token-crypto.ts new file mode 100644 index 0000000..5e562e8 --- /dev/null +++ b/server/src/smart-launch/token-crypto.ts @@ -0,0 +1,51 @@ +import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto"; + +/** + * At-rest encryption for an EHR access/refresh token this server holds on + * a user's behalf (server/src/store/smart-launch-store.ts) — same AES-256- + * GCM envelope shape as imaging/object-store.ts's own + * LocalFilesystemImagingObjectStore (`iv || authTag || ciphertext`, same + * byte lengths), applied to a short string instead of pixel data. A real + * OAuth access/refresh token is exactly as sensitive as a password — this + * server must never store one in plaintext, and must never log it (no + * caller in this codebase passes a decrypted token to a logger; grep for + * any future violation of that before trusting this comment alone). + * + * Public-client only (PKCE, no client_secret) is a deliberate scope + * boundary, not an oversight: a confidential client's client_secret would + * need this exact same at-rest protection PLUS a decision about how an + * operator provisions/rotates it per trusted issuer — a real, separate + * secrets-management problem this pass doesn't attempt to solve. + */ +const GCM_AUTH_TAG_LENGTH_BYTES = 16; +const GCM_IV_LENGTH_BYTES = 12; + +export class SmartLaunchEncryptionKeyError extends Error {} + +/** Validates and returns the 32-byte key from a base64 env value — throws + * a clear, specific error rather than a cryptic node:crypto failure deep + * inside encrypt/decrypt when misconfigured. */ +export function loadTokenEncryptionKey(base64Key: string | undefined): Buffer { + if (!base64Key) throw new SmartLaunchEncryptionKeyError("SMART_LAUNCH_ENCRYPTION_KEY is not configured."); + const decoded = Buffer.from(base64Key, "base64"); + if (decoded.length !== 32) throw new SmartLaunchEncryptionKeyError("SMART_LAUNCH_ENCRYPTION_KEY must be base64 for exactly 32 bytes."); + return decoded; +} + +export function encryptToken(plaintext: string, key: Buffer): string { + const iv = randomBytes(GCM_IV_LENGTH_BYTES); + const cipher = createCipheriv("aes-256-gcm", key, iv, { authTagLength: GCM_AUTH_TAG_LENGTH_BYTES }); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([iv, authTag, ciphertext]).toString("base64"); +} + +export function decryptToken(envelopeBase64: string, key: Buffer): string { + const envelope = Buffer.from(envelopeBase64, "base64"); + const iv = envelope.subarray(0, GCM_IV_LENGTH_BYTES); + const authTag = envelope.subarray(GCM_IV_LENGTH_BYTES, GCM_IV_LENGTH_BYTES + GCM_AUTH_TAG_LENGTH_BYTES); + const ciphertext = envelope.subarray(GCM_IV_LENGTH_BYTES + GCM_AUTH_TAG_LENGTH_BYTES); + const decipher = createDecipheriv("aes-256-gcm", key, iv, { authTagLength: GCM_AUTH_TAG_LENGTH_BYTES }); + decipher.setAuthTag(authTag); + return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8"); +} diff --git a/server/src/store/ai-gateway-store.ts b/server/src/store/ai-gateway-store.ts index 6654f22..7e22a81 100644 --- a/server/src/store/ai-gateway-store.ts +++ b/server/src/store/ai-gateway-store.ts @@ -55,6 +55,7 @@ export interface CreateAiOutputInput { requestId: string; providerModelId: string; modelVersion: string; + promptVersion: string; summary: string; evidence: string[]; uncertainty?: string; @@ -113,6 +114,14 @@ export interface TenantAiGatewayRepository { createOutput(input: CreateAiOutputInput, actor: AuditActor): Promise<{ output: AiOutput; citations: AiCitation[] }>; getOutput(id: string): Promise; listOutputsForRequest(requestId: string): Promise; + /** Every output for one provider model across the whole tenant + * (not scoped to a single case/request) — backs + * eval-harness/production-monitor.ts's online quality snapshot. + * `since`, when given, excludes outputs generated at or before that + * ISO timestamp (an open lower bound, matching `readChanges`'s own + * cursor convention elsewhere in this codebase). Ordered oldest-first, + * same as listOutputsForRequest. */ + listOutputsForProviderModel(providerModelId: string, since?: string): Promise; listCitationsForOutput(outputId: string): Promise; /** Immutable — a review is a new row, never an edit of a prior one; an diff --git a/server/src/store/hl7-ingestion-store.ts b/server/src/store/hl7-ingestion-store.ts new file mode 100644 index 0000000..c8b8cd9 --- /dev/null +++ b/server/src/store/hl7-ingestion-store.ts @@ -0,0 +1,27 @@ +import type { Hl7IngestionJob } from "@modelforge/contracts"; +import type { TenantContext } from "../tenant-context.js"; +import type { AuditActor } from "./audit-store.js"; + +/** + * Tenant-scoped repository for HL7 v2 inbound ingestion jobs — mirrors + * imaging-store.ts's own createIngestionJob/getIngestionJob/ + * updateIngestionJob shape exactly (same "one job row per inbound item, + * created regardless of outcome, updated in place as review happens" + * pattern DICOM ingestion already established). See hl7/ingestion.ts for + * the actual match/apply logic this backs. + */ +export interface TenantHl7IngestionRepository { + readonly context: TenantContext; + createJob(input: Omit, actor: AuditActor): Promise; + getJob(id: string): Promise; + listJobs(filter?: { status?: Hl7IngestionJob["status"] }): Promise; + updateJob( + id: string, + partial: Partial>, + actor: AuditActor + ): Promise; +} + +export interface Hl7IngestionStore { + forTenant(context: TenantContext): TenantHl7IngestionRepository; +} diff --git a/server/src/store/in-memory-ai-gateway-store.test.ts b/server/src/store/in-memory-ai-gateway-store.test.ts index c61e0ee..59a45b4 100644 --- a/server/src/store/in-memory-ai-gateway-store.test.ts +++ b/server/src/store/in-memory-ai-gateway-store.test.ts @@ -135,7 +135,7 @@ describe("InMemoryAiGatewayStore", () => { it("creates an output with citations, separate from the output row itself", async () => { const { repo, request } = await setup(); const { output, citations } = await repo.createOutput({ - requestId: request.id, providerModelId: "model-1", modelVersion: "1.0", + requestId: request.id, providerModelId: "model-1", modelVersion: "1.0", promptVersion: "clinical-gateway-prompt-v1", summary: "No acute findings.", evidence: ["Note dated 2026-01-01 mentions stable vitals."], followUp: ["Recheck in 2 weeks."], abstained: false, outputHash: "b".repeat(64), citations: [{ resourceType: "clinicalNote", resourceId: "note-1", locator: "line 4" }], @@ -149,7 +149,7 @@ describe("InMemoryAiGatewayStore", () => { it("an output that abstains carries abstainReason and no fabricated confidence", async () => { const { repo, request } = await setup(); const { output } = await repo.createOutput({ - requestId: request.id, providerModelId: "model-1", modelVersion: "1.0", + requestId: request.id, providerModelId: "model-1", modelVersion: "1.0", promptVersion: "clinical-gateway-prompt-v1", summary: "Insufficient data to draw a conclusion.", evidence: [], followUp: [], abstained: true, abstainReason: "Contradictory lab values across two source documents.", outputHash: "c".repeat(64), citations: [], @@ -161,7 +161,7 @@ describe("InMemoryAiGatewayStore", () => { it("a review is immutable — a second review attempt on the same output throws rather than overwriting", async () => { const { repo, request } = await setup(); const { output } = await repo.createOutput({ - requestId: request.id, providerModelId: "model-1", modelVersion: "1.0", + requestId: request.id, providerModelId: "model-1", modelVersion: "1.0", promptVersion: "clinical-gateway-prompt-v1", summary: "x", evidence: [], followUp: [], abstained: false, outputHash: "d".repeat(64), citations: [], }, actor()); await repo.createReview({ outputId: output.id, reviewedByUserId: "clinician-1", decision: "accepted" }, actor()); @@ -171,7 +171,7 @@ describe("InMemoryAiGatewayStore", () => { it("accepting a review updates the output's own reviewStatus flag", async () => { const { repo, request } = await setup(); const { output } = await repo.createOutput({ - requestId: request.id, providerModelId: "model-1", modelVersion: "1.0", + requestId: request.id, providerModelId: "model-1", modelVersion: "1.0", promptVersion: "clinical-gateway-prompt-v1", summary: "x", evidence: [], followUp: [], abstained: false, outputHash: "e".repeat(64), citations: [], }, actor()); await repo.createReview({ outputId: output.id, reviewedByUserId: "clinician-1", decision: "corrected", correctedText: "Actually, recheck in 1 week." }, actor()); diff --git a/server/src/store/in-memory-ai-gateway-store.ts b/server/src/store/in-memory-ai-gateway-store.ts index 6a26794..ccc41ff 100644 --- a/server/src/store/in-memory-ai-gateway-store.ts +++ b/server/src/store/in-memory-ai-gateway-store.ts @@ -193,7 +193,7 @@ export class InMemoryAiGatewayStore implements AiGatewayStore { async createOutput(input: CreateAiOutputInput, actor) { const id = randomUUID(); const output: AiOutput = { - id, requestId: input.requestId, providerModelId: input.providerModelId, modelVersion: input.modelVersion, + id, requestId: input.requestId, providerModelId: input.providerModelId, modelVersion: input.modelVersion, promptVersion: input.promptVersion, generatedAt: new Date().toISOString(), summary: input.summary, evidence: input.evidence, uncertainty: input.uncertainty, followUp: input.followUp, abstained: input.abstained, abstainReason: input.abstainReason, confidence: input.confidence, outputHash: input.outputHash, @@ -215,6 +215,11 @@ export class InMemoryAiGatewayStore implements AiGatewayStore { const ids = state.outputsByRequest.get(requestId) ?? []; return ids.map((id) => state.outputs.get(id)).filter((o): o is AiOutput => o !== undefined); }, + async listOutputsForProviderModel(providerModelId, since) { + return [...state.outputs.values()] + .filter((o) => o.providerModelId === providerModelId && (!since || o.generatedAt > since)) + .sort((a, b) => a.generatedAt.localeCompare(b.generatedAt) || a.id.localeCompare(b.id)); + }, async listCitationsForOutput(outputId) { return state.citations.get(outputId) ?? []; }, diff --git a/server/src/store/in-memory-hl7-ingestion-store.ts b/server/src/store/in-memory-hl7-ingestion-store.ts new file mode 100644 index 0000000..f352842 --- /dev/null +++ b/server/src/store/in-memory-hl7-ingestion-store.ts @@ -0,0 +1,67 @@ +import { randomUUID } from "node:crypto"; +import type { Hl7IngestionJob } from "@modelforge/contracts"; +import type { TenantContext } from "../tenant-context.js"; +import { type AuditActor, type AuditStore, InMemoryAuditStore } from "./audit-store.js"; +import type { Hl7IngestionStore, TenantHl7IngestionRepository } from "./hl7-ingestion-store.js"; + +interface OrgState { + jobs: Map; +} + +function emptyOrgState(): OrgState { + return { jobs: new Map() }; +} + +export class InMemoryHl7IngestionStore implements Hl7IngestionStore { + private readonly orgs = new Map(); + + constructor(private readonly auditStore: AuditStore = new InMemoryAuditStore()) {} + + private stateFor(organizationId: string): OrgState { + let state = this.orgs.get(organizationId); + if (!state) { + state = emptyOrgState(); + this.orgs.set(organizationId, state); + } + return state; + } + + forTenant(context: TenantContext): TenantHl7IngestionRepository { + const state = this.stateFor(context.organizationId); + const auditStore = this.auditStore; + const organizationId = context.organizationId; + + const repository: TenantHl7IngestionRepository = { + context, + + async createJob(input, actor: AuditActor) { + const id = randomUUID(); + const now = new Date().toISOString(); + const job: Hl7IngestionJob = { id, createdAt: now, updatedAt: now, ...input }; + state.jobs.set(id, job); + await auditStore.record({ organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "hl7IngestionJob.create", targetType: "hl7IngestionJob", targetId: id, details: { messageType: input.messageType, matchStatus: input.matchStatus, status: input.status } }); + return job; + }, + + async getJob(id) { + return state.jobs.get(id) ?? null; + }, + + async listJobs(filter) { + let all = [...state.jobs.values()]; + if (filter?.status !== undefined) all = all.filter((j) => j.status === filter.status); + return all.sort((a, b) => (a.createdAt < b.createdAt ? 1 : -1)); + }, + + async updateJob(id, partial, actor: AuditActor) { + const existing = state.jobs.get(id); + if (!existing) return null; + const updated: Hl7IngestionJob = { ...existing, ...partial, updatedAt: new Date().toISOString() }; + state.jobs.set(id, updated); + await auditStore.record({ organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "hl7IngestionJob.update", targetType: "hl7IngestionJob", targetId: id, details: { status: updated.status, matchedCaseId: updated.matchedCaseId } }); + return updated; + }, + }; + return repository; + } +} diff --git a/server/src/store/in-memory-smart-launch-store.ts b/server/src/store/in-memory-smart-launch-store.ts new file mode 100644 index 0000000..1af6682 --- /dev/null +++ b/server/src/store/in-memory-smart-launch-store.ts @@ -0,0 +1,122 @@ +import { randomUUID } from "node:crypto"; +import type { SmartTrustedIssuer } from "@modelforge/contracts"; +import type { TenantContext } from "../tenant-context.js"; +import { type AuditActor, type AuditStore, InMemoryAuditStore } from "./audit-store.js"; +import type { CreateLaunchSessionInput, CreateTokenInput, InternalSmartLaunchSession, InternalSmartLaunchToken, SmartLaunchStore, TenantSmartLaunchRepository } from "./smart-launch-store.js"; + +interface OrgState { + trustedIssuers: Map; // keyed by issuer URL + launchSessions: Map; // keyed by state + tokens: Map; +} + +function emptyOrgState(): OrgState { + return { trustedIssuers: new Map(), launchSessions: new Map(), tokens: new Map() }; +} + +export class InMemorySmartLaunchStore implements SmartLaunchStore { + private readonly orgs = new Map(); + + constructor(private readonly auditStore: AuditStore = new InMemoryAuditStore()) {} + + private stateFor(organizationId: string): OrgState { + let state = this.orgs.get(organizationId); + if (!state) { + state = emptyOrgState(); + this.orgs.set(organizationId, state); + } + return state; + } + + forTenant(context: TenantContext): TenantSmartLaunchRepository { + const state = this.stateFor(context.organizationId); + const auditStore = this.auditStore; + const organizationId = context.organizationId; + + const repository: TenantSmartLaunchRepository = { + context, + + async upsertTrustedIssuer(input: Omit, actor: AuditActor) { + const existing = state.trustedIssuers.get(input.issuer); + const value: SmartTrustedIssuer = { id: existing?.id ?? randomUUID(), createdAt: existing?.createdAt ?? new Date().toISOString(), ...input }; + state.trustedIssuers.set(input.issuer, value); + await auditStore.record({ organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "smartTrustedIssuer.upsert", targetType: "smartTrustedIssuer", targetId: value.id, details: { issuer: input.issuer } }); + return value; + }, + + async getTrustedIssuer(issuer) { + return state.trustedIssuers.get(issuer) ?? null; + }, + + async listTrustedIssuers() { + return [...state.trustedIssuers.values()]; + }, + + async deleteTrustedIssuer(issuer, actor: AuditActor) { + const existing = state.trustedIssuers.get(issuer); + if (!existing) return false; + state.trustedIssuers.delete(issuer); + await auditStore.record({ organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "smartTrustedIssuer.delete", targetType: "smartTrustedIssuer", targetId: existing.id, details: { issuer } }); + return true; + }, + + async createLaunchSession(stateKey: string, input: CreateLaunchSessionInput, actor: AuditActor) { + const now = new Date().toISOString(); + const session: InternalSmartLaunchSession = { + id: stateKey, issuer: input.issuer, requestedByUserId: input.requestedByUserId, scope: input.scope, + status: "pending", createdAt: now, expiresAt: input.expiresAt, + codeVerifier: input.codeVerifier, redirectUri: input.redirectUri, launch: input.launch, + }; + state.launchSessions.set(stateKey, session); + await auditStore.record({ organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "smartLaunchSession.create", targetType: "smartLaunchSession", targetId: stateKey, details: { issuer: input.issuer } }); + return session; + }, + + async getLaunchSession(stateKey) { + return state.launchSessions.get(stateKey) ?? null; + }, + + async completeLaunchSession(stateKey: string, actor: AuditActor) { + const existing = state.launchSessions.get(stateKey); + if (!existing || existing.status !== "pending") return null; + const updated: InternalSmartLaunchSession = { ...existing, status: "completed" }; + state.launchSessions.set(stateKey, updated); + await auditStore.record({ organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "smartLaunchSession.complete", targetType: "smartLaunchSession", targetId: stateKey, details: {} }); + return updated; + }, + + async createToken(input: CreateTokenInput, actor: AuditActor) { + const id = randomUUID(); + const now = new Date().toISOString(); + const token: InternalSmartLaunchToken = { + id, issuer: input.issuer, requestedByUserId: input.requestedByUserId, scope: input.scope, + patientId: input.patientId, hasRefreshToken: input.encryptedRefreshToken !== undefined, + expiresAt: input.expiresAt, createdAt: now, + encryptedAccessToken: input.encryptedAccessToken, encryptedRefreshToken: input.encryptedRefreshToken, + }; + state.tokens.set(id, token); + await auditStore.record({ organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "smartLaunchToken.create", targetType: "smartLaunchToken", targetId: id, details: { issuer: input.issuer, hasPatientContext: input.patientId !== undefined } }); + return token; + }, + + async getToken(id) { + return state.tokens.get(id) ?? null; + }, + + async listTokensForUser(userId) { + return [...state.tokens.values()] + .filter((t) => t.requestedByUserId === userId) + .map(({ encryptedAccessToken: _a, encryptedRefreshToken: _r, ...rest }) => rest); + }, + + async deleteToken(id, actor: AuditActor) { + const existing = state.tokens.get(id); + if (!existing) return false; + state.tokens.delete(id); + await auditStore.record({ organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "smartLaunchToken.delete", targetType: "smartLaunchToken", targetId: id, details: {} }); + return true; + }, + }; + return repository; + } +} diff --git a/server/src/store/mcp-clinical-store.test.ts b/server/src/store/mcp-clinical-store.test.ts new file mode 100644 index 0000000..57c94d0 --- /dev/null +++ b/server/src/store/mcp-clinical-store.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { InMemoryAuditStore } from "./audit-store.js"; +import { InMemoryMcpClinicalStore } from "./mcp-clinical-store.js"; + +const ORG = "10000000-0000-4000-8000-000000000001"; +const actor = { userId: "10000000-0000-4000-8000-000000000002", externalSubject: "clinician-1", organizationId: ORG }; + +describe("InMemoryMcpClinicalStore", () => { + it("issues introspectable grants and binds approval confirmation to the original subject/client", async () => { + const audit = new InMemoryAuditStore(); + const store = new InMemoryMcpClinicalStore(audit); + const grant = await store.createGrant({ organizationId: ORG, subjectId: "clinician-1", clientId: "desktop-1", caseId: "case-1", allowedTools: ["clinical.medication_conflict_check"], allowedFields: ["medications", "allergies"], purpose: "medication-review", destination: "managed_model_forge", expiresAtEpochSeconds: Math.floor(Date.now() / 1000) + 60 }, actor); + expect(await store.introspectGrant(grant.id)).toEqual(grant); + expect(grant.id.startsWith(`${ORG}.`)).toBe(true); + + const approval = await store.createApprovalRequest({ organizationId: ORG, registryEntryId: "10000000-0000-4000-8000-000000000003", subjectId: "clinician-1", clientId: "desktop-1", toolName: "clinical.record_review_decision", operationDigest: `sha256:${"b".repeat(64)}`, caseId: "case-1", expiresAt: new Date(Date.now() + 60_000).toISOString() }, actor); + expect(await store.confirmApprovalRequest(ORG, approval.id, "other", "desktop-1", actor)).toBeNull(); + expect((await store.confirmApprovalRequest(ORG, approval.id, "clinician-1", "desktop-1", actor))?.status).toBe("confirmed"); + expect((await audit.listByOrganization(ORG)).map((entry) => entry.action)).toEqual(expect.arrayContaining(["mcpClinical.grantCreate", "mcpClinical.approvalPrepare", "mcpClinical.approvalConfirm"])); + }); + + it("makes review recording idempotent by reviewed operation", async () => { + const store = new InMemoryMcpClinicalStore(); + const input = { organizationId: ORG, caseId: "case-1", reviewerSubjectId: "clinician-1", reviewedOperationId: "10000000-0000-4000-8000-000000000004", decision: "approved" as const, rationale: "Reviewed against source record." }; + expect(await store.recordReview(input, actor)).toEqual(await store.recordReview(input, actor)); + }); +}); diff --git a/server/src/store/mcp-clinical-store.ts b/server/src/store/mcp-clinical-store.ts new file mode 100644 index 0000000..56d2626 --- /dev/null +++ b/server/src/store/mcp-clinical-store.ts @@ -0,0 +1,201 @@ +import { randomUUID } from "node:crypto"; +import type { McpApprovalRequest, McpContextGrant } from "@modelforge/contracts"; +import type { Pool, PoolClient } from "pg"; +import { type AuditActor, type AuditStore, InMemoryAuditStore, insertAuditEntry } from "./audit-store.js"; + +export interface CreateMcpContextGrantInput { + organizationId: string; + subjectId: string; + clientId: string; + caseId: string; + allowedTools: string[]; + allowedFields: string[]; + purpose: string; + destination: McpContextGrant["destination"]; + expiresAtEpochSeconds: number; +} + +export interface CreateMcpApprovalRequestInput { + organizationId: string; + registryEntryId: string; + subjectId: string; + clientId: string; + toolName: string; + operationDigest: string; + caseId?: string; + expiresAt: string; +} + +export interface RecordMcpReviewInput { + organizationId: string; + caseId: string; + reviewerSubjectId: string; + reviewedOperationId: string; + decision: "approved" | "rejected" | "needs_revision"; + rationale: string; +} + +export interface McpReviewResult { reviewId: string; decision: RecordMcpReviewInput["decision"] } + +export interface McpClinicalStore { + createGrant(input: CreateMcpContextGrantInput, actor: AuditActor): Promise; + introspectGrant(grantId: string): Promise; + createApprovalRequest(input: CreateMcpApprovalRequestInput, actor: AuditActor): Promise; + getApprovalRequest(organizationId: string, id: string): Promise; + confirmApprovalRequest(organizationId: string, id: string, subjectId: string, clientId: string, actor: AuditActor): Promise; + recordReview(input: RecordMcpReviewInput, actor: AuditActor): Promise; +} + +function currentStatus(value: McpApprovalRequest): McpApprovalRequest { + return value.status === "pending" && value.expiresAt <= new Date().toISOString() ? { ...value, status: "expired" } : value; +} + +export class InMemoryMcpClinicalStore implements McpClinicalStore { + private readonly grants = new Map(); + private readonly approvals = new Map(); + private readonly reviewKeys = new Map(); + + constructor(private readonly audit: AuditStore = new InMemoryAuditStore()) {} + + async createGrant(input: CreateMcpContextGrantInput, actor: AuditActor): Promise { + const grant: McpContextGrant = { + id: `${input.organizationId}.${randomUUID()}`, + subjectId: input.subjectId, + clientId: input.clientId, + organizationId: input.organizationId, + caseId: input.caseId, + allowedTools: [...new Set(input.allowedTools)].sort(), + allowedFields: [...new Set(input.allowedFields)].sort(), + purpose: input.purpose, + destination: input.destination, + expiresAtEpochSeconds: input.expiresAtEpochSeconds, + version: 1, + }; + this.grants.set(grant.id, grant); + await this.audit.record({ organizationId: input.organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "mcpClinical.grantCreate", targetType: "mcpContextGrant", targetId: grant.id, details: { toolCount: grant.allowedTools.length, fieldCount: grant.allowedFields.length, purpose: grant.purpose, destination: grant.destination } }); + return grant; + } + + async introspectGrant(grantId: string): Promise { + const grant = this.grants.get(grantId); + return grant && grant.expiresAtEpochSeconds > Math.floor(Date.now() / 1000) ? grant : null; + } + + async createApprovalRequest(input: CreateMcpApprovalRequestInput, actor: AuditActor): Promise { + const createdAt = new Date().toISOString(); + const approval: McpApprovalRequest = { id: randomUUID(), status: "pending", createdAt, ...input }; + this.approvals.set(approval.id, approval); + await this.audit.record({ organizationId: input.organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "mcpClinical.approvalPrepare", targetType: "mcpApprovalRequest", targetId: approval.id, details: { registryEntryId: input.registryEntryId, toolName: input.toolName } }); + return approval; + } + + async getApprovalRequest(organizationId: string, id: string): Promise { + const value = this.approvals.get(id); + return value?.organizationId === organizationId ? currentStatus(value) : null; + } + + async confirmApprovalRequest(organizationId: string, id: string, subjectId: string, clientId: string, actor: AuditActor): Promise { + const current = await this.getApprovalRequest(organizationId, id); + if (!current || current.status !== "pending" || current.subjectId !== subjectId || current.clientId !== clientId) return null; + const confirmed = { ...current, status: "confirmed" as const, confirmedAt: new Date().toISOString() }; + this.approvals.set(id, confirmed); + await this.audit.record({ organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "mcpClinical.approvalConfirm", targetType: "mcpApprovalRequest", targetId: id, details: { registryEntryId: current.registryEntryId, toolName: current.toolName } }); + return confirmed; + } + + async recordReview(input: RecordMcpReviewInput, actor: AuditActor): Promise { + const key = `${input.organizationId}:${input.reviewedOperationId}`; + const existing = this.reviewKeys.get(key); + if (existing) return existing; + const result = { reviewId: randomUUID(), decision: input.decision }; + this.reviewKeys.set(key, result); + await this.audit.record({ organizationId: input.organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "mcpClinical.reviewRecord", targetType: "mcpOperation", targetId: input.reviewedOperationId, details: { reviewId: result.reviewId, decision: input.decision } }); + return result; + } +} + +interface GrantRow { id: string; organization_id: string; subject_id: string; client_id: string; case_id: string; allowed_tools: string[]; allowed_fields: string[]; purpose: string; destination: McpContextGrant["destination"]; expires_at: Date; version: string } +interface ApprovalRow { id: string; organization_id: string; registry_entry_id: string; subject_id: string; client_id: string; tool_name: string; operation_digest: string; case_id: string | null; status: "pending" | "confirmed"; expires_at: Date; created_at: Date; confirmed_at: Date | null } + +function mapGrant(row: GrantRow): McpContextGrant { + return { id: row.id, subjectId: row.subject_id, clientId: row.client_id, organizationId: row.organization_id, caseId: row.case_id, allowedTools: row.allowed_tools, allowedFields: row.allowed_fields, purpose: row.purpose, destination: row.destination, expiresAtEpochSeconds: Math.floor(row.expires_at.getTime() / 1000), version: Number(row.version) }; +} + +function mapApproval(row: ApprovalRow): McpApprovalRequest { + return currentStatus({ id: row.id, organizationId: row.organization_id, registryEntryId: row.registry_entry_id, subjectId: row.subject_id, clientId: row.client_id, toolName: row.tool_name, operationDigest: row.operation_digest, caseId: row.case_id ?? undefined, status: row.status, expiresAt: row.expires_at.toISOString(), createdAt: row.created_at.toISOString(), confirmedAt: row.confirmed_at?.toISOString() }); +} + +function organizationFromGrantId(grantId: string): string | null { + const candidate = grantId.slice(0, 36); + return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(candidate) && grantId[36] === "." ? candidate : null; +} + +export class PostgresMcpClinicalStore implements McpClinicalStore { + constructor(private readonly pool: Pool) {} + + private async tenantTx(organizationId: string, work: (client: PoolClient) => Promise): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT set_config('app.tenant_id', $1, true)", [organizationId]); + const value = await work(client); + await client.query("COMMIT"); + return value; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { client.release(); } + } + + async createGrant(input: CreateMcpContextGrantInput, actor: AuditActor): Promise { + return this.tenantTx(input.organizationId, async (client) => { + const id = `${input.organizationId}.${randomUUID()}`; + const result = await client.query(`INSERT INTO mcp_context_grants (id, organization_id, subject_id, client_id, case_id, allowed_tools, allowed_fields, purpose, destination, expires_at, version) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,to_timestamp($10),1) RETURNING *`, [id, input.organizationId, input.subjectId, input.clientId, input.caseId, [...new Set(input.allowedTools)].sort(), [...new Set(input.allowedFields)].sort(), input.purpose, input.destination, input.expiresAtEpochSeconds]); + await insertAuditEntry(client, { organizationId: input.organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "mcpClinical.grantCreate", targetType: "mcpContextGrant", targetId: id, details: { toolCount: input.allowedTools.length, fieldCount: input.allowedFields.length, purpose: input.purpose, destination: input.destination } }); + return mapGrant(result.rows[0]); + }); + } + + async introspectGrant(grantId: string): Promise { + const organizationId = organizationFromGrantId(grantId); + if (!organizationId) return null; + return this.tenantTx(organizationId, async (client) => { + const result = await client.query("SELECT * FROM mcp_context_grants WHERE organization_id=$1 AND id=$2 AND revoked_at IS NULL AND expires_at > now()", [organizationId, grantId]); + return result.rows[0] ? mapGrant(result.rows[0]) : null; + }); + } + + async createApprovalRequest(input: CreateMcpApprovalRequestInput, actor: AuditActor): Promise { + return this.tenantTx(input.organizationId, async (client) => { + const id = randomUUID(); + const result = await client.query(`INSERT INTO mcp_approval_requests (id,organization_id,registry_entry_id,subject_id,client_id,tool_name,operation_digest,case_id,status,expires_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'pending',$9) RETURNING *`, [id, input.organizationId, input.registryEntryId, input.subjectId, input.clientId, input.toolName, input.operationDigest, input.caseId ?? null, input.expiresAt]); + await insertAuditEntry(client, { organizationId: input.organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "mcpClinical.approvalPrepare", targetType: "mcpApprovalRequest", targetId: id, details: { registryEntryId: input.registryEntryId, toolName: input.toolName } }); + return mapApproval(result.rows[0]); + }); + } + + async getApprovalRequest(organizationId: string, id: string): Promise { + return this.tenantTx(organizationId, async (client) => { + const result = await client.query("SELECT * FROM mcp_approval_requests WHERE organization_id=$1 AND id=$2", [organizationId, id]); + return result.rows[0] ? mapApproval(result.rows[0]) : null; + }); + } + + async confirmApprovalRequest(organizationId: string, id: string, subjectId: string, clientId: string, actor: AuditActor): Promise { + return this.tenantTx(organizationId, async (client) => { + const result = await client.query(`UPDATE mcp_approval_requests SET status='confirmed', confirmed_at=now() WHERE organization_id=$1 AND id=$2 AND subject_id=$3 AND client_id=$4 AND status='pending' AND expires_at > now() RETURNING *`, [organizationId, id, subjectId, clientId]); + if (!result.rows[0]) return null; + await insertAuditEntry(client, { organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "mcpClinical.approvalConfirm", targetType: "mcpApprovalRequest", targetId: id, details: { registryEntryId: result.rows[0].registry_entry_id, toolName: result.rows[0].tool_name } }); + return mapApproval(result.rows[0]); + }); + } + + async recordReview(input: RecordMcpReviewInput, actor: AuditActor): Promise { + return this.tenantTx(input.organizationId, async (client) => { + const reviewId = randomUUID(); + const result = await client.query<{ id: string; decision: RecordMcpReviewInput["decision"] }>(`INSERT INTO mcp_operation_reviews (id,organization_id,case_id,reviewer_subject_id,reviewed_operation_id,decision,rationale) VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (organization_id, reviewed_operation_id) DO UPDATE SET reviewed_operation_id=EXCLUDED.reviewed_operation_id RETURNING id,decision`, [reviewId, input.organizationId, input.caseId, input.reviewerSubjectId, input.reviewedOperationId, input.decision, input.rationale]); + await insertAuditEntry(client, { organizationId: input.organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "mcpClinical.reviewRecord", targetType: "mcpOperation", targetId: input.reviewedOperationId, details: { reviewId: result.rows[0].id, decision: result.rows[0].decision } }); + return { reviewId: result.rows[0].id, decision: result.rows[0].decision }; + }); + } +} diff --git a/server/src/store/mcp-registry-store.ts b/server/src/store/mcp-registry-store.ts index 7b793db..8e72aa6 100644 --- a/server/src/store/mcp-registry-store.ts +++ b/server/src/store/mcp-registry-store.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { Pool, PoolClient } from "pg"; -import type { McpAllowedTools, McpDataEgressPolicy, McpRegistryEntry, McpRegistryStatus, McpTransport } from "../domain/types.js"; +import type { McpAllowedTools, McpDataEgressPolicy, McpIntegrationProfile, McpRegistryEntry, McpRegistryStatus, McpTransport } from "../domain/types.js"; import { type AuditActor, type AuditStore, InMemoryAuditStore, insertAuditEntry } from "./audit-store.js"; /** @@ -46,6 +46,10 @@ export interface CreateMcpRegistryEntryInput { endpoint: string; allowedTools: McpAllowedTools; dataEgressPolicy: McpDataEgressPolicy; + integrationProfile?: McpIntegrationProfile; + oauthClientId?: string; + catalogVersionConstraint?: string; + approvalChallengeEndpoint?: string; description?: string; } @@ -74,6 +78,7 @@ export class InMemoryMcpRegistryStore implements McpRegistryStore { createdAt: now, updatedAt: now, ...input, + integrationProfile: input.integrationProfile ?? "generic", }; this.entries.set(entry.id, entry); await this.auditStore.record({ @@ -127,6 +132,10 @@ interface McpRegistryRow { endpoint: string; allowed_tools: McpAllowedTools; data_egress_policy: McpDataEgressPolicy; + integration_profile: McpIntegrationProfile; + oauth_client_id: string | null; + catalog_version_constraint: string | null; + approval_challenge_endpoint: string | null; status: McpRegistryStatus; description: string | null; created_by_user_id: string; @@ -144,6 +153,10 @@ function mapRow(row: McpRegistryRow): McpRegistryEntry { endpoint: row.endpoint, allowedTools: row.allowed_tools, dataEgressPolicy: row.data_egress_policy, + integrationProfile: row.integration_profile, + oauthClientId: row.oauth_client_id ?? undefined, + catalogVersionConstraint: row.catalog_version_constraint ?? undefined, + approvalChallengeEndpoint: row.approval_challenge_endpoint ?? undefined, status: row.status, description: row.description ?? undefined, createdByUserId: row.created_by_user_id, @@ -177,9 +190,9 @@ export class PostgresMcpRegistryStore implements McpRegistryStore { const id = randomUUID(); const now = new Date(); const result = await client.query( - `INSERT INTO mcp_registry_entries (id, organization_id, name, transport, endpoint, allowed_tools, data_egress_policy, status, description, created_by_user_id, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, 'active', $8, $9, $10, $10) RETURNING *`, - [id, organizationId, input.name, input.transport, input.endpoint, JSON.stringify(input.allowedTools), input.dataEgressPolicy, input.description ?? null, createdByUserId, now] + `INSERT INTO mcp_registry_entries (id, organization_id, name, transport, endpoint, allowed_tools, data_egress_policy, integration_profile, oauth_client_id, catalog_version_constraint, approval_challenge_endpoint, status, description, created_by_user_id, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, 'active', $12, $13, $14, $14) RETURNING *`, + [id, organizationId, input.name, input.transport, input.endpoint, JSON.stringify(input.allowedTools), input.dataEgressPolicy, input.integrationProfile ?? "generic", input.oauthClientId ?? null, input.catalogVersionConstraint ?? null, input.approvalChallengeEndpoint ?? null, input.description ?? null, createdByUserId, now] ); await insertAuditEntry(client, { organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, @@ -219,13 +232,17 @@ export class PostgresMcpRegistryStore implements McpRegistryStore { endpoint: partial.endpoint ?? current.endpoint, allowedTools: partial.allowedTools ?? current.allowedTools, dataEgressPolicy: partial.dataEgressPolicy ?? current.dataEgressPolicy, + integrationProfile: partial.integrationProfile ?? current.integrationProfile, + oauthClientId: partial.oauthClientId ?? current.oauthClientId, + catalogVersionConstraint: partial.catalogVersionConstraint ?? current.catalogVersionConstraint, + approvalChallengeEndpoint: partial.approvalChallengeEndpoint ?? current.approvalChallengeEndpoint, description: partial.description ?? current.description, }; const updatedAt = new Date(); const result = await client.query( - `UPDATE mcp_registry_entries SET name=$3, transport=$4, endpoint=$5, allowed_tools=$6, data_egress_policy=$7, description=$8, updated_by_user_id=$9, updated_at=$10 + `UPDATE mcp_registry_entries SET name=$3, transport=$4, endpoint=$5, allowed_tools=$6, data_egress_policy=$7, integration_profile=$8, oauth_client_id=$9, catalog_version_constraint=$10, approval_challenge_endpoint=$11, description=$12, updated_by_user_id=$13, updated_at=$14 WHERE organization_id=$1 AND id=$2 RETURNING *`, - [organizationId, id, merged.name, merged.transport, merged.endpoint, JSON.stringify(merged.allowedTools), merged.dataEgressPolicy, merged.description ?? null, updatedByUserId, updatedAt] + [organizationId, id, merged.name, merged.transport, merged.endpoint, JSON.stringify(merged.allowedTools), merged.dataEgressPolicy, merged.integrationProfile, merged.oauthClientId ?? null, merged.catalogVersionConstraint ?? null, merged.approvalChallengeEndpoint ?? null, merged.description ?? null, updatedByUserId, updatedAt] ); await insertAuditEntry(client, { organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, diff --git a/server/src/store/postgres-ai-gateway-store.ts b/server/src/store/postgres-ai-gateway-store.ts index cbc0640..cf68ac3 100644 --- a/server/src/store/postgres-ai-gateway-store.ts +++ b/server/src/store/postgres-ai-gateway-store.ts @@ -17,7 +17,7 @@ function consent(r: Row): AiConsent { return { id:r.id as string,patientCaseId:r function request(r: Row): AiRequestEnvelope { return { id:r.id as string,patientCaseId:r.patient_case_id as string,requestedByUserId:r.requested_by_user_id as string,providerModelId:r.provider_model_id as string,purposeOfUse:r.purpose_of_use as AiRequestEnvelope["purposeOfUse"],consentId:r.consent_id as string,policySnapshotHash:r.policy_snapshot_hash as string,dataScope:r.data_scope as AiRequestEnvelope["dataScope"],deidentificationApplied:r.deidentification_applied as boolean,status:r.status as AiRequestEnvelope["status"],rejectionReason:(r.rejection_reason as string|null)??undefined,createdAt:iso(r.created_at),expiresAt:iso(r.expires_at),completedAt:maybeIso(r.completed_at) }; } function requestInput(r: Row): AiRequestInput { return { id:r.id as string,requestId:r.request_id as string,resourceType:r.resource_type as string,resourceId:r.resource_id as string,resourceVersionHash:(r.resource_version_hash as string|null)??undefined,includedInPrompt:r.included_in_prompt as boolean }; } function transformation(r: Row): AiDataTransformation { return { id:r.id as string,requestId:r.request_id as string,kind:r.kind as AiDataTransformation["kind"],appliedAt:iso(r.applied_at),details:(r.details as Record|null)??undefined }; } -function output(r: Row): AiOutput { return { id:r.id as string,requestId:r.request_id as string,providerModelId:r.provider_model_id as string,modelVersion:r.model_version as string,generatedAt:iso(r.generated_at),summary:r.summary as string,evidence:r.evidence as string[],uncertainty:(r.uncertainty as string|null)??undefined,followUp:r.follow_up as string[],abstained:r.abstained as boolean,abstainReason:(r.abstain_reason as string|null)??undefined,confidence:r.confidence===null||r.confidence===undefined?undefined:Number(r.confidence),outputHash:r.output_hash as string,reviewStatus:r.review_status as AiOutput["reviewStatus"] }; } +function output(r: Row): AiOutput { return { id:r.id as string,requestId:r.request_id as string,providerModelId:r.provider_model_id as string,modelVersion:r.model_version as string,promptVersion:r.prompt_version as string,generatedAt:iso(r.generated_at),summary:r.summary as string,evidence:r.evidence as string[],uncertainty:(r.uncertainty as string|null)??undefined,followUp:r.follow_up as string[],abstained:r.abstained as boolean,abstainReason:(r.abstain_reason as string|null)??undefined,confidence:r.confidence===null||r.confidence===undefined?undefined:Number(r.confidence),outputHash:r.output_hash as string,reviewStatus:r.review_status as AiOutput["reviewStatus"] }; } function citation(r: Row): AiCitation { return { id:r.id as string,outputId:r.output_id as string,resourceType:r.resource_type as string,resourceId:r.resource_id as string,resourceVersionHash:(r.resource_version_hash as string|null)??undefined,locator:(r.locator as string|null)??undefined }; } function review(r: Row): AiReview { return { id:r.id as string,outputId:r.output_id as string,reviewedByUserId:r.reviewed_by_user_id as string,decision:r.decision as AiReview["decision"],correctedText:(r.corrected_text as string|null)??undefined,escalationReason:(r.escalation_reason as string|null)??undefined,reviewedAt:iso(r.reviewed_at) }; } function safetyEvent(r: Row): AiSafetyEvent { return { id:r.id as string,requestId:(r.request_id as string|null)??undefined,kind:r.kind as AiSafetyEvent["kind"],severity:r.severity as AiSafetyEvent["severity"],details:(r.details as string|null)??undefined,createdAt:iso(r.created_at) }; } @@ -83,12 +83,22 @@ export class PostgresAiGatewayStore implements AiGatewayStore { listTransformations:requestId=>read(async c=>(await c.query(`SELECT * FROM ${schema}.ai_data_transformations WHERE request_id=$1 ORDER BY applied_at,id`,[requestId])).rows.map(transformation)), createOutput:(input:CreateAiOutputInput,actor)=>tx(async client=>{ - const r=await client.query(`INSERT INTO ${schema}.ai_outputs (request_id,provider_model_id,model_version,summary,evidence,uncertainty,follow_up,abstained,abstain_reason,confidence,output_hash) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) RETURNING *`,[input.requestId,input.providerModelId,input.modelVersion,input.summary,input.evidence,input.uncertainty??null,input.followUp,input.abstained,input.abstainReason??null,input.confidence??null,input.outputHash]); + const r=await client.query(`INSERT INTO ${schema}.ai_outputs (request_id,provider_model_id,model_version,prompt_version,summary,evidence,uncertainty,follow_up,abstained,abstain_reason,confidence,output_hash) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) RETURNING *`,[input.requestId,input.providerModelId,input.modelVersion,input.promptVersion,input.summary,input.evidence,input.uncertainty??null,input.followUp,input.abstained,input.abstainReason??null,input.confidence??null,input.outputHash]); const value=output(r.rows[0]);const citations:AiCitation[]=[];for(const item of input.citations){const cr=await client.query(`INSERT INTO ${schema}.ai_citations (output_id,resource_type,resource_id,resource_version_hash,locator) VALUES ($1,$2,$3,$4,$5) RETURNING *`,[value.id,item.resourceType,item.resourceId,item.resourceVersionHash??null,item.locator??null]);citations.push(citation(cr.rows[0]));} await nextChange(client,"output",value.id,value);await audit(client,actor,"aiOutput.create","aiOutput",value.id,{requestId:input.requestId,abstained:input.abstained,citationCount:citations.length});return {output:value,citations}; }), getOutput:id=>read(async c=>{const r=await c.query(`SELECT * FROM ${schema}.ai_outputs WHERE id=$1`,[id]);return r.rows[0]?output(r.rows[0]):null;}), listOutputsForRequest:requestId=>read(async c=>(await c.query(`SELECT * FROM ${schema}.ai_outputs WHERE request_id=$1 ORDER BY generated_at,id`,[requestId])).rows.map(output)), + // No index on (provider_model_id, generated_at) exists yet + // (migration 018 only indexes ai_outputs(request_id) — this + // query pattern is new, added for the production quality + // monitor). Correct as-is; a real deployment with enough output + // volume for this scan to matter should add one via a new + // migration re-running provision_tenant_ai_gateway_tables's own + // backfill pattern — not done here since this store has never + // run against a live Postgres in this environment to verify + // against (see docs/reference on local dev constraints). + listOutputsForProviderModel:(providerModelId,since)=>read(async c=>(await c.query(`SELECT * FROM ${schema}.ai_outputs WHERE provider_model_id=$1${since?" AND generated_at>$2":""} ORDER BY generated_at,id`,since?[providerModelId,new Date(since)]:[providerModelId])).rows.map(output)), listCitationsForOutput:outputId=>read(async c=>(await c.query(`SELECT * FROM ${schema}.ai_citations WHERE output_id=$1 ORDER BY id`,[outputId])).rows.map(citation)), createReview:(input,actor)=>tx(async client=>{ try { diff --git a/server/src/store/postgres-hl7-ingestion-store.test.ts b/server/src/store/postgres-hl7-ingestion-store.test.ts new file mode 100644 index 0000000..f656d63 --- /dev/null +++ b/server/src/store/postgres-hl7-ingestion-store.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Pool } from "pg"; +import { PostgresHl7IngestionStore } from "./postgres-hl7-ingestion-store.js"; + +vi.mock("./audit-store.js", async () => { + const actual = await vi.importActual("./audit-store.js"); + return { ...actual, insertAuditEntry: vi.fn(async () => {}) }; +}); + +const tenant = { organizationId: "11111111-1111-4111-8111-111111111111", schemaName: "tenant_11111111111141118111111111111111", issuer: "test", subject: "test" }; + +describe("PostgresHl7IngestionStore", () => { + it("binds every query to the validated tenant schema", async () => { + const queries: Array<{ text: string; values?: unknown[] }> = []; + const now = new Date("2026-03-15T12:00:00Z"); + const pool = { + query: vi.fn(async (text: string, values?: unknown[]) => { + queries.push({ text, values }); + if (text.includes("INSERT INTO")) { + return { + rows: [{ + id: "22222222-2222-4222-8222-222222222222", message_type: "ORU^R01", message_control_id: "MSG001", + raw_message: "MSH|...", received_at: now, patient_identifier_value: "MRN-001", patient_identifier_issuer: "TEST", + match_status: "matched", matched_case_id: "case-1", candidate_case_ids: null, status: "applied", + observations_added: 1, reviewed_by_user_id: null, reviewed_at: null, rejection_reason: null, + created_at: now, updated_at: now, + }], + }; + } + return { rows: [] }; + }), + } as unknown as Pool; + + const job = await new PostgresHl7IngestionStore(pool).forTenant(tenant).createJob( + { messageType: "ORU^R01", messageControlId: "MSG001", rawMessage: "MSH|...", receivedAt: now.toISOString(), patientIdentifierValue: "MRN-001", patientIdentifierIssuer: "TEST", matchStatus: "matched", matchedCaseId: "case-1", status: "applied", observationsAdded: 1 }, + { externalSubject: "idp|system", userId: "user-1", organizationId: tenant.organizationId } + ); + + expect(job).toMatchObject({ messageType: "ORU^R01", matchedCaseId: "case-1", observationsAdded: 1 }); + expect(queries.some((q) => q.text.includes(tenant.schemaName) && q.text.includes(".hl7_ingestion_jobs"))).toBe(true); + }); + + it("rejects an untrusted dynamic schema identifier before querying", () => { + const pool = { query: vi.fn() } as unknown as Pool; + expect(() => new PostgresHl7IngestionStore(pool).forTenant({ ...tenant, schemaName: 'tenant_safe";DROP SCHEMA public;--' })).toThrow("Unsafe tenant schema identifier"); + expect(pool.query).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/store/postgres-hl7-ingestion-store.ts b/server/src/store/postgres-hl7-ingestion-store.ts new file mode 100644 index 0000000..427819b --- /dev/null +++ b/server/src/store/postgres-hl7-ingestion-store.ts @@ -0,0 +1,107 @@ +import { randomUUID } from "node:crypto"; +import type { Hl7IngestionJob } from "@modelforge/contracts"; +import type { Pool } from "pg"; +import type { TenantContext } from "../tenant-context.js"; +import { insertAuditEntry, type AuditActor } from "./audit-store.js"; +import type { Hl7IngestionStore, TenantHl7IngestionRepository } from "./hl7-ingestion-store.js"; + +type Row = Record; +function schemaName(value: string): string { + if (!/^tenant_[a-f0-9]{32}$/.test(value)) throw new Error("Unsafe tenant schema identifier."); + return `"${value}"`; +} + +function mapRow(r: Row): Hl7IngestionJob { + return { + id: r.id as string, + messageType: r.message_type as string, + messageControlId: r.message_control_id as string, + rawMessage: r.raw_message as string, + receivedAt: (r.received_at as Date).toISOString(), + patientIdentifierValue: (r.patient_identifier_value as string | null) ?? undefined, + patientIdentifierIssuer: (r.patient_identifier_issuer as string | null) ?? undefined, + matchStatus: r.match_status as Hl7IngestionJob["matchStatus"], + matchedCaseId: (r.matched_case_id as string | null) ?? undefined, + candidateCaseIds: (r.candidate_case_ids as string[] | null) ?? undefined, + status: r.status as Hl7IngestionJob["status"], + observationsAdded: r.observations_added === null || r.observations_added === undefined ? undefined : Number(r.observations_added), + reviewedByUserId: (r.reviewed_by_user_id as string | null) ?? undefined, + reviewedAt: r.reviewed_at ? (r.reviewed_at as Date).toISOString() : undefined, + rejectionReason: (r.rejection_reason as string | null) ?? undefined, + createdAt: (r.created_at as Date).toISOString(), + updatedAt: (r.updated_at as Date).toISOString(), + }; +} + +/** Postgres-backed HL7 v2 ingestion job store — schema-per-tenant, + * migration `024_hl7_ingestion.sql`. Not run against a real Postgres + * instance in the environment this was built in — same disclosed + * limitation as every other postgres-*.ts store in this package. */ +export class PostgresHl7IngestionStore implements Hl7IngestionStore { + constructor(private readonly pool: Pool) {} + + forTenant(context: TenantContext): TenantHl7IngestionRepository { + const pool = this.pool; + const organizationId = context.organizationId; + const schema = schemaName(context.schemaName); + + return { + context, + + async createJob(input, actor: AuditActor) { + const id = randomUUID(); + const result = await pool.query( + `INSERT INTO ${schema}.hl7_ingestion_jobs + (id, message_type, message_control_id, raw_message, received_at, patient_identifier_value, patient_identifier_issuer, match_status, matched_case_id, candidate_case_ids, status, observations_added, reviewed_by_user_id, reviewed_at, rejection_reason, created_at, updated_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15, now(), now()) RETURNING *`, + [ + id, input.messageType, input.messageControlId, input.rawMessage, new Date(input.receivedAt), + input.patientIdentifierValue ?? null, input.patientIdentifierIssuer ?? null, input.matchStatus, + input.matchedCaseId ?? null, input.candidateCaseIds ?? null, input.status, + input.observationsAdded ?? null, input.reviewedByUserId ?? null, + input.reviewedAt ? new Date(input.reviewedAt) : null, input.rejectionReason ?? null, + ] + ); + await insertAuditEntry(pool, { organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "hl7IngestionJob.create", targetType: "hl7IngestionJob", targetId: id, details: { messageType: input.messageType, matchStatus: input.matchStatus, status: input.status } }); + return mapRow(result.rows[0]); + }, + + async getJob(id) { + const r = await pool.query(`SELECT * FROM ${schema}.hl7_ingestion_jobs WHERE id = $1`, [id]); + return r.rows[0] ? mapRow(r.rows[0]) : null; + }, + + async listJobs(filter) { + const where = filter?.status !== undefined ? "WHERE status = $1" : ""; + const params = filter?.status !== undefined ? [filter.status] : []; + const r = await pool.query(`SELECT * FROM ${schema}.hl7_ingestion_jobs ${where} ORDER BY created_at DESC`, params); + return r.rows.map(mapRow); + }, + + async updateJob(id, partial, actor: AuditActor) { + const columnFor: Record = { + status: "status", matchedCaseId: "matched_case_id", observationsAdded: "observations_added", + reviewedByUserId: "reviewed_by_user_id", reviewedAt: "reviewed_at", rejectionReason: "rejection_reason", + }; + const sets: string[] = []; + const params: unknown[] = []; + for (const [key, value] of Object.entries(partial)) { + const column = columnFor[key]; + if (!column) continue; + params.push(key === "reviewedAt" && typeof value === "string" ? new Date(value) : value); + sets.push(`${column} = $${params.length}`); + } + if (sets.length === 0) { + const existing = await pool.query(`SELECT * FROM ${schema}.hl7_ingestion_jobs WHERE id = $1`, [id]); + return existing.rows[0] ? mapRow(existing.rows[0]) : null; + } + params.push(id); + const result = await pool.query(`UPDATE ${schema}.hl7_ingestion_jobs SET ${sets.join(", ")}, updated_at = now() WHERE id = $${params.length} RETURNING *`, params); + if (!result.rows[0]) return null; + const updated = mapRow(result.rows[0]); + await insertAuditEntry(pool, { organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "hl7IngestionJob.update", targetType: "hl7IngestionJob", targetId: id, details: { status: updated.status, matchedCaseId: updated.matchedCaseId } }); + return updated; + }, + }; + } +} diff --git a/server/src/store/postgres-smart-launch-store.test.ts b/server/src/store/postgres-smart-launch-store.test.ts new file mode 100644 index 0000000..b466852 --- /dev/null +++ b/server/src/store/postgres-smart-launch-store.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Pool } from "pg"; +import { PostgresSmartLaunchStore } from "./postgres-smart-launch-store.js"; + +vi.mock("./audit-store.js", async () => { + const actual = await vi.importActual("./audit-store.js"); + return { ...actual, insertAuditEntry: vi.fn(async () => {}) }; +}); + +const tenant = { organizationId: "11111111-1111-4111-8111-111111111111", schemaName: "tenant_11111111111141118111111111111111", issuer: "test", subject: "test" }; + +describe("PostgresSmartLaunchStore", () => { + it("binds every query to the validated tenant schema", async () => { + const queries: Array<{ text: string; values?: unknown[] }> = []; + const now = new Date("2026-03-15T12:00:00Z"); + const pool = { + query: vi.fn(async (text: string, values?: unknown[]) => { + queries.push({ text, values }); + if (text.includes("INSERT INTO")) { + return { + rows: [{ + id: "22222222-2222-4222-8222-222222222222", issuer: "https://ehr.example.test/fhir", client_id: "modelforge-client", + redirect_uris: ["https://modelforge.example.test/callback"], added_by_user_id: "33333333-3333-4333-8333-333333333333", created_at: now, + }], + }; + } + return { rows: [] }; + }), + } as unknown as Pool; + + const issuer = await new PostgresSmartLaunchStore(pool).forTenant(tenant).upsertTrustedIssuer( + { issuer: "https://ehr.example.test/fhir", clientId: "modelforge-client", redirectUris: ["https://modelforge.example.test/callback"], addedByUserId: "33333333-3333-4333-8333-333333333333" }, + { externalSubject: "idp|admin", userId: "user-1", organizationId: tenant.organizationId } + ); + + expect(issuer).toMatchObject({ issuer: "https://ehr.example.test/fhir", clientId: "modelforge-client" }); + expect(queries.some((q) => q.text.includes(tenant.schemaName) && q.text.includes(".smart_trusted_issuers"))).toBe(true); + }); + + it("rejects an untrusted dynamic schema identifier before querying", () => { + const pool = { query: vi.fn() } as unknown as Pool; + expect(() => new PostgresSmartLaunchStore(pool).forTenant({ ...tenant, schemaName: 'tenant_safe";DROP SCHEMA public;--' })).toThrow("Unsafe tenant schema identifier"); + expect(pool.query).not.toHaveBeenCalled(); + }); +}); diff --git a/server/src/store/postgres-smart-launch-store.ts b/server/src/store/postgres-smart-launch-store.ts new file mode 100644 index 0000000..ff77f5f --- /dev/null +++ b/server/src/store/postgres-smart-launch-store.ts @@ -0,0 +1,137 @@ +import { randomUUID } from "node:crypto"; +import type { SmartTrustedIssuer } from "@modelforge/contracts"; +import type { Pool } from "pg"; +import type { TenantContext } from "../tenant-context.js"; +import { insertAuditEntry, type AuditActor } from "./audit-store.js"; +import type { CreateLaunchSessionInput, CreateTokenInput, InternalSmartLaunchSession, InternalSmartLaunchToken, SmartLaunchStore, TenantSmartLaunchRepository } from "./smart-launch-store.js"; + +type Row = Record; +function schemaName(value: string): string { + if (!/^tenant_[a-f0-9]{32}$/.test(value)) throw new Error("Unsafe tenant schema identifier."); + return `"${value}"`; +} + +function mapIssuerRow(r: Row): SmartTrustedIssuer { + return { id: r.id as string, issuer: r.issuer as string, clientId: r.client_id as string, redirectUris: r.redirect_uris as string[], addedByUserId: r.added_by_user_id as string, createdAt: (r.created_at as Date).toISOString() }; +} + +function mapSessionRow(r: Row): InternalSmartLaunchSession { + return { + id: r.state as string, issuer: r.issuer as string, requestedByUserId: r.requested_by_user_id as string, scope: r.scope as string, + status: r.status as InternalSmartLaunchSession["status"], createdAt: (r.created_at as Date).toISOString(), expiresAt: (r.expires_at as Date).toISOString(), + codeVerifier: r.code_verifier as string, redirectUri: r.redirect_uri as string, launch: (r.launch as string | null) ?? undefined, + }; +} + +function mapTokenRow(r: Row): InternalSmartLaunchToken { + return { + id: r.id as string, issuer: r.issuer as string, requestedByUserId: r.requested_by_user_id as string, scope: r.scope as string, + patientId: (r.patient_id as string | null) ?? undefined, hasRefreshToken: r.encrypted_refresh_token !== null, + expiresAt: (r.expires_at as Date).toISOString(), createdAt: (r.created_at as Date).toISOString(), + encryptedAccessToken: r.encrypted_access_token as string, encryptedRefreshToken: (r.encrypted_refresh_token as string | null) ?? undefined, + }; +} + +/** Postgres-backed SMART App Launch store — schema-per-tenant, migration + * `026_smart_launch.sql`. Not run against a real Postgres instance in the + * environment this was built in — same disclosed limitation as every other + * postgres-*.ts store in this package. */ +export class PostgresSmartLaunchStore implements SmartLaunchStore { + constructor(private readonly pool: Pool) {} + + forTenant(context: TenantContext): TenantSmartLaunchRepository { + const pool = this.pool; + const organizationId = context.organizationId; + const schema = schemaName(context.schemaName); + + return { + context, + + async upsertTrustedIssuer(input, actor: AuditActor) { + const result = await pool.query( + `INSERT INTO ${schema}.smart_trusted_issuers (id, issuer, client_id, redirect_uris, added_by_user_id, created_at) + VALUES ($1,$2,$3,$4,$5, now()) + ON CONFLICT (issuer) DO UPDATE SET client_id = EXCLUDED.client_id, redirect_uris = EXCLUDED.redirect_uris, added_by_user_id = EXCLUDED.added_by_user_id + RETURNING *`, + [randomUUID(), input.issuer, input.clientId, input.redirectUris, input.addedByUserId] + ); + const value = mapIssuerRow(result.rows[0]); + await insertAuditEntry(pool, { organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "smartTrustedIssuer.upsert", targetType: "smartTrustedIssuer", targetId: value.id, details: { issuer: input.issuer } }); + return value; + }, + + async getTrustedIssuer(issuer) { + const r = await pool.query(`SELECT * FROM ${schema}.smart_trusted_issuers WHERE issuer = $1`, [issuer]); + return r.rows[0] ? mapIssuerRow(r.rows[0]) : null; + }, + + async listTrustedIssuers() { + const r = await pool.query(`SELECT * FROM ${schema}.smart_trusted_issuers ORDER BY created_at DESC`); + return r.rows.map(mapIssuerRow); + }, + + async deleteTrustedIssuer(issuer, actor: AuditActor) { + const r = await pool.query(`DELETE FROM ${schema}.smart_trusted_issuers WHERE issuer = $1 RETURNING id`, [issuer]); + if (!r.rows[0]) return false; + await insertAuditEntry(pool, { organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "smartTrustedIssuer.delete", targetType: "smartTrustedIssuer", targetId: r.rows[0].id as string, details: { issuer } }); + return true; + }, + + async createLaunchSession(stateKey: string, input: CreateLaunchSessionInput, actor: AuditActor) { + const result = await pool.query( + `INSERT INTO ${schema}.smart_launch_sessions (state, issuer, requested_by_user_id, scope, status, code_verifier, redirect_uri, launch, created_at, expires_at) + VALUES ($1,$2,$3,$4,'pending',$5,$6,$7, now(), $8) RETURNING *`, + [stateKey, input.issuer, input.requestedByUserId, input.scope, input.codeVerifier, input.redirectUri, input.launch ?? null, new Date(input.expiresAt)] + ); + const value = mapSessionRow(result.rows[0]); + await insertAuditEntry(pool, { organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "smartLaunchSession.create", targetType: "smartLaunchSession", targetId: stateKey, details: { issuer: input.issuer } }); + return value; + }, + + async getLaunchSession(stateKey) { + const r = await pool.query(`SELECT * FROM ${schema}.smart_launch_sessions WHERE state = $1`, [stateKey]); + return r.rows[0] ? mapSessionRow(r.rows[0]) : null; + }, + + async completeLaunchSession(stateKey: string, actor: AuditActor) { + const r = await pool.query(`UPDATE ${schema}.smart_launch_sessions SET status = 'completed' WHERE state = $1 AND status = 'pending' RETURNING *`, [stateKey]); + if (!r.rows[0]) return null; + const value = mapSessionRow(r.rows[0]); + await insertAuditEntry(pool, { organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "smartLaunchSession.complete", targetType: "smartLaunchSession", targetId: stateKey, details: {} }); + return value; + }, + + async createToken(input: CreateTokenInput, actor: AuditActor) { + const id = randomUUID(); + const result = await pool.query( + `INSERT INTO ${schema}.smart_launch_tokens (id, issuer, requested_by_user_id, scope, patient_id, encrypted_access_token, encrypted_refresh_token, created_at, expires_at) + VALUES ($1,$2,$3,$4,$5,$6,$7, now(), $8) RETURNING *`, + [id, input.issuer, input.requestedByUserId, input.scope, input.patientId ?? null, input.encryptedAccessToken, input.encryptedRefreshToken ?? null, new Date(input.expiresAt)] + ); + const value = mapTokenRow(result.rows[0]); + await insertAuditEntry(pool, { organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "smartLaunchToken.create", targetType: "smartLaunchToken", targetId: id, details: { issuer: input.issuer, hasPatientContext: input.patientId !== undefined } }); + return value; + }, + + async getToken(id) { + const r = await pool.query(`SELECT * FROM ${schema}.smart_launch_tokens WHERE id = $1`, [id]); + return r.rows[0] ? mapTokenRow(r.rows[0]) : null; + }, + + async listTokensForUser(userId) { + const r = await pool.query(`SELECT * FROM ${schema}.smart_launch_tokens WHERE requested_by_user_id = $1 ORDER BY created_at DESC`, [userId]); + return r.rows.map((row) => { + const { encryptedAccessToken: _a, encryptedRefreshToken: _r, ...rest } = mapTokenRow(row); + return rest; + }); + }, + + async deleteToken(id, actor: AuditActor) { + const r = await pool.query(`DELETE FROM ${schema}.smart_launch_tokens WHERE id = $1 RETURNING id`, [id]); + if (!r.rows[0]) return false; + await insertAuditEntry(pool, { organizationId, actorUserId: actor.userId, actorExternalSubject: actor.externalSubject, action: "smartLaunchToken.delete", targetType: "smartLaunchToken", targetId: id, details: {} }); + return true; + }, + }; + } +} diff --git a/server/src/store/smart-launch-store.ts b/server/src/store/smart-launch-store.ts new file mode 100644 index 0000000..f4847e2 --- /dev/null +++ b/server/src/store/smart-launch-store.ts @@ -0,0 +1,90 @@ +import type { SmartLaunchSession, SmartLaunchToken, SmartTrustedIssuer } from "@modelforge/contracts"; +import type { TenantContext } from "../tenant-context.js"; +import type { AuditActor } from "./audit-store.js"; + +/** + * Tenant-scoped repository for SMART App Launch client-role state — mirrors + * the "one interface per domain" shape every other store in this codebase + * uses. Two sub-resources, each internal-vs-public split for the same + * reason: neither the PKCE `codeVerifier` (session) nor the actual + * encrypted access/refresh token (completed launch) may ever appear in an + * API response — see smart-launch.ts's own contracts doc comment and + * routes/smart-launch.ts. + */ + +export interface InternalSmartLaunchSession extends SmartLaunchSession { + codeVerifier: string; + redirectUri: string; + launch?: string; +} + +export interface InternalSmartLaunchToken extends SmartLaunchToken { + /** AES-256-GCM envelope, base64 (smart-launch/token-crypto.ts) — never + * the plaintext token. */ + encryptedAccessToken: string; + encryptedRefreshToken?: string; +} + +export interface CreateLaunchSessionInput { + issuer: string; + requestedByUserId: string; + scope: string; + codeVerifier: string; + redirectUri: string; + launch?: string; + expiresAt: string; +} + +export interface CreateTokenInput { + issuer: string; + requestedByUserId: string; + scope: string; + patientId?: string; + encryptedAccessToken: string; + encryptedRefreshToken?: string; + expiresAt: string; +} + +export interface TenantSmartLaunchRepository { + readonly context: TenantContext; + + upsertTrustedIssuer(input: Omit, actor: AuditActor): Promise; + getTrustedIssuer(issuer: string): Promise; + listTrustedIssuers(): Promise; + deleteTrustedIssuer(issuer: string, actor: AuditActor): Promise; + + /** `state` (RFC 6749's CSRF-protection parameter) doubles as this + * row's own id — it is already required to be unguessable and unique, + * the same property a store id needs, and using it directly avoids a + * separate lookup-by-state index. */ + createLaunchSession(state: string, input: CreateLaunchSessionInput, actor: AuditActor): Promise; + getLaunchSession(state: string): Promise; + /** Marks a pending session completed — a session already `completed` + * or `expired` cannot be completed again (the store itself enforces + * this, returning null, so a caller can never double-spend one launch + * attempt into two token exchanges by racing this call). */ + completeLaunchSession(state: string, actor: AuditActor): Promise; + + createToken(input: CreateTokenInput, actor: AuditActor): Promise; + getToken(id: string): Promise; + listTokensForUser(userId: string): Promise; + deleteToken(id: string, actor: AuditActor): Promise; +} + +export interface SmartLaunchStore { + forTenant(context: TenantContext): TenantSmartLaunchRepository; +} + +/** Strips store-internal secret fields before a launch session ever + * reaches an API response — routes/smart-launch.ts's own safety net, + * called on every response that carries one, so a future field added to + * the internal shape can't leak by omission. */ +export function publicLaunchSession(session: InternalSmartLaunchSession): SmartLaunchSession { + const { codeVerifier: _codeVerifier, redirectUri: _redirectUri, launch: _launch, ...rest } = session; + return rest; +} + +export function publicToken(token: InternalSmartLaunchToken): SmartLaunchToken { + const { encryptedAccessToken: _a, encryptedRefreshToken: _r, ...rest } = token; + return rest; +}