From 177ea17e8148cc5391395bc79d2021bd7da7057a Mon Sep 17 00:00:00 2001 From: spicyfalafel <58147555+spicyfalafel@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:00:13 +0200 Subject: [PATCH 01/13] aidbox-health-card-issue: spec-conformant SMART Health Cards + QR/file/viewer Make issued cards pass strict SMART Health Cards verification and add the missing delivery surfaces (QR, .smart-health-card file) plus an in-browser verifier. Conformance fixes: - Actually DEFLATE the JWS payload before signing (SignJWT declared zip:DEF but never compressed); now deflateRaw + CompactSign. - kid = base64url SHA-256 JWK thumbprint (RFC 7638), read from the JWK so the signing kid always matches the published JWKS. - Minify bundle refs to short resource:N URIs (fullUrl + Reference.reference). - iss now matches the JWKS location; JWKS op made public (allow policy) + CORS. - includeIdentityClaim treated as repeating string claim paths (not boolean). Features: - Accept both #covid19 (VCI) and generic Immunization/Observation credentialType (uri/dateTime datatypes). - Delivery: shc:/ QR (numeric-mode encoder), .smart-health-card download, /demo/issue + /demo/patients, and an Issue/Verify viewer (WebCrypto ES256). - README with sequence diagrams + ADR-005; retire key-utils. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aidbox-health-card-issue/.env.example | 4 +- .../aidbox-health-card-issue/README.md | 195 +++---- .../adr/005-delivery-and-conformance.md | 76 +++ .../docker-compose.yaml | 6 +- .../init-bundle/bundle.json | 5 +- .../scripts/generate-keys.ts | 28 +- .../src/handlers/health-cards.ts | 145 ++---- .../aidbox-health-card-issue/src/server.ts | 146 +++++- .../src/services/bundle-builder.ts | 133 +++-- .../src/services/fhir-client.ts | 42 ++ .../src/services/health-card.ts | 81 ++- .../src/types/config.ts | 28 +- .../src/types/health-card.ts | 4 +- .../src/types/operation.ts | 2 + .../src/utils/credential-utils.ts | 34 +- .../src/utils/crypto.ts | 61 ++- .../src/utils/key-utils.ts | 25 - .../src/utils/shc-encode.ts | 25 + .../src/utils/validation.ts | 88 ++-- .../aidbox-health-card-issue/src/viewer.html | 484 ++++++++++++++++++ 20 files changed, 1230 insertions(+), 382 deletions(-) create mode 100644 aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md delete mode 100644 aidbox-custom-operations/aidbox-health-card-issue/src/utils/key-utils.ts create mode 100644 aidbox-custom-operations/aidbox-health-card-issue/src/utils/shc-encode.ts create mode 100644 aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html diff --git a/aidbox-custom-operations/aidbox-health-card-issue/.env.example b/aidbox-custom-operations/aidbox-health-card-issue/.env.example index 900aa65b..c16d1585 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/.env.example +++ b/aidbox-custom-operations/aidbox-health-card-issue/.env.example @@ -4,7 +4,9 @@ AIDBOX_CLIENT_ID=health-cards-client AIDBOX_CLIENT_SECRET=secret # Health Cards Configuration -HEALTH_CARDS_ISSUER=https://example.org/health-cards +# iss must be the base that serves /.well-known/jwks.json (the namespaced +# Aidbox App op). Spec requires https + no trailing slash in production. +HEALTH_CARDS_ISSUER=http://localhost:8080/health-cards-app HEALTH_CARDS_KEY_PATH=./keys/private-key.pem # Server Configuration diff --git a/aidbox-custom-operations/aidbox-health-card-issue/README.md b/aidbox-custom-operations/aidbox-health-card-issue/README.md index 3b749575..9407ada0 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/README.md +++ b/aidbox-custom-operations/aidbox-health-card-issue/README.md @@ -1,116 +1,135 @@ --- -features: [SMART health cards, Custom operations, JWKS, JWS signing, FHIR operations] +features: [SMART Health Cards, $health-cards-issue, JWKS, JWS/ES256, QR (shc:/), file download, in-browser verifier, Aidbox Apps] languages: [TypeScript] +runtimes: [Node.js] --- -# SMART Health Cards Issue Operation +# SMART Health Cards: Issue, Deliver & Verify -A minimal TypeScript implementation of the FHIR `$health-cards-issue` operation that integrates with Aidbox to generate SMART Health Cards from patient health data. +A Node/Express implementation of the FHIR [`$health-cards-issue`](https://hl7.org/fhir/uv/smart-health-cards-and-links/STU1/OperationDefinition-patient-i-health-cards-issue.html) operation on [Aidbox](https://www.health-samurai.io/aidbox). It pulls patient data from Aidbox, minimizes it per the [SMART Health Cards spec](https://spec.smarthealth.cards/), and issues a **signed** verifiable credential (JWS/ES256) — delivered three ways: **FHIR API**, **QR (`shc:/`)**, and **`.smart-health-card` file** — plus an in-browser **verifier**. -## Overview - -This project implements the [SMART Health Cards specification](https://hl7.org/fhir/uv/smart-health-cards-and-links/STU1/OperationDefinition-patient-i-health-cards-issue.html) as a FHIR operation. It retrieves patient health data from Aidbox, sanitizes it according to SMART Health Cards requirements, and generates verifiable health cards in [JWS](https://datatracker.ietf.org/doc/html/rfc7515) format. +A SMART Health Card is a FHIR `Bundle` → W3C Verifiable Credential → **JWS (ES256)**, payload minified and **DEFLATE**-compressed (`zip:"DEF"`). It proves *authenticity* via the issuer's signature (public key at `/.well-known/jwks.json`) — not confidentiality. (Encrypted sharing is the sibling `smart-health-link` example.) ## Architecture ```mermaid flowchart LR - Client[Client] - Aidbox[Aidbox
FHIR Server] - App[Health Cards App
Node.js] + Client["Client"] + Aidbox["Aidbox
FHIR server + App routing"] + App["Health Cards App
Node/Express
signs JWS, serves JWKS + viewer"] + + Client -->|"POST $health-cards-issue"| Aidbox + Aidbox -->|"http-rpc: operation"| App + App -->|"fetch Patient + clinical data"| Aidbox + App -->|"verifiableCredential (JWS)"| Aidbox + Aidbox -->|"Parameters { verifiableCredential }"| Client + Client -.->|"GET /.well-known/jwks.json"| Aidbox + Aidbox -.->|"namespaced App op"| App +``` + +## Flow 1 — Issue (`$health-cards-issue`) + +```mermaid +sequenceDiagram + actor C as Client + participant A as Aidbox + participant S as Health Cards App + C->>A: POST /fhir/Patient/{id}/$health-cards-issue
Parameters { credentialType, _since? } + A->>S: http-rpc (operation: health-cards-issue) + S->>A: GET Patient/{id}, Immunization?/Observation? + A-->>S: FHIR resources + Note over S: minimize (strip id/meta/text/display,
fullUrl + refs → resource:N), DEFLATE, sign ES256 + S-->>A: Parameters { verifiableCredential: "" } + A-->>C: Parameters { verifiableCredential } +``` - Client -->|$health-cards-issue| Aidbox - Aidbox -->|$health-cards-issue| App - App -->|Fetch FHIR data| Aidbox - App -->|Signed Health Card| Aidbox - Aidbox -->|Signed Health Card| Client +## Flow 2 — Verify (JWKS) +Aidbox already owns its own `/.well-known/jwks.json` (RSA, for OAuth), so the SHC EC key is published under a **namespaced** path, and `iss` points at that base. - style Aidbox fill:#e1f5fe - style App fill:#f3e5f5 - style Client fill:#e8f5e8 +```mermaid +sequenceDiagram + actor V as Verifier + participant A as Aidbox + participant S as Health Cards App + Note over V: read iss + kid from the JWS + V->>A: GET /.well-known/jwks.json
(iss = http://localhost:8080/health-cards-app) + A->>S: http-rpc (operation: well-known-jwks, public) + S-->>A: { keys: [ EC/P-256 public JWK, kid ] } + A-->>V: JWKS (CORS) + Note over V: pick key by kid → verify ES256 → inflate DEFLATE ``` -**Components:** -- **FHIR Server**: Aidbox with [custom operation routing](https://www.health-samurai.io/docs/aidbox/developer-experience/aidbox-sdk/apps) -- **Application**: Node.js TypeScript backend service - actual implementation of `$health-cards-issue` operation -- **Output**: Cryptographically signed SMART Health Cards (JWS format) +## Flow 3 — Deliver & verify in the browser -## Quick Start +```mermaid +sequenceDiagram + actor U as User (browser) + participant S as Health Cards App + U->>S: POST /demo/issue { patientId, credentialType } + S-->>U: { verifiableCredential, qr: "shc:/…", downloadUrl } + U->>S: GET /.well-known/jwks.json (same-origin) + S-->>U: JWKS + Note over U: WebCrypto ES256 verify + deflate-raw inflate → render Bundle + U->>S: GET /download → application/smart-health-card +``` -### Prerequisites +## Endpoints -- Docker and Docker Compose -- Node.js 18+ (for development) +| Method | Path | Auth | Purpose | +|--------|------|------|---------| +| POST | `/fhir/Patient/{id}/$health-cards-issue` | required | Issue a card | +| GET | `/health-cards-app/.well-known/jwks.json` | public | Issuer JWKS (`= /.well-known/jwks.json`) | +| GET | `:3000/` | public | Viewer (Issue + Verify) | +| POST | `:3000/demo/issue` | demo | Issue → `{ jws, qr, downloadUrl }` | +| GET | `:3000/download` | demo | `.smart-health-card` file | +| GET | `:3000/.well-known/jwks.json` | public | Same JWKS, same-origin for the viewer | -### Running the Application +## Quick Start -1. **Create .env file** ```bash cp .env.example .env +npm install && npm run generate-keys # EC P-256; kid = JWK thumbprint +docker compose up --build ``` +Activate Aidbox at [localhost:8080](http://localhost:8080) (init bundle seeds `Patient/example-patient` + a COVID `Immunization` + `Observation`), then open the viewer at [localhost:3000](http://localhost:3000). -2. **Generate signing keys for JWS generation**: - ```bash - npm install - npm run generate-keys - ``` - - -3. **Run docker compose**: - ```bash - docker compose up --build - ``` - -4. **Initialize Aidbox instance** -Navigate to [Aidbox UI](http://localhost:8080) and [initialize](https://docs.aidbox.app/getting-started/run-aidbox-locally#id-4.-activate-your-aidbox-instance) the Aidbox instance. - -### Testing Health Cards generation - -1. **Run `$health-cards-issue` operation** - -Navigate to [Aidbox Rest Console](http://localhost:8080/ui/console#/rest) and execute the following request: - -```http -POST /fhir/Patient/example-patient/$health-cards-issue -Content-Type: application/fhir+json - -{ - "resourceType": "Parameters", - "parameter": [ - { - "name": "credentialType", - "valueString": "Immunization" - }, - { - "name": "credentialType", - "valueString": "Observation" - }, - { - "name": "_since", - "valueInstant": "2023-01-01T00:00:00Z" - }, - { - "name": "includeIdentityClaim", - "valueBoolean": false - } - ] -} -``` +## Testing + +- **Viewer** (`:3000`): **Issue** a card (`#covid19`, `Immunization`, or `Observation`) → get JWS, QR, and a `.smart-health-card` download, auto-verified. **Verify** any pasted JWS against the JWKS. +- **API**: + ```http + POST /fhir/Patient/example-patient/$health-cards-issue + Content-Type: application/fhir+json + + { "resourceType": "Parameters", + "parameter": [ { "name": "credentialType", "valueUri": "https://smarthealth.cards#covid19" } ] } + ``` + Also accepts `Immunization` / `Observation` (uri or string), `_since` (dateTime), `includeIdentityClaim` (string claim paths). +- **Spec validator**: paste the JWS at [demo-portals.smarthealth.cards](https://demo-portals.smarthealth.cards/). Header / `zip:DEF` / signature / `kid` / Bundle should be **valid**. Expected warnings (local-dev only): unknown issuer + http keys — paste `keys/public-key.jwk.json` to check the signature. + +## Conformance -Example response: -```json -{ - "resourceType": "Parameters", - "parameter": [ - { - "name": "verifiableCredential", - "valueString": "eyJhbGciOiJFUzI1NiIsInppcCI6IkRFRiIsImtpZCI6IjcxMTFkNDhkMzNhYmJmZTIifQ.eyJpc3MiOiJodHRwczovL2V4YW1wbGUub3JnL2hlYWx0aC1jYXJkcyIsIm5iZiI6MTc1NTY5OTM5MywidmMiOnsidHlwZSI6WyJodHRwczovL3NtYXJ0aGVhbHRoLmNhcmRzI2hlYWx0aC1jYXJkIiwiaHR0cHM6Ly9zbWFydGhlYWx0aC5jYXJkcyNpbW11bml6YXRpb24iXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiZmhpclZlcnNpb24iOiI0LjAuMSIsImZoaXJCdW5kbGUiOnsicmVzb3VyY2VUeXBlIjoiQnVuZGxlIiwidHlwZSI6ImNvbGxlY3Rpb24iLCJlbnRyeSI6W3sicmVzb3VyY2UiOnsicmVzb3VyY2VUeXBlIjoiUGF0aWVudCIsIm5hbWUiOlt7ImZhbWlseSI6IkRvZSIsImdpdmVuIjpbIkpvaG4iXX1dLCJiaXJ0aERhdGUiOiIxOTgwLTAxLTAxIn19LHsicmVzb3VyY2UiOnsicGF0aWVudCI6eyJyZWZlcmVuY2UiOiJQYXRpZW50L2V4YW1wbGUtcGF0aWVudCJ9LCJwcm90b2NvbEFwcGxpZWQiOlt7InNlcmllcyI6IkNPVklELTE5IFByaW1hcnkgU2VyaWVzIiwiZG9zZU51bWJlclBvc2l0aXZlSW50IjoxLCJzZXJpZXNEb3Nlc1Bvc2l0aXZlSW50IjoyfV0sInNpdGUiOnsiY29kaW5nIjpbeyJjb2RlIjoiTEEiLCJzeXN0ZW0iOiJodHRwOi8vdGVybWlub2xvZ3kuaGw3Lm9yZy9Db2RlU3lzdGVtL3YzLUFjdFNpdGUifV19LCJ2YWNjaW5lQ29kZSI6eyJjb2RpbmciOlt7ImNvZGUiOiIyMDgiLCJzeXN0ZW0iOiJodHRwOi8vaGw3Lm9yZy9maGlyL3NpZC9jdngifV19LCJkb3NlUXVhbnRpdHkiOnsiY29kZSI6Im1MIiwidW5pdCI6Im1MIiwidmFsdWUiOjAuMywic3lzdGVtIjoiaHR0cDovL3VuaXRzb2ZtZWFzdXJlLm9yZyJ9LCJyb3V0ZSI6eyJjb2RpbmciOlt7ImNvZGUiOiJJTSIsInN5c3RlbSI6Imh0dHA6Ly90ZXJtaW5vbG9neS5obDcub3JnL0NvZGVTeXN0ZW0vdjMtUm91dGVPZkFkbWluaXN0cmF0aW9uIn1dfSwicmVzb3VyY2VUeXBlIjoiSW1tdW5pemF0aW9uIiwicmVjb3JkZWQiOiIyMDIzLTAzLTE1IiwicHJpbWFyeVNvdXJjZSI6dHJ1ZSwic3RhdHVzIjoiY29tcGxldGVkIiwibG90TnVtYmVyIjoiQUJDMTIzIiwib2NjdXJyZW5jZURhdGVUaW1lIjoiMjAyMy0wMy0xNSIsImV4cGlyYXRpb25EYXRlIjoiMjAyNC0xMi0zMSIsInBlcmZvcm1lciI6W3siYWN0b3IiOnsiZGlzcGxheSI6IkRyLiBKYW5lIFNtaXRoLCBNRCJ9LCJmdW5jdGlvbiI6eyJjb2RpbmciOlt7ImNvZGUiOiJBUCIsInN5c3RlbSI6Imh0dHA6Ly90ZXJtaW5vbG9neS5obDcub3JnL0NvZGVTeXN0ZW0vdjItMDQ0MyJ9XX19XX19LHsicmVzb3VyY2UiOnsiY2F0ZWdvcnkiOlt7ImNvZGluZyI6W3siY29kZSI6ImxhYm9yYXRvcnkiLCJzeXN0ZW0iOiJodHRwOi8vdGVybWlub2xvZ3kuaGw3Lm9yZy9Db2RlU3lzdGVtL29ic2VydmF0aW9uLWNhdGVnb3J5In1dfV0sInJlc291cmNlVHlwZSI6Ik9ic2VydmF0aW9uIiwiZWZmZWN0aXZlRGF0ZVRpbWUiOiIyMDIzLTAzLTEwIiwic3RhdHVzIjoiZmluYWwiLCJjb2RlIjp7ImNvZGluZyI6W3siY29kZSI6Ijk0NTAwLTYiLCJzeXN0ZW0iOiJodHRwOi8vbG9pbmMub3JnIn1dfSwidmFsdWVDb2RlYWJsZUNvbmNlcHQiOnsiY29kaW5nIjpbeyJjb2RlIjoiMjYwMzg1MDA5Iiwic3lzdGVtIjoiaHR0cDovL3Nub21lZC5pbmZvL3NjdCJ9XX0sInN1YmplY3QiOnsicmVmZXJlbmNlIjoiUGF0aWVudC9leGFtcGxlLXBhdGllbnQifSwicGVyZm9ybWVyIjpbeyJkaXNwbGF5IjoiRXhhbXBsZSBMYWIifV19fV19fX19.3WVpGCl8qD3E2apBLma1OZ36DyLsS_AwhOaZWdQ0iu4rMkt2zuLj5o4V_xWL-Tv5175H7-gWbx6bqrmM3Foi3w" - } - ] +Built to pass strict verification — see [`adr/005-delivery-and-conformance.md`](adr/005-delivery-and-conformance.md): +- **JWS** `ES256`, `zip:DEF`, `kid` = base64url SHA-256 JWK thumbprint (RFC 7638). +- Payload minified + **raw-DEFLATE before signing** (jose `SignJWT` doesn't compress; we `deflateRaw` + `CompactSign`). +- Bundle `collection`; strip `id`/`meta`(≠security)/`text`/`Coding.display`; `fullUrl` + refs → `resource:N`. +- JWKS: EC/P-256/`use:sig`/`alg:ES256`/`kid`/`x`/`y`, **no `d`**, CORS; `iss` = the base serving it. +## VCI / trust + +Real verifiers check `iss` against the [VCI trusted-issuer directory](https://github.com/the-commons-project/vci-directory) and fetch JWKS over **https (TLS 1.2+)**. This demo self-hosts over `http://localhost` and is not VCI-listed (expected local-dev deviation). Production: https `iss` (no trailing slash) + VCI enrollment. + +**Card content & VCI profiles.** For `#covid19`, the bundle content follows the VCI / [US Public Health](https://build.fhir.org/ig/HL7/fhir-us-ph-library/) vaccine-credential profiles **by resource type and codes** — Patient (name + DOB), `Immunization` (CVX vaccine code), and COVID `Observation` (LOINC code + SNOMED value); the seed data is shaped accordingly. We do **not** formally validate against those `StructureDefinition`s or trim to their exact minimal data set — that's out of scope here (note: SHC strips `meta`, so conformance means the *set of elements*, not a `meta.profile` tag). To enforce it, load the IG package into Aidbox and run `$validate` on the issued bundle. + +## Layout + +``` +src/ server.ts · viewer.html + handlers/{health-cards,jwks}.ts + services/{fhir-client,bundle-builder,health-card,jwks}.ts + utils/{crypto,credential-utils,validation,shc-encode}.ts + types/{config,operation,health-card,jwks}.ts +scripts/generate-keys.ts init-bundle/bundle.json ``` -2. **Test the JWS** -Extract the `valueString` element and decode the JWT using `https://www.jwt.io/` -The decoded payload in the HealthCard should match the data you pre-loaded into Aidbox using [init-bundle](/init-bindle/bundle.json) -To validate the signature, retrieve the public key from [http://localhost:8080/health-cards-app/.well-known/jwks.json](http://localhost:8080/health-cards-app/.well-known/jwks.json) +**Out of scope**: SMART-on-FHIR OAuth (Aidbox config), VCI enrollment, formal VCI/us-ph profile validation, revocation, `resourceLink`, full `credentialValueSet`, key rotation. The inline QR is byte-mode (small cards); the `shc:/` payload is always spec-correct. diff --git a/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md b/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md new file mode 100644 index 00000000..d9226cae --- /dev/null +++ b/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md @@ -0,0 +1,76 @@ +# ADR-005: Delivery surfaces & strict SMART Health Cards conformance + +## Status +Accepted + +## Context +The original example issued a `verifiableCredential` from `$health-cards-issue` and published a JWKS, +but its cards did **not** pass strict verification, and it implemented only one of the delivery +surfaces for SMART Health Cards. We want cards that validate (e.g. at +`demo-portals.smarthealth.cards`) and all three delivery surfaces: FHIR API, QR, and file. + +## Decision + +### A. Conformance fixes (cards must verify) +1. **Real DEFLATE before signing.** `jose.SignJWT` sets `zip:"DEF"` in the header but does **not** + compress the JWS payload, so the old card advertised compression it never applied. We now + `deflateRawSync` the minified payload and sign the bytes with `CompactSign` + (`{ alg:'ES256', zip:'DEF', kid }`). (`src/utils/crypto.ts`) +2. **`kid` = RFC 7638 JWK thumbprint.** Previously `kid` was `SHA-256(PEM)[:16]`. Now + `generate-keys.ts` sets `kid = calculateJwkThumbprint(jwk)`, writes it into the JWK, and + `config.ts` reads `kid` **from the JWK file** so the signing `kid` always equals the published one. +3. **`resource:N` references.** `bundle-builder.ts` now sets `entry.fullUrl = "resource:i"` and rewrites + every `Reference.reference` that targets a bundled resource to `resource:i`, in addition to stripping + `id`/`meta`/`text`/`Coding.display`. +4. **`iss` matches the JWKS location.** Was `https://example.org/health-cards` while the key was served + at `…/health-cards-app/.well-known/jwks.json`. Now `HEALTH_CARDS_ISSUER = + http://localhost:8080/health-cards-app`, so `/.well-known/jwks.json` resolves to the key. +5. **Public, CORS-enabled JWKS.** The `well-known-jwks` App op gained an inline `allow` policy so + verifiers can fetch it unauthenticated; the app's Express `cors()` covers the same-origin route the + browser verifier uses. +6. **`includeIdentityClaim` is a string.** The operation defines it as a repeating string of claim paths + (e.g. `"Patient.name"`); we read `valueString` (tolerating the old boolean) and include exactly the + named claims. + +### B. JWKS hosting decision +Aidbox already serves its **own** `/.well-known/jwks.json` (RSA keys for OAuth/OIDC token signing) and +owns that route; those keys are the wrong type for SHC (which needs EC/ES256). We therefore keep the +existing design of **namespacing** the SHC JWKS under the app path +(`["health-cards-app",".well-known","jwks.json"]`) and set `iss` to that base. This avoids the +collision, keeps a single public host (Aidbox), and makes `/.well-known/jwks.json` correct. + +### C. Delivery surfaces +- **QR** (`src/utils/shc-encode.ts#toQrNumeric`): the `shc:/` numeric encoding (`Ord(c)-45`, two + digits; single chunk — multi-chunk is deprecated). The viewer renders it with a self-contained + byte-mode QR encoder; cards too large for byte-mode fall back to the file/JWS, with a note that + production issuers emit numeric-mode QR. +- **File** (`toFileBody` + `GET /download`): `application/smart-health-card`, + body `{ "verifiableCredential": [ "" ] }`, `.smart-health-card` filename. +- **Verifier viewer** (`src/viewer.html`): WebCrypto ES256 verification against the JWKS + + `DecompressionStream('deflate-raw')` to inflate, rendering the Bundle with a verified badge. +- **Pipeline refactor**: the fetch → minimize → sign steps moved into + `HealthCardService.issueForPatient`, shared by the Aidbox handler, `/demo/issue`, and `/download`. + +### D. Both credential styles +Accept the `https://smarthealth.cards#covid19` VCI type (→ COVID Immunizations by CVX code) **and** the +generic HL7-IG resource-type form (`Immunization`, `Observation`). + +### E. VCI content profiles — align, don't enforce +The VCI / US Public Health vaccine-credential profiles constrain the bundle **content** (profiles on +Patient, Immunization, and COVID lab-result Observation). We align by **resource type + codes** +(CVX / LOINC+SNOMED), and the seed data is shaped accordingly, but we deliberately **do not** validate +against those `StructureDefinition`s or trim to their minimal data set — that is out of scope for this +example. (SHC strips `meta`, so conformance is about the element set, not a `meta.profile` tag.) +Enforcement path if needed: load the IG package into Aidbox and `$validate` the issued bundle. + +## Consequences +- **Positive**: cards pass strict validation; all three delivery surfaces are demonstrated; JWKS is + correct and reachable; issuance logic is shared and testable. +- **Trade-offs**: local `iss` is `http://localhost` (spec wants https + no trailing slash) and the + issuer is not VCI-listed — both are documented local-dev deviations. The inline QR is byte-mode only. + +## References +- [SMART Health Cards Framework](https://spec.smarthealth.cards/) +- [OperationDefinition: $health-cards-issue](https://hl7.org/fhir/uv/smart-health-cards-and-links/STU1/OperationDefinition-patient-i-health-cards-issue.html) +- [RFC 7638 — JWK Thumbprint](https://www.rfc-editor.org/rfc/rfc7638) +- [VCI Directory](https://github.com/the-commons-project/vci-directory) diff --git a/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml b/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml index f7387233..4a722126 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml +++ b/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml @@ -78,7 +78,11 @@ services: AIDBOX_CLIENT_SECRET: secret # Health Cards Configuration - HEALTH_CARDS_ISSUER: https://example.org/health-cards + # iss MUST be the base whose /.well-known/jwks.json serves the key. + # Here that is the namespaced Aidbox App operation (see init-bundle App). + # NOTE: the spec requires https + no trailing slash; http/localhost is a + # local-dev deviation (prod: https + TLS 1.2 + VCI directory enrollment). + HEALTH_CARDS_ISSUER: http://localhost:8080/health-cards-app HEALTH_CARDS_KEY_PATH: ./keys/private-key.pem volumes: - ./keys:/app/keys:ro diff --git a/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json b/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json index e6f5021f..87959b45 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json +++ b/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json @@ -52,7 +52,10 @@ ".well-known", "jwks.json" ], - "method": "GET" + "method": "GET", + "policies": { + "jwks-public": { "engine": "allow" } + } } } }, diff --git a/aidbox-custom-operations/aidbox-health-card-issue/scripts/generate-keys.ts b/aidbox-custom-operations/aidbox-health-card-issue/scripts/generate-keys.ts index 75586d3d..b8252243 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/scripts/generate-keys.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/scripts/generate-keys.ts @@ -3,7 +3,6 @@ import * as jose from 'jose'; import * as fs from 'fs/promises'; import * as path from 'path'; -import { generateKeyId } from '../src/utils/key-utils'; const KEYS_DIR = path.join(__dirname, '..', 'keys'); const PRIVATE_KEY_PATH = path.join(KEYS_DIR, 'private-key.pem'); @@ -35,36 +34,35 @@ async function generateKeys(): Promise { // Export public key as JWK const jwk = await jose.exportJWK(publicKey); - // Generate consistent key ID using shared utility - const keyId = generateKeyId(publicKeyPem); + // SMART Health Cards REQUIRE kid to be the base64url-encoded SHA-256 JWK + // Thumbprint (RFC 7638) of the key. This is computed over the JWK members + // (kty, crv, x, y) — not the PEM — and is what the JWKS `kid` must equal. + const kid = await jose.calculateJwkThumbprint(jwk, 'sha256'); - // Add required fields for SMART Health Cards + // Add required fields for SMART Health Cards (RFC 7517 / spec §6) const healthCardJwk = { - ...jwk, kty: 'EC', use: 'sig', alg: 'ES256', - kid: keyId, + kid, crv: 'P-256', + x: jwk.x, + y: jwk.y, }; await fs.writeFile(JWK_PATH, JSON.stringify(healthCardJwk, null, 2), 'utf8'); - console.log(`Public key JWK saved to: ${JWK_PATH}`); - + console.log(`Public key JWK saved to: ${JWK_PATH} (kid=${kid})`); console.log('✅ Key generation completed successfully!'); - } catch (error) { console.error('Failed to generate keys:', error); process.exit(1); } } - if (require.main === module) { - generateKeys() - .catch((error) => { - console.error('Script failed:', error); - process.exit(1); - }); + generateKeys().catch(error => { + console.error('Script failed:', error); + process.exit(1); + }); } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/handlers/health-cards.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/handlers/health-cards.ts index 0e5afa4b..1af39370 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/handlers/health-cards.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/handlers/health-cards.ts @@ -1,144 +1,81 @@ import { Request, Response } from 'express'; -import { Config } from '../types/config'; import { OperationRequest, HealthCardResponse } from '../types/operation'; -import { FHIRClient } from '../services/fhir-client'; -import { BundleBuilder } from '../services/bundle-builder'; -import { HealthCardService } from '../services/health-card'; +import { HealthCardService, NoResourcesError } from '../services/health-card'; import { validateOperationRequest, extractParametersFromResource, } from '../utils/validation'; -export class HealthCardsHandler { - private fhirClient: FHIRClient; - private bundleBuilder: BundleBuilder; - private healthCardService: HealthCardService; +function operationOutcome( + code: string, + text: string | undefined, + severity: 'error' | 'information' = 'error' +): object { + return { + resourceType: 'OperationOutcome', + issue: [{ severity, code, details: { text } }], + }; +} - constructor(config: Config) { - this.fhirClient = new FHIRClient(config); - this.bundleBuilder = new BundleBuilder(); - this.healthCardService = new HealthCardService(config); - } +export class HealthCardsHandler { + constructor(private healthCardService: HealthCardService) {} async handleHealthCardsIssue(req: Request, res: Response): Promise { try { const operationRequest: OperationRequest = req.body; - // Validate the request const validation = validateOperationRequest(operationRequest); if (!validation.isValid) { - res.status(400).json({ - resourceType: 'OperationOutcome', - issue: [ - { - severity: 'error', - code: 'invalid', - details: { text: validation.error }, - }, - ], - }); + res.status(400).json(operationOutcome('invalid', validation.error)); return; } const patientId = operationRequest.request['route-params'].id; - - // Extract operation parameters from FHIR Parameters resource const params = extractParametersFromResource( operationRequest.request.resource ); - const credentialTypes = params.credentialType; - const includeIdentityClaim = params.includeIdentityClaim; - const since = params._since; - const credentialValueSet = params.credentialValueSet; - // Validate credential types - if (!this.healthCardService.validateCredentialTypes(credentialTypes)) { - res.status(400).json({ - resourceType: 'OperationOutcome', - issue: [ - { - severity: 'error', - code: 'not-supported', - details: { text: 'Unsupported credential type' }, - }, - ], - }); + if (!this.healthCardService.validateCredentialTypes(params.credentialType)) { + res + .status(400) + .json(operationOutcome('not-supported', 'Unsupported credential type')); return; } - // Fetch patient data - const patient = await this.fhirClient.getPatient(patientId); - - // Fetch clinical resources based on credential types - const resources = await this.fhirClient.getResourcesByType( + const jws = await this.healthCardService.issueForPatient( patientId, - credentialTypes, - since - ); - - // Filter by value set if specified - const filteredResources = this.bundleBuilder.filterByValueSet( - resources, - credentialValueSet + params.credentialType, + { + since: params._since, + includeIdentityClaim: params.includeIdentityClaim, + credentialValueSet: params.credentialValueSet, + } ); - if (filteredResources.length === 0) { - res.status(404).json({ - resourceType: 'OperationOutcome', - issue: [ - { - severity: 'information', - code: 'not-found', - details: { - text: 'No resources found for the specified criteria', - }, - }, - ], - }); - return; - } - - // Create FHIR Bundle - const bundle = this.bundleBuilder.createHealthCardBundle( - patient, - filteredResources, - includeIdentityClaim - ); - - // Generate health card - const healthCard = await this.healthCardService.generateHealthCard( - bundle, - credentialTypes - ); - - // Create response const response: HealthCardResponse = { resourceType: 'Parameters', - parameter: [ - { - name: 'verifiableCredential', - valueString: healthCard, - }, - ], + parameter: [{ name: 'verifiableCredential', valueString: jws }], }; res.status(200).json(response); } catch (error) { + if (error instanceof NoResourcesError) { + res + .status(404) + .json(operationOutcome('not-found', error.message, 'information')); + return; + } + // eslint-disable-next-line no-console console.error('Health cards operation error:', error); - - res.status(500).json({ - resourceType: 'OperationOutcome', - issue: [ - { - severity: 'error', - code: 'exception', - details: { - text: 'Internal server error during health card generation', - }, - }, - ], - }); + res + .status(500) + .json( + operationOutcome( + 'exception', + 'Internal server error during health card generation' + ) + ); } } } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts index 57d26664..4b8b4a72 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts @@ -1,10 +1,14 @@ import 'dotenv/config'; import express, { Request, Response, NextFunction } from 'express'; import cors from 'cors'; +import * as fs from 'fs'; +import * as path from 'path'; import { loadConfig } from './types/config'; import { HealthCardsHandler } from './handlers/health-cards'; import { JWKSHandler } from './handlers/jwks'; import { JWKSService } from './services/jwks'; +import { HealthCardService, NoResourcesError } from './services/health-card'; +import { toQrNumeric, toFileBody } from './utils/shc-encode'; const app = express(); const config = loadConfig(); @@ -14,9 +18,9 @@ app.use(cors()); app.use(express.json({ limit: '10mb' })); app.use(express.urlencoded({ extended: true })); - -// Initialize services -const healthCardsHandler = new HealthCardsHandler(config); +// Services (shared across the Aidbox operation, the demo route, and download). +const healthCardService = new HealthCardService(config); +const healthCardsHandler = new HealthCardsHandler(healthCardService); const jwksService = new JWKSService({ keyId: config.jwks.keyId, publicKeyPath: config.jwks.publicKeyPath, @@ -24,17 +28,105 @@ const jwksService = new JWKSService({ }); const jwksHandler = new JWKSHandler(jwksService); +// The viewer is served directly by the app (like the SHL example). Read from +// the source tree so it works both under nodemon (cwd = project root) and in the +// built container (cwd = /app; tsc does not copy .html into dist/). +const VIEWER_PATH = path.join(process.cwd(), 'src', 'viewer.html'); + +const DEFAULT_PATIENT_ID = 'example-patient'; +const DEFAULT_CREDENTIAL_TYPE = 'https://smarthealth.cards#covid19'; + // Health check endpoint -app.get('/health', (req: Request, res: Response) => { +app.get('/health', (_req: Request, res: Response) => { res.status(200).json({ status: 'healthy', timestamp: new Date().toISOString(), service: 'aidbox-health-card-issue', - version: '1.0.0' + version: '1.0.0', }); }); -// Main operation endpoint - matches Aidbox App configuration +// SHC issuer JWKS. Also published (canonically) through Aidbox at +// /.well-known/jwks.json via the namespaced App op; this same-origin route +// (CORS-enabled) is what the in-browser verifier fetches. +app.get('/.well-known/jwks.json', (req: Request, res: Response) => { + jwksHandler.handleWellKnownJWKS(req, res); +}); + +// Demo viewer (Issue + Verify). +app.get('/', (_req: Request, res: Response) => { + res.type('html').send(fs.readFileSync(VIEWER_PATH, 'utf8')); +}); + +// Patient list for the viewer's dropdown. +app.get('/demo/patients', async (_req: Request, res: Response) => { + try { + const patients = await healthCardService.listPatients(); + res.json(patients.length ? patients : [{ id: DEFAULT_PATIENT_ID, label: DEFAULT_PATIENT_ID }]); + } catch { + res.json([{ id: DEFAULT_PATIENT_ID, label: DEFAULT_PATIENT_ID }]); + } +}); + +// Unauthenticated demo trigger used by the viewer's Issue tab. In production the +// real entry point is the authenticated $health-cards-issue operation. +app.post('/demo/issue', async (req: Request, res: Response) => { + try { + const patientId = req.body.patientId || DEFAULT_PATIENT_ID; + const credentialType = req.body.credentialType || DEFAULT_CREDENTIAL_TYPE; + const credentialTypes = Array.isArray(credentialType) + ? credentialType + : [credentialType]; + + const jws = await healthCardService.issueForPatient(patientId, credentialTypes, { + includeIdentityClaim: true, + }); + + res.json({ + verifiableCredential: jws, + qr: toQrNumeric(jws), + downloadUrl: `/download?patientId=${encodeURIComponent(patientId)}&credentialType=${encodeURIComponent(String(credentialType))}`, + issuer: config.healthCards.issuer, + }); + } catch (error) { + if (error instanceof NoResourcesError) { + res.status(404).json({ error: error.message }); + return; + } + // eslint-disable-next-line no-console + console.error('demo/issue error:', error); + res.status(500).json({ error: 'Failed to issue health card' }); + } +}); + +// SMART Health Card file download (application/smart-health-card). +app.get('/download', async (req: Request, res: Response) => { + try { + const patientId = String(req.query.patientId || DEFAULT_PATIENT_ID); + const credentialType = String(req.query.credentialType || DEFAULT_CREDENTIAL_TYPE); + + const jws = await healthCardService.issueForPatient(patientId, [credentialType], { + includeIdentityClaim: true, + }); + + res.setHeader('Content-Type', 'application/smart-health-card'); + res.setHeader( + 'Content-Disposition', + 'attachment; filename="covid19.smart-health-card"' + ); + res.status(200).send(JSON.stringify(toFileBody(jws))); + } catch (error) { + if (error instanceof NoResourcesError) { + res.status(404).json({ error: error.message }); + return; + } + // eslint-disable-next-line no-console + console.error('download error:', error); + res.status(500).json({ error: 'Failed to build health card file' }); + } +}); + +// Aidbox App operation dispatch (matches init-bundle App endpoint). app.post('/health-cards-issue', (req: Request, res: Response) => { const operationId = req.body.operation.id; switch (operationId) { @@ -47,22 +139,18 @@ app.post('/health-cards-issue', (req: Request, res: Response) => { default: res.status(400).json({ error: 'Bad request', - message: `Unsupported operation: ${operationId}` + message: `Unsupported operation: ${operationId}`, }); } }); // Error handling middleware -app.use((error: Error, req: Request, res: Response, next: NextFunction) => { +app.use((error: Error, _req: Request, res: Response, _next: NextFunction) => { + // eslint-disable-next-line no-console console.error('Unhandled error:', error); - res.status(500).json({ resourceType: 'OperationOutcome', - issue: [{ - severity: 'error', - code: 'exception', - details: { text: 'Internal server error' } - }] + issue: [{ severity: 'error', code: 'exception', details: { text: 'Internal server error' } }], }); }); @@ -70,37 +158,41 @@ app.use((error: Error, req: Request, res: Response, next: NextFunction) => { app.use((req: Request, res: Response) => { res.status(404).json({ resourceType: 'OperationOutcome', - issue: [{ - severity: 'error', - code: 'not-found', - details: { text: `Endpoint not found: ${req.method} ${req.path}` } - }] + issue: [ + { + severity: 'error', + code: 'not-found', + details: { text: `Endpoint not found: ${req.method} ${req.path}` }, + }, + ], }); }); -// Initialize services and start server const PORT = config.server.port; -async function startServer() { +async function startServer(): Promise { try { - // Initialize JWKS service await jwksService.initialize(); + // eslint-disable-next-line no-console console.log('JWKS service initialized successfully'); - // Start the Express server app.listen(PORT, () => { + // eslint-disable-next-line no-console console.log(`Health Cards service started on port ${PORT}`); - console.log(`Health check: http://localhost:${PORT}/health`); - console.log(`Operation endpoint: http://localhost:${PORT}/health-cards-issue`); - console.log(`JWKS endpoint: http://localhost:${PORT}/.well-known/jwks.json`); + // eslint-disable-next-line no-console + console.log(`Viewer: http://localhost:${PORT}/`); + // eslint-disable-next-line no-console + console.log(`JWKS: http://localhost:${PORT}/.well-known/jwks.json`); + // eslint-disable-next-line no-console + console.log(`Issuer: ${config.healthCards.issuer}`); }); } catch (error) { + // eslint-disable-next-line no-console console.error('Failed to start server:', error); process.exit(1); } } -// Start the application startServer(); export default app; diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts index 4bf338c1..acdcda91 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts @@ -1,47 +1,85 @@ import { FHIRBundle, FHIRResource } from '../types/health-card'; +/** + * `includeIdentityClaim` per the operation is a repeating string of claim paths + * (e.g. "Patient.name", "Patient.birthDate"). We also tolerate a boolean for + * backward compatibility: true → default claims, false → omit the Patient. + */ +export type IdentityClaimInput = boolean | string[] | undefined; + +const DEFAULT_IDENTITY_CLAIMS = ['Patient.name', 'Patient.birthDate']; + export class BundleBuilder { createHealthCardBundle( patient: FHIRResource, resources: FHIRResource[], - includeIdentityClaim: boolean = true + includeIdentityClaim: IdentityClaimInput = true ): FHIRBundle { - const bundleEntries: Array<{ resource: FHIRResource }> = []; + const claims = this.resolveIdentityClaims(includeIdentityClaim); - // Add patient resource with identity claims - if (includeIdentityClaim) { - const patientCopy = this.createPatientWithIdentityClaims(patient); - bundleEntries.push({ resource: patientCopy }); + // Ordered list of resources that will populate the bundle. The Patient (if + // included) is always resource:0 so clinical references resolve to it. + const ordered: FHIRResource[] = []; + if (claims) { + ordered.push(this.createPatientWithIdentityClaims(patient, claims)); } - - // Add clinical resources - resources.forEach(resource => { - const resourceCopy = this.sanitizeResource(resource); - bundleEntries.push({ resource: resourceCopy }); + ordered.push(...resources.map(r => this.sanitizeResource(r))); + + // Map original absolute references (ResourceType/id) → short resource:N URIs. + // Built from the ORIGINAL resources (ids are stripped during sanitization). + const refMap = new Map(); + const originals: FHIRResource[] = []; + if (claims) originals.push(patient); + originals.push(...resources); + originals.forEach((r, i) => { + if (r?.resourceType && r?.id) { + refMap.set(`${r.resourceType}/${r.id}`, `resource:${i}`); + } }); + const entry = ordered.map((resource, i) => ({ + fullUrl: `resource:${i}`, + resource: this.rewriteReferences(resource, refMap), + })); + return { resourceType: 'Bundle', type: 'collection', - entry: bundleEntries, + entry, }; } - private createPatientWithIdentityClaims(patient: FHIRResource): FHIRResource { - const patientCopy: FHIRResource = { - resourceType: 'Patient', - }; - - // Include basic identity claims per SMART Health Cards spec - if (patient.name && patient.name.length > 0) { - patientCopy.name = patient.name.map((name: any) => ({ - family: name.family, - given: name.given, - })); + /** + * Normalizes the identity-claim input to the list of claim paths to include, + * or `null` when the Patient must be omitted entirely. + */ + private resolveIdentityClaims(input: IdentityClaimInput): string[] | null { + if (input === false) return null; + if (input === true || input === undefined) return DEFAULT_IDENTITY_CLAIMS; + if (Array.isArray(input)) { + return input.length > 0 ? input : DEFAULT_IDENTITY_CLAIMS; } + return DEFAULT_IDENTITY_CLAIMS; + } - if (patient.birthDate) { - patientCopy.birthDate = patient.birthDate; + private createPatientWithIdentityClaims( + patient: FHIRResource, + claims: string[] + ): FHIRResource { + const patientCopy: FHIRResource = { resourceType: 'Patient' }; + + for (const claim of claims) { + const field = claim.startsWith('Patient.') ? claim.slice('Patient.'.length) : claim; + + if (field === 'name' && Array.isArray(patient.name)) { + // Keep only family + given, drop use/text/period per data minimization. + patientCopy.name = patient.name.map((name: any) => ({ + family: name.family, + given: name.given, + })); + } else if (patient[field] !== undefined) { + patientCopy[field] = patient[field]; + } } return patientCopy; @@ -54,7 +92,6 @@ export class BundleBuilder { // - DomainResource.text elements // - CodeableConcept.text elements (recursively) // - Coding.display elements (recursively) - return this.deepSanitize(resource); } @@ -71,12 +108,13 @@ export class BundleBuilder { const sanitized: any = {}; for (const [key, value] of Object.entries(obj)) { - // Skip mandatory SMART Health Cards removals + // Mandatory removals: Resource.id and any *.text (DomainResource.text, + // CodeableConcept.text). if (key === 'id' || key === 'text') { continue; } - // Handle meta - only keep .meta.security if present + // meta: keep only .meta.security if present, drop everything else. if (key === 'meta') { if (value && typeof value === 'object' && (value as any).security) { sanitized[key] = { security: (value as any).security }; @@ -84,17 +122,11 @@ export class BundleBuilder { continue; } - // For CodeableConcept objects, remove .text - if (key === 'text' && obj.coding) { - continue; // Skip text field in CodeableConcept - } - - // For Coding objects, remove .display + // Coding.display — drop when this object looks like a Coding. if (key === 'display' && (obj.system || obj.code)) { - continue; // Skip display field in Coding + continue; } - // Recursively sanitize nested objects and arrays sanitized[key] = this.deepSanitize(value); } @@ -104,6 +136,35 @@ export class BundleBuilder { return obj; } + /** + * Rewrites every `Reference.reference` that targets a bundled resource to its + * short `resource:N` URI. References to resources not in the bundle are left + * untouched. + */ + private rewriteReferences(obj: any, refMap: Map): any { + if (obj === null || obj === undefined) { + return obj; + } + + if (Array.isArray(obj)) { + return obj.map(item => this.rewriteReferences(item, refMap)); + } + + if (typeof obj === 'object') { + const out: any = {}; + for (const [key, value] of Object.entries(obj)) { + if (key === 'reference' && typeof value === 'string' && refMap.has(value)) { + out[key] = refMap.get(value); + } else { + out[key] = this.rewriteReferences(value, refMap); + } + } + return out; + } + + return obj; + } + filterByValueSet( resources: FHIRResource[], valueSetUrl?: string diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/services/fhir-client.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/services/fhir-client.ts index d012c5da..f20a2502 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/services/fhir-client.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/services/fhir-client.ts @@ -3,6 +3,13 @@ import { Config } from '../types/config'; import { FHIRResource, } from '../types/health-card'; +import { COVID19_CREDENTIAL_TYPE } from '../utils/credential-utils'; + +// CVX codes for COVID-19 vaccines (used to filter Immunizations for #covid19). +const COVID19_CVX_CODES = [ + '207', '208', '210', '211', '212', '213', '217', '218', '219', '221', + '225', '227', '228', '229', '230', '300', '301', '302', +]; export class FHIRClient { private client: AxiosInstance; @@ -23,6 +30,21 @@ export class FHIRClient { }); } + async listPatients(): Promise> { + try { + const response = await this.client.get('/Patient?_count=50&_elements=name'); + const bundle = response.data; + return (bundle.entry || []).map((entry: any) => { + const p = entry.resource; + const n = Array.isArray(p.name) ? p.name[0] : undefined; + const name = n ? `${(n.given || []).join(' ')} ${n.family || ''}`.trim() : ''; + return { id: p.id, label: name ? `${name} (${p.id})` : p.id }; + }); + } catch { + return []; + } + } + async getPatient(patientId: string): Promise { try { const response = await this.client.get(`/Patient/${patientId}`); @@ -88,6 +110,13 @@ export class FHIRClient { const resources: FHIRResource[] = []; for (const type of credentialTypes) { + // VCI covid19 credential → COVID-19 Immunizations only. + if (type === COVID19_CREDENTIAL_TYPE) { + const immunizations = await this.getImmunizations(patientId, since); + resources.push(...immunizations.filter(isCovid19Immunization)); + continue; + } + switch (type.toLowerCase()) { case 'immunization': const immunizations = await this.getImmunizations(patientId, since); @@ -106,3 +135,16 @@ export class FHIRClient { return resources; } } + +/** + * True when the Immunization carries a CVX code known to be a COVID-19 vaccine. + */ +function isCovid19Immunization(immunization: FHIRResource): boolean { + const codings = immunization?.vaccineCode?.coding; + if (!Array.isArray(codings)) return false; + return codings.some( + (c: any) => + c?.system === 'http://hl7.org/fhir/sid/cvx' && + COVID19_CVX_CODES.includes(String(c?.code)) + ); +} diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts index 29e6485c..bdece100 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts @@ -1,10 +1,31 @@ import { Config } from '../types/config'; import { FHIRBundle } from '../types/health-card'; import { CryptoUtils } from '../utils/crypto'; +import { FHIRClient } from './fhir-client'; +import { BundleBuilder, IdentityClaimInput } from './bundle-builder'; import { validateCredentialTypes } from '../utils/credential-utils'; +/** + * Thrown when a patient has no resources matching the requested criteria, so + * that callers can map it to a 404 (vs a 500 for unexpected failures). + */ +export class NoResourcesError extends Error { + constructor(message = 'No resources found for the specified criteria') { + super(message); + this.name = 'NoResourcesError'; + } +} + +export interface IssueOptions { + since?: string; + includeIdentityClaim?: IdentityClaimInput; + credentialValueSet?: string; +} + export class HealthCardService { private crypto: CryptoUtils; + private fhir: FHIRClient; + private bundleBuilder: BundleBuilder; constructor(config: Config) { this.crypto = new CryptoUtils( @@ -12,25 +33,57 @@ export class HealthCardService { config.healthCards.issuer, config.jwks.keyId ); + this.fhir = new FHIRClient(config); + this.bundleBuilder = new BundleBuilder(); } - async generateHealthCard( - bundle: FHIRBundle, - credentialTypes: string[] + validateCredentialTypes(credentialTypes: string[]): boolean { + return validateCredentialTypes(credentialTypes); + } + + listPatients(): Promise> { + return this.fhir.listPatients(); + } + + /** + * Full issuance pipeline: fetch the patient + clinical resources, assemble the + * minified FHIR Bundle, and sign it into a SMART Health Card (JWS). Shared by + * the Aidbox operation handler, the demo route, QR, and file download. + */ + async issueForPatient( + patientId: string, + credentialTypes: string[], + opts: IssueOptions = {} ): Promise { - try { - const healthCard = await this.crypto.generateHealthCard( - bundle, - credentialTypes - ); - return healthCard; - } catch (error) { - throw new Error(`Failed to generate health card: ${error}`); + const patient = await this.fhir.getPatient(patientId); + const resources = await this.fhir.getResourcesByType( + patientId, + credentialTypes, + opts.since + ); + const filtered = this.bundleBuilder.filterByValueSet( + resources, + opts.credentialValueSet + ); + + if (filtered.length === 0) { + throw new NoResourcesError(); } - } - validateCredentialTypes(credentialTypes: string[]): boolean { - return validateCredentialTypes(credentialTypes); + const bundle = this.bundleBuilder.createHealthCardBundle( + patient, + filtered, + opts.includeIdentityClaim + ); + + return this.crypto.generateHealthCard(bundle, credentialTypes); } + /** Sign a pre-built bundle (kept for direct/testing use). */ + async generateHealthCard( + bundle: FHIRBundle, + credentialTypes: string[] + ): Promise { + return this.crypto.generateHealthCard(bundle, credentialTypes); + } } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts index 6b186a4f..a117c1af 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts @@ -1,3 +1,5 @@ +import * as fs from 'fs'; + export interface Config { aidbox: { baseUrl: string; @@ -17,7 +19,26 @@ export interface Config { }; } -import { generateKeyIdFromFile } from '../utils/key-utils'; +/** + * Reads the `kid` from the generated JWK file so that the signing `kid` + * (used in the JWS header) is always identical to the `kid` published in the + * JWKS. The JWK is written by `scripts/generate-keys.ts` with + * `kid` = base64url SHA-256 JWK Thumbprint (RFC 7638), as the spec requires. + */ +function readKeyIdFromJwk(publicKeyPath: string): string { + const jwkPath = publicKeyPath.replace('.pem', '.jwk.json'); + try { + const jwk = JSON.parse(fs.readFileSync(jwkPath, 'utf8')); + if (!jwk.kid) { + throw new Error('JWK is missing "kid"'); + } + return jwk.kid; + } catch (error) { + throw new Error( + `Failed to read key id from ${jwkPath}. Run "npm run generate-keys" first. (${error})` + ); + } +} export function loadConfig(): Config { const requiredEnvVars = [ @@ -34,10 +55,10 @@ export function loadConfig(): Config { } } - // Generate default values for optional JWKS settings const publicKeyPath = process.env.JWKS_PUBLIC_KEY_PATH || './keys/public-key.pem'; - const keyId = process.env.JWKS_KEY_ID || generateKeyIdFromFile(publicKeyPath); + // kid is derived from the JWK thumbprint; JWKS_KEY_ID may override for testing. + const keyId = process.env.JWKS_KEY_ID || readKeyIdFromJwk(publicKeyPath); return { aidbox: { @@ -58,4 +79,3 @@ export function loadConfig(): Config { }, }; } - diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/types/health-card.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/types/health-card.ts index c6a9ae3c..75cdcb59 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/types/health-card.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/types/health-card.ts @@ -1,7 +1,8 @@ export interface HealthCardPayload { iss: string; // Issuer URL - nbf: number; // Not before timestamp + nbf: number; // Not before (seconds since epoch) + exp?: number; // Optional expiration (seconds since epoch) vc: { type: string[]; credentialSubject: { @@ -16,6 +17,7 @@ export interface FHIRBundle { resourceType: 'Bundle'; type: 'collection'; entry: Array<{ + fullUrl?: string; // short resource-scheme URI, e.g. "resource:0" resource: FHIRResource; }>; } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/types/operation.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/types/operation.ts index a6d87555..8c153a11 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/types/operation.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/types/operation.ts @@ -9,8 +9,10 @@ export interface OperationRequest { parameter: Array<{ name: string; valueString?: string; + valueUri?: string; valueBoolean?: boolean; valueInstant?: string; + valueDateTime?: string; }>; }; 'route-params': { diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/credential-utils.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/credential-utils.ts index 6c46b8d0..aed07005 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/credential-utils.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/credential-utils.ts @@ -1,28 +1,42 @@ /** - * Supported credential types for SMART Health Cards - * Centralized definition to ensure consistency across the application + * Supported credential types for the $health-cards-issue operation. + * + * Two styles are accepted: + * - generic HL7-IG form: FHIR Resource Types ("Immunization", "Observation") + * - VCI form: "https://smarthealth.cards#covid19" */ -export const SUPPORTED_CREDENTIAL_TYPES = ['Immunization', 'Observation'] as const; +export const COVID19_CREDENTIAL_TYPE = 'https://smarthealth.cards#covid19'; -export type SupportedCredentialType = typeof SUPPORTED_CREDENTIAL_TYPES[number]; +export const SUPPORTED_CREDENTIAL_TYPES = [ + 'Immunization', + 'Observation', + COVID19_CREDENTIAL_TYPE, +] as const; + +export type SupportedCredentialType = (typeof SUPPORTED_CREDENTIAL_TYPES)[number]; /** - * Validates if a single credential type is supported + * Validates if a single credential type is supported (case-insensitive for the + * FHIR resource-type form; exact for the smarthealth.cards URI form). */ -export function isValidCredentialType(type: string): type is SupportedCredentialType { - return SUPPORTED_CREDENTIAL_TYPES.includes(type as SupportedCredentialType); +export function isValidCredentialType(type: string): boolean { + if (type === COVID19_CREDENTIAL_TYPE) return true; + const lower = type.toLowerCase(); + return SUPPORTED_CREDENTIAL_TYPES.some( + t => t !== COVID19_CREDENTIAL_TYPE && t.toLowerCase() === lower + ); } /** - * Validates if all credential types in an array are supported + * Validates if all credential types in an array are supported. */ export function validateCredentialTypes(credentialTypes: string[]): boolean { return credentialTypes.every(type => isValidCredentialType(type)); } /** - * Gets the list of supported credential types + * Gets the list of supported credential types. */ export function getSupportedCredentialTypes(): readonly string[] { return SUPPORTED_CREDENTIAL_TYPES; -} \ No newline at end of file +} diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/crypto.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/crypto.ts index eec5e989..ce477ad2 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/crypto.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/crypto.ts @@ -1,7 +1,10 @@ import * as fs from 'fs'; -import { SignJWT, importPKCS8 } from 'jose'; +import { deflateRawSync } from 'zlib'; +import { CompactSign, importPKCS8 } from 'jose'; import { HealthCardPayload, FHIRBundle } from '../types/health-card'; +const COVID19_TYPE = 'https://smarthealth.cards#covid19'; + export class CryptoUtils { private privateKey: any; private issuer: string; @@ -33,15 +36,12 @@ export class CryptoUtils { throw new Error('Private key not loaded'); } - // Determine the credential type for VC - const vcType = this.getCredentialType(credentialTypes); - - // Create the verifiable credential payload + // Build the verifiable credential payload (SMART Health Cards §5) const payload: HealthCardPayload = { iss: this.issuer, - nbf: Math.floor(Date.now() / 1000), // Current timestamp + nbf: Math.floor(Date.now() / 1000), // seconds since epoch vc: { - type: ['https://smarthealth.cards#health-card', vcType], + type: this.buildVcTypes(credentialTypes), credentialSubject: { fhirVersion: '4.0.1', fhirBundle: bundle, @@ -49,26 +49,51 @@ export class CryptoUtils { }, }; - // Sign the payload using JWS - const jws = await new SignJWT(payload) + // The SMART Health Cards spec requires the JWS payload to be minified + // (JSON.stringify omits optional whitespace) and DEFLATE-compressed (raw, + // no zlib/gz header) BEFORE signing, with the header advertising zip:"DEF". + // jose's SignJWT does NOT compress for JWS, so we compress explicitly and + // sign the compressed bytes with CompactSign. + const compressed = deflateRawSync(Buffer.from(JSON.stringify(payload), 'utf8')); + + const jws = await new CompactSign(compressed) .setProtectedHeader({ alg: 'ES256', zip: 'DEF', - kid: this.keyId, // Use consistent key identifier + kid: this.keyId, // base64url SHA-256 JWK thumbprint (RFC 7638) }) .sign(this.privateKey); return jws; } - private getCredentialType(credentialTypes: string[]): string { - // Map FHIR resource types to SMART Health Cards credential types - if (credentialTypes.includes('Immunization')) { - return 'https://smarthealth.cards#immunization'; - } else if (credentialTypes.includes('Observation')) { - return 'https://smarthealth.cards#laboratory'; - } else { - return 'https://smarthealth.cards#health-card'; + /** + * Build the `vc.type` array. `https://smarthealth.cards#health-card` is always + * present; more specific types are added when they apply (spec: "other types + * SHOULD be included when they apply"). Supports both the generic FHIR + * resource-type credentials (Immunization/Observation) and the #covid19 VCI type. + */ + private buildVcTypes(credentialTypes: string[]): string[] { + const types = new Set(['https://smarthealth.cards#health-card']); + + const wantsCovid = credentialTypes.includes(COVID19_TYPE); + const wantsImmunization = credentialTypes.some( + t => t.toLowerCase() === 'immunization' + ); + const wantsObservation = credentialTypes.some( + t => t.toLowerCase() === 'observation' + ); + + if (wantsImmunization || wantsCovid) { + types.add('https://smarthealth.cards#immunization'); + } + if (wantsCovid) { + types.add(COVID19_TYPE); } + if (wantsObservation) { + types.add('https://smarthealth.cards#laboratory'); + } + + return [...types]; } } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/key-utils.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/key-utils.ts deleted file mode 100644 index a6a023f0..00000000 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/key-utils.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as crypto from 'crypto'; - -/** - * Generates a consistent key ID based on the public key content - * Uses SHA-256 hash of the key content and returns first 16 characters - */ -export function generateKeyId(publicKeyPem: string): string { - const hash = crypto.createHash('sha256').update(publicKeyPem).digest('hex'); - return hash.substring(0, 16); -} - -/** - * Generates a key ID from a file path with fallback to timestamp-based ID - * Used for configuration loading when file might not exist yet - */ -export function generateKeyIdFromFile(publicKeyPath: string): string { - try { - const fs = require('fs'); - const publicKeyPem = fs.readFileSync(publicKeyPath, 'utf8'); - return generateKeyId(publicKeyPem); - } catch (error) { - // Fallback to timestamp-based key ID if file doesn't exist yet - return `key-${Date.now().toString().slice(-8)}`; - } -} \ No newline at end of file diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/shc-encode.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/shc-encode.ts new file mode 100644 index 00000000..109a387e --- /dev/null +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/shc-encode.ts @@ -0,0 +1,25 @@ +/** + * SMART Health Cards delivery encodings (spec §6 QR and §5 file download). + */ + +/** + * Encode a JWS as the numeric `shc:/` QR payload: each character `c` becomes the + * two-digit number `Ord(c) - 45`. Single-chunk only (multi-chunk is deprecated + * as of Dec 2022). A compact COVID card fits a single QR. + */ +export function toQrNumeric(jws: string): string { + let digits = ''; + for (let i = 0; i < jws.length; i++) { + const v = jws.charCodeAt(i) - 45; + digits += v.toString().padStart(2, '0'); + } + return `shc:/${digits}`; +} + +/** + * The `.smart-health-card` file body: a JSON object with a `verifiableCredential` + * array of one or more JWS strings. Served as `application/smart-health-card`. + */ +export function toFileBody(jws: string | string[]): { verifiableCredential: string[] } { + return { verifiableCredential: Array.isArray(jws) ? jws : [jws] }; +} diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/validation.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/validation.ts index cfdf0498..38a99b78 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/validation.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/validation.ts @@ -47,22 +47,22 @@ export function validateOperationRequest( return { isValid: true }; } - function isValidDateTimeString(dateTime: string): boolean { try { const date = new Date(dateTime); - return !isNaN(date.getTime()) && dateTime.includes('T'); + // Accept FHIR date (YYYY-MM-DD) as well as full dateTime/instant. + return !isNaN(date.getTime()); } catch { return false; } } - - -interface ExtractedParameters { +export interface ExtractedParameters { credentialType: string[]; credentialValueSet?: string; - includeIdentityClaim: boolean; + // Per the operation, includeIdentityClaim is a repeating string of claim + // paths (e.g. "Patient.name"). A boolean is tolerated for backward compat. + includeIdentityClaim: boolean | string[]; _since?: string; } @@ -78,40 +78,54 @@ export function extractParametersFromResource( return defaults; } - const extracted = resource.parameter.reduce( - (acc: ExtractedParameters, param: any) => { - const value = - param.valueString ?? param.valueBoolean ?? param.valueInstant; - - if (!value) return acc; - - switch (param.name) { - case 'credentialType': - acc.credentialType = acc.credentialType || []; - acc.credentialType.push(value); - break; - case 'credentialValueSet': - acc.credentialValueSet = value; - break; - case 'includeIdentityClaim': - acc.includeIdentityClaim = value; - break; - case '_since': - acc._since = value; - break; + const credentialType: string[] = []; + const identityClaims: string[] = []; + let identityBoolean: boolean | undefined; + let credentialValueSet: string | undefined; + let since: string | undefined; + + for (const param of resource.parameter) { + switch (param.name) { + case 'credentialType': { + // Spec type is uri; tolerate valueString for compatibility. + const v = param.valueUri ?? param.valueString; + if (v) credentialType.push(v); + break; + } + case 'credentialValueSet': { + const v = param.valueUri ?? param.valueString; + if (v) credentialValueSet = v; + break; + } + case 'includeIdentityClaim': { + // Spec type is string (repeating claim paths). Tolerate boolean. + if (typeof param.valueBoolean === 'boolean') { + identityBoolean = param.valueBoolean; + } else if (param.valueString) { + identityClaims.push(param.valueString); + } + break; } + case '_since': { + // Spec type is dateTime; tolerate instant/string. + const v = param.valueDateTime ?? param.valueInstant ?? param.valueString; + if (v) since = v; + break; + } + } + } - return acc; - }, - {} as Partial - ); + const includeIdentityClaim: boolean | string[] = + identityClaims.length > 0 + ? identityClaims + : identityBoolean !== undefined + ? identityBoolean + : defaults.includeIdentityClaim; return { - ...defaults, - ...extracted, - credentialType: extracted.credentialType?.length - ? extracted.credentialType - : defaults.credentialType, + credentialType: credentialType.length ? credentialType : defaults.credentialType, + credentialValueSet, + includeIdentityClaim, + _since: since, }; } - diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html new file mode 100644 index 00000000..91978f0a --- /dev/null +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html @@ -0,0 +1,484 @@ + + + + + +SMART Health Card · Issue & Verify + + + +
+
+

SMART Health Card · Issue & Verify

+

Issue a verifiable health card

+

Issue a signed SMART Health Card (JWS/ES256) from patient data in Aidbox, get it as a QR and a .smart-health-card file, then verify its signature against the issuer's JWKS — right here in the browser.

+
+ +
+
+ + +
+ + +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ + + +
+ + + + + + + + + + + +
+ + + + From 15d5ac4684f09d5e7c45f67d50b5167c157fa288 Mon Sep 17 00:00:00 2001 From: spicyfalafel <58147555+spicyfalafel@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:37:50 +0200 Subject: [PATCH 02/13] health cards: QR EC-level L (<=v22), max compression, prune empties + drop dangling refs Follow-up conformance hardening after comparing against the official health-cards-dev-tools validator and reference issuers (dvci, kill-the-clipboard): - QR: switch the encoder to error-correction level L (EC-M forced version 24; the validator rejects QR > version 22). A real card now fits ~v17. - Compress the JWS payload at DEFLATE level 9 (card shrinks ~1037 -> ~708 chars). - Minification: drop empty elements (""/[]/{}) and dangling references (a card reference must resolve to a resource:N URI); keep Reference.display. - README: shorten diagram labels, JWKS note as a list, drop Layout + ADR link. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aidbox-health-card-issue/README.md | 43 ++++++++----------- .../src/services/bundle-builder.ts | 33 ++++++++++++-- .../src/utils/crypto.ts | 5 ++- .../aidbox-health-card-issue/src/viewer.html | 31 ++++++------- 4 files changed, 68 insertions(+), 44 deletions(-) diff --git a/aidbox-custom-operations/aidbox-health-card-issue/README.md b/aidbox-custom-operations/aidbox-health-card-issue/README.md index 9407ada0..bbf217da 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/README.md +++ b/aidbox-custom-operations/aidbox-health-card-issue/README.md @@ -14,16 +14,16 @@ A SMART Health Card is a FHIR `Bundle` → W3C Verifiable Credential → **JWS ( ```mermaid flowchart LR Client["Client"] - Aidbox["Aidbox
FHIR server + App routing"] - App["Health Cards App
Node/Express
signs JWS, serves JWKS + viewer"] - - Client -->|"POST $health-cards-issue"| Aidbox - Aidbox -->|"http-rpc: operation"| App - App -->|"fetch Patient + clinical data"| Aidbox - App -->|"verifiableCredential (JWS)"| Aidbox - Aidbox -->|"Parameters { verifiableCredential }"| Client - Client -.->|"GET /.well-known/jwks.json"| Aidbox - Aidbox -.->|"namespaced App op"| App + Aidbox["Aidbox"] + App["Health Cards App
(Node)"] + + Client -->|"$health-cards-issue"| Aidbox + Aidbox -->|"proxies op"| App + App -->|"fetch data"| Aidbox + App -->|"JWS"| Aidbox + Aidbox -->|"credential"| Client + Client -.->|"GET JWKS"| Aidbox + Aidbox -.->|"JWKS"| App ``` ## Flow 1 — Issue (`$health-cards-issue`) @@ -34,7 +34,7 @@ sequenceDiagram participant A as Aidbox participant S as Health Cards App C->>A: POST /fhir/Patient/{id}/$health-cards-issue
Parameters { credentialType, _since? } - A->>S: http-rpc (operation: health-cards-issue) + A->>S: proxies operation S->>A: GET Patient/{id}, Immunization?/Observation? A-->>S: FHIR resources Note over S: minimize (strip id/meta/text/display,
fullUrl + refs → resource:N), DEFLATE, sign ES256 @@ -44,7 +44,11 @@ sequenceDiagram ## Flow 2 — Verify (JWKS) -Aidbox already owns its own `/.well-known/jwks.json` (RSA, for OAuth), so the SHC EC key is published under a **namespaced** path, and `iss` points at that base. +The issuer publishes its **public** key so anyone can verify: + +- Aidbox already owns its own `/.well-known/jwks.json` (RSA, for OAuth). +- So the SHC EC key is published under a **namespaced** path: `/health-cards-app/.well-known/jwks.json`. +- `iss` points at that base, so `/.well-known/jwks.json` resolves to the key. ```mermaid sequenceDiagram @@ -109,7 +113,7 @@ Activate Aidbox at [localhost:8080](http://localhost:8080) (init bundle seeds `P ## Conformance -Built to pass strict verification — see [`adr/005-delivery-and-conformance.md`](adr/005-delivery-and-conformance.md): +Built to pass strict verification: - **JWS** `ES256`, `zip:DEF`, `kid` = base64url SHA-256 JWK thumbprint (RFC 7638). - Payload minified + **raw-DEFLATE before signing** (jose `SignJWT` doesn't compress; we `deflateRaw` + `CompactSign`). - Bundle `collection`; strip `id`/`meta`(≠security)/`text`/`Coding.display`; `fullUrl` + refs → `resource:N`. @@ -121,15 +125,4 @@ Real verifiers check `iss` against the [VCI trusted-issuer directory](https://gi **Card content & VCI profiles.** For `#covid19`, the bundle content follows the VCI / [US Public Health](https://build.fhir.org/ig/HL7/fhir-us-ph-library/) vaccine-credential profiles **by resource type and codes** — Patient (name + DOB), `Immunization` (CVX vaccine code), and COVID `Observation` (LOINC code + SNOMED value); the seed data is shaped accordingly. We do **not** formally validate against those `StructureDefinition`s or trim to their exact minimal data set — that's out of scope here (note: SHC strips `meta`, so conformance means the *set of elements*, not a `meta.profile` tag). To enforce it, load the IG package into Aidbox and run `$validate` on the issued bundle. -## Layout - -``` -src/ server.ts · viewer.html - handlers/{health-cards,jwks}.ts - services/{fhir-client,bundle-builder,health-card,jwks}.ts - utils/{crypto,credential-utils,validation,shc-encode}.ts - types/{config,operation,health-card,jwks}.ts -scripts/generate-keys.ts init-bundle/bundle.json -``` - -**Out of scope**: SMART-on-FHIR OAuth (Aidbox config), VCI enrollment, formal VCI/us-ph profile validation, revocation, `resourceLink`, full `credentialValueSet`, key rotation. The inline QR is byte-mode (small cards); the `shc:/` payload is always spec-correct. +**Out of scope**: SMART-on-FHIR OAuth (Aidbox config), VCI enrollment, formal VCI/us-ph profile validation, revocation, `resourceLink`, full `credentialValueSet`, key rotation. diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts index acdcda91..5a13f7bb 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts @@ -39,7 +39,9 @@ export class BundleBuilder { const entry = ordered.map((resource, i) => ({ fullUrl: `resource:${i}`, - resource: this.rewriteReferences(resource, refMap), + // Rewrite references, then drop empty elements (the validator rejects + // empty "", [], {} — and dropping a dangling reference can leave one). + resource: this.pruneEmpty(this.rewriteReferences(resource, refMap)), })); return { @@ -153,8 +155,10 @@ export class BundleBuilder { if (typeof obj === 'object') { const out: any = {}; for (const [key, value] of Object.entries(obj)) { - if (key === 'reference' && typeof value === 'string' && refMap.has(value)) { - out[key] = refMap.get(value); + if (key === 'reference' && typeof value === 'string') { + // Rewrite refs to bundled resources; drop dangling references (a card + // reference must be a resolvable resource:N URI). + if (refMap.has(value)) out[key] = refMap.get(value); } else { out[key] = this.rewriteReferences(value, refMap); } @@ -165,6 +169,29 @@ export class BundleBuilder { return obj; } + private isEmpty(v: any): boolean { + if (v === null || v === undefined || v === '') return true; + if (Array.isArray(v)) return v.length === 0; + if (typeof v === 'object') return Object.keys(v).length === 0; + return false; // numbers/booleans (incl. 0/false) are not empty + } + + /** Recursively remove empty strings, arrays, and objects (invalid in a card). */ + private pruneEmpty(obj: any): any { + if (Array.isArray(obj)) { + return obj.map(x => this.pruneEmpty(x)).filter(x => !this.isEmpty(x)); + } + if (obj && typeof obj === 'object') { + const out: any = {}; + for (const [k, v] of Object.entries(obj)) { + const pv = this.pruneEmpty(v); + if (!this.isEmpty(pv)) out[k] = pv; + } + return out; + } + return obj; + } + filterByValueSet( resources: FHIRResource[], valueSetUrl?: string diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/crypto.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/crypto.ts index ce477ad2..090b3376 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/crypto.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/crypto.ts @@ -54,7 +54,10 @@ export class CryptoUtils { // no zlib/gz header) BEFORE signing, with the header advertising zip:"DEF". // jose's SignJWT does NOT compress for JWS, so we compress explicitly and // sign the compressed bytes with CompactSign. - const compressed = deflateRawSync(Buffer.from(JSON.stringify(payload), 'utf8')); + // Max compression (level 9) keeps the card small so it fits a QR <= v22. + const compressed = deflateRawSync(Buffer.from(JSON.stringify(payload), 'utf8'), { + level: 9, + }); const jws = await new CompactSign(compressed) .setProtectedHeader({ diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html index 91978f0a..e403d568 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html @@ -180,16 +180,16 @@

Verified health card

for (const d of data) { const factor = d ^ res[0]; res.shift(); res.push(0); if (factor) for (let i = 0; i < ecLen; i++) res[i] ^= gmul(gen[i + 1], factor); } return res; } + // Error-correction LEVEL L (low ECC = max data) per version, which is what + // SMART Health Cards QR uses so a card fits within QR version <= 22. const VERSIONS = { - 1:{ec:10,groups:[[1,16]]},2:{ec:16,groups:[[1,28]]},3:{ec:26,groups:[[1,44]]},4:{ec:18,groups:[[2,32]]}, - 5:{ec:24,groups:[[2,43]]},6:{ec:16,groups:[[4,27]]},7:{ec:18,groups:[[4,31]]},8:{ec:22,groups:[[2,38],[2,39]]}, - 9:{ec:22,groups:[[3,36],[2,37]]},10:{ec:26,groups:[[4,43],[1,44]]},11:{ec:30,groups:[[1,50],[4,51]]}, - 12:{ec:22,groups:[[6,36],[2,37]]},13:{ec:22,groups:[[8,37],[1,38]]},14:{ec:24,groups:[[4,40],[5,41]]}, - 15:{ec:24,groups:[[5,41],[5,42]]},16:{ec:28,groups:[[7,45],[3,46]]},17:{ec:28,groups:[[10,46],[1,47]]}, - 18:{ec:26,groups:[[9,43],[4,44]]},19:{ec:26,groups:[[3,44],[11,45]]},20:{ec:26,groups:[[3,41],[13,42]]}, - 21:{ec:26,groups:[[17,42]]},22:{ec:28,groups:[[17,46]]},23:{ec:28,groups:[[4,47],[14,48]]}, - 24:{ec:28,groups:[[6,45],[14,46]]},25:{ec:28,groups:[[8,47],[13,48]]},26:{ec:28,groups:[[19,46],[4,47]]}, - 27:{ec:28,groups:[[22,45],[3,46]]}, + 1:{ec:7,groups:[[1,19]]},2:{ec:10,groups:[[1,34]]},3:{ec:15,groups:[[1,55]]},4:{ec:20,groups:[[1,80]]}, + 5:{ec:26,groups:[[1,108]]},6:{ec:18,groups:[[2,68]]},7:{ec:20,groups:[[2,78]]},8:{ec:24,groups:[[2,97]]}, + 9:{ec:30,groups:[[2,116]]},10:{ec:18,groups:[[2,68],[2,69]]},11:{ec:20,groups:[[4,81]]}, + 12:{ec:24,groups:[[2,92],[2,93]]},13:{ec:26,groups:[[4,107]]},14:{ec:30,groups:[[3,115],[1,116]]}, + 15:{ec:22,groups:[[5,87],[1,88]]},16:{ec:24,groups:[[5,98],[1,99]]},17:{ec:28,groups:[[1,107],[5,108]]}, + 18:{ec:30,groups:[[5,120],[1,121]]},19:{ec:28,groups:[[3,113],[4,114]]},20:{ec:28,groups:[[3,107],[5,108]]}, + 21:{ec:28,groups:[[4,116],[4,117]]},22:{ec:28,groups:[[2,111],[7,112]]}, }; const dataCapacity = (v) => VERSIONS[v].groups.reduce((n, [c, d]) => n + c * d, 0); function charCountBits(mode, v) { @@ -210,11 +210,12 @@

Verified health card

return [{ mode: 'byte', data: new TextEncoder().encode(text) }]; } function pickVersion(segments) { - for (let v = 1; v <= 27; v++) { + // Cap at version 22: SMART Health Cards require a single QR of version <= 22. + for (let v = 1; v <= 22; v++) { const bits = segments.reduce((s, seg) => s + segBits(seg, v), 0); if (Math.ceil(bits / 8) <= dataCapacity(v)) return v; } - throw new Error("Data too large for QR (max version 27)."); + throw new Error("Card too large for a single QR (> version 22)."); } function buildData(segments, v) { const bits = []; @@ -252,8 +253,7 @@

Verified health card

} const size = (v) => v * 4 + 17; const ALIGN = {2:[6,18],3:[6,22],4:[6,26],5:[6,30],6:[6,34],7:[6,22,38],8:[6,24,42],9:[6,26,46],10:[6,28,50],11:[6,30,54],12:[6,32,58],13:[6,34,62],14:[6,26,46,66],15:[6,26,48,70],16:[6,26,50,74],17:[6,30,54,78],18:[6,30,56,82],19:[6,30,58,86],20:[6,34,62,90], - 21:[6,28,50,72,94],22:[6,26,50,74,98],23:[6,30,54,78,102],24:[6,28,54,80,106], - 25:[6,32,58,84,110],26:[6,30,58,86,114],27:[6,34,62,90,118]}; + 21:[6,28,50,72,94],22:[6,26,50,74,98]}; function placeFunctionPatterns(m, reserved, v) { const n = size(v); const finder = (r, c) => { for (let dr = -1; dr <= 7; dr++) for (let dc = -1; dc <= 7; dc++) { const rr = r + dr, cc = c + dc; if (rr < 0 || cc < 0 || rr >= n || cc >= n) continue; const inRing = dr >= 0 && dr <= 6 && dc >= 0 && dc <= 6 && (dr === 0 || dr === 6 || dc === 0 || dc === 6); const inCore = dr >= 2 && dr <= 4 && dc >= 2 && dc <= 4; m[rr][cc] = inRing || inCore ? 1 : 0; reserved[rr][cc] = 1; } }; @@ -279,7 +279,8 @@

Verified health card

return out; } function placeFormat(m, v, mask) { - const n = size(v); let data = (0b00 << 3) | mask; let rem = data; + // 0b01 = EC level L in the format-info EC-level field (must match VERSIONS). + const n = size(v); let data = (0b01 << 3) | mask; let rem = data; for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >> 9) & 1 ? 0x537 : 0); const bits = (((data << 10) | rem) ^ 0x5412); const get = (i) => (bits >> i) & 1; const tl = [[8,0,14],[8,1,13],[8,2,12],[8,3,11],[8,4,10],[8,5,9],[8,7,8],[8,8,7],[7,8,6],[5,8,5],[4,8,4],[3,8,3],[2,8,2],[1,8,1],[0,8,0]]; @@ -418,7 +419,7 @@

Verified health card

$("qrbox").classList.remove("hidden"); } catch { $("qrbox").classList.add("hidden"); - showError('Card issued. It is a bit large for an inline byte-mode QR here — use the .smart-health-card download or the copied JWS. (Production issuers emit a numeric-mode QR.)'); + showError('Card issued, but too large for a single QR (> version 22) — use the .smart-health-card download or the copied JWS.'); } // Auto-verify the freshly issued card. From 9b9244803eed3217b7a0970b8441bd21d127634d Mon Sep 17 00:00:00 2001 From: spicyfalafel <58147555+spicyfalafel@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:15:03 +0200 Subject: [PATCH 03/13] health cards: run on ports 8081/3001 to coexist with the smart-health-link example The smart-health-link example uses aidbox 8080 + app 3000; this example now uses aidbox 8081 + app 3001 (app internal port + Aidbox App endpoint + iss all updated) so both can run at the same time. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aidbox-health-card-issue/.env.example | 6 +++--- .../aidbox-health-card-issue/Dockerfile | 2 +- .../aidbox-health-card-issue/README.md | 14 +++++++------- .../aidbox-health-card-issue/docker-compose.yaml | 10 +++++----- .../init-bundle/bundle.json | 2 +- .../aidbox-health-card-issue/src/types/config.ts | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/aidbox-custom-operations/aidbox-health-card-issue/.env.example b/aidbox-custom-operations/aidbox-health-card-issue/.env.example index c16d1585..824c8d36 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/.env.example +++ b/aidbox-custom-operations/aidbox-health-card-issue/.env.example @@ -1,13 +1,13 @@ # Aidbox Configuration -AIDBOX_BASE_URL=http://localhost:8080/fhir +AIDBOX_BASE_URL=http://localhost:8081/fhir AIDBOX_CLIENT_ID=health-cards-client AIDBOX_CLIENT_SECRET=secret # Health Cards Configuration # iss must be the base that serves /.well-known/jwks.json (the namespaced # Aidbox App op). Spec requires https + no trailing slash in production. -HEALTH_CARDS_ISSUER=http://localhost:8080/health-cards-app +HEALTH_CARDS_ISSUER=http://localhost:8081/health-cards-app HEALTH_CARDS_KEY_PATH=./keys/private-key.pem # Server Configuration -PORT=3000 +PORT=3001 diff --git a/aidbox-custom-operations/aidbox-health-card-issue/Dockerfile b/aidbox-custom-operations/aidbox-health-card-issue/Dockerfile index 51b78029..e86c654c 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/Dockerfile +++ b/aidbox-custom-operations/aidbox-health-card-issue/Dockerfile @@ -30,7 +30,7 @@ RUN chown -R healthcards:nodejs /app USER healthcards # Expose port -EXPOSE 3000 +EXPOSE 3001 # Start the application CMD ["npm", "start"] \ No newline at end of file diff --git a/aidbox-custom-operations/aidbox-health-card-issue/README.md b/aidbox-custom-operations/aidbox-health-card-issue/README.md index bbf217da..7704c8c3 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/README.md +++ b/aidbox-custom-operations/aidbox-health-card-issue/README.md @@ -56,7 +56,7 @@ sequenceDiagram participant A as Aidbox participant S as Health Cards App Note over V: read iss + kid from the JWS - V->>A: GET /.well-known/jwks.json
(iss = http://localhost:8080/health-cards-app) + V->>A: GET /.well-known/jwks.json
(iss = http://localhost:8081/health-cards-app) A->>S: http-rpc (operation: well-known-jwks, public) S-->>A: { keys: [ EC/P-256 public JWK, kid ] } A-->>V: JWKS (CORS) @@ -83,10 +83,10 @@ sequenceDiagram |--------|------|------|---------| | POST | `/fhir/Patient/{id}/$health-cards-issue` | required | Issue a card | | GET | `/health-cards-app/.well-known/jwks.json` | public | Issuer JWKS (`= /.well-known/jwks.json`) | -| GET | `:3000/` | public | Viewer (Issue + Verify) | -| POST | `:3000/demo/issue` | demo | Issue → `{ jws, qr, downloadUrl }` | -| GET | `:3000/download` | demo | `.smart-health-card` file | -| GET | `:3000/.well-known/jwks.json` | public | Same JWKS, same-origin for the viewer | +| GET | `:3001/` | public | Viewer (Issue + Verify) | +| POST | `:3001/demo/issue` | demo | Issue → `{ jws, qr, downloadUrl }` | +| GET | `:3001/download` | demo | `.smart-health-card` file | +| GET | `:3001/.well-known/jwks.json` | public | Same JWKS, same-origin for the viewer | ## Quick Start @@ -95,11 +95,11 @@ cp .env.example .env npm install && npm run generate-keys # EC P-256; kid = JWK thumbprint docker compose up --build ``` -Activate Aidbox at [localhost:8080](http://localhost:8080) (init bundle seeds `Patient/example-patient` + a COVID `Immunization` + `Observation`), then open the viewer at [localhost:3000](http://localhost:3000). +Activate Aidbox at [localhost:8081](http://localhost:8081) (init bundle seeds `Patient/example-patient` + a COVID `Immunization` + `Observation`), then open the viewer at [localhost:3001](http://localhost:3001). ## Testing -- **Viewer** (`:3000`): **Issue** a card (`#covid19`, `Immunization`, or `Observation`) → get JWS, QR, and a `.smart-health-card` download, auto-verified. **Verify** any pasted JWS against the JWKS. +- **Viewer** (`:3001`): **Issue** a card (`#covid19`, `Immunization`, or `Observation`) → get JWS, QR, and a `.smart-health-card` download, auto-verified. **Verify** any pasted JWS against the JWKS. - **API**: ```http POST /fhir/Patient/example-patient/$health-cards-issue diff --git a/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml b/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml index 4a722126..b67c815f 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml +++ b/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml @@ -21,7 +21,7 @@ services: depends_on: - postgres ports: - - 8080:8080 + - 8081:8080 volumes: - ./init-bundle:/app/init-bundle:ro environment: @@ -50,7 +50,7 @@ services: BOX_SECURITY_AUDIT_LOG_ENABLED: true BOX_SECURITY_DEV_MODE: true BOX_SETTINGS_MODE: read-write - BOX_WEB_BASE_URL: http://localhost:8080 + BOX_WEB_BASE_URL: http://localhost:8081 BOX_WEB_PORT: 8080 BOX_INIT_BUNDLE: file:///app/init-bundle/bundle.json healthcheck: @@ -63,13 +63,13 @@ services: health-cards-app: build: . ports: - - 3000:3000 + - 3001:3001 depends_on: aidbox: condition: service_healthy environment: # Server Configuration - PORT: 3000 + PORT: 3001 NODE_ENV: production # Aidbox Configuration @@ -82,7 +82,7 @@ services: # Here that is the namespaced Aidbox App operation (see init-bundle App). # NOTE: the spec requires https + no trailing slash; http/localhost is a # local-dev deviation (prod: https + TLS 1.2 + VCI directory enrollment). - HEALTH_CARDS_ISSUER: http://localhost:8080/health-cards-app + HEALTH_CARDS_ISSUER: http://localhost:8081/health-cards-app HEALTH_CARDS_KEY_PATH: ./keys/private-key.pem volumes: - ./keys:/app/keys:ro diff --git a/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json b/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json index 87959b45..b6599745 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json +++ b/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json @@ -38,7 +38,7 @@ "type": "app", "apiVersion": 1, "endpoint": { - "url": "http://health-cards-app:3000/health-cards-issue", + "url": "http://health-cards-app:3001/health-cards-issue", "secret": "health-cards-app-secret" }, "operations": { diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts index a117c1af..6f3b4d5f 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts @@ -75,7 +75,7 @@ export function loadConfig(): Config { publicKeyPath, }, server: { - port: parseInt(process.env.PORT || '3000', 10), + port: parseInt(process.env.PORT || '3001', 10), }, }; } From c18227648b7a3327d137218f282cf102de2f361c Mon Sep 17 00:00:00 2001 From: spicyfalafel <58147555+spicyfalafel@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:36:38 +0200 Subject: [PATCH 04/13] viewer: show the actual $health-cards-issue request/response on the Issue tab The Issue tab called the unauth /demo/issue stand-in without showing the real FHIR operation. Add a panel rendering the exact POST /fhir/Patient/{id}/$health-cards-issue request (auth header, Parameters body) and its Parameters { verifiableCredential } response. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aidbox-health-card-issue/src/viewer.html | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html index e403d568..c0361df1 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html @@ -61,6 +61,12 @@ .reveal { margin-top: 28px; } .reveal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } .reveal-head h2 { font-family: var(--display); font-weight: 600; font-size: 18px; margin: 0; letter-spacing: -0.01em; } + .opbox { margin-top: 28px; } + .op-note { font-size: 13px; color: var(--muted); margin: 0 0 6px; } + .op-note code { font-family: var(--mono); color: var(--text); } + .api-label { font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 12px 0 6px; } + .opbox pre { white-space: pre-wrap; word-break: break-word; font-size: 12px; } + .opbox .verb { color: var(--accent); } .badge { font-family: var(--mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); } .badge.verified { color: var(--accent); border-color: var(--accent); } .badge.bad { color: var(--danger); border-color: var(--danger); } @@ -126,6 +132,16 @@

Issue a verifiable health card

+ + + +
+ + +

Only resources with _lastUpdated ≥ this date (server write time, not clinical date). A future date returns nothing.

+
@@ -413,11 +418,12 @@

Verified health card

$("opbox").classList.add("hidden"); const patientId = $("patientId").value.trim() || "example-patient"; const credentialType = $("credentialType").value; + const since = $("since").value.trim(); $("issue").disabled = true; try { const res = await fetch("/demo/issue", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ patientId, credentialType }), + body: JSON.stringify({ patientId, credentialType, since: since || undefined }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); @@ -426,14 +432,24 @@

Verified health card

// Show the actual FHIR operation this maps to (the demo used an unauth // stand-in, but this is the real authenticated $health-cards-issue call). const fhirBase = String(data.issuer).replace(/\/health-cards-app$/, "") + "/fhir"; - const reqBody = { resourceType: "Parameters", parameter: [{ name: "credentialType", valueUri: credentialType }] }; + const opParams = [{ name: "credentialType", valueUri: credentialType }]; + if (since) opParams.push({ name: "_since", valueDateTime: since }); + const reqBody = { resourceType: "Parameters", parameter: opParams }; $("op-req").innerHTML = `POST ${esc(fhirBase)}/Patient/${esc(patientId)}/$health-cards-issue\n` + `Authorization: Basic base64(client:secret)\n` + `Content-Type: application/fhir+json\n\n` + esc(JSON.stringify(reqBody, null, 2)); - const resBody = { resourceType: "Parameters", parameter: [{ name: "verifiableCredential", valueString: jws.slice(0, 64) + "…(JWS truncated)" }] }; - $("op-res").textContent = JSON.stringify(resBody, null, 2); + const resParams = [{ name: "verifiableCredential", valueString: jws.slice(0, 64) + "…(JWS truncated)" }]; + (data.resourceLinks || []).forEach((l) => resParams.push({ + name: "resourceLink", + part: [ + { name: "vcIndex", valueInteger: 0 }, + { name: "bundledResource", valueUri: l.bundledResource }, + { name: "hostedResource", valueUri: l.hostedResource }, + ], + })); + $("op-res").textContent = JSON.stringify({ resourceType: "Parameters", parameter: resParams }, null, 2); $("op-note").innerHTML = 'The real entry point is this authenticated FHIR operation. This demo page can\'t hold Aidbox credentials in the browser, so it actually called an unauthenticated stand-in (POST /demo/issue) — but the request/response below is exactly what $health-cards-issue looks like.'; $("opbox").classList.remove("hidden"); From 3adca5fed52549a471b6340c38bffde1b4b39bd5 Mon Sep 17 00:00:00 2001 From: spicyfalafel <58147555+spicyfalafel@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:21:26 +0200 Subject: [PATCH 06/13] health cards: drop FHIR_PUBLIC_BASE_URL override, restore README title resourceLink.hostedResource is always derived from the issuer origin + /fhir (one less knob). Revert the README H1 to 'SMART Health Cards Issue Operation'. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aidbox-health-card-issue/.env.example | 3 --- aidbox-custom-operations/aidbox-health-card-issue/README.md | 2 +- .../adr/005-delivery-and-conformance.md | 2 +- .../aidbox-health-card-issue/src/types/config.ts | 6 ++---- 4 files changed, 4 insertions(+), 9 deletions(-) diff --git a/aidbox-custom-operations/aidbox-health-card-issue/.env.example b/aidbox-custom-operations/aidbox-health-card-issue/.env.example index 47f21609..824c8d36 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/.env.example +++ b/aidbox-custom-operations/aidbox-health-card-issue/.env.example @@ -8,9 +8,6 @@ AIDBOX_CLIENT_SECRET=secret # Aidbox App op). Spec requires https + no trailing slash in production. HEALTH_CARDS_ISSUER=http://localhost:8081/health-cards-app HEALTH_CARDS_KEY_PATH=./keys/private-key.pem -# Optional: public FHIR base for resourceLink.hostedResource -# (defaults to the issuer origin + /fhir). -# FHIR_PUBLIC_BASE_URL=http://localhost:8081/fhir # Server Configuration PORT=3001 diff --git a/aidbox-custom-operations/aidbox-health-card-issue/README.md b/aidbox-custom-operations/aidbox-health-card-issue/README.md index b48cf02b..c076fecf 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/README.md +++ b/aidbox-custom-operations/aidbox-health-card-issue/README.md @@ -2,7 +2,7 @@ features: [SMART health cards, Custom operations, JWKS, JWS signing, FHIR operations] languages: [TypeScript] --- -# SMART Health Cards: Issue, Deliver & Verify +# SMART Health Cards Issue Operation A Node/Express implementation of the FHIR [`$health-cards-issue`](https://hl7.org/fhir/uv/smart-health-cards-and-links/STU1/OperationDefinition-patient-i-health-cards-issue.html) operation on [Aidbox](https://www.health-samurai.io/aidbox). It pulls patient data from Aidbox, minimizes it per the [SMART Health Cards spec](https://spec.smarthealth.cards/), and issues a **signed** verifiable credential (JWS/ES256) — delivered three ways: **FHIR API**, **QR (`shc:/`)**, and **`.smart-health-card` file** — plus an in-browser **verifier**. diff --git a/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md b/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md index 8b51b621..387cbf57 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md +++ b/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md @@ -67,7 +67,7 @@ Enforcement path if needed: load the IG package into Aidbox and `$validate` the The operation returns a `resourceLink` per bundled resource, mapping each minified `resource:N` entry to the live FHIR URL it came from (`bundledResource` → `hostedResource`, `vcIndex` = 0). The mapping is already built during minification (`bundle-builder` refMap), so it is surfaced directly; `hostedResource` -uses `FHIR_PUBLIC_BASE_URL` (default: issuer origin + `/fhir`). This supersedes ADR-003's out-of-scope note. +is the issuer origin + `/fhir`. This supersedes ADR-003's out-of-scope note. ## Consequences - **Positive**: cards pass strict validation; all three delivery surfaces are demonstrated; JWKS is diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts index 153db48e..83138bc6 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/types/config.ts @@ -70,10 +70,8 @@ export function loadConfig(): Config { healthCards: { issuer: process.env.HEALTH_CARDS_ISSUER!, keyPath: process.env.HEALTH_CARDS_KEY_PATH!, - // Public FHIR base for resourceLink.hostedResource (same host as iss). - fhirBaseUrl: - process.env.FHIR_PUBLIC_BASE_URL || - new URL(process.env.HEALTH_CARDS_ISSUER!).origin + '/fhir', + // Public FHIR base for resourceLink.hostedResource (issuer origin + /fhir). + fhirBaseUrl: new URL(process.env.HEALTH_CARDS_ISSUER!).origin + '/fhir', }, jwks: { keyId, From 1266edae4272e3d8d106bcf4e51892dc9e2cada5 Mon Sep 17 00:00:00 2001 From: spicyfalafel <58147555+spicyfalafel@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:45:01 +0200 Subject: [PATCH 07/13] health cards: implement credentialValueSet filtering via a real FHIR package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - credentialValueSet (IN): filter resources by content — keep only those whose code is a member of the ValueSet, via Aidbox ValueSet/$validate-code. - Load the real hl7.fhir.uv.shc-vaccination#1.0.0 package via BOX_BOOTSTRAP_FHIR_PACKAGES (':'-separated) for its immunization-all-cvx ValueSet — no hand-authored ValueSet. - Viewer: add a credentialValueSet dropdown (forwarded through /demo/issue, shown in the operation request panel). - Drop the filterByValueSet stub; remove credentialValueSet from out-of-scope. Verified: covid19 + the CVX ValueSet issues a card (CVX 208 is a member); Observation + the same ValueSet filters the LOINC lab out. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aidbox-health-card-issue/README.md | 29 +++++++-------- .../adr/005-delivery-and-conformance.md | 7 ++++ .../docker-compose.yaml | 2 +- .../aidbox-health-card-issue/src/server.ts | 2 ++ .../src/services/bundle-builder.ts | 17 --------- .../src/services/fhir-client.ts | 22 ++++++++++++ .../src/services/health-card.ts | 35 +++++++++++++++++-- .../aidbox-health-card-issue/src/viewer.html | 12 ++++++- 8 files changed, 91 insertions(+), 35 deletions(-) diff --git a/aidbox-custom-operations/aidbox-health-card-issue/README.md b/aidbox-custom-operations/aidbox-health-card-issue/README.md index c076fecf..18588c85 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/README.md +++ b/aidbox-custom-operations/aidbox-health-card-issue/README.md @@ -4,9 +4,9 @@ languages: [TypeScript] --- # SMART Health Cards Issue Operation -A Node/Express implementation of the FHIR [`$health-cards-issue`](https://hl7.org/fhir/uv/smart-health-cards-and-links/STU1/OperationDefinition-patient-i-health-cards-issue.html) operation on [Aidbox](https://www.health-samurai.io/aidbox). It pulls patient data from Aidbox, minimizes it per the [SMART Health Cards spec](https://spec.smarthealth.cards/), and issues a **signed** verifiable credential (JWS/ES256) — delivered three ways: **FHIR API**, **QR (`shc:/`)**, and **`.smart-health-card` file** — plus an in-browser **verifier**. +A Node/Express implementation of the FHIR [`$health-cards-issue`](https://hl7.org/fhir/uv/smart-health-cards-and-links/STU1/OperationDefinition-patient-i-health-cards-issue.html) operation on [Aidbox](https://www.health-samurai.io/aidbox). It pulls patient data from Aidbox, minimizes it per the [SMART Health Cards spec](https://spec.smarthealth.cards/), and issues a signed verifiable credential (JWS/ES256). The card ships three ways (FHIR API, QR `shc:/`, and a `.smart-health-card` file), with an in-browser verifier included. -A SMART Health Card is a FHIR `Bundle` → W3C Verifiable Credential → **JWS (ES256)**, payload minified and **DEFLATE**-compressed (`zip:"DEF"`). It proves *authenticity* via the issuer's signature (public key at `/.well-known/jwks.json`) — not confidentiality. (Encrypted sharing is the sibling `smart-health-link` example.) +A SMART Health Card is a FHIR `Bundle` wrapped as a W3C Verifiable Credential and signed as a JWS (ES256), payload minified and DEFLATE-compressed (`zip:"DEF"`). The issuer's signature proves the card is authentic; it does not keep the contents confidential. For encrypted sharing, see the sibling `smart-health-link` example. ## Architecture @@ -25,7 +25,7 @@ flowchart LR Aidbox -.->|"JWKS"| App ``` -## Flow 1 — Issue (`$health-cards-issue`) +## Flow 1: Issue (`$health-cards-issue`) ```mermaid sequenceDiagram @@ -41,12 +41,12 @@ sequenceDiagram A-->>C: Parameters { verifiableCredential } ``` -## Flow 2 — Verify (JWKS) +## Flow 2: Verify (JWKS) -The issuer publishes its **public** key so anyone can verify: +The issuer publishes its public key so anyone can verify: - Aidbox already owns its own `/.well-known/jwks.json` (RSA, for OAuth). -- So the SHC EC key is published under a **namespaced** path: `/health-cards-app/.well-known/jwks.json`. +- The SHC EC key therefore lives under a namespaced path: `/health-cards-app/.well-known/jwks.json`. - `iss` points at that base, so `/.well-known/jwks.json` resolves to the key. ```mermaid @@ -62,7 +62,7 @@ sequenceDiagram Note over V: pick key by kid → verify ES256 → inflate DEFLATE ``` -## Flow 3 — Deliver & verify in the browser +## Flow 3: Deliver and verify in the browser ```mermaid sequenceDiagram @@ -108,9 +108,10 @@ Activate Aidbox at [localhost:8081](http://localhost:8081) (init bundle seeds `P "parameter": [ { "name": "credentialType", "valueUri": "https://smarthealth.cards#covid19" } ] } ``` Also accepts `Immunization` / `Observation` (uri or string), `_since` (dateTime), `includeIdentityClaim` (string claim paths). -- **`credentialType` mapping**: `#covid19` → COVID `Immunization`s only (filtered by CVX vaccine code); `Immunization` → all immunizations; `Observation` → lab results. All matches go into a single card. -- **`resourceLink` (OUT)**: the response also returns a `resourceLink` per bundled resource, mapping each minified `resource:N` entry back to its live FHIR URL (e.g. `resource:0` → `/Patient/example-patient`). -- **Spec validator**: paste the JWS at [demo-portals.smarthealth.cards](https://demo-portals.smarthealth.cards/). Header / `zip:DEF` / signature / `kid` / Bundle should be **valid**. Expected warnings (local-dev only): unknown issuer + http keys — paste `keys/public-key.jwk.json` to check the signature. +- **`credentialType` mapping**: `#covid19` selects COVID `Immunization`s only (filtered by CVX vaccine code); `Immunization` selects all immunizations; `Observation` selects lab results. All matches go into a single card. +- **`resourceLink` (OUT)**: the response also returns a `resourceLink` per bundled resource, mapping each minified `resource:N` entry back to its live FHIR URL (for example, `resource:0` maps to `/Patient/example-patient`). +- **`credentialValueSet` (IN)**: restricts resources by content — keeps only those whose code (`Immunization.vaccineCode` / `Observation.code`) is a member of the given ValueSet, via Aidbox `ValueSet/$validate-code`. The `hl7.fhir.uv.shc-vaccination` package (loaded through `BOX_BOOTSTRAP_FHIR_PACKAGES`) supplies `http://hl7.org/fhir/uv/shc-vaccination/ValueSet/immunization-all-cvx` for this. Example: `#covid19` + that ValueSet issues a card (CVX 208 is a member); requesting `Observation` with the same ValueSet filters the LOINC lab result out. +- **Spec validator**: paste the JWS at [demo-portals.smarthealth.cards](https://demo-portals.smarthealth.cards/). Header, `zip:DEF`, signature, `kid`, and Bundle should all read valid. Two warnings are expected in local dev (unknown issuer and http keys); paste `keys/public-key.jwk.json` to check the signature. ## Conformance @@ -122,12 +123,12 @@ Built to pass strict verification: ## VCI / trust -Real verifiers check `iss` against the [VCI trusted-issuer directory](https://github.com/the-commons-project/vci-directory) and fetch JWKS over **https (TLS 1.2+)**. This demo self-hosts over `http://localhost` and is not VCI-listed (expected local-dev deviation). Production: https `iss` (no trailing slash) + VCI enrollment. +Real verifiers check `iss` against the [VCI trusted-issuer directory](https://github.com/the-commons-project/vci-directory) and fetch JWKS over https (TLS 1.2+). This demo self-hosts over `http://localhost` and is not VCI-listed, which is the expected local-dev deviation. Production needs an https `iss` (no trailing slash) plus VCI enrollment. -**Card content & VCI profiles.** For `#covid19`, the bundle content follows the VCI / [US Public Health](https://build.fhir.org/ig/HL7/fhir-us-ph-library/) vaccine-credential profiles **by resource type and codes** — Patient (name + DOB), `Immunization` (CVX vaccine code), and COVID `Observation` (LOINC code + SNOMED value); the seed data is shaped accordingly. We do **not** formally validate against those `StructureDefinition`s or trim to their exact minimal data set — that's out of scope here (note: SHC strips `meta`, so conformance means the *set of elements*, not a `meta.profile` tag). To enforce it, load the IG package into Aidbox and run `$validate` on the issued bundle. +**Card content and VCI profiles.** For `#covid19`, the bundle content follows the VCI / [US Public Health](https://build.fhir.org/ig/HL7/fhir-us-ph-library/) vaccine-credential profiles by resource type and codes: Patient (name + DOB), `Immunization` (CVX vaccine code), and COVID `Observation` (LOINC code + SNOMED value). The seed data is shaped accordingly. This demo stops short of validating against those `StructureDefinition`s or trimming to their exact minimal data set, which is out of scope here. (SHC strips `meta`, so conformance means the set of elements, not a `meta.profile` tag.) To enforce it, load the IG package into Aidbox and run `$validate` on the issued bundle. -**Out of scope**: SMART-on-FHIR OAuth (Aidbox config), VCI enrollment, formal VCI/us-ph profile validation, revocation, full `credentialValueSet` filtering, key rotation. +**Out of scope**: SMART-on-FHIR OAuth (Aidbox config), VCI enrollment, formal VCI/us-ph profile validation, revocation, key rotation. ## Related -- [`smart-health-link`](../../aidbox-integrations/smart-health-link) — the encrypted-link counterpart (JWE): shares data via a `shlink:` instead of a signed card. +- [`smart-health-link`](../../aidbox-integrations/smart-health-link): the encrypted-link counterpart (JWE), which shares data via a `shlink:` instead of a signed card. diff --git a/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md b/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md index 387cbf57..d39bae0a 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md +++ b/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md @@ -69,6 +69,13 @@ to the live FHIR URL it came from (`bundledResource` → `hostedResource`, `vcIn already built during minification (`bundle-builder` refMap), so it is surfaced directly; `hostedResource` is the issuer origin + `/fhir`. This supersedes ADR-003's out-of-scope note. +### G. credentialValueSet (IN) — implemented +Resources are filtered by content: a resource is kept only if its code (`Immunization.vaccineCode` / +`Observation.code`) is a member of the given ValueSet, checked via Aidbox `ValueSet/$validate-code`. +The ValueSet comes from a real FHIR package — `hl7.fhir.uv.shc-vaccination#1.0.0` loaded through +`BOX_BOOTSTRAP_FHIR_PACKAGES` (packages are `:`-separated) — which provides `immunization-all-cvx`. +No hand-authored ValueSet. This supersedes ADR-003's out-of-scope note. + ## Consequences - **Positive**: cards pass strict validation; all three delivery surfaces are demonstrated; JWKS is correct and reachable; issuance logic is shared and testable. diff --git a/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml b/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml index b67c815f..8e8a4203 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml +++ b/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml @@ -26,7 +26,7 @@ services: - ./init-bundle:/app/init-bundle:ro environment: BOX_ADMIN_PASSWORD: uN6jhgu8eo - BOX_BOOTSTRAP_FHIR_PACKAGES: hl7.fhir.r4.core#4.0.1 + BOX_BOOTSTRAP_FHIR_PACKAGES: hl7.fhir.r4.core#4.0.1:hl7.fhir.uv.shc-vaccination#1.0.0 BOX_COMPATIBILITY_VALIDATION_JSON__SCHEMA_REGEX: '#{:fhir-datetime}' BOX_DB_DATABASE: aidbox BOX_DB_HOST: postgres diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts index e6c0fff2..b37780ec 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts @@ -74,6 +74,7 @@ app.post('/demo/issue', async (req: Request, res: Response) => { try { const patientId = req.body.patientId || DEFAULT_PATIENT_ID; const credentialType = req.body.credentialType || DEFAULT_CREDENTIAL_TYPE; + const credentialValueSet = req.body.credentialValueSet || undefined; const since = req.body.since || undefined; const credentialTypes = Array.isArray(credentialType) ? credentialType @@ -82,6 +83,7 @@ app.post('/demo/issue', async (req: Request, res: Response) => { const { jws, resourceLinks } = await healthCardService.issueForPatient(patientId, credentialTypes, { includeIdentityClaim: true, since, + credentialValueSet, }); res.json({ diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts index 2a86047f..954ee789 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/services/bundle-builder.ts @@ -205,21 +205,4 @@ export class BundleBuilder { } return obj; } - - filterByValueSet( - resources: FHIRResource[], - valueSetUrl?: string - ): FHIRResource[] { - if (!valueSetUrl) { - return resources; - } - - // Basic value set filtering - in a real implementation, - // this would validate against actual FHIR ValueSet resources - // eslint-disable-next-line no-console - console.warn( - `Value set filtering not fully implemented for: ${valueSetUrl}` - ); - return resources; - } } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/services/fhir-client.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/services/fhir-client.ts index f20a2502..2123cdd2 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/services/fhir-client.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/services/fhir-client.ts @@ -30,6 +30,28 @@ export class FHIRClient { }); } + /** + * True if `system|code` is a member of the ValueSet, via Aidbox terminology + * (`ValueSet/$validate-code`). Used to filter resources by `credentialValueSet`. + */ + async validateCode( + valueSetUrl: string, + system: string, + code: string + ): Promise { + try { + const response = await this.client.get('/ValueSet/$validate-code', { + params: { url: valueSetUrl, system, code }, + }); + const result = (response.data?.parameter || []).find( + (p: any) => p.name === 'result' + ); + return result?.valueBoolean === true; + } catch { + return false; + } + } + async listPatients(): Promise> { try { const response = await this.client.get('/Patient?_count=50&_elements=name'); diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts index ef2f060e..587d23f7 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts @@ -1,5 +1,5 @@ import { Config } from '../types/config'; -import { FHIRBundle } from '../types/health-card'; +import { FHIRBundle, FHIRResource } from '../types/health-card'; import { CryptoUtils } from '../utils/crypto'; import { FHIRClient } from './fhir-client'; import { BundleBuilder, IdentityClaimInput } from './bundle-builder'; @@ -74,7 +74,7 @@ export class HealthCardService { credentialTypes, opts.since ); - const filtered = this.bundleBuilder.filterByValueSet( + const filtered = await this.filterByValueSet( resources, opts.credentialValueSet ); @@ -98,6 +98,37 @@ export class HealthCardService { return { jws, resourceLinks }; } + /** + * Keep only resources whose code (Immunization.vaccineCode / Observation.code) + * is a member of the given ValueSet, using Aidbox terminology. No-op when no + * credentialValueSet is provided. + */ + private async filterByValueSet( + resources: FHIRResource[], + valueSetUrl?: string + ): Promise { + if (!valueSetUrl) return resources; + + const kept: FHIRResource[] = []; + for (const r of resources) { + const codings = this.extractCodings(r); + let inValueSet = false; + for (const c of codings) { + if (c.system && c.code && (await this.fhir.validateCode(valueSetUrl, c.system, c.code))) { + inValueSet = true; + break; + } + } + if (inValueSet) kept.push(r); + } + return kept; + } + + private extractCodings(resource: FHIRResource): Array<{ system?: string; code?: string }> { + const cc = resource.vaccineCode || resource.code; // Immunization / Observation + return Array.isArray(cc?.coding) ? cc.coding : []; + } + /** Sign a pre-built bundle (kept for direct/testing use). */ async generateHealthCard( bundle: FHIRBundle, diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html index 22adeb23..a540ec8b 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html @@ -116,6 +116,14 @@

Issue a verifiable health card

+
+ + +

Keeps only resources whose code is in this ValueSet (via $validate-code). E.g. requesting Observation with the CVX set filters LOINC labs out.

+
@@ -418,12 +426,13 @@

Verified health card

$("opbox").classList.add("hidden"); const patientId = $("patientId").value.trim() || "example-patient"; const credentialType = $("credentialType").value; + const credentialValueSet = $("credentialValueSet").value; const since = $("since").value.trim(); $("issue").disabled = true; try { const res = await fetch("/demo/issue", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ patientId, credentialType, since: since || undefined }), + body: JSON.stringify({ patientId, credentialType, credentialValueSet: credentialValueSet || undefined, since: since || undefined }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); @@ -433,6 +442,7 @@

Verified health card

// stand-in, but this is the real authenticated $health-cards-issue call). const fhirBase = String(data.issuer).replace(/\/health-cards-app$/, "") + "/fhir"; const opParams = [{ name: "credentialType", valueUri: credentialType }]; + if (credentialValueSet) opParams.push({ name: "credentialValueSet", valueUri: credentialValueSet }); if (since) opParams.push({ name: "_since", valueDateTime: since }); const reqBody = { resourceType: "Parameters", parameter: opParams }; $("op-req").innerHTML = From 7dffd31b16877abb16eda52a4525d32db5c3c744 Mon Sep 17 00:00:00 2001 From: spicyfalafel <58147555+spicyfalafel@users.noreply.github.com> Date: Tue, 21 Jul 2026 13:56:57 +0200 Subject: [PATCH 08/13] health cards: repeatable IN params in viewer + second (SNOMED) immunization for the credentialValueSet demo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Viewer Issue form: credentialType, credentialValueSet, includeIdentityClaim are now repeatable (+ buttons); credentialValueSet + includeIdentityClaim live under an Advanced section. Arrays are forwarded through /demo/issue and shown in the operation request panel. - credentialValueSet is now string[] end to end (multiple = logical AND). - init-bundle: add a SNOMED-coded Immunization so credentialValueSet filtering between two immunizations is demonstrable (CVX kept, SNOMED filtered by the CVX ValueSet — validated locally). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aidbox-health-card-issue/README.md | 2 +- .../init-bundle/bundle.json | 24 ++++ .../aidbox-health-card-issue/src/server.ts | 11 +- .../src/services/health-card.ts | 25 +++-- .../src/utils/validation.ts | 8 +- .../aidbox-health-card-issue/src/viewer.html | 106 +++++++++++++----- 6 files changed, 136 insertions(+), 40 deletions(-) diff --git a/aidbox-custom-operations/aidbox-health-card-issue/README.md b/aidbox-custom-operations/aidbox-health-card-issue/README.md index 18588c85..b124956d 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/README.md +++ b/aidbox-custom-operations/aidbox-health-card-issue/README.md @@ -110,7 +110,7 @@ Activate Aidbox at [localhost:8081](http://localhost:8081) (init bundle seeds `P Also accepts `Immunization` / `Observation` (uri or string), `_since` (dateTime), `includeIdentityClaim` (string claim paths). - **`credentialType` mapping**: `#covid19` selects COVID `Immunization`s only (filtered by CVX vaccine code); `Immunization` selects all immunizations; `Observation` selects lab results. All matches go into a single card. - **`resourceLink` (OUT)**: the response also returns a `resourceLink` per bundled resource, mapping each minified `resource:N` entry back to its live FHIR URL (for example, `resource:0` maps to `/Patient/example-patient`). -- **`credentialValueSet` (IN)**: restricts resources by content — keeps only those whose code (`Immunization.vaccineCode` / `Observation.code`) is a member of the given ValueSet, via Aidbox `ValueSet/$validate-code`. The `hl7.fhir.uv.shc-vaccination` package (loaded through `BOX_BOOTSTRAP_FHIR_PACKAGES`) supplies `http://hl7.org/fhir/uv/shc-vaccination/ValueSet/immunization-all-cvx` for this. Example: `#covid19` + that ValueSet issues a card (CVX 208 is a member); requesting `Observation` with the same ValueSet filters the LOINC lab result out. +- **`credentialValueSet` (IN)**: restricts resources by content — keeps only those whose code (`Immunization.vaccineCode` / `Observation.code`) is a member of the given ValueSet, via Aidbox `ValueSet/$validate-code`. The `hl7.fhir.uv.shc-vaccination` package (loaded through `BOX_BOOTSTRAP_FHIR_PACKAGES`) supplies `http://hl7.org/fhir/uv/shc-vaccination/ValueSet/immunization-all-cvx` for this. The seed has two immunizations (one CVX, one SNOMED), so `Immunization` + the CVX ValueSet keeps only the CVX one; `Observation` + the same ValueSet filters the LOINC lab out. Repeating IN params (`credentialType`, `credentialValueSet`, `includeIdentityClaim`) are supported — the viewer's Issue tab has `+` buttons (the last two under **Advanced**). - **Spec validator**: paste the JWS at [demo-portals.smarthealth.cards](https://demo-portals.smarthealth.cards/). Header, `zip:DEF`, signature, `kid`, and Bundle should all read valid. Two warnings are expected in local dev (unknown issuer and http keys); paste `keys/public-key.jwk.json` to check the signature. ## Conformance diff --git a/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json b/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json index b6599745..8b5e27b0 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json +++ b/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json @@ -256,6 +256,30 @@ "method": "PUT", "url": "Observation/example-covid-test" } + }, + { + "resource": { + "resourceType": "Immunization", + "id": "example-flu-snomed", + "status": "completed", + "vaccineCode": { + "coding": [ + { + "system": "http://snomed.info/sct", + "code": "86198006", + "display": "Influenza vaccine" + } + ] + }, + "patient": { + "reference": "Patient/example-patient" + }, + "occurrenceDateTime": "2023-10-01" + }, + "request": { + "method": "PUT", + "url": "Immunization/example-flu-snomed" + } } ] } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts index b37780ec..e88a27c3 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/server.ts @@ -74,14 +74,21 @@ app.post('/demo/issue', async (req: Request, res: Response) => { try { const patientId = req.body.patientId || DEFAULT_PATIENT_ID; const credentialType = req.body.credentialType || DEFAULT_CREDENTIAL_TYPE; - const credentialValueSet = req.body.credentialValueSet || undefined; const since = req.body.since || undefined; const credentialTypes = Array.isArray(credentialType) ? credentialType : [credentialType]; + const credentialValueSet = Array.isArray(req.body.credentialValueSet) + ? req.body.credentialValueSet.filter(Boolean) + : req.body.credentialValueSet + ? [req.body.credentialValueSet] + : undefined; + const identityClaims = Array.isArray(req.body.includeIdentityClaim) + ? req.body.includeIdentityClaim.filter(Boolean) + : undefined; const { jws, resourceLinks } = await healthCardService.issueForPatient(patientId, credentialTypes, { - includeIdentityClaim: true, + includeIdentityClaim: identityClaims && identityClaims.length ? identityClaims : true, since, credentialValueSet, }); diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts index 587d23f7..2587ea60 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/services/health-card.ts @@ -19,7 +19,7 @@ export class NoResourcesError extends Error { export interface IssueOptions { since?: string; includeIdentityClaim?: IdentityClaimInput; - credentialValueSet?: string; + credentialValueSet?: string[]; } /** Links a `resource:N` entry in the card to the live FHIR resource it came from. */ @@ -105,21 +105,30 @@ export class HealthCardService { */ private async filterByValueSet( resources: FHIRResource[], - valueSetUrl?: string + valueSetUrls?: string[] ): Promise { - if (!valueSetUrl) return resources; + if (!valueSetUrls || valueSetUrls.length === 0) return resources; const kept: FHIRResource[] = []; for (const r of resources) { const codings = this.extractCodings(r); - let inValueSet = false; - for (const c of codings) { - if (c.system && c.code && (await this.fhir.validateCode(valueSetUrl, c.system, c.code))) { - inValueSet = true; + // Multiple credentialValueSet values are logical AND: the resource must + // have a code that is a member of every specified ValueSet. + let inAll = true; + for (const vs of valueSetUrls) { + let inThis = false; + for (const c of codings) { + if (c.system && c.code && (await this.fhir.validateCode(vs, c.system, c.code))) { + inThis = true; + break; + } + } + if (!inThis) { + inAll = false; break; } } - if (inValueSet) kept.push(r); + if (inAll) kept.push(r); } return kept; } diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/validation.ts b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/validation.ts index 38a99b78..04e123ff 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/utils/validation.ts +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/utils/validation.ts @@ -59,7 +59,7 @@ function isValidDateTimeString(dateTime: string): boolean { export interface ExtractedParameters { credentialType: string[]; - credentialValueSet?: string; + credentialValueSet?: string[]; // Per the operation, includeIdentityClaim is a repeating string of claim // paths (e.g. "Patient.name"). A boolean is tolerated for backward compat. includeIdentityClaim: boolean | string[]; @@ -80,8 +80,8 @@ export function extractParametersFromResource( const credentialType: string[] = []; const identityClaims: string[] = []; + const credentialValueSet: string[] = []; let identityBoolean: boolean | undefined; - let credentialValueSet: string | undefined; let since: string | undefined; for (const param of resource.parameter) { @@ -94,7 +94,7 @@ export function extractParametersFromResource( } case 'credentialValueSet': { const v = param.valueUri ?? param.valueString; - if (v) credentialValueSet = v; + if (v) credentialValueSet.push(v); break; } case 'includeIdentityClaim': { @@ -124,7 +124,7 @@ export function extractParametersFromResource( return { credentialType: credentialType.length ? credentialType : defaults.credentialType, - credentialValueSet, + credentialValueSet: credentialValueSet.length ? credentialValueSet : undefined, includeIdentityClaim, _since: since, }; diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html index a540ec8b..6a27d369 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html @@ -67,6 +67,12 @@ .api-label { font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 12px 0 6px; } .opbox pre { white-space: pre-wrap; word-break: break-word; font-size: 12px; } .opbox .verb { color: var(--accent); } + .opt { color: var(--muted); font-weight: 400; text-transform: none; letter-spacing: 0; } + .rrow { display: flex; gap: 8px; margin-bottom: 8px; } + .rrow select { flex: 1; } + .adv-toggle { margin: 22px 0 10px; padding: 0; background: none; border: 0; color: var(--text); font-family: var(--display); font-weight: 600; font-size: 15px; cursor: pointer; display: flex; align-items: center; gap: 6px; } + .adv-toggle .chev { color: var(--muted); transition: transform .15s; } + .adv-toggle[aria-expanded="true"] .chev { transform: rotate(90deg); } .badge { font-family: var(--mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); } .badge.verified { color: var(--accent); border-color: var(--accent); } .badge.bad { color: var(--danger); border-color: var(--danger); } @@ -102,33 +108,37 @@

Issue a verifiable health card

-
-
- - -
-
- - -
+
+ +
- - -

Keeps only resources whose code is in this ValueSet (via $validate-code). E.g. requesting Observation with the CVX set filters LOINC labs out.

+ +
+
- +

Only resources with _lastUpdated ≥ this date (server write time, not clinical date). A future date returns nothing.

+ + + +
@@ -425,14 +435,22 @@

Verified health card

$("reveal").classList.add("hidden"); $("opbox").classList.add("hidden"); const patientId = $("patientId").value.trim() || "example-patient"; - const credentialType = $("credentialType").value; - const credentialValueSet = $("credentialValueSet").value; + const credentialType = gather("ct-rows"); + if (!credentialType.length) credentialType.push("https://smarthealth.cards#covid19"); + const credentialValueSet = gather("cvs-rows"); + const includeIdentityClaim = gather("ic-rows"); const since = $("since").value.trim(); $("issue").disabled = true; try { const res = await fetch("/demo/issue", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ patientId, credentialType, credentialValueSet: credentialValueSet || undefined, since: since || undefined }), + body: JSON.stringify({ + patientId, + credentialType, + credentialValueSet: credentialValueSet.length ? credentialValueSet : undefined, + includeIdentityClaim: includeIdentityClaim.length ? includeIdentityClaim : undefined, + since: since || undefined, + }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`); @@ -441,8 +459,9 @@

Verified health card

// Show the actual FHIR operation this maps to (the demo used an unauth // stand-in, but this is the real authenticated $health-cards-issue call). const fhirBase = String(data.issuer).replace(/\/health-cards-app$/, "") + "/fhir"; - const opParams = [{ name: "credentialType", valueUri: credentialType }]; - if (credentialValueSet) opParams.push({ name: "credentialValueSet", valueUri: credentialValueSet }); + const opParams = credentialType.map((t) => ({ name: "credentialType", valueUri: t })); + credentialValueSet.forEach((v) => opParams.push({ name: "credentialValueSet", valueUri: v })); + includeIdentityClaim.forEach((v) => opParams.push({ name: "includeIdentityClaim", valueString: v })); if (since) opParams.push({ name: "_since", valueDateTime: since }); const reqBody = { resourceType: "Parameters", parameter: opParams }; $("op-req").innerHTML = @@ -521,6 +540,43 @@

Verified health card

try { await navigator.clipboard.writeText($("jws-out").textContent); $("copy").textContent = "Copied"; setTimeout(() => ($("copy").textContent = "Copy"), 1200); } catch {} }); + // ---- repeatable IN-parameter rows (credentialType/credentialValueSet/includeIdentityClaim) ---- + const CT_OPTIONS = [ + { v: "https://smarthealth.cards#covid19", t: "#covid19" }, + { v: "Immunization", t: "Immunization" }, + { v: "Observation", t: "Observation" }, + ]; + const CVS_OPTIONS = [ + { v: "http://hl7.org/fhir/uv/shc-vaccination/ValueSet/immunization-all-cvx", t: "immunization-all-cvx (CVX)" }, + { v: "http://hl7.org/fhir/uv/shc-vaccination/ValueSet/SnomedVaccineCodes", t: "SnomedVaccineCodes (SNOMED, needs tx)" }, + ]; + const IC_OPTIONS = [ + { v: "Patient.name", t: "Patient.name" }, + { v: "Patient.birthDate", t: "Patient.birthDate" }, + { v: "Patient.gender", t: "Patient.gender" }, + ]; + function optsHtml(opts) { return opts.map((o) => ``).join(""); } + function addRow(containerId, opts, removable) { + const row = document.createElement("div"); + row.className = "rrow"; + row.innerHTML = `` + (removable ? `` : ""); + const rm = row.querySelector("button"); + if (rm) rm.addEventListener("click", () => row.remove()); + $(containerId).appendChild(row); + } + function gather(id) { return [...$(id).querySelectorAll("select")].map((s) => s.value).filter(Boolean); } + + addRow("ct-rows", CT_OPTIONS, false); // credentialType is 1..* — one fixed row + $("ct-add").addEventListener("click", () => addRow("ct-rows", CT_OPTIONS, true)); + $("cvs-add").addEventListener("click", () => addRow("cvs-rows", CVS_OPTIONS, true)); + $("ic-add").addEventListener("click", () => addRow("ic-rows", IC_OPTIONS, true)); + $("adv-toggle").addEventListener("click", () => { + const b = $("adv-toggle"); + const open = b.getAttribute("aria-expanded") === "true"; + b.setAttribute("aria-expanded", String(!open)); + $("advanced").classList.toggle("hidden", open); + }); + // Populate the Patient dropdown from Aidbox (via the app). async function loadPatients() { const sel = $("patientId"); From 649c3cbc3e31fd3fc5691e2c3f4dc25404e0784b Mon Sep 17 00:00:00 2001 From: spicyfalafel <58147555+spicyfalafel@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:01:56 +0200 Subject: [PATCH 09/13] viewer: show verification steps + decoded JWS header and payload claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Verify result now explains what happened (6-step check with pass/fail marks) and shows what's inside the JWS: decoded header (alg/zip/kid), payload claims (iss, nbf as a date, exp, vc.type, fhirVersion), and the FHIR bundle — instead of a single opaque 'signed by …' line. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aidbox-health-card-issue/src/viewer.html | 43 +++++++++++++++++-- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html index 6a27d369..22d35477 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html @@ -73,6 +73,10 @@ .adv-toggle { margin: 22px 0 10px; padding: 0; background: none; border: 0; color: var(--text); font-family: var(--display); font-weight: 600; font-size: 15px; cursor: pointer; display: flex; align-items: center; gap: 6px; } .adv-toggle .chev { color: var(--muted); transition: transform .15s; } .adv-toggle[aria-expanded="true"] .chev { transform: rotate(90deg); } + .vsteps { margin: 0 0 16px; padding: 12px 14px; background: var(--panel-2); border: 1px solid var(--line); border-radius: 10px; } + .vsteps div { font-family: var(--mono); font-size: 12px; color: var(--muted); margin: 3px 0; } + .vsteps .ok { color: var(--accent); } .vsteps .bad { color: var(--danger); } + #vheader, #vclaims { max-height: 240px; } .badge { font-family: var(--mono); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; padding: 4px 10px; border-radius: 999px; border: 1px solid var(--line); color: var(--muted); } .badge.verified { color: var(--accent); border-color: var(--accent); } .badge.bad { color: var(--danger); border-color: var(--danger); } @@ -188,6 +192,12 @@

Verified health card

+
+
+

JWS header

+

Payload claims

+
+

FHIR bundle (vc.credentialSubject.fhirBundle)


     
@@ -418,14 +428,39 @@

Verified health card

badge.textContent = valid ? "signature verified" : "signature INVALID"; badge.className = "badge " + (valid ? "verified" : "bad"); + const bundle = payload?.vc?.credentialSubject?.fhirBundle; + const entries = (bundle?.entry || []).length; + const iso = (secs) => new Date(secs * 1000).toISOString().replace(".000Z", "Z"); + const nbfStr = payload?.nbf ? `${payload.nbf} (${iso(payload.nbf)})` : undefined; + const expStr = payload?.exp ? `${payload.exp} (${iso(payload.exp)})` : undefined; + const verdict = $("verdict"); verdict.className = "verdict " + (valid ? "ok" : "bad"); - verdict.textContent = valid - ? `✓ Signed by ${iss} (kid ${header.kid.slice(0, 10)}…) — payload intact` - : "✕ Signature does not verify against the issuer JWKS"; + verdict.innerHTML = valid + ? `✓ Valid. The ES256 signature matches the issuer's published key, and the DEFLATE-compressed payload was intact — not altered since it was signed.` + : `✕ Invalid. The signature does not verify against the issuer's JWKS: the card was not issued by this key, or it was tampered with.`; verdict.classList.remove("hidden"); - const bundle = payload?.vc?.credentialSubject?.fhirBundle; + // What the verifier actually did, step by step. + const step = (ok, txt) => `
${ok ? "✓" : "✕"} ${esc(txt)}
`; + $("vsteps").innerHTML = [ + step(true, `Split the compact JWS into header · payload · signature`), + step(header.alg === "ES256", `Header: alg=${header.alg}, zip=${header.zip}, kid=${(header.kid || "").slice(0, 16)}…`), + step(true, `Read iss=${iss}, fetched ${iss}/.well-known/jwks.json`), + step(true, `Selected the JWKS key whose kid matches the header`), + step(valid, `Verified the ES256 signature over header.payload`), + step(true, `Inflated the DEFLATE payload → FHIR bundle with ${entries} ${entries === 1 ? "entry" : "entries"}`), + ].join(""); + + $("vheader").textContent = JSON.stringify(header, null, 2); + $("vclaims").textContent = JSON.stringify({ + iss, + nbf: nbfStr, + ...(expStr ? { exp: expStr } : {}), + "vc.type": payload?.vc?.type, + fhirVersion: payload?.vc?.credentialSubject?.fhirVersion, + }, null, 2); + $("out").innerHTML = highlight(JSON.stringify(bundle || payload, null, 2)); } From b864f5547266985b97bb520121319b3cf1a469a5 Mon Sep 17 00:00:00 2001 From: spicyfalafel <58147555+spicyfalafel@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:06:48 +0200 Subject: [PATCH 10/13] viewer: drop the non-resolving SnomedVaccineCodes credentialValueSet option SnomedVaccineCodes is a SNOMED descendent-of filter that the configured external\ntx server doesn't resolve here (returns false for every code), so it only ever\nproduced empty results and confused the AND semantics. Keep immunization-all-cvx. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aidbox-health-card-issue/src/viewer.html | 1 - 1 file changed, 1 deletion(-) diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html index 22d35477..b2d6baa4 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html @@ -583,7 +583,6 @@

Verified health card

]; const CVS_OPTIONS = [ { v: "http://hl7.org/fhir/uv/shc-vaccination/ValueSet/immunization-all-cvx", t: "immunization-all-cvx (CVX)" }, - { v: "http://hl7.org/fhir/uv/shc-vaccination/ValueSet/SnomedVaccineCodes", t: "SnomedVaccineCodes (SNOMED, needs tx)" }, ]; const IC_OPTIONS = [ { v: "Patient.name", t: "Patient.name" }, From 508858de818e6e8bd70f1d03c444c2e26ea6009a Mon Sep 17 00:00:00 2001 From: spicyfalafel <58147555+spicyfalafel@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:12:50 +0200 Subject: [PATCH 11/13] health cards: fix the SNOMED demo immunization to a real Vaccine-product code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second immunization used SNOMED 86198006 (Influenza vaccine), which is NOT a\ndescendant of 787859002 (Vaccine product), so SnomedVaccineCodes never matched it\nand that credentialValueSet option always returned empty. Use 1119349007\n(COVID-19 mRNA vaccine), a real Vaccine-product descendant the external tx\nresolves. Re-add SnomedVaccineCodes to the viewer — now Immunization + that\nValueSet keeps only the SNOMED shot, Immunization + immunization-all-cvx keeps\nonly the CVX shot. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aidbox-health-card-issue/README.md | 2 +- .../aidbox-health-card-issue/init-bundle/bundle.json | 8 ++++---- .../aidbox-health-card-issue/src/viewer.html | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/aidbox-custom-operations/aidbox-health-card-issue/README.md b/aidbox-custom-operations/aidbox-health-card-issue/README.md index b124956d..e55b3264 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/README.md +++ b/aidbox-custom-operations/aidbox-health-card-issue/README.md @@ -110,7 +110,7 @@ Activate Aidbox at [localhost:8081](http://localhost:8081) (init bundle seeds `P Also accepts `Immunization` / `Observation` (uri or string), `_since` (dateTime), `includeIdentityClaim` (string claim paths). - **`credentialType` mapping**: `#covid19` selects COVID `Immunization`s only (filtered by CVX vaccine code); `Immunization` selects all immunizations; `Observation` selects lab results. All matches go into a single card. - **`resourceLink` (OUT)**: the response also returns a `resourceLink` per bundled resource, mapping each minified `resource:N` entry back to its live FHIR URL (for example, `resource:0` maps to `/Patient/example-patient`). -- **`credentialValueSet` (IN)**: restricts resources by content — keeps only those whose code (`Immunization.vaccineCode` / `Observation.code`) is a member of the given ValueSet, via Aidbox `ValueSet/$validate-code`. The `hl7.fhir.uv.shc-vaccination` package (loaded through `BOX_BOOTSTRAP_FHIR_PACKAGES`) supplies `http://hl7.org/fhir/uv/shc-vaccination/ValueSet/immunization-all-cvx` for this. The seed has two immunizations (one CVX, one SNOMED), so `Immunization` + the CVX ValueSet keeps only the CVX one; `Observation` + the same ValueSet filters the LOINC lab out. Repeating IN params (`credentialType`, `credentialValueSet`, `includeIdentityClaim`) are supported — the viewer's Issue tab has `+` buttons (the last two under **Advanced**). +- **`credentialValueSet` (IN)**: restricts resources by content — keeps only those whose code (`Immunization.vaccineCode` / `Observation.code`) is a member of the given ValueSet, via Aidbox `ValueSet/$validate-code`. The `hl7.fhir.uv.shc-vaccination` package (loaded through `BOX_BOOTSTRAP_FHIR_PACKAGES`) supplies `http://hl7.org/fhir/uv/shc-vaccination/ValueSet/immunization-all-cvx` for this. The seed has two immunizations (one CVX, one SNOMED): `Immunization` + `immunization-all-cvx` keeps only the CVX one, while `Immunization` + `SnomedVaccineCodes` keeps only the SNOMED one (a `descendent-of` filter resolved via the external tx server); `Observation` + the CVX ValueSet filters the LOINC lab out. Repeating IN params (`credentialType`, `credentialValueSet`, `includeIdentityClaim`) are supported — the viewer's Issue tab has `+` buttons (the last two under **Advanced**). Multiple `credentialValueSet` values are logical AND (intersection), so combining CVX + SNOMED sets matches nothing. - **Spec validator**: paste the JWS at [demo-portals.smarthealth.cards](https://demo-portals.smarthealth.cards/). Header, `zip:DEF`, signature, `kid`, and Bundle should all read valid. Two warnings are expected in local dev (unknown issuer and http keys); paste `keys/public-key.jwk.json` to check the signature. ## Conformance diff --git a/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json b/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json index 8b5e27b0..35784cfa 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json +++ b/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json @@ -260,14 +260,14 @@ { "resource": { "resourceType": "Immunization", - "id": "example-flu-snomed", + "id": "example-immunization-snomed", "status": "completed", "vaccineCode": { "coding": [ { "system": "http://snomed.info/sct", - "code": "86198006", - "display": "Influenza vaccine" + "code": "1119349007", + "display": "COVID-19 mRNA vaccine" } ] }, @@ -278,7 +278,7 @@ }, "request": { "method": "PUT", - "url": "Immunization/example-flu-snomed" + "url": "Immunization/example-immunization-snomed" } } ] diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html index b2d6baa4..056cba8c 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html @@ -583,6 +583,7 @@

Verified health card

]; const CVS_OPTIONS = [ { v: "http://hl7.org/fhir/uv/shc-vaccination/ValueSet/immunization-all-cvx", t: "immunization-all-cvx (CVX)" }, + { v: "http://hl7.org/fhir/uv/shc-vaccination/ValueSet/SnomedVaccineCodes", t: "SnomedVaccineCodes (SNOMED, via tx)" }, ]; const IC_OPTIONS = [ { v: "Patient.name", t: "Patient.name" }, From 7223f029e3e0345221c1f08cc4b49ade97e18943 Mon Sep 17 00:00:00 2001 From: spicyfalafel <58147555+spicyfalafel@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:34:07 +0200 Subject: [PATCH 12/13] health cards: install the official SMART Health Cards terminology via $fhir-package-install MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the expected value sets: install cards.smarthealth.terminology (the official\nSHC terminology, not on any FHIR registry) via the $fhir-package-install operation\nin the init bundle, passing the package URL directly\n(https://terminology.smarthealth.cards/package.tgz) — no tarball committed or\nmounted. Drop hl7.fhir.uv.shc-vaccination from BOX_BOOTSTRAP. The viewer's credentialValueSet now offers immunization-covid-cvx (the COVID-\nspecific, enumerated CVX set — validated locally). SNOMED value sets are dropped:\nthey enumerate SNOMED extension codes the configured external tx doesn't resolve. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aidbox-health-card-issue/README.md | 2 +- .../adr/005-delivery-and-conformance.md | 12 ++++++++---- .../aidbox-health-card-issue/docker-compose.yaml | 2 +- .../init-bundle/bundle.json | 16 ++++++++++++++-- .../aidbox-health-card-issue/src/viewer.html | 3 +-- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/aidbox-custom-operations/aidbox-health-card-issue/README.md b/aidbox-custom-operations/aidbox-health-card-issue/README.md index e55b3264..dce59d7b 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/README.md +++ b/aidbox-custom-operations/aidbox-health-card-issue/README.md @@ -110,7 +110,7 @@ Activate Aidbox at [localhost:8081](http://localhost:8081) (init bundle seeds `P Also accepts `Immunization` / `Observation` (uri or string), `_since` (dateTime), `includeIdentityClaim` (string claim paths). - **`credentialType` mapping**: `#covid19` selects COVID `Immunization`s only (filtered by CVX vaccine code); `Immunization` selects all immunizations; `Observation` selects lab results. All matches go into a single card. - **`resourceLink` (OUT)**: the response also returns a `resourceLink` per bundled resource, mapping each minified `resource:N` entry back to its live FHIR URL (for example, `resource:0` maps to `/Patient/example-patient`). -- **`credentialValueSet` (IN)**: restricts resources by content — keeps only those whose code (`Immunization.vaccineCode` / `Observation.code`) is a member of the given ValueSet, via Aidbox `ValueSet/$validate-code`. The `hl7.fhir.uv.shc-vaccination` package (loaded through `BOX_BOOTSTRAP_FHIR_PACKAGES`) supplies `http://hl7.org/fhir/uv/shc-vaccination/ValueSet/immunization-all-cvx` for this. The seed has two immunizations (one CVX, one SNOMED): `Immunization` + `immunization-all-cvx` keeps only the CVX one, while `Immunization` + `SnomedVaccineCodes` keeps only the SNOMED one (a `descendent-of` filter resolved via the external tx server); `Observation` + the CVX ValueSet filters the LOINC lab out. Repeating IN params (`credentialType`, `credentialValueSet`, `includeIdentityClaim`) are supported — the viewer's Issue tab has `+` buttons (the last two under **Advanced**). Multiple `credentialValueSet` values are logical AND (intersection), so combining CVX + SNOMED sets matches nothing. +- **`credentialValueSet` (IN)**: restricts resources by content — keeps only those whose code (`Immunization.vaccineCode` / `Observation.code`) is a member of the given ValueSet, via Aidbox `ValueSet/$validate-code`. The official SMART Health Cards terminology package (`cards.smarthealth.terminology`) is installed by the **init bundle** through the `$fhir-package-install` operation (from `https://terminology.smarthealth.cards/package.tgz`), which provides `https://terminology.smarthealth.cards/ValueSet/immunization-covid-cvx`. The seed has two immunizations (one CVX, one SNOMED): `Immunization` + `immunization-covid-cvx` keeps only the CVX one; `Observation` + it filters the LOINC lab out. (The package's SNOMED value sets aren't demoed — they enumerate SNOMED extension codes the configured external tx doesn't resolve.) Repeating IN params (`credentialType`, `credentialValueSet`, `includeIdentityClaim`) are supported — the viewer's Issue tab has `+` buttons (the last two under **Advanced**). Multiple `credentialValueSet` values are logical AND. - **Spec validator**: paste the JWS at [demo-portals.smarthealth.cards](https://demo-portals.smarthealth.cards/). Header, `zip:DEF`, signature, `kid`, and Bundle should all read valid. Two warnings are expected in local dev (unknown issuer and http keys); paste `keys/public-key.jwk.json` to check the signature. ## Conformance diff --git a/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md b/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md index d39bae0a..75e95040 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md +++ b/aidbox-custom-operations/aidbox-health-card-issue/adr/005-delivery-and-conformance.md @@ -71,10 +71,14 @@ is the issuer origin + `/fhir`. This supersedes ADR-003's out-of-scope note. ### G. credentialValueSet (IN) — implemented Resources are filtered by content: a resource is kept only if its code (`Immunization.vaccineCode` / -`Observation.code`) is a member of the given ValueSet, checked via Aidbox `ValueSet/$validate-code`. -The ValueSet comes from a real FHIR package — `hl7.fhir.uv.shc-vaccination#1.0.0` loaded through -`BOX_BOOTSTRAP_FHIR_PACKAGES` (packages are `:`-separated) — which provides `immunization-all-cvx`. -No hand-authored ValueSet. This supersedes ADR-003's out-of-scope note. +`Observation.code` — the two elements the operation names) is a member of the given ValueSet, checked +via Aidbox `ValueSet/$validate-code`. The ValueSet comes from the official SMART Health Cards +terminology package `cards.smarthealth.terminology`, installed by the init bundle via the +`$fhir-package-install` operation (`https://terminology.smarthealth.cards/package.tgz`) — it isn't on a +FHIR registry, and `$fhir-package-install` accepts a URL directly, so no tarball is committed/mounted. +The demo uses `immunization-covid-cvx` (enumerated CVX, validated locally). No hand-authored ValueSet. +The package's SNOMED value sets aren't demoed — they enumerate SNOMED extension codes the configured +external tx server doesn't resolve. This supersedes ADR-003's out-of-scope note. ## Consequences - **Positive**: cards pass strict validation; all three delivery surfaces are demonstrated; JWKS is diff --git a/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml b/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml index 8e8a4203..b67c815f 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml +++ b/aidbox-custom-operations/aidbox-health-card-issue/docker-compose.yaml @@ -26,7 +26,7 @@ services: - ./init-bundle:/app/init-bundle:ro environment: BOX_ADMIN_PASSWORD: uN6jhgu8eo - BOX_BOOTSTRAP_FHIR_PACKAGES: hl7.fhir.r4.core#4.0.1:hl7.fhir.uv.shc-vaccination#1.0.0 + BOX_BOOTSTRAP_FHIR_PACKAGES: hl7.fhir.r4.core#4.0.1 BOX_COMPATIBILITY_VALIDATION_JSON__SCHEMA_REGEX: '#{:fhir-datetime}' BOX_DB_DATABASE: aidbox BOX_DB_HOST: postgres diff --git a/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json b/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json index 35784cfa..b8a5a5cf 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json +++ b/aidbox-custom-operations/aidbox-health-card-issue/init-bundle/bundle.json @@ -2,6 +2,18 @@ "resourceType": "Bundle", "type": "transaction", "entry": [ + { + "request": { "method": "POST", "url": "$fhir-package-install" }, + "resource": { + "resourceType": "Parameters", + "parameter": [ + { + "name": "package", + "valueString": "https://terminology.smarthealth.cards/package.tgz" + } + ] + } + }, { "resource": { "resourceType": "Client", @@ -266,8 +278,8 @@ "coding": [ { "system": "http://snomed.info/sct", - "code": "1119349007", - "display": "COVID-19 mRNA vaccine" + "code": "1031000172108", + "display": "COVID-19 mRNA vaccine (BNT162b2) of Pfizer-BioNTech" } ] }, diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html index 056cba8c..feaa424e 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html @@ -582,8 +582,7 @@

Verified health card

{ v: "Observation", t: "Observation" }, ]; const CVS_OPTIONS = [ - { v: "http://hl7.org/fhir/uv/shc-vaccination/ValueSet/immunization-all-cvx", t: "immunization-all-cvx (CVX)" }, - { v: "http://hl7.org/fhir/uv/shc-vaccination/ValueSet/SnomedVaccineCodes", t: "SnomedVaccineCodes (SNOMED, via tx)" }, + { v: "https://terminology.smarthealth.cards/ValueSet/immunization-covid-cvx", t: "immunization-covid-cvx (COVID CVX)" }, ]; const IC_OPTIONS = [ { v: "Patient.name", t: "Patient.name" }, From 62f27525f082802d386635cfc0b5ba073fdcfe57 Mon Sep 17 00:00:00 2001 From: spicyfalafel <58147555+spicyfalafel@users.noreply.github.com> Date: Tue, 21 Jul 2026 14:44:54 +0200 Subject: [PATCH 13/13] Move too-large-for-QR notice next to the QR, not the error box MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A card that exceeds single-QR capacity (> version 22) is not an issuance error — the card is valid and still delivered via file/JWS. Show that note in the QR caption instead of the bottom error box; real issuance failures stay in the error box. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../aidbox-health-card-issue/src/viewer.html | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html index feaa424e..c992cfcc 100644 --- a/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html +++ b/aidbox-custom-operations/aidbox-health-card-issue/src/viewer.html @@ -525,13 +525,15 @@

Verified health card

$("dlrow").classList.remove("hidden"); // QR of the shc:/ payload (best effort; falls back for large cards). + // A too-large-for-QR note belongs next to the QR, not in the error box. + $("qrbox").classList.remove("hidden"); try { QR.draw($("qr"), data.qr); + $("qr").classList.remove("hidden"); $("qr-caption").innerHTML = 'The QR holds the shc:/ health card. Scan it with the Health app.'; - $("qrbox").classList.remove("hidden"); } catch { - $("qrbox").classList.add("hidden"); - showError('Card issued, but too large for a single QR (> version 22) — use the .smart-health-card download or the copied JWS.'); + $("qr").classList.add("hidden"); + $("qr-caption").innerHTML = 'Too large for a single QR (> version 22) — use the .smart-health-card download or the copied JWS above.'; } // Auto-verify the freshly issued card.