diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a856694ad..fbd41e001 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -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/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