mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
2258cbb489
commit
6c3b8be099
5 files changed
+590
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
});
|
||||
@@ -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>;
|
||||
Reference in new issue
Block a user