diff --git a/docs/self-hosting/account-sharing.md b/docs/self-hosting/account-sharing.md index abd6a128a..c05814d85 100644 --- a/docs/self-hosting/account-sharing.md +++ b/docs/self-hosting/account-sharing.md @@ -49,16 +49,16 @@ Either the entire record, which is what sharing has always meant and what every grant written before v1.37.0 carries, or a set of named sections chosen when the invitation is written: -| Section | What it covers | -| ----------------- | -------------------------------------------------------------------------- | -| Readings | Weight, blood pressure, pulse, glucose, sleep, workouts and custom metrics | -| Medications | The medication list, its schedules, doses taken, side effects and stock | -| Lab results | Lab results and the analytes behind them | -| Health background | Allergies, family history and the facts in the health profile | -| Illness | Illness episodes and the day logs kept under them | -| Mood and mind | Mood entries and the mental-health questionnaire history | -| Cycle | Cycle tracking | -| Documents | Everything in the document vault | +| Section | What it covers | +| ----------------- | ---------------------------------------------------------------------------------------------------------------------------- | +| Readings | Weight, blood pressure, pulse, glucose, sleep, workouts and custom metrics | +| Medications | The medication list, its schedules, doses taken, side effects and stock | +| Lab results | Lab results and the analytes behind them | +| Health background | Allergies, family history, the facts in the health profile, visits and the practices behind them, and the vaccination record | +| Illness | Illness episodes and the day logs kept under them | +| Mood and mind | Mood entries and the mental-health questionnaire history | +| Cycle | Cycle tracking | +| Documents | Everything in the document vault | A section that was not shared is refused exactly the way a record nobody shared is, so the shape of what was held back is not readable from @@ -68,8 +68,12 @@ shared. The invitation screen says this in as many words, because a note or a document name inside one section can be about any other. Aggregate surfaces (the dashboard overview, the health score, the daily -digest) appear only on a whole-record grant. A figure derived from part of -a record reads as a figure about the person, and it would not be one. +digest, every insight) read across sections, so a grant naming sections +never opens one. A figure derived from part of a record reads as a figure +about the person, and it would not be one. Whole-record grants do not all +open them equally either: the daily digest and the dashboard's snapshot +answer at read, while the health score, the dashboard's summary and the +insights need manage. A stored scope never grows. A section added by a later release is not in a set somebody ticked before it existed. @@ -80,11 +84,17 @@ Three levels, fixed when the invitation is written. Raising one means revoking and inviting again, because widening a grant in place would carry a consent the other person never gave. -| Level | Adds | -| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Read | Read what the grant opens, and change nothing. | -| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, and marking a dose taken or skipped. Editing, deleting and restoring stay with you. | -| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. | +| Level | Adds | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Read | Read what the grant opens, and change nothing. | +| Write | Add entries: a reading, a lab result, an analyte, an illness entry, a side effect, a medication, marking a dose taken or skipped, a visit, a practice, a vaccination dose and the booster reminder it suggests. Editing, deleting and restoring stay with you. | +| Manage | Change and remove entries, including ones the owner wrote; record the health background; read the insights generated from the record. Always the entire record. | + +Some entries are creates only **manage** reaches, and the split runs by +entry rather than by section: an allergy, a family-history entry, a mood +entry, a screener, a cycle day, a water entry and a preventive-care +reminder are all created at manage, in sections a write grant can +otherwise add to. Nothing in the document vault is writable at any level. Offering **manage** asks the owner for a fresh second factor if they have one enrolled, which makes it a browser-only act either way: a Bearer @@ -106,12 +116,19 @@ whole feature is built on, and it is drawn on the server: | API tokens, the MCP connector | A durable credential that outlives the access | | Connected services (Withings, Fitbit, WHOOP, Google Health, Polar, Oura, Strava, Nightscout) | Re-pointing a sync, or feeding somebody else's wearable into this record | | Notification channels and push devices | The owner's health alerts redirected to another phone or chat | -| Exports, the health-record archive, clinician share links | A one-click copy of everything, or a new door that outlives the access | +| Full exports, the encrypted archive, clinician share links | A one-click copy of everything, or a new door that outlives the access | | Asking an AI provider anything | AI processing of the owner's data, under the owner's own consent | | Modules, thresholds, units, the record's language and timezone | Configuration of the account, not content of the record | | Sharing itself | Inviting somebody else, widening the access, or passing it on | | Locale, theme, dashboard layout | Presentation belongs to the person, not to the record | +One export is the exception, and it is deliberate: the doctor report +(`POST /api/export/health-record`) answers at manage on a whole-record +grant. It renders a prepared artefact from a selection the caller declares +rather than handing the record over whole, which is the difference the row +above is drawing. The full exports and the passphrase-encrypted archive +stay with the owner. + This table is about a grant between two accounts. A managed profile is the one place where some of it is deliberately different, because a record with no login has nobody else to keep its own configuration: see "Records diff --git a/src/__tests__/openapi-sharing-denial.test.ts b/src/__tests__/openapi-sharing-denial.test.ts index 9d0cbeff1..d2dafbdac 100644 --- a/src/__tests__/openapi-sharing-denial.test.ts +++ b/src/__tests__/openapi-sharing-denial.test.ts @@ -45,16 +45,16 @@ * - Comments are stripped before matching. `.../guardians/route.ts` names both * fence helpers in a docblock explaining why it uses neither, and counting * prose would classify an actor surface as delegable. - * - It covers the PUBLISHED surface, which is not the whole delegable surface. - * 116 route modules resolve the fence; 90 of them are in the OpenAPI document - * and 26 have never been (mood entries, the record-settings tree, the - * medication intake tree, several insight reads). That gap predates this - * release and is not closed here — "frozen" means the published contract is - * complete and consistent, not that every delegable route is described. What - * this guard does guarantee is that one of those 26 cannot be published - * without its refusal, because publishing it puts it in scope on the next run. + * - It covered the PUBLISHED surface and not the whole delegable one. That was + * the honest limit at v1.37.0, when 116 route modules resolved the fence and + * 26 of them were in no OpenAPI document at all. The gap is closed on this + * tree, so the limit is now an assertion rather than a caveat: the last leg + * below recomputes the fenced modules from the route sources and requires + * every one of them to be published. Two counts is one too many, so it names + * no number — a hard-coded figure is what turned the paragraph it replaces + * into a false claim about the contract. */ -import { readFileSync, existsSync } from "node:fs"; +import { readdirSync, readFileSync, existsSync } from "node:fs"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -84,6 +84,42 @@ function stripComments(text: string): string { return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|\s)\/\/.*$/gm, "$1"); } +/** + * Every `route.ts` under `src/app/api` whose handlers resolve the record fence. + * + * Comments stripped first, for the reason the operation matcher strips them: + * one module names both fence helpers in a docblock explaining why it uses + * neither. + */ +function fencedRouteModules(): string[] { + const out: string[] = []; + const walk = (dir: string) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + continue; + } + if (entry.name !== "route.ts") continue; + const source = stripComments(readFileSync(full, "utf8")); + if (/require(RecordAuth|GuardianAuth)\s*\(/.test(source)) { + out.push(full); + } + } + }; + walk(join(ROOT, "src/app/api")); + return out; +} + +/** `…/src/app/api/labs/[id]/route.ts` → `/api/labs/{id}`. */ +function apiPathFor(moduleFile: string): string { + return moduleFile + .slice(join(ROOT, "src/app").length) + .replaceAll("\\", "/") + .replace(/\/route\.ts$/, "") + .replace(/\[([^\]]+)\]/g, "{$1}"); +} + function routeModulePath(apiPath: string): string { return join( ROOT, @@ -334,6 +370,33 @@ describe("the sharing refusal every delegable path publishes", () => { } }); + it("publishes every module that resolves the fence", () => { + // The reverse direction of the leg above, and the one the docblock used to + // apologise for: that one holds every PUBLISHED fenced operation to the + // refusal, and says nothing about a fenced module nobody published. A + // module missing from the document is a delegable surface a generated + // client cannot see and a reader of the contract cannot find, which is + // exactly how the mood-entry tree and the record-settings tree stayed + // invisible for a release. + const modules = fencedRouteModules(); + expect( + modules.length, + "no fenced route module was discovered — the matcher found nothing", + ).toBeGreaterThan(100); + + const published = new Set(Object.keys(openApiPaths)); + const missing = modules + .map((rel) => apiPathFor(rel)) + .filter((apiPath) => !published.has(apiPath)); + + expect( + missing, + `these route modules resolve the record fence and are in no published path:\n${missing + .map((apiPath) => ` ❌ ${apiPath}`) + .join("\n")}`, + ).toEqual([]); + }); + it("carries the refusal into the generated document", () => { const spec = readFileSync(join(ROOT, "docs/api/openapi.yaml"), "utf8"); expect(spec).toContain(SHARING_ACCESS_DENIED_DESCRIPTION.slice(0, 60));