mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-07 04:12:04 +00:00
chore(release): v1.28.16 — documents-only shares stay documents-only
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.
This commit is contained in:
1 parent
fcff168112
commit
6d02711e33
11 files changed
+121
-8
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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)');
|
||||
@@ -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")
|
||||
|
||||
+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.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/*`
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user