mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
Merge pull request #461 from MBombeck/release/v1.28.17
release/v1.28.17 — dashboard edits stick; share scope honoured
This commit is contained in:
14 files changed
+236
-61
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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/*`
|
||||
|
||||
Binary file not shown.
+40
-44
@@ -83,17 +83,25 @@ async function primeAllCaches(): Promise<void> {
|
||||
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.
|
||||
|
||||
Vendored
+8
-3
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<string, boolean>;
|
||||
};
|
||||
// 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<string, boolean>;
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof parseDoctorReportPrefs>;
|
||||
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<ShareViewData> {
|
||||
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
|
||||
|
||||
@@ -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<typeof import("@napi-rs/canvas")>();
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -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({
|
||||
|
||||
+1
-1
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@@ -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<typeof exportSectionsSchema>;
|
||||
|
||||
/**
|
||||
* 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<string, unknown>;
|
||||
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`.
|
||||
|
||||
Reference in new issue
Block a user