mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 22:02:16 +00:00
A share link created to carry only documents now records an explicit, frozen document_only flag, and the shared view gates on that flag directly instead of inferring documents-only from "are all report sections off?". The inference would have re-opened an existing documents-only link the day a new report section was added — the link's frozen settings never mention the new section, so it would default on and begin serving that data. The flag closes that path; the derived check stays only as the fallback for links created before the column. Migration 0237 adds the column and freezes it on for every existing link whose sections are entirely off (checked at any nesting depth), leaving full-record shares untouched.
254 lines
9.3 KiB
TypeScript
254 lines
9.3 KiB
TypeScript
/**
|
|
* /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 { EMPTY_DOCTOR_REPORT_PREFS } from "@/lib/validations/doctor-report-prefs";
|
|
import {
|
|
generatePassphrase,
|
|
hashPassphrase,
|
|
normalisePassphrase,
|
|
PASSPHRASE_FRAGMENT_KEY,
|
|
} from "@/lib/clinician-share/passphrase";
|
|
import type { Prisma } from "@/generated/prisma/client";
|
|
|
|
/**
|
|
* The absolute origin to build the public share URL from. Prefers the
|
|
* forwarded host/proto a reverse proxy sets (the documented self-hosting path
|
|
* fronts the app with Caddy/Traefik/Nginx), falling back to the request URL's
|
|
* own origin. The QR payload carries the passphrase in the FRAGMENT only.
|
|
*/
|
|
function resolveOrigin(request: NextRequest): string {
|
|
const proto =
|
|
request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ||
|
|
new URL(request.url).protocol.replace(":", "");
|
|
const host =
|
|
request.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ||
|
|
request.headers.get("host") ||
|
|
new URL(request.url).host;
|
|
return `${proto}://${host}`;
|
|
}
|
|
|
|
/**
|
|
* The row selection every owner-facing projection reads. `_count.documents`
|
|
* surfaces the size of the frozen document set without ever loading the ids
|
|
* (let alone the blobs) — the metadata-only discipline (P3-D5).
|
|
*/
|
|
const shareLinkSummarySelect = {
|
|
id: true,
|
|
label: true,
|
|
rangeStart: true,
|
|
rangeEnd: true,
|
|
resourceTypes: true,
|
|
allowFhirApi: true,
|
|
documentOnly: true,
|
|
passphraseHash: true,
|
|
expiresAt: true,
|
|
createdAt: true,
|
|
revokedAt: true,
|
|
lastAccessAt: true,
|
|
accessCount: true,
|
|
_count: { select: { documents: true } },
|
|
} as const;
|
|
|
|
/** 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;
|
|
documentOnly: boolean;
|
|
passphraseHash: string | null;
|
|
expiresAt: Date;
|
|
createdAt: Date;
|
|
revokedAt: Date | null;
|
|
lastAccessAt: Date | null;
|
|
accessCount: number;
|
|
_count: { documents: 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,
|
|
// v1.28.16 — the frozen documents-only flag (owner-facing; the serve path
|
|
// gates on the column, not on "are all sections off?").
|
|
documentOnly: row.documentOnly,
|
|
// v1.18.7 — surface only WHETHER a passphrase guards the link, never the
|
|
// hash itself. Legacy (null-hash) links read `false` and stay ungated.
|
|
protected: row.passphraseHash !== null,
|
|
// v1.28 — how many documents this share carries (never the ids, never the
|
|
// bytes; the serve route is the only decrypt path).
|
|
documentCount: row._count.documents,
|
|
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;
|
|
|
|
// v1.28.13 — a documents-only share carries NO report scope. Force the scope
|
|
// columns to empty here (not just at the client) so a document link can never
|
|
// serve a single health metric, whatever the body tried to smuggle: no
|
|
// sections (an explicit all-OFF prefs blob, distinct from the `{}` that means
|
|
// "full record defaults"), no FHIR resource types, FHIR API off. "Share this
|
|
// document" means the document, not the whole record.
|
|
const documentOnly = input.documentOnly === true;
|
|
const sectionsJson = documentOnly
|
|
? EMPTY_DOCTOR_REPORT_PREFS
|
|
: (input.sections ?? {});
|
|
const resourceTypes = documentOnly ? [] : (input.resourceTypes ?? []);
|
|
const allowFhirApi = documentOnly ? false : (input.allowFhirApi ?? false);
|
|
annotate({ meta: { documentOnly } });
|
|
|
|
// Mint the 192-bit raw token, store only its HMAC hash.
|
|
const rawToken = `hls_${randomBytes(24).toString("hex")}`;
|
|
const tokenHash = hashToken(rawToken);
|
|
|
|
// v1.18.7 — always mint a passphrase second factor; store only its HMAC
|
|
// hash. The raw passphrase is returned exactly once below. `normalisePassphrase`
|
|
// is the canonical store form so the grouped (`XXXX-XXXX-…`) display and the
|
|
// bare fragment value collide to one hash.
|
|
const rawPassphrase = generatePassphrase();
|
|
const passphraseHash = hashPassphrase(normalisePassphrase(rawPassphrase)!);
|
|
|
|
// v1.28 — resolve the frozen document set. The ids are deduped (a client
|
|
// could repeat one, which the unique membership index would otherwise
|
|
// reject) and then validated as the caller's OWN live documents. A single
|
|
// foreign / deleted / unknown id seals the whole create as 422 — no share is
|
|
// ever minted with a document the caller does not own (B5). The rows are
|
|
// created in the SAME transaction as the link (nested create), so the
|
|
// membership is frozen write-once atomically with the scope columns.
|
|
const documentIds = Array.from(new Set(input.documentIds ?? []));
|
|
if (documentIds.length > 0) {
|
|
const owned = await prisma.inboundDocument.findMany({
|
|
where: { id: { in: documentIds }, userId: user.id, deletedAt: null },
|
|
select: { id: true },
|
|
});
|
|
if (owned.length !== documentIds.length) {
|
|
return apiError("One or more documents are not available to share", 422, {
|
|
errorCode: "share-link.documents.invalid",
|
|
});
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
passphraseHash,
|
|
label: input.label,
|
|
rangeStart: new Date(input.rangeStart),
|
|
rangeEnd: input.rangeEnd ? new Date(input.rangeEnd) : null,
|
|
sectionsJson: sectionsJson as Prisma.InputJsonValue,
|
|
resourceTypes,
|
|
allowFhirApi,
|
|
documentOnly,
|
|
expiresAt: new Date(input.expiresAt),
|
|
documents:
|
|
documentIds.length > 0
|
|
? { create: documentIds.map((documentId) => ({ documentId })) }
|
|
: undefined,
|
|
},
|
|
select: shareLinkSummarySelect,
|
|
});
|
|
|
|
await auditLog("share-link.create", {
|
|
userId: user.id,
|
|
ipAddress: getClientIp(request),
|
|
details: {
|
|
shareLinkId: created.id,
|
|
resourceTypes: created.resourceTypes,
|
|
allowFhirApi: created.allowFhirApi,
|
|
documentCount: created._count.documents,
|
|
expiresAt: created.expiresAt.toISOString(),
|
|
},
|
|
});
|
|
|
|
// The raw token AND raw passphrase are returned exactly once; both are
|
|
// unrecoverable thereafter. The QR/deep-link payload carries the passphrase
|
|
// in the URL FRAGMENT (`#k=`) — never the path or query — so it is never
|
|
// sent to the server, kept out of logs, referrers, and the access record.
|
|
const origin = resolveOrigin(request);
|
|
const shareUrl = `${origin}/c/${rawToken}`;
|
|
const qrUrl = `${shareUrl}#${PASSPHRASE_FRAGMENT_KEY}=${rawPassphrase}`;
|
|
return apiSuccess(
|
|
{
|
|
...toSummary(created),
|
|
token: rawToken,
|
|
passphrase: rawPassphrase,
|
|
shareUrl,
|
|
qrUrl,
|
|
},
|
|
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: shareLinkSummarySelect,
|
|
});
|
|
|
|
return apiSuccess({ shareLinks: rows.map(toSummary) });
|
|
});
|