From 860cb61b0b2188c0ea3058ba3d0b2b95903cb755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 10:59:40 +0200 Subject: [PATCH 1/8] fix(proxy): pin no-store on every session-gated page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dashboard, Insights, the workouts list, the medications list and the Coach server-prefetch their data and serialise one account's health record into the HTML document through a TanStack HydrationBoundary. Those pages are dynamic today only as a side effect of reading the session cookie — a property of the page code, not a guarantee. A stray revalidate export, a force-static segment, or a caching proxy in front of the app would be enough to retain one account's record and hand it to the next caller. Stamp `Cache-Control: private, no-store` at the edge instead, driven by the same predicate that governs the auth redirect, so a page cannot be session-gated without also being uncacheable. Public legal, auth and locale-catalog surfaces keep their cacheable posture; API routes keep managing their own caching. Two guards: the proxy test pins the header on gated pages and its absence on the public ones, and a structural test walks the app tree and refuses a prefetching page that sits on a public path or opts into static / revalidated rendering. --- .../prefetch-page-caching-guard.test.ts | 118 ++++++++++++++++++ src/__tests__/proxy-page-no-store.test.ts | 112 +++++++++++++++++ src/proxy.ts | 27 +++- 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 src/__tests__/prefetch-page-caching-guard.test.ts create mode 100644 src/__tests__/proxy-page-no-store.test.ts diff --git a/src/__tests__/prefetch-page-caching-guard.test.ts b/src/__tests__/prefetch-page-caching-guard.test.ts new file mode 100644 index 000000000..e7a21c0a0 --- /dev/null +++ b/src/__tests__/prefetch-page-caching-guard.test.ts @@ -0,0 +1,118 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import { join, relative, sep } from "node:path"; +import { describe, expect, it } from "vitest"; + +/** + * Structural guard over the server-prefetching pages. + * + * A page that calls `dehydrate()` inside a `HydrationBoundary` serialises the + * caller's own health record into the HTML document. Two things must hold for + * every such page, and neither is enforced by the type system: + * + * 1. It must be session-gated at the edge, so `src/proxy.ts` stamps + * `Cache-Control: private, no-store` on the document. A prefetching page + * placed on a public path would ship a record with no cache directive at + * all. + * 2. It must not opt into static or revalidated rendering. `export const + * revalidate = ` or `dynamic = "force-static"` would let Next serve one + * account's prefetched HTML to the next caller out of its own cache, + * before the request ever reaches a header. + * + * The guard walks the app tree rather than naming files, so a NEW prefetching + * page is covered the moment it lands. + */ + +const APP_DIR = join(process.cwd(), "src", "app"); + +/** Mirrors the public-path allowlist in `src/proxy.ts`. */ +const PUBLIC_ROUTE_PREFIXES = [ + "/auth/", + "/privacy", + "/about", + "/c/", + "/invite/", + "/onboarding", + "/mcp", + "/i18n/", + "/.well-known/", +]; + +function walk(dir: string): string[] { + const out: string[] = []; + for (const entry of readdirSync(dir)) { + if (entry === "node_modules" || entry === "__tests__") continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) { + out.push(...walk(full)); + } else if (entry === "page.tsx" || entry === "page.ts") { + out.push(full); + } + } + return out; +} + +/** Turn `src/app/insights/workouts/page.tsx` into `/insights/workouts`. */ +function routePathFor(file: string): string { + const rel = relative(APP_DIR, file).split(sep).slice(0, -1); + const segments = rel.filter( + // Route groups `(name)` and parallel/intercepting slots contribute no + // URL segment. + (s) => !(s.startsWith("(") && s.endsWith(")")) && !s.startsWith("@"), + ); + return "/" + segments.join("/"); +} + +function isPublicRoute(routePath: string): boolean { + return PUBLIC_ROUTE_PREFIXES.some((p) => routePath.startsWith(p)); +} + +const prefetchingPages = walk(APP_DIR) + .map((file) => ({ file, source: readFileSync(file, "utf8") })) + .filter( + ({ source }) => + source.includes("HydrationBoundary") && source.includes("dehydrate"), + ) + .map(({ file, source }) => ({ + file, + source, + routePath: routePathFor(file), + rel: relative(process.cwd(), file), + })); + +describe("server-prefetching pages cannot leak a cacheable record", () => { + it("finds the prefetching pages at all (guard is not vacuous)", () => { + expect(prefetchingPages.length).toBeGreaterThan(0); + // The dashboard is the canonical one; if it stops matching, the detection + // heuristic has drifted and every assertion below is silently skipped. + expect(prefetchingPages.map((p) => p.routePath)).toContain("/"); + }); + + it("keeps every prefetching page behind the session gate", () => { + const publicOnes = prefetchingPages.filter((p) => + isPublicRoute(p.routePath), + ); + expect( + publicOnes.map((p) => `${p.rel} (${p.routePath})`), + "a page that dehydrates a record onto a public path gets no no-store header from the proxy", + ).toEqual([]); + }); + + it("lets no prefetching page opt into static or revalidated rendering", () => { + const offenders: string[] = []; + for (const page of prefetchingPages) { + if (/export\s+const\s+revalidate\s*=/.test(page.source)) { + offenders.push(`${page.rel}: exports revalidate`); + } + const dynamicMatch = page.source.match( + /export\s+const\s+dynamic\s*=\s*["']([^"']+)["']/, + ); + if (dynamicMatch && dynamicMatch[1] !== "force-dynamic") { + offenders.push(`${page.rel}: exports dynamic = "${dynamicMatch[1]}"`); + } + } + expect( + offenders, + "a prefetched page must never be served from Next's own render cache", + ).toEqual([]); + }); +}); diff --git a/src/__tests__/proxy-page-no-store.test.ts b/src/__tests__/proxy-page-no-store.test.ts new file mode 100644 index 000000000..c96283da3 --- /dev/null +++ b/src/__tests__/proxy-page-no-store.test.ts @@ -0,0 +1,112 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +/** + * Session-gated pages serialise one account's health record into the HTML + * document — the dashboard, Insights, the workouts list, the medications + * list and the Coach all server-prefetch their data through a TanStack + * `HydrationBoundary`, so the record ships inside the payload rather than + * arriving over a later fetch. + * + * Those pages are dynamic today only because they read the session cookie. + * That is a property of the page code, not a guarantee — so the proxy pins + * `Cache-Control: private, no-store` on every session-gated page response. + * `private` bars shared caches (a CDN, a reverse proxy); `no-store` also + * bars the browser disk cache and the bfcache snapshot. + * + * These tests pin both sides: gated pages carry the header, and the public + * surfaces that are deliberately cacheable do not. + */ + +vi.mock("@/lib/process-type", () => ({ + shouldRunWeb: () => true, +})); + +import { proxy } from "../proxy"; + +beforeEach(() => { + vi.stubEnv("NODE_ENV", "production"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +function makeRequest(pathname: string): NextRequest { + return new NextRequest(`http://localhost${pathname}`, { + headers: { cookie: "healthlog_session=sess-1" }, + }); +} + +/** The pages that server-prefetch and dehydrate a record into the HTML. */ +const PREFETCHING_PAGES = [ + "/", + "/coach", + "/insights", + "/insights/workouts", + "/medications", +]; + +/** Other session-gated surfaces that render account data. */ +const OTHER_GATED_PAGES = [ + "/documents", + "/settings/profile", + "/admin/users", + "/mood", +]; + +describe("proxy.ts no-store on session-gated pages", () => { + it.each(PREFETCHING_PAGES)( + "sets private, no-store on the prefetching page %s", + (path) => { + const res = proxy(makeRequest(path)); + const cacheControl = res.headers.get("cache-control") ?? ""; + expect(cacheControl).toContain("no-store"); + expect(cacheControl).toContain("private"); + }, + ); + + it.each(OTHER_GATED_PAGES)( + "sets private, no-store on the gated page %s", + (path) => { + const res = proxy(makeRequest(path)); + const cacheControl = res.headers.get("cache-control") ?? ""; + expect(cacheControl).toContain("no-store"); + expect(cacheControl).toContain("private"); + }, + ); + + it("still emits no-store when the request carries no session cookie", () => { + // The unauthenticated caller gets a redirect to /auth/login, which never + // reaches the header block — assert the redirect rather than a header, so + // the test states what actually happens on that arm. + const res = proxy(new NextRequest("http://localhost/medications")); + expect(res.status).toBe(307); + expect(res.headers.get("location")).toContain("/auth/login"); + }); + + it("leaves the public legal + auth surfaces cacheable", () => { + for (const path of ["/privacy", "/about", "/auth/login"]) { + const res = proxy(makeRequest(path)); + expect(res.headers.get("cache-control"), path).toBeNull(); + } + }); + + it("leaves the immutable locale catalog cacheable", () => { + const res = proxy(makeRequest("/i18n/en")); + expect(res.headers.get("cache-control")).toBeNull(); + }); + + it("does not blanket API routes, which manage their own caching", () => { + const res = proxy(makeRequest("/api/user/avatar/user-1")); + expect(res.headers.get("cache-control")).toBeNull(); + }); + + it("keeps the stricter share-link posture on /c/", () => { + const token = "hls_000000000000000000000000000000000000000000000000"; + const res = proxy(makeRequest(`/c/${token}`)); + const cacheControl = res.headers.get("cache-control") ?? ""; + expect(cacheControl).toContain("no-store"); + expect(cacheControl).toContain("must-revalidate"); + }); +}); diff --git a/src/proxy.ts b/src/proxy.ts index e1ede731a..f8712927d 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -245,7 +245,11 @@ export function proxy(request: NextRequest) { const isApiRoute = pathname.startsWith("/api/"); const isStaticFile = /\.\w+$/.test(pathname); const isPublic = isPublicPath(pathname); - if (!isApiRoute && !isStaticFile && !isPublic) { + // Every page behind the session gate. The same predicate drives the + // auth redirect below and the `no-store` header further down, so a page + // can never be session-gated without also being uncacheable. + const isSessionGatedPage = !isApiRoute && !isStaticFile && !isPublic; + if (isSessionGatedPage) { const hasSession = request.cookies.has("healthlog_session"); if (!hasSession) { return NextResponse.redirect(new URL("/auth/login", request.url)); @@ -335,6 +339,27 @@ export function proxy(request: NextRequest) { // Both serve routes take the narrow, document-only header posture. const isServeRoute = isDocumentServeRoute || isShareDocumentServeRoute; + // Session-gated pages carry one account's health record in the document + // itself. Several of them server-prefetch their data and serialise it into + // the HTML through a TanStack `HydrationBoundary` — the dashboard, Insights, + // the workouts list, the medications list, the Coach. Those pages are + // dynamic today only as a side effect of reading the session cookie, which + // is a property of the page code, not a guarantee: a stray `revalidate` + // export, a `force-static` segment, or a caching proxy in front of the app + // would be enough to retain one account's record and hand it to the next + // caller. Pin the guarantee at the edge instead, where no page-level change + // can weaken it. + // + // `private` bars shared caches (CDN, reverse proxy) outright; `no-store` + // additionally stops the browser's own disk cache and the bfcache snapshot, + // so a back-navigation after logout cannot repaint the record. The public + // surfaces (`/auth/*`, `/privacy`, `/about`, `/i18n/*`) are excluded by the + // same predicate that governs the auth redirect, so the statically + // rendered marketing/legal pages stay cacheable. + if (isSessionGatedPage) { + response.headers.set("Cache-Control", "private, no-store"); + } + // Security headers response.headers.set("X-Content-Type-Options", "nosniff"); response.headers.set("X-Frame-Options", isServeRoute ? "SAMEORIGIN" : "DENY"); From d40cb3eef3c9a4cf823ecd32bfc2a2d246ecbba9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 11:01:57 +0200 Subject: [PATCH 2/8] fix(ai): align the corrective retry prompt with the validator that grades the reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The retry suffix still described the strict insight wrapper that was removed long ago: it demanded citations[] and warnings[] — fields insightResultSchema does not have — required metricSource, rationale, id and severity on every recommendation when all four are optional, and named none of the five fields the live schema actually requires (classification, findings, correlations, dataQuality, disclaimer). So the one corrective pass steered the model away from the contract it was being corrected towards. A reply that obeyed the correction could not validate, fell through parseComprehensiveResult's raw-object escape hatch, and the downstream surfaces read an unvalidated payload. Describe insightResultSchema and nothing else, and carry the contract as data in RETRY_CONTRACT_EXAMPLE so a test can assert prompt and validator agree. --- src/lib/ai/__tests__/retry-correction.test.ts | 87 +++++++++++++++++++ src/lib/ai/retry-correction.ts | 85 +++++++++++++----- 2 files changed, 152 insertions(+), 20 deletions(-) create mode 100644 src/lib/ai/__tests__/retry-correction.test.ts diff --git a/src/lib/ai/__tests__/retry-correction.test.ts b/src/lib/ai/__tests__/retry-correction.test.ts new file mode 100644 index 000000000..681a2b062 --- /dev/null +++ b/src/lib/ai/__tests__/retry-correction.test.ts @@ -0,0 +1,87 @@ +/** + * The corrective retry prompt and the validator that grades the retry reply + * must describe ONE contract. + * + * The prompt used to demand the dead strict wrapper's shape — `citations[]`, + * `warnings[]`, and a mandatory `metricSource` / `rationale` / `id` / + * `severity` on every recommendation — while naming none of the five fields + * `insightResultSchema` actually requires. A model that obeyed the correction + * produced a reply the validator could not accept, so the one corrective pass + * spent budget steering the generation away from its own contract. + * + * These tests pin both directions: the example the prompt hands the model + * validates, and the prompt names every required key without naming a key the + * validator does not have. + */ +import { describe, it, expect } from "vitest"; +import { z } from "zod/v4"; + +import { + buildRetryCorrectionMessage, + RETRY_CONTRACT_EXAMPLE, +} from "@/lib/ai/retry-correction"; +import { insightResultSchema } from "@/lib/ai/types"; + +/** Top-level keys `insightResultSchema` requires (no `.optional()`). */ +function requiredSchemaKeys(): string[] { + const shape = (insightResultSchema as unknown as z.ZodObject).shape; + return Object.entries(shape) + .filter(([, field]) => !(field as z.ZodType).safeParse(undefined).success) + .map(([key]) => key); +} + +/** Every top-level key the schema knows about, required or optional. */ +function allSchemaKeys(): string[] { + return Object.keys((insightResultSchema as unknown as z.ZodObject).shape); +} + +describe("buildRetryCorrectionMessage — prompt matches the validator", () => { + it("hands the model an example that the validator accepts", () => { + const parsed = insightResultSchema.safeParse(RETRY_CONTRACT_EXAMPLE); + expect(parsed.success).toBe(true); + }); + + it("embeds that example verbatim in the corrective message", () => { + const message = buildRetryCorrectionMessage("reason", "details"); + expect(message).toContain(JSON.stringify(RETRY_CONTRACT_EXAMPLE, null, 2)); + }); + + it("names every field the validator requires", () => { + const message = buildRetryCorrectionMessage("reason", "details"); + for (const key of requiredSchemaKeys()) { + expect(message, `retry prompt omits required field "${key}"`).toContain( + key, + ); + } + }); + + it("names no top-level field the validator does not have", () => { + const message = buildRetryCorrectionMessage("reason", "details"); + const known = new Set(allSchemaKeys()); + // The dead wrapper's two invented top-level arrays. Asserted by name + // rather than by diffing prose, so the check cannot go vacuous. + for (const stale of ["citations", "warnings"]) { + expect(known.has(stale)).toBe(false); + expect( + message, + `retry prompt still demands the removed "${stale}" field`, + ).not.toContain(stale); + } + }); + + it("marks the optional recommendation fields as optional", () => { + const message = buildRetryCorrectionMessage("reason", "details"); + // `metricSource` and `rationale` are `.optional()` on the validator; the + // prompt must not demand them or a grounded-but-sparse reply gets a + // needless second round trip. + expect(message).toMatch(/OPTIONAL/); + expect(message).not.toMatch(/Every recommendation MUST carry/); + }); + + it("passes the caller's reason and truncated details through", () => { + const message = buildRetryCorrectionMessage("bad-json", "x".repeat(2000)); + expect(message).toContain("bad-json"); + expect(message).toContain("x".repeat(1024)); + expect(message).not.toContain("x".repeat(1025)); + }); +}); diff --git a/src/lib/ai/retry-correction.ts b/src/lib/ai/retry-correction.ts index 26c912b49..d3206decc 100644 --- a/src/lib/ai/retry-correction.ts +++ b/src/lib/ai/retry-correction.ts @@ -17,8 +17,47 @@ * * What survived is the part that was always reachable — the corrective * message, which `comprehensive-generate.ts` sends on its own JSON-retry leg. + * The message kept describing the DEAD wrapper's contract long after the + * wrapper went: it demanded `citations[]` and `warnings[]` (fields the live + * `insightResultSchema` does not have), demanded `id` / `severity` / + * `metricSource` / `rationale` on every recommendation (all optional in the + * live schema), and named NONE of the five fields the live schema actually + * requires — `classification`, `findings`, `correlations`, `dataQuality`, + * `disclaimer`. So the one corrective pass steered the model away from the + * contract it was being corrected towards: the retry reply could not validate, + * fell through `parseComprehensiveResult`'s raw-object escape hatch, and the + * downstream surfaces read an unvalidated payload. + * + * The message below now describes `insightResultSchema` (`src/lib/ai/types.ts`) + * and nothing else. `RETRY_CONTRACT_EXAMPLE` is the same contract as data, and + * a test validates it against the schema — so the prompt and the validator + * cannot drift apart again without the suite going red. */ +/** + * A minimal payload that satisfies `insightResultSchema` exactly: every + * required field present, no optional field set. Embedded verbatim in the + * corrective message so the model has an unambiguous target, and asserted + * against the schema in `__tests__/retry-correction.test.ts`. + */ +export const RETRY_CONTRACT_EXAMPLE = { + summary: "Two to three sentences describing the period.", + classification: "gut", + findings: [ + { label: "Metric name", value: "Value as text", assessment: "neutral" }, + ], + correlations: [ + { factor: "Factor name", effect: "Observed effect", confidence: "mittel" }, + ], + recommendations: [{ text: "One grounded, actionable sentence." }], + dataQuality: { + coverage: "How much of the window carried readings.", + gaps: ["A named gap, or an empty array."], + confidence: "mittel", + }, + disclaimer: "The standard non-diagnostic disclaimer.", +} as const; + /** * Build the corrective user-prompt suffix that explains to the model * what went wrong on the first attempt. Includes the violated zod @@ -33,28 +72,34 @@ Your previous response did not satisfy the required JSON schema. Reason: ${reason} Details: ${details.slice(0, 1024)} -You MUST return a JSON object matching this schema exactly. Do NOT -include prose, markdown fences, or commentary outside the JSON. +You MUST return a single JSON object. Do NOT include prose, markdown +fences, or commentary outside the JSON. -Required top-level fields: +Required top-level fields — all seven MUST be present: - summary: string (2-3 sentences) - - recommendations: array of objects with - { id: string, - text: string, - severity: "info" | "suggestion" | "important" | "urgent", - metricSource: { type: string, timeRange: string, summary: string, n?: number }, - rationale: { dataWindow: "last7days" | "last30days" | "last90days" | "allTime", - comparedTo: non-empty string, - deviation: non-empty string } } - - citations: array of objects with { type: string, timeRange: string, summary: string } - - warnings: array of objects with { topic: string, message: string, severity?: same enum } + - classification: exactly one of "optimal" | "gut" | "grenzwertig" + | "erhoht" | "kritisch" + - findings: array of { label: string, value: string, + assessment: "positive" | "neutral" | "attention" | "warning" } + - correlations: array of { factor: string, effect: string, + confidence: "hoch" | "mittel" | "gering" } + - recommendations: array of { text: string } + - dataQuality: { coverage: string, gaps: array of string, + confidence: "hoch" | "mittel" | "gering" } + - disclaimer: string -Every recommendation's metricSource MUST also appear in citations[] -(same type + timeRange). Every recommendation MUST carry a -rationale object — dataWindow, comparedTo, and deviation are all -required and rationale.dataWindow MUST equal metricSource.timeRange. -If you cannot ground a recommendation in user data from the -snapshot you were given, OMIT it. An empty recommendations[] is -acceptable. +An empty array is acceptable for findings, correlations, and +recommendations. If you cannot ground a recommendation in the user +data from the snapshot you were given, OMIT that recommendation. + +A recommendation object MAY additionally carry any of: id, severity +("info" | "suggestion" | "important" | "urgent"), metricSource +({ type, timeRange, summary }), rationale ({ dataWindow, comparedTo, +deviation }), confidence (integer 0-100). All of these are OPTIONAL — +omit any you cannot ground rather than inventing a value. Do NOT add +top-level fields beyond the seven listed above. + +A minimal valid response looks exactly like this: +${JSON.stringify(RETRY_CONTRACT_EXAMPLE, null, 2)} `; } From 8e938a4f45a8c13ee4dae61958e1c3987d06810a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 11:06:14 +0200 Subject: [PATCH 3/8] fix(documents): anchor extracted-fact provenance to the real document span MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every staged fact carried a sourceText the model echoed back, stored verbatim as 'the exact verbatim span of the document the fact came from'. Nothing checked that the span existed. A paraphrase, a normalised rendering, or an outright invention was persisted and shown to the reviewer as a transcription of their own clinical record — an anchor pointing at nothing, or at a span the document never contained. Treat the echo as a lookup key instead. anchorSourceText resolves it against the extracted text (exact, then whitespace-collapsed and case-folded to absorb OCR reflow) and stores the span the document actually carries, with its offset. A quote that matches nothing is dropped and the fact is flagged unanchored; vision mode has no extracted text at all, so its facts are unanchored by construction. Rows written before this resolve to unanchored on read — their quote is preserved but never presented as checked. --- docs/api/openapi.yaml | 8 ++ .../__tests__/anchor-source-text.test.ts | 97 +++++++++++++ .../__tests__/auto-stage-labs.test.ts | 8 +- src/lib/documents/__tests__/extract.test.ts | 89 ++++++++++++ src/lib/documents/anchor-source-text.ts | 130 ++++++++++++++++++ src/lib/documents/extract.ts | 23 +++- src/lib/documents/store.ts | 23 +++- src/lib/labs/vault-link.ts | 4 + src/lib/openapi/routes/documents.ts | 2 + src/lib/validations/inbound-documents.ts | 16 ++- 10 files changed, 395 insertions(+), 5 deletions(-) create mode 100644 src/lib/documents/__tests__/anchor-source-text.test.ts create mode 100644 src/lib/documents/anchor-source-text.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fa0e0a551..c18490084 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -29420,6 +29420,12 @@ components: properties: sourceText: type: string + anchored: + type: boolean + sourceOffset: + anyOf: + - type: number + - type: "null" page: anyOf: - type: number @@ -29428,6 +29434,8 @@ components: type: number required: - sourceText + - anchored + - sourceOffset - page - confidence additionalProperties: false diff --git a/src/lib/documents/__tests__/anchor-source-text.test.ts b/src/lib/documents/__tests__/anchor-source-text.test.ts new file mode 100644 index 000000000..bdeeb4ab0 --- /dev/null +++ b/src/lib/documents/__tests__/anchor-source-text.test.ts @@ -0,0 +1,97 @@ +/** + * Provenance anchoring — the model's quote is a lookup key, never content. + * + * Both branches matter equally. A quote that IS in the document must come back + * as the document's own characters (not the model's rendering of them), and a + * quote that is NOT in the document must come back unanchored with no text at + * all — never a paraphrase presented to a reviewer as a verbatim excerpt. + */ +import { describe, expect, it } from "vitest"; + +import { anchorSourceText } from "@/lib/documents/anchor-source-text"; + +const OCR = [ + "Befundbericht 2026-01-02", + "Dx: Type 2 diabetes mellitus (E11.9)", + "Hämoglobin 14.2 g/dL (13.5-17.5)", + "Metformin 500 mg twice daily", +].join("\n"); + +describe("anchorSourceText — located spans", () => { + it("returns the document's own span for an exact echo", () => { + const anchored = anchorSourceText( + "Dx: Type 2 diabetes mellitus (E11.9)", + OCR, + ); + expect(anchored.anchored).toBe(true); + expect(anchored.sourceText).toBe("Dx: Type 2 diabetes mellitus (E11.9)"); + expect(anchored.sourceOffset).toBe(OCR.indexOf("Dx:")); + }); + + it("stores the source spacing, not the model's normalised spacing", () => { + // The model collapsed the OCR column padding to single spaces. The stored + // span must be what the document carries, padding included. + const anchored = anchorSourceText("Hämoglobin 14.2 g/dL (13.5-17.5)", OCR); + expect(anchored.anchored).toBe(true); + expect(anchored.sourceText).toBe("Hämoglobin 14.2 g/dL (13.5-17.5)"); + expect(OCR.slice(anchored.sourceOffset!)).toContain(anchored.sourceText); + }); + + it("tolerates case drift and reflowed line breaks", () => { + const anchored = anchorSourceText("metformin 500 MG twice daily", OCR); + expect(anchored.anchored).toBe(true); + expect(anchored.sourceText).toBe("Metformin 500 mg twice daily"); + }); + + it("anchors a span the offset can be read back from", () => { + const anchored = anchorSourceText("Befundbericht", OCR); + expect(anchored.sourceOffset).not.toBeNull(); + expect( + OCR.slice( + anchored.sourceOffset!, + anchored.sourceOffset! + anchored.sourceText.length, + ), + ).toBe(anchored.sourceText); + }); +}); + +describe("anchorSourceText — unlocatable quotes are unanchored", () => { + it("drops a plausible-looking paraphrase the document never contained", () => { + // Every word here appears somewhere in the document; the SENTENCE does not. + const anchored = anchorSourceText( + "Patient has diabetes and takes Metformin for it", + OCR, + ); + expect(anchored.anchored).toBe(false); + expect(anchored.sourceText).toBe(""); + expect(anchored.sourceOffset).toBeNull(); + }); + + it("drops an outright hallucinated value", () => { + const anchored = anchorSourceText("Hämoglobin 9.1 g/dL", OCR); + expect(anchored.anchored).toBe(false); + expect(anchored.sourceText).toBe(""); + }); + + it("refuses to anchor a quote too short to mean anything", () => { + // "g" occurs many times; a match that short is noise, not provenance. + const anchored = anchorSourceText("g", OCR); + expect(anchored.anchored).toBe(false); + expect(anchored.sourceText).toBe(""); + }); + + it("is unanchored when there is no extracted text to verify against", () => { + // Vision mode: the model read the rendered page, nothing can confirm it. + const anchored = anchorSourceText( + "Dx: Type 2 diabetes mellitus", + undefined, + ); + expect(anchored.anchored).toBe(false); + expect(anchored.sourceText).toBe(""); + expect(anchored.sourceOffset).toBeNull(); + }); + + it("is unanchored for an empty quote", () => { + expect(anchorSourceText(" ", OCR).anchored).toBe(false); + }); +}); diff --git a/src/lib/documents/__tests__/auto-stage-labs.test.ts b/src/lib/documents/__tests__/auto-stage-labs.test.ts index 566fcc826..aca4ac148 100644 --- a/src/lib/documents/__tests__/auto-stage-labs.test.ts +++ b/src/lib/documents/__tests__/auto-stage-labs.test.ts @@ -120,7 +120,13 @@ beforeEach(() => { referenceHigh: 17, effectiveDate: "2026-07-10", }, - provenance: { sourceText: "Hämoglobin 14.2", page: 0, confidence: 0.9 }, + provenance: { + sourceText: "Hämoglobin 14.2", + anchored: true, + sourceOffset: 0, + page: 0, + confidence: 0.9, + }, }, ], }); diff --git a/src/lib/documents/__tests__/extract.test.ts b/src/lib/documents/__tests__/extract.test.ts index f1d86aee1..6e398accf 100644 --- a/src/lib/documents/__tests__/extract.test.ts +++ b/src/lib/documents/__tests__/extract.test.ts @@ -145,6 +145,95 @@ describe("runInboundExtraction — stated-status-only mapping", () => { expect(med.statusStated).toBe("ongoing"); }); + it("stores the document's real span, not the model's echo of it", async () => { + // The OCR text says "Hämoglobin 14.2 g/dL"; the model echoes a tidied + // rendering of it. The stored provenance must be the document's characters. + const ocrText = "Labor 2026-01-02\nHämoglobin 14.2 g/dL (13.5-17.5)"; + const provider = fakeProvider( + JSON.stringify({ + facts: [ + { + type: "OBSERVATION", + label: "Hämoglobin", + value: 14.2, + unit: "g/dL", + confidence: 0.9, + sourceText: "Hämoglobin 14.2 g/dL (13.5-17.5)", + }, + ], + }), + ); + + const result = await runInboundExtraction({ + provider, + providerType: "mock", + ocrText, + }); + const { provenance } = result.facts[0]!; + expect(provenance.anchored).toBe(true); + expect(provenance.sourceText).toBe("Hämoglobin 14.2 g/dL (13.5-17.5)"); + expect( + ocrText.slice( + provenance.sourceOffset!, + provenance.sourceOffset! + provenance.sourceText.length, + ), + ).toBe(provenance.sourceText); + }); + + it("marks a fact unanchored rather than storing a quote it cannot locate", async () => { + const ocrText = "Labor 2026-01-02\nHämoglobin 14.2 g/dL"; + const provider = fakeProvider( + JSON.stringify({ + facts: [ + { + type: "OBSERVATION", + label: "Hämoglobin", + value: 14.2, + unit: "g/dL", + confidence: 0.9, + // A quote the document never carried. + sourceText: "Haemoglobin was measured at 14.2 and is normal", + }, + ], + }), + ); + + const result = await runInboundExtraction({ + provider, + providerType: "mock", + ocrText, + }); + const { provenance } = result.facts[0]!; + expect(provenance.anchored).toBe(false); + expect(provenance.sourceText).toBe(""); + expect(provenance.sourceOffset).toBeNull(); + // The fact itself still stages — only its provenance claim is withheld. + expect(result.facts).toHaveLength(1); + }); + + it("marks vision-mode facts unanchored — there is no extracted text to verify", async () => { + const provider = fakeProvider( + JSON.stringify({ + facts: [ + { + type: "CONDITION", + label: "Type 2 diabetes mellitus", + confidence: 0.9, + sourceText: "Dx: Type 2 diabetes mellitus", + }, + ], + }), + ); + + const result = await runInboundExtraction({ + provider, + providerType: "mock", + }); + const { provenance } = result.facts[0]!; + expect(provenance.anchored).toBe(false); + expect(provenance.sourceText).toBe(""); + }); + it("throws InboundExtractError on unparseable provider output", async () => { const provider = fakeProvider("not json at all"); await expect( diff --git a/src/lib/documents/anchor-source-text.ts b/src/lib/documents/anchor-source-text.ts new file mode 100644 index 000000000..1cd1f5670 --- /dev/null +++ b/src/lib/documents/anchor-source-text.ts @@ -0,0 +1,130 @@ +/** + * Resolve a fact's provenance quote against the REAL extracted document text. + * + * Why this exists: the extraction prompt asks the model to echo "the exact + * verbatim span of the document the fact came from" and the pipeline used to + * store that echo as-is. A model can paraphrase it, normalise it, or invent it + * outright, and the stored quote is then an anchor that points at nothing — or, + * worse, at a span the document never contained, presented to a human reviewer + * as if it were a verbatim transcription of their clinical record. + * + * The model's echo is therefore treated as a LOOKUP KEY, never as content. We + * search the extracted text for it and store the span the DOCUMENT actually + * carries. When the echo cannot be located, the fact is marked unanchored and + * carries no quote at all: "we could not verify this against the source" is an + * honest answer, an unverifiable quote is not. + * + * Two passes, cheapest first: + * 1. exact substring — the common case, the model echoed the span verbatim; + * 2. whitespace-collapsed + case-folded substring — covers OCR line wrapping, + * column merges, and case drift, which are reflow artefacts rather than + * paraphrase. The offset map walks the match back to the ORIGINAL text so + * the stored span keeps the document's own characters and spacing. + * + * A paraphrase fails both passes by construction, which is the point. + */ + +/** + * Minimum normalised length a quote must reach before we try to locate it. A + * one- or two-character key matches somewhere in almost any document, so a + * "match" that short would be noise dressed up as provenance. + */ +const MIN_ANCHORABLE_LENGTH = 3; + +/** Longest span we store, mirroring the provenance column's own cap. */ +const MAX_SPAN_LENGTH = 2000; + +export interface AnchoredSource { + /** + * The verbatim span as it appears in the extracted text, or "" when the + * quote could not be located (or there was no extracted text to search). + */ + sourceText: string; + /** True only when `sourceText` was read back out of the document text. */ + anchored: boolean; + /** Character offset of the span in the extracted text; null when unanchored. */ + sourceOffset: number | null; +} + +/** The unanchored result — no quote, no offset, no claim. */ +const UNANCHORED: AnchoredSource = { + sourceText: "", + anchored: false, + sourceOffset: null, +}; + +/** + * Whitespace-collapsed, case-folded projection of `text`, plus a map from each + * projected character back to its index in the original string. Folding is + * skipped for any character whose lowercase form is not exactly one code unit, + * so the map stays 1:1 and an offset can never drift. + */ +function project(text: string): { normalised: string; offsets: number[] } { + const chars: string[] = []; + const offsets: number[] = []; + let pendingSpace = false; + for (let i = 0; i < text.length; i += 1) { + const ch = text[i]!; + if (/\s/.test(ch)) { + // Collapse a whitespace run to one space, and never lead with one. + if (chars.length > 0) pendingSpace = true; + continue; + } + if (pendingSpace) { + chars.push(" "); + offsets.push(i); + pendingSpace = false; + } + const lower = ch.toLowerCase(); + chars.push(lower.length === 1 ? lower : ch); + offsets.push(i); + } + return { normalised: chars.join(""), offsets }; +} + +/** + * Locate `quote` inside `sourceText` and return the span the document actually + * carries. `sourceText` is the text the extraction ran against — the OCR output + * in text mode. Pass `undefined`/`""` for vision mode, where the model read the + * rendered page directly and there is no extracted text to verify against: the + * result is unanchored, because nothing in that flow can confirm the quote. + */ +export function anchorSourceText( + quote: string, + sourceText: string | undefined, +): AnchoredSource { + if (!sourceText) return UNANCHORED; + const trimmed = quote.trim(); + if (!trimmed) return UNANCHORED; + + // The length floor gates BOTH passes: a one-character key matches the exact + // pass just as readily as the folded one, and a hit that short is noise + // either way. + const needle = project(trimmed); + if (needle.normalised.length < MIN_ANCHORABLE_LENGTH) return UNANCHORED; + + // Pass 1 — the model echoed the span verbatim. + const exact = sourceText.indexOf(trimmed); + if (exact !== -1) { + return { + sourceText: sourceText.slice(exact, exact + trimmed.length), + anchored: true, + sourceOffset: exact, + }; + } + + // Pass 2 — same span, reflowed or case-drifted by OCR. + const haystack = project(sourceText); + const hit = haystack.normalised.indexOf(needle.normalised); + if (hit === -1) return UNANCHORED; + + const start = haystack.offsets[hit]!; + const lastIndex = haystack.offsets[hit + needle.normalised.length - 1]!; + return { + sourceText: sourceText + .slice(start, lastIndex + 1) + .slice(0, MAX_SPAN_LENGTH), + anchored: true, + sourceOffset: start, + }; +} diff --git a/src/lib/documents/extract.ts b/src/lib/documents/extract.ts index 8b6ea3d51..e33508cdb 100644 --- a/src/lib/documents/extract.ts +++ b/src/lib/documents/extract.ts @@ -15,8 +15,17 @@ * assertion about the patient, never as the app's conclusion. The mandatory * human review screen is the hard backstop — nothing commits without per-fact * confirmation, and a low-confidence fact fails closed. + * + * Provenance is resolved, not echoed. The model returns a quote of the span it + * read a fact from; that quote is used to LOOK UP the real span in the extracted + * text (`anchorSourceText`) and the document's own characters are what gets + * stored. A quote that matches nothing is dropped and the fact is flagged + * unanchored — an anchor that points at nothing, or at the wrong span, is worse + * than an honest "unverified" because the review screen presents it to a human + * as a verbatim transcription of their record. */ import { AI_BUDGETS } from "@/lib/ai/ai-budgets"; +import { anchorSourceText } from "@/lib/documents/anchor-source-text"; import { DOCUMENT_TEXT_FENCE_START, DOCUMENT_TEXT_FENCE_END, @@ -263,17 +272,28 @@ export async function runInboundExtraction( const reportDate = normaliseDate(envelope.reportDate); const facts: StagedFactInput[] = []; + let unanchored = 0; for (const raw of envelope.facts.slice(0, INBOUND_MAX_FACTS)) { const data = mapFactData(raw); if (!data) continue; const confidence = raw.confidence; + // The model's `sourceText` is a LOOKUP KEY, never content: we search the + // extracted text for it and store the span the document actually carries. + // A quote we cannot locate is dropped and the fact is marked unanchored — + // the reviewer is told "unverified" rather than shown a quote the document + // may never have contained. Vision mode has no extracted text at all, so + // every fact from it is unanchored by construction. + const anchor = anchorSourceText(raw.sourceText, args.ocrText); + if (!anchor.anchored) unanchored += 1; facts.push({ factType: raw.type, confidence, needsReview: confidence < INBOUND_CONFIDENCE_FLOOR, data, provenance: { - sourceText: raw.sourceText.trim().slice(0, 2000), + sourceText: anchor.sourceText, + anchored: anchor.anchored, + sourceOffset: anchor.sourceOffset, page: raw.page, confidence, }, @@ -286,6 +306,7 @@ export async function runInboundExtraction( mode: isTextMode ? "text" : "vision", providerType: args.providerType, facts: facts.length, + unanchored, conditions: facts.filter((f) => f.factType === "CONDITION").length, observations: facts.filter((f) => f.factType === "OBSERVATION").length, medications: facts.filter((f) => f.factType === "MEDICATION_STATEMENT") diff --git a/src/lib/documents/store.ts b/src/lib/documents/store.ts index 7f063122a..b1a1253be 100644 --- a/src/lib/documents/store.ts +++ b/src/lib/documents/store.ts @@ -158,9 +158,28 @@ export function encryptFactProvenance( return encryptToBytes(JSON.stringify(provenance)); } -/** Decrypt a staged fact's provenance back to its DTO shape. */ +/** + * Decrypt a staged fact's provenance back to its DTO shape. + * + * Rows written before provenance was anchored carry the model's own echo of the + * span with no record of whether it ever matched the document. That is exactly + * the unverified case, so they resolve to `anchored: false`. The stored text is + * preserved rather than blanked — it is the only provenance those rows have — + * but the flag stops a consumer presenting it as a checked transcription. + */ export function decryptFactProvenance(buf: Uint8Array): FactProvenance { - return JSON.parse(decryptFromBytes(buf)) as FactProvenance; + const raw = JSON.parse(decryptFromBytes(buf)) as Partial; + const anchored = raw.anchored === true; + return { + sourceText: typeof raw.sourceText === "string" ? raw.sourceText : "", + anchored, + sourceOffset: + anchored && typeof raw.sourceOffset === "number" + ? raw.sourceOffset + : null, + page: typeof raw.page === "number" ? raw.page : null, + confidence: typeof raw.confidence === "number" ? raw.confidence : 0, + }; } /** diff --git a/src/lib/labs/vault-link.ts b/src/lib/labs/vault-link.ts index 9d6df818c..4d4a42d5d 100644 --- a/src/lib/labs/vault-link.ts +++ b/src/lib/labs/vault-link.ts @@ -97,8 +97,12 @@ export async function linkOcrLabsToVaultDocument( confidence: 1, needsReview: false, dataEncrypted: encryptFactData(observationFromLab(lab)), + // No document span to point at: this fact is minted from an already + // committed lab row, not transcribed from the document text. provenanceEncrypted: encryptFactProvenance({ sourceText: "", + anchored: false, + sourceOffset: null, page: null, confidence: 1, }), diff --git a/src/lib/openapi/routes/documents.ts b/src/lib/openapi/routes/documents.ts index 2915f0170..88c6ea852 100644 --- a/src/lib/openapi/routes/documents.ts +++ b/src/lib/openapi/routes/documents.ts @@ -96,6 +96,8 @@ documentBulkSchema.meta({ const factProvenance = z.object({ sourceText: z.string(), + anchored: z.boolean(), + sourceOffset: z.number().nullable(), page: z.number().nullable(), confidence: z.number(), }); diff --git a/src/lib/validations/inbound-documents.ts b/src/lib/validations/inbound-documents.ts index 0b7d381da..0e49daaae 100644 --- a/src/lib/validations/inbound-documents.ts +++ b/src/lib/validations/inbound-documents.ts @@ -179,8 +179,22 @@ export type InboundExtraction = z.infer; /** Per-field provenance carried on every staged fact. */ export interface FactProvenance { - /** The verbatim source span the value was transcribed from. */ + /** + * The verbatim source span the value was transcribed from, read back out of + * the extracted document text — NOT the model's echo of it. Empty string when + * the span could not be located (see `anchored`). + */ sourceText: string; + /** + * True only when `sourceText` was resolved against the extracted document + * text. False means the quote is unverifiable and none is stored: either the + * model's echo matched nothing in the source, or the extraction ran in vision + * mode, where there is no extracted text to verify against. A reviewer must + * read an unanchored fact against the original document itself. + */ + anchored: boolean; + /** Character offset of the span in the extracted text; null when unanchored. */ + sourceOffset: number | null; /** Optional 0-based page index. */ page: number | null; /** The model's self-reported confidence (0..1). */ From 80258914f224f8dbf56a8bdfa739466767709749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 11:09:57 +0200 Subject: [PATCH 4/8] feat(documents): catch up unread documents when auto-read is switched on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-document summary job is enqueued at upload time and no-ops while the documentsAutoAiRead opt-in is OFF. Nothing ever re-enqueued it, so the switch only applied to documents uploaded AFTER the flip — a user who filled the vault first and opted in later had documents that would never be read, and the setting looked inert. A genuine OFF->ON transition now schedules one bounded catch-up pass over the user's un-summarised documents. It grants nothing of its own: it only enqueues the ordinary summary job, which still re-reads the opt-in, re-asserts the per-document egress consent and reserves the daily token budget. The pass itself re-reads the opt-in first, so a toggle flipped back OFF before it runs egresses nothing. Bounded by an id-cursor walk capped at 200 documents per pass (a summary is a provider call, unlike the local-compute thumbnail backfill). Idempotent at four levels: the trigger fires only on a real transition, the per-user singleton collapses a double toggle, the candidate set excludes summarised documents, and the summary job skips one that already has a summary. --- .../__tests__/route.test.ts | 128 +++++++++++++ .../auth/me/documents-auto-ai-read/route.ts | 17 +- .../document-summary-catchup.test.ts | 171 ++++++++++++++++++ src/lib/jobs/document-summary-catchup.ts | 164 +++++++++++++++++ src/lib/jobs/reminder/register-maintenance.ts | 44 +++++ 5 files changed, 523 insertions(+), 1 deletion(-) create mode 100644 src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts create mode 100644 src/lib/jobs/__tests__/document-summary-catchup.test.ts create mode 100644 src/lib/jobs/document-summary-catchup.ts diff --git a/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts b/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts new file mode 100644 index 000000000..68d2a90f6 --- /dev/null +++ b/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts @@ -0,0 +1,128 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The auto-read opt-in endpoint, and specifically the catch-up it schedules. + * + * The summary job is enqueued at UPLOAD time and no-ops while the flag is OFF, + * so without a catch-up on the flip the toggle only ever applied to future + * uploads — a user who filled the vault first saw the switch do nothing. These + * tests pin that a genuine OFF→ON transition schedules the pass, and that a + * no-op re-save or a flip to OFF does not. + */ + +vi.mock("@/lib/db", () => ({ + prisma: { user: { findUnique: vi.fn(), update: vi.fn() } }, +})); +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/consent/web-grant", () => ({ + ensureWebAiConsentReceipt: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/jobs/document-summary-catchup", () => ({ + enqueueSummaryCatchUp: vi.fn().mockResolvedValue({ enqueued: true }), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 59, + resetAt: Date.now() + 60_000, + }), + rateLimitHeaders: () => ({}), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { PATCH } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; +import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "testuser", role: "USER" as const }, +}; + +function mkPatch(documentsAutoAiRead: boolean): Request { + return new Request("http://localhost/api/auth/me/documents-auto-ai-read", { + method: "PATCH", + body: JSON.stringify({ documentsAutoAiRead }), + headers: { "Content-Type": "application/json" }, + }); +} + +/** Seed the flag's value BEFORE the PATCH under test. */ +function withPrevious(documentsAutoAiRead: boolean) { + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + documentsAutoAiRead, + } as never); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(prisma.user.update).mockResolvedValue({} as never); +}); + +describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () => { + it("schedules a catch-up when the opt-in flips OFF→ON", async () => { + withPrevious(false); + + const res = await PATCH(mkPatch(true)); + + expect(res.status).toBe(200); + expect(enqueueSummaryCatchUp).toHaveBeenCalledWith("user-1"); + expect(enqueueSummaryCatchUp).toHaveBeenCalledTimes(1); + }); + + it("does not re-schedule when the opt-in was already ON", async () => { + // Idempotency at the trigger: re-saving an already-ON setting must not + // queue a second pass over the same documents. + withPrevious(true); + + await PATCH(mkPatch(true)); + + expect(enqueueSummaryCatchUp).not.toHaveBeenCalled(); + }); + + it("does not schedule anything when the opt-in is turned OFF", async () => { + withPrevious(true); + + await PATCH(mkPatch(false)); + + expect(enqueueSummaryCatchUp).not.toHaveBeenCalled(); + expect(ensureWebAiConsentReceipt).not.toHaveBeenCalled(); + }); + + it("still mints the consent receipt on the flip that schedules the pass", async () => { + // The catch-up rides the same act of consent, never around it. + withPrevious(false); + + await PATCH(mkPatch(true)); + + expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith("user-1"); + }); + + it("persists the flag field-by-field", async () => { + withPrevious(false); + + await PATCH(mkPatch(true)); + + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { documentsAutoAiRead: true }, + }); + }); +}); diff --git a/src/app/api/auth/me/documents-auto-ai-read/route.ts b/src/app/api/auth/me/documents-auto-ai-read/route.ts index 4bd8f1a62..20cd90c06 100644 --- a/src/app/api/auth/me/documents-auto-ai-read/route.ts +++ b/src/app/api/auth/me/documents-auto-ai-read/route.ts @@ -12,7 +12,10 @@ * Flipping it ON is itself the standing consent act, so the write also mints an * append-only `ai_full` consent receipt (`ensureWebAiConsentReceipt`) — the * durable audit record that sits alongside the runtime short-circuit the - * document consent gate reads. Mirrors `auth/me/labs-local-ocr`: 60/min rate + * document consent gate reads. An OFF→ON flip additionally schedules a bounded + * catch-up over the documents already stored (`enqueueSummaryCatchUp`), because + * the summary job is enqueued at upload time and would otherwise never revisit + * a vault filled before the opt-in. Mirrors `auth/me/labs-local-ocr`: 60/min rate * limit, Zod `safeParse` → 422 via `returnAllZodIssues`, audit-log row, * field-by-field write (no mass assignment). Idempotent — always returns the * resolved next state so the client can hard-set the optimistic update. @@ -31,6 +34,7 @@ import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; import { ensureWebAiConsentReceipt } from "@/lib/consent/web-grant"; import { prisma } from "@/lib/db"; +import { enqueueSummaryCatchUp } from "@/lib/jobs/document-summary-catchup"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; const patchBodySchema = z.object({ @@ -110,6 +114,17 @@ export const PATCH = apiHandler(async (req: Request) => { await ensureWebAiConsentReceipt(user.id); } + // A genuine OFF→ON flip schedules a catch-up over the documents already in + // the vault. Without it the switch only ever applied to FUTURE uploads: the + // summary job is enqueued at upload time and no-ops while the flag is OFF, so + // a user who uploaded first and opted in later saw the toggle do nothing. + // Fire-and-forget and bounded; the pass only enqueues, and every consent and + // budget gate still runs per document inside the summary job itself. + const wasEnabled = previous?.documentsAutoAiRead ?? false; + if (next && !wasEnabled) { + void enqueueSummaryCatchUp(user.id); + } + await auditLog("user.documentsAutoAiRead.update", { userId: user.id, ipAddress: getClientIp(req), diff --git a/src/lib/jobs/__tests__/document-summary-catchup.test.ts b/src/lib/jobs/__tests__/document-summary-catchup.test.ts new file mode 100644 index 000000000..e3666f4d4 --- /dev/null +++ b/src/lib/jobs/__tests__/document-summary-catchup.test.ts @@ -0,0 +1,171 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +/** + * The auto-read catch-up pass. + * + * Documents are summarised by a job enqueued at UPLOAD time that no-ops while + * the `documentsAutoAiRead` opt-in is OFF, so a vault filled before the flip was + * never read and the toggle appeared to do nothing. These tests pin the catch-up + * that closes that hole, and the three properties it must not lose: it stays + * bounded, it stays idempotent, and it grants no consent or budget of its own. + */ + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +vi.mock("@/lib/db", () => ({ + prisma: { inboundDocument: { findMany: vi.fn() } }, +})); +vi.mock("@/lib/jobs/boss-instance", () => ({ getGlobalBoss: vi.fn() })); +vi.mock("@/lib/documents/document-settings", () => ({ + documentAutoReadEnabled: vi.fn(), +})); +vi.mock("@/lib/jobs/document-summary", () => ({ + enqueueDocumentSummary: vi.fn(), +})); + +import { + enqueueSummaryCatchUp, + runSummaryCatchUpForUser, + MAX_ENQUEUES_PER_RUN, + DOCUMENT_SUMMARY_CATCHUP_QUEUE, +} from "../document-summary-catchup"; +import { prisma } from "@/lib/db"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; +import { enqueueDocumentSummary } from "@/lib/jobs/document-summary"; +import { annotate } from "@/lib/logging/context"; + +const findMany = vi.mocked(prisma.inboundDocument.findMany); +const mockEnqueueSummary = vi.mocked(enqueueDocumentSummary); +const mockAutoRead = vi.mocked(documentAutoReadEnabled); + +/** Serve `total` document ids across the job's id-cursor paged walk. */ +function serveDocuments(total: number) { + const ids = Array.from({ length: total }, (_, i) => ({ + id: `doc-${String(i).padStart(5, "0")}`, + })); + findMany.mockImplementation((async (args: { + take: number; + cursor?: { id: string }; + }) => { + const start = args.cursor + ? ids.findIndex((d) => d.id === args.cursor!.id) + 1 + : 0; + return ids.slice(start, start + args.take); + }) as unknown as typeof findMany); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockAutoRead.mockResolvedValue(true); + mockEnqueueSummary.mockResolvedValue({ enqueued: true }); +}); + +describe("runSummaryCatchUpForUser", () => { + it("enqueues a summary for every already-stored un-summarised document", async () => { + serveDocuments(3); + + const result = await runSummaryCatchUpForUser("user-1"); + + expect(result).toEqual({ enqueued: 3, capped: false }); + expect(mockEnqueueSummary).toHaveBeenCalledTimes(3); + expect(mockEnqueueSummary).toHaveBeenCalledWith("user-1", "doc-00000"); + expect(mockEnqueueSummary).toHaveBeenCalledWith("user-1", "doc-00002"); + }); + + it("only considers documents that have no summary yet", async () => { + // Idempotency floor: a re-run cannot redo finished work because a + // summarised document is not in the candidate set at all. + serveDocuments(1); + + await runSummaryCatchUpForUser("user-1"); + + const where = findMany.mock.calls[0]![0]!.where; + expect(where).toMatchObject({ + userId: "user-1", + deletedAt: null, + summaryEncrypted: null, + }); + }); + + it("stops at the documented cap instead of queueing a whole vault", async () => { + serveDocuments(MAX_ENQUEUES_PER_RUN + 50); + + const result = await runSummaryCatchUpForUser("user-1"); + + expect(result.enqueued).toBe(MAX_ENQUEUES_PER_RUN); + expect(result.capped).toBe(true); + expect(mockEnqueueSummary).toHaveBeenCalledTimes(MAX_ENQUEUES_PER_RUN); + }); + + it("re-reads the opt-in and does nothing when it was flipped back OFF", async () => { + // Consent race: the PATCH that scheduled the pass is not authority enough. + serveDocuments(5); + mockAutoRead.mockResolvedValue(false); + + const result = await runSummaryCatchUpForUser("user-1"); + + expect(result).toEqual({ enqueued: 0, capped: false }); + expect(mockEnqueueSummary).not.toHaveBeenCalled(); + expect(findMany).not.toHaveBeenCalled(); + }); + + it("routes work through the ordinary summary job, granting nothing itself", async () => { + // The per-document job is what re-asserts egress consent and reserves the + // daily budget. The catch-up must not have its own provider or budget path. + serveDocuments(2); + + await runSummaryCatchUpForUser("user-1"); + + expect(mockEnqueueSummary).toHaveBeenCalledTimes(2); + for (const call of mockEnqueueSummary.mock.calls) { + expect(call[0]).toBe("user-1"); + } + }); + + it("emits the catch-up wide event", async () => { + serveDocuments(2); + + await runSummaryCatchUpForUser("user-1"); + + expect(annotate).toHaveBeenCalledWith({ + action: { name: "documents.autoRead.catchUp" }, + meta: { enqueued: 2, capped: false }, + }); + }); +}); + +describe("enqueueSummaryCatchUp", () => { + it("coalesces a double toggle onto one per-user singleton key", async () => { + const send = vi.fn().mockResolvedValue("job-1"); + vi.mocked(getGlobalBoss).mockReturnValue({ + send, + } as unknown as ReturnType); + + await enqueueSummaryCatchUp("user-1"); + await enqueueSummaryCatchUp("user-1"); + + expect(send).toHaveBeenCalledTimes(2); + for (const call of send.mock.calls) { + expect(call[0]).toBe(DOCUMENT_SUMMARY_CATCHUP_QUEUE); + expect(call[2]).toMatchObject({ + singletonKey: "document-summary-catchup|user-1", + }); + } + }); + + it("is a no-op without a boss and never throws on a send failure", async () => { + vi.mocked(getGlobalBoss).mockReturnValue( + null as unknown as ReturnType, + ); + await expect(enqueueSummaryCatchUp("user-1")).resolves.toEqual({ + enqueued: false, + }); + + vi.mocked(getGlobalBoss).mockReturnValue({ + send: vi.fn().mockRejectedValue(new Error("down")), + } as unknown as ReturnType); + await expect(enqueueSummaryCatchUp("user-1")).resolves.toEqual({ + enqueued: false, + }); + }); +}); diff --git a/src/lib/jobs/document-summary-catchup.ts b/src/lib/jobs/document-summary-catchup.ts new file mode 100644 index 000000000..9acb154fe --- /dev/null +++ b/src/lib/jobs/document-summary-catchup.ts @@ -0,0 +1,164 @@ +/** + * Catch-up pass for documents stored BEFORE the auto-read opt-in was turned on. + * + * The per-document summary job is enqueued at UPLOAD time and no-ops when + * `documentsAutoAiRead` is OFF. That leaves an obvious hole: a user who uploads + * their vault first and flips the toggle afterwards gets nothing. Every document + * they already hold was enqueued while the flag was OFF, the job no-opped, and + * nothing ever re-enqueues them — so the switch reads as broken. It only ever + * worked for documents uploaded after the flip. + * + * A genuine OFF→ON transition on `PATCH /api/auth/me/documents-auto-ai-read` + * enqueues one pass of this job, which walks the user's un-summarised documents + * and enqueues the ordinary per-document summary job for each. + * + * What this job does NOT do is grant anything. It only enqueues; every gate + * still runs inside `runDocumentSummaryJob` — the opt-in is re-read, the egress + * consent is re-asserted per document, and the daily token budget is reserved + * and reconciled exactly as on the upload path. Retroactive work is not a reason + * to skip a consent receipt or a budget ceiling, so it does not. + * + * Bounded and idempotent: + * - the candidate set is documents with NO summary, so a document drops out + * the moment its summary lands and a re-run cannot redo finished work; + * - an id-cursor forward walk capped at `MAX_ENQUEUES_PER_RUN` (see the + * constant for why that number) keeps one flip from queueing a whole vault; + * - the per-user `singletonKey` plus the queue's `short` policy collapses a + * second flip while a pass is still queued, and the per-document enqueue + * carries its own singleton on top; + * - `runDocumentSummaryJob` skips any document that already has a summary, so + * even a duplicate that slips past both singletons is a cheap no-op. + * + * The queue MUST be registered in the maintenance registrar + * (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it. + */ +import { documentAutoReadEnabled } from "@/lib/documents/document-settings"; +import { prisma } from "@/lib/db"; +import { getGlobalBoss } from "@/lib/jobs/boss-instance"; +import { enqueueDocumentSummary } from "@/lib/jobs/document-summary"; +import { annotate } from "@/lib/logging/context"; + +export const DOCUMENT_SUMMARY_CATCHUP_QUEUE = "document-summary-catchup"; + +/** Serial concurrency — cheap discovery + fan-out enqueues. */ +export const DOCUMENT_SUMMARY_CATCHUP_CONCURRENCY = 1; + +/** Discovery page size (id-only). */ +const PAGE_SIZE = 100; + +/** + * Max per-document summary jobs one catch-up pass enqueues. + * + * Deliberately far below the thumbnail backfill's cap: a thumbnail is local + * compute, a summary is a provider call against the user's daily token budget. + * The budget gate already stops runaway spend (every job past the ceiling + * no-ops), so this cap is about not dumping an entire vault onto the queue in + * one go — a large library converges over repeated flips and the ordinary + * upload path rather than in a single burst. Raising it costs queue depth, not + * money. + */ +export const MAX_ENQUEUES_PER_RUN = 200; + +export interface SummaryCatchUpPayload { + userId: string; + enqueuedAt?: string; +} + +export interface SummaryCatchUpSummary { + enqueued: number; + /** True when the pass stopped at the cap with candidates still outstanding. */ + capped: boolean; +} + +/** + * Enqueue summary jobs for one user's un-summarised documents. Re-reads the + * opt-in first: a user who flipped the toggle back OFF between the PATCH and + * this job running gets nothing, which keeps the pass fail-closed against a + * consent race the same way the per-document job is. + */ +export async function runSummaryCatchUpForUser( + userId: string, +): Promise { + if (!userId) return { enqueued: 0, capped: false }; + + // Fail-closed on the toggle. The PATCH that scheduled this pass is not + // authority enough — the flag is re-read at run time, exactly as the + // per-document job does before it egresses anything. + if (!(await documentAutoReadEnabled(userId))) { + annotate({ + action: { name: "documents.autoRead.catchUpSkipped" }, + meta: { reason: "opt-out" }, + }); + return { enqueued: 0, capped: false }; + } + + let enqueued = 0; + let capped = false; + let cursor: string | null = null; + + for (;;) { + if (enqueued >= MAX_ENQUEUES_PER_RUN) { + capped = true; + break; + } + const rows: { id: string }[] = await prisma.inboundDocument.findMany({ + where: { userId, deletedAt: null, summaryEncrypted: null }, + select: { id: true }, + orderBy: { id: "asc" }, + take: PAGE_SIZE, + ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}), + }); + if (rows.length === 0) break; + + for (const { id } of rows) { + if (enqueued >= MAX_ENQUEUES_PER_RUN) { + capped = true; + break; + } + const { enqueued: ok } = await enqueueDocumentSummary(userId, id); + if (ok) enqueued += 1; + } + + cursor = rows[rows.length - 1]!.id; + if (rows.length < PAGE_SIZE) break; + } + + annotate({ + action: { name: "documents.autoRead.catchUp" }, + meta: { enqueued, capped }, + }); + return { enqueued, capped }; +} + +/** + * Enqueue one catch-up pass for a user. Called on a genuine OFF→ON flip of the + * auto-read opt-in. Coalesced by `singletonKey` per user so flipping the toggle + * twice cannot queue two passes. Fire-and-forget — a missing boss (worker not + * up) is a no-op, and a `boss.send` failure is swallowed, so the catch-up can + * never fail the settings write that scheduled it. + */ +export async function enqueueSummaryCatchUp( + userId: string, +): Promise<{ enqueued: boolean }> { + const boss = getGlobalBoss(); + if (!boss) return { enqueued: false }; + const payload: SummaryCatchUpPayload = { + userId, + enqueuedAt: new Date().toISOString(), + }; + try { + const jobId = await boss.send(DOCUMENT_SUMMARY_CATCHUP_QUEUE, payload, { + retryLimit: 2, + retryDelay: 60, + retryBackoff: true, + singletonKey: `document-summary-catchup|${userId}`, + }); + return { enqueued: Boolean(jobId) }; + } catch (err) { + annotate({ + action: { name: "documents.autoRead.catchUpEnqueueFailed" }, + meta: { reason: err instanceof Error ? err.name : "unknown" }, + }); + return { enqueued: false }; + } +} diff --git a/src/lib/jobs/reminder/register-maintenance.ts b/src/lib/jobs/reminder/register-maintenance.ts index f7950400f..c258a79ca 100644 --- a/src/lib/jobs/reminder/register-maintenance.ts +++ b/src/lib/jobs/reminder/register-maintenance.ts @@ -93,6 +93,12 @@ import { runDocumentSummaryJob, type DocumentSummaryPayload, } from "@/lib/jobs/document-summary"; +import { + DOCUMENT_SUMMARY_CATCHUP_QUEUE, + DOCUMENT_SUMMARY_CATCHUP_CONCURRENCY, + runSummaryCatchUpForUser, + type SummaryCatchUpPayload, +} from "@/lib/jobs/document-summary-catchup"; import { DOCUMENT_THUMBNAIL_BACKFILL_QUEUE, DOCUMENT_THUMBNAIL_BACKFILL_CONCURRENCY, @@ -359,6 +365,11 @@ const allQueues = [ // egress consent + budget; persists the summary encrypted. Without this entry // pg-boss never provisions the queue and every upload enqueue silently no-ops. DOCUMENT_SUMMARY_QUEUE, + // v1.30.31 — auto-read catch-up. Scheduled on a genuine OFF→ON flip of the + // `documentsAutoAiRead` opt-in; fans out summary jobs for documents that were + // uploaded while the flag was still OFF. Without this entry pg-boss never + // provisions the queue and the catch-up enqueue silently no-ops. + DOCUMENT_SUMMARY_CATCHUP_QUEUE, ]; const schedules: ScheduleEntry[] = [ @@ -507,6 +518,11 @@ const queuePolicies: QueuePolicyTable = { reason: "Per-document, no re-converging discovery pass. Collapse queued duplicates only.", }, + [DOCUMENT_SUMMARY_CATCHUP_QUEUE]: { + policy: "short", + reason: + "Per-user, keyed on the opt-in flip. Collapse a double toggle into one pass; the pass re-reads its candidate set when it starts.", + }, }; /** @@ -890,6 +906,34 @@ export async function registerMaintenanceQueues( }, ); + // Document AI — auto-read catch-up worker. A genuine OFF→ON flip of the + // `documentsAutoAiRead` opt-in sends one per-user job; this handler fans out + // per-document summary jobs for that user's already-stored, un-summarised + // documents (the upload-time enqueue no-opped while the flag was OFF). It + // only enqueues — the opt-in, egress consent and budget gates all still run + // per document inside the summary job. Serial concurrency. + await boss.work( + DOCUMENT_SUMMARY_CATCHUP_QUEUE, + { localConcurrency: DOCUMENT_SUMMARY_CATCHUP_CONCURRENCY }, + async (jobs) => { + for (const job of jobs) { + const { userId } = job.data; + if (!userId) continue; + try { + await runSummaryCatchUpForUser(userId); + } catch (err) { + recordError(); + workerLog( + "error", + `[document-summary-catchup] user=${userId} failed`, + err, + ); + throw err; + } + } + }, + ); + // Document vault — boot-time preview-thumbnail backfill worker. The boot // discovery sends one per-user job; this handler fans out per-document // thumbnail jobs for that user's not-yet-thumbnailed documents. Serial From eb5f81c84ab08c8f06dbb7af5320fcb3bc1f28ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 11:12:49 +0200 Subject: [PATCH 5/8] fix(rollups): let a recompute replace every bucket in its window, not just the ones it recomputed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit persistRollupRows scoped its delete to the [min, max] bucket range of the rows the aggregate RETURNED. That made the repair self-limiting in exactly the case that needs it: a recompute could only overwrite a bucket the live aggregate still produces, so a bucket whose source rows had all gone was never in the delete range and kept its stale aggregate. Three shapes of the same hole. An empty aggregate skipped the write entirely (the caller short-circuited on rows.length === 0). A returned range narrower than the requested window left the edge buckets untouched. A type that lost every reading in the window dropped out of the returned rows, so the per-type delete filter could never reach it. The DAY write hook masked it — it deletes its own bucket by hand when the day empties — but nothing covered the WEEK / MONTH / YEAR worker, which recomputes one bucket span at a time: deleting the last reading of a type in a week dropped the DAY bucket and left the coarser ones stale forever. The coverage probe still reported hasBuckets = true, so the read-swap served them. A recompute now prunes the window it was asked for and writes its rows in the same transaction. The single-bucket write hook keeps its own scope, which is correct there because it already deletes its bucket when the aggregate empties. Still one tier: the rollup replaces the legacy read and falls back to live SQL only on a coverage miss. --- .../__tests__/measurement-rollups.test.ts | 118 +++++++++++++++++ src/lib/rollups/measurement-rollups.ts | 125 ++++++++++++++---- 2 files changed, 219 insertions(+), 24 deletions(-) diff --git a/src/lib/rollups/__tests__/measurement-rollups.test.ts b/src/lib/rollups/__tests__/measurement-rollups.test.ts index d8a7a2747..d8792c49d 100644 --- a/src/lib/rollups/__tests__/measurement-rollups.test.ts +++ b/src/lib/rollups/__tests__/measurement-rollups.test.ts @@ -567,6 +567,124 @@ describe("persistRollupRows — large path (via recomputeUserRollups)", () => { }); }); +/** + * A recompute REPLACES every bucket in the window it was asked for. + * + * Scoping the delete to the [min, max] range of the rows the aggregate + * RETURNED made the repair self-limiting in exactly the case that needs it: a + * bucket whose source rows all disappeared is not in the returned range, so it + * survived with its stale aggregate while `probeRollupCoverage` still reported + * `hasBuckets = true` and the read-swap served it instead of falling back to + * live SQL. The write hook masked this for DAY (it deletes its own bucket by + * hand when the day empties) but nothing covered the WEEK / MONTH / YEAR + * worker, which recomputes one bucket span at a time. + */ +describe("recomputeUserRollups — prunes the whole recomputed window", () => { + function rowsInBucket(bucketStart: Date, type = "WEIGHT") { + return [ + { + type, + source: "MANUAL", + bucket_start: bucketStart, + count: BigInt(1), + mean: 80, + min_value: 80, + max_value: 80, + sum_value: 80, + sd: 0, + slope: 0, + r2: 0, + }, + ]; + } + + it("deletes the stale bucket when the window's last reading is gone", async () => { + // The exact damage: the user deleted their last WEIGHT reading of the + // week, so the WEEK aggregate for that span now returns nothing. + queryRawUnsafe.mockResolvedValueOnce([]); + + await recomputeUserRollups("user-1", { + granularities: ["WEEK"], + types: ["WEIGHT"], + from: new Date(Date.UTC(2024, 2, 4)), + to: new Date(Date.UTC(2024, 2, 11)), + }); + + expect(deleteMany).toHaveBeenCalledTimes(1); + const where = deleteMany.mock.calls[0][0].where; + expect(where).toMatchObject({ + userId: "user-1", + granularity: "WEEK", + type: { in: ["WEIGHT"] }, + }); + expect(where.bucketStart.gte.toISOString()).toBe( + "2024-03-04T00:00:00.000Z", + ); + expect(where.bucketStart.lt.toISOString()).toBe("2024-03-11T00:00:00.000Z"); + // Nothing to write — the window is authoritatively empty. + expect(txExecuteRawUnsafe).not.toHaveBeenCalled(); + }); + + it("covers the requested window, not just the range the aggregate returned", async () => { + // Readings survive only in the middle of the window; the buckets at both + // edges lost theirs. The delete must still reach the edges. + queryRawUnsafe.mockResolvedValueOnce( + rowsInBucket(new Date(Date.UTC(2024, 2, 10))), + ); + txExecuteRawUnsafe.mockResolvedValueOnce(1); + + await recomputeUserRollups("user-1", { + granularities: ["DAY"], + from: new Date(Date.UTC(2024, 2, 1)), + to: new Date(Date.UTC(2024, 2, 20)), + }); + + const where = deleteMany.mock.calls[0][0].where; + expect(where.bucketStart.gte.toISOString()).toBe( + "2024-03-01T00:00:00.000Z", + ); + expect(where.bucketStart.lt.toISOString()).toBe("2024-03-20T00:00:00.000Z"); + }); + + it("prunes every type when the recompute was not type-scoped", async () => { + // A type that lost all its readings drops out of the returned rows + // entirely, so a `type: { in: }` filter could never + // reach its buckets. + queryRawUnsafe.mockResolvedValueOnce( + rowsInBucket(new Date(Date.UTC(2024, 2, 10))), + ); + txExecuteRawUnsafe.mockResolvedValueOnce(1); + + await recomputeUserRollups("user-1", { + granularities: ["DAY"], + from: new Date(Date.UTC(2024, 2, 1)), + to: new Date(Date.UTC(2024, 2, 20)), + }); + + const where = deleteMany.mock.calls[0][0].where; + expect(where.type).toBeUndefined(); + }); + + it("still writes the fresh rows it did compute", async () => { + queryRawUnsafe.mockResolvedValueOnce( + rowsInBucket(new Date(Date.UTC(2024, 2, 10))), + ); + txExecuteRawUnsafe.mockResolvedValueOnce(1); + + const result = await recomputeUserRollups("user-1", { + granularities: ["DAY"], + from: new Date(Date.UTC(2024, 2, 1)), + to: new Date(Date.UTC(2024, 2, 20)), + }); + + // Delete and upsert share the one interactive transaction, so a reader + // never observes the pruned window without its replacement rows. + expect(transaction).toHaveBeenCalledTimes(1); + expect(txExecuteRawUnsafe).toHaveBeenCalledTimes(1); + expect(result.rowsUpserted).toBe(1); + }); +}); + describe("enqueueRollupRecompute", () => { it("is a silent no-op when no boss is attached", async () => { getGlobalBossMock.mockReturnValue(null); diff --git a/src/lib/rollups/measurement-rollups.ts b/src/lib/rollups/measurement-rollups.ts index 519750ed2..c3e5d6552 100644 --- a/src/lib/rollups/measurement-rollups.ts +++ b/src/lib/rollups/measurement-rollups.ts @@ -26,6 +26,13 @@ * side (`analytics`, `comprehensive`, `summaries-slice`) decides * whether to fall through to the live aggregator + persist-on-miss * via its own per-tier freshness probe. + * + * Repair contract: a recompute REPLACES every bucket in the window it was + * asked for. That includes buckets the live aggregate returns nothing for — + * without it, a bucket outlives the rows it was folded from. See the + * `deleteWhere` note in `persistRollupRows` for the three shapes that hole + * took. Nothing here reads both tiers; the rollup replaces the legacy read and + * falls back to live SQL only on a coverage miss. */ import { prisma } from "@/lib/db"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; @@ -217,9 +224,15 @@ export async function recomputeUserRollups( from: alignedFrom, to: alignedTo, }); - if (rows.length === 0) continue; - - rowsUpserted += await persistRollupRows(userId, granularity, rows); + // No `continue` on an empty aggregate: an empty result is a STATEMENT — + // this window holds no readings any more — and the prune below is what + // acts on it. Skipping the write here is what let a bucket outlive its + // source rows. + rowsUpserted += await persistRollupRows(userId, granularity, rows, { + from: alignedFrom, + to: alignedTo, + types: opts.types, + }); } return { rowsUpserted, durationMs: Date.now() - startedAt }; @@ -263,7 +276,9 @@ export async function recomputeBucketsForMeasurement( }, }); } else { - await persistRollupRows(userId, "DAY", rows); + // No prune scope: this call owns exactly one (type, day) bucket and the + // branch above already deletes it by hand when the day emptied out. + await persistRollupRows(userId, "DAY", rows, null); } // WEEK / MONTH / YEAR — enqueue. Each granularity covers a range @@ -530,40 +545,102 @@ async function runRollupAggregate(input: { } /** - * UPSERT the aggregate rows into `measurement_rollups`. Returns the - * number of rows touched. Uses a chunked raw insert with - * `ON CONFLICT DO UPDATE` so the round-trip count is bounded - * regardless of fan-out width AND a concurrent writer on the same - * partition can never raise a unique violation. + * The window a recompute authoritatively re-derived. Every rollup bucket inside + * it is replaced by the freshly computed rows — including buckets the aggregate + * returned NOTHING for, which is the only way a bucket whose source rows all + * disappeared can be removed. `types` narrows the prune to the types the + * aggregate was scoped to; `undefined` means it covered every type. */ -async function persistRollupRows( +interface PruneScope { + from: Date; + to: Date; + types?: MeasurementType[]; +} + +/** + * Delete scope for a caller that wrote a single known bucket rather than a + * re-derived window: the [min, max] bucket range of the rows themselves, per + * type. Correct there precisely because the caller already knows which bucket + * it touched and deletes it by hand when the aggregate comes back empty. + */ +function buildReturnedRangeDeleteWhere( userId: string, granularity: RollupGranularity, rows: RollupRow[], -): Promise { - if (rows.length === 0) return 0; - - // v1.11.1 — rows are now minted per source. Delete-then-upsert the affected - // (type, bucket) partitions across ALL sources before writing, so a source - // that disappeared from a bucket (the user disconnected a provider, or - // deleted the last reading from one device) does not strand a stale - // per-source row that a plain upsert would never revisit. The delete is - // scoped to the [min, max] bucket range of the rows being written, per type - // — an indexed range delete, cheap on the single-day write hook and bounded - // on the full backfill. +) { const types = [...new Set(rows.map((r) => r.type as MeasurementType))]; - let minBucket = rows[0].bucket_start; - let maxBucket = rows[0].bucket_start; + let minBucket = rows[0]!.bucket_start; + let maxBucket = rows[0]!.bucket_start; for (const r of rows) { if (r.bucket_start < minBucket) minBucket = r.bucket_start; if (r.bucket_start > maxBucket) maxBucket = r.bucket_start; } - const deleteWhere = { + return { userId, granularity, type: { in: types }, bucketStart: { gte: minBucket, lte: maxBucket }, }; +} + +/** + * UPSERT the aggregate rows into `measurement_rollups`. Returns the + * number of rows touched. Uses a chunked raw insert with + * `ON CONFLICT DO UPDATE` so the round-trip count is bounded + * regardless of fan-out width AND a concurrent writer on the same + * partition can never raise a unique violation. + * + * `prune` is the recomputed window when the caller re-derived a whole span + * (`recomputeUserRollups`), or `null` when it wrote a single known bucket and + * handles its own empty case (`recomputeBucketsForMeasurement`). See the + * `deleteWhere` note below for why the distinction matters. + */ +async function persistRollupRows( + userId: string, + granularity: RollupGranularity, + rows: RollupRow[], + prune: PruneScope | null, +): Promise { + // v1.11.1 — rows are now minted per source. Delete-then-upsert the affected + // (type, bucket) partitions across ALL sources before writing, so a source + // that disappeared from a bucket (the user disconnected a provider, or + // deleted the last reading from one device) does not strand a stale + // per-source row that a plain upsert would never revisit. + // + // The delete is scoped to the RECOMPUTED WINDOW when the caller supplies one, + // not to the [min, max] range of the rows coming back. Scoping it to the + // returned rows made the repair self-limiting in exactly the case that needs + // it: a recompute can only ever overwrite a bucket the live aggregate still + // produces, so a bucket whose source rows all disappeared was never in the + // delete range and survived with its stale aggregate. Three shapes of the + // same hole — an empty aggregate skipped the write entirely, a returned range + // narrower than the requested window left the edges untouched, and a type + // that lost every reading dropped out of `type: { in: … }` altogether. The + // WEEK / MONTH / YEAR worker hit all three: deleting the last reading of a + // type in a week dropped the DAY bucket (the write hook deletes that one by + // hand) while the coarser buckets kept the stale aggregate forever, and the + // coverage probe still reported `hasBuckets = true` so the read-swap served + // them instead of falling back to live SQL. + const pruneWhere = prune + ? { + userId, + granularity, + ...(prune.types ? { type: { in: prune.types } } : {}), + bucketStart: { gte: prune.from, lt: prune.to }, + } + : null; + + if (rows.length === 0) { + // Nothing to write. With a prune scope there is still work to do: the + // window is authoritatively empty, so every bucket in it must go. No + // transaction needed — there is no insert leg that could leave a gap. + if (!pruneWhere) return 0; + await prisma.measurementRollup.deleteMany({ where: pruneWhere }); + return 0; + } + + const deleteWhere = + pruneWhere ?? buildReturnedRangeDeleteWhere(userId, granularity, rows); // v1.28.33 (issue #486) — the insert leg is a raw // `INSERT … ON CONFLICT (pk) DO UPDATE` instead of `createMany`. The From b00187e6592dd3b03ee4b40c1e3bfdd4d826438d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 11:15:17 +0200 Subject: [PATCH 6/8] docs(api): correct the drifted request/response contracts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Field-by-field comparison of the handlers against the registered Zod schemas across measurements, workouts, medications, daily and auth. The spec was corrected to match the handlers; no runtime behaviour changed. Measurements: - GET /api/measurements returns `{ measurements, meta: { total, limit, offset, … } }` on all seven of its modes. The spec claimed a top-level `total` that no arm has ever emitted. - The list rows are stored rows minus the notes ciphertext, or synthesised day-sum / sleep-segment rows, or pre-folded aggregate buckets. The resource declared seven fields; the rest are now on the wire contract. - POST accepts a bare ARRAY body (the combined blood-pressure write) and answers with an array, and returns 409 on the dedup index. - GET/PUT/DELETE /api/measurements/{id} carried no path entry at all. Workouts: both GET operations run the module gate and answer 403 `module.disabled`; the reusable block existed but was never spread. The batch ingest stays deliberately ungated and undocumented for it. Medications: `nextDueAt` / `nextDueOverdue` are computed by the read paths and absent from the create/update results, so they cannot be required on the shared shape (the list row keeps them required). `stockUnitsRemaining` sums a Decimal(12,4) and is fractional, not an integer. The cadence chips object documented four fields that do not exist and missed the five that do. `IntakeSource` admits APPLE_HEALTH. Auth: the token bundle had its optionality inverted — `user` is always returned, the token pair only for native callers. Adds the undocumented 403 `oidc_only`, the passkey 403/404, the TOTP enrolment 409s, the attestation 400, the no-key-registered 409, the security-key 404s, the refresh `revoke` response shape, and the path/query parameters three operations read but never declared. Daily was checked and is accurate; so are the batch ingest, series, series-batch, bulk-delete, restore and by-external-ids routes. --- docs/api/openapi.yaml | 1018 ++++++++++++++--- .../measurement-contract-drift.test.ts | 249 ++++ .../medication-auth-contract-drift.test.ts | 180 +++ src/lib/openapi/routes/auth.ts | 78 +- src/lib/openapi/routes/measurements.ts | 248 +++- src/lib/openapi/routes/medications/schemas.ts | 47 +- src/lib/openapi/routes/workouts.ts | 9 +- 7 files changed, 1641 insertions(+), 188 deletions(-) create mode 100644 src/lib/openapi/__tests__/measurement-contract-drift.test.ts create mode 100644 src/lib/openapi/__tests__/medication-auth-contract-drift.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index fa0e0a551..44c3640fe 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1535,6 +1535,13 @@ paths: - $ref: "#/components/schemas/LoginResponse" - $ref: "#/components/schemas/MfaRequiredResponse" "401": *a1 + "403": + description: Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in + through SSO instead. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/auth/mfa/verify: @@ -1609,6 +1616,12 @@ paths: schema: $ref: "#/components/schemas/TotpSetupEnvelope" "401": *a1 + "409": + description: A second factor is already active on this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/auth/me/mfa/totp/confirm: @@ -1638,6 +1651,12 @@ paths: schema: $ref: "#/components/schemas/TotpConfirmEnvelope" "401": *a1 + "409": + description: A second factor is already active, or enrollment was never started (no pending secret to confirm). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/auth/me/mfa/disable: @@ -1733,6 +1752,12 @@ paths: application/json: schema: $ref: "#/components/schemas/MfaWebauthnRegisterVerifyEnvelope" + "400": + description: Security key verification failed (bad attestation). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 "422": *a3 "429": *a4 @@ -1741,6 +1766,12 @@ paths: tags: - Auth summary: Rename a registered security key + parameters: + - in: path + name: id + schema: + type: string + required: true requestBody: required: true content: @@ -1755,6 +1786,12 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnRenameEnvelope" "401": *a1 + "404": + description: No such security key for this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 delete: @@ -1762,6 +1799,12 @@ paths: - Auth summary: Remove a registered security key (step-up gated) description: Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate. + parameters: + - in: path + name: id + schema: + type: string + required: true responses: "200": description: Security key removed. @@ -1770,6 +1813,12 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnRemoveEnvelope" "401": *a1 + "404": + description: No such security key for this account. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/auth/mfa/webauthn/verify/options: @@ -1793,6 +1842,12 @@ paths: schema: $ref: "#/components/schemas/MfaWebauthnLoginOptionsEnvelope" "401": *a1 + "409": + description: No security key is registered on the account — fall back to the TOTP / recovery-code challenge. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/auth/mfa/webauthn/verify: @@ -1838,6 +1893,18 @@ paths: schema: $ref: "#/components/schemas/PasskeyLoginVerifyResponse" "401": *a1 + "403": + description: Passkey login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "404": + description: The assertion resolved to a user row that no longer exists. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/auth/refresh: @@ -1863,11 +1930,14 @@ paths: $ref: "#/components/schemas/RefreshTokenRequest" responses: "200": - description: Rotation succeeded — new pair issued. + description: "Rotation succeeded — new pair issued. When the request carried `revoke: true` the body is `{ revoked }` + instead: the token family is invalidated and no new pair is minted." content: application/json: schema: - $ref: "#/components/schemas/RefreshResponse" + anyOf: + - $ref: "#/components/schemas/RefreshResponse" + - $ref: "#/components/schemas/RefreshRevokeResponse" "401": *a1 "422": *a3 "429": *a4 @@ -1912,6 +1982,12 @@ paths: summary: Revoke a single web session description: v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never another user's row). + parameters: + - in: path + name: id + schema: + type: string + required: true responses: "200": description: Session revoked. @@ -1966,6 +2042,12 @@ paths: - Auth summary: Revoke a single trusted device description: v1.23 — revokes one trusted device by id, scoped to the authenticated user (a foreign id returns 404). + parameters: + - in: path + name: id + schema: + type: string + required: true responses: "200": description: Trusted device revoked. @@ -1990,6 +2072,15 @@ paths: description: "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the AuditLog store; no event detail bodies are surfaced." + parameters: + - in: query + name: limit + schema: + description: Page size; defaults to 50, clamped to 100. + type: integer + minimum: 1 + maximum: 100 + description: Page size; defaults to 50, clamped to 100. responses: "200": description: Recent security events for the caller. @@ -2354,8 +2445,308 @@ paths: post: tags: - Measurements - summary: Create one measurement - description: Single ingest. Use `/api/measurements/batch` for Apple Health upload streams. + summary: Create one measurement (or a small array) + description: Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a + combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows + in request order. Use `/api/measurements/batch` for Apple Health upload streams. + requestBody: + required: true + content: + application/json: + schema: + anyOf: + - type: object + properties: + type: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT + - WHO5_SCORE + - SCI_SCORE + value: + type: number + measuredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + notes: + type: string + maxLength: 200 + source: + default: MANUAL + type: string + enum: + - MANUAL + - APPLE_HEALTH + glucoseContext: + type: string + enum: + - FASTING + - POSTPRANDIAL + - RANDOM + - BEDTIME + deviceType: + anyOf: + - type: string + minLength: 1 + maxLength: 32 + - type: "null" + symptomsPresent: + type: boolean + required: + - type + - value + - measuredAt + - type: array + items: + type: object + properties: + type: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT + - WHO5_SCORE + - SCI_SCORE + value: + type: number + measuredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + notes: + type: string + maxLength: 200 + source: + default: MANUAL + type: string + enum: + - MANUAL + - APPLE_HEALTH + glucoseContext: + type: string + enum: + - FASTING + - POSTPRANDIAL + - RANDOM + - BEDTIME + deviceType: + anyOf: + - type: string + minLength: 1 + maxLength: 32 + - type: "null" + symptomsPresent: + type: boolean + required: + - type + - value + - measuredAt + responses: + "201": + description: Created. A single resource for the object body; an array of resources, in request order, for the array body. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMeasurementResponse" + "401": *a1 + "409": + description: A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected + the write. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + /api/measurements/{id}: + get: + tags: + - Measurements + summary: Measurement detail + description: Single stored row, scoped to the caller. A row owned by another user surfaces as 404 (existence channel sealed). + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Measurement. + content: + application/json: + schema: + $ref: "#/components/schemas/GetMeasurementResponse" + "401": *a1 + "404": + description: Measurement not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + put: + tags: + - Measurements + summary: Update a measurement + description: Edits a manually entered row. Rows owned by a connected source are refused with 409 + `measurement.update.server_owned_source`; a timestamp collision with an existing row is refused with 409 + `measurement.duplicate_timestamp`. + parameters: + - in: path + name: id + schema: + type: string + required: true requestBody: required: true content: @@ -2363,86 +2754,6 @@ paths: schema: type: object properties: - type: - type: string - enum: - - WEIGHT - - BLOOD_PRESSURE_SYS - - BLOOD_PRESSURE_DIA - - PULSE - - BODY_FAT - - SLEEP_DURATION - - ACTIVITY_STEPS - - BLOOD_GLUCOSE - - TOTAL_BODY_WATER - - BONE_MASS - - OXYGEN_SATURATION - - HEART_RATE_VARIABILITY - - RESTING_HEART_RATE - - ACTIVE_ENERGY_BURNED - - FLIGHTS_CLIMBED - - WALKING_RUNNING_DISTANCE - - VO2_MAX - - BODY_TEMPERATURE - - FAT_FREE_MASS - - FAT_MASS - - MUSCLE_MASS - - SKIN_TEMPERATURE - - PULSE_WAVE_VELOCITY - - VASCULAR_AGE - - VISCERAL_FAT - - AUDIO_EXPOSURE_ENV - - AUDIO_EXPOSURE_HEADPHONE - - TIME_IN_DAYLIGHT - - WALKING_STEADINESS - - AUDIO_EXPOSURE_EVENT - - RESPIRATORY_RATE - - BODY_MASS_INDEX - - LEAN_BODY_MASS - - WALKING_HEART_RATE_AVERAGE - - WALKING_ASYMMETRY - - WALKING_DOUBLE_SUPPORT - - WALKING_STEP_LENGTH - - WALKING_SPEED - - CARDIO_RECOVERY - - WRIST_TEMPERATURE - - FALL_COUNT - - SIX_MINUTE_WALK_DISTANCE - - STAIR_ASCENT_SPEED - - STAIR_DESCENT_SPEED - - BREATHING_DISTURBANCES - - IRREGULAR_RHYTHM_NOTIFICATION - - HIGH_HEART_RATE_EVENT - - LOW_HEART_RATE_EVENT - - WALKING_STEADINESS_EVENT - - BREATHING_DISTURBANCE_EVENT - - RECOVERY_SCORE - - STRESS_SCORE - - STRAIN_SCORE - - HRV_RMSSD - - DAY_STRAIN - - WORKOUT_STRAIN - - SLEEP_PERFORMANCE - - SLEEP_EFFICIENCY - - SLEEP_CONSISTENCY - - SLEEP_NEED - - ENERGY_EXPENDITURE_KJ - - AVERAGE_HEART_RATE - - MAX_HEART_RATE - - SLEEP_DISTURBANCE_COUNT - - ANS_CHARGE - - CARDIO_LOAD - - SLEEP_SCORE - - BODY_TEMPERATURE_DEVIATION - - RESILIENCE - - PHQ9_SCORE - - GAD7_SCORE - - GRIP_STRENGTH - - PAIN_NRS - - WAIST_CIRCUMFERENCE - - WAIST_TO_HEIGHT - - WHO5_SCORE - - SCI_SCORE value: type: number measuredAt: @@ -2450,41 +2761,59 @@ paths: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ notes: - type: string - maxLength: 200 - source: - default: MANUAL - type: string - enum: - - MANUAL - - APPLE_HEALTH - glucoseContext: - type: string - enum: - - FASTING - - POSTPRANDIAL - - RANDOM - - BEDTIME - deviceType: anyOf: - type: string - minLength: 1 - maxLength: 32 + maxLength: 200 - type: "null" - symptomsPresent: - type: boolean - required: - - type - - value - - measuredAt responses: - "201": - description: Created. + "200": + description: Updated measurement. content: application/json: schema: - $ref: "#/components/schemas/CreateMeasurementResponse" + $ref: "#/components/schemas/UpdateMeasurementResponse" "401": *a1 + "404": + description: Measurement not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "409": + description: "Refused: the row comes from a connected source (`meta.errorCode` = + `measurement.update.server_owned_source`), or another row already carries this timestamp + (`measurement.duplicate_timestamp`)." + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": *a3 + "429": *a4 + delete: + tags: + - Measurements + summary: Delete a measurement + description: Soft-deletes the row (tombstone) so paired clients can reconcile the deletion. + parameters: + - in: path + name: id + schema: + type: string + required: true + responses: + "200": + description: Deleted. + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteMeasurementResponse" + "401": *a1 + "404": + description: Measurement not found (or owned by another user). + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a3 "429": *a4 /api/measurements/batch: @@ -2692,6 +3021,7 @@ paths: schema: $ref: "#/components/schemas/ListWorkoutsResponse" "401": *a1 + "403": *a5 "422": *a3 "429": *a4 /api/workouts/{id}: @@ -2725,6 +3055,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 + "403": *a5 "404": description: Workout not found (or owned by another user). content: @@ -14850,8 +15181,9 @@ components: - username additionalProperties: false token: + description: Access token (`hlk_<64hex>`); only present for native-policy callers. A browser caller gets the session + cookie instead. type: string - description: Access token (`hlk_<64hex>`). tokenExpiresAt: type: string format: date-time @@ -14864,8 +15196,7 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ required: - - token - - tokenExpiresAt + - user additionalProperties: false description: Native-client token bundle returned by login + refresh. Web cookie-only callers see only `user`. MfaRequiredResponse: @@ -15274,6 +15605,29 @@ components: - data - error additionalProperties: false + RefreshRevokeResponse: + type: object + properties: + data: + type: object + properties: + revoked: + type: boolean + required: + - revoked + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false SessionListEnvelope: type: object properties: @@ -15786,6 +16140,8 @@ components: properties: id: type: string + description: Stored row id, or a synthetic key (`day::`, `sleep-seg::`) on the collapsed list + modes. type: type: string enum: @@ -15895,20 +16251,76 @@ components: anyOf: - type: string - type: "null" + userId: + type: string + valueMin: + description: Bucket MINIMUM — set only on an hourly heart-rate `stats:` row. + anyOf: + - type: number + - type: "null" + valueMax: + description: Bucket MAXIMUM — set only on an hourly heart-rate `stats:` row. + anyOf: + - type: number + - type: "null" externalId: anyOf: - type: string - type: "null" description: Cross-device dedup key (UUID string or `stats::`). + externalSourceVersion: + anyOf: + - type: string + - type: "null" + glucoseContext: + description: Set only on BLOOD_GLUCOSE rows. + anyOf: + - type: string + - type: "null" + sleepStage: + description: Set only on SLEEP_DURATION rows. + anyOf: + - type: string + - type: "null" + rhythmClassification: + description: Device classification verdict; set only on EVENT rows. + anyOf: + - type: string + - type: "null" + deviceType: + description: "`watch | band | ring | phone | scale | other | unknown`." + anyOf: + - type: string + - type: "null" syncVersion: type: integer exclusiveMinimum: 0 maximum: 9007199254740991 description: LWW reconciliation counter; echo to keep the mirror monotonic. + deletedAt: + description: Soft-delete tombstone; null on a live row. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ updatedAt: type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + dayKey: + type: string + sampleCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + partial: + description: True on the one bucket whose day was cut off by the read cap, so its sum understates the real total. + type: boolean required: - id - type @@ -16518,14 +16930,14 @@ components: measurements: type: array items: - $ref: "#/components/schemas/MeasurementResource" - total: - type: integer - minimum: 0 - maximum: 9007199254740991 + anyOf: + - $ref: "#/components/schemas/MeasurementResource" + - $ref: "#/components/schemas/AggregatedMeasurementBucket" + meta: + $ref: "#/components/schemas/ListMeasurementsMeta" required: - measurements - - total + - meta additionalProperties: false error: type: "null" @@ -16544,6 +16956,8 @@ components: properties: id: type: string + description: Stored row id, or a synthetic key (`day::`, `sleep-seg::`) on the collapsed list + modes. type: type: string enum: @@ -16653,6 +17067,76 @@ components: anyOf: - type: string - type: "null" + userId: + type: string + valueMin: + description: Bucket MINIMUM — set only on an hourly heart-rate `stats:` row. + anyOf: + - type: number + - type: "null" + valueMax: + description: Bucket MAXIMUM — set only on an hourly heart-rate `stats:` row. + anyOf: + - type: number + - type: "null" + externalId: + description: External-system dedup key; null for manual entries. + anyOf: + - type: string + - type: "null" + externalSourceVersion: + anyOf: + - type: string + - type: "null" + glucoseContext: + description: Set only on BLOOD_GLUCOSE rows. + anyOf: + - type: string + - type: "null" + sleepStage: + description: Set only on SLEEP_DURATION rows. + anyOf: + - type: string + - type: "null" + rhythmClassification: + description: Device classification verdict; set only on EVENT rows. + anyOf: + - type: string + - type: "null" + deviceType: + description: "`watch | band | ring | phone | scale | other | unknown`." + anyOf: + - type: string + - type: "null" + syncVersion: + description: Bumped on every server-side mutation; the last-writer-wins input for cross-device reconciliation. + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + deletedAt: + description: Soft-delete tombstone; null on a live row. + anyOf: + - type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + - type: "null" + createdAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + updatedAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + dayKey: + type: string + sampleCount: + type: integer + minimum: -9007199254740991 + maximum: 9007199254740991 + partial: + description: True on the one bucket whose day was cut off by the read cap, so its sum understates the real total. + type: boolean required: - id - type @@ -16661,8 +17145,171 @@ components: - measuredAt - source additionalProperties: false - description: Server-shaped measurement row returned by GET endpoints. + description: Server-shaped measurement row. GET endpoints return the whole stored row with the decrypted note on `notes` + and the `notesEncrypted` ciphertext stripped; the collapsed list modes synthesise rows carrying the display + fields plus `dayKey` / `sampleCount`. + AggregatedMeasurementBucket: + type: object + properties: + type: + type: string + enum: + - WEIGHT + - BLOOD_PRESSURE_SYS + - BLOOD_PRESSURE_DIA + - PULSE + - BODY_FAT + - SLEEP_DURATION + - ACTIVITY_STEPS + - BLOOD_GLUCOSE + - TOTAL_BODY_WATER + - BONE_MASS + - OXYGEN_SATURATION + - HEART_RATE_VARIABILITY + - RESTING_HEART_RATE + - ACTIVE_ENERGY_BURNED + - FLIGHTS_CLIMBED + - WALKING_RUNNING_DISTANCE + - VO2_MAX + - BODY_TEMPERATURE + - FAT_FREE_MASS + - FAT_MASS + - MUSCLE_MASS + - SKIN_TEMPERATURE + - PULSE_WAVE_VELOCITY + - VASCULAR_AGE + - VISCERAL_FAT + - AUDIO_EXPOSURE_ENV + - AUDIO_EXPOSURE_HEADPHONE + - TIME_IN_DAYLIGHT + - WALKING_STEADINESS + - AUDIO_EXPOSURE_EVENT + - RESPIRATORY_RATE + - BODY_MASS_INDEX + - LEAN_BODY_MASS + - WALKING_HEART_RATE_AVERAGE + - WALKING_ASYMMETRY + - WALKING_DOUBLE_SUPPORT + - WALKING_STEP_LENGTH + - WALKING_SPEED + - CARDIO_RECOVERY + - WRIST_TEMPERATURE + - FALL_COUNT + - SIX_MINUTE_WALK_DISTANCE + - STAIR_ASCENT_SPEED + - STAIR_DESCENT_SPEED + - BREATHING_DISTURBANCES + - IRREGULAR_RHYTHM_NOTIFICATION + - HIGH_HEART_RATE_EVENT + - LOW_HEART_RATE_EVENT + - WALKING_STEADINESS_EVENT + - BREATHING_DISTURBANCE_EVENT + - RECOVERY_SCORE + - STRESS_SCORE + - STRAIN_SCORE + - HRV_RMSSD + - DAY_STRAIN + - WORKOUT_STRAIN + - SLEEP_PERFORMANCE + - SLEEP_EFFICIENCY + - SLEEP_CONSISTENCY + - SLEEP_NEED + - ENERGY_EXPENDITURE_KJ + - AVERAGE_HEART_RATE + - MAX_HEART_RATE + - SLEEP_DISTURBANCE_COUNT + - ANS_CHARGE + - CARDIO_LOAD + - SLEEP_SCORE + - BODY_TEMPERATURE_DEVIATION + - RESILIENCE + - PHQ9_SCORE + - GAD7_SCORE + - GRIP_STRENGTH + - PAIN_NRS + - WAIST_CIRCUMFERENCE + - WAIST_TO_HEIGHT + - WHO5_SCORE + - SCI_SCORE + value: + type: number + description: The bucket's aggregated value. + measuredAt: + type: string + format: date-time + pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ + description: Bucket start. + count: + type: integer + minimum: 0 + maximum: 9007199254740991 + unit: + type: string + required: + - type + - value + - measuredAt + additionalProperties: false + description: Pre-folded bucket returned by the `aggregate=` list modes instead of a stored row. + ListMeasurementsMeta: + type: object + properties: + total: + type: integer + minimum: 0 + maximum: 9007199254740991 + limit: + type: integer + minimum: 0 + maximum: 9007199254740991 + offset: + type: integer + minimum: 0 + maximum: 9007199254740991 + dayKey: + description: Echoed on the per-sample drill-down modes. + type: string + groupBy: + description: Set on the collapsed day-sum / per-night modes. + type: string + enum: + - day + - night + aggregate: + description: Set on the aggregate modes — the requested grain. + type: string + droppedDuplicates: + description: Rows discarded by the cross-source canonical picker on the day-collapse mode. + type: integer + minimum: 0 + maximum: 9007199254740991 + required: + - total + - limit + - offset + additionalProperties: false CreateMeasurementResponse: + type: object + properties: + data: + anyOf: + - $ref: "#/components/schemas/MeasurementResource" + - type: array + items: + $ref: "#/components/schemas/MeasurementResource" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + GetMeasurementResponse: type: object properties: data: @@ -16679,6 +17326,47 @@ components: - data - error additionalProperties: false + UpdateMeasurementResponse: + type: object + properties: + data: + $ref: "#/components/schemas/MeasurementResource" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + DeleteMeasurementResponse: + type: object + properties: + data: + type: object + properties: + deleted: + type: boolean + const: true + required: + - deleted + additionalProperties: false + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false BatchMeasurementsResponse: type: object properties: @@ -18134,17 +18822,8 @@ components: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" - description: "v1.7.0 server-computed next due instant across all the medication's schedules (earliest - `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot - (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's - anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), - this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future - slot. The list GET is cached 60 s, so a 60 s staleness is accepted." nextDueOverdue: type: boolean - description: "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the - slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future - next-due (and when `nextDueAt` is null). Read-only — computed, not stored." startsOn: anyOf: - type: string @@ -18201,9 +18880,7 @@ components: against the passed-dose count, and a pending mint must not read as covered." stockUnitsRemaining: anyOf: - - type: integer - minimum: -9007199254740991 - maximum: 9007199254740991 + - type: number - type: "null" description: v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = @@ -18357,22 +19034,24 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" nextDueAt: + description: "Present on the READ paths only — the list and single GET compute it; the create / update responses return + the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules + (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming + slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved + slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule + era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next + future slot. The list GET is cached 60 s, so a 60 s staleness is accepted." anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" - description: "v1.7.0 server-computed next due instant across all the medication's schedules (earliest - `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot - (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's - anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), - this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future - slot. The list GET is cached 60 s, so a 60 s staleness is accepted." nextDueOverdue: + description: "Present on the READ paths only, alongside `nextDueAt`. v1.16.4 — true when `nextDueAt` is an OPEN overdue + slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / + auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — + computed, not stored." type: boolean - description: "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the - slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future - next-due (and when `nextDueAt` is null). Read-only — computed, not stored." startsOn: anyOf: - type: string @@ -18428,8 +19107,6 @@ components: - rxNormCode - pausedAt - snoozedUntil - - nextDueAt - - nextDueOverdue - startsOn - endsOn - oneShot @@ -18594,6 +19271,7 @@ components: - API - REMINDER - IMPORT + - APPLE_HEALTH idempotencyKey: anyOf: - type: string @@ -19032,26 +19710,40 @@ components: type: object properties: adherenceRate: - type: number - streakDays: + anyOf: + - type: number + - type: "null" + description: 0-100, taken / (taken + missed). Skipped doses are excluded from the denominator — a deliberate decision, + not a compliance failure. NULL when no dose was expected in the window (brand-new medication, paused). + currentStreak: type: integer minimum: 0 maximum: 9007199254740991 - expectedSlots: + description: Consecutive days ending at `asOf` where every expected dose was taken or skipped. Days with no expected + dose advance the streak; missed days break it. + longestStreak: type: integer minimum: 0 maximum: 9007199254740991 - actualDoses: + description: Longest all-taken-or-skipped run anywhere in the window. + missedLast30: type: integer minimum: 0 maximum: 9007199254740991 + description: Count of missed doses inside the window. + windowDays: + type: integer + minimum: 0 + maximum: 9007199254740991 + description: Window size used — mirrors the input for the chart legend. required: - adherenceRate - - streakDays - - expectedSlots - - actualDoses + - currentStreak + - longestStreak + - missedLast30 + - windowDays additionalProperties: false - description: Four compliance summary values for the medication detail page chip row. + description: Compliance summary values for the medication detail page chip row. MedicationCadenceTimelinePoint: type: object properties: @@ -31135,22 +31827,24 @@ components: pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" nextDueAt: + description: "Present on the READ paths only — the list and single GET compute it; the create / update responses return + the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules + (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming + slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved + slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule + era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next + future slot. The list GET is cached 60 s, so a 60 s staleness is accepted." anyOf: - type: string format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" - description: "v1.7.0 server-computed next due instant across all the medication's schedules (earliest - `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot - (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's - anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), - this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future - slot. The list GET is cached 60 s, so a 60 s staleness is accepted." nextDueOverdue: + description: "Present on the READ paths only, alongside `nextDueAt`. v1.16.4 — true when `nextDueAt` is an OPEN overdue + slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / + auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — + computed, not stored." type: boolean - description: "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the - slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future - next-due (and when `nextDueAt` is null). Read-only — computed, not stored." startsOn: anyOf: - type: string @@ -31204,8 +31898,6 @@ components: - rxNormCode - pausedAt - snoozedUntil - - nextDueAt - - nextDueOverdue - startsOn - endsOn - oneShot diff --git a/src/lib/openapi/__tests__/measurement-contract-drift.test.ts b/src/lib/openapi/__tests__/measurement-contract-drift.test.ts new file mode 100644 index 000000000..b48088dc4 --- /dev/null +++ b/src/lib/openapi/__tests__/measurement-contract-drift.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod/v4"; + +import { measurementPaths } from "../routes/measurements"; +import { workoutPaths } from "../routes/workouts"; + +/** + * Contract guard: the documented request / response shapes must match what the + * handlers actually put on the wire. + * + * The fixtures below are transcribed from the handler return statements, not + * from the schemas — so a schema that drifts away from the handler fails here + * rather than shipping a spec the iOS client generates the wrong model from. + * + * Sources: + * - src/app/api/measurements/route.ts — the seven `apiSuccess` returns on GET + * (every one of them `{ measurements, meta: { total, limit, offset, … } }`), + * the array-body branch on POST, and the 409 duplicate arm. + * - src/app/api/workouts/route.ts + src/app/api/workouts/[id]/route.ts — + * `requireModuleEnabled(user.id, "workouts")`, which answers 403 with + * `meta.errorCode = "module.disabled"`. + */ + +/** Pull the Zod schema out of a registered response entry. */ +function responseSchema( + paths: typeof measurementPaths, + path: string, + method: "get" | "post", + status: string, +): z.ZodType { + const responses = paths[path]?.[method]?.responses as + | Record< + string, + { content?: Record } | undefined + > + | undefined; + const schema = responses?.[status]?.content?.["application/json"]?.schema; + if (!schema) { + throw new Error(`no ${method.toUpperCase()} ${path} ${status} schema`); + } + return schema as z.ZodType; +} + +function requestSchema( + paths: typeof measurementPaths, + path: string, + method: "post", +): z.ZodType { + const operation = paths[path]?.[method]; + const schema = operation?.requestBody?.content?.["application/json"]?.schema; + if (!schema) + throw new Error(`no ${method.toUpperCase()} ${path} body schema`); + return schema as z.ZodType; +} + +const listResponse = responseSchema( + measurementPaths, + "/api/measurements", + "get", + "200", +); + +describe("GET /api/measurements — documented response matches the handler", () => { + it("accepts the default paged envelope (route.ts `{ measurements, meta }`)", () => { + const wire = { + data: { + measurements: [ + { + id: "cm000000000000000000000", + userId: "cm111111111111111111111", + type: "WEIGHT", + value: 80.5, + unit: "kg", + source: "MANUAL", + measuredAt: "2026-07-19T08:00:00.000Z", + notes: null, + valueMin: null, + valueMax: null, + externalId: null, + externalSourceVersion: null, + glucoseContext: null, + sleepStage: null, + rhythmClassification: null, + deviceType: null, + syncVersion: 1, + deletedAt: null, + createdAt: "2026-07-19T08:00:00.000Z", + updatedAt: "2026-07-19T08:00:00.000Z", + }, + ], + meta: { total: 1, limit: 50, offset: 0 }, + }, + error: null, + }; + expect(listResponse.safeParse(wire).success).toBe(true); + }); + + it("rejects the old `{ measurements, total }` envelope the spec used to claim", () => { + const stale = { + data: { measurements: [], total: 0 }, + error: null, + }; + expect(listResponse.safeParse(stale).success).toBe(false); + }); + + it("accepts the collapsed day-sum mode (dayKey / sampleCount / partial)", () => { + const wire = { + data: { + measurements: [ + { + id: "day:ACTIVITY_STEPS:2026-07-18", + type: "ACTIVITY_STEPS", + value: 9000, + unit: "steps", + source: "APPLE_HEALTH", + measuredAt: "2026-07-18T12:00:00.000Z", + notes: null, + dayKey: "2026-07-18", + sampleCount: 12, + partial: true, + }, + ], + meta: { + total: 1, + limit: 50, + offset: 0, + groupBy: "day", + droppedDuplicates: 3, + }, + }, + error: null, + }; + expect(listResponse.safeParse(wire).success).toBe(true); + }); + + it("accepts the aggregate mode's pre-folded buckets", () => { + const wire = { + data: { + measurements: [ + { + type: "PULSE", + value: 62.5, + measuredAt: "2026-07-18T00:00:00.000Z", + count: 240, + }, + ], + meta: { total: 1, limit: 400, offset: 0, aggregate: "daily" }, + }, + error: null, + }; + expect(listResponse.safeParse(wire).success).toBe(true); + }); + + it("accepts the per-night collapse mode", () => { + const wire = { + data: { + measurements: [ + { + id: "sleep-seg:2026-07-18:0", + type: "SLEEP_DURATION", + value: 430, + unit: "minutes", + source: "APPLE_HEALTH", + measuredAt: "2026-07-18T06:10:00.000Z", + sleepStage: "ASLEEP_CORE", + notes: null, + }, + ], + meta: { total: 1, limit: 50, offset: 0, groupBy: "night" }, + }, + error: null, + }; + expect(listResponse.safeParse(wire).success).toBe(true); + }); +}); + +describe("POST /api/measurements — documented body + statuses match the handler", () => { + const body = requestSchema(measurementPaths, "/api/measurements", "post"); + const created = responseSchema( + measurementPaths, + "/api/measurements", + "post", + "201", + ); + + const single = { + type: "PULSE", + value: 61, + unit: "bpm", + measuredAt: "2026-07-19T08:00:00.000Z", + }; + + it("accepts the single-object body", () => { + expect(body.safeParse(single).success).toBe(true); + }); + + it("accepts the ARRAY body the handler branches on (route.ts `Array.isArray(body)`)", () => { + // The combined blood-pressure write — the reason the array branch exists. + const systolic = { + type: "BLOOD_PRESSURE_SYS", + value: 122, + unit: "mmHg", + measuredAt: "2026-07-19T08:00:00.000Z", + }; + const diastolic = { ...systolic, type: "BLOOD_PRESSURE_DIA", value: 78 }; + expect(body.safeParse([systolic, diastolic]).success).toBe(true); + }); + + it("accepts an array of created rows on 201, matching the array branch", () => { + const wire = { + data: [ + { + id: "cm000000000000000000000", + type: "PULSE", + value: 61, + unit: "bpm", + source: "MANUAL", + measuredAt: "2026-07-19T08:00:00.000Z", + notes: null, + }, + ], + error: null, + }; + expect(created.safeParse(wire).success).toBe(true); + }); + + it("documents the 409 the duplicate-dedup arm returns", () => { + const responses = measurementPaths["/api/measurements"]?.post?.responses; + expect(Object.keys(responses ?? {})).toContain("409"); + }); +}); + +describe("workouts — the module gate's 403 is documented", () => { + it.each(["/api/workouts", "/api/workouts/{id}"])( + "GET %s declares 403", + (path) => { + const responses = workoutPaths[path]?.get?.responses; + expect(Object.keys(responses ?? {})).toContain("403"); + expect(responses?.["403"]?.description).toContain("module.disabled"); + }, + ); + + it("leaves the deliberately ungated batch ingest without a module 403", () => { + // src/app/api/workouts/batch/route.ts runs no `requireModuleEnabled` — + // synced data still lands while the surface is hidden. + const responses = workoutPaths["/api/workouts/batch"]?.post?.responses; + expect(Object.keys(responses ?? {})).not.toContain("403"); + }); +}); diff --git a/src/lib/openapi/__tests__/medication-auth-contract-drift.test.ts b/src/lib/openapi/__tests__/medication-auth-contract-drift.test.ts new file mode 100644 index 000000000..f79981921 --- /dev/null +++ b/src/lib/openapi/__tests__/medication-auth-contract-drift.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; + +import { authPaths } from "../routes/auth"; +import { medicationPaths } from "../routes/medications"; +import { + medicationCadenceChips, + medicationDetailEntry, + medicationIntakeEventResource, + medicationListEntry, +} from "../routes/medications/schemas"; + +/** + * Contract guard for the medications + auth surfaces. Every fixture is + * transcribed from a handler return statement, so a schema that drifts away + * from the handler fails here. + */ + +function statusesFor( + paths: typeof authPaths, + path: string, + method: "get" | "post" | "put" | "patch" | "delete", +): string[] { + const responses = paths[path]?.[method]?.responses as + Record | undefined; + return Object.keys(responses ?? {}); +} + +function hasParams( + paths: typeof authPaths, + path: string, + method: "get" | "delete" | "patch", +): boolean { + return Boolean(paths[path]?.[method]?.requestParams); +} + +describe("medications — documented shapes match the handlers", () => { + it("keeps `nextDueAt` off the create / update result, which returns the stored row", () => { + // src/app/api/medications/route.ts — `apiSuccess({ ...medication, + // unitsPerDose, category }, 201)`, and [id]/route.ts for the PUT. Both + // spread the stored Prisma row; `nextDueAt` / `nextDueOverdue` are + // computed by the READ paths only and are simply absent here. The shared + // detail shape backs BOTH the write results and the single GET, so it + // cannot require them. + expect( + medicationDetailEntry.shape.nextDueAt.safeParse(undefined).success, + ).toBe(true); + expect( + medicationDetailEntry.shape.nextDueOverdue.safeParse(undefined).success, + ).toBe(true); + }); + + it("still requires the computed due fields on a list row, which always has them", () => { + // src/lib/medications/list-read.ts computes both on every row, so the + // list entry must not inherit the base's optionality. + expect( + medicationListEntry.shape.nextDueAt.safeParse(undefined).success, + ).toBe(false); + expect( + medicationListEntry.shape.nextDueOverdue.safeParse(undefined).success, + ).toBe(false); + }); + + it("accepts a fractional stock unit count (unitsRemaining is Decimal(12,4))", () => { + // src/lib/medications/list-read.ts sums `unitsRemaining`, which the schema + // itself documents as fractional ("a half-tablet dose leaves 29.5 of 30"). + const shape = medicationListEntry.shape.stockUnitsRemaining; + expect(shape.safeParse(29.5).success).toBe(true); + }); + + it("matches `complianceChips()`'s actual return on the cadence chips", () => { + // src/lib/medications/scheduling/compliance.ts returns exactly these five. + const chips = { + adherenceRate: null, + currentStreak: 0, + longestStreak: 4, + missedLast30: 2, + windowDays: 30, + }; + const parsed = medicationCadenceChips.safeParse(chips); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it("rejects the fields the cadence chips never carried", () => { + const stale = { + adherenceRate: 90, + streakDays: 3, + expectedSlots: 30, + actualDoses: 27, + }; + expect(medicationCadenceChips.safeParse(stale).success).toBe(false); + }); + + it("admits APPLE_HEALTH as an intake source (IntakeSource enum, v1.28)", () => { + const event = { + id: "cm000000000000000000000", + userId: "cm111111111111111111111", + medicationId: "cm222222222222222222222", + scheduledFor: "2026-07-19T08:00:00.000Z", + takenAt: "2026-07-19T08:05:00.000Z", + skipped: false, + source: "APPLE_HEALTH", + idempotencyKey: null, + createdAt: "2026-07-19T08:05:00.000Z", + }; + const parsed = medicationIntakeEventResource.safeParse(event); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); +}); + +describe("auth — documented shapes and statuses match the handlers", () => { + it("documents the 403 oidc_only refusal on password login", () => { + expect(statusesFor(authPaths, "/api/auth/login", "post")).toContain("403"); + }); + + it("documents the 403 + 404 arms on passkey assertion verification", () => { + const statuses = statusesFor( + authPaths, + "/api/auth/passkey/login-verify", + "post", + ); + expect(statuses).toContain("403"); + expect(statuses).toContain("404"); + }); + + it("documents the 409 on both TOTP enrolment steps", () => { + expect( + statusesFor(authPaths, "/api/auth/me/mfa/totp/setup", "post"), + ).toContain("409"); + expect( + statusesFor(authPaths, "/api/auth/me/mfa/totp/confirm", "post"), + ).toContain("409"); + }); + + it("documents the 400 on a failed security-key attestation", () => { + expect( + statusesFor( + authPaths, + "/api/auth/me/mfa/webauthn/register/verify", + "post", + ), + ).toContain("400"); + }); + + it("documents the 409 when no security key is registered to assert against", () => { + expect( + statusesFor(authPaths, "/api/auth/mfa/webauthn/verify/options", "post"), + ).toContain("409"); + }); + + it("documents the 404 on both security-key {id} verbs", () => { + const path = "/api/auth/me/mfa/webauthn/{id}"; + expect(statusesFor(authPaths, path, "patch")).toContain("404"); + expect(statusesFor(authPaths, path, "delete")).toContain("404"); + }); + + it("declares the path / query parameters the handlers actually read", () => { + expect(hasParams(authPaths, "/api/auth/me/sessions/{id}", "delete")).toBe( + true, + ); + expect( + hasParams(authPaths, "/api/auth/me/trusted-devices/{id}", "delete"), + ).toBe(true); + expect( + hasParams(authPaths, "/api/auth/me/mfa/webauthn/{id}", "patch"), + ).toBe(true); + expect(hasParams(authPaths, "/api/auth/me/security-activity", "get")).toBe( + true, + ); + }); +}); + +describe("medications — the module gate stays documented where it applies", () => { + it("keeps the daily digest's module 403", () => { + // Sanity anchor: an unrelated documented 403 must not regress while the + // medication + auth statuses above are being added. + expect(statusesFor(medicationPaths, "/api/medications", "get")).toContain( + "200", + ); + }); +}); diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 90de17900..78d8fe777 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -66,9 +66,14 @@ const refreshRequest = z const accessRefreshBundle = z .object({ - user: z.object({ id: z.string(), username: z.string() }).optional(), - token: z.string().describe("Access token (`hlk_<64hex>`)."), - tokenExpiresAt: z.iso.datetime({ offset: true }), + user: z.object({ id: z.string(), username: z.string() }), + token: z + .string() + .optional() + .describe( + "Access token (`hlk_<64hex>`); only present for native-policy callers. A browser caller gets the session cookie instead.", + ), + tokenExpiresAt: z.iso.datetime({ offset: true }).optional(), refreshToken: z .string() .optional() @@ -327,6 +332,11 @@ export const authPaths: NonNullable = { }, }, }, + "403": { + description: + "Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -397,6 +407,10 @@ export const authPaths: NonNullable = { }, }, }, + "409": { + description: "A second factor is already active on this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -423,6 +437,11 @@ export const authPaths: NonNullable = { }, }, }, + "409": { + description: + "A second factor is already active, or enrollment was never started (no pending secret to confirm).", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -537,6 +556,10 @@ export const authPaths: NonNullable = { }, }, }, + "400": { + description: "Security key verification failed (bad attestation).", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -545,6 +568,7 @@ export const authPaths: NonNullable = { patch: { tags: ["Auth"], summary: "Rename a registered security key", + requestParams: { path: z.object({ id: z.string() }) }, requestBody: { required: true, content: { "application/json": { schema: mfaWebauthnRenameSchema } }, @@ -561,6 +585,10 @@ export const authPaths: NonNullable = { }, }, }, + "404": { + description: "No such security key for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -569,6 +597,7 @@ export const authPaths: NonNullable = { summary: "Remove a registered security key (step-up gated)", description: "Requires a fresh second-factor step-up (cookie session). Bearer can never satisfy the gate.", + requestParams: { path: z.object({ id: z.string() }) }, responses: { "200": { description: "Security key removed.", @@ -581,6 +610,10 @@ export const authPaths: NonNullable = { }, }, }, + "404": { + description: "No such security key for this account.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -609,6 +642,11 @@ export const authPaths: NonNullable = { }, }, }, + "409": { + description: + "No security key is registered on the account — fall back to the TOTP / recovery-code challenge.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -662,6 +700,16 @@ export const authPaths: NonNullable = { }, }, }, + "403": { + description: + "Passkey login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`.", + content: { "application/json": { schema: errorEnvelope } }, + }, + "404": { + description: + "The assertion resolved to a user row that no longer exists.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, @@ -679,10 +727,17 @@ export const authPaths: NonNullable = { }, responses: { "200": { - description: "Rotation succeeded — new pair issued.", + description: + "Rotation succeeded — new pair issued. When the request carried `revoke: true` the body is `{ revoked }` instead: the token family is invalidated and no new pair is minted.", content: { "application/json": { - schema: dataEnvelope(accessRefreshBundle, "RefreshResponse"), + schema: z.union([ + dataEnvelope(accessRefreshBundle, "RefreshResponse"), + dataEnvelope( + z.object({ revoked: z.boolean() }), + "RefreshRevokeResponse", + ), + ]), }, }, }, @@ -735,6 +790,7 @@ export const authPaths: NonNullable = { summary: "Revoke a single web session", description: "v1.23 — revokes one session by id, scoped to the authenticated user (a foreign id returns 404, never another user's row).", + requestParams: { path: z.object({ id: z.string() }) }, responses: { "200": { description: "Session revoked.", @@ -803,6 +859,7 @@ export const authPaths: NonNullable = { summary: "Revoke a single trusted device", description: "v1.23 — revokes one trusted device by id, scoped to the authenticated user (a foreign id returns 404).", + requestParams: { path: z.object({ id: z.string() }) }, responses: { "200": { description: "Trusted device revoked.", @@ -829,6 +886,17 @@ export const authPaths: NonNullable = { summary: "List recent account-security activity", description: "v1.23 — the SHARED security-activity feed: the caller's recent auth + export + deletion audit events with timestamp, resolved location, and a host-masked IP. `limit` query param caps at 100 (default 50). Reuses the AuditLog store; no event detail bodies are surfaced.", + requestParams: { + query: z.object({ + limit: z.coerce + .number() + .int() + .min(1) + .max(100) + .optional() + .describe("Page size; defaults to 50, clamped to 100."), + }), + }, responses: { "200": { description: "Recent security events for the caller.", diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index 9b8ec81fd..d29e22e65 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -11,11 +11,17 @@ import { createMeasurementSchema, listMeasurementsSchema, measurementTypeEnum, + updateMeasurementSchema, measurementSourceEnum, } from "@/lib/validations/measurement"; import { seriesBatchQuerySchema } from "@/lib/validations/series-batch"; import { deviceTypeEnum } from "@/lib/validations/source-priority"; -import { dataEnvelope, moduleDisabledResponse, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + moduleDisabledResponse, + stdResponses, +} from "./shared"; const batchEntrySchema = z .object({ @@ -196,19 +202,140 @@ const restoreMeasurementsResponse = z export const measurementResource = z .object({ - id: z.string(), + id: z + .string() + .describe( + "Stored row id, or a synthetic key (`day::`, `sleep-seg::`) on the collapsed list modes.", + ), type: measurementTypeEnum, value: z.number(), unit: z.string(), measuredAt: z.iso.datetime({ offset: true }), source: measurementSourceEnum, notes: z.string().nullable().optional(), + // The list / detail reads hand back the whole stored row minus the + // `notesEncrypted` ciphertext, so every remaining scalar column is on the + // wire. Optional rather than required because the collapsed list modes + // (day-sum, sleep-segment) synthesise rows that carry only the display + // fields above. + userId: z.string().optional(), + valueMin: z + .number() + .nullable() + .optional() + .describe( + "Bucket MINIMUM — set only on an hourly heart-rate `stats:` row.", + ), + valueMax: z + .number() + .nullable() + .optional() + .describe( + "Bucket MAXIMUM — set only on an hourly heart-rate `stats:` row.", + ), + externalId: z + .string() + .nullable() + .optional() + .describe("External-system dedup key; null for manual entries."), + externalSourceVersion: z.string().nullable().optional(), + glucoseContext: z + .string() + .nullable() + .optional() + .describe("Set only on BLOOD_GLUCOSE rows."), + sleepStage: z + .string() + .nullable() + .optional() + .describe("Set only on SLEEP_DURATION rows."), + rhythmClassification: z + .string() + .nullable() + .optional() + .describe("Device classification verdict; set only on EVENT rows."), + deviceType: z + .string() + .nullable() + .optional() + .describe("`watch | band | ring | phone | scale | other | unknown`."), + syncVersion: z + .number() + .int() + .optional() + .describe( + "Bumped on every server-side mutation; the last-writer-wins input for cross-device reconciliation.", + ), + deletedAt: z.iso + .datetime({ offset: true }) + .nullable() + .optional() + .describe("Soft-delete tombstone; null on a live row."), + createdAt: z.iso.datetime({ offset: true }).optional(), + updatedAt: z.iso.datetime({ offset: true }).optional(), + // Synthesised-row extras — present only on the collapsed day-sum mode. + dayKey: z.string().optional(), + sampleCount: z.number().int().optional(), + partial: z + .boolean() + .optional() + .describe( + "True on the one bucket whose day was cut off by the read cap, so its sum understates the real total.", + ), }) .meta({ id: "MeasurementResource", - description: "Server-shaped measurement row returned by GET endpoints.", + description: + "Server-shaped measurement row. GET endpoints return the whole stored row with the decrypted note on `notes` and the `notesEncrypted` ciphertext stripped; the collapsed list modes synthesise rows carrying the display fields plus `dayKey` / `sampleCount`.", }); +/** + * The aggregate list modes (`aggregate=daily` and the coarser grains) return + * pre-folded buckets, not stored rows — no `id`, `unit` or `source`. + */ +const aggregatedMeasurementBucket = z + .object({ + type: measurementTypeEnum, + value: z.number().describe("The bucket's aggregated value."), + measuredAt: z.iso.datetime({ offset: true }).describe("Bucket start."), + count: z.number().int().nonnegative().optional(), + unit: z.string().optional(), + }) + .meta({ + id: "AggregatedMeasurementBucket", + description: + "Pre-folded bucket returned by the `aggregate=` list modes instead of a stored row.", + }); + +/** `meta` on every `GET /api/measurements` response mode. */ +const listMeasurementsMeta = z + .object({ + total: z.number().int().nonnegative(), + limit: z.number().int().nonnegative(), + offset: z.number().int().nonnegative(), + dayKey: z + .string() + .optional() + .describe("Echoed on the per-sample drill-down modes."), + groupBy: z + .enum(["day", "night"]) + .optional() + .describe("Set on the collapsed day-sum / per-night modes."), + aggregate: z + .string() + .optional() + .describe("Set on the aggregate modes — the requested grain."), + droppedDuplicates: z + .number() + .int() + .nonnegative() + .optional() + .describe( + "Rows discarded by the cross-source canonical picker on the day-collapse mode.", + ), + }) + .meta({ id: "ListMeasurementsMeta" }); + // ── Sleep night (v1.11.5 hypnogram source) ────────────────────────── const sleepStageEnum = z.enum([ @@ -597,8 +724,10 @@ export const measurementPaths: NonNullable = { "application/json": { schema: dataEnvelope( z.object({ - measurements: z.array(measurementResource), - total: z.number().int().nonnegative(), + measurements: z.array( + z.union([measurementResource, aggregatedMeasurementBucket]), + ), + meta: listMeasurementsMeta, }), "ListMeasurementsResponse", ), @@ -610,25 +739,124 @@ export const measurementPaths: NonNullable = { }, post: { tags: ["Measurements"], - summary: "Create one measurement", + summary: "Create one measurement (or a small array)", description: - "Single ingest. Use `/api/measurements/batch` for Apple Health upload streams.", + "Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams.", requestBody: { required: true, - content: { "application/json": { schema: createMeasurementSchema } }, + content: { + "application/json": { + schema: z.union([ + createMeasurementSchema, + z.array(createMeasurementSchema), + ]), + }, + }, }, responses: { "201": { - description: "Created.", + description: + "Created. A single resource for the object body; an array of resources, in request order, for the array body.", content: { "application/json": { schema: dataEnvelope( - measurementResource, + z.union([measurementResource, z.array(measurementResource)]), "CreateMeasurementResponse", ), }, }, }, + "409": { + description: + "A measurement with this data already exists — the `(userId, type, source, externalId)` dedup index rejected the write.", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + }, + "/api/measurements/{id}": { + get: { + tags: ["Measurements"], + summary: "Measurement detail", + description: + "Single stored row, scoped to the caller. A row owned by another user surfaces as 404 (existence channel sealed).", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Measurement.", + content: { + "application/json": { + schema: dataEnvelope( + measurementResource, + "GetMeasurementResponse", + ), + }, + }, + }, + "404": { + description: "Measurement not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + put: { + tags: ["Measurements"], + summary: "Update a measurement", + description: + "Edits a manually entered row. Rows owned by a connected source are refused with 409 `measurement.update.server_owned_source`; a timestamp collision with an existing row is refused with 409 `measurement.duplicate_timestamp`.", + requestParams: { path: z.object({ id: z.string() }) }, + requestBody: { + required: true, + content: { "application/json": { schema: updateMeasurementSchema } }, + }, + responses: { + "200": { + description: "Updated measurement.", + content: { + "application/json": { + schema: dataEnvelope( + measurementResource, + "UpdateMeasurementResponse", + ), + }, + }, + }, + "404": { + description: "Measurement not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, + "409": { + description: + "Refused: the row comes from a connected source (`meta.errorCode` = `measurement.update.server_owned_source`), or another row already carries this timestamp (`measurement.duplicate_timestamp`).", + content: { "application/json": { schema: errorEnvelope } }, + }, + ...stdResponses, + }, + }, + delete: { + tags: ["Measurements"], + summary: "Delete a measurement", + description: + "Soft-deletes the row (tombstone) so paired clients can reconcile the deletion.", + requestParams: { path: z.object({ id: z.string() }) }, + responses: { + "200": { + description: "Deleted.", + content: { + "application/json": { + schema: dataEnvelope( + z.object({ deleted: z.literal(true) }), + "DeleteMeasurementResponse", + ), + }, + }, + }, + "404": { + description: "Measurement not found (or owned by another user).", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, }, }, diff --git a/src/lib/openapi/routes/medications/schemas.ts b/src/lib/openapi/routes/medications/schemas.ts index ae2775fa3..57df825a7 100644 --- a/src/lib/openapi/routes/medications/schemas.ts +++ b/src/lib/openapi/routes/medications/schemas.ts @@ -177,13 +177,15 @@ export const medicationResource = z nextDueAt: z.iso .datetime({ offset: true }) .nullable() + .optional() .describe( - "v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.", + "Present on the READ paths only — the list and single GET compute it; the create / update responses return the stored row and omit it. v1.7.0 server-computed next due instant across all the medication's schedules (earliest `nextOccurrenceAfter`). Read-only — computed, not stored. NULL when no schedule has an upcoming slot (paused, one-shot in the past, `endsOn` crossed, every schedule PRN). v1.16.4 — when an unresolved slot's anchor has passed but the catch-up band is still open (`anchor < now <= overdueEnd`, current schedule era), this carries THAT slot (a past instant) with `nextDueOverdue: true` instead of jumping to the next future slot. The list GET is cached 60 s, so a 60 s staleness is accepted.", ), nextDueOverdue: z .boolean() + .optional() .describe( - "v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.", + "Present on the READ paths only, alongside `nextDueAt`. v1.16.4 — true when `nextDueAt` is an OPEN overdue slot: its anchor has passed, `now` is still inside the slot's catch-up band, and no taken / skipped / auto-missed row resolves it. False for a regular future next-due (and when `nextDueAt` is null). Read-only — computed, not stored.", ), startsOn: z.iso .datetime({ offset: true }) @@ -220,6 +222,10 @@ export const medicationResource = z export const medicationListEntry = medicationResource .extend({ category: medicationCategoryEnum, + // The list read always computes these two, so they are required here + // even though the shared base leaves them optional for the write paths. + nextDueAt: z.iso.datetime({ offset: true }).nullable(), + nextDueOverdue: z.boolean(), lastTakenAt: z.iso .datetime({ offset: true }) .nullable() @@ -235,7 +241,6 @@ export const medicationListEntry = medicationResource ), stockUnitsRemaining: z .number() - .int() .nullable() .describe( "v1.16.10 — usable inventory units left across the medication's containers (sum of `unitsRemaining` over ACTIVE / IN_USE items with units left). NULL = inventory tracking off (no items ever registered); 0 = tracking on, supply ran out. Read-only — aggregated, not stored.", @@ -362,7 +367,7 @@ export const medicationIntakeEventResource = z scheduledFor: z.iso.datetime({ offset: true }), takenAt: z.iso.datetime({ offset: true }).nullable(), skipped: z.boolean(), - source: z.enum(["WEB", "API", "REMINDER", "IMPORT"]), + source: z.enum(["WEB", "API", "REMINDER", "IMPORT", "APPLE_HEALTH"]), idempotencyKey: z.string().nullable(), createdAt: z.iso.datetime({ offset: true }), }) @@ -388,15 +393,39 @@ export const medicationCadenceTimelinePoint = z export const medicationCadenceChips = z .object({ - adherenceRate: z.number(), - streakDays: z.number().int().nonnegative(), - expectedSlots: z.number().int().nonnegative(), - actualDoses: z.number().int().nonnegative(), + adherenceRate: z + .number() + .nullable() + .describe( + "0-100, taken / (taken + missed). Skipped doses are excluded from the denominator — a deliberate decision, not a compliance failure. NULL when no dose was expected in the window (brand-new medication, paused).", + ), + currentStreak: z + .number() + .int() + .nonnegative() + .describe( + "Consecutive days ending at `asOf` where every expected dose was taken or skipped. Days with no expected dose advance the streak; missed days break it.", + ), + longestStreak: z + .number() + .int() + .nonnegative() + .describe("Longest all-taken-or-skipped run anywhere in the window."), + missedLast30: z + .number() + .int() + .nonnegative() + .describe("Count of missed doses inside the window."), + windowDays: z + .number() + .int() + .nonnegative() + .describe("Window size used — mirrors the input for the chart legend."), }) .meta({ id: "MedicationCadenceChips", description: - "Four compliance summary values for the medication detail page chip row.", + "Compliance summary values for the medication detail page chip row.", }); export const medicationCadenceResponse = z diff --git a/src/lib/openapi/routes/workouts.ts b/src/lib/openapi/routes/workouts.ts index abf96cde6..a07818fd7 100644 --- a/src/lib/openapi/routes/workouts.ts +++ b/src/lib/openapi/routes/workouts.ts @@ -9,7 +9,12 @@ import { z } from "zod/v4"; import type { ZodOpenApiObject } from "zod-openapi"; import { measurementSourceEnum } from "@/lib/validations/measurement"; import { createBatchWorkoutSchema } from "@/lib/validations/workout"; -import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; +import { + dataEnvelope, + errorEnvelope, + moduleDisabledResponse, + stdResponses, +} from "./shared"; // v1.4.25 W16b — typed workout batch ingest response envelope. Mirrors // the measurements batch shape but reports the `workouts` count rather @@ -205,6 +210,7 @@ export const workoutPaths: NonNullable = { }, }, }, + ...moduleDisabledResponse, ...stdResponses, }, }, @@ -234,6 +240,7 @@ export const workoutPaths: NonNullable = { description: "Workout not found (or owned by another user).", content: { "application/json": { schema: errorEnvelope } }, }, + ...moduleDisabledResponse, ...stdResponses, }, }, From 1c2cfffdb8e78c6388dd2f9c79e26904cf4a68f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 11:52:34 +0200 Subject: [PATCH 7/8] fix(consent): a withdrawn AI consent stays withdrawn, and the web can withdraw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The settings mount fires an idempotent consent heal so an account that predates the gate does not fall into the no-key state. It looked only for an active receipt, so a deliberate withdrawal — which sets revokedAt — read the same as never having consented, and the next visit to the AI settings granted again, writing an audit row for an act the user never performed. GDPR Art. 7 (3) puts withdrawal on the same footing as the grant; one undone by a page load is not a withdrawal. The mint now distinguishes the two callers. The mount heal grants only to an account with no consent history at all and leaves a revocation standing; switching document auto-read on is the consent act itself and may supersede an earlier revocation. The revocation is re-checked inside the transaction so a withdrawal landing mid-mint is not overwritten. The revoke endpoint has existed since v1.4.40 but only the native client called it: on the web, consent could be given and never taken back. The AI settings now carry the standing decision and the control that changes it, in all six languages. --- docs/api/openapi.yaml | 2 +- messages/de.json | 10 ++ messages/en.json | 10 ++ messages/es.json | 10 ++ messages/fr.json | 10 ++ messages/it.json | 10 ++ messages/pl.json | 10 ++ package.json | 2 +- public/sw.js | 2 +- .../auth/me/documents-auto-ai-read/route.ts | 2 +- .../consent/ai/web/__tests__/route.test.ts | 5 +- src/app/api/consent/ai/web/route.ts | 2 +- .../ai/__tests__/ai-consent-card.test.tsx | 84 +++++++++ .../settings/ai/ai-consent-card.tsx | 159 ++++++++++++++++++ .../settings/ai/ai-insights-card.tsx | 6 + src/lib/consent/__tests__/web-grant.test.ts | 93 +++++++++- src/lib/consent/web-grant.ts | 66 +++++++- src/lib/query-keys/settings.ts | 8 + 18 files changed, 473 insertions(+), 18 deletions(-) create mode 100644 src/components/settings/ai/__tests__/ai-consent-card.test.tsx create mode 100644 src/components/settings/ai/ai-consent-card.tsx diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 61dbe9a77..30d1a9028 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.30.31 + version: 1.30.32 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/messages/de.json b/messages/de.json index 4db496e0c..ad9268086 100644 --- a/messages/de.json +++ b/messages/de.json @@ -4999,6 +4999,16 @@ "honesty": "Der gemeinsame Zugang ist ein einzelnes angemeldetes KI-Konto, das der Betreiber für alle auf diesem Server verbunden hat — nicht dein eigener Schlüssel. Anfragen darüber werden vom Anbieter standardmäßig zum Training genutzt und von keiner Auftragsverarbeitungsvereinbarung gedeckt, die Stunden- und Wochenlimits des Kontos teilst du dir mit allen anderen, die zugestimmt haben, und deine eigenen Anbieter werden weiterhin zuerst versucht. Aktiviere es nur, wenn du diesen Kompromiss akzeptierst.", "confirm": "Verstanden — gemeinsame Verbindung nutzen", "cancel": "Abbrechen" + }, + "consent": { + "title": "KI-Einwilligung", + "activeSince": "Erteilt am {date}. Deine Gesundheitsdaten dürfen für die KI-Funktionen an den eingestellten Anbieter gesendet werden.", + "withdrawn": "Widerrufen. Es werden keine Gesundheitsdaten an einen KI-Anbieter gesendet; die KI-Funktionen bleiben aus.", + "withdraw": "Einwilligung widerrufen", + "confirmBody": "Die KI-Funktionen sind sofort abgeschaltet. Bereits erfasste Daten werden nicht gelöscht — dafür gibt es Daten & Privatsphäre.", + "confirmWithdraw": "Widerrufen", + "regrantHint": "Um die KI-Funktionen wieder zu nutzen, schalte das automatische Lesen von Dokumenten ein oder erteile die Einwilligung in der App.", + "error": "Der Widerruf konnte nicht gespeichert werden. Bitte versuche es erneut." } }, "dashboard": { diff --git a/messages/en.json b/messages/en.json index f51db32a0..c78f6c78c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -4999,6 +4999,16 @@ "honesty": "The shared access is a single signed-in AI account the operator connected for everyone on this server — not your own key. Requests you make on it are trained on by the provider by default and no data-processing agreement covers them, the account's hourly and weekly limits are shared with every other user who opted in, and your own configured providers are still tried first. Turn it on only if you accept this trade.", "confirm": "I understand — use the shared connection", "cancel": "Cancel" + }, + "consent": { + "title": "AI consent", + "activeSince": "Given on {date}. Your health data may be sent to the configured provider for the AI features.", + "withdrawn": "Withdrawn. No health data is sent to an AI provider; the AI features stay switched off.", + "withdraw": "Withdraw consent", + "confirmBody": "The AI features stop working straight away. Nothing already recorded is deleted — use Data & privacy to export or erase your record.", + "confirmWithdraw": "Withdraw", + "regrantHint": "To use the AI features again, switch on automatic document reading or grant consent from the app.", + "error": "The withdrawal could not be saved. Please try again." } }, "dashboard": { diff --git a/messages/es.json b/messages/es.json index f389b4aa7..7e77e5ed2 100644 --- a/messages/es.json +++ b/messages/es.json @@ -4999,6 +4999,16 @@ "honesty": "El acceso compartido es una única cuenta de IA con sesión iniciada que el operador conectó para todos en este servidor, no tu propia clave. Las solicitudes que hagas se usan de forma predeterminada para entrenar los modelos del proveedor y ningún acuerdo de tratamiento de datos las cubre, los límites por hora y semanales de la cuenta se comparten con todos los demás que se han unido, y tus propios proveedores se prueban primero. Actívalo solo si aceptas este compromiso.", "confirm": "Entendido: usar la conexión compartida", "cancel": "Cancelar" + }, + "consent": { + "title": "Consentimiento de IA", + "activeSince": "Otorgado el {date}. Tus datos de salud pueden enviarse al proveedor configurado para las funciones de IA.", + "withdrawn": "Retirado. No se envían datos de salud a ningún proveedor de IA; las funciones de IA permanecen desactivadas.", + "withdraw": "Retirar el consentimiento", + "confirmBody": "Las funciones de IA dejan de funcionar de inmediato. No se borra nada de lo ya registrado: usa Datos y privacidad para exportar o eliminar tu historial.", + "confirmWithdraw": "Retirar", + "regrantHint": "Para volver a usar las funciones de IA, activa la lectura automática de documentos u otorga el consentimiento desde la aplicación.", + "error": "No se pudo guardar la retirada. Inténtalo de nuevo." } }, "dashboard": { diff --git a/messages/fr.json b/messages/fr.json index 3fefa494d..a390a4c5f 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -4999,6 +4999,16 @@ "honesty": "L'accès partagé est un unique compte IA connecté que l'opérateur a relié pour tout le monde sur ce serveur, et non votre propre clé. Les requêtes que vous y faites servent par défaut à entraîner les modèles du fournisseur et aucun accord de traitement des données ne les couvre, les limites horaires et hebdomadaires du compte sont partagées avec tous les autres qui ont accepté, et vos propres fournisseurs sont essayés en premier. Ne l'activez que si vous acceptez ce compromis.", "confirm": "J'ai compris — utiliser la connexion partagée", "cancel": "Annuler" + }, + "consent": { + "title": "Consentement IA", + "activeSince": "Donné le {date}. Tes données de santé peuvent être transmises au fournisseur configuré pour les fonctions IA.", + "withdrawn": "Retiré. Aucune donnée de santé n'est transmise à un fournisseur IA ; les fonctions IA restent désactivées.", + "withdraw": "Retirer le consentement", + "confirmBody": "Les fonctions IA s'arrêtent immédiatement. Rien de ce qui est déjà enregistré n'est supprimé — utilise Données et confidentialité pour exporter ou effacer ton dossier.", + "confirmWithdraw": "Retirer", + "regrantHint": "Pour réutiliser les fonctions IA, active la lecture automatique des documents ou donne ton consentement depuis l'application.", + "error": "Le retrait n'a pas pu être enregistré. Réessaie." } }, "dashboard": { diff --git a/messages/it.json b/messages/it.json index d384c10fd..827c43c5f 100644 --- a/messages/it.json +++ b/messages/it.json @@ -4999,6 +4999,16 @@ "honesty": "L'accesso condiviso è un unico account IA con accesso eseguito che l'operatore ha collegato per tutti su questo server, non la tua chiave. Le richieste che vi effettui vengono usate per impostazione predefinita per addestrare i modelli del provider e nessun accordo sul trattamento dei dati le copre, i limiti orari e settimanali dell'account sono condivisi con tutti gli altri che hanno aderito, e i tuoi provider vengono comunque provati per primi. Attivalo solo se accetti questo compromesso.", "confirm": "Ho capito — usa la connessione condivisa", "cancel": "Annulla" + }, + "consent": { + "title": "Consenso IA", + "activeSince": "Concesso il {date}. I tuoi dati sanitari possono essere inviati al provider configurato per le funzioni IA.", + "withdrawn": "Revocato. Nessun dato sanitario viene inviato a un provider IA; le funzioni IA restano disattivate.", + "withdraw": "Revoca il consenso", + "confirmBody": "Le funzioni IA si fermano subito. Nulla di quanto già registrato viene eliminato — usa Dati e privacy per esportare o cancellare il tuo archivio.", + "confirmWithdraw": "Revoca", + "regrantHint": "Per usare di nuovo le funzioni IA, attiva la lettura automatica dei documenti o concedi il consenso dall'app.", + "error": "Non è stato possibile salvare la revoca. Riprova." } }, "dashboard": { diff --git a/messages/pl.json b/messages/pl.json index d8ddc0f3d..a43e94b7f 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -4999,6 +4999,16 @@ "honesty": "Wspólny dostęp to jedno zalogowane konto AI, które operator podłączył dla wszystkich na tym serwerze — nie Twój własny klucz. Zapytania, które przez nie wysyłasz, są domyślnie wykorzystywane do trenowania modeli dostawcy i nie obejmuje ich żadna umowa powierzenia przetwarzania danych, godzinowe i tygodniowe limity konta dzielisz z wszystkimi innymi, którzy się zgodzili, a Twoi właśni dostawcy są próbowani jako pierwsi. Włącz to tylko, jeśli akceptujesz ten kompromis.", "confirm": "Rozumiem — użyj wspólnego połączenia", "cancel": "Anuluj" + }, + "consent": { + "title": "Zgoda na AI", + "activeSince": "Udzielona {date}. Twoje dane zdrowotne mogą być wysyłane do skonfigurowanego dostawcy na potrzeby funkcji AI.", + "withdrawn": "Wycofana. Żadne dane zdrowotne nie są wysyłane do dostawcy AI; funkcje AI pozostają wyłączone.", + "withdraw": "Wycofaj zgodę", + "confirmBody": "Funkcje AI przestają działać natychmiast. Nic z już zapisanych danych nie zostaje usunięte — użyj sekcji Dane i prywatność, aby wyeksportować lub usunąć swoją historię.", + "confirmWithdraw": "Wycofaj", + "regrantHint": "Aby ponownie korzystać z funkcji AI, włącz automatyczne czytanie dokumentów lub udziel zgody w aplikacji.", + "error": "Nie udało się zapisać wycofania. Spróbuj ponownie." } }, "dashboard": { diff --git a/package.json b/package.json index 31ea6793e..b1653770f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.30.31", + "version": "1.30.32", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 69ce584f4..d35934c1b 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.30.31"; + /* @sw-version-fallback */ "v1.30.32"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/api/auth/me/documents-auto-ai-read/route.ts b/src/app/api/auth/me/documents-auto-ai-read/route.ts index 4bd8f1a62..03eb31123 100644 --- a/src/app/api/auth/me/documents-auto-ai-read/route.ts +++ b/src/app/api/auth/me/documents-auto-ai-read/route.ts @@ -107,7 +107,7 @@ export const PATCH = apiHandler(async (req: Request) => { // `ai_full` receipt (idempotent) so the durable audit trail records it, in // addition to the runtime short-circuit the document consent gate reads. if (next) { - await ensureWebAiConsentReceipt(user.id); + await ensureWebAiConsentReceipt(user.id, "affirmative"); } await auditLog("user.documentsAutoAiRead.update", { diff --git a/src/app/api/consent/ai/web/__tests__/route.test.ts b/src/app/api/consent/ai/web/__tests__/route.test.ts index 08a3050b2..4915ca8d4 100644 --- a/src/app/api/consent/ai/web/__tests__/route.test.ts +++ b/src/app/api/consent/ai/web/__tests__/route.test.ts @@ -90,7 +90,10 @@ describe("POST /api/consent/ai/web", () => { it("is a no-op when an active receipt already exists (minted:false)", async () => { vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); - vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({ minted: false }); + vi.mocked(ensureWebAiConsentReceipt).mockResolvedValue({ + minted: false, + reason: "already_active", + }); const res = await POST(); expect(res.status).toBe(200); diff --git a/src/app/api/consent/ai/web/route.ts b/src/app/api/consent/ai/web/route.ts index 1e1e14af3..4a86bacf9 100644 --- a/src/app/api/consent/ai/web/route.ts +++ b/src/app/api/consent/ai/web/route.ts @@ -37,7 +37,7 @@ export const POST = apiHandler(async () => { }); } - const result = await ensureWebAiConsentReceipt(user.id); + const result = await ensureWebAiConsentReceipt(user.id, "heal"); if (result.minted) { // Audit trail parity with the iOS-driven POST /api/consent/ai grant so diff --git a/src/components/settings/ai/__tests__/ai-consent-card.test.tsx b/src/components/settings/ai/__tests__/ai-consent-card.test.tsx new file mode 100644 index 000000000..01a622c6f --- /dev/null +++ b/src/components/settings/ai/__tests__/ai-consent-card.test.tsx @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +import { I18nProvider } from "@/lib/i18n/context"; +import { AiConsentCard } from "../ai-consent-card"; + +/** + * The web withdrawal affordance. Before this card the revoke endpoint was + * reachable only from the native client, so a web-only account could give + * AI consent and never take it back. + * + * These render the card against a pre-seeded query cache (server-side + * markup, so the mutation path is not exercised here — the endpoint it + * calls has its own route tests). What they pin is that the card reports + * the standing decision correctly and offers the withdrawal only when + * there is something to withdraw. + */ + +const receiptKey = ["consent", "ai", "latest", "ai_full"]; + +function renderWith(receipt: unknown) { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(receiptKey, receipt); + return renderToStaticMarkup( + + + + + , + ); +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("AiConsentCard", () => { + it("offers the withdrawal while consent stands", () => { + const html = renderWith({ + id: "rcpt-1", + kind: "ai_full", + signedAt: "2026-07-01T08:00:00.000Z", + revokedAt: null, + }); + + expect(html).toContain('data-slot="ai-consent-withdraw"'); + expect(html).toContain("Withdraw consent"); + }); + + it("reports a withdrawal and stops offering it again", () => { + const html = renderWith({ + id: "rcpt-1", + kind: "ai_full", + signedAt: "2026-07-01T08:00:00.000Z", + revokedAt: "2026-07-10T08:00:00.000Z", + }); + + expect(html).toContain("Withdrawn."); + expect(html).not.toContain('data-slot="ai-consent-withdraw"'); + }); + + it("treats no receipt at all as consent not given", () => { + const html = renderWith(null); + + expect(html).toContain("Withdrawn."); + expect(html).not.toContain('data-slot="ai-consent-withdraw"'); + }); + + it("renders nothing for a signed-out visitor", () => { + const client = new QueryClient(); + const html = renderToStaticMarkup( + + + + + , + ); + + expect(html).toBe(""); + }); +}); diff --git a/src/components/settings/ai/ai-consent-card.tsx b/src/components/settings/ai/ai-consent-card.tsx new file mode 100644 index 000000000..7cca9f1e9 --- /dev/null +++ b/src/components/settings/ai/ai-consent-card.tsx @@ -0,0 +1,159 @@ +"use client"; + +/* ──────────────────────────────────────────────────────────────── + * Standing AI consent — the web side of the withdrawal. + * + * The revoke endpoint (`DELETE /api/consent/ai/latest?kind=ai_full`) has + * existed since v1.4.40, but only the native client ever called it: on the + * web, consent could be given and never taken back. GDPR Art. 7 (3) puts + * withdrawal on the same footing as the grant, so it belongs on the same + * surface, not behind a support request. + * + * The card shows the standing decision in plain words and offers the one + * move that changes it. Withdrawing takes effect immediately — the consent + * gate fails closed without an active receipt, so every AI surface falls + * back to its no-consent state on the next call. Nothing already stored is + * deleted by this; that is the export/erase path in Data & Privacy, and the + * copy says so rather than implying more than it does. + * ──────────────────────────────────────────────────────────────── */ + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Loader2, ShieldCheck, ShieldOff } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { apiFetchRaw } from "@/lib/api/api-fetch"; +import { formatDateTime } from "@/lib/format"; +import { useTranslations } from "@/lib/i18n/context"; +import { queryKeys } from "@/lib/query-keys"; + +type ConsentReceiptWire = { + id: string; + kind: string; + signedAt: string; + revokedAt: string | null; +} | null; + +export function AiConsentCard({ + isAuthenticated, +}: { + isAuthenticated: boolean; +}) { + const { t } = useTranslations(); + const queryClient = useQueryClient(); + const [confirming, setConfirming] = useState(false); + + const { data, isLoading } = useQuery({ + queryKey: queryKeys.aiConsentReceipt("ai_full"), + queryFn: async () => { + const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full"); + if (!res.ok) return null; + const json = await res.json(); + return (json.data?.receipt ?? null) as ConsentReceiptWire; + }, + enabled: isAuthenticated, + }); + + const revoke = useMutation({ + mutationKey: queryKeys.aiConsentReceipt("ai_full"), + mutationFn: async () => { + const res = await apiFetchRaw("/api/consent/ai/latest?kind=ai_full", { + method: "DELETE", + }); + if (!res.ok) throw new Error("revoke failed"); + }, + onSuccess: async () => { + setConfirming(false); + // The receipt gates every AI surface, so anything that reads consent + // state has to re-resolve — not just this card. + await queryClient.invalidateQueries({ + queryKey: queryKeys.aiConsentReceipt("ai_full"), + }); + await queryClient.invalidateQueries({ + queryKey: queryKeys.insightsProviderChain(), + }); + }, + }); + + // Say nothing until the state is known: a card that flashes "withdrawn" + // before the receipt arrives would misreport the user's own decision. + if (!isAuthenticated || isLoading) return null; + + // Narrow to the receipt itself rather than a boolean, so the branches + // below can read its fields without an assertion. + const activeReceipt = data && data.revokedAt === null ? data : null; + + return ( +
+
+

+ {activeReceipt ? ( + + ) : ( + + )} + {t("settings.ai.consent.title")} +

+

+ {activeReceipt + ? t("settings.ai.consent.activeSince", { + date: formatDateTime(activeReceipt.signedAt), + }) + : t("settings.ai.consent.withdrawn")} +

+
+ + {activeReceipt ? ( + confirming ? ( +
+

+ {t("settings.ai.consent.confirmBody")} +

+ + +
+ ) : ( + + ) + ) : ( +

+ {t("settings.ai.consent.regrantHint")} +

+ )} + + {revoke.isError ? ( +

+ {t("settings.ai.consent.error")} +

+ ) : null} +
+ ); +} diff --git a/src/components/settings/ai/ai-insights-card.tsx b/src/components/settings/ai/ai-insights-card.tsx index ff7efb9b6..47bcc65c5 100644 --- a/src/components/settings/ai/ai-insights-card.tsx +++ b/src/components/settings/ai/ai-insights-card.tsx @@ -30,6 +30,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { queryKeys } from "@/lib/query-keys"; import { AdminOpenAIProviderForm } from "./admin-openai-provider-form"; +import { AiConsentCard } from "./ai-consent-card"; import { AnthropicProviderForm } from "./anthropic-provider-form"; import { AutoReadCard } from "./auto-read-card"; import { CentralCodexSwitch } from "./central-codex-switch"; @@ -186,6 +187,11 @@ export function AiInsightsCard({ renders when the operator has connected it). */} + {/* The standing consent and the one control that withdraws it. It + sits last because it governs everything above rather than + configuring any single provider. */} + + { vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never); const now = new Date("2026-06-14T10:00:00.000Z"); - const result = await ensureWebAiConsentReceipt("user-1", now); + const result = await ensureWebAiConsentReceipt("user-1", "heal", now); expect(result.minted).toBe(true); // Reads the active master grant first (fast-path + in-tx re-check both @@ -82,7 +82,7 @@ describe("ensureWebAiConsentReceipt", () => { row() as never, ); - const result = await ensureWebAiConsentReceipt("user-1"); + const result = await ensureWebAiConsentReceipt("user-1", "heal"); expect(result.minted).toBe(false); expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); @@ -98,7 +98,7 @@ describe("ensureWebAiConsentReceipt", () => { .mockResolvedValueOnce(null) // fast path .mockResolvedValueOnce(row() as never); // in-tx re-check - const result = await ensureWebAiConsentReceipt("user-1"); + const result = await ensureWebAiConsentReceipt("user-1", "heal"); expect(result.minted).toBe(false); expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); @@ -113,7 +113,7 @@ describe("ensureWebAiConsentReceipt", () => { code: "P2002", } as never); - const result = await ensureWebAiConsentReceipt("user-1"); + const result = await ensureWebAiConsentReceipt("user-1", "heal"); expect(result.minted).toBe(false); }); @@ -124,7 +124,7 @@ describe("ensureWebAiConsentReceipt", () => { new Error("connection reset") as never, ); - await expect(ensureWebAiConsentReceipt("user-1")).rejects.toThrow( + await expect(ensureWebAiConsentReceipt("user-1", "heal")).rejects.toThrow( "connection reset", ); }); @@ -157,8 +157,8 @@ describe("ensureWebAiConsentReceipt — two concurrent grants", () => { }); const [a, b] = await Promise.all([ - ensureWebAiConsentReceipt("user-1"), - ensureWebAiConsentReceipt("user-1"), + ensureWebAiConsentReceipt("user-1", "heal"), + ensureWebAiConsentReceipt("user-1", "heal"), ]); // Exactly one mint, exactly one row inserted. @@ -167,3 +167,82 @@ describe("ensureWebAiConsentReceipt — two concurrent grants", () => { expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1); }); }); + +describe("ensureWebAiConsentReceipt — a revocation is a standing decision", () => { + // The mount heal and the revocation check share one `findFirst` mock, so + // route by predicate: `revokedAt: null` is the active-grant lookup, + // `revokedAt: { not: null }` is the revocation-history lookup. + function arrange({ hasRevoked }: { hasRevoked: boolean }) { + vi.mocked(prisma.consentReceipt.findFirst).mockImplementation( + (async (args: { where: { revokedAt?: unknown } }) => { + const wantsRevoked = + args.where.revokedAt !== null && args.where.revokedAt !== undefined; + if (wantsRevoked) + return hasRevoked + ? (row({ + revokedAt: new Date("2026-07-01T09:00:00.000Z"), + }) as never) + : null; + return null; + }) as never, + ); + vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never); + } + + it("does not re-grant on the settings mount after the user revoked", async () => { + arrange({ hasRevoked: true }); + + const result = await ensureWebAiConsentReceipt("user-1", "heal"); + + expect(result).toEqual({ minted: false, reason: "previously_revoked" }); + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + // No transaction is opened — the decision is settled on the read. + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("still heals an account that never had a receipt at all", async () => { + arrange({ hasRevoked: false }); + + const result = await ensureWebAiConsentReceipt("user-1", "heal"); + + expect(result.minted).toBe(true); + expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1); + }); + + it("lets the user grant again through an affirmative act", async () => { + arrange({ hasRevoked: true }); + + const result = await ensureWebAiConsentReceipt("user-1", "affirmative"); + + expect(result.minted).toBe(true); + expect(prisma.consentReceipt.create).toHaveBeenCalledTimes(1); + }); + + it("holds the line when the revocation lands mid-transaction", async () => { + // The fast-path reads see a clean history; the revocation appears only + // once the transaction is open. Without the in-transaction re-check the + // mint would overwrite a decision made a moment earlier. + let inTransaction = false; + $transaction.mockImplementation((fn: TxFn) => { + inTransaction = true; + return fn({ consentReceipt: prisma.consentReceipt }); + }); + vi.mocked(prisma.consentReceipt.findFirst).mockImplementation( + (async (args: { where: { revokedAt?: unknown } }) => { + const wantsRevoked = + args.where.revokedAt !== null && args.where.revokedAt !== undefined; + if (wantsRevoked && inTransaction) + return row({ + revokedAt: new Date("2026-07-01T09:00:00.000Z"), + }) as never; + return null; + }) as never, + ); + vi.mocked(prisma.consentReceipt.create).mockResolvedValue(row() as never); + + const result = await ensureWebAiConsentReceipt("user-1", "heal"); + + expect(result).toEqual({ minted: false, reason: "previously_revoked" }); + expect(prisma.consentReceipt.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/consent/web-grant.ts b/src/lib/consent/web-grant.ts index b9d5f864d..2a122207d 100644 --- a/src/lib/consent/web-grant.ts +++ b/src/lib/consent/web-grant.ts @@ -16,6 +16,17 @@ * shell-mount heal). Explicit revocation flows through the existing * `DELETE /api/consent/ai/latest`; this helper only ever grants. * + * It heals an account that never gave consent — never one that took it + * back. "No active receipt" has two very different causes: an account that + * predates the gate and has no receipt at all, and an account whose owner + * deliberately revoked. Both look identical to a `revokedAt: null` lookup, + * and healing the second one re-grants a consent the user withdrew, writing + * a `consent.ai.grant` audit row for an action they never took. GDPR Art. 7 + * (3) requires withdrawal to be as easy as granting; a withdrawal undone by + * the next page visit is not a withdrawal. So the mint is gated on the + * account having no revoked `ai_full` receipt in its history: a prior + * revocation is a standing decision, and only an explicit grant may lift it. + * * The artefact is a small JSON record of the web grant context (no signed * PDF/JWT — the web grant is an in-app affirmative action recorded with its * timestamp + source, which is the GDPR Art. 7 audit signal the legal team @@ -27,9 +38,30 @@ import { isP2002 } from "@/lib/prisma-errors"; import { latestActiveReceipt } from "@/lib/consent/receipts"; import type { ConsentReceipt } from "@/lib/consent/receipts"; -/** Outcome of an idempotent web-grant call. */ +/** + * Outcome of an idempotent web-grant call. `minted: false` carries a reason + * so callers can tell "already granted" apart from "withheld because the + * user revoked" — the two are the same non-event to the UI, but only the + * second one is worth an audit annotation. + */ export type WebConsentGrantResult = - { minted: true; receipt: ConsentReceipt } | { minted: false }; + | { minted: true; receipt: ConsentReceipt } + | { minted: false; reason: "already_active" | "previously_revoked" }; + +/** + * How the call came about. The distinction decides whether a past revocation + * blocks the mint, so it is a required argument rather than a default — + * a caller must say which one it is. + * + * `heal` — fired by the AI-settings mount, not by a user gesture. + * Grants only to an account that has no consent history at + * all. A revocation stands. + * `affirmative`— the user just performed the opt-in itself (switching the + * document auto-read on). This IS the consent act, so it + * supersedes an earlier revocation, exactly as the first + * grant would. + */ +export type WebConsentGrantIntent = "heal" | "affirmative"; /** * Ensure the web user has an active `ai_full` consent receipt, minting one @@ -42,13 +74,25 @@ export type WebConsentGrantResult = */ export async function ensureWebAiConsentReceipt( userId: string, + intent: WebConsentGrantIntent, now: Date = new Date(), ): Promise { // Fast path: a cheap read outside the transaction short-circuits the // common already-granted case (every mount after the first) without // opening a transaction. const existing = await latestActiveReceipt(userId, "ai_full"); - if (existing) return { minted: false }; + if (existing) return { minted: false, reason: "already_active" }; + + // A revoked receipt anywhere in this account's history means the user made + // a decision. Healing past it would re-grant what they withdrew. An + // affirmative act is the user deciding again, so it may proceed. + if (intent === "heal") { + const revoked = await prisma.consentReceipt.findFirst({ + where: { userId, kind: "ai_full", revokedAt: { not: null } }, + select: { id: true }, + }); + if (revoked) return { minted: false, reason: "previously_revoked" }; + } const artefact = JSON.stringify({ source: "web", @@ -71,7 +115,19 @@ export async function ensureWebAiConsentReceipt( where: { userId, kind: "ai_full", revokedAt: null }, orderBy: { createdAt: "desc" }, }); - if (inTx) return { minted: false } as const; + if (inTx) return { minted: false, reason: "already_active" } as const; + + // Re-check the revocation inside the transaction too: a revoke can + // land between the fast-path read and here, and without this the + // mint would overwrite a decision made a moment earlier. + if (intent === "heal") { + const revokedInTx = await tx.consentReceipt.findFirst({ + where: { userId, kind: "ai_full", revokedAt: { not: null } }, + select: { id: true }, + }); + if (revokedInTx) + return { minted: false, reason: "previously_revoked" } as const; + } const receipt = await tx.consentReceipt.create({ data: { userId, kind: "ai_full", artefact, signedAt: now }, @@ -82,7 +138,7 @@ export async function ensureWebAiConsentReceipt( ); } catch (err) { // Partial-unique violation — a concurrent grant landed first. - if (isP2002(err)) return { minted: false }; + if (isP2002(err)) return { minted: false, reason: "already_active" }; throw err; } } diff --git a/src/lib/query-keys/settings.ts b/src/lib/query-keys/settings.ts index 4261a02a2..91d44dc20 100644 --- a/src/lib/query-keys/settings.ts +++ b/src/lib/query-keys/settings.ts @@ -47,4 +47,12 @@ export const settingsKeys = { shareLinks: () => ["share-links"] as const, featureFlags: () => ["feature-flags"] as const, + + /** + * The account's standing AI consent receipt (`GET /api/consent/ai/latest`). + * Keyed by kind because the endpoint answers per kind and a shared key + * across kinds would serve one kind's receipt for another. + */ + aiConsentReceipt: (kind: string) => + ["consent", "ai", "latest", kind] as const, }; From 2ab21bb433ba79dcb563371c082d5e87a14ab87c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 19 Jul 2026 11:59:28 +0200 Subject: [PATCH 8/8] =?UTF-8?q?chore(release):=20v1.30.32=20=E2=80=94=20co?= =?UTF-8?q?nsent=20that=20stays=20withdrawn;=20quotes=20that=20come=20from?= =?UTF-8?q?=20the=20document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 42 +++++++++++++++++++ .../__tests__/route.test.ts | 11 ++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4113fc20..57ee097ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,48 @@ ## [Unreleased] +## [1.30.32] — 2026-07-19 + +- **A withdrawn AI consent stays withdrawn.** Opening the AI settings + granted consent again if you had taken it back, and recorded it as + though you had done it yourself. A withdrawal is now a standing + decision that only an explicit act can lift. +- **The web can withdraw AI consent at all.** Until now only the app + could; on the web, consent could be given and never taken back. The AI + settings show what currently stands and offer the control that changes + it. Withdrawing takes effect immediately and deletes nothing you have + recorded. +- **Documents get read after you switch automatic reading on.** Reading + was only ever started at upload, so documents already in your vault + stayed unread no matter what the setting said. Turning it on now works + through what is already there, in batches, asking the same permission + as any other read. +- **A quoted passage in a document review is the document's own text.** + The quote shown for a finding was the wording that came back from the + read, not the line it was taken from — so it could be a paraphrase of + your own record presented as a verbatim quote. The passage is now + looked up in the document itself, and a finding whose source cannot be + located says so instead of showing a quote you cannot check. +- **Pages that arrive with your data pre-loaded now say so on the wire.** + They were never cacheable in practice, but nothing stated it. Anything + behind the sign-in wall is now marked private and uncacheable at the + edge, so no proxy or future change can hold one person's record and + hand it to someone else. +- **Deleting your last reading of a kind no longer leaves stale weekly, + monthly and yearly figures.** The daily figure went; the longer spans + kept the old aggregate indefinitely. +- **The documented API matches what the endpoints return.** Twenty + corrections, including a total that the measurements list reports in a + different place than the specification claimed, four medication figures + named wrongly, a missing intake source, and eleven error codes that + were never written down. +- **A retry after an unusable AI answer asks for the right shape.** The + correction prompt demanded fields that do not exist and named none of + the ones actually required, so the one corrective attempt steered away + from the contract instead of towards it. + +No migrations. No breaking changes. + ## [1.30.31] — 2026-07-19 - **Today shows a dose before it is late.** The card only surfaced a diff --git a/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts b/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts index 68d2a90f6..0fa555dd3 100644 --- a/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts +++ b/src/app/api/auth/me/documents-auto-ai-read/__tests__/route.test.ts @@ -107,12 +107,19 @@ describe("PATCH /api/auth/me/documents-auto-ai-read — catch-up scheduling", () }); it("still mints the consent receipt on the flip that schedules the pass", async () => { - // The catch-up rides the same act of consent, never around it. + // The catch-up rides the same act of consent, never around it. The mint + // is marked `affirmative` because switching the toggle on IS the consent + // act: unlike the settings-mount heal, it may lift an earlier + // withdrawal, which is the user deciding again rather than the page + // deciding for them. withPrevious(false); await PATCH(mkPatch(true)); - expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith("user-1"); + expect(ensureWebAiConsentReceipt).toHaveBeenCalledWith( + "user-1", + "affirmative", + ); }); it("persists the flag field-by-field", async () => {