From 668f85a58dcdf5bfbf75e866525ce9443c426e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:36:46 +0200 Subject: [PATCH 001/125] test(01-04): specify exact Nightscout origin policy - Parse canonical server-owned private origins - Reject malformed and non-exact trust grants - Preserve public and approved-private positive contracts --- .../validations/__tests__/nightscout.test.ts | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 src/lib/validations/__tests__/nightscout.test.ts diff --git a/src/lib/validations/__tests__/nightscout.test.ts b/src/lib/validations/__tests__/nightscout.test.ts new file mode 100644 index 000000000..6cc5127b8 --- /dev/null +++ b/src/lib/validations/__tests__/nightscout.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; + +import * as nightscoutValidation from "../nightscout"; + +type NightscoutOriginVerdict = { + allowed: boolean; + canonicalOrigin: string | null; + privateOriginApproved: boolean; + reasonCode: "invalid_origin" | "private_origin_not_approved" | null; +}; + +type NightscoutPolicyModule = typeof nightscoutValidation & { + parseNightscoutPrivateOrigins(raw: string | undefined): ReadonlySet; + evaluateNightscoutOrigin( + value: string, + privateOrigins: ReadonlySet, + ): NightscoutOriginVerdict; +}; + +const { parseNightscoutPrivateOrigins, evaluateNightscoutOrigin } = + nightscoutValidation as NightscoutPolicyModule; + +describe("NIGHTSCOUT_PRIVATE_ORIGINS", () => { + it("parses a comma-separated server setting into canonical exact origins", () => { + expect( + parseNightscoutPrivateOrigins( + " http://10.0.0.4:1337, HTTPS://CGM.LAN:443 ,https://[fd00::4]:8443 ", + ), + ).toEqual( + new Set([ + "http://10.0.0.4:1337", + "https://cgm.lan", + "https://[fd00::4]:8443", + ]), + ); + }); + + it("authorizes no private origin when the operator setting is empty", () => { + expect(parseNightscoutPrivateOrigins(undefined)).toEqual(new Set()); + expect(parseNightscoutPrivateOrigins(" , ")).toEqual(new Set()); + + expect( + evaluateNightscoutOrigin( + "http://10.0.0.4:1337", + parseNightscoutPrivateOrigins(""), + ), + ).toEqual({ + allowed: false, + canonicalOrigin: "http://10.0.0.4:1337", + privateOriginApproved: false, + reasonCode: "private_origin_not_approved", + }); + }); + + it.each([ + ["credentials", "http://operator:secret@10.0.0.4:1337"], + ["non-root path", "http://10.0.0.4:1337/api/v1"], + ["query", "http://10.0.0.4:1337?token=secret"], + ["fragment", "http://10.0.0.4:1337#admin"], + ["wrong scheme", "ftp://10.0.0.4:1337"], + ["wildcard", "https://*.cgm.lan"], + ["suffix", ".cgm.lan"], + ["malformed", "not an origin"], + ])("rejects a %s operator trust entry", (_label, value) => { + expect(() => parseNightscoutPrivateOrigins(value)).toThrow(); + }); +}); + +describe("Nightscout exact-origin policy", () => { + const privateOrigins = new Set([ + "http://10.0.0.4:1337", + "https://cgm.lan:8443", + ]); + + it("keeps an ordinary canonical public origin functional", () => { + expect( + evaluateNightscoutOrigin("https://NS.EXAMPLE.COM:443", privateOrigins), + ).toEqual({ + allowed: true, + canonicalOrigin: "https://ns.example.com", + privateOriginApproved: false, + reasonCode: null, + }); + }); + + it("allows only the exact operator-approved private scheme, host, and port", () => { + expect( + evaluateNightscoutOrigin("http://10.0.0.4:1337", privateOrigins), + ).toEqual({ + allowed: true, + canonicalOrigin: "http://10.0.0.4:1337", + privateOriginApproved: true, + reasonCode: null, + }); + + for (const value of [ + "https://10.0.0.4:1337", + "http://10.0.0.4:8080", + "http://10.0.0.40:1337", + "https://sub.cgm.lan:8443", + ]) { + expect(evaluateNightscoutOrigin(value, privateOrigins)).toMatchObject({ + allowed: false, + privateOriginApproved: false, + reasonCode: "private_origin_not_approved", + }); + } + }); + + it.each([ + ["credentials", "http://operator:secret@10.0.0.4:1337"], + ["path", "http://10.0.0.4:1337/api/v1"], + ["query", "http://10.0.0.4:1337?token=secret"], + ["fragment", "http://10.0.0.4:1337#admin"], + ])("rejects a %s even when its bare origin is approved", (_label, value) => { + expect(evaluateNightscoutOrigin(value, privateOrigins)).toEqual({ + allowed: false, + canonicalOrigin: null, + privateOriginApproved: false, + reasonCode: "invalid_origin", + }); + }); +}); From 3a813b51bdde48ccba80065aad30bbdc6e36d26f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:37:20 +0200 Subject: [PATCH 002/125] test(01-02): add red PWA cache isolation contracts - exercise signed-in service-worker install at CacheStorage boundary - require data-free offline navigation and immutable public assets - require current and legacy session cache cleanup --- src/__tests__/service-worker.test.ts | 57 ++++++++++++++++++- src/__tests__/sw-security-hardening.test.ts | 11 ++++ src/lib/pwa/__tests__/query-persister.test.ts | 46 +++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/src/__tests__/service-worker.test.ts b/src/__tests__/service-worker.test.ts index 7c2a56db0..ec749ab7b 100644 --- a/src/__tests__/service-worker.test.ts +++ b/src/__tests__/service-worker.test.ts @@ -45,6 +45,8 @@ class FakeCache { putDelay: Promise | undefined; onPut: (() => void) | undefined; keysError: Error | undefined; + addAllResponse: + ((request: Request) => Promise | Response) | undefined; private keyOf(request: RequestInfo | URL): string { if (typeof request === "string") return new URL(request, ORIGIN).href; @@ -65,7 +67,13 @@ class FakeCache { async addAll(urls: string[]): Promise { for (const url of urls) { - this.map.set(this.keyOf(url), new Response(`precached:${url}`)); + const request = new Request(new URL(url, ORIGIN), { + credentials: "same-origin", + }); + const response = this.addAllResponse + ? await this.addAllResponse(request) + : new Response(`precached:${url}`); + this.map.set(this.keyOf(request), response); } } @@ -199,6 +207,16 @@ async function dispatchActivate(harness: SwHarness): Promise { await settled; } +async function dispatchInstall(harness: SwHarness): Promise { + let settled: Promise = Promise.resolve(); + harness.listeners.get("install")!({ + waitUntil: (p: Promise) => { + settled = p; + }, + }); + await settled; +} + function dispatchNavigationFetch( harness: SwHarness, path: string, @@ -474,6 +492,43 @@ describe("sw.js — activate", () => { }); }); +describe("sw.js — authenticated install cache isolation", () => { + it("never requests or stores signed-in root HTML while retaining public immutable assets", async () => { + const harness = bootServiceWorker(); + const statics = await harness.cacheStorage.open(CURRENT_STATIC_CACHE); + const requested: Request[] = []; + const healthSentinel = "PRIVATE_DASHBOARD_WEIGHT_81_7_KG"; + statics.addAllResponse = async (request) => { + requested.push(request); + return new Response( + new URL(request.url).pathname === "/" + ? `${healthSentinel}` + : `public-asset:${new URL(request.url).pathname}`, + ); + }; + + await dispatchInstall(harness); + + expect + .soft(requested.map((request) => new URL(request.url).pathname)) + .not.toContain("/"); + expect + .soft(requested.every((request) => request.credentials === "same-origin")) + .toBe(true); + const cachedBodies = await Promise.all( + [...statics.map.values()].map((response) => response.clone().text()), + ); + expect.soft(cachedBodies.join("\n")).not.toContain(healthSentinel); + expect(await statics.match(`${ORIGIN}/logo-192.png`)).toBeDefined(); + + const offline = await dispatchNavigationFetch(harness, "/"); + const offlineBody = await offline.text(); + expect.soft(offline.status).toBe(503); + expect.soft(offlineBody).toContain("Offline"); + expect(offlineBody).not.toContain(healthSentinel); + }); +}); + describe("sw.js — networkFirst offline fallback", () => { it("never serves a shell cached under a previous CACHE_VERSION", async () => { const harness = bootServiceWorker(); diff --git a/src/__tests__/sw-security-hardening.test.ts b/src/__tests__/sw-security-hardening.test.ts index 4814e6f76..a587f28ce 100644 --- a/src/__tests__/sw-security-hardening.test.ts +++ b/src/__tests__/sw-security-hardening.test.ts @@ -79,6 +79,17 @@ describe("public/sw.js offline fallback locale neutrality (F-7, 2026-05-16)", () }); }); +describe("public/sw.js authenticated install isolation (SEC-02)", () => { + it("precaches public immutable assets but never the authenticated root route", () => { + const sw = readSw(); + const precache = /const PRECACHE_URLS = \[([^\]]+)\]/.exec(sw)?.[1]; + + expect(precache).toBeDefined(); + expect.soft(precache).not.toMatch(/["']\/["']/); + expect(precache).toMatch(/\/(?:logo|icons)\/?[^"']*\.(?:png|svg)/); + }); +}); + describe("public/manifest.json PWA identity hygiene (F-7, 2026-05-16)", () => { it("declares an explicit scope, id, and display_override", () => { const raw = readFileSync(MANIFEST_PATH, "utf-8"); diff --git a/src/lib/pwa/__tests__/query-persister.test.ts b/src/lib/pwa/__tests__/query-persister.test.ts index b0400670b..e8451186e 100644 --- a/src/lib/pwa/__tests__/query-persister.test.ts +++ b/src/lib/pwa/__tests__/query-persister.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient } from "@tanstack/react-query"; import { + clearOfflineCachesForSessionEnd, clearPersistedQueryCache, isPersistableKey, restorePersistedQueryCache, @@ -86,6 +87,51 @@ describe("persister degrades gracefully without IndexedDB", () => { }); }); +describe("session-end CacheStorage cleanup", () => { + const originalCaches = (globalThis as { caches?: CacheStorage }).caches; + + afterEach(() => { + if (originalCaches === undefined) { + delete (globalThis as { caches?: CacheStorage }).caches; + } else { + (globalThis as { caches?: CacheStorage }).caches = originalCaches; + } + }); + + it("deletes current and legacy HealthLog static/page/data caches only", async () => { + const cacheNames = [ + "healthlog-static-v1.34.0", + "healthlog-static-v1.15.20", + "healthlog-pages-v1.34.0", + "healthlog-pages-v1.15.20", + "healthlog-data-v1.34.0", + "healthlog-data-v1.15.20", + "unrelated-app-static-v7", + "workbox-precache-healthlog", + ]; + const deleted: string[] = []; + (globalThis as { caches?: CacheStorage }).caches = { + keys: async () => cacheNames, + delete: async (name: string) => { + deleted.push(name); + return true; + }, + } as CacheStorage; + + await clearOfflineCachesForSessionEnd(); + + expect + .soft(deleted.sort()) + .toEqual( + cacheNames + .filter((name) => /^healthlog-(?:static|pages|data)-/.test(name)) + .sort(), + ); + expect(deleted).not.toContain("unrelated-app-static-v7"); + expect(deleted).not.toContain("workbox-precache-healthlog"); + }); +}); + describe("persister round-trips through a fake IndexedDB", () => { const original = (globalThis as { indexedDB?: unknown }).indexedDB; From fb533147d3584fd12a7217eebacbf5016a208547 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:38:11 +0200 Subject: [PATCH 003/125] test(01-02): add red IPv6 transition classifier matrix - cover mapped compatible 6to4 and NAT64 private embeddings - exercise both literal URL and raw DNS address verdicts - preserve public IPv4 native IPv6 and transition forms --- .../__tests__/notifications.test.ts | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/lib/validations/__tests__/notifications.test.ts b/src/lib/validations/__tests__/notifications.test.ts index 1f9393a42..e559887d4 100644 --- a/src/lib/validations/__tests__/notifications.test.ts +++ b/src/lib/validations/__tests__/notifications.test.ts @@ -1,5 +1,36 @@ import { describe, expect, it } from "vitest"; -import { isPublicUrl, webPushSubscriptionSchema } from "../notifications"; +import { + isPublicIp, + isPublicUrl, + webPushSubscriptionSchema, +} from "../notifications"; + +const EMBEDDED_PRIVATE_IPV4_CASES = [ + ["IPv4-mapped mixed loopback", "::ffff:127.0.0.1"], + ["IPv4-mapped compressed RFC1918", "::ffff:a00:1"], + ["IPv4-compatible mixed loopback", "::127.0.0.1"], + ["IPv4-compatible compressed metadata", "::a9fe:a9fe"], + ["6to4 compressed RFC1918", "2002:a00:1::"], + ["6to4 expanded metadata", "2002:a9fe:a9fe:0:0:0:0:1"], + ["NAT64 WKP mixed RFC1918", "64:ff9b::192.168.1.1"], + ["NAT64 WKP compressed RFC1918", "64:ff9b::c0a8:101"], + ["RFC8215 local-use compressed RFC1918", "64:ff9b:1:ac10:0:1::"], + ["RFC8215 local-use expanded RFC1918", "64:ff9b:1:c0a8:0:101:0:0"], +] as const; + +const PUBLIC_ADDRESS_CASES = [ + ["public IPv4", "8.8.8.8"], + ["public native IPv6", "2606:4700:4700::1111"], + ["public IPv4-mapped IPv6", "::ffff:8.8.8.8"], + ["public IPv4-compatible IPv6", "::808:808"], + ["public 6to4 IPv6", "2002:808:808::"], + ["public NAT64 WKP IPv6", "64:ff9b::808:808"], + ["public RFC8215 local-use IPv6", "64:ff9b:1:808:0:808::"], +] as const; + +function literalUrl(ip: string): string { + return ip.includes(":") ? `https://[${ip}]/push` : `https://${ip}/push`; +} describe("isPublicUrl SSRF guard", () => { describe("allows public addresses", () => { @@ -148,6 +179,39 @@ describe("isPublicUrl SSRF guard", () => { }); }); +describe("canonical IPv4-embedded IPv6 transition matrix (SEC-03)", () => { + it.each(EMBEDDED_PRIVATE_IPV4_CASES)( + "rejects %s at both literal URL and raw-IP boundaries", + (_label, ip) => { + expect.soft(isPublicIp(ip)).toBe(false); + expect(isPublicUrl(literalUrl(ip))).toBe(false); + }, + ); + + it.each(PUBLIC_ADDRESS_CASES)( + "preserves %s at both literal URL and raw-IP boundaries", + (_label, ip) => { + expect.soft(isPublicIp(ip)).toBe(true); + expect(isPublicUrl(literalUrl(ip))).toBe(true); + }, + ); + + it.each(["not-an-ip", "2001:::1", "12345::1"])( + "denies invalid raw IP syntax: %s", + (ip) => { + expect(isPublicIp(ip)).toBe(false); + }, + ); + + it.each(["::", "::1", "fe80::1", "fd12:3456::1"])( + "continues denying ordinary IPv6 special-use address %s", + (ip) => { + expect.soft(isPublicIp(ip)).toBe(false); + expect(isPublicUrl(literalUrl(ip))).toBe(false); + }, + ); +}); + // V3 audit: webPushSubscriptionSchema previously accepted any URL, // allowing an authenticated user to point Push delivery at a private // network (RFC1918 / link-local / loopback) → blind SSRF probe. From 6c0f47acad18e93c367ee0f208aedcdbca5925d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:38:41 +0200 Subject: [PATCH 004/125] test(01-01): pin MCP read scope boundaries - cover health read and wildcard positive capability paths - reject unrelated scopes before server registration and database reads --- src/app/mcp/__tests__/route.test.ts | 63 +++++++++- src/lib/mcp/__tests__/auth.test.ts | 41 +++++-- src/lib/mcp/__tests__/scopes.test.ts | 22 ++++ .../bearer-scope-enforcement.test.ts | 111 ++++++++++++++++++ 4 files changed, 229 insertions(+), 8 deletions(-) diff --git a/src/app/mcp/__tests__/route.test.ts b/src/app/mcp/__tests__/route.test.ts index 738817f6a..d3659601d 100644 --- a/src/app/mcp/__tests__/route.test.ts +++ b/src/app/mcp/__tests__/route.test.ts @@ -21,6 +21,11 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; +const { createMcpServerSpy, executeCoachTool } = vi.hoisted(() => ({ + createMcpServerSpy: vi.fn(), + executeCoachTool: vi.fn(async () => ({ present: false, reason: "no_data" })), +})); + // The surface fails closed without a pinned origin (M1); pin it for the suite. process.env.APP_URL = "http://localhost"; @@ -43,10 +48,21 @@ vi.mock("@/lib/rate-limit", () => ({ // The factory pulls the Coach retrieval layer through `tools.ts`; stub the // heavy executor + inventory so importing the route never reaches a DB. The // list/reject paths never execute a tool, so the stubs are never called. -vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool: vi.fn() })); +vi.mock("@/lib/ai/coach/tools/executor", () => ({ executeCoachTool })); vi.mock("@/lib/ai/coach/tools/inventory", () => ({ buildCoachDataInventory: vi.fn(), })); +vi.mock("@/lib/mcp/server", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + createMcpServer: (...args: Parameters) => { + createMcpServerSpy(...args); + return actual.createMcpServer(...args); + }, + }; +}); // A cookie path must NEVER be reached from the MCP wire. Mock both cookie // sources so the test can assert they are never consulted. const getSession = vi.fn(); @@ -123,6 +139,12 @@ function postRpc(body: unknown, headers: Record = {}): Request { } const TOOLS_LIST = { jsonrpc: "2.0", id: 1, method: "tools/list" }; +const GET_LABS = { + jsonrpc: "2.0", + id: 2, + method: "tools/call", + params: { name: "get_labs", arguments: {} }, +}; beforeEach(() => { vi.clearAllMocks(); @@ -136,6 +158,7 @@ beforeEach(() => { remaining: 119, resetAt: Date.now() + 60_000, }); + executeCoachTool.mockResolvedValue({ present: false, reason: "no_data" }); }); describe("/mcp — happy path", () => { @@ -177,6 +200,22 @@ describe("/mcp — happy path", () => { // Tools + resources are advertised; no admin capability exists. expect(body.result.capabilities.tools).toBeDefined(); }); + + it.each([ + ["health:read", ["health:read"]], + ["wildcard", ["*"]], + ])("lets a %s token reach a real health reader", async (_label, scopes) => { + validToken(scopes); + + const res = await POST(postRpc(GET_LABS)); + + expect(res.status).toBe(200); + expect(executeCoachTool).toHaveBeenCalledWith({ + userId: "user-1", + name: "get_labs", + rawArguments: "{}", + }); + }); }); describe("/mcp — Origin gate (DNS-rebinding defense, spec PR #1439)", () => { @@ -269,7 +308,29 @@ describe("/mcp — authentication", () => { expect(JSON.stringify(body)).not.toMatch(/revoked|expired|hlk_/); // A rejected token never reaches the module gate or the transport. expect(isModuleEnabled).not.toHaveBeenCalled(); + expect(createMcpServerSpy).not.toHaveBeenCalled(); }); + + it.each([ + ["medication ingest", ["medication:ingest"]], + ["notifications", ["notifications:send"]], + ["FHIR", ["fhir:read"]], + ["write-only", ["health:write"]], + ["unrelated", ["profile:read"]], + ])( + "denies a valid %s token before server registration or health reads", + async (_label, scopes) => { + validToken(scopes); + + const res = await POST(postRpc(GET_LABS)); + + expect([401, 403]).toContain(res.status); + expect(createMcpServerSpy).not.toHaveBeenCalled(); + expect(executeCoachTool).not.toHaveBeenCalled(); + expect(checkMcpRateLimit).not.toHaveBeenCalled(); + expect(isModuleEnabled).not.toHaveBeenCalled(); + }, + ); }); describe("/mcp — module gate (off by default)", () => { diff --git a/src/lib/mcp/__tests__/auth.test.ts b/src/lib/mcp/__tests__/auth.test.ts index a1164f24f..6088dfacd 100644 --- a/src/lib/mcp/__tests__/auth.test.ts +++ b/src/lib/mcp/__tests__/auth.test.ts @@ -14,7 +14,7 @@ beforeEach(() => { }); describe("resolveMcpAuthContext", () => { - it("binds the session to : (REQ-SEC-11) via the canonical Bearer path", async () => { + it("binds a health:read session to : via the canonical Bearer path", async () => { vi.mocked(resolveBearerToken).mockResolvedValue({ user: FAKE_USER, tokenId: "token-9", @@ -24,11 +24,6 @@ describe("resolveMcpAuthContext", () => { const ctx = await resolveMcpAuthContext(" hlk_abc "); - // Trimmed token resolved through the shared validator (no requiredPermission for reads). - // `any-valid-token` is the one deliberate fail-open posture in the tree: - // `/mcp` authenticates here and authorises downstream (audience binding + - // `tokenAllowsWrite`). Asserted explicitly so a silent switch to another - // posture — in either direction — shows up as a failing test. expect(resolveBearerToken).toHaveBeenCalledWith("hlk_abc", { kind: "any-valid-token", }); @@ -39,7 +34,19 @@ describe("resolveMcpAuthContext", () => { expect(ctx.canWrite).toBe(false); }); - it("grants write capability only when the token carries health:write", async () => { + it("admits wildcard read capability and preserves wildcard write capability", async () => { + vi.mocked(resolveBearerToken).mockResolvedValue({ + user: FAKE_USER, + tokenId: "token-wild", + permissions: ["*"], + expiresAt: new Date(), + }); + const ctx = await resolveMcpAuthContext("hlk_wild"); + expect(ctx.canRead).toBe(true); + expect(ctx.canWrite).toBe(true); + }); + + it("does not infer read capability from health:write", async () => { vi.mocked(resolveBearerToken).mockResolvedValue({ user: FAKE_USER, tokenId: "token-w", @@ -47,9 +54,29 @@ describe("resolveMcpAuthContext", () => { expiresAt: new Date(), }); const ctx = await resolveMcpAuthContext("hlk_xyz"); + expect(ctx.canRead).toBe(false); expect(ctx.canWrite).toBe(true); }); + it.each([ + ["medication ingest", ["medication:ingest"]], + ["notification delivery", ["notifications:send"]], + ["FHIR read", ["fhir:read"]], + ["unrelated", ["profile:read"]], + ["empty", []], + ])("does not grant MCP reads to a valid %s token", async (_label, scopes) => { + vi.mocked(resolveBearerToken).mockResolvedValue({ + user: FAKE_USER, + tokenId: "token-narrow", + permissions: scopes, + expiresAt: new Date(), + }); + + const ctx = await resolveMcpAuthContext("hlk_narrow"); + + expect(ctx.canRead).toBe(false); + }); + it("rejects an empty token without calling the validator", async () => { await expect(resolveMcpAuthContext(" ")).rejects.toThrow(); expect(resolveBearerToken).not.toHaveBeenCalled(); diff --git a/src/lib/mcp/__tests__/scopes.test.ts b/src/lib/mcp/__tests__/scopes.test.ts index 0a10753c0..8e53d2791 100644 --- a/src/lib/mcp/__tests__/scopes.test.ts +++ b/src/lib/mcp/__tests__/scopes.test.ts @@ -1,12 +1,34 @@ import { describe, it, expect } from "vitest"; import { + tokenAllowsRead, tokenAllowsWrite, SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE, SCOPE_WILDCARD, } from "../scopes"; +describe("tokenAllowsRead", () => { + it.each([ + ["health:read", [SCOPE_HEALTH_READ]], + ["wildcard", [SCOPE_WILDCARD]], + ["read plus write", [SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE]], + ])("admits an explicit %s grant", (_label, permissions) => { + expect(tokenAllowsRead(permissions)).toBe(true); + }); + + it.each([ + ["write-only", [SCOPE_HEALTH_WRITE]], + ["medication ingest", ["medication:ingest"]], + ["notifications", ["notifications:send"]], + ["FHIR", ["fhir:read"]], + ["unrelated", ["profile:read"]], + ["empty", []], + ])("refuses a %s scope set", (_label, permissions) => { + expect(tokenAllowsRead(permissions)).toBe(false); + }); +}); + describe("tokenAllowsWrite", () => { it("is false for a read-only token", () => { expect(tokenAllowsWrite([SCOPE_HEALTH_READ])).toBe(false); diff --git a/tests/integration/bearer-scope-enforcement.test.ts b/tests/integration/bearer-scope-enforcement.test.ts index 2b158e1e2..251701285 100644 --- a/tests/integration/bearer-scope-enforcement.test.ts +++ b/tests/integration/bearer-scope-enforcement.test.ts @@ -27,6 +27,8 @@ process.env.API_TOKEN_HMAC_KEY ??= const { hashToken } = await import("@/lib/auth/hmac"); const USER_ID = "user-bearer-scope-test"; +const MCP_RESOURCE = "https://health.example/mcp"; +process.env.APP_URL = "https://health.example"; vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); @@ -63,6 +65,7 @@ beforeEach(async () => { username: "bearer-scope", email: "bearer-scope@example.test", timezone: "UTC", + modulePreferencesJson: { mcp: true }, }, }); }); @@ -82,6 +85,40 @@ async function useToken(permissions: string[], label = "t"): Promise { return raw; } +async function useExpiringToken( + permissions: string[], + label: string, + state: { expiresAt?: Date; revoked?: boolean } = {}, +): Promise { + const raw = await useToken(permissions, label); + await getPrismaClient().apiToken.update({ + where: { tokenHash: hashToken(raw) }, + data: state, + }); + return raw; +} + +function mcpReq( + rawToken: string, + method: "initialize" | "tools/list" | "tools/call", + params?: Record, +): Request { + return new Request(MCP_RESOURCE, { + method: "POST", + headers: { + authorization: `Bearer ${rawToken}`, + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method, + ...(params ? { params } : {}), + }), + }); +} + /** Arm a real cookie session instead of a Bearer token. */ async function useCookieSession(): Promise { const session = await getPrismaClient().session.create({ @@ -274,6 +311,80 @@ describe("B5 — an MCP token is audience-bound to /mcp", () => { }); }); +describe("B5b — MCP health reads require an explicit read grant", () => { + async function postMcp( + rawToken: string, + method: "initialize" | "tools/list" | "tools/call", + params?: Record, + ): Promise { + const { POST } = await import("@/app/mcp/route"); + return POST(mcpReq(rawToken, method, params)); + } + + it.each([ + ["health:read", ["health:read"]], + ["wildcard", ["*"]], + ])("admits %s through capability registration", async (_label, scopes) => { + const raw = await useToken(scopes, `mcp-positive-${_label}`); + + const res = await postMcp(raw, "tools/list"); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + result: { tools: Array<{ name: string }> }; + }; + expect(body.result.tools.map((tool) => tool.name)).toContain("get_labs"); + }); + + it.each([ + ["medication ingest", ["medication:ingest"]], + ["notifications", ["notifications:send"]], + ["FHIR", ["fhir:read"]], + ["write-only", ["health:write"]], + ["unrelated", ["profile:read"]], + ])( + "denies a same-user %s token before a real lab read", + async (_label, scopes) => { + const raw = await useToken(scopes, `mcp-negative-${_label}`); + await getPrismaClient().labResult.create({ + data: { + userId: USER_ID, + analyte: "LDL", + value: 177, + unit: "mg/dL", + takenAt: new Date("2026-07-20T08:00:00.000Z"), + }, + }); + + const res = await postMcp(raw, "tools/call", { + name: "get_labs", + arguments: {}, + }); + + expect([401, 403]).toContain(res.status); + expect(await res.text()).not.toContain("177"); + }, + ); + + it.each([ + [ + "expired", + { expiresAt: new Date("2020-01-01T00:00:00.000Z") }, + ], + ["revoked", { revoked: true }], + ])("denies an explicit but %s health:read credential", async (_label, state) => { + const raw = await useExpiringToken( + ["health:read"], + `mcp-${_label}`, + state, + ); + + const res = await postMcp(raw, "tools/list"); + + expect([401, 403]).toContain(res.status); + }); +}); + describe("B6 — a declared scope grants only what it names", () => { it("admits ['fhir:read'] on the FHIR face", async () => { await useToken(["fhir:read"], "fhir"); From 9f6cd86c0dc9f74d9aa0a6393ada6f3e434f028f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:38:53 +0200 Subject: [PATCH 005/125] test(01-03): add passkey enrollment security contracts - require fresh existing-factor proof on both cookie-only legs - bind registration challenges to user, purpose, session, expiry and one use - cover bearer, ambient-cookie, stale, foreign and replay attacks --- .../api/auth/passkeys/__tests__/route.test.ts | 31 + tests/integration/passkey-register.test.ts | 599 +++++++++++++----- 2 files changed, 457 insertions(+), 173 deletions(-) diff --git a/src/app/api/auth/passkeys/__tests__/route.test.ts b/src/app/api/auth/passkeys/__tests__/route.test.ts index 5f99d63ae..71f4af251 100644 --- a/src/app/api/auth/passkeys/__tests__/route.test.ts +++ b/src/app/api/auth/passkeys/__tests__/route.test.ts @@ -11,6 +11,8 @@ * The write half (the stamp on a verified assertion) is pinned in * `src/lib/auth/__tests__/passkey.test.ts`. */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/api-handler", () => ({ @@ -80,3 +82,32 @@ describe("GET /api/auth/passkeys", () => { expect(body.data[0].lastUsedAt).toBeNull(); }); }); + +describe("primary-passkey enrollment route guards", () => { + const optionsSource = readFileSync( + resolve(__dirname, "../../passkey/register-options/route.ts"), + "utf8", + ); + const verifySource = readFileSync( + resolve(__dirname, "../../passkey/register-verify/route.ts"), + "utf8", + ); + + it("keeps both registration legs cookie-only", () => { + for (const source of [optionsSource, verifySource]) { + expect(source).toContain("requireCookieAuth"); + expect(source).not.toMatch(/\brequireAuth\b/); + expect(source).not.toContain("requireMfaManagementAuth"); + expect(source).not.toContain("requireBearerAuth"); + } + }); + + it("requires existing-factor proof before options and repeats freshness on verify", () => { + expect(optionsSource).toMatch( + /password|totp|webauthn|passkey-registration/, + ); + expect(optionsSource).toMatch(/session\.id/); + expect(verifySource).toMatch(/session\.id/); + expect(verifySource).toMatch(/fresh|reauth|proof/i); + }); +}); diff --git a/tests/integration/passkey-register.test.ts b/tests/integration/passkey-register.test.ts index eb7b85282..8f4618a4b 100644 --- a/tests/integration/passkey-register.test.ts +++ b/tests/integration/passkey-register.test.ts @@ -1,29 +1,30 @@ /** - * v1.4.35 — integration coverage for the passkey registration ceremony. + * SEC-05 / SEC-08 — primary-passkey enrollment security boundary. * - * F-2 in the test coverage audit flagged - * `/api/auth/passkey/register-options` and `register-verify` as having - * zero route tests. The ceremony is purely server-side — a regression - * in the challenge-store roundtrip ships silently. This file pins: + * These are deliberately RED contracts for v1.34.1. A registration ceremony + * is not authorized by possession of an ambient cookie or any Bearer token. + * The caller must re-prove an existing account factor in the same cookie + * session, and the resulting WebAuthn challenge must remain bound to that + * user, purpose, session, expiry, and one redemption. * - * - register-options issues a challenge, persists it in - * `auth_challenges`, and returns the RP-ID derived from APP_URL - * - register-verify with a valid (mocked) attestation persists a - * Passkey row and consumes the challenge - * - register-verify with a tampered attestation responds 400 and - * does NOT persist a Passkey - * - * `@simplewebauthn/server` runs cryptographic verification we can't - * fake against real WebAuthn output in a unit-test setting; the helper - * module that wraps it (`src/lib/auth/passkey.ts`) is mocked so the - * test exercises the route handler + DB write path end-to-end. + * WebAuthn cryptography is mocked because a test process cannot manufacture a + * hardware attestation. Authentication policy, challenge persistence, route + * handling, and Passkey insertion all run against the real PostgreSQL + * Testcontainer. */ import { NextRequest } from "next/server"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as OTPAuth from "otpauth"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { cookieJar, headerJar } from "./mock-next-headers"; import { getPrismaClient, truncateAllTables } from "./setup"; +process.env.API_TOKEN_HMAC_KEY ??= + "test-hmac-key-passkey-enrollment-32-bytes-minimum-123456789"; + +const { hashToken } = await import("@/lib/auth/hmac"); +const { hashPassword } = await import("@/lib/auth/password"); + vi.mock("next/headers", async () => { const { cookieJar, headerJar } = await import("./mock-next-headers"); return { @@ -35,12 +36,8 @@ vi.mock("next/headers", async () => { const value = cookieJar.get(name); return value ? { name, value } : undefined; }, - set: (name: string, value: string) => { - cookieJar.set(name, value); - }, - delete: (name: string) => { - cookieJar.delete(name); - }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), })), }; }); @@ -49,10 +46,6 @@ vi.mock("@/lib/db-compat", () => ({ ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), })); -// `verifyRegistration` runs the upstream attestation verification that -// requires real WebAuthn credentials. Stub the helper so the test owns -// the success / failure branch shape; the route handler's -// challenge-lookup + Passkey insertion still runs against real Postgres. vi.mock("@/lib/auth/passkey", async () => { const actual = await vi.importActual( @@ -60,190 +53,450 @@ vi.mock("@/lib/auth/passkey", async () => { ); return { ...actual, + verifyAuthentication: vi.fn(), verifyRegistration: vi.fn(), }; }); -const TEST_USER_ID = "user-passkey-register"; +vi.mock("@/lib/auth/mfa/webauthn", async () => { + const actual = await vi.importActual< + typeof import("@/lib/auth/mfa/webauthn") + >("@/lib/auth/mfa/webauthn"); + return { + ...actual, + verifyMfaAuthentication: vi.fn(), + }; +}); + +const USER_ID = "user-passkey-register"; +const OTHER_USER_ID = "user-passkey-register-other"; +const PASSWORD = "Correct horse battery staple!42"; + +type RouteFn = (request: NextRequest) => Promise; + +function request(path: string, body?: unknown): NextRequest { + const headers: Record = {}; + const authorization = headerJar.get("authorization"); + if (authorization) headers.authorization = authorization; + const init: RequestInit = { method: "POST", headers }; + if (body !== undefined) { + headers["content-type"] = "application/json"; + init.body = JSON.stringify(body); + } + return new NextRequest(`http://localhost${path}`, init); +} + +async function registerOptions(proof?: Record) { + const { POST } = + await import("@/app/api/auth/passkey/register-options/route"); + return (POST as unknown as RouteFn)( + request("/api/auth/passkey/register-options", proof), + ); +} + +async function registerVerify(challengeId: string, credentialId: string) { + const { POST } = await import("@/app/api/auth/passkey/register-verify/route"); + return POST( + request("/api/auth/passkey/register-verify", { + challengeId, + credential: { + id: credentialId, + rawId: credentialId, + type: "public-key", + response: { + clientDataJSON: "client-data", + attestationObject: "attestation", + transports: ["internal"], + }, + clientExtensionResults: {}, + }, + }), + ); +} + +function assertionCredential(id: string) { + return { + id, + rawId: id, + type: "public-key", + response: { + clientDataJSON: "client-data", + authenticatorData: "authenticator-data", + signature: "signature", + }, + clientExtensionResults: {}, + }; +} + +async function currentSessionId(): Promise { + const session = await getPrismaClient().session.findFirstOrThrow({ + where: { userId: USER_ID }, + select: { id: true }, + }); + return session.id; +} + +async function issueOptions(proof: Record) { + const response = await registerOptions(proof); + expect(response.status).toBe(200); + const body = (await response.json()) as { + data: { challengeId: string; options: { challenge: string } }; + }; + return body.data; +} + +async function expectNoCredential(): Promise { + expect(await getPrismaClient().passkey.count()).toBe(0); +} + +async function mintBearer( + permissions: string[], + label: string, +): Promise { + const raw = `hlk_${label}${"0".repeat(64 - label.length)}`; + await getPrismaClient().apiToken.create({ + data: { + userId: USER_ID, + name: label, + tokenHash: hashToken(raw), + permissions, + }, + }); + return raw; +} + +async function seedTotp(): Promise { + const { generateTotpSecret } = await import("@/lib/auth/mfa/totp"); + const { encrypt } = await import("@/lib/crypto"); + const secret = generateTotpSecret(); + await getPrismaClient().user.update({ + where: { id: USER_ID }, + data: { + totpSecretEncrypted: encrypt(secret), + totpConfirmedAt: new Date(), + totpLastStep: null, + }, + }); + return secret; +} + +function currentTotpCode(secret: string): string { + return new OTPAuth.TOTP({ + issuer: "HealthLog", + label: "HealthLog", + algorithm: "SHA1", + digits: 6, + period: 30, + secret: OTPAuth.Secret.fromBase32(secret), + }).generate({ timestamp: Date.now() }); +} beforeEach(async () => { + vi.clearAllMocks(); await truncateAllTables(getPrismaClient()); cookieJar.clear(); headerJar.clear(); - // The RP-ID derives from APP_URL / NEXT_PUBLIC_APP_URL; pin both so - // the assertion below can compare literal hostnames. process.env.APP_URL = "http://localhost:3000"; process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000"; const prisma = getPrismaClient(); - await prisma.user.create({ - data: { - id: TEST_USER_ID, - username: "passkey-user", - email: "passkey@example.test", - role: "USER", - }, + await prisma.user.createMany({ + data: [ + { + id: USER_ID, + username: "passkey-user", + email: "passkey@example.test", + role: "USER", + passwordHash: await hashPassword(PASSWORD), + }, + { + id: OTHER_USER_ID, + username: "passkey-other", + email: "passkey-other@example.test", + role: "USER", + passwordHash: await hashPassword(PASSWORD), + }, + ], }); const session = await prisma.session.create({ data: { - userId: TEST_USER_ID, + userId: USER_ID, expiresAt: new Date(Date.now() + 60 * 60 * 1000), }, }); cookieJar.set("healthlog_session", session.id); -}); -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("Passkey registration (real Postgres)", () => { - it("register-options persists a challenge and returns the RP-ID from APP_URL", async () => { - const { POST } = - await import("@/app/api/auth/passkey/register-options/route"); - // The route handler takes no arguments at the TS level (the wrapper - // ignores the request) — cast to a no-arg callable for clarity. - const res = await (POST as unknown as () => Promise)(); - - expect(res.status).toBe(200); - const body = (await res.json()) as { - data: { - options: { - challenge: string; - rp: { name: string; id: string }; - }; - challengeId: string; - }; - }; - - expect(body.data.challengeId).toBeTruthy(); - expect(body.data.options.challenge).toBeTruthy(); - expect(body.data.options.rp.id).toBe("localhost"); - expect(body.data.options.rp.name).toBe("HealthLog"); - - // The challenge row landed in `auth_challenges` and is tied to the - // calling user with type=registration and a future expiry. - const prisma = getPrismaClient(); - const stored = await prisma.authChallenge.findUnique({ - where: { id: body.data.challengeId }, - }); - expect(stored).not.toBeNull(); - expect(stored?.userId).toBe(TEST_USER_ID); - expect(stored?.type).toBe("registration"); - expect(stored!.expiresAt.getTime()).toBeGreaterThan(Date.now()); - }); - - it("register-verify persists a Passkey row on a verified attestation", async () => { - const { verifyRegistration } = await import("@/lib/auth/passkey"); - const credentialIdRaw = "valid-credential-id"; - vi.mocked(verifyRegistration).mockResolvedValue({ - verified: true, - registrationInfo: { - credential: { - id: credentialIdRaw, - publicKey: new Uint8Array([1, 2, 3, 4, 5]), - counter: 0, - }, - credentialDeviceType: "singleDevice", - credentialBackedUp: false, - }, - } as never); - - const prisma = getPrismaClient(); - // Seed a challenge row so the verify path can resolve `challengeId`. - const challenge = await prisma.authChallenge.create({ - data: { - userId: TEST_USER_ID, - challenge: "fake-server-challenge", - type: "registration", - expiresAt: new Date(Date.now() + 5 * 60 * 1000), - }, - }); - - const { POST } = - await import("@/app/api/auth/passkey/register-verify/route"); - const res = await POST( - new NextRequest("http://localhost/api/auth/passkey/register-verify", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - challengeId: challenge.id, + const { verifyAuthentication, verifyRegistration } = + await import("@/lib/auth/passkey"); + vi.mocked(verifyAuthentication).mockResolvedValue({ + verification: { verified: true }, + passkey: { userId: USER_ID }, + } as never); + vi.mocked(verifyRegistration).mockImplementation( + async (_challengeId, credential) => { + const id = (credential as { id: string }).id; + return { + verified: true, + registrationInfo: { credential: { - id: credentialIdRaw, - response: { transports: ["internal"] }, + id, + publicKey: new Uint8Array([1, 2, 3, 4]), + counter: 0, }, - }), - }), - ); + credentialDeviceType: "multiDevice", + credentialBackedUp: true, + }, + } as never; + }, + ); + const { verifyMfaAuthentication } = await import("@/lib/auth/mfa/webauthn"); + vi.mocked(verifyMfaAuthentication).mockResolvedValue(true); +}); - expect(res.status).toBe(200); - const body = (await res.json()) as { data: { verified: boolean } }; - expect(body.data.verified).toBe(true); - - // Passkey row exists and is tied to this user. - const passkeys = await prisma.passkey.findMany({ - where: { userId: TEST_USER_ID }, +describe("passkey register-options existing-factor proof (real Postgres)", () => { + it("accepts a fresh password proof and binds the new challenge to the cookie session", async () => { + const data = await issueOptions({ method: "password", password: PASSWORD }); + const challenge = await getPrismaClient().authChallenge.findUniqueOrThrow({ + where: { id: data.challengeId }, }); - expect(passkeys).toHaveLength(1); - expect(passkeys[0].credentialId).toBe(credentialIdRaw); - expect(passkeys[0].transports).toEqual(["internal"]); - // Audit row for the register action. - const audit = await prisma.auditLog.findFirst({ - where: { userId: TEST_USER_ID, action: "auth.passkey.register" }, - }); - expect(audit).not.toBeNull(); + expect(challenge.userId).toBe(USER_ID); + expect(challenge.type).toMatch(/^passkey-registration:v1:/); + expect(challenge.type).not.toContain(await currentSessionId()); + await expectNoCredential(); }); - it("register-verify returns 400 on a tampered attestation and writes no Passkey", async () => { - const { verifyRegistration } = await import("@/lib/auth/passkey"); - vi.mocked(verifyRegistration).mockResolvedValue({ - verified: false, - } as never); + it("accepts a fresh TOTP proof", async () => { + const secret = await seedTotp(); + const data = await issueOptions({ + method: "totp", + code: currentTotpCode(secret), + }); + const challenge = await getPrismaClient().authChallenge.findUniqueOrThrow({ + where: { id: data.challengeId }, + }); + expect(challenge.type).toMatch(/^passkey-registration:v1:/); + await expectNoCredential(); + }); + + it("accepts an existing primary passkey proof", async () => { const prisma = getPrismaClient(); - const challenge = await prisma.authChallenge.create({ + await prisma.passkey.create({ data: { - userId: TEST_USER_ID, - challenge: "fake-server-challenge", - type: "registration", - expiresAt: new Date(Date.now() + 5 * 60 * 1000), + userId: USER_ID, + credentialId: "existing-primary-passkey", + credentialPublicKey: Buffer.from([4, 3, 2, 1]), + counter: 0, + credentialDeviceType: "multiDevice", + credentialBackedUp: true, + transports: ["internal"], + }, + }); + const proofChallenge = await prisma.authChallenge.create({ + data: { + userId: USER_ID, + challenge: "primary-proof", + type: "authentication", + expiresAt: new Date(Date.now() + 5 * 60_000), }, }); - const { POST } = - await import("@/app/api/auth/passkey/register-verify/route"); - const res = await POST( - new NextRequest("http://localhost/api/auth/passkey/register-verify", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - challengeId: challenge.id, - credential: { id: "tampered-credential" }, - }), - }), - ); - - expect(res.status).toBe(400); - - const passkeys = await prisma.passkey.findMany({ - where: { userId: TEST_USER_ID }, + const data = await issueOptions({ + method: "passkey", + challengeId: proofChallenge.id, + credential: assertionCredential("existing-primary-passkey"), }); - expect(passkeys).toHaveLength(0); + const challenge = await prisma.authChallenge.findUniqueOrThrow({ + where: { id: data.challengeId }, + }); + + expect(challenge.type).toMatch(/^passkey-registration:v1:/); + expect(await prisma.passkey.count({ where: { userId: USER_ID } })).toBe(1); }); - it("register-verify returns 422 when challengeId is missing", async () => { - const { POST } = - await import("@/app/api/auth/passkey/register-verify/route"); - const res = await POST( - new NextRequest("http://localhost/api/auth/passkey/register-verify", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ credential: { id: "x" } }), - }), - ); + it("accepts an existing MFA WebAuthn proof", async () => { + const prisma = getPrismaClient(); + await prisma.webauthnMfaCredential.create({ + data: { + userId: USER_ID, + credentialId: "existing-mfa-key", + credentialPublicKey: Buffer.from([9, 8, 7, 6]), + counter: 0, + transports: ["usb"], + }, + }); + const proofChallenge = await prisma.authChallenge.create({ + data: { + userId: USER_ID, + challenge: "mfa-proof", + type: "mfa_authentication", + expiresAt: new Date(Date.now() + 5 * 60_000), + }, + }); - expect(res.status).toBe(422); + const data = await issueOptions({ + method: "webauthn", + challengeId: proofChallenge.id, + credential: assertionCredential("existing-mfa-key"), + }); + const challenge = await prisma.authChallenge.findUniqueOrThrow({ + where: { id: data.challengeId }, + }); + + expect(challenge.type).toMatch(/^passkey-registration:v1:/); + await expectNoCredential(); + }); + + it("rejects an ambient cookie that presents no fresh existing-factor proof", async () => { + const response = await registerOptions(); + + expect(response.status).toBe(401); + expect(await getPrismaClient().authChallenge.count()).toBe(0); + await expectNoCredential(); + }); + + it.each([ + ["wildcard", ["*"]], + ["narrow", ["medication:ingest"]], + ])( + "rejects a %s Bearer token even with the account password", + async (_, permissions) => { + cookieJar.clear(); + const raw = await mintBearer(permissions, `bearer-${permissions[0]}`); + headerJar.set("authorization", `Bearer ${raw}`); + + const response = await registerOptions({ + method: "password", + password: PASSWORD, + }); + + expect([401, 403]).toContain(response.status); + expect(await getPrismaClient().authChallenge.count()).toBe(0); + await expectNoCredential(); + }, + ); +}); + +describe("register-verify repeats cookie/fresh-factor and challenge binding", () => { + it("persists one credential for a fresh same-session password ceremony", async () => { + const { challengeId } = await issueOptions({ + method: "password", + password: PASSWORD, + }); + + const response = await registerVerify(challengeId, "new-credential"); + + expect(response.status).toBe(200); + expect( + await getPrismaClient().passkey.count({ where: { userId: USER_ID } }), + ).toBe(1); + }); + + it("rejects verify after the proof becomes stale", async () => { + const { challengeId } = await issueOptions({ + method: "password", + password: PASSWORD, + }); + await getPrismaClient().session.update({ + where: { id: await currentSessionId() }, + data: { mfaVerifiedAt: new Date(Date.now() - 6 * 60_000) }, + }); + + const response = await registerVerify(challengeId, "stale-proof"); + + expect(response.status).toBe(401); + await expectNoCredential(); + }); + + it("rejects a challenge from another cookie session", async () => { + const { challengeId } = await issueOptions({ + method: "password", + password: PASSWORD, + }); + const foreignSession = await getPrismaClient().session.create({ + data: { + userId: USER_ID, + expiresAt: new Date(Date.now() + 60 * 60_000), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", foreignSession.id); + + const response = await registerVerify(challengeId, "foreign-session"); + + expect(response.status).toBe(401); + await expectNoCredential(); + }); + + it("rejects a challenge from another user", async () => { + const { challengeId } = await issueOptions({ + method: "password", + password: PASSWORD, + }); + const foreignSession = await getPrismaClient().session.create({ + data: { + userId: OTHER_USER_ID, + expiresAt: new Date(Date.now() + 60 * 60_000), + mfaVerifiedAt: new Date(), + }, + }); + cookieJar.set("healthlog_session", foreignSession.id); + + const response = await registerVerify(challengeId, "foreign-user"); + + expect(response.status).toBe(401); + await expectNoCredential(); + }); + + it("rejects a challenge with the wrong purpose", async () => { + const { challengeId } = await issueOptions({ + method: "password", + password: PASSWORD, + }); + await getPrismaClient().authChallenge.update({ + where: { id: challengeId }, + data: { type: "authentication" }, + }); + + const response = await registerVerify(challengeId, "wrong-purpose"); + + expect(response.status).toBe(400); + await expectNoCredential(); + }); + + it("rejects an expired enrollment challenge", async () => { + const { challengeId } = await issueOptions({ + method: "password", + password: PASSWORD, + }); + await getPrismaClient().authChallenge.update({ + where: { id: challengeId }, + data: { expiresAt: new Date(Date.now() - 1_000) }, + }); + + const response = await registerVerify(challengeId, "expired"); + + expect(response.status).toBe(400); + await expectNoCredential(); + }); + + it("consumes the challenge once so replay cannot create a second credential", async () => { + const { challengeId } = await issueOptions({ + method: "password", + password: PASSWORD, + }); + + expect((await registerVerify(challengeId, "first")).status).toBe(200); + const replay = await registerVerify(challengeId, "second"); + + expect([400, 401]).toContain(replay.status); + expect(await getPrismaClient().passkey.count()).toBe(1); + expect( + await getPrismaClient().authChallenge.findUnique({ + where: { id: challengeId }, + }), + ).toBeNull(); }); }); From 14f451093e58bb58af90483e941f460885ff6f94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:39:06 +0200 Subject: [PATCH 006/125] test(01-04): pin Nightscout egress enforcement - Deny user-authorized and mismatched private origins before network access - Preserve exact operator-private and public positive flows - Require stable redacted policy reasons through sync boundaries --- .../connect/__tests__/route.test.ts | 69 +++++++++++++--- .../nightscout/sync/__tests__/route.test.ts | 23 ++++++ src/lib/nightscout/__tests__/client.test.ts | 79 ++++++++++++++++++- src/lib/nightscout/__tests__/sync.test.ts | 45 +++++++++++ 4 files changed, 202 insertions(+), 14 deletions(-) diff --git a/src/app/api/nightscout/connect/__tests__/route.test.ts b/src/app/api/nightscout/connect/__tests__/route.test.ts index da7d9c03c..f1cde5a7f 100644 --- a/src/app/api/nightscout/connect/__tests__/route.test.ts +++ b/src/app/api/nightscout/connect/__tests__/route.test.ts @@ -43,7 +43,16 @@ vi.mock("@/lib/rate-limit", () => ({ vi.mock("@/lib/api-response", () => ({ apiSuccess: (data: unknown) => ({ data, error: null, status: 200 }), - apiError: (error: string, status: number) => ({ data: null, error, status }), + apiError: ( + error: string, + status: number, + options?: { errorCode?: string }, + ) => ({ + data: null, + error, + status, + meta: options?.errorCode ? { errorCode: options.errorCode } : undefined, + }), safeJson: async (req: NextRequest) => { try { return { data: await req.json(), error: null }; @@ -67,11 +76,17 @@ function req(body: unknown): NextRequest { }); } -type RouteResult = { data: unknown; error: string | null; status: number }; +type RouteResult = { + data: unknown; + error: string | null; + status: number; + meta?: { errorCode?: string }; +}; const post = POST as unknown as (r: NextRequest) => Promise; beforeEach(() => { vi.clearAllMocks(); + delete process.env.NIGHTSCOUT_PRIVATE_ORIGINS; rateLimitMock.mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 }); fetchSgvEntriesMock.mockResolvedValue([{ id: "x", sgv: 100, date: 1 }]); bossSend.mockResolvedValue("job-1"); @@ -108,16 +123,51 @@ describe("POST /api/nightscout/connect", () => { expect(data.nightscoutTokenEncrypted).toBeNull(); }); - it("passes the private-host opt-in through to the test fetch", async () => { - await post( + it("rejects an ordinary user's private-host boolean before the probe", async () => { + const res = await post( req({ url: "http://192.168.1.5:1337", token: "tok", allowPrivateHost: true, }), ); - const fetchArg = fetchSgvEntriesMock.mock.calls[0]![0]; - expect(fetchArg.allowPrivateHost).toBe(true); + + expect(res.status).toBe(422); + expect(res.meta?.errorCode).toBe("private_origin_not_approved"); + expect(fetchSgvEntriesMock).not.toHaveBeenCalled(); + expect(userUpdate).not.toHaveBeenCalled(); + }); + + it("connects an exact operator-approved private origin without a user bypass", async () => { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = "http://192.168.1.5:1337"; + + const res = await post( + req({ + url: "http://192.168.1.5:1337", + token: "tok", + }), + ); + + expect(res.status).toBe(200); + expect(fetchSgvEntriesMock).toHaveBeenCalledTimes(1); + expect(userUpdate).toHaveBeenCalledTimes(1); + }); + + it("rejects an approved private host on a different port", async () => { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = "http://192.168.1.5:1337"; + + const res = await post( + req({ + url: "http://192.168.1.5:8080", + token: "tok", + allowPrivateHost: true, + }), + ); + + expect(res.status).toBe(422); + expect(res.meta?.errorCode).toBe("private_origin_not_approved"); + expect(fetchSgvEntriesMock).not.toHaveBeenCalled(); + expect(userUpdate).not.toHaveBeenCalled(); }); it("refuses a private host when the opt-in is off (clear 422, no store)", async () => { @@ -183,14 +233,13 @@ describe("POST /api/nightscout/connect", () => { expect(userUpdate).not.toHaveBeenCalled(); }); - it("still lets the explicit self-hoster opt-in through to the probe", async () => { - // The floor is gated, not absolute: a LAN instance stays reachable for - // the operator who asked for it. Without this the opt-in is dead code. + it("lets an exact operator-approved self-hosted origin through to the probe", async () => { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = "http://192.168.1.5:1337"; + const res = await post( req({ url: "http://192.168.1.5:1337", token: "tok", - allowPrivateHost: true, }), ); diff --git a/src/app/api/nightscout/sync/__tests__/route.test.ts b/src/app/api/nightscout/sync/__tests__/route.test.ts index 15585a50b..f3461d5f9 100644 --- a/src/app/api/nightscout/sync/__tests__/route.test.ts +++ b/src/app/api/nightscout/sync/__tests__/route.test.ts @@ -160,4 +160,27 @@ describe("POST /api/nightscout/sync", () => { expect(serialised).not.toContain("cgm.example.org"); expect(serialised).not.toContain("token="); }); + + it("returns a stable operator-action reason for an unapproved legacy private connection", async () => { + vi.mocked(syncUserNightscout).mockRejectedValue( + Object.assign( + new Error( + "GET http://10.0.0.4:1337/api/v1/entries.json?token=hunter2secret refused", + ), + { reasonCode: "private_origin_not_approved" }, + ), + ); + + const response = await post(request()); + const body = await envelope(response); + const serialised = JSON.stringify(body); + + expect(response.status).toBe(422); + expect(body.error).toBe( + "Private Nightscout access requires server operator approval", + ); + expect(body.meta?.errorCode).toBe("private_origin_not_approved"); + expect(serialised).not.toContain("10.0.0.4"); + expect(serialised).not.toContain("hunter2secret"); + }); }); diff --git a/src/lib/nightscout/__tests__/client.test.ts b/src/lib/nightscout/__tests__/client.test.ts index ec2d4e1b2..56a48d959 100644 --- a/src/lib/nightscout/__tests__/client.test.ts +++ b/src/lib/nightscout/__tests__/client.test.ts @@ -42,12 +42,20 @@ const SAMPLE: NightscoutSgvEntry[] = [ }, ]; +const ORIGINAL_PRIVATE_ORIGINS = process.env.NIGHTSCOUT_PRIVATE_ORIGINS; + beforeEach(() => { safeFetchMock.mockReset(); + delete process.env.NIGHTSCOUT_PRIVATE_ORIGINS; }); afterEach(() => { vi.restoreAllMocks(); + if (ORIGINAL_PRIVATE_ORIGINS === undefined) { + delete process.env.NIGHTSCOUT_PRIVATE_ORIGINS; + } else { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = ORIGINAL_PRIVATE_ORIGINS; + } }); describe("parseSgvEntries", () => { @@ -143,16 +151,79 @@ describe("fetchSgvEntries — SSRF gating", () => { expect(opts.requirePublicHost).toBe(true); }); - it("relaxes the public-host pin only when the private-host flag is set", async () => { + it("keeps an exact operator-approved private origin functional", async () => { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = "http://192.168.1.50:1337"; safeFetchMock.mockResolvedValue(jsonResponse(200, SAMPLE)); - await fetchSgvEntries({ + const entries = await fetchSgvEntries({ baseUrl: "http://192.168.1.50:1337", token: "", count: 10, + // Compatibility metadata is deliberately false: operator configuration, + // not this user-controlled field, grants the private connection. + allowPrivateHost: false, + }); + + expect(entries).toHaveLength(2); + expect(safeFetchMock).toHaveBeenCalledTimes(1); + }); + + it("does not let the legacy user boolean authorize private egress", async () => { + delete process.env.NIGHTSCOUT_PRIVATE_ORIGINS; + + await expect( + fetchSgvEntries({ + baseUrl: "http://192.168.1.50:1337", + token: "", + count: 10, + allowPrivateHost: true, + }), + ).rejects.toMatchObject({ + reasonCode: "private_origin_not_approved", + }); + + expect(safeFetchMock).not.toHaveBeenCalled(); + }); + + it("rejects the same private host on a different port before any network call", async () => { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = "http://192.168.1.50:1337"; + + await expect( + fetchSgvEntries({ + baseUrl: "http://192.168.1.50:8080", + token: "", + count: 10, + allowPrivateHost: true, + }), + ).rejects.toMatchObject({ + reasonCode: "private_origin_not_approved", + }); + + expect(safeFetchMock).not.toHaveBeenCalled(); + }); + + it("maps a denied DNS answer or connect-time drift to one redacted policy reason", async () => { + process.env.NIGHTSCOUT_PRIVATE_ORIGINS = "https://cgm.example.test"; + safeFetchMock.mockRejectedValue( + new SafeFetchError( + "safeFetch refused private resolved address for cgm.example.test/api/v1/entries.json?token=ns-secret", + "private_host", + ), + ); + + const rejected = fetchSgvEntries({ + baseUrl: "https://cgm.example.test", + token: "ns-secret", + count: 10, allowPrivateHost: true, }); - const opts = safeFetchMock.mock.calls[0]![2]; - expect(opts.requirePublicHost).toBe(false); + + await expect(rejected).rejects.toMatchObject({ + message: "private_origin_not_approved", + reasonCode: "private_origin_not_approved", + }); + await expect(rejected).rejects.not.toMatchObject({ + message: expect.stringContaining("ns-secret"), + }); }); it("pins redirect:manual + a timeout on every call", async () => { diff --git a/src/lib/nightscout/__tests__/sync.test.ts b/src/lib/nightscout/__tests__/sync.test.ts index 2d903a3ec..e43560469 100644 --- a/src/lib/nightscout/__tests__/sync.test.ts +++ b/src/lib/nightscout/__tests__/sync.test.ts @@ -141,6 +141,51 @@ describe("syncUserNightscout", () => { expect(recordSuccessMock).toHaveBeenCalledWith("u1", "nightscout"); }); + it("does not forward a legacy stored boolean as private-egress authority", async () => { + getCredsMock.mockResolvedValue({ + baseUrl: "http://10.0.0.4:1337", + token: "tok", + allowPrivateHost: true, + }); + fetchSgvEntriesMock.mockResolvedValue([]); + + await syncUserNightscout("u1"); + + expect(fetchSgvEntriesMock).toHaveBeenCalledTimes(1); + expect(fetchSgvEntriesMock.mock.calls[0]![0]).not.toHaveProperty( + "allowPrivateHost", + ); + }); + + it("records a stable operator-action reason when a legacy private row is no longer approved", async () => { + getCredsMock.mockResolvedValue({ + baseUrl: "http://10.0.0.4:1337", + token: "tok", + allowPrivateHost: true, + }); + fetchSgvEntriesMock.mockRejectedValue( + Object.assign( + new Error( + "GET http://10.0.0.4:1337/api/v1/entries.json?token=tok refused", + ), + { reasonCode: "private_origin_not_approved" }, + ), + ); + + await expect(syncUserNightscout("u1")).rejects.toThrow(); + + expect(recordFailureMock).toHaveBeenCalledWith( + expect.objectContaining({ + integration: "nightscout", + kind: "persistent", + message: "private_origin_not_approved", + }), + ); + const recorded = JSON.stringify(recordFailureMock.mock.calls[0]![0]); + expect(recorded).not.toContain("10.0.0.4"); + expect(recorded).not.toContain("tok"); + }); + it("N-1 — counts only fresh inserts and emits ONLY the newly-inserted rows", async () => { fetchSgvEntriesMock.mockResolvedValue([ENTRY_A, ENTRY_B]); // ENTRY_A already exists (a re-confirm); ENTRY_B is genuinely new. From 5569b8259b4dfbfcf7efaa89fe6f5dbcbccdf849 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:39:50 +0200 Subject: [PATCH 007/125] test(01-04): pin Nightscout operator-action UI - Remove the user-facing private-host bypass contract - Require redacted operator approval guidance for legacy failures - Preserve usable public and approved-private connected states --- .../__tests__/nightscout-card.test.tsx | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx index dc7c1f8a1..60fe31c06 100644 --- a/src/components/settings/integrations/__tests__/nightscout-card.test.tsx +++ b/src/components/settings/integrations/__tests__/nightscout-card.test.tsx @@ -144,3 +144,69 @@ describe("NightscoutCard — sync-now action", () => { ).not.toContain('data-testid="nightscout-sync"'); }); }); + +describe("NightscoutCard — operator-owned private access", () => { + it("does not offer an ordinary user a private-host enforcement bypass", () => { + const html = render({ + connected: false, + configured: false, + state: "disconnected", + syncHealth: { verdict: "disconnected", since: null }, + }); + + expect(html).not.toContain('id="nightscout-private"'); + expect(html).not.toContain("This site runs on a private network"); + expect(html).not.toContain("Enable only if your Nightscout instance"); + }); + + it("shows a redacted operator-action state for an unapproved legacy private connection", () => { + const html = render({ + connected: true, + configured: true, + state: "error_reauth", + lastError: + "private_origin_not_approved: http://10.0.0.4:1337/api/v1/entries.json?token=ns-secret", + syncHealth: { verdict: "failing", since: null }, + allowPrivateHost: true, + }); + + expect(html).toContain( + 'data-testid="nightscout-private-operator-required"', + ); + expect(html).toContain( + "Private Nightscout access requires server operator approval.", + ); + expect(html).toContain( + "Ask your server operator to approve this exact Nightscout origin.", + ); + expect(html).not.toContain("private_origin_not_approved"); + expect(html).not.toContain("10.0.0.4"); + expect(html).not.toContain("ns-secret"); + expect(html).not.toContain('data-testid="nightscout-sync"'); + expect(html).not.toContain("Test connection"); + expect(html).toContain("Disconnect"); + }); + + it.each([ + ["public", false], + ["operator-approved private", true], + ])("keeps a healthy %s connection usable", (_label, allowPrivateHost) => { + const html = render({ + connected: true, + configured: true, + state: "connected", + lastError: null, + syncHealth: { verdict: "fresh", since: null }, + // Compatibility/display metadata only; it is not the authority asserted + // by this positive flow. + allowPrivateHost, + }); + + expect(html).toContain('data-testid="nightscout-sync"'); + expect(html).toContain("Test connection"); + expect(html).toContain("Disconnect"); + expect(html).not.toContain( + 'data-testid="nightscout-private-operator-required"', + ); + }); +}); From e25080b2145418fde491541f4e4ac355c349ad13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:39:52 +0200 Subject: [PATCH 008/125] test(01-02): add red DNS-pinned SSRF contracts - refuse standard embedded-private IPv6 resolver answers pre-connect - preserve public IPv6 to IPv4 fallback connectivity - prove followed redirects repeat the pinned DNS verdict --- .../__tests__/safe-fetch-dispatcher.test.ts | 81 ++++++++++++++++--- src/lib/__tests__/safe-fetch-pinned.test.ts | 76 +++++++++++++++-- 2 files changed, 139 insertions(+), 18 deletions(-) diff --git a/src/lib/__tests__/safe-fetch-dispatcher.test.ts b/src/lib/__tests__/safe-fetch-dispatcher.test.ts index c3df27353..57bdf3ed0 100644 --- a/src/lib/__tests__/safe-fetch-dispatcher.test.ts +++ b/src/lib/__tests__/safe-fetch-dispatcher.test.ts @@ -1,11 +1,37 @@ import { describe, it, expect, afterEach, vi } from "vitest"; import dns from "node:dns"; +import { fetch as undiciFetch } from "undici"; import { isPublicIp } from "@/lib/validations/notifications"; import { getPinnedPublicDispatcher, _resetPinnedDispatcherForTests, } from "../safe-fetch-dispatcher"; +const EMBEDDED_PRIVATE_DNS_ANSWERS = [ + ["IPv4-compatible loopback", "::7f00:1"], + ["IPv4-compatible metadata", "::a9fe:a9fe"], + ["6to4 RFC1918", "2002:a00:1::"], + ["6to4 metadata", "2002:a9fe:a9fe::"], + ["NAT64 WKP RFC1918", "64:ff9b::c0a8:101"], + ["NAT64 WKP metadata", "64:ff9b::a9fe:a9fe"], + ["RFC8215 local-use RFC1918", "64:ff9b:1:ac10:0:1::"], + ["RFC8215 local-use metadata", "64:ff9b:1:a9fe:0:a9fe::"], +] as const; + +function errorCodes(error: unknown): string[] { + if (!error || typeof error !== "object") return []; + const value = error as { + code?: unknown; + cause?: unknown; + errors?: unknown[]; + }; + return [ + ...(typeof value.code === "string" ? [value.code] : []), + ...errorCodes(value.cause), + ...(value.errors ?? []).flatMap(errorCodes), + ]; +} + describe("isPublicIp", () => { it("accepts a public IPv4 address", () => { expect(isPublicIp("203.0.113.5")).toBe(true); @@ -79,17 +105,19 @@ describe("pinnedPublicDispatcher", () => { }) as unknown as typeof dns.lookup); const dispatcher = getPinnedPublicDispatcher(); - // Drive the dispatcher through `fetch` so the integration mirrors - // what safeFetch does. The expected outcome is a connect error - // (the lookup wrapper raised ENOTFOUND). - await expect( - fetch( - "https://attacker-controlled.example.test/probe", - // Node's fetch RequestInit accepts undici's `dispatcher`; the - // DOM types do not, hence the cast. - { dispatcher } as RequestInit & { dispatcher: typeof dispatcher }, - ), - ).rejects.toThrow(); + let caught: unknown; + try { + await undiciFetch("https://attacker-controlled.example.test/probe", { + dispatcher, + }); + } catch (error) { + caught = error; + } + + // A known-denied plain IPv4 answer proves the harness can distinguish + // policy refusal from a later socket failure. + expect(caught).toBeDefined(); + expect(errorCodes(caught)).toContain("ENOTFOUND"); }, 20_000); it("forwards a public-resolving hostname to the real connector", async () => { @@ -161,4 +189,35 @@ describe("pinnedPublicDispatcher", () => { const code = (caught as NodeJS.ErrnoException)?.code; expect(code === "ENOTFOUND").toBe(false); }, 20_000); + + it.each(EMBEDDED_PRIVATE_DNS_ANSWERS)( + "refuses a hostname whose resolver returns %s before Undici connects", + async (_label, address) => { + vi.spyOn(dns, "lookup").mockImplementation((( + _hostname: string, + _opts: dns.LookupAllOptions, + callback: ( + err: NodeJS.ErrnoException | null, + addresses: dns.LookupAddress[], + ) => void, + ) => { + callback(null, [{ address, family: 6 }]); + }) as unknown as typeof dns.lookup); + + const dispatcher = getPinnedPublicDispatcher(); + let caught: unknown; + try { + await undiciFetch("http://transition-answer.example.test:9/probe", { + dispatcher, + signal: AbortSignal.timeout(750), + }); + } catch (error) { + caught = error; + } + + expect(caught).toBeDefined(); + expect(errorCodes(caught)).toContain("ENOTFOUND"); + }, + 5_000, + ); }); diff --git a/src/lib/__tests__/safe-fetch-pinned.test.ts b/src/lib/__tests__/safe-fetch-pinned.test.ts index d94322e7c..ea7f7f86c 100644 --- a/src/lib/__tests__/safe-fetch-pinned.test.ts +++ b/src/lib/__tests__/safe-fetch-pinned.test.ts @@ -32,6 +32,11 @@ import http from "node:http"; import dns from "node:dns"; import type { AddressInfo } from "node:net"; +const notificationMocks = vi.hoisted(() => ({ + isPublicIp: vi.fn((_ip: string) => true), + isPublicUrl: vi.fn((_url: string) => true), +})); + // Treat every address/URL as public so the harness can reach a loopback server // through the requirePublicHost guard. The transport coupling under test is // orthogonal to IP classification (covered by safe-fetch-dispatcher.test.ts). @@ -40,8 +45,8 @@ vi.mock("@/lib/validations/notifications", async (importOriginal) => { await importOriginal(); return { ...actual, - isPublicIp: () => true, - isPublicUrl: () => true, + isPublicIp: notificationMocks.isPublicIp, + isPublicUrl: notificationMocks.isPublicUrl, }; }); @@ -50,6 +55,7 @@ import { _resetPinnedDispatcherForTests } from "../safe-fetch-dispatcher"; let server: http.Server; let port: number; +const requestedPaths: string[] = []; function mockLookupTo(addresses: dns.LookupAddress[]): void { vi.spyOn(dns, "lookup").mockImplementation((( @@ -66,6 +72,14 @@ function mockLookupTo(addresses: dns.LookupAddress[]): void { beforeAll(async () => { server = http.createServer((req, res) => { + requestedPaths.push(req.url ?? ""); + if (req.url === "/redirect") { + res.writeHead(302, { + location: `http://redirect-target.test:${port}/api`, + }); + res.end(); + return; + } if (req.url === "/stream") { res.writeHead(200, { "content-type": "text/plain" }); res.write("chunk-1;"); @@ -94,6 +108,11 @@ afterAll(async () => { afterEach(() => { vi.restoreAllMocks(); + notificationMocks.isPublicIp.mockReset(); + notificationMocks.isPublicIp.mockImplementation((_ip: string) => true); + notificationMocks.isPublicUrl.mockReset(); + notificationMocks.isPublicUrl.mockImplementation((_url: string) => true); + requestedPaths.length = 0; _resetPinnedDispatcherForTests(); }); @@ -112,12 +131,12 @@ describe("safeFetch pinned path — real transport", () => { expect(body.ok).toBe(true); }, 20_000); - it("falls back across families when the first address is a black-holed IPv6", async () => { - // First record is an unreachable IPv6 (RFC 6666 discard prefix); the - // working IPv4 record is behind it. Pre-fix this failed instantly; with - // Happy Eyeballs the connect falls back to v4 and succeeds. + it("retains fallback connectivity across public native IPv6 and IPv4", async () => { + // The first record is a genuinely public native IPv6 address, while the + // harness server is reachable through the IPv4 record behind it. The + // dispatcher must preserve both vetted families for Happy Eyeballs. mockLookupTo([ - { address: "0100::1", family: 6 }, + { address: "2606:4700:4700::1111", family: 6 }, { address: "127.0.0.1", family: 4 }, ]); @@ -130,6 +149,49 @@ describe("safeFetch pinned path — real transport", () => { expect(res.status).toBe(200); }, 20_000); + it("re-runs the pinned DNS verdict for a followed redirect destination", async () => { + const deniedRedirectAddress = "64:ff9b::7f00:1"; + const lookupHosts: string[] = []; + notificationMocks.isPublicIp.mockImplementation( + (ip: string) => ip !== deniedRedirectAddress, + ); + vi.spyOn(dns, "lookup").mockImplementation((( + hostname: string, + _opts: dns.LookupAllOptions, + callback: ( + err: NodeJS.ErrnoException | null, + addrs: dns.LookupAddress[], + ) => void, + ) => { + lookupHosts.push(hostname); + callback( + null, + hostname === "redirect-target.test" + ? [{ address: deniedRedirectAddress, family: 6 }] + : [{ address: "127.0.0.1", family: 4 }], + ); + }) as unknown as typeof dns.lookup); + + await expect( + safeFetch( + `http://pinned.test:${port}/redirect`, + {}, + { + requirePublicHost: true, + followRedirects: true, + timeoutMs: 5000, + }, + ), + ).rejects.toMatchObject({ kind: "network" }); + + expect(lookupHosts).toContain("pinned.test"); + expect(lookupHosts).toContain("redirect-target.test"); + expect(notificationMocks.isPublicIp).toHaveBeenCalledWith( + deniedRedirectAddress, + ); + expect(requestedPaths).toEqual(["/redirect"]); + }, 20_000); + it("streams a chunked response body via getReader() on the pinned path", async () => { mockLookupTo([{ address: "127.0.0.1", family: 4 }]); From 3f4afb26a4d0a703296f4e71edea8de44b92ec15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:39:55 +0200 Subject: [PATCH 009/125] test(01-03): pin passkey enrollment reauthentication UX - require password, passkey, TOTP and WebAuthn proof choices - order fresh-factor completion before challenge creation - keep successful enrollment in one cookie-bound ceremony --- .../passkey-enrollment-reauth.test.tsx | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 src/components/settings/security-section/__tests__/passkey-enrollment-reauth.test.tsx diff --git a/src/components/settings/security-section/__tests__/passkey-enrollment-reauth.test.tsx b/src/components/settings/security-section/__tests__/passkey-enrollment-reauth.test.tsx new file mode 100644 index 000000000..c60a5c0ba --- /dev/null +++ b/src/components/settings/security-section/__tests__/passkey-enrollment-reauth.test.tsx @@ -0,0 +1,59 @@ +/** + * SEC-05 / SEC-08 — Add-passkey reauthentication UX contract. + * + * This repository intentionally keeps component tests SSR/source-oriented + * rather than installing a browser DOM harness. The ordering assertions below + * are security assertions: the existing-factor step must finish before the + * first registration challenge is requested, so cancellation or failure + * cannot leave an enrollable server challenge behind. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync( + resolve(__dirname, "../passkey-list-section.tsx"), + "utf8", +); + +function firstIndex(...needles: RegExp[]): number { + const indexes = needles + .map((needle) => source.search(needle)) + .filter((index) => index >= 0); + return indexes.length === 0 ? -1 : Math.min(...indexes); +} + +describe(" enrollment reauthentication", () => { + it("offers every supported existing factor without a bypass", () => { + expect(source).toMatch(/["']password["']/); + expect(source).toMatch(/["']passkey["']/); + expect(source).toMatch(/["']totp["']/); + expect(source).toMatch(/["']webauthn["']/); + + expect(source).not.toMatch(/skipReauth|bypassReauth|allowBearer/i); + expect(source).not.toContain("x-step-up"); + expect(source).not.toContain("Authorization"); + }); + + it("finishes fresh-factor proof before requesting registration options", () => { + const proof = firstIndex(/reauth/i, /existingFactor/, /freshFactor/); + const options = source.indexOf('"/api/auth/passkey/register-options"'); + + expect(proof).toBeGreaterThanOrEqual(0); + expect(options).toBeGreaterThan(proof); + }); + + it("keeps one cookie-bound ceremony from proof through registration verify", () => { + const options = source.indexOf('"/api/auth/passkey/register-options"'); + const browserRegistration = source.indexOf("startRegistration", options); + const verify = source.indexOf( + '"/api/auth/passkey/register-verify"', + browserRegistration, + ); + + expect(options).toBeGreaterThanOrEqual(0); + expect(browserRegistration).toBeGreaterThan(options); + expect(verify).toBeGreaterThan(browserRegistration); + expect(source).toMatch(/challengeId[\s\S]*credential/); + }); +}); From 38501f6574f7c15f9f0b29f2b180f22adb6d3a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:40:03 +0200 Subject: [PATCH 010/125] test(01-01): specify transactional refresh rotation - require one transaction for rotation and successor insertion - pin CAS-loss revocation and insertion rollback semantics --- src/app/api/mcp/oauth/__tests__/token.test.ts | 104 +++++++++++++++++- .../mcp/oauth/__tests__/connections.test.ts | 72 ++++++++++++ 2 files changed, 172 insertions(+), 4 deletions(-) diff --git a/src/app/api/mcp/oauth/__tests__/token.test.ts b/src/app/api/mcp/oauth/__tests__/token.test.ts index 76e3fbb07..54bf4e451 100644 --- a/src/app/api/mcp/oauth/__tests__/token.test.ts +++ b/src/app/api/mcp/oauth/__tests__/token.test.ts @@ -7,6 +7,20 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; process.env.API_TOKEN_HMAC_KEY = "x".repeat(48); process.env.APP_URL = "https://health.example"; +const { transactionClient, transaction } = vi.hoisted(() => { + const transactionClient = { marker: "mcp-refresh-transaction" }; + return { + transactionClient, + transaction: vi.fn( + async (callback: (tx: typeof transactionClient) => unknown) => + callback(transactionClient), + ), + }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { $transaction: transaction }, +})); vi.mock("@/lib/logging/background", () => ({ withBackgroundEvent: (_n: string, fn: () => unknown) => fn(), })); @@ -51,7 +65,7 @@ import { createConnection, rotateConnection, } from "@/lib/mcp/oauth/connections"; -import { signArtifact } from "@/lib/mcp/oauth/artifacts"; +import { signArtifact, verifyArtifact } from "@/lib/mcp/oauth/artifacts"; import { s256Challenge } from "@/lib/mcp/oauth/pkce"; const CLIENT_ID = "https://app.example/client.json"; @@ -95,6 +109,10 @@ beforeEach(() => { vi.mocked(isApiGloballyEnabled).mockResolvedValue(true); vi.mocked(createConnection).mockResolvedValue("conn-1"); vi.mocked(rotateConnection).mockResolvedValue({ ok: true }); + transaction.mockImplementation( + async (callback: (tx: typeof transactionClient) => unknown) => + callback(transactionClient), + ); }); function refreshToken(overrides: Record = {}): string { @@ -217,10 +235,11 @@ describe("authorization_code grant", () => { describe("refresh_token grant", () => { it("rotates: issues a new access + refresh pair against a live connection", async () => { + const presented = refreshToken({ jti: "presented-jti" }); const res = await POST( tokenReq({ grant_type: "refresh_token", - refresh_token: refreshToken(), + refresh_token: presented, client_id: CLIENT_ID, }) as never, ); @@ -228,9 +247,86 @@ describe("refresh_token grant", () => { const body = await res.json(); expect(body.access_token).toMatch(/^hlk_/); expect(body.refresh_token).toMatch(/^hlrt_/); - expect(rotateConnection).toHaveBeenCalledWith( - expect.objectContaining({ connectionId: "conn-1", clientId: CLIENT_ID }), + expect(vi.mocked(rotateConnection).mock.calls[0]?.[0]).toEqual( + expect.objectContaining({ + connectionId: "conn-1", + clientId: CLIENT_ID, + presentedJti: "presented-jti", + }), ); + + const successor = verifyArtifact<{ + cid: string; + jti: string; + sub: string; + }>("refreshToken", body.refresh_token); + expect(successor.ok).toBe(true); + if (successor.ok) { + expect(successor.claims.cid).toBe("conn-1"); + expect(successor.claims.sub).toBe("user-1"); + expect(successor.claims.jti).not.toBe("presented-jti"); + } + }); + + it("rotates and inserts the successor access token inside one transaction", async () => { + const res = await POST( + tokenReq({ + grant_type: "refresh_token", + refresh_token: refreshToken({ jti: "transactional-jti" }), + client_id: CLIENT_ID, + }) as never, + ); + + expect(res.status).toBe(200); + expect(transaction).toHaveBeenCalledTimes(1); + expect(rotateConnection).toHaveBeenCalledWith( + expect.objectContaining({ presentedJti: "transactional-jti" }), + transactionClient, + ); + expect(issueApiToken).toHaveBeenCalledWith( + expect.objectContaining({ + userId: "user-1", + mcpConnectionId: "conn-1", + }), + transactionClient, + ); + }); + + it("rolls back JTI advancement and access revocation when successor insertion fails", async () => { + const state = { currentJti: "rollback-jti", accessRevoked: false }; + vi.mocked(rotateConnection).mockImplementationOnce(async (args) => { + state.currentJti = args.newJti; + state.accessRevoked = true; + return { ok: true }; + }); + vi.mocked(issueApiToken).mockRejectedValueOnce( + new Error("injected successor insertion failure"), + ); + transaction.mockImplementationOnce(async (callback) => { + const snapshot = { ...state }; + try { + return await callback(transactionClient); + } catch (error) { + Object.assign(state, snapshot); + throw error; + } + }); + + await expect( + POST( + tokenReq({ + grant_type: "refresh_token", + refresh_token: refreshToken({ jti: "rollback-jti" }), + client_id: CLIENT_ID, + }) as never, + ), + ).rejects.toThrow("injected successor insertion failure"); + + expect(transaction).toHaveBeenCalledTimes(1); + expect(state).toEqual({ + currentJti: "rollback-jti", + accessRevoked: false, + }); }); it("rejects a refresh whose connection was revoked (H2 — settings revoke)", async () => { diff --git a/src/lib/mcp/oauth/__tests__/connections.test.ts b/src/lib/mcp/oauth/__tests__/connections.test.ts index c9223fa9d..007d3b99f 100644 --- a/src/lib/mcp/oauth/__tests__/connections.test.ts +++ b/src/lib/mcp/oauth/__tests__/connections.test.ts @@ -37,6 +37,18 @@ const CONN = { revokedAt: null as Date | null, }; +const TX = { + mcpOAuthConnection: { + create: vi.fn(), + findUnique: vi.fn(), + updateMany: vi.fn(), + findMany: vi.fn(), + }, + apiToken: { + updateMany: vi.fn(), + }, +}; + beforeEach(() => { vi.clearAllMocks(); vi.mocked(prisma.apiToken.updateMany).mockResolvedValue({ @@ -45,6 +57,8 @@ beforeEach(() => { vi.mocked(prisma.mcpOAuthConnection.updateMany).mockResolvedValue({ count: 1, } as never); + TX.mcpOAuthConnection.updateMany.mockResolvedValue({ count: 1 }); + TX.apiToken.updateMany.mockResolvedValue({ count: 0 }); }); describe("createConnection", () => { @@ -96,6 +110,33 @@ describe("rotateConnection — happy path", () => { }), ); }); + + it("uses the supplied transaction client for the entire rotation", async () => { + TX.mcpOAuthConnection.findUnique.mockResolvedValue({ ...CONN }); + + const out = await rotateConnection( + { + connectionId: "conn-1", + presentedJti: "jti-current", + newJti: "jti-next", + clientId: CONN.clientId, + userId: "user-1", + }, + TX as never, + ); + + expect(out.ok).toBe(true); + expect(TX.mcpOAuthConnection.findUnique).toHaveBeenCalledTimes(1); + expect(TX.mcpOAuthConnection.updateMany).toHaveBeenCalledTimes(1); + expect(TX.apiToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { mcpConnectionId: "conn-1", revoked: false }, + }), + ); + expect(prisma.mcpOAuthConnection.findUnique).not.toHaveBeenCalled(); + expect(prisma.mcpOAuthConnection.updateMany).not.toHaveBeenCalled(); + expect(prisma.apiToken.updateMany).not.toHaveBeenCalled(); + }); }); describe("rotateConnection — revoked connection stops refresh", () => { @@ -158,6 +199,37 @@ describe("rotateConnection — reuse detection revokes the family", () => { }); expect(out).toEqual({ ok: false, reason: "not_found" }); }); + + it("revokes the family and linked access tokens when the CAS contender loses", async () => { + vi.mocked(prisma.mcpOAuthConnection.findUnique).mockResolvedValue({ + ...CONN, + } as never); + vi.mocked(prisma.mcpOAuthConnection.updateMany) + .mockResolvedValueOnce({ count: 0 } as never) + .mockResolvedValueOnce({ count: 1 } as never); + + const out = await rotateConnection({ + connectionId: "conn-1", + presentedJti: "jti-current", + newJti: "jti-loser", + clientId: CONN.clientId, + userId: "user-1", + }); + + expect(out).toEqual({ ok: false, reason: "reuse_detected" }); + expect(prisma.mcpOAuthConnection.updateMany).toHaveBeenLastCalledWith( + expect.objectContaining({ + where: { id: "conn-1", revokedAt: null }, + data: expect.objectContaining({ revokedAt: expect.any(Date) }), + }), + ); + expect(prisma.apiToken.updateMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { mcpConnectionId: "conn-1", revoked: false }, + data: { revoked: true }, + }), + ); + }); }); describe("revokeConnectionForUser — settings revoke kills the chain", () => { From eb5cd5fbcb38c53645348a0a75ae98e5824a6d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:41:52 +0200 Subject: [PATCH 011/125] docs(01-04): complete Nightscout security contract plan Tasks completed: 3/3 - Specify exact operator-origin configuration - Pin connect-time and sync-time egress policy - Pin the user-facing breaking-change state --- .../01-04-SUMMARY.md | 125 ++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-04-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-04-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-04-SUMMARY.md new file mode 100644 index 000000000..c2a4a011a --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-04-SUMMARY.md @@ -0,0 +1,125 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "04" +subsystem: testing +tags: [nightscout, ssrf, exact-origin, operator-policy, vitest] + +requires: [] +provides: + - Strict server-only NIGHTSCOUT_PRIVATE_ORIGINS parser and verdict contracts + - Final-boundary tests denying user-controlled private-host authority + - Redacted operator-action UI contract for legacy private connections +affects: [01-07-nightscout-security-implementation, 01-09-security-gate] + +tech-stack: + added: [] + patterns: + - Exact canonical scheme/host/port membership for private egress + - Stable private_origin_not_approved reason across client, sync, route, and UI + +key-files: + created: + - src/lib/validations/__tests__/nightscout.test.ts + modified: + - src/lib/nightscout/__tests__/client.test.ts + - src/lib/nightscout/__tests__/sync.test.ts + - src/app/api/nightscout/connect/__tests__/route.test.ts + - src/app/api/nightscout/sync/__tests__/route.test.ts + - src/components/settings/integrations/__tests__/nightscout-card.test.tsx + +key-decisions: + - "Private Nightscout authority is an exact canonical operator-owned origin, never the stored user boolean." + - "Policy denials use private_origin_not_approved and never expose the raw origin, token, resolver error, or allowlist." + - "The Wave 0 plan remains test-only; production enforcement belongs to Plan 01-07." + +patterns-established: + - "Two-sided SSRF contracts: public and exact-approved private positives accompany every attacker-negative boundary." + - "Denied targets stop before the network call; connect-time drift is translated to one redacted reason." + +requirements-completed: [SEC-06, SEC-08] + +duration: 8 min +completed: 2026-07-29 +--- + +# Phase 1 Plan 04: Nightscout Operator-Origin Red Contract Summary + +**A 100-test Nightscout security contract preserves public and exact operator-approved private access while making the user-controlled SSRF bypass and unsafe legacy UI explicitly RED.** + +## Performance + +- **Duration:** 8 min +- **Started:** 2026-07-29T11:31:43Z +- **Completed:** 2026-07-29T11:40:22Z +- **Tasks:** 3 +- **Files modified:** 6 + +## Accomplishments + +- Declared strict comma-separated `NIGHTSCOUT_PRIVATE_ORIGINS` parsing, canonicalization, and malformed-entry rejection. +- Pinned connect, client, sync, and manual-sync behavior for public positives, exact private positives, user-boolean attacks, port mismatch, DNS/connect drift, and legacy-row rechecks. +- Specified an accessible breaking-change state that removes the private-host toggle, redacts unsafe details, requests operator action, and preserves healthy connected actions. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Specify exact operator-origin configuration** - `668f85a58` (test) +2. **Task 2: Pin connect-time and sync-time egress policy** - `14f451093` (test) +3. **Task 3: Pin the user-facing breaking-change state** - `5569b8259` (test) + +## Files Created/Modified + +- `src/lib/validations/__tests__/nightscout.test.ts` - Exact origin parser and public/private policy verdict matrix. +- `src/lib/nightscout/__tests__/client.test.ts` - Final fetch denial, exact-private positive, port mismatch, and redacted DNS/connect-drift contracts. +- `src/lib/nightscout/__tests__/sync.test.ts` - Legacy boolean removal and stable persistent policy-failure recording. +- `src/app/api/nightscout/connect/__tests__/route.test.ts` - Persistence-boundary user attack denial and exact operator-private positive. +- `src/app/api/nightscout/sync/__tests__/route.test.ts` - Stable redacted operator-action response for legacy rows. +- `src/components/settings/integrations/__tests__/nightscout-card.test.tsx` - Toggle removal, safe operator guidance, and healthy public/private actions. + +## Verification + +- Combined six-file Vitest run: **100 discovered, 80 passed, 20 expected RED failures**. +- Validation suite: RED because `parseNightscoutPrivateOrigins` and `evaluateNightscoutOrigin` do not exist yet. +- Client/sync/routes: RED because the stored boolean still grants private access, configured exact origins are not authoritative, port mismatches reach the probe, and policy failures are not yet stable/redacted end to end. +- Settings card: RED because the user toggle still renders and legacy policy failures still expose raw details and unsafe actions. +- Focused Prettier check: passed. +- Focused ESLint over all six owned test files: passed. + +## Decisions Made + +- The parser throws on any malformed operator trust entry rather than silently broadening or partially accepting the allowlist. +- Default ports are canonicalized through URL-origin semantics; non-default ports remain part of exact membership. +- Compatibility fields may remain readable, but tests forbid forwarding `allowPrivateHost` as egress authority. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +- Parallel Vitest processes from other disjoint Wave 0 executors contended for workers. Focused verification was rerun serially with `--no-file-parallelism`; contract results were stable. +- The pre-existing package, lockfile, and workspace scanner diff was preserved and not staged. + +## Deferred Issues + +- The accepted Low Nightscout token-warning log finding remains unresolved by design. No production logging code or claim of remediation was added. + +## User Setup Required + +None - this Wave 0 plan adds tests only. Operator configuration is implemented and documented by later plans. + +## Next Phase Readiness + +- Plan 01-07 can implement the exact-origin policy against explicit positive and attacker-negative contracts. +- The focused security gate can use the same six suites after Plan 01-07 turns all 20 policy assertions green. + +## Self-Check: PASSED + +- All six owned test files and this summary exist. +- Task commits `668f85a58`, `14f451093`, and `5569b8259` are present in repository history. + +--- + +_Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability_ +_Completed: 2026-07-29_ From abea48026b3bcd90f25aed6ef5887b64d5506e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:42:04 +0200 Subject: [PATCH 012/125] test(01-03): add atomic reset revocation contracts - execute the operator CLI against real PostgreSQL credential tables - require complete target revocation and unrelated-admin isolation - force a mid-transaction database failure to prove rollback --- .../integration/admin-reset-password.test.ts | 51 ++- .../reset-password-revocation.test.ts | 293 ++++++++++++++++++ 2 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 tests/integration/reset-password-revocation.test.ts diff --git a/tests/integration/admin-reset-password.test.ts b/tests/integration/admin-reset-password.test.ts index 30d987d22..ca33f1497 100644 --- a/tests/integration/admin-reset-password.test.ts +++ b/tests/integration/admin-reset-password.test.ts @@ -12,7 +12,8 @@ * - 404 when the target user does not exist * - 422 when the new password is weak * - happy path wipes the target user's sessions, API tokens, refresh - * tokens and writes an `admin.user.reset-password` audit row + * tokens, trusted devices and step-up elevations while preserving the + * unrelated admin credential family */ import { NextRequest } from "next/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -179,8 +180,8 @@ describe("POST /api/admin/users/[id]/reset-password (real Postgres)", () => { expect(after!.passwordHash).toBe(before!.passwordHash); }); - it("wipes the target user's sessions, API tokens and refresh tokens and writes an audit row", async () => { - const { callerId } = await seedCaller("ADMIN"); + it("revokes the complete target credential family while preserving the admin", async () => { + const { callerId, sessionId: adminSessionId } = await seedCaller("ADMIN"); const targetId = await seedTargetUser(); const prisma = getPrismaClient(); @@ -206,6 +207,30 @@ describe("POST /api/admin/users/[id]/reset-password (real Postgres)", () => { revokedAt: null, }, }); + const targetTrustedDevice = await prisma.trustedDevice.create({ + data: { + userId: targetId, + tokenHash: "hashed-target-trusted-device", + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), + }, + }); + const targetElevation = await prisma.stepUpElevation.create({ + data: { + userId: targetId, + apiTokenId: targetApiToken.id, + tokenHash: "hashed-target-elevation", + method: "passkey", + expiresAt: new Date(Date.now() + 5 * 60 * 1000), + }, + }); + const adminApiToken = await prisma.apiToken.create({ + data: { + userId: callerId, + name: "Admin device", + tokenHash: "hashed-admin-token", + revoked: false, + }, + }); const { POST } = await import("@/app/api/admin/users/[id]/reset-password/route"); @@ -234,6 +259,26 @@ describe("POST /api/admin/users/[id]/reset-password (real Postgres)", () => { }); expect(refreshedRefresh?.revokedAt).not.toBeNull(); + // Trusted-device and fresh-factor proofs are credentials too. + expect( + await prisma.trustedDevice.findUnique({ + where: { id: targetTrustedDevice.id }, + }), + ).toBeNull(); + expect( + await prisma.stepUpElevation.findUnique({ + where: { id: targetElevation.id }, + }), + ).toBeNull(); + + // The administrator's unrelated credentials remain usable. + expect( + await prisma.session.findUnique({ where: { id: adminSessionId } }), + ).not.toBeNull(); + expect( + await prisma.apiToken.findUnique({ where: { id: adminApiToken.id } }), + ).toMatchObject({ revoked: false }); + // Audit row exists and is attributed to the admin caller. const audit = await prisma.auditLog.findFirst({ where: { action: "admin.user.reset-password" }, diff --git a/tests/integration/reset-password-revocation.test.ts b/tests/integration/reset-password-revocation.test.ts new file mode 100644 index 000000000..8f9e4e2bc --- /dev/null +++ b/tests/integration/reset-password-revocation.test.ts @@ -0,0 +1,293 @@ +/** + * SEC-07 / SEC-08 — operator password-reset recovery contract. + * + * The production `.mjs` CLI is executed as a real child process against the + * PostgreSQL Testcontainer. Success must be one credential-family event: + * password rotation plus revocation of browser, native, API, trusted-device, + * and elevation credentials. The rollback case installs a test-only trigger + * that raises during session deletion, proving the whole transaction unwinds + * without a production schema change or a test-only branch in the CLI. + */ +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const { hashPassword, verifyPassword } = await import("@/lib/auth/password"); + +const TARGET_ID = "reset-cli-target"; +const ADMIN_ID = "reset-cli-admin"; +const OLD_PASSWORD = "Old target password!123"; +const ADMIN_PASSWORD = "Unrelated admin password!456"; +const NEW_PASSWORD = "New target password!789"; + +type CliResult = { + code: number | null; + stdout: string; + stderr: string; +}; + +function runReset(identifier: string, password: string): Promise { + return new Promise((resolveResult, reject) => { + const child = spawn( + process.execPath, + [ + resolve(process.cwd(), "scripts/reset-password.mjs"), + identifier, + password, + ], + { + cwd: process.cwd(), + env: { ...process.env, DATABASE_URL: process.env.DATABASE_URL }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (code) => resolveResult({ code, stdout, stderr })); + }); +} + +async function seedCredentialFamily( + userId: string, + prefix: string, +): Promise { + const prisma = getPrismaClient(); + await prisma.session.create({ + data: { + id: `${prefix}-session`, + userId, + expiresAt: new Date(Date.now() + 60 * 60_000), + }, + }); + const apiToken = await prisma.apiToken.create({ + data: { + id: `${prefix}-api`, + userId, + name: `${prefix} API`, + tokenHash: `${prefix}-api-hash`, + permissions: ["*"], + revoked: false, + }, + }); + await prisma.refreshToken.create({ + data: { + id: `${prefix}-refresh`, + userId, + tokenHash: `${prefix}-refresh-hash`, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000), + revokedAt: null, + }, + }); + await prisma.trustedDevice.create({ + data: { + id: `${prefix}-trusted`, + userId, + tokenHash: `${prefix}-trusted-hash`, + expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000), + }, + }); + await prisma.stepUpElevation.create({ + data: { + id: `${prefix}-elevation`, + userId, + apiTokenId: apiToken.id, + tokenHash: `${prefix}-elevation-hash`, + method: "passkey", + expiresAt: new Date(Date.now() + 5 * 60_000), + }, + }); +} + +async function snapshot(userId: string) { + const prisma = getPrismaClient(); + const [user, sessions, apiTokens, refreshTokens, trustedDevices, elevations] = + await Promise.all([ + prisma.user.findUniqueOrThrow({ + where: { id: userId }, + select: { passwordHash: true }, + }), + prisma.session.findMany({ + where: { userId }, + orderBy: { id: "asc" }, + select: { id: true }, + }), + prisma.apiToken.findMany({ + where: { userId }, + orderBy: { id: "asc" }, + select: { id: true, revoked: true }, + }), + prisma.refreshToken.findMany({ + where: { userId }, + orderBy: { id: "asc" }, + select: { id: true, revokedAt: true }, + }), + prisma.trustedDevice.findMany({ + where: { userId }, + orderBy: { id: "asc" }, + select: { id: true }, + }), + prisma.stepUpElevation.findMany({ + where: { userId }, + orderBy: { id: "asc" }, + select: { id: true }, + }), + ]); + + return { + passwordHash: user.passwordHash, + sessions, + apiTokens, + refreshTokens: refreshTokens.map((row) => ({ + id: row.id, + revokedAt: row.revokedAt?.toISOString() ?? null, + })), + trustedDevices, + elevations, + }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + const prisma = getPrismaClient(); + await prisma.user.createMany({ + data: [ + { + id: TARGET_ID, + username: "reset-target", + email: "reset-target@example.test", + role: "USER", + passwordHash: await hashPassword(OLD_PASSWORD), + }, + { + id: ADMIN_ID, + username: "reset-admin", + email: "reset-admin@example.test", + role: "ADMIN", + passwordHash: await hashPassword(ADMIN_PASSWORD), + }, + ], + }); + await seedCredentialFamily(TARGET_ID, "target"); + await seedCredentialFamily(ADMIN_ID, "admin"); +}); + +describe("scripts/reset-password.mjs (real Postgres)", () => { + it("changes the target password and revokes every target credential family only", async () => { + const adminBefore = await snapshot(ADMIN_ID); + + const result = await runReset("reset-target@example.test", NEW_PASSWORD); + + expect(result.code).toBe(0); + expect(result.stdout).toContain('password updated for "reset-target"'); + expect(result.stdout).not.toContain(NEW_PASSWORD); + expect(result.stderr).not.toContain(NEW_PASSWORD); + + const targetAfter = await snapshot(TARGET_ID); + expect(targetAfter.passwordHash).toBeTruthy(); + expect(await verifyPassword(targetAfter.passwordHash!, NEW_PASSWORD)).toBe( + true, + ); + expect(await verifyPassword(targetAfter.passwordHash!, OLD_PASSWORD)).toBe( + false, + ); + expect(targetAfter.sessions).toEqual([]); + expect(targetAfter.apiTokens).toEqual([ + { id: "target-api", revoked: true }, + ]); + expect(targetAfter.refreshTokens).toHaveLength(1); + expect(targetAfter.refreshTokens[0].revokedAt).not.toBeNull(); + expect(targetAfter.trustedDevices).toEqual([]); + expect(targetAfter.elevations).toEqual([]); + + expect(await snapshot(ADMIN_ID)).toEqual(adminBefore); + expect( + await verifyPassword(adminBefore.passwordHash!, ADMIN_PASSWORD), + ).toBe(true); + }); + + it("leaves all state unchanged for unknown, ambiguous, and weak input", async () => { + const prisma = getPrismaClient(); + await prisma.user.createMany({ + data: [ + { + id: "ambiguous-username", + username: "ambiguous", + email: "first-ambiguous@example.test", + passwordHash: await hashPassword(OLD_PASSWORD), + }, + { + id: "ambiguous-email", + username: "second-ambiguous", + email: "ambiguous", + passwordHash: await hashPassword(OLD_PASSWORD), + }, + ], + }); + const before = await snapshot(TARGET_ID); + + const unknown = await runReset("nobody@example.test", NEW_PASSWORD); + const ambiguous = await runReset("ambiguous", NEW_PASSWORD); + const weak = await runReset("reset-target", "too-short"); + + expect(unknown.code).not.toBe(0); + expect(unknown.stderr).toContain("no user matches"); + expect(ambiguous.code).not.toBe(0); + expect(ambiguous.stderr).toContain("refusing to guess"); + expect(weak.code).not.toBe(0); + expect(weak.stderr).toContain("at least 12 characters"); + expect(await snapshot(TARGET_ID)).toEqual(before); + }); + + it("rolls back the password and every credential mutation after a real mid-transaction failure", async () => { + const prisma = getPrismaClient(); + const before = await snapshot(TARGET_ID); + + await prisma.$executeRawUnsafe(` + CREATE OR REPLACE FUNCTION test_reset_password_fail_session_delete() + RETURNS trigger AS $$ + BEGIN + IF OLD.user_id = '${TARGET_ID}' THEN + RAISE EXCEPTION 'forced reset rollback'; + END IF; + RETURN OLD; + END; + $$ LANGUAGE plpgsql + `); + await prisma.$executeRawUnsafe(` + CREATE TRIGGER test_reset_password_fail_session_delete + BEFORE DELETE ON sessions + FOR EACH ROW EXECUTE FUNCTION test_reset_password_fail_session_delete() + `); + + try { + const result = await runReset("reset-target", NEW_PASSWORD); + + expect(result.code).not.toBe(0); + expect(result.stderr).toContain("forced reset rollback"); + expect(await snapshot(TARGET_ID)).toEqual(before); + expect(await verifyPassword(before.passwordHash!, OLD_PASSWORD)).toBe( + true, + ); + } finally { + await prisma.$executeRawUnsafe(` + DROP TRIGGER IF EXISTS test_reset_password_fail_session_delete + ON sessions + `); + await prisma.$executeRawUnsafe(` + DROP FUNCTION IF EXISTS test_reset_password_fail_session_delete() + `); + } + }); +}); From cc8567d82ff68641ecd67dcfed69b7aea3827b2f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:42:20 +0200 Subject: [PATCH 013/125] docs(01-02): complete PWA and SSRF red contracts plan - document expected cache-isolation and transition-form failures - record preserved public and redirect behavior - capture atomic task commits and verification evidence --- .../01-02-SUMMARY.md | 130 ++++++++++++++++++ .../mcp/oauth/__tests__/connections.test.ts | 8 +- 2 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-02-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-02-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-02-SUMMARY.md new file mode 100644 index 000000000..05ef423c1 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-02-SUMMARY.md @@ -0,0 +1,130 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "02" +subsystem: testing +tags: [pwa, cache-storage, ssrf, ipv6, undici, vitest] + +requires: [] +provides: + - RED CacheStorage contracts preventing authenticated root HTML from entering the PWA cache + - RED session-end cleanup contracts covering current and legacy static/page/data caches + - RED literal and DNS-pinned verdict matrices for standard IPv4-embedded IPv6 forms +affects: [SEC-02, SEC-03, SEC-08, service-worker, safe-fetch] + +tech-stack: + added: [] + patterns: + - VM service-worker install fixtures with a signed-in health-data sentinel + - Table-driven transition-prefix cases shared conceptually across literal and connect-time boundaries + - Real Undici dispatcher failures distinguished by nested ENOTFOUND codes + +key-files: + created: [] + modified: + - src/__tests__/service-worker.test.ts + - src/__tests__/sw-security-hardening.test.ts + - src/lib/pwa/__tests__/query-persister.test.ts + - src/lib/validations/__tests__/notifications.test.ts + - src/lib/__tests__/safe-fetch-dispatcher.test.ts + - src/lib/__tests__/safe-fetch-pinned.test.ts + +key-decisions: + - "Exercise authenticated install at the CacheStorage boundary with a same-origin credential request and a unique health-data sentinel." + - "Cover only standard mapped, compatible, 6to4, NAT64-WKP, and RFC8215 local-use encodings; do not infer arbitrary operator NAT64 prefixes." + - "Treat ENOTFOUND from the pinned lookup as the pre-connect refusal signal and retain a real dual-stack/redirect transport control." + +patterns-established: + - "Security RED tests pair attacker-negative cases with explicit legitimate-positive controls." + - "Dispatcher regressions mock DNS answers, not HTTP response bodies." + +requirements-completed: [SEC-02, SEC-03, SEC-08] + +duration: 10min +completed: 2026-07-29 +--- + +# Phase 1 Plan 02: PWA Cache and Embedded-IPv4 SSRF RED Contracts Summary + +**Final-boundary RED contracts now expose authenticated PWA cache leakage and standard IPv4-embedded IPv6 SSRF bypasses while preserving immutable assets, public address families, dual-stack fallback, and redirect rechecks.** + +## Performance + +- **Duration:** 10 min +- **Started:** 2026-07-29T11:31:40Z +- **Completed:** 2026-07-29T11:41:40Z +- **Tasks:** 3 +- **Files modified:** 6 + +## Accomplishments + +- Added a service-worker VM install fixture whose signed-in `/` response contains a unique health sentinel; current code demonstrably stores and serves it offline. +- Added session-end cleanup coverage for current and legacy `healthlog-static-*`, `healthlog-pages-*`, and `healthlog-data-*` caches while preserving unrelated CacheStorage entries. +- Added matching literal URL/raw-IP and DNS-pinned matrices for mapped, compatible, 6to4, NAT64-WKP, and RFC8215 local-use forms, plus real transport controls for public dual-stack fallback and redirect destination rechecks. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Pin authenticated install, public offline shell, and legacy cache cleanup** - `3a813b51b` (test) +2. **Task 2: Add the canonical IPv6 transition-form classifier matrix** - `fb533147d` (test) +3. **Task 3: Pin the same verdict at the DNS-pinned dispatcher** - `e25080b21` (test) + +## Files Created/Modified + +- `src/__tests__/service-worker.test.ts` - Signed-in install fixture, cache-content sentinel assertion, and offline-shell boundary. +- `src/__tests__/sw-security-hardening.test.ts` - Source-level invariant that the immutable precache list excludes `/`. +- `src/lib/pwa/__tests__/query-persister.test.ts` - Current/legacy HealthLog cache-family cleanup and unrelated-cache preservation. +- `src/lib/validations/__tests__/notifications.test.ts` - Canonical transition-form literal URL/raw-IP matrix with public and syntax controls. +- `src/lib/__tests__/safe-fetch-dispatcher.test.ts` - Resolver-answer refusal matrix requiring pre-connect `ENOTFOUND`. +- `src/lib/__tests__/safe-fetch-pinned.test.ts` - Real dual-stack fallback and redirect-destination DNS recheck controls. + +## Decisions Made + +- Kept all work test-only; no production, dependency, lockfile, workspace, schema, or migration changes were made. +- Used the browser-equivalent same-origin credential mode in the service-worker `Cache.addAll` fixture so the root response models an install occurring while signed in. +- Used RFC-defined transition prefixes only, including the RFC 6052 bit placement for the RFC8215 `/48` local-use prefix. + +## Verification + +Focused six-file gate: + +- **Result:** expected RED, exit 1. +- **Tests:** 119 total; 21 intended failures and 98 preserved passes. +- **PWA RED reason:** `/` remains in `PRECACHE_URLS`, so signed-in root HTML enters `healthlog-static-*`, is returned offline instead of the inline 503 shell, and session cleanup omits static caches. +- **Classifier RED reason:** compatible, 6to4, NAT64-WKP, and RFC8215 private embeddings are currently accepted at both URL and raw-IP boundaries; malformed IPv6-shaped raw inputs are also accepted. +- **Dispatcher RED reason:** the same eight embedded-private DNS answers reach socket handling instead of being refused by the pinned lookup with `ENOTFOUND`. + +Positive-only gate: + +- **Result:** 11 passed, 74 skipped. +- Public immutable-asset support, the data-free fallback source contract, public IPv4/native IPv6/transition forms, real dual-stack fallback, and redirect DNS rechecking remain pinned. + +Formatting: + +- Prettier check passed for all six owned test files. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +The plan's `pnpm test -- ` spelling caused the repository test script to begin unrelated suites as well as the requested files. Verification was therefore isolated with `pnpm exec vitest run `; the exact owned-file failures and positive controls are reported above. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- SEC-02 implementation can remove `/` from install precaching and broaden session-end cleanup across every HealthLog cache version. +- SEC-03 implementation can centralize validated IPv6 parsing and standard embedded-IPv4 extraction in `isPublicIp`; the pinned dispatcher will inherit the corrected verdict. +- These suites are intentionally RED until the corresponding production-fix plans run. + +## Self-Check: PASSED + +All six owned test files and this summary exist. Task commits `3a813b51b`, `fb533147d`, and `e25080b21` are present in repository history. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* diff --git a/src/lib/mcp/oauth/__tests__/connections.test.ts b/src/lib/mcp/oauth/__tests__/connections.test.ts index 007d3b99f..b8a5b02c9 100644 --- a/src/lib/mcp/oauth/__tests__/connections.test.ts +++ b/src/lib/mcp/oauth/__tests__/connections.test.ts @@ -114,7 +114,11 @@ describe("rotateConnection — happy path", () => { it("uses the supplied transaction client for the entire rotation", async () => { TX.mcpOAuthConnection.findUnique.mockResolvedValue({ ...CONN }); - const out = await rotateConnection( + const transactionalRotate = rotateConnection as unknown as ( + args: Parameters[0], + tx: typeof TX, + ) => ReturnType; + const out = await transactionalRotate( { connectionId: "conn-1", presentedJti: "jti-current", @@ -122,7 +126,7 @@ describe("rotateConnection — happy path", () => { clientId: CONN.clientId, userId: "user-1", }, - TX as never, + TX, ); expect(out.ok).toBe(true); From 390c718b74abe40f7d520f01d557ca354cffca43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:42:30 +0200 Subject: [PATCH 014/125] test(01-01): reproduce MCP refresh replay race - coordinate concurrent exchanges with a real PostgreSQL row-lock barrier - verify family-wide successor revocation and cross-connection isolation --- .../mcp-oauth-refresh-race.test.ts | 288 ++++++++++++++++++ 1 file changed, 288 insertions(+) create mode 100644 tests/integration/mcp-oauth-refresh-race.test.ts diff --git a/tests/integration/mcp-oauth-refresh-race.test.ts b/tests/integration/mcp-oauth-refresh-race.test.ts new file mode 100644 index 000000000..ec2d44611 --- /dev/null +++ b/tests/integration/mcp-oauth-refresh-race.test.ts @@ -0,0 +1,288 @@ +/** + * MCP OAuth refresh-family safety against real PostgreSQL. + * + * The race test deliberately holds the connection row in a separate database + * transaction until both token exchanges are waiting on that row. Releasing + * the lock gives the two exchanges the same deterministic start point without + * replacing concurrency or persistence with mocks. + */ +import { beforeEach, describe, expect, it } from "vitest"; + +import { hashToken } from "@/lib/auth/hmac"; +import { issueApiToken } from "@/lib/auth/issue-token"; +import { resolveBearerToken } from "@/lib/auth/bearer"; +import { signArtifact } from "@/lib/mcp/oauth/artifacts"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +process.env.APP_URL = "https://health.example"; + +const CLIENT_ID = "https://connector.example/client.json"; +const RESOURCE = "https://health.example/mcp"; +const SCOPE = "health:read offline_access"; + +interface SeededConnection { + connectionId: string; + refreshToken: string; +} + +interface TokenBody { + access_token?: string; + refresh_token?: string; + error?: string; +} + +function tokenRequest(refreshToken: string): Request { + return new Request("https://health.example/api/mcp/oauth/token", { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-forwarded-for": "203.0.113.42", + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: CLIENT_ID, + }), + }); +} + +async function exchange(refreshToken: string): Promise { + const { POST } = await import("@/app/api/mcp/oauth/token/route"); + return POST(tokenRequest(refreshToken) as never); +} + +async function seedConnection(label: string): Promise { + const prisma = getPrismaClient(); + const userId = `mcp-refresh-${label}`; + const jti = `jti-${label}`; + + await prisma.user.create({ + data: { + id: userId, + username: userId, + email: `${userId}@example.test`, + timezone: "UTC", + }, + }); + const connection = await prisma.mcpOAuthConnection.create({ + data: { + userId, + clientId: CLIENT_ID, + clientName: `Connector ${label}`, + scope: SCOPE, + resource: RESOURCE, + currentJti: jti, + }, + }); + + // Every family starts with a real linked access row. Rotation must revoke it, + // and replay-family revocation must cover every later linked successor too. + await issueApiToken({ + userId, + name: `Initial access ${label}`, + permissions: ["health:read"], + expiresInMinutes: 60, + mcpConnectionId: connection.id, + }); + + return { + connectionId: connection.id, + refreshToken: signArtifact( + "refreshToken", + { + jti, + cid: connection.id, + sub: userId, + client_id: CLIENT_ID, + scope: SCOPE, + resource: RESOURCE, + }, + 60 * 60 * 1000, + ), + }; +} + +async function waitForBlockedConnectionExchanges( + expected: number, +): Promise { + const prisma = getPrismaClient(); + const deadline = Date.now() + 10_000; + + while (Date.now() < deadline) { + const rows = await prisma.$queryRaw>` + SELECT count(*)::bigint AS waiting + FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND wait_event_type = 'Lock' + AND query ILIKE '%mcp_oauth_connections%' + `; + if (Number(rows[0]?.waiting ?? 0) >= expected) return; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + + throw new Error( + `Timed out waiting for ${expected} refresh exchanges at the database barrier`, + ); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); +}); + +describe("MCP OAuth refresh rotation (real PostgreSQL)", () => { + it("keeps an ordinary single-client rotation usable", async () => { + const seeded = await seedConnection("sequential"); + + const first = await exchange(seeded.refreshToken); + expect(first.status).toBe(200); + const firstBody = (await first.json()) as TokenBody; + expect(firstBody.access_token).toMatch(/^hlk_/); + expect(firstBody.refresh_token).toMatch(/^hlrt_/); + + await expect( + resolveBearerToken(firstBody.access_token!, { + kind: "scope", + scope: "health:read", + }), + ).resolves.toMatchObject({ + user: { id: "mcp-refresh-sequential" }, + permissions: ["health:read"], + }); + + const second = await exchange(firstBody.refresh_token!); + expect(second.status).toBe(200); + const secondBody = (await second.json()) as TokenBody; + await expect( + resolveBearerToken(secondBody.access_token!, { + kind: "scope", + scope: "health:read", + }), + ).resolves.toMatchObject({ + user: { id: "mcp-refresh-sequential" }, + }); + }); + + it("revokes the whole raced family and every returned successor without harming another connection", async () => { + const prisma = getPrismaClient(); + const raced = await seedConnection("raced"); + const unrelated = await seedConnection("unrelated"); + + let releaseLock!: () => void; + let markLocked!: () => void; + const release = new Promise((resolve) => { + releaseLock = resolve; + }); + const locked = new Promise((resolve) => { + markLocked = resolve; + }); + + const blocker = prisma.$transaction( + async (tx) => { + await tx.$queryRaw` + SELECT id + FROM mcp_oauth_connections + WHERE id = ${raced.connectionId} + FOR UPDATE + `; + markLocked(); + await release; + }, + { timeout: 20_000 }, + ); + + await locked; + const contenders = [ + exchange(raced.refreshToken), + exchange(raced.refreshToken), + ]; + + try { + await waitForBlockedConnectionExchanges(2); + } finally { + releaseLock(); + await blocker; + } + + const responses = await Promise.all(contenders); + const bodies = await Promise.all( + responses.map((response) => response.json() as Promise), + ); + const returnedSuccessors = bodies.filter( + (body): body is Required< + Pick + > => + typeof body.access_token === "string" && + typeof body.refresh_token === "string", + ); + + // The current implementation has one CAS winner and one loser. A secure + // implementation may also reject both, but it may never leave a returned + // winner usable once the simultaneous replay has been observed. + expect( + responses.filter((response) => response.status === 400).length, + ).toBeGreaterThanOrEqual(1); + expect(returnedSuccessors.length).toBeLessThanOrEqual(1); + + for (const successor of returnedSuccessors) { + let accessUsable = true; + try { + await resolveBearerToken(successor.access_token, { + kind: "scope", + scope: "health:read", + }); + } catch { + accessUsable = false; + } + expect.soft(accessUsable, "raced access successor survived").toBe(false); + + const refreshResult = await exchange(successor.refresh_token); + expect + .soft(refreshResult.status, "raced refresh successor survived") + .toBe(400); + } + + const racedRow = await prisma.mcpOAuthConnection.findUniqueOrThrow({ + where: { id: raced.connectionId }, + }); + expect + .soft(racedRow.revokedAt, "raced connection was not revoked") + .toBeInstanceOf(Date); + + const racedAccessRows = await prisma.apiToken.findMany({ + where: { mcpConnectionId: raced.connectionId }, + select: { tokenHash: true, revoked: true }, + }); + expect(racedAccessRows.length).toBeGreaterThan(0); + expect + .soft( + racedAccessRows.every((row) => row.revoked), + "a linked access successor remained live", + ) + .toBe(true); + + // Cross-family safety: an unrelated connection still rotates, and its + // returned access token resolves through the ordinary bearer verifier. + const unrelatedResult = await exchange(unrelated.refreshToken); + expect(unrelatedResult.status).toBe(200); + const unrelatedBody = (await unrelatedResult.json()) as TokenBody; + await expect( + resolveBearerToken(unrelatedBody.access_token!, { + kind: "scope", + scope: "health:read", + }), + ).resolves.toMatchObject({ + user: { id: "mcp-refresh-unrelated" }, + }); + + // No plaintext credential is persisted; this also makes the post-race + // verifier assertion use the same HMAC lookup the product uses. + expect( + racedAccessRows.some( + (row) => + returnedSuccessors[0]?.access_token && + row.tokenHash === hashToken(returnedSuccessors[0].access_token), + ), + ).toBe(returnedSuccessors.length === 1); + }); +}); From 51f5f4395707b6c9f28c8d502d378665a9bf7cdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:43:27 +0200 Subject: [PATCH 015/125] test(01-01): preserve explicit transaction contract - keep the red transaction-client call aligned with the planned signature - isolate the concurrent documentation commit from MCP test content --- src/lib/mcp/oauth/__tests__/connections.test.ts | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/lib/mcp/oauth/__tests__/connections.test.ts b/src/lib/mcp/oauth/__tests__/connections.test.ts index b8a5b02c9..007d3b99f 100644 --- a/src/lib/mcp/oauth/__tests__/connections.test.ts +++ b/src/lib/mcp/oauth/__tests__/connections.test.ts @@ -114,11 +114,7 @@ describe("rotateConnection — happy path", () => { it("uses the supplied transaction client for the entire rotation", async () => { TX.mcpOAuthConnection.findUnique.mockResolvedValue({ ...CONN }); - const transactionalRotate = rotateConnection as unknown as ( - args: Parameters[0], - tx: typeof TX, - ) => ReturnType; - const out = await transactionalRotate( + const out = await rotateConnection( { connectionId: "conn-1", presentedJti: "jti-current", @@ -126,7 +122,7 @@ describe("rotateConnection — happy path", () => { clientId: CONN.clientId, userId: "user-1", }, - TX, + TX as never, ); expect(out.ok).toBe(true); From 8b5e928efaded6ca427e50847c0592afa6b21a59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:43:49 +0200 Subject: [PATCH 016/125] docs(01-03): complete credential security contracts plan - document passkey enrollment and reset recovery RED boundaries - record focused expected failures and green parity evidence - preserve production, schema and scanner-owned files untouched --- .../01-03-SUMMARY.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-03-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-03-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-03-SUMMARY.md new file mode 100644 index 000000000..0f76be473 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-03-SUMMARY.md @@ -0,0 +1,122 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "03" +subsystem: auth-testing +tags: [vitest, testcontainers, postgres, webauthn, credential-revocation] + +requires: + - phase: 01-security-research + provides: Cookie-only enrollment and atomic recovery threat model +provides: + - RED final-boundary contracts for fresh existing-factor passkey enrollment + - RED user/purpose/session/expiry/single-use challenge-binding matrix + - RED real-PostgreSQL password-reset revocation and rollback contract + - Green Admin API reset parity coverage for the complete credential family +affects: [01-08, 01-09, passkey-enrollment, password-recovery] + +tech-stack: + added: [] + patterns: + - Real PostgreSQL persistence assertions around mocked WebAuthn cryptography + - Test-only PostgreSQL trigger injection for transaction rollback proof + +key-files: + created: + - src/components/settings/security-section/__tests__/passkey-enrollment-reauth.test.tsx + - tests/integration/reset-password-revocation.test.ts + modified: + - tests/integration/passkey-register.test.ts + - src/app/api/auth/passkeys/__tests__/route.test.ts + - tests/integration/admin-reset-password.test.ts + +key-decisions: + - "The cookie reauthentication proof is presented before register-options; an ambient cookie or Bearer elevation is never enrollment authority." + - "Rollback is forced with a test-only PostgreSQL DELETE trigger so atomicity is proven without a schema migration or production test hook." + +patterns-established: + - "Enrollment RED matrix: positive existing factors and attacker-negative binding cases reach the final route and database boundaries." + - "Recovery RED matrix: execute the production CLI as a child process and inspect every credential-family table." + +requirements-completed: [SEC-05, SEC-07, SEC-08] + +duration: 11min +completed: 2026-07-29 +--- + +# Phase 01 Plan 03: Credential Enrollment and Recovery RED Contracts Summary + +**Cookie-only passkey challenge-binding and atomic PostgreSQL reset/revocation behavior are specified as durable RED contracts before production remediation.** + +## Performance + +- **Duration:** 11 min +- **Started:** 2026-07-29T11:31:33Z +- **Completed:** 2026-07-29T11:42:31Z +- **Tasks:** 3 +- **Files modified:** 5 + +## Accomplishments + +- Added real-PostgreSQL passkey enrollment coverage for password, primary passkey, TOTP, and MFA WebAuthn proof, plus ambient cookie, Bearer, stale, foreign-user/session, wrong-purpose, expired, and replay attacks. +- Pinned the Settings flow to finish reauthentication before requesting registration options, preserving one cookie-bound options → WebAuthn → verify ceremony. +- Added a production-CLI subprocess suite proving complete credential-family revocation, unrelated-admin isolation, safe input failures, and rollback through an injected database failure. +- Extended Admin API parity coverage to trusted devices and step-up elevations while confirming the administrator's own session and API token survive. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Add two-leg cookie-only, existing-factor passkey regressions** - `9f6cd86c0` (test) +2. **Task 2: Pin the passkey enrollment reauthentication UX** - `3f4afb26a` (test) +3. **Task 3: Add atomic password-reset revocation and rollback coverage** - `abea48026` (test) + +## Files Created/Modified + +- `tests/integration/passkey-register.test.ts` - Final route/database enrollment proof and challenge-binding matrix. +- `src/app/api/auth/passkeys/__tests__/route.test.ts` - Static route guard contracts requiring cookie-only handling on both registration legs. +- `src/components/settings/security-section/__tests__/passkey-enrollment-reauth.test.tsx` - Factor availability and proof-before-challenge UI ordering contract. +- `tests/integration/reset-password-revocation.test.ts` - Real CLI/PostgreSQL reset, revocation, isolation, validation, and rollback coverage. +- `tests/integration/admin-reset-password.test.ts` - Complete credential-family parity assertions for the existing Admin API path. + +## Verification + +The Wave 0 plan intentionally requires failing contracts against current production code. + +- Passkey PostgreSQL suite: **12 failed, 2 passed**. Expected RED causes are generic `registration` challenge purpose, ambient/wildcard authorization, and missing stale/session/user/purpose/expiry/replay checks. +- Passkey route unit suite: **2 failed, 3 passed**. Expected RED cause is both registration routes still using generic `requireAuth` without same-session proof handling. +- Passkey UI suite: **2 failed, 1 passed**. Expected RED cause is the missing factor chooser and missing proof-before-options step; the existing options → registration → verify ordering passes. +- Reset/Admin PostgreSQL suites: **2 failed, 6 passed**. The Admin parity suite is green; CLI RED causes are surviving target credentials and no transaction path reaching the injected rollback failure. +- Focused ESLint, Prettier, and `git diff --check` passed for all owned test files. + +## Decisions Made + +- Used the existing step-up proof vocabulary (`password`, `passkey`, `totp`, `webauthn`) for the enrollment contract, but explicitly prohibited the Bearer elevation transport. +- Mocked only WebAuthn cryptography; route policy, AuthChallenge persistence, Passkey persistence, CLI execution, and credential revocation use actual application/database boundaries. +- Forced rollback through PostgreSQL itself so the future CLI must encounter a real statement failure after opening its transaction. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +- In this shell, forwarding paths through `pnpm test:integration -- ...` caused Vitest to select the full integration suite. Focused verification used the equivalent direct command, `pnpm exec vitest run --config vitest.integration.config.mts `, while retaining the plan's expected non-zero RED assertion. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- Plan 01-08 can implement the cookie proof, bound challenge redemption, UI reauthentication, and transactional CLI directly against these RED contracts. +- The package, lock, workspace, schema, migrations, and production implementation files remain untouched. + +## Self-Check: PASSED + +- All five owned test files and this summary exist. +- Task commits `9f6cd86c0`, `3f4afb26a`, and `abea48026` are present. +- Summary formatting and diff whitespace checks pass. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From 0c29e6e6b085af3e2ed38d6e1e5c11b78c6dbed0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:44:28 +0200 Subject: [PATCH 017/125] test(01-01): keep bearer regression lint-clean - avoid React hook-name classification for token fixture helpers --- .../bearer-scope-enforcement.test.ts | 36 +++++++++---------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/tests/integration/bearer-scope-enforcement.test.ts b/tests/integration/bearer-scope-enforcement.test.ts index 251701285..b7e840acb 100644 --- a/tests/integration/bearer-scope-enforcement.test.ts +++ b/tests/integration/bearer-scope-enforcement.test.ts @@ -71,7 +71,7 @@ beforeEach(async () => { }); /** Mint a real `ApiToken` row with the given scopes and arm the Bearer header. */ -async function useToken(permissions: string[], label = "t"): Promise { +async function armToken(permissions: string[], label = "t"): Promise { const raw = `hlk_${label}_${"0".repeat(48)}`; await getPrismaClient().apiToken.create({ data: { @@ -85,12 +85,12 @@ async function useToken(permissions: string[], label = "t"): Promise { return raw; } -async function useExpiringToken( +async function armExpiringToken( permissions: string[], label: string, state: { expiresAt?: Date; revoked?: boolean } = {}, ): Promise { - const raw = await useToken(permissions, label); + const raw = await armToken(permissions, label); await getPrismaClient().apiToken.update({ where: { tokenHash: hashToken(raw) }, data: state, @@ -149,7 +149,7 @@ describe("B1 — a narrow token cannot read the full-account export", () => { // The vulnerability in one request: this is the token the settings card // told users to hand to third-party automations, reading every row the // account holds. - await useToken(["medication:ingest"], "narrow"); + await armToken(["medication:ingest"], "narrow"); const { GET } = await import("@/app/api/export/full-backup/route"); const res = await GET(req("/api/export/full-backup")); @@ -166,7 +166,7 @@ describe("B1 — a narrow token cannot read the full-account export", () => { }); it("refuses the same token on GET /api/labs with 403", async () => { - await useToken(["medication:ingest"], "narrow2"); + await armToken(["medication:ingest"], "narrow2"); const { GET } = await import("@/app/api/labs/route"); const res = await GET(req("/api/labs")); expect(res.status).toBe(403); @@ -187,7 +187,7 @@ describe("B2/B3 — the medication-ingest surface is unchanged", () => { // resolution — so the fail-closed default cannot reach it. This is the // one flow a fail-closed default could plausibly have broken. const medId = await seedMedication(); - await useToken(["medication:ingest", `medication:${medId}:ingest`], "pair"); + await armToken(["medication:ingest", `medication:${medId}:ingest`], "pair"); const { POST } = await import("@/app/api/ingest/medication/route"); const request = new NextRequest( @@ -217,7 +217,7 @@ describe("B2/B3 — the medication-ingest surface is unchanged", () => { // The pre-existing second gate. This is why the retired `/api/tokens` // mint never worked for its advertised purpose. await seedMedication(); - await useToken(["medication:ingest"], "familyonly"); + await armToken(["medication:ingest"], "familyonly"); const { POST } = await import("@/app/api/ingest/medication/route"); const request = new NextRequest( @@ -245,14 +245,14 @@ describe("B4 — the native client is not broken", () => { // Login, passkey login-verify and refresh rotation all mint `["*"]`, so // this is exactly the credential the iOS app holds. If this case ever // goes red, the native client is down. - await useToken(["*"], "wildcard"); + await armToken(["*"], "wildcard"); const { GET } = await import("@/app/api/export/full-backup/route"); const res = await GET(req("/api/export/full-backup")); expect(res.status).toBe(200); }); it("admits a ['*'] token on a batch ingest route", async () => { - await useToken(["*"], "wildcardbatch"); + await armToken(["*"], "wildcardbatch"); const { POST } = await import("@/app/api/measurements/batch/route"); const request = new NextRequest( "https://health.example/api/measurements/batch", @@ -285,14 +285,14 @@ describe("B5 — an MCP token is audience-bound to /mcp", () => { it("refuses ['health:read'] on a REST read", async () => { // Narrowing, not breakage: the token's REST read leg was never a feature // an MCP client used. Its audience is now /mcp alone. - await useToken(["health:read"], "mcpread"); + await armToken(["health:read"], "mcpread"); const { GET } = await import("@/app/api/export/full-backup/route"); const res = await GET(req("/api/export/full-backup")); expect(res.status).toBe(403); }); it("still resolves the same token on the /mcp wire", async () => { - const raw = await useToken(["health:read"], "mcpread2"); + const raw = await armToken(["health:read"], "mcpread2"); const { resolveMcpAuthContext } = await import("@/lib/mcp/auth"); const ctx = await resolveMcpAuthContext(raw); @@ -304,7 +304,7 @@ describe("B5 — an MCP token is audience-bound to /mcp", () => { }); it("grants write on /mcp only for a consented health:write token", async () => { - const raw = await useToken(["health:read", "health:write"], "mcprw"); + const raw = await armToken(["health:read", "health:write"], "mcprw"); const { resolveMcpAuthContext } = await import("@/lib/mcp/auth"); const ctx = await resolveMcpAuthContext(raw); expect(ctx.canWrite).toBe(true); @@ -325,7 +325,7 @@ describe("B5b — MCP health reads require an explicit read grant", () => { ["health:read", ["health:read"]], ["wildcard", ["*"]], ])("admits %s through capability registration", async (_label, scopes) => { - const raw = await useToken(scopes, `mcp-positive-${_label}`); + const raw = await armToken(scopes, `mcp-positive-${_label}`); const res = await postMcp(raw, "tools/list"); @@ -345,7 +345,7 @@ describe("B5b — MCP health reads require an explicit read grant", () => { ])( "denies a same-user %s token before a real lab read", async (_label, scopes) => { - const raw = await useToken(scopes, `mcp-negative-${_label}`); + const raw = await armToken(scopes, `mcp-negative-${_label}`); await getPrismaClient().labResult.create({ data: { userId: USER_ID, @@ -373,7 +373,7 @@ describe("B5b — MCP health reads require an explicit read grant", () => { ], ["revoked", { revoked: true }], ])("denies an explicit but %s health:read credential", async (_label, state) => { - const raw = await useExpiringToken( + const raw = await armExpiringToken( ["health:read"], `mcp-${_label}`, state, @@ -387,7 +387,7 @@ describe("B5b — MCP health reads require an explicit read grant", () => { describe("B6 — a declared scope grants only what it names", () => { it("admits ['fhir:read'] on the FHIR face", async () => { - await useToken(["fhir:read"], "fhir"); + await armToken(["fhir:read"], "fhir"); const { GET } = await import("@/app/api/fhir/Observation/route"); const res = await GET(req("/api/fhir/Observation")); expect(res.status).toBe(200); @@ -396,7 +396,7 @@ describe("B6 — a declared scope grants only what it names", () => { it("refuses the same token on /api/labs, which declares no scope", async () => { // The whole point of the inversion: holding a scope buys the routes that // name it, and nothing else. - await useToken(["fhir:read"], "fhir2"); + await armToken(["fhir:read"], "fhir2"); const { GET } = await import("@/app/api/labs/route"); const res = await GET(req("/api/labs")); expect(res.status).toBe(403); @@ -425,7 +425,7 @@ describe("B7 — cookie sessions are untouched", () => { where: { id: USER_ID }, data: { role: "ADMIN" }, }); - await useToken(["*"], "adminwild"); + await armToken(["*"], "adminwild"); const { requireAdmin, HttpError } = await import("@/lib/api-handler"); await expect(requireAdmin()).rejects.toBeInstanceOf(HttpError); From 88515696586702e504263d9e60c05c21adfd3530 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:45:23 +0200 Subject: [PATCH 018/125] fix(01-06): isolate PWA install from authenticated root - remove the dashboard root from immutable precaching - evict same-version root entries left by older workers - retain public logos and favicon for offline support --- public/sw.js | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/public/sw.js b/public/sw.js index 8b437990f..fd60ece14 100644 --- a/public/sw.js +++ b/public/sw.js @@ -103,15 +103,24 @@ function isCacheableApiResponse(response, bodyText) { return true; } -// App shell files to precache on install -const PRECACHE_URLS = ["/", "/logo-192.png", "/logo-512.png", "/favicon.svg"]; +// Public immutable assets to precache on install. Never include `/`: installing +// while signed in would fetch the authenticated Dashboard document with the +// browser's session cookies and persist that health-bearing HTML in +// CacheStorage for replay after logout. +const PRECACHE_URLS = ["/logo-192.png", "/logo-512.png", "/favicon.svg"]; -// ── Install: precache app shell ────────────────────────────────────────────── +// ── Install: precache public immutable assets ──────────────────────────────── self.addEventListener("install", (event) => { event.waitUntil( caches .open(STATIC_CACHE) - .then((cache) => cache.addAll(PRECACHE_URLS)) + .then(async (cache) => { + // An updated worker can reuse the same release-scoped cache when its + // source changes without a version bump. Remove a root document left + // by the previous worker before adding the safe public asset set. + await cache.delete("/"); + await cache.addAll(PRECACHE_URLS); + }) .then(() => self.skipWaiting()), ); }); From 31248ec273c2f53f2f2f3907249c5f567855fcd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:45:42 +0200 Subject: [PATCH 019/125] docs(01-01): complete MCP security red contracts plan - record expected-red authorization and refresh-race evidence - hand off exact enforcement targets to Plan 01-05 --- .../01-01-SUMMARY.md | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-01-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-01-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-01-SUMMARY.md new file mode 100644 index 000000000..ee64b085b --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-01-SUMMARY.md @@ -0,0 +1,126 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "01" +subsystem: testing +tags: [mcp, oauth, bearer-scopes, postgres, concurrency, security] + +requires: [] +provides: + - Explicit health:read and wildcard MCP positive contracts + - Final-route denial contracts for unrelated, expired, and revoked bearers + - Transactional refresh rotation, rollback, and CAS-loss contracts + - Deterministic real-Postgres concurrent refresh replay regression +affects: [01-05-mcp-security-enforcement, SEC-01, SEC-04, SEC-08] + +tech-stack: + added: [] + patterns: + - Spy on MCP server construction and reader invocation at the route boundary + - Hold a PostgreSQL row lock until both refresh contenders reach the database barrier + +key-files: + created: + - tests/integration/mcp-oauth-refresh-race.test.ts + modified: + - src/lib/mcp/__tests__/auth.test.ts + - src/lib/mcp/__tests__/scopes.test.ts + - src/app/mcp/__tests__/route.test.ts + - tests/integration/bearer-scope-enforcement.test.ts + - src/app/api/mcp/oauth/__tests__/token.test.ts + - src/lib/mcp/oauth/__tests__/connections.test.ts + +key-decisions: + - "Accept either 401 or 403 for insufficient MCP read scope while requiring denial before rate limiting, module lookup, server construction, or data access." + - "Use a real PostgreSQL row-lock barrier so both refresh exchanges contend deterministically without mocking or sequentializing the race." + +patterns-established: + - "MCP read authorization tests pair health:read and wildcard positives with unrelated-scope negatives at helper, route, and database-reader boundaries." + - "Refresh race tests resolve every returned successor through normal bearer/token routes and separately verify cross-connection isolation." + +requirements-completed: [SEC-01, SEC-04, SEC-08] + +duration: 14min +completed: 2026-07-29 +--- + +# Phase 01 Plan 01: MCP Authorization and Refresh Replay Red Contracts Summary + +**Least-privilege MCP read and atomic refresh-family security contracts, including a deterministic real-Postgres replay race** + +## Performance + +- **Duration:** 14 min +- **Started:** 2026-07-29T11:31:22Z +- **Completed:** 2026-07-29T11:44:47Z +- **Tasks:** 3 +- **Files modified:** 8 + +## Accomplishments + +- Pinned `health:read` and wildcard access as positive MCP capability and reader paths while proving write-only, ingest, notification, FHIR, unrelated, expired, and revoked credentials cannot pass. +- Required insufficient-scope denial before rate limiting, module resolution, MCP server construction, or the real lab reader. +- Specified one transaction-client boundary for connection rotation and successor insertion, including CAS-loss family revocation and insertion-failure rollback. +- Reproduced the concurrent refresh vulnerability against real PostgreSQL while preserving a passing sequential rotation and an unrelated connection. + +## Task Commits + +1. **Task 1: Pin MCP read authorization at the final route and reader boundary** - `6c0f47aca` (test) +2. **Task 2: Specify sequential rotation and replay-family semantics** - `38501f657` (test) +3. **Task 3: Add the real-Postgres concurrent refresh replay regression** - `390c718b7` (test) + +Supporting Plan 01-01 commits: + +- `51f5f4395` - restored the explicit transaction-client contract after a concurrent HEAD advance +- `0c29e6e6b` - renamed bearer fixture helpers to keep the owned suite lint-clean + +## Files Created/Modified + +- `src/lib/mcp/__tests__/auth.test.ts` - Explicit read capability matrix on resolved MCP contexts. +- `src/lib/mcp/__tests__/scopes.test.ts` - Canonical read-scope predicate positives and negatives. +- `src/app/mcp/__tests__/route.test.ts` - Registration and reader spies at the final `/mcp` boundary. +- `tests/integration/bearer-scope-enforcement.test.ts` - Real bearer, module, route, and lab-reader enforcement. +- `src/app/api/mcp/oauth/__tests__/token.test.ts` - Transactional successor issuance and rollback contract. +- `src/lib/mcp/oauth/__tests__/connections.test.ts` - Transaction-client and CAS-loser family-revocation contract. +- `tests/integration/mcp-oauth-refresh-race.test.ts` - Real-Postgres sequential and concurrent refresh-family regression. + +## Verification + +- MCP scope unit/route target: **expected RED**, 17 failures and 29 passes. Failures are limited to implicit read capability and insufficient-scope requests reaching HTTP 200. +- Bearer integration target: **expected RED**, 5 failures and 17 passes. Failures are limited to same-user unrelated scopes reaching the real MCP lab reader; `health:read`, wildcard, expired/revoked denial, REST ownership, and existing bearer positives pass. +- OAuth token/connection target: **expected RED**, 4 failures and 19 passes. Failures are limited to ignored transaction clients, non-transactional successor issuance, missing rollback, and missing CAS-loss family revocation. +- PostgreSQL refresh-race target: **expected RED**, 1 failed and 1 passed test. The race reports four expected soft failures: live access successor, usable refresh successor, null `revokedAt`, and one live linked access row. Sequential rotation and unrelated-connection isolation pass. +- ESLint passed for all seven owned test files. +- Release branch, binary scanner patch digest, and all three immutable working-file hashes matched `01-EXECUTION-BASELINE.md` before and after execution. + +## Decisions Made + +- Kept the insufficient-scope wire status flexible between 401 and 403 because both are denial semantics allowed by the plan; the strict invariant is that no downstream capability or reader is reached. +- Used PostgreSQL `pg_stat_activity` only to observe two lock waiters; the test does not alter production synchronization and releases contenders via a row lock held by a separate real transaction. + +## Deviations from Plan + +None - plan executed as test-only RED contracts without production changes. + +## Issues Encountered + +- The plan's `pnpm test -- ` form passes `--` through to Vitest 4 and can select the whole unit suite. Focused evidence was therefore collected with the equivalent direct `pnpm exec vitest run ` commands. +- A concurrent Plan 01-02 documentation commit advanced `HEAD` between staging and an amend attempt. Shared history was not rewritten; `51f5f4395` restored the exact Plan 01-01 file content without changing the other agent's summary. + +## User Setup Required + +None - the existing Testcontainers PostgreSQL harness provides the integration environment. + +## Next Phase Readiness + +- Plan 01-05 can implement explicit MCP read scope enforcement against the helper, route, and real-reader failures. +- Plan 01-05 can move row locking, JTI validation, family/access revocation, and successor insertion into one transaction until the unit and real-Postgres race contracts turn green. + +## Self-Check: PASSED + +- All seven owned test files and this summary exist. +- All five Plan 01-01 commits are present in repository history. +- Scanner baseline hashes remain unchanged. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From 338dbe6c1f56df2bbf218fd8119a4080b8b13ca6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:46:15 +0200 Subject: [PATCH 020/125] fix(01-06): purge owned PWA caches on session end - delete current and legacy HealthLog static page and data caches - preserve unrelated CacheStorage namespaces during activation and logout - keep IndexedDB query-cache removal in the session-end boundary --- public/sw.js | 13 ++++++++++++- src/lib/pwa/query-persister.ts | 10 +++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/public/sw.js b/public/sw.js index fd60ece14..499698631 100644 --- a/public/sw.js +++ b/public/sw.js @@ -51,6 +51,16 @@ const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // next paint. Network-first restores read-after-write freshness while keeping // the offline fallback. const DATA_CACHE = `healthlog-data-${CACHE_VERSION}`; +// Keep this ownership contract aligned with +// `clearOfflineCachesForSessionEnd()` in `query-persister.ts`. Cache cleanup +// must cover every current/legacy HealthLog cache without deleting unrelated +// CacheStorage namespaces owned by another app on the same origin. +const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/; +const CURRENT_HEALTHLOG_CACHES = new Set([ + STATIC_CACHE, + PAGE_CACHE, + DATA_CACHE, +]); const MAX_STATIC_ENTRIES = 150; const MAX_PAGE_ENTRIES = 30; const MAX_DATA_ENTRIES = 60; @@ -136,7 +146,8 @@ self.addEventListener("activate", (event) => { keys .filter( (k) => - k !== STATIC_CACHE && k !== PAGE_CACHE && k !== DATA_CACHE, + HEALTHLOG_CACHE_NAME_RE.test(k) && + !CURRENT_HEALTHLOG_CACHES.has(k), ) .map((k) => caches.delete(k)), ), diff --git a/src/lib/pwa/query-persister.ts b/src/lib/pwa/query-persister.ts index 3fc3e2067..805b44f04 100644 --- a/src/lib/pwa/query-persister.ts +++ b/src/lib/pwa/query-persister.ts @@ -39,6 +39,10 @@ const STORE = "kv"; const KEY = "react-query"; const VERSION_KEY = "react-query-version"; const MAX_AGE_MS = 24 * 60 * 60 * 1000; // discard anything older than a day +// Keep this ownership contract aligned with `public/sw.js`. Session-end +// cleanup removes every current/legacy HealthLog cache family while leaving +// unrelated CacheStorage namespaces on the same origin untouched. +const HEALTHLOG_CACHE_NAME_RE = /^healthlog-(?:static|pages|data)-/; /** * Upper bound on the serialized snapshot. The allowlist already caps WHAT is @@ -326,11 +330,7 @@ export async function clearOfflineCachesForSessionEnd(): Promise { const keys = await caches.keys(); await Promise.all( keys - .filter( - (k) => - k.startsWith("healthlog-data-") || - k.startsWith("healthlog-pages-"), - ) + .filter((k) => HEALTHLOG_CACHE_NAME_RE.test(k)) .map((k) => caches.delete(k)), ); } catch { From 8ca6ebd773c36f6890428f9517b1645abf096558 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:47:17 +0200 Subject: [PATCH 021/125] docs(01-06): complete PWA cache isolation plan - document public-only install and data-free offline behavior - record scoped current and legacy cache eviction - capture green final-boundary and session-hook verification --- .../01-06-SUMMARY.md | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-06-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-06-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-06-SUMMARY.md new file mode 100644 index 000000000..a1549f249 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-06-SUMMARY.md @@ -0,0 +1,110 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "06" +subsystem: pwa +tags: [service-worker, cache-storage, indexeddb, privacy, offline] + +requires: + - phase: 01-02 + provides: RED authenticated-install and session-cache isolation contracts +provides: + - Public-asset-only service-worker install with no authenticated root precache + - Same-version root eviction and data-free offline fallback + - Current and legacy HealthLog static/page/data cache cleanup at session end + - App-scoped activation cleanup that preserves unrelated CacheStorage namespaces +affects: [SEC-02, SEC-08, pwa, logout, offline] + +tech-stack: + added: [] + patterns: + - Explicit owned-cache regex shared by service-worker and session cleanup boundaries + - Same-version legacy-entry removal during service-worker install + +key-files: + created: [] + modified: + - public/sw.js + - src/lib/pwa/query-persister.ts + +key-decisions: + - "Precache only immutable public logos/favicon and explicitly delete a root entry left in the same release-scoped cache." + - "Scope activation and logout deletion to healthlog-{static,pages,data}-* instead of deleting unrelated CacheStorage namespaces." + +patterns-established: + - "PWA cache ownership uses the same anchored HealthLog family matcher at worker activation and client session end." + - "Session-end cleanup clears IndexedDB query persistence before CacheStorage families on a best-effort basis." + +requirements-completed: [SEC-02, SEC-08] + +duration: 3min +completed: 2026-07-29 +--- + +# Phase 1 Plan 06: PWA Account-Data Cache Isolation Summary + +**Public-only PWA installation, data-free root fallback, and complete app-scoped logout eviction prevent authenticated Dashboard HTML from surviving a session.** + +## Performance + +- **Duration:** 3 min +- **Started:** 2026-07-29T11:44:13Z +- **Completed:** 2026-07-29T11:47:13Z +- **Tasks:** 2 +- **Files modified:** 2 + +## Accomplishments + +- Removed `/` from install precaching and evicted same-version root entries left by older worker source, preventing signed-in Dashboard HTML from entering the static cache. +- Preserved the inline locale-neutral, data-free offline document and immutable public logo/favicon support. +- Expanded logout cleanup across every current and legacy HealthLog static/page/data cache while keeping unrelated cache namespaces intact. +- Narrowed service-worker activation cleanup to owned HealthLog cache families rather than deleting every non-current cache on the origin. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Replace authenticated root precache with a data-free offline shell** - `885156965` (fix) +2. **Task 2: Purge all current and legacy HealthLog caches on logout** - `338dbe6c1` (fix) + +## Files Created/Modified + +- `public/sw.js` - Public-only precache list, same-version root eviction, and app-scoped stale-cache activation cleanup. +- `src/lib/pwa/query-persister.ts` - Session-end deletion of all owned static/page/data cache versions alongside the IndexedDB query snapshot. + +## Decisions Made + +- Kept normal online navigation and the existing `Cache-Control: no-store` privacy gate intact; the reported leak was the credential-bearing install fetch of `/`. +- Used the anchored `^healthlog-(?:static|pages|data)-` ownership contract in both runtimes, covering documented historical names without an indiscriminate browser-wide purge. +- Preserved all existing public/offline behavior and added no dependency, schema, migration, package, lockfile, or workspace changes. + +## Verification + +- Plan 01-06 focused gate: **42/42 passed** across service-worker VM, source hardening, and query-persister tests. +- Positive controls: **52/52 passed** when including logout/session-expiry hook integration suites. +- Prettier passed for both owned production files. +- The former Plan 01-02 RED failures are green: no signed-in root request/storage/replay, static caches are included in current/legacy logout cleanup, and unrelated sentinel caches survive. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +None. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- SEC-02 is closed at install, offline navigation, activation, logout, and persisted-query boundaries. +- The merged security gate can use the Plan 01-02 tests as final-boundary positive/negative evidence. + +## Self-Check: PASSED + +Both owned production files and this summary exist. Task commits `885156965` and `338dbe6c1` are present in repository history. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From 20a6731c8b82bb0223982ab9713db35e075028f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:47:46 +0200 Subject: [PATCH 022/125] fix(01-05): enforce MCP health read scope - require health:read or wildcard for MCP read capability - reject insufficient scopes before downstream gates and server construction --- src/app/mcp/route.ts | 14 +++++++++++++- src/lib/mcp/auth.ts | 4 ++-- src/lib/mcp/scopes.ts | 13 ++++++++----- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/app/mcp/route.ts b/src/app/mcp/route.ts index db3b25980..ae4a7dceb 100644 --- a/src/app/mcp/route.ts +++ b/src/app/mcp/route.ts @@ -45,7 +45,11 @@ */ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js"; -import { createMcpServer, resolveMcpAuthContext } from "@/lib/mcp"; +import { + createMcpServer, + resolveMcpAuthContext, + SCOPE_HEALTH_READ, +} from "@/lib/mcp"; import { withBackgroundEvent } from "@/lib/logging/background"; import { annotate } from "@/lib/logging/context"; import { isModuleEnabled } from "@/lib/modules/gate"; @@ -163,6 +167,14 @@ async function handleMcp(request: Request): Promise { return unauthorized(request); } + // Authorization is distinct from credential validity. A write-only or + // unrelated bearer is still a real token, but it must not reach rate + // limiting, module discovery, server construction, or any health reader. + if (!ctx.canRead) { + annotate({ action: { name: "mcp.auth.insufficient_scope" } }); + return unauthorized(request, SCOPE_HEALTH_READ); + } + // 2. RATE LIMIT — per `:` credential binding. const rl = await checkMcpRateLimit(ctx.binding); if (!rl.allowed) { diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts index 7ed23f852..f144931d8 100644 --- a/src/lib/mcp/auth.ts +++ b/src/lib/mcp/auth.ts @@ -28,7 +28,7 @@ export interface McpAuthContext { scopes: string[]; /** `:` — the session binding (REQ-SEC-11). */ binding: string; - /** Whether this session may read (always true for a valid token). */ + /** Whether this session may read (requires `health:read` or wildcard). */ canRead: boolean; /** Whether this session may write (requires `health:write`; gates the live write tools). */ canWrite: boolean; @@ -61,7 +61,7 @@ export async function resolveMcpAuthContext( tokenId, scopes: permissions, binding: sessionBinding(user.id, tokenId), - canRead: tokenAllowsRead(), + canRead: tokenAllowsRead(permissions), canWrite: tokenAllowsWrite(permissions), }; } diff --git a/src/lib/mcp/scopes.ts b/src/lib/mcp/scopes.ts index 45087e151..7a6d15249 100644 --- a/src/lib/mcp/scopes.ts +++ b/src/lib/mcp/scopes.ts @@ -28,12 +28,15 @@ export const SCOPE_HEALTH_READ = "health:read"; export const SCOPE_HEALTH_WRITE = "health:write"; /** - * Whether a token's scopes admit the read surface. Any valid token qualifies — - * the underlying reads are unscoped — so this is permissive by design and exists - * to document the contract and to give a single place to tighten later. + * Whether a token's scopes admit the read surface. Reads require the explicit + * `health:read` grant or the wildcard; write and unrelated grants never imply + * read access. */ -export function tokenAllowsRead(): boolean { - return true; +export function tokenAllowsRead(permissions: readonly string[]): boolean { + return ( + permissions.includes(SCOPE_WILDCARD) || + permissions.includes(SCOPE_HEALTH_READ) + ); } /** From 36cb64c8402a1356625cd465671c7809ffeb7715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:49:46 +0200 Subject: [PATCH 023/125] test(01-02): exercise DNS transition cases past fetch port guard - replace blocked port 9 with non-blocked test port 8089 - preserve pre-connect ENOTFOUND assertions for all transition forms --- src/lib/__tests__/safe-fetch-dispatcher.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/__tests__/safe-fetch-dispatcher.test.ts b/src/lib/__tests__/safe-fetch-dispatcher.test.ts index 57bdf3ed0..aa1d2d639 100644 --- a/src/lib/__tests__/safe-fetch-dispatcher.test.ts +++ b/src/lib/__tests__/safe-fetch-dispatcher.test.ts @@ -207,7 +207,7 @@ describe("pinnedPublicDispatcher", () => { const dispatcher = getPinnedPublicDispatcher(); let caught: unknown; try { - await undiciFetch("http://transition-answer.example.test:9/probe", { + await undiciFetch("http://transition-answer.example.test:8089/probe", { dispatcher, signal: AbortSignal.timeout(750), }); From a1778696094e8264bfe80f5366407ccb58ed6ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:49:49 +0200 Subject: [PATCH 024/125] fix(01-07): classify embedded IPv4 destinations - Normalize IPv6 literals before transition-prefix inspection - Reject mapped, compatible, 6to4, and NAT64 private embeddings - Share the verdict with pinned public and operator dispatchers --- src/lib/safe-fetch-dispatcher.ts | 77 +++++++++-- src/lib/validations/notifications.ts | 200 ++++++++++++++++----------- 2 files changed, 186 insertions(+), 91 deletions(-) diff --git a/src/lib/safe-fetch-dispatcher.ts b/src/lib/safe-fetch-dispatcher.ts index 184c242d0..ee32a25e3 100644 --- a/src/lib/safe-fetch-dispatcher.ts +++ b/src/lib/safe-fetch-dispatcher.ts @@ -1,4 +1,5 @@ import dns from "node:dns"; +import { isIP } from "node:net"; import { Agent } from "undici"; import { isPublicIp } from "@/lib/validations/notifications"; @@ -40,7 +41,10 @@ import { isPublicIp } from "@/lib/validations/notifications"; * Wired into `safeFetch` only when `opts.requirePublicHost` is true. * The outbound paths that accept a user-supplied host all use that flag. */ -function pinnedLookup( +type AddressPolicy = "public" | "operator-approved"; + +function pinnedLookupWithPolicy( + policy: AddressPolicy, hostname: string, options: dns.LookupOptions, callback: ( @@ -64,13 +68,20 @@ function pinnedLookup( callback(err, ""); return; } - const allowed = addresses.filter((a) => isPublicIp(a.address)); + const allowed = addresses.filter((address) => + policy === "public" + ? isPublicIp(address.address) + : isIP(address.address) !== 0, + ); if (allowed.length === 0) { const refused: NodeJS.ErrnoException = new Error( `safeFetch refused private resolved address for ${hostname}`, ); refused.code = "ENOTFOUND"; - callback(refused, ""); + // Preserve the result shape Undici requested. Auto-select-family asks + // for `all: true`; handing that path a scalar empty string can mask the + // policy error behind an invalid-address error for IPv6 answers. + callback(refused, options.all ? [] : ""); return; } @@ -89,19 +100,44 @@ function pinnedLookup( }); } +function pinnedPublicLookup( + hostname: string, + options: dns.LookupOptions, + callback: ( + err: NodeJS.ErrnoException | null, + address: string | dns.LookupAddress[], + family?: number, + ) => void, +): void { + pinnedLookupWithPolicy("public", hostname, options, callback); +} + +function pinnedOperatorApprovedLookup( + hostname: string, + options: dns.LookupOptions, + callback: ( + err: NodeJS.ErrnoException | null, + address: string | dns.LookupAddress[], + family?: number, + ) => void, +): void { + pinnedLookupWithPolicy("operator-approved", hostname, options, callback); +} + /** * Lazily-instantiated singleton. `undici.Agent` keeps an internal pool * per origin, so reusing one instance across calls preserves the keep- * alive benefit while still pinning each new connection through the * vetted lookup. */ -let cached: Agent | null = null; +let cachedPublic: Agent | null = null; +let cachedOperatorApproved: Agent | null = null; -export function getPinnedPublicDispatcher(): Agent { - if (cached) return cached; - cached = new Agent({ +function createPinnedDispatcher(policy: AddressPolicy): Agent { + return new Agent({ connect: { - lookup: pinnedLookup, + lookup: + policy === "public" ? pinnedPublicLookup : pinnedOperatorApprovedLookup, // RFC 8305 Happy Eyeballs across the vetted survivor set. Without // this undici pins the single first address and dies when that is // an unreachable IPv6 record on a v4-only host. With it, undici @@ -114,7 +150,24 @@ export function getPinnedPublicDispatcher(): Agent { autoSelectFamilyAttemptTimeout: 250, }, }); - return cached; +} + +export function getPinnedPublicDispatcher(): Agent { + cachedPublic ??= createPinnedDispatcher("public"); + return cachedPublic; +} + +/** + * Pinned dispatcher for an exact operator-approved origin. + * + * Private addresses are permitted only after the Nightscout origin policy has + * matched the complete canonical scheme/host/port trust grant. Resolution is + * still performed once inside Undici's connector and the vetted answer set is + * pinned to the socket, so a later DNS answer cannot replace the destination. + */ +export function getPinnedOperatorApprovedDispatcher(): Agent { + cachedOperatorApproved ??= createPinnedDispatcher("operator-approved"); + return cachedOperatorApproved; } /** @@ -123,6 +176,8 @@ export function getPinnedPublicDispatcher(): Agent { * binds the mock. */ export function _resetPinnedDispatcherForTests(): void { - cached?.destroy().catch(() => {}); - cached = null; + cachedPublic?.destroy().catch(() => {}); + cachedOperatorApproved?.destroy().catch(() => {}); + cachedPublic = null; + cachedOperatorApproved = null; } diff --git a/src/lib/validations/notifications.ts b/src/lib/validations/notifications.ts index d7ef8bca5..2ee35963d 100644 --- a/src/lib/validations/notifications.ts +++ b/src/lib/validations/notifications.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { isIP } from "node:net"; import { EVENT_TYPES } from "@/lib/notifications/types"; export const notificationPreferenceSchema = z.object({ @@ -65,6 +66,109 @@ function isPrivateIpv4(ip: [number, number, number, number]): boolean { return false; } +/** + * Parse an IPv6 literal into its canonical 16 bytes. + * + * Node's `net.isIP` supplies the syntax verdict. The small expansion below is + * only for policy inspection: comparing normalized bytes avoids the compressed + * and mixed-spelling bypasses that string-prefix checks cannot cover. + */ +function parseIpv6Bytes(input: string): Uint8Array | null { + if (isIP(input) !== 6) return null; + + let value = input.toLowerCase(); + const dottedTail = value.match(/(\d+\.\d+\.\d+\.\d+)$/)?.[1]; + if (dottedTail) { + const ipv4 = parseIpv4Strict(dottedTail); + if (!ipv4) return null; + const high = ((ipv4[0] << 8) | ipv4[1]).toString(16); + const low = ((ipv4[2] << 8) | ipv4[3]).toString(16); + value = `${value.slice(0, -dottedTail.length)}${high}:${low}`; + } + + const compressed = value.split("::"); + if (compressed.length > 2) return null; + const left = compressed[0] ? compressed[0].split(":") : []; + const right = + compressed.length === 2 && compressed[1] ? compressed[1].split(":") : []; + const missing = 8 - left.length - right.length; + if ( + missing < 0 || + (compressed.length === 1 && missing !== 0) || + (compressed.length === 2 && missing < 1) + ) { + return null; + } + + const words = [ + ...left, + ...Array.from({ length: missing }, () => "0"), + ...right, + ].map((word) => Number.parseInt(word, 16)); + if ( + words.length !== 8 || + words.some((word) => !Number.isInteger(word) || word < 0 || word > 0xffff) + ) { + return null; + } + + const bytes = new Uint8Array(16); + words.forEach((word, index) => { + bytes[index * 2] = word >> 8; + bytes[index * 2 + 1] = word & 0xff; + }); + return bytes; +} + +function bytesEqual( + bytes: Uint8Array, + offset: number, + expected: readonly number[], +): boolean { + return expected.every((value, index) => bytes[offset + index] === value); +} + +/** + * Extract the embedded IPv4 address from standard transition formats. + * + * The local-use `64:ff9b:1::/48` layout follows the repository's RFC8215 + * fixtures: the first two IPv4 octets occupy bytes 6–7 and the final two + * occupy bytes 10–11 around the reserved zero field. + */ +function embeddedIpv4( + bytes: Uint8Array, +): [number, number, number, number] | null { + const firstTenZero = bytesEqual(bytes, 0, Array(10).fill(0)); + if (firstTenZero && bytes[10] === 0xff && bytes[11] === 0xff) { + return [bytes[12], bytes[13], bytes[14], bytes[15]]; + } + + if (bytesEqual(bytes, 0, Array(12).fill(0))) { + return [bytes[12], bytes[13], bytes[14], bytes[15]]; + } + + if (bytes[0] === 0x20 && bytes[1] === 0x02) { + return [bytes[2], bytes[3], bytes[4], bytes[5]]; + } + + if ( + bytesEqual(bytes, 0, [0x00, 0x64, 0xff, 0x9b]) && + bytesEqual(bytes, 4, Array(8).fill(0)) + ) { + return [bytes[12], bytes[13], bytes[14], bytes[15]]; + } + + if ( + bytesEqual(bytes, 0, [0x00, 0x64, 0xff, 0x9b, 0x00, 0x01]) && + bytes[8] === 0 && + bytes[9] === 0 + ) { + return [bytes[6], bytes[7], bytes[10], bytes[11]]; + } + + return null; +} + /** * Decide whether a raw IP address string (as returned by `dns.lookup`) * points at a public range — i.e. is safe to dial. @@ -86,45 +190,27 @@ export function isPublicIp(ip: string): boolean { if (!ip) return false; const lower = ip.toLowerCase(); - // IPv4 dotted-quad (what dns.lookup with family=4 returns). - if (/^\d+\.\d+\.\d+\.\d+$/.test(lower)) { + const family = isIP(lower); + if (family === 4) { const parsed = parseIpv4Strict(lower); if (!parsed) return false; return !isPrivateIpv4(parsed); } - // IPv6. Only strings with a colon make it past the previous check. - if (lower.includes(":")) { - if (lower === "::1" || lower === "::") return false; - if (lower.startsWith("fe80:")) return false; - if (/^fc[0-9a-f]{0,2}:/.test(lower)) return false; - if (/^fd[0-9a-f]{0,2}:/.test(lower)) return false; + if (family === 6) { + const bytes = parseIpv6Bytes(lower); + if (!bytes) return false; - // IPv4-mapped IPv6 dotted form ("::ffff:127.0.0.1"). Some resolvers - // emit this when an AAAA falls through to a synthesized A. - const mappedDotted = lower.match(/^(?:::ffff:)(\d+\.\d+\.\d+\.\d+)$/); - if (mappedDotted) { - const parsed = parseIpv4Strict(mappedDotted[1]); - if (!parsed || isPrivateIpv4(parsed)) return false; - return true; + // Unspecified, loopback, link-local, and unique-local IPv6. + if (bytesEqual(bytes, 0, Array(16).fill(0))) return false; + if (bytesEqual(bytes, 0, Array(15).fill(0)) && bytes[15] === 1) { + return false; } + if (bytes[0] === 0xfe && (bytes[1] & 0xc0) === 0x80) return false; + if ((bytes[0] & 0xfe) === 0xfc) return false; - // IPv4-mapped IPv6 hex pair form ("::ffff:7f00:1"). - const mappedHex = lower.match( - /^(?:::ffff:)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/, - ); - if (mappedHex) { - const high = parseInt(mappedHex[1], 16); - const low = parseInt(mappedHex[2], 16); - const parsed: [number, number, number, number] = [ - (high >> 8) & 0xff, - high & 0xff, - (low >> 8) & 0xff, - low & 0xff, - ]; - if (isPrivateIpv4(parsed)) return false; - return true; - } + const embedded = embeddedIpv4(bytes); + if (embedded && isPrivateIpv4(embedded)) return false; return true; } @@ -176,55 +262,9 @@ export function isPublicUrl(url: string): boolean { return false; } - // IPv6 loopback / unspecified / link-local / unique-local. Must be - // gated on a colon — the previous `startsWith("fc")` falsely blocked - // any DNS hostname starting with "fc" or "fd" (e.g. fcm.googleapis.com). - if (h === "::1" || h === "::") { - return false; - } - if (h.includes(":")) { - if ( - h.startsWith("fe80:") || - /^fc[0-9a-f]{0,2}:/.test(h) || - /^fd[0-9a-f]{0,2}:/.test(h) - ) { - return false; - } - } - - // IPv4-mapped IPv6 ("::ffff:127.0.0.1" or "::ffff:7f00:1") and - // 6to4 / NAT64 with embedded private IPv4. Extract the trailing - // dotted-quad if present, otherwise extract the last 32 bits as - // hex pairs and reconstruct. - if (h.includes(":")) { - const ipv4MappedDotted = h.match(/^(?:::ffff:)(\d+\.\d+\.\d+\.\d+)$/); - if (ipv4MappedDotted) { - const ip = parseIpv4Strict(ipv4MappedDotted[1]); - if (!ip || isPrivateIpv4(ip)) return false; - } - const ipv4MappedHex = h.match( - /^(?:::ffff:)([0-9a-f]{1,4}):([0-9a-f]{1,4})$/, - ); - if (ipv4MappedHex) { - const high = parseInt(ipv4MappedHex[1], 16); - const low = parseInt(ipv4MappedHex[2], 16); - const ip: [number, number, number, number] = [ - (high >> 8) & 0xff, - high & 0xff, - (low >> 8) & 0xff, - low & 0xff, - ]; - if (isPrivateIpv4(ip)) return false; - } - } - - // Strict IPv4 parsing: reject malformed forms outright (better a false - // positive than an SSRF) and apply RFC1918 / CGNAT / loopback bans. - if (/^\d+\.\d+\.\d+\.\d+$/.test(h)) { - const ip = parseIpv4Strict(h); - if (!ip) return false; - if (isPrivateIpv4(ip)) return false; - } + // One normalized byte-level verdict for every literal IP. DNS hostnames + // deliberately fall through to the pinned resolver boundary. + if (isIP(h) !== 0) return isPublicIp(h); return true; } catch { From db08f6c1758822c05d42a2815948ae491f1f88e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:50:14 +0200 Subject: [PATCH 025/125] fix(01-05): serialize MCP refresh families - lock and rotate refresh families inside one database transaction - revoke CAS losers and insert successors before commit - sign refresh artifacts only after committed state --- src/app/api/mcp/oauth/token/route.ts | 86 ++++++++++++++++++++-------- src/lib/auth/issue-token.ts | 4 +- src/lib/mcp/oauth/connections.ts | 41 ++++++++++--- 3 files changed, 100 insertions(+), 31 deletions(-) diff --git a/src/app/api/mcp/oauth/token/route.ts b/src/app/api/mcp/oauth/token/route.ts index c61342fdd..e9493db20 100644 --- a/src/app/api/mcp/oauth/token/route.ts +++ b/src/app/api/mcp/oauth/token/route.ts @@ -29,6 +29,8 @@ import { randomUUID } from "node:crypto"; import { NextRequest } from "next/server"; +import type { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; import { annotate } from "@/lib/logging/context"; import { withBackgroundEvent } from "@/lib/logging/background"; import { auditLog } from "@/lib/auth/audit"; @@ -109,6 +111,16 @@ interface RefreshClaims { resource: string; } +type TokenPairArgs = { + userId: string; + clientId: string; + scope: string; + resource: string; + grant: "authorization_code" | "refresh_token"; + connectionId?: string; + refreshJti?: string; +}; + /** * Mint the access token (+ refresh artifact when `offline_access` is granted). * @@ -119,15 +131,15 @@ interface RefreshClaims { * the connection seeded with it; for `refresh_token` the caller has just * advanced the connection to it. */ -async function mintTokenPair(args: { - userId: string; - clientId: string; - scope: string; - resource: string; - grant: "authorization_code" | "refresh_token"; - connectionId?: string; - refreshJti?: string; -}): Promise { +async function mintTokenPair(args: TokenPairArgs): Promise { + const access = await persistAccessToken(args); + return finalizeTokenPair(args, access); +} + +async function persistAccessToken( + args: TokenPairArgs, + db?: Pick, +) { // Grant EXACTLY the scopes the authorization code carried (read, or // read+write). `health:read` is always present; `health:write` is added // only when the user consented to it at the authorize step. The token is @@ -139,14 +151,20 @@ async function mintTokenPair(args: { permissions.push(SCOPE_HEALTH_WRITE); } - const access = await issueApiToken({ + const options = { userId: args.userId, name: "MCP connector", permissions, expiresInMinutes: ACCESS_TOKEN_TTL_MINUTES, ...(args.connectionId ? { mcpConnectionId: args.connectionId } : {}), - }); + }; + return db ? issueApiToken(options, db) : issueApiToken(options); +} +async function finalizeTokenPair( + args: TokenPairArgs, + access: Awaited>, +): Promise { const refresh = args.connectionId && args.refreshJti ? signArtifact( @@ -367,14 +385,42 @@ export async function POST(request: NextRequest): Promise { ); } const newJti = randomUUID(); - const rotation = await rotateConnection({ + const rotationArgs = { connectionId: claims.cid, presentedJti: claims.jti, newJti, clientId: claims.client_id, userId: claims.sub, - }); - if (!rotation.ok) { + }; + const pairArgs: TokenPairArgs = { + userId: claims.sub, + clientId: claims.client_id, + scope: claims.scope, + resource: claims.resource, + grant: "refresh_token", + connectionId: claims.cid, + refreshJti: newJti, + }; + + // Rotation and successor insertion are one serialized state transition. + // A replay transaction commits family revocation; a successful + // transaction commits both JTI advancement and the successor access row. + // Any insertion error rejects the callback and PostgreSQL rolls every + // earlier state change back. No artifact is signed inside this callback. + const exchange = await prisma.$transaction( + async (tx) => { + const rotation = await rotateConnection(rotationArgs, tx); + if (!rotation.ok) { + return { ok: false as const, rotation }; + } + const access = await persistAccessToken(pairArgs, tx); + return { ok: true as const, access }; + }, + { timeout: 10_000 }, + ); + + if (!exchange.ok) { + const { rotation } = exchange; annotate({ action: { name: @@ -392,15 +438,9 @@ export async function POST(request: NextRequest): Promise { ); } - return mintTokenPair({ - userId: claims.sub, - clientId: claims.client_id, - scope: claims.scope, - resource: claims.resource, - grant: "refresh_token", - connectionId: claims.cid, - refreshJti: newJti, - }); + // The transaction has committed. Only now may the stateless successor + // refresh artifact be signed and returned to the client. + return finalizeTokenPair(pairArgs, exchange.access); } return oauthError( diff --git a/src/lib/auth/issue-token.ts b/src/lib/auth/issue-token.ts index 291b34342..0939aaec2 100644 --- a/src/lib/auth/issue-token.ts +++ b/src/lib/auth/issue-token.ts @@ -5,6 +5,7 @@ * cookie. The raw `hlk_` value is returned exactly once. */ import { randomBytes } from "node:crypto"; +import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { hashToken } from "@/lib/auth/hmac"; @@ -44,6 +45,7 @@ export interface IssueTokenOptions { */ export async function issueApiToken( opts: IssueTokenOptions, + db: Pick = prisma, ): Promise { const rawToken = `hlk_${randomBytes(32).toString("hex")}`; const tokenHashValue = hashToken(rawToken); @@ -52,7 +54,7 @@ export async function issueApiToken( ? new Date(Date.now() + opts.expiresInMinutes * 60 * 1000) : new Date(Date.now() + (opts.expiresInDays ?? 90) * 24 * 60 * 60 * 1000); - const created = await prisma.apiToken.create({ + const created = await db.apiToken.create({ data: { userId: opts.userId, name: opts.name, diff --git a/src/lib/mcp/oauth/connections.ts b/src/lib/mcp/oauth/connections.ts index 091143e01..f2fafa4a9 100644 --- a/src/lib/mcp/oauth/connections.ts +++ b/src/lib/mcp/oauth/connections.ts @@ -26,8 +26,14 @@ * The access tokens themselves stay ordinary `hlk_` `ApiToken` rows, linked * back through `ApiToken.mcpConnectionId`. */ +import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; +type ConnectionDb = Pick< + Prisma.TransactionClient, + "mcpOAuthConnection" | "apiToken" | "$queryRaw" +>; + export interface ConnectionRotation { /** The connection id (`cid`) embedded in the refresh artifact. */ connectionId: string; @@ -82,8 +88,22 @@ export async function createConnection(args: { */ export async function rotateConnection( args: ConnectionRotation, + db: ConnectionDb = prisma, ): Promise { - const connection = await prisma.mcpOAuthConnection.findUnique({ + // PostgreSQL has no Prisma model-level row-lock primitive. Inside the token + // route's interactive transaction this serializes every decision for one + // refresh family before we re-read its current state. The runtime guard keeps + // lightweight unit doubles compatible while production clients always lock. + if (typeof db.$queryRaw === "function") { + await db.$queryRaw` + SELECT id + FROM mcp_oauth_connections + WHERE id = ${args.connectionId} + FOR UPDATE + `; + } + + const connection = await db.mcpOAuthConnection.findUnique({ where: { id: args.connectionId }, select: { id: true, @@ -106,13 +126,13 @@ export async function rotateConnection( if (connection.currentJti !== args.presentedJti) { // Replay of an already-rotated refresh token — treat as theft and revoke // the whole connection (reuse-detection family revocation). - await revokeConnectionRow(connection.id); + await revokeConnectionRow(connection.id, db); return { ok: false, reason: "reuse_detected" }; } // Advance the chain atomically: only the caller presenting the current jti // wins, so two concurrent refreshes cannot both rotate the same row. - const advanced = await prisma.mcpOAuthConnection.updateMany({ + const advanced = await db.mcpOAuthConnection.updateMany({ where: { id: connection.id, currentJti: args.presentedJti, @@ -121,12 +141,16 @@ export async function rotateConnection( data: { currentJti: args.newJti, lastUsedAt: new Date() }, }); if (advanced.count === 0) { + // A contender that loses the guarded advancement is a replay signal just + // like a stale JTI observed after taking the row lock. Revoke the complete + // family before returning; the surrounding transaction commits this deny. + await revokeConnectionRow(connection.id, db); return { ok: false, reason: "reuse_detected" }; } // L4 — revoke the access tokens minted before this rotation so a leaked // 60-minute access token cannot outlive the refresh that spawned it. - await prisma.apiToken.updateMany({ + await db.apiToken.updateMany({ where: { mcpConnectionId: connection.id, revoked: false }, data: { revoked: true }, }); @@ -135,12 +159,15 @@ export async function rotateConnection( } /** Internal — stamp `revokedAt` and revoke every linked access token. */ -async function revokeConnectionRow(connectionId: string): Promise { - await prisma.mcpOAuthConnection.updateMany({ +async function revokeConnectionRow( + connectionId: string, + db: Pick = prisma, +): Promise { + await db.mcpOAuthConnection.updateMany({ where: { id: connectionId, revokedAt: null }, data: { revokedAt: new Date() }, }); - await prisma.apiToken.updateMany({ + await db.apiToken.updateMany({ where: { mcpConnectionId: connectionId, revoked: false }, data: { revoked: true }, }); From 9cb11abedb6220d5fb37e79bc61057900c265665 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:52:03 +0200 Subject: [PATCH 026/125] docs(01-05): complete MCP security enforcement plan - record green scope and PostgreSQL replay-family gates - document transactional rotation and post-commit artifact semantics --- .../01-05-SUMMARY.md | 127 ++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-05-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-05-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-05-SUMMARY.md new file mode 100644 index 000000000..f374cd3d6 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-05-SUMMARY.md @@ -0,0 +1,127 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "05" +subsystem: auth +tags: [mcp, oauth, bearer-scopes, postgres, prisma, concurrency] + +requires: + - phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability + provides: Plan 01-01 MCP authorization and refresh-race RED contracts +provides: + - Explicit health:read or wildcard authorization before MCP registration + - PostgreSQL row-locked refresh-family rotation + - Transactional JTI advancement, access revocation, and successor insertion + - Post-commit refresh artifact signing +affects: [SEC-01, SEC-04, SEC-08, MCP OAuth] + +tech-stack: + added: [] + patterns: + - Canonical MCP read-scope predicate enforced before downstream route gates + - Prisma interactive transaction with PostgreSQL SELECT FOR UPDATE + - Transaction-client-capable access-token issuance + +key-files: + created: [] + modified: + - src/lib/mcp/auth.ts + - src/lib/mcp/scopes.ts + - src/app/mcp/route.ts + - src/lib/mcp/oauth/connections.ts + - src/app/api/mcp/oauth/token/route.ts + - src/lib/auth/issue-token.ts + +key-decisions: + - "Return an incremental-consent 401 for a valid bearer lacking health:read, before rate limiting, module lookup, or server construction." + - "Serialize refresh decisions with SELECT FOR UPDATE and commit replay-family revocation in the same transaction that handles successful successor insertion." + - "Sign stateless refresh artifacts only after the database transaction commits." + +patterns-established: + - "Write and unrelated bearer scopes never imply MCP read authority." + - "Database helpers accept the active Prisma transaction client instead of opening nested transactions." + +requirements-completed: [SEC-01, SEC-04, SEC-08] + +duration: 5min +completed: 2026-07-29 +--- + +# Phase 01 Plan 05: MCP Authorization and Refresh Replay Enforcement Summary + +**Explicit MCP health-read authorization and row-locked, rollback-safe OAuth refresh-family rotation** + +## Performance + +- **Duration:** 5 min +- **Started:** 2026-07-29T11:46:55Z +- **Completed:** 2026-07-29T11:51:26Z +- **Tasks:** 3 +- **Files modified:** 7 + +## Accomplishments + +- Restricted MCP health capabilities to `health:read` and wildcard tokens, rejecting write-only and unrelated valid bearers before any downstream route work. +- Serialized each refresh family with a PostgreSQL connection-row lock and revoked the family on stale JTI or guarded-update loss. +- Moved JTI advancement, linked-access revocation, and successor access insertion into one Prisma interactive transaction. +- Deferred successor refresh signing until after commit, so rolled-back credentials can never be returned. +- Turned the real-Postgres concurrency regression green while preserving sequential rotation and unrelated-connection usability. + +## Task Commits + +1. **Task 1: Enforce explicit MCP health-read scope at the final boundary** - `20a6731c8` (fix) +2. **Task 2: Make refresh rotation and successor issuance one transaction** - `db08f6c17` (fix) +3. **Task 3: Prove real-Postgres family revocation under concurrency** - verification-only; covered by `db08f6c17` + +## Files Created/Modified + +- `src/lib/mcp/scopes.ts` - Canonical explicit read-scope predicate. +- `src/lib/mcp/auth.ts` - Permission-derived `canRead` context. +- `src/app/mcp/route.ts` - Final pre-registration insufficient-scope denial. +- `src/lib/mcp/oauth/connections.ts` - Transaction-client rotation, row lock, and complete CAS/replay revocation. +- `src/app/api/mcp/oauth/token/route.ts` - One refresh transaction and post-commit artifact finalization. +- `src/lib/auth/issue-token.ts` - Optional transaction-client token insertion seam. + +## Verification + +- MCP auth/scope/route unit bundle: **46/46 passed**. +- Real bearer-to-reader integration: **22/22 passed**. +- OAuth token/connection unit bundle: **23/23 passed**. +- Real PostgreSQL refresh-race plus bearer integration bundle: **24/24 passed**. +- Broader owned-seam unit regression, including native refresh and manual MCP token routes: **90/90 passed**. +- ESLint passed across all six production files. +- Full typecheck was attempted and reached one unrelated concurrent Wave-1 passkey test error at `tests/integration/passkey-register.test.ts:86`; it reported no Plan 01-05 owned-file errors. +- Release branch, scanner binary patch digest, and all immutable package/workspace hashes matched the execution baseline after implementation. + +## Decisions Made + +- Kept bearer validation centralized and used `canRead` as defense in depth at the route boundary, allowing the response to advertise `health:read` for incremental consent. +- Retained the guarded JTI update after row locking. The lock provides serialization; the guard remains a fail-closed invariant and any zero-count result revokes the family. +- No deadlock or serialization retry was added because the deterministic real-Postgres race passed repeatedly with row locking alone. + +## Deviations from Plan + +None - the plan was implemented without schema, migration, package, or test changes. + +## Issues Encountered + +- Full-tree typecheck was temporarily blocked by an unrelated concurrently edited passkey integration fixture. Focused tests and ESLint for every owned file passed. + +## User Setup Required + +None. + +## Next Phase Readiness + +- SEC-01 and SEC-04 are closed against helper, final-route, rollback, and real-concurrency contracts. +- The merged security gate can include the Plan 01-01 suites without expected failures. + +## Self-Check: PASSED + +- All six owned production files and this summary exist. +- Both Plan 01-05 implementation commits exist. +- All focused MCP unit and PostgreSQL integration suites are green. +- Scanner baseline files remain byte-for-byte unchanged. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From 89c83510d4e9dc34e9851c57e7a308df7b40ab02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:52:22 +0200 Subject: [PATCH 027/125] feat(01-08): bind passkey enrollment to fresh cookie proof - require an existing-factor proof before issuing registration options - atomically claim session-bound challenges before credential persistence --- .../auth/passkey/register-options/route.ts | 110 ++++++++++++++++- .../api/auth/passkey/register-verify/route.ts | 47 ++++++- src/lib/auth/passkey.ts | 115 +++++++++++++----- 3 files changed, 230 insertions(+), 42 deletions(-) diff --git a/src/app/api/auth/passkey/register-options/route.ts b/src/app/api/auth/passkey/register-options/route.ts index 23bb29e64..ec7852eb2 100644 --- a/src/app/api/auth/passkey/register-options/route.ts +++ b/src/app/api/auth/passkey/register-options/route.ts @@ -1,14 +1,114 @@ -import { createRegistrationOptions } from "@/lib/auth/passkey"; -import { apiSuccess } from "@/lib/api-response"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { NextRequest } from "next/server"; +import { + createAuthenticationOptions, + createRegistrationOptions, + verifyAuthentication, +} from "@/lib/auth/passkey"; +import { + createMfaAuthenticationOptions, + verifyMfaAuthentication, +} from "@/lib/auth/mfa/webauthn"; +import { verifyMfaFactor } from "@/lib/auth/mfa/verify-factor"; +import { verifyPassword } from "@/lib/auth/password"; +import { prisma } from "@/lib/db"; +import { apiError, apiSuccess, safeJson } from "@/lib/api-response"; +import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; +import { stepUpMintSchema } from "@/lib/validations/step-up"; -export const POST = apiHandler(async () => { - const { user } = await requireAuth(); +const proofRequired = () => + apiError("Fresh existing-factor proof required", 401); + +export const POST = apiHandler(async (request: NextRequest) => { + const { user, session } = await requireCookieAuth(); + + if (!request.headers.get("content-type")?.includes("application/json")) { + return proofRequired(); + } + + const { data: body, error: jsonError } = await safeJson< + Record + >(request, { maxBytes: 64 * 1024 }); + if (jsonError) return proofRequired(); + + // Begin an assertion ceremony without beginning registration. This lets the + // browser re-prove a passkey or second-factor security key before this route + // creates any enrollment challenge. + if ( + Object.keys(body).length === 1 && + (body.method === "passkey" || body.method === "webauthn") + ) { + const result = + body.method === "passkey" + ? await createAuthenticationOptions(user.id) + : await createMfaAuthenticationOptions(user.id); + if (!result) { + return apiError("That verification method is unavailable", 409); + } + return apiSuccess({ ...result, reauth: true }); + } + + const parsed = stepUpMintSchema.safeParse(body); + if (!parsed.success) return proofRequired(); + + let proved = false; + + if (parsed.data.method === "password") { + proved = Boolean( + user.passwordHash && + (await verifyPassword(user.passwordHash, parsed.data.password)), + ); + } else if (parsed.data.method === "totp") { + const result = await verifyMfaFactor(user, "totp", parsed.data.code); + proved = result.ok; + } else { + const expectedType = + parsed.data.method === "passkey" + ? "authentication" + : "mfa_authentication"; + const challenge = await prisma.authChallenge.findUnique({ + where: { id: parsed.data.challengeId }, + select: { userId: true, type: true }, + }); + + if (challenge?.userId === user.id && challenge.type === expectedType) { + try { + if (parsed.data.method === "passkey") { + const result = await verifyAuthentication( + parsed.data.challengeId, + parsed.data.credential, + ); + proved = + result.verification.verified && result.passkey.userId === user.id; + } else { + proved = await verifyMfaAuthentication( + parsed.data.challengeId, + user.id, + parsed.data.credential, + ); + } + } catch { + proved = false; + } + } + } + + if (!proved) return proofRequired(); + + // A strong existing factor refreshes the session's MFA stamp. Password proof + // deliberately clears it: registration is authorized by this single-use, + // session-bound challenge but cannot upgrade password-only authentication. + await prisma.session.update({ + where: { id: session.id }, + data: { + mfaVerifiedAt: parsed.data.method === "password" ? null : new Date(), + }, + }); const { options, challengeId } = await createRegistrationOptions( user.id, user.email ?? user.username, + session.id, ); annotate({ action: { name: "auth.passkey.register-options" } }); diff --git a/src/app/api/auth/passkey/register-verify/route.ts b/src/app/api/auth/passkey/register-verify/route.ts index 1b9ae3b84..e50fc106c 100644 --- a/src/app/api/auth/passkey/register-verify/route.ts +++ b/src/app/api/auth/passkey/register-verify/route.ts @@ -1,5 +1,8 @@ import { prisma } from "@/lib/db"; -import { verifyRegistration } from "@/lib/auth/passkey"; +import { + consumeRegistrationChallenge, + verifyRegistration, +} from "@/lib/auth/passkey"; import { auditLog } from "@/lib/auth/audit"; import { apiSuccess, @@ -8,11 +11,11 @@ import { safeJson, } from "@/lib/api-response"; import { NextRequest } from "next/server"; -import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiHandler, requireCookieAuth } from "@/lib/api-handler"; import { annotate } from "@/lib/logging/context"; export const POST = apiHandler(async (request: NextRequest) => { - const { user } = await requireAuth(); + const { user, session } = await requireCookieAuth(); const { data: body, error: jsonError } = await safeJson< Record @@ -26,7 +29,43 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("challengeId and credential required", 422); } - const verification = await verifyRegistration(challengeId, credential); + // Read the live session row so a stale fresh-factor stamp cannot be carried + // through a registration ceremony after it has been downgraded/revoked. + const freshSession = await prisma.session.findUnique({ + where: { id: session.id }, + select: { mfaVerifiedAt: true }, + }); + if ( + !freshSession || + (freshSession.mfaVerifiedAt && + freshSession.mfaVerifiedAt.getTime() < Date.now() - 5 * 60 * 1000) + ) { + return apiError("Fresh reauthentication proof required", 401); + } + + const claim = await consumeRegistrationChallenge({ + challengeId, + userId: user.id, + sessionId: session.id, + }); + if (!claim.ok) { + return apiError( + claim.reason === "wrong_context" + ? "Fresh reauthentication proof required" + : "Registration challenge invalid or expired", + claim.reason === "wrong_context" ? 401 : 400, + ); + } + + let verification; + try { + verification = await verifyRegistration( + claim.expectedChallenge, + credential, + ); + } catch { + return apiError("Passkey verification failed", 400); + } if (!verification.verified || !verification.registrationInfo) { return apiError("Passkey verification failed", 400); diff --git a/src/lib/auth/passkey.ts b/src/lib/auth/passkey.ts index 4cd89840f..ab7cbd1c3 100644 --- a/src/lib/auth/passkey.ts +++ b/src/lib/auth/passkey.ts @@ -15,6 +15,7 @@ import { getRpId, getExpectedOrigin, } from "@/lib/auth/webauthn-rp"; +import { hashToken } from "@/lib/auth/hmac"; // v1.4.43 W13 L-3 — explicit Zod narrowing in front of // `verifyAuthentication`. Replaces the raw @@ -75,6 +76,12 @@ const registrationResponseSchema = z type Transport = "ble" | "cable" | "hybrid" | "internal" | "nfc" | "smart-card" | "usb"; +const PASSKEY_REGISTRATION_CHALLENGE_PREFIX = "passkey-registration:v1:"; + +function registrationChallengeType(sessionId: string): string { + return `${PASSKEY_REGISTRATION_CHALLENGE_PREFIX}${hashToken(sessionId)}`; +} + // The relying-party config (`rpName` / `getRpId` / `getExpectedOrigin`) is // shared with the second-factor security-key path via `@/lib/auth/webauthn-rp` // so both ceremonies bind credentials to the identical RP. @@ -90,6 +97,7 @@ async function cleanupExpiredChallenges() { export async function createRegistrationOptions( userId: string, username: string, + sessionId: string, ) { await cleanupExpiredChallenges(); @@ -124,7 +132,7 @@ export async function createRegistrationOptions( data: { userId, challenge: options.challenge, - type: "registration", + type: registrationChallengeType(sessionId), expiresAt: new Date(Date.now() + 5 * 60 * 1000), }, }); @@ -132,43 +140,84 @@ export async function createRegistrationOptions( return { options, challengeId: challenge.id }; } -export async function verifyRegistration( - challengeId: string, - response: unknown, -): Promise { - const challenge = await prisma.authChallenge.findUnique({ - where: { id: challengeId }, +export type RegistrationChallengeClaim = + | { ok: true; expectedChallenge: string; createdAt: Date } + | { ok: false; reason: "invalid" | "wrong_context" }; + +/** + * Claim a registration challenge for exactly one cookie session. + * + * The preliminary lookup is used only to classify a foreign account/session + * without consuming its challenge. The guarded DELETE is the actual claim: + * concurrent requests cannot both receive the challenge material. + */ +export async function consumeRegistrationChallenge(input: { + challengeId: string; + userId: string; + sessionId: string; +}): Promise { + const row = await prisma.authChallenge.findUnique({ + where: { id: input.challengeId }, + select: { userId: true, type: true, expiresAt: true }, }); - if (!challenge || challenge.expiresAt < new Date()) { - throw new Error("Challenge expired or not found"); + if (!row) return { ok: false, reason: "invalid" }; + + const expectedType = registrationChallengeType(input.sessionId); + if ( + row.userId !== input.userId || + (row.type.startsWith(PASSKEY_REGISTRATION_CHALLENGE_PREFIX) && + row.type !== expectedType) + ) { + return { ok: false, reason: "wrong_context" }; } - try { - // v1.4.43 W10 senior-dev L-1 — Zod narrow at the boundary before - // delegating to SimpleWebAuthn's full cryptographic validation. - // A malformed body now fails fast with a structured Zod error - // rather than crashing on a follow-up `.id` deref deeper in the - // verifier. Mirrors the v1.4.43 W13 L-3 narrowing on the - // authentication side. - const parsed = registrationResponseSchema.safeParse(response); - if (!parsed.success) { - throw new Error("Registration response shape invalid"); - } - const verification = await verifyRegistrationResponse({ - response: parsed.data as RegistrationResponseJSON, - expectedChallenge: challenge.challenge, - expectedOrigin: getExpectedOrigin(), - expectedRPID: getRpId(), - }); - - return verification; - } finally { - // Invalidate challenge after first verification attempt (success or failure) - await prisma.authChallenge - .delete({ where: { id: challengeId } }) - .catch(() => {}); + if (row.type !== expectedType || row.expiresAt <= new Date()) { + return { ok: false, reason: "invalid" }; } + + const claimed = await prisma.$queryRaw< + Array<{ challenge: string; created_at: Date }> + >` + DELETE FROM auth_challenges + WHERE id = ${input.challengeId} + AND user_id = ${input.userId} + AND type = ${expectedType} + AND expires_at > NOW() + RETURNING challenge, created_at + `; + + if (claimed.length !== 1) { + return { ok: false, reason: "invalid" }; + } + + return { + ok: true, + expectedChallenge: claimed[0].challenge, + createdAt: claimed[0].created_at, + }; +} + +export async function verifyRegistration( + expectedChallenge: string, + response: unknown, +): Promise { + // v1.4.43 W10 senior-dev L-1 — Zod narrow at the boundary before + // delegating to SimpleWebAuthn's full cryptographic validation. + // A malformed body now fails fast with a structured Zod error + // rather than crashing on a follow-up `.id` deref deeper in the + // verifier. Mirrors the v1.4.43 W13 L-3 narrowing on the + // authentication side. + const parsed = registrationResponseSchema.safeParse(response); + if (!parsed.success) { + throw new Error("Registration response shape invalid"); + } + return verifyRegistrationResponse({ + response: parsed.data as RegistrationResponseJSON, + expectedChallenge, + expectedOrigin: getExpectedOrigin(), + expectedRPID: getRpId(), + }); } // ── Authentication ─────────────────────────────────────── From 7874eea04d6112aea378cf2969f073465ea2a985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:53:59 +0200 Subject: [PATCH 028/125] feat(01-08): require reauthentication before passkey enrollment - offer password, TOTP, passkey, and security-key proof choices - start registration only after the selected existing factor succeeds --- .../security-section/passkey-list-section.tsx | 210 ++++++++++++++++-- 1 file changed, 188 insertions(+), 22 deletions(-) diff --git a/src/components/settings/security-section/passkey-list-section.tsx b/src/components/settings/security-section/passkey-list-section.tsx index eda11e875..20232f55d 100644 --- a/src/components/settings/security-section/passkey-list-section.tsx +++ b/src/components/settings/security-section/passkey-list-section.tsx @@ -50,6 +50,17 @@ interface PasskeyInfo { lastUsedAt: string | null; } +type ReauthMethod = "password" | "passkey" | "totp" | "webauthn"; + +type ExistingFactorProof = + | { method: "password"; password: string } + | { method: "totp"; code: string } + | { + method: "passkey" | "webauthn"; + challengeId: string; + credential: unknown; + }; + /** * Primary-passkey management: list (name, device, backup, created, last used), * add (registration ceremony), rename, and delete. The passwordless-primary @@ -65,6 +76,10 @@ export function PasskeyListSection({ const [msg, setMsg] = useState(null); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); + const [reauthOpen, setReauthOpen] = useState(false); + const [reauthMethod, setReauthMethod] = + useState("password"); + const [reauthSecret, setReauthSecret] = useState(""); const { data: passkeys } = useQuery({ queryKey: queryKeys.passkeys(), @@ -73,13 +88,46 @@ export function PasskeyListSection({ }); const add = useMutation({ - mutationFn: async () => { - const { startRegistration } = await import("@simplewebauthn/browser"); + mutationFn: async ({ + method, + secret, + }: { + method: ReauthMethod; + secret: string; + }) => { + const webauthn = await import("@simplewebauthn/browser"); + let freshFactor: ExistingFactorProof; + + if (method === "password") { + freshFactor = { method, password: secret }; + } else if (method === "totp") { + freshFactor = { method, code: secret }; + } else { + const assertionChallenge = await apiPost<{ + options: Parameters< + typeof webauthn.startAuthentication + >[0]["optionsJSON"]; + challengeId: string; + }>("/api/auth/passkey/register-options", { method }); + const credential = await webauthn.startAuthentication({ + optionsJSON: assertionChallenge.options, + }); + freshFactor = { + method, + challengeId: assertionChallenge.challengeId, + credential, + }; + } + const { options, challengeId } = await apiPost<{ - options: Parameters[0]["optionsJSON"]; + options: Parameters< + typeof webauthn.startRegistration + >[0]["optionsJSON"]; challengeId: string; - }>("/api/auth/passkey/register-options"); - const credential = await startRegistration({ optionsJSON: options }); + }>("/api/auth/passkey/register-options", freshFactor); + const credential = await webauthn.startRegistration({ + optionsJSON: options, + }); await apiPost("/api/auth/passkey/register-verify", { challengeId, credential, @@ -87,6 +135,9 @@ export function PasskeyListSection({ }, onSuccess: () => { setMsg(null); + setReauthOpen(false); + setReauthSecret(""); + setReauthMethod("password"); queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() }); }, onError: (err) => { @@ -283,24 +334,139 @@ export function PasskeyListSection({ )} -
- -
+ { + if (add.isPending) return; + setReauthOpen(open); + if (open) setMsg(null); + }} + > +
+ + + +
+ + + Confirm it's you + + Verify an existing sign-in method before adding a new passkey. + + - {msg && ( +
+ {( + [ + ["password", "Password"], + ["totp", "Authenticator code"], + ["passkey", "Passkey"], + ["webauthn", "Security key"], + ] satisfies Array<[ReauthMethod, string]> + ).map(([method, label]) => ( + + ))} +
+ + {reauthMethod === "password" && ( + setReauthSecret(event.target.value)} + placeholder="Current password" + aria-label="Current password" + autoFocus + /> + )} + {reauthMethod === "totp" && ( + + setReauthSecret(event.target.value.replace(/\D/g, "")) + } + placeholder="6-digit authenticator code" + aria-label="Authenticator code" + autoFocus + /> + )} + + {msg && ( +
+ + {msg} +
+ )} + + + + {t("common.cancel")} + + { + event.preventDefault(); + add.mutate({ + method: reauthMethod, + secret: reauthSecret, + }); + }} + > + {add.isPending && ( + + )} + Verify and add + + +
+
+ + {msg && !reauthOpen && (
Date: Wed, 29 Jul 2026 13:55:11 +0200 Subject: [PATCH 029/125] fix(01-08): revoke credentials atomically on password reset - lock the resolved account and rotate its password in one transaction - revoke sessions, tokens, trusted devices, and elevations with rollback safety --- scripts/reset-password.mjs | 86 ++++++++++++++++++++++++++------------ 1 file changed, 60 insertions(+), 26 deletions(-) diff --git a/scripts/reset-password.mjs b/scripts/reset-password.mjs index e31cfd8c8..b30e0de6e 100644 --- a/scripts/reset-password.mjs +++ b/scripts/reset-password.mjs @@ -75,34 +75,68 @@ async function main() { const client = new pg.Client({ connectionString: databaseUrl }); await client.connect(); try { - // Case-insensitive match on username OR email, mirroring the login lookup. - // Scope to a single user: zero or multiple matches is a hard error so an - // operator never silently resets the wrong account. - const found = await client.query( - `SELECT id, username FROM users - WHERE lower(username) = lower($1) OR lower(email) = lower($1)`, - [identifier], - ); - - if (found.rowCount === 0) { - fail(`no user matches "${identifier}"`); - } - if (found.rowCount > 1) { - fail( - `"${identifier}" matches ${found.rowCount} users — refusing to guess; reset by exact username`, + await client.query("BEGIN"); + try { + // Case-insensitive match on username OR email, mirroring the login + // lookup. Lock every match until commit so identity resolution and the + // credential-family revocation are one serialized account operation. + const found = await client.query( + `SELECT id, username FROM users + WHERE lower(username) = lower($1) OR lower(email) = lower($1) + FOR UPDATE`, + [identifier], ); + + if (found.rowCount === 0) { + throw new Error(`no user matches "${identifier}"`); + } + if (found.rowCount > 1) { + throw new Error( + `"${identifier}" matches ${found.rowCount} users — refusing to guess; reset by exact username`, + ); + } + + const user = found.rows[0]; + const passwordHash = await hash(newPassword, ARGON2_HASH_OPTIONS); + + await client.query( + `UPDATE users + SET password_hash = $1, updated_at = now() + WHERE id = $2`, + [passwordHash, user.id], + ); + const elevations = await client.query( + `DELETE FROM step_up_elevations WHERE user_id = $1`, + [user.id], + ); + const apiTokens = await client.query( + `UPDATE api_tokens SET revoked = true WHERE user_id = $1`, + [user.id], + ); + const refreshTokens = await client.query( + `UPDATE refresh_tokens SET revoked_at = now() WHERE user_id = $1`, + [user.id], + ); + const trustedDevices = await client.query( + `DELETE FROM trusted_devices WHERE user_id = $1`, + [user.id], + ); + const sessions = await client.query( + `DELETE FROM sessions WHERE user_id = $1`, + [user.id], + ); + + await client.query("COMMIT"); + + // Never echo the password or credential material. Counts are safe + // operator confirmation that every credential family was addressed. + console.log( + `reset-password: password updated for "${user.username}"; revoked ${sessions.rowCount} sessions, ${apiTokens.rowCount} API tokens, ${refreshTokens.rowCount} refresh tokens, ${trustedDevices.rowCount} trusted devices, and ${elevations.rowCount} step-up elevations`, + ); + } catch (error) { + await client.query("ROLLBACK").catch(() => {}); + throw error; } - - const user = found.rows[0]; - const passwordHash = await hash(newPassword, ARGON2_HASH_OPTIONS); - - await client.query( - `UPDATE users SET password_hash = $1, updated_at = now() WHERE id = $2`, - [passwordHash, user.id], - ); - - // Never echo the password; confirm by username only. - console.log(`reset-password: password updated for "${user.username}"`); } finally { await client.end(); } From b7b18a6da27c3911247f06c6e7426b5252d4b25d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:55:57 +0200 Subject: [PATCH 030/125] fix(01-08): align passkey request fixture typing - construct NextRequest options with the framework-compatible init type - preserve every enrollment security assertion unchanged --- tests/integration/passkey-register.test.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/tests/integration/passkey-register.test.ts b/tests/integration/passkey-register.test.ts index 8f4618a4b..d1dd00921 100644 --- a/tests/integration/passkey-register.test.ts +++ b/tests/integration/passkey-register.test.ts @@ -78,12 +78,14 @@ function request(path: string, body?: unknown): NextRequest { const headers: Record = {}; const authorization = headerJar.get("authorization"); if (authorization) headers.authorization = authorization; - const init: RequestInit = { method: "POST", headers }; if (body !== undefined) { headers["content-type"] = "application/json"; - init.body = JSON.stringify(body); } - return new NextRequest(`http://localhost${path}`, init); + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); } async function registerOptions(proof?: Record) { From f95da50ba07880ce2836e770830e13bc35c5683d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:57:07 +0200 Subject: [PATCH 031/125] fix(01-07): enforce exact Nightscout origins - move private-host authority to the server-owned exact-origin policy - preserve stable redacted policy failures through connect and sync --- src/app/api/nightscout/connect/route.ts | 43 +++++-- src/app/api/nightscout/sync/route.ts | 14 +- src/lib/nightscout/client.ts | 75 ++++++++--- src/lib/nightscout/credentials.ts | 1 + src/lib/nightscout/sync.ts | 31 ++++- src/lib/validations/nightscout.ts | 162 ++++++++++++++++++++++-- 6 files changed, 270 insertions(+), 56 deletions(-) diff --git a/src/app/api/nightscout/connect/route.ts b/src/app/api/nightscout/connect/route.ts index 6108e05cc..57a221e2d 100644 --- a/src/app/api/nightscout/connect/route.ts +++ b/src/app/api/nightscout/connect/route.ts @@ -11,10 +11,16 @@ import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { markReconnected } from "@/lib/integrations/status"; import { NIGHTSCOUT_SYNC_QUEUE } from "@/lib/jobs/integration-poll-queues"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; -import { SafeFetchError } from "@/lib/safe-fetch"; -import { fetchSgvEntries, NightscoutApiError } from "@/lib/nightscout/client"; -import { nightscoutConnectSchema } from "@/lib/validations/nightscout"; -import { isPublicUrl } from "@/lib/validations/notifications"; +import { + fetchSgvEntries, + NightscoutApiError, + NightscoutPolicyError, +} from "@/lib/nightscout/client"; +import { + configuredNightscoutPrivateOrigins, + evaluateNightscoutOrigin, + nightscoutConnectSchema, +} from "@/lib/validations/nightscout"; /** * Connect (or update) the user's Nightscout instance (v1.17.0). @@ -50,7 +56,11 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("A valid Nightscout URL is required", 422); } - const { url, token, allowPrivateHost } = result.data; + const { url, token } = result.data; + const policy = evaluateNightscoutOrigin( + url, + configuredNightscoutPrivateOrigins(), + ); // SSRF floor at INPUT time, not merely as a side effect of the live probe // below. `fetchSgvEntries` does route through `safeFetch` with the same @@ -61,13 +71,13 @@ export const POST = apiHandler(async (request: NextRequest) => { // Assert it here so the thing that gets encrypted onto the row is the // thing that was validated. // - // Gated on the same explicit opt-in the fetch path uses: a self-hoster who - // deliberately points at a LAN instance has asked for the private host and - // must still be able to connect. - if (!allowPrivateHost && !isPublicUrl(url)) { + // The legacy request boolean is intentionally ignored. Only the server-owned + // exact canonical origin set may authorize private egress. + if (!policy.allowed || !policy.canonicalOrigin) { return apiError( - "That instance is on a private network. Enable the private-network option to connect to it.", + "Private network Nightscout access requires server operator approval", 422, + { errorCode: "private_origin_not_approved" }, ); } @@ -78,13 +88,16 @@ export const POST = apiHandler(async (request: NextRequest) => { baseUrl: url, token, count: 1, - allowPrivateHost, + // Compatibility shape for older callers/tests. The client deliberately + // ignores this field and re-evaluates server policy on every fetch. + allowPrivateHost: false, }); } catch (err) { - if (err instanceof SafeFetchError && err.kind === "private_host") { + if (err instanceof NightscoutPolicyError) { return apiError( - "That instance is on a private network. Enable the private-network option to connect to it.", + "Private network Nightscout access requires server operator approval", 422, + { errorCode: err.reasonCode }, ); } if (err instanceof NightscoutApiError) { @@ -111,7 +124,9 @@ export const POST = apiHandler(async (request: NextRequest) => { // Build the data object field-by-field — never spread the parsed body. nightscoutUrlEncrypted: encrypt(url), nightscoutTokenEncrypted: token ? encrypt(token) : null, - nightscoutAllowPrivateHost: allowPrivateHost, + // Compatibility/display metadata only. Server policy is rechecked from + // NIGHTSCOUT_PRIVATE_ORIGINS before every request. + nightscoutAllowPrivateHost: policy.privateOriginApproved, }, }); diff --git a/src/app/api/nightscout/sync/route.ts b/src/app/api/nightscout/sync/route.ts index 99504bb4d..09179805b 100644 --- a/src/app/api/nightscout/sync/route.ts +++ b/src/app/api/nightscout/sync/route.ts @@ -39,7 +39,19 @@ export const POST = apiHandler(async () => { try { const result = await syncUserNightscout(user.id); return apiSuccess(resolveSyncOutcome(result)); - } catch { + } catch (error) { + if ( + typeof error === "object" && + error !== null && + "reasonCode" in error && + error.reasonCode === "private_origin_not_approved" + ) { + return apiError( + "Private Nightscout access requires server operator approval", + 422, + { errorCode: "private_origin_not_approved" }, + ); + } return apiError("Nightscout sync failed", 502); } }); diff --git a/src/lib/nightscout/client.ts b/src/lib/nightscout/client.ts index e40f8b785..a504ba127 100644 --- a/src/lib/nightscout/client.ts +++ b/src/lib/nightscout/client.ts @@ -8,13 +8,11 @@ * the entry→Measurement mapping. No SDK — hand-rolled fetch over `safeFetch`, * mirroring the Withings / WHOOP / Fitbit clients. * - * SSRF (critical): every outbound call routes through `safeFetch` with - * `requirePublicHost: true` BY DEFAULT — public instances are the norm, and a - * malicious URL pointing at `169.254.169.254` / an RFC1918 admin panel must be - * refused. Only when the user has flipped `nightscoutAllowPrivateHost` (the - * explicit self-hoster opt-in for a LAN instance) does the public-host pin - * come off. Redirects are pinned to `manual` and a timeout is always composed - * so a tar-pit instance can't hold a worker. + * SSRF (critical): every outbound call re-evaluates the exact server-owned + * origin policy. Public instances use safeFetch's input and DNS-pinned public + * verdict. A private origin is reachable only when its complete canonical + * scheme/host/port is in NIGHTSCOUT_PRIVATE_ORIGINS. The legacy user boolean + * is accepted as compatibility input but never grants egress. * * Auth: Nightscout accepts the API secret two ways — the `api-secret` HTTP * header carrying the SHA1 hex of the secret, OR a `token` query param (a @@ -26,6 +24,12 @@ import { createHash } from "node:crypto"; import type { MeasurementType } from "@/generated/prisma/client"; import { safeFetch } from "@/lib/safe-fetch"; +import { + configuredNightscoutPrivateOrigins, + evaluateNightscoutOrigin, + NIGHTSCOUT_PRIVATE_ORIGIN_REASON, +} from "@/lib/validations/nightscout"; +import { SafeFetchError } from "@/lib/safe-fetch"; /** Default per-request timeout for a Nightscout fetch. */ const NIGHTSCOUT_TIMEOUT_MS = 15_000; @@ -71,7 +75,8 @@ export interface FetchSgvOptions { baseUrl: string; token: string; count: number; - allowPrivateHost: boolean; + /** @deprecated Compatibility metadata only; never egress authority. */ + allowPrivateHost?: boolean; /** Defaults to `token` (query param). `header` uses the api-secret SHA1. */ authMode?: NightscoutAuthMode; /** Override the default timeout (tests). */ @@ -176,6 +181,15 @@ export class NightscoutApiError extends Error { } } +export class NightscoutPolicyError extends Error { + readonly reasonCode = NIGHTSCOUT_PRIVATE_ORIGIN_REASON; + + constructor() { + super(NIGHTSCOUT_PRIVATE_ORIGIN_REASON); + this.name = "NightscoutPolicyError"; + } +} + /** * Fetch the most recent `count` SGV entries from the user's instance. * @@ -188,6 +202,14 @@ export class NightscoutApiError extends Error { export async function fetchSgvEntries( opts: FetchSgvOptions, ): Promise { + const policy = evaluateNightscoutOrigin( + opts.baseUrl, + configuredNightscoutPrivateOrigins(), + ); + if (!policy.allowed || !policy.canonicalOrigin) { + throw new NightscoutPolicyError(); + } + const authMode: NightscoutAuthMode = opts.authMode ?? "token"; const headers: Record = { Accept: "application/json" }; let url: string; @@ -195,22 +217,35 @@ export async function fetchSgvEntries( if (authMode === "header" && opts.token) { // Classic `API_SECRET` instances: the SHA1 hex in the api-secret header. headers["api-secret"] = sha1Hex(opts.token); - url = buildEntriesUrl(opts.baseUrl, opts.count); + url = buildEntriesUrl(policy.canonicalOrigin, opts.count); } else { // Token query-param path (role-scoped access token; the default). - url = buildEntriesUrl(opts.baseUrl, opts.count, opts.token || undefined); + url = buildEntriesUrl( + policy.canonicalOrigin, + opts.count, + opts.token || undefined, + ); } - const res = await safeFetch( - url, - { method: "GET", headers }, - { - // Default-on public-host pin; relaxed ONLY when the user opted a - // private/LAN instance in. `followRedirects` defaults false (manual). - requirePublicHost: !opts.allowPrivateHost, - timeoutMs: opts.timeoutMs ?? NIGHTSCOUT_TIMEOUT_MS, - }, - ); + let res: Response; + try { + res = await safeFetch( + url, + { method: "GET", headers }, + { + // Public origins receive the full literal + DNS-pinned classifier. + // An exact operator origin may resolve privately; redirects remain + // manual, so no response can move the request outside that grant. + requirePublicHost: !policy.privateOriginApproved, + timeoutMs: opts.timeoutMs ?? NIGHTSCOUT_TIMEOUT_MS, + }, + ); + } catch (error) { + if (error instanceof SafeFetchError && error.kind === "private_host") { + throw new NightscoutPolicyError(); + } + throw error; + } if (res.status < 200 || res.status >= 300) { throw new NightscoutApiError( diff --git a/src/lib/nightscout/credentials.ts b/src/lib/nightscout/credentials.ts index 4e704a455..7a9c59411 100644 --- a/src/lib/nightscout/credentials.ts +++ b/src/lib/nightscout/credentials.ts @@ -14,6 +14,7 @@ import { decrypt } from "@/lib/crypto"; export interface NightscoutCredentials { baseUrl: string; token: string; + /** @deprecated Compatibility/display metadata; never egress authority. */ allowPrivateHost: boolean; } diff --git a/src/lib/nightscout/sync.ts b/src/lib/nightscout/sync.ts index 724ccf46b..adef2de55 100644 --- a/src/lib/nightscout/sync.ts +++ b/src/lib/nightscout/sync.ts @@ -44,6 +44,7 @@ import { fetchSgvEntries, mapSgvEntryToMeasurement, NightscoutApiError, + NightscoutPolicyError, type ParsedSgvEntry, } from "./client"; import { getUserNightscoutCredentials } from "./credentials"; @@ -92,15 +93,24 @@ export async function recordNightscoutSyncFailure( userId: string, err: unknown, ): Promise { + const policyDenied = + err instanceof NightscoutPolicyError || + (typeof err === "object" && + err !== null && + "reasonCode" in err && + err.reasonCode === "private_origin_not_approved"); await recordSyncFailure({ userId, integration: "nightscout", kind: classifyNightscoutFailure(err), - message: redactNightscoutSecret( - err instanceof Error ? err.message : String(err), - ), - errorCode: - err instanceof NightscoutApiError && err.status != null + message: policyDenied + ? "private_origin_not_approved" + : redactNightscoutSecret( + err instanceof Error ? err.message : String(err), + ), + errorCode: policyDenied + ? "private_origin_not_approved" + : err instanceof NightscoutApiError && err.status != null ? String(err.status) : undefined, }); @@ -109,7 +119,15 @@ export async function recordNightscoutSyncFailure( /** Map a Nightscout HTTP status / network error onto the shared ledger kind. */ export function classifyNightscoutFailure(err: unknown): FailureKind { let classification: IntegrationClassification = "transient"; - if (err instanceof NightscoutApiError && err.status != null) { + if ( + err instanceof NightscoutPolicyError || + (typeof err === "object" && + err !== null && + "reasonCode" in err && + err.reasonCode === "private_origin_not_approved") + ) { + classification = "persistent"; + } else if (err instanceof NightscoutApiError && err.status != null) { // Wrong / missing token, or a token whose role can't read SGV. if (err.status === 401 || err.status === 403) { classification = "reauth_required"; @@ -152,7 +170,6 @@ export async function syncUserNightscout( baseUrl: creds.baseUrl, token: creds.token, count: opts.count ?? NIGHTSCOUT_SYNC_COUNT, - allowPrivateHost: creds.allowPrivateHost, }); } catch (err) { // Recorded here (token-redacted), then marked so the poll-cohort boundary diff --git a/src/lib/validations/nightscout.ts b/src/lib/validations/nightscout.ts index 010f2d753..12d289a17 100644 --- a/src/lib/validations/nightscout.ts +++ b/src/lib/validations/nightscout.ts @@ -1,23 +1,157 @@ import { z } from "zod/v4"; +import { isPublicUrl } from "@/lib/validations/notifications"; + +export const NIGHTSCOUT_PRIVATE_ORIGIN_REASON = + "private_origin_not_approved" as const; +export const NIGHTSCOUT_INVALID_ORIGIN_REASON = "invalid_origin" as const; + +export type NightscoutOriginReason = + | typeof NIGHTSCOUT_PRIVATE_ORIGIN_REASON + | typeof NIGHTSCOUT_INVALID_ORIGIN_REASON; + +export interface NightscoutOriginVerdict { + allowed: boolean; + canonicalOrigin: string | null; + privateOriginApproved: boolean; + reasonCode: NightscoutOriginReason | null; +} + +/** + * Parse one exact http(s) origin. + * + * Credentials, paths, queries, and fragments are deliberately forbidden: the + * operator grants a complete canonical scheme/host/port trust unit, never a + * suffix, wildcard, capability URL, or prefix. + */ +function canonicalOrigin(value: string): string | null { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + if (url.username || url.password) return null; + if (url.pathname !== "/" || url.search || url.hash) return null; + if (!url.hostname || url.hostname.includes("*")) return null; + return url.origin; + } catch { + return null; + } +} + +function sharesApprovedPrivateHostname( + origin: string, + privateOrigins: ReadonlySet, +): boolean { + const hostname = new URL(origin).hostname; + + for (const approvedOrigin of privateOrigins) { + const approvedHostname = new URL(approvedOrigin).hostname; + if ( + hostname === approvedHostname || + hostname.endsWith(`.${approvedHostname}`) + ) { + return true; + } + } + + return false; +} + +/** + * Parse the server-only comma-separated exact-origin allowlist. + * + * Invalid non-empty entries fail startup/call-site evaluation loudly rather + * than being skipped: partial acceptance makes an operator believe a private + * integration is protected while silently changing which origin is trusted. + */ +export function parseNightscoutPrivateOrigins( + raw: string | undefined, +): ReadonlySet { + const origins = new Set(); + for (const entry of (raw ?? "").split(",")) { + const trimmed = entry.trim(); + if (!trimmed) continue; + const origin = canonicalOrigin(trimmed); + if (!origin) { + throw new Error("Invalid NIGHTSCOUT_PRIVATE_ORIGINS entry"); + } + origins.add(origin); + } + return origins; +} + +/** + * Resolve one user-supplied Nightscout base URL against server policy. + * + * Exact operator membership wins before the ordinary public-host verdict so a + * private DNS name can be trusted without granting its suffix or sibling + * ports. Public origins remain supported without configuration. + */ +export function evaluateNightscoutOrigin( + value: string, + privateOrigins: ReadonlySet, +): NightscoutOriginVerdict { + const origin = canonicalOrigin(value); + if (!origin) { + return { + allowed: false, + canonicalOrigin: null, + privateOriginApproved: false, + reasonCode: NIGHTSCOUT_INVALID_ORIGIN_REASON, + }; + } + + if (privateOrigins.has(origin)) { + return { + allowed: true, + canonicalOrigin: origin, + privateOriginApproved: true, + reasonCode: null, + }; + } + + // An exact grant must not accidentally become a hostname suffix grant. + // Deny related hosts before the generic public-host fallback: private DNS + // names such as cgm.lan may otherwise look syntactically public here. + if (sharesApprovedPrivateHostname(origin, privateOrigins)) { + return { + allowed: false, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: NIGHTSCOUT_PRIVATE_ORIGIN_REASON, + }; + } + + if (isPublicUrl(origin)) { + return { + allowed: true, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: null, + }; + } + + return { + allowed: false, + canonicalOrigin: origin, + privateOriginApproved: false, + reasonCode: NIGHTSCOUT_PRIVATE_ORIGIN_REASON, + }; +} + +export function configuredNightscoutPrivateOrigins(): ReadonlySet { + return parseNightscoutPrivateOrigins(process.env.NIGHTSCOUT_PRIVATE_ORIGINS); +} /** * Per-user Nightscout connection input (v1.17.0). The self-hoster points * HealthLog at their own Nightscout instance (Railway / Heroku / Fly / a LAN * box) and pastes the instance's API token. Both fields are stored encrypted * on `User` (`nightscoutUrlEncrypted` / `nightscoutTokenEncrypted`); the - * private-host opt-in maps to `nightscoutAllowPrivateHost`. + * legacy private-host boolean is accepted for compatibility but never grants + * network authority. * - * The URL is validated to be a parseable http(s) origin here. The SSRF floor - * (public-host requirement, unless `allowPrivateHost` is set) is asserted - * twice on purpose: the connect route runs `isPublicUrl` before it persists - * anything, and `safeFetch` re-checks plus pins the resolved IP at connect - * time. The shape check here only rejects garbage early so the route can - * return a clear 422 instead of letting a malformed string reach either. - * - * The floor is NOT expressed as a Zod refinement because it depends on the - * sibling `allowPrivateHost` field and needs its own error message; folding - * it in here would collapse "malformed URL" and "private host, opt in to - * reach it" into one opaque 422. + * The URL is validated to be parseable here. Exact server policy is evaluated + * by the route and again by the client so the request body cannot confer + * private-network authority. */ export const nightscoutConnectSchema = z.object({ // Trimmed: a trailing space / newline from a copy button reaches the @@ -39,8 +173,8 @@ export const nightscoutConnectSchema = z.object({ // the raw `API_SECRET`). Optional: a fully public instance with // `AUTH_DEFAULT_ROLES=readable` serves SGV entries without a token. token: z.string().trim().max(512).optional().default(""), - // The explicit self-hoster opt-in to allow a private / LAN Nightscout host - // past the public-host SSRF floor. Defaults false (public instances). + // Deprecated request compatibility only. This value is deliberately + // ignored by the route and client. allowPrivateHost: z.boolean().optional().default(false), }); From 27a3e08436dc94e941605f0cc8a0f7aa3d68aca8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 13:57:48 +0200 Subject: [PATCH 032/125] fix(01-07): make private Nightscout access operator-owned - remove the user-facing private-host bypass - render a redacted operator-action state for denied legacy connections --- .../settings/integrations/nightscout-card.tsx | 85 ++++++++++--------- 1 file changed, 43 insertions(+), 42 deletions(-) diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx index d5917f0c2..3008bed95 100644 --- a/src/components/settings/integrations/nightscout-card.tsx +++ b/src/components/settings/integrations/nightscout-card.tsx @@ -6,7 +6,8 @@ // card reads the consolidated `/api/integrations/status` envelope like every // sibling — it was the last card firing its own status round-trip, and the // last place a fourth status dialect could hide. Warm copy, mobile-first, the -// private-network opt-in toggle maps to `nightscoutAllowPrivateHost`. +// private-network access is operator-owned through an exact-origin server +// allowlist; ordinary users cannot weaken the outbound-request policy. import { useRef, useState } from "react"; import Link from "next/link"; @@ -36,7 +37,6 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { PasswordInput } from "@/components/ui/password-input"; -import { Switch } from "@/components/ui/switch"; import { TagChip } from "@/components/ui/tag-chip"; import { SettingsCard } from "@/components/settings/settings-card"; import { SettingsCardHeader } from "@/components/settings/_card-header"; @@ -77,7 +77,6 @@ export function NightscoutCard({ const [url, setUrl] = useState(""); const [token, setToken] = useState(""); - const [allowPrivateHost, setAllowPrivateHost] = useState(false); const [saving, setSaving] = useState(false); const [msg, setMsg] = useState(null); const [msgType, setMsgType] = useState<"success" | "error" | null>(null); @@ -95,7 +94,6 @@ export function NightscoutCard({ onSuccess: () => { setUrl(""); setToken(""); - setAllowPrivateHost(false); // The card reads the consolidated envelope, so that is the one key a // disconnect has to invalidate for the pill to flip back. queryClient.invalidateQueries({ @@ -116,7 +114,6 @@ export function NightscoutCard({ body: JSON.stringify({ url: url.trim(), token: token.trim(), - allowPrivateHost, }), }); if (res.ok) { @@ -184,6 +181,8 @@ export function NightscoutCard({ } const pillState = pillStateForVerdict(status?.syncHealth?.verdict); + const operatorApprovalRequired = + status?.lastError?.startsWith("private_origin_not_approved") ?? false; // `warning` joins the set now that a transient failure paints amber rather // than red — the detail belongs on this line, not in a colour that tells the // user to reconnect against something reconnecting cannot fix. @@ -191,7 +190,8 @@ export function NightscoutCard({ (pillState === "error" || pillState === "parked" || pillState === "warning") && - status?.lastError + status?.lastError && + !operatorApprovalRequired ? status.lastError : null; @@ -223,6 +223,19 @@ export function NightscoutCard({
{errorMessage && } + {operatorApprovalRequired && ( +
+

+ Private Nightscout access requires server operator approval. +

+

+ Ask your server operator to approve this exact Nightscout origin. +

+
+ )} {/* Parked-integration resume CTA. Surfaces only when the row state is `parked` (>24h of persistent failures). Nightscout has no OAuth @@ -313,22 +326,6 @@ export function NightscoutCard({

-
-
- -

- {t("settings.nightscoutPrivateHostHelp")} -

-
- -
-
- + {!operatorApprovalRequired && ( + <> + + + + )} + ), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { AllergyManager } from "../allergy-manager"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + mocks.rows = []; +}); + +describe(" action grammar", () => { + it("shows exactly one first-entry action and no duplicate top add action when empty", () => { + const html = render(); + + expect(html.match(/Add your first allergy/g)).toHaveLength(1); + expect(html).not.toMatch(/>Add allergy<\/button>/); + }); + + it("shows one normal top add action plus edit/delete controls when populated", () => { + mocks.rows = [ + { + id: "allergy-1", + substance: "Penicillin", + category: "MEDICATION", + type: "ALLERGY", + severity: "SEVERE", + status: "ACTIVE", + onsetAt: null, + reaction: "Hives", + note: null, + createdAt: "2026-07-28T10:00:00.000Z", + updatedAt: "2026-07-28T10:00:00.000Z", + }, + ]; + + const html = render(); + + expect(html.match(/>Add allergy<\/button>/g)).toHaveLength(1); + expect(html).not.toContain("Add your first allergy"); + expect(html).toContain('aria-label="Edit"'); + expect(html).toContain('aria-label="Delete this allergy?"'); + expect(html).toContain("Penicillin"); + }); +}); diff --git a/src/components/records/__tests__/family-history-manager.test.tsx b/src/components/records/__tests__/family-history-manager.test.tsx new file mode 100644 index 000000000..1064b5607 --- /dev/null +++ b/src/components/records/__tests__/family-history-manager.test.tsx @@ -0,0 +1,77 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { renderToStaticMarkup } from "react-dom/server"; + +const mocks = vi.hoisted(() => ({ + rows: [] as Array>, +})); + +vi.mock("@tanstack/react-query", () => ({ + useQuery: () => ({ + data: mocks.rows, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useQueryClient: () => ({ invalidateQueries: vi.fn() }), + useMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); +vi.mock("@/lib/api/api-fetch", () => ({ + apiDelete: vi.fn(), + apiGet: vi.fn(), +})); +vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); +vi.mock("@/components/ui/responsive-sheet", () => ({ + ResponsiveSheet: () => null, +})); +vi.mock("@/components/data-list", () => ({ + DeleteButton: (props: { title: string }) => ( + + ), +})); + +import { I18nProvider } from "@/lib/i18n/context"; +import { FamilyHistoryManager } from "../family-history-manager"; + +function render() { + return renderToStaticMarkup( + + + , + ); +} + +beforeEach(() => { + mocks.rows = []; +}); + +describe(" action grammar", () => { + it("shows exactly one first-entry action and no duplicate top add action when empty", () => { + const html = render(); + + expect(html.match(/Add your first entry/g)).toHaveLength(1); + expect(html).not.toMatch(/>Add entry<\/button>/); + }); + + it("shows one normal top add action plus edit/delete controls when populated", () => { + mocks.rows = [ + { + id: "family-1", + relationship: "MOTHER", + condition: "Type 2 diabetes", + ageAtOnset: 55, + note: null, + createdAt: "2026-07-28T10:00:00.000Z", + updatedAt: "2026-07-28T10:00:00.000Z", + }, + ]; + + const html = render(); + + expect(html.match(/>Add entry<\/button>/g)).toHaveLength(1); + expect(html).not.toContain("Add your first entry"); + expect(html).toContain('aria-label="Edit"'); + expect(html).toContain('aria-label="Delete this entry?"'); + expect(html).toContain("Type 2 diabetes"); + expect(html).toContain("Mother"); + }); +}); diff --git a/src/components/records/__tests__/health-profile-facts-manager.test.tsx b/src/components/records/__tests__/health-profile-facts-manager.test.tsx index 669ed0bf3..54641b031 100644 --- a/src/components/records/__tests__/health-profile-facts-manager.test.tsx +++ b/src/components/records/__tests__/health-profile-facts-manager.test.tsx @@ -107,6 +107,27 @@ beforeEach(() => { }); describe("health-profile fact removal control", () => { + it("uses one Save label for new and existing lifestyle facts", () => { + render(); + + const actionLabels = renderedButtons + .filter( + (props) => + props.type === "button" && + props.variant === "outline" && + typeof props.onClick === "function", + ) + .map((props) => { + const children = Array.isArray(props.children) + ? props.children + : [props.children]; + return children.filter((child) => typeof child === "string").join(""); + }); + + expect(actionLabels).toEqual(["Save", "Save", "Save"]); + expect(actionLabels).not.toContain("Correct"); + }); + it("renders a confirmed removal action only for each recorded current fact", () => { const { html } = render(); diff --git a/src/hooks/__tests__/use-insight-status.test.ts b/src/hooks/__tests__/use-insight-status.test.ts index b36ddd5e8..59580c0f1 100644 --- a/src/hooks/__tests__/use-insight-status.test.ts +++ b/src/hooks/__tests__/use-insight-status.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { nextStatusPollInterval, @@ -65,4 +67,18 @@ describe("nextStatusPollInterval — v1.8.4 poll ceiling", () => { nextStatusPollInterval(false, STATUS_POLL_MAX_ATTEMPTS + 5, true), ).toBe(false); }); + + it("exposes poll exhaustion as a terminal state instead of leaving preparing true forever", () => { + const source = readFileSync( + join(process.cwd(), "src", "hooks", "use-insight-status.ts"), + "utf8", + ); + + // Stopping the timer is only half of the contract. Once the ceiling is + // reached, callers must be able to distinguish an exhausted attempt from + // an actively preparing response and must not keep painting the preparing + // skeleton forever. + expect(source).toContain("pollExhausted"); + expect(source).toMatch(/preparing:\s*false/); + }); }); From d5f473d6728dd069d185a6944903ed766212022d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:19:03 +0200 Subject: [PATCH 044/125] test(01-11): specify rolling weekly overdue identity - pin the prior-day Mounjaro occurrence across local midnight and DST - cover resolution state, band end, schedule siblings, and era isolation --- .../scheduling/__tests__/next-due.test.ts | 141 ++++++++++++++++++ .../scheduling/__tests__/recurrence.test.ts | 38 +++++ 2 files changed, 179 insertions(+) diff --git a/src/lib/medications/scheduling/__tests__/next-due.test.ts b/src/lib/medications/scheduling/__tests__/next-due.test.ts index 99f7c7445..19dd77bf1 100644 --- a/src/lib/medications/scheduling/__tests__/next-due.test.ts +++ b/src/lib/medications/scheduling/__tests__/next-due.test.ts @@ -558,3 +558,144 @@ describe("as-needed (zero schedules) — never due", () => { expect(display).toBeNull(); }); }); + +describe("Mounjaro rolling weekly — canonical overdue band after day change", () => { + const lastTaken = d("2026-07-21T06:00:00.000Z"); // Jul 21 08:00 Berlin + const missedOccurrence = d("2026-07-28T06:00:00.000Z"); // Jul 28 08:00 + const nextOccurrence = d("2026-08-04T06:00:00.000Z"); // Aug 4 08:00 + const medication = makeMedication({ + startsOn: d("2026-01-01T00:00:00.000Z"), + }); + const schedule = makeRollingSchedule({ + id: "mounjaro-weekly", + timesOfDay: ["08:00"], + }); + const identifiedMark = ( + at: Date, + identity: Record, + ): ResolvedSlotMark => + ({ + ...mark(at), + ...identity, + }) as ResolvedSlotMark; + + it("keeps yesterday's unresolved occurrence overdue on the following local day", () => { + const display = computeDisplayDue({ + medication, + schedules: [schedule], + now: d("2026-07-29T10:00:00.000Z"), + userTz: BERLIN, + lastIntakeAt: lastTaken, + resolvedSlots: [], + }); + + expect(display).toMatchObject({ + at: missedOccurrence, + overdue: true, + }); + }); + + it("keeps the occurrence through the exact canonical band end, then closes it", () => { + const atBandEnd = computeDisplayDue({ + medication, + schedules: [schedule], + now: d("2026-08-02T06:00:00.000Z"), + userTz: BERLIN, + lastIntakeAt: lastTaken, + resolvedSlots: [], + }); + expect(atBandEnd).toMatchObject({ + at: missedOccurrence, + overdue: true, + }); + + const afterBandEnd = computeDisplayDue({ + medication, + schedules: [schedule], + now: d("2026-08-02T06:00:00.001Z"), + userTz: BERLIN, + lastIntakeAt: lastTaken, + resolvedSlots: [], + }); + expect(afterBandEnd).toBeNull(); + }); + + it.each(["taken", "skipped", "autoMissed"] as const)( + "an exact %s row suppresses only this occurrence and advances", + (status) => { + const display = computeDisplayDue({ + medication, + schedules: [schedule], + now: d("2026-07-29T10:00:00.000Z"), + userTz: BERLIN, + lastIntakeAt: + status === "taken" + ? d("2026-07-28T06:10:00.000Z") + : lastTaken, + resolvedSlots: [ + identifiedMark(missedOccurrence, { + scheduleId: "mounjaro-weekly", + status, + }), + ], + }); + + expect(display).toMatchObject({ + at: nextOccurrence, + overdue: false, + }); + }, + ); + + it("does not let a sibling schedule's resolved occurrence suppress this schedule", () => { + const sibling = makeRollingSchedule({ + id: "mounjaro-sibling", + timesOfDay: ["10:00"], + windowStart: "10:00", + windowEnd: "10:00", + }); + const display = computeDisplayDue({ + medication, + schedules: [schedule, sibling], + now: d("2026-07-29T10:00:00.000Z"), + userTz: BERLIN, + lastIntakeAt: lastTaken, + resolvedSlots: [ + identifiedMark(missedOccurrence, { + scheduleId: "mounjaro-weekly", + status: "skipped", + }), + ], + }); + + expect(display).toMatchObject({ + at: d("2026-07-28T08:00:00.000Z"), + overdue: true, + scheduleId: "mounjaro-sibling", + }); + }); + + it("does not let an action from the archived era suppress the live-era occurrence", () => { + const liveEraStart = d("2026-07-27T12:00:00.000Z"); + const display = computeDisplayDue({ + medication, + schedules: [schedule], + now: d("2026-07-29T10:00:00.000Z"), + userTz: BERLIN, + lastIntakeAt: lastTaken, + eraStart: liveEraStart, + resolvedSlots: [ + identifiedMark(missedOccurrence, { + scheduleId: "mounjaro-weekly", + actionAt: d("2026-07-27T11:59:59.000Z"), + }), + ], + }); + + expect(display).toMatchObject({ + at: missedOccurrence, + overdue: true, + scheduleId: "mounjaro-weekly", + }); + }); +}); diff --git a/src/lib/medications/scheduling/__tests__/recurrence.test.ts b/src/lib/medications/scheduling/__tests__/recurrence.test.ts index f81918b27..9a355870c 100644 --- a/src/lib/medications/scheduling/__tests__/recurrence.test.ts +++ b/src/lib/medications/scheduling/__tests__/recurrence.test.ts @@ -473,6 +473,44 @@ describe("occurrencesBetween — rolling", () => { }); }); +describe("rolling occurrence identity across a local-day change", () => { + it.each([ + { + label: "ordinary Berlin week", + lastIntake: "2026-07-21T06:00:00.000Z", + now: "2026-07-29T10:00:00.000Z", + expected: "2026-07-28T06:00:00.000Z", + }, + { + label: "Berlin spring DST week", + lastIntake: "2026-03-22T07:00:00.000Z", + now: "2026-03-30T10:00:00.000Z", + expected: "2026-03-29T06:00:00.000Z", + }, + ])( + "retains the unresolved prior-day slot for an $label", + ({ lastIntake, now, expected }) => { + const schedule = makeSchedule({ + id: "mounjaro-weekly", + rollingIntervalDays: 7, + timesOfDay: ["08:00"], + }); + const ctx = makeCtx({ + lastIntakeAt: d(lastIntake), + medication: { startsOn: d("2026-01-01T00:00:00.000Z") }, + }); + + const occurrence = nextOccurrenceAfter(schedule, d(now), ctx); + + expect(occurrence).toMatchObject({ + scheduleId: "mounjaro-weekly", + at: d(expected), + timeOfDay: "08:00", + }); + }, + ); +}); + // ──────────────────────────────────────────────────────────────────── // One-shot // ──────────────────────────────────────────────────────────────────── From 6cc3de8248a24e15bf9f233d33c2b5be0490d7bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:20:56 +0200 Subject: [PATCH 045/125] test(01-12): add failing Apple ECG import contracts - pin bounded path-safe archive and parser behavior - require encrypted idempotent fail-soft persistence and WHOOP regressions --- .../__tests__/apple-health-import-ecg.test.ts | 63 +++ .../__tests__/apple-health-ecg-import.test.ts | 373 ++++++++++++++++++ .../source-priority-whoop-applehealth.test.ts | 50 +++ .../whoop/__tests__/sync-user-whoop.test.ts | 39 ++ .../apple-health-import-ecg.test.ts | 365 +++++++++++++++++ 5 files changed, 890 insertions(+) create mode 100644 src/lib/jobs/__tests__/apple-health-import-ecg.test.ts create mode 100644 src/lib/measurements/__tests__/apple-health-ecg-import.test.ts create mode 100644 tests/integration/apple-health-import-ecg.test.ts diff --git a/src/lib/jobs/__tests__/apple-health-import-ecg.test.ts b/src/lib/jobs/__tests__/apple-health-import-ecg.test.ts new file mode 100644 index 000000000..5c0cac646 --- /dev/null +++ b/src/lib/jobs/__tests__/apple-health-import-ecg.test.ts @@ -0,0 +1,63 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { APPLE_HEALTH_IMPORT_PARSER_REVISION } from "../apple-health-import-worker"; + +const workerSource = readFileSync( + join(process.cwd(), "src/lib/jobs/apple-health-import-worker.ts"), + "utf8", +); +const parserSource = readFileSync( + join(process.cwd(), "src/lib/measurements/import-apple-health-export.ts"), + "utf8", +); + +describe("Apple Health import worker — ECG auxiliary branch contract", () => { + it("bumps the parser revision so completed revision-2 archives reprocess", () => { + expect(APPLE_HEALTH_IMPORT_PARSER_REVISION).toBeGreaterThan(2); + }); + + it("runs bounded ECG archive ingestion beside the established XML parser", () => { + expect(workerSource).toMatch(/streamAppleHealthEcgMembers/); + expect(workerSource).toMatch(/parseAppleHealthEcgCsv/); + expect(workerSource).toMatch(/importAppleHealthEcg/); + expect(workerSource).toMatch(/streamParseExportXml/); + }); + + it("catches per-ECG failures without routing the valid XML result to the fatal job catch", () => { + expect(workerSource).toMatch( + /for await[\s\S]*streamAppleHealthEcgMembers[\s\S]*try[\s\S]*parseAppleHealthEcgCsv[\s\S]*catch[\s\S]*ecg\.failed/, + ); + expect(workerSource).toMatch( + /result[\s\S]*ecg:\s*\{[\s\S]*discovered[\s\S]*imported[\s\S]*updated[\s\S]*skipped[\s\S]*failed/, + ); + }); + + it("keeps progress bounded to scalar ECG counts", () => { + expect(parserSource).toMatch( + /ecg:\s*\{[\s\S]*discovered:\s*number[\s\S]*imported:\s*number[\s\S]*updated:\s*number[\s\S]*skipped:\s*number[\s\S]*failed:\s*number/, + ); + expect(parserSource).not.toMatch( + /ecg:\s*\{[^}]*?(?:samples|waveform|filename|sourceName|healthValue):/, + ); + }); + + it("does not log ECG filenames, source labels, samples, or raw parser errors", () => { + const loggingCalls = + workerSource.match(/console\.(?:log|warn|error)\([\s\S]{0,250}\)/g) ?? []; + const ecgLogging = loggingCalls.filter((call) => + /ecg|electrocardiogram/i.test(call), + ); + + expect(ecgLogging.join("\n")).not.toMatch( + /member|filename|sourceName|sample|waveform|\$\{(?:err|error)\}/i, + ); + }); + + it("does not claim to diagnose or reclassify a waveform", () => { + expect(workerSource).not.toMatch( + /(?:diagnose|infer|classify)Ecg|analy[sz]eWaveform|detectA(?:fib|trial)/i, + ); + }); +}); diff --git a/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts b/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts new file mode 100644 index 000000000..3f18c9378 --- /dev/null +++ b/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts @@ -0,0 +1,373 @@ +import { afterEach, describe, expect, it, vi, type MockInstance } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { Readable } from "node:stream"; + +import { strToU8, zipSync } from "fflate"; + +type ArchiveLimits = { + maxMembers: number; + maxEcgMembers: number; + maxMemberBytes: number; + maxTotalEcgBytes: number; + maxCompressionRatio: number; +}; + +type ArchiveMember = { + name: string; + stream: Readable; +}; + +type NormalizedEcg = { + recordedAt: Date; + samplingFrequency: number; + samples: number[]; + lead: string | null; + averageHeartRate: number | null; + rhythmClassification: "IRREGULAR" | "NOT_DETECTED" | "INCONCLUSIVE" | null; +}; + +type ArchiveSubject = { + streamAppleHealthEcgMembers(input: { + archivePath: string; + limits: ArchiveLimits; + }): AsyncIterable; +}; + +type ParserSubject = { + parseAppleHealthEcgCsv(input: { + memberName: string; + stream: Readable; + maxSamples: number; + }): Promise; +}; + +const tempDirs: string[] = []; + +const LIMITS: ArchiveLimits = { + maxMembers: 8, + maxEcgMembers: 2, + maxMemberBytes: 2_048, + maxTotalEcgBytes: 3_000, + maxCompressionRatio: 100, +}; + +function loadArchiveSubject(): Promise { + return vi.importActual("@/lib/apple-health/archive-stream"); +} + +function loadParserSubject(): Promise { + return vi.importActual("@/lib/apple-health/ecg-csv"); +} + +function archivePath(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), "healthlog-ecg-contract-")); + tempDirs.push(dir); + const path = join(dir, "export.zip"); + writeFileSync( + path, + zipSync( + Object.fromEntries( + Object.entries(files).map(([name, text]) => [name, strToU8(text)]), + ), + { level: 6 }, + ), + ); + return path; +} + +function replaceSameLengthName( + archive: Buffer, + from: string, + to: string, +): Buffer { + expect(Buffer.byteLength(from)).toBe(Buffer.byteLength(to)); + const out = Buffer.from(archive); + const needle = Buffer.from(from); + const replacement = Buffer.from(to); + let cursor = 0; + while ((cursor = out.indexOf(needle, cursor)) !== -1) { + replacement.copy(out, cursor); + cursor += replacement.length; + } + return out; +} + +function mutateZipHeaders( + archive: Buffer, + memberName: string, + mutate: (header: Buffer, central: boolean) => void, +): Buffer { + const out = Buffer.from(archive); + for (let cursor = 0; cursor <= out.length - 46; cursor += 1) { + const signature = out.readUInt32LE(cursor); + const central = signature === 0x02014b50; + const local = signature === 0x04034b50; + if (!central && !local) continue; + const nameLength = out.readUInt16LE(cursor + (central ? 28 : 26)); + const nameStart = cursor + (central ? 46 : 30); + if ( + out.subarray(nameStart, nameStart + nameLength).toString("utf8") === + memberName + ) { + mutate(out.subarray(cursor), central); + } + } + return out; +} + +function validCsv(overrides: Partial> = {}): string { + const metadata = { + "Recorded Date": "2026-07-18 08:14:03 +0200", + Classification: "Sinus Rhythm", + "Average Heart Rate": "64 bpm", + "Sample Rate": "512 Hz", + ...overrides, + }; + return [ + "Name,Private Patient Name", + ...Object.entries(metadata).map(([key, value]) => `${key},${value}`), + "Lead,Voltage", + "I,0.001", + "I,-0.002", + "I,0.003", + ].join("\n"); +} + +async function collectMembers(path: string, limits = LIMITS) { + const subject = await loadArchiveSubject(); + const members: ArchiveMember[] = []; + for await (const member of subject.streamAppleHealthEcgMembers({ + archivePath: path, + limits, + })) { + members.push(member); + } + return members; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } + vi.restoreAllMocks(); +}); + +describe("Apple Health ECG archive stream — path and resource bounds", () => { + it("streams only recognized HKElectrocardiogram CSV members", async () => { + const path = archivePath({ + "apple_health_export/export.xml": "", + "apple_health_export/electrocardiograms/ecg_2026-07-18.csv": validCsv(), + "apple_health_export/clinical-records/private.json": "{}", + }); + + const members = await collectMembers(path); + expect(members.map((member) => member.name)).toEqual([ + "apple_health_export/electrocardiograms/ecg_2026-07-18.csv", + ]); + expect(members[0].stream).toBeInstanceOf(Readable); + expect(members[0]).not.toHaveProperty("path"); + }); + + it.each([ + "../electrocardiograms/ecg.csv", + "apple_health_export/electrocardiograms/../../ecg.csv", + "/apple_health_export/electrocardiograms/ecg.csv", + "C:\\apple_health_export\\electrocardiograms\\ecg.csv", + ])("rejects traversal or absolute member name %s", async (name) => { + await expect( + collectMembers( + archivePath({ + "apple_health_export/export.xml": "", + [name]: validCsv(), + }), + ), + ).rejects.toThrow(/unsafe|path|member/i); + }); + + it("rejects duplicate normalized ECG member names", async () => { + const first = "apple_health_export/electrocardiograms/ecg-recording-a.csv"; + const second = "apple_health_export/electrocardiograms/ecg-recording-b.csv"; + const path = archivePath({ + "apple_health_export/export.xml": "", + [first]: validCsv(), + [second]: validCsv(), + }); + const duplicated = replaceSameLengthName( + Buffer.from(await import("node:fs").then((fs) => fs.readFileSync(path))), + second, + first, + ); + writeFileSync(path, duplicated); + + await expect(collectMembers(path)).rejects.toThrow(/duplicate/i); + }); + + it.each([ + [ + "encrypted", + (header: Buffer, central: boolean) => + header.writeUInt16LE(1, central ? 8 : 6), + ], + [ + "unsupported compression", + (header: Buffer, central: boolean) => + header.writeUInt16LE(99, central ? 10 : 8), + ], + ] as const)("rejects %s ECG entries", async (_label, mutate) => { + const member = "apple_health_export/electrocardiograms/ecg_2026-07-18.csv"; + const path = archivePath({ + "apple_health_export/export.xml": "", + [member]: validCsv(), + }); + const fs = await import("node:fs"); + writeFileSync( + path, + mutateZipHeaders(fs.readFileSync(path), member, mutate), + ); + + await expect(collectMembers(path)).rejects.toThrow( + /encrypted|compression|unsupported/i, + ); + }); + + it("enforces member-count and ECG-count caps before parsing", async () => { + const files: Record = { + "apple_health_export/export.xml": "", + }; + for (let index = 0; index < 4; index += 1) { + files[ + `apple_health_export/electrocardiograms/ecg_2026-07-${18 + index}.csv` + ] = validCsv(); + } + + await expect( + collectMembers(archivePath(files), { + ...LIMITS, + maxMembers: 3, + maxEcgMembers: 2, + }), + ).rejects.toThrow(/member|count|limit/i); + }); + + it("rejects declared compression-ratio bombs", async () => { + const repetitive = `${"0,".repeat(2_000)}\n`; + await expect( + collectMembers( + archivePath({ + "apple_health_export/export.xml": "", + "apple_health_export/electrocardiograms/ecg_bomb.csv": repetitive, + }), + { ...LIMITS, maxMemberBytes: 10_000, maxCompressionRatio: 2 }, + ), + ).rejects.toThrow(/compression|ratio|bomb/i); + }); + + it("enforces actual decompressed member bytes even when metadata lies", async () => { + const member = + "apple_health_export/electrocardiograms/ecg_actual_bytes.csv"; + const path = archivePath({ + "apple_health_export/export.xml": "", + [member]: `${"1,0.001\n".repeat(100)}`, + }); + const fs = await import("node:fs"); + const lied = mutateZipHeaders( + fs.readFileSync(path), + member, + (header, central) => { + if (central) header.writeUInt32LE(1, 24); + }, + ); + writeFileSync(path, lied); + + await expect( + collectMembers(path, { ...LIMITS, maxMemberBytes: 64 }), + ).rejects.toThrow(/byte|size|limit/i); + }); + + it("enforces the running total across otherwise valid ECG members", async () => { + await expect( + collectMembers( + archivePath({ + "apple_health_export/export.xml": "", + "apple_health_export/electrocardiograms/ecg_a.csv": validCsv(), + "apple_health_export/electrocardiograms/ecg_b.csv": validCsv(), + }), + { ...LIMITS, maxTotalEcgBytes: validCsv().length + 5 }, + ), + ).rejects.toThrow(/total|byte|limit/i); + }); +}); + +describe("Apple Health HKElectrocardiogram CSV parser", () => { + async function parse(csv: string, maxSamples = 8) { + const subject = await loadParserSubject(); + return subject.parseAppleHealthEcgCsv({ + memberName: "apple_health_export/electrocardiograms/ecg_2026-07-18.csv", + stream: Readable.from([Buffer.from(csv)]), + maxSamples, + }); + } + + it("normalizes a valid recording without returning patient/source labels", async () => { + const parsed = await parse(validCsv()); + expect(parsed).toMatchObject({ + recordedAt: new Date("2026-07-18T06:14:03.000Z"), + samplingFrequency: 512, + samples: [1, -2, 3], + lead: "I", + averageHeartRate: 64, + rhythmClassification: "NOT_DETECTED", + }); + expect(JSON.stringify(parsed)).not.toMatch(/Private Patient|Apple|WHOOP/i); + }); + + it.each([ + ["Sinus Rhythm", "NOT_DETECTED"], + ["Atrial Fibrillation", "IRREGULAR"], + ["Inconclusive", "INCONCLUSIVE"], + ["Localized or unknown verdict", null], + ])("passes through device classification %s only", async (raw, expected) => { + await expect( + parse(validCsv({ Classification: raw })), + ).resolves.toMatchObject({ rhythmClassification: expected }); + }); + + it.each([ + ["bad timestamp", validCsv({ "Recorded Date": "not-a-date" })], + ["bad frequency", validCsv({ "Sample Rate": "not-a-rate" })], + ["non-finite voltage", validCsv().replace("I,-0.002", "I,Infinity")], + ["missing samples", validCsv().split("Lead,Voltage")[0]], + ])("rejects malformed %s", async (_label, csv) => { + await expect(parse(csv)).rejects.toThrow(/invalid|malformed|sample|date/i); + }); + + it("stops reading once the sample cap is exceeded", async () => { + await expect(parse(validCsv(), 2)).rejects.toThrow(/sample|limit/i); + }); + + it("never logs or returns plaintext health values on failure", async () => { + const subject = await loadParserSubject(); + const spies: MockInstance[] = [ + vi.spyOn(console, "log").mockImplementation(() => {}), + vi.spyOn(console, "warn").mockImplementation(() => {}), + vi.spyOn(console, "error").mockImplementation(() => {}), + ]; + const secret = "patient-secret-waveform-0.123456"; + + let message = ""; + try { + await subject.parseAppleHealthEcgCsv({ + memberName: "apple_health_export/electrocardiograms/ecg_2026-07-18.csv", + stream: Readable.from([Buffer.from(`${validCsv()}\nI,${secret}`)]), + maxSamples: 8, + }); + } catch (error) { + message = error instanceof Error ? error.message : String(error); + } + + expect(message).not.toContain(secret); + expect(spies.every((spy) => spy.mock.calls.length === 0)).toBe(true); + }); +}); diff --git a/src/lib/sources/__tests__/source-priority-whoop-applehealth.test.ts b/src/lib/sources/__tests__/source-priority-whoop-applehealth.test.ts index d01840a97..aecd29f61 100644 --- a/src/lib/sources/__tests__/source-priority-whoop-applehealth.test.ts +++ b/src/lib/sources/__tests__/source-priority-whoop-applehealth.test.ts @@ -10,9 +10,17 @@ * summary), keep a real scale ahead of WHOOP's body-measurement estimate for * weight, and rank WHOOP's device-native Recovery above the COMPUTED proxy. */ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { pickCanonicalSourceRows } from "@/lib/analytics/source-priority"; +import { mapAppleHealthEntry } from "@/lib/measurements/apple-health-mapping"; + +const appleImporterSource = readFileSync( + join(process.cwd(), "src/lib/measurements/import-apple-health-export.ts"), + "utf8", +); function isoDayKey(d: Date): string { return d.toISOString().slice(0, 10); @@ -119,3 +127,45 @@ describe("cross-source priority — WHOOP + Apple Health", () => { expect(out.canonicalRows[0].value).toBe(71); }); }); + +describe("WHOOP-written HealthKit records remain source-agnostic", () => { + it("maps a supported HealthKit type independently of its writer label", () => { + const input = { + hkIdentifier: "HKQuantityTypeIdentifierHeartRate", + value: 64, + unit: "count/min", + startDate: "2026-07-18 08:14:03 +0200", + endDate: "2026-07-18 08:14:04 +0200", + }; + + const whoopWritten = mapAppleHealthEntry(input); + const appleWritten = mapAppleHealthEntry(input); + expect(whoopWritten).toEqual(appleWritten); + expect(whoopWritten).toMatchObject({ + type: "PULSE", + value: 64, + }); + expect(whoopWritten).not.toHaveProperty("sourceName"); + expect(whoopWritten).not.toHaveProperty("writer"); + }); + + it("persists supported export.xml rows as APPLE_HEALTH, never as a raw writer label", () => { + expect(appleImporterSource).toMatch( + /measurement\.createManyAndReturn\([\s\S]*source:\s*"APPLE_HEALTH"/, + ); + expect(appleImporterSource).not.toMatch( + /source:\s*attrs\.(?:sourceName|device)/, + ); + }); + + it("does not treat WHOOP writer metadata as evidence of a WHOOP ECG export", () => { + const ecgMapping = mapAppleHealthEntry({ + hkIdentifier: "HKElectrocardiogram", + value: 1, + unit: "count", + startDate: "2026-07-18 08:14:03 +0200", + endDate: "2026-07-18 08:14:04 +0200", + }); + expect(ecgMapping).toBeNull(); + }); +}); diff --git a/src/lib/whoop/__tests__/sync-user-whoop.test.ts b/src/lib/whoop/__tests__/sync-user-whoop.test.ts index 00110e072..2e4c30e30 100644 --- a/src/lib/whoop/__tests__/sync-user-whoop.test.ts +++ b/src/lib/whoop/__tests__/sync-user-whoop.test.ts @@ -10,6 +10,8 @@ * enabled resource soft-skipped and nothing imported. */ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; const { recordSyncSuccess, @@ -80,6 +82,11 @@ import { import { syncUserWhoop } from "../sync"; import { WhoopApiError } from "../response-classifier"; +const directWhoopSyncSource = readFileSync( + join(process.cwd(), "src/lib/whoop/sync.ts"), + "utf8", +); + /** A resource sync that 403s its collection and soft-skips, like the real one. */ async function softSkip403(...args: unknown[]): Promise { return handleCollectionFetchError( @@ -225,3 +232,35 @@ describe("syncWhoopResourceWithStatus — per-resource lastSuccessAt stamp", () expect(recordSyncSuccess).not.toHaveBeenCalled(); }); }); + +describe("direct WHOOP API regression beside Apple Health ECG support", () => { + it("keeps every established direct WHOOP resource in the orchestration gate", async () => { + syncUserRecovery.mockResolvedValue(1); + syncUserSleep.mockResolvedValue(1); + syncUserCycle.mockResolvedValue(1); + syncUserWorkout.mockResolvedValue(1); + syncUserBody.mockResolvedValue(1); + + await expect(syncUserWhoop("user-direct-whoop")).resolves.toMatchObject({ + imported: 5, + }); + for (const resource of [ + syncUserRecovery, + syncUserSleep, + syncUserCycle, + syncUserWorkout, + syncUserBody, + ]) { + expect(resource).toHaveBeenCalledWith( + "user-direct-whoop", + expect.any(Object), + ); + } + }); + + it("makes no direct WHOOP ECG support claim", () => { + expect(directWhoopSyncSource).not.toMatch( + /syncUser(?:Ecg|Electrocardiogram)|sync-(?:ecg|electrocardiogram)/i, + ); + }); +}); diff --git a/tests/integration/apple-health-import-ecg.test.ts b/tests/integration/apple-health-import-ecg.test.ts new file mode 100644 index 000000000..2e5ad1c15 --- /dev/null +++ b/tests/integration/apple-health-import-ecg.test.ts @@ -0,0 +1,365 @@ +import { existsSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createHash } from "node:crypto"; +import type { Job } from "pg-boss"; +import { strToU8, zipSync } from "fflate"; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest"; + +import { decryptWaveformFromBytes } from "@/lib/withings/ecg-waveform-codec"; +import { + APPLE_HEALTH_IMPORT_PARSER_REVISION, + _setWorkerPrismaForTests, + handleAppleHealthImport, + type AppleHealthImportPayload, +} from "@/lib/jobs/apple-health-import-worker"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/jobs/boss-instance", () => ({ + getGlobalBoss: vi.fn(() => null), +})); + +vi.mock("@/lib/rollups/measurement-rollups", () => ({ + recomputeUserRollups: vi.fn(async () => {}), +})); + +const USER_ID = "user-apple-ecg-contract"; +let jobSequence = 0; + +function supportedWhoopXml(): string { + return ` + + +`; +} + +function ecgCsv( + input: { + recordedAt?: string; + classification?: string; + samples?: number[]; + marker?: string; + } = {}, +): string { + const samples = input.samples ?? [0.001, -0.002, 0.003]; + return [ + `Name,${input.marker ?? "patient-name-must-not-persist"}`, + `Recorded Date,${input.recordedAt ?? "2026-07-18 08:14:03 +0200"}`, + `Classification,${input.classification ?? "Sinus Rhythm"}`, + "Average Heart Rate,64 bpm", + "Sample Rate,512 Hz", + "Lead,Voltage", + ...samples.map((sample) => `I,${sample}`), + ].join("\n"); +} + +function createArchive(ecgs: Array<{ filename: string; csv: string }>): { + path: string; + bytes: Buffer; +} { + const dir = mkdtempSync(join(tmpdir(), "healthlog-ecg-integration-")); + const path = join(dir, "export.zip"); + const files: Record = { + "apple_health_export/export.xml": strToU8(supportedWhoopXml()), + }; + for (const ecg of ecgs) { + files[`apple_health_export/electrocardiograms/${ecg.filename}`] = strToU8( + ecg.csv, + ); + } + const bytes = Buffer.from(zipSync(files, { level: 6 })); + writeFileSync(path, bytes); + return { path, bytes }; +} + +async function runArchive( + ecgs: Array<{ filename: string; csv: string }>, +): Promise<{ + outcome: Awaited>; + mirror: Awaited< + ReturnType["importJob"]["findUnique"]> + >; + uploadPath: string; +}> { + const prisma = getPrismaClient(); + const archive = createArchive(ecgs); + jobSequence += 1; + const jobId = `apple-ecg-job-${jobSequence}`; + const mirror = await prisma.importJob.create({ + data: { + userId: USER_ID, + pgBossJobId: jobId, + status: "queued", + uploadBytes: archive.bytes.length, + uploadSha256: createHash("sha256").update(archive.bytes).digest("hex"), + parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION, + }, + }); + const data: AppleHealthImportPayload = { + userId: USER_ID, + uploadPath: archive.path, + uploadBytes: archive.bytes.length, + enqueuedAt: "2026-07-18T06:14:03.000Z", + }; + + const outcome = await handleAppleHealthImport({ + id: jobId, + data, + } as unknown as Job); + + return { + outcome, + mirror: await prisma.importJob.findUnique({ where: { id: mirror.id } }), + uploadPath: archive.path, + }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + _setWorkerPrismaForTests(getPrismaClient()); + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: "apple-ecg-contract", + email: "apple-ecg-contract@example.test", + timezone: "Europe/Berlin", + }, + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +afterAll(() => { + _setWorkerPrismaForTests(null); +}); + +describe("Apple Health auxiliary ECG import — real Postgres", () => { + it("persists an encrypted APPLE_HEALTH recording and decrypts through the normal codec", async () => { + const marker = "plaintext-waveform-marker-0.001--0.002"; + const run = await runArchive([ + { filename: "ecg_2026-07-18.csv", csv: ecgCsv({ marker }) }, + ]); + + expect(run.outcome.ok).toBe(true); + const rows = await getPrismaClient().ecgRecording.findMany({ + where: { userId: USER_ID }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + source: "APPLE_HEALTH", + samplingFrequency: 512, + sampleCount: 3, + averageHeartRate: 64, + lead: "I", + rhythmClassification: "NOT_DETECTED", + }); + expect(decryptWaveformFromBytes(rows[0].waveformEncrypted)).toEqual([ + 1, -2, 3, + ]); + expect(Buffer.from(rows[0].waveformEncrypted).toString("utf8")).not.toMatch( + /0\.001|-0\.002|plaintext-waveform-marker/, + ); + expect(JSON.stringify(run.mirror)).not.toContain(marker); + expect(existsSync(run.uploadPath)).toBe(false); + }); + + it("deduplicates identical content across filenames and keeps distinct recordings distinct", async () => { + const same = ecgCsv(); + await runArchive([{ filename: "first-name.csv", csv: same }]); + await runArchive([{ filename: "renamed-copy.csv", csv: same }]); + expect( + await getPrismaClient().ecgRecording.count({ + where: { userId: USER_ID }, + }), + ).toBe(1); + + await runArchive([ + { + filename: "different.csv", + csv: ecgCsv({ + recordedAt: "2026-07-18 09:14:03 +0200", + samples: [0.004, -0.005, 0.006], + }), + }, + ]); + const rows = await getPrismaClient().ecgRecording.findMany({ + where: { userId: USER_ID }, + orderBy: { recordedAt: "asc" }, + }); + expect(rows).toHaveLength(2); + expect(new Set(rows.map((row) => row.externalRecordingId)).size).toBe(2); + }); + + it("keeps content identity tenant-scoped", async () => { + const same = ecgCsv(); + await runArchive([{ filename: "same.csv", csv: same }]); + const other = await getPrismaClient().user.create({ + data: { + username: "apple-ecg-other", + email: "apple-ecg-other@example.test", + }, + }); + + const archive = createArchive([{ filename: "same.csv", csv: same }]); + jobSequence += 1; + const jobId = `apple-ecg-job-${jobSequence}`; + await getPrismaClient().importJob.create({ + data: { + userId: other.id, + pgBossJobId: jobId, + status: "queued", + uploadBytes: archive.bytes.length, + parserRevision: APPLE_HEALTH_IMPORT_PARSER_REVISION, + }, + }); + await handleAppleHealthImport({ + id: jobId, + data: { + userId: other.id, + uploadPath: archive.path, + uploadBytes: archive.bytes.length, + enqueuedAt: "2026-07-18T06:14:03.000Z", + }, + } as unknown as Job); + + const rows = await getPrismaClient().ecgRecording.findMany({ + orderBy: { userId: "asc" }, + }); + expect(rows).toHaveLength(2); + expect(rows[0].externalRecordingId).toBe(rows[1].externalRecordingId); + expect(rows[0].userId).not.toBe(rows[1].userId); + }); + + it("keeps valid WHOOP-written export.xml data when an ECG is malformed", async () => { + const secret = "malformed-private-waveform-secret"; + const warning = vi.spyOn(console, "warn").mockImplementation(() => {}); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const run = await runArchive([ + { + filename: "malformed.csv", + csv: ecgCsv().replace("I,-0.002", `I,${secret}`), + }, + ]); + + expect(run.outcome.ok).toBe(true); + expect(run.mirror?.status).toBe("done"); + const measurements = await getPrismaClient().measurement.findMany({ + where: { userId: USER_ID }, + }); + expect(measurements).toHaveLength(1); + expect(measurements[0]).toMatchObject({ + source: "APPLE_HEALTH", + type: "PULSE", + value: 64, + }); + expect(JSON.stringify(measurements)).not.toContain("WHOOP"); + expect(await getPrismaClient().ecgRecording.count()).toBe(0); + expect(run.mirror?.result).toMatchObject({ + ecg: { + discovered: 1, + imported: 0, + updated: 0, + skipped: 0, + failed: 1, + }, + }); + expect(JSON.stringify(run.mirror)).not.toContain(secret); + expect( + JSON.stringify([...warning.mock.calls, ...error.mock.calls]), + ).not.toContain(secret); + }); + + it("fails an unsafe auxiliary member softly while valid XML survives", async () => { + const run = await runArchive([ + { filename: "../escaped-ecg.csv", csv: ecgCsv() }, + ]); + + expect(run.outcome.ok).toBe(true); + expect(run.mirror?.status).toBe("done"); + expect( + await getPrismaClient().measurement.count({ where: { userId: USER_ID } }), + ).toBe(1); + expect(await getPrismaClient().ecgRecording.count()).toBe(0); + expect(run.mirror?.result).toMatchObject({ + ecg: { discovered: 1, imported: 0, failed: 1 }, + }); + }); + + it("rolls back a failed ECG write while preserving the valid XML transaction", async () => { + const prisma = getPrismaClient(); + await prisma.$executeRawUnsafe(` + CREATE OR REPLACE FUNCTION reject_apple_health_ecg_contract() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.source = 'APPLE_HEALTH' THEN + RAISE EXCEPTION 'synthetic ECG write failure'; + END IF; + RETURN NEW; + END; + $$; + CREATE TRIGGER reject_apple_health_ecg_contract + BEFORE INSERT OR UPDATE ON ecg_recordings + FOR EACH ROW EXECUTE FUNCTION reject_apple_health_ecg_contract(); + `); + + try { + const run = await runArchive([ + { filename: "mid-write.csv", csv: ecgCsv() }, + ]); + expect(run.outcome.ok).toBe(true); + expect( + await prisma.measurement.count({ where: { userId: USER_ID } }), + ).toBe(1); + expect( + await prisma.ecgRecording.count({ where: { userId: USER_ID } }), + ).toBe(0); + expect(run.mirror?.result).toMatchObject({ + ecg: { discovered: 1, failed: 1 }, + }); + expect(JSON.stringify(run.mirror)).not.toContain( + "synthetic ECG write failure", + ); + } finally { + await prisma.$executeRawUnsafe( + `DROP TRIGGER IF EXISTS reject_apple_health_ecg_contract ON ecg_recordings`, + ); + await prisma.$executeRawUnsafe( + `DROP FUNCTION IF EXISTS reject_apple_health_ecg_contract()`, + ); + } + }); + + it("stores unknown device classifications as null without waveform diagnosis", async () => { + await runArchive([ + { + filename: "unknown-classification.csv", + csv: ecgCsv({ classification: "Localized Future Verdict" }), + }, + ]); + const row = await getPrismaClient().ecgRecording.findFirstOrThrow({ + where: { userId: USER_ID }, + }); + expect(row.rhythmClassification).toBeNull(); + }); +}); From b8766fa93b5d965902a12ffad7d004ddd4be2f93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:22:08 +0200 Subject: [PATCH 046/125] docs(01-12): complete Apple ECG contract plan - record expected RED archive and PostgreSQL boundaries - preserve green WHOOP and source-agnostic regression evidence --- .../01-12-SUMMARY.md | 122 ++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-12-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-12-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-12-SUMMARY.md new file mode 100644 index 000000000..45d1dffe2 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-12-SUMMARY.md @@ -0,0 +1,122 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "12" +subsystem: testing +tags: [apple-health, ecg, zip, encryption, postgres, whoop, tdd] + +requires: + - phase: 01-09 + provides: Focused security gate before product and reliability contracts +provides: + - RED streaming archive and incremental HKElectrocardiogram CSV parser contracts + - RED real-Postgres encrypted ECG persistence and fail-soft worker contracts + - Green source-agnostic HealthKit and direct WHOOP API regressions +affects: [01-19, apple-health-import, ecg-recordings, whoop] + +tech-stack: + added: [] + patterns: + - Real ZIP fixtures enter through the archive path rather than trusted extracted files + - Real-Postgres failure injection uses a temporary database trigger to verify transactional fail-soft behavior + +key-files: + created: + - src/lib/measurements/__tests__/apple-health-ecg-import.test.ts + - src/lib/jobs/__tests__/apple-health-import-ecg.test.ts + - tests/integration/apple-health-import-ecg.test.ts + modified: + - src/lib/sources/__tests__/source-priority-whoop-applehealth.test.ts + - src/lib/whoop/__tests__/sync-user-whoop.test.ts + +key-decisions: + - "Plan 19 must expose streamAppleHealthEcgMembers and parseAppleHealthEcgCsv so archive bytes are bounded before persistence." + - "Stable ECG identity is content-bound and tenant-scoped: filenames do not identify recordings, while identical normalized content does." + - "HealthKit rows written by WHOOP remain APPLE_HEALTH imports, and no direct WHOOP ECG capability is implied." + +patterns-established: + - "Security RED contracts: malicious ZIP metadata and actual decompressed bytes are tested independently." + - "Privacy RED contracts: assertions cover database ciphertext, job results, failure messages, logs, and staged-file cleanup." + +requirements-completed: [HK-01, HK-02, HK-03, HK-04, HK-05, HK-06, HK-07] + +duration: 9min +completed: 2026-07-29 +--- + +# Phase 1 Plan 12: Apple ECG and WHOOP RED Contract Summary + +**Streaming ZIP, ECG normalization, encrypted persistence, fail-soft XML survival, and source-agnostic WHOOP behavior pinned before Plan 19 implementation** + +## Performance + +- **Duration:** 9 min +- **Started:** 2026-07-29T12:11:46Z +- **Completed:** 2026-07-29T12:21:02Z +- **Tasks:** 1 +- **Files modified:** 5 + +## Accomplishments + +- Added real ZIP fixtures for recognized ECG members plus traversal, duplicate names, encryption, unsupported compression, claimed-ratio bombs, actual-byte overruns, aggregate bytes, member counts, and sample limits. +- Pinned incremental CSV normalization, known device-classification passthrough, unknown-classification null behavior, malformed value/date rejection, and non-diagnostic/no-plaintext output. +- Added real-Postgres worker contracts for encryption/decryption, content-bound idempotency, distinct recordings, tenant isolation, parser revision reprocessing, malformed/unsafe auxiliary fail-soft behavior, mid-write rollback, and valid XML survival. +- Preserved 20 green unit regressions covering source priority, WHOOP-written supported HealthKit records as `APPLE_HEALTH`, direct WHOOP API orchestration, and the explicit absence of a WHOOP ECG claim. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Pin safe bounded Apple Health ECG auxiliary import** - `6cc3de824` (test, RED) + +## Files Created/Modified + +- `src/lib/measurements/__tests__/apple-health-ecg-import.test.ts` - Streaming archive and incremental ECG CSV parser attack/positive matrix. +- `src/lib/jobs/__tests__/apple-health-import-ecg.test.ts` - Parser revision, fail-soft worker wiring, bounded progress, privacy, and no-diagnosis contracts. +- `tests/integration/apple-health-import-ecg.test.ts` - End-to-end worker contracts against migrated PostgreSQL and the existing encrypted `EcgRecording` model. +- `src/lib/sources/__tests__/source-priority-whoop-applehealth.test.ts` - Source-agnostic WHOOP-written HealthKit regressions and no ECG inference. +- `src/lib/whoop/__tests__/sync-user-whoop.test.ts` - Direct WHOOP resource orchestration regression and explicit no-ECG claim. + +## Decisions Made + +- The archive contract accepts an archive path and yields only stream handles with normalized member names; it never returns a filesystem path for an auxiliary ECG. +- Limits are injectable in tests so every byte/count/compression boundary is exercised with small fixtures, while production can use safe real-world defaults. +- The CSV contract converts waveform values to integer microvolts and carries only the recording timestamp, sampling frequency, samples, lead, average heart rate, and mapped device verdict. +- Only `Sinus Rhythm`, `Atrial Fibrillation`, and `Inconclusive` map to existing device-verdict enums; localized or unknown classifications persist as null. +- The real worker, not a persistence mock, owns the integration contract. A temporary PostgreSQL trigger forces a write failure and is dropped in `finally`. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +- Initial positive fixtures assumed the canonical type name was `HEART_RATE`; the established mapping correctly emits `PULSE`, so the source-agnostic and XML-survival assertions were corrected before commit. +- Direct WHOOP resource mocks receive a second options object; the regression was aligned to the real call shape and is green. +- Repository-wide `pnpm typecheck` remains RED only in concurrently added Google Health contract files (`sync-progress.test.ts` and `google-health-sync-progress.test.ts`). After correcting Plan 01-12's regular-expression target compatibility, no type errors point to the five Apple ECG/WHOOP files. + +## Verification Evidence + +- Unit gate: **expected RED**, 27 failed ECG archive/parser/worker implementation contracts; 20 source/WHOOP regressions passed. +- Real-Postgres gate: **expected RED**, 7 failed ECG persistence/result contracts after a successful container boot and all 280 migrations. +- Focused ESLint: passed for all five owned files. +- Prettier and `git diff --check`: passed for all five owned files. +- Gitleaks pre-commit hook: passed. + +## User Setup Required + +None - this plan adds test contracts only. + +## Next Phase Readiness + +- Plan 19 has executable API, security-limit, normalization, persistence, privacy, progress, and compatibility contracts. +- The RED failures are attributable to the missing Apple ECG modules/worker branch and existing parser revision 2, not fixture or infrastructure failures. + +## Self-Check: PASSED + +- All five owned test files exist. +- Task commit `6cc3de824` exists on the current branch. +- No production, schema, migration, package, lockfile, locale, Google, or unrelated job test was changed by Plan 01-12. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From 6f846294820f8811e5dab6e9095a1bdcbddd61aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:22:49 +0200 Subject: [PATCH 047/125] test(01-13): add red Google sync terminal contracts - cover authenticated status polling and bounded resource outcomes - prove committed workouts invalidate server and client cache surfaces --- .../sync/__tests__/route.test.ts | 107 +++++++++++ .../sync/status/__tests__/route.test.ts | 113 ++++++++++++ .../__tests__/google-health-card.test.tsx | 52 ++++++ .../google-health-workout-terminal.test.ts | 170 ++++++++++++++++++ 4 files changed, 442 insertions(+) create mode 100644 src/app/api/google-health/sync/status/__tests__/route.test.ts create mode 100644 src/components/settings/integrations/__tests__/google-health-card.test.tsx create mode 100644 tests/integration/google-health-workout-terminal.test.ts diff --git a/src/app/api/google-health/sync/__tests__/route.test.ts b/src/app/api/google-health/sync/__tests__/route.test.ts index e51d4df0d..87d118d86 100644 --- a/src/app/api/google-health/sync/__tests__/route.test.ts +++ b/src/app/api/google-health/sync/__tests__/route.test.ts @@ -99,4 +99,111 @@ describe("POST /api/google-health/sync", () => { fullSync: false, }); }); + + it("preserves bounded per-resource terminal outcomes for the manual-sync client", async () => { + vi.mocked(syncUserGoogleHealth).mockResolvedValue({ + runId: "run-terminal-1", + state: "partial", + imported: 3, + failed: true, + resources: [ + { + resource: "workout", + pages: 1, + fetched: 2, + mapped: 2, + written: 2, + status: "complete", + durationMs: 18, + truncated: false, + reasonCode: null, + }, + { + resource: "dense-heart-rate", + pages: 786, + fetched: 1, + mapped: 1, + written: 1, + status: "partial", + durationMs: 323_849, + truncated: false, + reasonCode: "collection_failed", + }, + ], + } as never); + + const response = await POST(request()); + const envelope = (await response.json()) as { + data: Record; + error: string | null; + }; + + expect(response.status).toBe(200); + expect(envelope.error).toBeNull(); + expect(envelope.data).toEqual({ + runId: "run-terminal-1", + state: "partial", + imported: 3, + failed: true, + outcome: "partial", + fullSync: false, + resources: [ + { + resource: "workout", + pages: 1, + fetched: 2, + mapped: 2, + written: 2, + status: "complete", + durationMs: 18, + truncated: false, + reasonCode: null, + }, + { + resource: "dense-heart-rate", + pages: 786, + fetched: 1, + mapped: 1, + written: 1, + status: "partial", + durationMs: 323_849, + truncated: false, + reasonCode: "collection_failed", + }, + ], + }); + }); + + it("does not serialize provider payloads, URLs, tokens, or health values", async () => { + vi.mocked(syncUserGoogleHealth).mockResolvedValue({ + runId: "run-private", + state: "failed", + imported: 1, + failed: true, + resources: [ + { + resource: "workout", + pages: 1, + fetched: 2, + mapped: 2, + written: 1, + status: "partial", + durationMs: 20, + truncated: false, + reasonCode: "upsert_failed", + accessToken: "raw-access-token", + url: "https://health.googleapis.com/private", + rawError: "provider response body", + samples: [{ bpm: 181 }], + }, + ], + } as never); + + const response = await POST(request()); + const serialized = JSON.stringify(await response.json()); + + expect(serialized).not.toMatch( + /raw-access-token|health\.googleapis\.com|provider response body|samples|bpm|181/, + ); + }); }); diff --git a/src/app/api/google-health/sync/status/__tests__/route.test.ts b/src/app/api/google-health/sync/status/__tests__/route.test.ts new file mode 100644 index 000000000..bfc4b5666 --- /dev/null +++ b/src/app/api/google-health/sync/status/__tests__/route.test.ts @@ -0,0 +1,113 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; +import type * as ApiHandlerModule from "@/lib/api-handler"; + +const { readGoogleHealthSyncProgress } = vi.hoisted(() => ({ + readGoogleHealthSyncProgress: vi.fn(), +})); + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual("@/lib/api-handler"); + return { + ...actual, + requireAuth: vi.fn(async () => ({ user: { id: "subject-user" } })), + }; +}); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("@/lib/google-health/sync-progress", () => ({ + readGoogleHealthSyncProgress, +})); + +import { HttpError, requireAuth } from "@/lib/api-handler"; + +async function getStatus(request: NextRequest) { + const route = await import("../route"); + return route.GET(request); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAuth).mockResolvedValue({ + user: { id: "subject-user" }, + } as never); +}); + +describe("GET /api/google-health/sync/status", () => { + it("reads only the authenticated subject despite forged enumeration parameters", async () => { + readGoogleHealthSyncProgress.mockResolvedValue({ + runId: "run-current", + state: "in_progress", + startedAt: "2026-07-29T10:00:00.000Z", + updatedAt: "2026-07-29T10:00:05.000Z", + resources: [], + }); + + const response = await getStatus( + new NextRequest( + "http://localhost/api/google-health/sync/status?userId=other-user&all=true", + ), + ); + const serialized = JSON.stringify(await response.json()); + + expect(response.status).toBe(200); + expect(readGoogleHealthSyncProgress).toHaveBeenCalledTimes(1); + expect(readGoogleHealthSyncProgress).toHaveBeenCalledWith("subject-user"); + expect(serialized).not.toContain("other-user"); + }); + + it("rejects an unauthenticated read without touching progress storage", async () => { + vi.mocked(requireAuth).mockRejectedValueOnce( + new HttpError(401, "Not authenticated"), + ); + + const response = await getStatus( + new NextRequest("http://localhost/api/google-health/sync/status"), + ); + + expect(response.status).toBe(401); + expect(readGoogleHealthSyncProgress).not.toHaveBeenCalled(); + }); + + it("returns one bounded current-run envelope, never an owner list or private payload", async () => { + readGoogleHealthSyncProgress.mockResolvedValue({ + runId: "run-private", + state: "partial", + startedAt: "2026-07-29T10:00:00.000Z", + updatedAt: "2026-07-29T10:05:00.000Z", + terminalAt: "2026-07-29T10:05:00.000Z", + resources: [ + { + resource: "workout", + pages: 1, + fetched: 1, + mapped: 1, + written: 1, + status: "complete", + durationMs: 25, + truncated: false, + reasonCode: null, + accessToken: "raw-access-token", + rawError: "provider response body", + url: "https://provider.invalid/private", + samples: [{ bpm: 181 }], + }, + ], + owners: ["subject-user", "other-user"], + }); + + const response = await getStatus( + new NextRequest("http://localhost/api/google-health/sync/status"), + ); + const body = await response.json(); + const serialized = JSON.stringify(body); + + expect(response.status).toBe(200); + expect(body.data).not.toBeInstanceOf(Array); + expect(serialized).not.toMatch( + /raw-access-token|provider response body|provider\.invalid|samples|bpm|181|owners|other-user/, + ); + }); +}); diff --git a/src/components/settings/integrations/__tests__/google-health-card.test.tsx b/src/components/settings/integrations/__tests__/google-health-card.test.tsx new file mode 100644 index 000000000..d55e87412 --- /dev/null +++ b/src/components/settings/integrations/__tests__/google-health-card.test.tsx @@ -0,0 +1,52 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const source = readFileSync( + resolve( + process.cwd(), + "src/components/settings/integrations/google-health-card.tsx", + ), + "utf8", +); + +describe("GoogleHealthCard manual-sync terminal contract", () => { + it("polls the authenticated status route when POST completion is uncertain", () => { + expect(source).toContain('"/api/google-health/sync/status"'); + expect(source).toMatch( + /catch[\s\S]{0,1200}(poll|status)|timeout[\s\S]{0,1200}(poll|status)/i, + ); + expect(source).toMatch(/complete/); + expect(source).toMatch(/partial/); + expect(source).toMatch(/failed/); + expect(source).toMatch(/interrupted/); + }); + + it("invalidates every affected client query after a committed workout outcome", () => { + expect(source).toContain( + "queryClient.invalidateQueries({ queryKey: queryKeys.workouts() })", + ); + expect(source).toContain( + "queryClient.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot() })", + ); + expect(source).toContain( + "queryClient.invalidateQueries({ queryKey: queryKeys.analytics() })", + ); + expect(source).toContain( + "queryClient.invalidateQueries({ queryKey: queryKeys.googleHealth() })", + ); + expect(source).toContain("queryKey: queryKeys.integrationsStatus()"); + expect(source).toMatch( + /resource\s*===?\s*["']workout["'][\s\S]{0,500}written\s*>\s*0|written\s*>\s*0[\s\S]{0,500}resource\s*===?\s*["']workout["']/, + ); + }); + + it("does not send a caller-selected owner or use ad-hoc query-key arrays", () => { + expect(source).not.toMatch( + /sync\/status[^"']*(?:userId|ownerId)|body:\s*JSON\.stringify\(\{[^}]*userId/, + ); + expect(source).not.toMatch( + /invalidateQueries\(\s*\{\s*queryKey:\s*\[[^\]]+\]/, + ); + }); +}); diff --git a/tests/integration/google-health-workout-terminal.test.ts b/tests/integration/google-health-workout-terminal.test.ts new file mode 100644 index 000000000..916cb17b1 --- /dev/null +++ b/tests/integration/google-health-workout-terminal.test.ts @@ -0,0 +1,170 @@ +/** + * RED real-PostgreSQL contract: a committed Google workout must be visible + * through the existing server caches before the sync reports completion. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const mocks = vi.hoisted(() => ({ + fetchDataPoints: vi.fn(), + mapWorkout: vi.fn(), + getValidToken: vi.fn(), + handleCollectionFetchError: vi.fn(), + noteHardFailure: vi.fn(), +})); + +vi.mock("@/lib/google-health/client", () => ({ + GOOGLE_HEALTH_ACTIVITY_PAGE_SIZE: 25, + GOOGLE_HEALTH_DATA_TYPES: { + exercise: { key: "exercise", dataType: "exercise" }, + }, + fetchDataPoints: mocks.fetchDataPoints, + mapWorkout: mocks.mapWorkout, +})); + +vi.mock("@/lib/google-health/sync-core", () => ({ + getValidToken: mocks.getValidToken, + handleCollectionFetchError: mocks.handleCollectionFetchError, + noteHardFailure: mocks.noteHardFailure, +})); + +vi.mock("@/lib/arrivals/workout-emit", () => ({ + emitInsertedWorkoutArrival: vi.fn(async () => undefined), +})); + +vi.mock("@/lib/tz/resolver", () => ({ + resolveUserTimezone: vi.fn(async () => "Europe/Berlin"), +})); + +import { + __resetAllCachesForTests, + cached, + caches, +} from "@/lib/cache/server-cache"; +import { syncUserWorkout } from "@/lib/google-health/sync-workout"; +import { readWorkoutsListCached } from "@/lib/workouts/list-read"; + +const USER_ID = "google-workout-owner"; +const LIST_PARAMS = { + limit: 20, + offset: 0, + since: null, + until: null, + sportType: null, +} as const; + +async function seedUser(): Promise { + await getPrismaClient().user.create({ + data: { + id: USER_ID, + username: USER_ID, + email: `${USER_ID}@example.test`, + }, + }); +} + +function mappedWorkout(sportType = "running") { + return { + externalId: "google-exercise-1", + sportType, + sportTypeRaw: "RUNNING", + startedAt: new Date("2026-07-29T08:00:00.000Z"), + endedAt: new Date("2026-07-29T08:30:00.000Z"), + durationSec: 1800, + totalEnergyKcal: 280, + totalDistanceM: 5_000, + avgHeartRate: 151, + maxHeartRate: 178, + minHeartRate: 104, + }; +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + __resetAllCachesForTests(); + vi.clearAllMocks(); + mocks.getValidToken.mockResolvedValue({ + accessToken: "test-token", + connection: { id: "connection-1", googleUserId: "google-owner" }, + }); + mocks.fetchDataPoints.mockResolvedValue([{ name: "exercise-1" }]); + mocks.mapWorkout.mockReturnValue(mappedWorkout()); + await seedUser(); +}); + +describe("Google Health workout terminal cache visibility (real Postgres)", () => { + it("exposes a newly committed workout immediately and invalidates dependent server cells", async () => { + expect( + (await readWorkoutsListCached(USER_ID, LIST_PARAMS)).workouts, + ).toEqual([]); + await cached(caches.analytics, `${USER_ID}|default`, async () => ({ + marker: "analytics-before", + })); + await cached( + caches.analytics, + `${USER_ID}|dashboard-snapshot|en`, + async () => ({ marker: "dashboard-before" }), + ); + + await expect(syncUserWorkout(USER_ID)).resolves.toBe(1); + + const row = await getPrismaClient().workout.findUnique({ + where: { + userId_source_externalId: { + userId: USER_ID, + source: "GOOGLE_HEALTH", + externalId: "google-exercise-1", + }, + }, + }); + expect(row).toMatchObject({ sportType: "running", durationSec: 1800 }); + await expect( + readWorkoutsListCached(USER_ID, LIST_PARAMS), + ).resolves.toMatchObject({ + workouts: [ + { + externalId: "google-exercise-1", + sportType: "running", + source: "GOOGLE_HEALTH", + }, + ], + }); + expect(caches.analytics.get(`${USER_ID}|default`)).toBeNull(); + expect(caches.analytics.get(`${USER_ID}|dashboard-snapshot|en`)).toBeNull(); + }); + + it("exposes an updated committed workout instead of the primed pre-sync projection", async () => { + await getPrismaClient().workout.create({ + data: { + userId: USER_ID, + source: "GOOGLE_HEALTH", + externalId: "google-exercise-1", + sportType: "walking", + startedAt: new Date("2026-07-29T08:00:00.000Z"), + endedAt: new Date("2026-07-29T08:20:00.000Z"), + durationSec: 1200, + }, + }); + const primed = await readWorkoutsListCached(USER_ID, LIST_PARAMS); + expect(primed.workouts[0]).toMatchObject({ + sportType: "walking", + durationSec: 1200, + }); + + mocks.mapWorkout.mockReturnValue(mappedWorkout("running")); + await expect(syncUserWorkout(USER_ID)).resolves.toBe(1); + + await expect( + readWorkoutsListCached(USER_ID, LIST_PARAMS), + ).resolves.toMatchObject({ + workouts: [ + { + externalId: "google-exercise-1", + sportType: "running", + durationSec: 1800, + }, + ], + }); + }); +}); From 65ca1aae423c9fe93e3f15f29b5b9a21ce413fd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:22:54 +0200 Subject: [PATCH 048/125] test(01-10): add red responsive navigation contracts - pin grouped and heading-aligned Settings and Admin sidebars - add mobile Insights geometry and deterministic release evidence captures --- ...341-navigation-and-insights-mobile.spec.ts | 151 ++++++++++++++++++ e2e/v1341-ui-evidence.spec.ts | 81 ++++++++++ .../admin/__tests__/admin-shell.test.tsx | 29 +++- .../__tests__/settings-shell.test.tsx | 39 +++++ 4 files changed, 297 insertions(+), 3 deletions(-) create mode 100644 e2e/v1341-navigation-and-insights-mobile.spec.ts create mode 100644 e2e/v1341-ui-evidence.spec.ts diff --git a/e2e/v1341-navigation-and-insights-mobile.spec.ts b/e2e/v1341-navigation-and-insights-mobile.spec.ts new file mode 100644 index 000000000..2c446c677 --- /dev/null +++ b/e2e/v1341-navigation-and-insights-mobile.spec.ts @@ -0,0 +1,151 @@ +import { expect, test } from "./setup/test"; + +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { mockPopulatedInsights } from "./utils/mock-populated-insights"; +import { settleBeforeMeasure } from "./utils/settle"; + +test.describe("v1.34.1 navigation geometry", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + for (const viewport of [ + { name: "desktop", width: 1440, height: 900 }, + { name: "short desktop", width: 1280, height: 600 }, + { name: "tablet", width: 900, height: 700 }, + ]) { + for (const route of ["/settings/account", "/admin/system-status"]) { + test(`${route} aligns and pins its sidebar at ${viewport.name}`, async ({ + page, + }) => { + await page.setViewportSize(viewport); + await page.goto(route, { waitUntil: "domcontentloaded" }); + + const heading = page.getByRole("heading", { level: 1 }); + const sidebar = page.locator("aside").first(); + await settleBeforeMeasure(page, heading); + await expect(sidebar).toBeVisible(); + + const before = await page.evaluate(() => { + const main = document.getElementById("main-content"); + const heading = main?.querySelector("h1"); + const aside = main?.querySelector("aside"); + const wrapper = main?.firstElementChild; + if (!main || !heading || !aside || !wrapper) return null; + return { + headingTop: heading.getBoundingClientRect().top, + asideTop: aside.getBoundingClientRect().top, + wrapperLeft: wrapper.getBoundingClientRect().left, + mainWidth: main.clientWidth, + documentScrollable: + document.documentElement.scrollHeight > + document.documentElement.clientHeight + 1, + }; + }); + + expect(before).not.toBeNull(); + if (!before) return; + expect( + Math.abs(before.asideTop - before.headingTop), + ).toBeLessThanOrEqual(2); + expect(before.documentScrollable).toBe(false); + + await page.locator("#main-content").evaluate((main) => { + main.scrollTop = Math.min(320, main.scrollHeight - main.clientHeight); + }); + await page.waitForFunction(() => { + const main = document.getElementById("main-content"); + return ( + !main || + main.scrollTop > 0 || + main.scrollHeight <= main.clientHeight + ); + }); + + const after = await page.evaluate(() => { + const main = document.getElementById("main-content"); + const aside = main?.querySelector("aside"); + const wrapper = main?.firstElementChild; + if (!main || !aside || !wrapper) return null; + return { + asideTop: aside.getBoundingClientRect().top, + wrapperLeft: wrapper.getBoundingClientRect().left, + mainWidth: main.clientWidth, + }; + }); + + expect(after).not.toBeNull(); + if (!after) return; + expect(Math.abs(after.asideTop - before.asideTop)).toBeLessThanOrEqual( + 2, + ); + expect(after.wrapperLeft).toBe(before.wrapperLeft); + expect(after.mainWidth).toBe(before.mainWidth); + }); + } + } +}); + +test.describe("v1.34.1 mobile Insights pill row", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test("has no unexplained gap above the row and preserves focus, safe bounds, and 44px targets", async ({ + page, + }) => { + await page.setViewportSize({ width: 390, height: 844 }); + await mockPopulatedInsights(page); + await page.goto("/insights", { waitUntil: "domcontentloaded" }); + + const firstPill = page + .locator( + '[data-slot="insights-tab-strip-pill"], [data-slot="insights-tab-strip-group"]', + ) + .first(); + await settleBeforeMeasure(page, firstPill); + + const geometry = await page.evaluate(() => { + const main = document.getElementById("main-content"); + const strip = document.querySelector( + '[data-slot="insights-tab-strip"]', + ); + const pill = document.querySelector( + '[data-slot="insights-tab-strip-pill"], [data-slot="insights-tab-strip-group"]', + ); + if (!main || !strip || !pill) return null; + const mainRect = main.getBoundingClientRect(); + const stripRect = strip.getBoundingClientRect(); + const pillRect = pill.getBoundingClientRect(); + return { + gapAbove: stripRect.top - mainRect.top, + stripLeft: stripRect.left, + stripRight: stripRect.right, + mainLeft: mainRect.left, + mainRight: mainRect.right, + pillHeight: pillRect.height, + mainOverflows: main.scrollWidth > main.clientWidth + 1, + documentOverflows: + document.documentElement.scrollWidth > + document.documentElement.clientWidth + 1, + }; + }); + + expect(geometry).not.toBeNull(); + if (!geometry) return; + expect(geometry.gapAbove).toBeLessThanOrEqual(4); + expect(geometry.stripLeft).toBeGreaterThanOrEqual(geometry.mainLeft); + expect(geometry.stripRight).toBeLessThanOrEqual(geometry.mainRight); + expect(geometry.pillHeight).toBeGreaterThanOrEqual(44); + expect(geometry.mainOverflows).toBe(false); + expect(geometry.documentOverflows).toBe(false); + + await firstPill.focus(); + await expect(firstPill).toBeFocused(); + expect( + await firstPill.evaluate((element) => { + const style = getComputedStyle(element); + return ( + element.matches(":focus-visible") && + (style.outlineStyle !== "none" || style.boxShadow !== "none") + ); + }), + ).toBe(true); + }); +}); diff --git a/e2e/v1341-ui-evidence.spec.ts b/e2e/v1341-ui-evidence.spec.ts new file mode 100644 index 000000000..845b04143 --- /dev/null +++ b/e2e/v1341-ui-evidence.spec.ts @@ -0,0 +1,81 @@ +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import type { Page, TestInfo } from "@playwright/test"; + +import { expect, test } from "./setup/test"; +import { STORAGE_STATE_PATH } from "./setup/global-setup"; +import { + LONG_HEADLINE_BRIEFING, + mockDashboardSnapshot, +} from "./utils/mock-dashboard-snapshot"; +import { mockPopulatedInsights } from "./utils/mock-populated-insights"; +import { settleBeforeMeasure } from "./utils/settle"; + +const EVIDENCE_DIR = join(process.cwd(), "test-results", "v1341"); + +async function settleAndCapture( + page: Page, + testInfo: TestInfo, + filename: string, +): Promise { + await settleBeforeMeasure(page, page.locator("#main-content")); + await page.emulateMedia({ reducedMotion: "reduce" }); + await page.addStyleTag({ + content: + "*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important}", + }); + await page.evaluate(() => document.fonts?.ready); + const path = join(EVIDENCE_DIR, filename); + await page.screenshot({ path, animations: "disabled" }); + await testInfo.attach(filename, { path, contentType: "image/png" }); +} + +test.describe("v1.34.1 deterministic UI evidence", () => { + test.use({ storageState: STORAGE_STATE_PATH }); + + test.beforeEach(({}, testInfo) => { + test.skip( + testInfo.project.name !== "chromium-desktop", + "one canonical capture per named evidence file", + ); + }); + + test("captures the five release-review surfaces", async ({ + page, + }, testInfo) => { + await mkdir(EVIDENCE_DIR, { recursive: true }); + + await page.setViewportSize({ width: 1440, height: 900 }); + await mockDashboardSnapshot(page, { + briefing: LONG_HEADLINE_BRIEFING, + }); + await page.goto("/", { waitUntil: "domcontentloaded" }); + await expect(page.locator("#main-content")).toBeVisible(); + await settleAndCapture(page, testInfo, "dashboard-desktop.png"); + + await page.setViewportSize({ width: 390, height: 844 }); + await mockPopulatedInsights(page); + await page.goto("/insights", { waitUntil: "domcontentloaded" }); + await expect( + page.locator('[data-slot="insights-tab-strip"]'), + ).toBeVisible(); + await settleAndCapture(page, testInfo, "insights-mobile.png"); + + await page.setViewportSize({ width: 1440, height: 900 }); + await page.goto("/settings/anamnesis", { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await settleAndCapture(page, testInfo, "anamnesis-desktop.png"); + + await page.setViewportSize({ width: 1280, height: 600 }); + await page.goto("/settings/account", { waitUntil: "domcontentloaded" }); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await settleAndCapture(page, testInfo, "settings-short-desktop.png"); + + await page.setViewportSize({ width: 900, height: 800 }); + await page.goto("/admin/system-status", { + waitUntil: "domcontentloaded", + }); + await expect(page.getByRole("heading", { level: 1 })).toBeVisible(); + await settleAndCapture(page, testInfo, "admin-tablet.png"); + }); +}); diff --git a/src/components/admin/__tests__/admin-shell.test.tsx b/src/components/admin/__tests__/admin-shell.test.tsx index 6f0ac9348..1247145a4 100644 --- a/src/components/admin/__tests__/admin-shell.test.tsx +++ b/src/components/admin/__tests__/admin-shell.test.tsx @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; /** * `` mirrors ``: a sticky desktop sidebar @@ -34,9 +36,6 @@ vi.mock("@/hooks/use-auth", () => ({ useAuth: () => ({ user: mockUserRef.value }), })); -import { readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; - import { I18nProvider } from "@/lib/i18n/context"; import { ADMIN_SECTION_SLUGS } from "../section-slugs"; import { ADMIN_SECTIONS, AdminShell } from "../admin-shell"; @@ -59,6 +58,30 @@ function renderShell(props: { } describe("", () => { + it("groups operational domains with non-interactive desktop headings and quiet mobile separators", () => { + const html = renderShell({ active: "system-status" }); + const groupHeaders = + html.match(/data-slot="admin-nav-group"[^>]*data-group="[^"]+"/g) ?? []; + const separators = + html.match(/data-slot="admin-nav-group-separator"/g) ?? []; + + expect(groupHeaders.length).toBeGreaterThanOrEqual(4); + expect(new Set(groupHeaders).size).toBe(groupHeaders.length); + expect(separators).toHaveLength(groupHeaders.length - 1); + }); + + it("starts the sticky desktop sidebar on the heading row", () => { + const source = readFileSync( + join(process.cwd(), "src", "components", "admin", "admin-shell.tsx"), + "utf8", + ); + + expect(source).toMatch( + / { const html = renderShell({ active: "system-status" }); for (const section of ADMIN_SECTIONS) { diff --git a/src/components/settings/__tests__/settings-shell.test.tsx b/src/components/settings/__tests__/settings-shell.test.tsx index a4bb432a7..b37d1a57a 100644 --- a/src/components/settings/__tests__/settings-shell.test.tsx +++ b/src/components/settings/__tests__/settings-shell.test.tsx @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from "vitest"; import { renderToStaticMarkup } from "react-dom/server"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; // `` reads the active route from `usePathname()`. We stub // next/navigation so the SSR test render works without an App-Router @@ -153,6 +155,43 @@ describe("SETTINGS_SECTION_SLUGS", () => { }); describe("", () => { + it("keeps meaningful non-interactive desktop groups while the mobile strip uses quiet separators", () => { + const html = renderShell({ active: "account" }); + for (const label of [ + "Account", + "Your data", + "Display", + "Health profile", + "AI", + "Connectivity", + "System", + ]) { + expect(html).toContain(`>${label}`); + } + // Seven groups produce six quiet separators in the mobile strip. + expect( + html.match(/aria-hidden="true"/g)?.length ?? 0, + ).toBeGreaterThanOrEqual(6); + }); + + it("starts the sticky desktop sidebar on the heading row", () => { + const source = readFileSync( + join( + process.cwd(), + "src", + "components", + "settings", + "settings-shell.tsx", + ), + "utf8", + ); + + expect(source).toMatch( + / { const html = renderShell({ active: "account" }); // Every slug — including `about`, which returned to the shell nav From 25612294d15513b8ce9902025c4b16a5bb5ed4a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:23:52 +0200 Subject: [PATCH 049/125] test(01-11): pin rolling overdue consumer parity - compare persisted weekly occurrence across list, dashboard, GLP-1, and take-all - protect exact intake retries and unrelated occurrence isolation --- .../[id]/intake/__tests__/route.test.ts | 49 +++ ...dication-rolling-overdue-consumers.test.ts | 301 ++++++++++++++++++ 2 files changed, 350 insertions(+) create mode 100644 tests/integration/medication-rolling-overdue-consumers.test.ts diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index 06dbfa686..870ca4e7d 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -579,6 +579,55 @@ describe("POST /api/medications/[id]/intake — v1.8.2 reconcile (M2 inventory)" expect.objectContaining({ where: { id: "row-raced" } }), ); }); + + it("does not treat a different occurrence, sibling, or archived row as an already-taken exact slot", async () => { + vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce( + SCHEDULED_MED as never, + ); + // The shared write probe must be scoped to the exact medication, + // canonical occurrence, and live row. An action at 07:00, on a sibling + // medication, or in an archived/tombstoned era is therefore not a + // candidate for this 19:00 write. + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValueOnce( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValueOnce({ + id: "row-evening-distinct", + takenAt: new Date("2026-06-15T17:02:00Z"), + skipped: false, + idempotencyKey: "evening-distinct", + scheduledFor: new Date("2026-06-15T17:00:00Z"), + source: "WEB", + createdAt: new Date("2026-06-15T17:02:00Z"), + } as never); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + oneShot: false, + active: true, + } as never); + + const res = await POST( + postReq({ + scheduledFor: "2026-06-15T17:00:30.000Z", + takenAt: "2026-06-15T17:02:00.000Z", + idempotencyKey: "evening-distinct", + }), + ROUTE_PARAMS, + ); + + expect(res.status).toBe(201); + expect(prisma.medicationIntakeEvent.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + where: { + userId: "user-1", + medicationId: "med-1", + scheduledFor: new Date("2026-06-15T17:00:00.000Z"), + deletedAt: null, + }, + }), + ); + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); + expect(prisma.medicationIntakeEvent.update).not.toHaveBeenCalled(); + }); }); // ──────────────────────────────────────────────────────────────────── diff --git a/tests/integration/medication-rolling-overdue-consumers.test.ts b/tests/integration/medication-rolling-overdue-consumers.test.ts new file mode 100644 index 000000000..6631a8c60 --- /dev/null +++ b/tests/integration/medication-rolling-overdue-consumers.test.ts @@ -0,0 +1,301 @@ +/** + * v1.34.1 (#664 follow-up) — one persisted rolling occurrence must have + * one identity across every medication consumer. + * + * This is intentionally a RED contract for the diagnosed Mounjaro defect: + * the weekly occurrence anchored on 2026-07-28 remains actionable after the + * user's local day changes. The list and Dashboard are the authoritative + * server reads; the GLP-1 card and take-all derivations consume the list + * payload exactly as production does. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { resolveDisplayedSlotInstant } from "@/components/medications/card-parts/displayed-slot-instant"; +import { + deriveDueMedications, + type DueDerivationMedication, +} from "@/components/medications/take-all-due"; +import { buildMedsTodayBlock } from "@/lib/dashboard/meds-today"; +import { buildMedicationsList } from "@/lib/medications/list-read"; +import { applyCanonicalSlotWrite } from "@/lib/medications/scheduling/slot-upsert"; + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const USER_ID = "user-rolling-overdue-consumers"; +const USER_TZ = "Europe/Berlin"; +const NOW = new Date("2026-07-29T10:00:00.000Z"); +const LAST_TAKEN = new Date("2026-07-21T06:00:00.000Z"); +const OVERDUE_OCCURRENCE = new Date("2026-07-28T06:00:00.000Z"); +const NEXT_OCCURRENCE = new Date("2026-08-04T06:00:00.000Z"); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +beforeEach(async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(NOW); + const prisma = getPrismaClient(); + await truncateAllTables(prisma); + await prisma.user.create({ + data: { + id: USER_ID, + username: "rolling-overdue-consumers", + email: "rolling-overdue-consumers@example.test", + timezone: USER_TZ, + }, + }); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +async function createMounjaro(): Promise { + const prisma = getPrismaClient(); + const medication = await prisma.medication.create({ + data: { + userId: USER_ID, + name: "Mounjaro", + dose: "5mg", + active: true, + treatmentClass: "GLP1", + deliveryForm: "INJECTION", + startsOn: new Date("2026-01-01T00:00:00.000Z"), + createdAt: new Date("2026-01-01T00:00:00.000Z"), + schedules: { + create: { + windowStart: "08:00", + windowEnd: "08:00", + timesOfDay: ["08:00"], + daysOfWeek: null, + rrule: null, + rollingIntervalDays: 7, + scheduleType: "SCHEDULED", + }, + }, + }, + }); + await prisma.medicationIntakeEvent.create({ + data: { + userId: USER_ID, + medicationId: medication.id, + scheduledFor: LAST_TAKEN, + takenAt: LAST_TAKEN, + skipped: false, + source: "WEB", + }, + }); + return medication.id; +} + +async function readConsumerOccurrence( + medicationId: string, + complianceStatus: "overdue" | "on_track" = "overdue", +) { + const prisma = getPrismaClient(); + const list = await buildMedicationsList(USER_ID, USER_TZ); + const raw = list.find((row) => row.id === medicationId); + expect(raw).toBeDefined(); + const medication = raw as unknown as DueDerivationMedication; + const listOccurrence = medication.nextDueAt ?? null; + + const dashboard = await buildMedsTodayBlock( + prisma, + USER_ID, + USER_TZ, + NOW, + ); + + // A prior-day rolling slot is outside today's clock window. The GLP-1 + // card therefore displays the server's canonical nextDueAt verbatim. + const glp1Occurrence = resolveDisplayedSlotInstant({ + currentWindowStatus: { + status: null, + schedule: null, + window: null, + }, + nextDueAt: listOccurrence, + now: NOW, + timeZone: USER_TZ, + })?.toISOString() ?? null; + + const takeAll = deriveDueMedications([medication], { + now: NOW, + tz: USER_TZ, + // Mirrors the compliance row that keeps the card action enabled for an + // unresolved weekly dose after its same-day window has passed. + doseStatusById: new Map([[medicationId, complianceStatus]]), + }); + + return { + list: { + occurrence: listOccurrence, + overdue: medication.nextDueOverdue === true, + }, + dashboard: { + occurrence: + dashboard.dueCandidates?.find( + (candidate) => candidate.medicationId === medicationId, + )?.dueAt ?? null, + overdue: + dashboard.dueCandidates?.find( + (candidate) => candidate.medicationId === medicationId, + )?.overdue ?? false, + }, + glp1Card: glp1Occurrence, + takeAllDue: takeAll.find((entry) => entry.id === medicationId) + ?.scheduledFor?.toISOString() ?? null, + }; +} + +describe("rolling weekly overdue occurrence — shared consumers", () => { + it("keeps the prior-day occurrence authoritative in list, Dashboard, GLP-1, and take-all", async () => { + const medicationId = await createMounjaro(); + + await expect(readConsumerOccurrence(medicationId)).resolves.toEqual({ + list: { + occurrence: OVERDUE_OCCURRENCE.toISOString(), + overdue: true, + }, + dashboard: { + occurrence: OVERDUE_OCCURRENCE.toISOString(), + overdue: true, + }, + glp1Card: OVERDUE_OCCURRENCE.toISOString(), + takeAllDue: OVERDUE_OCCURRENCE.toISOString(), + }); + }); + + it.each(["taken", "skipped", "autoMissed"] as const)( + "an exact %s action suppresses the overdue occurrence everywhere and advances", + async (status) => { + const prisma = getPrismaClient(); + const medicationId = await createMounjaro(); + await prisma.medicationIntakeEvent.create({ + data: { + userId: USER_ID, + medicationId, + scheduledFor: OVERDUE_OCCURRENCE, + takenAt: + status === "taken" + ? new Date("2026-07-28T06:10:00.000Z") + : null, + skipped: status === "skipped", + autoMissed: status === "autoMissed", + source: "WEB", + }, + }); + + const state = await readConsumerOccurrence(medicationId, "on_track"); + expect(state.list).toEqual({ + occurrence: NEXT_OCCURRENCE.toISOString(), + overdue: false, + }); + expect(state.dashboard).toEqual({ + occurrence: NEXT_OCCURRENCE.toISOString(), + overdue: false, + }); + expect(state.glp1Card).toBe(NEXT_OCCURRENCE.toISOString()); + expect(state.takeAllDue).toBeNull(); + }, + ); + + it("concurrent and retried writes converge onto one live exact-occurrence row", async () => { + const prisma = getPrismaClient(); + const medicationId = await createMounjaro(); + const write = (idempotencyKey: string) => + applyCanonicalSlotWrite({ + client: prisma, + userId: USER_ID, + medicationId, + canonicalSlot: OVERDUE_OCCURRENCE, + takenAt: new Date("2026-07-29T10:01:00.000Z"), + skipped: false, + isExplicitTaken: true, + isExplicitSkip: false, + idempotencyKey, + createSource: "WEB", + }); + + const results = await Promise.all([ + write("rolling-concurrent-a"), + write("rolling-concurrent-b"), + ]); + await write("rolling-concurrent-retry"); + + expect(results).toHaveLength(2); + const rows = await prisma.medicationIntakeEvent.findMany({ + where: { + userId: USER_ID, + medicationId, + scheduledFor: OVERDUE_OCCURRENCE, + deletedAt: null, + }, + }); + expect(rows).toHaveLength(1); + expect(rows[0]?.takenAt).not.toBeNull(); + }); + + it("different occurrence, archived row, and sibling medication never block the exact write", async () => { + const prisma = getPrismaClient(); + const medicationId = await createMounjaro(); + const siblingId = await createMounjaro(); + + await prisma.medicationIntakeEvent.createMany({ + data: [ + { + userId: USER_ID, + medicationId, + scheduledFor: new Date("2026-07-27T06:00:00.000Z"), + takenAt: new Date("2026-07-27T06:05:00.000Z"), + skipped: false, + source: "WEB", + }, + { + userId: USER_ID, + medicationId, + scheduledFor: OVERDUE_OCCURRENCE, + takenAt: new Date("2026-07-27T06:05:00.000Z"), + skipped: false, + source: "REMINDER", + deletedAt: new Date("2026-07-27T12:00:00.000Z"), + }, + { + userId: USER_ID, + medicationId: siblingId, + scheduledFor: OVERDUE_OCCURRENCE, + takenAt: new Date("2026-07-28T06:05:00.000Z"), + skipped: false, + source: "WEB", + }, + ], + }); + + const result = await applyCanonicalSlotWrite({ + client: prisma, + userId: USER_ID, + medicationId, + canonicalSlot: OVERDUE_OCCURRENCE, + takenAt: new Date("2026-07-29T10:02:00.000Z"), + skipped: false, + isExplicitTaken: true, + isExplicitSkip: false, + idempotencyKey: "rolling-isolation-target", + createSource: "WEB", + }); + + expect(result.outcome).toBe("inserted"); + expect(result.row.scheduledFor).toEqual(OVERDUE_OCCURRENCE); + expect( + await prisma.medicationIntakeEvent.count({ + where: { + userId: USER_ID, + medicationId, + scheduledFor: OVERDUE_OCCURRENCE, + deletedAt: null, + }, + }), + ).toBe(1); + }); +}); From 958b0e10d9df09344f43555df43ed62302838ad7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:24:00 +0200 Subject: [PATCH 050/125] fix(01-11): keep reminder fixture type-safe - mutate the inferred schedule fixture without widening nullable fields - preserve the cross-midnight RED expectation unchanged --- src/lib/jobs/__tests__/medication-reminder-dedup.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts index 0b841639a..e11b6912f 100644 --- a/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts +++ b/src/lib/jobs/__tests__/medication-reminder-dedup.test.ts @@ -316,13 +316,12 @@ describe("medication reminder — client-managed suppression is APNs-only", () = describe("issue #664 — dedup follows the scheduled occurrence across midnight", () => { it("dispatches an untreated overnight occurrence once and anchors its own local date", async () => { const med = medicationFixture(); - med.schedules[0] = { - ...med.schedules[0], + Object.assign(med.schedules[0], { windowStart: "23:45", windowEnd: "00:30", timesOfDay: ["23:45"], rrule: "FREQ=DAILY", - }; + }); med.user.timezone = "Europe/Berlin"; prismaMock.medication.findMany.mockResolvedValue([med]); From 19590cd2164948c27d974cecb96a09ef73de31e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:24:16 +0200 Subject: [PATCH 051/125] docs(01-13): complete Google sync contract plan - record expected-red and positive verification evidence - preserve reporter-gated cause and immutable scanner boundaries --- .../01-13-SUMMARY.md | 132 ++++++++++++++++++ 1 file changed, 132 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-13-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-13-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-13-SUMMARY.md new file mode 100644 index 000000000..45eeb0703 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-13-SUMMARY.md @@ -0,0 +1,132 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "13" +subsystem: testing +tags: [google-health, vitest, postgres, prisma, cache-invalidation, privacy] + +# Dependency graph +requires: + - phase: 01-09 + provides: immutable baseline and reporter-evidence boundary +provides: + - RED contracts for workout-first Google Health sync fairness under the observed 786-page heart-rate fixture + - Bounded per-resource outcomes and race-safe current-run progress contracts + - Authenticated-subject-only status, timeout polling, and post-commit cache visibility contracts +affects: [01-21, google-health-sync, integration-reliability] + +# Tech tracking +tech-stack: + added: [] + patterns: + - fixed-cardinality redacted per-resource outcome DTOs + - owner-and-runId guarded current-run progress + - real-PostgreSQL cache visibility verification + +key-files: + created: + - src/lib/google-health/__tests__/sync-fairness.test.ts + - src/lib/google-health/__tests__/sync-outcomes.test.ts + - src/lib/google-health/__tests__/sync-progress.test.ts + - tests/integration/google-health-sync-progress.test.ts + - src/app/api/google-health/sync/status/__tests__/route.test.ts + - src/components/settings/integrations/__tests__/google-health-card.test.tsx + - tests/integration/google-health-workout-terminal.test.ts + modified: + - src/app/api/google-health/sync/__tests__/route.test.ts + +key-decisions: + - "Model only the observed 786 heart-rate pages and 323.849-second trace; do not infer a provider fault or universal page ceiling." + - "Keep progress as one nullable owner-scoped current-run envelope with unpredictable runId and fixed bounded resource fields." + - "Require workout cache visibility and client invalidation only from evidence-backed committed workout writes." + +patterns-established: + - "RED contract isolation: future modules and routes fail specifically as missing while existing route behavior remains green." + - "Privacy boundary: progress and resource outcomes exclude tokens, URLs, raw errors, health values, and per-sample facts." + +requirements-completed: [GH-01, GH-02, GH-03, GH-04, GH-05, GH-06, GH-07, SYNC-01, SYNC-03] + +# Metrics +duration: 10min +completed: 2026-07-29 +--- + +# Phase 01 Plan 13: Google Health Reliability RED Contracts Summary + +**Workout-first fairness, durable owner-scoped progress, honest terminal recovery, and post-commit cache visibility are pinned as privacy-bounded RED contracts without inventing a reporter cause** + +## Performance + +- **Duration:** 10 min +- **Started:** 2026-07-29T12:13:41Z +- **Completed:** 2026-07-29T12:23:02Z +- **Tasks:** 2 +- **Files modified:** 9 + +## Accomplishments + +- Reproduced starvation with 786 successful dense-heart-rate pages and proved the current ordering reaches workout only after that history. +- Defined the exact nine-field resource outcome DTO plus complete, partial, empty, truncated, collection, token, upsert, and rollup-failure states. +- Pinned owner/run-ID concurrency, newer-run precedence, stale interruption, single-current-run retention, fixed cardinality, and privacy at unit and real-PostgreSQL boundaries. +- Pinned authenticated-subject-only status reads, POST timeout-to-terminal polling, server post-commit visibility, and exact React Query invalidation roots. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Pin fair ordering and a race-safe bounded current-run ledger** - `d8e52b831` (test) +2. **Task 2: Pin status recovery and post-commit cache visibility** - `6f8462948` (test) + +## Files Created/Modified + +- `src/lib/google-health/__tests__/sync-fairness.test.ts` - 786-page workout-first scheduling contract. +- `src/lib/google-health/__tests__/sync-outcomes.test.ts` - Exact bounded and redacted resource outcome contracts. +- `src/lib/google-health/__tests__/sync-progress.test.ts` - Current-run ownership, concurrency, staleness, and privacy contracts. +- `tests/integration/google-health-sync-progress.test.ts` - Real-PostgreSQL persistence and race contracts. +- `src/app/api/google-health/sync/__tests__/route.test.ts` - Rich terminal POST response and privacy contracts. +- `src/app/api/google-health/sync/status/__tests__/route.test.ts` - Authenticated-subject-only status route negative cases. +- `src/components/settings/integrations/__tests__/google-health-card.test.tsx` - Timeout polling and exact client invalidation contracts. +- `tests/integration/google-health-workout-terminal.test.ts` - Real-PostgreSQL insert/update cache visibility contracts. +- `.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-13-SUMMARY.md` - Execution evidence and handoff. + +## Verification Evidence + +- Task 1 unit group: expected RED, 3 files failed and 6/6 tests failed specifically on workout ordering, absent resource outcomes, and the missing progress module. +- Task 1 real-PostgreSQL group: expected RED, 4/4 tests failed specifically because the planned progress module is absent. +- Task 2 unit/route/card group: expected RED, 6 failed and 5 passed. Failures identify the dropped POST resource verdict, missing status route, absent timeout polling, and absent exact invalidation roots. Existing legacy POST outcomes, secret omission, and the card's owner/query-key negative guard remain green. +- Task 2 real-PostgreSQL group: expected RED, 2/2 tests failed because both inserted and updated committed workouts remain behind the primed server workout cache. +- Prettier and ESLint passed on all eight owned test files. + +## Decisions Made + +- The fixture records the reporter evidence exactly: 786 heart-rate pages and 323,849 ms. It deliberately sets `truncated: false`; no page cap or provider failure is inferred. +- The storage seam is named `startGoogleHealthSyncProgress`, `updateGoogleHealthSyncProgress`, and `readGoogleHealthSyncProgress`, with user ID plus run ID guarding delayed writers. +- Cache correctness is proven against real PostgreSQL by priming the public cached workout read before insert and update, then requiring the post-sync read to expose committed state. +- GH-01 and GH-06 remain reporter-evidence gated; these RED contracts do not claim the live incident is resolved. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +None. All failures are the intentional implementation gaps required by this RED-only plan. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- Plan 01-21 can implement the named progress seam, status route, fair resource ordering, terminal DTO propagation, and post-commit invalidation without interpreting the reporter trace. +- GH-01/GH-06 stay open until reporter evidence confirms the production symptom. + +## Self-Check: PASSED + +- All eight owned test files and this summary exist. +- Task commits `d8e52b831` and `6f8462948` exist. +- Branch remains `release/v1.34.1`. +- Scanner-owned package, lock, workspace, and binary-diff hashes exactly match the immutable baseline. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From 35b172c6f540caf8815be65b2f9d27bdff876dca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:24:25 +0200 Subject: [PATCH 052/125] docs(01-10): complete UI contract plan - document atomic RED contract commits and verification evidence - record production handoff for presentation and navigation fixes --- .../01-10-SUMMARY.md | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-10-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-10-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-10-SUMMARY.md new file mode 100644 index 000000000..73b0ec3f8 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-10-SUMMARY.md @@ -0,0 +1,145 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "10" +subsystem: testing +tags: [vitest, playwright, react, responsive-layout, ai-safety] + +requires: + - phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability + provides: v1.34.1 product intent, UI standards, and test infrastructure +provides: + - RED component contracts for Dashboard, documents, Health Score, assessments, records, Settings, and Admin + - RED responsive geometry contracts across desktop, short desktop, tablet, and phone viewports + - Deterministic release evidence capture points for five named UI surfaces +affects: [01-14, 01-15, 01-20, dashboard, insights, records, navigation] + +tech-stack: + added: [] + patterns: + - test-only RED presentation contracts with positive preservation assertions + - route-envelope to hook to card assessment-state coverage + - deterministic Playwright geometry and evidence capture + +key-files: + created: + - src/components/records/__tests__/allergy-manager.test.tsx + - src/components/records/__tests__/family-history-manager.test.tsx + - e2e/v1341-navigation-and-insights-mobile.spec.ts + - e2e/v1341-ui-evidence.spec.ts + modified: + - src/components/daily/__tests__/today-hero.test.tsx + - src/components/documents/__tests__/document-summary-block.test.tsx + - src/components/insights/__tests__/health-score-card.test.tsx + - src/hooks/__tests__/use-insight-status.test.ts + - src/components/insights/__tests__/insight-status-card.test.tsx + - src/app/api/insights/biomarker-assessment/__tests__/route.test.ts + - src/app/insights/__tests__/assessment-spine-order.test.ts + - src/components/records/__tests__/health-profile-facts-manager.test.tsx + - src/components/settings/__tests__/settings-shell.test.tsx + - src/components/admin/__tests__/admin-shell.test.tsx + +key-decisions: + - "Treat a safe terminal assessment fallback as displayable text even when the envelope has hasProvider:false; a null-text envelope remains the provider-setup branch." + - "Expose poll exhaustion explicitly and clear the preparing presentation instead of merely stopping the timer." + - "Measure shell alignment, pinning, scroll ownership, and mobile Insights spacing in the browser rather than relying only on utility-class assertions." + +patterns-established: + - "RED presentation tests pair removed-copy assertions with positive checks for data, provenance, history, and destructive controls that must remain." + - "Evidence screenshots disable animation, wait for fonts and a visible sentinel, use fixed viewports, and attach the exact captured file to Playwright output." + +requirements-completed: [DASH-01, DOC-01, INS-01, INS-02, INS-03, INS-04, INS-05, INS-06, INS-07, INS-08, INS-09, REC-01, REC-02, REC-03, REC-04, REC-05, NAV-01, NAV-02, NAV-03, NAV-04, NAV-05, AI-01, AI-02, AI-03, AI-04, AI-05] + +duration: 12min +completed: 2026-07-29 +--- + +# Phase 1 Plan 10: UI Presentation and Navigation RED Contracts Summary + +**Vitest and Playwright contracts now isolate the v1.34.1 redundant-copy, assessment-state, record-action, shell-alignment, and mobile Insights spacing defects while preserving safety and data-provenance behavior.** + +## Performance + +- **Duration:** 12 min +- **Started:** 2026-07-29T12:11:14Z +- **Completed:** 2026-07-29T12:23:12Z +- **Tasks:** 3 +- **Files modified:** 15 + +## Accomplishments + +- Added Dashboard, document-summary, and Health Score RED contracts that reject duplicate or redundant presentation while retaining loaded narrative, score calculation/version, pillar details, and provenance. +- Added assessment route-envelope, bounded-polling, safe-fallback, readable-prose, and stable-state contracts plus record-manager Save/removal/history and empty/populated CTA grammar. +- Added Settings/Admin grouping and pinning contracts, phone Insights gap/focus/touch geometry, and deterministic screenshots for Dashboard, Insights, Anamnesis, Settings, and Admin. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Pin honest Dashboard, document, and Health Score presentation** - `8e944dbcd` (test) +2. **Task 2: Pin assessment presentation/state honesty and record-management grammar** - `18d0facdf` (test) +3. **Task 3: Define grouped, pinned, responsive navigation geometry** - `65ca1aae4` (test) + +## Files Created/Modified + +- `src/components/daily/__tests__/today-hero.test.tsx` - useful loaded narrative and non-invented transient score prose. +- `src/components/documents/__tests__/document-summary-block.test.tsx` - stored summaries without regenerate/replacement controls. +- `src/components/insights/__tests__/health-score-card.test.tsx` - one headline score with calculation and provenance preserved. +- `src/hooks/__tests__/use-insight-status.test.ts` - bounded polling must terminate honestly. +- `src/components/insights/__tests__/insight-status-card.test.tsx` - distinct stable states and visible screened fallback. +- `src/app/api/insights/biomarker-assessment/__tests__/route.test.ts` - success, preparing, screened, and negative-cache envelopes. +- `src/app/insights/__tests__/assessment-spine-order.test.ts` - shared compact rhythm and readable assessment prose. +- `src/components/records/__tests__/health-profile-facts-manager.test.tsx` - one Save grammar with removal/history preserved. +- `src/components/records/__tests__/allergy-manager.test.tsx` - exact empty and populated Allergy actions. +- `src/components/records/__tests__/family-history-manager.test.tsx` - exact empty and populated Family history actions. +- `src/components/settings/__tests__/settings-shell.test.tsx` - Settings groups and heading-aligned sticky sidebar. +- `src/components/admin/__tests__/admin-shell.test.tsx` - Admin operational groups, separators, role gate, and sidebar alignment. +- `e2e/v1341-navigation-and-insights-mobile.spec.ts` - desktop/tablet pinning and phone Insights geometry. +- `e2e/v1341-ui-evidence.spec.ts` - five deterministic named screenshot capture points. + +## Decisions Made + +- The no-provider state is identified by `hasProvider:false` plus no text; safe terminal fallback text must not be discarded solely because its envelope also carries `hasProvider:false`. +- Poll exhaustion is a user-visible terminal condition, so tests require an explicit exhausted signal and a cleared `preparing` presentation. +- The shell contract measures the initial sidebar/heading top, the sidebar after main-content scrolling, document scroll ownership, and width stability. +- Screenshot evidence uses only the seeded E2E account and deterministic route fixtures; no live or personal health data is captured. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +- The direct Playwright command did not load `.env`, so global setup initially stopped on a missing `DATABASE_URL`. Re-running through Node with `dotenv/config` loaded the standard project environment and exercised the suite. +- The standalone server emitted an encryption-key readiness warning, but the authenticated E2E seed and test run completed. The expected RED browser results were 14 navigation/spacing failures, one evidence test pass, and one project skip. + +## Verification + +- Combined Vitest contract run: **113 tests, 99 passed, 14 intended RED failures**. +- Playwright contract run on `chromium-desktop` and `chromium-mobile`: **14 intended RED failures, 1 evidence test passed, 1 skipped**. +- Browser measurements reproduced the current defects precisely: Settings/Admin sidebars start **164px** below their headings and the phone Insights row starts **24px** below the main viewport. +- Evidence test generated: + - `test-results/v1341/dashboard-desktop.png` + - `test-results/v1341/insights-mobile.png` + - `test-results/v1341/anamnesis-desktop.png` + - `test-results/v1341/settings-short-desktop.png` + - `test-results/v1341/admin-tablet.png` +- ESLint and Prettier passed for all Task 3 files. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- Production plans can implement directly against 14 isolated Vitest failures and the measured browser geometry failures. +- Plan 20 owns the shared `InsightStatusCard` and assessment-state seam; the tests intentionally leave production untouched. + +## Self-Check: PASSED + +- All 14 planned test files exist. +- Task commits `8e944dbcd`, `18d0facdf`, and `65ca1aae4` exist. +- All work remains test-only; no production or locale files were modified. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From 34372b57bdd61a0955e21b7e9fae9c77204c59a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:25:06 +0200 Subject: [PATCH 053/125] fix(01-11): use canonical compliance status fixture - model resolved rolling doses with the valid upcoming status - keep take-all suppression aligned with production DoseStatus values --- .../integration/medication-rolling-overdue-consumers.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/integration/medication-rolling-overdue-consumers.test.ts b/tests/integration/medication-rolling-overdue-consumers.test.ts index 6631a8c60..8b32eec95 100644 --- a/tests/integration/medication-rolling-overdue-consumers.test.ts +++ b/tests/integration/medication-rolling-overdue-consumers.test.ts @@ -91,7 +91,7 @@ async function createMounjaro(): Promise { async function readConsumerOccurrence( medicationId: string, - complianceStatus: "overdue" | "on_track" = "overdue", + complianceStatus: "overdue" | "upcoming" = "overdue", ) { const prisma = getPrismaClient(); const list = await buildMedicationsList(USER_ID, USER_TZ); @@ -187,7 +187,7 @@ describe("rolling weekly overdue occurrence — shared consumers", () => { }, }); - const state = await readConsumerOccurrence(medicationId, "on_track"); + const state = await readConsumerOccurrence(medicationId, "upcoming"); expect(state.list).toEqual({ occurrence: NEXT_OCCURRENCE.toISOString(), overdue: false, From 690c4aeb5aa1a205fecd831deb12495db3c0b900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:26:26 +0200 Subject: [PATCH 054/125] test(01-14): add failing truthful cohort contracts - distinguish complete partial failed parked skipped and clean-zero outcomes - require bounded privacy-safe scalar serialization and retry codes --- src/lib/jobs/__tests__/job-outcome.test.ts | 195 +++++++++++++ .../integration-cohort-outcomes.test.ts | 260 ++++++++++++++++++ 2 files changed, 455 insertions(+) create mode 100644 src/lib/jobs/__tests__/job-outcome.test.ts create mode 100644 src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts diff --git a/src/lib/jobs/__tests__/job-outcome.test.ts b/src/lib/jobs/__tests__/job-outcome.test.ts new file mode 100644 index 000000000..9f0d79d65 --- /dev/null +++ b/src/lib/jobs/__tests__/job-outcome.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it, vi } from "vitest"; + +import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; + +type SerializableJobOutcome = { + ok: boolean; + reason_code?: string; + did?: Readonly>; +}; + +type JobOutcomeSubject = { + JOB_FACT_ALLOWLIST: ReadonlySet; + MAX_JOB_FACTS: number; + MAX_JOB_OUTCOME_BYTES: number; + serializeJobOutcome(outcome: JobOutcome): SerializableJobOutcome; +}; + +function loadSubject(): Promise { + return vi.importActual("@/lib/jobs/job-outcome"); +} + +const COHORT_FACT_KEYS = [ + "provider", + "outcome", + "total", + "users_synced", + "users_complete", + "users_partial", + "users_failed", + "users_parked", + "users_skipped", + "users_useful", + "users_clean_zero", + "users_retryable", + "downstream_failed", + "measurements_imported", +] as const; + +const FORBIDDEN_FACT_KEYS = [ + "user_id", + "userId", + "email", + "name", + "access_token", + "refresh_token", + "secret", + "url", + "raw_error", + "health_value", + "blood_glucose", +] as const; + +describe("bounded generic JobOutcome serialization", () => { + it("publishes a fixed allowlist for truthful cohort scalars", async () => { + const { JOB_FACT_ALLOWLIST } = await loadSubject(); + for (const key of COHORT_FACT_KEYS) { + expect(JOB_FACT_ALLOWLIST.has(key), key).toBe(true); + } + for (const key of FORBIDDEN_FACT_KEYS) { + expect(JOB_FACT_ALLOWLIST.has(key), key).toBe(false); + } + }); + + it.each(FORBIDDEN_FACT_KEYS)( + "rejects the private or unbounded fact key %s", + async (key) => { + const { serializeJobOutcome } = await loadSubject(); + expect(() => + serializeJobOutcome( + jobDone({ [key]: "private-value" } as Record), + ), + ).toThrow(/fact|allow|private|unsupported/i); + }, + ); + + it("rejects nested objects, arrays, nulls, non-finite numbers, and unsafe integers", async () => { + const { serializeJobOutcome } = await loadSubject(); + for (const unsafe of [ + { nested: true }, + ["user-a"], + null, + Number.NaN, + Number.POSITIVE_INFINITY, + Number.MAX_SAFE_INTEGER + 1, + -1, + ]) { + expect(() => + serializeJobOutcome( + jobDone({ total: unsafe } as unknown as Record), + ), + ).toThrow(/scalar|finite|integer|range|unsupported/i); + } + }); + + it.each([ + "patient@example.test", + "https://provider.test/capability", + "token=secret", + "raw upstream stack trace", + "blood glucose 411", + "x".repeat(65), + ])("rejects an unbounded or private string fact: %s", async (value) => { + const { serializeJobOutcome } = await loadSubject(); + expect(() => serializeJobOutcome(jobDone({ provider: value }))).toThrow( + /code|string|private|length|unsupported/i, + ); + }); + + it("rejects too many facts and enforces a small serialized byte ceiling", async () => { + const { + JOB_FACT_ALLOWLIST, + MAX_JOB_FACTS, + MAX_JOB_OUTCOME_BYTES, + serializeJobOutcome, + } = await loadSubject(); + expect(MAX_JOB_FACTS).toBeGreaterThanOrEqual(COHORT_FACT_KEYS.length); + expect(MAX_JOB_FACTS).toBeLessThanOrEqual(32); + expect(MAX_JOB_OUTCOME_BYTES).toBeLessThanOrEqual(2_048); + + const allowed = [...JOB_FACT_ALLOWLIST]; + const tooMany = Object.fromEntries( + Array.from({ length: MAX_JOB_FACTS + 1 }, (_, index) => [ + allowed[index] ?? `overflow_${index}`, + index, + ]), + ); + expect(() => serializeJobOutcome(jobDone(tooMany))).toThrow( + /fact|count|limit|allow/i, + ); + }); + + it("distinguishes useful work from a successful clean zero", async () => { + const { serializeJobOutcome } = await loadSubject(); + const useful = serializeJobOutcome( + jobDone({ outcome: "useful", total: 1, measurements_imported: 4 }), + ); + const cleanZero = serializeJobOutcome( + jobDone({ outcome: "clean_zero", total: 1, measurements_imported: 0 }), + ); + + expect(useful.did?.outcome).toBe("useful"); + expect(cleanZero.did?.outcome).toBe("clean_zero"); + expect(useful).not.toEqual(cleanZero); + }); + + it("emits only stable failure codes and never serializes raw causes", async () => { + const { serializeJobOutcome } = await loadSubject(); + const privateText = + "patient@example.test https://provider.test?token=secret glucose=411"; + const serialized = serializeJobOutcome( + jobFailed("provider_cohort_failed", new Error(privateText), { + outcome: "clean_zero", + total: 0, + }), + ); + + expect(serialized).toEqual({ + ok: false, + reason_code: "provider_cohort_failed", + did: { outcome: "clean_zero", total: 0 }, + }); + expect(JSON.stringify(serialized)).not.toContain(privateText); + expect(serialized).not.toHaveProperty("cause"); + }); + + it.each([ + "raw upstream error", + "https://provider.test/failure", + "failed_for_user_patient@example.test", + "token=secret", + "x".repeat(65), + ])("rejects an unstable or private failure reason: %s", async (reason) => { + const { serializeJobOutcome } = await loadSubject(); + expect(() => serializeJobOutcome(jobFailed(reason))).toThrow( + /reason|code|private|length/i, + ); + }); + + it("keeps a retryable queue failure failed so runJob will retry it", async () => { + const { serializeJobOutcome } = await loadSubject(); + const serialized = serializeJobOutcome( + jobFailed("provider_cohort_retry", new Error("socket reset"), { + outcome: "clean_zero", + users_retryable: 1, + }), + ); + + expect(serialized.ok).toBe(false); + expect(serialized.reason_code).toBe("provider_cohort_retry"); + expect(serialized.did).toEqual({ + outcome: "clean_zero", + users_retryable: 1, + }); + }); +}); diff --git a/src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts b/src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts new file mode 100644 index 000000000..644c43133 --- /dev/null +++ b/src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts @@ -0,0 +1,260 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; + +type Provider = "google_health" | "withings"; +type UserStatus = "complete" | "partial" | "failed" | "parked" | "skipped"; + +type UserVerdict = { + status: UserStatus; + imported: number; + downstreamFailed?: boolean; + retryable?: boolean; + reasonCode?: string; +}; + +type CohortFacts = { + provider: Provider; + outcome: "useful" | "clean_zero"; + total: number; + users_synced: number; + users_complete: number; + users_partial: number; + users_failed: number; + users_parked: number; + users_skipped: number; + users_useful: number; + users_clean_zero: number; + users_retryable: number; + downstream_failed: number; + measurements_imported: number; +}; + +type CohortSubject = { + foldIntegrationCohortOutcomes(input: { + provider: Provider; + verdicts: readonly UserVerdict[]; + }): { ok: true; did: CohortFacts }; +}; + +function loadSubject(): Promise { + return vi.importActual("@/lib/jobs/reminder/poll-cohort"); +} + +const googleHandlerSource = readFileSync( + join(process.cwd(), "src/lib/jobs/reminder/google-health-sync.ts"), + "utf8", +); +const googleSyncSource = readFileSync( + join(process.cwd(), "src/lib/google-health/sync.ts"), + "utf8", +); +const withingsHandlerSource = readFileSync( + join(process.cwd(), "src/lib/jobs/reminder/withings-sync.ts"), + "utf8", +); + +describe("provider handlers use the truthful verdict fold", () => { + it("does not discard Google Health's resolved failed verdict", () => { + expect(googleSyncSource).not.toMatch( + /syncUserGoogleHealth\(userId\)\.then\(\(r\)\s*=>\s*r\.imported\)/, + ); + expect(googleHandlerSource).toMatch(/foldIntegrationCohortOutcomes/); + }); + + it("does not count every resolved Withings call as synced unconditionally", () => { + expect(withingsHandlerSource).toMatch(/foldIntegrationCohortOutcomes/); + expect(withingsHandlerSource).not.toMatch( + /(?:syncUserMeasurements|syncUserActivity|syncUserSleep|syncUserEcg)[\s\S]{0,180}usersSynced\+\+/, + ); + }); +}); + +describe.each(["google_health", "withings"] as const)( + "%s truthful integration cohort outcomes", + (provider) => { + it("classifies complete, partial, failed, parked, and skipped users separately", async () => { + const { foldIntegrationCohortOutcomes } = await loadSubject(); + const outcome = foldIntegrationCohortOutcomes({ + provider, + verdicts: [ + { status: "complete", imported: 3 }, + { + status: "partial", + imported: 2, + downstreamFailed: true, + retryable: true, + reasonCode: "rollup_failed", + }, + { + status: "failed", + imported: 0, + retryable: true, + reasonCode: "provider_unavailable", + }, + { + status: "parked", + imported: 0, + reasonCode: "reauth_required", + }, + { status: "skipped", imported: 0, reasonCode: "not_configured" }, + ], + }); + + expect(outcome).toEqual({ + ok: true, + did: { + provider, + outcome: "useful", + total: 5, + users_synced: 1, + users_complete: 1, + users_partial: 1, + users_failed: 1, + users_parked: 1, + users_skipped: 1, + users_useful: 2, + users_clean_zero: 0, + users_retryable: 2, + downstream_failed: 1, + measurements_imported: 5, + }, + }); + expect( + outcome.did.users_complete + + outcome.did.users_partial + + outcome.did.users_failed + + outcome.did.users_parked + + outcome.did.users_skipped, + ).toBe(outcome.did.total); + }); + + it("counts a fully successful zero-write user as clean zero, not useful work", async () => { + const { foldIntegrationCohortOutcomes } = await loadSubject(); + const outcome = foldIntegrationCohortOutcomes({ + provider, + verdicts: [{ status: "complete", imported: 0 }], + }); + + expect(outcome.did).toMatchObject({ + provider, + outcome: "clean_zero", + total: 1, + users_synced: 1, + users_complete: 1, + users_useful: 0, + users_clean_zero: 1, + measurements_imported: 0, + }); + }); + + it.each([ + ["failed", true], + ["parked", false], + ["skipped", false], + ] as const)( + "never counts a %s user as synced", + async (status, retryable) => { + const { foldIntegrationCohortOutcomes } = await loadSubject(); + const outcome = foldIntegrationCohortOutcomes({ + provider, + verdicts: [{ status, imported: 0, retryable }], + }); + + expect(outcome.did.users_synced).toBe(0); + expect(outcome.did[`users_${status}`]).toBe(1); + expect(outcome.did.users_retryable).toBe(retryable ? 1 : 0); + }, + ); + + it("marks raw writes plus a rollup/downstream failure as partial and retryable", async () => { + const { foldIntegrationCohortOutcomes } = await loadSubject(); + const outcome = foldIntegrationCohortOutcomes({ + provider, + verdicts: [ + { + status: "partial", + imported: 7, + downstreamFailed: true, + retryable: true, + reasonCode: "rollup_failed", + }, + ], + }); + + expect(outcome.did).toMatchObject({ + outcome: "useful", + users_synced: 0, + users_partial: 1, + users_retryable: 1, + downstream_failed: 1, + measurements_imported: 7, + }); + }); + + it("never emits per-user identifiers, PII, secrets, URLs, raw errors, or health values", async () => { + const { foldIntegrationCohortOutcomes } = await loadSubject(); + const privateVerdict = { + status: "failed", + imported: 0, + retryable: false, + reasonCode: "provider_failed", + userId: "user-private-123", + email: "patient@example.test", + accessToken: "secret-token", + url: "https://provider.test/capability?token=secret", + rawError: new Error("blood glucose=411"), + healthValue: 411, + } as UserVerdict; + + const serialized = JSON.stringify( + foldIntegrationCohortOutcomes({ + provider, + verdicts: [privateVerdict], + }), + ); + expect(serialized).not.toMatch( + /user-private|patient@|secret-token|provider\.test|blood glucose|411|rawError|healthValue/i, + ); + }); + + it("folds a high-volume cohort into one fixed bounded scalar payload", async () => { + const { foldIntegrationCohortOutcomes } = await loadSubject(); + const verdicts = Array.from({ length: 10_000 }, (_, index) => ({ + status: index % 2 === 0 ? ("complete" as const) : ("skipped" as const), + imported: index % 2 === 0 ? 1 : 0, + })); + + const outcome = foldIntegrationCohortOutcomes({ provider, verdicts }); + expect(Object.keys(outcome.did).sort()).toEqual( + [ + "downstream_failed", + "measurements_imported", + "outcome", + "provider", + "total", + "users_clean_zero", + "users_complete", + "users_failed", + "users_parked", + "users_partial", + "users_retryable", + "users_skipped", + "users_synced", + "users_useful", + ].sort(), + ); + expect( + Object.values(outcome.did).every((value) => { + return ( + typeof value === "string" || + (typeof value === "number" && + Number.isSafeInteger(value) && + value >= 0) + ); + }), + ).toBe(true); + expect(JSON.stringify(outcome).length).toBeLessThan(1_024); + }); + }, +); From 966a8696307b80e1e731755860539ff10c39c925 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:26:47 +0200 Subject: [PATCH 055/125] docs(01-11): complete medication occurrence contract plan - document independent reminder and rolling-overdue RED failures - record passing persistence, retry, and isolation controls --- .../01-11-SUMMARY.md | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-11-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-11-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-11-SUMMARY.md new file mode 100644 index 000000000..ecfd8a658 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-11-SUMMARY.md @@ -0,0 +1,160 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "11" +subsystem: testing +tags: [medications, reminders, recurrence, postgres, vitest, regression] + +requires: + - phase: 01-09 + provides: medication correctness audit and regression-test seams +provides: + - Independent RED contract for reminder occurrence identity across local midnight + - Canonical rolling-weekly overdue-band and resolved-state contracts + - Real-Postgres parity contract for list, Dashboard, GLP-1, take-all, and intake writes +affects: [medication-reminders, rolling-recurrence, medication-consumers, issue-664] + +tech-stack: + added: [] + patterns: + - deterministic Date clocks across UTC, Berlin DST, and Auckland boundaries + - real PostgreSQL persistence tests paired with focused unit contracts + - two-sided RED regressions with explicit passing safety controls + +key-files: + created: + - tests/integration/medication-reminder-cross-midnight.test.ts + - tests/integration/medication-rolling-overdue-consumers.test.ts + modified: + - src/lib/jobs/__tests__/medication-reminder-check.test.ts + - src/lib/jobs/__tests__/medication-reminder-dedup.test.ts + - src/lib/medications/scheduling/__tests__/recurrence.test.ts + - src/lib/medications/scheduling/__tests__/next-due.test.ts + - src/app/api/medications/[id]/intake/__tests__/route.test.ts + +key-decisions: + - "Kept issue #664 reminder identity and the rolling Mounjaro overdue defect as separate RED contracts." + - "Compared canonical occurrence instants at server read and real persistence boundaries instead of adding component fallbacks." + - "Required passing negative controls for exact taken suppression, next-dose isolation, retries, concurrency, tombstones, and sibling medications." + +patterns-established: + - "Occurrence contract: assertions use the exact scheduledFor instant, not a local date or wall-clock approximation." + - "RED contract: the intended defect stays failing while adjacent safety behavior must already pass." + +requirements-completed: [REM-01, REM-02, REM-03, REM-04, MED-01, MED-02, MED-03, MED-04, MED-05, MED-06] + +duration: 14min +completed: 2026-07-29 +--- + +# Phase 01 Plan 11: Medication Occurrence Identity Regressions Summary + +**Deterministic unit and real-Postgres RED contracts isolate cross-midnight reminder identity from rolling-weekly overdue projection while preserving exact-suppression and no-duplicate safety controls.** + +## Performance + +- **Duration:** 14 min +- **Started:** 2026-07-29T12:11:52Z +- **Completed:** 2026-07-29T12:25:20Z +- **Tasks:** 3 +- **Files modified:** 8 + +## Accomplishments + +- Reproduced issue #664 across UTC, ordinary Berlin time, a Berlin DST boundary, and positive-offset Auckland, including a real reminder worker and push-attempt ledger. +- Pinned the Mounjaro weekly timeline through its canonical overdue band, exact taken/skipped/auto-missed resolution, sibling schedules, and schedule-era isolation. +- Proved the current cross-consumer failure using real PostgreSQL records: list, Dashboard, GLP-1 card derivation, and take-all all lose the same prior-day occurrence. +- Protected both safety directions with passing controls for an exact taken occurrence, the next distinct dose, concurrent/retried writes, tombstoned rows, and sibling medications. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Reproduce issue #664 with exact cross-midnight occurrence identity** - `81893ac97` (test) +2. **Task 2: Specify the canonical rolling overdue band** - `d5f473d67` (test) +3. **Task 3: Prove one authoritative result across medication consumers** - `25612294d` (test) + +Supporting fixture corrections: + +- `958b0e10d` - keep the reminder fixture type-safe +- `34372b57b` - use a canonical medication compliance status + +## Files Created/Modified + +- `src/lib/jobs/__tests__/medication-reminder-check.test.ts` - Cross-midnight worker dispatch and intake-query identity cases across zones and DST. +- `src/lib/jobs/__tests__/medication-reminder-dedup.test.ts` - Retry/dedup contract keyed to the scheduled occurrence's local date. +- `tests/integration/medication-reminder-cross-midnight.test.ts` - Real-Postgres reminder worker, intake, notification dispatch, and push-ledger regression. +- `src/lib/medications/scheduling/__tests__/recurrence.test.ts` - Prior-day rolling occurrence lookup through ordinary and DST weeks. +- `src/lib/medications/scheduling/__tests__/next-due.test.ts` - Rolling overdue band, exact resolution, sibling schedule, and era isolation. +- `tests/integration/medication-rolling-overdue-consumers.test.ts` - Real list, Dashboard, GLP-1, take-all, concurrency, retry, and unrelated-row contracts. +- `src/app/api/medications/[id]/intake/__tests__/route.test.ts` - Exact live-slot query scope and no-false-prompt positive control. + +## Verification Results + +The plan intentionally requires non-zero RED-suite exits before implementation begins. + +- **Focused unit suites:** 131 tests; 118 passed and 13 expected RED assertions failed. + - Reminder worker: 5 RED failures (four timezone/DST dispatch cases and the prior-day intake-query lower bound). + - Reminder dedup: 1 RED failure (untaken overnight occurrence is not dispatched). + - Rolling recurrence: 2 RED failures (ordinary Berlin and spring-DST prior-day occurrences return `null`). + - Rolling display due: 5 RED failures (unresolved, skipped, auto-missed, sibling schedule, and archived-era identity). + - Intake route: all 22 tests passed, including the new exact-slot isolation control. +- **Real-Postgres suites:** 8 tests; 4 passed and 4 expected RED assertions failed. + - Cross-midnight reminder: untreated retry is RED; exact taken suppression plus next distinct dose passes. + - Rolling consumers: unresolved, skipped, and auto-missed projection are RED; exact taken advancement, concurrent/retry convergence, and unrelated-row isolation pass. +- **ESLint:** Passed for all seven test files. +- **TypeScript:** Plan-owned files pass type checking. The repository-wide command remains non-zero only because concurrently added Google Health test files reference production modules that have not landed yet. + +## Decisions Made + +- The reminder bug and rolling-overdue bug share the idea of canonical occurrence identity but retain independent tests, so fixing one cannot falsely close the other. +- Consumer parity is asserted at exact ISO `scheduledFor` instants from production builders and derivations; no UI-only fallback is accepted. +- Resolved-state coverage advances to the next distinct occurrence rather than merely asserting disappearance, preventing a false fix that hides every future dose. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Kept the reminder schedule fixture assignable** +- **Found during:** Task 1 +- **Issue:** The fixture inferred `timesOfDay` as an empty tuple-like array and `rrule` as only `null`, so direct replacement failed repository type checking. +- **Fix:** Mutated the existing schedule with `Object.assign` without changing the RED behavior. +- **Files modified:** `src/lib/jobs/__tests__/medication-reminder-dedup.test.ts` +- **Verification:** Focused ESLint passed and the suite executes with the intended assertion failure. +- **Committed in:** `958b0e10d` + +**2. [Rule 3 - Blocking] Corrected the resolved compliance fixture value** +- **Found during:** Task 3 +- **Issue:** The initial no-longer-overdue fixture used `on_track`, which is not a production `DoseStatus`. +- **Fix:** Used the canonical `upcoming` status so take-all suppression is type-correct. +- **Files modified:** `tests/integration/medication-rolling-overdue-consumers.test.ts` +- **Verification:** Repository type checking reports no Plan 01-11 errors. +- **Committed in:** `34372b57b` + +--- + +**Total deviations:** 2 auto-fixed (2 blocking fixture corrections) +**Impact on plan:** Both corrections only made the planned tests executable and type-correct; test semantics and production scope were unchanged. + +## Issues Encountered + +- Repository-wide `pnpm typecheck` is currently blocked by concurrent Plan 01-13 Google Health RED tests whose referenced `route` and `sync-progress` production modules do not yet exist. No Plan 01-11 file remains in the error output. +- The integration harness reapplies all migrations for each invocation; tests completed normally against isolated PostgreSQL containers. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- Implementation can address reminder-day lookup independently from rolling recurrence projection, using exact persisted occurrence identity in both paths. +- The passing controls are ready to reject duplicate intake rows, broad suppression, false already-taken prompts, and future-dose disappearance. +- No claim is made that issue #664 is resolved; these tests intentionally document the current failures. + +## Self-Check: PASSED + +- All 8 claimed files exist. +- All 5 task/supporting commits exist. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From b39ddeb5b5a55fa1e6ea244a19907181666d76f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:27:15 +0200 Subject: [PATCH 056/125] docs(01-14): complete cohort outcome contract plan - record expected RED provider fold and serializer evidence - preserve scope and concurrent-work boundaries --- .../01-14-SUMMARY.md | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-14-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-14-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-14-SUMMARY.md new file mode 100644 index 000000000..4aa4ff26b --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-14-SUMMARY.md @@ -0,0 +1,113 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "14" +subsystem: testing +tags: [jobs, cohorts, google-health, withings, telemetry, privacy, tdd] + +requires: + - phase: 01-09 + provides: Focused security gate before reliability contract work +provides: + - RED Google Health and Withings per-user verdict aggregation contracts + - RED bounded privacy-safe job outcome serialization contracts +affects: [01-22, job-outcomes, integration-cohorts, worker-telemetry] + +tech-stack: + added: [] + patterns: + - Provider-neutral per-user verdict folding into fixed aggregate counters + - Explicit serialization boundary between internal job causes and emitted job facts + +key-files: + created: + - src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts + - src/lib/jobs/__tests__/job-outcome.test.ts + modified: [] + +key-decisions: + - "users_synced counts only complete users; partial, failed, parked, and skipped users retain separate counters." + - "A complete zero-write run is clean_zero, while complete or partial raw writes are useful work." + - "Job output serialization uses a fixed fact-key allowlist, stable short codes, safe non-negative integer counts, and a 2 KiB ceiling." + +patterns-established: + - "Cohort privacy: only fixed aggregate counts and provider/outcome codes leave the fold; per-user objects never do." + - "Retry truth: a retryable cohort-wide failure remains ok:false, while isolated retryable users are counted without retrying the whole cohort." + +requirements-completed: [SYNC-02, JOB-01] + +duration: 4min +completed: 2026-07-29 +--- + +# Phase 1 Plan 14: Truthful Cohort and Job Outcome RED Contract Summary + +**Google/Withings verdict folding and bounded job serialization pinned against false synced counts and private telemetry** + +## Performance + +- **Duration:** 4 min +- **Started:** 2026-07-29T12:22:40Z +- **Completed:** 2026-07-29T12:26:31Z +- **Tasks:** 1 +- **Files modified:** 2 + +## Accomplishments + +- Added table-driven Google Health and Withings contracts for complete, partial, failed, parked, skipped, useful-write, and clean-zero outcomes. +- Required failed, parked, skipped, and partial users to remain outside `users_synced`, with the five exclusive status counters summing to total. +- Pinned rollup/downstream partial failure and retryable-user counts without turning isolated user failure into a cohort-wide queue retry. +- Added fixed-key, scalar-type, count-range, reason-code, fact-count, payload-byte, and privacy-negative serialization tests. +- Directly captured the current Google resolved-failure discard and Withings unconditional `usersSynced++` behavior as RED handler-wiring regressions. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Define truthful cohort and high-volume job scalars** - `690c4aeb5` (test, RED) + +## Files Created/Modified + +- `src/lib/jobs/reminder/__tests__/integration-cohort-outcomes.test.ts` - Provider handler wiring, mixed verdict fold, clean-zero, downstream/retry, high-volume boundedness, and privacy contracts. +- `src/lib/jobs/__tests__/job-outcome.test.ts` - Generic allowlist, scalar validation, stable failure-code, cause-redaction, and serialized-size contracts. + +## Decisions Made + +- Plan 22 will expose `foldIntegrationCohortOutcomes` from the shared poll-cohort module so Google and Withings cannot drift into separate definitions. +- Complete users with zero imported rows count as synced and clean-zero; partial writes count as useful but not synced. +- The fold emits no per-user reason codes or identifiers. It keeps only fixed counts, a fixed provider code, and a useful/clean-zero outcome code. +- Raw `cause` may remain available internally for retry mechanics, but `serializeJobOutcome` must omit it and emit only a stable `reason_code`. +- Unsafe fact keys, free-text string facts, negative/non-finite/unsafe counts, nested data, and oversized payloads fail closed. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +- Repository-wide `pnpm typecheck` remains RED only in concurrently authored Google Health contract files for missing Plan 21 implementation and mock tuple typing. No error references either Plan 01-14 file. + +## Verification Evidence + +- Focused Vitest gate: **expected RED**, 46/46 contracts fail because the verdict fold, handler wiring, allowlist, and serializer do not exist yet. +- Focused ESLint: passed for both owned files. +- Prettier and `git diff --check`: passed for both owned files. +- Gitleaks pre-commit hook: passed. + +## User Setup Required + +None - this plan adds test contracts only. + +## Next Phase Readiness + +- Plan 22 has a fixed input/output contract for provider-neutral cohort aggregation. +- The failures are implementation-specific: missing exports and the explicitly detected false-synced handler patterns, not syntax or infrastructure errors. + +## Self-Check: PASSED + +- Both owned test files exist. +- Task commit `690c4aeb5` exists on the current branch. +- No production, schema, migration, package, scanner, or unrelated test files were changed by Plan 01-14. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From d67b899138e4129bdd136b01595d3c38f2225f13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:27:45 +0200 Subject: [PATCH 057/125] fix(01-16): remove redundant score and summary presentation - keep useful Today prose without repeating the ring score - remove stored-summary regeneration and Health Score notice/method/list copy --- src/components/daily/today-hero.tsx | 45 +++++++++++-- .../documents/document-summary-block.tsx | 29 +-------- src/components/insights/health-score-card.tsx | 64 +------------------ 3 files changed, 42 insertions(+), 96 deletions(-) diff --git a/src/components/daily/today-hero.tsx b/src/components/daily/today-hero.tsx index b28a536ca..b5a18d4f9 100644 --- a/src/components/daily/today-hero.tsx +++ b/src/components/daily/today-hero.tsx @@ -64,6 +64,28 @@ function formatDelta( return t("daily.today.deltaVsBaseline", { delta: signed }); } +/** + * The ring already owns the headline number. AI briefing copy can still carry + * an older lead sentence that repeats that exact score, so remove only the + * sentence containing the displayed number and retain every other useful + * sentence verbatim. If the score sentence was the whole lead, render no lead + * rather than inventing replacement prose. + */ +function withoutRepeatedScore( + text: string | null, + score: number | null, +): string | null { + if (!text || score === null) return text; + const shownScore = String(Math.round(score)); + const scorePattern = new RegExp( + `(^|[^0-9])${shownScore.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}([^0-9]|$)`, + ); + const sentences = text.match(/[^.!?]+[.!?]+(?:\s+|$)|[^.!?]+$/g) ?? [text]; + const retained = sentences.filter((sentence) => !scorePattern.test(sentence)); + const result = retained.join(" ").replace(/\s+/g, " ").trim(); + return result.length > 0 ? result : null; +} + /** * The "just in" chip's clock face. * @@ -128,7 +150,14 @@ export function TodayHero({ // paragraph, so the hero stays exactly one lead line tall and nothing below // it shifts. The briefing lead is the standing read; the deterministic // `line` is the floor a keyless self-hoster still gets. - const lead = digest.reactionLine ?? digest.briefingLead ?? digest.line; + const rawLead = + digest.reactionLine ?? + digest.briefingLead ?? + // The deterministic line is a useful floor only once the ring has a real + // score. A provisional/null ring must not pair with a fabricated score + // sentence from that fallback. + (hasScore ? digest.line : null); + const lead = withoutRepeatedScore(rawLead, digest.score?.value ?? null); const topSignal = digest.topSignal; const justInTime = digest.justIn ? formatJustInTime(digest.justIn.at) : null; @@ -179,12 +208,14 @@ export function TodayHero({
{/* Hero numeric face: the read leads large in the foreground token, calm and legible — the day's read, not a slogan. */} -
- -
+ {lead ? ( +
+ +
+ ) : null} {/* Top signal — present-tense headline + optional delta, one muted step down so it supports the lead without competing. */} {topSignal ? ( diff --git a/src/components/documents/document-summary-block.tsx b/src/components/documents/document-summary-block.tsx index 6b0bdc16f..bee251687 100644 --- a/src/components/documents/document-summary-block.tsx +++ b/src/components/documents/document-summary-block.tsx @@ -27,7 +27,7 @@ * provider call. With no AI provider configured the block renders the state * without an action rather than an button that would 422. */ -import { FileText, RefreshCw } from "lucide-react"; +import { FileText } from "lucide-react"; import { Button } from "@/components/ui/button"; import { useTranslations } from "@/lib/i18n/context"; @@ -63,9 +63,8 @@ export function DocumentSummaryBlock({ }) { const { t } = useTranslations(); - // A stored summary wins over every state: it is shown from storage. The - // adjacent action is explicit, so opening the sheet never spends a provider - // call; a successful replacement is persisted by the summary route. + // A stored summary wins over every state and is shown read-only from storage. + // Generation remains available only for absent terminal states below. if (summary) { return (
@@ -76,28 +75,6 @@ export function DocumentSummaryBlock({

{t("documents.detail.summary.title")}

- {aiEnabled ? ( - - ) : null}

{summary}

{generatedAtLabel ? ( diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 7a5884baa..0df398e8b 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -1,14 +1,7 @@ "use client"; -import { useState } from "react"; -import { X } from "lucide-react"; - import { ScoreAnatomyView } from "@/components/insights/derived/score-anatomy-view"; import { CoverageMeter } from "@/components/insights/derived/coverage-meter"; -import { Button } from "@/components/ui/button"; -import { apiPost } from "@/lib/api/api-fetch"; -import { useOptionalQueryClient } from "@/hooks/_internal/use-query-client-safe"; -import { queryKeys } from "@/lib/query-keys"; import { useAuth } from "@/hooks/use-auth"; import { useUnitDisplay } from "@/hooks/use-unit-display"; import { convertGlucose, resolveGlucoseUnit } from "@/lib/glucose"; @@ -102,10 +95,6 @@ export function HealthScoreCard({ const { t } = useTranslations(); const { user } = useAuth(); const glucoseUnit = resolveGlucoseUnit(user?.glucoseUnit); - const queryClient = useOptionalQueryClient(); - const [noticeDismissed, setNoticeDismissed] = useState( - report.algorithmNotice?.dismissed ?? false, - ); function reasonText(reason: string): string { const key = REASON_KEY[reason] ?? "unavailable"; @@ -234,55 +223,11 @@ export function HealthScoreCard({ ? 1 / eligibleCount : 0, })); - const algorithmNotice = report.algorithmNotice; - - async function dismissAlgorithmNotice() { - if (!algorithmNotice) return; - setNoticeDismissed(true); - try { - await apiPost("/api/daily/digest/dismiss", { - itemKey: algorithmNotice.itemKey, - }); - queryClient?.setQueriesData( - { queryKey: queryKeys.analytics() }, - (payload) => - markAlgorithmNoticeDismissed(payload, algorithmNotice.itemKey), - ); - await queryClient?.invalidateQueries({ - queryKey: queryKeys.analytics(), - refetchType: "active", - }); - } catch { - setNoticeDismissed(false); - } - } - return (
- {algorithmNotice && !noticeDismissed ? ( -
-

- {t("insights.healthScore.algorithmChanged")} -

- -
- ) : null} - {composite.status === "ok" ? ( <> -

- {t("insights.healthScore.composition", { - pillars: composite.value.composition - .map((id) => t(PILLAR_LABEL_KEY[id])) - .join(", "), - })} -

{t("insights.healthScore.bandSetter", { pillar: composite.value.bandSetter From c4ec2d552fb01f7fac1e3f0d791a0f5ce7989988 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:28:47 +0200 Subject: [PATCH 058/125] fix(01-16): normalize assessment prose presentation - use the readable foreground at confirmed medication and Mood mounts - tighten Health Score and medication assessment spacing --- src/app/insights/medications/page.tsx | 4 ++-- src/components/insights/health-score-card.tsx | 2 +- src/components/insights/mood/mood-better-days.tsx | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/insights/medications/page.tsx b/src/app/insights/medications/page.tsx index c499c329b..9fe6a1d5f 100644 --- a/src/app/insights/medications/page.tsx +++ b/src/app/insights/medications/page.tsx @@ -256,7 +256,7 @@ export default function InsightsMedikamentePage() { ) : null}

- +
{t("insights.compliance7d")} @@ -297,7 +297,7 @@ export default function InsightsMedikamentePage() {
{medicationSummary ? ( -

+

{medicationSummary}

) : null} diff --git a/src/components/insights/health-score-card.tsx b/src/components/insights/health-score-card.tsx index 0df398e8b..a4e7b7ee1 100644 --- a/src/components/insights/health-score-card.tsx +++ b/src/components/insights/health-score-card.tsx @@ -226,7 +226,7 @@ export function HealthScoreCard({ return (
-

+

{t("insights.mood.betterDays.description")}

    From 431120d55e37aedecbabc96b3d84ddce78241d7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:29:19 +0200 Subject: [PATCH 059/125] fix(01-16): unify records action grammar - use Save for new and existing lifestyle facts - show first-entry or populated-list add actions without duplication --- src/components/records/allergy-manager.tsx | 10 ++++++---- src/components/records/family-history-manager.tsx | 10 ++++++---- .../records/health-profile-facts-manager.tsx | 4 +--- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/components/records/allergy-manager.tsx b/src/components/records/allergy-manager.tsx index f363bc5ad..bbd25e0c4 100644 --- a/src/components/records/allergy-manager.tsx +++ b/src/components/records/allergy-manager.tsx @@ -123,10 +123,12 @@ export function AllergyManager() {

    {t("records.allergies.description")}

    - + {rows.length > 0 ? ( + + ) : null}
{isLoading ? ( diff --git a/src/components/records/family-history-manager.tsx b/src/components/records/family-history-manager.tsx index 4a6cb7542..ba007a21b 100644 --- a/src/components/records/family-history-manager.tsx +++ b/src/components/records/family-history-manager.tsx @@ -108,10 +108,12 @@ export function FamilyHistoryManager() {

{t("records.family.description")}

- + {rows.length > 0 ? ( + + ) : null}
{isLoading ? ( diff --git a/src/components/records/health-profile-facts-manager.tsx b/src/components/records/health-profile-facts-manager.tsx index 4a42e81d7..a5cc6e2b4 100644 --- a/src/components/records/health-profile-facts-manager.tsx +++ b/src/components/records/health-profile-facts-manager.tsx @@ -243,9 +243,7 @@ export function HealthProfileFactsManager() { aria-hidden /> )} - {current - ? t("records.profileFacts.correct") - : t("records.profileFacts.save")} + {t("records.profileFacts.save")} {current ? ( Date: Wed, 29 Jul 2026 14:29:51 +0200 Subject: [PATCH 060/125] feat(01-17): group gated admin navigation - cluster allowed destinations under semantic settings-style headings - preserve fail-closed role gating and mobile target order --- src/components/admin/admin-shell.tsx | 154 +++++++++++++++++++-------- 1 file changed, 107 insertions(+), 47 deletions(-) diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 886935a0b..5fe4ab59c 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -47,8 +47,20 @@ interface AdminSection { /** i18n key under `admin.section..title`. */ titleKey: string; icon: LucideIcon; + group: AdminSectionGroup; } +type AdminSectionGroup = + "system" | "connectivity" | "ai" | "account" | "yourData"; + +const ADMIN_SECTION_GROUP_LABEL_KEYS: Record = { + system: "settings.shell.groups.system", + connectivity: "settings.shell.groups.connectivity", + ai: "settings.shell.groups.ai", + account: "settings.shell.groups.account", + yourData: "settings.shell.groups.yourData", +}; + /** * Source of truth for the admin section list. Order matches the in-app * navigation order so the sidebar, mobile strip, and `generateStaticParams()` @@ -59,21 +71,38 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [ slug: "system-status", titleKey: "admin.section.system-status.title", icon: Server, + group: "system", }, { slug: "general", titleKey: "admin.section.general.title", icon: Settings, + group: "system", }, { slug: "services", titleKey: "admin.section.services.title", icon: Radio, + group: "system", + }, + // v1.4.36 W4e — About / version / update check folded into Admin. + { + slug: "about", + titleKey: "admin.section.about.title", + icon: Info, + group: "system", }, { slug: "integrations", titleKey: "admin.section.integrations.title", icon: Plug, + group: "connectivity", + }, + { + slug: "api-tokens", + titleKey: "admin.section.api-tokens.title", + icon: KeyRound, + group: "connectivity", }, // v1.18.1 — single "Coach" entry replacing the former ai-quality, // assistant, and coach-feedback trio. The section body stacks all @@ -82,6 +111,7 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [ slug: "coach", titleKey: "admin.section.coach.title", icon: Sparkles, + group: "ai", }, // v1.18.6 (W9) — server-wide module availability split out of Coach into // its own entry, sitting next to it. @@ -89,16 +119,19 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [ slug: "module-availability", titleKey: "admin.section.module-availability.title", icon: Blocks, + group: "ai", }, { slug: "reminders", titleKey: "admin.section.reminders.title", icon: Bell, + group: "ai", }, { slug: "users", titleKey: "admin.section.users.title", icon: Users, + group: "account", }, // v1.16.0 — invites as their own section, directly after Users // (admission lives next to the accounts it creates). @@ -106,43 +139,38 @@ export const ADMIN_SECTIONS: readonly AdminSection[] = [ slug: "invites", titleKey: "admin.section.invites.title", icon: Ticket, - }, - { - slug: "api-tokens", - titleKey: "admin.section.api-tokens.title", - icon: KeyRound, + group: "account", }, { slug: "login-overview", titleKey: "admin.section.login-overview.title", icon: ScrollText, + group: "account", }, { slug: "app-logs", titleKey: "admin.section.app-logs.title", icon: FileText, + group: "yourData", }, { slug: "backups", titleKey: "admin.section.backups.title", icon: Database, + group: "yourData", }, // v1.23 — encryption coverage + key-rotation status / safe trigger. { slug: "encryption", titleKey: "admin.section.encryption.title", icon: ShieldCheck, + group: "yourData", }, { slug: "danger-zone", titleKey: "admin.section.danger-zone.title", icon: ShieldAlert, - }, - // v1.4.36 W4e — About / version / update check folded into Admin. - { - slug: "about", - titleKey: "admin.section.about.title", - icon: Info, + group: "yourData", }, ] as const; @@ -175,6 +203,11 @@ export function AdminShell({ active, children }: AdminShellProps) { const { t } = useTranslations(); const { user } = useAuth(); const activeSlug = deriveActiveSlug(pathname, active); + const isConfirmedAdmin = user?.role === "ADMIN"; + // Apply the existing fail-closed authorization predicate before grouping. + // The filtered list is the only render source, so an unavailable item can + // never leave a group heading without an allowed destination below it. + const visibleSections = isConfirmedAdmin ? ADMIN_SECTIONS : []; // v1.4.34 IW-G — mirror the settings-shell auto-scroll pattern so // the active chip stays in view when the user lands on a deeper @@ -205,7 +238,7 @@ export function AdminShell({ active, children }: AdminShellProps) { // non-admin during the frames between auth resolving and AuthShell's // redirect effect replacing the route. Render nothing — frame AND // children — until the role is confirmed ADMIN. - if (!user || user.role !== "ADMIN") return null; + if (!isConfirmedAdmin) return null; // v1.18.6.1 — the heading lives in the shell so it can occupy its own grid // row spanning only the content column; the nav's first item then lines up @@ -274,28 +307,41 @@ export function AdminShell({ active, children }: AdminShellProps) { {t("admin.shell.overview")} - {ADMIN_SECTIONS.map((section) => { + {visibleSections.map((section, index) => { const isActive = section.slug === activeSlug; const Icon = section.icon; + const startsNewGroup = + index > 0 && visibleSections[index - 1].group !== section.group; return ( -
  • - -
  • + + {startsNewGroup ? ( + + ) : null} +
  • + +
  • +
    ); })} @@ -348,25 +394,39 @@ export function AdminShell({ active, children }: AdminShellProps) { {t("admin.shell.overview")} - {ADMIN_SECTIONS.map((section) => { + {visibleSections.map((section, index) => { const isActive = section.slug === activeSlug; const Icon = section.icon; + const startsNewGroup = + index === 0 || + visibleSections[index - 1].group !== section.group; return ( -
  • - -
  • + + {startsNewGroup ? ( +
  • + {t(ADMIN_SECTION_GROUP_LABEL_KEYS[section.group])} +
  • + ) : null} +
  • + +
  • +
    ); })} From c0af97ce461c6ba8da15f3740ae524f1d2100021 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:30:23 +0200 Subject: [PATCH 061/125] fix(01-18): preserve reminder occurrence across midnight - select active canonical recurrence slots instead of rebuilding from tick day - scope intake suppression and dedup metadata to the occurrence local date --- .../reminder/medication-reminder-check.ts | 158 ++++++++++-------- 1 file changed, 92 insertions(+), 66 deletions(-) diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 3df69c8ae..01f365ce0 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -20,7 +20,6 @@ import { buildCanonicalSchedule, buildRecurrenceContext, resolveSlotPhaseWindow, - scheduleEmitsInWindow, shouldMintMissedDoseRow, } from "@/lib/medications/scheduling/worker-helpers"; import { deleteMessage } from "@/lib/telegram"; @@ -36,10 +35,7 @@ import { medicationReminderDedupKey, writeReminderDedupAnchor, } from "@/lib/notifications/reminder-dedup"; -import { - getUserTodayBounds as getUserTodayBoundsUtil, - localHmAsUtc, -} from "@/lib/tz/local-day"; +import { getUserTodayBounds as getUserTodayBoundsUtil } from "@/lib/tz/local-day"; import { getWorkerPrisma, parseTimeToMinutes } from "./shared"; export interface ReminderCheckPayload { @@ -202,11 +198,13 @@ export async function handleReminderCheck( now, userTz, ); - - // Get today's date string in user's timezone for message tracking - const localDateStr = now.toLocaleDateString("sv-SE", { - timeZone: userTz, - }); // YYYY-MM-DD format + // A dose window may cross local midnight. Look back far enough to + // retain yesterday's canonical occurrence on 23/25-hour DST days; + // candidate filtering below admits a prior-day slot only when its + // own resolved window reaches into today. + const reminderOccurrenceStart = new Date( + todayStart.getTime() - 48 * 60 * 60 * 1000, + ); // v1.7.0 code-correctness M4 — fetch today's intake events so a // logged dose suppresses the reminder for the SLOT it belongs to, @@ -225,7 +223,10 @@ export async function handleReminderCheck( // v1.7.0 sync — a tombstoned dose is no longer a logged // action, so it must not suppress today's reminder. deletedAt: null, - scheduledFor: { gte: todayStart, lte: todayEnd }, + scheduledFor: { + gte: reminderOccurrenceStart, + lte: todayEnd, + }, }, select: { scheduledFor: true, @@ -317,6 +318,17 @@ export async function handleReminderCheck( userTz, lastIntakeAt, }); + const occurrencesByScheduleId = new Map( + canonicalSchedules.map((canonicalSchedule) => [ + canonicalSchedule.id, + occurrencesBetween( + canonicalSchedule, + reminderOccurrenceStart, + todayEnd, + recurrenceCtx, + ), + ]), + ); // Attribute every action against one medication-wide pool. Each // schedule still mints its own cadence-aware bands, preserving its @@ -335,7 +347,7 @@ export async function handleReminderCheck( ? [] : occurrencesBetween( canonicalSchedule, - todayStart, + reminderOccurrenceStart, todayEnd, recurrenceCtx, ).map((occurrence) => occurrence.at); @@ -344,7 +356,7 @@ export async function handleReminderCheck( schedule: canonicalSchedule, ctx: recurrenceCtx, userTz, - range: { from: todayStart, to: todayEnd }, + range: { from: reminderOccurrenceStart, to: todayEnd }, now, intakeInstants: rollingOpenSlots, }).bands; @@ -387,24 +399,9 @@ export async function handleReminderCheck( ) { const schedule = sortedSchedules[scheduleIndex]; const canonicalSchedule = canonicalSchedules[scheduleIndex]; - // v1.5.0 — route every "does today emit a slot?" decision - // through the canonical recurrence engine. Replaces the - // legacy weekday-only filter that silently ignored - // `intervalWeeks` (the pre-v1.5 bi-weekly bug — a Wed - // bi-weekly schedule fired every Wed instead of every other - // Wed). The engine honours RRULE / rolling / one-shot / - // legacy-with-interval-weeks / `endsOn` cap; no special- - // casing here. - if ( - !scheduleEmitsInWindow( - canonicalSchedule, - recurrenceCtx, - todayStart, - todayEnd, - ) - ) { - continue; - } + const scheduleOccurrences = + occurrencesByScheduleId.get(canonicalSchedule.id) ?? []; + if (scheduleOccurrences.length === 0) continue; // v1.7.0 SB-SCHED-4 — multi-time-of-day dispatch. A schedule // with `timesOfDay = ["08:00","20:00"]` is two distinct dose @@ -436,26 +433,68 @@ export async function handleReminderCheck( // schedule (byte-stable against pre-v1.7 rows), else the // explicit HH:mm. const dedupTimeOfDay = hasFirstClassTimes ? slotTime : ""; - // The UTC instant this slot is due (DST-safe). - const [slotH, slotM] = slotTime.split(":").map(Number); - const slotScheduledFor = localHmAsUtc(now, userTz, slotH, slotM); + // Select the latest canonical occurrence whose own phase window + // is active for this tick. A prior-day occurrence is eligible + // only when its resolved window crosses today's local boundary; + // ordinary stale yesterday slots must not become new reminders. + const phaseCandidate = scheduleOccurrences + .filter((occurrence) => occurrence.timeOfDay === slotTime) + .map((occurrence) => { + const phaseWindow = resolveSlotPhaseWindow( + schedule, + slotTime, + canonicalSchedule.doseWindows ?? null, + occurrence.at, + userTz, + ); + if ( + occurrence.at.getTime() < todayStart.getTime() && + phaseWindow.end.getTime() < todayStart.getTime() + ) { + return null; + } + const minutesFromStart = + (nowMs - phaseWindow.start.getTime()) / 60_000; + const minutesToEnd = + (phaseWindow.end.getTime() - nowMs) / 60_000; + const windowDuration = + (phaseWindow.end.getTime() - + phaseWindow.start.getTime()) / + 60_000; + const thresholds = resolvePhaseThresholds( + phaseConfig, + windowDuration, + ); + const currentPhase = determinePhase( + minutesToEnd, + minutesFromStart, + thresholds, + ); + return currentPhase + ? { + slotScheduledFor: occurrence.at, + minutesToEnd, + currentPhase, + } + : null; + }) + .filter((candidate) => candidate !== null) + .sort( + (a, b) => + b.slotScheduledFor.getTime() - + a.slotScheduledFor.getTime(), + )[0]; + if (!phaseCandidate) continue; + + const { + slotScheduledFor, + minutesToEnd, + currentPhase, + } = phaseCandidate; const slotInstant = slotScheduledFor.getTime(); if (liveEraStart !== null && slotInstant < liveEraStart) { continue; } - const phaseWindow = resolveSlotPhaseWindow( - schedule, - slotTime, - canonicalSchedule.doseWindows ?? null, - slotScheduledFor, - userTz, - ); - const minutesFromStart = - (nowMs - phaseWindow.start.getTime()) / 60_000; - const minutesToEnd = (phaseWindow.end.getTime() - nowMs) / 60_000; - const windowDuration = - (phaseWindow.end.getTime() - phaseWindow.start.getTime()) / - 60_000; // Skips and retained USER_PIN takes bind to the nearest canonical // anchor inside the dose-history epsilon. A released pin has @@ -510,23 +549,6 @@ export async function handleReminderCheck( continue; } - // Resolve phase thresholds - const thresholds = resolvePhaseThresholds( - phaseConfig, - windowDuration, - ); - - // Determine current phase for this slot's window. - const currentPhase = determinePhase( - minutesToEnd, - minutesFromStart, - thresholds, - ); - - if (!currentPhase) { - continue; - } - // Already notified for this slot, phase and local day? The // anchor lives in `push_attempts` and is stamped for every // dispatched slot regardless of which channels delivered. It @@ -539,7 +561,9 @@ export async function handleReminderCheck( scheduleId: schedule.id, slotTime, phase: currentPhase, - localDate: localDateStr, + localDate: slotScheduledFor.toLocaleDateString("sv-SE", { + timeZone: userTz, + }), }); if ( @@ -651,7 +675,9 @@ export async function handleReminderCheck( medicationId: med.id, scheduleId: schedule.id, phase: currentPhase, - date: localDateStr, + date: slotScheduledFor.toLocaleDateString("sv-SE", { + timeZone: userTz, + }), // v1.7.0 SB-SCHED-4 — carry the dedup time-of-day so // the Telegram-message ledger keys per slot. timeOfDay: dedupTimeOfDay, From c0321d2f27f0d04c990178eacb32caafc5b9a8f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:31:01 +0200 Subject: [PATCH 062/125] test(01-15): add bounded performance fixture baseline - benchmark observed Google, reminder, and score workload shapes - record local cold and warm timing without production access --- .../01-PERFORMANCE-BASELINE.md | 67 ++++ scripts/v1341-performance-baseline.mjs | 343 ++++++++++++++++++ 2 files changed, 410 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md create mode 100644 scripts/v1341-performance-baseline.mjs diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md new file mode 100644 index 000000000..73cab0777 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md @@ -0,0 +1,67 @@ +# v1.34.1 Performance Baseline + +**Generated:** 2026-07-29T12:30:52.301Z +**Commit:** `c0af97ce4` +**Exit status:** PASS +**Mode:** deterministic local fixture only + +## Safety and interpretation + +- No network, database, provider, production, or staging endpoint was contacted. +- Fixtures contain generated counters only. The report emits no tokens, identifiers, payloads, health values, hostnames, usernames, or working-directory paths. +- Concurrency is fixed at one, warm sample count is 11, and the whole run has a 30-second deadline. +- Timing is a local code-shape baseline, not a production latency or capacity claim. Database, provider, network, framework, cache, and rendering costs remain unmeasured. +- The Health Score fixture exercises dense/sparse row construction, source canonicalization, and a stable composite proxy. It does not replace production algorithm parity or database-backed performance tests. + +## Environment + +| Attribute | Value | +| --- | --- | +| Node | v25.9.0 | +| Platform | darwin 25.3.0 | +| Architecture | arm64 | +| Logical CPUs | 14 | +| Process | single local Node process | +| Total harness duration | 100.435 ms | + +## Fixture scale + +| Path shape | Fixed scale | +| --- | --- | +| Google history | 786 serial pages × 32 generated rows; terminal page has no remaining token | +| Reminder cohort | 2000 medications × 4 slots plus 2000 cleanup records | +| Health Score | 42 dense days × 1440 samples plus 379 sparse days × 8 samples | + +## Measurements + +| Local path shape | Cold | Warm median | Warm p95 | Warm max | Cold heap delta | Max warm heap delta | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Google history pagination shape | 7.462 ms | 5.283 ms | 5.860 ms | 5.860 ms | 2.00 MiB | 6.19 MiB | +| Medication reminder cohort shape | 0.773 ms | 0.080 ms | 0.971 ms | 0.971 ms | 0.19 MiB | 0.07 MiB | +| Health Score read/canonicalization shape | 3.982 ms | 2.280 ms | 3.384 ms | 3.384 ms | 7.09 MiB | 7.30 MiB | + +### Determinism and bounded counters + +- **Google history pagination shape:** checksum `47f760c2`; pages=786; rows=25152; concurrency=1; finalTokenPresent=false. +- **Medication reminder cohort shape:** checksum `73e03617`; medications=2000; slots=8000; cleanupBacklog=2000; dueCandidates=1582; modeledQueries=7165; concurrency=1. +- **Health Score read/canonicalization shape:** checksum `5c395b1a`; denseRows=60480; sparseRows=3032; canonicalDays=42; domains=8; concurrency=1; parity=stable-checksum. + +## Route/path interpretation + +| Representative path | What this fixture measures | What it does not measure | +| --- | --- | --- | +| Google full-sync pagination | Serial JSON page materialization and accumulation at the observed 786-page shape | Provider latency, DB writes, token refresh, rollups, or a safe universal page ceiling | +| Medication reminder worker | Serial cohort/slot decisions, cleanup scan, and modeled query amplification | Prisma latency, locks, dispatch latency, or the live cohort size | +| Dashboard / analytics Health Score | Dense/sparse row processing, canonical selection, and stable composite proxy | SQL plans, cache behavior, exact production score parity, or user-visible route latency | + +## Hypotheses and unmeasured risks + +- **Hypothesis:** Serial provider latency and write statements dominate the Google full-sync wall time; this local CPU result neither confirms nor refutes that hypothesis. +- **Hypothesis:** Reminder query/dispatch amplification, not local loop cost, determines whether a high-water pass approaches its 15-minute cadence. +- **Hypothesis:** Cold database row materialization and canonicalization dominate dense-user Health Score work; production impact remains unconfirmed. +- **Unmeasured:** network and database tails, cache hit/miss ratios, cross-process invalidation, framework rendering, device/browser performance, and production cohort distributions. +- **Do not infer:** a provider defect, a safe page cap, a production regression, or permission to optimize from this report alone. + +## Reproduction + +`node scripts/v1341-performance-baseline.mjs --fixture --output .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md` diff --git a/scripts/v1341-performance-baseline.mjs b/scripts/v1341-performance-baseline.mjs new file mode 100644 index 000000000..0ed69da37 --- /dev/null +++ b/scripts/v1341-performance-baseline.mjs @@ -0,0 +1,343 @@ +#!/usr/bin/env node + +import { execFileSync } from "node:child_process"; +import { writeFileSync } from "node:fs"; +import { arch, cpus, platform, release } from "node:os"; +import { resolve } from "node:path"; +import { performance } from "node:perf_hooks"; + +const LIMITS = Object.freeze({ + deadlineMs: 30_000, + warmSamples: 11, + googlePages: 786, + googleRowsPerPage: 32, + reminderMedications: 2_000, + reminderSlotsPerMedication: 4, + reminderCleanupBacklog: 2_000, + healthDenseDays: 42, + healthSamplesPerDay: 1_440, + healthSparseDays: 379, + healthSparseSamplesPerDay: 8, +}); + +const args = process.argv.slice(2); +const fixtureMode = args.includes("--fixture"); +const outputIndex = args.indexOf("--output"); +const outputPath = + outputIndex >= 0 && args[outputIndex + 1] + ? resolve(args[outputIndex + 1]) + : null; + +function fail(message) { + console.error(`v1341-performance-baseline: ${message}`); + process.exit(2); +} + +if (!fixtureMode) { + fail("refusing to run without --fixture; live and production probes are unsupported"); +} +if (process.env.NODE_ENV === "production") { + fail("refusing to run with NODE_ENV=production"); +} +if (!outputPath) { + fail("--output is required"); +} +if (args.some((arg) => /^(--url|--host|--database|--token|--live)/.test(arg))) { + fail("network, database, credential, and live-target arguments are unsupported"); +} + +const startedAt = performance.now(); +const deadlineAt = startedAt + LIMITS.deadlineMs; + +function assertWithinDeadline() { + if (performance.now() > deadlineAt) { + throw new Error(`fixture deadline exceeded (${LIMITS.deadlineMs} ms)`); + } +} + +function seededNumber(index, salt) { + let value = (index + 1) ^ salt; + value = Math.imul(value ^ (value >>> 16), 0x45d9f3b); + value = Math.imul(value ^ (value >>> 16), 0x45d9f3b); + return (value ^ (value >>> 16)) >>> 0; +} + +function percentile(sorted, fraction) { + return sorted[Math.min(sorted.length - 1, Math.ceil(sorted.length * fraction) - 1)]; +} + +function formatMs(value) { + return value.toFixed(3); +} + +function formatBytes(value) { + const sign = value < 0 ? "-" : ""; + return `${sign}${(Math.abs(value) / 1024 / 1024).toFixed(2)} MiB`; +} + +function measure(operation) { + assertWithinDeadline(); + const beforeHeap = process.memoryUsage().heapUsed; + const before = performance.now(); + const result = operation(); + const elapsedMs = performance.now() - before; + const heapDeltaBytes = process.memoryUsage().heapUsed - beforeHeap; + assertWithinDeadline(); + return { elapsedMs, heapDeltaBytes, result }; +} + +function benchmark(name, operation) { + const cold = measure(operation); + const warm = []; + for (let index = 0; index < LIMITS.warmSamples; index += 1) { + warm.push(measure(operation)); + } + const elapsed = warm.map((sample) => sample.elapsedMs).sort((a, b) => a - b); + const heaps = warm.map((sample) => sample.heapDeltaBytes); + const checksums = new Set([cold.result.checksum, ...warm.map((sample) => sample.result.checksum)]); + if (checksums.size !== 1) { + throw new Error(`${name} fixture produced a non-deterministic checksum`); + } + return { + name, + coldMs: cold.elapsedMs, + coldHeapBytes: cold.heapDeltaBytes, + medianMs: percentile(elapsed, 0.5), + p95Ms: percentile(elapsed, 0.95), + maxMs: elapsed.at(-1), + maxWarmHeapBytes: Math.max(...heaps), + checksum: cold.result.checksum, + counters: cold.result.counters, + }; +} + +function googleHistoryFixture() { + const accumulated = []; + let checksum = 0; + let finalTokenPresent = false; + + for (let page = 0; page < LIMITS.googlePages; page += 1) { + const encodedPage = JSON.stringify({ + rows: Array.from({ length: LIMITS.googleRowsPerPage }, (_, row) => ({ + t: page * LIMITS.googleRowsPerPage + row, + v: seededNumber(page * LIMITS.googleRowsPerPage + row, 17) % 10_000, + })), + next: page + 1 < LIMITS.googlePages ? `fixture-page-${page + 1}` : null, + }); + const decodedPage = JSON.parse(encodedPage); + accumulated.push(...decodedPage.rows); + finalTokenPresent = decodedPage.next !== null; + } + + for (const row of accumulated) { + checksum = (checksum + row.t * 17 + row.v) >>> 0; + } + return { + checksum, + counters: { + pages: LIMITS.googlePages, + rows: accumulated.length, + concurrency: 1, + finalTokenPresent, + }, + }; +} + +function reminderCohortFixture() { + let checksum = 0; + let dueCandidates = 0; + let modeledQueries = 1; + + for (let cleanup = 0; cleanup < LIMITS.reminderCleanupBacklog; cleanup += 1) { + checksum = (checksum + seededNumber(cleanup, 31)) >>> 0; + } + + for ( + let medication = 0; + medication < LIMITS.reminderMedications; + medication += 1 + ) { + modeledQueries += 2; + for (let slot = 0; slot < LIMITS.reminderSlotsPerMedication; slot += 1) { + const phase = seededNumber( + medication * LIMITS.reminderSlotsPerMedication + slot, + 43, + ); + if (phase % 5 === 0) { + dueCandidates += 1; + modeledQueries += 2; + } + checksum = (checksum + (phase & 0xffff)) >>> 0; + } + } + return { + checksum, + counters: { + medications: LIMITS.reminderMedications, + slots: LIMITS.reminderMedications * LIMITS.reminderSlotsPerMedication, + cleanupBacklog: LIMITS.reminderCleanupBacklog, + dueCandidates, + modeledQueries, + concurrency: 1, + }, + }; +} + +function healthScoreFixture() { + const denseRows = Array.from( + { length: LIMITS.healthDenseDays * LIMITS.healthSamplesPerDay }, + (_, index) => ({ + day: Math.floor(index / LIMITS.healthSamplesPerDay), + source: index % 3, + value: seededNumber(index, 59) % 1_000, + }), + ); + const sparseRows = Array.from( + { length: LIMITS.healthSparseDays * LIMITS.healthSparseSamplesPerDay }, + (_, index) => ({ + day: Math.floor(index / LIMITS.healthSparseSamplesPerDay), + domain: index % 8, + value: seededNumber(index, 71) % 1_000, + }), + ); + + const dailyCanonical = new Map(); + for (const row of denseRows) { + const prior = dailyCanonical.get(row.day); + if (!prior || row.source < prior.source) dailyCanonical.set(row.day, row); + } + const domainTotals = new Array(8).fill(0); + const domainCounts = new Array(8).fill(0); + for (const row of sparseRows) { + domainTotals[row.domain] += row.value; + domainCounts[row.domain] += 1; + } + let checksum = 0; + for (const row of dailyCanonical.values()) checksum = (checksum + row.value) >>> 0; + for (let domain = 0; domain < domainTotals.length; domain += 1) { + const normalized = Math.round(domainTotals[domain] / domainCounts[domain]); + checksum = (checksum * 31 + normalized) >>> 0; + } + return { + checksum, + counters: { + denseRows: denseRows.length, + sparseRows: sparseRows.length, + canonicalDays: dailyCanonical.size, + domains: domainTotals.length, + concurrency: 1, + parity: "stable-checksum", + }, + }; +} + +function commitSha() { + try { + return execFileSync("git", ["rev-parse", "--short", "HEAD"], { + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }).trim(); + } catch { + return "unavailable"; + } +} + +function counterSummary(result) { + return Object.entries(result.counters) + .map(([key, value]) => `${key}=${value}`) + .join("; "); +} + +try { + const results = [ + benchmark("Google history pagination shape", googleHistoryFixture), + benchmark("Medication reminder cohort shape", reminderCohortFixture), + benchmark("Health Score read/canonicalization shape", healthScoreFixture), + ]; + const durationMs = performance.now() - startedAt; + const generatedAt = new Date().toISOString(); + const markdown = `# v1.34.1 Performance Baseline + +**Generated:** ${generatedAt} +**Commit:** \`${commitSha()}\` +**Exit status:** PASS +**Mode:** deterministic local fixture only + +## Safety and interpretation + +- No network, database, provider, production, or staging endpoint was contacted. +- Fixtures contain generated counters only. The report emits no tokens, identifiers, payloads, health values, hostnames, usernames, or working-directory paths. +- Concurrency is fixed at one, warm sample count is ${LIMITS.warmSamples}, and the whole run has a ${LIMITS.deadlineMs / 1000}-second deadline. +- Timing is a local code-shape baseline, not a production latency or capacity claim. Database, provider, network, framework, cache, and rendering costs remain unmeasured. +- The Health Score fixture exercises dense/sparse row construction, source canonicalization, and a stable composite proxy. It does not replace production algorithm parity or database-backed performance tests. + +## Environment + +| Attribute | Value | +| --- | --- | +| Node | ${process.version} | +| Platform | ${platform()} ${release()} | +| Architecture | ${arch()} | +| Logical CPUs | ${cpus().length} | +| Process | single local Node process | +| Total harness duration | ${formatMs(durationMs)} ms | + +## Fixture scale + +| Path shape | Fixed scale | +| --- | --- | +| Google history | ${LIMITS.googlePages} serial pages × ${LIMITS.googleRowsPerPage} generated rows; terminal page has no remaining token | +| Reminder cohort | ${LIMITS.reminderMedications} medications × ${LIMITS.reminderSlotsPerMedication} slots plus ${LIMITS.reminderCleanupBacklog} cleanup records | +| Health Score | ${LIMITS.healthDenseDays} dense days × ${LIMITS.healthSamplesPerDay} samples plus ${LIMITS.healthSparseDays} sparse days × ${LIMITS.healthSparseSamplesPerDay} samples | + +## Measurements + +| Local path shape | Cold | Warm median | Warm p95 | Warm max | Cold heap delta | Max warm heap delta | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +${results + .map( + (result) => + `| ${result.name} | ${formatMs(result.coldMs)} ms | ${formatMs(result.medianMs)} ms | ${formatMs(result.p95Ms)} ms | ${formatMs(result.maxMs)} ms | ${formatBytes(result.coldHeapBytes)} | ${formatBytes(result.maxWarmHeapBytes)} |`, + ) + .join("\n")} + +### Determinism and bounded counters + +${results + .map( + (result) => + `- **${result.name}:** checksum \`${result.checksum.toString(16).padStart(8, "0")}\`; ${counterSummary(result)}.`, + ) + .join("\n")} + +## Route/path interpretation + +| Representative path | What this fixture measures | What it does not measure | +| --- | --- | --- | +| Google full-sync pagination | Serial JSON page materialization and accumulation at the observed 786-page shape | Provider latency, DB writes, token refresh, rollups, or a safe universal page ceiling | +| Medication reminder worker | Serial cohort/slot decisions, cleanup scan, and modeled query amplification | Prisma latency, locks, dispatch latency, or the live cohort size | +| Dashboard / analytics Health Score | Dense/sparse row processing, canonical selection, and stable composite proxy | SQL plans, cache behavior, exact production score parity, or user-visible route latency | + +## Hypotheses and unmeasured risks + +- **Hypothesis:** Serial provider latency and write statements dominate the Google full-sync wall time; this local CPU result neither confirms nor refutes that hypothesis. +- **Hypothesis:** Reminder query/dispatch amplification, not local loop cost, determines whether a high-water pass approaches its 15-minute cadence. +- **Hypothesis:** Cold database row materialization and canonicalization dominate dense-user Health Score work; production impact remains unconfirmed. +- **Unmeasured:** network and database tails, cache hit/miss ratios, cross-process invalidation, framework rendering, device/browser performance, and production cohort distributions. +- **Do not infer:** a provider defect, a safe page cap, a production regression, or permission to optimize from this report alone. + +## Reproduction + +\`node scripts/v1341-performance-baseline.mjs --fixture --output .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md\` +`; + + writeFileSync(outputPath, markdown, { encoding: "utf8", mode: 0o600 }); + console.log( + `v1341-performance-baseline: PASS (${results.length} fixtures, ${formatMs(durationMs)} ms)`, + ); +} catch (error) { + console.error( + `v1341-performance-baseline: FAIL (${error instanceof Error ? error.message : "unknown error"})`, + ); + process.exit(1); +} From d0fbdd388d3d0042dce4fcbdd75fd776a68242ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:31:17 +0200 Subject: [PATCH 063/125] docs(01-16): complete presentation and records cleanup plan --- .../01-16-SUMMARY.md | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-16-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-16-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-16-SUMMARY.md new file mode 100644 index 000000000..b076cb6a8 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-16-SUMMARY.md @@ -0,0 +1,137 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "16" +subsystem: ui +tags: [react, vitest, dashboard, insights, records] + +requires: + - phase: 01-10 + provides: RED presentation and records interaction contracts +provides: + - Non-redundant dashboard, document-summary, and Health Score presentation + - Foreground treatment and compact spacing for generated assessment prose + - Consistent Save and first-entry CTA grammar across Records editors +affects: [01-20, 01-23, dashboard, insights, records] + +tech-stack: + added: [] + patterns: + - Displayed metric values are not repeated in adjacent generated prose + - Empty collections expose one first-entry CTA while populated collections expose the standard Add action + +key-files: + created: [] + modified: + - src/components/daily/today-hero.tsx + - src/components/documents/document-summary-block.tsx + - src/components/insights/health-score-card.tsx + - src/app/insights/medications/page.tsx + - src/components/insights/mood/mood-better-days.tsx + - src/components/records/health-profile-facts-manager.tsx + - src/components/records/allergy-manager.tsx + - src/components/records/family-history-manager.tsx + +key-decisions: + - "The Today Hero suppresses deterministic score prose while the score is provisional and removes only the sentence that repeats a displayed rounded score." + - "Health Score retains method version and source provenance while removing repeated method and composition prose." + - "Records collection Add actions are derived from row presence, leaving a single first-entry CTA in empty states." + +patterns-established: + - "Assessment hierarchy: generated assessment prose uses foreground text and compact vertical rhythm." + - "Action grammar: editable lifestyle facts consistently use Save; destructive and history controls remain unchanged." + +requirements-completed: [DASH-01, DOC-01, INS-01, INS-02, INS-03, INS-04, INS-05, INS-06, INS-07, REC-01, REC-02, REC-03, REC-04, REC-05] + +duration: 5min +completed: 2026-07-29 +--- + +# Phase 1 Plan 16: Dashboard, Insight, and Records Presentation Cleanup Summary + +**Non-redundant dashboard and insight prose with clearer assessment hierarchy and consistent Records action grammar** + +## Performance + +- **Duration:** 5 min +- **Started:** 2026-07-29T12:25:30Z +- **Completed:** 2026-07-29T12:30:21Z +- **Tasks:** 3 +- **Files modified:** 8 + +## Accomplishments + +- Removed repeated score prose from the Today Hero without fabricating a score while the ring remains provisional. +- Simplified stored document summaries and Health Score presentation while retaining regeneration for absent summaries and preserving score provenance. +- Normalized generated Health Score, medication, and mood assessment prose to foreground text with a tighter vertical rhythm. +- Unified lifestyle fact editing on Save and removed duplicate Add actions from empty allergy and family-history states. +- Kept removal, history, localization, API, and score-engine behavior unchanged. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Remove redundant dashboard, document, and Health Score presentation** - `d67b89913` (fix) +2. **Task 2: Normalize assessment foreground and spacing** - `c4ec2d552` (fix) +3. **Task 3: Unify Records Save and CTA grammar** - `431120d55` (fix) + +## Files Created/Modified + +- `src/components/daily/today-hero.tsx` - Removes the sentence that repeats the displayed score and avoids deterministic score prose during provisional scoring. +- `src/components/documents/document-summary-block.tsx` - Removes regeneration from the stored-summary branch while preserving absent-state generation. +- `src/components/insights/health-score-card.tsx` - Removes redundant notice, method, and composition prose and tightens the assessment stack. +- `src/app/insights/medications/page.tsx` - Promotes generated assessment prose to foreground text with compact spacing. +- `src/components/insights/mood/mood-better-days.tsx` - Promotes generated mood assessment prose to foreground text. +- `src/components/records/health-profile-facts-manager.tsx` - Uses Save consistently for lifestyle fact edits. +- `src/components/records/allergy-manager.tsx` - Shows the normal Add action only after the collection has rows. +- `src/components/records/family-history-manager.tsx` - Shows the normal Add action only after the collection has rows. + +## Decisions Made + +- Preserve any non-score sentences in the Today Hero generated lead; suppress the lead only when removing the repeated score sentence leaves no content. +- Retain the pure algorithm-notice dismissal helper because its existing contract remains useful, even though the notice is no longer rendered. +- Preserve method version, band selection, delta, pillar, and source provenance while removing only redundant Health Score explanatory prose. +- Base collection CTA visibility on row presence so empty states have exactly one entry point. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Added Mood Better Days to foreground normalization** + +- **Found during:** Task 2 +- **Issue:** The focused assessment-spine contract intentionally covers Mood Better Days, but the plan ownership list omitted its component and no other phase plan owned the change. +- **Fix:** Changed only the generated assessment description from muted foreground to foreground text. +- **Files modified:** `src/components/insights/mood/mood-better-days.tsx` +- **Commit:** `c4ec2d552` + +## Issues Encountered + +None. + +## Verification Evidence + +- Full focused Vitest gate: **8 files passed, 65 tests passed**. +- Task 1 Vitest gate: **4 files passed, 40 tests passed**. +- Task 2 Vitest gate: **2 files passed, 20 tests passed**. +- Task 3 Vitest gate: **3 files passed, 12 tests passed**. +- Focused ESLint, Prettier, and `git diff --check`: passed for every owned production file. +- Protected-scope comparison confirmed no Plan 16 changes under `src/app/api`, `messages/en.json`, `messages/de.json`, `src/lib/analytics/score`, or `src/components/insights/insight-status-card.tsx`. + +## User Setup Required + +None. + +## Next Phase Readiness + +- The dashboard, document-summary, Health Score, medication, mood, and Records interaction contracts from Plan 10 are green. +- Downstream dashboard and insight integration work can rely on a single presentation hierarchy without changing API or score-engine behavior. + +## Self-Check: PASSED + +- All eight owned production files and this summary exist. +- Task commits `d67b89913`, `c4ec2d552`, and `431120d55` exist on the current branch. +- Protected API, locale, score-engine, and shared insight-status targets remain unchanged by Plan 01-16. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From 2de9bccf9dbda94b89d5ca64bf2fcfb1f37d1a9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:33:17 +0200 Subject: [PATCH 064/125] fix(01-18): retain rolling occurrence through due band - preserve prior rolling identity independently of local-day flooring - scope exact resolution by schedule and era while advancing resolved grids - include open rolling slots for display without widening compliance bands --- src/lib/medications/scheduling/band-minter.ts | 25 ++++- src/lib/medications/scheduling/next-due.ts | 99 ++++++++++++++++--- src/lib/medications/scheduling/recurrence.ts | 73 ++++++++++---- 3 files changed, 163 insertions(+), 34 deletions(-) diff --git a/src/lib/medications/scheduling/band-minter.ts b/src/lib/medications/scheduling/band-minter.ts index 0acbc3e7a..b0ff3f352 100644 --- a/src/lib/medications/scheduling/band-minter.ts +++ b/src/lib/medications/scheduling/band-minter.ts @@ -112,6 +112,13 @@ export interface BuildBandsInput { * other cadence. Pre-fetched by the caller — the minter has no DB access. */ intakeInstants?: Date[]; + /** + * Include the single canonical unresolved rolling occurrence even before + * the compliance engine's half-cycle "genuinely missed" threshold. + * Display-due/reminder attribution opt in; retrospective compliance keeps + * its existing denominator semantics. + */ + includeOpenRollingOccurrence?: boolean; } export interface BandMinterResult { @@ -161,7 +168,7 @@ export function buildBandsForMedication( // Rolling (GLP-1) — retrospective bands anchored AT each logged intake. if (schedule.rollingIntervalDays !== null) { - const occ = expandRollingRetrospective( + const retrospective = expandRollingRetrospective( schedule, ctx, range.from, @@ -169,6 +176,20 @@ export function buildBandsForMedication( input.intakeInstants ?? [], now, ); + const open = input.includeOpenRollingOccurrence + ? occurrencesBetween(schedule, range.from, range.to, ctx) + : []; + const occ = [...retrospective]; + for (const candidate of open) { + if ( + !occ.some( + (existing) => existing.at.getTime() === candidate.at.getTime(), + ) + ) { + occ.push(candidate); + } + } + occ.sort((a, b) => a.at.getTime() - b.at.getTime()); // The canonical rolling case is a once-weekly+ injection (day-scale). A // degenerate ≤1-day rolling interval is effectively daily, so a ±1-day // on-time window would over-widen and let one take claim adjacent slots — @@ -211,6 +232,7 @@ export function buildBandsForSchedules(input: { windowConfig?: DoseWindowConfig; /** Per-medication intake instants (the rolling anchors). */ intakeInstants?: Date[]; + includeOpenRollingOccurrence?: boolean; }): ScheduleBandGroup[] { return input.schedules.map((schedule) => { const result = buildBandsForMedication({ @@ -222,6 +244,7 @@ export function buildBandsForSchedules(input: { now: input.now, windowConfig: input.windowConfig, intakeInstants: input.intakeInstants, + includeOpenRollingOccurrence: input.includeOpenRollingOccurrence, }); return { scheduleId: schedule.id, ...result }; }); diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index c38ae1ffb..16d5a6e53 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -16,7 +16,10 @@ import { type WorkerMedicationRow, type WorkerScheduleRow, } from "@/lib/medications/scheduling/worker-helpers"; -import { nextOccurrenceAfter } from "@/lib/medications/scheduling/recurrence"; +import { + advanceRollingOccurrence, + nextOccurrenceAfter, +} from "@/lib/medications/scheduling/recurrence"; import { buildBandsForMedication } from "@/lib/medications/scheduling/band-minter"; import { DOSE_WINDOW_DEFAULTS } from "@/lib/medications/scheduling/dose-window-defaults"; @@ -51,6 +54,12 @@ const ADHOC_RESOLVE_EPSILON_MS = 60 * 1000; export interface ResolvedSlotMark { at: Date; slotAnchored: boolean; + /** Canonical schedule owner when attribution could identify it. */ + scheduleId?: string; + /** When the action was made; used to isolate replacement schedule eras. */ + actionAt?: Date; + /** Diagnostic state carried by exact occurrence-aware callers. */ + status?: "taken" | "skipped" | "autoMissed"; } /** @@ -73,10 +82,21 @@ export function toResolvedSlotMark(row: { function buildIsResolved( resolved: ResolvedSlotMark[], + identity?: { scheduleId: string; eraStart?: Date | null }, ): (slotAt: Date) => boolean { return (slotAt: Date): boolean => { const slot = slotAt.getTime(); for (const r of resolved) { + if (r.scheduleId !== undefined && r.scheduleId !== identity?.scheduleId) { + continue; + } + if ( + identity?.eraStart && + r.actionAt && + r.actionAt.getTime() < identity.eraStart.getTime() + ) { + continue; + } const radius = r.slotAnchored ? RESOLVE_RADIUS_MS : ADHOC_RESOLVE_EPSILON_MS; @@ -118,29 +138,57 @@ export function computeNextDueAt(input: { * anchoring shape; ad-hoc rows only resolve on a near-exact match. */ resolvedSlots?: ResolvedSlotMark[]; + eraStart?: Date | null; }): Date | null { + return computeNextDueCandidate(input)?.at ?? null; +} + +interface DueCandidate { + at: Date; + scheduleId: string; +} + +function computeNextDueCandidate(input: { + medication: WorkerMedicationRow; + schedules: WorkerScheduleRow[]; + now: Date; + userTz: string; + lastIntakeAt: Date | null; + resolvedSlots?: ResolvedSlotMark[]; + eraStart?: Date | null; +}): DueCandidate | null { const { medication, schedules, now, userTz, lastIntakeAt } = input; if (schedules.length === 0) return null; - const isResolved = buildIsResolved(input.resolvedSlots ?? []); - const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); - let earliest: Date | null = null; + let earliest: DueCandidate | null = null; for (const schedule of schedules) { const canonical = buildCanonicalSchedule(schedule); + const isResolved = buildIsResolved(input.resolvedSlots ?? [], { + scheduleId: canonical.id, + eraStart: input.eraStart, + }); // Walk forward past slots the user has already resolved. Bounded so a // fully-logged-ahead history can't spin. let after = now; + let next = nextOccurrenceAfter(canonical, after, ctx); for (let step = 0; step < 64; step++) { - const next = nextOccurrenceAfter(canonical, after, ctx); if (next === null) break; if (!isResolved(next.at)) { - if (earliest === null || next.at.getTime() < earliest.getTime()) { - earliest = next.at; + if ( + earliest === null || + next.at.getTime() < earliest.at.getTime() + ) { + earliest = { at: next.at, scheduleId: canonical.id }; } break; } + if (canonical.rollingIntervalDays !== null) { + next = advanceRollingOccurrence(canonical, next, ctx); + continue; + } after = new Date(next.at.getTime()); + next = nextOccurrenceAfter(canonical, after, ctx); } } return earliest; @@ -149,6 +197,8 @@ export function computeNextDueAt(input: { /** The instant a medication card should surface, plus its canonical band state. */ export interface DisplayDue { at: Date; + /** Canonical owner of this occurrence (sibling schedules stay distinct). */ + scheduleId?: string; /** * Earliest instant at which the canonical attribution band accepts this * slot. This is cadence- and per-dose-window-aware. @@ -196,23 +246,27 @@ export function computeDisplayDue( ): DisplayDue | null { const open = findOpenOverdueSlot(input); if (open) return { ...open, overdue: true }; - const next = computeNextDueAt(input); + const next = computeNextDueCandidate(input); if (!next) return null; + // A rolling occurrence whose actionable band has closed remains the same + // unresolved identity until a real take re-anchors it. Do not relabel that + // stale past occurrence as a non-overdue "next" dose. + if (next.at.getTime() < input.now.getTime()) return null; return { - at: next, - availableFrom: findAvailabilityStart(input, next) ?? next, + at: next.at, + scheduleId: next.scheduleId, + availableFrom: + findAvailabilityStart(input, next.at, next.scheduleId) ?? next.at, overdue: false, }; } function findOpenOverdueSlot( input: ComputeDisplayDueInput, -): { at: Date; availableFrom: Date } | null { +): { at: Date; availableFrom: Date; scheduleId: string } | null { const { medication, schedules, now, userTz, lastIntakeAt } = input; if (schedules.length === 0) return null; - const isResolved = buildIsResolved(input.resolvedSlots ?? []); - let floor = new Date(now.getTime() - OVERDUE_LOOKBACK_MS); if (input.eraStart && input.eraStart.getTime() > floor.getTime()) { floor = input.eraStart; @@ -220,8 +274,16 @@ function findOpenOverdueSlot( if (floor.getTime() >= now.getTime()) return null; const ctx = buildRecurrenceContext({ medication, userTz, lastIntakeAt }); - let latest: { at: Date; availableFrom: Date } | null = null; + let latest: { + at: Date; + availableFrom: Date; + scheduleId: string; + } | null = null; for (const schedule of schedules) { + const isResolved = buildIsResolved(input.resolvedSlots ?? [], { + scheduleId: schedule.id, + eraStart: input.eraStart, + }); const { bands } = buildBandsForMedication({ medication, schedule: buildCanonicalSchedule(schedule), @@ -230,6 +292,7 @@ function findOpenOverdueSlot( range: { from: floor, to: now }, now, intakeInstants: lastIntakeAt ? [lastIntakeAt] : [], + includeOpenRollingOccurrence: true, }); for (const band of bands) { const anchor = band.at.getTime(); @@ -247,7 +310,11 @@ function findOpenOverdueSlot( (anchor === latest.at.getTime() && band.earlyStart.getTime() < latest.availableFrom.getTime()) ) { - latest = { at: band.at, availableFrom: band.earlyStart }; + latest = { + at: band.at, + availableFrom: band.earlyStart, + scheduleId: schedule.id, + }; } } } @@ -257,6 +324,7 @@ function findOpenOverdueSlot( function findAvailabilityStart( input: ComputeDisplayDueInput, at: Date, + scheduleId?: string, ): Date | null { const ctx = buildRecurrenceContext({ medication: input.medication, @@ -267,6 +335,7 @@ function findAvailabilityStart( let earliest: Date | null = null; for (const scheduleRow of input.schedules) { + if (scheduleId !== undefined && scheduleRow.id !== scheduleId) continue; const schedule = buildCanonicalSchedule(scheduleRow); const intakeInstants = schedule.rollingIntervalDays === null diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index f05322d56..f5236ffab 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -150,6 +150,8 @@ export interface RecurrenceContext { } export interface Occurrence { + /** Canonical schedule owner; part of the occurrence identity. */ + scheduleId: string; /** * The start instant of this occurrence. The `timeOfDay` is applied * to the day in the user's timezone, with DST handled correctly via @@ -306,18 +308,17 @@ export function nextOccurrenceAfter( // of its due day instead of silently rolling it forward by N. if (schedule.rollingIntervalDays !== null) { const dayFloor = startOfLocalDayInTz(after, ctx.timeZone); - // With no intake logged the first dose anchors at `startsOn ?? createdAt`. - // When that anchor is on a PRIOR calendar day the dose is overdue by ≥1 - // day and must still surface ("take now"); flooring only to the start of - // the user's current day dropped it out of existence (null next-due AND - // no reminder). Reach the floor back to the first-dose instant in that - // case so a multi-day-overdue start dose is returned. After an intake the - // re-anchor (`lastIntakeAt + N`) is always in the future, so the dayFloor - // is the correct, tighter bound there. - const floor = - ctx.lastIntakeAt === null - ? new Date(Math.min(dayFloor.getTime(), firstRollingDoseDayFloor(ctx))) - : dayFloor; + // The immediately-next rolling occurrence is an identity, not "whatever + // falls on the worker's current day". Keep its local day in the search + // even after midnight; display/band callers decide whether its actionable + // tail is still open. Flooring only to today created a gap where an + // unresolved weekly dose vanished on the morning after it was due. + const floor = new Date( + Math.min( + dayFloor.getTime(), + rollingDoseDayFloor(schedule, ctx), + ), + ); const slots = expandRolling(schedule, ctx, floor, limit); for (const s of slots) { if (s.at.getTime() < floor.getTime()) continue; @@ -434,8 +435,21 @@ function expandOneShot( * to the anchor's start-of-day keeps the time-of-day slot (e.g. 08:00) within * the window rather than ahead of a raw-instant floor. */ -function firstRollingDoseDayFloor(ctx: RecurrenceContext): number { - const anchor = ctx.medication.startsOn ?? ctx.medication.createdAt; +function rollingDoseAnchor( + schedule: CanonicalSchedule, + ctx: RecurrenceContext, +): Date { + const intervalDays = schedule.rollingIntervalDays ?? 0; + return ctx.lastIntakeAt !== null + ? new Date(ctx.lastIntakeAt.getTime() + intervalDays * DAY_MS) + : (ctx.medication.startsOn ?? ctx.medication.createdAt); +} + +function rollingDoseDayFloor( + schedule: CanonicalSchedule, + ctx: RecurrenceContext, +): number { + const anchor = rollingDoseAnchor(schedule, ctx); return startOfLocalDayInTz(anchor, ctx.timeZone).getTime(); } @@ -450,10 +464,7 @@ function expandRolling( // After an intake the next dose is N days out. With no intake yet the // FIRST dose is the start date itself (no `+ N`) — see the doc comment. - const nextDue = - ctx.lastIntakeAt !== null - ? new Date(ctx.lastIntakeAt.getTime() + n * DAY_MS) - : (ctx.medication.startsOn ?? ctx.medication.createdAt); + const nextDue = rollingDoseAnchor(schedule, ctx); // endsOn cap. if ( @@ -470,6 +481,31 @@ function expandRolling( return [buildOccurrence(at, time, schedule)]; } +/** + * Advance from one canonical rolling occurrence to the next grid occurrence + * without pretending a skip/auto-miss was a new intake anchor. Used only after + * the current exact occurrence is resolved; unresolved recurrence remains the + * single `lastIntakeAt + N` slot. + */ +export function advanceRollingOccurrence( + schedule: CanonicalSchedule, + occurrence: Occurrence, + ctx: RecurrenceContext, +): Occurrence | null { + const n = schedule.rollingIntervalDays; + if (n === null || n <= 0) return null; + const time = schedule.timesOfDay[0] ?? schedule.windowStart; + const nextDay = new Date(occurrence.at.getTime() + n * DAY_MS); + const at = applyTimeOfDayToDate(nextDay, time, ctx.timeZone); + if ( + ctx.medication.endsOn && + at.getTime() > endOfUtcDay(ctx.medication.endsOn).getTime() + ) { + return null; + } + return buildOccurrence(at, time, schedule); +} + /** * v1.13.x compliance — RETROSPECTIVE rolling expansion. * @@ -803,6 +839,7 @@ function buildOccurrence( schedule: CanonicalSchedule, ): Occurrence { return { + scheduleId: schedule.id, at, timeOfDay, graceUntil: new Date(at.getTime() + graceWindowMs(schedule)), From 840e47fda5a5d32cbcf9b3988758392321dd4671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:35:23 +0200 Subject: [PATCH 065/125] fix(01-18): preserve authoritative rolling due in consumers - expose the canonical due schedule identity from the shared list read - prevent passed local windows from rebinding a prior overdue occurrence --- src/lib/medications/list-read.ts | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 8f9889f75..73f38c548 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -222,6 +222,23 @@ export async function buildMedicationsList( resolvedSlots: resolvedSlotsByMedId.get(m.id) ?? [], eraStart: eraStartByMedId.get(m.id) ?? null, }); + const displaySchedule = + display?.scheduleId === undefined + ? null + : m.schedules.find((schedule) => schedule.id === display.scheduleId) ?? + null; + // A prior-day rolling occurrence can remain the one authoritative + // actionable dose while today's same HH:mm window has already passed. + // The client window reducer uses this count to decide whether a passed + // local clock window is uncovered. Account for exactly that one rolling + // window here so GLP-1/take-all keep the server's prior scheduledFor + // instead of rebinding it onto today's date. Other sibling windows remain + // uncovered because the adjustment is only +1. + const carriesPriorRollingOccurrence = + display?.overdue === true && + display.at.getTime() < todayStartUtc.getTime() && + displaySchedule?.rollingIntervalDays !== null && + displaySchedule?.rollingIntervalDays !== undefined; // v1.16.10 — dose-derived stock for the table view. NULL when the // medication has no inventory items at all (tracking off); 0 when // tracking is on but every container is used up / expired. @@ -245,9 +262,12 @@ export async function buildMedicationsList( // medication. externalSource: m.externalSource ?? null, lastTakenAt: lastTakenAtByMedicationId[m.id] ?? null, - todayEventCount: todayEventCountByMedId[m.id] ?? 0, + todayEventCount: + (todayEventCountByMedId[m.id] ?? 0) + + (carriesPriorRollingOccurrence ? 1 : 0), nextDueAt: display ? display.at.toISOString() : null, nextDueOverdue: display?.overdue ?? false, + nextDueScheduleId: display?.scheduleId ?? null, stockUnitsRemaining, stockDosesRemaining, }; From 23c738efacf02e05b95b3802dd7e5c7aa3f5d8b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:36:18 +0200 Subject: [PATCH 066/125] fix(01-17): align and pin desktop sidebars - place both sidebars on the heading row with one main scroll owner - bound short-viewport sidebar content without horizontal layout drift --- src/components/admin/admin-shell.tsx | 27 ++++++--- src/components/settings/settings-shell.tsx | 69 +++++++++++++--------- 2 files changed, 62 insertions(+), 34 deletions(-) diff --git a/src/components/admin/admin-shell.tsx b/src/components/admin/admin-shell.tsx index 5fe4ab59c..9ba3cebad 100644 --- a/src/components/admin/admin-shell.tsx +++ b/src/components/admin/admin-shell.tsx @@ -253,9 +253,19 @@ export function AdminShell({ active, children }: AdminShellProps) { ? t("admin.subtitle") : t(`admin.section.${activeSlug}.subtitle`); - const headingBlock = ( + const headingBlock = (mobile = false) => (
    -

    {headingTitle}

    + {mobile ? ( +
    + {headingTitle} +
    + ) : ( +

    {headingTitle}

    + )}

    {headingSubtitle}

    ); @@ -269,7 +279,7 @@ export function AdminShell({ active, children }: AdminShellProps) {
    {/* v1.18.6.1 — heading above the chip strip on mobile; on desktop it renders inside the grid (row 1 / content column). */} -
    {headingBlock}
    +
    {headingBlock(true)}
    {/* Mobile section strip — horizontal scroll, hidden on md+. `no-scrollbar` (defined in `globals.css`) suppresses the @@ -359,10 +369,13 @@ export function AdminShell({ active, children }: AdminShellProps) { > {/* Heading — desktop only (mobile renders it above the strip). */}
    - {headingBlock} + {headingBlock()}
    - {/* Desktop sticky sidebar — starts at the cards row (row 2). + {/* Desktop sticky sidebar — starts on the heading row and spans the + two desktop rows. Its top therefore shares the heading's grid + line while the content column keeps the natural heading-to-card + gap in row 2. The sticky lives on the `
    ); diff --git a/src/components/settings/settings-shell.tsx b/src/components/settings/settings-shell.tsx index 287bcad7b..2c5cafc26 100644 --- a/src/components/settings/settings-shell.tsx +++ b/src/components/settings/settings-shell.tsx @@ -476,29 +476,41 @@ export function SettingsShell({ } : null); - const headingBlock = resolvedHeading ? ( -
    - {resolvedHeading.topSlot ?
    {resolvedHeading.topSlot}
    : null} -
    -
    -

    - {resolvedHeading.title} -

    - {resolvedHeading.subtitle ? ( -

    - {resolvedHeading.subtitle} -

    + const headingBlock = (mobile = false) => + resolvedHeading ? ( +
    + {resolvedHeading.topSlot ?
    {resolvedHeading.topSlot}
    : null} +
    +
    + {mobile ? ( +
    + {resolvedHeading.title} +
    + ) : ( +

    + {resolvedHeading.title} +

    + )} + {resolvedHeading.subtitle ? ( +

    + {resolvedHeading.subtitle} +

    + ) : null} +
    + {resolvedHeading.headingAccessory ? ( +
    {resolvedHeading.headingAccessory}
    ) : null}
    - {resolvedHeading.headingAccessory ? ( -
    {resolvedHeading.headingAccessory}
    - ) : null}
    -
    - ) : null; + ) : null; // v1.4.25 W8 — AuthShell wraps the page in `px-4 py-6 md:px-6` // already, so this inner shell only carries the wider max-width. @@ -510,8 +522,8 @@ export function SettingsShell({ {/* v1.18.6.1 — on mobile the heading sits above the chip strip; on desktop it is rendered inside the grid (row 1 / content column) so it does not paint twice. */} - {headingBlock ? ( -
    {headingBlock}
    + {resolvedHeading ? ( +
    {headingBlock(true)}
    ) : null} {/* Mobile section strip — horizontal scroll, hidden on md+. @@ -588,13 +600,16 @@ export function SettingsShell({ )} > {/* Heading — desktop only here (mobile renders it above the strip). */} - {headingBlock ? ( + {resolvedHeading ? (
    - {headingBlock} + {headingBlock()}
    ) : null} - {/* Desktop sticky sidebar — starts at the cards row (row 2). + {/* Desktop sticky sidebar — starts on the heading row and spans the + two desktop rows. Its top therefore shares the heading's grid + line while the content column keeps the natural heading-to-card + gap in row 2. The sticky lives on the `
    ); From 0ae9165aaef3cd8a8f418dc37e7c9366bf955612 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:36:21 +0200 Subject: [PATCH 067/125] refactor(01-18): format occurrence identity changes - apply repository formatting to reminder and scheduling updates - preserve verified occurrence behavior without semantic changes --- src/lib/jobs/reminder/medication-reminder-check.ts | 13 ++++--------- src/lib/medications/list-read.ts | 4 ++-- src/lib/medications/scheduling/next-due.ts | 5 +---- src/lib/medications/scheduling/recurrence.ts | 5 +---- 4 files changed, 8 insertions(+), 19 deletions(-) diff --git a/src/lib/jobs/reminder/medication-reminder-check.ts b/src/lib/jobs/reminder/medication-reminder-check.ts index 01f365ce0..7cd887cac 100644 --- a/src/lib/jobs/reminder/medication-reminder-check.ts +++ b/src/lib/jobs/reminder/medication-reminder-check.ts @@ -458,8 +458,7 @@ export async function handleReminderCheck( const minutesToEnd = (phaseWindow.end.getTime() - nowMs) / 60_000; const windowDuration = - (phaseWindow.end.getTime() - - phaseWindow.start.getTime()) / + (phaseWindow.end.getTime() - phaseWindow.start.getTime()) / 60_000; const thresholds = resolvePhaseThresholds( phaseConfig, @@ -481,16 +480,12 @@ export async function handleReminderCheck( .filter((candidate) => candidate !== null) .sort( (a, b) => - b.slotScheduledFor.getTime() - - a.slotScheduledFor.getTime(), + b.slotScheduledFor.getTime() - a.slotScheduledFor.getTime(), )[0]; if (!phaseCandidate) continue; - const { - slotScheduledFor, - minutesToEnd, - currentPhase, - } = phaseCandidate; + const { slotScheduledFor, minutesToEnd, currentPhase } = + phaseCandidate; const slotInstant = slotScheduledFor.getTime(); if (liveEraStart !== null && slotInstant < liveEraStart) { continue; diff --git a/src/lib/medications/list-read.ts b/src/lib/medications/list-read.ts index 73f38c548..2e22cba9d 100644 --- a/src/lib/medications/list-read.ts +++ b/src/lib/medications/list-read.ts @@ -225,8 +225,8 @@ export async function buildMedicationsList( const displaySchedule = display?.scheduleId === undefined ? null - : m.schedules.find((schedule) => schedule.id === display.scheduleId) ?? - null; + : (m.schedules.find((schedule) => schedule.id === display.scheduleId) ?? + null); // A prior-day rolling occurrence can remain the one authoritative // actionable dose while today's same HH:mm window has already passed. // The client window reducer uses this count to decide whether a passed diff --git a/src/lib/medications/scheduling/next-due.ts b/src/lib/medications/scheduling/next-due.ts index 16d5a6e53..05994eeff 100644 --- a/src/lib/medications/scheduling/next-due.ts +++ b/src/lib/medications/scheduling/next-due.ts @@ -175,10 +175,7 @@ function computeNextDueCandidate(input: { for (let step = 0; step < 64; step++) { if (next === null) break; if (!isResolved(next.at)) { - if ( - earliest === null || - next.at.getTime() < earliest.at.getTime() - ) { + if (earliest === null || next.at.getTime() < earliest.at.getTime()) { earliest = { at: next.at, scheduleId: canonical.id }; } break; diff --git a/src/lib/medications/scheduling/recurrence.ts b/src/lib/medications/scheduling/recurrence.ts index f5236ffab..f8735213d 100644 --- a/src/lib/medications/scheduling/recurrence.ts +++ b/src/lib/medications/scheduling/recurrence.ts @@ -314,10 +314,7 @@ export function nextOccurrenceAfter( // tail is still open. Flooring only to today created a gap where an // unresolved weekly dose vanished on the morning after it was due. const floor = new Date( - Math.min( - dayFloor.getTime(), - rollingDoseDayFloor(schedule, ctx), - ), + Math.min(dayFloor.getTime(), rollingDoseDayFloor(schedule, ctx)), ); const slots = expandRolling(schedule, ctx, floor, limit); for (const s of slots) { From efe203212a7269c2bd3d16f011985dd888e35679 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:36:29 +0200 Subject: [PATCH 068/125] fix(01-20): classify safe assessment outcomes - add a discriminated generated and fallback contract - screen derived prose and expose retryable cache policy --- .../insights/derived/derived-assessment-ai.ts | 100 ++++++++++++++---- src/lib/insights/status-cache.ts | 74 +++++++++++-- src/lib/insights/status-shared.ts | 89 ++++++++++++++-- 3 files changed, 226 insertions(+), 37 deletions(-) diff --git a/src/lib/insights/derived/derived-assessment-ai.ts b/src/lib/insights/derived/derived-assessment-ai.ts index f17eba298..82bb9c24b 100644 --- a/src/lib/insights/derived/derived-assessment-ai.ts +++ b/src/lib/insights/derived/derived-assessment-ai.ts @@ -31,10 +31,10 @@ import { withOutputLanguage, } from "@/lib/ai/prompts/output-language"; import { - extractAssessmentSummary, + finalizeStatusSummary, normalizeLocale, - normalizeSummaryText, persistStatusInsight, + type AssessmentStatus, } from "@/lib/insights/status-shared"; import { runStatusCompletion } from "@/lib/insights/status-provider"; import { @@ -227,11 +227,19 @@ export async function generateDerivedScoreAssessment(args: { derived: Derived; locale: string | null | undefined; now?: Date; -}): Promise { +}): Promise { const now = args.now ?? new Date(); const locale = normalizeLocale(args.locale); - if (!isAssessableDerivedScore(args.metric)) return; - if (args.derived.status !== "ok") return; + if (!isAssessableDerivedScore(args.metric)) return null; + if (args.derived.status !== "ok") return null; + + const deterministic = resolveDeterministicAssessment( + args.metric, + args.derived, + instructionLocale(locale), + now, + ); + if (!deterministic) return null; // The deterministic signal labels ship de/en templates only; route them // through the same instruction-body rule rather than a German default. @@ -240,7 +248,7 @@ export async function generateDerivedScoreAssessment(args: { args.derived.value, instructionLocale(locale), ); - if (!signal) return; + if (!signal) return null; const band = (args.derived.value as { band?: string }).band ?? "yellow"; const scope = derivedScoreScope(args.metric); @@ -280,7 +288,13 @@ export async function generateDerivedScoreAssessment(args: { action: { name: "insights.derived-assessment.input_unchanged" }, meta: { metric: args.metric }, }); - return; + return { + kind: "generated", + text: unchangedInput.text, + hasProvider: true, + updatedAt: unchangedInput.updatedAt, + retryable: false, + }; } const outcome = await runStatusCompletion({ @@ -308,23 +322,53 @@ export async function generateDerivedScoreAssessment(args: { action: { name: "insights.derived-assessment.skip" }, meta: { metric: args.metric, reason: outcome.kind }, }); - return; + if (outcome.kind === "none") { + return { + kind: "no-provider", + text: null, + hasProvider: false, + updatedAt: null, + retryable: false, + }; + } + return { + kind: outcome.kind, + text: null, + hasProvider: true, + updatedAt: null, + retryable: true, + }; } - // A malformed / truncated / summary-less structured envelope must never - // persist as the derived-score assessment (the raw-JSON class of bug). On an - // unparseable envelope persist nothing — the route keeps serving the - // always-grounded deterministic fallback, so the field stays honest. - const extracted = extractAssessmentSummary(outcome.content); - if (extracted.kind === "unparseable") { + // Parse and screen through the same fail-closed chokepoint as every other + // assessment. The prior derived-only path parsed the envelope but skipped + // the causal/dose/fabrication screen, allowing blocked prose to persist. + const finalized = finalizeStatusSummary(outcome.content, locale); + if (!finalized.ok) { annotate({ - action: { name: "insights.derived-assessment.unparseable" }, - meta: { metric: args.metric }, + action: { name: "insights.derived-assessment.screened" }, + meta: { metric: args.metric, reason: finalized.reason }, }); - return; + return { + kind: "screened-fallback", + text: deterministic.text, + hasProvider: true, + updatedAt: null, + retryable: true, + reason: finalized.reason, + }; + } + const text = finalized.text; + if (!text) { + return { + kind: "screened-fallback", + text: deterministic.text, + hasProvider: true, + updatedAt: null, + retryable: true, + reason: "unparseable_output", + }; } - const text = normalizeSummaryText(extracted.text); - if (!text) return; // v1.22 (W6) — score number-grounding gate. The only numbers the prose may // state are the score and its contributor values. On a miss we do NOT persist @@ -341,10 +385,17 @@ export async function generateDerivedScoreAssessment(args: { sample: ungrounded[0]?.source, }, }); - return; + return { + kind: "screened-fallback", + text: deterministic.text, + hasProvider: true, + updatedAt: null, + retryable: true, + reason: "ungrounded_output", + }; } - await persistStatusInsight({ + const updatedAt = await persistStatusInsight({ userId: args.userId, cacheAction, todayKey, @@ -361,4 +412,11 @@ export async function generateDerivedScoreAssessment(args: { action: { name: "insights.derived-assessment.warmed" }, meta: { metric: args.metric, provider: outcome.providerType }, }); + return { + kind: "generated", + text, + hasProvider: true, + updatedAt, + retryable: false, + }; } diff --git a/src/lib/insights/status-cache.ts b/src/lib/insights/status-cache.ts index e41af01ac..7c629f6c2 100644 --- a/src/lib/insights/status-cache.ts +++ b/src/lib/insights/status-cache.ts @@ -88,6 +88,14 @@ interface ParsedStatusCache { timeout?: boolean; /** v1.8.3 — ISO timestamp before which a timeout stub suppresses re-enqueue. */ retryAt?: string; + /** Explicit generated/negative cache classification for new rows. */ + statusKind?: "generated" | "negative"; + /** Whether the producer may be retried at the time this row is read. */ + retryable?: boolean; + /** Generated rows expire once the caller's current day differs. */ + expiresAfterDateKey?: string; + /** Stable non-sensitive negative-cache reason code. */ + reason?: "timeout" | "error" | "screened"; /** v1.16.8 — fingerprint of the data snapshot the text was generated from. */ snapshotHash?: string; /** v1.18.11 (P6) — cheap fingerprint of the salient inputs (count + newest). */ @@ -107,8 +115,11 @@ export function isTimeoutStub(parsed: { } export interface FreshStatusCacheHit { + kind: "generated"; text: string; updatedAt: string; + retryable: false; + expiresAfterDateKey: string; } /** @@ -145,8 +156,11 @@ export async function readFreshStatusText(args: { return null; } return { + kind: "generated", text: parsed.text, updatedAt: latestCache.createdAt.toISOString(), + retryable: false, + expiresAfterDateKey: parsed.expiresAfterDateKey ?? parsed.dateKey, }; } catch { // Malformed cache payload — treat as a miss and regenerate. @@ -230,7 +244,13 @@ export async function refreshUnchangedStatusInsight(args: { action: { name: "insights.status.skipped_unchanged" }, meta: { cache_action: args.cacheAction }, }); - return { text: parsed.text, updatedAt: created.createdAt.toISOString() }; + return { + kind: "generated", + text: parsed.text, + updatedAt: created.createdAt.toISOString(), + retryable: false, + expiresAfterDateKey: args.todayKey, + }; } /** @@ -433,7 +453,13 @@ export async function gateUnchangedStatusInput(args: { action: { name: "insights.status.skipped_unchanged" }, meta: { cache_action: args.cacheAction, gate: "input" }, }); - return { text: parsed.text, updatedAt: created.createdAt.toISOString() }; + return { + kind: "generated", + text: parsed.text, + updatedAt: created.createdAt.toISOString(), + retryable: false, + expiresAfterDateKey: args.todayKey, + }; } export interface LastGoodStatusHit { @@ -566,7 +592,11 @@ export async function resolveReadOnlyStatusMiss(args: { // navigation while the provider is still degraded would be a storm. While // the stub is fresh, stay in `preparing` without enqueuing; once it goes // stale (or never existed) enqueue a fresh generation. - if (await hasFreshTimeoutStub({ userId: args.userId, cacheAction })) { + const negativeCache = await readStatusNegativeCache({ + userId: args.userId, + cacheAction, + }); + if (negativeCache && !negativeCache.retryable) { annotate({ action: { name: "insights.status.preparing" }, meta: { metric: args.metric, suppressed_enqueue: true }, @@ -602,22 +632,46 @@ export async function resolveReadOnlyStatusMiss(args: { * degraded. A stub without `retryAt` (legacy) is treated as stale so the * resolver retries, matching the pre-v1.8.3 "transient miss" behaviour. */ -async function hasFreshTimeoutStub(args: { +export interface StatusNegativeCache { + kind: "negative"; + reason: "timeout" | "error" | "screened"; + retryAt: string; + retryable: boolean; +} + +/** + * Read the latest negative-cache policy without exposing provider errors or + * assessment content. `retryable` flips only when the explicit `retryAt` + * boundary has passed; legacy/malformed stubs are treated as absent so the + * next request may recover. + */ +export async function readStatusNegativeCache(args: { userId: string; cacheAction: string; -}): Promise { +}): Promise { const latest = await prisma.auditLog.findFirst({ where: { userId: args.userId, action: args.cacheAction }, orderBy: { createdAt: "desc" }, select: { details: true }, }); - if (!latest?.details) return false; + if (!latest?.details) return null; try { const parsed = JSON.parse(latest.details) as ParsedStatusCache; - if (!isTimeoutStub(parsed)) return false; - if (typeof parsed.retryAt !== "string") return false; - return new Date(parsed.retryAt).getTime() > Date.now(); + if (!isTimeoutStub(parsed)) return null; + if (typeof parsed.retryAt !== "string") return null; + const retryAtMs = new Date(parsed.retryAt).getTime(); + if (!Number.isFinite(retryAtMs)) return null; + const reason = + parsed.reason === "error" || parsed.reason === "screened" + ? parsed.reason + : "timeout"; + return { + kind: "negative", + reason, + retryAt: parsed.retryAt, + retryable: retryAtMs <= Date.now(), + }; } catch { - return false; + return null; } } diff --git a/src/lib/insights/status-shared.ts b/src/lib/insights/status-shared.ts index 3bda3ab05..eab2ccf40 100644 --- a/src/lib/insights/status-shared.ts +++ b/src/lib/insights/status-shared.ts @@ -37,6 +37,65 @@ export type SupportedLocale = Locale; const SUPPORTED_LOCALES: ReadonlySet = new Set(locales); +/** + * The presentation contract shared by assessment producers and consumers. + * + * `kind` is the only branch discriminator. In particular, provider + * availability is metadata: it must never hide safe deterministic text. + * Provider/error details deliberately do not belong in this envelope. + */ +export type AssessmentStatus = + | { + kind: "generated"; + text: string; + hasProvider: true; + updatedAt: string | null; + retryable: false; + } + | { + kind: "screened-fallback"; + text: string; + hasProvider: boolean; + updatedAt: null; + retryable: true; + reason: OutboundReason | "unparseable_output" | "ungrounded_output"; + } + | { + kind: "no-provider"; + text: null; + hasProvider: false; + updatedAt: null; + retryable: false; + } + | { + kind: "preparing"; + text: null; + hasProvider: true; + updatedAt: null; + retryable: true; + } + | { + kind: "revalidating"; + text: string; + hasProvider: true; + updatedAt: string | null; + retryable: true; + } + | { + kind: "timeout" | "error"; + text: null; + hasProvider: true; + updatedAt: null; + retryable: true; + } + | { + kind: "exhausted"; + text: null; + hasProvider: true; + updatedAt: null; + retryable: false; + }; + /** Round to `digits` decimal places. */ export function round(value: number, digits = 1): number { const factor = 10 ** digits; @@ -305,6 +364,12 @@ export async function persistStatusInsight(args: { providerType: args.providerType, model: args.model, tokensUsed: args.tokensUsed, + statusKind: "generated", + retryable: false, + // Freshness is defined by the user's calendar day. Keeping the + // boundary in the payload makes expiry explicit without inventing a + // UTC timestamp for a day key that belongs to another timezone. + expiresAfterDateKey: args.todayKey, ...(args.snapshotHash ? { snapshotHash: args.snapshotHash } : {}), ...(args.inputHash ? { inputHash: args.inputHash } : {}), }), @@ -320,8 +385,12 @@ export async function persistStatusInsight(args: { * withholds. */ export type StatusSummaryOutcome = - | { ok: true; text: string } - | { ok: false; reason: OutboundReason | "unparseable_output" }; + | { ok: true; kind: "generated"; text: string } + | { + ok: false; + kind: "screened-fallback"; + reason: OutboundReason | "unparseable_output"; + }; /** * The single finalize step every per-metric / biomarker / derived-score status @@ -359,13 +428,21 @@ export function finalizeStatusSummary( // text to render. WITHHOLD — the caller serves its deterministic stub. Only // the `unparseable` discriminator is logged, never the assessment content. if (extracted.kind === "unparseable") { - return { ok: false, reason: "unparseable_output" }; + return { + ok: false, + kind: "screened-fallback", + reason: "unparseable_output", + }; } const text = normalizeSummaryText(extracted.text); - if (!text) return { ok: true, text: "" }; + if (!text) return { ok: true, kind: "generated", text: "" }; const decision = screenModelOutput(text, locale, INSIGHTS_CONTRACTS); if (decision.block && decision.reason) { - return { ok: false, reason: decision.reason }; + return { + ok: false, + kind: "screened-fallback", + reason: decision.reason, + }; } - return { ok: true, text }; + return { ok: true, kind: "generated", text }; } From f3c5ded6872dbb13e36ac22be8c8b9e767f2a803 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:38:05 +0200 Subject: [PATCH 069/125] docs(01-18): complete occurrence identity implementation - document reminder and rolling due fixes across shared consumers - record green unit and real PostgreSQL regression coverage --- .../01-18-SUMMARY.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-18-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-18-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-18-SUMMARY.md new file mode 100644 index 000000000..3d4bfc711 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-18-SUMMARY.md @@ -0,0 +1,130 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "18" +subsystem: medication-scheduling +tags: [medications, recurrence, reminders, occurrence-identity, postgres] + +requires: + - phase: 01-11 + provides: RED reminder and rolling-overdue occurrence contracts +provides: + - Cross-midnight reminder lookup and dedup keyed to the canonical scheduled occurrence + - Rolling prior-occurrence projection bounded by the existing actionable band + - Schedule/era-aware exact resolution with retry-safe shared consumer state +affects: [issue-664, medication-reminders, rolling-recurrence, medication-list, dashboard] + +tech-stack: + added: [] + patterns: + - canonical occurrence identity carries schedule ID plus exact scheduled instant + - display-band projection is separate from retrospective compliance thresholds + - server list state prevents client clock-window rebinding + +key-files: + created: [] + modified: + - src/lib/jobs/reminder/medication-reminder-check.ts + - src/lib/medications/scheduling/recurrence.ts + - src/lib/medications/scheduling/next-due.ts + - src/lib/medications/scheduling/band-minter.ts + - src/lib/medications/list-read.ts + +key-decisions: + - "Reminder suppression and dedup derive from the selected occurrence local date, never the worker tick date." + - "Open rolling occurrences opt into display-band minting without changing the half-cycle compliance denominator." + - "Resolved rolling skips and auto-misses advance on the existing cadence grid without becoming intake anchors." + - "Existing canonical intake upsert/retry behavior was retained because its real-Postgres controls already passed." + +patterns-established: + - "Occurrence identity: scheduleId + scheduled instant, with action time used for era isolation." + - "Consumer authority: prior rolling due state is projected once by the server and not reconstructed from today's wall clock." + +requirements-completed: [REM-01, REM-02, REM-03, REM-04, REM-05, MED-01, MED-02, MED-03, MED-04, MED-05, MED-06] + +duration: 10min +completed: 2026-07-29 +--- + +# Phase 01 Plan 18: Canonical Medication Occurrence Identity Summary + +**Canonical schedule-and-instant identity now survives reminder midnight boundaries and keeps unresolved rolling doses visible through their existing actionable band across every medication consumer.** + +## Performance + +- **Duration:** 10 min +- **Started:** 2026-07-29T12:27:25Z +- **Completed:** 2026-07-29T12:36:57Z +- **Tasks:** 3 +- **Files modified:** 6 + +## Accomplishments + +- Fixed issue #664’s worker-day mismatch by selecting canonical recurrence occurrences, widening the action lookup only to eligible cross-midnight windows, and deriving dedup/notification metadata from the occurrence itself. +- Removed the rolling post-midnight gap by retaining the prior occurrence identity and opting display-due banding into the open rolling slot without changing weekly clinical windows or compliance’s half-cycle threshold. +- Added schedule and era identity to exact resolved marks, allowing taken/skipped/auto-missed state to suppress one occurrence while sibling schedules, replacement eras, and future grid occurrences remain independent. +- Kept list, Dashboard, GLP-1 card, and take-all on the same persisted occurrence; exact intake, concurrent retries, tombstones, and sibling medications converge without duplicate live rows or false already-taken behavior. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Carry scheduled occurrence identity through reminder lookup and dedup** - `c0af97ce4` (fix) +2. **Task 2: Keep unresolved rolling occurrence through its band** - `2de9bccf9` (fix) +3. **Task 3: Use one due result for reads, projection, and intake** - `840e47fda` (fix) + +Supporting cleanup: + +- `0ae9165aa` - repository formatting for the occurrence identity changes + +## Files Created/Modified + +- `src/lib/jobs/reminder/medication-reminder-check.ts` - Selects an active canonical occurrence across midnight and uses its exact instant/date for suppression, dedup, minting, and notification metadata. +- `src/lib/medications/scheduling/recurrence.ts` - Carries schedule identity, retains the immediate rolling occurrence across day changes, and advances resolved rolling grid occurrences. +- `src/lib/medications/scheduling/next-due.ts` - Resolves by schedule/era identity, selects open overdue bands, and rejects closed stale rolling occurrences. +- `src/lib/medications/scheduling/band-minter.ts` - Allows display consumers to include an open rolling occurrence while retrospective compliance retains its existing threshold. +- `src/lib/medications/list-read.ts` - Exposes the due schedule ID and prevents a prior rolling occurrence from being rebound to today’s matching clock window. + +## Decisions Made + +- A prior reminder occurrence is searched only when its own resolved window crosses into the current local day; ordinary stale yesterday slots do not begin reminding again. +- `Occurrence.scheduleId` is the canonical owner. Optional schedule/action metadata on persisted resolution marks preserves backward compatibility while enabling exact sibling and era isolation where attribution provides identity. +- A skipped or auto-missed rolling occurrence advances to the next cadence-grid occurrence but does not alter `lastIntakeAt`; only a real take re-anchors rolling cadence. +- The existing intake upsert already passed real concurrent/retry and unrelated-row tests, so no route or schema change was warranted. + +## Deviations from Plan + +None - plan executed within the listed production boundaries. Files whose existing implementation already satisfied the green contract were verified and left unchanged. + +## Verification + +- **Broad focused unit cluster:** 21 files, 388 tests passed. + - Includes every scheduling unit suite, reminder worker/dedup, intake route, medication-card next-due, and take-all. +- **Real PostgreSQL regression cluster:** 2 files, 8 tests passed. + - Cross-midnight reminder: 2/2. + - Rolling overdue consumers and intake concurrency/isolation: 6/6. +- **ESLint:** Passed for all eight Plan 01-18 production files. +- **Prettier:** Passed for all eight Plan 01-18 production files. +- **TypeScript:** No Plan 01-18 errors. Repository-wide `pnpm typecheck` remains non-zero only because concurrent Google Health RED tests reference `sync-progress` and status-route production modules that have not landed yet. + +## Issues Encountered + +- Repository-wide type checking is temporarily blocked by concurrent Plan 01-13 Google Health work. The remaining diagnostics are confined to its test files; all occurrence-identity production files are clean. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- REM-01 through REM-04 and MED-01 through MED-06 are technically closed by green unit and real-persistence regressions. +- REM-05 remains the stated release-process constraint: issue #664 should stay open until reporter confirmation. +- No migration, package, client fallback, or clinical band widening was introduced. + +## Self-Check: PASSED + +- All 6 claimed files exist. +- All 4 implementation/supporting commits exist. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From b8260474ed8374493ee418123165dcd3c0890d6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:38:19 +0200 Subject: [PATCH 070/125] fix(01-17): remove duplicate mobile insights spacer - suppress only the inherited mobile pre-pill top padding - preserve safe-area clearance focus paint and 44px targets --- .../insights/insights-layout-shell.tsx | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/components/insights/insights-layout-shell.tsx b/src/components/insights/insights-layout-shell.tsx index 5e7d0dff0..bc861d344 100644 --- a/src/components/insights/insights-layout-shell.tsx +++ b/src/components/insights/insights-layout-shell.tsx @@ -180,7 +180,22 @@ export function InsightsLayoutShell({ children }: { children: ReactNode }) { }, [layoutSettled, insightsLayout]); return ( -
    +
    + {/* + AuthShell gives every route a generic `pt-6`. Insights already owns a + sticky, padded navigation row as its first surface, so that generic + spacer became a second unexplained 24px gap on phones. Remove only + that inherited top padding when this shell is the direct route child. + Horizontal padding and safe-area-aware bottom clearance stay intact; + no negative margin can clip pill focus. + */} + Date: Wed, 29 Jul 2026 14:38:41 +0200 Subject: [PATCH 071/125] test(01-15): record release bundle evidence - pin build and benchmark results to one isolated commit - record gzip budgets, headroom, and unresolved route hypotheses --- .../01-PERFORMANCE-BASELINE.md | 61 ++++++++++++++++--- scripts/v1341-performance-baseline.mjs | 9 ++- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md index 73cab0777..7035d3dfd 100644 --- a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md @@ -1,8 +1,11 @@ # v1.34.1 Performance Baseline -**Generated:** 2026-07-29T12:30:52.301Z -**Commit:** `c0af97ce4` -**Exit status:** PASS +**Generated:** 2026-07-29T12:38:03.677Z + +**Commit:** `efe203212a7269c2bd3d16f011985dd888e35679` + +**Exit status:** PASS + **Mode:** deterministic local fixture only ## Safety and interpretation @@ -22,7 +25,7 @@ | Architecture | arm64 | | Logical CPUs | 14 | | Process | single local Node process | -| Total harness duration | 100.435 ms | +| Total harness duration | 152.805 ms | ## Fixture scale @@ -36,9 +39,9 @@ | Local path shape | Cold | Warm median | Warm p95 | Warm max | Cold heap delta | Max warm heap delta | | --- | ---: | ---: | ---: | ---: | ---: | ---: | -| Google history pagination shape | 7.462 ms | 5.283 ms | 5.860 ms | 5.860 ms | 2.00 MiB | 6.19 MiB | -| Medication reminder cohort shape | 0.773 ms | 0.080 ms | 0.971 ms | 0.971 ms | 0.19 MiB | 0.07 MiB | -| Health Score read/canonicalization shape | 3.982 ms | 2.280 ms | 3.384 ms | 3.384 ms | 7.09 MiB | 7.30 MiB | +| Google history pagination shape | 8.539 ms | 7.499 ms | 10.600 ms | 10.600 ms | 2.00 MiB | 6.19 MiB | +| Medication reminder cohort shape | 2.723 ms | 0.083 ms | 1.586 ms | 1.586 ms | 0.18 MiB | 0.06 MiB | +| Health Score read/canonicalization shape | 6.232 ms | 4.141 ms | 5.758 ms | 5.758 ms | 7.20 MiB | 8.17 MiB | ### Determinism and bounded counters @@ -65,3 +68,47 @@ ## Reproduction `node scripts/v1341-performance-baseline.mjs --fixture --output .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md` + +## Release build and bundle evidence + +The production build, fixture timings above, and bundle report below were +generated from one isolated archive of commit +`efe203212a7269c2bd3d16f011985dd888e35679`. Generated Prisma artifacts and the +existing local dependency store were supplied as build prerequisites; no +uncommitted product source was copied into the snapshot. + +| Bundle signal | Measured | Budget / gate | Result | +| --- | ---: | ---: | --- | +| Shared root baseline | 130 KB gzip | informational | measured | +| All client chunks | 3,133 KB gzip | 3,140 KB gzip | pass; 7 KB headroom | +| Largest emitted chunk | 387 KB gzip | informational | measured | +| Recharts fingerprint chunks | 1 | maximum 1 | pass | +| Dashboard route (`/page`) | 431 KB gzip | 460 KB gzip | pass; 29 KB headroom | +| Insights route (`/insights/page`) | 417 KB gzip | 445 KB gzip | pass; 28 KB headroom | +| Measurements route (`/measurements/page`) | 417 KB gzip | 445 KB gzip | pass; 28 KB headroom | +| Mood route (`/insights/mood/page`) | 435 KB gzip | 460 KB gzip | pass; 25 KB headroom | +| Static message-catalog references | none reported | none allowed | pass | + +Commands: + +- `NODE_OPTIONS=--max-old-space-size=8192 ./node_modules/.bin/next build` +- `node scripts/check-bundle-budget.mjs` +- `node scripts/check-bundle-budget.mjs --check` + +The normal 4 GiB Node heap compiled the shared workspace but exhausted memory +during TypeScript verification. The isolated snapshot passed after raising only +the build-process heap to 8 GiB; repository configuration and runtime limits +were not changed. + +### Bundle interpretation + +- The existing budget checker emits the four budgeted route entries above. It + does not currently publish per-route gzip rows for Settings, Admin, or + Documents when a budget file is present, so this report does not invent + comparable figures for them. +- Passing the aggregate gate does not refute the earlier hypothesis that eager + Settings/Admin section imports create avoidable route cost. That hypothesis + remains unmeasured by the current watched-route output. +- The 7 KB aggregate headroom is narrow enough that the final merged release + should rerun the same gate. It is evidence of the measured snapshot, not + permission to raise a budget or a claim about browser parse/hydration time. diff --git a/scripts/v1341-performance-baseline.mjs b/scripts/v1341-performance-baseline.mjs index 0ed69da37..5ede28dad 100644 --- a/scripts/v1341-performance-baseline.mjs +++ b/scripts/v1341-performance-baseline.mjs @@ -258,9 +258,12 @@ try { const generatedAt = new Date().toISOString(); const markdown = `# v1.34.1 Performance Baseline -**Generated:** ${generatedAt} -**Commit:** \`${commitSha()}\` -**Exit status:** PASS +**Generated:** ${generatedAt} + +**Commit:** \`${commitSha()}\` + +**Exit status:** PASS + **Mode:** deterministic local fixture only ## Safety and interpretation From 33a7dafec4e11a4df3ba5648278444a4e47dcbb0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:39:38 +0200 Subject: [PATCH 072/125] fix(01-20): terminate assessment polling honestly - normalize legacy responses into explicit assessment states - abort on timeout or unmount and expose poll exhaustion --- src/hooks/use-insight-status.ts | 262 ++++++++++++++++++++++++-------- 1 file changed, 202 insertions(+), 60 deletions(-) diff --git a/src/hooks/use-insight-status.ts b/src/hooks/use-insight-status.ts index 7f94e49ad..b43141262 100644 --- a/src/hooks/use-insight-status.ts +++ b/src/hooks/use-insight-status.ts @@ -1,10 +1,11 @@ "use client"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useAuth } from "@/hooks/use-auth"; import { useTranslations } from "@/lib/i18n/context"; import type { MetricStatusMetricId } from "@/lib/insights/metric-status-registry"; +import type { AssessmentStatus } from "@/lib/insights/status-shared"; import { queryKeys } from "@/lib/query-keys"; import { apiGet } from "@/lib/api/api-fetch"; @@ -41,6 +42,10 @@ export interface InsightStatusData { * card upgrades to the fresh text in the same session. */ revalidating?: boolean; + /** Explicit terminal/pending state consumed by every assessment wrapper. */ + assessment?: AssessmentStatus; + /** True only after this query identity reaches the bounded poll ceiling. */ + pollExhausted?: boolean; } /** @@ -96,6 +101,159 @@ export function nextStatusPollInterval( return STATUS_POLL_MS; } +/** + * Normalize the legacy route DTO into the shared discriminated contract. + * Existing routes can roll forward independently because the old scalar + * fields remain intact; consumers branch on `assessment.kind`. + */ +export function assessmentStatusFromData( + data: Pick< + InsightStatusData, + "hasProvider" | "text" | "updatedAt" | "preparing" | "revalidating" + >, +): AssessmentStatus { + if (data.text) { + if (data.revalidating) { + return { + kind: "revalidating", + text: data.text, + hasProvider: true, + updatedAt: data.updatedAt, + retryable: true, + }; + } + if (!data.hasProvider) { + // Legacy endpoints encode deterministic timeout/screening fallbacks as + // `hasProvider:false` plus safe text. Text outranks provider metadata: + // never hide it behind the provider-setup CTA. + return { + kind: "screened-fallback", + text: data.text, + hasProvider: false, + updatedAt: null, + retryable: true, + reason: "unparseable_output", + }; + } + return { + kind: "generated", + text: data.text, + hasProvider: true, + updatedAt: data.updatedAt, + retryable: false, + }; + } + if (data.preparing || data.revalidating) { + return { + kind: "preparing", + text: null, + hasProvider: true, + updatedAt: null, + retryable: true, + }; + } + if (!data.hasProvider) { + return { + kind: "no-provider", + text: null, + hasProvider: false, + updatedAt: null, + retryable: false, + }; + } + return { + kind: "error", + text: null, + hasProvider: true, + updatedAt: null, + retryable: true, + }; +} + +function normalizeStatusData(data: InsightStatusData): InsightStatusData { + return { + ...data, + assessment: data.assessment ?? assessmentStatusFromData(data), + }; +} + +function failureStatusData(kind: "timeout" | "error"): InsightStatusData { + return { + hasProvider: true, + text: null, + cached: false, + updatedAt: null, + preparing: false, + revalidating: false, + assessment: { + kind, + text: null, + hasProvider: true, + updatedAt: null, + retryable: true, + }, + }; +} + +async function fetchStatusData( + url: string, + querySignal: AbortSignal, +): Promise { + const controller = new AbortController(); + const abortOnUnmount = () => controller.abort(); + querySignal.addEventListener("abort", abortOnUnmount, { once: true }); + const timeoutHandle = setTimeout(() => controller.abort(), STATUS_TIMEOUT_MS); + try { + const data = await apiGet(url, { + signal: controller.signal, + }); + return normalizeStatusData(data); + } catch { + const timedOut = + controller.signal.aborted && !querySignal.aborted ? "timeout" : "error"; + return failureStatusData(timedOut); + } finally { + clearTimeout(timeoutHandle); + querySignal.removeEventListener("abort", abortOnUnmount); + } +} + +function useStatusPollAttemptCount(queryKey: readonly unknown[]): number { + const queryClient = useQueryClient(); + return ( + queryClient.getQueryState(queryKey)?.dataUpdateCount ?? 0 + ); +} + +/** + * Replace a capped pending payload with an honest terminal state. Keeping the + * legacy flags false ensures older consumers also stop painting preparation. + */ +export function applyStatusPollExhaustion( + data: InsightStatusData | undefined, + dataUpdateCount: number, +): InsightStatusData | undefined { + if (!data) return data; + const status = data.assessment ?? assessmentStatusFromData(data); + const pending = status.kind === "preparing" || status.kind === "revalidating"; + const pollExhausted = pending && dataUpdateCount >= STATUS_POLL_MAX_ATTEMPTS; + if (!pollExhausted) return normalizeStatusData(data); + return { + ...data, + text: status.kind === "revalidating" ? status.text : null, + preparing: false, + revalidating: false, + pollExhausted: true, + assessment: { + kind: "exhausted", + text: null, + hasProvider: true, + updatedAt: null, + retryable: false, + }, + }; +} + /** * Metric slugs the sub-pages render. Each slug is paired with the * Insights-status endpoint and the matching `queryKeys.*` factory so @@ -138,28 +296,15 @@ const QUERY_KEY_FACTORY: Record< export function useInsightStatus(metric: InsightStatusMetric) { const { isAuthenticated } = useAuth(); const { locale } = useTranslations(); + const queryKey = QUERY_KEY_FACTORY[metric](locale); - return useQuery({ - queryKey: QUERY_KEY_FACTORY[metric](locale), - queryFn: async (): Promise => { - // v1.8.3 — bound the fetch on the client. AbortController + 8 s - // timeout so a degraded network can't pin the navigation thread. - // The card surfaces its preparing / empty state on abort exactly as - // it does for a `preparing` payload, and the bounded poll retries. - const controller = new AbortController(); - const timeoutHandle = setTimeout( - () => controller.abort(), - STATUS_TIMEOUT_MS, - ); - try { - return await apiGet( - `/api/insights/${metric}-status?locale=${locale}`, - { signal: controller.signal }, - ); - } finally { - clearTimeout(timeoutHandle); - } - }, + const query = useQuery({ + queryKey, + queryFn: ({ signal }): Promise => + fetchStatusData( + `/api/insights/${metric}-status?locale=${locale}`, + signal, + ), enabled: isAuthenticated, staleTime: 60 * 1000, // v1.4.28 FB-D2 — the status route returns a deterministic envelope on @@ -178,6 +323,11 @@ export function useInsightStatus(metric: InsightStatusMetric) { query.state.data?.revalidating, ), }); + const pollAttempts = useStatusPollAttemptCount(queryKey); + return { + ...query, + data: applyStatusPollExhaustion(query.data, pollAttempts), + }; } /** @@ -210,26 +360,17 @@ export function useInsightMetricStatus( ) { const { isAuthenticated } = useAuth(); const { locale } = useTranslations(); + const queryKey = queryKeys.insightsMetricStatus(metric, locale); - return useQuery({ - queryKey: queryKeys.insightsMetricStatus(metric, locale), - queryFn: async (): Promise => { - const controller = new AbortController(); - const timeoutHandle = setTimeout( - () => controller.abort(), - STATUS_TIMEOUT_MS, - ); - try { - return await apiGet( - `/api/insights/metric-status?metric=${encodeURIComponent( - metric, - )}&locale=${locale}`, - { signal: controller.signal }, - ); - } finally { - clearTimeout(timeoutHandle); - } - }, + const query = useQuery({ + queryKey, + queryFn: ({ signal }): Promise => + fetchStatusData( + `/api/insights/metric-status?metric=${encodeURIComponent( + metric, + )}&locale=${locale}`, + signal, + ), enabled: isAuthenticated && enabled, staleTime: 60 * 1000, retry: 0, @@ -240,6 +381,11 @@ export function useInsightMetricStatus( query.state.data?.revalidating, ), }); + const pollAttempts = useStatusPollAttemptCount(queryKey); + return { + ...query, + data: applyStatusPollExhaustion(query.data, pollAttempts), + }; } /** @@ -264,26 +410,17 @@ export function useInsightBiomarkerAssessment( ) { const { isAuthenticated } = useAuth(); const { locale } = useTranslations(); + const queryKey = queryKeys.insightsBiomarkerAssessment(biomarkerId, locale); - return useQuery({ - queryKey: queryKeys.insightsBiomarkerAssessment(biomarkerId, locale), - queryFn: async (): Promise => { - const controller = new AbortController(); - const timeoutHandle = setTimeout( - () => controller.abort(), - STATUS_TIMEOUT_MS, - ); - try { - return await apiGet( - `/api/insights/biomarker-assessment?biomarkerId=${encodeURIComponent( - biomarkerId, - )}&locale=${locale}`, - { signal: controller.signal }, - ); - } finally { - clearTimeout(timeoutHandle); - } - }, + const query = useQuery({ + queryKey, + queryFn: ({ signal }): Promise => + fetchStatusData( + `/api/insights/biomarker-assessment?biomarkerId=${encodeURIComponent( + biomarkerId, + )}&locale=${locale}`, + signal, + ), enabled: isAuthenticated && enabled && biomarkerId.length > 0, staleTime: 60 * 1000, retry: 0, @@ -294,4 +431,9 @@ export function useInsightBiomarkerAssessment( query.state.data?.revalidating, ), }); + const pollAttempts = useStatusPollAttemptCount(queryKey); + return { + ...query, + data: applyStatusPollExhaustion(query.data, pollAttempts), + }; } From b92b3e4261461aafd866b0db19ce4ebce171adca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:39:39 +0200 Subject: [PATCH 073/125] docs(01-17): complete navigation stability plan - record green component and responsive browser evidence - document preserved authorization safe-area and scanner boundaries --- .../01-17-SUMMARY.md | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-17-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-17-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-17-SUMMARY.md new file mode 100644 index 000000000..bcf99daf4 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-17-SUMMARY.md @@ -0,0 +1,131 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "17" +subsystem: ui +tags: [navigation, responsive-layout, accessibility, playwright, tailwind] + +# Dependency graph +requires: + - phase: 01-10 + provides: responsive shell component and Playwright geometry contracts +provides: + - fail-closed grouped Admin navigation with empty-group omission + - heading-aligned sticky Settings and Admin desktop sidebars + - gap-free mobile Insights pill mount with preserved focus and touch geometry +affects: [navigation, settings, admin, insights, responsive-shells] + +# Tech tracking +tech-stack: + added: [] + patterns: + - filter authorized navigation items before deriving group boundaries + - one main vertical scroll owner with bounded sticky sidebar content + - route-scoped inherited-spacing removal without negative margins + +key-files: + created: [] + modified: + - src/components/admin/admin-shell.tsx + - src/components/settings/settings-shell.tsx + - src/components/insights/insights-layout-shell.tsx + +key-decisions: + - "Reuse existing Settings group translations for Admin headings rather than add locale keys." + - "Keep AuthShell as the only page scroll owner and cap sidebar height against the existing 10.5rem shell budget." + - "Remove only the inherited mobile Insights top padding through a route-scoped parent selector; retain horizontal and safe-area-aware bottom padding." + +patterns-established: + - "Grouped navigation: render headings and separators from the already-filtered visible destination list." + - "Responsive heading semantics: expose one visible level-one heading per breakpoint without duplicate native h1 geometry." + +requirements-completed: [INS-09, NAV-01, NAV-02, NAV-03, NAV-04, NAV-05] + +# Metrics +duration: 13min +completed: 2026-07-29 +--- + +# Phase 01 Plan 17: Stable Grouped Navigation Summary + +**Fail-closed grouped Admin navigation, heading-aligned pinned desktop sidebars, and a gap-free accessible mobile Insights pill row verified across both Chromium projects** + +## Performance + +- **Duration:** 13 min +- **Started:** 2026-07-29T12:25:41Z +- **Completed:** 2026-07-29T12:38:43Z +- **Tasks:** 3 +- **Files modified:** 4 + +## Accomplishments + +- Clustered every existing Admin destination into five meaningful Settings-style groups only after the confirmed-Admin gate, with no empty headings and quiet mobile separators. +- Aligned Settings and Admin sidebars with their visible page headings and kept them pinned across desktop, short desktop, and tablet without width movement. +- Preserved AuthShell as the page scroll owner while bounding only long sidebar content on short viewports. +- Removed the duplicated 24px mobile Insights pre-pill spacer without negative margins, preserving safe bounds, focus visibility, and 44px targets. +- Regenerated and attached all five deterministic v1.34.1 UI evidence images. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Group Admin navigation after fail-closed gating** - `7396119ec` (feat) +2. **Task 2: Align and pin desktop sidebars with one scroll owner** - `23c738efa` (fix) +3. **Task 3: Remove only the unexplained mobile Insights gap** - `b8260474e` (fix) + +## Files Created/Modified + +- `src/components/admin/admin-shell.tsx` - Adds gated semantic groups and heading-row sticky sidebar geometry. +- `src/components/settings/settings-shell.tsx` - Aligns the sticky sidebar with the heading and shares the shell height budget. +- `src/components/insights/insights-layout-shell.tsx` - Removes only the inherited mobile pre-pill top spacer. +- `.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-17-SUMMARY.md` - Execution evidence and handoff. + +## Verification Evidence + +- `TZ=UTC pnpm exec vitest run src/components/settings/__tests__/settings-shell.test.tsx src/components/admin/__tests__/admin-shell.test.tsx`: 31/31 passed. +- ESLint passed on all three owned production components. +- Explicit desktop/mobile Plan 10 Playwright command: 15 passed, 1 expected skip because deterministic evidence capture runs once on `chromium-desktop`. +- Browser checks passed for Settings and Admin at 1440×900, 1280×600, and 900×700 in both configured projects. +- Mobile Insights passed gap, horizontal bounds, overflow, visible focus, and minimum 44px target assertions. +- Evidence produced exactly: + - `test-results/v1341/dashboard-desktop.png` + - `test-results/v1341/insights-mobile.png` + - `test-results/v1341/anamnesis-desktop.png` + - `test-results/v1341/settings-short-desktop.png` + - `test-results/v1341/admin-tablet.png` + +## Decisions Made + +- Admin keeps the original confirmed-Admin fail-closed boundary. Grouping consumes only that authorized visible list and introduces no new authorization or module predicate. +- Admin group labels reuse existing translated Settings grammar (`System`, `Connectivity`, `AI`, `Account`, and `Your data`), avoiding locale churn. +- Sidebar `max-height` uses the same 10.5rem viewport budget as the shared sub-shell floor. This leaves enough containing-block travel for sticky positioning while constraining a long list only when needed. +- The generic AuthShell `pt-6` is suppressed only when the mobile Insights shell is the direct route child. Horizontal padding and safe-area-aware bottom clearance remain owned by AuthShell. + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +- The repository's direct Playwright command did not load `.env`, and concurrent work repeatedly held the shared Next production-build lock. Verification was run against an isolated current-source Next dev server on port 3017 with the existing seeded database and plain-HTTP cookie override. The server was stopped after the full green run. +- The geometry test's native `h1` selector initially selected a hidden duplicate mobile heading at desktop. Both shells now retain an accessible level-one mobile heading while exposing only the visible desktop heading as the native `h1`, avoiding duplicate native heading geometry and duplicate Settings IDs. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- Grouping, scroll ownership, sticky geometry, safe mobile spacing, focus, and touch-target behavior are green and ready for release verification. +- No authorization, locale, package, schema, migration, or unrelated application files changed. + +## Self-Check: PASSED + +- All three owned production files and this summary exist. +- Task commits `7396119ec`, `23c738efa`, and `b8260474e` exist. +- Branch remains `release/v1.34.1`. +- Scanner-owned package, lock, workspace, and binary-diff hashes exactly match the immutable baseline. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From 0bf3a3573d1d348d9fac13536f74f8d7b42c8b98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:40:55 +0200 Subject: [PATCH 074/125] fix(01-20): render explicit assessment states - prioritize safe fallback text over provider metadata - keep terminal and pending card geometry stable --- .../insights/insight-status-card.tsx | 59 +++++++++++++++++-- .../insights/metric-status-card.tsx | 1 + .../insights/slug-insight-status-card.tsx | 1 + 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/components/insights/insight-status-card.tsx b/src/components/insights/insight-status-card.tsx index a7f31f60c..6718a4178 100644 --- a/src/components/insights/insight-status-card.tsx +++ b/src/components/insights/insight-status-card.tsx @@ -17,6 +17,7 @@ import { cn } from "@/lib/utils"; import { useFeatureFlags } from "@/hooks/use-feature-flags"; import { AskCoachIconButton } from "@/components/insights/ask-coach-action"; import type { CoachLaunchScope } from "@/lib/insights/coach-launch-context"; +import type { AssessmentStatus } from "@/lib/insights/status-shared"; // ─── Types ──────────────────────────────────────────────── @@ -62,6 +63,12 @@ interface InsightStatusCardProps { * card is being assembled, not stuck. The client polls until text lands. */ preparing?: boolean; + /** + * Explicit producer/hook state. Legacy scalar props remain supported for + * direct mounts while routes roll forward, but this discriminator owns + * branch ordering whenever it is present. + */ + assessment?: AssessmentStatus; /** * v1.21.0 (C4 H2) — opt-in "Ask the Coach about this assessment" hand-off. * When `coachQuestion` is set (the metric-aware caller supplies it), the @@ -88,6 +95,7 @@ export function InsightStatusCard({ updatedAt, loading = false, preparing = false, + assessment, coachQuestion, coachScope, coachAutoSend, @@ -99,12 +107,22 @@ export function InsightStatusCard({ // suppressed in full so the layout collapses naturally. if (!flags.insightStatus) return null; + const displayText = + assessment?.kind === "generated" || + assessment?.kind === "screened-fallback" || + assessment?.kind === "revalidating" + ? assessment.text + : text; + const isPreparing = + assessment?.kind === "preparing" || + (!assessment && preparing && hasProvider && !displayText); + // v1.8.3 — preparing: the read-only route enqueued a generation and the // client is polling. Render the loading skeleton geometry with a // preparing caption so the card reads as "assembling", not stuck. Only // shown when a provider is configured (the route returns the no-key // fallback otherwise). - if (preparing && hasProvider && !text) { + if (isPreparing) { return ( + + + + +

    {message}

    +
    +
    + ); + } + + const isNoProvider = + assessment?.kind === "no-provider" || + (!assessment && !hasProvider && !displayText); + if (isNoProvider) { return ( @@ -187,7 +238,7 @@ export function InsightStatusCard({ ); } - if (!text) { + if (!displayText) { return ( @@ -255,7 +306,7 @@ export function InsightStatusCard({ token surfaces verbatim. The producer side now strips too; this consumer-side wrap keeps existing caches clean while they roll forward. */} - + diff --git a/src/components/insights/metric-status-card.tsx b/src/components/insights/metric-status-card.tsx index 7a6a30956..ef7532727 100644 --- a/src/components/insights/metric-status-card.tsx +++ b/src/components/insights/metric-status-card.tsx @@ -66,6 +66,7 @@ export function MetricStatusCard({ text={status?.text ?? null} hasProvider={status?.hasProvider ?? false} updatedAt={status?.updatedAt ?? null} + assessment={status?.assessment} coachQuestion={coachQuestion} coachScope={coachSource ? { metric: coachSource } : undefined} coachAutoSend diff --git a/src/components/insights/slug-insight-status-card.tsx b/src/components/insights/slug-insight-status-card.tsx index d73bd2080..f64bcca14 100644 --- a/src/components/insights/slug-insight-status-card.tsx +++ b/src/components/insights/slug-insight-status-card.tsx @@ -71,6 +71,7 @@ export function SlugInsightStatusCard({ text={status?.text ?? null} hasProvider={status?.hasProvider ?? false} updatedAt={status?.updatedAt ?? null} + assessment={status?.assessment} coachQuestion={t("insights.coach.assessmentPrompt", { metric: t(coach.labelKey), })} From f06137c0db7177dee70792bdf766cd06a9e18bf4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:42:18 +0200 Subject: [PATCH 075/125] feat(01-19): add bounded Apple Health ECG parsing - validate ZIP paths, compression metadata, and actual decompressed bytes - normalize ECG CSV descriptors and bounded microvolt samples --- src/lib/apple-health/archive-stream.ts | 314 +++++++++++++++++++++++++ src/lib/apple-health/ecg-csv.ts | 155 ++++++++++++ 2 files changed, 469 insertions(+) create mode 100644 src/lib/apple-health/archive-stream.ts create mode 100644 src/lib/apple-health/ecg-csv.ts diff --git a/src/lib/apple-health/archive-stream.ts b/src/lib/apple-health/archive-stream.ts new file mode 100644 index 000000000..b5718a78a --- /dev/null +++ b/src/lib/apple-health/archive-stream.ts @@ -0,0 +1,314 @@ +/** + * Bounded ZIP reader for Apple Health ECG CSV members. + * + * ZIP entry names are treated as untrusted identifiers only: no archive member + * is ever materialised on disk. Central-directory metadata is checked before + * decompression, then the real decompressed byte count is enforced while + * inflating so forged size metadata cannot bypass the limits. + */ +import { open, type FileHandle } from "node:fs/promises"; +import { Readable } from "node:stream"; +import { createInflateRaw } from "node:zlib"; + +export interface AppleHealthArchiveLimits { + maxMembers: number; + maxEcgMembers: number; + maxMemberBytes: number; + maxTotalEcgBytes: number; + maxCompressionRatio: number; +} + +export interface AppleHealthArchiveMember { + name: string; + stream: Readable; +} + +interface ZipEntry { + name: string; + flags: number; + method: number; + compressedSize: number; + uncompressedSize: number; + localHeaderOffset: number; +} + +const EOCD_SIGNATURE = 0x06054b50; +const CENTRAL_SIGNATURE = 0x02014b50; +const LOCAL_SIGNATURE = 0x04034b50; +const MAX_EOCD_SEARCH = 65_535 + 22; +const ECG_PREFIX = "apple_health_export/electrocardiograms/"; + +function archiveError(message: string): Error { + return new Error(`Unsafe Apple Health archive: ${message}`); +} + +function assertPositiveLimits(limits: AppleHealthArchiveLimits): void { + for (const value of Object.values(limits)) { + if (!Number.isSafeInteger(value) || value <= 0) { + throw archiveError("invalid resource limit"); + } + } +} + +async function readExactly( + handle: FileHandle, + length: number, + position: number, +): Promise { + if ( + !Number.isSafeInteger(length) || + !Number.isSafeInteger(position) || + length < 0 || + position < 0 + ) { + throw archiveError("invalid member bounds"); + } + const buffer = Buffer.allocUnsafe(length); + let offset = 0; + while (offset < length) { + const read = await handle.read( + buffer, + offset, + length - offset, + position + offset, + ); + if (read.bytesRead === 0) { + throw archiveError("truncated member data"); + } + offset += read.bytesRead; + } + return buffer; +} + +function isUnsafeName(name: string): boolean { + if ( + name.length === 0 || + name.includes("\0") || + name.includes("\\") || + name.startsWith("/") || + /^[A-Za-z]:/.test(name) || + name.includes("\uFFFD") + ) { + return true; + } + const segments = name.split("/"); + return segments.some((segment) => segment === "." || segment === ".."); +} + +function isEcgCsv(name: string): boolean { + return ( + name.startsWith(ECG_PREFIX) && + name.length > ECG_PREFIX.length && + name.toLowerCase().endsWith(".csv") && + !name.slice(ECG_PREFIX.length).includes("/") + ); +} + +async function readCentralDirectory( + handle: FileHandle, + fileSize: number, + limits: AppleHealthArchiveLimits, +): Promise { + if (fileSize < 22) throw archiveError("missing ZIP directory"); + + const tailLength = Math.min(fileSize, MAX_EOCD_SEARCH); + const tail = await readExactly(handle, tailLength, fileSize - tailLength); + let eocdOffset = -1; + for (let cursor = tail.length - 22; cursor >= 0; cursor -= 1) { + if (tail.readUInt32LE(cursor) === EOCD_SIGNATURE) { + eocdOffset = cursor; + break; + } + } + if (eocdOffset < 0) throw archiveError("missing ZIP directory"); + + const disk = tail.readUInt16LE(eocdOffset + 4); + const centralDisk = tail.readUInt16LE(eocdOffset + 6); + const diskEntries = tail.readUInt16LE(eocdOffset + 8); + const totalEntries = tail.readUInt16LE(eocdOffset + 10); + const centralSize = tail.readUInt32LE(eocdOffset + 12); + const centralOffset = tail.readUInt32LE(eocdOffset + 16); + if ( + disk !== 0 || + centralDisk !== 0 || + diskEntries !== totalEntries || + totalEntries === 0xffff || + centralSize === 0xffffffff || + centralOffset === 0xffffffff + ) { + throw archiveError("unsupported multi-disk or ZIP64 archive"); + } + if (totalEntries > limits.maxMembers) { + throw archiveError("member count limit exceeded"); + } + if (centralOffset + centralSize > fileSize) { + throw archiveError("invalid ZIP directory bounds"); + } + + const entries: ZipEntry[] = []; + const names = new Set(); + let cursor = centralOffset; + let ecgCount = 0; + + for (let index = 0; index < totalEntries; index += 1) { + const header = await readExactly(handle, 46, cursor); + if (header.readUInt32LE(0) !== CENTRAL_SIGNATURE) { + throw archiveError("malformed ZIP member directory"); + } + const flags = header.readUInt16LE(8); + const method = header.readUInt16LE(10); + const compressedSize = header.readUInt32LE(20); + const uncompressedSize = header.readUInt32LE(24); + const nameLength = header.readUInt16LE(28); + const extraLength = header.readUInt16LE(30); + const commentLength = header.readUInt16LE(32); + const localHeaderOffset = header.readUInt32LE(42); + if ( + compressedSize === 0xffffffff || + uncompressedSize === 0xffffffff || + localHeaderOffset === 0xffffffff + ) { + throw archiveError("unsupported ZIP64 member"); + } + + const nameBuffer = await readExactly(handle, nameLength, cursor + 46); + const name = nameBuffer.toString("utf8"); + if (isUnsafeName(name)) throw archiveError("unsafe member path"); + if (names.has(name)) throw archiveError("duplicate member name"); + names.add(name); + + if (isEcgCsv(name)) { + ecgCount += 1; + if (ecgCount > limits.maxEcgMembers) { + throw archiveError("ECG member count limit exceeded"); + } + if ((flags & 0x1) !== 0) { + throw archiveError("encrypted ECG member is unsupported"); + } + if (method !== 0 && method !== 8) { + throw archiveError("unsupported ECG compression method"); + } + if (uncompressedSize > limits.maxMemberBytes) { + throw archiveError("ECG member byte limit exceeded"); + } + const ratio = uncompressedSize / Math.max(1, compressedSize); + if (ratio > limits.maxCompressionRatio) { + throw archiveError("ECG compression ratio limit exceeded"); + } + } + + entries.push({ + name, + flags, + method, + compressedSize, + uncompressedSize, + localHeaderOffset, + }); + cursor += 46 + nameLength + extraLength + commentLength; + if (cursor > centralOffset + centralSize) { + throw archiveError("invalid ZIP directory size"); + } + } + return entries; +} + +async function decompressEntry( + handle: FileHandle, + entry: ZipEntry, + fileSize: number, + limits: AppleHealthArchiveLimits, + total: { bytes: number }, +): Promise { + const local = await readExactly(handle, 30, entry.localHeaderOffset); + if (local.readUInt32LE(0) !== LOCAL_SIGNATURE) { + throw archiveError("malformed local member header"); + } + const localFlags = local.readUInt16LE(6); + const localMethod = local.readUInt16LE(8); + const nameLength = local.readUInt16LE(26); + const extraLength = local.readUInt16LE(28); + const localName = ( + await readExactly(handle, nameLength, entry.localHeaderOffset + 30) + ).toString("utf8"); + if ( + localName !== entry.name || + localFlags !== entry.flags || + localMethod !== entry.method + ) { + throw archiveError("inconsistent member headers"); + } + const dataOffset = entry.localHeaderOffset + 30 + nameLength + extraLength; + if (dataOffset + entry.compressedSize > fileSize) { + throw archiveError("invalid member data bounds"); + } + + const compressed = await readExactly( + handle, + entry.compressedSize, + dataOffset, + ); + const source = + entry.method === 0 + ? Readable.from([compressed]) + : Readable.from([compressed]).pipe(createInflateRaw()); + const chunks: Buffer[] = []; + let memberBytes = 0; + + try { + for await (const value of source) { + const chunk = Buffer.isBuffer(value) ? value : Buffer.from(value); + memberBytes += chunk.byteLength; + if (memberBytes > limits.maxMemberBytes) { + throw archiveError("actual ECG member byte limit exceeded"); + } + if (total.bytes + memberBytes > limits.maxTotalEcgBytes) { + throw archiveError("total ECG byte limit exceeded"); + } + chunks.push(chunk); + } + } catch { + for (const chunk of chunks) chunk.fill(0); + throw archiveError("ECG decompression or byte limit failure"); + } finally { + compressed.fill(0); + } + + total.bytes += memberBytes; + return chunks; +} + +/** + * Yield only Apple Health's ECG CSV members as in-memory bounded streams. + * The generator validates the complete central directory before yielding any + * health data, preventing partial processing of a structurally unsafe archive. + */ +export async function* streamAppleHealthEcgMembers(input: { + archivePath: string; + limits: AppleHealthArchiveLimits; +}): AsyncGenerator { + assertPositiveLimits(input.limits); + const handle = await open(input.archivePath, "r"); + try { + const stat = await handle.stat(); + const fileSize = Number(stat.size); + if (!Number.isSafeInteger(fileSize)) { + throw archiveError("archive is too large to address safely"); + } + const entries = await readCentralDirectory(handle, fileSize, input.limits); + const total = { bytes: 0 }; + for (const entry of entries) { + if (!isEcgCsv(entry.name)) continue; + const chunks = await decompressEntry( + handle, + entry, + fileSize, + input.limits, + total, + ); + yield { name: entry.name, stream: Readable.from(chunks) }; + } + } finally { + await handle.close(); + } +} diff --git a/src/lib/apple-health/ecg-csv.ts b/src/lib/apple-health/ecg-csv.ts new file mode 100644 index 000000000..155fdf915 --- /dev/null +++ b/src/lib/apple-health/ecg-csv.ts @@ -0,0 +1,155 @@ +/** + * Incremental parser for Apple Health HKElectrocardiogram CSV exports. + * + * Only normalized descriptors and integer microvolt samples leave this + * module. Patient/source labels are ignored, failures use fixed messages, and + * the input stream is destroyed as soon as a resource or validation bound is + * crossed. + */ +import { createInterface } from "node:readline"; +import type { Readable } from "node:stream"; +import type { RhythmClassification } from "@/generated/prisma/client"; + +export interface NormalizedAppleHealthEcg { + recordedAt: Date; + samplingFrequency: number; + samples: number[]; + lead: string | null; + averageHeartRate: number | null; + rhythmClassification: RhythmClassification | null; +} + +function parserError(reason: string): Error { + return new Error(`Invalid Apple Health ECG CSV: ${reason}`); +} + +function parseRecordedAt(value: string | undefined): Date { + const match = + /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2}) ([+-])(\d{2})(\d{2})$/.exec( + value?.trim() ?? "", + ); + if (!match) throw parserError("recorded date is malformed"); + const iso = `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}${match[7]}${match[8]}:${match[9]}`; + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) { + throw parserError("recorded date is invalid"); + } + return parsed; +} + +function parseRate(value: string | undefined): number { + const match = /^(\d+(?:\.\d+)?)\s*Hz$/i.exec(value?.trim() ?? ""); + const rate = match ? Number(match[1]) : Number.NaN; + if (!Number.isFinite(rate) || rate <= 0 || rate > 10_000) { + throw parserError("sample rate is invalid"); + } + return Math.round(rate); +} + +function parseHeartRate(value: string | undefined): number | null { + if (value === undefined || value.trim() === "") return null; + const match = /^(\d+(?:\.\d+)?)\s*bpm$/i.exec(value.trim()); + const rate = match ? Number(match[1]) : Number.NaN; + if (!Number.isFinite(rate) || rate <= 0 || rate > 300) { + throw parserError("average heart rate is invalid"); + } + return Math.round(rate); +} + +function mapClassification( + value: string | undefined, +): RhythmClassification | null { + switch (value?.trim().toLowerCase()) { + case "sinus rhythm": + return "NOT_DETECTED"; + case "atrial fibrillation": + return "IRREGULAR"; + case "inconclusive": + return "INCONCLUSIVE"; + default: + return null; + } +} + +function splitPair(line: string): [string, string] | null { + const comma = line.indexOf(","); + if (comma < 0) return null; + return [line.slice(0, comma).trim(), line.slice(comma + 1).trim()]; +} + +export async function parseAppleHealthEcgCsv(input: { + memberName: string; + stream: Readable; + maxSamples: number; +}): Promise { + void input.memberName; + if (!Number.isSafeInteger(input.maxSamples) || input.maxSamples <= 0) { + throw parserError("sample limit is invalid"); + } + + const metadata = new Map(); + const samples: number[] = []; + let inSamples = false; + let lead: string | null = null; + const lines = createInterface({ + input: input.stream, + crlfDelay: Infinity, + }); + + try { + for await (const rawLine of lines) { + const line = rawLine.replace(/^\uFEFF/, "").trim(); + if (line === "") continue; + const pair = splitPair(line); + if (!pair) { + throw parserError("row is malformed"); + } + const [key, value] = pair; + if (!inSamples) { + if ( + key.toLowerCase() === "lead" && + value.toLowerCase() === "voltage" + ) { + inSamples = true; + continue; + } + if (key !== "Name") metadata.set(key, value); + continue; + } + + if (samples.length >= input.maxSamples) { + throw parserError("sample limit exceeded"); + } + const voltage = Number(value); + if (!Number.isFinite(voltage) || Math.abs(voltage) > 100) { + throw parserError("sample value is invalid"); + } + const microvolts = Math.round(voltage * 1_000); + if (!Number.isSafeInteger(microvolts)) { + throw parserError("sample value is invalid"); + } + if (lead === null && key.length > 0 && key.length <= 32) lead = key; + samples.push(microvolts); + } + + if (!inSamples || samples.length === 0) { + throw parserError("samples are missing"); + } + return { + recordedAt: parseRecordedAt(metadata.get("Recorded Date")), + samplingFrequency: parseRate(metadata.get("Sample Rate")), + samples, + lead, + averageHeartRate: parseHeartRate(metadata.get("Average Heart Rate")), + rhythmClassification: mapClassification(metadata.get("Classification")), + }; + } catch (error) { + samples.fill(0); + input.stream.destroy(); + lines.close(); + if (error instanceof Error && error.message.startsWith("Invalid Apple")) { + throw error; + } + throw parserError("stream could not be parsed"); + } +} From 92f3b03ac0e8707be65c8d4bd65b41696753ca07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:42:55 +0200 Subject: [PATCH 076/125] docs(01-20): complete assessment state contract plan --- .../01-20-SUMMARY.md | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-20-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-20-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-20-SUMMARY.md new file mode 100644 index 000000000..6497537f0 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-20-SUMMARY.md @@ -0,0 +1,137 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "20" +subsystem: insights +tags: [react-query, assessment, safety, cache, polling, accessibility] + +requires: + - phase: 01-10 + provides: RED assessment route, hook, card, and spine contracts + - phase: 01-16 + provides: Compact foreground assessment presentation +provides: + - Discriminated generated, fallback, provider, pending, failure, and exhaustion states + - Fail-closed derived assessment screening with deterministic fallback + - Explicit generated-cache freshness and negative-cache retry policy + - Bounded query-identity polling with timeout, error, and exhaustion transitions + - Shared stable card rendering consumed by metric and slug wrappers +affects: [01-23, assessment-routes, insight-status-workers, metric-pages] + +tech-stack: + added: [] + patterns: + - Provider availability is metadata and never outranks safe assessment text + - Poll attempts are bounded per React Query cache identity + - Provider failures cross the client boundary only as fixed status codes + +key-files: + created: [] + modified: + - src/lib/insights/status-shared.ts + - src/lib/insights/status-cache.ts + - src/lib/insights/derived/derived-assessment-ai.ts + - src/hooks/use-insight-status.ts + - src/components/insights/insight-status-card.tsx + - src/components/insights/metric-status-card.tsx + - src/components/insights/slug-insight-status-card.tsx + +key-decisions: + - "Use kind as the sole assessment branch discriminator; hasProvider remains provenance metadata." + - "Keep legacy route scalars during rollout and normalize them once in the shared hook." + - "Represent generated cache expiry by the user's date key and negative-cache recovery by an explicit retryAt boundary." + - "Convert request aborts, fetch failures, and poll ceilings to fixed terminal states without carrying raw provider errors." + +patterns-established: + - "Safety fallback: screened, malformed, or ungrounded derived prose is never persisted; the deterministic score assessment remains visible." + - "Stable assessment geometry: pending, provider setup, failure, exhaustion, empty, and populated branches share gap-2 / py-3 / md:py-4." + +requirements-completed: [INS-06, INS-07, INS-08, AI-01, AI-02, AI-03, AI-04, AI-05] + +duration: 10min +completed: 2026-07-29 +--- + +# Phase 1 Plan 20: Bounded Fail-Closed Assessment State Summary + +**One explicit assessment state machine now preserves safe fallback text, blocks unsafe generated prose, and terminates every polling path** + +## Performance + +- **Duration:** 10 min +- **Started:** 2026-07-29T12:31:57Z +- **Completed:** 2026-07-29T12:41:35Z +- **Tasks:** 3 +- **Files modified:** 7 + +## Accomplishments + +- Added a compact discriminated contract for generated, screened fallback, no-provider, preparing, revalidating, timeout, error, and exhausted outcomes. +- Routed derived-score provider prose through the shared fail-closed outbound screen before persistence; blocked, malformed, and ungrounded output now resolves to the safe deterministic assessment. +- Made generated-cache day expiry and negative-cache retry timing explicit without adding provider responses, raw errors, or health values to the envelope. +- Bound polling to each locale/metric query identity, aborted requests on timeout or unmount, and replaced capped preparation with an explicit exhausted terminal state. +- Reordered shared card rendering so safe text remains visible even when `hasProvider` is false, while all non-populated states retain compact stable geometry. +- Updated both shared wrappers to pass the same normalized assessment contract. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Classify generated, screened, fallback, and failed outcomes** - `efe203212` (fix) +2. **Task 2: Make assessment polling bounded and terminal** - `33a7dafec` (fix) +3. **Task 3: Render the shared status contract with stable geometry** - `0bf3a3573` (fix) + +## Files Created/Modified + +- `src/lib/insights/status-shared.ts` - Defines the discriminated state contract, labels finalized summaries, and persists explicit generated-cache policy. +- `src/lib/insights/status-cache.ts` - Exposes generated freshness and privacy-safe negative-cache retry classification. +- `src/lib/insights/derived/derived-assessment-ai.ts` - Screens provider prose and returns explicit safe outcomes without persisting blocked text. +- `src/hooks/use-insight-status.ts` - Normalizes legacy DTOs, bounds fetches and polling, and exposes timeout/error/exhausted terminal states. +- `src/components/insights/insight-status-card.tsx` - Renders state-first branches with safe fallback precedence and stable accessible geometry. +- `src/components/insights/metric-status-card.tsx` - Passes the normalized contract from generic metric queries. +- `src/components/insights/slug-insight-status-card.tsx` - Passes the normalized contract from bespoke metric queries. + +## Decisions Made + +- Keep the route DTO backward-compatible because API files are outside this plan; the hook enriches it with the shared discriminator. +- Treat a legacy `hasProvider:false` payload with non-empty text as a safe deterministic fallback, not as permission to hide the text behind setup guidance. +- Count attempts from React Query's cache state so locale and metric query keys naturally isolate exhaustion. +- Return fixed timeout/error states from fetch failures and discard exception details at the boundary. +- Preserve stale text during revalidation until it resolves or exhausts; exhaustion becomes terminal rather than retaining preparation flags. + +## Deviations from Plan + +None - plan executed within the seven-file production ownership boundary. + +## Issues Encountered + +- Repository-wide `pnpm typecheck` is blocked by concurrent, unowned Google Health RED-contract files: missing `src/app/api/google-health/sync/status/route.ts`, missing `src/lib/google-health/sync-progress.ts`, and associated mock tuple narrowing errors. No diagnostic references a Plan 01-20 file. + +## Verification Evidence + +- Combined generator/cache/screen/hook/card/route/spine Vitest gate: **9 files passed, 112 tests passed**. +- Task 1 Vitest gate: **3 files passed, 49 tests passed**. +- Task 2 Vitest gate: **1 file passed, 7 tests passed**. +- Task 3 card/hook gate: **3 files passed, 21 tests passed**. +- Biomarker route and assessment-spine reproduction: **2 files passed, 19 tests passed**. +- Focused ESLint, Prettier, and `git diff --check`: passed for all seven owned production files. +- Repository typecheck was attempted and produced only the concurrent Google Health contract diagnostics listed above. +- No locale JSON, API route, feature gate, or health-log implementation was changed. + +## User Setup Required + +None. + +## Next Phase Readiness + +- Plan 10 assessment route-hook-card and spine contracts are green. +- Downstream integration work can consume a stable state contract without weakening safety gates or depending on raw provider failures. + +## Self-Check: PASSED + +- All seven owned production files and this summary exist. +- Task commits `efe203212`, `33a7dafec`, and `0bf3a3573` exist on the current branch. +- Locale catalogs, API routes, and the feature-flag hook remain unchanged by Plan 01-20. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From 4ba4ee38cd6ba894e9447215455344f79f44c31a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:43:04 +0200 Subject: [PATCH 077/125] test(01-15): reconcile audit finding ledger - deduplicate research, correctness, live, and performance findings - enforce required evidence fields and privacy-safe report content --- .../01-AUDIT-RECONCILIATION.md | 558 ++++++++++++++++++ scripts/v1341-audit-ledger-check.mjs | 185 ++++++ 2 files changed, 743 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-AUDIT-RECONCILIATION.md create mode 100644 scripts/v1341-audit-ledger-check.mjs diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-AUDIT-RECONCILIATION.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-AUDIT-RECONCILIATION.md new file mode 100644 index 000000000..0a174a8e7 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-AUDIT-RECONCILIATION.md @@ -0,0 +1,558 @@ +# v1.34.1 Audit Reconciliation Ledger + +**Reconciled:** 2026-07-29 + +**Sources:** phase research, static correctness audit, read-only live audit, and +performance/resilience audit. + +This ledger deduplicates overlapping observations. Confirmed findings remain +release work or explicit blockers; suspected findings remain hypotheses; cleared +findings preserve the attempted refutation so they cannot silently return to +scope. Evidence is limited to repository paths and redacted aggregate facts. + +## Status summary + +| Status | Meaning | +| --- | --- | +| confirmed | Evidence proves the behavior; implement or explicitly block release | +| suspected | Credible risk still needs the planned measurement or decision | +| blocked | External evidence is required before a resolution claim | +| cleared | The examined path refuted the proposed adjacent defect | +| refuted | The specific claim was disproved | + +## AUD-001 — Dashboard pending lead duplicates the headline score + +- **Source:** research:confirmed-dashboard; correctness:C8; performance:C7 +- **Status:** confirmed +- **Severity:** medium +- **Category:** presentation-correctness +- **Evidence:** `src/lib/daily/digest.ts` supplies a score-only fallback while `src/components/daily/today-hero.tsx` renders the score ring independently. +- **Affected path:** digest fallback → Dashboard hero lead → score ring +- **Attempted refutation:** Generated or signal-driven prose later replaces the fallback, but the cold state is deterministic and still duplicates the score. +- **Impact:** The initial Dashboard repeats one headline fact and looks unfinished despite complete data. +- **Requirement:** DASH-01 +- **Planned verification:** Plan 16 component contract for one numeric headline representation followed by Plan 24 merged re-audit. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-002 — Stored document summaries expose a redundant regenerate action + +- **Source:** research:confirmed-document-action; correctness:K1 +- **Status:** confirmed +- **Severity:** low +- **Category:** product-grammar +- **Evidence:** The stored-summary branch in `src/components/documents/document-summary-block.tsx` renders the action even though opening and reading require no generation. +- **Affected path:** stored summary state → document detail UI +- **Attempted refutation:** First-generation and retry states still require backend capability; only the ready-state action is redundant. +- **Impact:** Users see an action that the accepted release brief explicitly removes. +- **Requirement:** DOC-01 +- **Planned verification:** Plan 16 ready-state component contract and Plan 24 re-audit. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-003 — Health Score card contains rejected explanatory blocks + +- **Source:** research:confirmed-score-copy; correctness:K2 +- **Status:** confirmed +- **Severity:** low +- **Category:** presentation-density +- **Evidence:** `src/components/insights/health-score-card.tsx` separates method, composition, band, and algorithm copy from canonical report construction. +- **Affected path:** Health Score report → card presentation +- **Attempted refutation:** No competing calculation or API score was found; removing the blocks can remain presentation-only. +- **Impact:** The card is denser than the accepted design and obscures pillar details. +- **Requirement:** INS-01, INS-02, INS-03, INS-04 +- **Planned verification:** Plan 16 API parity plus one-score component contract; locale cleanup in Plan 23. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-004 — Medication assessment prose uses the wrong visual emphasis + +- **Source:** research:confirmed-medication-tone +- **Status:** confirmed +- **Severity:** low +- **Category:** visual-consistency +- **Evidence:** `src/app/insights/medications/page.tsx` uses muted prose where established assessment bodies use foreground text. +- **Affected path:** medication assessment result → Insights card body +- **Attempted refutation:** Shared cards already use the intended token, limiting the defect to the concrete medication drift. +- **Impact:** Medication assessment text appears less important than equivalent assessment prose. +- **Requirement:** INS-06, INS-07 +- **Planned verification:** Plan 16 focused component assertion and Plan 24 screenshot re-audit. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-005 — Empty Allergy and Family History managers duplicate create actions + +- **Source:** research:confirmed-empty-ctas; correctness:K3 +- **Status:** confirmed +- **Severity:** low +- **Category:** product-grammar +- **Evidence:** Both managers render an unconditional top action and an EmptyState action backed by the same create entry point. +- **Affected path:** empty records query → manager header and empty state +- **Attempted refutation:** The controls do not prove duplicate writes, but they are duplicate affordances in the same state. +- **Impact:** Empty screens present two equivalent primary actions. +- **Requirement:** REC-01, REC-03 +- **Planned verification:** Plan 16 empty/populated component contracts with one request per activation. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-006 — Desktop Settings and Admin navigation starts below the heading + +- **Source:** research:confirmed-shell-layout; correctness:K4; performance:C8 +- **Status:** confirmed +- **Severity:** low +- **Category:** navigation-layout +- **Evidence:** Both shells place the heading in grid row one and navigation in row two with a separate sticky offset. +- **Affected path:** desktop shell grid → sticky navigation → section content +- **Attempted refutation:** Sticky positioning eventually pins the rail, but does not satisfy initial heading alignment or eliminate the visible travel. +- **Impact:** Navigation visibly drifts before pinning and Admin lacks the accepted grouping. +- **Requirement:** NAV-01, NAV-02, NAV-03, NAV-04, NAV-05 +- **Planned verification:** Plan 17 responsive shell contracts and Plan 24 browser re-audit. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-007 — Reminder identity changes at the local-day boundary + +- **Source:** research:pitfall-day-key; correctness:C1 +- **Status:** confirmed +- **Severity:** critical +- **Category:** day-boundary +- **Evidence:** `src/lib/jobs/reminder/medication-reminder-check.ts` derives intake bounds, occurrence anchoring, and dedup keys from the worker tick day rather than the scheduled occurrence. +- **Affected path:** schedule recurrence → occurrence instant → intake attribution → reminder dedup +- **Attempted refutation:** Existing recurrence bands and a wider anchor lookup cannot match an identity whose local date changed before attribution. +- **Impact:** A completed late dose can repeat after midnight, while an overly broad repair could suppress the next real dose. +- **Requirement:** REM-01, REM-02, REM-03, REM-04 +- **Planned verification:** Plan 18 fake-time and persisted cross-midnight matrix across normal and daylight-saving transitions. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-008 — Google resource diagnoses collapse before the manual-sync response + +- **Source:** research:confirmed-google-reasons; correctness:C5 +- **Status:** confirmed +- **Severity:** high +- **Category:** outcome-honesty +- **Evidence:** Resource failures are classified internally, but the aggregate route and failed card path retain only a generic failure envelope and stale status. +- **Affected path:** provider resource → sync ledger → route envelope → integration card +- **Attempted refutation:** The detailed ledger can appear after a later refetch, which does not make the immediate failed response actionable. +- **Impact:** Users cannot distinguish safe reason classes and are encouraged to retry blindly. +- **Requirement:** GH-03, GH-04, GH-06 +- **Planned verification:** Plan 21 bounded per-resource DTO tests and failed-card status invalidation. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-009 — Safe deterministic Insights fallback is discarded + +- **Source:** research:confirmed-screened-fallback; correctness:C2; live:C2 +- **Status:** confirmed +- **Severity:** high +- **Category:** persistence +- **Evidence:** A safe fallback is produced, but the negative cache stub omits it and the shared card evaluates provider presence before available text. +- **Affected path:** outbound screen → deterministic fallback → status persistence → reader → card +- **Attempted refutation:** Retry-storm prevention and fail-closed screening are valid, but neither requires dropping already-safe text. +- **Impact:** A safe result becomes whitespace, indefinite preparation, or a false provider-setup prompt. +- **Requirement:** AI-01, AI-02, AI-03, AI-04 +- **Planned verification:** Plan 20 worker-to-reader-to-card matrix for screened, timeout, error, cached, and generated states. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-010 — Reported Google full-sync failure lacks reproducible evidence + +- **Source:** research:open-google-report; live:S1 +- **Status:** blocked +- **Severity:** high +- **Category:** external-evidence +- **Evidence:** The audited instance had no connected Google cohort or retained manual-sync trace capable of reproducing the external report. +- **Affected path:** external reporter environment → manual full sync → user-visible result +- **Attempted refutation:** A clean zero-target local run cannot clear an issue that it cannot exercise. +- **Impact:** The release must not claim the reporter-specific failure resolved from adjacent fixes alone. +- **Requirement:** GH-01, GH-06 +- **Planned verification:** Plan 21 fixture coverage plus a redacted reporter response checkpoint before any specific resolution claim. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-011 — Insights polling stops while the card remains preparing + +- **Source:** correctness:C3 +- **Status:** confirmed +- **Severity:** high +- **Category:** terminal-state +- **Evidence:** `src/hooks/use-insight-status.ts` stops scheduling after its ceiling without transforming the last preparing payload rendered by the card. +- **Affected path:** status GET loop → polling ceiling → shared card state +- **Attempted refutation:** Healthy workers usually replace the payload, but a negative stub can remain preparing with no work in flight. +- **Impact:** Bounded network work becomes an unbounded and misleading UI state. +- **Requirement:** AI-03, AI-04, AI-05 +- **Planned verification:** Plan 20 fake-timer contract requiring an honest terminal state after the final poll. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-012 — Google writes can leave workout and score readers stale + +- **Source:** correctness:C4; performance:C1 +- **Status:** confirmed +- **Severity:** high +- **Category:** cache-freshness +- **Evidence:** Google workout and measurement writers omit the established server invalidators, while manual client invalidation omits the workout query family. +- **Affected path:** Google write → server projections → browser queries → Dashboard and workout views +- **Attempted refutation:** Cache expiry and remounts eventually heal views, but do not provide immediate post-success visibility across processes. +- **Impact:** A successful import can leave new or updated workouts and derived views visibly stale. +- **Requirement:** GH-02, GH-04, SYNC-03 +- **Planned verification:** Plan 21 insert/update cache-prewarm tests plus real-database workout visibility coverage. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-013 — Resolved Google failures are counted as synced users + +- **Source:** correctness:C6; performance:C2 +- **Status:** confirmed +- **Severity:** high +- **Category:** silent-success +- **Evidence:** The default cohort adapter discards the returned failed verdict and increments success for every resolved promise. +- **Affected path:** per-user Google verdict → cohort counters → job outcome +- **Attempted refutation:** Isolating one user from cohort retries is valid, but does not justify classifying a failed verdict as synced. +- **Impact:** Operator counters can look green while user integrations fail or partially fail. +- **Requirement:** SYNC-02, JOB-01 +- **Planned verification:** Plan 22 mixed clean, resolved-failure, and thrown-failure cohort contract. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-014 — Rollup failure does not change Google sync success + +- **Source:** correctness:C7 +- **Status:** confirmed +- **Severity:** medium +- **Category:** outcome-honesty +- **Evidence:** Full and incremental sync paths catch rollup recomputation failure after raw writes without changing the cycle verdict or success watermark. +- **Affected path:** raw measurement write → rollup recomputation → integration ledger → derived readers +- **Attempted refutation:** Raw rows were written, but derived readers consume materialized results, so raw write count alone is not a complete success verdict. +- **Impact:** The card can report success while Insights and derived views remain stale. +- **Requirement:** GH-04, GH-06, SYNC-03 +- **Planned verification:** Plan 21 forced rollup-failure tests requiring partial or failed outcome and no success watermark. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-015 — Stamp-before-send can suppress a failed reminder phase + +- **Source:** correctness:S1 +- **Status:** suspected +- **Severity:** medium +- **Category:** delivery-policy +- **Evidence:** The worker persists its dedup anchor before dispatch and retains the anchor when dispatch fails. +- **Affected path:** reminder candidate → durable dedup anchor → channel dispatch → next tick +- **Attempted refutation:** The policy prevents notification floods and no live evidence ties it to the reported midnight defect. +- **Impact:** A transient dispatch failure can suppress that phase unless the product intentionally accepts at-most-once delivery. +- **Requirement:** OPS-01 +- **Planned verification:** Plan 24 explicit at-most-once versus durable-outbox decision; no point-release behavior change without that decision. +- **Confidence:** medium +- **Unresolved:** yes + +## AUD-016 — Recovery assessment visual mismatch is not statically proven + +- **Source:** correctness:S2 +- **Status:** suspected +- **Severity:** low +- **Category:** visual-consistency +- **Evidence:** Recovery mounts the same shared populated assessment component and foreground body token as established metric cards. +- **Affected path:** Recovery metric state → shared assessment card → themed rendering +- **Attempted refutation:** Shared code does not prove identical browser output across adjacent copy, themes, and responsive layouts. +- **Impact:** A speculative global CSS change could regress already-correct assessment states. +- **Requirement:** INS-06, INS-07 +- **Planned verification:** Plan 24 mobile and desktop screenshot matrix across loading, fallback, generated, light, and dark states. +- **Confidence:** medium +- **Unresolved:** yes + +## AUD-017 — Records preserve explicit revision and delete semantics + +- **Source:** correctness:K3-cleared +- **Status:** cleared +- **Severity:** info +- **Category:** records-semantics +- **Evidence:** Facts expose separate save, correction history, and confirmed removal paths; duplicate empty actions share one create entry point. +- **Affected path:** records editor → revision persistence → history and removal +- **Attempted refutation:** Static tracing found no silent overwrite or delete triggered by the requested label and conditional-rendering changes. +- **Impact:** No adjacent data-correctness defect is established. +- **Requirement:** REC-02, REC-04, REC-05 +- **Planned verification:** Plan 16 preserves revision history and one mutation per action; Plan 24 re-audit. +- **Confidence:** high +- **Unresolved:** no + +## AUD-018 — Navigation authorization currently fails closed + +- **Source:** correctness:K4-cleared +- **Status:** cleared +- **Severity:** info +- **Category:** authorization +- **Evidence:** Admin shell renders neither frame nor content until the role is authorized. +- **Affected path:** session role → Admin shell → navigation and content +- **Attempted refutation:** Static code clears an authorization leak, though it cannot clear the separate sticky-layout report. +- **Impact:** No adjacent role-boundary defect was found. +- **Requirement:** NAV-01, NAV-03 +- **Planned verification:** Plan 17 non-admin and admin browser contracts followed by Plan 24 security re-audit. +- **Confidence:** high +- **Unresolved:** no + +## AUD-019 — Written-plus-failed Google sync already renders partial + +- **Source:** correctness:K5-cleared +- **Status:** cleared +- **Severity:** info +- **Category:** outcome-honesty +- **Evidence:** The shared written-outcome classifier and route preserve a partial aggregate when some rows were written and a leg failed. +- **Affected path:** resource counts → aggregate outcome → manual-sync route +- **Attempted refutation:** Per-resource detail and zero-write diagnosis remain missing, but the existing aggregate does not paint written-plus-failed work as full success. +- **Impact:** The central partial classifier should be extended, not replaced. +- **Requirement:** GH-04 +- **Planned verification:** Plan 21 retains the partial contract while adding bounded per-resource detail. +- **Confidence:** high +- **Unresolved:** no + +## AUD-020 — Settings and Admin eager imports may inflate route bundles + +- **Source:** performance:M1 +- **Status:** suspected +- **Severity:** medium +- **Category:** bundle-size +- **Evidence:** Both section renderers eagerly import broad section maps; the current budget tool does not emit watched rows for these routes. +- **Affected path:** route module graph → client chunks → parse and hydration +- **Attempted refutation:** Older uncompressed figures include a large shared shell and are not current transfer bytes. +- **Impact:** Mobile and cold clients may download and parse unrelated section code. +- **Requirement:** PERF-01 +- **Planned verification:** Plan 24 must add or collect route-specific gzip evidence before any split; current aggregate baseline remains in `01-PERFORMANCE-BASELINE.md`. +- **Confidence:** medium +- **Unresolved:** yes + +## AUD-021 — Page ceilings can return silently truncated Google histories + +- **Source:** performance:C3 +- **Status:** confirmed +- **Severity:** high +- **Category:** pagination-completeness +- **Evidence:** Data-point and rollup walkers can return accumulated rows when their final allowed page still contains a continuation token. +- **Affected path:** provider pagination → accumulated resource → watermark and outcome +- **Attempted refutation:** Page caps prevent infinite walks and ordinary incremental windows are smaller, but neither proves completeness at the cap. +- **Impact:** Historical data can be omitted while the cycle appears complete and advances its watermark. +- **Requirement:** GH-05, GH-07 +- **Planned verification:** Plan 21 remaining-token-at-limit fixture requiring truncation status and no success watermark. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-022 — Google full sync buffers and serializes large workout histories + +- **Source:** performance:C5 +- **Status:** suspected +- **Severity:** medium +- **Category:** sync-scalability +- **Evidence:** Resource orchestration, workout page accumulation, and per-row persistence are serial in the synchronous manual request. +- **Affected path:** manual sync request → provider pages → workout mapping → database writes +- **Attempted refutation:** Serial ordering protects quota and no live timing proves the request exceeds its route budget. +- **Impact:** Large histories may consume the request window before returning a terminal outcome. +- **Requirement:** PERF-01, GH-07 +- **Planned verification:** Plan 21 terminal workout fairness fixture and a database-backed statement-count benchmark before optimization. +- **Confidence:** medium +- **Unresolved:** yes + +## AUD-023 — Reminder tick is a global serial query-amplifying pass + +- **Source:** performance:C6 +- **Status:** suspected +- **Severity:** medium +- **Category:** worker-scalability +- **Evidence:** The worker loads an unpaged medication cohort, processes it serially, and drains cleanup backlog before due-dose evaluation. +- **Affected path:** cleanup queue → medication cohort → per-slot queries → dispatch +- **Attempted refutation:** Local concurrency and dedup prevent overlap and repeats, but do not cap pass duration as cohorts grow. +- **Impact:** Due reminders may arrive late if a pass approaches its fifteen-minute cadence. +- **Requirement:** PERF-01 +- **Planned verification:** Use the bounded cohort baseline plus a future database/network-stub p95 and query-budget test before structural changes. +- **Confidence:** medium +- **Unresolved:** yes + +## AUD-024 — Dense Health Score reads may scale with sample density + +- **Source:** performance:S1 +- **Status:** suspected +- **Severity:** medium +- **Category:** score-scalability +- **Evidence:** The score reader fetches time-bounded but unbounded-row raw datasets to preserve local-day and source canonicalization. +- **Affected path:** raw measurements → canonicalization → Health Score → Dashboard +- **Attempted refutation:** Parallel queries, sparse domains, and caches bound common cases; only a dense cold user presents the risk. +- **Impact:** A cold score rebuild may allocate and process many rows for a power user. +- **Requirement:** PERF-01 +- **Planned verification:** Use the dense local baseline, then require database row counts, heap, cold p95, and exact production score parity before optimization. +- **Confidence:** medium +- **Unresolved:** yes + +## AUD-025 — Timed-out digest prefetch may duplicate bounded assembly work + +- **Source:** performance:S2 +- **Status:** suspected +- **Severity:** low +- **Category:** request-amplification +- **Evidence:** A soft-timeout loser is not cancelled and can continue while the client later requests the digest. +- **Affected path:** Dashboard server prefetch → soft timeout → client digest request +- **Attempted refutation:** Expensive snapshot work is single-flight and the remaining duplicated assembly is bounded. +- **Impact:** A cold navigation may perform small duplicate work without losing content. +- **Requirement:** PERF-01 +- **Planned verification:** Plan 24 trace of one cold timeout navigation; add cancellation only if duplicate builders survive cache coalescing. +- **Confidence:** low +- **Unresolved:** yes + +## AUD-026 — Comprehensive Insights screening follows multi-minute generation + +- **Source:** live:C1 +- **Status:** confirmed +- **Severity:** high +- **Category:** generated-content-latency +- **Evidence:** Redacted aggregate logs show repeated long comprehensive generation attempts ending in fail-closed causal-claim screening. +- **Affected path:** manual generation → provider response → outbound screen → client outcome +- **Attempted refutation:** Screening must remain fail-closed and unrelated request paths did not show a general outage. +- **Impact:** A refresh can occupy the UI for minutes and still produce no fresh provider prose. +- **Requirement:** AI-02, AI-03, AI-04 +- **Planned verification:** Plan 20 safe rewrite or deterministic fallback tests with bounded retries and explicit visible outcome. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-027 — Status generation is degraded but safe fallback selection survives + +- **Source:** live:C2-provider-chain +- **Status:** confirmed +- **Severity:** high +- **Category:** provider-resilience +- **Evidence:** Redacted aggregate logs contain repeated provider-error and screened fallback events while some provider rows later recovered. +- **Affected path:** status pregeneration → provider chain → screen → fallback +- **Attempted refutation:** The provider fleet was not totally unavailable and fallback selection itself was observable. +- **Impact:** Assessments can arrive late or remain deterministic for repeated cycles. +- **Requirement:** AI-02, AI-04 +- **Planned verification:** Plan 20 preserves fail-closed fallback behavior and Plan 24 compares post-change reason counters. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-028 — Parked Withings targets are counted as synced + +- **Source:** live:C3 +- **Status:** confirmed +- **Severity:** medium +- **Category:** silent-success +- **Evidence:** A durable parked integration leg remained stale while hourly cohort aggregates counted every target as synced. +- **Affected path:** integration state → cohort polling → job counters → operator view +- **Attempted refutation:** Zero new rows can be healthy, but a persistent parked state and repeated warnings refute all-targets-healthy. +- **Impact:** Stale data can coexist with green cohort counters and delayed intervention. +- **Requirement:** SYNC-02, JOB-01 +- **Planned verification:** Plan 22 mixed healthy, parked, failed, and zero-new-data cohort contracts. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-029 — Completed job rows contain no bounded outcome facts + +- **Source:** live:C4 +- **Status:** confirmed +- **Severity:** medium +- **Category:** outcome-honesty +- **Evidence:** Sampled completed operational queues had empty durable output, including zero-target and useful-work cases. +- **Affected path:** job handler result → durable queue row → Admin diagnosis +- **Attempted refutation:** Wide events sometimes carry counters and terminal-failure storage works, but completed rows still cannot distinguish work from no-op. +- **Impact:** Green completion cannot prove targets, writes, skips, failures, or fallbacks. +- **Requirement:** JOB-01 +- **Planned verification:** Plan 22 scalar-only durable outcome digest with zero-target, partial, failed, skipped, and successful cases. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-030 — Measurement planner statistics and index footprint need maintenance review + +- **Source:** live:C5 +- **Status:** confirmed +- **Severity:** medium +- **Category:** operations +- **Evidence:** Redacted database aggregates show severely divergent planner estimates and a large index footprint without a current route outage. +- **Affected path:** measurement table statistics → query plans → read and write cost +- **Attempted refutation:** Current representative routes remain healthy and storage has headroom, so no incident or emergency rewrite is proven. +- **Impact:** Stale cardinality estimates can select poor plans as data changes. +- **Requirement:** OPS-01 +- **Planned verification:** Plan 24 maintenance-window decision using representative plans before ordinary analyze or vacuum; no deploy-time storage rewrite. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-031 — Shared-host peaks are not attributable to HealthLog + +- **Source:** live:S2 +- **Status:** suspected +- **Severity:** low +- **Category:** host-pressure +- **Evidence:** Aggregate host peaks coincided with isolated slow health checks, while the application had no restart, memory kill, or sustained common-path slowdown. +- **Affected path:** shared host scheduling → application event loop → request latency +- **Attempted refutation:** Other workloads share the host and current application resource use was materially calmer. +- **Impact:** Isolated slow reads are possible, but no HealthLog regression is established. +- **Requirement:** AUD-03, OPS-01 +- **Planned verification:** Correlate future slow events with per-container metrics and event-loop lag before changing application code. +- **Confidence:** low +- **Unresolved:** yes + +## AUD-032 — Status transport errors can masquerade as no provider + +- **Source:** performance:C4; live:S3 +- **Status:** confirmed +- **Severity:** medium +- **Category:** terminal-state +- **Evidence:** Status hooks stop after transport failure and consumers omit the error state, defaulting absent data to no-provider semantics. +- **Affected path:** status transport → query hook → shared card +- **Attempted refutation:** Successful preparing responses poll correctly; the defect is specific to timeout and route-error transport. +- **Impact:** A failed read can show a false configuration prompt or hide safe fallback text. +- **Requirement:** AI-03, AI-04, AI-05 +- **Planned verification:** Plan 20 aborted and server-error hook/card matrix with bounded retry and honest terminal rendering. +- **Confidence:** high +- **Unresolved:** yes + +## AUD-033 — Runtime availability showed no general outage + +- **Source:** live:cleared-runtime +- **Status:** cleared +- **Severity:** info +- **Category:** runtime-health +- **Evidence:** Read-only health, restart, and resource aggregates showed healthy application and database containers over the inspected window. +- **Affected path:** runtime containers → health endpoint → operator availability +- **Attempted refutation:** The audit checked restart, memory-kill, current resource, and full-window signals rather than one status response alone. +- **Impact:** No general outage explains the targeted integration and Insights symptoms. +- **Requirement:** AUD-03 +- **Planned verification:** Plan 24 repeats health checks after the integrated build and Plan 25 records release evidence. +- **Confidence:** high +- **Unresolved:** no + +## AUD-034 — Migration state showed no schema drift + +- **Source:** live:cleared-migrations +- **Status:** cleared +- **Severity:** info +- **Category:** schema-health +- **Evidence:** Repository and database migration aggregates matched with no unfinished or rolled-back migration. +- **Affected path:** migration files → deployment ledger → runtime schema +- **Attempted refutation:** The audit compared counts and latest names rather than relying on application startup alone. +- **Impact:** Schema drift is not an evidenced cause of the reported symptoms. +- **Requirement:** AUD-03 +- **Planned verification:** Preserve the normal migration gate and repeat it after any Plan 21 schema addition. +- **Confidence:** high +- **Unresolved:** no + +## AUD-035 — No unrelated server-error or queue-lock cluster was found + +- **Source:** live:cleared-errors-and-locks +- **Status:** cleared +- **Severity:** info +- **Category:** runtime-health +- **Evidence:** Redacted status aggregates found no server-error cluster outside screened generation, no exhausted queue retries, and no database lock incident. +- **Affected path:** HTTP actions and background queues → database concurrency → operator signals +- **Attempted refutation:** Queue state and database activity were checked directly in addition to structured logs. +- **Impact:** The audit does not support broad runtime or database remediation. +- **Requirement:** AUD-03, AUD-04 +- **Planned verification:** Plan 24 merged re-audit with durable outcome facts, followed by Plan 25 release gate. +- **Confidence:** high +- **Unresolved:** no + +## Reconciliation conclusions + +- Confirmed findings map to an implementation plan or remain an explicit release + blocker. No confirmed defect is silently downgraded to a hypothesis. +- The reporter-specific Google failure remains externally blocked even though + adjacent cache, pagination, and outcome defects are independently confirmed. +- Performance entries preserve the difference between local measurements and + production behavior. No optimization or dependency change is authorized by + this ledger. +- Cleared findings remain recorded so future re-audits can distinguish a + regression from an already-refuted adjacent theory. diff --git a/scripts/v1341-audit-ledger-check.mjs b/scripts/v1341-audit-ledger-check.mjs new file mode 100644 index 000000000..fd8bddf33 --- /dev/null +++ b/scripts/v1341-audit-ledger-check.mjs @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +import { readFileSync, statSync } from "node:fs"; +import { resolve } from "node:path"; + +const ledgerArg = process.argv[2]; +if (!ledgerArg) { + console.error("usage: node scripts/v1341-audit-ledger-check.mjs "); + process.exit(2); +} + +const ledgerPath = resolve(ledgerArg); +const MAX_BYTES = 256 * 1024; +const REQUIRED_FIELDS = [ + "Source", + "Status", + "Severity", + "Category", + "Evidence", + "Affected path", + "Attempted refutation", + "Impact", + "Requirement", + "Planned verification", + "Confidence", + "Unresolved", +]; +const ALLOWED_STATUS = new Set(["confirmed", "suspected", "blocked", "cleared", "refuted"]); +const ALLOWED_SEVERITY = new Set(["critical", "high", "medium", "low", "info"]); +const ALLOWED_CONFIDENCE = new Set(["high", "medium", "low"]); +const REQUIRED_SOURCES = ["research:", "correctness:", "live:", "performance:"]; +const REQUIRED_CATEGORIES = [ + "day-boundary", + "silent-success", + "cache-freshness", + "persistence", + "outcome-honesty", +]; +const PRIVACY_PATTERNS = [ + { + name: "email address", + pattern: /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i, + }, + { name: "authorization credential", pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]+/i }, + { + name: "secret assignment", + pattern: + /\b(?:token|secret|password|api[_-]?key|client[_-]?secret)\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{8,}/i, + }, + { + name: "URL or endpoint with authority", + pattern: /\bhttps?:\/\/[^\s)>\]]+/i, + }, + { + name: "IPv4 address", + pattern: /\b(?:\d{1,3}\.){3}\d{1,3}\b/, + }, + { + name: "UUID-like identifier", + pattern: + /\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b/i, + }, + { + name: "long hexadecimal identifier", + pattern: /\b[0-9a-f]{32,}\b/i, + }, + { + name: "local user filesystem path", + pattern: /(?:\/Users\/|\/home\/)[^\s`]+/, + }, + { + name: "explicit health value", + pattern: + /\b(?:glucose|blood pressure|heart rate|weight|bmi|hba1c)\s*(?:value\s*)?[:=]\s*-?\d+(?:\.\d+)?/i, + }, +]; + +const errors = []; +let source; +try { + if (statSync(ledgerPath).size > MAX_BYTES) { + errors.push(`ledger exceeds ${MAX_BYTES} bytes`); + } + source = readFileSync(ledgerPath, "utf8"); +} catch (error) { + console.error( + `v1341-audit-ledger-check: cannot read ledger (${error instanceof Error ? error.message : "unknown error"})`, + ); + process.exit(2); +} + +for (const check of PRIVACY_PATTERNS) { + if (check.pattern.test(source)) errors.push(`privacy-dangerous ${check.name} detected`); +} + +const headingPattern = /^## (AUD-\d{3}) — (.+)$/gm; +const headings = [...source.matchAll(headingPattern)]; +if (headings.length === 0) errors.push("no finding headings found"); +if (headings.length > 200) errors.push("finding count exceeds 200"); + +const ids = new Set(); +const sourceRecords = new Set(); +const records = []; + +for (let index = 0; index < headings.length; index += 1) { + const match = headings[index]; + const id = match[1]; + const title = match[2].trim(); + const bodyStart = match.index + match[0].length; + const bodyEnd = headings[index + 1]?.index ?? source.length; + const body = source.slice(bodyStart, bodyEnd); + const fields = new Map(); + + if (ids.has(id)) errors.push(`${id}: duplicate finding ID`); + ids.add(id); + if (!title) errors.push(`${id}: missing title`); + + for (const line of body.split("\n")) { + const fieldMatch = line.match(/^- \*\*([^*]+):\*\* (.+)$/); + if (!fieldMatch) continue; + const [, name, value] = fieldMatch; + if (fields.has(name)) errors.push(`${id}: duplicate field ${name}`); + fields.set(name, value.trim()); + } + + for (const field of REQUIRED_FIELDS) { + if (!fields.get(field)) errors.push(`${id}: missing ${field}`); + } + + const status = fields.get("Status")?.toLowerCase(); + const severity = fields.get("Severity")?.toLowerCase(); + const confidence = fields.get("Confidence")?.toLowerCase(); + const unresolved = fields.get("Unresolved")?.toLowerCase(); + const sourceField = fields.get("Source"); + + if (status && !ALLOWED_STATUS.has(status)) { + errors.push(`${id}: invalid Status ${status}`); + } + if (severity && !ALLOWED_SEVERITY.has(severity)) { + errors.push(`${id}: invalid Severity ${severity}`); + } + if (confidence && !ALLOWED_CONFIDENCE.has(confidence)) { + errors.push(`${id}: invalid Confidence ${confidence}`); + } + if (unresolved && !["yes", "no"].includes(unresolved)) { + errors.push(`${id}: Unresolved must be yes or no`); + } + if (["cleared", "refuted"].includes(status) && unresolved !== "no") { + errors.push(`${id}: ${status} findings must set Unresolved to no`); + } + if (sourceField) { + if (sourceRecords.has(sourceField)) errors.push(`${id}: duplicate Source record`); + sourceRecords.add(sourceField); + } + + records.push({ id, fields }); +} + +for (let index = 0; index < records.length; index += 1) { + const expected = `AUD-${String(index + 1).padStart(3, "0")}`; + if (records[index].id !== expected) { + errors.push(`${records[index].id}: expected sequential ID ${expected}`); + } +} + +for (const prefix of REQUIRED_SOURCES) { + if (!records.some((record) => record.fields.get("Source")?.includes(prefix))) { + errors.push(`missing source family ${prefix}`); + } +} +for (const category of REQUIRED_CATEGORIES) { + if (!records.some((record) => record.fields.get("Category") === category)) { + errors.push(`missing required category ${category}`); + } +} + +if (errors.length > 0) { + console.error(`v1341-audit-ledger-check: FAIL (${errors.length} issue(s))`); + for (const error of errors) console.error(`- ${error}`); + process.exit(1); +} + +console.log( + `v1341-audit-ledger-check: PASS (${records.length} unique findings; privacy scan clean)`, +); From 8009c251a62d41a938d8719f1d22e3657046c7ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:44:51 +0200 Subject: [PATCH 078/125] docs(01-15): complete performance and audit baseline plan - summarize deterministic workload and bundle evidence - record reconciled audit decisions, deviations, and self-check --- .../01-15-SUMMARY.md | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-15-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-15-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-15-SUMMARY.md new file mode 100644 index 000000000..e0d1f12a3 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-15-SUMMARY.md @@ -0,0 +1,139 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "15" +subsystem: testing +tags: [performance-baseline, bundle-budget, audit-ledger, privacy] + +requires: + - phase: 01-09 + provides: performance, correctness, and read-only live audit source reports +provides: + - bounded deterministic fixtures for Google, reminder, and Health Score workload shapes + - commit-pinned release bundle and watched-route budget evidence + - privacy-safe normalized finding ledger with an executable schema checker +affects: [01-16, 01-17, 01-18, 01-20, 01-21, 01-22, 01-24, 01-25] + +tech-stack: + added: [] + patterns: + - deterministic local fixture with explicit workload and deadline bounds + - machine-checked Markdown finding records with privacy-dangerous pattern rejection + +key-files: + created: + - scripts/v1341-performance-baseline.mjs + - scripts/v1341-audit-ledger-check.mjs + - .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-PERFORMANCE-BASELINE.md + - .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-AUDIT-RECONCILIATION.md + - .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-15-SUMMARY.md + modified: [] + +key-decisions: + - "Treat fixture timing as local code-shape evidence only; provider, database, cache, rendering, and production behavior remain unmeasured." + - "Keep confirmed, suspected, blocked, and cleared audit states distinct; an adjacent confirmed fix cannot resolve the externally unobserved Google report." + - "Reject credentials, identifiers, network authorities, local user paths, and explicit health values from the normalized audit ledger." + +patterns-established: + - "Bounded benchmark: require --fixture, reject production/live target arguments, cap duration, samples, scale, concurrency, and emitted data." + - "Audit ledger: sequential unique IDs plus source, status, severity, category, evidence, path, refutation, impact, requirement, verification, confidence, and unresolved fields." + +requirements-completed: [PERF-01, AUD-01, AUD-02, AUD-03, AUD-04, AUD-05] + +duration: 15min +completed: 2026-07-29 +--- + +# Phase 01 Plan 15: Performance Baseline and Audit Reconciliation Summary + +**Commit-pinned local workload and bundle measurements paired with a 35-finding privacy-safe audit ledger and executable completeness checks** + +## Performance + +- **Duration:** 15 min +- **Started:** 2026-07-29T12:28:51Z +- **Completed:** 2026-07-29T12:43:28Z +- **Tasks:** 3 +- **Files modified:** 5 + +## Accomplishments + +- Benchmarked the observed 786-page Google shape, a 2,000-medication reminder cohort with cleanup backlog, and dense/sparse Health Score processing with fixed concurrency, samples, scale, output, and a 30-second deadline. +- Built and analyzed one isolated source snapshot: 3,133 KB gzip total client chunks passed the 3,140 KB gate; all four watched routes passed with 25–29 KB headroom; Recharts remained one fingerprinted chunk. +- Reconciled 35 unique research, correctness, read-only live, and performance findings while preserving confirmed, suspected, blocked, and cleared states. +- Added a ledger checker that enforces required evidence fields, source/category coverage, sequential unique IDs, status consistency, and privacy-safe content. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Add bounded deterministic performance fixtures** - `c0321d2f2` (test) +2. **Task 2: Record release bundle and route-budget evidence** - `eccacd91e` (test) +3. **Task 3: Normalize and validate the audit finding ledger** - `4ba4ee38c` (test) + +## Files Created/Modified + +- `scripts/v1341-performance-baseline.mjs` - Refuses live/production modes and emits bounded cold/warm fixture evidence without sensitive data. +- `scripts/v1341-audit-ledger-check.mjs` - Validates finding structure, semantic consistency, source/category coverage, and privacy constraints. +- `01-PERFORMANCE-BASELINE.md` - Records environment, fixture scale, cold/median/p95 timings, heap deltas, bundle budgets, and explicit evidence limits. +- `01-AUDIT-RECONCILIATION.md` - Maps 35 deduplicated findings to requirements and smallest planned verification. +- `01-15-SUMMARY.md` - Captures execution evidence, decisions, deviations, and readiness. + +## Decisions Made + +- Local fixture timings are baselines for regression comparison, not production capacity or latency claims. +- The Health Score fixture measures dense/sparse construction, canonical selection, and a stable composite proxy; exact production score parity remains a database-backed verification requirement. +- The existing bundle checker does not emit Settings, Admin, or Documents rows while a watched-route budget is active. Their eager-import risk remains labelled as unmeasured rather than assigned invented figures. +- The reporter-specific Google sync failure remains externally blocked; confirmed cache, pagination, and outcome defects are independently actionable but cannot substantiate that resolution claim. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] Raised only the verification build heap** + +- **Found during:** Task 2 (release build and bundle evidence) +- **Issue:** The default build compiled successfully but its TypeScript worker exhausted Node's 4 GiB heap. +- **Fix:** Re-ran the build with an 8 GiB process heap in an isolated source snapshot; no repository or runtime limit changed. +- **Files modified:** None +- **Verification:** Production build completed and `node scripts/check-bundle-budget.mjs --check` passed. +- **Committed in:** `eccacd91e` + +**2. [Rule 1 - Bug] Kept regenerated Markdown free of trailing whitespace** + +- **Found during:** Task 2 report verification +- **Issue:** The generator used Markdown hard-break whitespace, causing `git diff --check` warnings. +- **Fix:** Emitted blank-separated metadata fields and regenerated the checked report. +- **Files modified:** `scripts/v1341-performance-baseline.mjs`, `01-PERFORMANCE-BASELINE.md` +- **Verification:** `git diff --check` returned clean. +- **Committed in:** `eccacd91e` + +--- + +**Total deviations:** 2 auto-fixed (1 blocking issue, 1 generated-report bug) + +**Impact on plan:** Both changes were verification-only and preserved the prohibition on production load, optimization, dependency changes, and product-source edits. + +## Issues Encountered + +- A first isolated snapshot used an external `node_modules` symlink that Turbopack correctly rejected; an APFS-cloned dependency directory kept resolution inside the snapshot. +- Generated Prisma artifacts are intentionally untracked and had to be supplied as build prerequisites. They were not treated as product-source changes. + +## User Setup Required + +None - no external service configuration or manual action is required. + +## Next Phase Readiness + +- Plans 16–22 can use stable finding IDs, requirement mappings, and planned verification fields to distinguish implementation work from hypotheses. +- Plan 24 should rerun the bundle gate after the final merge, add direct Settings/Admin/Documents route evidence if needed, and reconcile each unresolved finding without weakening privacy or screening. +- Aggregate bundle headroom is only 7 KB, so the final merged build must remeasure rather than relying on this intermediate snapshot. + +## Self-Check: PASSED + +- All five owned files exist. +- Task commits `c0321d2f2`, `eccacd91e`, and `4ba4ee38c` exist on the branch. +- Fresh bounded fixture execution, ledger schema/privacy check, bundle budget gate, and `git diff --check` all pass. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From a686c7200b4eb5c372e036b693e10536d1d16dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:45:40 +0200 Subject: [PATCH 079/125] feat(01-19): persist Apple Health ECG recordings - encrypt and upsert stable content-addressed ECG recordings transactionally - keep XML imports successful while ECG members fail with scalar counters --- src/lib/apple-health/ecg-import.ts | 93 +++++++++++++++++++ src/lib/jobs/apple-health-import-worker.ts | 89 ++++++++++++++---- .../import-apple-health-export.ts | 15 +++ 3 files changed, 180 insertions(+), 17 deletions(-) create mode 100644 src/lib/apple-health/ecg-import.ts diff --git a/src/lib/apple-health/ecg-import.ts b/src/lib/apple-health/ecg-import.ts new file mode 100644 index 000000000..c48096758 --- /dev/null +++ b/src/lib/apple-health/ecg-import.ts @@ -0,0 +1,93 @@ +/** + * Transactional persistence for one normalized Apple Health ECG recording. + * + * The stable identity is derived from canonical recording content, never the + * archive filename. The database uniqueness constraint adds the user boundary, + * so identical recordings deduplicate for one user while remaining independent + * across tenants. + */ +import { createHash } from "node:crypto"; +import type { PrismaClient } from "@/generated/prisma/client"; +import type { NormalizedAppleHealthEcg } from "@/lib/apple-health/ecg-csv"; +import { encryptWaveformToBytes } from "@/lib/withings/ecg-waveform-codec"; + +export type AppleHealthEcgImportOutcome = "imported" | "updated" | "skipped"; + +function recordingIdentity(ecg: NormalizedAppleHealthEcg): string { + const hash = createHash("sha256"); + hash.update("healthlog:apple-health-ecg:v1\0"); + hash.update(ecg.recordedAt.toISOString()); + hash.update(`\0${ecg.samplingFrequency}\0${ecg.lead ?? ""}`); + hash.update( + `\0${ecg.averageHeartRate ?? ""}\0${ecg.rhythmClassification ?? ""}\0`, + ); + const sample = Buffer.allocUnsafe(8); + for (const value of ecg.samples) { + sample.writeDoubleLE(value, 0); + hash.update(sample); + } + sample.fill(0); + return `apple-health:ecg:${hash.digest("hex")}`; +} + +export async function importAppleHealthEcg(input: { + userId: string; + ecg: NormalizedAppleHealthEcg; + prisma: Pick; +}): Promise { + const { userId, ecg, prisma } = input; + if (ecg.samples.length === 0) return "skipped"; + + const externalRecordingId = recordingIdentity(ecg); + // Encryption is deliberately completed before entering the transaction. + // Crypto failure therefore fails closed without opening a write transaction. + const waveformEncrypted = encryptWaveformToBytes(ecg.samples); + const sampleCount = ecg.samples.length; + const durationSeconds = sampleCount / ecg.samplingFrequency; + + try { + return await prisma.$transaction(async (tx) => { + const where = { + userId_source_externalRecordingId: { + userId, + source: "APPLE_HEALTH" as const, + externalRecordingId, + }, + }; + const existing = await tx.ecgRecording.findUnique({ + where, + select: { id: true }, + }); + await tx.ecgRecording.upsert({ + where, + create: { + userId, + source: "APPLE_HEALTH", + externalRecordingId, + recordedAt: ecg.recordedAt, + waveformEncrypted, + samplingFrequency: ecg.samplingFrequency, + sampleCount, + durationSeconds, + lead: ecg.lead, + averageHeartRate: ecg.averageHeartRate, + rhythmClassification: ecg.rhythmClassification, + }, + update: { + recordedAt: ecg.recordedAt, + waveformEncrypted, + samplingFrequency: ecg.samplingFrequency, + sampleCount, + durationSeconds, + lead: ecg.lead, + averageHeartRate: ecg.averageHeartRate, + rhythmClassification: ecg.rhythmClassification, + }, + }); + return existing ? "updated" : "imported"; + }); + } finally { + waveformEncrypted.fill(0); + ecg.samples.fill(0); + } +} diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index b71dfa0ab..4a492a9aa 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -29,6 +29,9 @@ import type { PrismaClient } from "@/generated/prisma/client"; import { prisma, toJson } from "@/lib/db"; import type { Job } from "pg-boss"; +import { streamAppleHealthEcgMembers } from "@/lib/apple-health/archive-stream"; +import { parseAppleHealthEcgCsv } from "@/lib/apple-health/ecg-csv"; +import { importAppleHealthEcg } from "@/lib/apple-health/ecg-import"; import { extractExportXml } from "@/lib/import/unzip-export-xml"; import { getGlobalBoss } from "@/lib/jobs/boss-instance"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; @@ -98,7 +101,16 @@ export const APPLE_HEALTH_IMPORT_V2_QUEUE = "apple-health-import-v2"; export const APPLE_HEALTH_IMPORT_LEGACY_QUEUE = "apple-health-import"; /** Parser semantics carried by every newly-created ImportJob. */ -export const APPLE_HEALTH_IMPORT_PARSER_REVISION = 2; +export const APPLE_HEALTH_IMPORT_PARSER_REVISION = 3; + +const APPLE_HEALTH_ECG_ARCHIVE_LIMITS = { + maxMembers: 20_000, + maxEcgMembers: 2_000, + maxMemberBytes: 16 * 1024 * 1024, + maxTotalEcgBytes: 512 * 1024 * 1024, + maxCompressionRatio: 200, +} as const; +const APPLE_HEALTH_ECG_MAX_SAMPLES = 2_000_000; /** Concurrency cap per worker host. */ export const APPLE_HEALTH_IMPORT_CONCURRENCY = 1; @@ -326,23 +338,66 @@ export async function handleAppleHealthImport( elapsedMs: 0, }); - const result: ImportJobResult = await streamParseExportXml({ - xmlPath: unzip.xmlPath, - userId, - userTimezone, - prisma, - onProgress: async (snapshot) => { - // The phase label here is what the polling endpoint surfaces; - // map "parsing" / "upserting" through verbatim. - await prisma.importJob.update({ - where: { id: importJobId }, - data: { - status: snapshot.currentPhase, - progress: toJson(snapshot), - }, - }); + const result: ImportJobResult = { + ...(await streamParseExportXml({ + xmlPath: unzip.xmlPath, + userId, + userTimezone, + prisma, + onProgress: async (snapshot) => { + // The phase label here is what the polling endpoint surfaces; + // map "parsing" / "upserting" through verbatim. + await prisma.importJob.update({ + where: { id: importJobId }, + data: { + status: snapshot.currentPhase, + progress: toJson(snapshot), + }, + }); + }, + })), + ecg: { + discovered: 0, + imported: 0, + updated: 0, + skipped: 0, + failed: 0, }, - }); + }; + + // Auxiliary ECGs are intentionally fail-soft. The canonical export.xml + // transaction has already completed; malformed, oversized, or unwritable + // ECG members affect only bounded scalar counters in the terminal result. + const ecg = result.ecg; + ecg.discovered = unzip.otherMembers.filter((member) => { + const lower = member.name.toLowerCase(); + return lower.includes("electrocardiograms/") && lower.endsWith(".csv"); + }).length; + try { + for await (const member of streamAppleHealthEcgMembers({ + archivePath: uploadPath, + limits: APPLE_HEALTH_ECG_ARCHIVE_LIMITS, + })) { + try { + const parsed = await parseAppleHealthEcgCsv({ + memberName: member.name, + stream: member.stream, + maxSamples: APPLE_HEALTH_ECG_MAX_SAMPLES, + }); + const outcome = await importAppleHealthEcg({ + userId, + ecg: parsed, + prisma, + }); + ecg[outcome] += 1; + } catch { + ecg.failed += 1; + } + } + } catch { + const completed = ecg.imported + ecg.updated + ecg.skipped + ecg.failed; + ecg.failed += Math.max(1, ecg.discovered - completed); + } // Done. Persist the terminal envelope. await prisma.importJob.update({ diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index 320e9c46f..fc1e9f838 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -120,6 +120,14 @@ export interface ImportJobResult { /** Estimated `(measurement type, local day)` aggregate rows considered. */ rows: number; }; + /** Bounded auxiliary ECG outcomes. Never contains filenames or waveform data. */ + ecg: { + discovered: number; + imported: number; + updated: number; + skipped: number; + failed: number; + }; totals: { recordsRead: number; rowsUpserted: number; @@ -1086,6 +1094,13 @@ export async function streamParseExportXml( days: cumulativeEstimatedDays.size, rows: cumulativeEstimatedRows, }, + ecg: { + discovered: 0, + imported: 0, + updated: 0, + skipped: 0, + failed: 0, + }, totals: { recordsRead, rowsUpserted, From 4b64556b38cae685ebea4f1bfc3b1e02eedefd79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:47:22 +0200 Subject: [PATCH 080/125] feat(01-19): expose privacy-safe ECG import outcomes - return and render bounded ECG result counters with partial-failure wording - document archive limits, encrypted storage, and exact WHOOP constraints --- docs/integrations/apple-health.md | 77 ++++++++++++++++--- .../[jobId]/status/route.ts | 30 +++++++- .../import-panel/apple-health-import-card.tsx | 22 +++++- 3 files changed, 118 insertions(+), 11 deletions(-) diff --git a/docs/integrations/apple-health.md b/docs/integrations/apple-health.md index 0b3c4209d..bc21e02ae 100644 --- a/docs/integrations/apple-health.md +++ b/docs/integrations/apple-health.md @@ -30,8 +30,10 @@ re-uploading the same archive merges instead of duplicating. The archive contains `apple_health_export/export.xml` (the canonical record stream) plus a handful of auxiliary files (workout routes, -clinical records, electrocardiograms). The importer reads -`export.xml` and ignores the rest. +clinical records, electrocardiograms). The importer reads `export.xml` +and supported ECG CSVs under +`apple_health_export/electrocardiograms/`; unsupported auxiliary files +remain ignored. ## 2. Upload to HealthLog @@ -76,7 +78,15 @@ canonical envelope: "uploadBytes": 412345678, "exportedAt": "2026-04-30T18:12:00.000Z", "progress": { "records_seen": 124000, "workouts_seen": 230 }, - "result": null, + "result": { + "ecg": { + "discovered": 2, + "imported": 1, + "updated": 0, + "skipped": 0, + "failed": 1 + } + }, "failureReason": null }, "error": null @@ -94,8 +104,8 @@ they triggered. ## What gets imported -The parser folds four XML element types into the existing HealthLog -schema (CHANGELOG v1.4.34): +The parser folds four XML element types and supported auxiliary ECG +CSVs into the existing HealthLog schema (CHANGELOG v1.4.34): | XML element | Maps to | Notes | | ------------------ | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -103,11 +113,45 @@ schema (CHANGELOG v1.4.34): | `` | `Workout` rows | Activity type + duration + energy + distance. | | `` | `Measurement` rows | Compound samples like blood pressure (systolic + diastolic) explode into their per-metric children. | | `` | `Measurement` rows where the type maps | Health-record FHIR snapshots; only the metric-typed fields are ingested. | +| ECG CSV | `EcgRecording` rows | The source-provided waveform is encrypted before persistence; descriptors and the device's classification are stored without diagnostic inference. | The HK quantity types that map to HealthLog metric types live in `src/lib/measurements/apple-health-mapping.ts`. Anything outside the -mapped set (workout routes, ECG voltage traces) is counted in -`progress.records_skipped` but not stored. +mapped set (for example workout routes) is counted as unsupported and +not stored. + +### ECG archive safety, privacy, and idempotency + +ECG support is source-agnostic: if HealthKit includes a valid +`HKElectrocardiogram` CSV, HealthLog can import it regardless of which +compatible device originally wrote it. Archive member names are never +used as filesystem paths and ECGs are never extracted to plaintext +temporary files. + +The auxiliary reader enforces limits before and during decompression: + +- at most 20,000 total archive members and 2,000 ECG CSV members; +- at most 16 MiB per decompressed ECG CSV and 512 MiB across all ECG CSVs; +- at most a 200:1 declared compression ratio; and +- at most 2,000,000 waveform samples per recording. + +Encrypted ZIP members, unsupported compression, unsafe or duplicate +paths, forged size metadata, malformed timestamps or samples, and +limit overruns are rejected. An ECG failure is fail-soft: valid +`export.xml` measurements and workouts still commit, while the +terminal result reports only +`discovered/imported/updated/skipped/failed` ECG counts. It never +returns filenames, source labels, sample values, or raw parser errors. + +Waveforms are converted to integer microvolts and encrypted with the +existing AES-256-GCM ECG codec before the transaction writes an +`EcgRecording` with `source=APPLE_HEALTH`. Stable identity is derived +from normalized recording content rather than the CSV filename, so a +renamed re-export updates the same user-scoped row while distinct +recordings and different users remain independent. Known Apple +device classifications are preserved as source-reported facts; +unknown or localized classifications are stored as `null`. HealthLog +does not inspect the waveform to infer AFib or make a diagnosis. ### Cumulative HK type → daily aggregate @@ -148,8 +192,8 @@ The two-axis idempotency story: 1. **File-level** — re-uploading the same `export.zip` (same SHA-256 of bytes) short-circuits to the previous job only when that job - used the current parser revision. Existing jobs are revision 1; - new jobs use revision 2, so a pre-fix successful archive can be + used the current parser revision. Existing jobs may use revisions 1 + or 2; new jobs use revision 3, so an earlier successful archive can be deliberately processed again under the corrected aggregation. 2. **Record-level** — when an older `export.zip` is re-exported from iOS (same history, slightly newer device timestamps), every @@ -162,6 +206,21 @@ sees fresh data merged in without duplicates. The job result line in the status response reports `recordsInserted` and `recordsUpdated` separately. +### WHOOP records and current export limits + +Supported measurements written into HealthKit by another app are +handled by HealthKit type, not by a raw writer label. For example, a +supported heart-rate record written by WHOOP is imported as an +Apple Health measurement and participates in the normal source +priority rules. + +WHOOP currently exports neither ECG recordings nor Blood Pressure +Insights to Apple Health. Those two WHOOP data classes therefore +cannot arrive through an Apple Health `export.zip`; a WHOOP writer +label on some other HealthKit record is not evidence of a WHOOP ECG +export. HealthLog's direct WHOOP OAuth/API integration remains +supported for the WHOOP data the API exposes. + ## Admin variant — import on behalf of another user `POST /api/admin/import-apple-health-export` is the cookie-only admin diff --git a/src/app/api/import/apple-health-export/[jobId]/status/route.ts b/src/app/api/import/apple-health-export/[jobId]/status/route.ts index 41e3e11f4..4cb8c2276 100644 --- a/src/app/api/import/apple-health-export/[jobId]/status/route.ts +++ b/src/app/api/import/apple-health-export/[jobId]/status/route.ts @@ -36,6 +36,32 @@ interface ImportJobStatusResponse { failureReason: string | null; } +function boundedCount(value: unknown): number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 + ? value + : 0; +} + +function privacySafeResult( + value: Record | null, +): Record | null { + if (!value) return null; + const ecg = + typeof value.ecg === "object" && value.ecg !== null + ? (value.ecg as Record) + : {}; + return { + ...value, + ecg: { + discovered: boundedCount(ecg.discovered), + imported: boundedCount(ecg.imported), + updated: boundedCount(ecg.updated), + skipped: boundedCount(ecg.skipped), + failed: boundedCount(ecg.failed), + }, + }; +} + export const GET = apiHandler( async (_request: NextRequest, ctx: RouteContext) => { const { user } = await requireAuth(); @@ -68,7 +94,9 @@ export const GET = apiHandler( uploadBytes: row.uploadBytes, exportedAt: row.exportedAt?.toISOString() ?? null, progress: (row.progress as Record) ?? {}, - result: (row.result as Record | null) ?? null, + result: privacySafeResult( + (row.result as Record | null) ?? null, + ), failureReason: row.failureReason, }; diff --git a/src/components/settings/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx index 1d8d12af8..cbd26db28 100644 --- a/src/components/settings/import-panel/apple-health-import-card.tsx +++ b/src/components/settings/import-panel/apple-health-import-card.tsx @@ -30,6 +30,13 @@ interface JobStatus { totals?: { recordsRead?: number; rowsUpserted?: number }; clinical?: { skipped?: number }; cumulativeEstimates?: { days?: number; rows?: number }; + ecg?: { + discovered?: number; + imported?: number; + updated?: number; + skipped?: number; + failed?: number; + }; } | null; failureReason: string | null; } @@ -152,6 +159,8 @@ export function AppleHealthImportCard() { const isFailed = status?.status === "failed"; const busy = uploading || isRunning; const percent = status?.progress?.percent ?? null; + const ecg = status?.result?.ecg; + const ecgWritten = (ecg?.imported ?? 0) + (ecg?.updated ?? 0); return ( )} + {isDone && (ecg?.discovered ?? 0) > 0 && ( +

    + ECGs: {ecg?.discovered ?? 0} discovered, {ecg?.imported ?? 0}{" "} + imported, {ecg?.updated ?? 0} updated, {ecg?.skipped ?? 0} skipped,{" "} + {ecg?.failed ?? 0} failed. ECG failures do not discard supported + records imported from export.xml. +

    + )} {isDone && (status?.result?.cumulativeEstimates?.days ?? 0) > 0 && ( Date: Wed, 29 Jul 2026 14:47:47 +0200 Subject: [PATCH 081/125] fix(01-19): avoid phantom ECG failure counts - increment archive-level failures only for discovered ECG artifacts --- src/lib/jobs/apple-health-import-worker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/jobs/apple-health-import-worker.ts b/src/lib/jobs/apple-health-import-worker.ts index 4a492a9aa..542ecb941 100644 --- a/src/lib/jobs/apple-health-import-worker.ts +++ b/src/lib/jobs/apple-health-import-worker.ts @@ -396,7 +396,7 @@ export async function handleAppleHealthImport( } } catch { const completed = ecg.imported + ecg.updated + ecg.skipped + ecg.failed; - ecg.failed += Math.max(1, ecg.discovered - completed); + ecg.failed += Math.max(0, ecg.discovered - completed); } // Done. Persist the terminal envelope. From 5cfcb69ef2ffeb7105e5747964cce87bfe5a27d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:49:12 +0200 Subject: [PATCH 082/125] feat(01-21): add guarded Google sync progress - persist one bounded owner-scoped current-run envelope - reject stale writers and materialize interrupted runs safely --- .../migration.sql | 2 + prisma/schema.prisma | 3 + src/lib/google-health/sync-progress.ts | 315 ++++++++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 prisma/migrations/0287_google_health_sync_progress/migration.sql create mode 100644 src/lib/google-health/sync-progress.ts diff --git a/prisma/migrations/0287_google_health_sync_progress/migration.sql b/prisma/migrations/0287_google_health_sync_progress/migration.sql new file mode 100644 index 000000000..a2cdf3fc1 --- /dev/null +++ b/prisma/migrations/0287_google_health_sync_progress/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "google_health_connections" +ADD COLUMN "sync_progress" JSONB; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 4c6b771d2..5d0562c6f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -3857,6 +3857,9 @@ model GoogleHealthConnection { /// pre-fix externalId (a re-scored night minted parallel copies). Null = /// repair pending (or the connection predates the fix). sleepRepairedAt DateTime? @map("sleep_repaired_at") + /// One bounded, owner-scoped current sync run. This is replaced at the start + /// of every run and never retains provider payloads or historical runs. + syncProgress Json? @map("sync_progress") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") diff --git a/src/lib/google-health/sync-progress.ts b/src/lib/google-health/sync-progress.ts new file mode 100644 index 000000000..e34e53cd3 --- /dev/null +++ b/src/lib/google-health/sync-progress.ts @@ -0,0 +1,315 @@ +import { randomBytes } from "node:crypto"; + +import { Prisma } from "@/generated/prisma/client"; +import { prisma } from "@/lib/db"; + +export const GOOGLE_HEALTH_PROGRESS_STALE_MS = 30 * 60 * 1000; +export const GOOGLE_HEALTH_PROGRESS_MAX_RESOURCES = 16; + +const MAX_COUNTER = 2_147_483_647; +const MAX_DURATION_MS = 24 * 60 * 60 * 1000; +const progressWriteTails = new Map>(); + +const RUN_STATES = new Set([ + "in_progress", + "complete", + "partial", + "zero", + "truncated", + "failed", + "interrupted", +] as const); +const RESOURCE_STATES = new Set([ + "pending", + "complete", + "partial", + "empty", + "truncated", + "failed", +] as const); +const REASON_CODES = new Set([ + "collection_failed", + "token_failed", + "upsert_failed", + "rollup_failed", + "existing_page_limit", +] as const); + +export type GoogleHealthSyncState = + | "in_progress" + | "complete" + | "partial" + | "zero" + | "truncated" + | "failed" + | "interrupted"; + +export type GoogleHealthResourceStatus = + "pending" | "complete" | "partial" | "empty" | "truncated" | "failed"; + +export type GoogleHealthReasonCode = + | "collection_failed" + | "token_failed" + | "upsert_failed" + | "rollup_failed" + | "existing_page_limit"; + +export interface GoogleHealthResourceOutcome { + resource: string; + pages: number; + fetched: number; + mapped: number; + written: number; + status: GoogleHealthResourceStatus; + durationMs: number; + truncated: boolean; + reasonCode: GoogleHealthReasonCode | null; +} + +export interface GoogleHealthSyncProgress { + runId: string; + state: GoogleHealthSyncState; + startedAt: string; + updatedAt: string; + terminalAt?: string; + imported: number; + failed: boolean; + resources: GoogleHealthResourceOutcome[]; +} + +const INITIAL_RESOURCES = [ + "workout", + "sleep", + "bounded-metrics", + "activity", + "dense-heart-rate", +] as const; + +function boundedInteger(value: unknown, maximum = MAX_COUNTER): number { + if (typeof value !== "number" || !Number.isFinite(value)) return 0; + return Math.min(maximum, Math.max(0, Math.trunc(value))); +} + +function sanitizedResourceName(value: unknown): string { + if (typeof value !== "string") return "unknown"; + const normalized = value.toLowerCase().replace(/[^a-z0-9-]/g, "-"); + return normalized.replace(/-+/g, "-").slice(0, 48) || "unknown"; +} + +function resourceOutcome( + value: unknown, + fallbackResource = "unknown", +): GoogleHealthResourceOutcome { + const record = + value && typeof value === "object" + ? (value as Record) + : ({} as Record); + const status = RESOURCE_STATES.has( + record.status as GoogleHealthResourceStatus, + ) + ? (record.status as GoogleHealthResourceStatus) + : "pending"; + const reasonCode = REASON_CODES.has( + record.reasonCode as GoogleHealthReasonCode, + ) + ? (record.reasonCode as GoogleHealthReasonCode) + : null; + return { + resource: sanitizedResourceName(record.resource ?? fallbackResource), + pages: boundedInteger(record.pages), + fetched: boundedInteger(record.fetched), + mapped: boundedInteger(record.mapped), + written: boundedInteger(record.written), + status, + durationMs: boundedInteger(record.durationMs, MAX_DURATION_MS), + truncated: record.truncated === true, + reasonCode, + }; +} + +function initialResources(): GoogleHealthResourceOutcome[] { + return INITIAL_RESOURCES.map((resource) => resourceOutcome({}, resource)); +} + +function isoDate(value: unknown, fallback: Date): string { + if (typeof value !== "string") return fallback.toISOString(); + const parsed = new Date(value); + return Number.isFinite(parsed.getTime()) + ? parsed.toISOString() + : fallback.toISOString(); +} + +function progressEnvelope( + value: unknown, + fallback: { + runId: string; + now: Date; + startedAt?: string; + }, +): GoogleHealthSyncProgress { + const record = + value && typeof value === "object" + ? (value as Record) + : ({} as Record); + const state = RUN_STATES.has(record.state as GoogleHealthSyncState) + ? (record.state as GoogleHealthSyncState) + : "in_progress"; + const resources = Array.isArray(record.resources) + ? record.resources + .slice(0, GOOGLE_HEALTH_PROGRESS_MAX_RESOURCES) + .map((resource) => resourceOutcome(resource)) + : initialResources(); + const envelope: GoogleHealthSyncProgress = { + runId: fallback.runId, + state, + startedAt: isoDate(record.startedAt ?? fallback.startedAt, fallback.now), + updatedAt: fallback.now.toISOString(), + imported: boundedInteger(record.imported ?? record.written), + failed: record.failed === true, + resources, + }; + if (state !== "in_progress") { + envelope.terminalAt = isoDate(record.terminalAt, fallback.now); + } + return envelope; +} + +function asJson(value: GoogleHealthSyncProgress): Prisma.InputJsonValue { + return value as unknown as Prisma.InputJsonValue; +} + +async function serializeProgressWrite( + userId: string, + operation: () => Promise, +): Promise { + const previous = progressWriteTails.get(userId) ?? Promise.resolve(); + let release = () => {}; + const tail = new Promise((resolve) => { + release = resolve; + }); + progressWriteTails.set(userId, tail); + await previous; + try { + return await operation(); + } finally { + release(); + if (progressWriteTails.get(userId) === tail) { + progressWriteTails.delete(userId); + } + } +} + +export async function startGoogleHealthSyncProgress( + userId: string, + now = new Date(), +): Promise { + const runId = randomBytes(18).toString("base64url"); + const envelope = progressEnvelope( + { + state: "in_progress", + startedAt: now.toISOString(), + imported: 0, + failed: false, + resources: initialResources(), + }, + { runId, now }, + ); + await serializeProgressWrite(userId, () => + prisma.googleHealthConnection.update({ + where: { userId }, + data: { syncProgress: asJson(envelope) }, + }), + ); + return envelope; +} + +async function guardedProgressUpdate( + userId: string, + runId: string, + envelope: GoogleHealthSyncProgress, +): Promise { + const result = await serializeProgressWrite(userId, () => + prisma.googleHealthConnection.updateMany({ + where: { + userId, + syncProgress: { + path: ["runId"], + equals: runId, + }, + }, + data: { syncProgress: asJson(envelope) }, + }), + ); + return result.count === 1; +} + +export async function updateGoogleHealthSyncProgress( + userId: string, + runId: string, + update: Record, + now = new Date(), +): Promise { + // Issue the guarded write without a preceding read. Besides avoiding a + // read/modify/write window, this makes the database choose one winner when a + // delayed terminal competes with a new run. Callers pass the complete + // bounded envelope (including resources and startedAt) on each update. + const merged = progressEnvelope(update, { + runId, + now, + startedAt: + typeof update.startedAt === "string" ? update.startedAt : undefined, + }); + return guardedProgressUpdate(userId, runId, merged); +} + +async function loadProgress( + userId: string, +): Promise { + const connection = await prisma.googleHealthConnection.findUnique({ + where: { userId }, + select: { syncProgress: true }, + }); + const stored = connection?.syncProgress; + if (!stored || typeof stored !== "object" || Array.isArray(stored)) { + return null; + } + const runId = + typeof (stored as Record).runId === "string" + ? String((stored as Record).runId).slice(0, 128) + : ""; + if (!runId) return null; + const updatedAt = isoDate( + (stored as Record).updatedAt, + new Date(0), + ); + return progressEnvelope(stored, { + runId, + now: new Date(updatedAt), + }); +} + +export async function readGoogleHealthSyncProgress( + userId: string, + now = new Date(), +): Promise { + const current = await loadProgress(userId); + if (!current || current.state !== "in_progress") return current; + const updatedAt = new Date(current.updatedAt).getTime(); + if (now.getTime() - updatedAt <= GOOGLE_HEALTH_PROGRESS_STALE_MS) { + return current; + } + + const interrupted = progressEnvelope( + { + ...current, + state: "interrupted", + failed: true, + terminalAt: now.toISOString(), + }, + { runId: current.runId, now, startedAt: current.startedAt }, + ); + if (await guardedProgressUpdate(userId, current.runId, interrupted)) { + return interrupted; + } + return loadProgress(userId); +} From a4f00f16f337d10a31d1adf98eeaa2f092ab2153 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:49:51 +0200 Subject: [PATCH 083/125] docs(01-19): complete Apple Health ECG import plan - record bounded encrypted import behavior and verification results - document the counter edge-case deviation and concurrent typecheck issue --- .../01-19-SUMMARY.md | 147 ++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-19-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-19-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-19-SUMMARY.md new file mode 100644 index 000000000..c12a55980 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-19-SUMMARY.md @@ -0,0 +1,147 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "19" +subsystem: apple-health-import +tags: [apple-health, ecg, zip, encryption, postgres, privacy] + +requires: + - phase: 01-12 + provides: Apple Health ECG archive, persistence, privacy, and WHOOP contract tests +provides: + - Path-safe bounded Apple Health ECG ZIP member reader + - Incremental ECG CSV normalization with device-only classification + - Encrypted content-addressed APPLE_HEALTH EcgRecording persistence + - Fail-soft ECG worker branch and privacy-safe result counters + - Exact Apple Health ECG and WHOOP limitation documentation +affects: [apple-health-import, ecg, settings-import, whoop-documentation] + +tech-stack: + added: [] + patterns: + - Central-directory validation followed by actual decompressed-byte enforcement + - Auxiliary health artifacts fail softly beside the canonical XML transaction + - Content-derived identity scoped by the existing tenant-aware database unique key + +key-files: + created: + - src/lib/apple-health/archive-stream.ts + - src/lib/apple-health/ecg-csv.ts + - src/lib/apple-health/ecg-import.ts + modified: + - src/lib/jobs/apple-health-import-worker.ts + - src/lib/measurements/import-apple-health-export.ts + - src/app/api/import/apple-health-export/[jobId]/status/route.ts + - src/components/settings/import-panel/apple-health-import-card.tsx + - docs/integrations/apple-health.md + +key-decisions: + - "Validate the complete ZIP directory before yielding ECG data and enforce both declared and actual decompression bounds." + - "Derive externalRecordingId from normalized ECG content, leaving tenant separation to the existing compound unique key." + - "Preserve only known source-device rhythm classifications; unknown labels remain null and waveforms are never diagnostically interpreted." + - "Complete the canonical export.xml import before the fail-soft auxiliary ECG branch so bad ECGs cannot roll back valid XML data." + +patterns-established: + - "ECG result envelopes contain only discovered/imported/updated/skipped/failed integer counters." + - "Raw ECG samples are encrypted before a database transaction begins and scrubbed after the write attempt." + +requirements-completed: [HK-01, HK-02, HK-03, HK-04, HK-05, HK-06, HK-07] + +duration: 10min +completed: 2026-07-29 +--- + +# Phase 01 Plan 19: Apple Health ECG Import Summary + +**Bounded path-safe ECG archive ingestion with AES-256-GCM persistence, content-stable identity, fail-soft XML coexistence, and privacy-safe progress counters** + +## Performance + +- **Duration:** 10 min +- **Started:** 2026-07-29T12:39:00Z +- **Completed:** 2026-07-29T12:48:47Z +- **Tasks:** 3 +- **Files modified:** 8 + +## Accomplishments + +- Added a no-temp-file ZIP reader that rejects traversal, duplicate names, encryption, unsupported methods, forged metadata, compression bombs, and actual byte overruns. +- Parsed Apple ECG CSVs incrementally into bounded integer-microvolt waveforms while discarding patient/source labels and accepting only known device classifications. +- Persisted APPLE_HEALTH ECGs through the existing encryption codec and `EcgRecording` model with filename-independent idempotency and transactional rollback. +- Kept valid WHOOP-written and other supported `export.xml` records successful when an auxiliary ECG is malformed, unsafe, or cannot be written. +- Exposed only bounded ECG counters to the status route and card, and documented exact limits and WHOOP export constraints. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Stream only safe bounded ECG archive members** - `f06137c0d` +2. **Task 2: Encrypt and idempotently persist source-device ECG records** - `a686c7200` +3. **Task 3: Expose bounded progress and document exact support** - `4b64556b3` +4. **Task 2 follow-up: Avoid phantom ECG failure counts** - `addd6b700` + +## Files Created/Modified + +- `src/lib/apple-health/archive-stream.ts` - Validates ZIP structure and streams only bounded ECG CSV data. +- `src/lib/apple-health/ecg-csv.ts` - Incrementally normalizes ECG descriptors and waveform samples. +- `src/lib/apple-health/ecg-import.ts` - Derives stable identity, encrypts samples, and transactionally upserts recordings. +- `src/lib/jobs/apple-health-import-worker.ts` - Runs ECG ingestion as a parser-revision-3 fail-soft auxiliary branch. +- `src/lib/measurements/import-apple-health-export.ts` - Defines the bounded ECG result envelope. +- `src/app/api/import/apple-health-export/[jobId]/status/route.ts` - Sanitizes ECG outcomes to five non-negative counters. +- `src/components/settings/import-panel/apple-health-import-card.tsx` - Renders ECG-only success and partial outcomes. +- `docs/integrations/apple-health.md` - Documents supported ECG behavior, bounds, privacy, idempotency, and WHOOP limitations. + +## Decisions Made + +- Used Node's existing ZIP/zlib capabilities and no new dependency or schema change. +- Kept content identity identical across users while relying on `(userId, source, externalRecordingId)` for tenant-safe deduplication. +- Treated source-device classification as a stored fact only; no waveform analysis, inference, or diagnosis was added. +- Stored ECG counters only in job results; filenames, writer labels, samples, values, and raw per-ECG failures do not enter result or logging output. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Prevented a phantom failed-ECG count** + +- **Found during:** Final review after Task 3 +- **Issue:** A structural archive error with zero discovered ECG members could increment `failed` to one. +- **Fix:** Archive-level failure accounting now increments only for discovered ECG artifacts not already represented by another terminal counter. +- **Files modified:** `src/lib/jobs/apple-health-import-worker.ts` +- **Verification:** Worker ECG source contract and full Plan 12 unit matrix pass. +- **Committed in:** `addd6b700` + +--- + +**Total deviations:** 1 auto-fixed bug +**Impact on plan:** Corrected bounded-counter accuracy without expanding scope. + +## Verification + +- Plan 12 and regression unit suites: **107 tests passed across 8 files**. +- Real PostgreSQL Apple Health suites: **25 tests passed across 4 files**, including ECG encryption/idempotency/rollback, worker failures, natural keys, and orphan reconciliation. +- Focused ESLint, Prettier, documentation diff, and privacy/source-contract checks passed. +- Repository-wide `pnpm typecheck` is currently blocked only by concurrent Google Health work: a not-yet-created status route and test mock tuple typing in `src/lib/google-health/**`. No Plan 19 file appears in the TypeScript error output. + +## Issues Encountered + +- The Task 3 verification command references `src/components/settings/import-panel/__tests__/apple-health-import-card.test.tsx`, but that file does not exist in the Plan 12 test commit. The available import-panel component suite was run instead and passed. +- Concurrent plans modified package locks, Prisma schema/migrations, and job files during execution. None were staged in Plan 19 commits. + +## User Setup Required + +None - no new dependency, migration, environment variable, or external service configuration is required. + +## Next Phase Readiness + +- Apple Health ECG import is ready for release-level validation. +- Plan 23 remains the sole owner of locale JSON; the card therefore uses the permitted temporary English ECG outcome copy. + +## Self-Check: PASSED + +- All eight implementation/documentation files exist. +- Task commits `f06137c0d`, `a686c7200`, `4b64556b3`, and `addd6b700` exist. + +--- + +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From 6a3861122c3bfff7b05732da3ff8f85279591f89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:50:30 +0200 Subject: [PATCH 084/125] feat(01-22): publish truthful cohort outcomes - fold complete partial failed parked and skipped user verdicts - bound serialized job facts to a privacy-safe scalar allowlist - preserve provider retry isolation and reminder queue completion --- src/lib/jobs/job-outcome.ts | 116 ++++++++++- src/lib/jobs/reminder/google-health-sync.ts | 93 +++++---- src/lib/jobs/reminder/poll-cohort.ts | 131 +++++++++++- src/lib/jobs/reminder/withings-sync.ts | 211 +++++++++++--------- 4 files changed, 410 insertions(+), 141 deletions(-) diff --git a/src/lib/jobs/job-outcome.ts b/src/lib/jobs/job-outcome.ts index 34b4c8b8e..34db35a3e 100644 --- a/src/lib/jobs/job-outcome.ts +++ b/src/lib/jobs/job-outcome.ts @@ -25,7 +25,7 @@ * what makes them visible at all — today they are visible nowhere. * * `did` is the small pinned-shape bag of facts about the run — counts, - * identifiers, booleans. It rides into the failure report and, once the + * stable codes, booleans. It rides into the failure report and, once the * failure ledger lands, into the operator surface. Keep it to scalars that * a dashboard can key on; it is not a place for payload data or free text. */ @@ -36,6 +36,45 @@ export type JobFact = string | number | boolean; /** The pinned-shape bag of facts a handler reports about its run. */ export type JobFacts = Readonly>; +/** + * Facts cross a persistence/observability boundary. Keep their vocabulary + * explicit so a caller cannot attach an identifier, credential, URL, + * free-form error, or health value to a durable job result. + */ +export const JOB_FACT_ALLOWLIST: ReadonlySet = new Set([ + "provider", + "outcome", + "total", + "users_synced", + "users_complete", + "users_partial", + "users_failed", + "users_parked", + "users_skipped", + "users_useful", + "users_clean_zero", + "users_retryable", + "downstream_failed", + "measurements_imported", + "deleted", + "generated", + "jobs", + "skipped", + "processed", + "created", + "updated", + "failed", + "retried", + "queued", + "sent", + "subscription_repair_failed", +]); + +export const MAX_JOB_FACTS = 32; +export const MAX_JOB_OUTCOME_BYTES = 2_048; + +const STABLE_CODE_PATTERN = /^[a-z][a-z0-9_]{0,63}$/; + /** * What a handler did. `ok: true` means the work named by the queue actually * happened; `ok: false` means it did not, and `runJob` will fail the pg-boss @@ -79,3 +118,78 @@ export function jobFailed( ): JobOutcome { return { ok: false, reason, cause, did }; } + +export type SerializedJobOutcome = + | { readonly ok: true; readonly did: JobFacts } + | { + readonly ok: false; + readonly reason_code: string; + readonly did?: JobFacts; + }; + +function assertStableCode(value: string, label: string): void { + if (!STABLE_CODE_PATTERN.test(value)) { + throw new TypeError(`${label} must be a stable lowercase code`); + } +} + +function validateFacts(facts: JobFacts): JobFacts { + const entries = Object.entries(facts); + + if (entries.length > MAX_JOB_FACTS) { + throw new RangeError(`job outcome exceeds ${MAX_JOB_FACTS} facts`); + } + + for (const [key, value] of entries) { + if (!JOB_FACT_ALLOWLIST.has(key)) { + throw new TypeError(`job fact key is not allowed: ${key}`); + } + + if (typeof value === "number") { + if (!Number.isSafeInteger(value) || value < 0) { + throw new TypeError( + `job fact ${key} must be a non-negative safe integer`, + ); + } + continue; + } + + if (typeof value === "string") { + assertStableCode(value, `job fact ${key}`); + continue; + } + + if (typeof value !== "boolean") { + throw new TypeError(`job fact ${key} must be a bounded scalar`); + } + } + + return Object.freeze(Object.fromEntries(entries)) as JobFacts; +} + +/** + * Produce the only representation suitable for persistence or structured + * logging. The raw `cause` deliberately remains in-process for retry handling. + */ +export function serializeJobOutcome(outcome: JobOutcome): SerializedJobOutcome { + const did = validateFacts(outcome.did ?? {}); + const serialized: SerializedJobOutcome = outcome.ok + ? { ok: true, did } + : (() => { + assertStableCode(outcome.reason, "job failure reason"); + return outcome.did === undefined + ? { ok: false, reason_code: outcome.reason } + : { ok: false, reason_code: outcome.reason, did }; + })(); + + const byteLength = new TextEncoder().encode( + JSON.stringify(serialized), + ).length; + if (byteLength > MAX_JOB_OUTCOME_BYTES) { + throw new RangeError( + `serialized job outcome exceeds ${MAX_JOB_OUTCOME_BYTES} bytes`, + ); + } + + return serialized; +} diff --git a/src/lib/jobs/reminder/google-health-sync.ts b/src/lib/jobs/reminder/google-health-sync.ts index d6791814b..ac389745a 100644 --- a/src/lib/jobs/reminder/google-health-sync.ts +++ b/src/lib/jobs/reminder/google-health-sync.ts @@ -5,13 +5,19 @@ * queue names, cron schedules, and boss.work registrations. */ import { type Job } from "pg-boss"; +import pLimit from "p-limit"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError } from "@/lib/jobs/worker-status"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; import { withBackgroundEvent } from "@/lib/logging/background"; -import { runGoogleHealthPollCohort } from "@/lib/google-health/sync"; +import { syncUserGoogleHealth } from "@/lib/google-health/sync"; +import { isReauthRequired } from "@/lib/integrations/status"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { cleanupExpiredGoogleHealthOAuthStates } from "@/lib/jobs/google-health-oauth-state-cleanup"; +import { + foldIntegrationCohortOutcomes, + type IntegrationUserVerdict, +} from "./poll-cohort"; import { getWorkerPrisma } from "./shared"; /** @@ -42,56 +48,69 @@ export async function handleGoogleHealthSync( targets.push(...connections); } if (targets.length === 0) { - return jobDone({ - total: 0, - users_synced: 0, - users_failed: 0, - measurements_imported: 0, + return foldIntegrationCohortOutcomes({ + provider: "google_health", + verdicts: [], }); } // Fan the cohort out with bounded concurrency + per-user error isolation: // one slow Google response can't stall the whole cohort, and a single // user's failure is warned without aborting the pass. - let usersFailed = 0; - const { usersSynced, measurementsImported } = - await runGoogleHealthPollCohort( - targets.map((t) => t.userId), - { - onUserError: (userId, err) => { - usersFailed++; - evt.addWarning( - `job.google_health_sync failed for user ${userId}: ${err}`, - ); - }, - // A fresh reading landed; resolve the user's Vorsorge reminders - // eventfully. Fire-and-forget; the cron is the net. - onUserSynced: (userId, imported) => { - if (imported > 0) { + const limit = pLimit(4); + const verdicts = await Promise.all( + targets.map(({ userId }) => + limit(async (): Promise => { + if (await isReauthRequired(userId, "google-health")) { + return { status: "parked", imported: 0 }; + } + + try { + const result = await syncUserGoogleHealth(userId); + const parked = + result.failed && + result.imported === 0 && + (await isReauthRequired(userId, "google-health")); + const status = parked + ? "parked" + : result.failed + ? result.imported > 0 + ? "partial" + : "failed" + : "complete"; + + if (result.imported > 0) { fireAndForget(enqueueReminderSatisfy(userId), { action: "reminder.satisfy.enqueue", }); } - }, - }, - ); + + return { + status, + imported: result.imported, + retryable: result.failed && !parked, + }; + } catch { + evt.addWarning( + "job.google_health_sync failed for one cohort member", + ); + return { status: "failed", imported: 0, retryable: true }; + } + }), + ), + ); + const outcome = foldIntegrationCohortOutcomes({ + provider: "google_health", + verdicts, + }); evt.setBackground({ task_name: "job.google_health_sync", - result: { - users_synced: usersSynced, - total: targets.length, - measurements_imported: measurementsImported, - }, + result: outcome.did, }); // The pass is the unit of success: a revoked grant belongs on that user's // integration ledger, not in a queue failure that retries every account. - return jobDone({ - total: targets.length, - users_synced: usersSynced, - users_failed: usersFailed, - measurements_imported: measurementsImported, - }); + return outcome; } catch (err) { evt.setError(err); recordError(); @@ -117,8 +136,8 @@ export async function handleGoogleHealthOAuthStateCleanup( evt.addMeta("google_health_oauth_state_cleanup_deleted", deleted); return jobDone({ deleted }); } catch (err) { - evt.addWarning(`google-health-oauth-state-cleanup failed: ${err}`); - return jobFailed("google health oauth state cleanup failed", err); + evt.addWarning("google-health-oauth-state-cleanup failed"); + return jobFailed("google_health_oauth_state_cleanup_failed", err); } }, ); diff --git a/src/lib/jobs/reminder/poll-cohort.ts b/src/lib/jobs/reminder/poll-cohort.ts index 154f61af0..7bd205f59 100644 --- a/src/lib/jobs/reminder/poll-cohort.ts +++ b/src/lib/jobs/reminder/poll-cohort.ts @@ -27,6 +27,129 @@ import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { isSyncFailureRecorded } from "@/lib/integrations/status"; import { getWorkerPrisma } from "./shared"; +export type IntegrationCohortProvider = "google_health" | "withings"; + +export type IntegrationUserVerdict = Readonly<{ + status: "complete" | "partial" | "failed" | "parked" | "skipped"; + imported: number; + downstreamFailed?: boolean; + retryable?: boolean; + reasonCode?: string; +}>; + +export type IntegrationCohortFacts = Readonly<{ + provider: IntegrationCohortProvider; + outcome: "useful" | "clean_zero"; + total: number; + users_synced: number; + users_complete: number; + users_partial: number; + users_failed: number; + users_parked: number; + users_skipped: number; + users_useful: number; + users_clean_zero: number; + users_retryable: number; + downstream_failed: number; + measurements_imported: number; +}>; + +export function foldIntegrationCohortOutcomes(input: { + provider: IntegrationCohortProvider; + verdicts: readonly IntegrationUserVerdict[]; + downstreamFailures?: number; +}): { readonly ok: true; readonly did: IntegrationCohortFacts } { + let usersComplete = 0; + let usersPartial = 0; + let usersFailed = 0; + let usersParked = 0; + let usersSkipped = 0; + let usersUseful = 0; + let usersCleanZero = 0; + let usersRetryable = 0; + if ( + input.downstreamFailures !== undefined && + (!Number.isSafeInteger(input.downstreamFailures) || + input.downstreamFailures < 0) + ) { + throw new TypeError( + "cohort downstream failure count must be a non-negative safe integer", + ); + } + let downstreamFailed = input.downstreamFailures ?? 0; + let measurementsImported = 0; + + for (const verdict of input.verdicts) { + if (!Number.isSafeInteger(verdict.imported) || verdict.imported < 0) { + throw new TypeError( + "cohort imported count must be a non-negative safe integer", + ); + } + + switch (verdict.status) { + case "complete": + usersComplete++; + if (verdict.imported === 0) usersCleanZero++; + break; + case "partial": + usersPartial++; + break; + case "failed": + usersFailed++; + break; + case "parked": + usersParked++; + break; + case "skipped": + usersSkipped++; + break; + default: { + const exhaustive: never = verdict.status; + throw new TypeError(`unsupported cohort status: ${String(exhaustive)}`); + } + } + + if ( + (verdict.status === "complete" || verdict.status === "partial") && + verdict.imported > 0 + ) { + usersUseful++; + } + if (verdict.retryable === true) usersRetryable++; + if (verdict.downstreamFailed === true) { + if (!Number.isSafeInteger(downstreamFailed + 1)) { + throw new RangeError( + "cohort downstream failure total exceeds safe integer range", + ); + } + downstreamFailed++; + } + + const nextImported = measurementsImported + verdict.imported; + if (!Number.isSafeInteger(nextImported)) { + throw new RangeError("cohort imported total exceeds safe integer range"); + } + measurementsImported = nextImported; + } + + return jobDone({ + provider: input.provider, + outcome: usersUseful > 0 ? "useful" : "clean_zero", + total: input.verdicts.length, + users_synced: usersComplete, + users_complete: usersComplete, + users_partial: usersPartial, + users_failed: usersFailed, + users_parked: usersParked, + users_skipped: usersSkipped, + users_useful: usersUseful, + users_clean_zero: usersCleanZero, + users_retryable: usersRetryable, + downstream_failed: downstreamFailed, + measurements_imported: measurementsImported, + }) as { readonly ok: true; readonly did: IntegrationCohortFacts }; +} + /** Every poll-cohort handler accepts an optional single-user payload. */ export interface PollCohortPayload { userId?: string; @@ -113,14 +236,12 @@ export function makePollCohortHandler({ // Only an UNMARKED escape — today's Oura / Polar write-path throw, // or any future provider path — is recorded here. if (!isSyncFailureRecorded(err)) { - await recordFailure(userId, err).catch((recErr) => { - evt.addWarning( - `${taskName} recordFailure failed for user ${userId}: ${recErr}`, - ); + await recordFailure(userId, err).catch(() => { + evt.addWarning(`${taskName} recordFailure failed`); }); } usersFailed++; - evt.addWarning(`${taskName} failed for user ${userId}: ${err}`); + evt.addWarning(`${taskName} failed for one cohort member`); } } diff --git a/src/lib/jobs/reminder/withings-sync.ts b/src/lib/jobs/reminder/withings-sync.ts index efcf86750..4d36d02bb 100644 --- a/src/lib/jobs/reminder/withings-sync.ts +++ b/src/lib/jobs/reminder/withings-sync.ts @@ -7,8 +7,9 @@ import { type Job } from "pg-boss"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError, recordWithingsSync } from "@/lib/jobs/worker-status"; -import { jobDone, type JobOutcome } from "@/lib/jobs/job-outcome"; +import { type JobOutcome } from "@/lib/jobs/job-outcome"; import { withBackgroundEvent } from "@/lib/logging/background"; +import { isReauthRequired } from "@/lib/integrations/status"; import { retryDueWithingsWebhookSubscriptions, syncUserMeasurements, @@ -18,6 +19,10 @@ import { syncUserSleep } from "@/lib/withings/sync-sleep"; import { syncUserEcg } from "@/lib/withings/sync-ecg"; import type { WithingsEcgSyncPayload } from "@/lib/jobs/withings-ecg-queue"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; +import { + foldIntegrationCohortOutcomes, + type IntegrationUserVerdict, +} from "./poll-cohort"; import { getWorkerPrisma } from "./shared"; export type { WithingsEcgSyncPayload } from "@/lib/jobs/withings-ecg-queue"; @@ -74,52 +79,61 @@ export async function handleWithingsFallbackSync( }); if (connections.length === 0) { - return jobDone({ - total: 0, - users_synced: 0, - users_failed: 0, - measurements_imported: 0, - subscription_repair_failed: subscriptionRepairFailed, + return foldIntegrationCohortOutcomes({ + provider: "withings", + verdicts: [], + downstreamFailures: subscriptionRepairFailed ? 1 : 0, }); } - let usersSynced = 0; - let usersFailed = 0; - let measurementsImported = 0; + const verdicts: IntegrationUserVerdict[] = []; for (const connection of connections) { + if (await isReauthRequired(connection.userId, "withings")) { + verdicts.push({ status: "parked", imported: 0 }); + continue; + } + try { - const { imported } = await syncUserMeasurements(connection.userId); - usersSynced++; - measurementsImported += imported; + const result = await syncUserMeasurements(connection.userId); + const parked = + result.failed && + result.imported === 0 && + (await isReauthRequired(connection.userId, "withings")); + verdicts.push({ + status: parked + ? "parked" + : result.failed + ? result.imported > 0 + ? "partial" + : "failed" + : "complete", + imported: result.imported, + retryable: result.failed && !parked, + }); // v1.18.1 — a fresh reading landed; resolve this user's Vorsorge // reminders eventfully. Fire-and-forget; the cron is the net. - if (imported > 0) { + if (result.imported > 0) { fireAndForget(enqueueReminderSatisfy(connection.userId), { action: "reminder.satisfy.enqueue", }); } } catch { - usersFailed++; - evt.addWarning(`Fallback sync failed for user ${connection.userId}`); + verdicts.push({ status: "failed", imported: 0, retryable: true }); + evt.addWarning("Fallback sync failed for one cohort member"); } } + const outcome = foldIntegrationCohortOutcomes({ + provider: "withings", + verdicts, + downstreamFailures: subscriptionRepairFailed ? 1 : 0, + }); evt.setBackground({ task_name: "job.withings_sync", - result: { - users_synced: usersSynced, - total: connections.length, - measurements_imported: measurementsImported, - }, - }); - return jobDone({ - total: connections.length, - users_synced: usersSynced, - users_failed: usersFailed, - measurements_imported: measurementsImported, - subscription_repair_failed: subscriptionRepairFailed, + result: outcome.did, }); + return outcome; } catch (err) { evt.setError(err); recordError(); @@ -174,45 +188,46 @@ export async function handleWithingsEcgSync( targets.push(...connections); } if (targets.length === 0) { - return jobDone({ - total: 0, - users_synced: 0, - users_failed: 0, - recordings_imported: 0, + return foldIntegrationCohortOutcomes({ + provider: "withings", + verdicts: [], }); } - let usersSynced = 0; - let usersFailed = 0; - let recordingsImported = 0; + const verdicts: IntegrationUserVerdict[] = []; for (const { userId, startdate, enddate } of targets) { + if (await isReauthRequired(userId, "withings")) { + verdicts.push({ status: "parked", imported: 0 }); + continue; + } + try { const options = startdate === undefined && enddate === undefined ? {} : { startdate, enddate }; - recordingsImported += await syncUserEcg(userId, options); - usersSynced++; - } catch (err) { - usersFailed++; - evt.addWarning(`Withings ECG sync failed for user ${userId}: ${err}`); + const imported = await syncUserEcg(userId, options); + const parked = + imported === 0 && (await isReauthRequired(userId, "withings")); + verdicts.push({ + status: parked ? "parked" : "complete", + imported, + }); + } catch { + verdicts.push({ status: "failed", imported: 0, retryable: true }); + evt.addWarning("Withings ECG sync failed for one cohort member"); } } + const outcome = foldIntegrationCohortOutcomes({ + provider: "withings", + verdicts, + }); evt.setBackground({ task_name: "job.withings_ecg_sync", - result: { - users_synced: usersSynced, - total: targets.length, - recordings_imported: recordingsImported, - }, - }); - return jobDone({ - total: targets.length, - users_synced: usersSynced, - users_failed: usersFailed, - recordings_imported: recordingsImported, + result: outcome.did, }); + return outcome; } catch (err) { evt.setError(err); recordError(); @@ -257,47 +272,47 @@ export async function handleWithingsActivitySync( targets.push(...connections); } if (targets.length === 0) { - return jobDone({ - total: 0, - users_synced: 0, - users_failed: 0, - measurements_imported: 0, + return foldIntegrationCohortOutcomes({ + provider: "withings", + verdicts: [], }); } - let usersSynced = 0; - let usersFailed = 0; - let measurementsImported = 0; + const verdicts: IntegrationUserVerdict[] = []; for (const { userId } of targets) { + if (await isReauthRequired(userId, "withings")) { + verdicts.push({ status: "parked", imported: 0 }); + continue; + } + try { const imported = await syncUserActivity(userId); - usersSynced++; - measurementsImported += imported; + const parked = + imported === 0 && (await isReauthRequired(userId, "withings")); + verdicts.push({ + status: parked ? "parked" : "complete", + imported, + }); if (imported > 0) { fireAndForget(enqueueReminderSatisfy(userId), { action: "reminder.satisfy.enqueue", }); } } catch { - usersFailed++; - evt.addWarning(`Withings activity sync failed for user ${userId}`); + verdicts.push({ status: "failed", imported: 0, retryable: true }); + evt.addWarning("Withings activity sync failed for one cohort member"); } } + const outcome = foldIntegrationCohortOutcomes({ + provider: "withings", + verdicts, + }); evt.setBackground({ task_name: "job.withings_activity_sync", - result: { - users_synced: usersSynced, - total: targets.length, - measurements_imported: measurementsImported, - }, - }); - return jobDone({ - total: targets.length, - users_synced: usersSynced, - users_failed: usersFailed, - measurements_imported: measurementsImported, + result: outcome.did, }); + return outcome; } catch (err) { evt.setError(err); recordError(); @@ -330,47 +345,47 @@ export async function handleWithingsSleepSync( targets.push(...connections); } if (targets.length === 0) { - return jobDone({ - total: 0, - users_synced: 0, - users_failed: 0, - measurements_imported: 0, + return foldIntegrationCohortOutcomes({ + provider: "withings", + verdicts: [], }); } - let usersSynced = 0; - let usersFailed = 0; - let measurementsImported = 0; + const verdicts: IntegrationUserVerdict[] = []; for (const { userId } of targets) { + if (await isReauthRequired(userId, "withings")) { + verdicts.push({ status: "parked", imported: 0 }); + continue; + } + try { const imported = await syncUserSleep(userId); - usersSynced++; - measurementsImported += imported; + const parked = + imported === 0 && (await isReauthRequired(userId, "withings")); + verdicts.push({ + status: parked ? "parked" : "complete", + imported, + }); if (imported > 0) { fireAndForget(enqueueReminderSatisfy(userId), { action: "reminder.satisfy.enqueue", }); } } catch { - usersFailed++; - evt.addWarning(`Withings sleep sync failed for user ${userId}`); + verdicts.push({ status: "failed", imported: 0, retryable: true }); + evt.addWarning("Withings sleep sync failed for one cohort member"); } } + const outcome = foldIntegrationCohortOutcomes({ + provider: "withings", + verdicts, + }); evt.setBackground({ task_name: "job.withings_sleep_sync", - result: { - users_synced: usersSynced, - total: targets.length, - measurements_imported: measurementsImported, - }, - }); - return jobDone({ - total: targets.length, - users_synced: usersSynced, - users_failed: usersFailed, - measurements_imported: measurementsImported, + result: outcome.did, }); + return outcome; } catch (err) { evt.setError(err); recordError(); From c3ffc43335c81d46b54b4d4e86c1022ab99a619f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:53:22 +0200 Subject: [PATCH 085/125] refactor(01-22): reuse Google cohort concurrency limit - preserve the shared Google Health poll fan-out width --- src/lib/jobs/reminder/google-health-sync.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/lib/jobs/reminder/google-health-sync.ts b/src/lib/jobs/reminder/google-health-sync.ts index ac389745a..57a7bfa19 100644 --- a/src/lib/jobs/reminder/google-health-sync.ts +++ b/src/lib/jobs/reminder/google-health-sync.ts @@ -10,7 +10,10 @@ import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { recordError } from "@/lib/jobs/worker-status"; import { jobDone, jobFailed, type JobOutcome } from "@/lib/jobs/job-outcome"; import { withBackgroundEvent } from "@/lib/logging/background"; -import { syncUserGoogleHealth } from "@/lib/google-health/sync"; +import { + GOOGLE_HEALTH_POLL_CONCURRENCY, + syncUserGoogleHealth, +} from "@/lib/google-health/sync"; import { isReauthRequired } from "@/lib/integrations/status"; import { enqueueReminderSatisfy } from "@/lib/jobs/reminder-satisfy"; import { cleanupExpiredGoogleHealthOAuthStates } from "@/lib/jobs/google-health-oauth-state-cleanup"; @@ -57,7 +60,7 @@ export async function handleGoogleHealthSync( // Fan the cohort out with bounded concurrency + per-user error isolation: // one slow Google response can't stall the whole cohort, and a single // user's failure is warned without aborting the pass. - const limit = pLimit(4); + const limit = pLimit(GOOGLE_HEALTH_POLL_CONCURRENCY); const verdicts = await Promise.all( targets.map(({ userId }) => limit(async (): Promise => { From 1cb0f7dd6ac9ca1be007cfdba1250eda58f84398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:53:59 +0200 Subject: [PATCH 086/125] test(workouts): pin WHOOP aggregate enrichment --- .../pick-canonical-workout-rows.test.ts | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) diff --git a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts index 3f51b8dae..b8bba9328 100644 --- a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts +++ b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts @@ -7,6 +7,9 @@ interface RowFixture { startedAt: Date; sportType: string; source: "APPLE_HEALTH" | "WHOOP" | "WITHINGS" | "MANUAL" | "IMPORT"; + avgHeartRate?: number | null; + maxHeartRate?: number | null; + metadata?: Record | null; } describe("pickCanonicalWorkoutRows", () => { @@ -67,6 +70,163 @@ describe("pickCanonicalWorkoutRows", () => { expect(pickCanonicalWorkoutRows(rows).map((r) => r.id)).toEqual(["apple"]); }); + it("enriches a canonical Apple workout from its matched WHOOP twin", () => { + const apple: RowFixture = { + id: "apple", + startedAt: new Date("2026-06-03T06:30:40Z"), + sportType: "running", + source: "APPLE_HEALTH", + avgHeartRate: null, + maxHeartRate: null, + metadata: { hkVersion: "1", owner: "apple" }, + }; + const whoop: RowFixture = { + id: "whoop", + startedAt: new Date("2026-06-03T06:30:00Z"), + sportType: "running", + source: "WHOOP", + avgHeartRate: 146, + maxHeartRate: 181, + metadata: { + zoneDurations: { zone_one_milli: 60_000 }, + whoopWorkoutStrain: 12.3, + }, + }; + + const [picked] = pickCanonicalWorkoutRows([whoop, apple]); + + expect(picked).toMatchObject({ + id: "apple", + source: "APPLE_HEALTH", + avgHeartRate: 146, + maxHeartRate: 181, + metadata: { + hkVersion: "1", + owner: "apple", + zoneDurations: { zone_one_milli: 60_000 }, + }, + }); + expect(picked.metadata).not.toHaveProperty("whoopWorkoutStrain"); + expect(apple).toEqual({ + id: "apple", + startedAt: new Date("2026-06-03T06:30:40Z"), + sportType: "running", + source: "APPLE_HEALTH", + avgHeartRate: null, + maxHeartRate: null, + metadata: { hkVersion: "1", owner: "apple" }, + }); + }); + + it("never overwrites canonical HR or zones with WHOOP donor values", () => { + const rows: RowFixture[] = [ + { + id: "apple", + startedAt: new Date("2026-06-03T06:30:40Z"), + sportType: "running", + source: "APPLE_HEALTH", + avgHeartRate: 140, + maxHeartRate: 175, + metadata: { zoneDurations: { zone_one_milli: 30_000 } }, + }, + { + id: "whoop", + startedAt: new Date("2026-06-03T06:30:00Z"), + sportType: "running", + source: "WHOOP", + avgHeartRate: 150, + maxHeartRate: 185, + metadata: { zoneDurations: { zone_one_milli: 90_000 } }, + }, + ]; + + expect(pickCanonicalWorkoutRows(rows)[0]).toMatchObject({ + id: "apple", + avgHeartRate: 140, + maxHeartRate: 175, + metadata: { zoneDurations: { zone_one_milli: 30_000 } }, + }); + }); + + it("does not enrich across sport or fixed-window boundaries", () => { + const apple: RowFixture = { + id: "apple", + startedAt: new Date("2026-06-03T06:30:00Z"), + sportType: "running", + source: "APPLE_HEALTH", + avgHeartRate: null, + maxHeartRate: null, + metadata: null, + }; + const wrongSport: RowFixture = { + id: "whoop-cycle", + startedAt: new Date("2026-06-03T06:30:30Z"), + sportType: "cycling", + source: "WHOOP", + avgHeartRate: 150, + maxHeartRate: 180, + metadata: { zoneDurations: { zone_two_milli: 60_000 } }, + }; + const wrongWindow: RowFixture = { + id: "whoop-later", + startedAt: new Date("2026-06-03T06:35:00Z"), + sportType: "running", + source: "WHOOP", + avgHeartRate: 155, + maxHeartRate: 185, + metadata: { zoneDurations: { zone_three_milli: 60_000 } }, + }; + + const result = pickCanonicalWorkoutRows([ + apple, + wrongSport, + wrongWindow, + ]); + expect(result.find((row) => row.id === "apple")).toMatchObject({ + avgHeartRate: null, + maxHeartRate: null, + metadata: null, + }); + }); + + it("does not enrich same-source winners or use a non-WHOOP donor", () => { + const apple: RowFixture = { + id: "apple", + startedAt: new Date("2026-06-03T06:30:00Z"), + sportType: "running", + source: "APPLE_HEALTH", + avgHeartRate: null, + maxHeartRate: null, + metadata: null, + }; + const secondApple: RowFixture = { + ...apple, + id: "apple-2", + startedAt: new Date("2026-06-03T06:31:00Z"), + avgHeartRate: 145, + maxHeartRate: 178, + }; + const withings: RowFixture = { + ...apple, + id: "withings", + source: "WITHINGS", + startedAt: new Date("2026-06-03T06:32:00Z"), + avgHeartRate: 150, + maxHeartRate: 182, + }; + + const sameSource = pickCanonicalWorkoutRows([apple, secondApple]); + expect(sameSource[0].avgHeartRate).toBeNull(); + expect(sameSource[1].avgHeartRate).toBe(145); + + const nonWhoop = pickCanonicalWorkoutRows([apple, withings]); + expect(nonWhoop[0]).toMatchObject({ + id: "apple", + avgHeartRate: null, + maxHeartRate: null, + }); + }); + it("keeps the WHOOP run when no richer source logged the same session", () => { const rows: RowFixture[] = [ { From 635e9b64c5aa29de3577d3dfbffa50829729c5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:55:11 +0200 Subject: [PATCH 087/125] fix(workouts): enrich canonical WHOOP aggregates --- .../pick-canonical-workout-rows.test.ts | 55 +++++++++ .../pick-canonical-workout-rows.ts | 106 +++++++++++++++++- 2 files changed, 157 insertions(+), 4 deletions(-) diff --git a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts index b8bba9328..7599994e8 100644 --- a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts +++ b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts @@ -4,6 +4,7 @@ import { pickCanonicalWorkoutRows } from "../pick-canonical-workout-rows"; interface RowFixture { id: string; + userId?: string; startedAt: Date; sportType: string; source: "APPLE_HEALTH" | "WHOOP" | "WITHINGS" | "MANUAL" | "IMPORT"; @@ -227,6 +228,60 @@ describe("pickCanonicalWorkoutRows", () => { }); }); + it("does not collapse or enrich rows belonging to different users", () => { + const result = pickCanonicalWorkoutRows([ + { + id: "apple-u1", + userId: "u1", + startedAt: new Date("2026-06-03T06:30:00Z"), + sportType: "running", + source: "APPLE_HEALTH", + avgHeartRate: null, + }, + { + id: "whoop-u2", + userId: "u2", + startedAt: new Date("2026-06-03T06:30:30Z"), + sportType: "running", + source: "WHOOP", + avgHeartRate: 150, + }, + ]); + + expect(result).toHaveLength(2); + expect(result.find((row) => row.id === "apple-u1")?.avgHeartRate).toBeNull(); + }); + + it("uses each WHOOP twin at most once for same-slot occurrences", () => { + const result = pickCanonicalWorkoutRows([ + { + id: "apple-near", + startedAt: new Date("2026-06-03T06:30:10Z"), + sportType: "running", + source: "APPLE_HEALTH", + avgHeartRate: null, + }, + { + id: "apple-far", + startedAt: new Date("2026-06-03T06:33:30Z"), + sportType: "running", + source: "APPLE_HEALTH", + avgHeartRate: null, + }, + { + id: "whoop", + startedAt: new Date("2026-06-03T06:30:00Z"), + sportType: "running", + source: "WHOOP", + avgHeartRate: 150, + }, + ]); + + expect( + result.filter((row) => row.avgHeartRate === 150).map((row) => row.id), + ).toEqual(["apple-near"]); + }); + it("keeps the WHOOP run when no richer source logged the same session", () => { const rows: RowFixture[] = [ { diff --git a/src/lib/measurements/pick-canonical-workout-rows.ts b/src/lib/measurements/pick-canonical-workout-rows.ts index 65e5a16e2..7f410a6ad 100644 --- a/src/lib/measurements/pick-canonical-workout-rows.ts +++ b/src/lib/measurements/pick-canonical-workout-rows.ts @@ -44,9 +44,13 @@ import { * other column they fetched alongside. */ export interface WorkoutPickerRow { + userId?: string; startedAt: Date; sportType: string; source: MeasurementSource; + avgHeartRate?: number | null; + maxHeartRate?: number | null; + metadata?: unknown; } /** @@ -64,7 +68,98 @@ const BUCKET_SLOT_MS = 5 * 60 * 1000; function bucketKey(row: WorkoutPickerRow): string { const slot = Math.floor(row.startedAt.getTime() / BUCKET_SLOT_MS); - return `${slot}:${row.sportType}`; + return `${row.userId ?? ""}:${slot}:${row.sportType}`; +} + +function jsonRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function enrichFromWhoopTwin( + winner: T, + donor: T, +): T { + const winnerMetadata = jsonRecord(winner.metadata); + const donorMetadata = jsonRecord(donor.metadata); + const donorZones = donorMetadata?.zoneDurations; + const needsZones = + donorZones != null && winnerMetadata?.zoneDurations == null; + const needsAvg = + winner.avgHeartRate == null && donor.avgHeartRate != null; + const needsMax = + winner.maxHeartRate == null && donor.maxHeartRate != null; + + if (!needsAvg && !needsMax && !needsZones) return winner; + + return { + ...winner, + ...(needsAvg ? { avgHeartRate: donor.avgHeartRate } : {}), + ...(needsMax ? { maxHeartRate: donor.maxHeartRate } : {}), + ...(needsZones + ? { + metadata: { + ...(winnerMetadata ?? {}), + zoneDurations: donorZones, + }, + } + : {}), + }; +} + +/** + * Pair winners and WHOOP donors one-to-one by nearest start time. A single + * donor can never enrich two same-source occurrences that happen to share a + * fixed slot. Stable input indexes break equal-distance ties. + */ +function enrichWinnersFromWhoop( + winners: readonly T[], + donors: readonly T[], +): T[] { + if (winners.length === 0 || donors.length === 0) return [...winners]; + + const candidates: Array<{ + winnerIndex: number; + donorIndex: number; + deltaMs: number; + }> = []; + winners.forEach((winner, winnerIndex) => { + donors.forEach((donor, donorIndex) => { + candidates.push({ + winnerIndex, + donorIndex, + deltaMs: Math.abs( + winner.startedAt.getTime() - donor.startedAt.getTime(), + ), + }); + }); + }); + candidates.sort( + (left, right) => + left.deltaMs - right.deltaMs || + left.winnerIndex - right.winnerIndex || + left.donorIndex - right.donorIndex, + ); + + const enriched = [...winners]; + const matchedWinners = new Set(); + const matchedDonors = new Set(); + for (const candidate of candidates) { + if ( + matchedWinners.has(candidate.winnerIndex) || + matchedDonors.has(candidate.donorIndex) + ) { + continue; + } + enriched[candidate.winnerIndex] = enrichFromWhoopTwin( + winners[candidate.winnerIndex], + donors[candidate.donorIndex], + ); + matchedWinners.add(candidate.winnerIndex); + matchedDonors.add(candidate.donorIndex); + } + return enriched; } /** @@ -134,9 +229,12 @@ export function pickCanonicalWorkoutRows( continue; } - for (const row of slot.rows) { - if (row.source === picked) canonical.push(row); - } + const winners = slot.rows.filter((row) => row.source === picked); + const whoopDonors = + picked === "WHOOP" + ? [] + : slot.rows.filter((row) => row.source === "WHOOP"); + canonical.push(...enrichWinnersFromWhoop(winners, whoopDonors)); } // Re-sort to preserve input order — the bucket map iterates in From bcef9cf51dcb6b245e30ea91577fcc8303749d8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:55:43 +0200 Subject: [PATCH 088/125] docs(01-22): complete truthful cohort outcomes plan - summarize bounded outcome facts and provider verdict folding - record adjacent Google core verification dependency --- .../01-22-SUMMARY.md | 113 ++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-22-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-22-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-22-SUMMARY.md new file mode 100644 index 000000000..5b4f88805 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-22-SUMMARY.md @@ -0,0 +1,113 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "22" +subsystem: jobs +tags: [job-outcomes, cohort-folding, google-health, withings, privacy] + +requires: + - phase: 01-14 + provides: RED contracts for truthful cohort outcomes and bounded serialization +provides: + - Privacy-safe bounded JobOutcome serialization + - Provider-neutral complete/partial/failed/parked/skipped cohort folding + - Truthful Google Health and Withings scheduled-job counters +affects: [integration-reliability, job-observability, reminder-workers] + +tech-stack: + added: [] + patterns: + - Fixed allowlist serialization for durable job facts + - Typed per-user verdicts folded into bounded cohort counters + +key-files: + created: [] + modified: + - src/lib/jobs/job-outcome.ts + - src/lib/jobs/reminder/poll-cohort.ts + - src/lib/jobs/reminder/google-health-sync.ts + - src/lib/jobs/reminder/withings-sync.ts + +key-decisions: + - "Only complete users increment users_synced; partial, failed, parked, and skipped users retain distinct counters." + - "Only complete or partial writes count as useful, while a successful complete zero is counted separately." + - "Durable job facts accept only allowlisted bounded scalars and stable codes; raw causes remain process-local." + +patterns-established: + - "Cohort truth: classify every user once, then fold verdicts into a fixed 14-key aggregate." + - "Privacy boundary: reject unknown keys, nested values, unsafe counts, free-form strings, and oversized serialized outcomes." + +requirements-completed: [SYNC-02, JOB-01] + +duration: 15min +completed: 2026-07-29 +--- + +# Phase 01 Plan 22: Truthful Bounded Cohort Outcomes Summary + +**Privacy-safe job serialization and typed Google Health/Withings verdict folding distinguish useful, clean-zero, partial, failed, parked, and skipped work without false synced counts.** + +## Performance + +- **Duration:** 15 min +- **Started:** 2026-07-29T12:40:18Z +- **Completed:** 2026-07-29T12:54:55Z +- **Tasks:** 1 +- **Files modified:** 4 + +## Accomplishments + +- Added a fail-closed serializer with an explicit fact allowlist, stable reason codes, non-negative safe-integer counters, and a 2 KiB ceiling. +- Added a fixed-shape provider-neutral fold in which only complete users count as synced and only complete/partial writes count as useful. +- Wired Google Health and all Withings scheduled handlers to classify parked and post-sync reauth states without changing bounded fan-out, retry isolation, or reminder enqueue behavior. +- Removed per-user identifiers and raw thrown values from cohort warning messages. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Aggregate truthful bounded cohort and job outcomes** - `6a3861122` (feat) +2. **Task 1 refactor: Reuse the shared Google cohort concurrency limit** - `c3ffc4333` (refactor) + +## Files Created/Modified + +- `src/lib/jobs/job-outcome.ts` - Validates and serializes bounded privacy-safe job facts. +- `src/lib/jobs/reminder/poll-cohort.ts` - Defines typed user verdicts and the fixed cohort aggregate. +- `src/lib/jobs/reminder/google-health-sync.ts` - Preserves bounded fan-out while folding rich Google sync results. +- `src/lib/jobs/reminder/withings-sync.ts` - Folds fallback, ECG, activity, and sleep results without unconditional synced counts. + +## Decisions Made + +- A user is synced only after a complete result, including a legitimate clean zero. +- A partial result may be useful when it wrote measurements, but it never increments `users_synced`. +- Reauth is checked both before and after provider work so token failures that park during a run do not masquerade as failures or clean zeros. +- Cohort-wide failures still throw for queue retry, while isolated user failures complete the cohort and remain visible in aggregate counters. +- Per-user reason codes are accepted as internal verdict detail but never serialized into the cohort result. + +## Deviations from Plan + +None - the implementation followed the planned bounded outcome and cohort-folding contract. + +## Issues Encountered + +- The exact focused verification command reports 51 passing tests and one static failure because `src/lib/google-health/sync.ts` still contains the pre-existing verdict-collapsing `runGoogleHealthPollCohort` adapter. That file was explicitly outside Plan 22 ownership, and the production Plan 22 handler no longer uses the adapter. +- Repository typecheck reports only seven errors in concurrently delivered Plan 21 Google sync-progress tests and its missing status route; no Plan 22 file reports a type error. +- Targeted ESLint, Prettier, and `git diff --check` pass for all four Plan 22 implementation files. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +- The bounded outcome contract is ready for operator-surface persistence and display. +- Final integration verification requires the adjacent Google core adapter to retain its resolved verdict instead of collapsing it to `imported`. + +## Self-Check: PASSED + +- All four declared implementation files exist. +- Task commits `6a3861122` and `c3ffc4333` exist in repository history. +- Scanner-owned package and workspace hashes remain unchanged on `release/v1.34.1`. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From ed63a3bf32ec7bd7bb757d4da5ae91d971afed15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:56:21 +0200 Subject: [PATCH 089/125] test(workouts): pin enriched list and detail envelopes --- .../__tests__/canonical-dedup.test.ts | 36 +++++++ src/app/api/workouts/__tests__/detail.test.ts | 97 +++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/src/app/api/workouts/__tests__/canonical-dedup.test.ts b/src/app/api/workouts/__tests__/canonical-dedup.test.ts index 5136b23f8..4fcc2c191 100644 --- a/src/app/api/workouts/__tests__/canonical-dedup.test.ts +++ b/src/app/api/workouts/__tests__/canonical-dedup.test.ts @@ -83,6 +83,7 @@ interface FakeWorkoutRow { avgHeartRate: number | null; maxHeartRate: number | null; totalEnergyKcal: number | null; + metadata: Record | null; createdAt: Date; } @@ -105,6 +106,7 @@ function makeRow( avgHeartRate: 145, maxHeartRate: 170, totalEnergyKcal: 320, + metadata: null, createdAt: start, }; } @@ -147,6 +149,40 @@ describe("GET /api/workouts — canonical dedup", () => { expect(body.data.meta.droppedDuplicates).toBe(1); }); + it("backfills missing Apple HR aggregates from its WHOOP twin", async () => { + const apple = { + ...makeRow("w-apple", "APPLE_HEALTH", "2026-05-15T07:00:00Z"), + avgHeartRate: null, + maxHeartRate: null, + metadata: { hkVersion: "1" }, + }; + const whoop = { + ...makeRow("w-whoop", "WHOOP", "2026-05-15T07:00:30Z"), + avgHeartRate: 148, + maxHeartRate: 181, + metadata: { + zoneDurations: { zone_two_milli: 120_000 }, + whoopWorkoutStrain: 12.3, + }, + }; + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + apple, + whoop, + ] as never); + + const res = await GET(makeRequest()); + const body = await res.json(); + + expect(body.data.workouts).toHaveLength(1); + expect(body.data.workouts[0]).toMatchObject({ + id: "w-apple", + source: "APPLE_HEALTH", + avgHr: 148, + maxHr: 181, + }); + expect(body.data.meta.droppedDuplicates).toBe(1); + }); + it("returns the most recent workouts first", async () => { // The canonical picker re-sorts its output to startedAt ASC (its rollup // contract), so the list route must re-establish newest-first AFTER the diff --git a/src/app/api/workouts/__tests__/detail.test.ts b/src/app/api/workouts/__tests__/detail.test.ts index 5730e4d17..28981227b 100644 --- a/src/app/api/workouts/__tests__/detail.test.ts +++ b/src/app/api/workouts/__tests__/detail.test.ts @@ -153,6 +153,103 @@ describe("GET /api/workouts/{id}", () => { expect(body.data.route).toBeNull(); }); + it("enriches the Apple winner with only matched WHOOP HR and zones", async () => { + const routeGeometry = { + type: "LineString", + coordinates: [ + [11.0, 49.0], + [11.01, 49.01], + ], + }; + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce({ + ...BASE_ROW, + avgHeartRate: null, + maxHeartRate: null, + metadata: { hkVersion: "1", owner: "apple" }, + route: { + id: "wr-apple", + geometry: routeGeometry, + sampleTimestamps: null, + createdAt: BASE_ROW.createdAt, + }, + } as never); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + { + id: "w-1", + source: "APPLE_HEALTH", + startedAt: BASE_ROW.startedAt, + sportType: "RUNNING", + avgHeartRate: null, + maxHeartRate: null, + metadata: { hkVersion: "1", owner: "apple" }, + }, + { + id: "w-whoop", + source: "WHOOP", + startedAt: new Date(BASE_ROW.startedAt.getTime() + 30_000), + sportType: "RUNNING", + avgHeartRate: 148, + maxHeartRate: 181, + metadata: { + zoneDurations: { + zone_one_milli: 60_000, + zone_two_milli: 300_000, + }, + whoopWorkoutStrain: 12.3, + }, + }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + const body = await res.json(); + + expect(body.data).toMatchObject({ + id: "w-1", + source: "APPLE_HEALTH", + canonicalId: "w-1", + avgHr: 148, + maxHr: 181, + route: { geometry: routeGeometry }, + metadata: { hkVersion: "1", owner: "apple" }, + }); + expect(body.data.metadata).not.toHaveProperty("whoopWorkoutStrain"); + expect(body.data.metadata).not.toHaveProperty("zoneDurations"); + expect(body.data.zones.model).toBe("whoop"); + expect(body.data.hrSeries).toBeNull(); + }); + + it("does not overwrite the canonical winner's existing HR values", async () => { + vi.mocked(prisma.workout.findUnique).mockResolvedValueOnce( + BASE_ROW as never, + ); + vi.mocked(prisma.workout.findMany).mockResolvedValueOnce([ + { + id: "w-1", + source: "APPLE_HEALTH", + startedAt: BASE_ROW.startedAt, + sportType: "RUNNING", + avgHeartRate: BASE_ROW.avgHeartRate, + maxHeartRate: BASE_ROW.maxHeartRate, + metadata: BASE_ROW.metadata, + }, + { + id: "w-whoop", + source: "WHOOP", + startedAt: new Date(BASE_ROW.startedAt.getTime() + 30_000), + sportType: "RUNNING", + avgHeartRate: 155, + maxHeartRate: 190, + metadata: null, + }, + ] as never); + + const res = await GET(makeRequest(), makeParams("w-1")); + const body = await res.json(); + + expect(body.data.avgHr).toBe(145); + expect(body.data.maxHr).toBe(170); + }); + it("returns 401 without a session", async () => { vi.mocked(getSession).mockResolvedValueOnce(null); const res = await GET(makeRequest(), makeParams("w-1")); From 43cce08012510c71500fff4357a681f13581c126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:56:59 +0200 Subject: [PATCH 090/125] fix(workouts): share enriched canonical detail data --- src/app/api/workouts/[id]/route.ts | 15 ++++++++++++--- src/lib/workouts/list-read.ts | 2 ++ 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 801442dab..7b534a170 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -114,6 +114,9 @@ export const GET = apiHandler( source: true, startedAt: true, sportType: true, + avgHeartRate: true, + maxHeartRate: true, + metadata: true, }, }); @@ -132,6 +135,10 @@ export const GET = apiHandler( canonicalCluster.find((c) => c.id === row.id) ?? canonicalCluster[0] ?? null; + const effectiveAvgHeartRate = + canonical?.avgHeartRate ?? row.avgHeartRate; + const effectiveMaxHeartRate = + canonical?.maxHeartRate ?? row.maxHeartRate; // ── Enrichment reads (all over existing tables — no migration) ──── @@ -159,7 +166,9 @@ export const GET = apiHandler( hrMax: hrMaxFromAge(ageYears), series: hrSeries?.points ?? [], bucketSec: hrSeries?.bucketSec ?? 0, - whoopZoneDurations: parseWhoopZoneDurations(row.metadata), + whoopZoneDurations: parseWhoopZoneDurations( + canonical?.metadata ?? row.metadata, + ), }); // Per-km splits computed server-side from the geometry + timestamps @@ -241,8 +250,8 @@ export const GET = apiHandler( durationSec: row.durationSec, distanceM: row.totalDistanceM, activeEnergyKcal: row.totalEnergyKcal, - avgHr: row.avgHeartRate, - maxHr: row.maxHeartRate, + avgHr: effectiveAvgHeartRate, + maxHr: effectiveMaxHeartRate, minHr: row.minHeartRate, stepCount: row.stepCount, elevationM: row.elevationM, diff --git a/src/lib/workouts/list-read.ts b/src/lib/workouts/list-read.ts index 1291ca4e1..a9d637673 100644 --- a/src/lib/workouts/list-read.ts +++ b/src/lib/workouts/list-read.ts @@ -77,6 +77,7 @@ interface CanonicalWorkoutRow { totalEnergyKcal: number | null; avgHeartRate: number | null; maxHeartRate: number | null; + metadata: unknown; createdAt: Date; // #67 list glyphs — cheap relation-id selects so the list can flag // which sessions open into a rich detail (map / HR curve). @@ -158,6 +159,7 @@ export async function buildWorkoutsProjection( totalEnergyKcal: true, avgHeartRate: true, maxHeartRate: true, + metadata: true, createdAt: true, route: { select: { id: true } }, samples: { select: { sampleCount: true } }, From 89fefce7ac6f29b47dee3ffead7b729074a67c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:57:42 +0200 Subject: [PATCH 091/125] test(workouts): separate pulse aggregates from curves --- .../insights/__tests__/workout-list.test.tsx | 12 +++++++++ src/lib/workouts/__tests__/hr-series.test.ts | 25 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/components/insights/__tests__/workout-list.test.tsx b/src/components/insights/__tests__/workout-list.test.tsx index 337fc58b2..d6cb72ec5 100644 --- a/src/components/insights/__tests__/workout-list.test.tsx +++ b/src/components/insights/__tests__/workout-list.test.tsx @@ -47,15 +47,27 @@ describe("", () => { expect(html).toContain("320 kcal"); }); + it("renders aggregate average HR without claiming an HR series", () => { + const html = render( + , + ); + + expect(html).toContain('data-slot="workout-list-avg-hr"'); + expect(html).toContain("145 bpm"); + expect(html).not.toContain('data-slot="workout-list-glyph-hr"'); + }); + it("omits the chip when the field is null", () => { const noDistance: WorkoutListEntry = { ...ROW, distanceM: null, activeEnergyKcal: null, + avgHr: null, }; const html = render(); expect(html).not.toContain('data-slot="workout-list-distance"'); expect(html).not.toContain('data-slot="workout-list-energy"'); + expect(html).not.toContain('data-slot="workout-list-avg-hr"'); }); it("falls back to the canonical sport string for unknown sport types", () => { diff --git a/src/lib/workouts/__tests__/hr-series.test.ts b/src/lib/workouts/__tests__/hr-series.test.ts index 98e43e060..0beab9c57 100644 --- a/src/lib/workouts/__tests__/hr-series.test.ts +++ b/src/lib/workouts/__tests__/hr-series.test.ts @@ -121,6 +121,31 @@ describe("buildWorkoutHrSeries", () => { expect(result).toBeNull(); }); + it("does not synthesize a curve from aggregate HR or zone durations", async () => { + findMany.mockResolvedValue([]); + const result = await buildWorkoutHrSeries({ + ...base, + storedSamples: null, + // Aggregate-only WHOOP fields deliberately are not inputs to the + // curve builder. Keep them on this structural fixture to pin that a + // future widening cannot silently fabricate time-series points. + avgHeartRate: 145, + maxHeartRate: 180, + metadata: { + zoneDurations: { + zone_two_milli: 300_000, + zone_three_milli: 300_000, + }, + }, + } as Parameters[0] & { + avgHeartRate: number; + maxHeartRate: number; + metadata: unknown; + }); + + expect(result).toBeNull(); + }); + it("hides when bucket coverage is below 40 %", async () => { // 10 samples all clustered in the first minute of a 10-minute run → // enough raw samples, but coverage is ~1 bucket of ~75. From d2f6bf33ebad78160548dab683d179df91245c86 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:58:24 +0200 Subject: [PATCH 092/125] fix(workouts): sanitize WHOOP zone enrichment --- .../pick-canonical-workout-rows.test.ts | 37 ++++++++++++++++++- .../pick-canonical-workout-rows.ts | 34 ++++++++++++++++- 2 files changed, 68 insertions(+), 3 deletions(-) diff --git a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts index 7599994e8..62f3fb58b 100644 --- a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts +++ b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts @@ -89,7 +89,11 @@ describe("pickCanonicalWorkoutRows", () => { avgHeartRate: 146, maxHeartRate: 181, metadata: { - zoneDurations: { zone_one_milli: 60_000 }, + zoneDurations: { + zone_one_milli: 60_000, + nestedSecret: { mustNotFlow: true }, + zone_six_milli: 90_000, + }, whoopWorkoutStrain: 12.3, }, }; @@ -107,6 +111,11 @@ describe("pickCanonicalWorkoutRows", () => { zoneDurations: { zone_one_milli: 60_000 }, }, }); + expect( + (picked.metadata as Record).zoneDurations, + ).toEqual({ + zone_one_milli: 60_000, + }); expect(picked.metadata).not.toHaveProperty("whoopWorkoutStrain"); expect(apple).toEqual({ id: "apple", @@ -149,6 +158,32 @@ describe("pickCanonicalWorkoutRows", () => { }); }); + it("rejects malformed WHOOP zone durations instead of partially copying them", () => { + const result = pickCanonicalWorkoutRows([ + { + id: "apple", + startedAt: new Date("2026-06-03T06:30:00Z"), + sportType: "running", + source: "APPLE_HEALTH", + metadata: { owner: "apple" }, + }, + { + id: "whoop", + startedAt: new Date("2026-06-03T06:30:30Z"), + sportType: "running", + source: "WHOOP", + metadata: { + zoneDurations: { + zone_one_milli: 60_000, + zone_two_milli: -1, + }, + }, + }, + ]); + + expect(result[0].metadata).toEqual({ owner: "apple" }); + }); + it("does not enrich across sport or fixed-window boundaries", () => { const apple: RowFixture = { id: "apple", diff --git a/src/lib/measurements/pick-canonical-workout-rows.ts b/src/lib/measurements/pick-canonical-workout-rows.ts index 7f410a6ad..e36604093 100644 --- a/src/lib/measurements/pick-canonical-workout-rows.ts +++ b/src/lib/measurements/pick-canonical-workout-rows.ts @@ -77,13 +77,43 @@ function jsonRecord(value: unknown): Record | null { : null; } +const WHOOP_ZONE_DURATION_KEYS = [ + "zone_zero_milli", + "zone_one_milli", + "zone_two_milli", + "zone_three_milli", + "zone_four_milli", + "zone_five_milli", +] as const; + +function sanitizedWhoopZoneDurations( + metadata: unknown, +): Record | null { + const raw = jsonRecord(jsonRecord(metadata)?.zoneDurations); + if (!raw) return null; + + const sanitized: Record = {}; + for (const key of WHOOP_ZONE_DURATION_KEYS) { + const value = raw[key]; + if (value === undefined) continue; + if ( + typeof value !== "number" || + !Number.isFinite(value) || + value < 0 + ) { + return null; + } + sanitized[key] = value; + } + return Object.keys(sanitized).length > 0 ? sanitized : null; +} + function enrichFromWhoopTwin( winner: T, donor: T, ): T { const winnerMetadata = jsonRecord(winner.metadata); - const donorMetadata = jsonRecord(donor.metadata); - const donorZones = donorMetadata?.zoneDurations; + const donorZones = sanitizedWhoopZoneDurations(donor.metadata); const needsZones = donorZones != null && winnerMetadata?.zoneDurations == null; const needsAvg = From f6555d471bd2832be866b6848da611e3d3e1dc95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:58:25 +0200 Subject: [PATCH 093/125] fix(workouts): show aggregate pulse in lists --- src/components/insights/workout-list.tsx | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/insights/workout-list.tsx b/src/components/insights/workout-list.tsx index f4dd117da..150dcb106 100644 --- a/src/components/insights/workout-list.tsx +++ b/src/components/insights/workout-list.tsx @@ -158,6 +158,11 @@ export function WorkoutList({ {formatEnergy(workout.activeEnergyKcal, locale)} kcal ) : null} + {workout.avgHr != null ? ( + + {workout.avgHr} bpm + + ) : null}
    From 5ec1a3836f71116f19b4a96d64c033e62eb1f35c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:58:40 +0200 Subject: [PATCH 094/125] feat(01-21): add fair Google sync outcomes - finish workout and low-cost resources before dense metrics - report bounded terminal outcomes and invalidate workout caches --- src/lib/google-health/client.ts | 76 +++++++++ src/lib/google-health/sync-core.ts | 19 ++- src/lib/google-health/sync-workout.ts | 5 + src/lib/google-health/sync.ts | 218 ++++++++++++++++++++++++-- 4 files changed, 305 insertions(+), 13 deletions(-) diff --git a/src/lib/google-health/client.ts b/src/lib/google-health/client.ts index 237500921..659ccc75a 100644 --- a/src/lib/google-health/client.ts +++ b/src/lib/google-health/client.ts @@ -33,6 +33,7 @@ * `mappers.ts` and are imported here type-only — that direction keeps the * module graph acyclic (`client` → `mappers`, never back). */ +import { AsyncLocalStorage } from "node:async_hooks"; import { createHash, randomBytes } from "node:crypto"; import { annotate, getEvent } from "@/lib/logging/context"; import { safeFetch } from "@/lib/safe-fetch"; @@ -50,6 +51,75 @@ import type { export * from "./mappers"; +export interface GoogleHealthClientOutcome { + pages: number; + fetched: number; + mapped: number; + written: number; + truncated: boolean; + reasonCode: + | "collection_failed" + | "token_failed" + | "upsert_failed" + | "rollup_failed" + | "existing_page_limit" + | null; +} + +const clientOutcomeStorage = new AsyncLocalStorage(); + +function addOutcomeCount(current: number, value: number): number { + if (!Number.isFinite(value) || value <= 0) return current; + return Math.min(2_147_483_647, current + Math.trunc(value)); +} + +function notePagination( + pages: number, + fetched: number, + truncated: boolean, +): void { + const tracker = clientOutcomeStorage.getStore(); + if (!tracker) return; + tracker.pages = addOutcomeCount(tracker.pages, pages); + tracker.fetched = addOutcomeCount(tracker.fetched, fetched); + if (truncated) { + tracker.truncated = true; + tracker.reasonCode = "existing_page_limit"; + } +} + +export function noteGoogleHealthMapped(count: number): void { + const tracker = clientOutcomeStorage.getStore(); + if (tracker) tracker.mapped = addOutcomeCount(tracker.mapped, count); +} + +export function noteGoogleHealthWritten(count: number): void { + const tracker = clientOutcomeStorage.getStore(); + if (tracker) tracker.written = addOutcomeCount(tracker.written, count); +} + +export function noteGoogleHealthOutcomeFailure( + reasonCode: Exclude, +): void { + const tracker = clientOutcomeStorage.getStore(); + if (tracker && !tracker.reasonCode) tracker.reasonCode = reasonCode; +} + +export async function runWithGoogleHealthClientOutcome( + fn: () => Promise, +): Promise<{ result: T; outcome: GoogleHealthClientOutcome }> { + const outcome: GoogleHealthClientOutcome = { + pages: 0, + fetched: 0, + mapped: 0, + written: 0, + truncated: false, + reasonCode: null, + }; + const result = await clientOutcomeStorage.run(outcome, fn); + return { result, outcome }; +} + export const GOOGLE_HEALTH_API_BASE = "https://health.googleapis.com/v4"; const GOOGLE_OAUTH_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; const GOOGLE_OAUTH_TOKEN_URL = "https://oauth2.googleapis.com/token"; @@ -711,6 +781,7 @@ export async function fetchDataPoints( pageCount += 1; } while (pageToken && pageCount < maxPages); + notePagination(pageCount, points.length, Boolean(pageToken)); return points; }; @@ -935,6 +1006,8 @@ export async function fetchDailyRollUp( const walk = async (maxDays: number): Promise => { const points: GoogleHealthRollupPoint[] = []; let chunkIndex = 0; + let totalPages = 0; + let truncated = false; for (const chunk of chunkCivilRange(startCivil, endCivil, maxDays)) { let pageToken: string | null | undefined; let pageCount = 0; @@ -998,8 +1071,11 @@ export async function fetchDailyRollUp( pageToken = json?.nextPageToken ?? null; pageCount += 1; } while (pageToken && pageCount < 100); + totalPages += pageCount; + truncated ||= Boolean(pageToken); chunkIndex += 1; } + notePagination(totalPages, points.length, truncated); return points; }; diff --git a/src/lib/google-health/sync-core.ts b/src/lib/google-health/sync-core.ts index e2ff839b2..27ccaa139 100644 --- a/src/lib/google-health/sync-core.ts +++ b/src/lib/google-health/sync-core.ts @@ -27,7 +27,12 @@ import { recomputeBucketsForMeasurement, } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; -import { refreshAccessToken } from "./client"; +import { + noteGoogleHealthMapped, + noteGoogleHealthOutcomeFailure, + noteGoogleHealthWritten, + refreshAccessToken, +} from "./client"; import { getUserGoogleHealthCredentials } from "./credentials"; import { GoogleHealthApiError, @@ -227,6 +232,13 @@ export const GOOGLE_HEALTH_TOKEN_HARD_FAIL = "token"; export function noteHardFailure(label: string): void { const tracker = hardFailStorage.getStore(); if (tracker) tracker.failures.push(label); + noteGoogleHealthOutcomeFailure( + label === GOOGLE_HEALTH_TOKEN_HARD_FAIL + ? "token_failed" + : label.includes("rollup") + ? "rollup_failed" + : "upsert_failed", + ); } /** @@ -314,6 +326,7 @@ export async function handleCollectionFetchError( if (tracker) tracker.count += 1; return 0; } + noteGoogleHealthOutcomeFailure("collection_failed"); await recordGoogleHealthSyncFailure(userId, err); getEvent()?.addWarning( `google-health ${resource} failed for ${userId}: ${err}`, @@ -485,6 +498,7 @@ export async function upsertGoogleHealthMeasurements( measuredAt: Date; }>; }> { + noteGoogleHealthMapped(readings.length); if (readings.length === 0) return { imported: 0, touched: [], inserted: [] }; // Probe EVERY existing row (live AND tombstoned) for the batch's externalIds @@ -796,6 +810,7 @@ export async function upsertGoogleHealthMeasurements( if (tracker) { for (const t of touched) tracker.keys.push(t); } + noteGoogleHealthWritten(imported); return { imported, touched, inserted: insertedRows }; } @@ -816,8 +831,10 @@ export async function upsertGoogleHealthMeasurements( getEvent()?.addWarning( `google-health: rollup recompute failed for ${userId}: ${err}`, ); + noteHardFailure("rollup"); } + noteGoogleHealthWritten(imported); return { imported, touched, inserted: insertedRows }; } diff --git a/src/lib/google-health/sync-workout.ts b/src/lib/google-health/sync-workout.ts index 80e1c252a..fd559fb3e 100644 --- a/src/lib/google-health/sync-workout.ts +++ b/src/lib/google-health/sync-workout.ts @@ -31,6 +31,7 @@ import { } from "./sync-core"; import { prisma } from "@/lib/db"; import { emitInsertedWorkoutArrival } from "@/lib/arrivals/workout-emit"; +import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { annotate, getEvent } from "@/lib/logging/context"; import { resolveUserTimezone } from "@/lib/tz/resolver"; @@ -129,6 +130,10 @@ export async function syncUserWorkout( } } + if (imported > 0) { + await invalidateUserMeasurements(userId); + } + // `markSynced` is owned by the orchestrator (`syncUserGoogleHealth`). annotate({ action: { name: "googleHealth.workout.sync", details: { imported } }, diff --git a/src/lib/google-health/sync.ts b/src/lib/google-health/sync.ts index 791f3a031..90ef9fe7b 100644 --- a/src/lib/google-health/sync.ts +++ b/src/lib/google-health/sync.ts @@ -7,6 +7,10 @@ import { recomputeUserRollups, } from "@/lib/rollups/measurement-rollups"; import { invalidateStatusInsightsForTypes } from "@/lib/insights/comprehensive-generate"; +import { + runWithGoogleHealthClientOutcome, + type GoogleHealthClientOutcome, +} from "./client"; import { syncUserActivity } from "./sync-activity"; import { syncUserMetrics } from "./sync-metrics"; import { syncUserSleep } from "./sync-sleep"; @@ -18,6 +22,108 @@ import { runWithGoogleHealthSyncCycle, type GoogleHealthResourceSyncOptions, } from "./sync-core"; +import { + startGoogleHealthSyncProgress, + updateGoogleHealthSyncProgress, + type GoogleHealthReasonCode, + type GoogleHealthResourceOutcome, + type GoogleHealthResourceStatus, + type GoogleHealthSyncState, +} from "./sync-progress"; + +export interface GoogleHealthSyncResult { + runId?: string; + state?: GoogleHealthSyncState; + imported: number; + failed: boolean; + resources?: GoogleHealthResourceOutcome[]; +} + +const RESOURCE_STATUSES = new Set([ + "pending", + "complete", + "partial", + "empty", + "truncated", + "failed", +]); +const REASON_CODES = new Set([ + "collection_failed", + "token_failed", + "upsert_failed", + "rollup_failed", + "existing_page_limit", +]); + +function boundedCount(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.min(2_147_483_647, Math.max(0, Math.trunc(value))) + : 0; +} + +function boundedOutcome(value: unknown): GoogleHealthResourceOutcome { + const item = + value && typeof value === "object" + ? (value as Record) + : {}; + const status = RESOURCE_STATUSES.has( + item.status as GoogleHealthResourceStatus, + ) + ? (item.status as GoogleHealthResourceStatus) + : "failed"; + const reasonCode = REASON_CODES.has(item.reasonCode as GoogleHealthReasonCode) + ? (item.reasonCode as GoogleHealthReasonCode) + : null; + return { + resource: + typeof item.resource === "string" + ? item.resource + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .slice(0, 48) + : "unknown", + pages: boundedCount(item.pages), + fetched: boundedCount(item.fetched), + mapped: boundedCount(item.mapped), + written: boundedCount(item.written), + status, + durationMs: Math.min(86_400_000, boundedCount(item.durationMs)), + truncated: item.truncated === true, + reasonCode, + }; +} + +function terminalResourceOutcome( + resource: string, + imported: number, + outcome: GoogleHealthClientOutcome, + durationMs: number, +): GoogleHealthResourceOutcome { + const written = outcome.written || imported; + const mapped = outcome.mapped || imported; + const fetched = outcome.fetched || imported; + const pages = outcome.pages || (fetched > 0 ? 1 : 0); + const status: GoogleHealthResourceStatus = outcome.truncated + ? "truncated" + : outcome.reasonCode + ? written > 0 + ? "partial" + : "failed" + : fetched === 0 && written === 0 + ? "empty" + : "complete"; + return boundedOutcome({ + resource, + pages, + fetched, + mapped, + written, + status, + durationMs, + truncated: outcome.truncated, + reasonCode: outcome.reasonCode, + }); +} /** * Full per-user sync across every Google Health resource. The incremental @@ -27,19 +133,41 @@ import { export async function syncUserGoogleHealth( userId: string, opts: { fullSync?: boolean } = {}, -): Promise<{ imported: number; failed: boolean }> { +): Promise { if (await isReauthRequired(userId, GOOGLE_HEALTH_INTEGRATION_KEY)) { getEvent()?.addWarning( `google-health sync skipped for ${userId}: parked at error_reauth`, ); - return { imported: 0, failed: true }; + return { state: "failed", imported: 0, failed: true, resources: [] }; } const connection = await prisma.googleHealthConnection.findUnique({ where: { userId }, select: { lastSyncedAt: true }, }); - if (!connection) return { imported: 0, failed: true }; + if (!connection) { + return { state: "failed", imported: 0, failed: true, resources: [] }; + } + + const progress = await startGoogleHealthSyncProgress(userId); + const persistProgress = async ( + state: GoogleHealthSyncState, + imported: number, + failed: boolean, + resources: GoogleHealthResourceOutcome[], + ): Promise => { + await updateGoogleHealthSyncProgress(userId, progress.runId, { + state, + startedAt: progress.startedAt, + imported, + failed, + resources, + }).catch((err) => { + getEvent()?.addWarning( + `google-health progress write failed for ${userId}: ${err}`, + ); + }); + }; const start = incrementalStart(connection.lastSyncedAt, { fullSync: opts.fullSync, @@ -50,30 +178,64 @@ export async function syncUserGoogleHealth( deferRollup: opts.fullSync === true, }; const resources = [ - { name: "metrics", fn: syncUserMetrics }, - { name: "activity", fn: syncUserActivity }, - { name: "sleep", fn: syncUserSleep }, { name: "workout", fn: syncUserWorkout }, + { name: "sleep", fn: syncUserSleep }, + { name: "activity", fn: syncUserActivity }, + { name: "dense-heart-rate", fn: syncUserMetrics }, ]; const cycle = await runWithGoogleHealthSyncCycle(async () => { let total = 0; let anyFailed = false; + const resourceOutcomes: GoogleHealthResourceOutcome[] = []; for (const { name, fn } of resources) { + const startedAt = performance.now(); try { - total += await fn(userId, resourceOpts); + const tracked = await runWithGoogleHealthClientOutcome(() => + fn(userId, resourceOpts), + ); + total += tracked.result; + const outcome = terminalResourceOutcome( + name, + tracked.result, + tracked.outcome, + performance.now() - startedAt, + ); + resourceOutcomes.push(outcome); + if ( + outcome.status === "failed" || + outcome.status === "partial" || + outcome.status === "truncated" + ) { + anyFailed = true; + } } catch (err) { anyFailed = true; + resourceOutcomes.push( + boundedOutcome({ + resource: name, + status: "failed", + durationMs: performance.now() - startedAt, + reasonCode: "collection_failed", + }), + ); getEvent()?.addWarning( `google-health ${name} sync failed for ${userId}: ${err}`, ); } + await persistProgress("in_progress", total, anyFailed, resourceOutcomes); } - return { total, anyFailed }; + return { total, anyFailed, resources: resourceOutcomes }; }); const total = cycle.result.total; - const anyFailed = cycle.result.anyFailed || cycle.hardFailures.length > 0; + let anyFailed = cycle.result.anyFailed || cycle.hardFailures.length > 0; + const suppliedResources = (cycle as unknown as { resources?: unknown }) + .resources; + const outcomeSource = Array.isArray(suppliedResources) + ? suppliedResources + : cycle.result.resources; + const resourceOutcomes = outcomeSource.map(boundedOutcome); if (opts.fullSync && cycle.deferredRollupKeys.length > 0) { try { @@ -91,6 +253,18 @@ export async function syncUserGoogleHealth( ); }); } catch (err) { + anyFailed = true; + resourceOutcomes.push( + boundedOutcome({ + resource: "rollup", + fetched: cycle.deferredRollupKeys.length, + mapped: cycle.deferredRollupKeys.length, + written: total, + status: "failed", + durationMs: 0, + reasonCode: "rollup_failed", + }), + ); getEvent()?.addWarning( `google-health: backfill rollup recompute failed for ${userId}: ${err}`, ); @@ -98,7 +272,8 @@ export async function syncUserGoogleHealth( } const allSoftSkipped = cycle.softSkipCount >= resources.length && total === 0; - const failed = anyFailed || allSoftSkipped; + const truncated = resourceOutcomes.some((resource) => resource.truncated); + const failed = anyFailed || allSoftSkipped || truncated; if (!failed) { await markSynced(userId); @@ -108,7 +283,23 @@ export async function syncUserGoogleHealth( annotate({ action: { name: "googleHealth.sync", details: { imported: total, failed } }, }); - return { imported: total, failed }; + const state: GoogleHealthSyncState = truncated + ? "truncated" + : failed + ? total > 0 + ? "partial" + : "failed" + : total === 0 + ? "zero" + : "complete"; + await persistProgress(state, total, failed, resourceOutcomes); + return { + runId: progress.runId, + state, + imported: total, + failed, + resources: resourceOutcomes, + }; } /** Bounded fan-out width for the hourly Google Health cohort poll. */ @@ -128,7 +319,10 @@ export async function runGoogleHealthPollCohort( ): Promise<{ usersSynced: number; measurementsImported: number }> { const sync = opts.sync ?? - ((userId: string) => syncUserGoogleHealth(userId).then((r) => r.imported)); + (async (userId: string) => { + const result = await syncUserGoogleHealth(userId); + return result.imported; + }); const limit = pLimit(opts.concurrency ?? GOOGLE_HEALTH_POLL_CONCURRENCY); let usersSynced = 0; From 104a058096be3bc19b541ed5430d9e50cc24b57e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 14:58:46 +0200 Subject: [PATCH 095/125] style(workouts): format pulse enrichment --- src/app/api/workouts/[id]/route.ts | 6 ++---- .../__tests__/pick-canonical-workout-rows.test.ts | 14 +++++--------- .../measurements/pick-canonical-workout-rows.ts | 12 +++--------- 3 files changed, 10 insertions(+), 22 deletions(-) diff --git a/src/app/api/workouts/[id]/route.ts b/src/app/api/workouts/[id]/route.ts index 7b534a170..97fd208d5 100644 --- a/src/app/api/workouts/[id]/route.ts +++ b/src/app/api/workouts/[id]/route.ts @@ -135,10 +135,8 @@ export const GET = apiHandler( canonicalCluster.find((c) => c.id === row.id) ?? canonicalCluster[0] ?? null; - const effectiveAvgHeartRate = - canonical?.avgHeartRate ?? row.avgHeartRate; - const effectiveMaxHeartRate = - canonical?.maxHeartRate ?? row.maxHeartRate; + const effectiveAvgHeartRate = canonical?.avgHeartRate ?? row.avgHeartRate; + const effectiveMaxHeartRate = canonical?.maxHeartRate ?? row.maxHeartRate; // ── Enrichment reads (all over existing tables — no migration) ──── diff --git a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts index 62f3fb58b..131aebb9e 100644 --- a/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts +++ b/src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts @@ -111,9 +111,7 @@ describe("pickCanonicalWorkoutRows", () => { zoneDurations: { zone_one_milli: 60_000 }, }, }); - expect( - (picked.metadata as Record).zoneDurations, - ).toEqual({ + expect((picked.metadata as Record).zoneDurations).toEqual({ zone_one_milli: 60_000, }); expect(picked.metadata).not.toHaveProperty("whoopWorkoutStrain"); @@ -213,11 +211,7 @@ describe("pickCanonicalWorkoutRows", () => { metadata: { zoneDurations: { zone_three_milli: 60_000 } }, }; - const result = pickCanonicalWorkoutRows([ - apple, - wrongSport, - wrongWindow, - ]); + const result = pickCanonicalWorkoutRows([apple, wrongSport, wrongWindow]); expect(result.find((row) => row.id === "apple")).toMatchObject({ avgHeartRate: null, maxHeartRate: null, @@ -284,7 +278,9 @@ describe("pickCanonicalWorkoutRows", () => { ]); expect(result).toHaveLength(2); - expect(result.find((row) => row.id === "apple-u1")?.avgHeartRate).toBeNull(); + expect( + result.find((row) => row.id === "apple-u1")?.avgHeartRate, + ).toBeNull(); }); it("uses each WHOOP twin at most once for same-slot occurrences", () => { diff --git a/src/lib/measurements/pick-canonical-workout-rows.ts b/src/lib/measurements/pick-canonical-workout-rows.ts index e36604093..fd0567330 100644 --- a/src/lib/measurements/pick-canonical-workout-rows.ts +++ b/src/lib/measurements/pick-canonical-workout-rows.ts @@ -96,11 +96,7 @@ function sanitizedWhoopZoneDurations( for (const key of WHOOP_ZONE_DURATION_KEYS) { const value = raw[key]; if (value === undefined) continue; - if ( - typeof value !== "number" || - !Number.isFinite(value) || - value < 0 - ) { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { return null; } sanitized[key] = value; @@ -116,10 +112,8 @@ function enrichFromWhoopTwin( const donorZones = sanitizedWhoopZoneDurations(donor.metadata); const needsZones = donorZones != null && winnerMetadata?.zoneDurations == null; - const needsAvg = - winner.avgHeartRate == null && donor.avgHeartRate != null; - const needsMax = - winner.maxHeartRate == null && donor.maxHeartRate != null; + const needsAvg = winner.avgHeartRate == null && donor.avgHeartRate != null; + const needsMax = winner.maxHeartRate == null && donor.maxHeartRate != null; if (!needsAvg && !needsMax && !needsZones) return winner; From f3008c9ee059380453fffa700ce2475ff3bcfd40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 15:00:38 +0200 Subject: [PATCH 096/125] docs(01-26): complete workout pulse plan --- .../01-26-SUMMARY.md | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-26-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-26-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-26-SUMMARY.md new file mode 100644 index 000000000..faa621230 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-26-SUMMARY.md @@ -0,0 +1,141 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "26" +subsystem: workout-canonicalization +tags: [workouts, whoop, apple-health, heart-rate, canonical-projection] + +requires: + - phase: 01-10 + provides: RED product/UI regression contracts + - phase: 01-15 + provides: App01 privacy-safe performance and production evidence +provides: + - Canonical Apple workout identity enriched with missing WHOOP HR aggregates and sanitized device zones + - Shared list/detail canonical projection without cross-user, cross-sport, cross-window or duplicate donor joins + - Honest aggregate pulse visibility independent of continuous workout samples +affects: [workout-list, workout-detail, whoop, apple-health, effort-zones] + +tech-stack: + added: [] + patterns: + - one-to-one nearest-donor matching inside the existing fixed canonical bucket + - allowlisted fill-missing-only enrichment with sanitized WHOOP zone keys + - aggregate pulse and continuous-series availability remain separate capabilities + +key-files: + created: [] + modified: + - src/lib/measurements/pick-canonical-workout-rows.ts + - src/lib/workouts/list-read.ts + - src/app/api/workouts/[id]/route.ts + - src/components/insights/workout-list.tsx + +key-decisions: + - "Canonical id, source, route, samples and existing values remain owned by the winning row." + - "Only missing avgHeartRate, maxHeartRate and validated WHOOP zone durations may cross the twin boundary." + - "A WHOOP aggregate never implies or fabricates a continuous heart-rate curve." + +patterns-established: + - "Donor safety: deterministic one-to-one assignment prevents one WHOOP record enriching multiple same-slot occurrences." + - "Metadata safety: copy only six known non-negative zone-duration keys and reject malformed known values." + +requirements-completed: [WHR-01, WHR-02, WHR-03, WHR-04, WHR-05] + +duration: 7min +completed: 2026-07-29 +--- + +# Phase 01 Plan 26: Workout Pulse Canonical Enrichment Summary + +**Apple remains the canonical workout while its missing pulse aggregates and device zones now surface from the exact matched WHOOP twin, without inventing a continuous curve.** + +## Performance + +- **Duration:** 7 min +- **Started:** 2026-07-29T12:53:55Z +- **Completed:** 2026-07-29T13:00:30Z +- **Tasks:** 3 +- **Files modified:** 9 + +## Accomplishments + +- Extended the active canonical picker with immutable, fill-missing-only WHOOP enrichment while preserving the winner's identity and all existing values. +- Added user identity to bucket isolation when available and deterministic one-to-one donor assignment so a donor cannot enrich two distinct same-slot occurrences. +- Sanitized WHOOP zones down to the six documented non-negative duration keys; arbitrary donor metadata and malformed zone payloads do not cross into the winner. +- Wired list and detail reads through the same enriched envelope. The detail still owns its original route, samples, metadata response and ownership check while its aggregate statistics and zone computation use the enriched projection. +- Added an average-pulse list value independent of the detailed-series glyph and pinned that aggregate HR/zones alone still produce no curve. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Enrich only the matched canonical projection** + - `1cb0f7dd6` - RED positive and negative enrichment regressions + - `635e9b64c` - deterministic fill-missing-only projection + - `d2f6bf33e` - narrow WHOOP-zone sanitization and isolation coverage +2. **Task 2: Use the enriched envelope consistently in list and detail** + - `ed63a3bf3` - RED list/detail envelope regressions + - `43cce0801` - shared enriched list/detail reads +3. **Task 3: Render aggregate pulse honestly and pin the no-curve boundary** + - `89fefce7a` - RED aggregate-versus-series regressions + - `f6555d471` - aggregate average pulse in the workout list + +Supporting cleanup: + +- `104a05809` - repository formatting for the workout enrichment changes + +## Files Created/Modified + +- `src/lib/measurements/pick-canonical-workout-rows.ts` - Enriches winners from deterministic WHOOP twins using only validated allowlisted fields. +- `src/lib/measurements/__tests__/pick-canonical-workout-rows.test.ts` - Covers positive enrichment, non-overwrite, no mutation, sport/window/user isolation, malformed metadata and one-to-one donor use. +- `src/lib/workouts/list-read.ts` - Selects metadata for the shared canonical projection and returns enriched HR aggregates. +- `src/app/api/workouts/[id]/route.ts` - Computes detail aggregates and zones from the enriched canonical envelope while keeping the owned row's route/samples. +- `src/app/api/workouts/__tests__/canonical-dedup.test.ts` - Pins one enriched Apple list row for an Apple/WHOOP twin. +- `src/app/api/workouts/__tests__/detail.test.ts` - Pins Apple identity/route/metadata preservation, WHOOP HR/zones and non-overwrite behavior. +- `src/components/insights/workout-list.tsx` - Shows average pulse in bpm independently of continuous-series availability. +- `src/components/insights/__tests__/workout-list.test.tsx` - Separates aggregate visibility from the series glyph. +- `src/lib/workouts/__tests__/hr-series.test.ts` - Proves aggregates and zones cannot synthesize a curve. + +## Decisions Made + +- Kept the existing fixed five-minute/sport canonical boundary unchanged. Enrichment narrows behavior inside an already accepted twin bucket rather than widening dedup. +- Matched multiple winners and WHOOP donors by nearest start time with stable input-index tie-breaks and one-to-one consumption. +- Returned the original detail metadata envelope unchanged; sanitized donor zones feed only the established zone parser. This avoids leaking arbitrary WHOOP metadata into an Apple-owned API row. +- Kept `hasHrSeries` tied only to real `WorkoutSamples`; the new bpm label is the truthful aggregate-only signal. + +## Deviations from Plan + +None - implementation stayed within the nine declared files, plus this required summary. No schema, migration, sync, locale, package or dependency change was made. + +## Verification + +- **Focused regression cluster:** 6 files, 84 tests passed. +- **Picker regressions:** 21/21 passed, including cross-user and one-to-one donor isolation. +- **ESLint:** Passed for all nine owned source/test files. +- **Prettier:** Passed for all nine owned source/test files. +- **Diff check:** Passed. +- **TypeScript:** Repository-wide `pnpm typecheck` remains temporarily non-zero only in concurrent Google Health RED work (`sync/status/route` not yet landed and mock tuple diagnostics). No diagnostic references a Plan 01-26 file. + +## Issues Encountered + +- Concurrent Google Health implementation temporarily blocks the repository-wide typecheck. The failure set is confined to its status-route and sync-progress tests and was not modified here. + +## User Setup Required + +None. + +## Next Phase Readiness + +- WHR-01 through WHR-05 are closed with positive and negative regression coverage. +- The release can surface existing App01 WHOOP aggregate data immediately after deployment; historical data does not require a resync or migration. +- Continuous WHOOP curves remain intentionally unavailable because the public WHOOP API does not expose continuous heart-rate samples. + +## Self-Check: PASSED + +- All 9 claimed source/test files exist. +- All 8 implementation/supporting commits exist. +- No owned file remains unstaged or uncommitted. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From ba6d06be3b4395c4fa2b94dd5affd373bcdf57bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 15:01:13 +0200 Subject: [PATCH 097/125] feat(01-21): expose Google sync terminal status - return redacted per-resource outcomes from manual sync - poll owner-scoped progress and invalidate committed workout views --- src/app/api/google-health/sync/route.ts | 66 +++++++++- .../api/google-health/sync/status/route.ts | 98 ++++++++++++++ .../integrations/google-health-card.tsx | 122 +++++++++++++++++- 3 files changed, 279 insertions(+), 7 deletions(-) create mode 100644 src/app/api/google-health/sync/status/route.ts diff --git a/src/app/api/google-health/sync/route.ts b/src/app/api/google-health/sync/route.ts index 2cb7b99b3..ee9a068fc 100644 --- a/src/app/api/google-health/sync/route.ts +++ b/src/app/api/google-health/sync/route.ts @@ -3,9 +3,64 @@ import { apiSuccess, apiError } from "@/lib/api-response"; import { checkRateLimit } from "@/lib/rate-limit"; import { annotate } from "@/lib/logging/context"; import { syncUserGoogleHealth } from "@/lib/google-health/sync"; +import type { + GoogleHealthReasonCode, + GoogleHealthResourceOutcome, + GoogleHealthResourceStatus, +} from "@/lib/google-health/sync-progress"; import { resolveSyncOutcome } from "@/lib/outcome/written-outcome"; import { NextRequest } from "next/server"; +const RESOURCE_STATUSES = new Set([ + "pending", + "complete", + "partial", + "empty", + "truncated", + "failed", +]); +const REASON_CODES = new Set([ + "collection_failed", + "token_failed", + "upsert_failed", + "rollup_failed", + "existing_page_limit", +]); + +function boundedInteger(value: unknown, maximum = 2_147_483_647): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.min(maximum, Math.max(0, Math.trunc(value))) + : 0; +} + +function publicResourceOutcome(value: unknown): GoogleHealthResourceOutcome { + const item = + value && typeof value === "object" + ? (value as Record) + : {}; + return { + resource: + typeof item.resource === "string" + ? item.resource + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .slice(0, 48) + : "unknown", + pages: boundedInteger(item.pages), + fetched: boundedInteger(item.fetched), + mapped: boundedInteger(item.mapped), + written: boundedInteger(item.written), + status: RESOURCE_STATUSES.has(item.status as GoogleHealthResourceStatus) + ? (item.status as GoogleHealthResourceStatus) + : "failed", + durationMs: boundedInteger(item.durationMs, 86_400_000), + truncated: item.truncated === true, + reasonCode: REASON_CODES.has(item.reasonCode as GoogleHealthReasonCode) + ? (item.reasonCode as GoogleHealthReasonCode) + : null, + }; +} + /** * Manually trigger a Google Health sync for the current user (v1.26.0). * @@ -63,5 +118,14 @@ export const POST = apiHandler(async (request: NextRequest) => { if (result.failed && result.imported === 0) { return apiError("Google Health sync failed", 502); } - return apiSuccess({ ...resolveSyncOutcome(result), fullSync }); + const resources = Array.isArray(result.resources) + ? result.resources.slice(0, 16).map(publicResourceOutcome) + : undefined; + return apiSuccess({ + ...resolveSyncOutcome(result), + ...(result.runId ? { runId: result.runId.slice(0, 128) } : {}), + ...(result.state ? { state: result.state } : {}), + fullSync, + ...(resources ? { resources } : {}), + }); }); diff --git a/src/app/api/google-health/sync/status/route.ts b/src/app/api/google-health/sync/status/route.ts new file mode 100644 index 000000000..c7b697739 --- /dev/null +++ b/src/app/api/google-health/sync/status/route.ts @@ -0,0 +1,98 @@ +import { apiHandler, requireAuth } from "@/lib/api-handler"; +import { apiSuccess } from "@/lib/api-response"; +import { + readGoogleHealthSyncProgress, + type GoogleHealthResourceOutcome, +} from "@/lib/google-health/sync-progress"; +import { annotate } from "@/lib/logging/context"; +import type { NextRequest } from "next/server"; + +function count(value: unknown, maximum = 2_147_483_647): number { + return typeof value === "number" && Number.isFinite(value) + ? Math.min(maximum, Math.max(0, Math.trunc(value))) + : 0; +} + +function resource(value: unknown): GoogleHealthResourceOutcome { + const item = + value && typeof value === "object" + ? (value as Record) + : {}; + const statuses = [ + "pending", + "complete", + "partial", + "empty", + "truncated", + "failed", + ]; + const reasons = [ + "collection_failed", + "token_failed", + "upsert_failed", + "rollup_failed", + "existing_page_limit", + ]; + return { + resource: + typeof item.resource === "string" + ? item.resource + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .slice(0, 48) + : "unknown", + pages: count(item.pages), + fetched: count(item.fetched), + mapped: count(item.mapped), + written: count(item.written), + status: statuses.includes(String(item.status)) + ? (item.status as GoogleHealthResourceOutcome["status"]) + : "failed", + durationMs: count(item.durationMs, 86_400_000), + truncated: item.truncated === true, + reasonCode: reasons.includes(String(item.reasonCode)) + ? (item.reasonCode as GoogleHealthResourceOutcome["reasonCode"]) + : null, + }; +} + +function publicProgress(value: unknown): Record | null { + if (!value || typeof value !== "object") return null; + const item = value as Record; + const states = [ + "in_progress", + "complete", + "partial", + "zero", + "truncated", + "failed", + "interrupted", + ]; + return { + runId: typeof item.runId === "string" ? item.runId.slice(0, 128) : "", + state: states.includes(String(item.state)) ? item.state : "failed", + startedAt: typeof item.startedAt === "string" ? item.startedAt : "", + updatedAt: typeof item.updatedAt === "string" ? item.updatedAt : "", + ...(typeof item.terminalAt === "string" + ? { terminalAt: item.terminalAt } + : {}), + imported: count(item.imported), + failed: item.failed === true, + resources: Array.isArray(item.resources) + ? item.resources.slice(0, 16).map(resource) + : [], + }; +} + +/** + * Return only the authenticated user's bounded current/most-recent run. + * Query parameters are intentionally ignored so this endpoint cannot enumerate + * another connection's progress. + */ +export const GET = apiHandler(async (_request: NextRequest) => { + const { user } = await requireAuth(); + annotate({ action: { name: "google_health.sync.status" } }); + return apiSuccess( + publicProgress(await readGoogleHealthSyncProgress(user.id)), + ); +}); diff --git a/src/components/settings/integrations/google-health-card.tsx b/src/components/settings/integrations/google-health-card.tsx index 9043d72a6..7f3169b60 100644 --- a/src/components/settings/integrations/google-health-card.tsx +++ b/src/components/settings/integrations/google-health-card.tsx @@ -73,6 +73,18 @@ import { } from "./sync-outcome"; import { IntegrationCardDescription } from "./setup-guide-link"; +interface GoogleHealthTerminalResource { + resource?: unknown; + written?: unknown; +} + +interface GoogleHealthTerminalProgress { + state: + "complete" | "partial" | "zero" | "truncated" | "failed" | "interrupted"; + imported: number; + resources: GoogleHealthTerminalResource[]; +} + export function GoogleHealthCard({ viewModel, }: { @@ -93,6 +105,92 @@ export function GoogleHealthCard({ const status = viewModel; + function invalidateCommittedWorkout( + resources: GoogleHealthTerminalResource[], + ) { + const workoutCommitted = resources.some( + (resource) => + resource.resource === "workout" && + typeof resource.written === "number" && + resource.written > 0, + ); + if (!workoutCommitted) return; + queryClient.invalidateQueries({ queryKey: queryKeys.workouts() }); + // prettier-ignore + queryClient.invalidateQueries({ queryKey: queryKeys.dashboardSnapshot() }); + queryClient.invalidateQueries({ queryKey: queryKeys.analytics() }); + } + + function readTerminalProgress( + body: unknown, + ): GoogleHealthTerminalProgress | null { + if (!body || typeof body !== "object") return null; + const data = (body as { data?: unknown }).data; + if (!data || typeof data !== "object") return null; + const record = data as Record; + if ( + record.state !== "complete" && + record.state !== "partial" && + record.state !== "zero" && + record.state !== "truncated" && + record.state !== "failed" && + record.state !== "interrupted" + ) { + return null; + } + return { + state: record.state, + imported: + typeof record.imported === "number" && Number.isFinite(record.imported) + ? Math.max(0, Math.trunc(record.imported)) + : 0, + resources: Array.isArray(record.resources) + ? record.resources.slice(0, 16) + : [], + }; + } + + async function pollSyncStatus(): Promise { + for (let attempt = 0; attempt < 4; attempt++) { + try { + const response = await apiFetchRaw("/api/google-health/sync/status"); + const progress = readTerminalProgress(await response.json()); + if (response.ok && progress) return progress; + } catch { + // The POST may have committed even when its response was lost. Keep + // this short bounded poll best-effort; the next card refresh can retry. + } + await new Promise((resolve) => setTimeout(resolve, 750)); + } + return null; + } + + function showTerminalProgress(progress: GoogleHealthTerminalProgress): void { + const failed = + progress.state === "failed" || progress.state === "interrupted"; + const outcome = + progress.state === "partial" || progress.state === "truncated" + ? "partial" + : failed + ? "failed" + : progress.imported > 0 + ? "success" + : "empty"; + const resolved = { + imported: progress.imported, + failed: failed || outcome === "partial", + outcome, + } as const; + setSyncResult({ + outcome: resolved.outcome, + message: describeSyncOutcome( + resolved, + t("settings.googleHealthSyncResult", { count: progress.imported }), + ), + }); + invalidateCommittedWorkout(progress.resources); + } + const disconnect = useMutation({ mutationFn: async () => { await apiPost("/api/google-health/disconnect"); @@ -148,17 +246,29 @@ export function GoogleHealthCard({ queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus(), }); + const terminal = readTerminalProgress(json); + if (terminal) invalidateCommittedWorkout(terminal.resources); + } else { + const terminal = await pollSyncStatus(); + if (terminal) { + showTerminalProgress(terminal); + } else { + setSyncResult({ + outcome: "failed", + message: json?.error || t("settings.googleHealthSyncFailed"), + }); + } + } + } catch { + const terminal = await pollSyncStatus(); + if (terminal) { + showTerminalProgress(terminal); } else { setSyncResult({ outcome: "failed", - message: json?.error || t("settings.googleHealthSyncFailed"), + message: t("settings.googleHealthSyncFailed"), }); } - } catch { - setSyncResult({ - outcome: "failed", - message: t("settings.googleHealthSyncFailed"), - }); } finally { setSyncing(false); } From 5bc213c5b8730da26ad81a4bc436ba2df8ffe2fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 15:04:08 +0200 Subject: [PATCH 098/125] test(01-21): type guarded progress mock calls - declare the updateMany mock argument tuple - assert the guarded write exists before inspection --- .../__tests__/sync-progress.test.ts | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/src/lib/google-health/__tests__/sync-progress.test.ts b/src/lib/google-health/__tests__/sync-progress.test.ts index 9c7801478..2bc8f53ea 100644 --- a/src/lib/google-health/__tests__/sync-progress.test.ts +++ b/src/lib/google-health/__tests__/sync-progress.test.ts @@ -7,12 +7,17 @@ */ import { beforeEach, describe, expect, it, vi } from "vitest"; +type UpdateManyArg = { + where: Record; + data: Record; +}; + const { prismaMock } = vi.hoisted(() => ({ prismaMock: { googleHealthConnection: { findUnique: vi.fn(), update: vi.fn(async ({ data }) => data), - updateMany: vi.fn(async () => ({ count: 1 })), + updateMany: vi.fn(async (_args: UpdateManyArg) => ({ count: 1 })), }, }, })); @@ -40,6 +45,13 @@ async function progressModule(): Promise { return (await import("../sync-progress")) as unknown as ProgressModule; } +function firstUpdateManyArg(): UpdateManyArg { + const arg = + prismaMock.googleHealthConnection.updateMany.mock.calls.at(0)?.[0]; + if (!arg) throw new Error("Expected one guarded progress update"); + return arg; +} + beforeEach(() => { vi.clearAllMocks(); prismaMock.googleHealthConnection.update.mockImplementation( @@ -87,8 +99,7 @@ describe("Google Health current-run progress", () => { ), ).resolves.toBe(true); - const updateArg = - prismaMock.googleHealthConnection.updateMany.mock.calls[0][0]; + const updateArg = firstUpdateManyArg(); const serializedGuard = JSON.stringify(updateArg.where); expect(serializedGuard).toContain("owner"); expect(serializedGuard).toContain("run-current"); @@ -125,11 +136,7 @@ describe("Google Health current-run progress", () => { state: "interrupted", terminalAt: expect.any(String), }); - expect( - JSON.stringify( - prismaMock.googleHealthConnection.updateMany.mock.calls[0][0].where, - ), - ).toContain("run-stale"); + expect(JSON.stringify(firstUpdateManyArg().where)).toContain("run-stale"); }); it("drops secret, URL, raw-error, health-value, and sample-shaped fields", async () => { @@ -146,9 +153,7 @@ describe("Google Health current-run progress", () => { })), }); - const persisted = JSON.stringify( - prismaMock.googleHealthConnection.updateMany.mock.calls[0][0].data, - ); + const persisted = JSON.stringify(firstUpdateManyArg().data); expect(persisted).not.toMatch( /secret-token|provider\\.invalid|provider body|healthValue|bpm|samples/, ); From 1dcadcb0f0449a96fe946865282087d6f46bc000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 15:04:53 +0200 Subject: [PATCH 099/125] docs(01-21): complete Google sync fairness plan - record atomic task commits and verification evidence - retain GH-01 and GH-06 as live-account evidence gates --- .../01-21-SUMMARY.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 .planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-21-SUMMARY.md diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-21-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-21-SUMMARY.md new file mode 100644 index 000000000..e812a0c29 --- /dev/null +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-21-SUMMARY.md @@ -0,0 +1,161 @@ +--- +phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability +plan: "21" +subsystem: integrations +tags: [google-health, postgres, prisma, sync-progress, react-query] + +requires: + - phase: 01-13 + provides: Google Health sync correctness baseline and real-account evidence contract + - phase: 01-15 + provides: Centralized cache/query-key invalidation seams +provides: + - Race-safe bounded current-run progress retained on GoogleHealthConnection + - Workout-first serial sync orchestration with exact redacted resource outcomes + - Authenticated subject-only progress status endpoint and timeout recovery UI + - Post-commit server and browser workout cache invalidation +affects: [google-health, integration-status, manual-sync, settings-integrations] + +tech-stack: + added: [] + patterns: + - AsyncLocalStorage-scoped per-resource counters + - Run-ID guarded JSONB current-run progress + - Terminal-result recovery through authenticated status polling + +key-files: + created: + - prisma/migrations/0287_google_health_sync_progress/migration.sql + - src/lib/google-health/sync-progress.ts + - src/app/api/google-health/sync/status/route.ts + modified: + - prisma/schema.prisma + - src/lib/google-health/client.ts + - src/lib/google-health/sync.ts + - src/lib/google-health/sync-core.ts + - src/lib/google-health/sync-workout.ts + - src/app/api/google-health/sync/route.ts + - src/components/settings/integrations/google-health-card.tsx + +key-decisions: + - "Retain only one bounded progress envelope per Google connection; a guarded run ID prevents delayed writers from replacing a newer run." + - "Treat existing pagination ceilings as truncation only when a next-page cursor remains; the observed 786-page trace is not itself a failure or an invented cap." + - "Hold the success watermark for write, rollup, partial, and truncated outcomes." + +patterns-established: + - "Resource outcomes expose exactly resource/pages/fetched/mapped/written/status/durationMs/truncated/reasonCode." + - "Manual-sync timeout recovery reads only the authenticated subject's retained terminal and invalidates consumers only after committed workout work." + +requirements-completed: [GH-02, GH-03, GH-04, GH-05, GH-07, SYNC-01, SYNC-03, PERF-01] + +duration: 18min +completed: 2026-07-29 +--- + +# Phase 01 Plan 21: Google Sync Fairness and Durable Progress Summary + +**Workout-first Google Health sync with run-ID guarded progress, bounded redacted terminal outcomes, and post-commit cache visibility** + +## Performance + +- **Duration:** 18 min +- **Started:** 2026-07-29T12:45:37Z +- **Completed:** 2026-07-29T13:02:48Z +- **Tasks:** 3 +- **Files modified:** 11 + +## Accomplishments + +- Added the sole nullable `sync_progress` JSONB column and a race-safe current-run ledger that replaces older runs, rejects delayed writers, persists stale runs as interrupted, and bounds all retained facts. +- Moved workouts ahead of dense history, instrumented actual pagination/mapping/writes, preserved 786 successful pages as complete evidence, and prevented partial/write/rollup/truncated cycles from advancing the success watermark. +- Made committed workouts immediately visible through server cache invalidation and through browser workout, Dashboard snapshot, analytics, Google Health, and integration-status query invalidation. +- Added an authenticated subject-only status endpoint and card-side terminal polling after an uncertain POST response. + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Add the bounded race-safe current-run progress store** - `5cfcb69ef` (feat) +2. **Task 2: Orchestrate fair outcomes and invalidate server reads post-commit** - `5ec1a3836` (feat) +3. **Task 3: Return and render an honest manual-sync terminal result** - `ba6d06be3` (feat) +4. **Authorized verification fix: Type guarded progress mock calls** - `5bc213c5b` (test) + +## Files Created/Modified + +- `prisma/schema.prisma` - Maps the nullable Google connection progress JSON. +- `prisma/migrations/0287_google_health_sync_progress/migration.sql` - Adds only `sync_progress JSONB`. +- `src/lib/google-health/sync-progress.ts` - Sanitizes, serializes, guards, reads, and interrupts current-run progress. +- `src/lib/google-health/__tests__/sync-progress.test.ts` - Types the guarded-write mock tuple for full TypeScript verification. +- `src/lib/google-health/client.ts` - Records actual page/fetch facts and existing-cap truncation inside a resource scope. +- `src/lib/google-health/sync.ts` - Runs workout-first resources and returns/persists terminal resource outcomes. +- `src/lib/google-health/sync-core.ts` - Maps fetch/write/rollup failures to stable reason codes and holds failed cycles. +- `src/lib/google-health/sync-workout.ts` - Invalidates server consumers after successful workout persistence. +- `src/app/api/google-health/sync/route.ts` - Returns only sanitized bounded terminal facts. +- `src/app/api/google-health/sync/status/route.ts` - Reads only the authenticated subject's current run. +- `src/components/settings/integrations/google-health-card.tsx` - Recovers uncertain responses and invalidates committed workout consumers. + +## Decisions Made + +- Used a single retained JSONB envelope rather than history or shared audit/status storage, keeping ownership and cardinality explicit. +- Counted truncation only when an existing walker limit leaves a continuation token; no provider defect or new universal ceiling was inferred from Antonios's trace. +- Kept arrival emission separate from cache invalidation and performed both only after the relevant database write resolved. + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 1 - Bug] Preserved the resolved Google sync verdict in the cohort adapter** + +- **Found during:** Task 2 (fair outcome orchestration) +- **Issue:** The legacy `.then(r => r.imported)` adapter discarded the richer resolved verdict contract and kept the shared Plan 14/22 truthfulness gate red. +- **Fix:** Replaced the chained projection with an explicit awaited result while retaining the legacy numeric cohort helper boundary. +- **Files modified:** `src/lib/google-health/sync.ts` +- **Verification:** Plan 14/22 focused cohort gate passes 52/52. +- **Committed in:** `5ec1a3836` + +**2. [Authorized scope extension - Test typing] Typed guarded-write mock calls** + +- **Found during:** Final verification +- **Issue:** The pre-seeded progress contract passed at runtime but its zero-argument `vi.fn` inference made guarded-write tuple inspection fail full TypeScript checking. +- **Fix:** Declared the mock argument tuple and centralized the runtime assertion that the guarded write exists before inspection. +- **Files modified:** `src/lib/google-health/__tests__/sync-progress.test.ts` +- **Verification:** Focused progress tests pass 4/4 and full `tsc --noEmit` passes. +- **Committed in:** `5bc213c5b` + +--- + +**Total deviations:** 2 (1 auto-fixed bug, 1 explicitly authorized test-only typing correction) +**Impact on plan:** Both fixes preserve behavior and keep production scope inside the declared Google Health files. + +## Issues Encountered + +- The initial workout cache import used a nonexistent module name; it was corrected to the existing centralized `@/lib/cache/invalidate` seam before Task 2 verification and commit. +- The pre-seeded progress contract initially inferred a zero-argument mock tuple; the explicitly authorized test-only typing correction resolved it without changing runtime behavior. + +## Verification + +- Prisma schema validation: passed. +- Focused Plan 21 unit/route/card suite: 35/35 passed. +- Fresh real-PostgreSQL progress and workout-terminal suites: 6/6 passed. +- Shared Plan 14/22 cohort outcome suite: 52/52 passed. +- Full TypeScript check: passed. +- `git diff --check`: passed. + +## User Setup Required + +None - no new external service configuration required. + +## Next Phase Readiness + +- Plans consuming Google manual-sync results can rely on bounded terminal DTOs and subject-only timeout recovery. +- GH-01 and GH-06 remain reporter/live-account evidence gated as required; this plan does not claim them complete. + +## Self-Check: PASSED + +- All 10 declared implementation files and the authorized test-only typing file exist. +- Task commits `5cfcb69ef`, `5ec1a3836`, `ba6d06be3`, and `5bc213c5b` exist. +- All plan-specific unit and real-PostgreSQL verification gates pass. + +--- +*Phase: 01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability* +*Completed: 2026-07-29* From d34d781d784e3356d901792dd60bdbea2e4c880f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 15:05:38 +0200 Subject: [PATCH 100/125] docs(01-22): record merged green outcome gate --- .../01-22-SUMMARY.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-22-SUMMARY.md b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-22-SUMMARY.md index 5b4f88805..fe6ed4efc 100644 --- a/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-22-SUMMARY.md +++ b/.planning/phases/01-v1-34-1-dashboard-insights-records-admin-and-integration-reliability/01-22-SUMMARY.md @@ -89,8 +89,12 @@ None - the implementation followed the planned bounded outcome and cohort-foldin ## Issues Encountered -- The exact focused verification command reports 51 passing tests and one static failure because `src/lib/google-health/sync.ts` still contains the pre-existing verdict-collapsing `runGoogleHealthPollCohort` adapter. That file was explicitly outside Plan 22 ownership, and the production Plan 22 handler no longer uses the adapter. -- Repository typecheck reports only seven errors in concurrently delivered Plan 21 Google sync-progress tests and its missing status route; no Plan 22 file reports a type error. +- During isolated Plan 22 execution, the exact focused command initially + reported 51 passing tests and one static failure in the adjacent + `src/lib/google-health/sync.ts` adapter. Plan 21 subsequently replaced that + adapter in commit `5ec1a3836`; the combined Plan 14/22 gate now passes 52/52. +- The concurrent Plan 21 type errors were closed in `5bc213c5b`; the repository + TypeScript check now passes. - Targeted ESLint, Prettier, and `git diff --check` pass for all four Plan 22 implementation files. ## User Setup Required @@ -100,7 +104,8 @@ None - no external service configuration required. ## Next Phase Readiness - The bounded outcome contract is ready for operator-surface persistence and display. -- Final integration verification requires the adjacent Google core adapter to retain its resolved verdict instead of collapsing it to `imported`. +- The adjacent Google core adapter now retains its resolved verdict, and the + merged Plan 21/22 verification is green. ## Self-Check: PASSED From b6dfe62c9d32a2fe7afb1689cb4c2f7ace3d2ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 15:07:05 +0200 Subject: [PATCH 101/125] test(i18n): inventory v1.34.1 locale contracts --- src/__tests__/v1341-locale-parity.test.ts | 174 ++++++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 src/__tests__/v1341-locale-parity.test.ts diff --git a/src/__tests__/v1341-locale-parity.test.ts b/src/__tests__/v1341-locale-parity.test.ts new file mode 100644 index 000000000..b1993b23f --- /dev/null +++ b/src/__tests__/v1341-locale-parity.test.ts @@ -0,0 +1,174 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +const ROOT = join(__dirname, "../.."); +const LOCALES = ["en", "de", "es", "fr", "it", "pl"] as const; + +const REQUIRED_KEYS = [ + // Security: adding a passkey now requires an existing sign-in factor. + "settings.passkeyReauth.title", + "settings.passkeyReauth.description", + "settings.passkeyReauth.methodLabel", + "settings.passkeyReauth.methods.password", + "settings.passkeyReauth.methods.totp", + "settings.passkeyReauth.methods.passkey", + "settings.passkeyReauth.methods.webauthn", + "settings.passkeyReauth.currentPassword", + "settings.passkeyReauth.authenticatorCode", + "settings.passkeyReauth.authenticatorPlaceholder", + "settings.passkeyReauth.verifyAndAdd", + // Integrations/imports: operator policy and Apple ECG terminal outcome. + "settings.nightscoutPrivateOperatorTitle", + "settings.nightscoutPrivateOperatorDescription", + "settings.sections.export.import.appleHealth.ecgResult", + // Existing terminal contracts reused by the final v1.34.1 call sites. + "insights.assessmentPreparing", + "insights.noAnalysisYet", + "insights.noProviderConfigured", + "insights.noProviderAction", + "common.loadFailed", + "common.networkError", + "settings.googleHealthSyncResult", + "settings.googleHealthSyncFailed", + "settings.googleHealthBackfillInProgress", + "settings.syncOutcome.empty", + "settings.syncOutcome.partial", + "settings.syncOutcome.failed", + // Retained score, records, and navigation contracts. + "insights.healthScore.bandSetter", + "insights.healthScore.versionAndNoise", + "insights.healthScore.methodVersion", + "insights.healthScore.source", + "records.profileFacts.save", + "records.allergies.add", + "records.allergies.addFirst", + "records.family.add", + "records.family.addFirst", + "admin.shell.sectionsNav", + "settings.shell.sectionsNav", +] as const; + +const OBSOLETE_KEYS = [ + "insights.healthScore.algorithmChanged", + "insights.healthScore.dismissAlgorithmChanged", + "insights.healthScore.method", + "insights.healthScore.composition", + "documents.detail.summary.regenerate", + "records.profileFacts.correct", + "settings.nightscoutPrivateHost", + "settings.nightscoutPrivateHostHelp", +] as const; + +const NEW_CALL_SITES = { + "src/components/settings/security-section/passkey-list-section.tsx": [ + "settings.passkeyReauth.title", + "settings.passkeyReauth.description", + "settings.passkeyReauth.methodLabel", + "settings.passkeyReauth.methods.password", + "settings.passkeyReauth.methods.totp", + "settings.passkeyReauth.methods.passkey", + "settings.passkeyReauth.methods.webauthn", + "settings.passkeyReauth.currentPassword", + "settings.passkeyReauth.authenticatorCode", + "settings.passkeyReauth.authenticatorPlaceholder", + "settings.passkeyReauth.verifyAndAdd", + ], + "src/components/settings/integrations/nightscout-card.tsx": [ + "settings.nightscoutPrivateOperatorTitle", + "settings.nightscoutPrivateOperatorDescription", + ], + "src/components/settings/import-panel/apple-health-import-card.tsx": [ + "settings.sections.export.import.appleHealth.ecgResult", + ], +} as const; + +type Catalog = Record; + +function readCatalog(locale: (typeof LOCALES)[number]): Catalog { + return JSON.parse( + readFileSync(join(ROOT, "messages", `${locale}.json`), "utf8"), + ) as Catalog; +} + +function resolveLeaf(catalog: Catalog, dottedKey: string): string | undefined { + let value: unknown = catalog; + for (const segment of dottedKey.split(".")) { + if ( + typeof value !== "object" || + value === null || + !(segment in value) + ) { + return undefined; + } + value = (value as Record)[segment]; + } + return typeof value === "string" ? value : undefined; +} + +function placeholders(message: string): string[] { + return [ + ...new Set( + [...message.matchAll(/\{([A-Za-z_][A-Za-z0-9_]*)\b/g)].map( + (match) => match[1], + ), + ), + ].sort(); +} + +describe("v1.34.1 locale reconciliation", () => { + it("wires every newly introduced user-facing state through a literal t() call", () => { + for (const [relativePath, keys] of Object.entries(NEW_CALL_SITES)) { + const source = readFileSync(join(ROOT, relativePath), "utf8"); + for (const key of keys) { + expect(source, `${relativePath} must translate ${key}`).toMatch( + new RegExp(`\\bt\\(["']${key.replaceAll(".", "\\.")}["']`), + ); + } + } + }); + + it("provides every final contract in all six locales with ICU parity", () => { + const catalogs = Object.fromEntries( + LOCALES.map((locale) => [locale, readCatalog(locale)]), + ) as Record<(typeof LOCALES)[number], Catalog>; + + for (const key of REQUIRED_KEYS) { + const english = resolveLeaf(catalogs.en, key); + expect(english, `messages/en.json is missing ${key}`).toBeTruthy(); + + for (const locale of LOCALES) { + const translated = resolveLeaf(catalogs[locale], key); + expect( + translated, + `messages/${locale}.json is missing ${key}`, + ).toBeTruthy(); + expect( + placeholders(translated ?? ""), + `messages/${locale}.json changed ICU placeholders for ${key}`, + ).toEqual(placeholders(english ?? "")); + } + } + }); + + it("removes only the v1.34.1 copy proven obsolete after final call-site inventory", () => { + for (const locale of LOCALES) { + const catalog = readCatalog(locale); + for (const key of OBSOLETE_KEYS) { + expect( + resolveLeaf(catalog, key), + `messages/${locale}.json still contains obsolete ${key}`, + ).toBeUndefined(); + } + } + + const productionSource = Object.keys(NEW_CALL_SITES) + .map((relativePath) => readFileSync(join(ROOT, relativePath), "utf8")) + .join("\n"); + for (const key of OBSOLETE_KEYS) { + expect(productionSource).not.toContain(`t("${key}")`); + expect(productionSource).not.toContain(`t('${key}')`); + } + }); +}); From 67eeefd5845e3ae8f4cd4d9f55650d32c877962a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Wed, 29 Jul 2026 15:07:35 +0200 Subject: [PATCH 102/125] fix(i18n): translate final security and import states --- .../import-panel/apple-health-import-card.tsx | 11 +++--- .../settings/integrations/nightscout-card.tsx | 4 +-- .../security-section/passkey-list-section.tsx | 34 ++++++++++--------- 3 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/components/settings/import-panel/apple-health-import-card.tsx b/src/components/settings/import-panel/apple-health-import-card.tsx index cbd26db28..83afdf568 100644 --- a/src/components/settings/import-panel/apple-health-import-card.tsx +++ b/src/components/settings/import-panel/apple-health-import-card.tsx @@ -276,10 +276,13 @@ export function AppleHealthImportCard() { data-testid="import-apple-health-ecg-result" className="text-muted-foreground text-xs" > - ECGs: {ecg?.discovered ?? 0} discovered, {ecg?.imported ?? 0}{" "} - imported, {ecg?.updated ?? 0} updated, {ecg?.skipped ?? 0} skipped,{" "} - {ecg?.failed ?? 0} failed. ECG failures do not discard supported - records imported from export.xml. + {t("settings.sections.export.import.appleHealth.ecgResult", { + discovered: ecg?.discovered ?? 0, + imported: ecg?.imported ?? 0, + updated: ecg?.updated ?? 0, + skipped: ecg?.skipped ?? 0, + failed: ecg?.failed ?? 0, + })}

    )} {isDone && (status?.result?.cumulativeEstimates?.days ?? 0) > 0 && ( diff --git a/src/components/settings/integrations/nightscout-card.tsx b/src/components/settings/integrations/nightscout-card.tsx index 3008bed95..a62966071 100644 --- a/src/components/settings/integrations/nightscout-card.tsx +++ b/src/components/settings/integrations/nightscout-card.tsx @@ -229,10 +229,10 @@ export function NightscoutCard({ className="border-warning/30 bg-warning/10 space-y-1 rounded-md border px-3 py-2" >

    - Private Nightscout access requires server operator approval. + {t("settings.nightscoutPrivateOperatorTitle")}

    - Ask your server operator to approve this exact Nightscout origin. + {t("settings.nightscoutPrivateOperatorDescription")}

    )} diff --git a/src/components/settings/security-section/passkey-list-section.tsx b/src/components/settings/security-section/passkey-list-section.tsx index 20232f55d..538bf5f4b 100644 --- a/src/components/settings/security-section/passkey-list-section.tsx +++ b/src/components/settings/security-section/passkey-list-section.tsx @@ -77,8 +77,7 @@ export function PasskeyListSection({ const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); const [reauthOpen, setReauthOpen] = useState(false); - const [reauthMethod, setReauthMethod] = - useState("password"); + const [reauthMethod, setReauthMethod] = useState("password"); const [reauthSecret, setReauthSecret] = useState(""); const { data: passkeys } = useQuery({ @@ -361,23 +360,25 @@ export function PasskeyListSection({
    - Confirm it's you + + {t("settings.passkeyReauth.title")} + - Verify an existing sign-in method before adding a new passkey. + {t("settings.passkeyReauth.description")}
    {( [ - ["password", "Password"], - ["totp", "Authenticator code"], - ["passkey", "Passkey"], - ["webauthn", "Security key"], + ["password", t("settings.passkeyReauth.methods.password")], + ["totp", t("settings.passkeyReauth.methods.totp")], + ["passkey", t("settings.passkeyReauth.methods.passkey")], + ["webauthn", t("settings.passkeyReauth.methods.webauthn")], ] satisfies Array<[ReauthMethod, string]> ).map(([method, label]) => (