mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
Merge branch 'feat/vault-p3-backend' into feat/vault-p3-integrated
# Conflicts: # prisma/schema.prisma
This commit is contained in:
commit
9ff39b5846
16 files changed
+1490
-41
No files matched your search
+69
-2
@@ -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:<userId>`, 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:<userId>`, 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
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
-- Document vault, Phase 3: attach hand-picked documents to a clinician share
|
||||
-- link.
|
||||
--
|
||||
-- `clinician_share_link_documents` — frozen, write-once membership rows
|
||||
-- binding a stored document to a share link. The document set a share
|
||||
-- exposes is modelled as this join, never a mutable column on the link, so
|
||||
-- it inherits the link's frozen-scope discipline: rows are created in the
|
||||
-- same transaction as the link, each document is owner-checked at create,
|
||||
-- and the set is never widened afterwards. The public share serve route
|
||||
-- serves ONLY a document id present in this set for the resolved token.
|
||||
--
|
||||
-- Both foreign keys cascade: deleting the link or the underlying document
|
||||
-- removes the membership cleanly (a shared document the owner later deletes
|
||||
-- simply disappears from the link).
|
||||
--
|
||||
-- Additive; applies clean on a 0228 database and on a fresh database as part
|
||||
-- of the ordered migration chain.
|
||||
|
||||
CREATE TABLE "clinician_share_link_documents" (
|
||||
"id" TEXT NOT NULL,
|
||||
"share_link_id" TEXT NOT NULL,
|
||||
"document_id" TEXT NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "clinician_share_link_documents_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "clinician_share_link_documents_share_link_id_document_id_key"
|
||||
ON "clinician_share_link_documents" ("share_link_id", "document_id");
|
||||
|
||||
CREATE INDEX "clinician_share_link_documents_share_link_id_idx"
|
||||
ON "clinician_share_link_documents" ("share_link_id");
|
||||
|
||||
CREATE INDEX "clinician_share_link_documents_document_id_idx"
|
||||
ON "clinician_share_link_documents" ("document_id");
|
||||
|
||||
ALTER TABLE "clinician_share_link_documents"
|
||||
ADD CONSTRAINT "clinician_share_link_documents_share_link_id_fkey"
|
||||
FOREIGN KEY ("share_link_id") REFERENCES "clinician_share_links" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "clinician_share_link_documents"
|
||||
ADD CONSTRAINT "clinician_share_link_documents_document_id_fkey"
|
||||
FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id")
|
||||
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
+32
-1
@@ -4853,13 +4853,41 @@ model ClinicianShareLink {
|
||||
lastAccessAt DateTime? @map("last_access_at")
|
||||
accessCount Int @default(0) @map("access_count")
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
/// v1.28 — the hand-picked document set this link exposes (0..N). A relation,
|
||||
/// not a column: frozen write-once at create, owner-checked, never widened.
|
||||
documents ClinicianShareLinkDocument[]
|
||||
|
||||
@@index([userId, createdAt(sort: Desc)])
|
||||
@@index([expiresAt])
|
||||
@@map("clinician_share_links")
|
||||
}
|
||||
|
||||
/// v1.28 (document vault, Phase 3) — frozen, write-once membership binding a
|
||||
/// stored document to a clinician share link. The document set a share exposes
|
||||
/// is modelled as this relation, never a mutable column: the rows are created
|
||||
/// in the same transaction as the link, each `documentId` is validated as the
|
||||
/// caller's own live document at create time, and the set is never widened
|
||||
/// afterwards. The public share serve route serves ONLY a document id present
|
||||
/// in this set for the resolved token — a guessed / foreign id is a flat 404.
|
||||
/// Both foreign keys cascade: deleting the link or the underlying document
|
||||
/// removes the membership cleanly (a shared document the owner later deletes
|
||||
/// simply disappears from the link).
|
||||
model ClinicianShareLinkDocument {
|
||||
id String @id @default(cuid())
|
||||
shareLinkId String @map("share_link_id")
|
||||
documentId String @map("document_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
shareLink ClinicianShareLink @relation(fields: [shareLinkId], references: [id], onDelete: Cascade)
|
||||
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([shareLinkId, documentId])
|
||||
@@index([shareLinkId])
|
||||
@@index([documentId])
|
||||
@@map("clinician_share_link_documents")
|
||||
}
|
||||
|
||||
// v1.11.0 W3 — period-narrative cache (Pillar P1).
|
||||
//
|
||||
// One durable, typed row per (user, period, locale) carrying the latest
|
||||
@@ -5819,6 +5847,9 @@ model InboundDocument {
|
||||
/// 1:1 blind content-search index (encrypted extracted text + HMAC token
|
||||
/// array). Absent until the document is indexed; cascades on delete.
|
||||
contentIndex DocumentContentIndex?
|
||||
/// share-link memberships (m:n via `ClinicianShareLinkDocument`).
|
||||
/// Cascades: deleting this document removes it from every share it was on.
|
||||
shareLinks ClinicianShareLinkDocument[]
|
||||
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -86,3 +86,42 @@ describe("proxy.ts document serve-route framing carve-out", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxy.ts share document serve-route framing carve-out", () => {
|
||||
const shareToken = "hls_000000000000000000000000000000000000000000000000";
|
||||
|
||||
it("allows same-origin framing on the /c/<token>/d/<id> serve path", () => {
|
||||
const res = proxy(makeRequest(`/c/${shareToken}/d/doc123`));
|
||||
expect(res.headers.get("x-frame-options")).toBe("SAMEORIGIN");
|
||||
const csp = res.headers.get("content-security-policy") ?? "";
|
||||
expect(csp).toContain("frame-ancestors 'self'");
|
||||
expect(csp).toContain("default-src 'none'");
|
||||
// No sandbox (Chromium downloads sandboxed PDFs) and no script sources.
|
||||
expect(csp).not.toMatch(/sandbox/);
|
||||
expect(csp).not.toMatch(/script-src/);
|
||||
});
|
||||
|
||||
it("keeps the DENY + page-CSP posture on the /c/<token> view page itself", () => {
|
||||
const res = proxy(makeRequest(`/c/${shareToken}`));
|
||||
// The clinician view is a PAGE — it must not be framable and keeps the
|
||||
// page CSP; only the blob subresource under it takes the serve posture.
|
||||
expect(res.headers.get("x-frame-options")).toBe("DENY");
|
||||
expect(res.headers.get("content-security-policy") ?? "").toContain(
|
||||
"frame-ancestors 'none'",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not match nested, suffixed, or lookalike share serve paths", () => {
|
||||
for (const path of [
|
||||
`/c/${shareToken}/d/doc123/extra`, // extra trailing segment
|
||||
`/c/${shareToken}/d/doc123x/more/deep`, // deep suffix
|
||||
`/c/${shareToken}/dx/doc123`, // wrong segment name
|
||||
`/c/${shareToken}/d`, // missing id
|
||||
`/c/${shareToken}/d/`, // empty id
|
||||
"/c//d/doc123", // empty token (traversal-ish)
|
||||
]) {
|
||||
const res = proxy(makeRequest(path));
|
||||
expect(res.headers.get("x-frame-options"), path).toBe("DENY");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -15,6 +15,9 @@ vi.mock("@/lib/db", () => ({
|
||||
findMany: vi.fn(),
|
||||
updateMany: vi.fn(),
|
||||
},
|
||||
inboundDocument: {
|
||||
findMany: vi.fn(),
|
||||
},
|
||||
auditLog: { create: vi.fn() },
|
||||
},
|
||||
}));
|
||||
@@ -81,6 +84,7 @@ function storedRow(overrides: Record<string, unknown> = {}) {
|
||||
revokedAt: null,
|
||||
lastAccessAt: null,
|
||||
accessCount: 0,
|
||||
_count: { documents: 0 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -178,6 +182,84 @@ describe("POST /api/share-links — create", () => {
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("attaches own live documents in the create transaction (frozen write-once)", async () => {
|
||||
// The ownership probe returns exactly the requested (deduped) ids as live.
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ id: "doc-a" },
|
||||
{ id: "doc-b" },
|
||||
] as never);
|
||||
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
|
||||
storedRow({ _count: { documents: 2 } }) as never,
|
||||
);
|
||||
|
||||
const res = await POST(
|
||||
postReq(validBody({ documentIds: ["doc-a", "doc-b", "doc-a"] })),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { data: { documentCount: number } };
|
||||
expect(body.data.documentCount).toBe(2);
|
||||
|
||||
// Ownership was checked against the caller's own LIVE documents.
|
||||
const probeArg = vi.mocked(prisma.inboundDocument.findMany).mock
|
||||
.calls[0][0];
|
||||
expect(probeArg!.where).toEqual({
|
||||
id: { in: ["doc-a", "doc-b"] }, // deduped
|
||||
userId: "user-1",
|
||||
deletedAt: null,
|
||||
});
|
||||
|
||||
// Membership rows are created NESTED in the same create call (one txn),
|
||||
// deduped, never widened afterwards.
|
||||
const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
|
||||
.calls[0][0];
|
||||
expect(createArg.data.documents).toEqual({
|
||||
create: [{ documentId: "doc-a" }, { documentId: "doc-b" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a foreign / deleted document id at create (422, no link minted)", async () => {
|
||||
// Only one of the two requested ids resolves as the caller's own live doc.
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ id: "doc-a" },
|
||||
] as never);
|
||||
|
||||
const res = await POST(
|
||||
postReq(validBody({ documentIds: ["doc-a", "not-mine"] })),
|
||||
);
|
||||
expect(res.status).toBe(422);
|
||||
// No share is ever minted with a document the caller does not own (B5).
|
||||
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects more than the document cap via strict schema (422)", async () => {
|
||||
const tooMany = Array.from({ length: 51 }, (_, i) => `doc-${i}`);
|
||||
const res = await POST(postReq(validBody({ documentIds: tooMany })));
|
||||
expect(res.status).toBe(422);
|
||||
expect(prisma.inboundDocument.findMany).not.toHaveBeenCalled();
|
||||
expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows a documents-only share (empty report sections)", async () => {
|
||||
vi.mocked(prisma.inboundDocument.findMany).mockResolvedValue([
|
||||
{ id: "doc-a" },
|
||||
] as never);
|
||||
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
|
||||
storedRow({ resourceTypes: [], _count: { documents: 1 } }) as never,
|
||||
);
|
||||
const res = await POST(
|
||||
postReq(
|
||||
validBody({
|
||||
resourceTypes: [],
|
||||
allowFhirApi: false,
|
||||
documentIds: ["doc-a"],
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(res.status).toBe(201);
|
||||
const body = (await res.json()) as { data: { documentCount: number } };
|
||||
expect(body.data.documentCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/share-links — list", () => {
|
||||
|
||||
@@ -54,6 +54,27 @@ function resolveOrigin(request: NextRequest): string {
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The row selection every owner-facing projection reads. `_count.documents`
|
||||
* surfaces the size of the frozen document set without ever loading the ids
|
||||
* (let alone the blobs) — the metadata-only discipline (P3-D5).
|
||||
*/
|
||||
const shareLinkSummarySelect = {
|
||||
id: true,
|
||||
label: true,
|
||||
rangeStart: true,
|
||||
rangeEnd: true,
|
||||
resourceTypes: true,
|
||||
allowFhirApi: true,
|
||||
passphraseHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revokedAt: true,
|
||||
lastAccessAt: true,
|
||||
accessCount: true,
|
||||
_count: { select: { documents: true } },
|
||||
} as const;
|
||||
|
||||
/** Project a stored row to the safe owner-facing shape (never the token). */
|
||||
function toSummary(row: {
|
||||
id: string;
|
||||
@@ -68,6 +89,7 @@ function toSummary(row: {
|
||||
revokedAt: Date | null;
|
||||
lastAccessAt: Date | null;
|
||||
accessCount: number;
|
||||
_count: { documents: number };
|
||||
}) {
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -79,6 +101,9 @@ function toSummary(row: {
|
||||
// v1.18.7 — surface only WHETHER a passphrase guards the link, never the
|
||||
// hash itself. Legacy (null-hash) links read `false` and stay ungated.
|
||||
protected: row.passphraseHash !== null,
|
||||
// v1.28 — how many documents this share carries (never the ids, never the
|
||||
// bytes; the serve route is the only decrypt path).
|
||||
documentCount: row._count.documents,
|
||||
expiresAt: row.expiresAt.toISOString(),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null,
|
||||
@@ -120,6 +145,26 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
const rawPassphrase = generatePassphrase();
|
||||
const passphraseHash = hashPassphrase(normalisePassphrase(rawPassphrase)!);
|
||||
|
||||
// v1.28 — resolve the frozen document set. The ids are deduped (a client
|
||||
// could repeat one, which the unique membership index would otherwise
|
||||
// reject) and then validated as the caller's OWN live documents. A single
|
||||
// foreign / deleted / unknown id seals the whole create as 422 — no share is
|
||||
// ever minted with a document the caller does not own (B5). The rows are
|
||||
// created in the SAME transaction as the link (nested create), so the
|
||||
// membership is frozen write-once atomically with the scope columns.
|
||||
const documentIds = Array.from(new Set(input.documentIds ?? []));
|
||||
if (documentIds.length > 0) {
|
||||
const owned = await prisma.inboundDocument.findMany({
|
||||
where: { id: { in: documentIds }, userId: user.id, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (owned.length !== documentIds.length) {
|
||||
return apiError("One or more documents are not available to share", 422, {
|
||||
errorCode: "share-link.documents.invalid",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Field-by-field build — no mass assignment. Scope columns are written
|
||||
// exactly once here and never updated.
|
||||
const created = await prisma.clinicianShareLink.create({
|
||||
@@ -134,21 +179,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
resourceTypes: input.resourceTypes ?? [],
|
||||
allowFhirApi: input.allowFhirApi ?? false,
|
||||
expiresAt: new Date(input.expiresAt),
|
||||
documents:
|
||||
documentIds.length > 0
|
||||
? { create: documentIds.map((documentId) => ({ documentId })) }
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
rangeStart: true,
|
||||
rangeEnd: true,
|
||||
resourceTypes: true,
|
||||
allowFhirApi: true,
|
||||
passphraseHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revokedAt: true,
|
||||
lastAccessAt: true,
|
||||
accessCount: true,
|
||||
},
|
||||
select: shareLinkSummarySelect,
|
||||
});
|
||||
|
||||
await auditLog("share-link.create", {
|
||||
@@ -158,6 +194,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
|
||||
shareLinkId: created.id,
|
||||
resourceTypes: created.resourceTypes,
|
||||
allowFhirApi: created.allowFhirApi,
|
||||
documentCount: created._count.documents,
|
||||
expiresAt: created.expiresAt.toISOString(),
|
||||
},
|
||||
});
|
||||
@@ -188,20 +225,7 @@ export const GET = apiHandler(async () => {
|
||||
const rows = await prisma.clinicianShareLink.findMany({
|
||||
where: { userId: user.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
label: true,
|
||||
rangeStart: true,
|
||||
rangeEnd: true,
|
||||
resourceTypes: true,
|
||||
allowFhirApi: true,
|
||||
passphraseHash: true,
|
||||
expiresAt: true,
|
||||
createdAt: true,
|
||||
revokedAt: true,
|
||||
lastAccessAt: true,
|
||||
accessCount: true,
|
||||
},
|
||||
select: shareLinkSummarySelect,
|
||||
});
|
||||
|
||||
return apiSuccess({ shareLinks: rows.map(toSummary) });
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
/**
|
||||
* v1.28 (document vault, Phase 3) — public share-scoped document serve route
|
||||
* `GET /c/[token]/d/[id]`.
|
||||
*
|
||||
* Load-bearing behaviour under test: it resolves ONLY via the share token
|
||||
* (never a session), confines serving to the link's frozen document set (a
|
||||
* foreign / unknown / soft-deleted id → flat 404), re-enforces the passphrase
|
||||
* gate and revocation/expiry BEFORE any decrypt, applies the identical
|
||||
* serving-class posture as the owner route (inline Class A / attachment Class
|
||||
* B + octet-stream + nosniff + no-store, no route-level CSP), strips EXIF from
|
||||
* raster images on egress, and fails closed on a decrypt error.
|
||||
*/
|
||||
|
||||
// Reversible crypto stub (mirrors the owner /original route test) so the
|
||||
// document codec round-trips without real keys.
|
||||
const decryptMock = vi.fn((s: string) =>
|
||||
Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"),
|
||||
);
|
||||
vi.mock("@/lib/crypto", () => ({
|
||||
encrypt: (s: string) => `v1.${Buffer.from(s, "utf8").toString("base64")}`,
|
||||
decrypt: (s: string) => decryptMock(s),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
clinicianShareLinkDocument: { findUnique: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
|
||||
resolveShareGateState: vi.fn(),
|
||||
resolveShareToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/clinician-share/unlock-cookie", () => ({
|
||||
unlockCookieName: (h: string) => `hls_unlock_${h.slice(0, 16)}`,
|
||||
verifyUnlockValue: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ allowed: true, remaining: 299, resetAt: Date.now() }),
|
||||
rateLimitHeaders: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: vi.fn(async () => ({ get: () => null })),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: () => undefined,
|
||||
set: () => {},
|
||||
delete: () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
resolveShareGateState,
|
||||
resolveShareToken,
|
||||
} from "@/lib/clinician-share/resolve-share-token";
|
||||
import { verifyUnlockValue } from "@/lib/clinician-share/unlock-cookie";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { encryptDocumentToBytes } from "@/lib/documents/store";
|
||||
|
||||
const TOKEN = "hls_000000000000000000000000000000000000000000000000";
|
||||
const OWNER = "owner-1";
|
||||
const SHARE_LINK_ID = "link-1";
|
||||
|
||||
type Ctx = { params: Promise<{ token: string; id: string }> };
|
||||
const callGet = GET as unknown as (
|
||||
req: NextRequest,
|
||||
ctx: Ctx,
|
||||
) => Promise<Response>;
|
||||
|
||||
function makeReq(id: string): NextRequest {
|
||||
return new NextRequest(new URL(`http://localhost/c/${TOKEN}/d/${id}`));
|
||||
}
|
||||
function ctx(id: string): Ctx {
|
||||
return { params: Promise.resolve({ token: TOKEN, id }) };
|
||||
}
|
||||
|
||||
/** Seed a membership row whose document decrypts to `bytes` of `mimeType`. */
|
||||
function seedMembership(opts: {
|
||||
id?: string;
|
||||
userId?: string;
|
||||
filename?: string | null;
|
||||
mimeType: string;
|
||||
bytes: Buffer;
|
||||
deletedAt?: Date | null;
|
||||
}) {
|
||||
vi.mocked(prisma.clinicianShareLinkDocument.findUnique).mockResolvedValue({
|
||||
document: {
|
||||
id: opts.id ?? "doc-1",
|
||||
userId: opts.userId ?? OWNER,
|
||||
filename: opts.filename ?? "report.pdf",
|
||||
mimeType: opts.mimeType,
|
||||
contentCodec: "base64v1",
|
||||
contentEncrypted: encryptDocumentToBytes(opts.bytes),
|
||||
deletedAt: opts.deletedAt ?? null,
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
|
||||
const PDF_BYTES = Buffer.from("%PDF-1.7 fake report body");
|
||||
|
||||
// A minimal JPEG carrying a GPS marker inside an APP1 (Exif) segment.
|
||||
function jpegWithGps(): Buffer {
|
||||
const payload = Buffer.concat([
|
||||
Buffer.from("Exif\0\0"),
|
||||
Buffer.from("GPSLatitude=48.137"),
|
||||
]);
|
||||
const app1 = Buffer.concat([
|
||||
Buffer.from([0xff, 0xe1]),
|
||||
(() => {
|
||||
const b = Buffer.alloc(2);
|
||||
b.writeUInt16BE(payload.length + 2);
|
||||
return b;
|
||||
})(),
|
||||
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]);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
decryptMock.mockImplementation((s: string) =>
|
||||
Buffer.from(s.replace(/^v1\./, ""), "base64").toString("utf8"),
|
||||
);
|
||||
vi.mocked(verifyUnlockValue).mockReturnValue(true);
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 299,
|
||||
resetAt: Date.now(),
|
||||
} as never);
|
||||
// Default: a live, unprotected link resolving to the owner scope.
|
||||
vi.mocked(resolveShareGateState).mockResolvedValue({
|
||||
tokenHash: "hash-1",
|
||||
passphraseHash: null,
|
||||
} as never);
|
||||
vi.mocked(resolveShareToken).mockResolvedValue({
|
||||
shareLinkId: SHARE_LINK_ID,
|
||||
ownerUserId: OWNER,
|
||||
label: "Clinic",
|
||||
rangeStart: new Date(),
|
||||
rangeEnd: null,
|
||||
sectionsJson: {},
|
||||
resourceTypes: [],
|
||||
allowFhirApi: false,
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /c/[token]/d/[id] — share document serve", () => {
|
||||
it("serves a PDF inline through the token, confined to the frozen set", async () => {
|
||||
seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES });
|
||||
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/pdf");
|
||||
expect(res.headers.get("Content-Disposition")).toContain("inline");
|
||||
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
// The response CSP is proxy-owned; the route sets none.
|
||||
expect(res.headers.get("Content-Security-Policy")).toBeNull();
|
||||
const out = Buffer.from(await res.arrayBuffer());
|
||||
expect(out.equals(PDF_BYTES)).toBe(true);
|
||||
|
||||
// The membership lookup ties the id to THIS link (token-confined scope).
|
||||
const arg = vi.mocked(prisma.clinicianShareLinkDocument.findUnique).mock
|
||||
.calls[0][0];
|
||||
expect(arg!.where).toEqual({
|
||||
shareLinkId_documentId: {
|
||||
shareLinkId: SHARE_LINK_ID,
|
||||
documentId: "doc-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("strips EXIF/GPS from a shared JPEG on egress", async () => {
|
||||
const jpeg = jpegWithGps();
|
||||
seedMembership({ mimeType: "image/jpeg", bytes: jpeg, filename: "p.jpg" });
|
||||
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(200);
|
||||
const out = Buffer.from(await res.arrayBuffer());
|
||||
// The GPS marker is gone; the image is strictly smaller than the original.
|
||||
expect(out.includes(Buffer.from("GPSLatitude"))).toBe(false);
|
||||
expect(out.length).toBeLessThan(jpeg.length);
|
||||
// Still an inline image.
|
||||
expect(res.headers.get("Content-Type")).toBe("image/jpeg");
|
||||
expect(res.headers.get("Content-Disposition")).toContain("inline");
|
||||
});
|
||||
|
||||
it("serves a Class B document as an opaque attachment (never inline)", async () => {
|
||||
seedMembership({
|
||||
mimeType:
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
bytes: Buffer.from("docx payload"),
|
||||
filename: "letter.docx",
|
||||
});
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
||||
const disposition = res.headers.get("Content-Disposition") ?? "";
|
||||
expect(disposition.startsWith("attachment;")).toBe(true);
|
||||
expect(disposition).toContain("letter.docx");
|
||||
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
});
|
||||
|
||||
it("never serves a would-be HTML/SVG payload inline (byte-class denies it)", async () => {
|
||||
// HTML/SVG can never be stored (upload deny-list); even if a row carried
|
||||
// such a type, the serving class forces attachment + octet-stream so it
|
||||
// can never script in-origin.
|
||||
for (const mime of ["text/html", "image/svg+xml"]) {
|
||||
seedMembership({ mimeType: mime, bytes: Buffer.from("<svg/>") });
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
|
||||
expect(res.headers.get("Content-Disposition")).toContain("attachment");
|
||||
}
|
||||
});
|
||||
|
||||
it("404s a document id not on the link's frozen set", async () => {
|
||||
vi.mocked(prisma.clinicianShareLinkDocument.findUnique).mockResolvedValue(
|
||||
null as never,
|
||||
);
|
||||
const res = await callGet(makeReq("foreign"), ctx("foreign"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s a soft-deleted document even if the membership row survives", async () => {
|
||||
seedMembership({
|
||||
mimeType: "application/pdf",
|
||||
bytes: PDF_BYTES,
|
||||
deletedAt: new Date(),
|
||||
});
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("404s when the document owner does not match the token owner", async () => {
|
||||
seedMembership({
|
||||
mimeType: "application/pdf",
|
||||
bytes: PDF_BYTES,
|
||||
userId: "someone-else",
|
||||
});
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it("serves NOTHING for a revoked/expired/unknown token (gate null) — no decrypt", async () => {
|
||||
vi.mocked(resolveShareGateState).mockResolvedValue(null as never);
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(404);
|
||||
// Revocation is enforced BEFORE any decrypt or membership lookup.
|
||||
expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled();
|
||||
expect(resolveShareToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serves NOTHING for a protected link without a valid unlock cookie", async () => {
|
||||
vi.mocked(resolveShareGateState).mockResolvedValue({
|
||||
tokenHash: "hash-1",
|
||||
passphraseHash: "hash(pass)",
|
||||
} as never);
|
||||
vi.mocked(verifyUnlockValue).mockReturnValue(false);
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(404);
|
||||
// The passphrase gate stands in front of any decrypt.
|
||||
expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("429s when the per-link serve rate limit trips", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now(),
|
||||
} as never);
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(429);
|
||||
expect(prisma.clinicianShareLinkDocument.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("fails closed (500) on a decrypt error — never serves ciphertext", async () => {
|
||||
seedMembership({ mimeType: "application/pdf", bytes: PDF_BYTES });
|
||||
decryptMock.mockImplementation(() => {
|
||||
throw new Error("unknown key id");
|
||||
});
|
||||
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
|
||||
expect(res.status).toBe(500);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
@@ -0,0 +1,188 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { stripImageMetadata } from "@/lib/clinician-share/exif-strip";
|
||||
|
||||
/**
|
||||
* The stripper walks container structure and drops metadata carriers without
|
||||
* decoding pixels. These fixtures are hand-built minimal-but-valid segment
|
||||
* streams so we can assert exactly which markers survive.
|
||||
*/
|
||||
|
||||
function u16be(n: number): Buffer {
|
||||
const b = Buffer.alloc(2);
|
||||
b.writeUInt16BE(n);
|
||||
return b;
|
||||
}
|
||||
function u32be(n: number): Buffer {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n);
|
||||
return b;
|
||||
}
|
||||
function u32le(n: number): Buffer {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32LE(n);
|
||||
return b;
|
||||
}
|
||||
|
||||
/** A JPEG segment: FF <marker> <len BE incl. the 2 len bytes> <payload>. */
|
||||
function jpegSegment(marker: number, payload: Buffer): Buffer {
|
||||
return Buffer.concat([
|
||||
Buffer.from([0xff, marker]),
|
||||
u16be(payload.length + 2),
|
||||
payload,
|
||||
]);
|
||||
}
|
||||
|
||||
describe("stripImageMetadata — passthrough", () => {
|
||||
it("leaves non-strippable types byte-for-byte unchanged", () => {
|
||||
const pdf = Buffer.from("%PDF-1.4 fake pdf body");
|
||||
expect(stripImageMetadata(pdf, "application/pdf")).toBe(pdf);
|
||||
const tiff = Buffer.from([0x49, 0x49, 0x2a, 0x00, 1, 2, 3]);
|
||||
expect(stripImageMetadata(tiff, "image/tiff")).toBe(tiff);
|
||||
const heic = Buffer.from("ftyp-heic-fake");
|
||||
expect(stripImageMetadata(heic, "image/heic")).toBe(heic);
|
||||
});
|
||||
|
||||
it("returns malformed input for the claimed type unchanged (fail-safe)", () => {
|
||||
const notJpeg = Buffer.from([0x00, 0x01, 0x02, 0x03]);
|
||||
expect(stripImageMetadata(notJpeg, "image/jpeg")).toBe(notJpeg);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripImageMetadata — JPEG", () => {
|
||||
// SOI + APP0(JFIF) + APP1(Exif w/ GPS) + APP2(ICC) + COM + DQT + SOS + scan.
|
||||
const soi = Buffer.from([0xff, 0xd8]);
|
||||
const app0 = jpegSegment(0xe0, Buffer.from("JFIF\0\0\0\0\0\0"));
|
||||
const exifPayload = Buffer.concat([
|
||||
Buffer.from("Exif\0\0"),
|
||||
// A recognisable GPS marker string we can assert is gone.
|
||||
Buffer.from("GPSLatitude=48.137 GPSLongitude=11.575"),
|
||||
]);
|
||||
const app1 = jpegSegment(0xe1, exifPayload);
|
||||
const iccPayload = Buffer.concat([
|
||||
Buffer.from("ICC_PROFILE\0"),
|
||||
Buffer.from([1, 2, 3, 4]),
|
||||
]);
|
||||
const app2 = jpegSegment(0xe2, iccPayload);
|
||||
const com = jpegSegment(0xfe, Buffer.from("private comment"));
|
||||
const dqt = jpegSegment(0xdb, Buffer.from([0x00, 0x01, 0x02, 0x03]));
|
||||
const sosAndScan = Buffer.concat([
|
||||
Buffer.from([0xff, 0xda]),
|
||||
u16be(2 + 6),
|
||||
Buffer.from([0x01, 0x01, 0x00, 0x00, 0x3f, 0x00]),
|
||||
Buffer.from([0xaa, 0xbb, 0xcc, 0xdd]), // entropy-coded scan bytes
|
||||
]);
|
||||
const jpeg = Buffer.concat([soi, app0, app1, app2, com, dqt, sosAndScan]);
|
||||
|
||||
it("removes EXIF/GPS, XMP-carrying APP1, and the comment segment", () => {
|
||||
const out = stripImageMetadata(jpeg, "image/jpeg");
|
||||
expect(out.includes(Buffer.from("Exif\0\0"))).toBe(false);
|
||||
expect(out.includes(Buffer.from("GPSLatitude"))).toBe(false);
|
||||
expect(out.includes(Buffer.from("private comment"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps JFIF (APP0), ICC (APP2), the quantisation table, and the scan data", () => {
|
||||
const out = stripImageMetadata(jpeg, "image/jpeg");
|
||||
expect(out.subarray(0, 2)).toEqual(soi);
|
||||
expect(out.includes(Buffer.from("JFIF"))).toBe(true);
|
||||
expect(out.includes(Buffer.from("ICC_PROFILE"))).toBe(true);
|
||||
// The DQT segment and the entropy-coded scan tail must survive verbatim.
|
||||
expect(out.includes(dqt)).toBe(true);
|
||||
expect(out.includes(Buffer.from([0xaa, 0xbb, 0xcc, 0xdd]))).toBe(true);
|
||||
// Output is strictly smaller (metadata removed).
|
||||
expect(out.length).toBeLessThan(jpeg.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripImageMetadata — PNG", () => {
|
||||
const sig = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
function pngChunk(type: string, data: Buffer): Buffer {
|
||||
return Buffer.concat([
|
||||
u32be(data.length),
|
||||
Buffer.from(type, "ascii"),
|
||||
data,
|
||||
u32be(0),
|
||||
]);
|
||||
}
|
||||
const ihdr = pngChunk(
|
||||
"IHDR",
|
||||
Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0]),
|
||||
);
|
||||
const exif = pngChunk("eXIf", Buffer.from("GPSInfo private-location"));
|
||||
const text = pngChunk("tEXt", Buffer.from("Comment\0secret note"));
|
||||
const itxt = pngChunk(
|
||||
"iTXt",
|
||||
Buffer.from("XML:com.adobe.xmp\0\0\0\0\0<x:xmpmeta/>"),
|
||||
);
|
||||
const gama = pngChunk("gAMA", Buffer.from([0, 0, 0x8a, 0x3d]));
|
||||
const idat = pngChunk("IDAT", Buffer.from([0x78, 0x9c, 0x01, 0x00]));
|
||||
const iend = pngChunk("IEND", Buffer.alloc(0));
|
||||
const png = Buffer.concat([sig, ihdr, exif, text, itxt, gama, idat, iend]);
|
||||
|
||||
it("removes eXIf, tEXt, and iTXt (XMP) chunks", () => {
|
||||
const out = stripImageMetadata(png, "image/png");
|
||||
expect(out.includes(Buffer.from("GPSInfo"))).toBe(false);
|
||||
expect(out.includes(Buffer.from("secret note"))).toBe(false);
|
||||
expect(out.includes(Buffer.from("xmpmeta"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps IHDR, gAMA, IDAT, IEND and the signature", () => {
|
||||
const out = stripImageMetadata(png, "image/png");
|
||||
expect(out.subarray(0, 8)).toEqual(sig);
|
||||
expect(out.includes(gama)).toBe(true);
|
||||
expect(out.includes(idat)).toBe(true);
|
||||
expect(out.includes(iend)).toBe(true);
|
||||
expect(out.length).toBeLessThan(png.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripImageMetadata — WebP", () => {
|
||||
function webpChunk(fourcc: string, data: Buffer): Buffer {
|
||||
const padded =
|
||||
data.length % 2 === 1 ? Buffer.concat([data, Buffer.from([0])]) : data;
|
||||
return Buffer.concat([
|
||||
Buffer.from(fourcc, "ascii"),
|
||||
u32le(data.length),
|
||||
padded,
|
||||
]);
|
||||
}
|
||||
// VP8X with EXIF (0x08) + XMP (0x04) flags set, an image chunk, plus EXIF/XMP.
|
||||
const vp8xFlags = Buffer.from([0x08 | 0x04, 0, 0, 0, 0, 0, 0, 0, 0, 0]);
|
||||
const vp8x = webpChunk("VP8X", vp8xFlags);
|
||||
const vp8 = webpChunk("VP8 ", Buffer.from([0x11, 0x22, 0x33, 0x44]));
|
||||
const exif = webpChunk("EXIF", Buffer.from("GPSLocation private"));
|
||||
const xmp = webpChunk("XMP ", Buffer.from("<x:xmpmeta>secret</x:xmpmeta>"));
|
||||
const body = Buffer.concat([vp8x, vp8, exif, xmp]);
|
||||
const header = Buffer.concat([
|
||||
Buffer.from("RIFF", "ascii"),
|
||||
u32le(4 + body.length),
|
||||
Buffer.from("WEBP", "ascii"),
|
||||
]);
|
||||
const webp = Buffer.concat([header, body]);
|
||||
|
||||
it("removes the EXIF and XMP chunks", () => {
|
||||
const out = stripImageMetadata(webp, "image/webp");
|
||||
expect(out.includes(Buffer.from("GPSLocation"))).toBe(false);
|
||||
expect(out.includes(Buffer.from("xmpmeta"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the image chunk and clears the VP8X EXIF/XMP flag bits", () => {
|
||||
const out = stripImageMetadata(webp, "image/webp");
|
||||
expect(out.includes(Buffer.from([0x11, 0x22, 0x33, 0x44]))).toBe(true);
|
||||
// Locate VP8X and check its flag byte: EXIF/XMP bits cleared.
|
||||
const vp8xIdx = out.indexOf(Buffer.from("VP8X", "ascii"));
|
||||
expect(vp8xIdx).toBeGreaterThan(0);
|
||||
const flags = out[vp8xIdx + 8];
|
||||
expect(flags & 0x08).toBe(0);
|
||||
expect(flags & 0x04).toBe(0);
|
||||
});
|
||||
|
||||
it("rewrites the RIFF size header to match the trimmed body", () => {
|
||||
const out = stripImageMetadata(webp, "image/webp");
|
||||
expect(out.toString("ascii", 0, 4)).toBe("RIFF");
|
||||
expect(out.toString("ascii", 8, 12)).toBe("WEBP");
|
||||
// Declared size == 4 (for "WEBP") + the remaining chunk bytes.
|
||||
expect(out.readUInt32LE(4)).toBe(out.length - 8);
|
||||
});
|
||||
});
|
||||
@@ -16,12 +16,21 @@ vi.mock("@/lib/doctor-report-data", () => ({
|
||||
vi.mock("@/lib/validations/doctor-report-prefs", () => ({
|
||||
parseDoctorReportPrefs: vi.fn((s: unknown) => s ?? {}),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
clinicianShareLinkDocument: { findMany: vi.fn() },
|
||||
},
|
||||
}));
|
||||
|
||||
import { loadShareViewData } from "../share-view-data";
|
||||
import { collectDoctorReportData } from "@/lib/doctor-report-data";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { ShareContext } from "../resolve-share-token";
|
||||
|
||||
const collect = collectDoctorReportData as ReturnType<typeof vi.fn>;
|
||||
const findDocs = prisma.clinicianShareLinkDocument.findMany as ReturnType<
|
||||
typeof vi.fn
|
||||
>;
|
||||
|
||||
function ctx(overrides: Partial<ShareContext> = {}): ShareContext {
|
||||
return {
|
||||
@@ -44,6 +53,7 @@ describe("loadShareViewData — KVNR default OFF", () => {
|
||||
collect.mockResolvedValue({
|
||||
patient: { displayName: "Shared record" },
|
||||
});
|
||||
findDocs.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("scopes the aggregator to the OWNER from the share context, never the wire", async () => {
|
||||
@@ -71,6 +81,63 @@ describe("loadShareViewData — KVNR default OFF", () => {
|
||||
expect(patient).not.toHaveProperty("kvnr");
|
||||
});
|
||||
|
||||
it("lists the frozen document set as metadata only (never bytes)", async () => {
|
||||
findDocs.mockResolvedValue([
|
||||
{
|
||||
document: {
|
||||
id: "doc-a",
|
||||
title: "Blood panel",
|
||||
kind: "LAB_REPORT",
|
||||
documentDate: new Date("2026-01-15T00:00:00Z"),
|
||||
byteSize: 12345,
|
||||
mimeType: "application/pdf",
|
||||
},
|
||||
},
|
||||
{
|
||||
document: {
|
||||
id: "doc-b",
|
||||
title: null,
|
||||
kind: "OTHER",
|
||||
documentDate: null,
|
||||
byteSize: 6789,
|
||||
mimeType: "application/msword",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const { documents } = await loadShareViewData(ctx());
|
||||
|
||||
// Scoped to THIS link + owner + live rows; the blob column is never named.
|
||||
const arg = findDocs.mock.calls[0]![0] as {
|
||||
where: Record<string, unknown>;
|
||||
select: { document: { select: Record<string, boolean> } };
|
||||
};
|
||||
expect(arg.where).toEqual({
|
||||
shareLinkId: "link-1",
|
||||
document: { userId: "owner-1", deletedAt: null },
|
||||
});
|
||||
expect(arg.select.document.select).not.toHaveProperty("contentEncrypted");
|
||||
|
||||
expect(documents).toEqual([
|
||||
{
|
||||
id: "doc-a",
|
||||
title: "Blood panel",
|
||||
kind: "LAB_REPORT",
|
||||
documentDate: "2026-01-15",
|
||||
byteSize: 12345,
|
||||
servingClass: "inline",
|
||||
},
|
||||
{
|
||||
id: "doc-b",
|
||||
title: null,
|
||||
kind: "OTHER",
|
||||
documentDate: null,
|
||||
byteSize: 6789,
|
||||
servingClass: "attachment",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the frozen rangeStart and resolves a rolling rangeEnd to now", async () => {
|
||||
const now = Date.now();
|
||||
await loadShareViewData(ctx({ rangeEnd: null }));
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* v1.28 (document vault, Phase 3) — dependency-free metadata stripper for the
|
||||
* share egress boundary (P3-D4).
|
||||
*
|
||||
* A recipient of a shared document should not inadvertently receive the
|
||||
* owner's camera EXIF / GPS / XMP metadata. This module removes those markers
|
||||
* from the common camera-photo raster formats (JPEG, PNG, WebP) as the bytes
|
||||
* are served through the share route — NEVER at rest. The owner's stored
|
||||
* original is the artefact and is untouched (v1 §6.5): the strip is a pure
|
||||
* transform applied to a decrypted copy on its way out.
|
||||
*
|
||||
* Pure JS, no `sharp` / `libvips` / native decode (repo guardrail G6). We do
|
||||
* not decode or re-encode pixels: we walk the container's segment/chunk
|
||||
* structure and drop exactly the metadata carriers, copying every image-data
|
||||
* segment through verbatim. Output renders identically; only the metadata is
|
||||
* gone.
|
||||
*
|
||||
* Honest limits (documented in the operator docs + the share copy): PDF, TIFF,
|
||||
* HEIC/HEIF, and Office formats are out of this stripper's reach and pass
|
||||
* through unchanged. Those leave the origin as attachment-class downloads.
|
||||
* Any format we don't recognise — or any parse that runs off the end of the
|
||||
* buffer — is returned byte-for-byte unchanged (fail-safe: never corrupt a
|
||||
* document we can't confidently rewrite).
|
||||
*/
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
/** MIME types this stripper actively rewrites. Everything else passes through. */
|
||||
const STRIPPABLE_MIMES: ReadonlySet<string> = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Strip EXIF/XMP/GPS metadata from a raster image's bytes, dispatched by the
|
||||
* document's stored (magic-byte-sniffed) MIME type. Returns the original
|
||||
* buffer unchanged for any non-strippable type, and — fail-safe — for any
|
||||
* input that does not parse cleanly as the claimed format.
|
||||
*/
|
||||
export function stripImageMetadata(bytes: Buffer, mimeType: string): Buffer {
|
||||
if (!STRIPPABLE_MIMES.has(mimeType)) return bytes;
|
||||
try {
|
||||
switch (mimeType) {
|
||||
case "image/jpeg":
|
||||
return stripJpeg(bytes);
|
||||
case "image/png":
|
||||
return stripPng(bytes);
|
||||
case "image/webp":
|
||||
return stripWebp(bytes);
|
||||
default:
|
||||
return bytes;
|
||||
}
|
||||
} catch {
|
||||
// Never corrupt a document because the stripper tripped: on any parse
|
||||
// error, serve the original bytes. The serving-class posture (nosniff +
|
||||
// true type) is the security boundary; the strip is a privacy nicety.
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── JPEG ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A JPEG is `FFD8` (SOI) followed by marker segments. Each non-entropy marker
|
||||
// is `FF <marker> <len-hi> <len-lo> <payload…>` where `len` counts the two
|
||||
// length bytes + payload. We drop the metadata-bearing APP segments and the
|
||||
// comment segment, copy every other segment verbatim, and once we hit SOS
|
||||
// (`FFDA`) copy the rest of the file (entropy-coded scan data) unchanged.
|
||||
//
|
||||
// Dropped: APP1 (`FFE1`, Exif + XMP), APP13 (`FFED`, Photoshop / IPTC), and
|
||||
// COM (`FFFE`, free-text comment). Kept: APP0 (`FFE0`, JFIF — some decoders
|
||||
// expect it) and APP2 (`FFE2`, ICC colour profile — dropping it would shift
|
||||
// colours), plus every structural segment (quantisation/Huffman tables, frame
|
||||
// headers, …).
|
||||
|
||||
const JPEG_DROP_MARKERS: ReadonlySet<number> = new Set([
|
||||
0xe1, // APP1 — Exif, XMP
|
||||
0xed, // APP13 — Photoshop / IPTC-IIM
|
||||
0xfe, // COM — comment
|
||||
]);
|
||||
|
||||
function stripJpeg(bytes: Buffer): Buffer {
|
||||
if (bytes.length < 2 || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
|
||||
return bytes; // not a JPEG SOI — pass through
|
||||
}
|
||||
const out: Buffer[] = [bytes.subarray(0, 2)]; // SOI
|
||||
let offset = 2;
|
||||
while (offset + 1 < bytes.length) {
|
||||
if (bytes[offset] !== 0xff) {
|
||||
// Misaligned — bail and keep the remainder verbatim (fail-safe).
|
||||
out.push(bytes.subarray(offset));
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
const marker = bytes[offset + 1];
|
||||
// Start Of Scan: everything after is entropy-coded image data. Copy it all.
|
||||
if (marker === 0xda) {
|
||||
out.push(bytes.subarray(offset));
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
// Standalone markers without a length payload (RSTn, TEM) — copy the 2
|
||||
// marker bytes and continue.
|
||||
if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd7)) {
|
||||
out.push(bytes.subarray(offset, offset + 2));
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
// Length-prefixed segment.
|
||||
if (offset + 4 > bytes.length) {
|
||||
out.push(bytes.subarray(offset));
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
const segLen = bytes.readUInt16BE(offset + 2);
|
||||
const segEnd = offset + 2 + segLen;
|
||||
if (segLen < 2 || segEnd > bytes.length) {
|
||||
// Corrupt length — keep the remainder verbatim rather than lose data.
|
||||
out.push(bytes.subarray(offset));
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
if (!JPEG_DROP_MARKERS.has(marker)) {
|
||||
out.push(bytes.subarray(offset, segEnd));
|
||||
}
|
||||
offset = segEnd;
|
||||
}
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
|
||||
// ─── PNG ─────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// An 8-byte signature followed by `length(4 BE) type(4 ascii) data crc(4)`
|
||||
// chunks. We drop the ancillary metadata chunks and copy everything else
|
||||
// (IHDR, PLTE, IDAT, IEND, gAMA, cHRM, iCCP, sRGB, …) verbatim.
|
||||
//
|
||||
// Dropped: `eXIf` (EXIF), `tEXt` / `zTXt` / `iTXt` (text incl. XMP), `tIME`
|
||||
// (last-modified timestamp).
|
||||
|
||||
const PNG_SIGNATURE = Buffer.from([
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
|
||||
]);
|
||||
|
||||
const PNG_DROP_CHUNKS: ReadonlySet<string> = new Set([
|
||||
"eXIf",
|
||||
"tEXt",
|
||||
"zTXt",
|
||||
"iTXt",
|
||||
"tIME",
|
||||
]);
|
||||
|
||||
function stripPng(bytes: Buffer): Buffer {
|
||||
if (bytes.length < 8 || !bytes.subarray(0, 8).equals(PNG_SIGNATURE)) {
|
||||
return bytes;
|
||||
}
|
||||
const out: Buffer[] = [bytes.subarray(0, 8)];
|
||||
let offset = 8;
|
||||
while (offset + 8 <= bytes.length) {
|
||||
const dataLen = bytes.readUInt32BE(offset);
|
||||
const type = bytes.toString("ascii", offset + 4, offset + 8);
|
||||
const chunkEnd = offset + 12 + dataLen; // len + type + data + crc
|
||||
if (chunkEnd > bytes.length) {
|
||||
// Truncated chunk — keep the remainder verbatim (fail-safe).
|
||||
out.push(bytes.subarray(offset));
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
if (!PNG_DROP_CHUNKS.has(type)) {
|
||||
out.push(bytes.subarray(offset, chunkEnd));
|
||||
}
|
||||
offset = chunkEnd;
|
||||
if (type === "IEND") break; // nothing legitimate follows IEND
|
||||
}
|
||||
return Buffer.concat(out);
|
||||
}
|
||||
|
||||
// ─── WebP ────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// A RIFF container: `"RIFF" <size LE> "WEBP"` then FourCC chunks
|
||||
// (`<fourcc(4)> <size LE(4)> <payload…>`, payload padded to an even length).
|
||||
// We drop the `EXIF` and `XMP ` chunks and, when the extended header `VP8X` is
|
||||
// present, clear its EXIF/XMP presence flag bits so the rewritten file is
|
||||
// self-consistent. The image-data chunks (VP8/VP8L/VP8X/ALPH/ANIM/ANMF) copy
|
||||
// through verbatim.
|
||||
|
||||
function stripWebp(bytes: Buffer): Buffer {
|
||||
if (
|
||||
bytes.length < 12 ||
|
||||
bytes.toString("ascii", 0, 4) !== "RIFF" ||
|
||||
bytes.toString("ascii", 8, 12) !== "WEBP"
|
||||
) {
|
||||
return bytes;
|
||||
}
|
||||
const kept: Buffer[] = [];
|
||||
let offset = 12;
|
||||
while (offset + 8 <= bytes.length) {
|
||||
const fourcc = bytes.toString("ascii", offset, offset + 4);
|
||||
const chunkSize = bytes.readUInt32LE(offset + 4);
|
||||
const padded = chunkSize + (chunkSize % 2); // chunks pad to even length
|
||||
const payloadStart = offset + 8;
|
||||
const chunkEnd = payloadStart + padded;
|
||||
if (chunkEnd > bytes.length) {
|
||||
// Truncated — keep the remainder verbatim (fail-safe).
|
||||
kept.push(bytes.subarray(offset));
|
||||
offset = bytes.length;
|
||||
break;
|
||||
}
|
||||
if (fourcc === "EXIF" || fourcc === "XMP ") {
|
||||
// Drop the metadata chunk entirely.
|
||||
offset = chunkEnd;
|
||||
continue;
|
||||
}
|
||||
if (fourcc === "VP8X") {
|
||||
// Extended-format header: byte 0 of the payload carries the feature
|
||||
// flags. Clear the EXIF (0x08) and XMP (0x04) presence bits so the
|
||||
// header no longer advertises metadata we just removed.
|
||||
const chunk = Buffer.from(bytes.subarray(offset, chunkEnd));
|
||||
chunk[8] = chunk[8] & ~0x08 & ~0x04;
|
||||
kept.push(chunk);
|
||||
offset = chunkEnd;
|
||||
continue;
|
||||
}
|
||||
kept.push(bytes.subarray(offset, chunkEnd));
|
||||
offset = chunkEnd;
|
||||
}
|
||||
// Rebuild the RIFF header with the recomputed payload size (the sum of the
|
||||
// kept chunks plus the trailing "WEBP" FourCC).
|
||||
const body = Buffer.concat(kept);
|
||||
const header = Buffer.alloc(12);
|
||||
header.write("RIFF", 0, "ascii");
|
||||
header.writeUInt32LE(4 + body.length, 4); // "WEBP" + chunk bytes
|
||||
header.write("WEBP", 8, "ascii");
|
||||
return Buffer.concat([header, body]);
|
||||
}
|
||||
@@ -12,19 +12,40 @@
|
||||
* them under an explicit "not a clinical assessment" card), but they are read
|
||||
* straight from the aggregator — no AI call, no coach, no insight generation.
|
||||
*/
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
collectDoctorReportData,
|
||||
type DoctorReportData,
|
||||
type DoctorReportRange,
|
||||
} from "@/lib/doctor-report-data";
|
||||
import { servingClassFor } from "@/lib/documents/upload-policy";
|
||||
import type { DocumentServingClass } from "@/lib/documents/upload-policy";
|
||||
import { parseDoctorReportPrefs } from "@/lib/validations/doctor-report-prefs";
|
||||
import type { ShareContext } from "@/lib/clinician-share/resolve-share-token";
|
||||
|
||||
/**
|
||||
* v1.28 — metadata for one document on the share's frozen set. NEVER carries
|
||||
* bytes: the share serve route (`/c/<token>/d/<id>`) is the only decrypt path
|
||||
* (P3-D5). The recipient view renders this list and points each entry at that
|
||||
* route (Class A inline preview / Class B download).
|
||||
*/
|
||||
export interface ShareViewDocument {
|
||||
id: string;
|
||||
title: string | null;
|
||||
kind: string;
|
||||
/** Filing date (YYYY-MM-DD at UTC) or null. */
|
||||
documentDate: string | null;
|
||||
byteSize: number;
|
||||
servingClass: DocumentServingClass;
|
||||
}
|
||||
|
||||
export interface ShareViewData {
|
||||
/** The aggregated, owner-scoped report payload over the frozen window. */
|
||||
report: DoctorReportData;
|
||||
/** The resolved section toggles (mood opt-in, defaults otherwise). */
|
||||
sections: ReturnType<typeof parseDoctorReportPrefs>;
|
||||
/** v1.28 — the hand-picked documents on this link (metadata only). */
|
||||
documents: ShareViewDocument[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,9 +74,51 @@ export async function loadShareViewData(
|
||||
const sections = parseDoctorReportPrefs(context.sectionsJson);
|
||||
const range = frozenRange(context);
|
||||
|
||||
const report = await collectDoctorReportData(context.ownerUserId, range, {
|
||||
sections,
|
||||
const [report, documents] = await Promise.all([
|
||||
collectDoctorReportData(context.ownerUserId, range, { sections }),
|
||||
loadShareDocuments(context),
|
||||
]);
|
||||
|
||||
return { report, sections, documents };
|
||||
}
|
||||
|
||||
/**
|
||||
* The frozen document set for a resolved share, as metadata only. Scoped to
|
||||
* the link's membership rows AND the owner (defence in depth) AND live rows —
|
||||
* a document the owner soft-deleted after sharing drops out of the list, just
|
||||
* as it 404s at the serve route. The blob column is never selected.
|
||||
*/
|
||||
async function loadShareDocuments(
|
||||
context: ShareContext,
|
||||
): Promise<ShareViewDocument[]> {
|
||||
const rows = await prisma.clinicianShareLinkDocument.findMany({
|
||||
where: {
|
||||
shareLinkId: context.shareLinkId,
|
||||
document: { userId: context.ownerUserId, deletedAt: null },
|
||||
},
|
||||
select: {
|
||||
document: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
kind: true,
|
||||
documentDate: true,
|
||||
byteSize: true,
|
||||
mimeType: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
});
|
||||
|
||||
return { report, sections };
|
||||
return rows.map(({ document }) => ({
|
||||
id: document.id,
|
||||
title: document.title,
|
||||
kind: document.kind,
|
||||
documentDate: document.documentDate
|
||||
? document.documentDate.toISOString().slice(0, 10)
|
||||
: null,
|
||||
byteSize: document.byteSize,
|
||||
servingClass: servingClassFor(document.mimeType),
|
||||
}));
|
||||
}
|
||||
@@ -25,6 +25,9 @@ const shareLinkSummary = z.object({
|
||||
// v1.18.7 — whether a passphrase second factor guards this link. Always true
|
||||
// for links created on v1.18.7+; false only for legacy links.
|
||||
protected: z.boolean(),
|
||||
// v1.28 — how many documents this share carries. Never the ids, never the
|
||||
// bytes; the share serve route is the only decrypt path.
|
||||
documentCount: z.number(),
|
||||
expiresAt: z.string(),
|
||||
createdAt: z.string(),
|
||||
revokedAt: z.string().nullable(),
|
||||
@@ -103,7 +106,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
tags: ["Export"],
|
||||
summary: "Create a clinician share link (v1.11.0)",
|
||||
description:
|
||||
"Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY 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:<userId>`, 20/h). Strict: unknown keys 422.",
|
||||
"Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY 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. 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:<userId>`, 20/h). Strict: unknown keys 422.",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
@@ -204,6 +207,40 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
|
||||
},
|
||||
},
|
||||
},
|
||||
"/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.",
|
||||
requestParams: {
|
||||
path: z.object({
|
||||
token: z.string().describe("Raw `hls_` share token."),
|
||||
id: z.string().describe("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: z.string().meta({ 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: errorEnvelope } },
|
||||
},
|
||||
"429": {
|
||||
description: "Per-link serve rate limit exceeded.",
|
||||
content: { "application/json": { schema: errorEnvelope } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/fhir/metadata": {
|
||||
get: {
|
||||
tags: ["FHIR"],
|
||||
|
||||
@@ -33,10 +33,22 @@ export const shareLinkResourceTypeEnum = z.enum(SHARE_LINK_RESOURCE_TYPES);
|
||||
|
||||
const MAX_FUTURE_MS = SHARE_LINK_MAX_DAYS * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.28 — the most documents a single share may carry. A share is a hand-picked
|
||||
* handover, not a bulk dump; the cap bounds the create-time ownership check and
|
||||
* the public view's per-blob fetch fan-out. The ids are validated as the
|
||||
* caller's own LIVE documents in the route (a schema can only bound the shape).
|
||||
*/
|
||||
export const SHARE_LINK_MAX_DOCUMENTS = 50;
|
||||
|
||||
/**
|
||||
* Create payload. `expiresAt` is an absolute ISO instant, must be in the
|
||||
* future, and at most `SHARE_LINK_MAX_DAYS` ahead of now. `rangeStart` /
|
||||
* `rangeEnd` are the frozen reporting window (rangeEnd null = rolling).
|
||||
* `documentIds` (optional) is the hand-picked document set, frozen write-once
|
||||
* at create; each id is ownership-checked in the route before any row is
|
||||
* written. A documents-only share (empty report sections + a non-empty
|
||||
* `documentIds`) is valid.
|
||||
*/
|
||||
export const createShareLinkSchema = z
|
||||
.object({
|
||||
@@ -46,6 +58,10 @@ export const createShareLinkSchema = z
|
||||
sections: exportSectionsSchema.optional(),
|
||||
resourceTypes: z.array(shareLinkResourceTypeEnum).max(8).optional(),
|
||||
allowFhirApi: z.boolean().optional(),
|
||||
documentIds: z
|
||||
.array(z.string().trim().min(1).max(64))
|
||||
.max(SHARE_LINK_MAX_DOCUMENTS)
|
||||
.optional(),
|
||||
expiresAt: z.iso
|
||||
.datetime({ offset: true })
|
||||
.refine((v) => new Date(v).getTime() > Date.now(), {
|
||||
|
||||
+18
-6
@@ -313,12 +313,23 @@ export function proxy(request: NextRequest) {
|
||||
const isDocumentServeRoute =
|
||||
/^\/api\/documents\/inbound\/[^/]+\/original$/.test(pathname);
|
||||
|
||||
// v1.28 — the public share-scoped document serve route at
|
||||
// `/c/<token>/d/<id>` is the share analogue of the owner `/original` route:
|
||||
// it decrypts and serves a user-uploaded document that the clinician view
|
||||
// (`/c/<token>`) frames same-origin (an <iframe> for inline-class PDFs). It
|
||||
// needs the SAME narrow posture — SAMEORIGIN framing + a document CSP — and
|
||||
// NOT the `/c/` page CSP, which pins `frame-ancestors 'none'` and would
|
||||
// refuse the preview embed. Anchored to EXACTLY two dynamic segments so a
|
||||
// `/c/<token>` page, a `/c/<token>/d/<id>/extra`, or a `/c/<token>/dx/<id>`
|
||||
// lookalike never inherits the carve-out.
|
||||
const isShareDocumentServeRoute = /^\/c\/[^/]+\/d\/[^/]+$/.test(pathname);
|
||||
|
||||
// Both serve routes take the narrow, document-only header posture.
|
||||
const isServeRoute = isDocumentServeRoute || isShareDocumentServeRoute;
|
||||
|
||||
// Security headers
|
||||
response.headers.set("X-Content-Type-Options", "nosniff");
|
||||
response.headers.set(
|
||||
"X-Frame-Options",
|
||||
isDocumentServeRoute ? "SAMEORIGIN" : "DENY",
|
||||
);
|
||||
response.headers.set("X-Frame-Options", isServeRoute ? "SAMEORIGIN" : "DENY");
|
||||
response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
response.headers.set(
|
||||
"Permissions-Policy",
|
||||
@@ -405,8 +416,9 @@ export function proxy(request: NextRequest) {
|
||||
// page-load. Avatars now live on the User row and serve from
|
||||
// same-origin `/api/user/avatar/{id}`, so `img-src 'self'` covers
|
||||
// them.
|
||||
const csp = isDocumentServeRoute
|
||||
? // Vault serve route (see the X-Frame-Options note above): the
|
||||
const csp = isServeRoute
|
||||
? // Vault serve route (owner `/original` OR share `/c/<token>/d/<id>`; see
|
||||
// the X-Frame-Options note above): the
|
||||
// response is a user-uploaded document, not an app page.
|
||||
// `default-src 'none'` — a served document may load nothing as if
|
||||
// it were a page — and `frame-ancestors 'self'` permits exactly the
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* Document vault, Phase 3 — share a document through the clinician link, then
|
||||
* serve it through the public token, end-to-end against a real Postgres.
|
||||
*
|
||||
* Covers the properties the unit mocks cannot pin:
|
||||
* 1. Migration 0229: the `clinician_share_link_documents` join + its cascade.
|
||||
* 2. Create-with-documents: own live doc frozen onto the link in one txn;
|
||||
* a foreign / unknown id is refused at create (no link minted).
|
||||
* 3. The public serve route: passphrase-gated, token-confined to the frozen
|
||||
* set, serves the exact stored bytes with the class-correct posture; a
|
||||
* foreign id and a revoked link both 404.
|
||||
* 4. Deleting the document cascades its membership out of the link.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
process.env.ENCRYPTION_KEY ??=
|
||||
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
process.env.API_TOKEN_HMAC_KEY ??=
|
||||
"integration-share-link-hmac-key-at-least-32-chars";
|
||||
|
||||
import { cookieJar, headerJar } from "./mock-next-headers";
|
||||
import { getPrismaClient, truncateAllTables } from "./setup";
|
||||
|
||||
vi.mock("next/headers", async () => {
|
||||
const { cookieJar, headerJar } = await import("./mock-next-headers");
|
||||
return {
|
||||
headers: vi.fn(async () => ({
|
||||
get: (name: string) => headerJar.get(name.toLowerCase()) ?? null,
|
||||
})),
|
||||
cookies: vi.fn(async () => ({
|
||||
get: (name: string) => {
|
||||
const value = cookieJar.get(name);
|
||||
return value ? { name, value } : undefined;
|
||||
},
|
||||
set: (name: string, value: string) => {
|
||||
cookieJar.set(name, value);
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/db-compat", () => ({
|
||||
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
const PDF_SMALL = Buffer.from("%PDF-1.7\n1 0 obj\n<<>>\nendobj\n%%EOF\n");
|
||||
|
||||
async function seedVaultUser(username: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
email: `${username}@example.test`,
|
||||
role: "USER",
|
||||
timezone: "Europe/Berlin",
|
||||
modulePreferencesJson: { inboundDocuments: true },
|
||||
},
|
||||
});
|
||||
const session = await prisma.session.create({
|
||||
data: { userId: user.id, expiresAt: new Date(Date.now() + 600_000) },
|
||||
});
|
||||
cookieJar.set("healthlog_session", session.id);
|
||||
return user;
|
||||
}
|
||||
|
||||
function uploadRequest(bytes: Buffer, filename: string): Request {
|
||||
const payload = new Uint8Array(bytes.byteLength);
|
||||
payload.set(bytes);
|
||||
const formData = new FormData();
|
||||
formData.append("file", new Blob([payload]), filename);
|
||||
return new Request("http://localhost/api/documents/inbound", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
}
|
||||
|
||||
function jsonRequest(url: string, method: string, body: unknown): Request {
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function routes() {
|
||||
const upload = await import("@/app/api/documents/inbound/route");
|
||||
const shareLinks = await import("@/app/api/share-links/route");
|
||||
const unlock = await import("@/app/api/c/[token]/unlock/route");
|
||||
const serve = await import("@/app/c/[token]/d/[id]/route");
|
||||
return {
|
||||
upload: upload.POST as unknown as (r: Request) => Promise<Response>,
|
||||
createShare: shareLinks.POST as unknown as (
|
||||
r: Request,
|
||||
) => Promise<Response>,
|
||||
unlock: unlock.POST as unknown as (
|
||||
r: Request,
|
||||
c: { params: Promise<{ token: string }> },
|
||||
) => Promise<Response>,
|
||||
serve: serve.GET as unknown as (
|
||||
r: Request,
|
||||
c: { params: Promise<{ token: string; id: string }> },
|
||||
) => Promise<Response>,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await truncateAllTables(getPrismaClient());
|
||||
cookieJar.clear();
|
||||
headerJar.clear();
|
||||
});
|
||||
|
||||
describe("share-link documents — end-to-end", () => {
|
||||
it("attaches an own document, then serves it through the unlocked token", async () => {
|
||||
await seedVaultUser("share-owner");
|
||||
const prisma = getPrismaClient();
|
||||
const { upload, createShare, unlock, serve } = await routes();
|
||||
|
||||
// Upload a document the owner will share.
|
||||
const up = await upload(uploadRequest(PDF_SMALL, "befund.pdf"));
|
||||
expect(up.status).toBe(201);
|
||||
const docId = (await up.json()).data.id as string;
|
||||
|
||||
// Create a share link carrying exactly that document.
|
||||
const created = await createShare(
|
||||
jsonRequest("http://localhost/api/share-links", "POST", {
|
||||
label: "Cardiology",
|
||||
rangeStart: "2026-01-01T00:00:00Z",
|
||||
rangeEnd: null,
|
||||
expiresAt: new Date(Date.now() + 7 * 86_400_000).toISOString(),
|
||||
documentIds: [docId],
|
||||
}),
|
||||
);
|
||||
expect(created.status).toBe(201);
|
||||
const share = (await created.json()).data as {
|
||||
token: string;
|
||||
passphrase: string;
|
||||
documentCount: number;
|
||||
};
|
||||
expect(share.documentCount).toBe(1);
|
||||
|
||||
// The membership row exists in the DB (join table, migration 0229).
|
||||
expect(await prisma.clinicianShareLinkDocument.count()).toBe(1);
|
||||
|
||||
const serveUrl = `http://localhost/c/${share.token}/d/${docId}`;
|
||||
|
||||
// Before unlock: the passphrase gate serves nothing.
|
||||
const locked = await serve(new Request(serveUrl), {
|
||||
params: Promise.resolve({ token: share.token, id: docId }),
|
||||
});
|
||||
expect(locked.status).toBe(404);
|
||||
|
||||
// Unlock with the passphrase → the token-scoped cookie is set.
|
||||
const unlocked = await unlock(
|
||||
jsonRequest(`http://localhost/api/c/${share.token}/unlock`, "POST", {
|
||||
passphrase: share.passphrase,
|
||||
}),
|
||||
{ params: Promise.resolve({ token: share.token }) },
|
||||
);
|
||||
expect(unlocked.status).toBe(200);
|
||||
|
||||
// Now the blob serves: exact bytes, Class A inline posture.
|
||||
const ok = await serve(new Request(serveUrl), {
|
||||
params: Promise.resolve({ token: share.token, id: docId }),
|
||||
});
|
||||
expect(ok.status).toBe(200);
|
||||
expect(ok.headers.get("Content-Type")).toBe("application/pdf");
|
||||
expect(ok.headers.get("Content-Disposition")).toContain("inline");
|
||||
expect(ok.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(ok.headers.get("Content-Security-Policy")).toBeNull();
|
||||
const served = Buffer.from(await ok.arrayBuffer());
|
||||
expect(served.equals(PDF_SMALL)).toBe(true);
|
||||
|
||||
// A document id NOT on the frozen set is unreachable through the token.
|
||||
const foreign = await serve(
|
||||
new Request(`http://localhost/c/${share.token}/d/does-not-exist`),
|
||||
{ params: Promise.resolve({ token: share.token, id: "does-not-exist" }) },
|
||||
);
|
||||
expect(foreign.status).toBe(404);
|
||||
|
||||
// Revoke the link → the blob serves nothing (revocation before decrypt).
|
||||
await prisma.clinicianShareLink.updateMany({
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
const revoked = await serve(new Request(serveUrl), {
|
||||
params: Promise.resolve({ token: share.token, id: docId }),
|
||||
});
|
||||
expect(revoked.status).toBe(404);
|
||||
});
|
||||
|
||||
it("refuses a share create referencing a document the caller does not own", async () => {
|
||||
// Owner A uploads a document.
|
||||
const _a = await seedVaultUser("share-a");
|
||||
const { upload, createShare } = await routes();
|
||||
const up = await upload(uploadRequest(PDF_SMALL, "a.pdf"));
|
||||
const aDocId = (await up.json()).data.id as string;
|
||||
|
||||
// Owner B tries to attach A's document id to their own link.
|
||||
cookieJar.clear();
|
||||
await seedVaultUser("share-b");
|
||||
const created = await createShare(
|
||||
jsonRequest("http://localhost/api/share-links", "POST", {
|
||||
label: "Steal",
|
||||
rangeStart: "2026-01-01T00:00:00Z",
|
||||
rangeEnd: null,
|
||||
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
documentIds: [aDocId],
|
||||
}),
|
||||
);
|
||||
expect(created.status).toBe(422);
|
||||
// No link and no membership row were created.
|
||||
expect(await getPrismaClient().clinicianShareLink.count()).toBe(0);
|
||||
expect(await getPrismaClient().clinicianShareLinkDocument.count()).toBe(0);
|
||||
});
|
||||
|
||||
it("drops a shared document out of the link when the owner deletes it (cascade)", async () => {
|
||||
await seedVaultUser("share-cascade");
|
||||
const prisma = getPrismaClient();
|
||||
const { upload, createShare } = await routes();
|
||||
|
||||
const up = await upload(uploadRequest(PDF_SMALL, "c.pdf"));
|
||||
const docId = (await up.json()).data.id as string;
|
||||
await createShare(
|
||||
jsonRequest("http://localhost/api/share-links", "POST", {
|
||||
label: "Cascade",
|
||||
rangeStart: "2026-01-01T00:00:00Z",
|
||||
rangeEnd: null,
|
||||
expiresAt: new Date(Date.now() + 86_400_000).toISOString(),
|
||||
documentIds: [docId],
|
||||
}),
|
||||
);
|
||||
expect(await prisma.clinicianShareLinkDocument.count()).toBe(1);
|
||||
|
||||
// Hard-delete the document → its membership cascades away cleanly.
|
||||
await prisma.inboundDocument.delete({ where: { id: docId } });
|
||||
expect(await prisma.clinicianShareLinkDocument.count()).toBe(0);
|
||||
// The link itself survives (a share can lose all its documents).
|
||||
expect(await prisma.clinicianShareLink.count()).toBe(1);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user