diff --git a/CHANGELOG.md b/CHANGELOG.md index 72b6b6856..ef49af76b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +## [1.27.24] — 2026-07-07 — Share documents + +### Added + +- A share link can now carry a hand-picked set of your stored documents, so a clinician can open your reports and letters at the public `/c/` view without an account. Pick up to 50 documents when you create a link; the set is frozen at creation (revoke and re-share to change it). +- The share-create screen shows a prominent QR code that encodes the link and its passphrase together — a clinician scans it to open the shared record and its documents on their own device in one step (still shown only once, alongside the link and passphrase text). +- Recipients see the documents as a metadata list: images and PDFs preview inline, other formats download. Only the exact documents attached to that link are reachable, each fetch re-checks the link's live state, and revoking or expiring a link cuts off access to the documents immediately. + +### Security + +- Shared JPEG, PNG, and WebP images are stripped of embedded camera metadata (EXIF, XMP, GPS) on the way out; the stored original is untouched. PDF, TIFF, HEIC, and Office documents pass through as stored — a documented limit. +- The public document route is token-scoped to the link's frozen set, re-validates revocation and the passphrase before any decrypt, serves non-previewable types as opaque downloads, and carries its own strict framing policy. + ## [1.27.23] — 2026-07-07 — Dashboard hero polish ### Changed diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a856694ad..80f4f580b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.27.23 + version: 1.27.24 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. @@ -998,8 +998,11 @@ paths: ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. Every scope column (window, sections, FHIR resource types, API toggle) is frozen - write-once. `expiresAt` is required and capped at 90 days. Auth via cookie or Bearer; rate-limited - (`share-link:`, 20/h). Strict: unknown keys 422." + write-once. v1.28 accepts an optional `documentIds` array (bounded ≤50): each id is validated as the caller's + own live document and frozen onto the link at create (a foreign/deleted id → 422); the summary DTO then reports + `documentCount`. A documents-only share (empty report sections + non-empty `documentIds`) is valid. `expiresAt` + is required and capped at 90 days. Auth via cookie or Bearer; rate-limited (`share-link:`, 20/h). + Strict: unknown keys 422." requestBody: required: true content: @@ -1096,6 +1099,13 @@ paths: - MedicationAdministration allowFhirApi: type: boolean + documentIds: + maxItems: 50 + type: array + items: + type: string + minLength: 1 + maxLength: 64 expiresAt: type: string format: date-time @@ -1204,6 +1214,57 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 + /c/{token}/d/{id}: + get: + tags: + - Export + summary: Serve a shared document blob (v1.28, public) + description: "Anonymous, no-session share-scoped document serve route. The raw `hls_` share token in the path is the + ONLY credential — never a session; it never elevates a normal authed route. Serves ONLY a document id present in + the link's frozen `documentIds` set (a guessed / foreign / soft-deleted id → flat 404). A passphrase-protected + link additionally requires the short-lived, token-scoped unlock cookie; revocation and expiry collapse to the + same flat 404. The serving-class posture is identical to the owner `/api/documents/inbound/{id}/original` route: + Class A (PDF/JPEG/PNG/WebP/GIF) inline with its true type, everything else `application/octet-stream` + + `Content-Disposition: attachment` + `X-Content-Type-Options: nosniff`; `Cache-Control: private, no-store`. + Images (JPEG/PNG/WebP) are EXIF/XMP/GPS-stripped at egress (the stored original is untouched); + PDF/TIFF/HEIC/Office metadata passes through. Per-link rate-limited." + parameters: + - in: path + name: token + schema: + type: string + description: Raw `hls_` share token. + required: true + description: Raw `hls_` share token. + - in: path + name: id + schema: + type: string + description: A document id on the link's frozen set. + required: true + description: A document id on the link's frozen set. + responses: + "200": + description: The document bytes. Content-Type is the true stored type for Class A (inline) or `application/octet-stream` + for Class B (attachment). + content: + application/octet-stream: + schema: + type: string + format: binary + "404": + description: Flat 404 for every miss class (unknown/revoked/expired token, locked passphrase gate, or an id not on the + frozen set). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: Per-link serve rate limit exceeded. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/fhir/metadata: get: tags: @@ -13087,6 +13148,8 @@ components: type: boolean protected: type: boolean + documentCount: + type: number expiresAt: type: string createdAt: @@ -13124,6 +13187,7 @@ components: - resourceTypes - allowFhirApi - protected + - documentCount - expiresAt - createdAt - revokedAt @@ -13176,6 +13240,8 @@ components: type: boolean protected: type: boolean + documentCount: + type: number expiresAt: type: string createdAt: @@ -13200,6 +13266,7 @@ components: - resourceTypes - allowFhirApi - protected + - documentCount - expiresAt - createdAt - revokedAt diff --git a/e2e/medications-wizard-daily.spec.ts b/e2e/medications-wizard-daily.spec.ts index 7b62a9670..5bdb3855a 100644 --- a/e2e/medications-wizard-daily.spec.ts +++ b/e2e/medications-wizard-daily.spec.ts @@ -29,6 +29,10 @@ test.describe("medication wizard — daily", () => { test.use({ storageState: STORAGE_STATE_PATH }); test("creates a daily medication end-to-end", async ({ page }) => { + // Multi-step wizard with per-step validation gating the Next button; + // under a loaded CI runner the validation settle can lag the default + // 30s, so this legitimately-long flow earns the tripled timeout. + test.slow(); await stubDashboardAnalytics(page); const capture = mockMedicationsApi(page, { id: STUB_MEDICATION_ID, diff --git a/e2e/setup/vault-fixture.ts b/e2e/setup/vault-fixture.ts index 31320e09c..cf7ee7764 100644 --- a/e2e/setup/vault-fixture.ts +++ b/e2e/setup/vault-fixture.ts @@ -160,6 +160,30 @@ function tinyPng(marker: string): Buffer { return Buffer.concat([PNG_1X1, Buffer.from(marker, "utf8")]); } +/** + * A minimal JPEG carrying a GPS marker inside an APP1 (Exif) segment so the + * share serve route's egress EXIF strip is observable end-to-end: the stored + * bytes contain `GPSLatitude`, the served bytes must not. SOI → APP1(Exif) → + * SOS(+entropy). Not a decodable photo — the serve route sniffs the STORED + * mime type, never re-decodes pixels, so this is enough to exercise the strip. + */ +function jpegWithGps(marker: string): Buffer { + const payload = Buffer.concat([ + Buffer.from("Exif\0\0"), + Buffer.from(`GPSLatitude=48.137 ${marker}`), + ]); + const lenField = Buffer.alloc(2); + lenField.writeUInt16BE(payload.length + 2); + const app1 = Buffer.concat([Buffer.from([0xff, 0xe1]), lenField, payload]); + const sos = Buffer.concat([ + Buffer.from([0xff, 0xda]), + Buffer.from([0x00, 0x08]), + Buffer.from([0x01, 0x01, 0x00, 0x3f, 0x00, 0x00]), + Buffer.from([0xaa, 0xbb]), + ]); + return Buffer.concat([Buffer.from([0xff, 0xd8]), app1, sos]); +} + // ─── Fixture identity ─────────────────────────────────────────────────────── export const KNIE_EPISODE_ID = "e2evaultknie000000000001"; @@ -173,6 +197,21 @@ export const AI_PROBE_DOC_ID = "e2evaultaiprobe000000001"; export const CONTENT_DOC_ID = "e2evaultcontent00000001"; /** The whole word that appears only in `CONTENT_DOC_ID`'s indexed body. */ export const CONTENT_BODY_WORD = "pneumothorax"; + +/** + * v1.28 (Phase 3) — a fixed trio the clinician-share e2e attaches to a link and + * serves through the public `/c//d/` route: + * - a PDF (Class A, inline preview via