From 25e86b36b3a6f886266c6e22de47beb1bf7d94f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Tue, 4 Aug 2026 14:55:32 +0200 Subject: [PATCH 1/2] Close the delegated-access gaps the release audit left open Seven findings from the adversarial pass before v1.36.1 was tagged. One blocking defect shipped in that release; these did not, and they are all places where somebody looking after another person's health record meets a wall with no explanation, or where a guard cannot fail. The document vault was the largest. `/documents` is marked as a shared destination, so the shell offers it under a switch, and every read behind it resolved the caller instead of the record: the vault painted a query-error card and the illness view painted another beside it. That is the same failure the release describes as fixed on the dashboard. Five GET arms move to the record resolver now, and only the GET arms: the list, one document, its file, its thumbnail and the vault's usage payload, which the filter bar is built from. Uploading, retyping, deleting, linking a document to an illness and every AI verb stay with the owner, and the two reads that spend decryption CPU key their quota on the person calling rather than the record, the rule `medications/compliance` already follows. Logging a value on a metric you track yourself leaves the admitted set. It was admitted, named on the consent screen in six languages, and unreachable: the only form that posts one sits on a page a shared record does not open, and the component that renders it says so in its own comment while returning null. It is the third route the rule that withdrew allergies and family history fitted exactly, and it was applied to two of the three. Reading those values stays. Two guards could not fail, and both were proved by mutation before and after. The prefetch guard checked rule three with two regexes: one was satisfied by the doc comment every page carries, the other missed an aliased import, and the pair stayed green with the dashboard genuinely prefetching off the switch-blind session. It reads the AST now, resolves aliases through the module's own import table, and pins the five prefetching pages by name so a page that stops being detected is loud. The activity verb map decided membership on a literal `requireRecordAuth("write")`, so putting the argument on its own line, which is what the formatter does the moment the call grows, dropped a route out of the scan and turned a genuinely-red run green. Both counts are of matches now rather than of files walked. The rest are single instances of rules applied everywhere else. "Take all due" was withheld on a note saying it rides the bulk intake route; it does not, and its own docblock says why not, so a helper with five morning tablets tapped five times for nothing. Marking a dose skipped is offered wherever marking it taken is. A form could be opened in the frame before the app knew whose record was on screen and then stood there once the answer arrived, on the capture sheet and on the dashboard's quick-entry sheets. Two more copies of the intake toast were still building their own, one of them with an Undo pointing at a route no delegate may call. The customise link on the mood page led to a page that exists to say it is not part of what was shared. And the access-level radios on the invitation screen are hidden by design with no focus ring on their label, so keyboard focus moved invisibly across the one screen where somebody grants write access to their record. Three new structural guards, each watched fail first: the customise link sits inside a capability branch on every surface a delegate can reach, every intake surface confirms through the one shared decision, and the batch sweep is offered at the level that admits it. --- CHANGELOG.md | 48 +++ messages/de.json | 2 +- messages/en.json | 2 +- messages/es.json | 2 +- messages/fr.json | 2 +- messages/it.json | 2 +- messages/pl.json | 2 +- .../customize-shortcut-guard.test.ts | 218 +++++++++++++ src/__tests__/delegable-surface-guard.test.ts | 32 +- .../intake-toast-single-decision.test.ts | Bin 0 -> 8367 bytes .../prefetch-page-caching-guard.test.ts | 189 ++++++++++- .../success-affordance-guard.test.ts | 12 +- .../api/custom-metrics/[id]/entries/route.ts | 21 +- .../documents/inbound/[id]/original/route.ts | 14 +- src/app/api/documents/inbound/[id]/route.ts | 17 +- .../documents/inbound/[id]/thumbnail/route.ts | 16 +- src/app/api/documents/inbound/route.ts | 25 +- src/app/api/documents/inbound/usage/route.ts | 16 +- .../take-all-due-affordance.test.tsx | 164 ++++++++++ src/app/medications/page-client.tsx | 18 +- src/app/mood/page.tsx | 36 ++- .../delegated-write-affordances.test.tsx | 136 +++++++- .../custom-metrics/custom-metric-list.tsx | 6 +- .../dashboard/medication-intake-quick-add.tsx | 55 ++-- .../dashboard/quick-entry-sheets.tsx | 49 ++- .../documents/document-detail-sheet.tsx | 14 +- src/components/layout/capture-picker.tsx | 49 ++- .../medications/dose-history-add-dialog.tsx | 32 +- .../medications/dose-history-ledger.tsx | 35 +- .../medications/take-all-due-dialog.tsx | 9 +- src/components/medications/take-all-due.ts | 29 +- .../__tests__/grant-access-level.test.tsx | 23 ++ .../settings/access/grant-invite-card.tsx | 7 + src/hooks/use-record-capabilities.ts | 9 +- .../__tests__/verb-map-covers-routes.test.ts | 235 +++++++++++++- src/lib/record-activity/activity-verb.ts | 12 +- .../sharing-delegable-routes.test.ts | 305 ++++++++++++++++++ .../sharing-delegable-writes.test.ts | 35 +- 38 files changed, 1750 insertions(+), 128 deletions(-) create mode 100644 src/__tests__/customize-shortcut-guard.test.ts create mode 100644 src/__tests__/intake-toast-single-decision.test.ts create mode 100644 src/app/medications/__tests__/take-all-due-affordance.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index f601ee0b5..567183cc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,54 @@ ## [Unreleased] +### Added + +- The document vault opens inside a shared record. `/documents` was offered in + the navigation of somebody else's record and then refused every read behind + it, so the page rendered an error card and the illness view rendered another + one beside it. A clinical letter is health data belonging to the record, and + reading it is much of the reason to hand somebody the record in the first + place. The list, a document and its file, the preview tiles and the filter + bar all answer now. Uploading, retyping, deleting, linking a document to an + illness and every AI action stay with the owner. + +- "Take all due" is offered to somebody with write access. Marking a dose is + one of the things that access admits, and the sweep records exactly the doses + the individual cards would. It was withheld on a note about a different + endpoint that it does not use. A helper with five morning tablets to confirm + taps once. + +- Marking a dose skipped is offered wherever marking it taken is. The dose + history offered a delegate one of the pair and not the other, though both are + the same admitted verb and the medication card already offered both. + +### Changed + +- Write access no longer covers logging a value on a metric you track + yourself. The capability shipped named on the consent screen with nothing + behind it: the only form that records one sits on a page a shared record does + not open, so nobody could have used it. The consent copy now lists what the + level actually admits. Reading those values stays. + +### Fixed + +- A control could be tapped in the moment before the app knew whose record was + on screen, leaving somebody stranded in a form the server would refuse. The + capture sheet and the dashboard's quick-entry sheets now withdraw the form + when the answer arrives rather than standing on it, which is what the + measurements page and the medication wizard already did. + +- Recording a dose from the dashboard quick-add says which record it landed in + and no longer offers an Undo that only the owner may use. + +- The access-level options on the invitation screen are visible when reached by + keyboard. The radio itself is hidden by design and its label carried no focus + ring, so tabbing through the one screen where write access is granted moved + an invisible selection. + +- The customise link on the mood page no longer leads a delegate to a page that + tells them it is not part of what was shared. + ## [1.36.1] — 2026-08-04 ### Added diff --git a/messages/de.json b/messages/de.json index 3f53604d5..c75f67bee 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9438,7 +9438,7 @@ "accessReadLabel": "Darf lesen", "accessReadCapability": "Sie kann alles lesen, was von dieser Akte geteilt wird. Hinzufügen oder ändern kann sie nichts.", "accessWriteLabel": "Darf lesen und hinzufügen", - "accessWriteCapability": "Sie kann außerdem etwas zur Akte hinzufügen: Messwerte, Laborergebnisse, einen neuen Laborwert, einen Krankheitseintrag, einen Wert auf einer selbst angelegten Messgröße, eine Nebenwirkung zu einem Medikament, ein neues Medikament und eine Dosis als genommen oder ausgelassen markieren. Ändern oder löschen kann sie nichts, auch nicht das, was sie selbst eingetragen hat, und eine deiner Erinnerungen verschieben kann sie auch nicht. Das kannst nur du.", + "accessWriteCapability": "Sie kann außerdem etwas zur Akte hinzufügen: Messwerte, Laborergebnisse, einen neuen Laborwert, einen Krankheitseintrag, eine Nebenwirkung zu einem Medikament, ein neues Medikament und eine Dosis als genommen oder ausgelassen markieren. Ändern oder löschen kann sie nichts, auch nicht das, was sie selbst eingetragen hat, und eine deiner Erinnerungen verschieben kann sie auch nicht. Das kannst nur du.", "expiresLabel": "Zugriff endet am (optional)", "expiresHint": "Leer lassen für Zugriff, der gilt, bis ihn jemand beendet. Ein Datum wird bei jeder Anfrage geprüft, der Zugriff endet also am Ende dieses Tages von selbst.", "submit": "Einladung senden", diff --git a/messages/en.json b/messages/en.json index ab6c98451..6b46a70ef 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9438,7 +9438,7 @@ "accessReadLabel": "Can view", "accessReadCapability": "They can read everything this record shares. They cannot add or change anything.", "accessWriteLabel": "Can view and add", - "accessWriteCapability": "They can also add to the record: readings, lab results, a new analyte to track, an illness entry, a value on a metric you track yourself, a side effect against a drug, a new medication, and marking a dose taken or skipped. They cannot edit or delete anything, not even what they added, and they cannot defer one of your reminders. Only you can.", + "accessWriteCapability": "They can also add to the record: readings, lab results, a new analyte to track, an illness entry, a side effect against a drug, a new medication, and marking a dose taken or skipped. They cannot edit or delete anything, not even what they added, and they cannot defer one of your reminders. Only you can.", "expiresLabel": "Access ends on (optional)", "expiresHint": "Leave this empty for access that lasts until somebody ends it. A date here is checked on every request, so access stops on its own at the end of that day.", "submit": "Send invitation", diff --git a/messages/es.json b/messages/es.json index e4a1d2dfc..9160aa09f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9438,7 +9438,7 @@ "accessReadLabel": "Puede ver", "accessReadCapability": "Puede leer todo lo que este historial comparte. No puede añadir ni cambiar nada.", "accessWriteLabel": "Puede ver y añadir", - "accessWriteCapability": "También puede añadir a la ficha: mediciones, resultados de laboratorio, un nuevo analito a seguir, una entrada de enfermedad, un valor en una métrica propia, un efecto secundario de un medicamento, un medicamento nuevo, y marcar una dosis como tomada u omitida. No puede editar ni borrar nada, ni siquiera lo que añadió, ni aplazar uno de tus recordatorios. Solo tú puedes.", + "accessWriteCapability": "También puede añadir a la ficha: mediciones, resultados de laboratorio, un nuevo analito a seguir, una entrada de enfermedad, un efecto secundario de un medicamento, un medicamento nuevo, y marcar una dosis como tomada u omitida. No puede editar ni borrar nada, ni siquiera lo que añadió, ni aplazar uno de tus recordatorios. Solo tú puedes.", "expiresLabel": "El acceso termina el (opcional)", "expiresHint": "Déjalo vacío para un acceso que dure hasta que alguien lo termine. Una fecha aquí se comprueba en cada petición, así que el acceso se detiene solo al final de ese día.", "submit": "Enviar invitación", diff --git a/messages/fr.json b/messages/fr.json index 5063784bf..6f7c15361 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9438,7 +9438,7 @@ "accessReadLabel": "Peut consulter", "accessReadCapability": "Elle peut lire tout ce que ce dossier partage. Elle ne peut rien ajouter ni rien modifier.", "accessWriteLabel": "Peut consulter et ajouter", - "accessWriteCapability": "Elle peut aussi ajouter au dossier : des mesures, des résultats de laboratoire, un nouvel analyte à suivre, une entrée de maladie, une valeur sur une mesure que vous suivez vous-même, un effet indésirable lié à un médicament, un nouveau médicament, et marquer une dose comme prise ou sautée. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté, et elle ne peut pas reporter un de vos rappels. Vous seul le pouvez.", + "accessWriteCapability": "Elle peut aussi ajouter au dossier : des mesures, des résultats de laboratoire, un nouvel analyte à suivre, une entrée de maladie, un effet indésirable lié à un médicament, un nouveau médicament, et marquer une dose comme prise ou sautée. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté, et elle ne peut pas reporter un de vos rappels. Vous seul le pouvez.", "expiresLabel": "L'accès prend fin le (facultatif)", "expiresHint": "Laissez ce champ vide pour un accès qui dure jusqu'à ce que quelqu'un y mette fin. Une date ici est vérifiée à chaque requête, l'accès s'arrête donc de lui-même à la fin de cette journée.", "submit": "Envoyer l'invitation", diff --git a/messages/it.json b/messages/it.json index b49f25c5d..1a11a9ab5 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9438,7 +9438,7 @@ "accessReadLabel": "Può vedere", "accessReadCapability": "Può leggere tutto ciò che questa cartella condivide. Non può aggiungere né modificare nulla.", "accessWriteLabel": "Può vedere e aggiungere", - "accessWriteCapability": "Può anche aggiungere alla cartella: misurazioni, risultati di laboratorio, un nuovo analita da seguire, una voce di malattia, un valore su una metrica che segui tu, un effetto collaterale di un farmaco, un nuovo farmaco e segnare una dose come presa o saltata. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto, e non può rimandare uno dei tuoi promemoria. Solo tu puoi.", + "accessWriteCapability": "Può anche aggiungere alla cartella: misurazioni, risultati di laboratorio, un nuovo analita da seguire, una voce di malattia, un effetto collaterale di un farmaco, un nuovo farmaco e segnare una dose come presa o saltata. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto, e non può rimandare uno dei tuoi promemoria. Solo tu puoi.", "expiresLabel": "L'accesso termina il (facoltativo)", "expiresHint": "Lascia vuoto per un accesso che dura finché qualcuno non lo termina. Una data qui viene controllata a ogni richiesta, quindi l'accesso si interrompe da solo alla fine di quel giorno.", "submit": "Invia invito", diff --git a/messages/pl.json b/messages/pl.json index e6d13a1e2..5f5bc482c 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9438,7 +9438,7 @@ "accessReadLabel": "Może przeglądać", "accessReadCapability": "Może czytać wszystko, co ta karta udostępnia. Nie może niczego dodać ani zmienić.", "accessWriteLabel": "Może przeglądać i dodawać", - "accessWriteCapability": "Może też dodawać do kartoteki: pomiary, wyniki badań, nowy analit do śledzenia, wpis o chorobie, wartość we własnej mierze, działanie niepożądane leku, nowy lek oraz oznaczyć dawkę jako przyjętą lub pominiętą. Nie może niczego edytować ani usuwać, nawet tego, co sama dodała, ani odłożyć żadnego z Twoich przypomnień. Tylko Ty możesz.", + "accessWriteCapability": "Może też dodawać do kartoteki: pomiary, wyniki badań, nowy analit do śledzenia, wpis o chorobie, działanie niepożądane leku, nowy lek oraz oznaczyć dawkę jako przyjętą lub pominiętą. Nie może niczego edytować ani usuwać, nawet tego, co sama dodała, ani odłożyć żadnego z Twoich przypomnień. Tylko Ty możesz.", "expiresLabel": "Dostęp kończy się (opcjonalnie)", "expiresHint": "Zostaw puste, aby dostęp trwał, dopóki ktoś go nie zakończy. Data jest sprawdzana przy każdym żądaniu, więc dostęp kończy się sam z końcem tego dnia.", "submit": "Wyślij zaproszenie", diff --git a/src/__tests__/customize-shortcut-guard.test.ts b/src/__tests__/customize-shortcut-guard.test.ts new file mode 100644 index 000000000..6faafe83f --- /dev/null +++ b/src/__tests__/customize-shortcut-guard.test.ts @@ -0,0 +1,218 @@ +/** + * The customise wrench never leads a delegate into a wall. + * + * Every module surface carries the same glyph in the same slot, linking to its + * section under `/settings/layout/…`. `/settings` is not a shared-record + * destination: the shell renders "not part of what was shared" there before + * the page does. So the link has to be withheld inside somebody else's record, + * and five of the six that a delegate can reach withheld it. `/mood` did not, + * which made the wrench a control that existed only to explain itself. + * + * One missed instance out of six is what a rule looks like when nothing states + * it, so this states it. It is structural rather than rendered because these + * pages early-return a loading gate under SSR — the markup a delegate would + * see is not reachable without a browser, and the property is about the source + * either way: the link is inside a `canManage` conditional, or it is not. + * + * ## What decides + * + * The AST. The `href` is found as a string literal on a JSX attribute and the + * ancestor chain above it is walked for a `&&` whose left-hand side names + * `canManage`. A comment about gating cannot satisfy that, and a `canManage` + * used elsewhere in the same file cannot either — which a "does this file + * mention the symbol" matcher would have accepted, and which is exactly what + * `/mood` looked like the day it shipped ungated. + */ +import { readdirSync, readFileSync } from "node:fs"; +import { join, sep } from "node:path"; + +import ts from "typescript"; +import { describe, expect, it } from "vitest"; + +const SRC = join(process.cwd(), "src"); + +/** The prefix every customise shortcut points at. */ +const LAYOUT_PREFIX = "/settings/layout/"; + +/** The capability a shared-record surface withholds these on. */ +const GUARD = "canManage"; + +/** + * The surfaces that carry a layout shortcut and need no gate. + * + * All three are `/insights`, which has no `sharedRecord` flag in the nav model + * — Insights and the Coach drop out of a switch entirely, being AI surfaces — + * so a delegate never renders any of them. They are exempt by construction + * rather than by permission, and the exemption is written here so that "every + * one is guarded" does not have to be softened into something weaker for them. + * A surface that gains the flag has to leave this list in the same diff. + */ +const NOT_A_SHARED_DESTINATION: Record = { + "components/insights/insights-tab-strip.tsx": + "The Insights tab strip. `/insights` carries no `sharedRecord` flag, so a switched session never reaches the surface this sits on.", + "app/insights/page-client.tsx": + "The Insights page itself. Same flag, same reason.", + "components/insights/insights-edit-mode.tsx": + "The Insights arrange mode, reachable only from the page above.", +}; + +function allSourceFiles(): string[] { + return readdirSync(SRC, { recursive: true }) + .map((p) => String(p).split(sep).join("/")) + .filter((p) => p.endsWith(".tsx")) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !p.endsWith(".test.tsx")) + .sort(); +} + +/** Does any ancestor of `node` gate it behind ` && …`? */ +function guardedByCanManage(node: ts.Node): boolean { + for (let cur: ts.Node | undefined = node; cur; cur = cur.parent) { + const parent: ts.Node | undefined = cur.parent; + if ( + parent && + ts.isBinaryExpression(parent) && + parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + // Only the RIGHT side is the guarded branch. `canManage && x` guards `x`; + // `x && canManage` is `canManage` being the guarded thing. + parent.right === cur + ) { + const names = new Set(); + const collect = (n: ts.Node): void => { + if (ts.isIdentifier(n)) names.add(n.text); + n.forEachChild(collect); + }; + collect(parent.left); + if (names.has(GUARD)) return true; + } + // A conditional whose whenTrue branch holds the link, guarded on the + // capability: `canManage ? : null`, the shape `vorsorge-section` + // uses. + if ( + parent && + ts.isConditionalExpression(parent) && + parent.whenTrue === cur + ) { + const names = new Set(); + const collect = (n: ts.Node): void => { + if (ts.isIdentifier(n)) names.add(n.text); + n.forEachChild(collect); + }; + collect(parent.condition); + if (names.has(GUARD)) return true; + } + } + return false; +} + +/** Every unguarded layout-shortcut href in one module, as its literal text. */ +function unguardedShortcuts(source: string, fileName: string): string[] { + const parsed = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TSX, + ); + const found: string[] = []; + const visit = (node: ts.Node): void => { + if ( + ts.isStringLiteralLike(node) && + node.text.startsWith(LAYOUT_PREFIX) && + !guardedByCanManage(node) + ) { + found.push(node.text); + } + node.forEachChild(visit); + }; + parsed.forEachChild(visit); + return found; +} + +describe("the customise shortcut is withheld inside somebody else's record", () => { + const carrying = allSourceFiles().filter((rel) => + readFileSync(join(SRC, rel), "utf8").includes(LAYOUT_PREFIX), + ); + + it("finds the surfaces that carry one", () => { + // The count of MATCHES, not of files walked. An empty result set and a + // clean tree are the same sentence otherwise, and this is a leg whose + // verdict is an empty list. + expect(carrying.length).toBeGreaterThan(4); + expect(carrying).toContain("app/mood/page.tsx"); + expect(carrying).toContain("app/medications/page-client.tsx"); + for (const rel of Object.keys(NOT_A_SHARED_DESTINATION)) { + expect(carrying, rel).toContain(rel); + } + }); + + it("gates every one a delegate can reach", () => { + const offenders: string[] = []; + for (const rel of carrying) { + if (rel in NOT_A_SHARED_DESTINATION) continue; + for (const href of unguardedShortcuts( + readFileSync(join(SRC, rel), "utf8"), + rel, + )) { + offenders.push(`${rel} → ${href}`); + } + } + expect( + offenders, + `a customise link outside a \`${GUARD}\` branch sends a delegate to /settings, which a switch closes`, + ).toEqual([]); + }); + + it("the matcher reads the branch, not the file", () => { + // The shape that shipped: the capability is resolved and used elsewhere in + // the module while the link itself sits outside any branch. A matcher + // asking "does this file name canManage" calls this clean. + const UNGATED = ` + export function Page() { + const { canManage } = useRecordCapabilities(); + return ( + <> + {canManage && + +