diff --git a/CHANGELOG.md b/CHANGELOG.md index 89b7e1aca..91194379c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ ## [Unreleased] +## [1.28.17] — 2026-07-10 — Dashboard edits stick; share scope honoured + +Fixes two bugs and hardens two edges. + +Dashboard layout changes now take effect immediately. Reordering or toggling +tiles and pressing save could appear to revert to the previous selection for a +few minutes: the saved layout was written correctly, but the home screen kept +serving a cached snapshot that the save never evicted. The save now clears that +snapshot, so the dashboard reflects the new layout on the next paint. + +A share link created with a narrowed set of report sections now serves exactly +that set. Previously a link built with a reduced selection could fall back to the +full default set of sections when viewed — the opposite of what the owner chose. +The shared view now reads the frozen selection faithfully, so a section switched +off stays off for the recipient. Documents-only shares were never affected. + +Hardening: the shared-document download path and the owner download path now use +identical filename sanitising, and thumbnail generation enforces its size ceiling +a second time after decoding, so a malformed image that slips past the up-front +check still cannot exhaust memory. + ## [1.28.16] — 2026-07-10 — Documents-only shares stay documents-only Hardens the "share a document, not the record" guarantee. A link created as diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 97ef64605..731211ea3 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.28.16 + version: 1.28.17 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index 4f3d5e224..b98266754 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.16", + "version": "1.28.17", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 40a4000d5..9457b643f 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.28.16"; + /* @sw-version-fallback */ "v1.28.17"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*` diff --git a/src/app/c/[token]/d/[id]/route.ts b/src/app/c/[token]/d/[id]/route.ts index 7e50337e5..54d050d81 100644 Binary files a/src/app/c/[token]/d/[id]/route.ts and b/src/app/c/[token]/d/[id]/route.ts differ diff --git a/src/lib/cache/__tests__/invalidate.test.ts b/src/lib/cache/__tests__/invalidate.test.ts index 5b21eca84..34c441cef 100644 --- a/src/lib/cache/__tests__/invalidate.test.ts +++ b/src/lib/cache/__tests__/invalidate.test.ts @@ -83,17 +83,25 @@ async function primeAllCaches(): Promise { await cached(caches.dashboardWidgets, USER_B, async () => ({ d: 2 })); // v1.7.0 W6 — the unified dashboard snapshot lives under the analytics - // bucket keyed `${userId}|dashboard-snapshot`. - await cached( - caches.analytics, - dashboardSnapshotCacheKey(USER_A), - async () => ({ snap: 1 }), - ); - await cached( - caches.analytics, - dashboardSnapshotCacheKey(USER_B), - async () => ({ snap: 2 }), - ); + // bucket. Since v1.21.2 the real cell is keyed WITH the resolved locale + // (`${userId}|dashboard-snapshot|${locale}`) because the briefing prose is + // locale-specific. Seed the locale-suffixed cell (the production shape) so + // the snapshot invalidators are exercised against the key they must actually + // reach — a point-delete of the locale-less key silently misses it. + await cached(caches.analytics, snapshotCell(USER_A), async () => ({ + snap: 1, + })); + await cached(caches.analytics, snapshotCell(USER_B), async () => ({ + snap: 2, + })); +} + +/** + * The real snapshot cache cell — the locale-less base key plus the resolved + * locale segment. The invalidators must sweep by prefix to reach it. + */ +function snapshotCell(userId: string, locale = "en"): string { + return `${dashboardSnapshotCacheKey(userId)}|${locale}`; } describe("invalidateUserMeasurements", () => { @@ -104,15 +112,13 @@ describe("invalidateUserMeasurements", () => { expect(caches.analytics.get(`${USER_A}|default`)).toBeNull(); expect(caches.analytics.get(`${USER_A}|summaries`)).toBeNull(); // v1.7.0 W6 — the `${userId}|` prefix sweep covers the snapshot key. - expect(caches.analytics.get(dashboardSnapshotCacheKey(USER_A))).toBeNull(); + expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull(); expect(caches.achievements.get(USER_A)).toBeNull(); expect(caches.workouts.get(`${USER_A}|3|0||`)).toBeNull(); // User B's caches untouched. expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull(); - expect( - caches.analytics.get(dashboardSnapshotCacheKey(USER_B)), - ).not.toBeNull(); + expect(caches.analytics.get(snapshotCell(USER_B))).not.toBeNull(); expect(caches.achievements.get(USER_B)).not.toBeNull(); expect(caches.workouts.get(`${USER_B}|3|0||`)).not.toBeNull(); @@ -132,9 +138,10 @@ describe("invalidateUserMeasurements", () => { value: { a: 1 }, stale: true, }); - expect( - caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)), - ).toEqual({ value: { snap: 1 }, stale: true }); + expect(caches.analytics.getAllowStale(snapshotCell(USER_A))).toEqual({ + value: { snap: 1 }, + stale: true, + }); }); it("hard-evicts the analytics bucket with { evict: true } — interactive write posture", async () => { @@ -146,9 +153,7 @@ describe("invalidateUserMeasurements", () => { // back the pre-write payload. The evict drops the entries entirely. expect(caches.analytics.getAllowStale(`${USER_A}|default`)).toBeNull(); expect(caches.analytics.getAllowStale(`${USER_A}|summaries`)).toBeNull(); - expect( - caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)), - ).toBeNull(); + expect(caches.analytics.getAllowStale(snapshotCell(USER_A))).toBeNull(); // User B stays warm; the non-analytics buckets evict as before. expect(caches.analytics.getAllowStale(`${USER_B}|default`)).not.toBeNull(); @@ -165,7 +170,7 @@ describe("invalidateUserMood", () => { expect(caches.moodAnalytics.get(USER_A)).toBeNull(); expect(caches.achievements.get(USER_A)).toBeNull(); expect(caches.analytics.get(`${USER_A}|default`)).toBeNull(); - expect(caches.analytics.get(dashboardSnapshotCacheKey(USER_A))).toBeNull(); + expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull(); expect(caches.moodAnalytics.get(USER_B)).not.toBeNull(); expect(caches.analytics.get(`${USER_B}|default`)).not.toBeNull(); @@ -195,7 +200,7 @@ describe("invalidateUserMedications", () => { expect(caches.achievements.get(USER_A)).toBeNull(); // v1.7.0 W6 — medication writes sweep the analytics bucket, which // covers the snapshot key. - expect(caches.analytics.get(dashboardSnapshotCacheKey(USER_A))).toBeNull(); + expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull(); expect(caches.medications.get(USER_B)).not.toBeNull(); expect( caches.medicationsIntake.get(`${USER_B}|compliance|30`), @@ -232,9 +237,10 @@ describe("invalidateUserMedications", () => { expect( caches.medicationCompliance.getAllowStale(`${USER_A}|med-1|compliance`), ).toEqual({ value: { mc: 1 }, stale: true }); - expect( - caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)), - ).toEqual({ value: { snap: 1 }, stale: true }); + expect(caches.analytics.getAllowStale(snapshotCell(USER_A))).toEqual({ + value: { snap: 1 }, + stale: true, + }); // User B stays fresh. expect(caches.medications.getAllowStale(USER_B)).toEqual({ @@ -254,9 +260,7 @@ describe("invalidateUserMedications", () => { expect( caches.medicationCompliance.getAllowStale(`${USER_A}|med-1|compliance`), ).toBeNull(); - expect( - caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)), - ).toBeNull(); + expect(caches.analytics.getAllowStale(snapshotCell(USER_A))).toBeNull(); // User B stays warm; the non-SWR buckets evict as before. expect(caches.medications.getAllowStale(USER_B)).not.toBeNull(); @@ -278,11 +282,9 @@ describe("invalidateUserDashboardWidgets", () => { // reorder must drop the snapshot too. The widget invalidator does // NOT sweep the whole analytics bucket, so the slim/default cells // stay warm — only the point-keyed snapshot is dropped. - expect(caches.analytics.get(dashboardSnapshotCacheKey(USER_A))).toBeNull(); + expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull(); expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull(); - expect( - caches.analytics.get(dashboardSnapshotCacheKey(USER_B)), - ).not.toBeNull(); + expect(caches.analytics.get(snapshotCell(USER_B))).not.toBeNull(); }); }); @@ -290,12 +292,10 @@ describe("invalidateUserDashboardSnapshot", () => { it("drops only the snapshot key, leaving the slim / default analytics cells warm", async () => { await primeAllCaches(); invalidateUserDashboardSnapshot(USER_A); - expect(caches.analytics.get(dashboardSnapshotCacheKey(USER_A))).toBeNull(); + expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull(); expect(caches.analytics.get(`${USER_A}|default`)).not.toBeNull(); expect(caches.analytics.get(`${USER_A}|summaries`)).not.toBeNull(); - expect( - caches.analytics.get(dashboardSnapshotCacheKey(USER_B)), - ).not.toBeNull(); + expect(caches.analytics.get(snapshotCell(USER_B))).not.toBeNull(); }); }); @@ -303,10 +303,8 @@ describe("invalidateUserInsights", () => { it("drops the snapshot key so a fresh briefing is re-embedded", async () => { await primeAllCaches(); invalidateUserInsights(USER_A); - expect(caches.analytics.get(dashboardSnapshotCacheKey(USER_A))).toBeNull(); - expect( - caches.analytics.get(dashboardSnapshotCacheKey(USER_B)), - ).not.toBeNull(); + expect(caches.analytics.get(snapshotCell(USER_A))).toBeNull(); + expect(caches.analytics.get(snapshotCell(USER_B))).not.toBeNull(); }); }); @@ -330,9 +328,7 @@ describe("invalidateUserProfile", () => { expect( caches.insightsDerived.getAllowStale(`${USER_A}|batch|x|en`), ).toBeNull(); - expect( - caches.analytics.getAllowStale(dashboardSnapshotCacheKey(USER_A)), - ).toBeNull(); + expect(caches.analytics.getAllowStale(snapshotCell(USER_A))).toBeNull(); expect(caches.analytics.getAllowStale(`${USER_A}|default`)).toBeNull(); // The other user's cells stay warm. diff --git a/src/lib/cache/invalidate.ts b/src/lib/cache/invalidate.ts index 9172aaa8a..150c48b18 100644 --- a/src/lib/cache/invalidate.ts +++ b/src/lib/cache/invalidate.ts @@ -34,11 +34,16 @@ export function dashboardSnapshotCacheKey(userId: string): string { } /** - * Drop the unified dashboard snapshot for a single user. Cheap - * point-delete; safe to call redundantly. + * Drop the unified dashboard snapshot for a single user. The snapshot cell is + * keyed `${userId}|dashboard-snapshot|${locale}` (the briefing prose is + * locale-specific), so this must sweep by PREFIX — an exact point-delete of the + * locale-less key silently misses every real cell and the dashboard keeps + * serving the pre-write layout for the whole SWR window. The prefix is + * distinctive (nothing else in the analytics bucket starts with it), so the + * sweep touches only this user's snapshot variants. Safe to call redundantly. */ export function invalidateUserDashboardSnapshot(userId: string): void { - caches.analytics.delete(dashboardSnapshotCacheKey(userId)); + caches.analytics.deleteByPrefix(dashboardSnapshotCacheKey(userId)); } /** diff --git a/src/lib/clinician-share/__tests__/share-view-data.test.ts b/src/lib/clinician-share/__tests__/share-view-data.test.ts index 6536058bd..48aac4355 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -260,3 +260,63 @@ describe("loadShareViewData — documentOnly column is authoritative", () => { expect(documentOnly).toBe(true); }); }); + +/** + * v1.28.17 — the create schema accepts the GROUPED export sections shape + * (`{ vitals: { bp, weight, … }, activity: { sleep }, medications: { … } }`) + * and persists it raw. The clinician-view loader MUST fold that grouped shape + * down to the flat toggles the aggregator consumes — reading it through the + * flat doctor-report parser silently drops every grouped toggle and re-defaults + * the section back ON, re-widening a scope the owner explicitly narrowed. This + * pins that a section switched OFF in the grouped shape reaches the aggregator + * as OFF, not as a defaults-on leak. + */ +describe("loadShareViewData — grouped sections are folded, not silently defaulted", () => { + beforeEach(() => { + vi.clearAllMocks(); + collect.mockResolvedValue({ patient: { displayName: "Shared record" } }); + findDocs.mockResolvedValue([]); + }); + + it("honours grouped OFF toggles instead of falling back to defaults-ON", async () => { + // Owner froze a labs-focused share: vitals OFF, but the grouped keys live + // under `vitals` where the flat parser cannot see them. Pre-fix, bp/weight/ + // pulse resolved to the defaults (ON) and leaked to the clinician. + await loadShareViewData( + ctx({ + sectionsJson: { + vitals: { bp: false, weight: false, pulse: false }, + activity: { sleep: false }, + labs: true, + }, + documentOnly: false, + }), + ); + + expect(collect).toHaveBeenCalledTimes(1); + const opts = collect.mock.calls[0]![2] as { + sections: Record; + }; + // The grouped OFF toggles must survive the fold. + expect(opts.sections.bp).toBe(false); + expect(opts.sections.weight).toBe(false); + expect(opts.sections.pulse).toBe(false); + expect(opts.sections.sleep).toBe(false); + // A toggle the owner left ON stays ON. + expect(opts.sections.labs).toBe(true); + }); + + it("still resolves a flat legacy blob through the flat parser unchanged", async () => { + // A flat shape has no grouped-only key, so it keeps the exact legacy path. + await loadShareViewData( + ctx({ sectionsJson: { bp: false, mood: true }, documentOnly: false }), + ); + const opts = collect.mock.calls[0]![2] as { + sections: Record; + }; + expect(opts.sections.bp).toBe(false); + expect(opts.sections.mood).toBe(true); + // Unspecified flat keys fall back to the documented defaults. + expect(opts.sections.weight).toBe(true); + }); +}); diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts index 4f09d5dfc..6c30e80e5 100644 --- a/src/lib/clinician-share/share-view-data.ts +++ b/src/lib/clinician-share/share-view-data.ts @@ -22,8 +22,9 @@ import { servingClassFor } from "@/lib/documents/upload-policy"; import type { DocumentServingClass } from "@/lib/documents/upload-policy"; import { hasAnyReportSection, - parseDoctorReportPrefs, + type DoctorReportPrefs, } from "@/lib/validations/doctor-report-prefs"; +import { resolveStoredReportSections } from "@/lib/validations/health-record-export"; import type { ShareContext } from "@/lib/clinician-share/resolve-share-token"; /** @@ -58,7 +59,7 @@ export interface ShareViewData { */ report: DoctorReportData | null; /** The resolved section toggles (mood opt-in, defaults otherwise). */ - sections: ReturnType; + sections: DoctorReportPrefs; /** v1.28 — the hand-picked documents on this link (metadata only). */ documents: ShareViewDocument[]; /** @@ -92,7 +93,12 @@ function frozenRange(context: ShareContext): DoctorReportRange { export async function loadShareViewData( context: ShareContext, ): Promise { - const sections = parseDoctorReportPrefs(context.sectionsJson); + // The stored `sectionsJson` may be the GROUPED export shape (what the create + // schema accepts) or the FLAT doctor-report shape (documents-only / legacy). + // `resolveStoredReportSections` folds either down correctly — reading a + // grouped blob through the flat parser would silently drop every grouped + // toggle and default the section back ON, re-widening a scope the owner froze. + const sections = resolveStoredReportSections(context.sectionsJson); const range = frozenRange(context); // A documents-only share never aggregates — no health metric is read from the diff --git a/src/lib/documents/__tests__/thumbnail.test.ts b/src/lib/documents/__tests__/thumbnail.test.ts index e20aa0b65..59a03ab64 100644 --- a/src/lib/documents/__tests__/thumbnail.test.ts +++ b/src/lib/documents/__tests__/thumbnail.test.ts @@ -5,6 +5,20 @@ import { describe, expect, it, vi } from "vitest"; vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); +// Partial mock of the native canvas module: `loadImage` delegates to the real +// decoder by default (so every real-fixture test below is unaffected) and can +// be overridden per-test via `loadImageMock` to simulate a decoded image whose +// dimensions the header sniff could not have known. +const { loadImageMock } = vi.hoisted(() => ({ loadImageMock: vi.fn() })); +vi.mock("@napi-rs/canvas", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + loadImage: (source: Buffer | Uint8Array) => + loadImageMock(source) ?? actual.loadImage(source), + }; +}); + import { generateThumbnail, THUMB_LONG_EDGE } from "../thumbnail"; /** @@ -141,4 +155,15 @@ describe("generateThumbnail", () => { ok: false, }); }); + + it("refuses an oversize image the header sniff missed (post-decode cap)", async () => { + // Bytes the JPEG sniffer cannot read (no SOI) → the pre-decode guard is + // skipped and the code falls through to `loadImage`. The decoder is forced + // to return 30000×30000 = 900 MP, so only the post-decode backstop can + // catch it. Without that backstop this would OOM the serial worker. + loadImageMock.mockResolvedValueOnce({ width: 30_000, height: 30_000 }); + await expect( + generateThumbnail(Buffer.from("desynced jpeg bytes"), "image/jpeg"), + ).resolves.toEqual({ ok: false }); + }); }); diff --git a/src/lib/documents/thumbnail.ts b/src/lib/documents/thumbnail.ts index f952de58b..d660e21d0 100644 --- a/src/lib/documents/thumbnail.ts +++ b/src/lib/documents/thumbnail.ts @@ -233,6 +233,21 @@ export async function generateThumbnail( return { ok: false }; } const image = await napi.loadImage(bytes); + // Post-decode backstop: the header sniff returns null on any marker + // desync (a malformed-but-decodable JPEG), falling through to here with + // NO ceiling. Re-check the DECODED dimensions so such a source can't + // bypass the cap and OOM the serial worker. + if (image.width * image.height > MAX_SOURCE_PIXELS) { + annotate({ + action: { name: "documents.thumbnail.rejected" }, + meta: { + source: "image", + reason: "pixel_cap_post_decode", + pixels: image.width * image.height, + }, + }); + return { ok: false }; + } const thumb = drawScaledJpeg(napi, image); if (!thumb) return { ok: false }; annotate({ diff --git a/src/lib/mcp/auth.ts b/src/lib/mcp/auth.ts index 7d6fb57dd..91eccc9c2 100644 --- a/src/lib/mcp/auth.ts +++ b/src/lib/mcp/auth.ts @@ -30,7 +30,7 @@ export interface McpAuthContext { binding: string; /** Whether this session may read (always true for a valid token). */ canRead: boolean; - /** Whether this session may write (requires `health:write`; no write tools yet). */ + /** Whether this session may write (requires `health:write`; gates the live write tools). */ canWrite: boolean; } diff --git a/src/lib/mcp/scopes.ts b/src/lib/mcp/scopes.ts index b04d57d7e..45087e151 100644 --- a/src/lib/mcp/scopes.ts +++ b/src/lib/mcp/scopes.ts @@ -6,15 +6,16 @@ * least-privilege contract: * * - `health:read` — read the user's own health data. The default MCP posture. - * - `health:write` — mutate (log a measurement / intake / mood). Reserved for - * the write tools, which are NOT registered in this milestone phase. + * - `health:write` — mutate (log a measurement / intake / mood). Required by + * the write tools, which ARE registered and live (see `write-tools.ts`). * * Read tools impose no scope requirement of their own: every underlying read * path is already an unscoped authenticated route, so a narrow-scope token works - * and a wildcard token is never required (REQ-SEC-5). The strong "read-only by - * default" guarantee (REQ-SEC-1 / ADR-003) is structural — only read tools are - * registered — not a runtime flag a future token could flip. Write registration - * (a later phase) gates on `health:write` via `tokenAllowsWrite`. + * and a wildcard token is never required (REQ-SEC-5). The "read-only by default" + * posture (REQ-SEC-1 / ADR-003) is preserved by the scope gate, not by omitting + * the write surface: write tools are registered but only reachable when the + * presented token carries `health:write` (or the wildcard), enforced through + * `tokenAllowsWrite` — a token without it sees the read surface alone. */ /** Wildcard scope — grants every capability the token model can express. */ @@ -23,7 +24,7 @@ export const SCOPE_WILDCARD = "*"; /** Read the authenticated user's own health data over MCP. */ export const SCOPE_HEALTH_READ = "health:read"; -/** Mutate the authenticated user's data over MCP (reserved; no write tools yet). */ +/** Mutate the authenticated user's data over MCP (gates the live write tools). */ export const SCOPE_HEALTH_WRITE = "health:write"; /** diff --git a/src/lib/validations/health-record-export.ts b/src/lib/validations/health-record-export.ts index 3cee53573..7563e0e82 100644 --- a/src/lib/validations/health-record-export.ts +++ b/src/lib/validations/health-record-export.ts @@ -17,6 +17,7 @@ import { z } from "zod/v4"; import { locales } from "@/lib/i18n/config"; import { DEFAULT_DOCTOR_REPORT_PREFS, + parseDoctorReportPrefs, type DoctorReportPrefs, } from "@/lib/validations/doctor-report-prefs"; @@ -87,6 +88,51 @@ export const exportSectionsSchema = z export type ExportSections = z.infer; +/** + * The top-level keys that exist ONLY in the grouped {@link exportSectionsSchema} + * (never in the flat `doctorReportPrefsSchema`). Their presence is the reliable + * signal that a persisted `sectionsJson` blob is the grouped shape rather than + * the flat one — the flat shape carries `bp` / `weight` / `pulse` at the top + * level, the grouped shape nests them under `vitals`. + */ +const GROUPED_SECTION_KEYS = [ + "vitals", + "cardioFitness", + "activity", + "medications", + "glucose", +] as const; + +/** + * v1.28.17 — resolve a STORED `sectionsJson` blob (from a clinician share link + * or any consumer that persists the export selection) to the flat + * {@link DoctorReportPrefs} the aggregator + PDF renderer consume. + * + * The share-link create schema accepts the GROUPED {@link exportSectionsSchema} + * and persists it raw, but the report aggregator speaks the FLAT shape. Reading + * a grouped blob through the flat parser silently DROPS every grouped toggle + * (`vitals.bp`, `activity.sleep`, `medications.compliance`) and falls back to + * defaults-ON — so a section the owner switched OFF would still be served. This + * resolver closes that gap: a grouped blob is folded through + * {@link toDoctorReportPrefs}; a flat / empty / `{}` / null blob keeps the exact + * legacy semantics via {@link parseDoctorReportPrefs}. Detection is by the + * grouped-only keys, so it can never misread a flat blob as grouped (or vice + * versa), and it needs no data migration — existing rows resolve correctly. + */ +export function resolveStoredReportSections(raw: unknown): DoctorReportPrefs { + if (raw != null && typeof raw === "object" && !Array.isArray(raw)) { + const obj = raw as Record; + const looksGrouped = GROUPED_SECTION_KEYS.some((k) => k in obj); + if (looksGrouped) { + const parsed = exportSectionsSchema.safeParse(obj); + // A well-formed grouped blob folds down; a drifted one falls through to + // the flat parser, which itself defaults on any shape it cannot read. + if (parsed.success) return toDoctorReportPrefs(parsed.data); + } + } + return parseDoctorReportPrefs(raw); +} + /** * Flagship export selection. `.strict()` so unknown keys (including any * attempt to smuggle a `userId`) 422 via `returnAllZodIssues`.