Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,15 @@ DynamoDB metadata, and SSE-KMS encrypted S3 backups.
| `npm run test:integration` | Explicit opt-in external-provider contract and encrypted-flow tests |
| `npm run storage:restore -- OBJECT_KEY` | Explicitly restore verified encrypted IPFS content from its configured backup |

## 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
Expand Down
90 changes: 90 additions & 0 deletions docs/LOCAL_API.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# 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.
Storage compare-and-swap conflicts return `409`; unlock and reconcile the
record before retrying rather than blindly repeating an append.

## 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.
19 changes: 18 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -39,6 +42,7 @@
"@azure/identity": "^4.13.3",
"@azure/keyvault-secrets": "^4.11.2",
"@azure/storage-blob": "^12.33.0",
"multiformats": "^14.0.5"
"multiformats": "^14.0.5",
"swagger-ui-dist": "5.32.15"
}
}
40 changes: 30 additions & 10 deletions scripts/run-ipfs-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -88,6 +89,30 @@ async function waitForMembers(cluster: string, expected: number): Promise<void>
});
}

async function waitForMetrics(cluster: string, expected: number): Promise<void> {
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<void> {
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<string> {
if (interrupted) throw new Error("Fixture interrupted.");
const name = `${prefix}-${kind}${index}`;
Expand Down Expand Up @@ -247,21 +272,16 @@ async function main(): Promise<void> {
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);
Expand Down
37 changes: 35 additions & 2 deletions src/web/server.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -24,6 +25,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;
Expand All @@ -49,6 +61,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;
Expand Down Expand Up @@ -191,14 +220,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":
Expand Down
8 changes: 6 additions & 2 deletions test/fixtures/ipfs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**
Expand Down
Loading
Loading