mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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.
80 lines
3.2 KiB
TypeScript
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>;
|