fix: say what a refused request actually was

Every delegable route files a breadcrumb when a request does not parse, and it
files it through auditLog on purpose: that helper is what stamps the actor, and
without the stamp a delegate's malformed request would read as the owner's own.
The rows are meant to be attributable. None of them had a sentence, so all of
them rendered in the owner's activity view as "made a change in your record" —
including one a view-only delegate can trigger by sending a query the route
cannot parse. Nothing had changed and nothing could have.

Thirty-three actions now have honest lines. A refusal says it was an attempt
and says it was refused; it never borrows the language of something that
happened. Two of them are filed by library modules the routes import — a
background import's completion row and a medication lifecycle reconciliation —
and a walk over route files alone could not see either.

The inventory guard was asking a different question from the feed. It kept only
awaited audit calls while the feed shows fire-and-forget rows identically, and
it read route files while the feed shows whatever the request reached. Both
criteria now match the feed's, the three rows the feed structurally cannot show
are excluded by name with the reason written down, and the exclusion list is
pinned at three. A leg asserts the shape of the sentence for every refusal, so
a future arm cannot map an attempt onto an ordinary verb and pass on coverage.

The whole round trip is covered against real Postgres: a view-only delegate
sends a malformed query, the row lands attributed to them under the owner, and
the sentence the owner reads is an attempt.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 00:40:40 +02:00
1 parent ef242f33bb
commit f91f701e13
9 files changed
+574 -65

No files matched your search

+29 -1
View File
@@ -9708,7 +9708,35 @@
"cyclePeriodBoundary": "{name} hat den Beginn oder das Ende einer Periode geändert",
"cycleSymptomCreate": "{name} hat ein Zyklussymptom angelegt",
"nutrientWaterWrite": "{name} hat die Trinkmenge erfasst",
"healthRecordExport": "{name} hat die Akte exportiert"
"healthRecordExport": "{name} hat die Akte exportiert",
"rejectedMeasurementQuery": "{name} hat eine Messwert-Suche gesendet, die diese Akte nicht lesen konnte",
"rejectedDoseQuery": "{name} hat eine Einnahme-Suche gesendet, die diese Akte nicht lesen konnte",
"rejectedMoodQuery": "{name} hat eine Stimmungs-Suche gesendet, die diese Akte nicht lesen konnte",
"rejectedMeasurementCreate": "{name} wollte einen Messwert eintragen und wurde abgewiesen",
"rejectedMeasurementBatch": "{name} wollte mehrere Messwerte eintragen und wurde abgewiesen",
"rejectedMeasurementUpdate": "{name} wollte einen Messwert ändern und wurde abgewiesen",
"rejectedLabCreate": "{name} wollte einen Laborwert eintragen und wurde abgewiesen",
"rejectedLabUpdate": "{name} wollte einen Laborwert ändern und wurde abgewiesen",
"rejectedBiomarkerCreate": "{name} wollte einen Biomarker anlegen und wurde abgewiesen",
"rejectedBiomarkerUpdate": "{name} wollte einen Biomarker ändern und wurde abgewiesen",
"rejectedReminderCreate": "{name} wollte eine Messerinnerung anlegen und wurde abgewiesen",
"rejectedReminderUpdate": "{name} wollte eine Messerinnerung ändern und wurde abgewiesen",
"rejectedMoodCreate": "{name} wollte einen Stimmungseintrag anlegen und wurde abgewiesen",
"rejectedMoodUpdate": "{name} wollte einen Stimmungseintrag ändern und wurde abgewiesen",
"rejectedDoseCreate": "{name} wollte eine Einnahme eintragen und wurde abgewiesen",
"rejectedDoseUpdate": "{name} wollte eine Einnahme ändern und wurde abgewiesen",
"rejectedDoseImport": "{name} wollte Einnahmen importieren und wurde abgewiesen",
"rejectedTrackedValue": "{name} wollte einen eigenen Messwert eintragen und wurde abgewiesen",
"deniedDoseImport": "Ein von {name} gestarteter Einnahme-Import konnte nicht eingereiht werden",
"anamnesisFactCreate": "{name} hat einen Fakt zur Vorgeschichte eingetragen",
"customMetricCreate": "{name} hat eine eigene Messgröße angelegt",
"customMetricUpdate": "{name} hat eine eigene Messgröße geändert",
"customMetricDelete": "{name} hat eine eigene Messgröße gelöscht",
"documentStore": "{name} hat ein Dokument hinzugefügt",
"documentUpdate": "{name} hat ein Dokument geändert",
"documentDiscard": "{name} hat ein Dokument verworfen",
"medicationIntakeImportFinished": "Der von {name} gestartete Einnahme-Import ist fertig",
"medicationOneShotReconciled": "Ein einmaliges Medikament wurde nach einer Einnahme von {name} geöffnet oder geschlossen"
},
"section": {
"measurements": {
+29 -1
View File
@@ -9708,7 +9708,35 @@
"cyclePeriodBoundary": "{name} changed the start or end of a period",
"cycleSymptomCreate": "{name} added a cycle symptom",
"nutrientWaterWrite": "{name} recorded water intake",
"healthRecordExport": "{name} exported the record"
"healthRecordExport": "{name} exported the record",
"rejectedMeasurementQuery": "{name} sent a reading search this record could not read",
"rejectedDoseQuery": "{name} sent a dose search this record could not read",
"rejectedMoodQuery": "{name} sent a mood search this record could not read",
"rejectedMeasurementCreate": "{name} tried to add a reading and was refused",
"rejectedMeasurementBatch": "{name} tried to add several readings and was refused",
"rejectedMeasurementUpdate": "{name} tried to change a reading and was refused",
"rejectedLabCreate": "{name} tried to add a lab result and was refused",
"rejectedLabUpdate": "{name} tried to change a lab result and was refused",
"rejectedBiomarkerCreate": "{name} tried to add a biomarker and was refused",
"rejectedBiomarkerUpdate": "{name} tried to change a biomarker and was refused",
"rejectedReminderCreate": "{name} tried to add a measurement reminder and was refused",
"rejectedReminderUpdate": "{name} tried to change a measurement reminder and was refused",
"rejectedMoodCreate": "{name} tried to add a mood entry and was refused",
"rejectedMoodUpdate": "{name} tried to change a mood entry and was refused",
"rejectedDoseCreate": "{name} tried to record a dose and was refused",
"rejectedDoseUpdate": "{name} tried to change a dose and was refused",
"rejectedDoseImport": "{name} tried to import doses and was refused",
"rejectedTrackedValue": "{name} tried to log a tracked value and was refused",
"deniedDoseImport": "A dose import {name} started could not be queued",
"anamnesisFactCreate": "{name} added a health background fact",
"customMetricCreate": "{name} added a tracked value type",
"customMetricUpdate": "{name} changed a tracked value type",
"customMetricDelete": "{name} removed a tracked value type",
"documentStore": "{name} added a document",
"documentUpdate": "{name} changed a document",
"documentDiscard": "{name} discarded a document",
"medicationIntakeImportFinished": "The dose import {name} started has finished",
"medicationOneShotReconciled": "A one-off medication was opened or closed after a dose {name} recorded"
},
"section": {
"measurements": {
+29 -1
View File
@@ -9708,7 +9708,35 @@
"cyclePeriodBoundary": "{name} cambió el inicio o el final de una regla",
"cycleSymptomCreate": "{name} añadió un síntoma del ciclo",
"nutrientWaterWrite": "{name} registró la ingesta de agua",
"healthRecordExport": "{name} exportó el historial"
"healthRecordExport": "{name} exportó el historial",
"rejectedMeasurementQuery": "{name} envió una búsqueda de mediciones que este historial no pudo leer",
"rejectedDoseQuery": "{name} envió una búsqueda de dosis que este historial no pudo leer",
"rejectedMoodQuery": "{name} envió una búsqueda de ánimo que este historial no pudo leer",
"rejectedMeasurementCreate": "{name} intentó añadir una medición y fue rechazado",
"rejectedMeasurementBatch": "{name} intentó añadir varias mediciones y fue rechazado",
"rejectedMeasurementUpdate": "{name} intentó cambiar una medición y fue rechazado",
"rejectedLabCreate": "{name} intentó añadir un resultado de laboratorio y fue rechazado",
"rejectedLabUpdate": "{name} intentó cambiar un resultado de laboratorio y fue rechazado",
"rejectedBiomarkerCreate": "{name} intentó añadir un biomarcador y fue rechazado",
"rejectedBiomarkerUpdate": "{name} intentó cambiar un biomarcador y fue rechazado",
"rejectedReminderCreate": "{name} intentó añadir un recordatorio de medición y fue rechazado",
"rejectedReminderUpdate": "{name} intentó cambiar un recordatorio de medición y fue rechazado",
"rejectedMoodCreate": "{name} intentó añadir una entrada de ánimo y fue rechazado",
"rejectedMoodUpdate": "{name} intentó cambiar una entrada de ánimo y fue rechazado",
"rejectedDoseCreate": "{name} intentó registrar una dosis y fue rechazado",
"rejectedDoseUpdate": "{name} intentó cambiar una dosis y fue rechazado",
"rejectedDoseImport": "{name} intentó importar dosis y fue rechazado",
"rejectedTrackedValue": "{name} intentó registrar un valor propio y fue rechazado",
"deniedDoseImport": "Una importación de dosis iniciada por {name} no se pudo poner en cola",
"anamnesisFactCreate": "{name} añadió un dato de antecedentes de salud",
"customMetricCreate": "{name} añadió un tipo de valor propio",
"customMetricUpdate": "{name} cambió un tipo de valor propio",
"customMetricDelete": "{name} eliminó un tipo de valor propio",
"documentStore": "{name} añadió un documento",
"documentUpdate": "{name} cambió un documento",
"documentDiscard": "{name} descartó un documento",
"medicationIntakeImportFinished": "La importación de dosis que inició {name} ha terminado",
"medicationOneShotReconciled": "Un medicamento de una sola toma se abrió o se cerró tras una dosis que registró {name}"
},
"section": {
"measurements": {
+29 -1
View File
@@ -9708,7 +9708,35 @@
"cyclePeriodBoundary": "{name} a modifié le début ou la fin de règles",
"cycleSymptomCreate": "{name} a ajouté un symptôme de cycle",
"nutrientWaterWrite": "{name} a enregistré la consommation d'eau",
"healthRecordExport": "{name} a exporté le dossier"
"healthRecordExport": "{name} a exporté le dossier",
"rejectedMeasurementQuery": "{name} a envoyé une recherche de mesures que ce dossier n'a pas pu lire",
"rejectedDoseQuery": "{name} a envoyé une recherche de prises que ce dossier n'a pas pu lire",
"rejectedMoodQuery": "{name} a envoyé une recherche d'humeur que ce dossier n'a pas pu lire",
"rejectedMeasurementCreate": "{name} a tenté d'ajouter une mesure et a été refusé",
"rejectedMeasurementBatch": "{name} a tenté d'ajouter plusieurs mesures et a été refusé",
"rejectedMeasurementUpdate": "{name} a tenté de modifier une mesure et a été refusé",
"rejectedLabCreate": "{name} a tenté d'ajouter un résultat de laboratoire et a été refusé",
"rejectedLabUpdate": "{name} a tenté de modifier un résultat de laboratoire et a été refusé",
"rejectedBiomarkerCreate": "{name} a tenté d'ajouter un biomarqueur et a été refusé",
"rejectedBiomarkerUpdate": "{name} a tenté de modifier un biomarqueur et a été refusé",
"rejectedReminderCreate": "{name} a tenté d'ajouter un rappel de mesure et a été refusé",
"rejectedReminderUpdate": "{name} a tenté de modifier un rappel de mesure et a été refusé",
"rejectedMoodCreate": "{name} a tenté d'ajouter une entrée d'humeur et a été refusé",
"rejectedMoodUpdate": "{name} a tenté de modifier une entrée d'humeur et a été refusé",
"rejectedDoseCreate": "{name} a tenté d'enregistrer une prise et a été refusé",
"rejectedDoseUpdate": "{name} a tenté de modifier une prise et a été refusé",
"rejectedDoseImport": "{name} a tenté d'importer des prises et a été refusé",
"rejectedTrackedValue": "{name} a tenté d'enregistrer une valeur personnalisée et a été refusé",
"deniedDoseImport": "Un import de prises lancé par {name} n'a pas pu être mis en file",
"anamnesisFactCreate": "{name} a ajouté un élément d'antécédents de santé",
"customMetricCreate": "{name} a ajouté un type de valeur personnalisée",
"customMetricUpdate": "{name} a modifié un type de valeur personnalisée",
"customMetricDelete": "{name} a supprimé un type de valeur personnalisée",
"documentStore": "{name} a ajouté un document",
"documentUpdate": "{name} a modifié un document",
"documentDiscard": "{name} a écarté un document",
"medicationIntakeImportFinished": "L'import de prises lancé par {name} est terminé",
"medicationOneShotReconciled": "Un médicament à prise unique a été rouvert ou clos après une prise enregistrée par {name}"
},
"section": {
"measurements": {
+29 -1
View File
@@ -9708,7 +9708,35 @@
"cyclePeriodBoundary": "{name} ha modificato l'inizio o la fine di una mestruazione",
"cycleSymptomCreate": "{name} ha aggiunto un sintomo del ciclo",
"nutrientWaterWrite": "{name} ha registrato l'assunzione di acqua",
"healthRecordExport": "{name} ha esportato la cartella"
"healthRecordExport": "{name} ha esportato la cartella",
"rejectedMeasurementQuery": "{name} ha inviato una ricerca di misurazioni che questa cartella non ha potuto leggere",
"rejectedDoseQuery": "{name} ha inviato una ricerca di dosi che questa cartella non ha potuto leggere",
"rejectedMoodQuery": "{name} ha inviato una ricerca dell'umore che questa cartella non ha potuto leggere",
"rejectedMeasurementCreate": "{name} ha provato ad aggiungere una misurazione ed è stato rifiutato",
"rejectedMeasurementBatch": "{name} ha provato ad aggiungere più misurazioni ed è stato rifiutato",
"rejectedMeasurementUpdate": "{name} ha provato a modificare una misurazione ed è stato rifiutato",
"rejectedLabCreate": "{name} ha provato ad aggiungere un risultato di laboratorio ed è stato rifiutato",
"rejectedLabUpdate": "{name} ha provato a modificare un risultato di laboratorio ed è stato rifiutato",
"rejectedBiomarkerCreate": "{name} ha provato ad aggiungere un biomarcatore ed è stato rifiutato",
"rejectedBiomarkerUpdate": "{name} ha provato a modificare un biomarcatore ed è stato rifiutato",
"rejectedReminderCreate": "{name} ha provato ad aggiungere un promemoria di misurazione ed è stato rifiutato",
"rejectedReminderUpdate": "{name} ha provato a modificare un promemoria di misurazione ed è stato rifiutato",
"rejectedMoodCreate": "{name} ha provato ad aggiungere una voce dell'umore ed è stato rifiutato",
"rejectedMoodUpdate": "{name} ha provato a modificare una voce dell'umore ed è stato rifiutato",
"rejectedDoseCreate": "{name} ha provato a registrare una dose ed è stato rifiutato",
"rejectedDoseUpdate": "{name} ha provato a modificare una dose ed è stato rifiutato",
"rejectedDoseImport": "{name} ha provato a importare delle dosi ed è stato rifiutato",
"rejectedTrackedValue": "{name} ha provato a registrare un valore personalizzato ed è stato rifiutato",
"deniedDoseImport": "Un'importazione di dosi avviata da {name} non è stata messa in coda",
"anamnesisFactCreate": "{name} ha aggiunto un dato di anamnesi",
"customMetricCreate": "{name} ha aggiunto un tipo di valore personalizzato",
"customMetricUpdate": "{name} ha modificato un tipo di valore personalizzato",
"customMetricDelete": "{name} ha eliminato un tipo di valore personalizzato",
"documentStore": "{name} ha aggiunto un documento",
"documentUpdate": "{name} ha modificato un documento",
"documentDiscard": "{name} ha scartato un documento",
"medicationIntakeImportFinished": "L'importazione delle dosi avviata da {name} è terminata",
"medicationOneShotReconciled": "Un farmaco monodose è stato riaperto o chiuso dopo una dose registrata da {name}"
},
"section": {
"measurements": {
+29 -1
View File
@@ -9708,7 +9708,35 @@
"cyclePeriodBoundary": "{name} zmienił początek lub koniec miesiączki",
"cycleSymptomCreate": "{name} dodał objaw cyklu",
"nutrientWaterWrite": "{name} zapisał ilość wypitej wody",
"healthRecordExport": "{name} wyeksportował kartę"
"healthRecordExport": "{name} wyeksportował kartę",
"rejectedMeasurementQuery": "{name} wysłał zapytanie o pomiary, którego ta karta nie mogła odczytać",
"rejectedDoseQuery": "{name} wysłał zapytanie o dawki, którego ta karta nie mogła odczytać",
"rejectedMoodQuery": "{name} wysłał zapytanie o nastrój, którego ta karta nie mogła odczytać",
"rejectedMeasurementCreate": "{name} próbował dodać pomiar i został odrzucony",
"rejectedMeasurementBatch": "{name} próbował dodać kilka pomiarów i został odrzucony",
"rejectedMeasurementUpdate": "{name} próbował zmienić pomiar i został odrzucony",
"rejectedLabCreate": "{name} próbował dodać wynik badania i został odrzucony",
"rejectedLabUpdate": "{name} próbował zmienić wynik badania i został odrzucony",
"rejectedBiomarkerCreate": "{name} próbował dodać biomarker i został odrzucony",
"rejectedBiomarkerUpdate": "{name} próbował zmienić biomarker i został odrzucony",
"rejectedReminderCreate": "{name} próbował dodać przypomnienie o pomiarze i został odrzucony",
"rejectedReminderUpdate": "{name} próbował zmienić przypomnienie o pomiarze i został odrzucony",
"rejectedMoodCreate": "{name} próbował dodać wpis nastroju i został odrzucony",
"rejectedMoodUpdate": "{name} próbował zmienić wpis nastroju i został odrzucony",
"rejectedDoseCreate": "{name} próbował zapisać przyjęcie dawki i został odrzucony",
"rejectedDoseUpdate": "{name} próbował zmienić przyjęcie dawki i został odrzucony",
"rejectedDoseImport": "{name} próbował zaimportować przyjęcia dawek i został odrzucony",
"rejectedTrackedValue": "{name} próbował zapisać własny pomiar i został odrzucony",
"deniedDoseImport": "Import przyjęć rozpoczęty przez {name} nie mógł zostać zakolejkowany",
"anamnesisFactCreate": "{name} dodał fakt z wywiadu zdrowotnego",
"customMetricCreate": "{name} dodał własny typ pomiaru",
"customMetricUpdate": "{name} zmienił własny typ pomiaru",
"customMetricDelete": "{name} usunął własny typ pomiaru",
"documentStore": "{name} dodał dokument",
"documentUpdate": "{name} zmienił dokument",
"documentDiscard": "{name} odrzucił dokument",
"medicationIntakeImportFinished": "Import przyjęć rozpoczęty przez {name} zakończył się",
"medicationOneShotReconciled": "Lek jednorazowy został otwarty lub zamknięty po dawce zapisanej przez {name}"
},
"section": {
"measurements": {
+221 -59
View File
@@ -1,5 +1,5 @@
import { readFileSync, readdirSync } from "node:fs";
import { join, relative } from "node:path";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import ts from "typescript";
import { describe, expect, it } from "vitest";
@@ -53,7 +53,8 @@ import { recordActivityVerbLine } from "@/lib/record-activity/activity-verb";
* both halves against synthetic modules, including the one shape that silenced
* the sibling guard: a resolver call prettier broke across lines.
*/
const API = join(process.cwd(), "src/app/api");
const SRC = join(process.cwd(), "src");
const API = join(SRC, "app/api");
const MESSAGES = join(process.cwd(), "messages");
const ACTIVITY_ROUTE = join(
process.cwd(),
@@ -64,10 +65,31 @@ const GRANTS_GIVEN_CARD = join(
"src/components/settings/access/grants-given-card.tsx",
);
/** The declaration a MANAGE-level route makes. */
/** The declaration a record-delegated route makes, at any level. */
const RECORD_RESOLVER = "requireRecordAuth";
/** The level this file is the inventory of. */
const MANAGE = "manage";
/**
* Rows the feed cannot show, and the reason each one cannot.
*
* `src/lib/api-handler.ts` is a direct import of every route, so the walk
* below reaches the three actions the auth kit files itself. None of them can
* appear in a record owner's activity view, and the reason is the same for all
* three: they are filed under `auth.user.id` — the CALLER — at a point where
* no record has been resolved. The feed selects rows where `userId` is the
* owner and `actorUserId` is somebody else, so a caller-filed row is excluded
* twice over.
*
* Frozen at three. A fourth entry is a claim about a row nobody looked at, and
* the leg below fails rather than letting one join quietly.
*/
const NOT_IN_THE_FEED = [
// The bearer path never resolves a record at all.
"auth.bearer.failure",
// Step-up is cookie-only and filed under the account being elevated.
"auth.stepup.elevation.rejected",
// `auditRefusal` files under the refused caller, before a record exists.
"sharing.access.denied",
] as const;
/** What an action with no sentence of its own renders as. */
const FALLBACK_KEY = "recordSharing.activity.acted";
/** The six bundles the product ships. */
@@ -86,15 +108,24 @@ function routeFiles(dir: string): string[] {
}
interface ModuleFacts {
/** Does the module pass `"manage"` to the record resolver anywhere. */
managesRecord: boolean;
/** Does the module reach the record resolver at ANY level. */
delegatesRecord: boolean;
/**
* The action names of the module's AWAITED `auditLog("…")` calls.
* Every action the module hands to `auditLog("…")`, awaited or not.
*
* Awaited on purpose: a fire-and-forget breadcrumb on a validation failure
* is not something to narrate to the owner.
* v1.37.0 — this used to keep only the AWAITED calls, on the reasoning that
* a fire-and-forget breadcrumb is not worth narrating. The feed disagrees,
* and the feed is what the owner reads: `/api/account/activity` selects on
* `actorUserId IS NOT NULL` with no action filter and no await in sight, so
* a `void auditLog(…)` breadcrumb lands in the view exactly like an awaited
* row. Ten `*.validation-failed` actions were invisible to this guard for
* that reason and rendered to owners as "made a change in your record" —
* including one a READ delegate can trigger. A guard whose inclusion rule
* differs from its subject's proves a property about the wrong set.
*/
awaitedAuditActions: string[];
auditActions: string[];
/** First-party modules this one imports, resolved to files. */
imports: string[];
}
/**
@@ -119,8 +150,15 @@ export function moduleFacts(source: string, fileName: string): ModuleFacts {
const aliases = new Map<string, string>();
const namespaces = new Set<string>();
const importPaths: string[] = [];
parsed.forEachChild((node) => {
if (!ts.isImportDeclaration(node)) return;
if (ts.isStringLiteral(node.moduleSpecifier)) {
const resolved = resolveFirstParty(node.moduleSpecifier.text, fileName);
if (resolved !== null && !resolved.includes("__tests__")) {
importPaths.push(resolved);
}
}
const bindings = node.importClause?.namedBindings;
if (!bindings) return;
if (ts.isNamespaceImport(bindings)) {
@@ -151,28 +189,69 @@ export function moduleFacts(source: string, fileName: string): ModuleFacts {
return first && ts.isStringLiteralLike(first) ? first.text : null;
};
let managesRecord = false;
const awaitedAuditActions: string[] = [];
let delegatesRecord = false;
const auditActions: string[] = [];
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
const name = calleeName(node.expression);
if (name === RECORD_RESOLVER && firstStringArg(node) === MANAGE) {
managesRecord = 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);
if (name === RECORD_RESOLVER) delegatesRecord = true;
if (name === "auditLog") {
const action = firstStringArg(node);
if (action !== null) auditActions.push(action);
}
}
node.forEachChild(visit);
};
parsed.forEachChild(visit);
return { managesRecord, awaitedAuditActions };
return { delegatesRecord, auditActions, imports: importPaths };
}
/**
* A module specifier resolved to a first-party file, or null.
*
* `@/…` and relative paths only. A package import is somebody else's tree and
* cannot be walked usefully.
*/
export function resolveFirstParty(
specifier: string,
fromFile: string,
): string | null {
let base: string;
if (specifier.startsWith("@/")) base = join(SRC, specifier.slice(2));
else if (specifier.startsWith(".")) base = join(dirname(fromFile), specifier);
else return null;
for (const candidate of [
`${base}.ts`,
`${base}.tsx`,
join(base, "index.ts"),
]) {
if (existsSync(candidate)) return candidate;
}
return null;
}
/**
* Every action one route module can file, including through what it imports.
*
* One level deep, deliberately. The transitive closure pulls in the whole auth
* and grant machinery through `api-handler`, which reports actions no request
* to this route can file; one level is where a route's own collaborators live
* — the import-admission module and the medication lifecycle module are both
* direct imports of the routes that use them, and both were invisible to a
* walk that read only `route.ts`.
*/
function actionsReachableFrom(
file: string,
read: (path: string) => ModuleFacts,
): string[] {
const own = read(file);
const out = [...own.auditActions];
for (const imported of own.imports) {
out.push(...read(imported).auditActions);
}
return out;
}
/** The message key one action resolves to, or the fallback. */
@@ -232,8 +311,8 @@ describe("the matcher sees what it looks for", () => {
// own line and a substring test goes quiet with nothing else changed.
expect(REFORMATTED.includes('requireRecordAuth("manage"')).toBe(false);
const facts = moduleFacts(REFORMATTED, "r.ts");
expect(facts.managesRecord).toBe(true);
expect(facts.awaitedAuditActions).toEqual(["thing.delete"]);
expect(facts.delegatesRecord).toBe(true);
expect(facts.auditActions).toEqual(["thing.delete"]);
});
it("matches an aliased and a namespace-qualified resolver call", () => {
@@ -247,10 +326,9 @@ describe("the matcher sees what it looks for", () => {
return apiSuccess({ id: user.id });
});
`;
expect(moduleFacts(ALIASED, "a.ts")).toEqual({
managesRecord: true,
awaitedAuditActions: ["thing.aliased"],
});
const facts = moduleFacts(ALIASED, "a.ts");
expect(facts.delegatesRecord).toBe(true);
expect(facts.auditActions).toEqual(["thing.aliased"]);
const NAMESPACED = `
import * as auth from "@/lib/api-handler";
@@ -260,27 +338,28 @@ describe("the matcher sees what it looks for", () => {
return apiSuccess({ id: user.id });
});
`;
expect(moduleFacts(NAMESPACED, "n.ts").managesRecord).toBe(true);
expect(moduleFacts(NAMESPACED, "n.ts").delegatesRecord).toBe(true);
});
it("does not enrol a write route that only talks about managing", () => {
const WRITE_ONLY = `
/** Delegable for writes. It must never take requireRecordAuth("manage"). */
import { requireRecordAuth } from "@/lib/api-handler";
const LEVEL = "manage";
it("does not enrol a route that only talks about delegating", () => {
const ACTOR_ONLY = `
/** An actor surface. It must never take requireRecordAuth("manage"). */
import { requireActorAuth } from "@/lib/api-handler";
export const POST = apiHandler(async () => {
const { user } = await requireRecordAuth("write", "labs");
return apiSuccess({ id: user.id, level: LEVEL });
const { user } = await requireActorAuth();
return apiSuccess({ id: user.id });
});
`;
// The raw text carries the exact substring a naive membership test uses.
expect(WRITE_ONLY.includes('requireRecordAuth("manage")')).toBe(true);
expect(moduleFacts(WRITE_ONLY, "wo.ts").managesRecord).toBe(false);
expect(ACTOR_ONLY.includes('requireRecordAuth("manage")')).toBe(true);
expect(moduleFacts(ACTOR_ONLY, "ao.ts").delegatesRecord).toBe(false);
});
it("reads only the awaited audit calls", () => {
it("reads the fire-and-forget audit calls too", () => {
// The defect this file shipped with. `void auditLog(…)` was invisible
// here and perfectly visible in the owner's feed, so ten refusal
// breadcrumbs rendered as "made a change in your record".
const MIXED = `
import { requireRecordAuth } from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
@@ -292,11 +371,28 @@ describe("the matcher sees what it looks for", () => {
return apiSuccess({ id: user.id });
});
`;
expect(moduleFacts(MIXED, "m.ts").awaitedAuditActions).toEqual([
expect(moduleFacts(MIXED, "m.ts").auditActions).toEqual([
"thing.breadcrumb",
"thing.delete",
]);
});
it("resolves a first-party import and skips a package one", () => {
// The other half of the same defect: two of the twelve unmapped actions
// were filed by library modules the routes import, which a walk over
// `route.ts` alone could not see.
const admission = resolveFirstParty(
"@/lib/medications/intake-import-admission",
join(API, "medications/route.ts"),
);
expect(admission).not.toBeNull();
expect(readFileSync(admission!, "utf8")).toContain(
"medication.intake.import.kickoff.denied",
);
expect(resolveFirstParty("next/server", "x.ts")).toBeNull();
expect(resolveFirstParty("@/lib/nothing-here", "x.ts")).toBeNull();
});
it("still reports the fallback for an action nobody mapped", () => {
// The positive control for the recording translator below. Without it, a
// `verbKeyFor` that always returned the same string would report every
@@ -305,30 +401,62 @@ describe("the matcher sees what it looks for", () => {
});
});
describe("every MANAGE action has a sentence of its own", () => {
describe("every delegated action has a sentence of its own", () => {
const routes = routeFiles(API);
const manageModules = routes
.map((file) => ({ file, ...moduleFacts(readFileSync(file, "utf8"), file) }))
.filter((m) => m.managesRecord);
const actions = [
...new Set(manageModules.flatMap((m) => m.awaitedAuditActions)),
].sort();
const cache = new Map<string, ModuleFacts>();
const read = (file: string): ModuleFacts => {
let facts = cache.get(file);
if (!facts) {
facts = moduleFacts(readFileSync(file, "utf8"), file);
cache.set(file, facts);
}
return facts;
};
it("matches the MANAGE modules, and not zero of them", () => {
const delegatedModules = routes.filter((file) => read(file).delegatesRecord);
const excluded = new Set<string>(NOT_IN_THE_FEED);
const reachable = [
...new Set(
delegatedModules.flatMap((file) => actionsReachableFrom(file, read)),
),
].sort();
const actions = reachable.filter((action) => !excluded.has(action));
it("matches the delegated modules, and not zero of them", () => {
// A walk that finds nothing agrees with an inventory that covers nothing.
expect(routes.length).toBeGreaterThan(300);
expect(manageModules.length).toBeGreaterThan(0);
expect(delegatedModules.length).toBeGreaterThan(0);
// Named anchors, so a matcher that collapsed to one stray file fails
// rather than clearing the floor. Deleting a reading and purging a dose
// history are the two acts the level exists for and the two an owner most
// needs named.
const found = manageModules.map((m) => relative(process.cwd(), m.file));
// history are the two acts MANAGE exists for; the mood list is the READ
// route whose refusal breadcrumb reaches the feed, and it is here because
// the level is no longer part of the inclusion rule.
const found = delegatedModules.map((file) => relative(process.cwd(), file));
expect(found).toContain("src/app/api/measurements/[id]/route.ts");
expect(found).toContain(
"src/app/api/medications/[id]/intake/purge/route.ts",
);
expect(found).toContain("src/app/api/mood-entries/route.ts");
// And the actions themselves were read, not merely looked for.
expect(actions.length).toBeGreaterThan(40);
expect(actions.length).toBeGreaterThan(60);
});
it("reaches the actions a route files through what it imports", () => {
// Both halves of the fix, pinned against the two that were invisible.
expect(actions).toContain("medication.intake.import.kickoff.denied");
expect(actions).toContain("medication.oneShot.reconciled");
// And the fire-and-forget half, on the READ route that started this.
expect(actions).toContain("mood-entries.list.validation-failed");
});
it("excludes exactly the three rows the feed cannot show", () => {
// The exclusion list is the one place a real action could be hidden, so
// its size is pinned and every member is asserted to have been REACHED —
// an entry naming an action nothing files would be a claim about nothing.
expect(NOT_IN_THE_FEED).toHaveLength(3);
for (const action of NOT_IN_THE_FEED) {
expect(reachable, action).toContain(action);
}
});
it("gives every one of them a specific verb rather than the fallback", () => {
@@ -338,12 +466,46 @@ describe("every MANAGE action has a sentence of its own", () => {
expect(
generic,
"a MANAGE action with no verb line renders to the owner as the generic " +
"'made a change'. Add a case to activity-verb.ts and a sentence to " +
"all six bundles.",
"a delegated action with no verb line renders to the owner as the " +
"generic 'made a change'. Add a case to activity-verb.ts and a " +
"sentence to all six bundles — or, if the row genuinely should not " +
"reach the feed, stop stamping the actor on it.",
).toEqual([]);
});
it("never reports a refused attempt as something that happened", () => {
// The half of the HIGH finding that a coverage count cannot express. Every
// one of these rows is an attempt that changed NOTHING, and the generic
// line told the owner their record had been edited. A future arm that maps
// `…validation-failed` onto an ordinary verb would be the same defect with
// coverage, so the shape of the key is asserted rather than its presence.
const attempts = actions.filter(
(action) =>
action.endsWith(".validation-failed") || action.endsWith(".denied"),
);
expect(attempts.length).toBeGreaterThan(10);
const dishonest = attempts.filter(
(action) => !/\.(rejected|denied)[A-Z]/.test(verbKeyFor(action).key),
);
expect(
dishonest,
"an attempt that was refused must not render as an act that happened",
).toEqual([]);
// And the sentences themselves say so, in the source language. A key named
// `rejected…` whose EN text read "added a reading" would pass the check
// above and be exactly the lie it exists to stop.
const en = bundle("en");
for (const action of attempts) {
const sentence = leaf(en, verbKeyFor(action).key);
expect(typeof sentence, action).toBe("string");
expect(String(sentence), action).toMatch(
/tried to|could not|was refused|search this record/,
);
}
});
it("resolves every verb key in all six bundles", () => {
const bundles = new Map(LOCALES.map((l) => [l, bundle(l)] as const));
const missing: string[] = [];
+105
View File
@@ -256,6 +256,111 @@ export function recordActivityVerbLine(
case "health-record.export":
return t("recordSharing.activityVerb.healthRecordExport", { name });
/* ---------------------------------------------------------------- */
/* Attempts that were REFUSED, and things that happened around them. */
/* */
/* Every delegable route files a `*.validation-failed` breadcrumb */
/* when a request does not parse, and it files it through */
/* `auditLog()` deliberately — that helper is what stamps */
/* `actorUserId`, and without the stamp a delegate's malformed */
/* request would read as the owner's own. So the rows are MEANT to */
/* reach the feed, and until this block existed every one of them */
/* rendered as "made a change in your record". Nothing changed. The */
/* one on a READ route is the sharpest case: a person with */
/* view-only access was reported to the owner as having edited */
/* something. */
/* */
/* The language below never says "changed" or "added". A refusal is */
/* an attempt, and the sentence has to be one an owner can read */
/* without reaching for a phone. */
/* ---------------------------------------------------------------- */
case "measurements.list.validation-failed":
case "measurements.series.validation-failed":
return t("recordSharing.activityVerb.rejectedMeasurementQuery", { name });
case "medications.intake.list.validation-failed":
return t("recordSharing.activityVerb.rejectedDoseQuery", { name });
case "mood-entries.list.validation-failed":
return t("recordSharing.activityVerb.rejectedMoodQuery", { name });
case "measurements.create.validation-failed":
return t("recordSharing.activityVerb.rejectedMeasurementCreate", {
name,
});
case "measurements.create.batch.validation-failed":
return t("recordSharing.activityVerb.rejectedMeasurementBatch", { name });
case "measurements.update.validation-failed":
return t("recordSharing.activityVerb.rejectedMeasurementUpdate", {
name,
});
case "labs.create.validation-failed":
return t("recordSharing.activityVerb.rejectedLabCreate", { name });
case "labs.update.validation-failed":
return t("recordSharing.activityVerb.rejectedLabUpdate", { name });
case "labs.biomarker.create.validation-failed":
return t("recordSharing.activityVerb.rejectedBiomarkerCreate", { name });
case "labs.biomarker.update.validation-failed":
return t("recordSharing.activityVerb.rejectedBiomarkerUpdate", { name });
case "measurement-reminders.create.validation-failed":
return t("recordSharing.activityVerb.rejectedReminderCreate", { name });
case "measurement-reminders.update.validation-failed":
return t("recordSharing.activityVerb.rejectedReminderUpdate", { name });
case "mood-entries.create.validation-failed":
return t("recordSharing.activityVerb.rejectedMoodCreate", { name });
case "mood-entries.update.validation-failed":
return t("recordSharing.activityVerb.rejectedMoodUpdate", { name });
case "medications.intake.create.validation-failed":
return t("recordSharing.activityVerb.rejectedDoseCreate", { name });
// Both spellings of the same refusal, for the same reason the admitted
// pair above carries both: the canonical route and the per-medication
// one audit under different names and have since v1.0.0.
case "medications.intake.update.validation-failed":
case "medications.intake.event.update.validation-failed":
return t("recordSharing.activityVerb.rejectedDoseUpdate", { name });
case "medications.intake.import.validation-failed":
return t("recordSharing.activityVerb.rejectedDoseImport", { name });
case "custom-metric.entry.create.validation-failed":
return t("recordSharing.activityVerb.rejectedTrackedValue", { name });
// Not a validation failure: the request was well-formed and admitted,
// and the queue would not take it. The owner sees an import that never
// ran rather than one that was refused, because those need different
// next steps.
case "medication.intake.import.kickoff.denied":
return t("recordSharing.activityVerb.deniedDoseImport", { name });
/* ---------------------------------------------------------------- */
/* Acts a delegable module performs outside its own route file. */
/* */
/* The two below are filed by library modules the routes import — a */
/* background worker's completion row and a lifecycle reconciliation */
/* — and a guard that walked only `route.ts` could not see either. */
/* Both carry the actor through, so both reach the owner's feed. */
/* ---------------------------------------------------------------- */
case "medication.intake.import":
return t("recordSharing.activityVerb.medicationIntakeImportFinished", {
name,
});
case "medication.oneShot.reconciled":
return t("recordSharing.activityVerb.medicationOneShotReconciled", {
name,
});
case "anamnesis.fact.create":
return t("recordSharing.activityVerb.anamnesisFactCreate", { name });
case "customMetric.create":
return t("recordSharing.activityVerb.customMetricCreate", { name });
case "customMetric.update":
return t("recordSharing.activityVerb.customMetricUpdate", { name });
case "customMetric.delete":
return t("recordSharing.activityVerb.customMetricDelete", { name });
case "documents.inbound.store":
return t("recordSharing.activityVerb.documentStore", { name });
case "documents.inbound.update":
return t("recordSharing.activityVerb.documentUpdate", { name });
case "documents.inbound.discard":
return t("recordSharing.activityVerb.documentDiscard", { name });
default:
return t("recordSharing.activity.acted", { name });
}
@@ -14,6 +14,9 @@
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { NextRequest, NextResponse } from "next/server";
import { cookieJar, headerJar } from "./mock-next-headers";
@@ -330,3 +333,74 @@ describe("the lifecycle verbs leave their own trail", () => {
expect(trail.every((r) => r.actorUserId === null)).toBe(true);
});
});
/**
* A view-only delegate sends a query the route cannot parse.
*
* This is the sharpest case the activity feed has, and it shipped saying the
* wrong thing. `GET /api/mood-entries` resolves `requireRecordAuth("read",
* "mind")` and files `mood-entries.list.validation-failed` through `auditLog`
* on a malformed query — deliberately through the helper, because that is what
* stamps `actorUserId` and the row is meant to be attributable. The feed
* selects every actor-stamped row with no action allowlist, and the verb map
* had no arm for any `*.validation-failed` action, so the owner was told that
* a person holding VIEW-ONLY access had made a change in their record.
*
* Nothing had changed. Nothing could have: the request was refused at the
* schema. So the whole round trip is asserted here rather than only the map —
* that the row lands, that it is attributed, and that the sentence the owner
* would read is an attempt rather than an act.
*/
describe("a refused read is reported as a refused read", () => {
it("files an attributable row and renders it as an attempt, not a change", async () => {
const { owner, delegate } = await household();
const { GET } = await import("@/app/api/mood-entries/route");
const response = await GET(
new NextRequest("http://localhost/api/mood-entries?limit=not-a-number", {
method: "GET",
}),
);
expect(response.status).toBe(422);
// The breadcrumb is fire-and-forget, which is the property that made it
// invisible to a guard reading only awaited calls.
await settle();
const rows = await rowsFor("mood-entries.list.validation-failed");
expect(rows, "no breadcrumb row was written").toHaveLength(1);
expect(rows[0]?.userId).toBe(owner.id);
expect(rows[0]?.actorUserId).toBe(delegate.id);
// Which means it reaches the owner's feed: same predicate the route uses.
const feedRows = await getPrismaClient().auditLog.findMany({
where: {
userId: owner.id,
actorUserId: { not: null },
NOT: { actorUserId: owner.id },
},
});
expect(feedRows.map((r) => r.action)).toContain(
"mood-entries.list.validation-failed",
);
// And what it says there. The English bundle is read rather than a key
// asserted, because the defect was the SENTENCE, not the wiring.
const { recordActivityVerbLine } =
await import("@/lib/record-activity/activity-verb");
const en = JSON.parse(
await readFile(join(process.cwd(), "messages/en.json"), "utf8"),
) as Record<string, Record<string, Record<string, string>>>;
const sentence = recordActivityVerbLine(
(key, params) =>
(en.recordSharing.activityVerb[key.split(".").pop()!] ?? key).replace(
"{name}",
String(params?.name ?? ""),
),
rows[0]!.action,
"Alex",
);
expect(sentence).not.toContain("made a change");
expect(sentence).toBe("Alex sent a mood search this record could not read");
});
});