From fc943b8f72b6047d50c5655e9dbc9cab8c2d8e3b Mon Sep 17 00:00:00 2001 From: Karthik Chandrasekaran Date: Sat, 19 Sep 2026 20:43:39 -0500 Subject: [PATCH 1/2] Add self-hosted Swagger UI and local OpenAPI workbench Document the five existing API operations and support same-origin Try it out with synthetic-data guidance. Add docs asset allowlists, scoped CSP and integration coverage without changing record behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fc4430e2-35f5-48b8-bd0f-5dc6fad2a0ff --- README.md | 9 + docs/LOCAL_API.md | 88 +++++++++ package-lock.json | 19 ++ package.json | 6 + src/web/server.ts | 37 +++- test/swagger.spec.ts | 237 +++++++++++++++++++++++ web/api-docs/docs.css | 23 +++ web/api-docs/index.html | 28 +++ web/api-docs/swagger-initializer.js | 24 +++ web/openapi.json | 287 ++++++++++++++++++++++++++++ 10 files changed, 756 insertions(+), 2 deletions(-) create mode 100644 docs/LOCAL_API.md create mode 100644 test/swagger.spec.ts create mode 100644 web/api-docs/docs.css create mode 100644 web/api-docs/index.html create mode 100644 web/api-docs/swagger-initializer.js create mode 100644 web/openapi.json diff --git a/README.md b/README.md index eedf7d3..5cf70b5 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,15 @@ the reference-inspired layout and the patient-specific privacy boundaries. | `npm run example` | Run the synthetic end-to-end example | | `npm run example:access-denied` | Demonstrate that one factor alone cannot unlock a record | +## Interactive local API + +After `npm start`, open **http://127.0.0.1:3000/docs** for Swagger UI. +**Try it out** calls the running local API; no cloud account is needed. +The specification is available at `/openapi.json` and checked in as +`web/openapi.json`. See [the local API guide](docs/LOCAL_API.md) for the +synthetic clinician -> enroll -> append -> timeline walkthrough. +Never enter real patient data or real credentials. + ## Minimal usage ```ts diff --git a/docs/LOCAL_API.md b/docs/LOCAL_API.md new file mode 100644 index 0000000..91cfcd5 --- /dev/null +++ b/docs/LOCAL_API.md @@ -0,0 +1,88 @@ +# Local API workbench + +Run from the project root: + +```sh +npm ci +npm start +``` + +Open **http://127.0.0.1:3000/docs** for Swagger UI, or +**http://127.0.0.1:3000/openapi.json** for the OpenAPI 3.0 document. +`web/openapi.json` is the checked-in specification. +`/docs` and `/docs/` both work. + +The spec uses `servers: [{ "url": "/" }]`, so **Try it out calls the same +local server and port that served the page**. If you set `PORT=3001`, open +`http://127.0.0.1:3001/docs`; no spec edit is needed. Keep the default loopback +host. No cloud account, external Swagger site, CORS change or storage PR is +required. This PR is independent of the pluggable-storage work. + +## Try the complete flow + +1. Expand **GET /api/status**, choose **Try it out**, then **Execute**. + Expect `200` with `"mode": "local-poc"`. +2. Execute **POST /api/clinicians** with no body. Copy the complete JSON + response containing `credentialId`, `publicKeyPem` and `privateKeyPem`. + This is a newly generated **demo** key pair, not a real clinical credential. +3. For **POST /api/enroll**, choose **Try it out**. Replace the entire + `clinician` object in the example with that response. Keep the escaped + `\n` characters in its PEM strings. Choose a new synthetic `biometricToken` + of at least 16 characters, then execute. Expect `201`. +4. For **POST /api/records**, paste the **same** clinician object and factor + into the example; leave the synthetic Condition or select another supported + resource type. Execute to append an encrypted entry. Expect `201`. +5. For **POST /api/timeline**, use the same two factors. Execute to see a `200` + response containing the verified, decrypted synthetic timeline. + +The `COPY_FROM_CLINICIAN_RESPONSE` strings are instructions, **not valid keys**. +Use a different factor for another new record. Re-enrolling an existing factor +returns `409`; an unknown factor returns `404` on timeline lookup; another +generated clinician cannot unlock that record (`403`). + +Supported resources are AllergyIntolerance, Condition, Immunization, +MedicationStatement and Observation. Validation is the existing POC JSON +validation, **not full FHIR profile validation**. Maximum resource size is +256 KiB; maximum request body is 512 KiB. The spec also documents the existing +generic `500` behavior for malformed credentials, too-short factors and +unmapped service errors; adding docs does not change API error handling. + +## Data and browser boundaries + +**Synthetic data only. This is research software, not production or clinical +software.** Never enter real patient information, biometrics or credentials. +The default local store writes encrypted demo objects into `.data/web`. +Try it out is a real API call, not a simulation, and enrollment/append persist +data. Use a disposable local checkout/store for demonstrations. + +Private demo PEMs, factors and decrypted timeline responses are visible in +the workbench and browser developer tools. Do not share screenshots, copied +requests, console output or HAR exports. Reload when finished; neither reload +nor closing a tab guarantees secure memory erasure. Losing the generated +credential/factor makes the demo record inaccessible. + +Swagger assets are served from the pinned local npm package, not a CDN. +External validation, query-string configuration overrides and authorization +persistence are disabled. The initializer refuses off-origin API calls and +the page's CSP restricts network connections to this server. Inline **styles** +are permitted only on the documentation HTML for Swagger's components; the +main UI policy and script restrictions stay unchanged. Installation analytics +are opted out through `scarfSettings.enabled=false` in `package.json`. + +Swagger UI is a browser dependency for `/docs` only; the existing main UI +remains dependency-free. Do not upload this spec or requests to a public +Swagger editor to test local data. + +## Maintenance + +Update the spec and tests together when routes or schemas change: + +```sh +npm test -- test/swagger.spec.ts test/web-server.spec.ts +npm run typecheck +npm run build +``` + +The suite renders the installed Swagger bundle, executes the status operation, +checks same-origin configuration and local references, and runs the documented +enroll/append/timeline examples against a temporary in-memory server. diff --git a/package-lock.json b/package-lock.json index e5bb847..e31644e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "global-private-health-records-poc", "version": "0.1.0", "license": "GPL-3.0-only", + "dependencies": { + "swagger-ui-dist": "5.32.15" + }, "devDependencies": { "@types/node": "^24.3.1", "@vitest/coverage-v8": "^3.2.4", @@ -1023,6 +1026,13 @@ "win32" ] }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha1-O7uYQIXb1tmCSUU4tSO+HOZWKXI=", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/@types/chai/-/chai-5.2.3.tgz", @@ -2182,6 +2192,15 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.32.15", + "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/swagger-ui-dist/-/swagger-ui-dist-5.32.15.tgz", + "integrity": "sha1-C7qmJpUQTb8tsGrZu7qqEn2DBhQ=", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/test-exclude": { "version": "7.0.2", "resolved": "https://ms-feed-25.pkgs.visualstudio.com/1es-public/_packaging/npm-public/npm/registry/test-exclude/-/test-exclude-7.0.2.tgz", diff --git a/package.json b/package.json index e6863c4..7a69751 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,9 @@ "type": "module", "description": "Encrypted, dual-unlock FHIR provenance proof of concept", "license": "GPL-3.0-only", + "scarfSettings": { + "enabled": false + }, "engines": { "node": ">=22" }, @@ -25,5 +28,8 @@ "tsx": "^4.20.5", "typescript": "^5.9.2", "vitest": "^3.2.4" + }, + "dependencies": { + "swagger-ui-dist": "5.32.15" } } diff --git a/src/web/server.ts b/src/web/server.ts index 180e841..d2c6d1f 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1,7 +1,8 @@ import { createReadStream } from "node:fs"; import { stat } from "node:fs/promises"; import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; -import { extname, normalize, resolve, sep } from "node:path"; +import { createRequire } from "node:module"; +import { dirname, extname, normalize, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { @@ -21,6 +22,17 @@ import { const MAX_REQUEST_BYTES = 512 * 1024; const DEFAULT_PORT = 3000; const DEFAULT_HOST = "127.0.0.1"; +const SWAGGER_ROOT = dirname(createRequire(import.meta.url).resolve("swagger-ui-dist/package.json")); +const SWAGGER_ASSETS = new Map([ + ["/docs/swagger-ui.css", "/swagger-ui.css"], + ["/docs/swagger-ui-bundle.js", "/swagger-ui-bundle.js"], +]); +const DOC_ASSETS = new Map([ + ["/docs", "/api-docs/index.html"], + ["/docs/", "/api-docs/index.html"], + ["/docs/swagger-initializer.js", "/api-docs/swagger-initializer.js"], + ["/docs/docs.css", "/api-docs/docs.css"], +]); interface WebServerOptions { store?: BlobStore; @@ -46,6 +58,23 @@ export function createHealthRecordServer(options: WebServerOptions = {}) { return createServer(async (request, response) => { try { const url = new URL(request.url ?? "/", "http://localhost"); + if (url.pathname === "/docs" || url.pathname.startsWith("/docs/")) { + if (request.method !== "GET" && request.method !== "HEAD") { + response.setHeader("Allow", "GET, HEAD"); + sendJson(response, 405, { error: "Documentation is read-only." }); + return; + } + const swaggerAsset = SWAGGER_ASSETS.get(url.pathname); + const docAsset = DOC_ASSETS.get(url.pathname); + if (swaggerAsset) { + await serveStaticFile(response, SWAGGER_ROOT, swaggerAsset); + } else if (docAsset) { + await serveStaticFile(response, webRoot, docAsset); + } else { + sendJson(response, 404, { error: "File not found." }); + } + return; + } if (url.pathname.startsWith("/api/")) { await handleApiRequest(request, response, url.pathname, records); return; @@ -188,14 +217,18 @@ async function serveStaticFile( "Content-Type": contentTypeFor(absolutePath), "X-Content-Type-Options": "nosniff", "X-Frame-Options": "DENY", + // Swagger's components set inline styles; this exception never applies to the main UI. "Content-Security-Policy": - "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", + `default-src 'self'; script-src 'self'; style-src 'self'${requestPath === "/api-docs/index.html" ? " 'unsafe-inline'" : ""}; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'`, + "Referrer-Policy": "no-referrer", }); createReadStream(absolutePath).pipe(response); } function contentTypeFor(path: string): string { switch (extname(path)) { + case ".json": + return "application/json; charset=utf-8"; case ".css": return "text/css; charset=utf-8"; case ".js": diff --git a/test/swagger.spec.ts b/test/swagger.spec.ts new file mode 100644 index 0000000..b7f91f5 --- /dev/null +++ b/test/swagger.spec.ts @@ -0,0 +1,237 @@ +import { randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { createRequire } from "node:module"; +import { resolve } from "node:path"; +import { Window } from "happy-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { InMemoryBlobStore, SUPPORTED_FHIR_RESOURCE_TYPES } from "../src/index.js"; +import { createHealthRecordServer } from "../src/web/server.js"; + +interface SwaggerRequest { + url: string; + credentials?: string; +} + +interface SwaggerOptions { + url: string; + validatorUrl: null; + queryConfigEnabled: boolean; + persistAuthorization: boolean; + supportedSubmitMethods: string[]; + requestInterceptor(request: SwaggerRequest): SwaggerRequest; +} + +describe("local Swagger workbench", () => { + let server: ReturnType; + let origin: string; + const windows: Window[] = []; + + beforeEach(async () => { + server = createHealthRecordServer({ + store: new InMemoryBlobStore(), + webRoot: resolve("web"), + }); + await new Promise((done) => server.listen(0, "127.0.0.1", done)); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("Expected local TCP server."); + origin = `http://127.0.0.1:${address.port}`; + }); + + afterEach(async () => { + for (const window of windows.splice(0)) await window.happyDOM.close(); + await new Promise((done, reject) => { + server.close((error) => error ? reject(error) : done()); + }); + }); + + it("serves both docs URLs with self-hosted assets and scoped browser policy", async () => { + for (const path of ["/docs", "/docs/"]) { + const response = await fetch(`${origin}${path}`); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(response.headers.get("referrer-policy")).toBe("no-referrer"); + const policy = response.headers.get("content-security-policy"); + expect(policy).toContain("script-src 'self';"); + expect(policy).toContain("connect-src 'self';"); + expect(policy).toContain("style-src 'self' 'unsafe-inline';"); + expect(policy).not.toContain("unsafe-eval"); + const html = await response.text(); + expect(html).toContain("Synthetic data only"); + expect(html).toContain("/docs/swagger-ui-bundle.js"); + expect(html).not.toMatch(/<(?:script|link|iframe)[^>]+(?:src|href)="https?:/); + } + const main = await fetch(origin); + expect(main.headers.get("content-security-policy")).not.toContain("unsafe-inline"); + for (const [path, type] of [ + ["/docs/swagger-ui-bundle.js", "text/javascript"], + ["/docs/swagger-ui.css", "text/css"], + ["/docs/swagger-initializer.js", "text/javascript"], + ["/docs/docs.css", "text/css"], + ]) { + const response = await fetch(`${origin}${path}`); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain(type); + expect((await response.text()).length).toBeGreaterThan(0); + } + }); + + it("does not expose arbitrary package files or accept documentation writes", async () => { + for (const path of ["/docs/package.json", "/docs/oauth2-redirect.html", "/docs/swagger-ui.js"]) { + expect((await fetch(`${origin}${path}`)).status).toBe(404); + } + const response = await fetch(`${origin}/docs`, { method: "POST", body: "{}" }); + expect(response.status).toBe(405); + expect(response.headers.get("allow")).toBe("GET, HEAD"); + }); + + it("serves a same-origin OpenAPI document with complete operations and local references", async () => { + const response = await fetch(`${origin}/openapi.json`); + expect(response.headers.get("content-type")).toContain("application/json"); + expect(response.headers.get("cache-control")).toBe("no-store"); + const spec = await response.json(); + expect(spec.openapi).toBe("3.0.3"); + expect(spec.servers).toEqual([{ url: "/", description: expect.any(String) }]); + expect(new URL(spec.servers[0].url, `${origin}/openapi.json`).origin).toBe(origin); + expect(Object.keys(spec.paths).sort()).toEqual([ + "/api/clinicians", "/api/enroll", "/api/records", "/api/status", "/api/timeline", + ]); + const operations = Object.values(spec.paths).flatMap((path) => Object.values(path as object)); + expect(operations).toHaveLength(5); + expect(spec.components.schemas.ResourceType.enum).toEqual([...SUPPORTED_FHIR_RESOURCE_TYPES]); + expect(spec.components.schemas.ClinicianCredential.required).toEqual([ + "credentialId", "publicKeyPem", "privateKeyPem", + ]); + expect(spec.components.schemas.AccessRequest.properties.biometricToken.minLength).toBe(16); + expect(spec.paths["/api/clinicians"].post.requestBody).toBeUndefined(); + + function checkReferences(value: unknown): void { + if (!value || typeof value !== "object") return; + if ("$ref" in value) { + expect(typeof value.$ref).toBe("string"); + const ref = String(value.$ref); + expect(ref).toMatch(/^#\/components\//); + let target: unknown = spec; + for (const part of ref.slice(2).split("/")) { + expect(target).toHaveProperty(part); + if (!target || typeof target !== "object" || !(part in target)) { + throw new Error("Unresolved local OpenAPI reference."); + } + target = Reflect.get(target, part); + } + } + for (const child of Object.values(value)) checkReferences(child); + } + checkReferences(spec); + const serialized = JSON.stringify(spec); + expect(serialized).not.toMatch(/BEGIN (?:RSA )?PRIVATE KEY/); + expect(spec.components.securitySchemes).toBeUndefined(); + }); + + it("disables external validation/config overrides and intercepts off-origin calls", async () => { + const window = new Window({ url: `${origin}/docs?url=https://example.test/spec.json` }); + windows.push(window); + const bundle = Object.assign(vi.fn<(options: SwaggerOptions) => void>(), { presets: { apis: {} } }); + Object.assign(window, { SwaggerUIBundle: bundle }); + window.eval(await readFile(resolve("web/api-docs/swagger-initializer.js"), "utf8")); + const options = bundle.mock.calls[0]?.[0]; + expect(options).toBeDefined(); + expect(options).toMatchObject({ + url: "/openapi.json", validatorUrl: null, queryConfigEnabled: false, + persistAuthorization: false, supportedSubmitMethods: ["get", "post"], + }); + expect(options!.requestInterceptor({ url: `${origin}/api/status` }).credentials).toBe("same-origin"); + for (const url of [ + "https://example.test/api/enroll", "//example.test/api/timeline", + "http://127.0.0.1:1/api/status", "data:text/plain,not-an-api", + `${origin.replace("://", "://username@")}/api/status`, + ]) { + expect(() => options!.requestInterceptor({ url })).toThrow("own local server"); + } + expect(window.localStorage.length).toBe(0); + expect(window.sessionStorage.length).toBe(0); + }); + + it("renders the real Swagger bundle and executes status through Try it out", async () => { + const window = new Window({ url: `${origin}/docs` }); + windows.push(window); + window.document.body.innerHTML = '
'; + const requested: string[] = []; + window.fetch = async (input, init) => { + const url = new URL( + typeof input === "string" ? input : "url" in input ? input.url : input.href, + origin, + ); + expect(url.origin).toBe(origin); + requested.push(url.pathname); + const response = await fetch(url, { + method: init?.method ?? "GET", + headers: { "Content-Type": "application/json", Origin: origin }, + }); + return new window.Response(await response.text(), { + status: response.status, + // Happy DOM preserves header casing; native fetch iterates lowercase names. + headers: { "content-type": response.headers.get("content-type") ?? "application/json" }, + }); + }; + const bundle = createRequire(import.meta.url).resolve("swagger-ui-dist/swagger-ui-bundle.js"); + window.eval(await readFile(bundle, "utf8")); + window.eval(await readFile(resolve("web/api-docs/swagger-initializer.js"), "utf8")); + await vi.waitFor(() => { + expect(window.document.querySelectorAll(".opblock")).toHaveLength(5); + }, { timeout: 10_000 }); + const status = window.document.querySelector("#operations-Status-getLocalStatus"); + expect(status).not.toBeNull(); + status!.querySelector("button.opblock-summary-control")?.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + await vi.waitFor(() => expect(status!.querySelector(".try-out__btn")).not.toBeNull()); + status!.querySelector(".try-out__btn")!.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + await vi.waitFor(() => expect(status!.querySelector("button.execute")).not.toBeNull()); + status!.querySelector("button.execute")!.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + await vi.waitFor(() => expect(requested).toEqual(["/openapi.json", "/api/status"])); + await vi.waitFor(() => expect(status!.querySelector(".live-responses-table")?.textContent) + .toContain("synthetic-browser-token")); + expect(window.localStorage.length).toBe(0); + }, 15_000); + + it("runs the documented examples against the local API without cloud accounts", async () => { + const spec = await (await fetch(`${origin}/openapi.json`)).json(); + const post = (path: string, body?: unknown) => fetch(new URL(path, origin), { + method: "POST", + headers: { "Content-Type": "application/json", Origin: origin }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + const clinicianResponse = await post("/api/clinicians"); + expect(clinicianResponse.status).toBe(201); + const clinician = await clinicianResponse.json(); + const access = { + ...spec.components.requestBodies.Access.content["application/json"].example, + biometricToken: randomBytes(32).toString("base64url"), + clinician, + }; + const enrolled = await post("/api/enroll", access); + expect(enrolled.status).toBe(201); + expect(await enrolled.json()).toEqual({ enrolled: true }); + expect((await post("/api/enroll", access)).status).toBe(409); + const appended = await post("/api/records", { + ...spec.paths["/api/records"].post.requestBody.content["application/json"].example, + ...access, + }); + expect(appended.status).toBe(201); + const identifiers = await appended.json(); + expect(Object.keys(identifiers).sort()).toEqual(["eventId", "fragmentId"]); + const timeline = await post("/api/timeline", access); + expect(timeline.status).toBe(200); + const result = await timeline.json(); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].event.eventId).toBe(identifiers.eventId); + expect(result.entries[0].resource.id).toBe("synthetic-swagger-condition"); + expect((await post("/api/enroll", {})).status).toBe(400); + expect((await post("/api/records", { ...access, resource: { resourceType: "Patient" } })).status).toBe(400); + expect((await post("/api/timeline", { ...access, biometricToken: randomBytes(32).toString("hex") })).status).toBe(404); + const other = await (await post("/api/clinicians")).json(); + expect((await post("/api/timeline", { ...access, clinician: other })).status).toBe(403); + const rejected = await fetch(`${origin}/api/clinicians`, { + method: "POST", headers: { Origin: "https://example.test" }, + }); + expect(rejected.status).toBe(403); + }); +}); diff --git a/web/api-docs/docs.css b/web/api-docs/docs.css new file mode 100644 index 0000000..69506c4 --- /dev/null +++ b/web/api-docs/docs.css @@ -0,0 +1,23 @@ +body { + margin: 0; + background: #fafafa; +} + +.local-api-notice { + max-width: 1400px; + margin: 16px auto; + padding: 16px 24px; + border-left: 4px solid #956000; + background: #fff5d6; + color: #242424; + font: 16px/1.5 system-ui, sans-serif; +} + +.local-api-notice h1 { + margin: 0; + font-size: 1.5rem; +} + +.local-api-notice a { + color: #18467c; +} diff --git a/web/api-docs/index.html b/web/api-docs/index.html new file mode 100644 index 0000000..27a31a0 --- /dev/null +++ b/web/api-docs/index.html @@ -0,0 +1,28 @@ + + + + + + Global Patient Record Project - Local API + + + + + + +
+

Local API workbench

+

Synthetic data only. This research POC is not a production or clinical system. + Requests, generated demo private keys and decrypted responses are visible in this browser. + Never use real patient data, biometrics or real credentials.

+

Try it out calls this server and can write encrypted demo records to its configured store. + Generate a clinician, copy its complete JSON into the access examples, and use the same + synthetic factor for enrollment, append and timeline. Change the factor for each new record.

+

Nothing in this workbench saves credentials to browser storage. Reload when finished; + this is not a guarantee of secure memory erasure. Do not share screenshots or HAR exports.

+ +
+
+ + + diff --git a/web/api-docs/swagger-initializer.js b/web/api-docs/swagger-initializer.js new file mode 100644 index 0000000..f646438 --- /dev/null +++ b/web/api-docs/swagger-initializer.js @@ -0,0 +1,24 @@ +(() => { + const origin = window.location.origin; + window.SwaggerUIBundle({ + url: "/openapi.json", + dom_id: "#swagger-ui", + layout: "BaseLayout", + presets: [window.SwaggerUIBundle.presets.apis], + validatorUrl: null, + queryConfigEnabled: false, + persistAuthorization: false, + withCredentials: false, + supportedSubmitMethods: ["get", "post"], + displayRequestDuration: true, + defaultModelsExpandDepth: -1, + requestInterceptor(request) { + const url = new URL(request.url, origin); + if (url.origin !== origin || url.username || url.password) { + throw new Error("The API workbench only calls its own local server."); + } + request.credentials = "same-origin"; + return request; + }, + }); +})(); diff --git a/web/openapi.json b/web/openapi.json new file mode 100644 index 0000000..527e6ce --- /dev/null +++ b/web/openapi.json @@ -0,0 +1,287 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Global Patient Record Project - Local POC API", + "version": "0.1.0", + "description": "Synthetic-only research API. Not clinically or production ready. Use the locally hosted Swagger UI at /docs. Generate a demo clinician first, copy its complete JSON (including both PEM strings) into each access request, then enroll, append, and assemble a timeline with the same synthetic factor. Change the factor for each new record. Request bodies and decrypted responses are visible in the browser; never use real patient data or real credentials. There is no bearer-token authentication: record access requires both factors in the request body. This document describes the existing POC, not full FHIR conformance." + }, + "servers": [ + { "url": "/", "description": "This local server, including its current port" } + ], + "tags": [ + { "name": "Status", "description": "Local server readiness" }, + { "name": "Demo credentials", "description": "Generate ephemeral synthetic-workflow credentials" }, + { "name": "Records", "description": "Dual-unlock encrypted record operations" } + ], + "paths": { + "/api/status": { + "get": { + "operationId": "getLocalStatus", + "tags": ["Status"], + "summary": "Check the local server", + "responses": { + "200": { + "description": "Local POC status", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Status" } } } + }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "500": { "$ref": "#/components/responses/ServerError" } + } + } + }, + "/api/clinicians": { + "post": { + "operationId": "createDemoClinician", + "tags": ["Demo credentials"], + "summary": "Generate a fresh demo clinician credential", + "description": "No request body is required. Copy the entire JSON response into the clinician field of subsequent requests. Keep this same credential for that record. This returns a private key for the synthetic local demo, not a production credential-enrollment mechanism. Do not save the response in source control or share it.", + "responses": { + "201": { + "description": "Fresh demo RSA credential; response includes a private key", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ClinicianCredential" } } } + }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "500": { "$ref": "#/components/responses/ServerError" } + } + } + }, + "/api/enroll": { + "post": { + "operationId": "enrollRecord", + "tags": ["Records"], + "summary": "Enroll a new encrypted demo record", + "description": "Use a fresh synthetic factor and the complete clinician response. Reusing an enrolled factor returns 409. The store receives encrypted material, not the supplied private key or plaintext factor.", + "requestBody": { "$ref": "#/components/requestBodies/Access" }, + "responses": { + "201": { + "description": "Record enrolled", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["enrolled"], + "properties": { "enrolled": { "type": "boolean", "enum": [true] } } + } + } + } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "413": { "$ref": "#/components/responses/TooLarge" }, + "500": { "$ref": "#/components/responses/ServerError" } + } + } + }, + "/api/records": { + "post": { + "operationId": "appendRecord", + "tags": ["Records"], + "summary": "Append an encrypted FHIR fragment and provenance event", + "description": "Enroll first. Supply the same factor and clinician. Supports only the listed resource types with POC JSON validation, not full FHIR profile validation. Resource JSON is limited to 262144 UTF-8 bytes; the entire request is limited to 524288 bytes.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AppendRequest" }, + "example": { + "biometricToken": "synthetic-swagger-factor-change-for-each-record-0001", + "clinician": { + "credentialId": "COPY_FROM_CLINICIAN_RESPONSE", + "publicKeyPem": "COPY_FROM_CLINICIAN_RESPONSE", + "privateKeyPem": "COPY_FROM_CLINICIAN_RESPONSE" + }, + "resource": { + "resourceType": "Condition", + "id": "synthetic-swagger-condition", + "code": { "text": "SYNTHETIC SWAGGER EXAMPLE ONLY" } + } + } + } + } + }, + "responses": { + "201": { + "description": "Opaque identifiers for the newly appended event and fragment", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AppendResult" } } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "413": { "$ref": "#/components/responses/TooLarge" }, + "500": { "$ref": "#/components/responses/ServerError" } + } + } + }, + "/api/timeline": { + "post": { + "operationId": "assembleTimeline", + "tags": ["Records"], + "summary": "Unlock and verify a record's complete timeline", + "description": "Read-only record operation expressed as POST so factors never appear in URL parameters. Verifies parent links, signatures, authorization MACs and content hashes before returning decrypted synthetic resources. The plaintext response is visible in Swagger UI. An enrolled record with no entries returns an empty array.", + "requestBody": { "$ref": "#/components/requestBodies/Access" }, + "responses": { + "200": { + "description": "Verified, ordered timeline", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Timeline" } } } + }, + "400": { "$ref": "#/components/responses/BadRequest" }, + "403": { "$ref": "#/components/responses/Forbidden" }, + "404": { "$ref": "#/components/responses/NotFound" }, + "409": { "$ref": "#/components/responses/Conflict" }, + "413": { "$ref": "#/components/responses/TooLarge" }, + "500": { "$ref": "#/components/responses/ServerError" } + } + } + } + }, + "components": { + "requestBodies": { + "Access": { + "required": true, + "description": "Use the complete clinician JSON from this local server and a synthetic factor of at least 16 characters. Replace every COPY_FROM_CLINICIAN_RESPONSE placeholder. Total body limit: 524288 bytes.", + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/AccessRequest" }, + "example": { + "biometricToken": "synthetic-swagger-factor-change-for-each-record-0001", + "clinician": { + "credentialId": "COPY_FROM_CLINICIAN_RESPONSE", + "publicKeyPem": "COPY_FROM_CLINICIAN_RESPONSE", + "privateKeyPem": "COPY_FROM_CLINICIAN_RESPONSE" + } + } + } + } + } + }, + "schemas": { + "Status": { + "type": "object", + "required": ["status", "mode", "biometricMode"], + "properties": { + "status": { "type": "string", "enum": ["ok"] }, + "mode": { "type": "string", "enum": ["local-poc"] }, + "biometricMode": { "type": "string", "enum": ["synthetic-browser-token"] } + } + }, + "ClinicianCredential": { + "type": "object", + "required": ["credentialId", "publicKeyPem", "privateKeyPem"], + "description": "Generated demo key pair and its derived identifier. Copy all three fields unchanged; placeholders are not usable keys.", + "properties": { + "credentialId": { "type": "string", "description": "Identifier bound to this public key" }, + "publicKeyPem": { "type": "string", "description": "Full generated public PEM, preserving JSON newline escapes" }, + "privateKeyPem": { "type": "string", "description": "Full generated demo private PEM. Sensitive even in a demo; do not persist or share." } + } + }, + "AccessRequest": { + "type": "object", + "required": ["biometricToken", "clinician"], + "properties": { + "biometricToken": { + "type": "string", + "minLength": 16, + "description": "Synthetic demo factor, never a real biometric. Use the same value throughout a record's flow." + }, + "clinician": { "$ref": "#/components/schemas/ClinicianCredential" } + } + }, + "AppendRequest": { + "allOf": [ + { "$ref": "#/components/schemas/AccessRequest" }, + { + "type": "object", + "required": ["resource"], + "properties": { "resource": { "$ref": "#/components/schemas/FhirResource" } } + } + ] + }, + "FhirResource": { + "type": "object", + "description": "Synthetic JSON resource with a supported type. Other JSON fields are permitted. This is not a full FHIR schema. Maximum serialized size: 262144 UTF-8 bytes.", + "required": ["resourceType"], + "properties": { + "resourceType": { "$ref": "#/components/schemas/ResourceType" }, + "id": { "type": "string", "minLength": 1 } + }, + "additionalProperties": true + }, + "ResourceType": { + "type": "string", + "enum": ["AllergyIntolerance", "Condition", "Immunization", "MedicationStatement", "Observation"] + }, + "AppendResult": { + "type": "object", + "required": ["eventId", "fragmentId"], + "properties": { + "eventId": { "type": "string" }, + "fragmentId": { "type": "string" } + } + }, + "ProvenanceEvent": { + "type": "object", + "required": ["schemaVersion", "eventId", "parents", "fragmentId", "resourceType", "contentHash", "recordedAt", "clinicianId"], + "properties": { + "schemaVersion": { "type": "integer", "enum": [1] }, + "eventId": { "type": "string" }, + "parents": { "type": "array", "items": { "type": "string" } }, + "fragmentId": { "type": "string" }, + "resourceType": { "$ref": "#/components/schemas/ResourceType" }, + "contentHash": { "type": "string" }, + "recordedAt": { "type": "string", "format": "date-time" }, + "clinicianId": { "type": "string" } + } + }, + "Timeline": { + "type": "object", + "required": ["entries"], + "properties": { + "entries": { + "type": "array", + "items": { + "type": "object", + "required": ["event", "resource"], + "properties": { + "event": { "$ref": "#/components/schemas/ProvenanceEvent" }, + "resource": { "$ref": "#/components/schemas/FhirResource" } + } + } + } + } + }, + "Error": { + "type": "object", + "required": ["error"], + "properties": { "error": { "type": "string" } } + } + }, + "responses": { + "BadRequest": { + "description": "Invalid JSON, missing access fields, or unsupported/malformed FHIR JSON.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "Forbidden": { + "description": "Browser origin rejected, or the provided clinician/factor cannot unlock the record.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "NotFound": { + "description": "No record exists for the supplied factor.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "Conflict": { + "description": "Record already enrolled or stored history failed integrity verification, depending on the operation.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "TooLarge": { + "description": "Request body exceeds 524288 bytes.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + }, + "ServerError": { + "description": "Local POC failure. This existing API also maps invalid PEM credentials, too-short factors and unmapped concurrent writes to 500. This spec does not change runtime validation/error behavior.", + "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } } + } + } + } +} From a66114e156e95f18bc901e91c6607b2565042b24 Mon Sep 17 00:00:00 2001 From: Karthik Chandrasekaran Date: Sat, 19 Sep 2026 21:19:19 -0500 Subject: [PATCH 2/2] Require committed bootstrap pins before adding Cluster peers Check allocator metrics and a bounded synthetic consensus write before each subsequent join, without retrying application writes or reducing their replication policy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fc4430e2-35f5-48b8-bd0f-5dc6fad2a0ff --- scripts/run-ipfs-integration.ts | 40 ++++++++++++++++++++++++--------- test/fixtures/ipfs/README.md | 8 +++++-- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/scripts/run-ipfs-integration.ts b/scripts/run-ipfs-integration.ts index 38df19e..8b2f7a7 100644 --- a/scripts/run-ipfs-integration.ts +++ b/scripts/run-ipfs-integration.ts @@ -4,6 +4,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { setTimeout as delay } from "node:timers/promises"; +import { IpfsClusterClient } from "../src/index.js"; import { CLUSTER_IMAGE, KUBO_IMAGE, SECRET_INIT_SCRIPT, containerArguments, isolatedIntegrationEnvironment, secretInput, } from "../test/fixtures/ipfs/fixture.js"; @@ -88,6 +89,30 @@ async function waitForMembers(cluster: string, expected: number): Promise }); } +async function waitForMetrics(cluster: string, expected: number): Promise { + stage = `waiting for ${expected} valid Cluster freespace metrics`; + let previous = ""; + await waitUntil(async () => { + const metrics = await (await request(`${cluster}/monitor/metrics/freespace`)).json() as { valid?: boolean }[]; + const valid = metrics.filter((metric) => metric.valid === true).length; + const summary = `Cluster freespace metrics observed: ${metrics.length}; valid: ${valid}.`; + if (summary !== previous) console.log(summary); + previous = summary; + return metrics.length === expected && valid === expected; + }); +} + +async function confirmBootstrapWrites(kubo: string, cluster: string, replicas: number): Promise { + stage = `confirming consensus writes across ${replicas} bootstrap peers`; + // Membership can be visible before Raft and allocation are ready for another join. + const client = new IpfsClusterClient({ + kuboUrl: kubo, clusterUrl: cluster, allowInsecureLocal: true, + minReplicas: replicas, maxReplicas: replicas, timeoutMs: 30_000, + }); + await client.put(randomBytes(64)); + console.log(`Consensus write and ${replicas} concrete bootstrap pin(s) confirmed.`); +} + async function node(kind: "kubo" | "cluster", index: number, bootstrap?: string): Promise { if (interrupted) throw new Error("Fixture interrupted."); const name = `${prefix}-${kind}${index}`; @@ -247,21 +272,16 @@ async function main(): Promise { const identity = await (await request(`${cluster}/id`)).json() as { id: string }; const bootstrap = `/dns4/cluster1/tcp/9096/p2p/${identity.id}`; await waitForMembers(cluster, 1); + await waitForMetrics(cluster, 1); + await confirmBootstrapWrites(kubo[0]!, cluster, 1); await node("cluster", 2, bootstrap); // An HTTP listener is ready before its Raft membership change has committed. await waitForMembers(cluster, 2); + await waitForMetrics(cluster, 2); + await confirmBootstrapWrites(kubo[0]!, cluster, 2); await node("cluster", 3, bootstrap); await waitForMembers(cluster, 3); - stage = "waiting for three valid Cluster freespace metrics"; - let metricSummary = ""; - await waitUntil(async () => { - const metrics = await (await request(`${cluster}/monitor/metrics/freespace`)).json() as { valid?: boolean }[]; - const valid = metrics.filter((metric) => metric.valid === true).length; - const summary = `Cluster freespace metrics observed: ${metrics.length}; valid: ${valid}.`; - if (summary !== metricSummary) console.log(summary); - metricSummary = summary; - return metrics.length === 3 && valid === 3; - }); + await waitForMetrics(cluster, 3); stage = "running application integration tests"; console.log("Three private Kubo/Cluster peers ready; running application integration tests."); await runSuite(kubo[0]!, cluster); diff --git a/test/fixtures/ipfs/README.md b/test/fixtures/ipfs/README.md index 3f78d03..b7ac930 100644 --- a/test/fixtures/ipfs/README.md +++ b/test/fixtures/ipfs/README.md @@ -71,8 +71,12 @@ The isolated file backend holds index pointers and CID-addressed synthetic backups. The suite includes explicit backup restoration with an unchanged metadata version. This is not an S3 or AWS integration result. -Readiness requires each Raft member to finish joining before starting the next, -then all three allocation metrics. After +Before starting each additional peer, readiness requires membership, allocation +metrics, and a successful consensus write with concrete pins on all existing +peers. The bootstrap probes use only random synthetic bytes, with one copy +allowed solely while the fixture has one peer; application tests still require +at least two copies. Neither probe writes nor application writes are retried. +After all three peers and allocation metrics are ready and the application suite succeeds, the runner adds a separate random opaque 1024-byte synthetic ciphertext marker to Kubo 1, requests Cluster replication with minimum 2 / maximum 3, and waits for recursive pins on **all three**