Merge pull request #556 from MBombeck/release/v1.30.32

release/v1.30.32 — consent that stays withdrawn; quotes that come from the document
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-19 12:10:26 +02:00
commit b0021a8258
45 files changed
+3708 -257

No files matched your search

+42
View File
@@ -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
+864 -164
View File
File diff suppressed because it is too large. Load diff
+10
View File
@@ -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": {
+10
View File
@@ -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": {
+10
View File
@@ -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": {
+10
View File
@@ -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": {
+10
View File
@@ -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": {
+10
View File
@@ -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": {
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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/*`
@@ -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 = <n>` 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([]);
});
});
+112
View File
@@ -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/<token>", () => {
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");
});
});
@@ -0,0 +1,135 @@
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. 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",
"affirmative",
);
});
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 },
});
});
});
@@ -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({
@@ -107,7 +111,18 @@ 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");
}
// 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", {
@@ -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);
+1 -1
View File
@@ -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
@@ -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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiConsentCard isAuthenticated />
</I18nProvider>
</QueryClientProvider>,
);
}
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(
<QueryClientProvider client={client}>
<I18nProvider initialLocale="en">
<AiConsentCard isAuthenticated={false} />
</I18nProvider>
</QueryClientProvider>,
);
expect(html).toBe("");
});
});
@@ -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 (
<section className="space-y-3" data-slot="ai-consent">
<div className="space-y-1">
<p className="flex items-center gap-2 text-sm font-medium">
{activeReceipt ? (
<ShieldCheck className="size-4" aria-hidden />
) : (
<ShieldOff className="text-muted-foreground size-4" aria-hidden />
)}
{t("settings.ai.consent.title")}
</p>
<p className="text-muted-foreground text-xs">
{activeReceipt
? t("settings.ai.consent.activeSince", {
date: formatDateTime(activeReceipt.signedAt),
})
: t("settings.ai.consent.withdrawn")}
</p>
</div>
{activeReceipt ? (
confirming ? (
<div className="flex flex-wrap items-center gap-2">
<p className="text-muted-foreground w-full text-xs">
{t("settings.ai.consent.confirmBody")}
</p>
<Button
type="button"
variant="destructive"
size="sm"
data-slot="ai-consent-withdraw-confirm"
onClick={() => revoke.mutate()}
disabled={revoke.isPending}
>
{revoke.isPending ? (
<Loader2 className="size-4 animate-spin motion-reduce:animate-none" />
) : null}
{t("settings.ai.consent.confirmWithdraw")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setConfirming(false)}
disabled={revoke.isPending}
>
{t("common.cancel")}
</Button>
</div>
) : (
<Button
type="button"
variant="outline"
size="sm"
data-slot="ai-consent-withdraw"
onClick={() => setConfirming(true)}
>
{t("settings.ai.consent.withdraw")}
</Button>
)
) : (
<p className="text-muted-foreground text-xs">
{t("settings.ai.consent.regrantHint")}
</p>
)}
{revoke.isError ? (
<p className="text-destructive text-xs" role="alert">
{t("settings.ai.consent.error")}
</p>
) : null}
</section>
);
}
@@ -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). */}
<CentralCodexSwitch settings={insightsSettings} />
{/* The standing consent and the one control that withdraws it. It
sits last because it governs everything above rather than
configuring any single provider. */}
<AiConsentCard isAuthenticated={isAuthenticated} />
<RuntimeActionsRow
provider={selectedProvider}
userProvider={userProvider}
@@ -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));
});
});
+65 -20
View File
@@ -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)}
`;
}
+86 -7
View File
@@ -56,7 +56,7 @@ describe("ensureWebAiConsentReceipt", () => {
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();
});
});
+61 -5
View File
@@ -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<WebConsentGrantResult> {
// 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;
}
}
@@ -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);
});
});
@@ -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,
},
},
],
});
@@ -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(
+130
View File
@@ -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,
};
}
+22 -1
View File
@@ -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")
+21 -2
View File
@@ -159,9 +159,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<FactProvenance>;
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,
};
}
/**
@@ -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<typeof getGlobalBoss>);
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<typeof getGlobalBoss>,
);
await expect(enqueueSummaryCatchUp("user-1")).resolves.toEqual({
enqueued: false,
});
vi.mocked(getGlobalBoss).mockReturnValue({
send: vi.fn().mockRejectedValue(new Error("down")),
} as unknown as ReturnType<typeof getGlobalBoss>);
await expect(enqueueSummaryCatchUp("user-1")).resolves.toEqual({
enqueued: false,
});
});
});
+164
View File
@@ -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<SummaryCatchUpSummary> {
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 };
}
}
@@ -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<SummaryCatchUpPayload>(
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
+4
View File
@@ -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,
}),
@@ -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<string, { schema?: unknown }> } | 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");
});
});
@@ -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<string, unknown> | 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",
);
});
});
+73 -5
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
"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<ZodOpenApiObject["paths"]> = {
},
},
},
"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<ZodOpenApiObject["paths"]> = {
},
},
},
"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<ZodOpenApiObject["paths"]> = {
},
},
},
"400": {
description: "Security key verification failed (bad attestation).",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -545,6 +568,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
},
},
},
"404": {
description: "No such security key for this account.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -569,6 +597,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
},
},
},
"404": {
description: "No such security key for this account.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
},
},
@@ -609,6 +642,11 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"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<ZodOpenApiObject["paths"]> = {
},
},
},
"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<ZodOpenApiObject["paths"]> = {
},
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<ZodOpenApiObject["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).",
requestParams: { path: z.object({ id: z.string() }) },
responses: {
"200": {
description: "Session revoked.",
@@ -803,6 +859,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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.",
+2
View File
@@ -97,6 +97,8 @@ documentBulkSchema.meta({
const factProvenance = z.object({
sourceText: z.string(),
anchored: z.boolean(),
sourceOffset: z.number().nullable(),
page: z.number().nullable(),
confidence: z.number(),
});
+238 -10
View File
@@ -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:<type>:<dayKey>`, `sleep-seg:<dayKey>:<n>`) 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<ZodOpenApiObject["paths"]> = {
"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<ZodOpenApiObject["paths"]> = {
},
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,
},
},
+38 -9
View File
@@ -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
+8 -1
View File
@@ -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<ZodOpenApiObject["paths"]> = {
},
},
},
...moduleDisabledResponse,
...stdResponses,
},
},
@@ -234,6 +240,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description: "Workout not found (or owned by another user).",
content: { "application/json": { schema: errorEnvelope } },
},
...moduleDisabledResponse,
...stdResponses,
},
},
+8
View File
@@ -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,
};
@@ -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: <returned types> }` 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);
+101 -24
View File
@@ -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<number> {
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<number> {
// 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
+15 -1
View File
@@ -195,8 +195,22 @@ export type InboundExtraction = z.infer<typeof inboundExtractionSchema>;
/** 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). */
+26 -1
View File
@@ -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");