From 6d02711e334de7b12189fb2bb3473c24d422d09d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 10 Jul 2026 02:08:03 +0200 Subject: [PATCH] =?UTF-8?q?chore(release):=20v1.28.16=20=E2=80=94=20docume?= =?UTF-8?q?nts-only=20shares=20stay=20documents-only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A share link created to carry only documents now records an explicit, frozen document_only flag, and the shared view gates on that flag directly instead of inferring documents-only from "are all report sections off?". The inference would have re-opened an existing documents-only link the day a new report section was added — the link's frozen settings never mention the new section, so it would default on and begin serving that data. The flag closes that path; the derived check stays only as the fallback for links created before the column. Migration 0237 adds the column and freezes it on for every existing link whose sections are entirely off (checked at any nesting depth), leaving full-record shares untouched. --- CHANGELOG.md | 11 +++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- .../migration.sql | 29 +++++++++++++ prisma/schema.prisma | 9 ++++ public/sw.js | 2 +- .../api/share-links/__tests__/route.test.ts | 4 ++ src/app/api/share-links/route.ts | 6 +++ .../__tests__/share-view-data.test.ts | 42 ++++++++++++++++++- .../clinician-share/resolve-share-token.ts | 9 ++++ src/lib/clinician-share/share-view-data.ts | 13 ++++-- 11 files changed, 121 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/0237_share_link_document_only/migration.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index 9c028ddd5..89b7e1aca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +## [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 +documents-only now carries an explicit frozen flag, and the shared view reads +that flag directly instead of inferring it from "are all report sections off?". +The old inference would have re-opened an existing documents-only link the day a +new report section was added — the link's frozen settings never mention the new +section, so it would have defaulted on. The flag closes that path: a +documents-only link can never begin serving health data later, whatever the +report grows to include. Existing documents-only links are migrated to the flag. + ## [1.28.15] — 2026-07-10 — Document thumbnails; admin-shared AI access The documents library now shows a thumbnail for each file — a small preview diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 2158e8a8a..97ef64605 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.15 + version: 1.28.16 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 9015373f2..4f3d5e224 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.28.15", + "version": "1.28.16", "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/prisma/migrations/0237_share_link_document_only/migration.sql b/prisma/migrations/0237_share_link_document_only/migration.sql new file mode 100644 index 000000000..6bd3c5202 --- /dev/null +++ b/prisma/migrations/0237_share_link_document_only/migration.sql @@ -0,0 +1,29 @@ +-- v1.28.16 — explicit `document_only` flag on a clinician share link. +-- +-- Until now "this link serves only documents, no health record" was DERIVED at +-- read time as "are all report sections off?" (`hasAnyReportSection`). That +-- coupling is fragile: the day a new report section is added to the prefs shape, +-- an OLD documents-only link — whose frozen `sections_json` never mentions the +-- new key — would resolve the new key from the defaults (ON), the derived check +-- would flip to "has a section", and the link would start serving that section's +-- data. This column makes the guarantee authoritative and frozen at creation, so +-- no future section can silently re-open an existing documents-only link. +ALTER TABLE "clinician_share_links" + ADD COLUMN "document_only" BOOLEAN NOT NULL DEFAULT false; + +-- Backfill: freeze the flag ON for every existing link whose sections are +-- EXPLICITLY all-off (a non-empty object with NO `true` value at ANY depth — the +-- shape the documents-only create flow persisted as `EMPTY_DOCTOR_REPORT_PREFS`). +-- The recursive `$.** ? (@ == true)` JSONPath is deliberate: a record share can +-- store a GROUPED shape (e.g. `{"vitals":{"bp":true}}`) whose only `true` sits in +-- a nested object, so a top-level-only scan would misread it as empty and +-- wrongly pin it documents-only. Checking every depth marks ONLY links that truly +-- enable no section — so this can never remove report access a viewer legitimately +-- had, only pin an already-empty report against a future schema growth. The `{}` +-- case is excluded (empty object = "full-record defaults", NOT documents-only). +-- Safe on a populated table (non-volatile default, no rewrite) and idempotent. +UPDATE "clinician_share_links" +SET "document_only" = true +WHERE jsonb_typeof("sections_json") = 'object' + AND "sections_json" <> '{}'::jsonb + AND NOT jsonb_path_exists("sections_json", '$.** ? (@ == true)'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 009860083..6a4f71534 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -4947,6 +4947,15 @@ model ClinicianShareLink { resourceTypes String[] @map("resource_types") /// Whether the FHIR API is reachable via this link at all (vs the view only). allowFhirApi Boolean @default(false) @map("allow_fhir_api") + /// v1.28.16 — the authoritative "documents-only" flag, frozen write-once at + /// create. When true the public view serves ONLY the attached documents and + /// the aggregator is never called, INDEPENDENT of `sectionsJson`. Decoupling + /// the guarantee from "are all section flags off?" means a future report + /// section added to the prefs shape can never silently re-open an old + /// documents-only link (the previous derived check would have leaked it once + /// the new key defaulted on). Legacy links (pre-column) keep the derived + /// all-off fallback in the view loader. + documentOnly Boolean @default(false) @map("document_only") /// REQUIRED — no never-expiring share (capped at `SHARE_LINK_MAX_DAYS`). expiresAt DateTime @map("expires_at") diff --git a/public/sw.js b/public/sw.js index 26b31cda7..40a4000d5 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.15"; + /* @sw-version-fallback */ "v1.28.16"; 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/api/share-links/__tests__/route.test.ts b/src/app/api/share-links/__tests__/route.test.ts index 3da5d5ebe..e040c6b6e 100644 --- a/src/app/api/share-links/__tests__/route.test.ts +++ b/src/app/api/share-links/__tests__/route.test.ts @@ -294,6 +294,10 @@ describe("POST /api/share-links — create", () => { // Sections persist as an explicit all-OFF blob (distinct from `{}` = full // record defaults). The clinician view reads this as "no report". expect(createArg.data.sectionsJson).toEqual(EMPTY_DOCTOR_REPORT_PREFS); + // v1.28.16 — the frozen documents-only flag is persisted so the view path + // gates on the column, not on inferring "all sections off". This is what a + // future report section can never re-open. + expect(createArg.data.documentOnly).toBe(true); // The picked document still rides along. expect(createArg.data.documents).toEqual({ create: [{ documentId: "doc-a" }], diff --git a/src/app/api/share-links/route.ts b/src/app/api/share-links/route.ts index 82b32bd2a..b92a22e33 100644 --- a/src/app/api/share-links/route.ts +++ b/src/app/api/share-links/route.ts @@ -67,6 +67,7 @@ const shareLinkSummarySelect = { rangeEnd: true, resourceTypes: true, allowFhirApi: true, + documentOnly: true, passphraseHash: true, expiresAt: true, createdAt: true, @@ -84,6 +85,7 @@ function toSummary(row: { rangeEnd: Date | null; resourceTypes: string[]; allowFhirApi: boolean; + documentOnly: boolean; passphraseHash: string | null; expiresAt: Date; createdAt: Date; @@ -99,6 +101,9 @@ function toSummary(row: { rangeEnd: row.rangeEnd ? row.rangeEnd.toISOString() : null, resourceTypes: row.resourceTypes, allowFhirApi: row.allowFhirApi, + // v1.28.16 — the frozen documents-only flag (owner-facing; the serve path + // gates on the column, not on "are all sections off?"). + documentOnly: row.documentOnly, // v1.18.7 — surface only WHETHER a passphrase guards the link, never the // hash itself. Legacy (null-hash) links read `false` and stay ungated. protected: row.passphraseHash !== null, @@ -193,6 +198,7 @@ export const POST = apiHandler(async (request: NextRequest) => { sectionsJson: sectionsJson as Prisma.InputJsonValue, resourceTypes, allowFhirApi, + documentOnly, expiresAt: new Date(input.expiresAt), documents: documentIds.length > 0 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 0cbe7c7f1..6536058bd 100644 --- a/src/lib/clinician-share/__tests__/share-view-data.test.ts +++ b/src/lib/clinician-share/__tests__/share-view-data.test.ts @@ -213,10 +213,50 @@ describe("loadShareViewData — documents-only share exposes no health data", () // share), so the aggregator DOES run — the empty-scope short-circuit must // not swallow a normal record share. const { report, documentOnly } = await loadShareViewData( - ctx({ sectionsJson: {} }), + ctx({ sectionsJson: {}, documentOnly: false }), ); expect(collect).toHaveBeenCalledTimes(1); expect(documentOnly).toBe(false); expect(report).not.toBeNull(); }); }); + +/** + * v1.28.16 — the frozen `documentOnly` COLUMN is authoritative. Once a link is + * created documents-only, it stays documents-only regardless of what the report + * sections resolve to — so a report section added to the prefs shape LATER can + * never re-open an existing documents-only link. The legacy fallback (derive + * from "all sections off") still holds for pre-column links where the flag is + * false. + */ +describe("loadShareViewData — documentOnly column is authoritative", () => { + beforeEach(() => { + vi.clearAllMocks(); + collect.mockResolvedValue({ patient: { displayName: "Shared record" } }); + findDocs.mockResolvedValue([]); + }); + + it("serves no report when the column is set, even if sections would aggregate", async () => { + // Sections that resolve to an ENABLED scope (defaults) — the derived check + // alone would run the aggregator. The frozen column must veto it. This is + // the exact future-leak the column closes: a new section defaulting on can + // no longer widen an old documents-only link. + const { report, documentOnly } = await loadShareViewData( + ctx({ sectionsJson: { bp: true }, documentOnly: true }), + ); + expect(collect).not.toHaveBeenCalled(); + expect(report).toBeNull(); + expect(documentOnly).toBe(true); + }); + + it("falls back to the derived all-off check for a legacy link (column false)", async () => { + // A pre-column documents-only link reads `documentOnly:false` from the row + // but still has every section off — the derived fallback keeps it closed. + const { report, documentOnly } = await loadShareViewData( + ctx({ sectionsJson: EMPTY_DOCTOR_REPORT_PREFS, documentOnly: false }), + ); + expect(collect).not.toHaveBeenCalled(); + expect(report).toBeNull(); + expect(documentOnly).toBe(true); + }); +}); diff --git a/src/lib/clinician-share/resolve-share-token.ts b/src/lib/clinician-share/resolve-share-token.ts index 7eb3c8494..53ca7ff3a 100644 --- a/src/lib/clinician-share/resolve-share-token.ts +++ b/src/lib/clinician-share/resolve-share-token.ts @@ -52,6 +52,13 @@ export interface ShareContext { resourceTypes: string[]; /** Whether the scoped FHIR API is reachable via this link at all. */ allowFhirApi: boolean; + /** + * v1.28.16 — authoritative documents-only flag, frozen at create. When true + * the view loader serves ONLY documents and never aggregates a health report, + * regardless of `sectionsJson`. Legacy links minted before the column read + * `false` here and fall back to the derived all-sections-off check. + */ + documentOnly: boolean; /** Absolute expiry instant. */ expiresAt: Date; } @@ -84,6 +91,7 @@ export async function resolveShareToken( sectionsJson: true, resourceTypes: true, allowFhirApi: true, + documentOnly: true, expiresAt: true, revokedAt: true, }, @@ -114,6 +122,7 @@ export async function resolveShareToken( sectionsJson: row.sectionsJson, resourceTypes: row.resourceTypes, allowFhirApi: row.allowFhirApi, + documentOnly: row.documentOnly, expiresAt: row.expiresAt, }; } diff --git a/src/lib/clinician-share/share-view-data.ts b/src/lib/clinician-share/share-view-data.ts index 10f15774c..4f09d5dfc 100644 --- a/src/lib/clinician-share/share-view-data.ts +++ b/src/lib/clinician-share/share-view-data.ts @@ -95,10 +95,15 @@ export async function loadShareViewData( const sections = parseDoctorReportPrefs(context.sectionsJson); const range = frozenRange(context); - // A share with NO report section enabled is a documents-only share: never - // aggregate — no health metric is read from the DB, let alone served. This is - // the load-bearing guarantee behind "share this document, not the record". - const documentOnly = !hasAnyReportSection(sections); + // A documents-only share never aggregates — no health metric is read from the + // DB, let alone served. This is the load-bearing guarantee behind "share this + // document, not the record". v1.28.16: the frozen `documentOnly` COLUMN is + // authoritative; the derived all-sections-off check remains as the fallback + // for legacy links minted before the column (where it reads `false`). Because + // the column can only pin — never widen — this is strictly safer than the + // derived check alone: a future report section can never re-open a link the + // owner froze as documents-only. + const documentOnly = context.documentOnly || !hasAnyReportSection(sections); const [report, documents] = await Promise.all([ documentOnly