mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +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) });
|
||||
});
|
||||
Reference in new issue
Block a user