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) });
});