Files
HealthLog/src/lib/validations/clinician-share-link.ts
T
Marc-André Bombeck 1aeb6f7957 Mint a passphrase second factor on share-link create
Every new share link now carries a high-entropy passphrase (80-bit,
Crockford-base32, grouped XXXX-XXXX-XXXX-XXXX). The create response
returns the raw passphrase, the share URL, and a QR deep link EXACTLY
ONCE alongside the token; the server stores only the passphrase's HMAC
hash (the same key as the token hash). The QR deep link carries the
passphrase in the URL fragment (#k=) so it never reaches a server log,
referrer, or access record.

Add the passphrase generator, normaliser, and constant-time verifier;
the short-lived token-scoped unlock-cookie mint/verify; a gate-state
resolver that reports live + passphrase status without bumping access
counters; and the unlock request schema.
2026-06-19 22:03:36 +02:00

80 lines
3.2 KiB
TypeScript

/**
* 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 { FHIR_REST_RESOURCE_TYPES } from "@/lib/fhir/rest";
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 — exactly the
* read-only catalogue the REST face exposes. Derived from the single canonical
* `FHIR_REST_RESOURCE_TYPES` so a share can never be scoped to a type the REST
* face does not actually route. A create request may select any subset; an
* empty array means "no FHIR resources" (view-only, moot when `allowFhirApi`
* is off).
*/
export const SHARE_LINK_RESOURCE_TYPES = FHIR_REST_RESOURCE_TYPES;
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>;
/**
* v1.18.7 — public unlock payload. A clinician submits the passphrase second
* factor (grouped or bare form). Bounded so a junk body cannot pin the hasher;
* the route normalises + constant-time compares against the stored hash.
*/
export const unlockShareLinkSchema = z
.object({
passphrase: z.string().trim().min(1).max(64),
})
.strict();
export type UnlockShareLinkInput = z.infer<typeof unlockShareLinkSchema>;