fix(export): every whole-record export asks for a fresh proof

GET /api/export with type=all (the default), POST /api/export/health-record
and GET /api/fhir/Patient/$everything hand over the whole record but took a
live session alone, so a stolen browser session could still walk off with
everything the full-backup gate was meant to protect. A browser session now
needs a sign-in or re-proof within five minutes, with the second factor on
an account that has one, and the doctor-report panel opens the re-proof
dialog and retries. When a manager builds the report for a record they
manage, their own session is the one asked.

On a token the shipped app keeps working: health-record and $everything
take the token as before, including a fhir:read token, and the recent-proof
guard names both. GET /api/export?type=all, which no shipped client calls
with a token, wants a step-up elevation there. Single-type exports are
unchanged.

The API reference now also describes the enrollment rule for accounts
with a second factor and the passkey refusal it adds.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 22:21:43 +02:00
1 parent 0692017f34
commit a4d27c618e
13 files changed
+304 -76

No files matched your search

+50 -21
View File
@@ -864,7 +864,11 @@ paths:
`selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and
there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape
is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or
Bearer; shared `export:<userId>` rate bucket (10/h). Strict validation: unknown keys 422."
Bearer; shared `export:<userId>` rate bucket (10/h). Strict validation: unknown keys 422. A browser session
needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes
(with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with
`meta.methods` naming the proofs the account can give. When a manager exports a record they manage, it is their
own session that is asked. A Bearer caller is not asked for more than its token."
requestBody:
required: true
content:
@@ -1489,7 +1493,10 @@ paths:
Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the
window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report
selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count`
(≤200) / `_offset`."
(≤200) / `_offset`. A browser session needs a fresh proof as well as a live one: a sign-in or `POST
/api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it
the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A token,
including a `fhir:read` one, is not asked for more."
parameters:
- in: query
name: _count
@@ -1524,7 +1531,13 @@ paths:
notes ride their exported column. Shares the `export:<userId>` bucket with the encrypted variant (10 per hour)
and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as
themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows
are still exported, because this is the account's own data leaving on request."
are still exported, because this is the account's own data leaving on request. `type=all`, the default, is the
whole record and asks for a fresh proof like the full backup; the single-type exports do not. A browser session
needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes
(with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with
`meta.methods` naming the proofs the account can give. A Bearer caller asking for `type=all` presents an
`X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and
receives 401 `auth.stepup.required` without it."
parameters:
- in: query
name: format
@@ -2172,14 +2185,18 @@ paths:
/api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused.
Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a
password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a
second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST
/api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey /
security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401
`auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw
on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the
step-up mint.
Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a
password-proved one is enough on an account without a second factor, and on an account that already has one the
elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor,
the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes;
otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey
assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor,
only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key
or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a
remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with
`meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed`
and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once
spent), the same budget as the step-up mint.
requestBody:
required: false
content:
@@ -2316,14 +2333,18 @@ paths:
refused.
Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a
password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a
second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST
/api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey /
security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401
`auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw
on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the
step-up mint.
Adding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a
password-proved one is enough on an account without a second factor, and on an account that already has one the
elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor,
the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes;
otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey
assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor,
only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key
or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a
remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with
`meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed`
and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once
spent), the same budget as the step-up mint.
requestBody:
required: false
content:
@@ -3453,7 +3474,9 @@ paths:
**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a
password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options
issued.
issued. On an account that already has a second factor a password is refused before it is checked, with 422
`auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone
would sign in without the second factor from then on.
Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the
@@ -3500,7 +3523,13 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"422":
description: "`auth.reproof.too_weak`: a password on an account that already has a second factor. `meta.methods` names
the proofs that would be accepted. Refused before the password is checked, so it spends no attempt."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
/api/auth/passkey/register-verify:
post:
@@ -73,6 +73,8 @@ function callers(exportName: string): string[] {
/** Routes that resolve their own caller and take the recent-proof gate. */
const RECENT_PROOF_ROUTES: Record<string, string> = {
// `type=all` only; the single-type exports stay on `requireAuth`.
"app/api/export/route.ts": "elevation",
"app/api/export/full-backup/route.ts": "elevation",
"app/api/export/encrypted/route.ts": "elevation-if-enrolled",
// The shipped iOS app calls these two on its token without an elevation and
@@ -82,6 +84,17 @@ const RECENT_PROOF_ROUTES: Record<string, string> = {
"app/api/mcp/tokens/route.ts": "token",
};
/**
* Routes that resolve a cookie OR a token through their own resolver
* (`requireAuth` with a scope, `requireRecordAuth`) and add the cookie arm
* only. Their Bearer arm is the token, as with the `token` rule above: the
* shipped app calls both with its token and no elevation.
*/
const TOKEN_ARM_COOKIE_PROOF_ROUTES = [
"app/api/export/health-record/route.ts",
"app/api/fhir/Patient/$everything/route.ts",
].sort();
/** Cookie-only routes that add the gate after their own resolver. */
const COOKIE_PROOF_ROUTES = [
"app/api/admin/backups/[id]/download/route.ts",
@@ -112,10 +125,20 @@ describe("the recent-proof gate", () => {
expect(found).toEqual(RECENT_PROOF_ROUTES);
});
it("the cookie arm is added by exactly the known cookie-only routes", () => {
it("the cookie arm is added by exactly the known routes", () => {
const found = callers("assertRecentCookieProof");
expect(found.length).toBeGreaterThan(0);
expect(found).toEqual(COOKIE_PROOF_ROUTES);
expect(found).toEqual(
[...COOKIE_PROOF_ROUTES, ...TOKEN_ARM_COOKIE_PROOF_ROUTES].sort(),
);
});
it("a route that resolves either transport asks only on the cookie", () => {
for (const rel of TOKEN_ARM_COOKIE_PROOF_ROUTES) {
expect(read(rel), rel).toMatch(
/if \(auth\.authMethod === "cookie"\) \{\s*await assertRecentCookieProof\(/,
);
}
});
it("each cookie-only route resolves a cookie before it asks", () => {
@@ -318,3 +318,42 @@ describe("v1.4.41 W-DELETED-2 — soft-delete invisibility", () => {
}
});
});
describe("GET /api/export — the whole record asks for a fresh proof", () => {
const stale = {
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
};
it("type=all on a session that signed in an hour ago is asked to confirm", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never);
const { GET } = await import("../route");
const res = await GET(mkReq("http://localhost/api/export?type=all"));
expect(res.status).toBe(401);
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
it("no type means the whole record, and is gated the same way", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce(stale as never);
const { GET } = await import("../route");
const res = await GET(mkReq("http://localhost/api/export"));
expect(res.status).toBe(401);
});
it("type=all right after signing in goes through", async () => {
const { GET } = await import("../route");
const res = await GET(mkReq("http://localhost/api/export?type=all"));
expect(res.status).toBe(200);
});
it("a single-type export stays on the session alone", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValue(stale as never);
const { GET } = await import("../route");
const res = await GET(
mkReq("http://localhost/api/export?type=measurements"),
);
expect(res.status).toBe(200);
});
});
@@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({
userHealthProfile: { findUnique: vi.fn() },
healthProfileFactRevision: { findMany: vi.fn() },
user: { findUnique: vi.fn(), update: vi.fn() },
// The whole-record gate reads the session's own stamps: a sign-in a
// moment ago is the proof, on an account without a second factor.
session: {
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
},
toJson: (value: unknown) => value,
}));
@@ -908,3 +919,18 @@ describe("POST /api/export/health-record — the insurance leaf", () => {
expect(text).toContain("A123456789");
});
});
describe("POST /api/export/health-record — a fresh proof on the browser", () => {
it("a session that signed in an hour ago is asked to confirm first", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
const { POST } = await import("../route");
const res = await POST(mkReq({ format: "pdf", selection: allLeaves() }));
expect(res.status).toBe(401);
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
expect(prisma.measurement.findMany).not.toHaveBeenCalled();
});
});
+16 -2
View File
@@ -22,7 +22,11 @@
*/
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import {
apiHandler,
assertRecentCookieProof,
requireRecordAuth,
} from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import {
@@ -88,7 +92,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
// v1.37.0 — MANAGE: the doctor report is the one export an invited manager
// reaches, because it renders a prepared artefact from a declared selection
// rather than handing over the record whole.
const { user, actor } = await requireRecordAuth("manage", "record");
const auth = await requireRecordAuth("manage", "record");
const { user, actor } = auth;
// A whole-record artefact: a browser session must have signed in or
// re-proved within five minutes, the gate every whole-record export takes.
// The proof is the person at the keyboard, so it is the actor's session
// that is asked, whether they export their own record or one they manage.
// A token is its own proof here, as before: the shipped app calls this
// route with its token and no elevation.
if (auth.authMethod === "cookie") {
await assertRecentCookieProof(actor, auth.session.id);
}
annotate({ action: { name: "export.health-record.build" } });
// The whole doctor-report surface is the `doctorReport` module. Refuse with
+18 -4
View File
@@ -1,5 +1,5 @@
import { prisma } from "@/lib/db";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiHandler, requireAuth, requireRecentProof } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
import { apiError, getClientIp } from "@/lib/api-response";
@@ -24,12 +24,23 @@ import { NextRequest, NextResponse } from "next/server";
* Export user data.
* Query params:
* format: "csv" | "json" (default: json)
* type: "measurements" | "medications" | "intake" | "all" (default: all)
* type: "measurements" | "medications" | "intake" | "mood" | "all"
* (default: all)
*
* Re-authentication is required via session (user must be logged in).
* `type=all` (the default) is the whole record in one file, so it takes the
* same fresh-proof gate as every other whole-record export
* (`requireRecentProof`): a browser session must have signed in or re-proved
* within five minutes, a token must present a step-up elevation. No shipped
* client calls this route with a token. The single-type exports stay on the
* session alone, like their dedicated routes under `/api/export/*`.
*/
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const wholeRecord =
(new URL(request.url).searchParams.get("type") ?? "all") === "all";
const auth = wholeRecord
? await requireRecentProof({ bearer: "elevation" })
: { ...(await requireAuth()), commitElevation: async () => {} };
const { user } = auth;
const rl = await checkRateLimit(`export:${user.id}`, 10, 60 * 60 * 1000);
if (!rl.allowed) {
@@ -54,6 +65,9 @@ export const GET = apiHandler(async (request: NextRequest) => {
);
}
// Spend a Bearer elevation only once the request is about to act.
await auth.commitElevation();
const userId = user.id;
await auditLog("export.download", {
userId,
+17 -3
View File
@@ -13,12 +13,17 @@
* resolves inside the response and the operation can never quietly carry less
* than the document does.
*
* Read-only; `userId` narrowed from `requireAuth`. Offset paging applies across
* Read-only; `userId` narrowed from `requireAuth`. A browser session needs a
* recent sign-in or re-proof (`assertRecentCookieProof`). Offset paging applies across
* the flattened resource list via `_count` (clamped ≤200) / `_offset`.
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiHandler,
assertRecentCookieProof,
requireAuth,
} from "@/lib/api-handler";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -32,7 +37,16 @@ import {
} from "@/lib/fhir/rest";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth(FHIR_READ_SCOPE);
const auth = await requireAuth(FHIR_READ_SCOPE);
const { user } = auth;
// The whole record in one response: a browser session must have signed in
// or re-proved within five minutes, like every other whole-record export.
// A token is its own proof here, as before: the shipped app and the FHIR
// read tokens call this route without an elevation, and a refusal would
// read to the app as a dead session.
if (auth.authMethod === "cookie") {
await assertRecentCookieProof(user, auth.session.id);
}
annotate({ action: { name: "fhir.everything.read" } });
// v1.30 — the FHIR REST face serves the SAME whole-record aggregate as
@@ -15,6 +15,21 @@ import { existsSync } from "node:fs";
import path from "node:path";
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/db", () => ({
prisma: {
// The whole-record gate reads the session's own stamps: a sign-in a
// moment ago is the proof, on an account without a second factor.
session: {
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
},
}));
vi.mock("@/lib/rate-limit", () => ({ checkRateLimit: vi.fn() }));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
@@ -39,6 +54,7 @@ vi.mock("next/headers", () => ({
import { GET as metadataGet } from "../metadata/route";
import { GET as everythingGet } from "../Patient/$everything/route";
import { prisma } from "@/lib/db";
import { GET as observationRead } from "../Observation/[id]/route";
import { GET as patientRead } from "../Patient/[id]/route";
import { GET as coverageRead } from "../Coverage/[id]/route";
@@ -291,6 +307,17 @@ describe("GET /api/fhir/{type}/{id} — read", () => {
});
describe("GET /api/fhir/Patient/$everything", () => {
it("asks a browser session that signed in an hour ago to confirm first", async () => {
vi.mocked(prisma.session.findUnique).mockResolvedValueOnce({
createdAt: new Date(Date.now() - 60 * 60 * 1000),
mfaVerifiedAt: null,
reproofAt: null,
} as never);
const res = await everythingGet(req("/api/fhir/Patient/$everything"));
expect(res.status).toBe(401);
expect((await res.json()).meta.errorCode).toBe("auth.reproof.required");
});
it("returns every resource family the document bundle carries", async () => {
const res = await everythingGet(
req("/api/fhir/Patient/$everything?_count=200"),
@@ -32,6 +32,17 @@ vi.mock("@/lib/db", () => ({
prisma: {
user: { findUnique: vi.fn() },
cycleProfile: { findUnique: vi.fn() },
// The whole-record gate reads the session's own stamps: a sign-in a
// moment ago is the proof, on an account without a second factor.
session: {
findUnique: vi.fn(async () => ({
createdAt: new Date(),
mfaVerifiedAt: null,
reproofAt: null,
})),
},
webauthnMfaCredential: { count: vi.fn(async () => 0) },
passkey: { count: vi.fn(async () => 0) },
},
}));
vi.mock("@/lib/modules/operator-availability", async (importOriginal) => {
@@ -48,6 +48,11 @@ import { ReportVisitOffer } from "./report-visit-offer";
import { Switch } from "@/components/ui/switch";
import { useRovingRadioGroup } from "@/hooks/use-roving-radio-group";
import { apiFetchRaw } from "@/lib/api/api-fetch";
import {
recentProofErrorMessage,
throwIfReproofRequired,
useRecentProof,
} from "@/components/settings/security-section/use-recent-proof";
import { useAuth } from "@/hooks/use-auth";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
@@ -67,6 +72,9 @@ type ExportFormat = "pdf" | "fhir" | "package";
const EXPORT_FORMATS: readonly ExportFormat[] = ["pdf", "fhir", "package"];
const PRESET_RANGES = [30, 90, 180, 365] as const;
/** A refusal already worded for the panel; shown as it is. */
class ReportRequestError extends Error {}
export function HealthRecordExportPanel() {
const { t, locale } = useTranslations();
const { user } = useAuth();
@@ -119,18 +127,40 @@ export function HealthRecordExportPanel() {
onSelect: (index) => setFormat(EXPORT_FORMATS[index]!),
});
// The report is built from the whole record, so it asks for a fresh proof
// unless the session signed in or re-proved within five minutes.
const recentProof = useRecentProof();
async function handleGenerate() {
setBusy(true);
setError(null);
try {
const range =
customRange && startDate && endDate
? {
startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
}
: { days };
const res = await apiFetchRaw("/api/export/health-record", {
await recentProof.run(generateOnce);
} catch (err) {
if (err instanceof ReportRequestError) {
setError(err.message);
return;
}
const message = recentProofErrorMessage(
err,
err instanceof Error ? err.message : String(err),
);
if (message) setError(message);
} finally {
setBusy(false);
}
}
async function generateOnce() {
const range =
customRange && startDate && endDate
? {
startDate: new Date(`${startDate}T00:00:00Z`).toISOString(),
endDate: new Date(`${endDate}T23:59:59Z`).toISOString(),
}
: { days };
const res = await throwIfReproofRequired(
await apiFetchRaw("/api/export/health-record", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
@@ -144,38 +174,33 @@ export function HealthRecordExportPanel() {
includeCharts,
selection: { v: 2, leaves: orderLeaves(selected) },
}),
});
if (!res.ok) {
setError(
res.status === 429
? t("settings.healthRecord.errorRateLimit")
: res.status === 403
? t("settings.healthRecord.errorModuleDisabled")
: t("settings.healthRecord.errorGeneric"),
);
return;
}
const blob = await res.blob();
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `healthlog-health-record-${new Date()
.toISOString()
.slice(0, 10)}.${ext}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
// The route just persisted the practice name and the selection, so the
// cached `/me` payload is stale. Refresh it, or a remount would seed from
// the previous values.
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}),
);
if (!res.ok) {
throw new ReportRequestError(
res.status === 429
? t("settings.healthRecord.errorRateLimit")
: res.status === 403
? t("settings.healthRecord.errorModuleDisabled")
: t("settings.healthRecord.errorGeneric"),
);
}
const blob = await res.blob();
const ext = format === "pdf" ? "pdf" : format === "fhir" ? "json" : "zip";
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `healthlog-health-record-${new Date()
.toISOString()
.slice(0, 10)}.${ext}`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
// The route just persisted the practice name and the selection, so the
// cached `/me` payload is stale. Refresh it, or a remount would seed from
// the previous values.
void queryClient.invalidateQueries({ queryKey: queryKeys.authMe() });
}
return (
@@ -397,6 +422,7 @@ export function HealthRecordExportPanel() {
</p>
)}
</div>
{recentProof.dialog}
</SettingsCard>
);
}
+7 -2
View File
@@ -332,7 +332,7 @@ const MFA_MANAGEMENT_AUTH_NOTE =
" Accepts a cookie session, or a Bearer token presenting a single-use elevation from POST /api/auth/step-up in the `X-Step-Up` header; a Bearer token alone is still refused.";
const ENROLLMENT_PROOF_NOTE =
"\n\nAdding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof (a password-proved one is enough). On the cookie path the request passes when the session signed in, or completed a second factor, within the last five minutes; otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, a `totp` code from an already confirmed authenticator, or a passkey / security-key assertion begun at POST /api/auth/passkey/register-options). Without one the answer is 401 `auth.reproof.required`; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the step-up mint.";
"\n\nAdding a factor needs a fresh proof, not just a session. On the Bearer path the elevation is that proof: a password-proved one is enough on an account without a second factor, and on an account that already has one the elevation must come from that factor or a passkey. On the cookie path, for an account without a second factor, the request passes when the session signed in, or completed a passkey sign-in, within the last five minutes; otherwise the body must carry a proof in the shape POST /api/auth/step-up takes (`password`, or a passkey assertion begun at POST /api/auth/passkey/register-options). For an account that already has a second factor, only that factor counts: a second factor completed within the last five minutes, or a `totp` code, security-key or passkey assertion in the body. A password or a young session (for example one that skipped the factor on a remembered browser) is not enough there. Without one the answer is 401 `auth.reproof.required` with `meta.methods` naming the proofs the account can give; a proof that does not verify is 401 `auth.reproof.failed` and is audited. Proofs draw on the account's shared re-proof budget of five per fifteen minutes (429 once spent), the same budget as the step-up mint.";
const enrollmentProofResponses = {
"401": {
@@ -2023,7 +2023,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description:
"Cookie-only — a Bearer token cannot enroll a sign-in credential on this surface at any scope. The route has TWO arms and the body decides which.\n\n" +
'**Re-proof arm.** A body of exactly `{ method: "passkey" }` or `{ method: "webauthn" }` begins an assertion ceremony WITHOUT beginning enrollment, and answers with `reauth: true` alongside the options. Use it to obtain the assertion the second arm needs.\n\n' +
"**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued.\n\n" +
"**Enrollment arm.** Any other body must be a full factor proof (the same shape POST /api/auth/step-up takes): a password, a TOTP code, or a completed passkey / security-key assertion. Only then are registration options issued. On an account that already has a second factor a password is refused before it is checked, with 422 `auth.reproof.too_weak` and `meta.methods` naming the proofs that work: a passkey added on the password alone would sign in without the second factor from then on.\n\n" +
"Which factor you prove changes the session, and the direction is not the obvious one: a strong proof stamps the session second-factor-verified, while a PASSWORD proof deliberately CLEARS that stamp. Registering with a password is authorised by the single-use, session-bound challenge alone and must not silently upgrade password-only authentication into something the rest of the app reads as a second factor.\n\n" +
"Every refusal on the proof path is the same 401 with the same prose — a missing content type, an unparseable body, a body that is not a valid proof shape, and a wrong password are indistinguishable to the caller. A `totp` proof counts only from an authenticator whose enrollment was confirmed.\n\n" +
"Each proof on the enrollment arm draws on the account's shared re-proof budget (five per fifteen minutes, the same budget as POST /api/auth/step-up), and every failed proof writes an `auth.mfa.failed` audit row. Once the budget is spent the answer is 429 until the window passes, even for a correct proof.",
@@ -2060,6 +2060,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
"422": {
description:
"`auth.reproof.too_weak`: a password on an account that already has a second factor. `meta.methods` names the proofs that would be accepted. Refused before the password is checked, so it spends no attempt.",
content: { "application/json": { schema: errorEnvelope } },
},
"401": {
description:
"No cookie session, or no acceptable existing-factor proof. One message covers every proof failure so the response cannot be used to probe which part was wrong.",
+1 -1
View File
@@ -168,7 +168,7 @@ export const exportPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Export"],
summary: "Download measurements, medications, intake and mood",
description:
"The plaintext download. Returns `application/json` or `text/csv` (never the standard response envelope) with a `Content-Disposition: attachment` filename of `healthlog-export-<YYYY-MM-DD>.<ext>`. Measurements stream page by page, so a multi-year account does not buffer its whole history in memory; the other sections are assembled in full first. The CSV variant concatenates the selected sections under `# Measurements` / `# Medications` / `# Intake Events` / `# Mood Entries` headings, so it is a CONCATENATION of tables rather than one table — a single-table CSV parser will choke on it. Mood notes are decrypted into the download; measurement notes ride their exported column. Shares the `export:<userId>` bucket with the encrypted variant (10 per hour) and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows are still exported, because this is the account's own data leaving on request.",
"The plaintext download. Returns `application/json` or `text/csv` (never the standard response envelope) with a `Content-Disposition: attachment` filename of `healthlog-export-<YYYY-MM-DD>.<ext>`. Measurements stream page by page, so a multi-year account does not buffer its whole history in memory; the other sections are assembled in full first. The CSV variant concatenates the selected sections under `# Measurements` / `# Medications` / `# Intake Events` / `# Mood Entries` headings, so it is a CONCATENATION of tables rather than one table — a single-table CSV parser will choke on it. Mood notes are decrypted into the download; measurement notes ride their exported column. Shares the `export:<userId>` bucket with the encrypted variant (10 per hour) and audits every attempt as `export.download`. Cookie or Bearer auth; the caller is always resolved as themselves, so this download cannot be delegated to a shared record. Not module-gated: a disabled module's rows are still exported, because this is the account's own data leaving on request. `type=all`, the default, is the whole record and asks for a fresh proof like the full backup; the single-type exports do not. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A Bearer caller asking for `type=all` presents an `X-Step-Up` elevation from `POST /api/auth/step-up` (a second-factor one on an account with a second factor) and receives 401 `auth.stepup.required` without it.",
requestParams: { query: exportQuery },
responses: {
"200": {
+2 -2
View File
@@ -113,7 +113,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Export"],
summary: "Generate a health-record export (PDF / FHIR / package)",
description:
"Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or Bearer; shared `export:<userId>` rate bucket (10/h). Strict validation: unknown keys 422.",
"Returns the doctor-handover artefact in the requested `format`: `pdf` → application/pdf, `fhir` → application/fhir+json (HL7 FHIR R4 document Bundle), `package` → application/zip (PDF + FHIR + README). `selection` is REQUIRED and carries the leaf inclusion list — membership is inclusion, absence is exclusion, and there is no server-side default. An unknown leaf id is a 422 that names it; the retired grouped `sections` shape is a 422 too, because accepting it would mean choosing a scope the caller did not express. Auth via cookie or Bearer; shared `export:<userId>` rate bucket (10/h). Strict validation: unknown keys 422. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. When a manager exports a record they manage, it is their own session that is asked. A Bearer caller is not asked for more than its token.",
requestBody: {
required: true,
content: {
@@ -647,7 +647,7 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["FHIR"],
summary: "FHIR R4 $everything (v1.11.0)",
description:
"Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`.",
"Read-only `Patient/$everything`: every resource the document bundle carries — Composition, Device, Patient, Coverage, Observations (including the cycle ones), MedicationStatements, MedicationAdministrations, Conditions, Encounters, AllergyIntolerances, FamilyMemberHistories and the DiagnosticReport (present when the window holds a vital sign) — flattened into one `searchset` Bundle. Scoped to the owner's SAVED report selection; an account that never saved one gets nothing. Auth: `fhir:read` scope. Offset paging via `_count` (≤200) / `_offset`. A browser session needs a fresh proof as well as a live one: a sign-in or `POST /api/auth/reproof` within the last five minutes (with a second factor, on an account that has one). Without it the answer is 401 `auth.reproof.required`, with `meta.methods` naming the proofs the account can give. A token, including a `fhir:read` one, is not asked for more.",
requestParams: {
query: z.object({
_count: z.coerce.number().optional(),