mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +00:00
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:
commit
aa6196a789
26 files changed
+1509
-90
No files matched your search
+91
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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_/);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user