feat(share-links): owner clinician share-link lifecycle API

Authenticated owner routes to create, list, and revoke a ClinicianShareLink.
Create mints an hls_ token (192-bit), stores only its HMAC hash, and returns
the raw token exactly once; the scope columns (window, sections, FHIR resource
types, API toggle) are frozen write-once. expiresAt is required and capped at
SHARE_LINK_MAX_DAYS (90). Revoke sets revokedAt and seals a cross-user id as
404. userId is narrowed from auth; rate-limited; strict Zod rejects unknown
keys. This is the owner lifecycle only — the public resolver/view is separate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.8 committed 2026-06-04 01:05:06 +02:00
1 parent 2258cbb489
commit 6c3b8be099
5 files changed
+590

No files matched your search

+57
View File
@@ -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 });
},
);
@@ -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<string, unknown> = {}) {
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<string, unknown> = {}) {
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<Record<string, unknown>> };
};
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);
});
});
+161
View File
@@ -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) });
});
+109
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"/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:<userId>`, 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"],
@@ -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<typeof createShareLinkSchema>;