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