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:
Marc-André Bombeck authored and GitHub committed 2026-07-10 12:46:52 +02:00
commit 3f4a845aae
14 files changed
+236 -61

No files matched your search

+21
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.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
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`.