diff --git a/src/app/api/share-links/[id]/route.ts b/src/app/api/share-links/[id]/route.ts new file mode 100644 index 000000000..c21710b61 --- /dev/null +++ b/src/app/api/share-links/[id]/route.ts @@ -0,0 +1,57 @@ +/** + * DELETE /api/share-links/{id} — owner revokes one of their own share links. + * + * Revocation is a soft state change: it sets `revokedAt` (the scope columns + * stay frozen). A cross-user id, an unknown id, or an already-revoked link all + * resolve to 404 — the existence channel is sealed against probing another + * user's ids. `userId` is narrowed from `requireAuth`; the update `where` + * always pins both `id` AND `userId`. + */ +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { apiError, apiSuccess, getClientIp } from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; + +export const DELETE = apiHandler( + async ( + request: NextRequest, + ctx: { params: Promise<{ id: string }> }, + ) => { + const { user } = await requireAuth(); + const { id } = await ctx.params; + annotate({ action: { name: "share-link.revoke" }, meta: { shareLinkId: id } }); + + const rl = await checkRateLimit( + `share-link:${user.id}`, + 20, + 60 * 60 * 1000, + ); + if (!rl.allowed) { + return apiError("Maximum 20 share-link operations per hour", 429); + } + + // Revoke only an own, not-yet-revoked link. The compound where pins the + // owner so a cross-user id can never be touched; updateMany returns a + // count so we can map "nothing matched" to a sealed 404. + const result = await prisma.clinicianShareLink.updateMany({ + where: { id, userId: user.id, revokedAt: null }, + data: { revokedAt: new Date() }, + }); + + if (result.count === 0) { + return apiError("Share link not found", 404); + } + + await auditLog("share-link.revoke", { + userId: user.id, + ipAddress: getClientIp(request), + details: { shareLinkId: id }, + }); + + return apiSuccess({ id, revoked: true }); + }, +); diff --git a/src/app/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts new file mode 100644 index 000000000..78f4f4271 --- /dev/null +++ b/src/app/api/share-links/__tests__/route.test.ts @@ -0,0 +1,195 @@ +/** + * v1.11.0 (Epic C, C4) — owner clinician-share-link lifecycle. + * + * Asserts the security-load-bearing properties: create returns the raw token + * exactly once and persists ONLY its hash; the expiry cap is enforced; revoke + * flips `revokedAt`; a cross-user revoke is sealed as 404. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + clinicianShareLink: { + create: vi.fn(), + findMany: vi.fn(), + updateMany: vi.fn(), + }, + auditLog: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` })); +vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); +vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() })); +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 { POST, GET } from "../route"; +import { DELETE } from "../[id]/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/share-links", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +function validBody(overrides: Record = {}) { + return { + label: "Cardiology clinic", + rangeStart: "2026-01-01T00:00:00Z", + rangeEnd: null, + resourceTypes: ["Observation"], + allowFhirApi: true, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(), + ...overrides, + }; +} + +function storedRow(overrides: Record = {}) { + return { + id: "link-1", + label: "Cardiology clinic", + rangeStart: new Date("2026-01-01T00:00:00Z"), + rangeEnd: null, + resourceTypes: ["Observation"], + allowFhirApi: true, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + createdAt: new Date(), + revokedAt: null, + lastAccessAt: null, + accessCount: 0, + ...overrides, + }; +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + count: 1, + resetAt: new Date(), + } as never); +}); + +describe("POST /api/share-links — create", () => { + it("returns the raw token once and persists ONLY its hash", async () => { + vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue( + storedRow() as never, + ); + + const res = await POST(postReq(validBody())); + expect(res.status).toBe(201); + const body = (await res.json()) as { data: { token: string; id: string } }; + + // Raw token returned, prefixed, and 48 hex chars (192-bit). + expect(body.data.token).toMatch(/^hls_[0-9a-f]{48}$/); + + // The persisted data carries the HMAC hash, never the raw token. + const createArg = vi.mocked(prisma.clinicianShareLink.create).mock + .calls[0][0]; + expect(createArg.data.tokenHash).toBe(`hash(${body.data.token})`); + // No column stores the raw token verbatim — only the (mock) hash wraps it. + const { tokenHash, ...rest } = createArg.data; + expect(tokenHash).not.toBe(body.data.token); + expect(JSON.stringify(rest)).not.toContain(body.data.token); + // userId is narrowed from the session, never the body. + expect(createArg.data.userId).toBe("user-1"); + }); + + it("rejects an expiry beyond the cap (422)", async () => { + const tooFar = new Date( + Date.now() + 200 * 24 * 60 * 60 * 1000, + ).toISOString(); + const res = await POST(postReq(validBody({ expiresAt: tooFar }))); + expect(res.status).toBe(422); + expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); + }); + + it("rejects a past expiry (422)", async () => { + const past = new Date(Date.now() - 1000).toISOString(); + const res = await POST(postReq(validBody({ expiresAt: past }))); + expect(res.status).toBe(422); + }); + + it("rejects an unknown key via strict schema (422)", async () => { + const res = await POST(postReq(validBody({ userId: "attacker" }))); + expect(res.status).toBe(422); + expect(prisma.clinicianShareLink.create).not.toHaveBeenCalled(); + }); +}); + +describe("GET /api/share-links — list", () => { + it("never returns a raw token", async () => { + vi.mocked(prisma.clinicianShareLink.findMany).mockResolvedValue([ + storedRow(), + ] as never); + const res = await GET(); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { shareLinks: Array> }; + }; + expect(body.data.shareLinks).toHaveLength(1); + expect(JSON.stringify(body.data)).not.toContain("hls_"); + // Scoped to the caller. + const arg = vi.mocked(prisma.clinicianShareLink.findMany).mock.calls[0][0]; + expect(arg!.where).toEqual({ userId: "user-1" }); + }); +}); + +describe("DELETE /api/share-links/[id] — revoke", () => { + function delReq(): NextRequest { + return new NextRequest("http://localhost/api/share-links/link-1", { + method: "DELETE", + }); + } + + it("revokes an own link (sets revokedAt, pins userId)", async () => { + vi.mocked(prisma.clinicianShareLink.updateMany).mockResolvedValue({ + count: 1, + } as never); + const res = await DELETE(delReq(), { + params: Promise.resolve({ id: "link-1" }), + }); + expect(res.status).toBe(200); + const arg = vi.mocked(prisma.clinicianShareLink.updateMany).mock.calls[0][0]; + expect(arg.where).toEqual({ + id: "link-1", + userId: "user-1", + revokedAt: null, + }); + expect(arg.data.revokedAt).toBeInstanceOf(Date); + }); + + it("seals a cross-user / unknown id as 404", async () => { + vi.mocked(prisma.clinicianShareLink.updateMany).mockResolvedValue({ + count: 0, + } as never); + const res = await DELETE(delReq(), { + params: Promise.resolve({ id: "someone-elses" }), + }); + expect(res.status).toBe(404); + }); +}); diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts new file mode 100644 index 000000000..4b0839681 --- /dev/null +++ b/src/app/api/share-links/route.ts @@ -0,0 +1,161 @@ +/** + * /api/share-links — owner clinician-share-link lifecycle (Epic C, C4). + * + * POST → create a link. Mints an `hls_<48 hex>` token (192-bit), stores + * ONLY its HMAC hash, returns the raw token EXACTLY ONCE. Every + * scope column is frozen write-once at creation. + * GET → list the caller's own links (never the raw token — it is + * unrecoverable after the create response). + * + * Auth: cookie session OR Bearer (`requireAuth`). `userId` is always narrowed + * from the auth context; there is no `userId` body field. Rate-limited. + * This is the OWNER lifecycle only — the public resolver / view (C3/C5) is a + * separate surface. + */ +import { randomBytes } from "node:crypto"; +import { NextRequest } from "next/server"; + +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import { auditLog } from "@/lib/auth/audit"; +import { hashToken } from "@/lib/auth/hmac"; +import { + apiError, + apiSuccess, + getClientIp, + returnAllZodIssues, + safeJson, +} from "@/lib/api-response"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { prisma } from "@/lib/db"; +import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; +import type { Prisma } from "@/generated/prisma/client"; + +/** Project a stored row to the safe owner-facing shape (never the token). */ +function toSummary(row: { + id: string; + label: string; + rangeStart: Date; + rangeEnd: Date | null; + resourceTypes: string[]; + allowFhirApi: boolean; + expiresAt: Date; + createdAt: Date; + revokedAt: Date | null; + lastAccessAt: Date | null; + accessCount: number; +}) { + return { + id: row.id, + label: row.label, + rangeStart: row.rangeStart.toISOString(), + rangeEnd: row.rangeEnd ? row.rangeEnd.toISOString() : null, + resourceTypes: row.resourceTypes, + allowFhirApi: row.allowFhirApi, + expiresAt: row.expiresAt.toISOString(), + createdAt: row.createdAt.toISOString(), + revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null, + lastAccessAt: row.lastAccessAt ? row.lastAccessAt.toISOString() : null, + accessCount: row.accessCount, + // Status the UI can render without re-deriving expiry/revocation. + active: + row.revokedAt === null && row.expiresAt.getTime() > Date.now(), + }; +} + +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "share-link.create" } }); + + const rl = await checkRateLimit( + `share-link:${user.id}`, + 20, + 60 * 60 * 1000, + ); + if (!rl.allowed) { + return apiError("Maximum 20 share-link operations per hour", 429); + } + + const { data: body, error: jsonError } = await safeJson(request, { + maxBytes: 16 * 1024, + }); + if (jsonError) return jsonError; + + const parsed = createShareLinkSchema.safeParse(body); + if (!parsed.success) { + return returnAllZodIssues(parsed.error); + } + const input = parsed.data; + + // Mint the 192-bit raw token, store only its HMAC hash. + const rawToken = `hls_${randomBytes(24).toString("hex")}`; + const tokenHash = hashToken(rawToken); + + // Field-by-field build — no mass assignment. Scope columns are written + // exactly once here and never updated. + const created = await prisma.clinicianShareLink.create({ + data: { + userId: user.id, + tokenHash, + label: input.label, + rangeStart: new Date(input.rangeStart), + rangeEnd: input.rangeEnd ? new Date(input.rangeEnd) : null, + sectionsJson: (input.sections ?? {}) as Prisma.InputJsonValue, + resourceTypes: input.resourceTypes ?? [], + allowFhirApi: input.allowFhirApi ?? false, + expiresAt: new Date(input.expiresAt), + }, + select: { + id: true, + label: true, + rangeStart: true, + rangeEnd: true, + resourceTypes: true, + allowFhirApi: true, + expiresAt: true, + createdAt: true, + revokedAt: true, + lastAccessAt: true, + accessCount: true, + }, + }); + + await auditLog("share-link.create", { + userId: user.id, + ipAddress: getClientIp(request), + details: { + shareLinkId: created.id, + resourceTypes: created.resourceTypes, + allowFhirApi: created.allowFhirApi, + expiresAt: created.expiresAt.toISOString(), + }, + }); + + // The raw token is returned exactly once; it is unrecoverable thereafter. + return apiSuccess({ ...toSummary(created), token: rawToken }, 201); +}); + +export const GET = apiHandler(async () => { + const { user } = await requireAuth(); + annotate({ action: { name: "share-link.list" } }); + + 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, + expiresAt: true, + createdAt: true, + revokedAt: true, + lastAccessAt: true, + accessCount: true, + }, + }); + + return apiSuccess({ shareLinks: rows.map(toSummary) }); +}); diff --git a/src/lib/openapi/routes.ts b/src/lib/openapi/routes.ts index d0590e5f0..b119ab02b 100644 --- a/src/lib/openapi/routes.ts +++ b/src/lib/openapi/routes.ts @@ -46,6 +46,7 @@ import { import { medicationExtractionSchema } from "@/lib/ai/coach/medication-extract-prompt"; import { ACCEPTED_INSIGHTS_TILE_IDS } from "@/lib/insights-layout"; import { exportSelectionSchema } from "@/lib/validations/health-record-export"; +import { createShareLinkSchema } from "@/lib/validations/clinician-share-link"; import { METRIC_STATUS_IDS } from "@/lib/insights/metric-status-registry"; import { DERIVED_METRIC_IDS, @@ -1848,12 +1849,53 @@ const insightsLayoutSchema = z // v1.7.0 — health-record export selection. Strict shape: unknown keys // (including any attempt to smuggle a userId) 422 via returnAllZodIssues. +// v1.11.0 — clinician share-link create payload. Strict; no `userId` field +// (the owner is always narrowed from the session/Bearer). `expiresAt` is +// required and capped at SHARE_LINK_MAX_DAYS; the scope columns are frozen +// write-once at creation. +createShareLinkSchema.meta({ + id: "CreateShareLinkRequest", + description: + "v1.11.0 — owner request to mint a clinician share link to their own health record. `expiresAt` is required (absolute ISO instant) and capped at 90 days. `rangeStart`/`rangeEnd` freeze the reporting window (rangeEnd null = rolling). `resourceTypes` scopes the FHIR resources the link may serve; `allowFhirApi` toggles REST reachability. Strict: unknown keys 422.", +}); + exportSelectionSchema.meta({ id: "HealthRecordExportRequest", description: "v1.7.0 — health-record / doctor-handover export selection. `format` picks PDF, FHIR R4 document Bundle, or a combined zip package. Grouped `sections` toggles drive which domains are read (mood is opt-in, off by default). No `userId` field — the user is always narrowed from the session/Bearer. The route is strict: unknown keys 422.", }); +// v1.11.0 — clinician share-link owner-facing summary (never the raw token). +const shareLinkSummary = z.object({ + id: z.string(), + label: z.string(), + rangeStart: z.string(), + rangeEnd: z.string().nullable(), + resourceTypes: z.array(z.string()), + allowFhirApi: z.boolean(), + expiresAt: z.string(), + createdAt: z.string(), + revokedAt: z.string().nullable(), + lastAccessAt: z.string().nullable(), + accessCount: z.number(), + active: z.boolean(), +}); + +const shareLinkCreatedResponse = shareLinkSummary.extend({ + token: z + .string() + .describe("Raw `hls_` token — returned ONCE and unrecoverable thereafter."), +}); + +const shareLinkListResponse = z.object({ + shareLinks: z.array(shareLinkSummary), +}); + +const shareLinkRevokedResponse = z.object({ + id: z.string(), + revoked: z.boolean(), +}); + // v1.10.2 — live capability / discovery response. Every list is sourced // server-side from the canonical registry it documents, so the wire shape // here is the contract; the runtime values are authoritative and never @@ -1993,6 +2035,73 @@ export const openApiPaths: NonNullable = { }, }, }, + "/api/share-links": { + post: { + 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. 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.", + requestBody: { + required: true, + content: { + "application/json": { schema: createShareLinkSchema }, + }, + }, + responses: { + "201": { + description: + "Share link created. `token` carries the raw `hls_` value and is unrecoverable after this response.", + content: { + "application/json": { + schema: dataEnvelope(shareLinkCreatedResponse, "ShareLinkCreated"), + }, + }, + }, + ...stdResponses, + }, + }, + get: { + tags: ["Export"], + summary: "List own clinician share links (v1.11.0)", + description: + "Owner-only. Returns the caller's own share links (never the raw token — it is unrecoverable after creation). Auth via cookie or Bearer.", + responses: { + "200": { + description: "Share links owned by the caller.", + content: { + "application/json": { + schema: dataEnvelope(shareLinkListResponse, "ShareLinkList"), + }, + }, + }, + ...stdResponses, + }, + }, + }, + "/api/share-links/{id}": { + delete: { + tags: ["Export"], + summary: "Revoke a clinician share link (v1.11.0)", + description: + "Owner-only. Sets `revokedAt` on the caller's own link. A cross-user or unknown id is sealed as 404. Auth via cookie or Bearer; rate-limited.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Link revoked.", + content: { + "application/json": { + schema: dataEnvelope(shareLinkRevokedResponse, "ShareLinkRevoked"), + }, + }, + }, + "404": { + description: "Link not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, "/api/auth/login": { post: { tags: ["Auth"], diff --git a/src/lib/validations/clinician-share-link.ts b/src/lib/validations/clinician-share-link.ts new file mode 100644 index 000000000..3878fa83a --- /dev/null +++ b/src/lib/validations/clinician-share-link.ts @@ -0,0 +1,68 @@ +/** + * v1.11.0 — clinician share-link lifecycle validation (Epic C, C4). + * + * The OWNER creates a time-boxed, scope-frozen share link to their own health + * record. On create the server mints an `hls_<48 hex>` token (192-bit), stores + * ONLY its HMAC hash, and returns the raw token exactly once. Every scope + * column (window, sections, FHIR resource types, API toggle) is write-once at + * creation — there is no widen/update path. `expiresAt` is REQUIRED and capped + * at `SHARE_LINK_MAX_DAYS` so no link ever lives forever. + * + * Strict: `.strict()` rejects unknown keys; there is intentionally no `userId` + * field (the owner is always narrowed from `requireAuth()`). + */ +import { z } from "zod/v4"; + +import { exportSectionsSchema } from "@/lib/validations/health-record-export"; + +/** Maximum lifetime of a share link, in days. No never-expiring share. */ +export const SHARE_LINK_MAX_DAYS = 90; + +/** + * The FHIR resource types a share link may be scoped to serve — the read-only + * catalogue the REST face exposes. A create request may select any subset; + * an empty array means "no FHIR resources" (view-only, when `allowFhirApi` is + * off this is moot). + */ +export const SHARE_LINK_RESOURCE_TYPES = [ + "Patient", + "Observation", + "MedicationStatement", + "MedicationAdministration", +] as const; + +export const shareLinkResourceTypeEnum = z.enum(SHARE_LINK_RESOURCE_TYPES); + +const MAX_FUTURE_MS = SHARE_LINK_MAX_DAYS * 24 * 60 * 60 * 1000; + +/** + * 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). + */ +export const createShareLinkSchema = z + .object({ + label: z.string().trim().min(1).max(120), + rangeStart: z.iso.datetime({ offset: true }), + rangeEnd: z.iso.datetime({ offset: true }).nullable().optional(), + sections: exportSectionsSchema.optional(), + resourceTypes: z.array(shareLinkResourceTypeEnum).max(8).optional(), + allowFhirApi: z.boolean().optional(), + expiresAt: z.iso + .datetime({ offset: true }) + .refine((v) => new Date(v).getTime() > Date.now(), { + message: "expiresAt must be in the future", + }) + .refine((v) => new Date(v).getTime() <= Date.now() + MAX_FUTURE_MS, { + message: `expiresAt must be within ${SHARE_LINK_MAX_DAYS} days`, + }), + }) + .strict() + .refine( + (v) => + v.rangeEnd == null || + new Date(v.rangeEnd).getTime() >= new Date(v.rangeStart).getTime(), + { message: "rangeEnd must not precede rangeStart", path: ["rangeEnd"] }, + ); + +export type CreateShareLinkInput = z.infer;