Merge wave-sharing-harden: passphrase + QR second factor for share links

Gate the clinician share view behind an auto-generated passphrase carried
in the QR URL fragment and stored only as an HMAC hash. The public view
verifies it in constant time behind a rate limit and sets a short-lived,
path-scoped cookie before rendering; links minted before this change keep
working ungated.
This commit is contained in:
Marc-André Bombeck committed 2026-06-19 22:05:49 +02:00
commit aa6196a789
26 files changed
+1509 -90

No files matched your search

+91 -1
View File
@@ -989,7 +989,9 @@ paths:
- 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
ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with
`shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase
cannot open the record. 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:
@@ -1148,6 +1150,50 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/c/{token}/unlock:
post:
tags:
- Export
summary: Verify a share-link passphrase (v1.18.7, public)
description: "Anonymous, no-session passphrase gate for a protected share link. The raw path token plus the submitted
passphrase are the only credentials. On success the server sets a short-lived (30 min), token-scoped,
httpOnly cookie so `/c/<token>` renders the record. Rate-limited (`share-unlock:<tokenHash>:<ip>`); every
failure class (bad token, no passphrase set, wrong passphrase) answers one blunt 401."
parameters:
- in: path
name: token
schema:
type: string
required: true
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
passphrase:
type: string
minLength: 1
maxLength: 64
required:
- passphrase
additionalProperties: false
responses:
"200":
description: Passphrase accepted; short-lived token-scoped unlock cookie set.
content:
application/json:
schema:
$ref: "#/components/schemas/ShareLinkUnlock"
"401":
description: Invalid passphrase (blunt — no failure detail leaked).
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/fhir/metadata:
get:
tags:
@@ -9701,6 +9747,8 @@ components:
type: string
allowFhirApi:
type: boolean
protected:
type: boolean
expiresAt:
type: string
createdAt:
@@ -9720,6 +9768,16 @@ components:
token:
type: string
description: Raw `hls_` token — returned ONCE and unrecoverable thereafter.
passphrase:
type: string
description: Raw passphrase second factor — returned ONCE; stored only as an HMAC hash, unrecoverable.
shareUrl:
type: string
description: Absolute `/c/<token>` URL (no passphrase).
qrUrl:
type: string
description: Absolute share URL with the passphrase in the URL FRAGMENT (`#k=<passphrase>`). Encode this as the QR
payload; the fragment is never sent to the server.
required:
- id
- label
@@ -9727,6 +9785,7 @@ components:
- rangeEnd
- resourceTypes
- allowFhirApi
- protected
- expiresAt
- createdAt
- revokedAt
@@ -9734,6 +9793,9 @@ components:
- accessCount
- active
- token
- passphrase
- shareUrl
- qrUrl
additionalProperties: false
error:
type: "null"
@@ -9774,6 +9836,8 @@ components:
type: string
allowFhirApi:
type: boolean
protected:
type: boolean
expiresAt:
type: string
createdAt:
@@ -9797,6 +9861,7 @@ components:
- rangeEnd
- resourceTypes
- allowFhirApi
- protected
- expiresAt
- createdAt
- revokedAt
@@ -9845,6 +9910,31 @@ components:
- data
- error
additionalProperties: false
ShareLinkUnlock:
type: object
properties:
data:
type: object
properties:
unlocked:
type: boolean
enum:
- true
required:
- unlocked
additionalProperties: false
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
LoginResponse:
type: object
properties:
+18 -2
View File
@@ -4676,7 +4676,16 @@
"statusActive": "Aktiv",
"statusRevoked": "Widerrufen",
"statusExpired": "Abgelaufen",
"inactiveTitle": "Widerrufen und abgelaufen ({count})"
"inactiveTitle": "Widerrufen und abgelaufen ({count})",
"shownOnce": "Nur einmal sichtbar. Erfassen Sie QR-Code und Passphrase jetzt — beide lassen sich später nicht wiederherstellen.",
"qrLabel": "QR-Code",
"qrAlt": "QR-Code mit Freigabelink und Passphrase",
"linkLabel": "Link",
"passphraseLabel": "Passphrase",
"passphraseHint": "Die Empfängerin oder der Empfänger benötigt Link und Passphrase. Der QR-Code enthält beides.",
"copyLink": "Link kopieren",
"copyPassphrase": "Passphrase kopieren",
"protected": "Passphrase"
},
"genderOther": "Divers",
"cycleTracking": {
@@ -5963,7 +5972,14 @@
"strain": "Belastungswert",
"score": "Wellness-Wert"
},
"footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker."
"footer": "Geteilt aus HealthLog — einem selbst gehosteten Gesundheits-Tracker.",
"unlock": {
"title": "Passphrase eingeben",
"description": "Diese geteilte Gesundheitsakte ist geschützt. Geben Sie die erhaltene Passphrase ein, um sie anzuzeigen.",
"label": "Passphrase",
"error": "Diese Passphrase ist nicht korrekt.",
"submit": "Entsperren"
}
},
"cycle": {
"symptomCategory": {
+18 -2
View File
@@ -4676,7 +4676,16 @@
"statusActive": "Active",
"statusRevoked": "Revoked",
"statusExpired": "Expired",
"inactiveTitle": "Revoked and expired ({count})"
"inactiveTitle": "Revoked and expired ({count})",
"shownOnce": "Shown once only. Capture the QR code and passphrase now — neither can be recovered later.",
"qrLabel": "QR code",
"qrAlt": "QR code containing the share link and passphrase",
"linkLabel": "Link",
"passphraseLabel": "Passphrase",
"passphraseHint": "The recipient needs both the link and this passphrase. The QR code carries both.",
"copyLink": "Copy link",
"copyPassphrase": "Copy passphrase",
"protected": "Passphrase"
},
"genderOther": "Other",
"cycleTracking": {
@@ -5963,7 +5972,14 @@
"strain": "Strain score",
"score": "Wellness score"
},
"footer": "Shared from HealthLog — a self-hosted personal health tracker."
"footer": "Shared from HealthLog — a self-hosted personal health tracker.",
"unlock": {
"title": "Enter passphrase",
"description": "This shared health record is protected. Enter the passphrase you were given to view it.",
"label": "Passphrase",
"error": "That passphrase is not correct.",
"submit": "Unlock"
}
},
"cycle": {
"symptomCategory": {
+18 -2
View File
@@ -4676,7 +4676,16 @@
"statusActive": "Activo",
"statusRevoked": "Revocado",
"statusExpired": "Caducado",
"inactiveTitle": "Revocados y caducados ({count})"
"inactiveTitle": "Revocados y caducados ({count})",
"shownOnce": "Se muestra una sola vez. Guarde ahora el código QR y la frase de acceso — no se pueden recuperar después.",
"qrLabel": "Código QR",
"qrAlt": "Código QR con el enlace para compartir y la frase de acceso",
"linkLabel": "Enlace",
"passphraseLabel": "Frase de acceso",
"passphraseHint": "El destinatario necesita el enlace y esta frase de acceso. El código QR contiene ambos.",
"copyLink": "Copiar enlace",
"copyPassphrase": "Copiar frase de acceso",
"protected": "Frase de acceso"
},
"genderOther": "Otro",
"cycleTracking": {
@@ -5963,7 +5972,14 @@
"strain": "Puntuación de esfuerzo",
"score": "Puntuación de bienestar"
},
"footer": "Compartido desde HealthLog — un registro de salud autoalojado."
"footer": "Compartido desde HealthLog — un registro de salud autoalojado.",
"unlock": {
"title": "Introduzca la frase de acceso",
"description": "Este registro de salud compartido está protegido. Introduzca la frase de acceso que recibió para verlo.",
"label": "Frase de acceso",
"error": "Esa frase de acceso no es correcta.",
"submit": "Desbloquear"
}
},
"cycle": {
"symptomCategory": {
+18 -2
View File
@@ -4676,7 +4676,16 @@
"statusActive": "Actif",
"statusRevoked": "Révoqué",
"statusExpired": "Expiré",
"inactiveTitle": "Révoqués et expirés ({count})"
"inactiveTitle": "Révoqués et expirés ({count})",
"shownOnce": "Affiché une seule fois. Enregistrez le code QR et la phrase secrète maintenant — ils ne pourront pas être récupérés ensuite.",
"qrLabel": "Code QR",
"qrAlt": "Code QR contenant le lien de partage et la phrase secrète",
"linkLabel": "Lien",
"passphraseLabel": "Phrase secrète",
"passphraseHint": "Le destinataire a besoin du lien et de cette phrase secrète. Le code QR contient les deux.",
"copyLink": "Copier le lien",
"copyPassphrase": "Copier la phrase secrète",
"protected": "Phrase secrète"
},
"genderOther": "Autre",
"cycleTracking": {
@@ -5963,7 +5972,14 @@
"strain": "Score de charge",
"score": "Score de bien-être"
},
"footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé."
"footer": "Partagé depuis HealthLog — un suivi de santé auto-hébergé.",
"unlock": {
"title": "Saisir la phrase secrète",
"description": "Ce dossier de santé partagé est protégé. Saisissez la phrase secrète qui vous a été communiquée pour le consulter.",
"label": "Phrase secrète",
"error": "Cette phrase secrète est incorrecte.",
"submit": "Déverrouiller"
}
},
"cycle": {
"symptomCategory": {
+18 -2
View File
@@ -4676,7 +4676,16 @@
"statusActive": "Attivo",
"statusRevoked": "Revocato",
"statusExpired": "Scaduto",
"inactiveTitle": "Revocati e scaduti ({count})"
"inactiveTitle": "Revocati e scaduti ({count})",
"shownOnce": "Mostrato una sola volta. Salva ora il codice QR e la passphrase — non potranno essere recuperati in seguito.",
"qrLabel": "Codice QR",
"qrAlt": "Codice QR contenente il link di condivisione e la passphrase",
"linkLabel": "Link",
"passphraseLabel": "Passphrase",
"passphraseHint": "Il destinatario ha bisogno del link e di questa passphrase. Il codice QR contiene entrambi.",
"copyLink": "Copia link",
"copyPassphrase": "Copia passphrase",
"protected": "Passphrase"
},
"genderOther": "Altro",
"cycleTracking": {
@@ -5963,7 +5972,14 @@
"strain": "Punteggio di sforzo",
"score": "Punteggio di benessere"
},
"footer": "Condiviso da HealthLog — un tracker della salute self-hosted."
"footer": "Condiviso da HealthLog — un tracker della salute self-hosted.",
"unlock": {
"title": "Inserisci la passphrase",
"description": "Questo referto sanitario condiviso è protetto. Inserisci la passphrase che hai ricevuto per visualizzarlo.",
"label": "Passphrase",
"error": "La passphrase non è corretta.",
"submit": "Sblocca"
}
},
"cycle": {
"symptomCategory": {
+18 -2
View File
@@ -4676,7 +4676,16 @@
"statusActive": "Aktywny",
"statusRevoked": "Unieważniony",
"statusExpired": "Wygasły",
"inactiveTitle": "Unieważnione i wygasłe ({count})"
"inactiveTitle": "Unieważnione i wygasłe ({count})",
"shownOnce": "Wyświetlane tylko raz. Zapisz teraz kod QR i hasło — nie można ich później odzyskać.",
"qrLabel": "Kod QR",
"qrAlt": "Kod QR zawierający link do udostępniania i hasło",
"linkLabel": "Link",
"passphraseLabel": "Hasło",
"passphraseHint": "Odbiorca potrzebuje linku i tego hasła. Kod QR zawiera oba.",
"copyLink": "Kopiuj link",
"copyPassphrase": "Kopiuj hasło",
"protected": "Hasło"
},
"genderOther": "Inna",
"cycleTracking": {
@@ -5963,7 +5972,14 @@
"strain": "Wynik obciążenia",
"score": "Wynik dobrostanu"
},
"footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia."
"footer": "Udostępnione z HealthLog — samodzielnie hostowanego trackera zdrowia.",
"unlock": {
"title": "Wprowadź hasło",
"description": "Ta udostępniona dokumentacja zdrowotna jest chroniona. Wprowadź otrzymane hasło, aby ją wyświetlić.",
"label": "Hasło",
"error": "To hasło jest nieprawidłowe.",
"submit": "Odblokuj"
}
},
"cycle": {
"symptomCategory": {
@@ -0,0 +1,7 @@
-- v1.18.7 — passphrase second factor for clinician share links.
-- A leaked share URL on its own no longer opens the record: every new
-- link carries a high-entropy passphrase, stored only as its HMAC-SHA256
-- hash (same key as token_hash). The column is NULLABLE so every link
-- minted before this change keeps the possession-only contract and the
-- public view renders for it without a gate.
ALTER TABLE "clinician_share_links" ADD COLUMN "passphrase_hash" TEXT;
+7
View File
@@ -3856,6 +3856,13 @@ model ClinicianShareLink {
userId String @map("user_id")
/// HMAC-SHA256 of the raw `hls_` token (same scheme as `ApiToken.tokenHash`).
tokenHash String @unique @map("token_hash")
/// v1.18.7 — HMAC-SHA256 of the passphrase second factor (same key as
/// `tokenHash`). NULL for legacy links minted before the passphrase gate;
/// those keep the possession-only contract (the public view renders without
/// a gate). A non-null value forces the unlock gate before the record paints.
/// The raw passphrase is returned exactly once at create time and stored only
/// as this hash — there is no recovery path.
passphraseHash String? @map("passphrase_hash")
/// Human label the owner sets (e.g. a clinic/specialty note). Plaintext, bounded.
label String
@@ -0,0 +1,150 @@
/**
* v1.18.7 — public passphrase-gate verifier (POST /api/c/{token}/unlock).
*
* Asserts the load-bearing properties:
* - a correct passphrase sets a short-lived, token-scoped httpOnly cookie;
* - a wrong passphrase is rejected with a blunt 401, no cookie set;
* - an unknown / revoked / expired token (gate null) is the same blunt 401;
* - a link with no passphrase set cannot be unlocked;
* - the route is rate-limited BEFORE any compare.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx";
const cookieSet = vi.fn();
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: cookieSet,
delete: () => {},
})),
}));
vi.mock("@/lib/db", () => ({
prisma: {},
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
resolveShareGateState: vi.fn(),
}));
vi.mock("@/lib/clinician-share/passphrase", () => ({
verifyPassphrase: vi.fn(),
}));
vi.mock("@/lib/rate-limit", () => ({
checkAuthSurfaceRateLimit: vi.fn(),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
import { POST } from "../route";
import { resolveShareGateState } from "@/lib/clinician-share/resolve-share-token";
import { verifyPassphrase } from "@/lib/clinician-share/passphrase";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
const TOKEN = `hls_${"a".repeat(48)}`;
const TOKEN_HASH = "f".repeat(64);
function req(body: unknown): NextRequest {
return new NextRequest(`http://localhost/api/c/${TOKEN}/unlock`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function call() {
return POST(req({ passphrase: "ABCD-EFGH-JKMN-PQRS" }), {
params: Promise.resolve({ token: TOKEN }),
});
}
beforeEach(() => {
vi.resetAllMocks();
vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY);
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: true,
remaining: 9,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
});
});
describe("POST /api/c/[token]/unlock", () => {
it("sets a token-scoped httpOnly cookie on a correct passphrase", async () => {
vi.mocked(resolveShareGateState).mockResolvedValue({
tokenHash: TOKEN_HASH,
passphraseHash: "deadbeef",
});
vi.mocked(verifyPassphrase).mockReturnValue(true);
const res = await call();
expect(res.status).toBe(200);
expect(cookieSet).toHaveBeenCalledTimes(1);
const [name, , opts] = cookieSet.mock.calls[0];
expect(name).toContain("hls_unlock_");
expect(opts.httpOnly).toBe(true);
expect(opts.sameSite).toBe("strict");
// Scoped to THIS token's view path only.
expect(opts.path).toBe(`/c/${TOKEN}`);
expect(opts.maxAge).toBeGreaterThan(0);
expect(opts.maxAge).toBeLessThanOrEqual(30 * 60);
});
it("rejects a wrong passphrase with a blunt 401, no cookie", async () => {
vi.mocked(resolveShareGateState).mockResolvedValue({
tokenHash: TOKEN_HASH,
passphraseHash: "deadbeef",
});
vi.mocked(verifyPassphrase).mockReturnValue(false);
const res = await call();
expect(res.status).toBe(401);
expect(cookieSet).not.toHaveBeenCalled();
});
it("rejects an unknown / revoked / expired token (gate null) with 401", async () => {
vi.mocked(resolveShareGateState).mockResolvedValue(null);
const res = await call();
expect(res.status).toBe(401);
expect(verifyPassphrase).not.toHaveBeenCalled();
expect(cookieSet).not.toHaveBeenCalled();
});
it("cannot unlock a link with no passphrase set", async () => {
vi.mocked(resolveShareGateState).mockResolvedValue({
tokenHash: TOKEN_HASH,
passphraseHash: null,
});
// verifyPassphrase returns false for a null stored hash.
vi.mocked(verifyPassphrase).mockReturnValue(false);
const res = await call();
expect(res.status).toBe(401);
expect(cookieSet).not.toHaveBeenCalled();
});
it("is rate-limited before any compare", async () => {
vi.mocked(resolveShareGateState).mockResolvedValue({
tokenHash: TOKEN_HASH,
passphraseHash: "deadbeef",
});
vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({
allowed: false,
remaining: 0,
resetAt: Date.now() + 60_000,
ip: "1.2.3.4",
});
const res = await call();
expect(res.status).toBe(429);
expect(verifyPassphrase).not.toHaveBeenCalled();
expect(cookieSet).not.toHaveBeenCalled();
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* v1.18.7 — POST /api/c/{token}/unlock — public passphrase gate verifier.
*
* The second factor for a passphrase-protected clinician share link. It is an
* anonymous, no-session surface (like the `/c/<token>` view itself): the raw
* path token plus the submitted passphrase are the only credentials. On a
* correct passphrase it mints a SHORT-LIVED, httpOnly, SameSite=Strict, Secure
* cookie SCOPED to this token's view path so the page renders the record; a
* cookie minted for one token never unlocks another.
*
* Brute-force protection is mandatory here — the passphrase is the only thing
* standing between a leaked URL and the record — so the route is rate-limited
* by `share-unlock:<tokenHash>:<ip>` BEFORE any compare, using the
* anonymous-surface limiter so a broken trust chain collapses to one tight
* bucket rather than a free-for-all.
*
* Failure is blunt: a malformed token, an unknown/revoked/expired link, a link
* with no passphrase set, or a wrong passphrase all answer the SAME flat error.
* `no-store` is preserved end to end. Nothing here leaks why a request failed.
*/
import { cookies } from "next/headers";
import { NextRequest } from "next/server";
import { apiHandler } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import {
apiError,
apiSuccess,
returnAllZodIssues,
safeJson,
} from "@/lib/api-response";
import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
import { resolveShareGateState } from "@/lib/clinician-share/resolve-share-token";
import { verifyPassphrase } from "@/lib/clinician-share/passphrase";
import {
mintUnlockValue,
unlockCookieName,
UNLOCK_TTL_SECONDS,
} from "@/lib/clinician-share/unlock-cookie";
import { unlockShareLinkSchema } from "@/lib/validations/clinician-share-link";
/** One blunt failure for every class — a probe distinguishes nothing. */
function reject() {
return apiError("Invalid passphrase", 401);
}
export const POST = apiHandler(
async (
request: NextRequest,
ctx: { params: Promise<{ token: string }> },
) => {
const { token } = await ctx.params;
annotate({ action: { name: "share-link.unlock" } });
// Resolve the live-gate state first (no counter bump). A malformed /
// unknown / revoked / expired token is `null` → the same blunt reject.
const gate = await resolveShareGateState(token);
// Rate-limit BEFORE any compare. Key on the resolved tokenHash when we
// have one (so the bucket is per-link), else a fixed sentinel so probing
// unknown tokens still shares one tight bucket. The anonymous-surface
// limiter collapses a broken trust chain to a single bucket.
const rlKey = gate ? gate.tokenHash : "unknown";
const rl = await checkAuthSurfaceRateLimit(
request,
`share-unlock:${rlKey}`,
10,
10 * 60 * 1000,
);
if (!rl.allowed) {
return apiError("Too many attempts", 429);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 4 * 1024,
});
if (jsonError) return jsonError;
const parsed = unlockShareLinkSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error);
// A token with no live gate, or one carrying no passphrase, cannot be
// unlocked. `verifyPassphrase` is constant-time and returns false for a
// null stored hash, so the two branches are indistinguishable in timing.
if (!gate) return reject();
if (!verifyPassphrase(parsed.data.passphrase, gate.passphraseHash)) {
annotate({ meta: { unlock: "rejected" } });
return reject();
}
// Correct passphrase — mint the short-lived, token-scoped unlock cookie.
const cookieStore = await cookies();
cookieStore.set(unlockCookieName(gate.tokenHash), mintUnlockValue(gate.tokenHash), {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "strict",
// Scope to THIS token's view path only — the cookie unlocks nothing else.
path: `/c/${token}`,
maxAge: UNLOCK_TTL_SECONDS,
});
annotate({ meta: { unlock: "granted" } });
return apiSuccess({ unlocked: true });
},
);
@@ -75,6 +75,7 @@ function storedRow(overrides: Record<string, unknown> = {}) {
rangeEnd: null,
resourceTypes: ["Observation"],
allowFhirApi: true,
passphraseHash: "hash(STORED)",
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
createdAt: new Date(),
revokedAt: null,
@@ -119,6 +120,44 @@ describe("POST /api/share-links — create", () => {
expect(createArg.data.userId).toBe("user-1");
});
it("returns the raw passphrase once and persists ONLY its hash", async () => {
vi.mocked(prisma.clinicianShareLink.create).mockResolvedValue(
storedRow({ passphraseHash: "hash(STORED)" }) as never,
);
const res = await POST(postReq(validBody()));
expect(res.status).toBe(201);
const body = (await res.json()) as {
data: {
passphrase: string;
token: string;
shareUrl: string;
qrUrl: string;
protected: boolean;
};
};
// The raw passphrase is returned exactly once in the grouped form.
expect(body.data.passphrase).toMatch(
/^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/,
);
// Stored as a hash (the mock wraps the NORMALISED bare form), never raw.
const createArg = vi.mocked(prisma.clinicianShareLink.create).mock
.calls[0][0];
const bare = body.data.passphrase.replace(/-/g, "");
expect(createArg.data.passphraseHash).toBe(`hash(${bare})`);
expect(createArg.data.passphraseHash).not.toContain(body.data.passphrase);
// The QR payload carries the passphrase ONLY in the URL fragment (`#k=`),
// never the path or query — and the bare share URL never carries it.
expect(body.data.qrUrl).toContain(`#k=${body.data.passphrase}`);
expect(body.data.shareUrl).not.toContain(body.data.passphrase);
expect(body.data.shareUrl).toContain(`/c/${body.data.token}`);
// Summary reports the link as passphrase-protected.
expect(body.data.protected).toBe(true);
});
it("rejects an expiry beyond the cap (422)", async () => {
const tooFar = new Date(
Date.now() + 200 * 24 * 60 * 60 * 1000,
+54 -2
View File
@@ -29,8 +29,31 @@ import {
import { checkRateLimit } from "@/lib/rate-limit";
import { prisma } from "@/lib/db";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
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}`;
}
/** Project a stored row to the safe owner-facing shape (never the token). */
function toSummary(row: {
id: string;
@@ -39,6 +62,7 @@ function toSummary(row: {
rangeEnd: Date | null;
resourceTypes: string[];
allowFhirApi: boolean;
passphraseHash: string | null;
expiresAt: Date;
createdAt: Date;
revokedAt: Date | null;
@@ -52,6 +76,9 @@ function toSummary(row: {
rangeEnd: row.rangeEnd ? row.rangeEnd.toISOString() : null,
resourceTypes: row.resourceTypes,
allowFhirApi: row.allowFhirApi,
// 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,
expiresAt: row.expiresAt.toISOString(),
createdAt: row.createdAt.toISOString(),
revokedAt: row.revokedAt ? row.revokedAt.toISOString() : null,
@@ -91,12 +118,20 @@ export const POST = apiHandler(async (request: NextRequest) => {
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)!);
// 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,
@@ -112,6 +147,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
rangeEnd: true,
resourceTypes: true,
allowFhirApi: true,
passphraseHash: true,
expiresAt: true,
createdAt: true,
revokedAt: true,
@@ -131,8 +167,23 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
});
// The raw token is returned exactly once; it is unrecoverable thereafter.
return apiSuccess({ ...toSummary(created), token: rawToken }, 201);
// 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 () => {
@@ -149,6 +200,7 @@ export const GET = apiHandler(async () => {
rangeEnd: true,
resourceTypes: true,
allowFhirApi: true,
passphraseHash: true,
expiresAt: true,
createdAt: true,
revokedAt: true,
+96 -20
View File
@@ -1,16 +1,25 @@
/**
* v1.11.0 (Epic C, C5) — public clinician-view page gating.
* v1.18.7 — passphrase gate: the page checks gate state first, gates a
* protected link until the unlock cookie is valid, and never bumps access
* counters on a gate-blocked hit.
*
* Asserts the page answers a flat `notFound()` (404) whenever the resolver
* yields null — the single blunt response for unknown / revoked / expired /
* malformed tokens — and renders the scoped view on a successful resolve.
* Asserts the page answers a flat `notFound()` (404) whenever the gate
* resolver yields null — the single blunt response for unknown / revoked /
* expired / malformed tokens; renders the scoped view for a legacy (no
* passphrase) link and for a protected link with a valid unlock cookie; and
* shows the gate (no record load) for a protected link without the cookie.
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx";
class NotFoundError extends Error {
digest = "NEXT_HTTP_ERROR_FALLBACK;404";
}
let cookieValue: string | undefined;
vi.mock("next/navigation", () => ({
notFound: vi.fn(() => {
throw new NotFoundError("NEXT_NOT_FOUND");
@@ -18,10 +27,11 @@ vi.mock("next/navigation", () => ({
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({ get: () => undefined })),
cookies: vi.fn(async () => ({ get: () => ({ value: cookieValue }) })),
}));
vi.mock("@/lib/clinician-share/resolve-share-token", () => ({
resolveShareToken: vi.fn(),
resolveShareGateState: vi.fn(),
}));
vi.mock("@/lib/clinician-share/share-view-data", () => ({
loadShareViewData: vi.fn(),
@@ -29,47 +39,113 @@ vi.mock("@/lib/clinician-share/share-view-data", () => ({
vi.mock("@/components/clinician/clinician-view", () => ({
ClinicianView: () => null,
}));
vi.mock("@/components/clinician/share-unlock-gate", () => ({
ShareUnlockGate: () => "GATE",
}));
import ClinicianSharePage from "../page";
import { resolveShareToken } from "@/lib/clinician-share/resolve-share-token";
import {
resolveShareToken,
resolveShareGateState,
} from "@/lib/clinician-share/resolve-share-token";
import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
import { mintUnlockValue } from "@/lib/clinician-share/unlock-cookie";
import { notFound } from "next/navigation";
const resolve = resolveShareToken as ReturnType<typeof vi.fn>;
const gateState = resolveShareGateState as ReturnType<typeof vi.fn>;
const loadData = loadShareViewData as ReturnType<typeof vi.fn>;
const TOKEN_HASH = "f".repeat(64);
function pageProps(token: string) {
return { params: Promise.resolve({ token }) };
}
/** The page returns a `<ShareUnlockGate>` element when it gates. */
function isGate(out: unknown): boolean {
return (
typeof out === "object" &&
out !== null &&
"props" in out &&
typeof (out as { props?: { token?: unknown } }).props?.token === "string"
);
}
function resolvedContext() {
return {
shareLinkId: "link-1",
ownerUserId: "owner-1",
label: "Clinic",
rangeStart: new Date(),
rangeEnd: null,
sectionsJson: {},
resourceTypes: [],
allowFhirApi: false,
expiresAt: new Date(Date.now() + 86_400_000),
};
}
describe("clinician share page", () => {
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY);
cookieValue = undefined;
});
it("404s when the token resolves to null (revoked / expired / unknown)", async () => {
resolve.mockResolvedValue(null);
gateState.mockResolvedValue(null);
await expect(
ClinicianSharePage(pageProps(`hls_${"a".repeat(48)}`)),
).rejects.toBeInstanceOf(NotFoundError);
expect(notFound).toHaveBeenCalledTimes(1);
// No data load is attempted on a failed resolve.
// No data load and no counter-bumping resolve on a failed gate.
expect(loadData).not.toHaveBeenCalled();
expect(resolve).not.toHaveBeenCalled();
});
it("loads the scoped view on a successful resolve", async () => {
resolve.mockResolvedValue({
shareLinkId: "link-1",
ownerUserId: "owner-1",
label: "Clinic",
rangeStart: new Date(),
rangeEnd: null,
sectionsJson: {},
resourceTypes: [],
allowFhirApi: false,
expiresAt: new Date(Date.now() + 86_400_000),
});
it("renders a legacy (no-passphrase) link without a gate", async () => {
gateState.mockResolvedValue({ tokenHash: TOKEN_HASH, passphraseHash: null });
resolve.mockResolvedValue(resolvedContext());
loadData.mockResolvedValue({ report: {}, sections: {} });
await ClinicianSharePage(pageProps(`hls_${"b".repeat(48)}`));
expect(notFound).not.toHaveBeenCalled();
expect(loadData).toHaveBeenCalledTimes(1);
});
it("gates a protected link with NO unlock cookie (no record load)", async () => {
gateState.mockResolvedValue({
tokenHash: TOKEN_HASH,
passphraseHash: "stored-hash",
});
const out = await ClinicianSharePage(pageProps(`hls_${"c".repeat(48)}`));
// The gate island is returned; the record is never resolved or loaded.
expect(isGate(out)).toBe(true);
expect(resolve).not.toHaveBeenCalled();
expect(loadData).not.toHaveBeenCalled();
});
it("renders a protected link WITH a valid unlock cookie", async () => {
gateState.mockResolvedValue({
tokenHash: TOKEN_HASH,
passphraseHash: "stored-hash",
});
cookieValue = mintUnlockValue(TOKEN_HASH);
resolve.mockResolvedValue(resolvedContext());
loadData.mockResolvedValue({ report: {}, sections: {} });
await ClinicianSharePage(pageProps(`hls_${"d".repeat(48)}`));
expect(loadData).toHaveBeenCalledTimes(1);
});
it("gates a protected link with an INVALID / cross-token cookie", async () => {
gateState.mockResolvedValue({
tokenHash: TOKEN_HASH,
passphraseHash: "stored-hash",
});
// A cookie minted for a different token must not unlock this one.
cookieValue = mintUnlockValue("a".repeat(64));
const out = await ClinicianSharePage(pageProps(`hls_${"e".repeat(48)}`));
expect(isGate(out)).toBe(true);
expect(loadData).not.toHaveBeenCalled();
});
});
+32 -4
View File
@@ -19,8 +19,15 @@ import type { Metadata } from "next";
import { cookies, headers } from "next/headers";
import { notFound } from "next/navigation";
import { resolveShareToken } from "@/lib/clinician-share/resolve-share-token";
import {
resolveShareGateState,
resolveShareToken,
} from "@/lib/clinician-share/resolve-share-token";
import { loadShareViewData } from "@/lib/clinician-share/share-view-data";
import {
unlockCookieName,
verifyUnlockValue,
} from "@/lib/clinician-share/unlock-cookie";
import { getServerTranslator } from "@/lib/i18n/server-translator";
import {
defaultLocale,
@@ -29,6 +36,7 @@ import {
} from "@/lib/i18n/config";
import { parseLocaleFromAcceptLanguage } from "@/lib/format-locale";
import { ClinicianView } from "@/components/clinician/clinician-view";
import { ShareUnlockGate } from "@/components/clinician/share-unlock-gate";
// Never cache a scoped health view — `no-store` end to end.
export const dynamic = "force-dynamic";
@@ -61,9 +69,29 @@ export default async function ClinicianSharePage({
}) {
const { token } = await params;
// The ONE trust boundary. No session is read; this proves only that the raw
// path token hashes to a live, in-window share link and yields the owner
// scope. Any failure → null → flat 404.
// v1.18.7 — gate check first, WITHOUT bumping access counters. Resolves a
// malformed / unknown / revoked / expired token to null → the same flat 404
// the full read would. A legacy link (no passphrase) falls straight through.
const gate = await resolveShareGateState(token);
if (!gate) notFound();
if (gate.passphraseHash !== null) {
// Protected link: render nothing of the record until the short-lived,
// token-scoped unlock cookie is present and valid. Verifying the cookie is
// constant-time; an invalid / expired / cross-token value shows the gate.
const cookieStore = await cookies();
const unlockValue = cookieStore.get(unlockCookieName(gate.tokenHash))?.value;
if (!verifyUnlockValue(unlockValue, gate.tokenHash)) {
const locale = await resolveLocale();
const { t } = getServerTranslator(locale);
return <ShareUnlockGate t={(key, vars) => t(key, vars)} token={token} />;
}
}
// The ONE trust boundary for the full read. No session is read; this proves
// only that the raw path token hashes to a live, in-window share link and
// yields the owner scope. Any failure → null → flat 404. The access counter
// is bumped here (on a real record render), not on a gate-blocked hit.
const context = await resolveShareToken(token);
if (!context) notFound();
@@ -0,0 +1,130 @@
"use client";
/**
* v1.18.7 — passphrase gate for a protected clinician share link.
*
* Renders ONLY when the server determined the link carries a passphrase and the
* short-lived unlock cookie is absent. The record is never sent to the client
* until the cookie is set — this island is the whole page until then.
*
* Two paths:
* - The owner shares a QR / deep link carrying `#k=<passphrase>` in the URL
* FRAGMENT (never sent to the server). On mount we read `location.hash`,
* auto-submit it, scrub the hash from history, and reload on success so the
* server re-renders the record with the now-set cookie.
* - No fragment → a localised passphrase input the clinician types into.
*
* The submit POSTs to `/api/c/<token>/unlock`; on success the server has set an
* httpOnly cookie scoped to this token's path, so a full reload paints the
* record. Failure shows one blunt localised error (the server leaks nothing).
*/
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { apiPost } from "@/lib/api/api-fetch";
type Translate = (
key: string,
vars?: Record<string, string | number>,
) => string;
interface ShareUnlockGateProps {
t: Translate;
/** The raw `hls_` token from the path — used only to build the verify URL. */
token: string;
}
export function ShareUnlockGate({ t, token }: ShareUnlockGateProps) {
const [passphrase, setPassphrase] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState(false);
const autoTried = useRef(false);
async function submit(value: string): Promise<void> {
const trimmed = value.trim();
if (!trimmed) return;
setPending(true);
setError(false);
try {
await apiPost(`/api/c/${encodeURIComponent(token)}/unlock`, {
passphrase: trimmed,
});
// Cookie is set server-side; a full reload re-renders with the record.
window.location.reload();
} catch {
setError(true);
setPending(false);
}
}
// Auto-unlock from the `#k=<passphrase>` fragment, once.
useEffect(() => {
if (autoTried.current) return;
autoTried.current = true;
const hash = window.location.hash.replace(/^#/, "");
const params = new URLSearchParams(hash);
const k = params.get("k");
if (k) {
// Scrub the secret from the address bar / history before the request.
history.replaceState(null, "", window.location.pathname);
// Defer out of the effect body so the submit's setState does not run
// synchronously inside the effect (no cascading render).
queueMicrotask(() => void submit(k));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className="mx-auto flex min-h-screen max-w-md flex-col justify-center px-4">
<form
className="space-y-4 rounded-lg border border-border bg-card p-6"
onSubmit={(e) => {
e.preventDefault();
void submit(passphrase);
}}
>
<div className="space-y-1.5">
<h1 className="text-lg font-semibold">
{t("clinicianView.unlock.title")}
</h1>
<p className="text-sm text-muted-foreground">
{t("clinicianView.unlock.description")}
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="share-passphrase">
{t("clinicianView.unlock.label")}
</Label>
<Input
id="share-passphrase"
value={passphrase}
onChange={(e) => setPassphrase(e.target.value)}
placeholder="XXXX-XXXX-XXXX-XXXX"
autoComplete="off"
autoCapitalize="characters"
spellCheck={false}
aria-invalid={error}
className="font-mono"
/>
</div>
{error && (
<p role="alert" className="text-sm text-destructive">
{t("clinicianView.unlock.error")}
</p>
)}
<Button
type="submit"
disabled={pending || !passphrase.trim()}
className="min-h-11 w-full sm:min-h-9"
>
{t("clinicianView.unlock.submit")}
</Button>
</form>
</main>
);
}
+153 -48
View File
@@ -21,9 +21,10 @@
* the share-links card. Cards paint through `<SettingsCard>` so the surface
* matches every sibling section 1:1.
*/
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Copy, Loader2, Share2, Trash2 } from "lucide-react";
import QRCode from "qrcode";
import { Copy, KeyRound, Loader2, Share2, Trash2 } from "lucide-react";
import {
AlertDialog,
@@ -71,6 +72,8 @@ interface ShareLinkSummary {
rangeEnd: string | null;
resourceTypes: string[];
allowFhirApi: boolean;
/** v1.18.7 — whether a passphrase second factor guards this link. */
protected: boolean;
expiresAt: string;
createdAt: string;
revokedAt: string | null;
@@ -79,6 +82,18 @@ interface ShareLinkSummary {
active: boolean;
}
/**
* v1.18.7 — the create response. The token, passphrase, and URLs are returned
* EXACTLY ONCE here; the list query never carries them (the server stores only
* hashes). `qrUrl` carries the passphrase in the URL fragment (`#k=`).
*/
interface ShareLinkCreated extends ShareLinkSummary {
token: string;
passphrase: string;
shareUrl: string;
qrUrl: string;
}
export function SharingSection() {
// v1.18.7 — the visible heading + subtitle come from the shared settings
// shell chrome in the route; this body is the share-links card only.
@@ -106,11 +121,34 @@ function ShareLinksCard() {
"Patient",
"Observation",
]);
const [newToken, setNewToken] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [created, setCreated] = useState<ShareLinkCreated | null>(null);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [copied, setCopied] = useState<"link" | "passphrase" | null>(null);
const [formError, setFormError] = useState<string | null>(null);
const [showRevoked, setShowRevoked] = useState(false);
// Render the QR from the `#k=` deep link once a link is created. The fragment
// carries the passphrase, so the QR alone opens the record — it is shown
// exactly once alongside the passphrase text. The QR is keyed off `qrUrl` so
// a re-create swaps it; the effect only WRITES on async completion (never a
// synchronous reset), so it does not trigger a cascading render.
const qrUrl = created?.qrUrl ?? null;
useEffect(() => {
if (!qrUrl) return;
let cancelled = false;
QRCode.toDataURL(qrUrl, { margin: 1, width: 220 })
.then((url) => {
if (!cancelled) setQrDataUrl(url);
})
.catch(() => {
/* clipboard/QR can fail in some contexts; the link + passphrase text
are still shown so the owner can share them by hand */
});
return () => {
cancelled = true;
};
}, [qrUrl]);
const { data: links } = useQuery({
queryKey: queryKeys.shareLinks(),
queryFn: () =>
@@ -128,27 +166,26 @@ function ShareLinksCard() {
if (expiryDays < 1 || expiryDays > MAX_DAYS) {
return Promise.reject(new Error("EXPIRY_RANGE"));
}
return apiPost<ShareLinkSummary & { token: string }>(
"/api/share-links",
{
label: trimmed,
rangeStart: isoDaysFromNow(-rangeDays),
rangeEnd: null,
resourceTypes,
allowFhirApi,
expiresAt: isoDaysFromNow(expiryDays),
},
);
return apiPost<ShareLinkCreated>("/api/share-links", {
label: trimmed,
rangeStart: isoDaysFromNow(-rangeDays),
rangeEnd: null,
resourceTypes,
allowFhirApi,
expiresAt: isoDaysFromNow(expiryDays),
});
},
onSuccess: (created) => {
setNewToken(created.token);
setCopied(false);
onSuccess: (result) => {
// Clear any stale QR before the effect renders the new one.
setQrDataUrl(null);
setCreated(result);
setCopied(null);
setLabel("");
setFormError(null);
queryClient.invalidateQueries({ queryKey: queryKeys.shareLinks() });
},
onError: (err: Error) => {
setNewToken(null);
setCreated(null);
setFormError(
err.message === "EXPIRY_RANGE"
? t("settings.sharing.expiryInvalid", { max: MAX_DAYS })
@@ -170,15 +207,13 @@ function ShareLinksCard() {
);
}
async function copyToken() {
if (!newToken) return;
async function copyValue(value: string, which: "link" | "passphrase") {
try {
const origin = window.location.origin;
await navigator.clipboard.writeText(`${origin}/c/${newToken}`);
setCopied(true);
await navigator.clipboard.writeText(value);
setCopied(which);
} catch {
// Clipboard can be unavailable (insecure context); the token stays
// visible in the card so the owner can copy it by hand.
// Clipboard can be unavailable (insecure context); the values stay
// visible in the card so the owner can copy them by hand.
}
}
@@ -313,37 +348,97 @@ function ShareLinksCard() {
</Button>
</form>
{newToken && (
{created && (
<div
className="bg-success/10 space-y-2 rounded-lg p-3 text-sm"
className="bg-success/10 space-y-3 rounded-lg p-3 text-sm"
data-testid="share-token-reveal"
>
<p className="text-success font-medium">
{t("settings.sharing.tokenCreated")}
</p>
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.tokenOnce")}
{t("settings.sharing.shownOnce")}
</p>
<div className="flex items-center gap-2">
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
{`${typeof window !== "undefined" ? window.location.origin : ""}/c/${newToken}`}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={copyToken}
aria-label={t("settings.sharing.copy")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{copied && (
<p className="text-success text-[11px]">
{t("settings.sharing.copied")}
</p>
{/* QR — carries the passphrase in the URL fragment, so scanning it
opens the record. Shown exactly once with the passphrase text. */}
{qrDataUrl && (
<div className="space-y-1">
<p className="text-foreground text-[11px] font-medium">
{t("settings.sharing.qrLabel")}
</p>
{/* The QR is a transient secret render, not stored content. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={qrDataUrl}
alt={t("settings.sharing.qrAlt")}
width={220}
height={220}
className="bg-background rounded border p-2"
/>
</div>
)}
{/* The link (no passphrase). On its own it cannot open the record. */}
<div className="space-y-1">
<p className="text-foreground text-[11px] font-medium">
{t("settings.sharing.linkLabel")}
</p>
<div className="flex items-center gap-2">
<code className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all">
{created.shareUrl}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={() => copyValue(created.shareUrl, "link")}
aria-label={t("settings.sharing.copyLink")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{copied === "link" && (
<p className="text-success text-[11px]">
{t("settings.sharing.copied")}
</p>
)}
</div>
{/* The passphrase — the second factor. Shown once; only its hash is
stored, so it cannot be recovered. */}
<div className="space-y-1">
<p className="text-foreground text-[11px] font-medium">
{t("settings.sharing.passphraseLabel")}
</p>
<div className="flex items-center gap-2">
<code
className="bg-muted block flex-1 rounded p-2 font-mono text-xs break-all"
data-testid="share-passphrase"
>
{created.passphrase}
</code>
<Button
type="button"
variant="outline"
size="icon"
className="min-h-11 min-w-11 shrink-0 sm:h-9 sm:w-9"
onClick={() => copyValue(created.passphrase, "passphrase")}
aria-label={t("settings.sharing.copyPassphrase")}
>
<Copy className="h-3.5 w-3.5" />
</Button>
</div>
{copied === "passphrase" && (
<p className="text-success text-[11px]">
{t("settings.sharing.copied")}
</p>
)}
<p className="text-muted-foreground text-[11px]">
{t("settings.sharing.passphraseHint")}
</p>
</div>
</div>
)}
@@ -373,6 +468,16 @@ function ShareLinksCard() {
<Badge className="bg-success/15 text-success text-[10px]">
{t("settings.sharing.statusActive")}
</Badge>
{link.protected && (
<Badge
variant="outline"
className="gap-1 text-[10px]"
data-testid="share-protected-badge"
>
<KeyRound className="h-2.5 w-2.5" />
{t("settings.sharing.protected")}
</Badge>
)}
{link.allowFhirApi && (
<Badge variant="outline" className="text-[10px]">
FHIR
@@ -0,0 +1,87 @@
/**
* v1.18.7 — share-link passphrase second factor.
*
* Asserts: generated passphrases match the grouped human-typeable form and are
* high-entropy / unique; normalisation collapses grouped and bare forms to one
* canonical hash; verification is correct, rejects a wrong passphrase, and a
* null stored hash; a malformed submission is rejected without throwing.
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx";
beforeEach(() => {
vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY);
});
import {
generatePassphrase,
hashPassphrase,
normalisePassphrase,
verifyPassphrase,
PASSPHRASE_PATTERN,
} from "../passphrase";
describe("generatePassphrase", () => {
it("emits the grouped XXXX-XXXX-XXXX-XXXX form", () => {
const p = generatePassphrase();
expect(p).toMatch(/^[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}-[0-9A-Z]{4}$/);
// The bare form is 16 Crockford-base32 chars (80 bits).
expect(normalisePassphrase(p)).toMatch(PASSPHRASE_PATTERN);
});
it("excludes the easily-confused I/L/O/U letters", () => {
for (let i = 0; i < 50; i++) {
expect(generatePassphrase()).not.toMatch(/[ILOU]/);
}
});
it("is unique across a sample (high entropy)", () => {
const seen = new Set<string>();
for (let i = 0; i < 200; i++) seen.add(generatePassphrase());
expect(seen.size).toBe(200);
});
});
describe("normalisePassphrase", () => {
it("collapses grouped, spaced, and lowercase forms to one canonical value", () => {
const p = generatePassphrase();
const bare = normalisePassphrase(p)!;
expect(normalisePassphrase(bare)).toBe(bare);
expect(normalisePassphrase(p.toLowerCase())).toBe(bare);
expect(normalisePassphrase(p.replace(/-/g, " "))).toBe(bare);
});
it("rejects a malformed value", () => {
expect(normalisePassphrase("not a passphrase!")).toBeNull();
expect(normalisePassphrase("")).toBeNull();
// Contains an excluded letter.
expect(normalisePassphrase("IIII-IIII-IIII-IIII")).toBeNull();
});
});
describe("verifyPassphrase", () => {
it("accepts the correct passphrase against its stored hash", () => {
const p = generatePassphrase();
const hash = hashPassphrase(normalisePassphrase(p)!);
expect(verifyPassphrase(p, hash)).toBe(true);
// Grouped vs bare vs lowercase all verify against the one hash.
expect(verifyPassphrase(normalisePassphrase(p)!, hash)).toBe(true);
expect(verifyPassphrase(p.toLowerCase(), hash)).toBe(true);
});
it("rejects a wrong passphrase", () => {
const hash = hashPassphrase(normalisePassphrase(generatePassphrase())!);
expect(verifyPassphrase(generatePassphrase(), hash)).toBe(false);
});
it("rejects against a null stored hash (legacy / unset)", () => {
expect(verifyPassphrase(generatePassphrase(), null)).toBe(false);
});
it("rejects a malformed submission without throwing", () => {
const hash = hashPassphrase(normalisePassphrase(generatePassphrase())!);
expect(verifyPassphrase("garbage", hash)).toBe(false);
expect(verifyPassphrase("", hash)).toBe(false);
});
});
@@ -0,0 +1,86 @@
/**
* v1.18.7 — share-link gate state (the page's gate-vs-render decision).
*
* Asserts: a legacy (null passphraseHash) live link resolves WITHOUT a gate;
* a protected link surfaces its passphraseHash for the gate; revoked / expired
* / unknown / malformed tokens all resolve to null (the same blunt 404); the
* gate resolver NEVER bumps access counters (no `update` call).
*/
import { describe, it, expect, vi, beforeEach } from "vitest";
vi.mock("@/lib/db", () => ({
prisma: {
clinicianShareLink: {
findUnique: vi.fn(),
update: vi.fn().mockResolvedValue({}),
},
},
}));
vi.mock("@/lib/auth/hmac", () => ({ hashToken: (s: string) => `hash(${s})` }));
import { resolveShareGateState } from "../resolve-share-token";
import { prisma } from "@/lib/db";
const findUnique = prisma.clinicianShareLink.findUnique as ReturnType<
typeof vi.fn
>;
const update = prisma.clinicianShareLink.update as ReturnType<typeof vi.fn>;
const VALID_TOKEN = `hls_${"a".repeat(48)}`;
function row(overrides: Record<string, unknown> = {}) {
return {
passphraseHash: "stored-hash",
revokedAt: null,
expiresAt: new Date(Date.now() + 86_400_000),
...overrides,
};
}
beforeEach(() => {
vi.clearAllMocks();
});
describe("resolveShareGateState", () => {
it("returns null for a malformed token (no DB hit)", async () => {
expect(await resolveShareGateState("nope")).toBeNull();
expect(findUnique).not.toHaveBeenCalled();
});
it("returns null for an unknown token", async () => {
findUnique.mockResolvedValue(null);
expect(await resolveShareGateState(VALID_TOKEN)).toBeNull();
});
it("returns null for a revoked link", async () => {
findUnique.mockResolvedValue(row({ revokedAt: new Date() }));
expect(await resolveShareGateState(VALID_TOKEN)).toBeNull();
});
it("returns null for an expired link", async () => {
findUnique.mockResolvedValue(row({ expiresAt: new Date(Date.now() - 1000) }));
expect(await resolveShareGateState(VALID_TOKEN)).toBeNull();
});
it("surfaces the passphraseHash for a protected live link", async () => {
findUnique.mockResolvedValue(row({ passphraseHash: "stored-hash" }));
const gate = await resolveShareGateState(VALID_TOKEN);
expect(gate).toEqual({
tokenHash: `hash(${VALID_TOKEN})`,
passphraseHash: "stored-hash",
});
});
it("resolves a legacy (null passphraseHash) live link WITHOUT a gate", async () => {
findUnique.mockResolvedValue(row({ passphraseHash: null }));
const gate = await resolveShareGateState(VALID_TOKEN);
expect(gate).not.toBeNull();
expect(gate!.passphraseHash).toBeNull();
});
it("never bumps the access counter (no update)", async () => {
findUnique.mockResolvedValue(row());
await resolveShareGateState(VALID_TOKEN);
expect(update).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,62 @@
/**
* v1.18.7 — short-lived, token-scoped unlock cookie.
*
* Asserts: a minted value verifies against its own token hash; it does NOT
* verify against a different token hash (cross-token isolation); an expired
* value is rejected; a forged / malformed value is rejected; the cookie name
* is per-token.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
const TEST_HMAC_KEY = "test-hmac-key-at-least-32-chars-long-xxxxx";
beforeEach(() => {
vi.stubEnv("API_TOKEN_HMAC_KEY", TEST_HMAC_KEY);
});
afterEach(() => {
vi.useRealTimers();
});
import {
mintUnlockValue,
verifyUnlockValue,
unlockCookieName,
UNLOCK_TTL_SECONDS,
} from "../unlock-cookie";
const HASH_A = "a".repeat(64);
const HASH_B = "b".repeat(64);
describe("unlock-cookie", () => {
it("verifies a freshly-minted value against its own token hash", () => {
const value = mintUnlockValue(HASH_A);
expect(verifyUnlockValue(value, HASH_A)).toBe(true);
});
it("does NOT verify against a different token hash (cross-token isolation)", () => {
const value = mintUnlockValue(HASH_A);
expect(verifyUnlockValue(value, HASH_B)).toBe(false);
});
it("rejects an expired value", () => {
vi.useFakeTimers();
const value = mintUnlockValue(HASH_A);
// Advance past the TTL.
vi.advanceTimersByTime((UNLOCK_TTL_SECONDS + 60) * 1000);
expect(verifyUnlockValue(value, HASH_A)).toBe(false);
});
it("rejects a forged or malformed value", () => {
expect(verifyUnlockValue(undefined, HASH_A)).toBe(false);
expect(verifyUnlockValue("", HASH_A)).toBe(false);
expect(verifyUnlockValue("nodot", HASH_A)).toBe(false);
expect(verifyUnlockValue(`${Date.now() + 1_000_000}.deadbeef`, HASH_A)).toBe(
false,
);
});
it("derives a per-token cookie name", () => {
expect(unlockCookieName(HASH_A)).not.toBe(unlockCookieName(HASH_B));
expect(unlockCookieName(HASH_A)).toMatch(/^hls_unlock_/);
});
});
+108
View File
@@ -0,0 +1,108 @@
/**
* v1.18.7 — clinician share-link passphrase second factor.
*
* A share URL is now possession AND knowledge: a leaked `/c/<token>` link
* without the passphrase cannot open the record. Every new link gets a
* high-entropy passphrase minted here, returned to the owner EXACTLY ONCE,
* and stored only as its HMAC-SHA256 hash (the same `hashToken` scheme as the
* token itself, keyed by `API_TOKEN_HMAC_KEY`). There is no recovery path.
*
* Shape: 16 Crockford-base32 characters grouped `XXXX-XXXX-XXXX-XXXX`. The raw
* 16 chars carry 80 bits of entropy; we draw them from 10 random bytes (80
* bits) so the human-typeable form is the full secret with no truncation. The
* QR payload (built by the create route) carries this in the URL FRAGMENT so it
* never reaches a server log, referrer, or access record.
*/
import { randomBytes, timingSafeEqual } from "node:crypto";
import { hashToken } from "@/lib/auth/hmac";
/**
* Crockford base32 alphabet (no I/L/O/U — removes the characters most often
* misread when typed by hand). 32 symbols → 5 bits each.
*/
const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
/** Group size for the human-typeable form (`XXXX-XXXX-XXXX-XXXX`). */
const GROUP = 4;
/** Total characters in a raw passphrase (16 × 5 bits = 80 bits). */
const LENGTH = 16;
/** The fragment key the QR / deep-link uses: `#k=<passphrase>`. */
export const PASSPHRASE_FRAGMENT_KEY = "k";
/**
* The canonical regex a submitted passphrase must match once normalised
* (uppercased, separators stripped): 16 Crockford-base32 characters.
*/
export const PASSPHRASE_PATTERN = new RegExp(`^[${ALPHABET}]{${LENGTH}}$`);
/**
* Generate a fresh passphrase in the grouped human-typeable form
* (`XXXX-XXXX-XXXX-XXXX`). Drawn from CSPRNG bytes via rejection-free
* masking — each character maps a 5-bit slice of the random stream onto the
* 32-symbol alphabet, so the distribution is uniform.
*/
export function generatePassphrase(): string {
// 16 chars × 5 bits = 80 bits → 10 bytes of entropy.
const bytes = randomBytes(10);
let bits = 0;
let value = 0;
const chars: string[] = [];
for (let i = 0; i < bytes.length && chars.length < LENGTH; i++) {
value = (value << 8) | bytes[i];
bits += 8;
while (bits >= 5 && chars.length < LENGTH) {
bits -= 5;
chars.push(ALPHABET[(value >>> bits) & 0x1f]);
}
}
const groups: string[] = [];
for (let i = 0; i < chars.length; i += GROUP) {
groups.push(chars.slice(i, i + GROUP).join(""));
}
return groups.join("-");
}
/**
* Normalise a passphrase a human (or a QR fragment) submitted: strip every
* separator / whitespace and uppercase. Lowercase `l`→`L` style confusions are
* left to the alphabet (which already excludes I/L/O/U), so a value that does
* not normalise to the canonical pattern is simply rejected by the caller.
* Returns the bare 16-char form, or `null` when it does not match the pattern.
*/
export function normalisePassphrase(raw: string): string | null {
const stripped = raw.replace(/[\s-]+/g, "").toUpperCase();
return PASSPHRASE_PATTERN.test(stripped) ? stripped : null;
}
/**
* Hash a passphrase for storage / comparison. Normalises first so the grouped
* and bare forms collide to one hash; throws (via `normalisePassphrase` →
* caller) only when the input is malformed. Returns the lowercase hex HMAC.
*/
export function hashPassphrase(normalised: string): string {
return hashToken(normalised);
}
/**
* Constant-time check of a submitted passphrase against a stored hash. Returns
* false for a malformed submission, a null stored hash, or any mismatch — and
* never short-circuits on length so timing leaks nothing about the secret.
*/
export function verifyPassphrase(
submitted: string,
storedHash: string | null,
): boolean {
if (!storedHash) return false;
const normalised = normalisePassphrase(submitted);
if (!normalised) return false;
const candidate = Buffer.from(hashPassphrase(normalised), "hex");
const expected = Buffer.from(storedHash, "hex");
if (candidate.length !== expected.length) return false;
try {
return timingSafeEqual(candidate, expected);
} catch {
return false;
}
}
+32 -1
View File
@@ -27,7 +27,7 @@ import { hashToken } from "@/lib/auth/hmac";
import { prisma } from "@/lib/db";
/** The `hls_<48 hex>` shape the lifecycle route mints (192-bit body). */
const SHARE_TOKEN_PATTERN = /^hls_[0-9a-f]{48}$/;
export const SHARE_TOKEN_PATTERN = /^hls_[0-9a-f]{48}$/;
/**
* The scoped read context a resolved share token yields. It is intentionally a
@@ -117,3 +117,34 @@ export async function resolveShareToken(
expiresAt: row.expiresAt,
};
}
/**
* v1.18.7 — the live-gate state for a raw token, WITHOUT bumping access
* counters. Used by the public page (decide gate vs render) and the unlock
* route (verify a passphrase). It deliberately resolves a malformed / unknown /
* revoked / expired token to `null` — the same blunt nothing the view returns —
* so the unlock surface leaks no more than the page does. It carries the stored
* `passphraseHash` and the `tokenHash` (for cookie scoping + rate-limit keying)
* but never the owner scope; the full read still flows through
* {@link resolveShareToken}.
*/
export interface ShareGateState {
tokenHash: string;
/** `null` for a legacy link minted before the passphrase gate. */
passphraseHash: string | null;
}
export async function resolveShareGateState(
rawToken: string | null | undefined,
): Promise<ShareGateState | null> {
if (!rawToken || !SHARE_TOKEN_PATTERN.test(rawToken)) return null;
const tokenHash = hashToken(rawToken);
const row = await prisma.clinicianShareLink.findUnique({
where: { tokenHash },
select: { passphraseHash: true, revokedAt: true, expiresAt: true },
});
if (!row) return null;
if (row.revokedAt !== null) return null;
if (row.expiresAt.getTime() <= Date.now()) return null;
return { tokenHash, passphraseHash: row.passphraseHash };
}
+81
View File
@@ -0,0 +1,81 @@
/**
* v1.18.7 — short-lived unlock cookie for a passphrase-gated share link.
*
* After the public unlock route verifies the passphrase it mints a signed,
* httpOnly, SameSite=Strict, Secure (per `shouldEmitSecureCookie`) cookie
* SCOPED to that one token's view path (`/c/<token>`). The page checks the
* cookie server-side before rendering the record; presenting an unlock for one
* token never unlocks another (the cookie name + path both pin the token hash).
*
* The value is opaque and self-verifying: `<expiryMs>.<hmac>` where the HMAC is
* over `"<tokenHash>.<expiryMs>"` keyed by `API_TOKEN_HMAC_KEY`. No server-side
* session row is needed; a forged or replayed-across-token value fails the
* constant-time HMAC check, and an expired one fails the timestamp check. TTL
* is short (30 min) so a shared device window closes quickly.
*/
import { createHmac, timingSafeEqual } from "node:crypto";
/** Unlock-cookie lifetime: 30 minutes. */
export const UNLOCK_TTL_SECONDS = 30 * 60;
const MIN_HMAC_KEY_LENGTH = 32;
function unlockKey(): string {
const key = process.env.API_TOKEN_HMAC_KEY;
if (!key) {
throw new Error("API_TOKEN_HMAC_KEY env var must be set");
}
if (key.length < MIN_HMAC_KEY_LENGTH) {
throw new Error(
`API_TOKEN_HMAC_KEY must be at least ${MIN_HMAC_KEY_LENGTH} characters, got ${key.length}.`,
);
}
return key;
}
/**
* The per-token cookie name. A short prefix of the token hash keeps cookies for
* different shares distinct without putting the full hash in the header. The
* path scoping (`/c/<token>`) is the real isolation boundary; the name suffix
* only avoids cross-token collisions inside the same browser.
*/
export function unlockCookieName(tokenHash: string): string {
return `hls_unlock_${tokenHash.slice(0, 16)}`;
}
function sign(tokenHash: string, expiryMs: number): string {
return createHmac("sha256", unlockKey())
.update(`${tokenHash}.${expiryMs}`)
.digest("hex");
}
/** Mint a fresh unlock-cookie value bound to this token hash. */
export function mintUnlockValue(tokenHash: string): string {
const expiryMs = Date.now() + UNLOCK_TTL_SECONDS * 1000;
return `${expiryMs}.${sign(tokenHash, expiryMs)}`;
}
/**
* Verify an unlock-cookie value against the token hash. Constant-time on the
* HMAC; returns false for any malformed, forged, cross-token, or expired value.
*/
export function verifyUnlockValue(
value: string | undefined,
tokenHash: string,
): boolean {
if (!value) return false;
const dot = value.indexOf(".");
if (dot <= 0) return false;
const expiryMs = Number(value.slice(0, dot));
if (!Number.isFinite(expiryMs) || expiryMs <= Date.now()) return false;
const received = value.slice(dot + 1);
const expected = sign(tokenHash, expiryMs);
const a = Buffer.from(received, "hex");
const b = Buffer.from(expected, "hex");
if (a.length !== b.length || a.length === 0) return false;
try {
return timingSafeEqual(a, b);
} catch {
return false;
}
}
+61 -2
View File
@@ -8,7 +8,10 @@
import { z } from "zod/v4";
import type { ZodOpenApiObject } from "zod-openapi";
import { exportSelectionSchema } from "@/lib/validations/health-record-export";
import { createShareLinkSchema } from "@/lib/validations/clinician-share-link";
import {
createShareLinkSchema,
unlockShareLinkSchema,
} from "@/lib/validations/clinician-share-link";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
// v1.11.0 — clinician share-link owner-facing summary (never the raw token).
@@ -19,6 +22,9 @@ const shareLinkSummary = z.object({
rangeEnd: z.string().nullable(),
resourceTypes: z.array(z.string()),
allowFhirApi: z.boolean(),
// v1.18.7 — whether a passphrase second factor guards this link. Always true
// for links created on v1.18.7+; false only for legacy links.
protected: z.boolean(),
expiresAt: z.string(),
createdAt: z.string(),
revokedAt: z.string().nullable(),
@@ -31,6 +37,18 @@ const shareLinkCreatedResponse = shareLinkSummary.extend({
token: z
.string()
.describe("Raw `hls_` token — returned ONCE and unrecoverable thereafter."),
// v1.18.7 — passphrase second factor + QR deep link, all returned ONCE.
passphrase: z
.string()
.describe(
"Raw passphrase second factor — returned ONCE; stored only as an HMAC hash, unrecoverable.",
),
shareUrl: z.string().describe("Absolute `/c/<token>` URL (no passphrase)."),
qrUrl: z
.string()
.describe(
"Absolute share URL with the passphrase in the URL FRAGMENT (`#k=<passphrase>`). Encode this as the QR payload; the fragment is never sent to the server.",
),
});
const shareLinkListResponse = z.object({
@@ -42,6 +60,11 @@ const shareLinkRevokedResponse = z.object({
revoked: z.boolean(),
});
// v1.18.7 — public passphrase-gate verify result.
const shareLinkUnlockResponse = z.object({
unlocked: z.literal(true),
});
export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/export/health-record": {
post: {
@@ -80,7 +103,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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.",
"Owner-only. Mints an `hls_` token (192-bit), stores only its HMAC hash, and returns the raw token EXACTLY ONCE in the response. v1.18.7 also mints a passphrase second factor (returned once as `passphrase`, with `shareUrl` and a `qrUrl` carrying the passphrase in the URL fragment); a leaked URL without the passphrase cannot open the record. 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: {
@@ -148,6 +171,42 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/c/{token}/unlock": {
post: {
tags: ["Export"],
summary: "Verify a share-link passphrase (v1.18.7, public)",
description:
"Anonymous, no-session passphrase gate for a protected share link. The raw path token plus the submitted passphrase are the only credentials. On success the server sets a short-lived (30 min), token-scoped, httpOnly cookie so `/c/<token>` renders the record. Rate-limited (`share-unlock:<tokenHash>:<ip>`); every failure class (bad token, no passphrase set, wrong passphrase) answers one blunt 401.",
requestParams: { path: z.object({ token: z.string() }) },
requestBody: {
required: true,
content: {
"application/json": { schema: unlockShareLinkSchema },
},
},
responses: {
"200": {
description:
"Passphrase accepted; short-lived token-scoped unlock cookie set.",
content: {
"application/json": {
schema: dataEnvelope(
shareLinkUnlockResponse,
"ShareLinkUnlock",
),
},
},
},
// Spread the standard responses first, then override 401 with the
// share-specific blunt-reject description.
...stdResponses,
"401": {
description: "Invalid passphrase (blunt — no failure detail leaked).",
content: { "application/json": { schema: errorEnvelope } },
},
},
},
},
"/api/fhir/metadata": {
get: {
tags: ["FHIR"],
@@ -64,3 +64,16 @@ export const createShareLinkSchema = z
);
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>;
+6
View File
@@ -42,6 +42,12 @@ const PUBLIC_PATHS = [
// by a session cookie, so it must reach the page without an auth gate.
// The page renders a flat 404 for any unknown / revoked / expired token.
"/c/",
// v1.18.7 — `/api/c/<token>/unlock` is the public passphrase-gate verifier
// for a protected share link. Like the view it carries no session — the raw
// path token plus the submitted passphrase are the only credentials — so it
// must reach the route handler without an auth gate. It is rate-limited and
// answers one blunt error for every failure class.
"/api/c/",
// v1.17.0 — `/invite/<hlv_token>` is the invite universal-link landing
// (iOS #16). It is a thin shape-validated redirect onto
// `/auth/register?invite=…`, carries no session, touches no database,