chore(release): v1.28.17 — dashboard edits stick; share scope honoured

Dashboard layout changes take effect immediately: the save now evicts the
locale-keyed home-screen snapshot it previously missed, so a re-ordered or
toggled tile set no longer reverts to the prior selection for the SWR window.

A share link built with a narrowed report-section selection now serves exactly
that selection. The stored selection is the grouped export shape; the shared
view read it through the flat parser, which dropped every grouped toggle and
fell back to the full defaults — an over-share against the owner's choice. The
view now folds the grouped shape down faithfully. Documents-only shares were
never affected.

Hardening: the shared-document download path now sanitises the filename
identically to the owner download path, and thumbnail generation re-checks the
pixel ceiling after decode so a malformed image that slips past the header
sniff still cannot exhaust the worker.
This commit is contained in:
Marc-André Bombeck committed 2026-07-10 12:36:35 +02:00
1 parent 3cdcb9c97c
commit 9b0d5dc0b7
14 files changed
+236 -61

No files matched your search

Binary file not shown.
+40 -44
View File
@@ -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.
+8 -3
View File
@@ -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);
});
});
+9 -3
View File
@@ -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 });
});
});
+15
View File
@@ -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
View File
@@ -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;
}
+8 -7
View File
@@ -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`.