mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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.
This commit is contained in:
1 parent
80ecbd7454
commit
25e86b36b3
38 files changed
+1750
-128
No files matched your search
@@ -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
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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<string, string> = {
|
||||
"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 `<GUARD> && …`? */
|
||||
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<string>();
|
||||
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 ? <Link/> : null`, the shape `vorsorge-section`
|
||||
// uses.
|
||||
if (
|
||||
parent &&
|
||||
ts.isConditionalExpression(parent) &&
|
||||
parent.whenTrue === cur
|
||||
) {
|
||||
const names = new Set<string>();
|
||||
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 && <Button data-slot="add" />}
|
||||
<Link href="/settings/layout/mood">Customise</Link>
|
||||
</>
|
||||
);
|
||||
}
|
||||
`;
|
||||
expect(UNGATED.includes(GUARD)).toBe(true);
|
||||
expect(unguardedShortcuts(UNGATED, "u.tsx")).toEqual([
|
||||
"/settings/layout/mood",
|
||||
]);
|
||||
|
||||
const GATED = `
|
||||
export function Page() {
|
||||
const { canManage } = useRecordCapabilities();
|
||||
return canManage && <Link href="/settings/layout/mood">Customise</Link>;
|
||||
}
|
||||
`;
|
||||
expect(unguardedShortcuts(GATED, "g.tsx")).toEqual([]);
|
||||
|
||||
const TERNARY = `
|
||||
export function Page() {
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const wrench = canManage ? (
|
||||
<Link href="/settings/layout/vorsorge">Customise</Link>
|
||||
) : null;
|
||||
return wrench;
|
||||
}
|
||||
`;
|
||||
expect(unguardedShortcuts(TERNARY, "t.tsx")).toEqual([]);
|
||||
|
||||
// And the inverted shape is not a gate: `href && canManage` puts the
|
||||
// capability in the guarded position, not the link.
|
||||
const INVERTED = `
|
||||
export function Page() {
|
||||
const { canManage } = useRecordCapabilities();
|
||||
return <Link href={"/settings/layout/labs"}>{something && canManage}</Link>;
|
||||
}
|
||||
`;
|
||||
expect(unguardedShortcuts(INVERTED, "i.tsx")).toEqual([
|
||||
"/settings/layout/labs",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -448,7 +448,7 @@ const DELEGABLE_ROUTES: Record<string, string> = {
|
||||
"app/api/custom-metrics/[id]/route.ts":
|
||||
"One custom metric of the record, scoped by the resolved user id in the query itself.",
|
||||
"app/api/custom-metrics/[id]/entries/route.ts":
|
||||
"The entries of one custom metric, reached only after the metric itself resolves under the record's user id — the ownership hop runs before the entry query, on the read arm and on the delegable create arm alike.",
|
||||
"The entries of one custom metric, reached only after the metric itself resolves under the record's user id — the ownership hop runs before the entry query. The READ arm only: the POST left the write literal below in v1.36.x for the reason its allergy and family-history siblings did, and the route comment carries the argument.",
|
||||
"app/api/personal-records/route.ts":
|
||||
"The record's personal bests, `where: { userId: user.id }`. Nothing else in the file.",
|
||||
"app/api/sleep/night/route.ts":
|
||||
@@ -517,6 +517,23 @@ const DELEGABLE_ROUTES: Record<string, string> = {
|
||||
"The record's dashboard layout, read only. Settled by a fact rather than a preference: the snapshot already carries this layout and the client seeds the same cache cell from it, so an actor answer would put two people's arrangements in one key. The PUT and DELETE stay bare — a delegate adds to a record, never redecorates it.",
|
||||
"app/api/medications/layout/route.ts":
|
||||
"The record's medication-list presentation, read only. The stored `order` is a list of the OWNER's medication ids and unknown ids are dropped at apply time, so the caller's own order resolves to nothing against the owner's cabinet. The PUT and DELETE stay bare, and this is the refusal a delegate can actually walk into, since `/medications` is a shared destination.",
|
||||
|
||||
// The document vault. Five reads admitted together for the reason the front
|
||||
// door's eight were: `/documents` carries `sharedRecord: true`, so the shell
|
||||
// offers the destination and every read behind it refused — the same failure
|
||||
// v1.36.1 describes as fixed on `/`. Uploading, retyping, deleting, linking,
|
||||
// indexing and every AI verb stay bare; the split is per-arm, because the
|
||||
// resolver escalates a non-safe method to `"write"` on its own.
|
||||
"app/api/documents/inbound/route.ts":
|
||||
"The record's document list. The GET never selects the encrypted blob column and scopes every predicate to the resolved user id; the POST upload beside it is not delegable and keeps `requireAuth()`.",
|
||||
"app/api/documents/inbound/[id]/route.ts":
|
||||
"One document of the record with its staged facts and condition links, fetched under the resolved user id before anything is serialised. The PATCH and DELETE are edits and stay with the owner.",
|
||||
"app/api/documents/inbound/[id]/thumbnail/route.ts":
|
||||
"One document's thumbnail — the tile that makes the admitted list browsable. Owner-scoped through the 1:1 relation on the resolved document; the read-quota bucket keys on the ACTOR because decrypting costs something.",
|
||||
"app/api/documents/inbound/[id]/original/route.ts":
|
||||
"The document itself, which is the read the rest of the vault exists to reach: a caregiver needs the discharge letter, not its filing card. Same owner-scoped lookup, and the same actor-keyed bucket as the thumbnail, tighter because it decrypts the whole file.",
|
||||
"app/api/documents/inbound/usage/route.ts":
|
||||
"The record's vault state: quota, the filter bar's condition chips, and index coverage. Without it the admitted list loses its filters. The `assistAvailable` boolean is the same integration-adjacent availability flag `nutrients` already returns — no credential, endpoint or token — and every AI action it would gate stays refused. No write arm exists.",
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -545,6 +562,15 @@ const DELEGABLE_ROUTES: Record<string, string> = {
|
||||
* builds a caregiver-reachable medical-history surface adds them back in the
|
||||
* same diff, which is the two-ended change this list is meant to hold to.
|
||||
*
|
||||
* `POST /api/custom-metrics/[id]/entries` followed them out, one release late,
|
||||
* and the delay is the lesson: the rule that removed the other two was applied
|
||||
* to two of the three routes it fitted. The only surface that posts a tracked
|
||||
* value is the entry form on `/custom-metrics/{id}`, which is not a
|
||||
* shared-record destination — the shell renders the unavailable panel there
|
||||
* before the form mounts, and `custom-metric-list.tsx` says so in its own
|
||||
* comment while returning null. So the capability shipped named on the consent
|
||||
* screen, in six languages, with nothing behind it. Its READ arm stays.
|
||||
*
|
||||
* Every member also has to call `auditLog`, asserted below. That is not a
|
||||
* stylistic preference. The decision not to add a `writtenBy` column to eleven
|
||||
* tables rests entirely on the audit trail carrying the actor, and `auditLog`
|
||||
@@ -563,8 +589,6 @@ const DELEGABLE_WRITE_ROUTES: Record<string, string> = {
|
||||
"Adding an analyte to the record's catalogue. A name the record already tracks is the ordinary 409 from the same `(userId, name)` uniqueness the owner would hit themselves.",
|
||||
"app/api/illness/episodes/route.ts":
|
||||
"Opening an illness episode. The module gate runs against the RECORD before the write, so a delegate cannot create an episode inside a record whose owner switched the module off.",
|
||||
"app/api/custom-metrics/[id]/entries/route.ts":
|
||||
"Adding a value to one of the record's own metrics. The metric resolves under the record first, so a delegate can only feed a definition the owner made.",
|
||||
"app/api/medications/[id]/side-effects/route.ts":
|
||||
"Recording a side effect. Admitted on one condition, met at the call site: the POST rate bucket keys on the ACTOR, so a delegate burns their own allowance rather than the owner's and cannot collect a fresh one by switching records.",
|
||||
"app/api/medications/route.ts":
|
||||
@@ -606,7 +630,7 @@ const ACTOR_ROUTES: Record<string, string> = {
|
||||
* stay a formality by accident: every addition has to be counted here as well
|
||||
* as listed above, which is one more place a careless admission has to pass.
|
||||
*/
|
||||
const FROZEN_ENTRY_COUNT = 65;
|
||||
const FROZEN_ENTRY_COUNT = 69;
|
||||
|
||||
/**
|
||||
* The two surfaces that authenticate a Bearer token outside `requireAuth` —
|
||||
|
||||
Binary file not shown.
@@ -1,5 +1,7 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join, relative, sep } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
@@ -30,6 +32,22 @@ import { describe, expect, it } from "vitest";
|
||||
* here rather than a line each page remembers: the five pages that prefetch
|
||||
* today all made the same mistake independently, which is what a rule looks
|
||||
* like when nothing states it.
|
||||
*
|
||||
* Rule 3 was checked by two regexes until 2026-08-04, and neither could fail
|
||||
* on the shape that matters. `/getUnswitchedSession\s*\(/` is satisfied by the
|
||||
* doc comment every one of these pages carries, so the "calls the right
|
||||
* helper" leg passed on prose. `/getSession\s*\(/` is not satisfied by
|
||||
* `import { getSession as loadSession }` — no parenthesis follows the name —
|
||||
* so the "never reaches the wrong one" leg passed on an aliased import. Both
|
||||
* legs were green with the dashboard genuinely prefetching off the
|
||||
* switch-blind session, which is the exact defect they exist for.
|
||||
*
|
||||
* So membership is decided from the AST now: the module's import table
|
||||
* resolves every local name back to the symbol it was exported as, and only
|
||||
* CALL expressions count. A comment cannot enrol a page and an alias cannot
|
||||
* hide one. `describe("the call matcher sees what it looks for")` proves both
|
||||
* halves against synthetic modules, so the day the matcher stops matching is
|
||||
* the day this file goes red rather than quiet.
|
||||
*/
|
||||
|
||||
const APP_DIR = join(process.cwd(), "src", "app");
|
||||
@@ -76,6 +94,60 @@ function isPublicRoute(routePath: string): boolean {
|
||||
return PUBLIC_ROUTE_PREFIXES.some((p) => routePath.startsWith(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* The names of every function this module CALLS, resolved back to the symbol
|
||||
* each one was exported as.
|
||||
*
|
||||
* Calls only — an import the page never reaches proves nothing, and neither
|
||||
* does a sentence in a comment. Aliases resolve through the module's own
|
||||
* import table (`getSession as loadSession` comes back as `getSession`), and a
|
||||
* namespace-qualified call (`auth.getSession()`) comes back the same way. Both
|
||||
* are the shapes the regexes could not see.
|
||||
*/
|
||||
export function calledSymbolsIn(source: string, fileName: string): Set<string> {
|
||||
const parsed = ts.createSourceFile(
|
||||
fileName,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TSX,
|
||||
);
|
||||
|
||||
const aliases = new Map<string, string>();
|
||||
const namespaces = new Set<string>();
|
||||
parsed.forEachChild((node) => {
|
||||
if (!ts.isImportDeclaration(node)) return;
|
||||
const bindings = node.importClause?.namedBindings;
|
||||
if (!bindings) return;
|
||||
if (ts.isNamespaceImport(bindings)) {
|
||||
namespaces.add(bindings.name.text);
|
||||
return;
|
||||
}
|
||||
for (const spec of bindings.elements) {
|
||||
aliases.set(spec.name.text, (spec.propertyName ?? spec.name).text);
|
||||
}
|
||||
});
|
||||
|
||||
const called = new Set<string>();
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const callee = node.expression;
|
||||
if (ts.isIdentifier(callee)) {
|
||||
called.add(aliases.get(callee.text) ?? callee.text);
|
||||
} else if (
|
||||
ts.isPropertyAccessExpression(callee) &&
|
||||
ts.isIdentifier(callee.expression) &&
|
||||
namespaces.has(callee.expression.text)
|
||||
) {
|
||||
called.add(callee.name.text);
|
||||
}
|
||||
}
|
||||
node.forEachChild(visit);
|
||||
};
|
||||
parsed.forEachChild(visit);
|
||||
return called;
|
||||
}
|
||||
|
||||
const prefetchingPages = walk(APP_DIR)
|
||||
.map((file) => ({ file, source: readFileSync(file, "utf8") }))
|
||||
.filter(
|
||||
@@ -87,14 +159,37 @@ const prefetchingPages = walk(APP_DIR)
|
||||
source,
|
||||
routePath: routePathFor(file),
|
||||
rel: relative(process.cwd(), file),
|
||||
called: calledSymbolsIn(source, file),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Every page that prefetches today, named.
|
||||
*
|
||||
* The walk is what covers a NEW page; this literal is what covers a page that
|
||||
* stops being found. Rule 3's legs report an empty offender list both when
|
||||
* every page complies and when the detector stopped seeing them, and the
|
||||
* dashboard pin alone would not notice four of the five going quiet.
|
||||
*/
|
||||
const PREFETCHING_ROUTES = [
|
||||
"/",
|
||||
"/coach",
|
||||
"/insights",
|
||||
"/insights/workouts",
|
||||
"/medications",
|
||||
] as const;
|
||||
|
||||
describe("server-prefetching pages cannot leak a cacheable record", () => {
|
||||
it("finds the prefetching pages at all (guard is not vacuous)", () => {
|
||||
expect(prefetchingPages.length).toBeGreaterThan(0);
|
||||
// The dashboard is the canonical one; if it stops matching, the detection
|
||||
// heuristic has drifted and every assertion below is silently skipped.
|
||||
expect(prefetchingPages.map((p) => p.routePath)).toContain("/");
|
||||
// And the other four with it. A page that stops being detected takes its
|
||||
// own coverage down silently, so the set is frozen: adding a prefetching
|
||||
// page is a line here, and losing one is a failure rather than a shrug.
|
||||
expect(prefetchingPages.map((p) => p.routePath).sort()).toEqual(
|
||||
[...PREFETCHING_ROUTES].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps every prefetching page behind the session gate", () => {
|
||||
@@ -139,8 +234,8 @@ describe("server-prefetching pages cannot seed a switched session", () => {
|
||||
* meant: the session a prefetching page acts on came from the accessor that
|
||||
* refuses under a switch.
|
||||
*/
|
||||
const UNSWITCHED = /\bgetUnswitchedSession\s*\(/;
|
||||
const RAW_SESSION = /\bgetSession\s*\(/;
|
||||
const UNSWITCHED = "getUnswitchedSession";
|
||||
const RAW_SESSION = "getSession";
|
||||
|
||||
it("resolves every prefetching page's session through getUnswitchedSession", () => {
|
||||
// Non-vacuous, and it has to be said again in this block: `missing` is
|
||||
@@ -150,7 +245,7 @@ describe("server-prefetching pages cannot seed a switched session", () => {
|
||||
expect(prefetchingPages.length).toBeGreaterThan(0);
|
||||
|
||||
const missing = prefetchingPages
|
||||
.filter((p) => !UNSWITCHED.test(p.source))
|
||||
.filter((p) => !p.called.has(UNSWITCHED))
|
||||
.map((p) => p.rel);
|
||||
expect(
|
||||
missing,
|
||||
@@ -160,7 +255,7 @@ describe("server-prefetching pages cannot seed a switched session", () => {
|
||||
|
||||
it("lets no prefetching page reach getSession directly", () => {
|
||||
const offenders = prefetchingPages
|
||||
.filter((p) => RAW_SESSION.test(p.source))
|
||||
.filter((p) => p.called.has(RAW_SESSION))
|
||||
.map((p) => p.rel);
|
||||
expect(
|
||||
offenders,
|
||||
@@ -168,3 +263,89 @@ describe("server-prefetching pages cannot seed a switched session", () => {
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the call matcher sees what it looks for", () => {
|
||||
/**
|
||||
* Against strings, not the tree. Every leg above reports an empty offender
|
||||
* list, and that sentence is worth exactly as much as the evidence that the
|
||||
* matcher could have said otherwise. Each case below is a shape the two
|
||||
* regexes this file used to carry got wrong.
|
||||
*/
|
||||
it("does not read a call out of a doc comment", () => {
|
||||
const PROSE_ONLY = `
|
||||
/**
|
||||
* Record identity: the session comes from \`getUnswitchedSession()\`,
|
||||
* which refuses to answer under a switch.
|
||||
*/
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getSession();
|
||||
return <HydrationBoundary state={dehydrate(qc)} />;
|
||||
}
|
||||
`;
|
||||
const called = calledSymbolsIn(PROSE_ONLY, "prose.tsx");
|
||||
// The parse produced calls, so the negative below is a measurement.
|
||||
expect(called.size).toBeGreaterThan(0);
|
||||
expect(called.has("getUnswitchedSession")).toBe(false);
|
||||
expect(called.has("getSession")).toBe(true);
|
||||
// …and the raw text says otherwise, which is what the old leg read.
|
||||
expect(/getUnswitchedSession\s*\(/.test(PROSE_ONLY)).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves an aliased import back to the symbol it was exported as", () => {
|
||||
const ALIASED = `
|
||||
import { getSession as loadSession } from "@/lib/auth/session";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await loadSession();
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
expect(calledSymbolsIn(ALIASED, "alias.tsx").has("getSession")).toBe(true);
|
||||
// The alias is why the old leg missed it: nothing in the file reads
|
||||
// `getSession(`, so a regex keyed on the call shape found nothing.
|
||||
expect(/\bgetSession\s*\(/.test(ALIASED)).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves a namespace-qualified call", () => {
|
||||
const NAMESPACED = `
|
||||
import * as auth from "@/lib/auth/session";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await auth.getSession();
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
expect(calledSymbolsIn(NAMESPACED, "ns.tsx").has("getSession")).toBe(true);
|
||||
});
|
||||
|
||||
it("does not count an imported-but-uncalled helper", () => {
|
||||
const IMPORTED_ONLY = `
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
import { getSession } from "@/lib/auth/session";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getSession();
|
||||
return <span>{getUnswitchedSession.name}</span>;
|
||||
}
|
||||
`;
|
||||
const called = calledSymbolsIn(IMPORTED_ONLY, "io.tsx");
|
||||
expect(called.has("getUnswitchedSession")).toBe(false);
|
||||
expect(called.has("getSession")).toBe(true);
|
||||
});
|
||||
|
||||
it("finds the plain call the compliant pages make", () => {
|
||||
const COMPLIANT = `
|
||||
import { getUnswitchedSession } from "@/lib/auth/acting-carrier";
|
||||
|
||||
export default async function Page() {
|
||||
const session = await getUnswitchedSession();
|
||||
return null;
|
||||
}
|
||||
`;
|
||||
const called = calledSymbolsIn(COMPLIANT, "ok.tsx");
|
||||
expect(called.has("getUnswitchedSession")).toBe(true);
|
||||
expect(called.has("getSession")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -143,8 +143,12 @@ const PINNED_AFFORDANCES: Record<
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/cycle/cycle-phase-crosstab.tsx": { "text-success": 1 },
|
||||
// v1.36.x — two call sites, one decision: the intake toast routes through
|
||||
// `intakeToastOptions()`, which returns `undefined` in the caller's own
|
||||
// record, and sonner's one-argument form is what the unit tests assert. So
|
||||
// the if/else that `runLogIntake` already uses landed here too.
|
||||
"src/components/dashboard/medication-intake-quick-add.tsx": {
|
||||
"toast.success": 1,
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/dashboard/range-display.tsx": { "text-success": 2 },
|
||||
"src/components/documents/document-card.tsx": { "text-success": 1 },
|
||||
@@ -211,8 +215,9 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/medications/card-parts/medication-status-pill.tsx": {
|
||||
"text-success": 1,
|
||||
},
|
||||
// Same pair, same reason as the dashboard quick-add above.
|
||||
"src/components/medications/dose-history-add-dialog.tsx": {
|
||||
"toast.success": 1,
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/medications/dose-history-ledger.tsx": {
|
||||
"text-success": 2,
|
||||
@@ -255,7 +260,8 @@ const PINNED_AFFORDANCES: Record<
|
||||
"src/components/medications/sections/settings-section.tsx": {
|
||||
"toast.success": 2,
|
||||
},
|
||||
"src/components/medications/take-all-due.ts": { "toast.success": 1 },
|
||||
// Same pair again: the batch summary names the record it landed in.
|
||||
"src/components/medications/take-all-due.ts": { "toast.success": 2 },
|
||||
// v1.36.x — one fewer: the log-intake path's three-armed toast collapsed
|
||||
// into the shared `intakeToastOptions` decision plus a single call pair.
|
||||
"src/components/medications/use-medication-intake.ts": { "toast.success": 4 },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -89,11 +89,20 @@ async function postCustomMetricEntry(
|
||||
request: NextRequest,
|
||||
{ params }: RouteParams,
|
||||
) {
|
||||
// v1.36.x — a delegated write. The ownership hop runs first and now resolves
|
||||
// against the record, so a delegate can only add a value to a metric the
|
||||
// OWNER defined; their own metric of the same name is unreachable from in
|
||||
// here and 404s.
|
||||
const { user } = await requireRecordAuth("write");
|
||||
// v1.36.x — NOT a delegated write, and it was one for a release. It left
|
||||
// the admitted set for the reason `POST /api/allergies` and
|
||||
// `POST /api/family-history` left it in the same release: no delegate can
|
||||
// reach a surface that posts here. The only one is the entry form on
|
||||
// `/custom-metrics/{id}`, and that route is not a shared-record destination,
|
||||
// so the shell turns a delegate away before the form mounts — the rule was
|
||||
// applied to two of the three siblings and this one was missed.
|
||||
//
|
||||
// A permission with no caller is a permission frozen ahead of the surface
|
||||
// that would exercise it, and the consent screen was naming a capability the
|
||||
// product did not offer. Whoever builds a delegate-reachable way to log a
|
||||
// tracked value adds it back in the same diff: the argument for admitting it
|
||||
// was never wrong, and the GET arm above stays delegable meanwhile.
|
||||
const { user } = await requireAuth();
|
||||
const { id } = await params;
|
||||
|
||||
const metric = await prisma.customMetric.findFirst({
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptDocumentContent } from "@/lib/documents/store";
|
||||
@@ -107,15 +107,23 @@ function safeDownloadName(
|
||||
return cleaned.length > 0 ? cleaned : `document-${id}.${ext}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.36.x — delegable, and the read the rest of the vault exists to reach: a
|
||||
* caregiver looking at a discharge letter needs the letter, not its filing
|
||||
* card. Same file, same 1:1 owner-scoped lookup, resolved against the record.
|
||||
*
|
||||
* The bucket keys on the ACTOR for the reason the thumbnail's does — this one
|
||||
* decrypts a whole document per call, so it is the tighter of the two.
|
||||
*/
|
||||
export const GET = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user, actor } = await requireRecordAuth("read");
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-original:${user.id}`,
|
||||
`documents-original:${actor.id}`,
|
||||
ORIGINAL_READ_LIMIT_PER_HOUR,
|
||||
ORIGINAL_READ_WINDOW_MS,
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
*/
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireAuth, requireRecordAuth } from "@/lib/api-handler";
|
||||
import {
|
||||
apiError,
|
||||
apiSuccess,
|
||||
@@ -61,9 +61,22 @@ async function enforceWriteRateLimit(userId: string): Promise<Response | null> {
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET — one document of the record, with its facts and condition links.
|
||||
*
|
||||
* v1.36.x — delegable. The list beside it is; opening a row from that list is
|
||||
* the same read one document deeper, and a vault whose rows cannot be opened
|
||||
* is a vault the delegate can only count. The row is fetched under the
|
||||
* resolved user id before anything is serialised, so the substitution reaches
|
||||
* the query rather than a filter after it.
|
||||
*
|
||||
* The PATCH and DELETE below keep `requireAuth()`. Retyping a document,
|
||||
* relabelling it and deleting it are edits, and an edit stays with the owner —
|
||||
* including of a document the delegate has just read.
|
||||
*/
|
||||
export const GET = apiHandler(
|
||||
async (_request: NextRequest, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
*/
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { decryptThumbnail } from "@/lib/documents/store";
|
||||
@@ -40,15 +40,25 @@ export const dynamic = "force-dynamic";
|
||||
const THUMBNAIL_READ_LIMIT_PER_HOUR = 3000;
|
||||
const THUMBNAIL_READ_WINDOW_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* v1.36.x — delegable. The vault grid is a wall of thumbnails, so a list a
|
||||
* delegate may read whose tiles all 403 is the list without the part that
|
||||
* makes it browsable. The thumbnail carries no more than the document it
|
||||
* belongs to, which the same delegate may open.
|
||||
*
|
||||
* The bucket keys on the ACTOR, following `medications/compliance`: this read
|
||||
* spends decryption CPU, so a delegate burns their own allowance rather than
|
||||
* the owner's, and cannot collect a fresh one by switching records.
|
||||
*/
|
||||
export const GET = apiHandler(
|
||||
async (_request: Request, { params }: RouteParams) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user, actor } = await requireRecordAuth("read");
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`documents-thumbnail:${user.id}`,
|
||||
`documents-thumbnail:${actor.id}`,
|
||||
THUMBNAIL_READ_LIMIT_PER_HOUR,
|
||||
THUMBNAIL_READ_WINDOW_MS,
|
||||
);
|
||||
|
||||
@@ -27,7 +27,12 @@ import { createHash } from "node:crypto";
|
||||
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiHandler, requireAuth, type AuthContext } from "@/lib/api-handler";
|
||||
import {
|
||||
apiHandler,
|
||||
requireAuth,
|
||||
requireRecordAuth,
|
||||
type AuthContext,
|
||||
} from "@/lib/api-handler";
|
||||
import { apiError, apiSuccess, getClientIp } from "@/lib/api-response";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -437,9 +442,23 @@ async function postUpload(request: Request): Promise<Response> {
|
||||
|
||||
export const POST = apiHandler(withIdempotency<[Request]>(postUpload));
|
||||
|
||||
/** GET — list the caller's documents (search / filter / sort / paginate). */
|
||||
/**
|
||||
* GET — list the record's documents (search / filter / sort / paginate).
|
||||
*
|
||||
* v1.36.x — delegable. `/documents` carries `sharedRecord: true` in the nav
|
||||
* model, so the shell offers the destination under a switch and every read
|
||||
* behind it refused: the vault rendered a query-error card, and the illness
|
||||
* page's episode-documents tile rendered another one beside it. A clinical
|
||||
* letter is health data belonging to the record, and reading it is the whole
|
||||
* point of handing somebody the record.
|
||||
*
|
||||
* The POST above is NOT delegable and keeps `requireAuth()`. Uploading is not
|
||||
* a verb the grant admits, and the module-level split matters here: the
|
||||
* resolver escalates any non-safe method to `"write"`, so the two arms have to
|
||||
* declare separately rather than share one line.
|
||||
*/
|
||||
export const GET = apiHandler(async (request: Request) => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* still hold TOAST bytes until the purge job reclaims them, so "deleted"
|
||||
* bytes are never invisible weight and an undo never changes usage.
|
||||
*/
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
|
||||
import { apiSuccess } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
@@ -21,8 +21,20 @@ import type { DocumentUsageDto } from "@/lib/validations/inbound-documents";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* v1.36.x — delegable. The quota gauge is the least of it: this payload also
|
||||
* carries the filter bar's condition chips (the record's episodes that hold a
|
||||
* live document link) and the index-coverage counts, so without it the vault a
|
||||
* delegate may read loses its filters and reads as a flat pile. Every figure is
|
||||
* the record's own. The `assistAvailable` flag is an availability boolean of
|
||||
* the same integration-adjacent kind `nutrients` already returns — no
|
||||
* credential, endpoint or token crosses the wire, and every AI action it would
|
||||
* gate stays refused under a switch.
|
||||
*
|
||||
* No write arm exists on this route.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
const { user } = await requireRecordAuth("read");
|
||||
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
|
||||
if (!gate.enabled) return gate.response;
|
||||
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* "Take all due" and who is offered it.
|
||||
*
|
||||
* v1.36.1 withheld the sweep from a delegate on a note saying it rides the
|
||||
* bulk intake route. It does not, and `take-all-due.ts` says so at length: it
|
||||
* loops `POST /api/medications/{id}/intake`, the delegable write, and each
|
||||
* request is byte-identical to tapping that medication's own button. So a
|
||||
* caregiver with five morning tablets to confirm was tapping five times for
|
||||
* nothing, on the release that exists to let them confirm doses at all.
|
||||
*
|
||||
* Rendered rather than described, on the three capability states. SSR-only, so
|
||||
* this holds the paint and never the click; the loop's own behaviour is pinned
|
||||
* in `take-all-due.test.ts` and the browser leg in `e2e/account-sharing.spec.ts`.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import type { AccountAccess } from "@/lib/sharing/account-access-view";
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({ replace: vi.fn(), push: vi.fn() }),
|
||||
useSearchParams: () => new URLSearchParams(""),
|
||||
}));
|
||||
|
||||
const accessRef: { value: AccountAccess } = {
|
||||
value: { accounts: [], active: null, canSwitch: false },
|
||||
};
|
||||
|
||||
vi.mock("@/hooks/use-auth", () => ({
|
||||
useAuth: () => ({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
user: {
|
||||
timezone: "Europe/Berlin",
|
||||
accountAccess: accessRef.value,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/medications/wizard/medication-wizard-dialog", () => ({
|
||||
MedicationWizardDialog: () => null,
|
||||
}));
|
||||
vi.mock("@/components/medications/log-intake-dialog", () => ({
|
||||
LogIntakeDialog: () => null,
|
||||
}));
|
||||
|
||||
import MedicationsPage from "@/app/medications/page-client";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
|
||||
const OWNER = {
|
||||
accountId: "acct-owner",
|
||||
username: "owner",
|
||||
displayName: "Margarethe",
|
||||
access: "read" as const,
|
||||
canWrite: false,
|
||||
};
|
||||
|
||||
const OWN_RECORD: AccountAccess = {
|
||||
accounts: [OWNER],
|
||||
active: null,
|
||||
canSwitch: true,
|
||||
};
|
||||
const READ_ONLY: AccountAccess = {
|
||||
accounts: [OWNER],
|
||||
active: OWNER,
|
||||
canSwitch: true,
|
||||
};
|
||||
const WRITABLE: AccountAccess = {
|
||||
accounts: [OWNER],
|
||||
active: { ...OWNER, access: "write", canWrite: true },
|
||||
canSwitch: true,
|
||||
};
|
||||
|
||||
/** A window straddling the frozen clock below, so both medications read due. */
|
||||
const openWindow = {
|
||||
windowStart: "11:00",
|
||||
windowEnd: "13:00",
|
||||
label: null,
|
||||
daysOfWeek: null,
|
||||
dose: null,
|
||||
};
|
||||
|
||||
/** Two due medications — the button earns its slot only from the second. */
|
||||
const DUE_MEDS = [
|
||||
{
|
||||
id: "m1",
|
||||
name: "Ramipril",
|
||||
dose: "5 mg",
|
||||
category: "OTHER",
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
pausedAt: null,
|
||||
lastTakenAt: null,
|
||||
todayEventCount: 0,
|
||||
stockDosesRemaining: null,
|
||||
schedules: [{ id: "s1", ...openWindow }],
|
||||
},
|
||||
{
|
||||
id: "m2",
|
||||
name: "Aspirin",
|
||||
dose: "100 mg",
|
||||
category: "OTHER",
|
||||
active: true,
|
||||
notificationsEnabled: true,
|
||||
pausedAt: null,
|
||||
lastTakenAt: null,
|
||||
todayEventCount: 0,
|
||||
stockDosesRemaining: null,
|
||||
schedules: [{ id: "s2", ...openWindow }],
|
||||
},
|
||||
];
|
||||
|
||||
function render(access: AccountAccess): string {
|
||||
accessRef.value = access;
|
||||
const client = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0, staleTime: Infinity },
|
||||
},
|
||||
});
|
||||
client.setQueryData(queryKeys.medications(), DUE_MEDS);
|
||||
client.setQueryData(queryKeys.medicationListLayout(), {
|
||||
version: 1,
|
||||
view: "cards",
|
||||
order: [],
|
||||
});
|
||||
return renderToStaticMarkup(
|
||||
<I18nProvider initialLocale="en">
|
||||
<QueryClientProvider client={client}>
|
||||
<MedicationsPage />
|
||||
</QueryClientProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
// Inside the 11:00–13:00 window in Europe/Berlin (UTC+2 in August).
|
||||
vi.setSystemTime(new Date("2026-08-04T10:00:00Z"));
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
const TAKE_ALL = "Take all due";
|
||||
|
||||
describe("take all due", () => {
|
||||
it("is offered in the caller's own record", () => {
|
||||
// The control this whole file is about: without it, the fixture is not
|
||||
// producing a due set and every negative below would be vacuous.
|
||||
expect(render(OWN_RECORD)).toContain(TAKE_ALL);
|
||||
});
|
||||
|
||||
it("is offered to a delegate who may write", () => {
|
||||
// Marking a dose is the admitted verb; doing it for several medications
|
||||
// at once is the same verb, N times, over the same route.
|
||||
expect(render(WRITABLE)).toContain(TAKE_ALL);
|
||||
});
|
||||
|
||||
it("is absent for a read-only delegate", () => {
|
||||
expect(render(READ_ONLY)).not.toContain(TAKE_ALL);
|
||||
});
|
||||
});
|
||||
@@ -161,9 +161,13 @@ export default function MedicationsPageClient() {
|
||||
const { user, isAuthenticated, isLoading: authLoading } = useAuth();
|
||||
const { t } = useTranslations();
|
||||
// v1.36.x — adding a medication and marking a dose are both admitted
|
||||
// delegated writes, so a WRITE delegate keeps the Add menu whole. The
|
||||
// take-all-due sweep is not (it rides the bulk intake route), and neither is
|
||||
// the customize page, which sharing does not cover at all.
|
||||
// delegated writes, so a WRITE delegate keeps the Add menu whole. So is the
|
||||
// take-all-due sweep, which was withheld on a note saying it rode the bulk
|
||||
// intake route: it does not, and its own docblock says why not. It loops
|
||||
// `POST /api/medications/{id}/intake` — the delegable write, byte-identical
|
||||
// to tapping each card in turn — so withholding it made a caregiver with
|
||||
// five morning tablets tap five times for nothing. The customize page stays
|
||||
// withheld; sharing does not cover it at all.
|
||||
const { canAdd, canManage } = useRecordCapabilities();
|
||||
// v1.18.1 (D3) — medications is an opt-out module. When the account has it
|
||||
// turned off the whole page disappears (the nav entry is hidden by the same
|
||||
@@ -423,7 +427,7 @@ export default function MedicationsPageClient() {
|
||||
card's own one-tap job). Calm outline variant so the primary
|
||||
Add button keeps the visual lead; same responsive 44-px
|
||||
mobile tap floor as its neighbours. */}
|
||||
{canManage && dueMeds.length >= 2 && (
|
||||
{canAdd && dueMeds.length >= 2 && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setTakeAllOpen(true)}
|
||||
@@ -636,7 +640,11 @@ export default function MedicationsPageClient() {
|
||||
through the per-medication intake route in a loop (slot
|
||||
attribution + inventory consumption identical to N individual
|
||||
taps — see take-all-due.ts); failed medications stay due. */}
|
||||
{takeAllOpen && (
|
||||
{/* Gated on the same answer the button is, for the first-paint reason
|
||||
`/measurements` documents: `canAdd` reads true until `/api/auth/me`
|
||||
settles, and a dialog opened in that frame withdraws when the answer
|
||||
lands rather than standing on a confirm the server would refuse. */}
|
||||
{takeAllOpen && canAdd && (
|
||||
<TakeAllDueDialog
|
||||
open={takeAllOpen}
|
||||
onOpenChange={setTakeAllOpen}
|
||||
|
||||
+22
-14
@@ -82,21 +82,29 @@ export default function MoodPage() {
|
||||
links to /settings/mood, which owns the tag groups, custom
|
||||
tags, visibility, and picker order. Same glyph, slot (left
|
||||
of the add button) and responsive 44-px mobile tap floor
|
||||
as the medications header. */}
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Link
|
||||
href="/settings/layout/mood"
|
||||
aria-label={t("mood.customize")}
|
||||
title={t("mood.customize")}
|
||||
as the medications header.
|
||||
|
||||
v1.36.x — and gated like the other four wrenches. It was the
|
||||
one of five left open, so a delegate tapped it straight into
|
||||
the "not part of what was shared" panel: `/settings` is not a
|
||||
shared-record destination and the shell answers before the
|
||||
page does. */}
|
||||
{canManage && (
|
||||
<Button
|
||||
asChild
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="min-h-11 min-w-11 sm:min-h-9 sm:min-w-9"
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Link
|
||||
href="/settings/layout/mood"
|
||||
aria-label={t("mood.customize")}
|
||||
title={t("mood.customize")}
|
||||
>
|
||||
<Wrench className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.36.x — a mood entry is not one of the verbs a grant
|
||||
admits, so it stays with the account whose record it is. A
|
||||
delegate at any level gets no add path here. */}
|
||||
|
||||
@@ -100,10 +100,15 @@ import { DeleteButton } from "@/components/data-list/delete-button";
|
||||
import { SelectionActionBar } from "@/components/data-list/selection-action-bar";
|
||||
import { MedicationCardMenu } from "@/components/medications/medication-card-menu";
|
||||
import { MedicationIntakeActions } from "@/components/medications/card-parts/medication-intake-actions";
|
||||
import { visibleCaptureKinds } from "@/components/layout/capture-picker";
|
||||
import {
|
||||
admittedCaptureKind,
|
||||
visibleCaptureKinds,
|
||||
} from "@/components/layout/capture-picker";
|
||||
import { admittedQuickEntry } from "@/components/dashboard/quick-entry-sheets";
|
||||
import { TodayHero } from "@/components/daily/today-hero";
|
||||
import { VorsorgeDashboardCard } from "@/components/measurement-reminders/vorsorge-dashboard-card";
|
||||
import { EpisodeDocumentsCard } from "@/components/documents/episode-documents-card";
|
||||
import { LedgerRowItem } from "@/components/medications/dose-history-ledger";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import type { DailyDigest } from "@/lib/daily/digest";
|
||||
import type { MeasurementReminder } from "@/hooks/use-measurement-reminders";
|
||||
@@ -348,6 +353,12 @@ describe("linking a document to an illness episode", () => {
|
||||
expect(html).not.toContain(">Link<");
|
||||
expect(html).not.toContain("Upload");
|
||||
// The card itself stays — reading the episode's documents is a read.
|
||||
// v1.36.x — and the resolver agrees now. This line was true of the
|
||||
// intent and false of the route: the card's list query hit
|
||||
// `GET /api/documents/inbound`, which resolved the CALLER and 403'd, so
|
||||
// the card that "stays" rendered a query-error tile. That GET is
|
||||
// delegable; `delegable-surface-guard.test.ts` freezes it and
|
||||
// `sharing-delegable-routes.test.ts` drives it.
|
||||
expect(html).toContain('data-slot="episode-documents-card"');
|
||||
}
|
||||
});
|
||||
@@ -382,3 +393,126 @@ describe("the capture picker's kinds", () => {
|
||||
).toEqual(["measurement", "medication", "mood"]);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* The dose-history ledger's row actions */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("marking a dose from the dose history", () => {
|
||||
const node = (
|
||||
<ul>
|
||||
<LedgerRowItem
|
||||
row={{
|
||||
kind: "slot",
|
||||
at: "2026-08-04T07:00:00.000Z",
|
||||
timeOfDay: "07:00",
|
||||
status: "upcoming",
|
||||
intake: null,
|
||||
}}
|
||||
marking={null}
|
||||
isToday
|
||||
onMark={() => {}}
|
||||
onToggleSkipped={() => {}}
|
||||
onEdit={() => {}}
|
||||
onDelete={() => {}}
|
||||
onPin={() => {}}
|
||||
onUnpin={() => {}}
|
||||
/>
|
||||
</ul>
|
||||
);
|
||||
|
||||
it("offers the owner take beside the kebab that holds the rest", () => {
|
||||
const html = render(OWN_RECORD, node);
|
||||
expect(html).toContain('data-slot="ledger-mark-taken"');
|
||||
expect(html).toContain('data-slot="ledger-row-menu"');
|
||||
});
|
||||
|
||||
it("offers a write delegate BOTH markings, and no row menu", () => {
|
||||
// Taken and skipped are one admitted verb, not two — the medication card
|
||||
// has always offered the pair. This surface offered a delegate only the
|
||||
// first, because skip lives in the kebab and the kebab also holds edit,
|
||||
// re-attribute and delete, which are the owner's. So skip gets its own
|
||||
// control here rather than the menu opening up.
|
||||
const html = render(WRITABLE, node);
|
||||
expect(html).toContain('data-slot="ledger-mark-taken"');
|
||||
expect(html).toContain('data-slot="ledger-mark-skipped"');
|
||||
expect(html).not.toContain('data-slot="ledger-row-menu"');
|
||||
});
|
||||
|
||||
it("offers a read-only delegate neither", () => {
|
||||
const html = render(READ_ONLY, node);
|
||||
expect(html).not.toContain('data-slot="ledger-mark-taken"');
|
||||
expect(html).not.toContain('data-slot="ledger-mark-skipped"');
|
||||
expect(html).not.toContain('data-slot="ledger-row-menu"');
|
||||
// The row itself stays — reading the history is a read.
|
||||
expect(html).toContain('data-slot="ledger-row"');
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* The first-paint window */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
describe("a form opened before the record answered", () => {
|
||||
/**
|
||||
* The gap both capture surfaces had. `resolveRecordCapabilities(undefined)`
|
||||
* reads as the caller's own record until `/api/auth/me` settles, and the
|
||||
* switch flow ends in a hard reload that wipes the persisted cache — so the
|
||||
* window is widest exactly when a delegate arrives. Both surfaces gated the
|
||||
* CHOOSER at tap time and never asked again, which left the form standing
|
||||
* once the answer landed.
|
||||
*
|
||||
* These assert the re-derivation, which is what closes it. What they cannot
|
||||
* assert is the render, because there is no click here: the SSR note at the
|
||||
* top of the file applies.
|
||||
*/
|
||||
it("withdraws a capture form the shrunken offer no longer holds", () => {
|
||||
// The chooser offered four; the answer arrives and offers two.
|
||||
expect(admittedCaptureKind("mood", ["measurement", "medication"])).toBe(
|
||||
null,
|
||||
);
|
||||
expect(admittedCaptureKind("water", ["measurement", "medication"])).toBe(
|
||||
null,
|
||||
);
|
||||
// …and leaves an admitted one exactly where it was.
|
||||
expect(
|
||||
admittedCaptureKind("medication", ["measurement", "medication"]),
|
||||
).toBe("medication");
|
||||
expect(admittedCaptureKind(null, ["measurement"])).toBe(null);
|
||||
});
|
||||
|
||||
it("withdraws a dashboard quick-entry sheet the delegation does not admit", () => {
|
||||
const DELEGATE = { canAdd: true, canManage: false };
|
||||
expect(admittedQuickEntry("mood", DELEGATE)).toBe(null);
|
||||
expect(admittedQuickEntry("water", DELEGATE)).toBe(null);
|
||||
expect(admittedQuickEntry("measurement", DELEGATE)).toBe("measurement");
|
||||
expect(admittedQuickEntry("medicationIntake", DELEGATE)).toBe(
|
||||
"medicationIntake",
|
||||
);
|
||||
});
|
||||
|
||||
it("withdraws every quick-entry sheet from a read-only delegate", () => {
|
||||
const READER = { canAdd: false, canManage: false };
|
||||
for (const sheet of [
|
||||
"measurement",
|
||||
"mood",
|
||||
"medicationIntake",
|
||||
"water",
|
||||
] as const) {
|
||||
expect(admittedQuickEntry(sheet, READER), sheet).toBe(null);
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves the owner's own sheets alone", () => {
|
||||
const OWNER_CAPS = { canAdd: true, canManage: true };
|
||||
for (const sheet of [
|
||||
"measurement",
|
||||
"mood",
|
||||
"medicationIntake",
|
||||
"water",
|
||||
] as const) {
|
||||
expect(admittedQuickEntry(sheet, OWNER_CAPS), sheet).toBe(sheet);
|
||||
}
|
||||
expect(admittedQuickEntry(null, OWNER_CAPS)).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -34,9 +34,9 @@ export function CustomMetricList() {
|
||||
// Defining a metric is not a delegated verb, and every row here links to
|
||||
// `/custom-metrics/{id}`, which sharing does not cover: the shell turns a
|
||||
// delegate away at that route, so the rows would be doors into a wall.
|
||||
// Logging a value against a custom metric IS admitted, but there is no
|
||||
// reachable surface for it under a switch, and inventing one is a read-model
|
||||
// change this does not make.
|
||||
// Logging a value against a custom metric was admitted for a release and is
|
||||
// not any more: this paragraph named the gap, the release shipped over it,
|
||||
// and the write withdrew rather than the sentence staying true.
|
||||
const { canManage } = useRecordCapabilities();
|
||||
const router = useRouter();
|
||||
const [addOpen, setAddOpen] = useState(false);
|
||||
|
||||
@@ -29,8 +29,12 @@ import {
|
||||
type DefaultMedicationOption,
|
||||
} from "@/lib/medications/default-medication";
|
||||
import { useAuth } from "@/hooks/use-auth";
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { ApiError, apiGet, apiPost } from "@/lib/api/api-fetch";
|
||||
import { runUndoIntake } from "@/components/medications/use-medication-intake";
|
||||
import {
|
||||
intakeToastOptions,
|
||||
runUndoIntake,
|
||||
} from "@/components/medications/use-medication-intake";
|
||||
|
||||
/**
|
||||
* v1.4.37 W7b — dashboard "Hinzufügen" → "Medikamenteneinnahme" quick-add.
|
||||
@@ -111,6 +115,8 @@ export function MedicationIntakeQuickAdd({
|
||||
// logged-out mounts behave unchanged.
|
||||
const { user } = useAuth();
|
||||
const userTz = user?.timezone || "Europe/Berlin";
|
||||
// The record this dose lands in, or null in the caller's own.
|
||||
const recordName = useActiveRecordName();
|
||||
|
||||
const { data: medicationsRaw, isLoading: medicationsLoading } = useQuery({
|
||||
queryKey: queryKeys.medications(),
|
||||
@@ -220,26 +226,35 @@ export function MedicationIntakeQuickAdd({
|
||||
queryKey: queryKeys.complianceChartInline(),
|
||||
});
|
||||
|
||||
toast.success(
|
||||
t("common.saved"),
|
||||
eventId && selectedMedication
|
||||
? {
|
||||
action: {
|
||||
label: t("medications.intakeUndo"),
|
||||
onClick: () =>
|
||||
void runUndoIntake({
|
||||
medication: {
|
||||
id: medicationId,
|
||||
name: selectedMedication.name,
|
||||
},
|
||||
eventId,
|
||||
t,
|
||||
queryClient,
|
||||
}),
|
||||
},
|
||||
}
|
||||
// v1.36.x — the shared decision, not a fourth copy of it. This surface
|
||||
// was left out of the collapse that took the other three: it offered a
|
||||
// delegate an Undo hitting `DELETE /api/medications/{id}/intake/{id}`,
|
||||
// which is not delegable and refuses, and it confirmed with a bare
|
||||
// "Saved" — the one confirmation a person acting for somebody else does
|
||||
// not need. It is reachable by a write delegate from the capture picker
|
||||
// and from the dashboard quick-add, so both mattered.
|
||||
const options = intakeToastOptions({
|
||||
recordName,
|
||||
eventId,
|
||||
t,
|
||||
onUndo: selectedMedication
|
||||
? (id) =>
|
||||
void runUndoIntake({
|
||||
medication: {
|
||||
id: medicationId,
|
||||
name: selectedMedication.name,
|
||||
},
|
||||
eventId: id,
|
||||
t,
|
||||
queryClient,
|
||||
})
|
||||
: undefined,
|
||||
);
|
||||
});
|
||||
if (options) {
|
||||
toast.success(t("common.saved"), options);
|
||||
} else {
|
||||
toast.success(t("common.saved"));
|
||||
}
|
||||
onSuccess?.();
|
||||
} catch (err) {
|
||||
setError(
|
||||
|
||||
@@ -26,10 +26,47 @@ import { MoodForm } from "@/components/mood/mood-form";
|
||||
import { MedicationIntakeQuickAdd } from "@/components/dashboard/medication-intake-quick-add";
|
||||
import { WaterQuickAddSheet } from "@/components/insights/nutrients/water-quick-add-sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import {
|
||||
useRecordCapabilities,
|
||||
type RecordCapabilities,
|
||||
} from "@/hooks/use-record-capabilities";
|
||||
|
||||
export type QuickEntryDialog =
|
||||
"measurement" | "mood" | "medicationIntake" | "water" | null;
|
||||
|
||||
/**
|
||||
* v1.36.x — which of the four the delegation admits, and the same rule the
|
||||
* capture picker states at length.
|
||||
*
|
||||
* A WRITE grant covers entering a reading and marking a dose. A mood entry and
|
||||
* a glass of water are not admitted verbs and the server refuses both under a
|
||||
* switch.
|
||||
*/
|
||||
const DELEGABLE_QUICK_ENTRIES: ReadonlySet<NonNullable<QuickEntryDialog>> =
|
||||
new Set(["measurement", "medicationIntake"]);
|
||||
|
||||
/**
|
||||
* The sheet that may actually be on screen.
|
||||
*
|
||||
* `DashboardHeader` gates the menu items that set this state, and that was the
|
||||
* whole gate — so it held only for as long as the answer was already known.
|
||||
* `resolveRecordCapabilities(undefined)` reads as the caller's own record
|
||||
* until `/api/auth/me` settles, and the switch flow ends in a hard reload that
|
||||
* wipes the persisted cache, so a delegate's first paint carries all four menu
|
||||
* items. One of them opened a sheet that then stayed open on a form the server
|
||||
* refuses.
|
||||
*
|
||||
* Re-deriving the open sheet from the current answer withdraws it instead.
|
||||
* Pure and exported so the rule can be pinned without a click.
|
||||
*/
|
||||
export function admittedQuickEntry(
|
||||
open: QuickEntryDialog,
|
||||
caps: Pick<RecordCapabilities, "canAdd" | "canManage">,
|
||||
): QuickEntryDialog {
|
||||
if (open === null || caps.canManage) return open;
|
||||
return caps.canAdd && DELEGABLE_QUICK_ENTRIES.has(open) ? open : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* v1.11.3 F3 — guard the quick-entry sheets against an accidental
|
||||
* dismiss (overlay tap, Esc, mobile swipe-down) discarding a partly
|
||||
@@ -101,6 +138,10 @@ export function QuickEntrySheets({
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslations();
|
||||
const capabilities = useRecordCapabilities();
|
||||
// Re-derived on every render, never latched: the answer can arrive after the
|
||||
// sheet is already open. See `admittedQuickEntry`.
|
||||
const openSheet = admittedQuickEntry(open, capabilities);
|
||||
// v1.4.27 R4 RC2 — DOM handles for the form action-row portal target
|
||||
// on each quick-entry sheet. The Sheet branch sticky-pins this slot.
|
||||
const [measurementFooterEl, setMeasurementFooterEl] =
|
||||
@@ -132,7 +173,7 @@ export function QuickEntrySheets({
|
||||
return (
|
||||
<>
|
||||
<ResponsiveSheet
|
||||
open={open === "measurement"}
|
||||
open={openSheet === "measurement"}
|
||||
onOpenChange={handleQuickEntryOpenChange}
|
||||
title={t("measurements.addMeasurement")}
|
||||
footer={<div ref={setMeasurementFooterEl} className="flex w-full" />}
|
||||
@@ -144,7 +185,7 @@ export function QuickEntrySheets({
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
<ResponsiveSheet
|
||||
open={open === "mood"}
|
||||
open={openSheet === "mood"}
|
||||
onOpenChange={handleQuickEntryOpenChange}
|
||||
title={t("mood.addEntry")}
|
||||
footer={<div ref={setMoodFooterEl} className="flex w-full" />}
|
||||
@@ -160,7 +201,7 @@ export function QuickEntrySheets({
|
||||
(Cancel + Save) portals into the sticky-pinned slot so it
|
||||
stays reachable above the soft keyboard on mobile. */}
|
||||
<ResponsiveSheet
|
||||
open={open === "medicationIntake"}
|
||||
open={openSheet === "medicationIntake"}
|
||||
onOpenChange={handleQuickEntryOpenChange}
|
||||
title={t("dashboard.medicationIntakeQuickAdd.sheetTitle")}
|
||||
description={t("dashboard.medicationIntakeQuickAdd.sheetDescription")}
|
||||
@@ -175,7 +216,7 @@ export function QuickEntrySheets({
|
||||
/>
|
||||
</ResponsiveSheet>
|
||||
<WaterQuickAddSheet
|
||||
open={open === "water"}
|
||||
open={openSheet === "water"}
|
||||
onOpenChange={handleQuickEntryOpenChange}
|
||||
onSuccess={onClose}
|
||||
/>
|
||||
|
||||
@@ -256,10 +256,20 @@ export function DocumentDetailSheet({
|
||||
// the review-first assist, the session-only summary, and content indexing.
|
||||
// The affordance itself is gated on `assistAvailable` (from usage); the
|
||||
// probe only resolves the transport mode + the actionable unavailable reason.
|
||||
const capability = useDocumentAiCapability(open && documentId !== null);
|
||||
//
|
||||
// v1.36.x — and neither probe fires inside somebody else's record. The
|
||||
// document reads behind this sheet are delegable; the AI verbs above them are
|
||||
// not, and both of these routes stay on the caller's own authentication. A
|
||||
// delegate would fire two queries that must 403 in order to compute an
|
||||
// affordance every branch below already withholds on `canManage`.
|
||||
const capability = useDocumentAiCapability(
|
||||
canManage && open && documentId !== null,
|
||||
);
|
||||
// When auto-read is ON, reading happens automatically on upload — the manual
|
||||
// per-document AI action row is redundant and collapses away.
|
||||
const autoRead = useDocumentsAutoAiRead(open && documentId !== null);
|
||||
const autoRead = useDocumentsAutoAiRead(
|
||||
canManage && open && documentId !== null,
|
||||
);
|
||||
const suggest = useSuggestDetails();
|
||||
const summary = useDocumentSummary();
|
||||
// Stored generation has an independent mutation state so transient
|
||||
|
||||
@@ -74,6 +74,34 @@ const CAPTURE_KIND_ORDER: ReadonlyArray<CaptureKind> = [
|
||||
"water",
|
||||
];
|
||||
|
||||
/**
|
||||
* The kind whose form may actually be on screen.
|
||||
*
|
||||
* Gating the chooser is not enough, and the gap is widest exactly when it
|
||||
* matters. `resolveRecordCapabilities(undefined)` answers "your own record"
|
||||
* until `/api/auth/me` settles — deliberately, so no add button blanks on
|
||||
* every cold load — and the switch flow ends in a hard reload that wipes the
|
||||
* persisted query cache. So a delegate's first paint offers all four kinds for
|
||||
* as long as that query takes. `chooseKind` refused an unoffered kind at tap
|
||||
* time and then never asked again, so a tap landing inside that window opened
|
||||
* a form and left it open once the answer arrived: the delegate ends up
|
||||
* standing on a form posting to a route the server refuses.
|
||||
*
|
||||
* Re-deriving the open form from the CURRENT offer is what closes it. Same
|
||||
* shape `/measurements` and the medication wizard already use (`open={x &&
|
||||
* capability}`) — a control that appears and withdraws is a much smaller lie
|
||||
* than one that stays and 403s.
|
||||
*
|
||||
* Pure and exported so the rule can be pinned without a click; an SSR test
|
||||
* cannot tap the button that opens the sheet.
|
||||
*/
|
||||
export function admittedCaptureKind(
|
||||
kind: CaptureKind | null,
|
||||
offered: ReadonlyArray<CaptureKind>,
|
||||
): CaptureKind | null {
|
||||
return kind !== null && offered.includes(kind) ? kind : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The capture kinds to offer, given what the record allows and whether the
|
||||
* nutrients module is on. Exported pure so the delegation rule can be pinned
|
||||
@@ -108,6 +136,9 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
CAPTURE_KIND_ORDER,
|
||||
);
|
||||
const [kind, setKind] = useState<CaptureKind | null>(null);
|
||||
// Re-derived on every render, never latched: the offer can shrink under a
|
||||
// sheet that is already open. See `admittedCaptureKind`.
|
||||
const openKind = admittedCaptureKind(kind, offered);
|
||||
const [footerEl, setFooterEl] = useState<HTMLDivElement | null>(null);
|
||||
// v1.30.1 — mirrors `QuickEntrySheets`' `confirmDiscardOpen`: hold a
|
||||
// dismiss attempt here instead of closing outright when the form
|
||||
@@ -177,10 +208,10 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
mood: t("mood.addEntry"),
|
||||
water: t("nutrients.hydration.quickAddTitle"),
|
||||
};
|
||||
// `kind === null` keeps the form sheet closed (the title is unread then);
|
||||
// the mood label is the harmless default, matching the prior ternary's
|
||||
// else branch.
|
||||
const formTitle = kind ? formTitleByKind[kind] : t("mood.addEntry");
|
||||
// `openKind === null` keeps the form sheet closed (the title is unread
|
||||
// then); the mood label is the harmless default, matching the prior
|
||||
// ternary's else branch.
|
||||
const formTitle = openKind ? formTitleByKind[openKind] : t("mood.addEntry");
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -219,26 +250,26 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
|
||||
{/* The chosen capture surface, reusing the existing form. */}
|
||||
<ResponsiveSheet
|
||||
open={kind !== null && kind !== "water"}
|
||||
open={openKind !== null && openKind !== "water"}
|
||||
onOpenChange={handleFormOpenChange}
|
||||
title={formTitle}
|
||||
footer={<div ref={setFooterEl} className="flex w-full" />}
|
||||
>
|
||||
{kind === "measurement" && (
|
||||
{openKind === "measurement" && (
|
||||
<MeasurementForm
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
)}
|
||||
{kind === "medication" && (
|
||||
{openKind === "medication" && (
|
||||
<MedicationIntakeQuickAdd
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
footerSlot={footerEl}
|
||||
/>
|
||||
)}
|
||||
{kind === "mood" && (
|
||||
{openKind === "mood" && (
|
||||
<MoodForm
|
||||
onSuccess={closeForm}
|
||||
onCancel={closeForm}
|
||||
@@ -247,7 +278,7 @@ export function CapturePicker({ open, onOpenChange }: CapturePickerProps) {
|
||||
)}
|
||||
</ResponsiveSheet>
|
||||
|
||||
{nutrientsEnabled && kind === "water" && (
|
||||
{openKind === "water" && (
|
||||
<WaterQuickAddSheet
|
||||
open
|
||||
onOpenChange={handleFormOpenChange}
|
||||
|
||||
@@ -34,6 +34,8 @@ import {
|
||||
refetchInactiveDailyReads,
|
||||
} from "@/lib/query-keys";
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { intakeToastOptions } from "@/components/medications/use-medication-intake";
|
||||
import type { QueryKey } from "@tanstack/react-query";
|
||||
import type { LedgerSchedule } from "@/components/medications/dose-history-ledger";
|
||||
|
||||
@@ -132,6 +134,8 @@ export function LedgerAddDialog({
|
||||
}: LedgerAddDialogProps) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
// The record this dose lands in, or null in the caller's own.
|
||||
const recordName = useActiveRecordName();
|
||||
const formId = useId();
|
||||
|
||||
const [takenAt, setTakenAt] = useState(() => toDateTimeLocal(new Date()));
|
||||
@@ -166,14 +170,28 @@ export function LedgerAddDialog({
|
||||
// apiPost throws so the catch below surfaces it rather than silently
|
||||
// dropping the dose.
|
||||
await apiPost(`/api/medications/${medicationId}/intake`, body);
|
||||
toast.success(
|
||||
t(
|
||||
skipped
|
||||
? "medications.intakeToastSkipped"
|
||||
: "medications.intakeToastTaken",
|
||||
{ name: medicationName },
|
||||
),
|
||||
// v1.36.x — the shared decision. This surface offers no Undo, so what
|
||||
// it was missing is the other half: inside somebody else's record the
|
||||
// confirmation names the record, because "Taken" alone is the one
|
||||
// confirmation a person acting for somebody else does not need. The
|
||||
// ledger's "+ Eintrag" button that opens this dialog is gated on
|
||||
// `canAdd`, so a delegate reaches it.
|
||||
const toastOptions = intakeToastOptions({
|
||||
recordName,
|
||||
eventId: undefined,
|
||||
t,
|
||||
});
|
||||
const message = t(
|
||||
skipped
|
||||
? "medications.intakeToastSkipped"
|
||||
: "medications.intakeToastTaken",
|
||||
{ name: medicationName },
|
||||
);
|
||||
if (toastOptions) {
|
||||
toast.success(message, toastOptions);
|
||||
} else {
|
||||
toast.success(message);
|
||||
}
|
||||
await invalidateKeys(queryClient, [
|
||||
...medicationDependentKeys,
|
||||
ledgerKey,
|
||||
|
||||
@@ -564,8 +564,12 @@ export function DoseHistoryLedger({
|
||||
* Every settled row (taken / skipped / a past missed day / ad-hoc) carries
|
||||
* ONLY the kebab: Bearbeiten, the Übersprungen↔Genommen toggle, Löschen —
|
||||
* and, for a past missed slot, the Genommen / Übersprungen backfill marks.
|
||||
*
|
||||
* v1.36.x — exported for the delegated-affordance suite. The row's capability
|
||||
* gating is the interesting part and the ledger around it needs a payload, a
|
||||
* medication and a query client to paint one row.
|
||||
*/
|
||||
function LedgerRowItem({
|
||||
export function LedgerRowItem({
|
||||
row,
|
||||
marking,
|
||||
isToday,
|
||||
@@ -725,6 +729,35 @@ function LedgerRowItem({
|
||||
{t("medications.detail.verlauf.markTaken")}
|
||||
</Button>
|
||||
)}
|
||||
{/* v1.36.x — the other half of the same admitted verb. Marking a dose
|
||||
taken OR skipped is one capability, and the medication card offers
|
||||
both; this ledger offered a delegate only the first, because skip
|
||||
lives in the kebab and the kebab is owner-only (it also holds edit,
|
||||
re-attribute and delete). Rather than mix admitted and withheld
|
||||
items in one menu, a delegate gets skip as its own control beside
|
||||
take, which is the shape the card already uses. The owner keeps it
|
||||
where it was. */}
|
||||
{showTakenButton && canAdd && !canManage && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onMark(row, "skipped")}
|
||||
disabled={takeBusy || skipBusy}
|
||||
aria-busy={skipBusy || undefined}
|
||||
className="min-h-9"
|
||||
data-slot="ledger-mark-skipped"
|
||||
>
|
||||
{skipBusy ? (
|
||||
<Loader2
|
||||
aria-hidden="true"
|
||||
className="size-4 animate-spin motion-reduce:animate-none"
|
||||
/>
|
||||
) : (
|
||||
<SkipForward aria-hidden="true" className="size-4" />
|
||||
)}
|
||||
{t("medications.detail.verlauf.markSkipped")}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { CheckCheck, Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ResponsiveSheet } from "@/components/ui/responsive-sheet";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { useActiveRecordName } from "@/hooks/use-record-capabilities";
|
||||
import { formatDose } from "@/lib/medications/format-dose";
|
||||
import { formatTimeWindowRange } from "@/lib/time-window-format";
|
||||
import {
|
||||
@@ -42,13 +43,19 @@ export function TakeAllDueDialog({
|
||||
}: TakeAllDueDialogProps) {
|
||||
const { t, locale } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const recordName = useActiveRecordName();
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleConfirm() {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await runTakeAllDue({ medications: dueMedications, t, queryClient });
|
||||
await runTakeAllDue({
|
||||
medications: dueMedications,
|
||||
t,
|
||||
queryClient,
|
||||
recordName,
|
||||
});
|
||||
// Close regardless of partial failures — the summary toast carries
|
||||
// the counts and failed medications stay due on the page behind it.
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -33,12 +33,23 @@
|
||||
*
|
||||
* The due set is small (a handful of medications, never the 500-entry
|
||||
* sync scale), so N sequential round trips are negligible.
|
||||
*
|
||||
* v1.36.x — a delegate may drive this, and the loop cannot half-apply on
|
||||
* them. The two refusals v1.36.1 added for a delegate — snoozing, and
|
||||
* changing an event the owner already resolved — live on
|
||||
* `POST /api/medications/intake`, the account-wide slot route, which this
|
||||
* loop does not call. The route it does call has no snooze arm at all, and
|
||||
* the derivation above cannot put a resolved dose in the set: an event with
|
||||
* a `takenAt` closes its window status, and `takenEarlyDaysAgo` drops the
|
||||
* day-scale case ahead of it. Every entry it sends is `skipped: false` on a
|
||||
* slot still in play, which is the same request the card's own button makes.
|
||||
*/
|
||||
|
||||
import type { QueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { apiPost } from "@/lib/api/api-fetch";
|
||||
import { intakeToastOptions } from "@/components/medications/use-medication-intake";
|
||||
import { invalidateMedicationReads } from "@/lib/query-keys";
|
||||
import {
|
||||
reduceCurrentWindowStatus,
|
||||
@@ -211,8 +222,16 @@ export async function runTakeAllDue(deps: {
|
||||
medications: DueMedication[];
|
||||
t: Translator;
|
||||
queryClient: QueryClient;
|
||||
/**
|
||||
* v1.36.x — the record the batch lands in, when it is not the caller's own.
|
||||
* The sweep is a delegated write now, so its summary carries the same
|
||||
* receipt every single-dose confirmation does: a helper who confirms four
|
||||
* tablets is told whose four they were. It offers no Undo, so the helper's
|
||||
* other half is a no-op here.
|
||||
*/
|
||||
recordName?: string | null;
|
||||
}): Promise<{ taken: number; failed: number }> {
|
||||
const { medications, t, queryClient } = deps;
|
||||
const { medications, t, queryClient, recordName } = deps;
|
||||
let taken = 0;
|
||||
let failed = 0;
|
||||
|
||||
@@ -244,7 +263,13 @@ export async function runTakeAllDue(deps: {
|
||||
}
|
||||
|
||||
if (failed === 0) {
|
||||
toast.success(t("medications.takeAllDue.successToast", { count: taken }));
|
||||
const options = intakeToastOptions({ recordName, eventId: undefined, t });
|
||||
const message = t("medications.takeAllDue.successToast", { count: taken });
|
||||
if (options) {
|
||||
toast.success(message, options);
|
||||
} else {
|
||||
toast.success(message);
|
||||
}
|
||||
} else if (taken > 0) {
|
||||
toast.error(t("medications.takeAllDue.partialToast", { taken, failed }));
|
||||
} else {
|
||||
|
||||
@@ -85,6 +85,23 @@ describe("offering a level", () => {
|
||||
expect(html).toContain('data-access="WRITE" data-selected="false"');
|
||||
});
|
||||
|
||||
it("shows keyboard focus on the option the radio is hidden inside", () => {
|
||||
// The radio is `sr-only`, so the browser's own ring lands on a zero-size
|
||||
// box and tabbing through this fieldset moved an invisible selection —
|
||||
// WCAG 2.4.7, on the screen where somebody grants write access to their
|
||||
// health record. The label wears the ring instead, and `has-[:focus-visible]`
|
||||
// is what carries it from the input to the label.
|
||||
const html = render(<GrantInviteCard />);
|
||||
const options = html.match(
|
||||
/<label[^>]*data-slot="grant-invite-access-option"[^>]*>/g,
|
||||
);
|
||||
expect(options).toHaveLength(2);
|
||||
for (const option of options ?? []) {
|
||||
expect(option, option).toContain("has-[:focus-visible]:ring-2");
|
||||
expect(option, option).toContain("has-[:focus-visible]:ring-ring/50");
|
||||
}
|
||||
});
|
||||
|
||||
it("says what the wider level actually does, and what it does not", () => {
|
||||
const html = render(<GrantInviteCard />);
|
||||
// The thing a person reading "write access" gets wrong.
|
||||
@@ -96,6 +113,12 @@ describe("offering a level", () => {
|
||||
// the presence of the rest: a consent notice that over-promises is worse
|
||||
// than one that is merely incomplete.
|
||||
expect(html).not.toContain("allergies");
|
||||
// v1.36.x — and the same again for logging a value on a tracked metric.
|
||||
// The route was admitted, the copy promised it, and no delegate could
|
||||
// reach a surface that posts one: `/custom-metrics/{id}` is not a
|
||||
// shared-record destination, so the shell answers before the form does.
|
||||
// The permission and the sentence went together.
|
||||
expect(html).not.toContain("a metric you track yourself");
|
||||
// Deferring a reminder is the capability the same request could reach and
|
||||
// delegation does not cover; the notice has to say so.
|
||||
expect(html).toContain("cannot defer one of your reminders");
|
||||
|
||||
@@ -191,6 +191,13 @@ function AccessOption({
|
||||
data-selected={selected ? "true" : "false"}
|
||||
className={[
|
||||
"flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors",
|
||||
// The radio itself is `sr-only`, so the browser's own focus ring lands
|
||||
// on a zero-size box: keyboard focus moved through this fieldset with
|
||||
// nothing visible moving with it. WCAG 2.4.7, on the screen where
|
||||
// somebody decides who may write into their health record. The label
|
||||
// wears the ring instead, on the same `ring-ring/50 ring-2` token the
|
||||
// capture picker's options use.
|
||||
"has-[:focus-visible]:ring-ring/50 has-[:focus-visible]:ring-2",
|
||||
selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:bg-muted/40",
|
||||
|
||||
@@ -22,12 +22,15 @@ import {
|
||||
* ## The two answers, and why there are two
|
||||
*
|
||||
* A grant at WRITE admits a short, closed list of verbs: entering readings,
|
||||
* lab results, an illness entry, a biomarker, a tracked value, a side effect, a
|
||||
* medication, and marking a dose taken or skipped. It admits nothing else.
|
||||
* lab results, an illness entry, a biomarker, a side effect, a medication, and
|
||||
* marking a dose taken or skipped. It admits nothing else.
|
||||
* Allergies and family history were on this list and left it before v1.36.1
|
||||
* shipped — the argument for them held, but the only surface that posts to
|
||||
* either lives under `/settings`, which a switch closes, so the permission had
|
||||
* no reachable caller. `delegable-surface-guard.test.ts` carries that argument
|
||||
* no reachable caller. Logging a value on a tracked metric left for the same
|
||||
* reason one release later: its only form sits on `/custom-metrics/{id}`,
|
||||
* which a switch closes too.
|
||||
* `delegable-surface-guard.test.ts` carries that argument
|
||||
* in full and is the list that binds; this paragraph has to agree with it. Editing, deleting, restoring, purging, importing and
|
||||
* bulk-acting stay with the owner, including on an entry the delegate added
|
||||
* themselves a minute ago, and so does every create the list does not name
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { join, relative } from "node:path";
|
||||
|
||||
import ts from "typescript";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
@@ -23,6 +24,32 @@ import { describe, expect, it } from "vitest";
|
||||
* So this reads the actions out of the routes rather than out of a list
|
||||
* somebody maintains beside them. A new delegable write whose action has no
|
||||
* sentence fails here, at the moment it is added.
|
||||
*
|
||||
* ## Why the AST
|
||||
*
|
||||
* Membership used to be `src.includes('requireRecordAuth("write")')` and the
|
||||
* action names came out of a regex. Both were satisfiable and both were
|
||||
* escapable by formatting alone: putting the argument on its own line — which
|
||||
* is what prettier does the moment the call grows a second one — dropped the
|
||||
* module out of the scan entirely, and a genuinely-red run went green with no
|
||||
* mapping added and nothing else changed. A guard a reformat can silence is
|
||||
* not a guard.
|
||||
*
|
||||
* Membership is decided by parsing now, through the module's own import table,
|
||||
* so an aliased or namespace-qualified resolver call resolves back to
|
||||
* `requireRecordAuth` and the argument is read off the call node rather than
|
||||
* off the neighbouring characters. The same walk finds the awaited `auditLog`
|
||||
* calls. `describe("the matcher sees what it looks for")` proves both halves
|
||||
* against synthetic modules.
|
||||
*
|
||||
* ## Why the counts below are of MATCHES
|
||||
*
|
||||
* The verdict this file returns is an EMPTY list, and an empty list is what a
|
||||
* matcher that matches nothing also returns. Counting the modules SCANNED
|
||||
* proved only that the directory walk worked — it stayed comfortably above its
|
||||
* floor while the membership test found no write routes at all. So the pins
|
||||
* are on the matched set: the write modules found, one of them named, and the
|
||||
* awaited audit actions read out of them.
|
||||
*/
|
||||
const API = join(process.cwd(), "src/app/api");
|
||||
const VERB_MAP = join(
|
||||
@@ -30,6 +57,9 @@ const VERB_MAP = join(
|
||||
"src/lib/record-activity/activity-verb.ts",
|
||||
);
|
||||
|
||||
/** The declaration a delegable write route makes. */
|
||||
const RECORD_RESOLVER = "requireRecordAuth";
|
||||
|
||||
/** Every `route.ts` under `src/app/api`, walked rather than globbed: a glob's
|
||||
* `*` skips a leading dot and this tree has dot-directories. */
|
||||
function routeFiles(dir: string): string[] {
|
||||
@@ -42,26 +72,213 @@ function routeFiles(dir: string): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
interface ModuleFacts {
|
||||
/** Does the module pass `"write"` to the record resolver anywhere. */
|
||||
writesToRecord: boolean;
|
||||
/**
|
||||
* The action names of the module's AWAITED `auditLog("…")` calls.
|
||||
*
|
||||
* Awaited on purpose: a fire-and-forget breadcrumb on a validation failure
|
||||
* is not something to narrate to the owner.
|
||||
*/
|
||||
awaitedAuditActions: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Read both facts off one parse.
|
||||
*
|
||||
* The import table resolves a local name back to the symbol it was exported
|
||||
* as, so `requireRecordAuth as resolveRecord` and `auth.requireRecordAuth`
|
||||
* both count, and a comment naming either symbol counts for nothing.
|
||||
*/
|
||||
export function moduleFacts(source: string, fileName: string): ModuleFacts {
|
||||
const parsed = ts.createSourceFile(
|
||||
fileName,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
ts.ScriptKind.TS,
|
||||
);
|
||||
|
||||
const aliases = new Map<string, string>();
|
||||
const namespaces = new Set<string>();
|
||||
parsed.forEachChild((node) => {
|
||||
if (!ts.isImportDeclaration(node)) return;
|
||||
const bindings = node.importClause?.namedBindings;
|
||||
if (!bindings) return;
|
||||
if (ts.isNamespaceImport(bindings)) {
|
||||
namespaces.add(bindings.name.text);
|
||||
return;
|
||||
}
|
||||
for (const spec of bindings.elements) {
|
||||
aliases.set(spec.name.text, (spec.propertyName ?? spec.name).text);
|
||||
}
|
||||
});
|
||||
|
||||
/** The original exported name behind this callee, or null. */
|
||||
const calleeName = (callee: ts.Expression): string | null => {
|
||||
if (ts.isIdentifier(callee)) return aliases.get(callee.text) ?? callee.text;
|
||||
if (
|
||||
ts.isPropertyAccessExpression(callee) &&
|
||||
ts.isIdentifier(callee.expression) &&
|
||||
namespaces.has(callee.expression.text)
|
||||
) {
|
||||
return callee.name.text;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
/** The first argument when it is a plain string literal. */
|
||||
const firstStringArg = (node: ts.CallExpression): string | null => {
|
||||
const first = node.arguments[0];
|
||||
return first && ts.isStringLiteralLike(first) ? first.text : null;
|
||||
};
|
||||
|
||||
let writesToRecord = false;
|
||||
const awaitedAuditActions: string[] = [];
|
||||
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const name = calleeName(node.expression);
|
||||
if (name === RECORD_RESOLVER && firstStringArg(node) === "write") {
|
||||
writesToRecord = true;
|
||||
}
|
||||
}
|
||||
if (ts.isAwaitExpression(node) && ts.isCallExpression(node.expression)) {
|
||||
const call = node.expression;
|
||||
if (calleeName(call.expression) === "auditLog") {
|
||||
const action = firstStringArg(call);
|
||||
if (action !== null) awaitedAuditActions.push(action);
|
||||
}
|
||||
}
|
||||
node.forEachChild(visit);
|
||||
};
|
||||
parsed.forEachChild(visit);
|
||||
|
||||
return { writesToRecord, awaitedAuditActions };
|
||||
}
|
||||
|
||||
describe("the matcher sees what it looks for", () => {
|
||||
const REFORMATTED = `
|
||||
import { requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth(
|
||||
"write",
|
||||
);
|
||||
await auditLog("thing.create", { userId: user.id });
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
|
||||
it("matches a resolver call broken across lines", () => {
|
||||
// The shape that silenced this guard: prettier moves the argument onto its
|
||||
// own line the moment the call grows, and the substring test that decided
|
||||
// membership went quiet without anything about the route changing.
|
||||
expect(REFORMATTED.includes('requireRecordAuth("write")')).toBe(false);
|
||||
const facts = moduleFacts(REFORMATTED, "r.ts");
|
||||
expect(facts.writesToRecord).toBe(true);
|
||||
expect(facts.awaitedAuditActions).toEqual(["thing.create"]);
|
||||
});
|
||||
|
||||
it("matches an aliased and a namespace-qualified resolver call", () => {
|
||||
const ALIASED = `
|
||||
import { requireRecordAuth as resolveRecord } from "@/lib/api-handler";
|
||||
import { auditLog as record } from "@/lib/auth/audit";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await resolveRecord("write");
|
||||
await record("thing.aliased");
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
expect(moduleFacts(ALIASED, "a.ts")).toEqual({
|
||||
writesToRecord: true,
|
||||
awaitedAuditActions: ["thing.aliased"],
|
||||
});
|
||||
|
||||
const NAMESPACED = `
|
||||
import * as auth from "@/lib/api-handler";
|
||||
|
||||
export const POST = auth.apiHandler(async () => {
|
||||
const { user } = await auth.requireRecordAuth("write");
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
expect(moduleFacts(NAMESPACED, "n.ts").writesToRecord).toBe(true);
|
||||
});
|
||||
|
||||
it("does not enrol a read route that only talks about writing", () => {
|
||||
const READ_ONLY = `
|
||||
/** Delegable for reads. It must never take requireRecordAuth("write"). */
|
||||
import { requireRecordAuth } from "@/lib/api-handler";
|
||||
|
||||
const MODE = "write";
|
||||
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("read");
|
||||
return apiSuccess({ id: user.id, mode: MODE });
|
||||
});
|
||||
`;
|
||||
// The raw text carries the exact substring the old membership test used.
|
||||
expect(READ_ONLY.includes('requireRecordAuth("write")')).toBe(true);
|
||||
expect(moduleFacts(READ_ONLY, "ro.ts").writesToRecord).toBe(false);
|
||||
});
|
||||
|
||||
it("reads only the awaited audit calls", () => {
|
||||
const MIXED = `
|
||||
import { requireRecordAuth } from "@/lib/api-handler";
|
||||
import { auditLog } from "@/lib/auth/audit";
|
||||
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireRecordAuth("write");
|
||||
void auditLog("thing.breadcrumb", {});
|
||||
await auditLog("thing.create", { userId: user.id });
|
||||
return apiSuccess({ id: user.id });
|
||||
});
|
||||
`;
|
||||
expect(moduleFacts(MIXED, "m.ts").awaitedAuditActions).toEqual([
|
||||
"thing.create",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the owner's activity view names what a delegate did", () => {
|
||||
const routes = routeFiles(API);
|
||||
const writeModules = routes
|
||||
.map((file) => ({ file, ...moduleFacts(readFileSync(file, "utf8"), file) }))
|
||||
.filter((m) => m.writesToRecord);
|
||||
|
||||
it("reads a plausible number of route modules", () => {
|
||||
// A walk that finds nothing agrees with a map that covers nothing.
|
||||
expect(routes.length).toBeGreaterThan(300);
|
||||
});
|
||||
|
||||
it("matches the delegable write modules, and not zero of them", () => {
|
||||
// The pin that the leg below actually rests on. "No unmapped actions" is
|
||||
// also what an empty match set says, so the match set is measured here
|
||||
// rather than inferred from the size of the walk.
|
||||
expect(writeModules.length).toBeGreaterThan(0);
|
||||
// One named anchor, so a matcher that collapsed to a single stray file
|
||||
// fails rather than clearing the floor. Marking a dose is the most-used
|
||||
// delegated write in the product and the one whose sentence was wrong.
|
||||
expect(writeModules.map((m) => relative(process.cwd(), m.file))).toContain(
|
||||
"src/app/api/medications/[id]/intake/route.ts",
|
||||
);
|
||||
// And the actions themselves were read, not merely looked for.
|
||||
expect(
|
||||
writeModules.flatMap((m) => m.awaitedAuditActions).length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("gives every delegable write action its own sentence", () => {
|
||||
const map = readFileSync(VERB_MAP, "utf8");
|
||||
|
||||
const unmapped: string[] = [];
|
||||
for (const file of routes) {
|
||||
const src = readFileSync(file, "utf8");
|
||||
if (!src.includes('requireRecordAuth("write")')) continue;
|
||||
// The awaited success-path audits only. A fire-and-forget breadcrumb on a
|
||||
// validation failure is not something to narrate to the owner.
|
||||
for (const m of src.matchAll(/await auditLog\(\s*"([^"]+)"/g)) {
|
||||
const action = m[1];
|
||||
if (!map.includes(`case "${action}"`)) unmapped.push(`${action}`);
|
||||
for (const writeModule of writeModules) {
|
||||
for (const action of writeModule.awaitedAuditActions) {
|
||||
if (!map.includes(`case "${action}"`)) unmapped.push(action);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,14 @@ type Translator = (
|
||||
* The activity view renders every non-read row as one generic line: "somebody
|
||||
* made a change in your record". That was honest while sharing was read-only
|
||||
* and no delegate could make one. Now a delegate can enter a reading, a lab
|
||||
* result, an allergy, a family-history entry, an illness entry, a biomarker,
|
||||
* a tracked value, a side effect, a medication, and can mark a dose taken —
|
||||
* and "made a change" for ten different things is a way of answering nothing.
|
||||
* An owner reading this at breakfast wants the verb.
|
||||
* result, an illness entry, a biomarker, a side effect, a medication, and can
|
||||
* mark a dose taken — and "made a change" for seven different things is a way
|
||||
* of answering nothing. An owner reading this at breakfast wants the verb.
|
||||
*
|
||||
* The map keeps a line for allergies, family history and a tracked value even
|
||||
* though a delegate can no longer add any of the three: rows written while
|
||||
* they were admitted sit in the same table and still have to render as
|
||||
* something, which is the same reason the fallback below exists.
|
||||
*
|
||||
* ## Why unknown actions fall back rather than fail
|
||||
*
|
||||
|
||||
@@ -1756,3 +1756,308 @@ describe("GET /api/medications/compliance — the bucket follows the actor", ()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* The document vault */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* v1.36.x — `/documents` carries `sharedRecord: true`, so the shell offered
|
||||
* the destination under a switch while every read behind it refused: the vault
|
||||
* painted a query-error card, and the illness page's episode-documents tile
|
||||
* painted another beside it. A clinical letter is health data belonging to the
|
||||
* record, and reading it is much of the point of handing somebody the record.
|
||||
*
|
||||
* Five GET arms moved. The upload, the retype, the delete, the episode link
|
||||
* and every AI verb did not, and `delegable-surface-guard.test.ts` is what
|
||||
* freezes that split — this file asserts the moved arms actually substitute.
|
||||
*/
|
||||
async function enableDocuments(userId: string, enabled = true) {
|
||||
await getPrismaClient().user.update({
|
||||
where: { id: userId },
|
||||
data: { modulePreferencesJson: { inboundDocuments: enabled } },
|
||||
});
|
||||
}
|
||||
|
||||
async function seedDocument(userId: string, title: string) {
|
||||
const { encryptDocumentContent } = await import("@/lib/documents/store");
|
||||
const { content, codec } = encryptDocumentContent(
|
||||
Buffer.from(`${title} body`, "utf8"),
|
||||
);
|
||||
return getPrismaClient().inboundDocument.create({
|
||||
data: {
|
||||
userId,
|
||||
title,
|
||||
filename: `${title}.pdf`,
|
||||
mimeType: "application/pdf",
|
||||
byteSize: 11,
|
||||
contentEncrypted: content,
|
||||
contentCodec: codec,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("GET /api/documents/inbound", () => {
|
||||
it("lists the owner's documents to a delegate, never the delegate's own", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await enableDocuments(owner.id);
|
||||
await enableDocuments(delegate.id);
|
||||
await seedDocument(owner.id, "owner-letter");
|
||||
await seedDocument(delegate.id, "delegate-letter");
|
||||
|
||||
await switchInto(owner.id, delegate.id);
|
||||
const { GET } = await import("@/app/api/documents/inbound/route");
|
||||
const response = await call(GET as Handler, "/api/documents/inbound");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const payload = (await body(response)).data as {
|
||||
documents: { title: string | null }[];
|
||||
};
|
||||
// Both named, separately: an empty list satisfies "the delegate's rows are
|
||||
// absent" while hiding the failure this whole feature is about.
|
||||
expect(payload.documents.map((d) => d.title)).toEqual(["owner-letter"]);
|
||||
});
|
||||
|
||||
it("follows the OWNER's module switch, not the delegate's", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await enableDocuments(owner.id, false);
|
||||
await enableDocuments(delegate.id, true);
|
||||
|
||||
await switchInto(owner.id, delegate.id);
|
||||
const { GET } = await import("@/app/api/documents/inbound/route");
|
||||
const response = await call(GET as Handler, "/api/documents/inbound");
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect((await body(response)).meta?.errorCode).toBe("module.disabled");
|
||||
});
|
||||
|
||||
it("refuses the next request after the grant is revoked", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await enableDocuments(owner.id);
|
||||
await seedDocument(owner.id, "owner-letter");
|
||||
|
||||
const { GET } = await import("@/app/api/documents/inbound/route");
|
||||
const { grant } = await switchInto(owner.id, delegate.id);
|
||||
expect((await call(GET as Handler, "/api/documents/inbound")).status).toBe(
|
||||
200,
|
||||
);
|
||||
|
||||
await revokeGrant(grant.id);
|
||||
await expectDenied(await call(GET as Handler, "/api/documents/inbound"));
|
||||
});
|
||||
|
||||
it("is unchanged for a caller who has not switched", async () => {
|
||||
const plain = await makeUser("plain");
|
||||
await enableDocuments(plain.id);
|
||||
await seedDocument(plain.id, "my-letter");
|
||||
await signIn(plain.id);
|
||||
|
||||
const { GET } = await import("@/app/api/documents/inbound/route");
|
||||
const response = await call(GET as Handler, "/api/documents/inbound");
|
||||
expect(response.status).toBe(200);
|
||||
const payload = (await body(response)).data as {
|
||||
documents: { title: string | null }[];
|
||||
};
|
||||
expect(payload.documents.map((d) => d.title)).toEqual(["my-letter"]);
|
||||
});
|
||||
|
||||
it("refuses the upload arm beside it, at either grant level", async () => {
|
||||
// The half that must NOT have moved. The module-level split is the whole
|
||||
// reason the two arms declare separately, and a future edit that hoisted
|
||||
// one resolver to the top of the file would make this go red rather than
|
||||
// quietly admitting uploads.
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await enableDocuments(owner.id);
|
||||
|
||||
const grant = await getPrismaClient().accountGrant.create({
|
||||
data: {
|
||||
grantorId: owner.id,
|
||||
granteeId: delegate.id,
|
||||
access: "WRITE",
|
||||
acceptedAt: new Date(),
|
||||
},
|
||||
});
|
||||
const session = await signIn(delegate.id);
|
||||
await getPrismaClient().session.update({
|
||||
where: { id: session.id },
|
||||
data: { actingAsUserId: owner.id },
|
||||
});
|
||||
expect(grant.access).toBe("WRITE");
|
||||
|
||||
const { POST } = await import("@/app/api/documents/inbound/route");
|
||||
const form = new FormData();
|
||||
form.set(
|
||||
"file",
|
||||
new File([new Uint8Array([0x25, 0x50, 0x44, 0x46])], "scan.pdf", {
|
||||
type: "application/pdf",
|
||||
}),
|
||||
);
|
||||
const response = await (
|
||||
POST as unknown as (r: Request) => Promise<Response>
|
||||
)(
|
||||
new NextRequest("http://localhost/api/documents/inbound", {
|
||||
method: "POST",
|
||||
body: form,
|
||||
}),
|
||||
);
|
||||
|
||||
// The undeclared-mode refusal: the POST arm names no sharing mode, so the
|
||||
// carrier is refused before any grant is read.
|
||||
expect(response.status).toBe(403);
|
||||
expect((await body(response)).meta?.errorCode).toBe(
|
||||
"sharing.not_permitted",
|
||||
);
|
||||
expect(
|
||||
await getPrismaClient().inboundDocument.count({
|
||||
where: { userId: owner.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
expect(
|
||||
await getPrismaClient().inboundDocument.count({
|
||||
where: { userId: delegate.id },
|
||||
}),
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the per-document reads", () => {
|
||||
it("serves the owner's document, its file and its thumbnail slot", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await enableDocuments(owner.id);
|
||||
const doc = await seedDocument(owner.id, "owner-letter");
|
||||
// The delegate's own document, so a handler that fell back to the caller
|
||||
// would resolve something rather than 404 and the assertion would be about
|
||||
// absence instead of substitution.
|
||||
const mine = await seedDocument(delegate.id, "delegate-letter");
|
||||
|
||||
await switchInto(owner.id, delegate.id);
|
||||
|
||||
const detail = await import("@/app/api/documents/inbound/[id]/route");
|
||||
const detailResponse = await call(
|
||||
detail.GET as Handler,
|
||||
`/api/documents/inbound/${doc.id}`,
|
||||
{ id: doc.id },
|
||||
);
|
||||
expect(detailResponse.status).toBe(200);
|
||||
expect(
|
||||
((await body(detailResponse)).data as { title: string | null }).title,
|
||||
).toBe("owner-letter");
|
||||
|
||||
// The delegate's OWN document is unreachable from inside the record: the
|
||||
// query is scoped to the resolved user, so their id resolves to nothing.
|
||||
const mineResponse = await call(
|
||||
detail.GET as Handler,
|
||||
`/api/documents/inbound/${mine.id}`,
|
||||
{ id: mine.id },
|
||||
);
|
||||
expect(mineResponse.status).toBe(404);
|
||||
|
||||
const original =
|
||||
await import("@/app/api/documents/inbound/[id]/original/route");
|
||||
const fileResponse = await call(
|
||||
original.GET as Handler,
|
||||
`/api/documents/inbound/${doc.id}/original`,
|
||||
{ id: doc.id },
|
||||
);
|
||||
expect(fileResponse.status).toBe(200);
|
||||
expect(await fileResponse.text()).toContain("owner-letter body");
|
||||
});
|
||||
|
||||
it("keys the read quota on the ACTOR, not the record", async () => {
|
||||
// The `medications/compliance` rule, applied to the two reads that spend
|
||||
// decryption CPU: a delegate burns their own allowance rather than locking
|
||||
// the owner out, and cannot collect a fresh one by switching records.
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await enableDocuments(owner.id);
|
||||
const doc = await seedDocument(owner.id, "owner-letter");
|
||||
|
||||
await switchInto(owner.id, delegate.id);
|
||||
const original =
|
||||
await import("@/app/api/documents/inbound/[id]/original/route");
|
||||
expect(
|
||||
(
|
||||
await call(
|
||||
original.GET as Handler,
|
||||
`/api/documents/inbound/${doc.id}/original`,
|
||||
{ id: doc.id },
|
||||
)
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
const keys = (
|
||||
await getPrismaClient().rateLimit.findMany({
|
||||
where: { key: { startsWith: "documents-original:" } },
|
||||
})
|
||||
).map((row) => row.key);
|
||||
expect(keys).toEqual([`documents-original:${delegate.id}`]);
|
||||
});
|
||||
|
||||
it("refuses the detail read once the grant ends", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await enableDocuments(owner.id);
|
||||
const doc = await seedDocument(owner.id, "owner-letter");
|
||||
|
||||
const { GET } = await import("@/app/api/documents/inbound/[id]/route");
|
||||
const { grant } = await switchInto(owner.id, delegate.id);
|
||||
expect(
|
||||
(
|
||||
await call(GET as Handler, `/api/documents/inbound/${doc.id}`, {
|
||||
id: doc.id,
|
||||
})
|
||||
).status,
|
||||
).toBe(200);
|
||||
|
||||
await revokeGrant(grant.id);
|
||||
await expectDenied(
|
||||
await call(GET as Handler, `/api/documents/inbound/${doc.id}`, {
|
||||
id: doc.id,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/documents/inbound/usage", () => {
|
||||
it("reports the record's vault, not the delegate's", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
await enableDocuments(owner.id);
|
||||
await seedDocument(owner.id, "owner-letter");
|
||||
await seedDocument(delegate.id, "delegate-a");
|
||||
await seedDocument(delegate.id, "delegate-b");
|
||||
|
||||
await switchInto(owner.id, delegate.id);
|
||||
const { GET } = await import("@/app/api/documents/inbound/usage/route");
|
||||
const response = await call(GET as Handler, "/api/documents/inbound/usage");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const payload = (await body(response)).data as {
|
||||
contentIndex: { totalCount: number };
|
||||
};
|
||||
// One, not two: the counts follow the record. Both accounts hold rows, so
|
||||
// a zero here would be a different failure and this is not it.
|
||||
expect(payload.contentIndex.totalCount).toBe(1);
|
||||
});
|
||||
|
||||
it("is unchanged for a caller who has not switched", async () => {
|
||||
const plain = await makeUser("plain");
|
||||
await enableDocuments(plain.id);
|
||||
await seedDocument(plain.id, "a");
|
||||
await seedDocument(plain.id, "b");
|
||||
await signIn(plain.id);
|
||||
|
||||
const { GET } = await import("@/app/api/documents/inbound/usage/route");
|
||||
const response = await call(GET as Handler, "/api/documents/inbound/usage");
|
||||
expect(response.status).toBe(200);
|
||||
expect(
|
||||
((await body(response)).data as { contentIndex: { totalCount: number } })
|
||||
.contentIndex.totalCount,
|
||||
).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -451,22 +451,27 @@ writeContract("POST /api/biomarkers", {
|
||||
* block with a `writeContract` — in the same diff as the caregiver-reachable
|
||||
* surface that made it worth doing.
|
||||
*/
|
||||
function refusedWriteContract(
|
||||
function refusedWriteContract<C = undefined>(
|
||||
name: string,
|
||||
c: {
|
||||
call: () => Promise<Response>;
|
||||
/** Seed whatever the write needs, owned by the RECORD. Mirrors `writeContract`. */
|
||||
prepare?: (recordOwnerId: string) => Promise<C>;
|
||||
call: (ctx: C) => Promise<Response>;
|
||||
ok: number;
|
||||
count: (userId: string) => Promise<number>;
|
||||
},
|
||||
) {
|
||||
const prepare = c.prepare ?? (async () => undefined as C);
|
||||
|
||||
describe(name, () => {
|
||||
for (const access of ["READ", "WRITE"] as const) {
|
||||
it(`refuses a delegate holding a ${access} grant, and writes nothing`, async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const delegate = await makeUser("delegate");
|
||||
const ctx = await prepare(owner.id);
|
||||
|
||||
await switchInto(owner.id, delegate.id, access);
|
||||
const response = await c.call();
|
||||
const response = await c.call(ctx);
|
||||
|
||||
// The undeclared-mode refusal, not the no-grant one: the route names
|
||||
// no sharing mode at all, so the carrier is refused before any grant
|
||||
@@ -485,9 +490,10 @@ function refusedWriteContract(
|
||||
|
||||
it("still lets the owner write it themselves", async () => {
|
||||
const owner = await makeUser("owner");
|
||||
const ctx = await prepare(owner.id);
|
||||
await signIn(owner.id);
|
||||
|
||||
const response = await c.call();
|
||||
const response = await c.call(ctx);
|
||||
|
||||
expect(response.status).toBe(c.ok);
|
||||
expect(await c.count(owner.id)).toBe(1);
|
||||
@@ -574,10 +580,26 @@ describe("POST /api/illness/episodes — the module gate", () => {
|
||||
});
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 7 — custom-metric entries */
|
||||
/* 7 — custom-metric entries, the third that is NOT delegable */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
writeContract<string>("POST /api/custom-metrics/[id]/entries", {
|
||||
/**
|
||||
* The third withdrawal, one release after the other two, and the delay is what
|
||||
* makes it worth a paragraph: the rule that removed allergies and family
|
||||
* history fitted this route exactly and was applied to two of the three.
|
||||
*
|
||||
* The only surface that posts a tracked value is the entry form on
|
||||
* `/custom-metrics/{id}`, which is not a shared-record destination — the shell
|
||||
* renders "not part of what was shared" there before the form mounts, and
|
||||
* `custom-metric-list.tsx` said so in its own comment while returning null. So
|
||||
* the permission shipped named on the consent screen, in six languages, with
|
||||
* nothing behind it.
|
||||
*
|
||||
* The GET arm stays delegable and the read suite pins it. Re-admitting means
|
||||
* the same three moves the block above names, in the same diff as the surface
|
||||
* that makes it reachable.
|
||||
*/
|
||||
refusedWriteContract<string>("POST /api/custom-metrics/[id]/entries", {
|
||||
prepare: async (recordOwnerId) => {
|
||||
const metric = await getPrismaClient().customMetric.create({
|
||||
data: { userId: recordOwnerId, name: "Water", unit: "ml" },
|
||||
@@ -595,7 +617,6 @@ writeContract<string>("POST /api/custom-metrics/[id]/entries", {
|
||||
);
|
||||
},
|
||||
ok: 201,
|
||||
auditAction: "customMetricEntry.create",
|
||||
count: (userId) =>
|
||||
getPrismaClient().customMetricEntry.count({ where: { userId } }),
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user