From 427e25d1aeae2ea3d1200d3c0d73de9005f80b4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 09:51:42 +0200 Subject: [PATCH 1/9] Add a notification for a dose somebody else marked The owner of a shared record gets an alert when a delegate marks one of their doses. It is the one exception to the rule that bookkeeping stays in the activity view: whether the tablets were taken is a fact about the owner's own body, and they should not have to go looking for it. Default ON, against the opt-in precedent of PERSONAL_RECORD, because the event only exists once the owner has invited somebody and given them write access. The per-channel row in the matrix is the valve for anyone who finds it noisy, and both positions of that preference are covered. --- .../__tests__/dispatcher.test.ts | 52 +++++++++++++++++++ src/lib/notifications/types.ts | 18 +++++++ 2 files changed, 70 insertions(+) diff --git a/src/lib/notifications/__tests__/dispatcher.test.ts b/src/lib/notifications/__tests__/dispatcher.test.ts index f350ec7b5..b8b543938 100644 --- a/src/lib/notifications/__tests__/dispatcher.test.ts +++ b/src/lib/notifications/__tests__/dispatcher.test.ts @@ -381,6 +381,58 @@ describe("dispatchNotification — per-event default policy (v1.4.25 W16c)", () expect(sendViaNtfyMock).toHaveBeenCalledTimes(1); }); + + // v1.36.x — the delegated-dose event goes the other way from + // PERSONAL_RECORD: ON without a row, because the owner invited the person + // whose action produces it. Both positions of the preference are asserted, + // so "default ON" cannot be true only because the dispatcher ignores the + // event entirely. + it("SHARED_RECORD_INTAKE defaults ON when no preference row exists", async () => { + const channel = makeChannel({ + type: "NTFY", + config: JSON.stringify({ + serverUrl: "https://ntfy.example", + topic: "t", + }), + preferences: [], + }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + channel, + ] as never); + sendViaNtfyMock.mockResolvedValueOnce({ ok: true }); + + await dispatchNotification({ + eventType: "SHARED_RECORD_INTAKE", + userId: "u-1", + title: "Wren marked a dose", + message: "Ramipril: marked as taken by Wren.", + }); + + expect(sendViaNtfyMock).toHaveBeenCalledTimes(1); + }); + + it("SHARED_RECORD_INTAKE is suppressed by an explicit per-channel opt-out", async () => { + const channel = makeChannel({ + type: "NTFY", + config: JSON.stringify({ + serverUrl: "https://ntfy.example", + topic: "t", + }), + preferences: [{ eventType: "SHARED_RECORD_INTAKE", enabled: false }], + }); + vi.mocked(prisma.notificationChannel.findMany).mockResolvedValueOnce([ + channel, + ] as never); + + await dispatchNotification({ + eventType: "SHARED_RECORD_INTAKE", + userId: "u-1", + title: "Wren marked a dose", + message: "Ramipril: marked as taken by Wren.", + }); + + expect(sendViaNtfyMock).not.toHaveBeenCalled(); + }); }); describe("dispatchNotification — MOOD_REMINDER single source of truth (v1.7.0)", () => { diff --git a/src/lib/notifications/types.ts b/src/lib/notifications/types.ts index 102871b2e..25b494515 100644 --- a/src/lib/notifications/types.ts +++ b/src/lib/notifications/types.ts @@ -112,6 +112,16 @@ export const EVENT_TYPES = [ // morning window, so a concerning reading always stays on // `MEASUREMENT_ANOMALY` and this never bypasses Focus. "DAILY_BRIEFING", + // v1.36.x — somebody with a write grant on this record marked one of the + // owner's doses. The only notification a delegated write produces: every + // other shared-record write is bookkeeping and stays in the owner's activity + // view, while "somebody said I took my tablets" is a fact about the owner's + // own body that they should not have to go looking for. Addressed to the + // OWNER — the delegate learns what they did from the response in their own + // session, not from a push (there is no channel that could carry it: the + // dispatcher addresses a userId, and under record substitution that userId + // is the owner by design). Default ON, see EVENT_DEFAULT_ENABLED. + "SHARED_RECORD_INTAKE", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; @@ -179,6 +189,14 @@ export const EVENT_DEFAULT_ENABLED: Record = { // channel that has no explicit enabled row, so a fresh account never // receives a morning push it did not ask for. DAILY_BRIEFING: false, + // v1.36.x — ON by default, deliberately against the PERSONAL_RECORD opt-in + // precedent. The event cannot exist unless the owner invited a second person + // and gave them write access, so there is no backfill class of volume to + // protect against; and an owner who has to discover a setting before they + // learn that somebody else marks their doses was never told at all. The row + // sits in the /notifications matrix from day one, so an owner who finds it + // noisy silences it per channel with one switch. + SHARED_RECORD_INTAKE: true, }; export const CHANNEL_TYPE_LABELS: Record = { From 696b4d7d1ccf018e38c7adbde9fe63aa05d63369 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 09:51:53 +0200 Subject: [PATCH 2/9] Give every notification event type words to render The matrix derives each row's label from the event name, so a new event type gets a row whether or not anybody wrote the strings for it. That is how SECURITY_ALERT has been rendering its own raw key since v1.23: the forward i18n coverage test only sees literal call sites, and this key is assembled from a runtime value. Move the derivation out of the page so a test can compute the keys the page computes, assert both resolve in all six bundles, and name the missing one. Write the sign-in row's strings, and the strings for the delegated-dose row and its push body. --- messages/de.json | 9 ++ messages/en.json | 9 ++ messages/es.json | 9 ++ messages/fr.json | 9 ++ messages/it.json | 9 ++ messages/pl.json | 9 ++ src/app/notifications/page.tsx | 19 +-- .../__tests__/event-labels.test.ts | 114 ++++++++++++++++++ src/lib/notifications/event-labels.ts | 28 +++++ 9 files changed, 202 insertions(+), 13 deletions(-) create mode 100644 src/lib/notifications/__tests__/event-labels.test.ts create mode 100644 src/lib/notifications/event-labels.ts diff --git a/messages/de.json b/messages/de.json index 68ee1bc1b..ae960aaca 100644 --- a/messages/de.json +++ b/messages/de.json @@ -7089,11 +7089,20 @@ "eventMedicationIntakeSyncDesc": "Geräteübergreifende Aktualisierung, wenn eine Dosis protokolliert, eingenommen oder übersprungen wird.", "eventDailyBriefing": "Täglicher Überblick", "eventDailyBriefingDesc": "Eine ruhige Morgenzusammenfassung deines Tages. Standardmäßig aus, bis du sie aktivierst.", + "eventSecurityAlert": "Neue Anmeldung", + "eventSecurityAlertDesc": "Wird gesendet, wenn dein Konto auf einem unbekannten Gerät oder von einem unbekannten Ort verwendet wird.", + "eventSharedRecordIntake": "Jemand mit Zugriff markiert eine Dosis", + "eventSharedRecordIntakeDesc": "Wird nur gesendet, wenn eine andere Person es tut, nie bei deinen eigenen Einträgen.", "security": { "newDeviceTitle": "Neue Anmeldung bei deinem Konto", "newDeviceBody": "Eine neue Anmeldung von {device} ({location}). Falls du das nicht warst, ändere sofort dein Passwort.", "unknownLocation": "unbekannter Ort" }, + "sharedRecordIntake": { + "title": "{name} hat eine Dosis markiert", + "bodyTaken": "{medication}: von {name} als eingenommen markiert.", + "bodySkipped": "{medication}: von {name} als ausgelassen markiert." + }, "loginRequired": "Bitte melde dich an, um Benachrichtigungen zu verwalten.", "someChannelsGloballyDisabled": "Ein oder mehrere Kanäle sind in den Einstellungen deaktiviert und erhalten keine Benachrichtigungen.", "reminderSettingsLink": "Erinnerungseinstellungen", diff --git a/messages/en.json b/messages/en.json index eaa97402e..59d320516 100644 --- a/messages/en.json +++ b/messages/en.json @@ -7089,11 +7089,20 @@ "eventMedicationIntakeSyncDesc": "Cross-device update when a dose is logged, taken or skipped.", "eventDailyBriefing": "Daily briefing", "eventDailyBriefingDesc": "One calm morning summary of your day's read. Off until you turn it on.", + "eventSecurityAlert": "New sign-in", + "eventSecurityAlertDesc": "Sent when your account is used on a device or from a place we have not seen before.", + "eventSharedRecordIntake": "Someone you shared your record with marks a dose", + "eventSharedRecordIntakeDesc": "Only sent when another person does it, never for your own entries.", "security": { "newDeviceTitle": "New sign-in to your account", "newDeviceBody": "A new sign-in from {device} ({location}). If this wasn't you, change your password immediately.", "unknownLocation": "unknown location" }, + "sharedRecordIntake": { + "title": "{name} marked a dose", + "bodyTaken": "{medication}: marked as taken by {name}.", + "bodySkipped": "{medication}: marked as skipped by {name}." + }, "loginRequired": "Please sign in to manage notifications.", "someChannelsGloballyDisabled": "One or more channels are turned off in Settings and won’t receive notifications.", "reminderSettingsLink": "Reminder settings", diff --git a/messages/es.json b/messages/es.json index 6b6cc1188..9105f8ebf 100644 --- a/messages/es.json +++ b/messages/es.json @@ -7089,11 +7089,20 @@ "eventMedicationIntakeSyncDesc": "Actualización entre dispositivos cuando se registra, toma u omite una dosis.", "eventDailyBriefing": "Resumen diario", "eventDailyBriefingDesc": "Un resumen matinal y tranquilo de tu día. Desactivado hasta que lo actives.", + "eventSecurityAlert": "Nuevo inicio de sesión", + "eventSecurityAlertDesc": "Se envía cuando se usa tu cuenta en un dispositivo o desde un lugar desconocidos.", + "eventSharedRecordIntake": "Alguien con acceso marca una dosis", + "eventSharedRecordIntakeDesc": "Solo se envía cuando lo hace otra persona, nunca con tus propias entradas.", "security": { "newDeviceTitle": "Nuevo inicio de sesión en tu cuenta", "newDeviceBody": "Un nuevo inicio de sesión desde {device} ({location}). Si no fuiste tú, cambia tu contraseña de inmediato.", "unknownLocation": "ubicación desconocida" }, + "sharedRecordIntake": { + "title": "{name} ha marcado una dosis", + "bodyTaken": "{medication}: marcada como tomada por {name}.", + "bodySkipped": "{medication}: marcada como omitida por {name}." + }, "loginRequired": "Inicia sesión para gestionar las notificaciones.", "someChannelsGloballyDisabled": "Uno o más canales están desactivados en los ajustes y no recibirán notificaciones.", "reminderSettingsLink": "Ajustes de recordatorios", diff --git a/messages/fr.json b/messages/fr.json index 6c6f91a58..72bd859ea 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -7089,11 +7089,20 @@ "eventMedicationIntakeSyncDesc": "Mise à jour entre appareils quand une dose est enregistrée, prise ou ignorée.", "eventDailyBriefing": "Résumé quotidien", "eventDailyBriefingDesc": "Un résumé matinal et posé de votre journée. Désactivé jusqu'à ce que vous l'activiez.", + "eventSecurityAlert": "Nouvelle connexion", + "eventSecurityAlertDesc": "Envoyé quand votre compte est utilisé sur un appareil ou depuis un lieu inconnus.", + "eventSharedRecordIntake": "Une personne avec accès marque une dose", + "eventSharedRecordIntakeDesc": "Envoyé uniquement quand une autre personne le fait, jamais pour vos propres saisies.", "security": { "newDeviceTitle": "Nouvelle connexion à votre compte", "newDeviceBody": "Une nouvelle connexion depuis {device} ({location}). Si ce n'était pas vous, changez votre mot de passe immédiatement.", "unknownLocation": "lieu inconnu" }, + "sharedRecordIntake": { + "title": "{name} a marqué une dose", + "bodyTaken": "{medication} : marquée comme prise par {name}.", + "bodySkipped": "{medication} : marquée comme ignorée par {name}." + }, "loginRequired": "Connecte-toi pour gérer les notifications.", "someChannelsGloballyDisabled": "Un ou plusieurs canaux sont désactivés dans les paramètres et ne recevront pas de notifications.", "reminderSettingsLink": "Réglages des rappels", diff --git a/messages/it.json b/messages/it.json index d6e1847c4..5ad71e1af 100644 --- a/messages/it.json +++ b/messages/it.json @@ -7089,11 +7089,20 @@ "eventMedicationIntakeSyncDesc": "Aggiornamento tra dispositivi quando una dose viene registrata, assunta o saltata.", "eventDailyBriefing": "Riepilogo quotidiano", "eventDailyBriefingDesc": "Un riepilogo mattutino e tranquillo della tua giornata. Disattivato finché non lo attivi.", + "eventSecurityAlert": "Nuovo accesso", + "eventSecurityAlertDesc": "Inviato quando il tuo account viene usato su un dispositivo o da un luogo sconosciuti.", + "eventSharedRecordIntake": "Una persona con accesso segna una dose", + "eventSharedRecordIntakeDesc": "Inviato solo quando lo fa un'altra persona, mai per le tue voci.", "security": { "newDeviceTitle": "Nuovo accesso al tuo account", "newDeviceBody": "Un nuovo accesso da {device} ({location}). Se non sei stato tu, cambia subito la password.", "unknownLocation": "posizione sconosciuta" }, + "sharedRecordIntake": { + "title": "{name} ha segnato una dose", + "bodyTaken": "{medication}: segnata come assunta da {name}.", + "bodySkipped": "{medication}: segnata come saltata da {name}." + }, "loginRequired": "Accedi per gestire le notifiche.", "someChannelsGloballyDisabled": "Uno o più canali sono disattivati nelle impostazioni e non riceveranno notifiche.", "reminderSettingsLink": "Impostazioni promemoria", diff --git a/messages/pl.json b/messages/pl.json index 7ca42645b..d84ad5e7d 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -7089,11 +7089,20 @@ "eventMedicationIntakeSyncDesc": "Aktualizacja między urządzeniami, gdy dawka zostanie zapisana, przyjęta lub pominięta.", "eventDailyBriefing": "Codzienne podsumowanie", "eventDailyBriefingDesc": "Spokojne poranne podsumowanie Twojego dnia. Wyłączone, dopóki go nie włączysz.", + "eventSecurityAlert": "Nowe logowanie", + "eventSecurityAlertDesc": "Wysyłane, gdy Twoje konto zostanie użyte na nieznanym urządzeniu lub z nieznanego miejsca.", + "eventSharedRecordIntake": "Ktoś z dostępem oznacza dawkę", + "eventSharedRecordIntakeDesc": "Wysyłane tylko wtedy, gdy robi to inna osoba, nigdy przy Twoich własnych wpisach.", "security": { "newDeviceTitle": "Nowe logowanie do Twojego konta", "newDeviceBody": "Nowe logowanie z {device} ({location}). Jeśli to nie Ty, natychmiast zmień hasło.", "unknownLocation": "nieznana lokalizacja" }, + "sharedRecordIntake": { + "title": "{name} oznaczył(a) dawkę", + "bodyTaken": "{medication}: oznaczona jako przyjęta przez {name}.", + "bodySkipped": "{medication}: oznaczona jako pominięta przez {name}." + }, "loginRequired": "Zaloguj się, aby zarządzać powiadomieniami.", "someChannelsGloballyDisabled": "Co najmniej jeden kanał jest wyłączony w ustawieniach i nie będzie otrzymywać powiadomień.", "reminderSettingsLink": "Ustawienia przypomnień", diff --git a/src/app/notifications/page.tsx b/src/app/notifications/page.tsx index f5c64067d..db199f853 100644 --- a/src/app/notifications/page.tsx +++ b/src/app/notifications/page.tsx @@ -15,6 +15,10 @@ import { EVENT_DEFAULT_ENABLED, type EventType, } from "@/lib/notifications/types"; +import { + eventDescriptionTranslationKey, + eventTranslationKey, +} from "@/lib/notifications/event-labels"; import { queryKeys } from "@/lib/query-keys"; import { apiGet, apiPut } from "@/lib/api/api-fetch"; @@ -38,17 +42,6 @@ interface PreferencesData { eventTypes: string[]; } -/** Maps SCREAMING_SNAKE event type to translation key, e.g. MEDICATION_REMINDER → eventMedicationReminder */ -function eventTranslationKey(eventType: string): string { - return ( - "notifications.event" + - eventType - .toLowerCase() - .replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) - .replace(/^./, (c) => c.toUpperCase()) - ); -} - export default function NotificationsPage() { const { isAuthenticated, isLoading: authLoading } = useAuth(); const { t } = useTranslations(); @@ -265,7 +258,7 @@ export default function NotificationsPage() { {eventTypes.map((eventType, idx) => { const nameKey = eventTranslationKey(eventType); - const descKey = nameKey + "Desc"; + const descKey = eventDescriptionTranslationKey(eventType); return ( {eventTypes.map((eventType) => { const nameKey = eventTranslationKey(eventType); - const descKey = nameKey + "Desc"; + const descKey = eventDescriptionTranslationKey(eventType); return (
{ + return JSON.parse( + readFileSync(join(MESSAGES_DIR, `${locale}.json`), "utf8"), + ) as Record; +} + +function resolve(root: Record, key: string): unknown { + return key + .split(".") + .reduce( + (node, part) => + node && typeof node === "object" + ? (node as Record)[part] + : undefined, + root, + ); +} + +const LOCALES = readdirSync(MESSAGES_DIR) + .filter((f) => f.endsWith(".json")) + .map((f) => f.replace(/\.json$/, "")); + +describe("notification event labels", () => { + it("derives the key the matrix renders under", () => { + expect(eventTranslationKey("MEDICATION_REMINDER")).toBe( + "notifications.eventMedicationReminder", + ); + expect(eventDescriptionTranslationKey("SHARED_RECORD_INTAKE")).toBe( + "notifications.eventSharedRecordIntakeDesc", + ); + }); + + it("has a label and a description for every event type, in every locale", () => { + expect(LOCALES.length).toBeGreaterThanOrEqual(6); + expect(EVENT_TYPES.length).toBeGreaterThan(10); + + const missing: string[] = []; + for (const locale of LOCALES) { + const root = bundle(locale); + for (const eventType of EVENT_TYPES) { + for (const key of [ + eventTranslationKey(eventType), + eventDescriptionTranslationKey(eventType), + ]) { + const value = resolve(root, key); + if (typeof value !== "string" || value.trim().length === 0) { + missing.push(`${locale} is missing ${key}`); + } + } + } + } + + expect(missing).toEqual([]); + }); +}); + +describe("the delegated-dose push copy", () => { + it("interpolates the same names in every locale", () => { + const keys = [ + "notifications.sharedRecordIntake.title", + "notifications.sharedRecordIntake.bodyTaken", + "notifications.sharedRecordIntake.bodySkipped", + ]; + const placeholders: Record = { + "notifications.sharedRecordIntake.title": ["{name}"], + "notifications.sharedRecordIntake.bodyTaken": ["{name}", "{medication}"], + "notifications.sharedRecordIntake.bodySkipped": [ + "{name}", + "{medication}", + ], + }; + + for (const locale of LOCALES) { + const root = bundle(locale); + for (const key of keys) { + const value = resolve(root, key); + expect(typeof value, `${locale} ${key}`).toBe("string"); + for (const token of placeholders[key]) { + expect(value as string, `${locale} ${key}`).toContain(token); + } + } + } + }); +}); diff --git a/src/lib/notifications/event-labels.ts b/src/lib/notifications/event-labels.ts new file mode 100644 index 000000000..ae7e725a8 --- /dev/null +++ b/src/lib/notifications/event-labels.ts @@ -0,0 +1,28 @@ +/** + * The i18n key an event type is rendered under. + * + * The /notifications matrix builds its rows from the event list the server + * publishes, so the label for a new event type is never written at a call site + * — it is derived here. Extracted from the page so the derivation can be + * pinned by a test without rendering anything, and so the guard that asserts + * every event type actually HAS its two strings (label + description, six + * locales) computes the key exactly the way the page does rather than a way + * that agrees with it today. + * + * `MEDICATION_REMINDER` → `notifications.eventMedicationReminder`; the + * description key is that plus `Desc`. + */ +export function eventTranslationKey(eventType: string): string { + return ( + "notifications.event" + + eventType + .toLowerCase() + .replace(/_([a-z])/g, (_, c: string) => c.toUpperCase()) + .replace(/^./, (c) => c.toUpperCase()) + ); +} + +/** The description key that sits under the label in the matrix row. */ +export function eventDescriptionTranslationKey(eventType: string): string { + return `${eventTranslationKey(eventType)}Desc`; +} From bf7fba4af034bad7bc5eb581c124de6635429673 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 09:52:02 +0200 Subject: [PATCH 3/9] Route the delegated-dose alert through one helper that refuses on self The intake routes call notifyDelegatedIntake with both account ids and the helper decides. Keeping the comparison here rather than at the call sites means a route added later cannot notify somebody about a dose they marked themselves, and a snooze is answered in one place as what it is: moving a dose, not resolving one. The medication name is read scoped to the owner, because that name goes onto the owner's lock screen. Nothing addresses the delegate: the dispatcher takes a single account id, and under record substitution that id is the owner. The person who marked the dose learns what they did from the response they are already awaiting. The integration test asserts the ledger row before the sender mock, so a dispatch nobody recorded cannot pass as a dispatch. --- .../__tests__/delegated-intake.test.ts | 198 ++++++++++++++ src/lib/notifications/delegated-intake.ts | 136 ++++++++++ .../delegated-intake-notification.test.ts | 253 ++++++++++++++++++ 3 files changed, 587 insertions(+) create mode 100644 src/lib/notifications/__tests__/delegated-intake.test.ts create mode 100644 src/lib/notifications/delegated-intake.ts create mode 100644 tests/integration/delegated-intake-notification.test.ts diff --git a/src/lib/notifications/__tests__/delegated-intake.test.ts b/src/lib/notifications/__tests__/delegated-intake.test.ts new file mode 100644 index 000000000..f659567a3 --- /dev/null +++ b/src/lib/notifications/__tests__/delegated-intake.test.ts @@ -0,0 +1,198 @@ +/** + * v1.36.x — "somebody else marked your dose" helper. + * + * What is pinned here, and why each one is a property rather than an + * observation: + * + * 1. The notification is addressed to the OWNER and names the acting person + * and the medication. The delegate is not a recipient anywhere in this + * file, because there is no delegate-directed push to assert. + * 2. The self refusal lives in the helper, so a future call site that forgets + * to compare ids still cannot notify somebody about their own dose. + * 3. A snooze is not a marking and produces nothing. + * 4. The medication is read scoped to the owner — the name that reaches a + * lock screen comes from the recipient's own rows. + * 5. Nothing here throws: a failed notification never fails the write that + * produced it. + * + * Mutation check (run before this file was committed): deleting the + * `actorId === ownerId` guard turns "fires nothing when the owner marks their + * own dose" red with `expected "spy" to not be called at all, but actually + * been called 1 times`. Deleting the `messageKey === null` return turns the + * snooze case red the same way. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const dispatchLocalisedMock = vi.fn(); +const addWarningMock = vi.fn(); + +vi.mock("@/lib/notifications/dispatch-localised", () => ({ + dispatchLocalisedNotification: (...args: unknown[]) => + dispatchLocalisedMock(...args), +})); + +vi.mock("@/lib/logging/context", () => ({ + getEvent: () => ({ addWarning: addWarningMock, addMeta: vi.fn() }), +})); + +const userFindUnique = vi.fn(); +const medicationFindFirst = vi.fn(); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: (...a: unknown[]) => userFindUnique(...a) }, + medication: { findFirst: (...a: unknown[]) => medicationFindFirst(...a) }, + }, +})); + +import { + delegatedIntakeMessageKey, + notifyDelegatedIntake, +} from "@/lib/notifications/delegated-intake"; + +const OWNER = "owner-account"; +const DELEGATE = "delegate-account"; +const MEDICATION = "med-1"; + +beforeEach(() => { + vi.clearAllMocks(); + userFindUnique.mockResolvedValue({ + username: "wren", + displayName: "Wren", + }); + medicationFindFirst.mockResolvedValue({ name: "Ramipril" }); + dispatchLocalisedMock.mockResolvedValue(undefined); +}); + +describe("delegatedIntakeMessageKey", () => { + it("maps a marking to its own body and a snooze to nothing", () => { + expect(delegatedIntakeMessageKey("taken")).toBe( + "notifications.sharedRecordIntake.bodyTaken", + ); + expect(delegatedIntakeMessageKey("skipped")).toBe( + "notifications.sharedRecordIntake.bodySkipped", + ); + expect(delegatedIntakeMessageKey("snoozed")).toBeNull(); + }); +}); + +describe("notifyDelegatedIntake", () => { + it("notifies the owner, naming the acting person and the medication", async () => { + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "taken", + }); + + expect(dispatchLocalisedMock).toHaveBeenCalledTimes(1); + expect(dispatchLocalisedMock).toHaveBeenCalledWith({ + userId: OWNER, + eventType: "SHARED_RECORD_INTAKE", + titleKey: "notifications.sharedRecordIntake.title", + messageKey: "notifications.sharedRecordIntake.bodyTaken", + params: { name: "Wren", medication: "Ramipril" }, + metadata: { medicationId: MEDICATION }, + }); + }); + + it("carries the skipped body for a skipped dose", async () => { + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "skipped", + }); + + expect(dispatchLocalisedMock.mock.calls[0][0]).toMatchObject({ + messageKey: "notifications.sharedRecordIntake.bodySkipped", + }); + }); + + it("falls back to the username when the acting account set no display name", async () => { + userFindUnique.mockResolvedValue({ username: "wren", displayName: null }); + + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "taken", + }); + + expect(dispatchLocalisedMock.mock.calls[0][0].params).toMatchObject({ + name: "wren", + }); + }); + + it("fires nothing when the owner marks their own dose", async () => { + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: OWNER, + medicationId: MEDICATION, + status: "taken", + }); + + expect(dispatchLocalisedMock).not.toHaveBeenCalled(); + // Not even a lookup: the refusal is the first thing the helper does. + expect(userFindUnique).not.toHaveBeenCalled(); + expect(medicationFindFirst).not.toHaveBeenCalled(); + }); + + it("fires nothing for a snooze", async () => { + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "snoozed", + }); + + expect(dispatchLocalisedMock).not.toHaveBeenCalled(); + }); + + it("reads the medication scoped to the owner", async () => { + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "taken", + }); + + expect(medicationFindFirst).toHaveBeenCalledWith({ + where: { id: MEDICATION, userId: OWNER }, + select: { name: true }, + }); + }); + + it("stays silent, with a warning, when the medication is not the owner's", async () => { + medicationFindFirst.mockResolvedValue(null); + + await notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "taken", + }); + + expect(dispatchLocalisedMock).not.toHaveBeenCalled(); + expect(addWarningMock).toHaveBeenCalledWith( + expect.stringContaining("medication=missing"), + ); + }); + + it("swallows a dispatch failure so the write that produced it survives", async () => { + dispatchLocalisedMock.mockRejectedValue(new Error("telegram is down")); + + await expect( + notifyDelegatedIntake({ + ownerId: OWNER, + actorId: DELEGATE, + medicationId: MEDICATION, + status: "taken", + }), + ).resolves.toBeUndefined(); + + expect(addWarningMock).toHaveBeenCalledWith( + expect.stringContaining("telegram is down"), + ); + }); +}); diff --git a/src/lib/notifications/delegated-intake.ts b/src/lib/notifications/delegated-intake.ts new file mode 100644 index 000000000..ec189555f --- /dev/null +++ b/src/lib/notifications/delegated-intake.ts @@ -0,0 +1,136 @@ +/** + * v1.36.x — "somebody else marked your dose". + * + * The one notification a delegated write produces. Everything else a delegate + * adds to a shared record (a reading, a lab result, an allergy) is bookkeeping + * and lives in the owner's activity view; a dose is a statement about the + * owner's own body, and the owner is the person best placed to decide whether + * they want to hear about it. So this rides the ordinary alert cascade with an + * ordinary per-channel preference row, and nothing about it is special-cased + * inside the dispatcher. + * + * Two properties are structural rather than observed: + * + * 1. **It refuses to fire on self.** The owner never gets a push about their + * own dose. The refusal lives HERE, not at the call sites, so a future + * route that calls this helper cannot forget it. + * 2. **It addresses the owner and only the owner.** The delegate gets their + * feedback from the response they are already awaiting, rendered in their + * own session. There is no delegate-directed push and this file is not the + * place to grow one: the dispatcher takes a `userId`, and under record + * substitution that id is the owner by design. + * + * The medication is named in the push body, the same verbosity class as + * MEDICATION_REMINDER, which has named medications on the lock screen since + * v1.4.40. No dose figures ride along. + */ +import { prisma } from "@/lib/db"; +import { getEvent } from "@/lib/logging/context"; +import { accountLabel } from "@/lib/sharing/account-access-view"; +import { dispatchLocalisedNotification } from "@/lib/notifications/dispatch-localised"; + +/** + * The dose states the intake routes write. `"snoozed"` is deliberately in the + * union: both intake routes can produce it, and the helper answering "that one + * is not a marking" in one place beats every call site remembering to. + */ +export type DelegatedIntakeStatus = "taken" | "skipped" | "snoozed"; + +/** + * Which body the push carries for a dose state, or `null` when the state is + * not a marking at all. + * + * Pure, exported, and tested on its own: a snooze moves a dose, it does not + * resolve it, and the web-push dose-clear draws the same line (`intake/route + * .ts` clears the pending reminder for taken / skipped only). An owner woken + * by "your carer postponed a dose by ten minutes" would be the noise this + * whole event type is trying not to be. + */ +export function delegatedIntakeMessageKey( + status: DelegatedIntakeStatus, +): string | null { + switch (status) { + case "taken": + return "notifications.sharedRecordIntake.bodyTaken"; + case "skipped": + return "notifications.sharedRecordIntake.bodySkipped"; + case "snoozed": + return null; + } +} + +export interface NotifyDelegatedIntakeInput { + /** The account the dose belongs to — the recipient of the notification. */ + ownerId: string; + /** The account that actually marked it. Equal to `ownerId` on a self-write. */ + actorId: string; + /** The medication whose dose was marked. Resolved for its name, owner-scoped. */ + medicationId: string; + /** What the dose was marked as. */ + status: DelegatedIntakeStatus; +} + +/** + * Tell the owner that somebody else marked one of their doses. + * + * Never throws: a notification that fails is not a reason to fail the write + * that produced it. Await it — the call sites reach this only on a delegated + * write, so the added latency lands on the caregiver's request and never on + * the owner's own hot path, and awaiting is what makes "one row in + * `push_attempts`" a thing a test can assert rather than a thing it can race. + */ +export async function notifyDelegatedIntake( + input: NotifyDelegatedIntakeInput, +): Promise { + const { ownerId, actorId, medicationId, status } = input; + + // The self refusal. First, cheap, and unconditional. + if (actorId === ownerId) return; + + const messageKey = delegatedIntakeMessageKey(status); + if (messageKey === null) return; + + try { + const [actor, medication] = await Promise.all([ + prisma.user.findUnique({ + where: { id: actorId }, + select: { username: true, displayName: true }, + }), + // Scoped to the owner: the name in this body goes onto the owner's lock + // screen, so it is read from the owner's own row set or not at all. + prisma.medication.findFirst({ + where: { id: medicationId, userId: ownerId }, + select: { name: true }, + }), + ]); + + // Both halves of the sentence or none of it. An acting account that no + // longer resolves is nobody we can name, and a medication that does not + // resolve under the OWNER is not one whose name belongs on their lock + // screen. Neither is reachable from the intake routes, which have both + // rows in hand before they call; a warning to ops beats a push that says + // "somebody marked something". + if (!actor || !medication) { + getEvent()?.addWarning( + `notifyDelegatedIntake — nothing to name (actor=${actor ? "ok" : "missing"} medication=${medication ? "ok" : "missing"}), no notification sent`, + ); + return; + } + + await dispatchLocalisedNotification({ + userId: ownerId, + eventType: "SHARED_RECORD_INTAKE", + titleKey: "notifications.sharedRecordIntake.title", + messageKey, + params: { + name: accountLabel(actor), + medication: medication.name, + }, + metadata: { medicationId }, + }); + } catch (err) { + getEvent()?.addWarning( + `notifyDelegatedIntake failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} diff --git a/tests/integration/delegated-intake-notification.test.ts b/tests/integration/delegated-intake-notification.test.ts new file mode 100644 index 000000000..c5eb0c47e --- /dev/null +++ b/tests/integration/delegated-intake-notification.test.ts @@ -0,0 +1,253 @@ +/** + * v1.36.x — the delegated-dose notification, end to end against Postgres. + * + * The helper is exercised through the real dispatcher, the real preference + * lookup and the real APNs sender (with the vendor client stubbed, the same + * shape `apns-dispatch.test.ts` uses), so what is asserted is the row the + * ledger actually holds rather than the call the helper meant to make. + * + * The three facts: + * + * 1. The owner gets exactly ONE `push_attempts` row for the event, and the + * delegate gets none. There is no delegate-directed push; the caregiver + * learns what they did from the response in their own session. + * 2. An owner marking their own dose produces no send and no row at all. + * 3. The owner's per-channel opt-out row suppresses it, which is what makes + * "default ON" a defaulted decision rather than an unconditional one. + * + * The ledger write is deliberately fire-and-forget in `recordPushAttempt`, so + * the assertions poll for the expected row instead of reading once and hoping. + * The poll is bounded and then asserted exactly: a helper that stops + * dispatching times out and fails on the count, it does not pass on an empty + * table. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY ??= + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +const apnsSendMock = vi.fn(); + +vi.mock("@parse/node-apn", () => { + class Provider { + constructor(_opts: unknown) { + void _opts; + } + send(...args: unknown[]) { + return apnsSendMock(...args); + } + shutdown() {} + } + class Notification { + public topic = ""; + public alert: unknown; + public sound: string | undefined; + public badge: number | undefined; + public threadId: string | undefined; + public collapseId: string | undefined; + public payload: unknown; + } + return { default: { Provider, Notification }, Provider, Notification }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { getPrismaClient, truncateAllTables } from "./setup"; + +const OWNER_ID = "user-delegated-intake-owner"; +const DELEGATE_ID = "user-delegated-intake-delegate"; + +// Test-only EC P-256 key — `loadApnsConfig` parses it before the sender will +// talk to the stubbed provider. Same fixture shape as `apns-dispatch.test.ts`. +const TEST_EC_PEM_LINES = [ + "-----BEGIN PRIVATE KEY-----", + "MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgLOXP3Exjr5L5tamN", + "pTxck85Iaum80PdRlWDpc/ezviOgCgYIKoZIzj0DAQehRANCAAT1x8nKRb8KshQU", + "1aPieSCqOY6ilgC959umaFSlhfav8eZ91UHP/xond9aMoZcuQ7lJG/Rsj70SWMvZ", + "bw81BG89", + "-----END PRIVATE KEY-----", +]; + +const APNS_ENV = { + APNS_KEY_ID: "ABCDE12345", + APNS_TEAM_ID: "TEAM123456", + APNS_BUNDLE_ID: "test.healthlog.ios", + APNS_KEY: TEST_EC_PEM_LINES.join("\\n"), +}; + +async function seedMedication(): Promise { + const med = await getPrismaClient().medication.create({ + data: { + userId: OWNER_ID, + name: "Ramipril", + dose: "5 mg", + }, + }); + return med.id; +} + +async function seedApnsChannel(): Promise { + const { encrypt } = await import("@/lib/crypto"); + await getPrismaClient().notificationChannel.create({ + data: { + userId: OWNER_ID, + type: "APNS", + enabled: true, + config: encrypt("{}"), + }, + }); + await getPrismaClient().device.create({ + data: { + userId: OWNER_ID, + platform: "ios", + token: "device-owner", + bundleId: APNS_ENV.APNS_BUNDLE_ID, + apnsToken: "owner-apns-token", + apnsEnvironment: "sandbox", + }, + }); +} + +/** Poll the ledger until it holds `expected` rows for the user, or give up. */ +async function pushAttemptsFor( + userId: string, + expected: number, +): Promise<{ channel: string; eventType: string; result: string }[]> { + for (let attempt = 0; attempt < 40; attempt += 1) { + const rows = await getPrismaClient().pushAttempt.findMany({ + where: { userId }, + select: { channel: true, eventType: true, result: true }, + }); + if (rows.length >= expected) return rows; + await new Promise((r) => setTimeout(r, 25)); + } + return getPrismaClient().pushAttempt.findMany({ + where: { userId }, + select: { channel: true, eventType: true, result: true }, + }); +} + +beforeEach(async () => { + vi.clearAllMocks(); + for (const [k, v] of Object.entries(APNS_ENV)) process.env[k] = v; + delete process.env.APNS_PRODUCTION; + const apns = await import("@/lib/notifications/senders/apns"); + apns.resetApnsForTesting(); + const { __resetDispatchLocaleCacheForTests } = + await import("@/lib/notifications/dispatch-localised"); + __resetDispatchLocaleCacheForTests(); + + await truncateAllTables(getPrismaClient()); + await getPrismaClient().user.createMany({ + data: [ + { + id: OWNER_ID, + username: "owner-delegated-intake", + email: "owner-delegated-intake@example.test", + locale: "en", + }, + { + id: DELEGATE_ID, + username: "wren", + displayName: "Wren", + email: "wren-delegated-intake@example.test", + locale: "de", + }, + ], + }); + + apnsSendMock.mockResolvedValue({ + sent: [{ device: "owner-apns-token" }], + failed: [], + }); +}); + +describe("delegated intake notification", () => { + it("notifies the owner once and the acting delegate not at all", async () => { + await seedApnsChannel(); + const medicationId = await seedMedication(); + + const { notifyDelegatedIntake } = + await import("@/lib/notifications/delegated-intake"); + await notifyDelegatedIntake({ + ownerId: OWNER_ID, + actorId: DELEGATE_ID, + medicationId, + status: "taken", + }); + + // The ledger first, deliberately: a dispatch that never landed and a + // dispatch nobody recorded look identical from the mock's side. + const ownerRows = await pushAttemptsFor(OWNER_ID, 1); + expect(ownerRows).toEqual([ + { channel: "APNS", eventType: "SHARED_RECORD_INTAKE", result: "ok" }, + ]); + + const delegateRows = await getPrismaClient().pushAttempt.findMany({ + where: { userId: DELEGATE_ID }, + }); + expect(delegateRows).toEqual([]); + + expect(apnsSendMock).toHaveBeenCalledTimes(1); + const note = apnsSendMock.mock.calls[0][0]; + // The owner's locale, not the delegate's, and the medication named the + // way MEDICATION_REMINDER names one. + expect(note.alert.title).toBe("Wren marked a dose"); + expect(note.alert.body).toBe("Ramipril: marked as taken by Wren."); + expect(note.payload).toMatchObject({ + eventType: "SHARED_RECORD_INTAKE", + medicationId, + }); + }); + + it("sends nothing when the owner marks their own dose", async () => { + await seedApnsChannel(); + const medicationId = await seedMedication(); + + const { notifyDelegatedIntake } = + await import("@/lib/notifications/delegated-intake"); + await notifyDelegatedIntake({ + ownerId: OWNER_ID, + actorId: OWNER_ID, + medicationId, + status: "taken", + }); + + expect(apnsSendMock).not.toHaveBeenCalled(); + // Wait out the ledger's fire-and-forget window before asserting absence, + // so "no row" means no row rather than "not yet". + await new Promise((r) => setTimeout(r, 250)); + expect(await getPrismaClient().pushAttempt.count()).toBe(0); + }); + + it("honours the owner's per-channel opt-out", async () => { + await seedApnsChannel(); + const medicationId = await seedMedication(); + const channel = + await getPrismaClient().notificationChannel.findFirstOrThrow({ + where: { userId: OWNER_ID, type: "APNS" }, + }); + await getPrismaClient().notificationPreference.create({ + data: { + channelId: channel.id, + eventType: "SHARED_RECORD_INTAKE", + enabled: false, + }, + }); + + const { notifyDelegatedIntake } = + await import("@/lib/notifications/delegated-intake"); + await notifyDelegatedIntake({ + ownerId: OWNER_ID, + actorId: DELEGATE_ID, + medicationId, + status: "taken", + }); + + expect(apnsSendMock).not.toHaveBeenCalled(); + await new Promise((r) => setTimeout(r, 250)); + expect(await getPrismaClient().pushAttempt.count()).toBe(0); + }); +}); From 533400ce60d4cc058c4441f19f2cbac23fd25c0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 10:12:08 +0200 Subject: [PATCH 4/9] Let an invitation carry the level it offers A grant has always had an access column and `inviteGrant` has always written READ into it. That was the right way to ship the first version: "this release never grants write" was a property of the file rather than a promise in a comment. Remove it deliberately now, so somebody sharing their record can offer reading or reading plus adding. The level is a required argument to `inviteGrant` and is defaulted in exactly one place, the invite schema, where an omitted field becomes READ. An older client's payload therefore stays valid and stays narrow, and the domain module never guesses a level nobody named. There is no upgrade path and that is the decision, not a gap. A grant is READ or WRITE from the moment it is offered and stays that way until somebody ends it. Widening a live one would change what the delegate agreed to without asking them again, and their consent is half of what makes a write grant legitimate. The way up is a new invitation, accepted again. The audit row and the wide event carry the level: "who was given what, and by whom" is the question that row exists to answer. --- docs/api/openapi.yaml | 28 +++- .../__tests__/invite-access-level.test.ts | 155 ++++++++++++++++++ src/app/api/account/grants/route.ts | 26 ++- src/lib/openapi/routes/account-sharing.ts | 8 +- src/lib/sharing/__tests__/grants.test.ts | 81 +++++++++ src/lib/sharing/account-access.ts | 11 +- src/lib/sharing/grants.ts | 47 ++++-- src/lib/validations/account-sharing.ts | 21 ++- .../account-grant-lifecycle.test.ts | 68 +++++++- .../acting-account-resolver.test.ts | 8 +- tests/integration/sharing-audit-actor.test.ts | 2 + 11 files changed, 408 insertions(+), 47 deletions(-) create mode 100644 src/app/api/account/grants/__tests__/invite-access-level.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index dd594f976..aa2d46dc1 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -12507,12 +12507,12 @@ paths: post: tags: - Account sharing - summary: Invite an account to read this record - description: Offers read access to somebody already registered on this instance; the grant confers nothing until they - accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller - on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a - mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor - re-delegate. + summary: Invite an account to this record + description: Offers access to somebody already registered on this instance, at READ or WRITE; the grant confers nothing + until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an + authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a + silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can + neither invite nor re-delegate. requestBody: required: true content: @@ -12530,6 +12530,17 @@ paths: format: date-time pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$ - type: "null" + access: + default: READ + description: "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, + observations, a medication, a marked dose) and can still edit or delete nothing, including its own. + Omitted means READ, so a client that predates the field keeps working. The level is fixed when the + invitation is written: no endpoint raises a live grant, because that would widen what the delegate + accepted without asking them again. The way up is a new invitation the delegate accepts." + type: string + enum: + - READ + - WRITE required: - identifier responses: @@ -36689,8 +36700,9 @@ components: - write canWrite: type: boolean - description: Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it; - never derive it. + description: "Whether this caller may ADD to that record: true for an accepted WRITE grant, false for a READ one. It + never means edit or delete, which stay with the owner at both levels. Resolved server-side. Render it; never + derive it." required: - accountId - username diff --git a/src/app/api/account/grants/__tests__/invite-access-level.test.ts b/src/app/api/account/grants/__tests__/invite-access-level.test.ts new file mode 100644 index 000000000..2507d194e --- /dev/null +++ b/src/app/api/account/grants/__tests__/invite-access-level.test.ts @@ -0,0 +1,155 @@ +/** + * `POST /api/account/grants` — the level a request carries is the level the row + * gets. + * + * The assertion is deliberately on the WRITE the route performs rather than on + * the JSON it answers with: a response echoing the request would pass either + * way, and the failure this pins is the one where a field is validated, + * annotated on, and then dropped on the way to the database. So every case + * reads the `data` object handed to `accountGrant.create`. + * + * Where the default lives matters and is asserted here rather than assumed: an + * omitted `access` becomes READ in the invite schema and nowhere else — + * `inviteGrant` takes the level as a required argument. Move the default and + * the third case below goes red. + * + * What this file cannot see: the browser. That the level a person picks on the + * invitation form reaches this route at all is proven in + * `e2e/account-sharing.spec.ts`, which reads the posted body — the component + * test renders the control and stays green with the control wired to a + * constant. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/api-handler", async () => { + const actual = + await vi.importActual( + "@/lib/api-handler", + ); + return { + ...actual, + apiHandler: Promise>(h: T): T => + h, + requireAuth: vi.fn(), + }; +}); + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findFirst: vi.fn() }, + accountGrant: { create: vi.fn() }, + }, +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi + .fn() + .mockResolvedValue({ allowed: true, remaining: 9, resetAt: 0 }), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() })); + +import { POST } from "../route"; +import { requireAuth } from "@/lib/api-handler"; +import { auditLog } from "@/lib/auth/audit"; +import { prisma } from "@/lib/db"; + +const OWNER = { id: "owner-1" }; +const INVITEE = { id: "invitee-1", username: "housemate", displayName: null }; + +function request(body: Record): NextRequest { + return new NextRequest("http://localhost/api/account/grants", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** The `data` object the route's write reached the client with. */ +function writtenRow(): Record { + const calls = vi.mocked(prisma.accountGrant.create).mock.calls; + expect(calls).toHaveLength(1); + return (calls[0][0] as { data: Record }).data; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAuth).mockResolvedValue({ + user: OWNER, + } as unknown as Awaited>); + vi.mocked(prisma.user.findFirst).mockResolvedValue( + INVITEE as never as Awaited>, + ); + vi.mocked(prisma.accountGrant.create).mockImplementation((async (args: { + data: Record; + }) => ({ + id: "grant-1", + grantorId: OWNER.id, + granteeId: INVITEE.id, + acceptedAt: null, + revokedAt: null, + revokedBy: null, + lastUsedAt: null, + expiresAt: null, + ...args.data, + })) as never); +}); + +describe("invitation access level", () => { + it("stores WRITE when the invitation offers it", async () => { + const res = await POST( + request({ identifier: "housemate", access: "WRITE" }), + ); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("WRITE"); + const body = (await res.json()) as { data: { access: string } }; + expect(body.data.access).toBe("WRITE"); + }); + + it("stores READ when the invitation offers it", async () => { + const res = await POST( + request({ identifier: "housemate", access: "READ" }), + ); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("READ"); + }); + + it("mints READ for a client that does not send the field", async () => { + // An older client's payload stays valid, and it stays NARROW. This is the + // case that must never widen by accident. + const res = await POST(request({ identifier: "housemate" })); + + expect(res.status).toBe(201); + expect(writtenRow().access).toBe("READ"); + }); + + it("refuses a level that is not one of the two, without writing a row", async () => { + const res = await POST( + request({ identifier: "housemate", access: "ADMIN" }), + ); + + expect(res.status).toBe(422); + expect(prisma.accountGrant.create).not.toHaveBeenCalled(); + }); + + it("records the level on the audit row", async () => { + // "Who was given what, and by whom" is the question this row answers. An + // entry naming the invitation without its level answers half of it. + await POST(request({ identifier: "housemate", access: "WRITE" })); + + expect(auditLog).toHaveBeenCalledWith( + "sharing.grant.invited", + expect.objectContaining({ + userId: OWNER.id, + details: expect.objectContaining({ access: "WRITE" }), + }), + ); + }); +}); diff --git a/src/app/api/account/grants/route.ts b/src/app/api/account/grants/route.ts index 8d5c785a8..8e92d7c15 100644 --- a/src/app/api/account/grants/route.ts +++ b/src/app/api/account/grants/route.ts @@ -6,7 +6,10 @@ * one question — "who can see my record, and whose can I see" — and because a * client that has to call twice will eventually render half an answer. * - * `POST` offers a grant to somebody already registered on this instance. + * `POST` offers a grant to somebody already registered on this instance, at + * the level the owner chose. There is no route that raises a live grant: the + * level is settled when the invitation is written and the only way to a wider + * one is a fresh invitation the delegate accepts again. * * **Neither is delegable, and that is structural.** Both resolve through bare * `requireAuth()`, which refuses outright while the caller is acting on @@ -111,7 +114,7 @@ export const POST = apiHandler(async (request: NextRequest) => { }); } - const { identifier, expiresAt } = parsed.data; + const { identifier, expiresAt, access } = parsed.data; // Either column, case-insensitively — the same lookup the login form does, // so the name somebody signs in with is the name they can be invited by. @@ -129,18 +132,30 @@ export const POST = apiHandler(async (request: NextRequest) => { } try { - // `inviteGrant` decides everything about the row: READ and only READ, the + // `inviteGrant` decides everything about the row except the level: the // self-grant refusal, and the one-live-grant-per-pair rule the partial - // unique index backs. None of it is re-decided here. + // unique index backs. Neither is re-decided here. The level travels from + // the parsed body, where the schema is the one place an omitted field + // becomes READ, and it is fixed for the life of the row — the domain + // module has no transition that raises it. const grant = await inviteGrant({ grantorId: user.id, granteeId: invitee.id, + access, expiresAt: expiresAt ? new Date(expiresAt) : null, }); + // The level is on the audit row, not only in the response: "who was given + // what, and by whom" is the question this row exists to answer, and an + // entry that recorded the invitation without its level would answer half + // of it. await auditLog("sharing.grant.invited", { userId: user.id, - details: { grantId: grant.id, granteeId: invitee.id }, + details: { + grantId: grant.id, + granteeId: invitee.id, + access: grant.access, + }, ipAddress: getClientIp(request), }).catch(() => {}); @@ -148,6 +163,7 @@ export const POST = apiHandler(async (request: NextRequest) => { action: { name: "sharing.grant.invited" }, meta: { grant_id: grant.id, + access: grant.access, expires: expiresAt !== null && expiresAt !== undefined, }, }); diff --git a/src/lib/openapi/routes/account-sharing.ts b/src/lib/openapi/routes/account-sharing.ts index d8224ce36..7ec3bb253 100644 --- a/src/lib/openapi/routes/account-sharing.ts +++ b/src/lib/openapi/routes/account-sharing.ts @@ -32,7 +32,7 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared"; inviteGrantSchema.meta({ id: "AccountGrantInvite", description: - "Offer read access to another account on this instance. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. The access level is not a parameter: v1 grants READ only.", + "Offer access to another account on this instance. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. `access` is READ when omitted, so a client that does not know about the field keeps working: READ can read the record and change nothing, WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own. The level is fixed when the invitation is written — no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts.", }); switchAccountSchema.meta({ @@ -205,7 +205,7 @@ const accountAccessEntry = z canWrite: z .boolean() .describe( - "Whether this caller may CHANGE that record. Resolved server-side; `false` for every grant in v1. Render it; never derive it.", + "Whether this caller may ADD to that record: true for an accepted WRITE grant, false for a READ one. It never means edit or delete, which stay with the owner at both levels. Resolved server-side. Render it; never derive it.", ), }) .meta({ @@ -327,9 +327,9 @@ export const accountSharingPaths: NonNullable = { }, post: { tags: ["Account sharing"], - summary: "Invite an account to read this record", + summary: "Invite an account to this record", description: - "Offers read access to somebody already registered on this instance; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate.", + "Offers access to somebody already registered on this instance, at READ or WRITE; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate.", requestBody: { required: true, content: { "application/json": { schema: inviteGrantSchema } }, diff --git a/src/lib/sharing/__tests__/grants.test.ts b/src/lib/sharing/__tests__/grants.test.ts index f0708338d..86153cf7a 100644 --- a/src/lib/sharing/__tests__/grants.test.ts +++ b/src/lib/sharing/__tests__/grants.test.ts @@ -16,9 +16,11 @@ import { describe, expect, it } from "vitest"; import { grantAllows, grantState, + inviteGrant, isGrantActive, type GrantLifecycle, } from "@/lib/sharing/grants"; +import type { AccountGrant, Prisma } from "@/generated/prisma/client"; const NOW = new Date("2026-08-02T12:00:00.000Z"); const EARLIER = new Date("2026-08-01T12:00:00.000Z"); @@ -131,3 +133,82 @@ describe("grantAllows", () => { } }); }); + +/** + * The level the caller offered is the level the row is written with. + * + * A capture client rather than a database: what is asserted here is the `data` + * object this module hands the client, which is the one thing a fake can see + * honestly. That the column then holds the value, that the enum accepts it and + * that the partial unique index still applies are properties of Postgres and + * live in `tests/integration/account-grant-lifecycle.test.ts` — a fake that + * returned whatever it was given would report all three as working. + * + * There is no upgrade test because there is no upgrade: no function in this + * module raises the level of a row that exists, and the way to a wider grant + * is a new invitation the delegate accepts again. + */ +describe("inviteGrant", () => { + function captureDb(): { + db: Pick; + writes: Array<{ access: string; grantorId: string; granteeId: string }>; + } { + const writes: Array<{ + access: string; + grantorId: string; + granteeId: string; + }> = []; + const db = { + accountGrant: { + create: async (args: { + data: { access: string; grantorId: string; granteeId: string }; + }) => { + writes.push(args.data); + return { id: "grant-1", ...args.data } as unknown as AccountGrant; + }, + }, + } as unknown as Pick; + return { db, writes }; + } + + it("writes the level it was given", async () => { + for (const access of ["READ", "WRITE"] as const) { + const { db, writes } = captureDb(); + const row = await inviteGrant( + { grantorId: "owner", granteeId: "delegate", access }, + db, + ); + expect(writes).toHaveLength(1); + expect(writes[0].access).toBe(access); + expect(row.access).toBe(access); + } + }); + + it("still refuses a self-grant before anything is written", async () => { + const { db, writes } = captureDb(); + await expect( + inviteGrant( + { grantorId: "solo", granteeId: "solo", access: "WRITE" }, + db, + ), + ).rejects.toMatchObject({ code: "self_grant" }); + expect(writes).toHaveLength(0); + }); + + it("confers the offered level only after acceptance", () => { + // The two halves of the write consent, as the state machine sees them: the + // owner's is the row, the delegate's is `acceptedAt`. Neither alone is + // enough, and the pending row proves it. + const offered = { + revokedAt: null, + expiresAt: null, + access: "WRITE" as const, + }; + expect(grantAllows({ ...offered, acceptedAt: null }, "write", NOW)).toBe( + false, + ); + expect(grantAllows({ ...offered, acceptedAt: EARLIER }, "write", NOW)).toBe( + true, + ); + }); +}); diff --git a/src/lib/sharing/account-access.ts b/src/lib/sharing/account-access.ts index 1a0fe3f54..74793b625 100644 --- a/src/lib/sharing/account-access.ts +++ b/src/lib/sharing/account-access.ts @@ -14,11 +14,12 @@ * `canSwitch` and `canWrite`; it never computes them, and there is nothing in * the payload it could compute them from. * - * `canWrite` is `false` for every entry in v1, because `inviteGrant` writes - * READ and only READ. It is resolved here through {@link grantAllows} against - * the real row rather than hardcoded, so the day a WRITE grant exists the - * answer changes without this file being edited — and, more importantly, so - * that nobody has to remember that it should. + * `canWrite` is resolved here through {@link grantAllows} against the real + * row rather than stored or hardcoded, which is why the level becoming an + * invitation choice needed no edit to this file: an accepted WRITE grant + * started answering true the moment one could exist. It answers "may add to + * this record" and never "may change it" — editing and deleting stay with the + * owner at both levels, and no field here offers them. * * What is deliberately NOT published: the other account's avatar. The avatar * bytes are owner-scoped (`/api/user/avatar/{id}` refuses any id but the diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index fb387b469..c3765a3be 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -2,10 +2,11 @@ * v1.36.0 — the account-grant state machine. One file, one set of rules. * * A grant is one account's standing permission to read another account's - * record, and the row is also the durable record of the consent that created - * it (there is no second receipt table — see the model docblock in - * `prisma/schema.prisma`). Everything that decides what a grant currently - * MEANS lives here, and nothing else in the tree is allowed to decide it. + * record, and since v1.36.x to add to it as well, and the row is also the + * durable record of the consent that created it (there is no second receipt + * table — see the model docblock in `prisma/schema.prisma`). Everything that + * decides what a grant currently MEANS lives here, and nothing else in the + * tree is allowed to decide it. * * Why one file rather than the rules at the call sites: two consumers read * these rows for different reasons — the acting-account resolver on every @@ -20,7 +21,7 @@ * Bearer perimeter that shouts at a mention is the right direction to be * wrong in.) * - * Three properties are the whole point, and each is a decision made HERE and + * Four properties are the whole point, and each is a decision made HERE and * nowhere else: * * * **Pending confers nothing.** An invitation is not access. A row with @@ -36,6 +37,11 @@ * the shape it does. Re-inviting the same person mints a NEW row; the * partial unique index in migration 0292 keeps at most one LIVE row per * pair while the history piles up underneath. + * * **The level never widens.** A row is READ or WRITE from the moment it is + * offered, and no transition here raises it. Widening a grant somebody has + * already accepted would change what they agreed to without asking them + * again, and the delegate's consent is half of what makes a write grant + * legitimate. {@link inviteGrant} carries the reasoning. * * Every write below is a CONDITIONAL update — the state the transition * requires is in the `where`, not in a read the caller performed a moment @@ -145,10 +151,8 @@ export function isGrantActive( * * Active first, then the level. A READ grant permits reads only; a WRITE grant * permits both, because write access to a record without read access describes - * nothing anyone asked for. v1 never writes a WRITE row (see - * {@link inviteGrant}), so in v1 this answers `false` for every write — which - * is the fail-closed arm the resolver wants, reached by the data rather than - * by a special case. + * nothing anyone asked for. The state outranks the level in both directions: a + * revoked WRITE grant permits nothing at all, and no level survives an expiry. */ export function grantAllows( grant: GrantLifecycle & { access: AccountGrantAccess }, @@ -166,6 +170,14 @@ export interface InviteGrantInput { grantorId: string; /** The account being invited to read it. */ granteeId: string; + /** + * What the invitation offers. Required, with no default in this module: a + * level nobody named is a level nobody chose, and the one place an omitted + * request field becomes READ is the invite schema + * (`src/lib/validations/account-sharing.ts`). Two defaults agreeing today is + * how they disagree later. + */ + access: AccountGrantAccess; /** Optional lapse date. Null = the grant runs until somebody ends it. */ expiresAt?: Date | null; } @@ -173,11 +185,16 @@ export interface InviteGrantInput { /** * Offer a grant. The delegate has access only once they accept it. * - * `access` is not a parameter. v1 grants READ and only READ (design §14): the - * column exists from day one so that delegated writes become enforcement work - * instead of migration work, and leaving the level out of the input is what - * makes "v1 never grants write" a property of this file rather than a promise - * in a comment. + * The level is fixed here and never moves. There is no widen, no PATCH, no + * upgrade endpoint, and the absence is the design rather than a gap: write + * access needs two consents, the owner's because it is their record and the + * delegate's because they are being asked to put something into somebody's + * health record under their own name. Acceptance is the single moment those + * two meet, and it happens once. Raising a live grant afterwards would carry + * one consent, and it would not be the delegate's. The way up is a new + * invitation at the level meant, accepted again — the partial unique index + * permits it as soon as the old row is ended, and the cost is one revoke and + * one accept. * * Self-grants are refused here rather than by a database CHECK — migration * 0292 carries the reason (the schema-driven wipe seeder plants a row with @@ -198,7 +215,7 @@ export async function inviteGrant( data: { grantorId: input.grantorId, granteeId: input.granteeId, - access: "READ", + access: input.access, invitedAt: now, expiresAt: input.expiresAt ?? null, }, diff --git a/src/lib/validations/account-sharing.ts b/src/lib/validations/account-sharing.ts index 293291bcd..d2ae406cb 100644 --- a/src/lib/validations/account-sharing.ts +++ b/src/lib/validations/account-sharing.ts @@ -5,10 +5,6 @@ * and only two of them carry a body worth validating. What is NOT here is as * deliberate as what is: * - * * No `access` level. v1 grants READ and only READ, and the level is not a - * parameter anywhere in the stack — `inviteGrant` hardcodes it. A field - * here would be an input the domain module then ignores, which is the - * shape of a promise nobody kept. * * No `userId`, `grantorId` or `granteeId` on any body. Both sides of a * grant come from the authenticated session and the row itself. A body * field naming a party would be an authorization decision made by the @@ -43,6 +39,23 @@ export const inviteGrantSchema = z.object({ * remember to renew is worse than one that does not exist. */ expiresAt: z.iso.datetime({ offset: true }).nullable().optional(), + /** + * What the invitation offers, and the ONLY place the level is defaulted. + * + * `inviteGrant` takes the level as a required argument, so the domain module + * never guesses; this line is the single answer to "what does an omitted + * field mean", which keeps an older client's payload valid without a second + * copy of the same decision further down. A grant is READ or WRITE from the + * moment it is offered and stays that way: there is no endpoint that widens + * a live grant, because widening it would carry one consent and it would not + * be the delegate's. The way up is a new invitation, accepted again. + */ + access: z + .enum(["READ", "WRITE"]) + .default("READ") + .describe( + "READ can read the record and change nothing. WRITE can additionally ADD entries (readings, results, observations, a medication, a marked dose) and can still edit or delete nothing, including its own. Omitted means READ, so a client that predates the field keeps working. The level is fixed when the invitation is written: no endpoint raises a live grant, because that would widen what the delegate accepted without asking them again. The way up is a new invitation the delegate accepts.", + ), }); export type InviteGrantBody = z.infer; diff --git a/tests/integration/account-grant-lifecycle.test.ts b/tests/integration/account-grant-lifecycle.test.ts index 908c240f4..a1afdb53f 100644 --- a/tests/integration/account-grant-lifecycle.test.ts +++ b/tests/integration/account-grant-lifecycle.test.ts @@ -32,6 +32,7 @@ import { GrantError, acceptGrant, findActiveGrant, + grantAllows, inviteGrant, renounceGrant, revokeGrant, @@ -98,6 +99,7 @@ async function acceptedGrant() { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); const grant = await acceptGrant({ grantId: invited.id, @@ -120,6 +122,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); expect(invited.acceptedAt).toBeNull(); expect(invited.access).toBe("READ"); @@ -147,10 +150,42 @@ describe("account grant lifecycle", () => { expect(active?.id).toBe(invited.id); }); + it("stores the level the invitation offered, and confers it only once accepted", async () => { + // The column, not the argument: a write grant is the one thing in this + // feature that lets somebody put data into another person's record, so the + // proof that WRITE survives the round trip belongs against real Postgres + // rather than against a client fake. + const owner = await makeUser("owner"); + const delegate = await makeUser("delegate"); + + const invited = await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "WRITE", + }); + const stored = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invited.id }, + }); + expect(stored.access).toBe("WRITE"); + // Offered is not accepted. Both consents or nothing. + expect(grantAllows(stored, "write")).toBe(false); + + await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); + const live = await getPrismaClient().accountGrant.findUniqueOrThrow({ + where: { id: invited.id }, + }); + expect(grantAllows(live, "write")).toBe(true); + expect(grantAllows(live, "read")).toBe(true); + }); + it("refuses an account sharing its record with itself", async () => { const solo = await makeUser("solo"); await expect( - inviteGrant({ grantorId: solo.id, granteeId: solo.id }), + inviteGrant({ + grantorId: solo.id, + granteeId: solo.id, + access: "READ", + }), ).rejects.toMatchObject({ code: "self_grant" }); expect(await getPrismaClient().accountGrant.count()).toBe(0); }); @@ -163,6 +198,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); await expect( @@ -201,6 +237,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", expiresAt: new Date(Date.now() - 1000), }); @@ -215,6 +252,7 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", expiresAt: new Date(Date.now() + 60_000), }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); @@ -331,13 +369,25 @@ describe("account grant lifecycle", () => { it("refuses a second live grant for the same pair", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); - await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }); await expect( - inviteGrant({ grantorId: owner.id, granteeId: delegate.id }), + inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }), ).rejects.toBeInstanceOf(GrantError); await expect( - inviteGrant({ grantorId: owner.id, granteeId: delegate.id }), + inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }), ).rejects.toMatchObject({ code: "duplicate_live_grant" }); expect(await getPrismaClient().accountGrant.count()).toBe(1); }); @@ -352,6 +402,7 @@ describe("account grant lifecycle", () => { const second = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); expect(second.id).not.toBe(first.id); expect(second.acceptedAt).toBeNull(); @@ -384,10 +435,15 @@ describe("account grant lifecycle", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); await revokeGrant({ grantId: invited.id, grantorId: owner.id }); } - await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }); expect( await getPrismaClient().accountGrant.count({ where: { grantorId: owner.id, granteeId: delegate.id }, @@ -484,6 +540,7 @@ describe("account grant lifecycle paths", () => { const liveInvite = await inviteGrant({ grantorId: admin.id, granteeId: live.id, + access: "READ", }); const liveGrant = await acceptGrant({ grantId: liveInvite.id, @@ -492,6 +549,7 @@ describe("account grant lifecycle paths", () => { const droppedInvite = await inviteGrant({ grantorId: admin.id, granteeId: dropped.id, + access: "READ", }); await acceptGrant({ grantId: droppedInvite.id, granteeId: dropped.id }); const revoked = await revokeGrant({ diff --git a/tests/integration/acting-account-resolver.test.ts b/tests/integration/acting-account-resolver.test.ts index 37ce752e3..819afec3f 100644 --- a/tests/integration/acting-account-resolver.test.ts +++ b/tests/integration/acting-account-resolver.test.ts @@ -162,6 +162,7 @@ async function household() { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); const grant = await acceptGrant({ grantId: invited.id, @@ -260,6 +261,7 @@ describe("acting-account resolver — the decision is never cached", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", expiresAt: new Date(Date.now() + 400), }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); @@ -275,7 +277,11 @@ describe("acting-account resolver — the decision is never cached", () => { it("refuses the moment the grant is only pending", async () => { const owner = await makeUser("owner"); const delegate = await makeUser("delegate"); - await inviteGrant({ grantorId: owner.id, granteeId: delegate.id }); + await inviteGrant({ + grantorId: owner.id, + granteeId: delegate.id, + access: "READ", + }); const session = await signIn(delegate.id); await switchTo(session.id, owner.id); diff --git a/tests/integration/sharing-audit-actor.test.ts b/tests/integration/sharing-audit-actor.test.ts index 422e87c25..a304b7ab1 100644 --- a/tests/integration/sharing-audit-actor.test.ts +++ b/tests/integration/sharing-audit-actor.test.ts @@ -98,6 +98,7 @@ async function household() { const invited = await inviteGrant({ grantorId: owner.id, granteeId: delegate.id, + access: "READ", }); await acceptGrant({ grantId: invited.id, granteeId: delegate.id }); const session = await signIn(delegate.id); @@ -223,6 +224,7 @@ describe("delegated reads coalesce to one row a day", () => { const invited = await inviteGrant({ grantorId: owner.id, granteeId: second.id, + access: "READ", }); await acceptGrant({ grantId: invited.id, granteeId: second.id }); From d82e29737b9dd9e24ebfa8b087db6bdf64fd7779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 10:12:22 +0200 Subject: [PATCH 5/9] Say what the level means on both screens that decide it The invitation form gains the choice, and the option labels say what actually ships rather than the word "write": a delegate can add readings, results, observations, a medication and a marked dose, and can edit or delete nothing, including what they entered a moment ago. That gap between what "write access" sounds like and what it does is where a support case lives, so it is on the card rather than in a reply. The delegate reads the same thing before they accept. A pending write invitation states its level and one sentence about what accepting means, on the screen where they accept it, because acceptance is where the two consents meet and it happens once. Ending an access says what stays: the entries remain and the owner can remove any of them, and the activity view keeps saying who entered what for as long as the instance's audit window reaches. That number comes from the server, so an instance running ninety days says ninety, and a browser that has not been told says nothing rather than a made-up year. The browser journey now invites at the wider level and reads the posted body for it. That is the only check in the tree that can: the component convention is server-rendered only, so no test under src/ runs the form's submit path, and every one of them stays green with the control wired to a constant. --- e2e/account-sharing.spec.ts | 29 +++ messages/de.json | 8 + messages/en.json | 8 + messages/es.json | 8 + messages/fr.json | 8 + messages/it.json | 8 + messages/pl.json | 8 + .../__tests__/grant-access-level.test.tsx | 174 ++++++++++++++++++ .../settings/access/grant-invite-card.tsx | 106 ++++++++++- src/components/settings/access/grant-row.tsx | 14 ++ .../settings/access/grants-given-card.tsx | 58 +++++- .../settings/access/grants-received-card.tsx | 22 +++ src/lib/queries/use-account-grants.ts | 7 + 13 files changed, 451 insertions(+), 7 deletions(-) create mode 100644 src/components/settings/access/__tests__/grant-access-level.test.tsx diff --git a/e2e/account-sharing.spec.ts b/e2e/account-sharing.spec.ts index fdc54aac3..6a19aff1d 100644 --- a/e2e/account-sharing.spec.ts +++ b/e2e/account-sharing.spec.ts @@ -208,6 +208,21 @@ test.describe("account sharing", () => { const chosenDay = isoDayFromNow(30); await lapse.fill(chosenDay); + // The level, through the real control and onto the real wire. + // + // Same class of gap as the lapse date above, and the reason it is checked + // here rather than in a component test is worth stating: the project's + // component convention is SSR-only, so no test under `src/` can run this + // form's submit path at all. Wire the radio back to a constant and every + // one of those stays green. Only the posted body can tell the level a + // person picked from the level the card sends. + const writeOption = ownerPage.locator( + '[data-slot="grant-invite-access-option"][data-access="WRITE"]', + ); + await expect(writeOption).toBeVisible(); + await writeOption.click(); + await expect(writeOption).toHaveAttribute("data-selected", "true"); + const invitePost = ownerPage.waitForRequest( (req) => req.method() === "POST" && req.url().endsWith("/api/account/grants"), @@ -215,6 +230,7 @@ test.describe("account sharing", () => { await submit.click(); const posted = JSON.parse((await invitePost).postData() ?? "{}") as { expiresAt?: string | null; + access?: string; }; expect( posted.expiresAt, @@ -222,6 +238,10 @@ test.describe("account sharing", () => { ).toBeTruthy(); // End of the chosen day, not the midnight that starts it. expect(posted.expiresAt).toContain(chosenDay.slice(0, 8)); + expect( + posted.access, + "the invitation must carry the level the owner chose", + ).toBe("WRITE"); const row = ownerPage .locator('[data-slot="grants-given-list"] [data-slot="grant-row"]') @@ -229,6 +249,9 @@ test.describe("account sharing", () => { await expect(row).toBeVisible(); // Pending, not active. The state is the server's answer, rendered. await expect(row).toHaveAttribute("data-grant-state", "PENDING"); + // And the level came back from the database on the list read, which is the + // far end of the same wire: form → POST → row → GET → attribute. + await expect(row).toHaveAttribute("data-grant-access", "WRITE"); }); test("the delegate is offered nothing until they accept", async ({ @@ -241,6 +264,12 @@ test.describe("account sharing", () => { .first(); await expect(row).toBeVisible(); await expect(row).toHaveAttribute("data-grant-state", "PENDING"); + // The delegate's half of the consent: what they are being offered, and + // what accepting it will mean, on the screen where they accept it. + await expect(row).toHaveAttribute("data-grant-access", "WRITE"); + await expect( + row.locator('[data-slot="grant-write-consent"]'), + ).toBeVisible(); // No switcher yet: `accountAccess.canSwitch` is false while the only // grant is pending, and the menu binds that boolean. diff --git a/messages/de.json b/messages/de.json index 68ee1bc1b..cb3bb119c 100644 --- a/messages/de.json +++ b/messages/de.json @@ -9424,6 +9424,11 @@ "identifierLabel": "Benutzername oder E-Mail", "identifierPlaceholder": "Der Name, mit dem sie sich anmeldet", "identifierHint": "Die Person braucht ein Konto auf dieser Instanz. An eine E-Mail-Adresse ohne Registrierung lässt sich keine Einladung schicken.", + "accessLegend": "Was die Person darf", + "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, Allergien und andere Beobachtungen, ein neues Medikament und eine als genommen markierte Dosis. Ändern oder löschen kann sie nichts, auch nicht das, was sie selbst eingetragen hat. 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", @@ -9446,6 +9451,8 @@ "withdrawInvite": "Zurückziehen", "revokeTitle": "Zugriff von {name} beenden?", "revokeBody": "Der Zugriff endet sofort, auch in jedem Browser, in dem deine Akte gerade offen ist. Was bereits gesehen wurde, lässt sich nicht zurückholen. Du kannst jederzeit erneut einladen.", + "revokeEntriesStay": "Was diese Person eingetragen hat, bleibt in deiner Akte, und du kannst alles davon selbst entfernen.", + "revokeAttribution": "Wer was eingetragen hat, bleibt {days} Tage lang in deiner Aktivitätsübersicht sichtbar.", "revokeConfirm": "Zugriff beenden" }, "received": { @@ -9456,6 +9463,7 @@ "loadError": "Die Liste konnte nicht geladen werden.", "accept": "Annehmen", "accepting": "Wird angenommen…", + "writeConsent": "Wenn du annimmst, wird das, was du hinzufügst, Teil der Akte von {name}. Ändern oder entfernen kannst du deine Einträge danach nicht mehr; das kann nur {name}.", "open": "Öffnen", "renounce": "Zurückgeben", "renounceTitle": "Akte von {name} zurückgeben?", diff --git a/messages/en.json b/messages/en.json index eaa97402e..cd9607368 100644 --- a/messages/en.json +++ b/messages/en.json @@ -9424,6 +9424,11 @@ "identifierLabel": "Username or e-mail", "identifierPlaceholder": "The name they sign in with", "identifierHint": "They need an account on this instance. Invitations cannot be sent to an e-mail address that has not registered.", + "accessLegend": "What they may do", + "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, allergies and other observations, a new medication, and marking a dose as taken. They cannot edit or delete anything, not even what they added. 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", @@ -9446,6 +9451,8 @@ "withdrawInvite": "Withdraw", "revokeTitle": "End {name}'s access?", "revokeBody": "They lose access immediately, including any browser they have your record open in. What they have already seen cannot be taken back. You can invite them again at any time.", + "revokeEntriesStay": "Anything they entered stays in your record, and you can remove any of it yourself.", + "revokeAttribution": "Who entered what stays visible in your activity view for {days} days.", "revokeConfirm": "End access" }, "received": { @@ -9456,6 +9463,7 @@ "loadError": "The list could not be loaded.", "accept": "Accept", "accepting": "Accepting…", + "writeConsent": "If you accept, what you add becomes part of {name}'s record. You will not be able to change or remove entries afterwards; only {name} can.", "open": "Open", "renounce": "Hand back", "renounceTitle": "Hand {name}'s record back?", diff --git a/messages/es.json b/messages/es.json index 6b6cc1188..bf879ea85 100644 --- a/messages/es.json +++ b/messages/es.json @@ -9424,6 +9424,11 @@ "identifierLabel": "Usuario o correo electrónico", "identifierPlaceholder": "El nombre con el que inicia sesión", "identifierHint": "Necesita una cuenta en esta instancia. No se pueden enviar invitaciones a una dirección que no se haya registrado.", + "accessLegend": "Qué puede hacer", + "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 cosas al historial: mediciones, resultados de laboratorio, alergias y otras observaciones, un medicamento nuevo y una dosis marcada como tomada. No puede editar ni eliminar nada, tampoco lo que ha añadido. 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", @@ -9446,6 +9451,8 @@ "withdrawInvite": "Retirar", "revokeTitle": "¿Finalizar el acceso de {name}?", "revokeBody": "Pierde el acceso de inmediato, incluido cualquier navegador donde tenga tu historial abierto. Lo que ya ha visto no se puede recuperar. Puedes volver a invitarle cuando quieras.", + "revokeEntriesStay": "Lo que haya añadido se queda en tu historial, y puedes eliminarlo tú cuando quieras.", + "revokeAttribution": "Quién añadió qué sigue visible en tu vista de actividad durante {days} días.", "revokeConfirm": "Finalizar acceso" }, "received": { @@ -9456,6 +9463,7 @@ "loadError": "No se pudo cargar la lista.", "accept": "Aceptar", "accepting": "Aceptando…", + "writeConsent": "Si aceptas, lo que añadas pasa a formar parte del historial de {name}. Después no podrás cambiar ni eliminar entradas; solo {name} puede.", "open": "Abrir", "renounce": "Devolver", "renounceTitle": "¿Devolver el historial de {name}?", diff --git a/messages/fr.json b/messages/fr.json index 6c6f91a58..f62db527a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -9424,6 +9424,11 @@ "identifierLabel": "Nom d'utilisateur ou e-mail", "identifierPlaceholder": "Le nom avec lequel elle se connecte", "identifierHint": "Cette personne a besoin d'un compte sur cette instance. Une invitation ne peut pas être envoyée à une adresse non inscrite.", + "accessLegend": "Ce que cette personne peut faire", + "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 des choses au dossier : des mesures, des résultats d'analyses, des allergies et d'autres observations, un nouveau médicament, et une prise marquée comme faite. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté. Vous êtes la seule personne à pouvoir le faire.", "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", @@ -9446,6 +9451,8 @@ "withdrawInvite": "Retirer", "revokeTitle": "Mettre fin à l'accès de {name} ?", "revokeBody": "L'accès cesse immédiatement, y compris dans tout navigateur où votre dossier est ouvert. Ce qui a déjà été vu ne peut pas être repris. Vous pouvez inviter de nouveau à tout moment.", + "revokeEntriesStay": "Ce que cette personne a saisi reste dans votre dossier, et vous pouvez en supprimer ce que vous voulez.", + "revokeAttribution": "Qui a saisi quoi reste visible dans votre vue d'activité pendant {days} jours.", "revokeConfirm": "Mettre fin à l'accès" }, "received": { @@ -9456,6 +9463,7 @@ "loadError": "La liste n'a pas pu être chargée.", "accept": "Accepter", "accepting": "Acceptation…", + "writeConsent": "Si vous acceptez, ce que vous ajoutez fait partie du dossier de {name}. Vous ne pourrez plus modifier ni supprimer vos entrées ensuite ; {name} est la seule personne à pouvoir le faire.", "open": "Ouvrir", "renounce": "Rendre", "renounceTitle": "Rendre le dossier de {name} ?", diff --git a/messages/it.json b/messages/it.json index d6e1847c4..524a67662 100644 --- a/messages/it.json +++ b/messages/it.json @@ -9424,6 +9424,11 @@ "identifierLabel": "Nome utente o e-mail", "identifierPlaceholder": "Il nome con cui accede", "identifierHint": "Serve un account su questa istanza. Non è possibile inviare inviti a un indirizzo non registrato.", + "accessLegend": "Che cosa può fare", + "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 qualcosa alla cartella: misurazioni, risultati di laboratorio, allergie e altre osservazioni, un nuovo farmaco e una dose segnata come presa. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto. Puoi farlo solo tu.", "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", @@ -9446,6 +9451,8 @@ "withdrawInvite": "Ritira", "revokeTitle": "Terminare l'accesso di {name}?", "revokeBody": "L'accesso finisce subito, anche in qualsiasi browser in cui la tua cartella è aperta. Ciò che è già stato visto non può essere ritirato. Puoi invitare di nuovo quando vuoi.", + "revokeEntriesStay": "Ciò che questa persona ha inserito resta nella tua cartella e sei tu a poterne rimuovere qualsiasi parte.", + "revokeAttribution": "Chi ha inserito che cosa resta visibile nella tua vista attività per {days} giorni.", "revokeConfirm": "Termina accesso" }, "received": { @@ -9456,6 +9463,7 @@ "loadError": "Non è stato possibile caricare l'elenco.", "accept": "Accetta", "accepting": "Accettazione…", + "writeConsent": "Se accetti, ciò che aggiungi entra a far parte della cartella di {name}. Dopo non potrai più modificare o rimuovere le voci; può farlo solo {name}.", "open": "Apri", "renounce": "Restituisci", "renounceTitle": "Restituire la cartella di {name}?", diff --git a/messages/pl.json b/messages/pl.json index 7ca42645b..b9d428617 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -9424,6 +9424,11 @@ "identifierLabel": "Nazwa użytkownika lub e-mail", "identifierPlaceholder": "Nazwa, którą się loguje", "identifierHint": "Ta osoba potrzebuje konta na tej instancji. Nie można wysłać zaproszenia na niezarejestrowany adres.", + "accessLegend": "Co ta osoba może robić", + "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 karty: pomiary, wyniki badań, alergie i inne obserwacje, nowy lek oraz oznaczenie dawki jako przyjętej. Nie może niczego edytować ani usuwać, także własnych wpisów. Możesz to zrobić tylko ty.", "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", @@ -9446,6 +9451,8 @@ "withdrawInvite": "Wycofaj", "revokeTitle": "Zakończyć dostęp dla {name}?", "revokeBody": "Dostęp kończy się natychmiast, także w każdej przeglądarce, w której twoja karta jest otwarta. Tego, co już zostało zobaczone, nie da się cofnąć. Możesz zaprosić ponownie w dowolnej chwili.", + "revokeEntriesStay": "To, co ta osoba wpisała, zostaje w twojej karcie i możesz to samodzielnie usunąć.", + "revokeAttribution": "Kto co wpisał, pozostaje widoczne w widoku aktywności przez {days} dni.", "revokeConfirm": "Zakończ dostęp" }, "received": { @@ -9456,6 +9463,7 @@ "loadError": "Nie udało się wczytać listy.", "accept": "Akceptuj", "accepting": "Akceptowanie…", + "writeConsent": "Jeśli zaakceptujesz, to, co dodasz, stanie się częścią karty użytkownika {name}. Później nie będziesz mieć możliwości zmiany ani usunięcia wpisów; może to zrobić tylko {name}.", "open": "Otwórz", "renounce": "Zwróć", "renounceTitle": "Zwrócić kartę użytkownika {name}?", diff --git a/src/components/settings/access/__tests__/grant-access-level.test.tsx b/src/components/settings/access/__tests__/grant-access-level.test.tsx new file mode 100644 index 000000000..1a907ba97 --- /dev/null +++ b/src/components/settings/access/__tests__/grant-access-level.test.tsx @@ -0,0 +1,174 @@ +/** + * The access level, on the three screens that decide it. + * + * What an SSR render can prove, and what it cannot, stated up front because the + * distinction is the reason this file is shaped the way it is. It can prove + * that a control exists, that a sentence is on the page beside it, and that a + * row states its level before the button that accepts it. It CANNOT prove that + * the chosen level reaches the server: there is no click here, so the submit + * path never runs, and every assertion below stays green with the form wired + * back to a constant. That half is the browser journey's + * (`e2e/account-sharing.spec.ts`, which reads the posted body), and the + * database half is `src/app/api/account/grants/__tests__/`. + * + * The invitation copy is asserted by its promise rather than by its wording: + * "cannot edit or delete" is the sentence somebody's expectation is set by, and + * a rewrite that quietly drops it should fail here. + */ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { I18nProvider } from "@/lib/i18n/context"; +import type { GrantList, GrantRow } from "@/lib/queries/use-account-grants"; + +const grantsRef: { value: GrantList } = { value: { given: [], received: [] } }; + +vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + useInviteGrant: () => ({ mutate: vi.fn(), isPending: false }), + useAccountGrants: () => ({ + data: grantsRef.value, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useRecordActivity: () => ({ + data: undefined, + isLoading: false, + isError: false, + refetch: vi.fn(), + }), + useAcceptGrant: () => ({ mutate: vi.fn(), isPending: false }), + useRenounceGrant: () => ({ mutate: vi.fn(), isPending: false }), + useRevokeGrant: () => ({ mutate: vi.fn(), isPending: false }), + }; +}); + +vi.mock("@/hooks/use-account-switch", () => ({ + useAccountSwitch: () => ({ mutate: vi.fn(), isPending: false }), +})); + +import { GrantInviteCard } from "../grant-invite-card"; +import { GrantsReceivedCard } from "../grants-received-card"; +import { revokeBody } from "../grants-given-card"; + +function render(node: React.ReactNode): string { + return renderToStaticMarkup( + {node}, + ); +} + +const BASE: GrantRow = { + id: "g1", + account: { id: "acct-1", username: "housemate", displayName: "Jo" }, + access: "READ", + state: "PENDING", + invitedAt: "2026-01-02T10:00:00.000Z", + acceptedAt: null, + expiresAt: null, + lastUsedAt: null, + revokedAt: null, + revokedBy: null, +}; + +describe("offering a level", () => { + it("offers both levels and preselects the narrower one", () => { + const html = render(); + expect(html).toContain('data-slot="grant-invite-access"'); + expect(html).toContain('data-access="READ"'); + expect(html).toContain('data-access="WRITE"'); + // The narrow level is the one somebody chooses their way out of. + expect(html).toContain('data-access="READ" data-selected="true"'); + expect(html).toContain('data-access="WRITE" data-selected="false"'); + }); + + it("says what the wider level actually does, and what it does not", () => { + const html = render(); + // The thing a person reading "write access" gets wrong. + expect(html).toContain("cannot edit or delete anything"); + expect(html).toContain("marking a dose as taken"); + // And the narrow option still says it is narrow. + expect(html).toContain("They cannot add or change anything."); + }); +}); + +describe("accepting a level", () => { + it("states the level and what accepting it means before the button", () => { + grantsRef.value = { + given: [], + received: [{ ...BASE, access: "WRITE" }], + }; + const html = render(); + + expect(html).toContain('data-grant-access="WRITE"'); + expect(html).toContain("Can read and add entries"); + expect(html).toContain('data-slot="grant-write-consent"'); + // In document order the consent sits ahead of the control it qualifies. + expect(html.indexOf('data-slot="grant-write-consent"')).toBeLessThan( + html.indexOf('data-slot="grant-accept"'), + ); + }); + + it("does not ask a read invitation for a write consent", () => { + grantsRef.value = { given: [], received: [{ ...BASE, access: "READ" }] }; + const html = render(); + expect(html).toContain('data-grant-access="READ"'); + expect(html).not.toContain('data-slot="grant-write-consent"'); + }); + + it("stops saying it once the invitation has been answered", () => { + // A sentence about a decision already taken is nagging, not consent. + grantsRef.value = { + given: [], + received: [ + { + ...BASE, + access: "WRITE", + state: "ACTIVE", + acceptedAt: "2026-01-02T11:00:00.000Z", + }, + ], + }; + const html = render(); + expect(html).not.toContain('data-slot="grant-write-consent"'); + }); +}); + +describe("ending an access, and what it says about what was entered", () => { + const t = (key: string, params?: Record): string => + params ? `${key}(${JSON.stringify(params)})` : key; + + it("says nothing about entries for a grant that could not make any", () => { + const body = revokeBody({ + t, + access: "READ", + name: "Jo", + retentionDays: 90, + }); + expect(body).toContain("recordSharing.given.revokeBody"); + expect(body).not.toContain("revokeEntriesStay"); + expect(body).not.toContain("revokeAttribution"); + }); + + it("tells a write owner that the entries stay and stay attributed", () => { + const body = revokeBody({ + t, + access: "WRITE", + name: "Jo", + retentionDays: 90, + }); + expect(body).toContain("recordSharing.given.revokeEntriesStay"); + expect(body).toContain('revokeAttribution({"days":90})'); + }); + + it("omits the window rather than inventing one", () => { + // The number is the operator's setting. A compiled-in 365 here would be + // exactly the invented value the sentence exists to avoid. + const body = revokeBody({ t, access: "WRITE", name: "Jo" }); + expect(body).toContain("recordSharing.given.revokeEntriesStay"); + expect(body).not.toContain("revokeAttribution"); + }); +}); diff --git a/src/components/settings/access/grant-invite-card.tsx b/src/components/settings/access/grant-invite-card.tsx index e54573446..ee9043738 100644 --- a/src/components/settings/access/grant-invite-card.tsx +++ b/src/components/settings/access/grant-invite-card.tsx @@ -13,12 +13,21 @@ import { useTranslations } from "@/lib/i18n/context"; import { useInviteGrant } from "@/lib/queries/use-account-grants"; /** - * v1.36.0 — offering somebody read access to this record. + * v1.36.0 — offering somebody access to this record. * - * One field, because there is one decision: who. The access level is not - * offered — v1 grants read and only read, and a control whose only setting is - * its default is a promise about a future release rather than a choice about - * this one. + * Two decisions: who, and what they may do. The level is a choice at the + * invitation and only there — a grant is READ or WRITE from the moment it is + * offered, nothing widens it later, and the way to a wider one is a fresh + * invitation the other person accepts again. So this form is the one screen + * where the owner's half of that consent is given, and the option labels have + * to be worth reading. + * + * The capability sentences say what actually ships rather than the word + * "write", which people read as larger than it is. A delegate can ADD: + * readings, results, observations, a medication, a dose marked as taken. They + * cannot edit or delete anything, including what they added ten seconds ago. + * A person who learns that from a support reply rather than from this card was + * not told. * * The invitation confers nothing until the other person accepts it. The card * says so above the field rather than after the submit, because that is the @@ -35,6 +44,9 @@ export function GrantInviteCard() { const invite = useInviteGrant(); const [identifier, setIdentifier] = useState(""); const [expiresOn, setExpiresOn] = useState(""); + // READ preselected. The narrower level is the one somebody should have to + // choose their way out of, not into. + const [access, setAccess] = useState("READ"); const [error, setError] = useState(null); const [invited, setInvited] = useState(null); @@ -45,11 +57,12 @@ export function GrantInviteCard() { setError(null); setInvited(null); invite.mutate( - { identifier: value, expiresAt: endOfDayIso(expiresOn) }, + { identifier: value, access, expiresAt: endOfDayIso(expiresOn) }, { onSuccess: (grant) => { setIdentifier(""); setExpiresOn(""); + setAccess("READ"); setInvited(grant.account.username); }, onError: (err) => setError(t(inviteErrorKey(err))), @@ -82,6 +95,30 @@ export function GrantInviteCard() { {t("recordSharing.invite.identifierHint")}

+
+ {/* The `Label` primitive is a `
{actions &&
{actions}
} diff --git a/src/components/settings/access/grants-given-card.tsx b/src/components/settings/access/grants-given-card.tsx index 756447179..006da72b3 100644 --- a/src/components/settings/access/grants-given-card.tsx +++ b/src/components/settings/access/grants-given-card.tsx @@ -12,6 +12,7 @@ import { useTranslations } from "@/lib/i18n/context"; import { accountLabel } from "@/lib/sharing/account-access-view"; import { useAccountGrants, + useRecordActivity, useRevokeGrant, type GrantRow, } from "@/lib/queries/use-account-grants"; @@ -29,11 +30,21 @@ import { * health record to somebody. The confirm dialog exists only because the click * target sits in a list and a mis-tap should not end an access silently; it * names the person and the act. + * + * What the dialog SAYS depends on what the grant could do: see + * {@link revokeBody}. */ + export function GrantsGivenCard() { const { t } = useTranslations(); const { data, isLoading, isError, refetch } = useAccountGrants(); const revoke = useRevokeGrant(); + // Read for one number: how long the activity view can still say who entered + // what. The activity card on this same page holds the query, so this is the + // cached answer rather than a second request — and when the server has not + // answered yet, the sentence that would need the number is left out instead + // of being written around a guessed 365. + const activity = useRecordActivity(); const given = data?.given ?? []; @@ -95,7 +106,12 @@ export function GrantsGivenCard() { : t("recordSharing.given.revoke") } title={t("recordSharing.given.revokeTitle", { name })} - body={t("recordSharing.given.revokeBody", { name })} + body={revokeBody({ + t, + access: grant.access, + name, + retentionDays: activity.data?.retentionDays, + })} confirmLabel={t("recordSharing.given.revokeConfirm")} pending={revoke.isPending} onConfirm={() => revoke.mutate(grant.id)} @@ -103,3 +119,43 @@ export function GrantsGivenCard() { ); } } + +/** + * What ending this particular access does, as one paragraph. + * + * Exported and pure because the dialog it feeds only exists after a click, and + * an SSR render cannot see inside it — the same reason `endOfDayIso` is + * exported from the invite card. The composition is the thing worth pinning: + * which sentences appear, and which one is omitted when its number is unknown. + * + * Two extra sentences for a grant that could add entries, and only for one: a + * read grant never put anything in the record, so telling its owner that + * "anything they entered stays" would describe something that cannot have + * happened. The last sentence is the honest bound on attribution — the activity + * view answers "who entered what" for as long as the audit window reaches, and + * that window is the operator's setting. It is said with the number the server + * resolved, or not said at all. + */ +export function revokeBody({ + t, + access, + name, + retentionDays, +}: { + t: (key: string, params?: Record) => string; + access: GrantRow["access"]; + name: string; + /** Undefined until the server has said what the window is. */ + retentionDays?: number; +}): string { + const lines = [t("recordSharing.given.revokeBody", { name })]; + if (access === "WRITE") { + lines.push(t("recordSharing.given.revokeEntriesStay")); + if (retentionDays !== undefined) { + lines.push( + t("recordSharing.given.revokeAttribution", { days: retentionDays }), + ); + } + } + return lines.join(" "); +} diff --git a/src/components/settings/access/grants-received-card.tsx b/src/components/settings/access/grants-received-card.tsx index 73ad296fe..4ff35cf68 100644 --- a/src/components/settings/access/grants-received-card.tsx +++ b/src/components/settings/access/grants-received-card.tsx @@ -78,6 +78,7 @@ export function GrantsReceivedCard() { key={grant.id} grant={grant} side="received" + notice={renderConsent(grant)} actions={renderActions(grant)} /> ))} @@ -87,6 +88,27 @@ export function GrantsReceivedCard() { ); + /** + * The delegate's half of the consent, on the screen where they give it. + * + * A write invitation asks for something a read invitation does not: that the + * person put entries into somebody else's health record under their own + * name, permanently, with no way to take one back. That is worth one + * sentence before the button rather than a sentence after the first entry. + * Shown only while the invitation is unanswered — once accepted, the level + * is the row's own line and this would be nagging. + */ + function renderConsent(grant: GrantRow) { + if (grant.state !== "PENDING" || grant.access !== "WRITE") return null; + return ( +

+ {t("recordSharing.received.writeConsent", { + name: accountLabel(grant.account), + })} +

+ ); + } + function renderActions(grant: GrantRow) { if (grant.state === "PENDING") { return ( diff --git a/src/lib/queries/use-account-grants.ts b/src/lib/queries/use-account-grants.ts index 8ea22df03..453bcca46 100644 --- a/src/lib/queries/use-account-grants.ts +++ b/src/lib/queries/use-account-grants.ts @@ -120,6 +120,13 @@ export interface InviteInput { identifier: string; /** ISO-8601 with offset, or null for a grant that runs until somebody ends it. */ expiresAt: string | null; + /** + * What the invitation offers. Required rather than optional even though the + * server defaults an omitted field to READ: a caller that does not name the + * level has not decided it, and this is the one moment the level can be + * decided at all. + */ + access: "READ" | "WRITE"; } export function useInviteGrant() { From f24ae6deb8580b760f811efff4045fb7bff569e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 10:13:55 +0200 Subject: [PATCH 6/9] Read the acting-account carrier from one place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule that decides what a request says it is acting as — session stamp on the cookie transport, selector header on Bearer, a header on a cookie request naming nothing — sat inside the resolver that acts on the answer. A second caller needs the same question answered before any auth context exists, and two implementations of "whose record is this" drift; the copy that drifts is the one nobody reads. Move the decision, the header name and the two predicates into `auth/acting-carrier`, as a pure function of what the transport carried. The resolver keeps what only it may do: turning a claim into a data scope once a live grant says so. No behaviour changes — the resolver's arms are the same arms, and its suite passes untouched. The header-name guard follows the constant to its new file. --- .../acting-account-boundary-guard.test.ts | 2 +- src/lib/api-handler.ts | 108 ++++-------------- src/lib/auth/acting-carrier.ts | 105 +++++++++++++++++ 3 files changed, 130 insertions(+), 85 deletions(-) create mode 100644 src/lib/auth/acting-carrier.ts diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index 1b01ae55f..c842443b8 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -113,7 +113,7 @@ describe("the acting-account carrier is read in one place", () => { const namers = filesMatching(/x-healthlog-account/i); expect(namers.length).toBeGreaterThan(0); expect(namers).toEqual([ - "lib/api-handler.ts", + "lib/auth/acting-carrier.ts", "lib/openapi/routes/account-sharing.ts", ]); }); diff --git a/src/lib/api-handler.ts b/src/lib/api-handler.ts index e4899cbcb..187b362f5 100644 --- a/src/lib/api-handler.ts +++ b/src/lib/api-handler.ts @@ -19,6 +19,13 @@ import { STEP_UP_ELEVATION_TTL_SECONDS, } from "./auth/step-up"; import { hashToken } from "./auth/hmac"; +import { + carrierTarget, + decideActingCarrier, + readSelectorHeader, + selectorNamesAnAccount, + type ActingCarrier, +} from "./auth/acting-carrier"; import { AssistantDisabledError } from "./feature-flags"; import { ConsentRequiredError } from "./ai/consent-guard"; import { SCOPE_HEALTH_READ, SCOPE_HEALTH_WRITE } from "./mcp/oauth/config"; @@ -597,69 +604,23 @@ async function authenticateBearer( // // One function decides, for every transport, whether this request acts on // somebody else's record — the same argument that put `resolveBearerToken` in -// one file for two wires. Two carriers converge here because they are two -// answers to one question, and two answers that live apart drift. +// one file for two wires. Two carriers converge on one decision because they +// are two answers to one question, and two answers that live apart drift. // -// Neither carrier authorises anything. Both merely NAME an account; the grant -// table decides, on this request, with no cached verdict anywhere. The -// difference between them is who can set them: -// -// * The cookie transport carries it on the session ROW (`actingAsUserId`). -// A browser cannot address that row — the cookie holds a secret whose hash -// is the lookup key — so the value is server state, written only by the -// switch endpoint after it has validated the grant. -// * The Bearer transport carries it in a per-request HEADER. A long-lived -// token must not accumulate switch state: stamping it on the ApiToken row -// would make the credential itself ambient authority, and the token has to -// keep meaning "this person" for as long as it exists. -// -// The header is a Bearer-only carrier. On a cookie request it is refused, not -// ignored — a client that sends a selector over a transport that does not read -// one believes it is selecting an account and is not, and the failure of that -// belief is a support case about data that looks wrong. +// That decision now lives in `./auth/acting-carrier`, because a second caller +// needs it: the idempotency wrapper files its replay cell under the record a +// request claims, and it runs before any auth context exists. What stays here +// is what only this file may do — turning a claim into a data scope after a +// live grant says so. /** - * The per-request account selector, on the Bearer transport only. + * The selector header's name, re-exported. * - * Cross-origin JavaScript cannot set it: a custom request header forces a CORS - * preflight and this app sends no CORS headers at all, so the preflight is - * never answered. It is a selector regardless — the grant table is what - * authorises, and the header is treated as hostile input everywhere below. + * It is declared beside the carrier reader it belongs to; every route-facing + * helper is imported from this module, and the OpenAPI table and the suites + * that drive a switched request were written against that path. */ -export const ACCOUNT_SELECTOR_HEADER = "x-healthlog-account"; - -/** - * Longest selector value worth a database round trip. Account ids are 25-char - * cuids; anything past this names no account that could exist and is refused - * as such, on the same path and with the same bytes as an unknown account. - */ -const MAX_SELECTOR_LENGTH = 64; - -/** - * What, if anything, this request says it is acting as. - * - * `misplaced-header` is its own case rather than an error thrown at read time - * so that every mode below decides for itself — the modes disagree about the - * session carrier and must not disagree about this one by accident. - */ -type ActingCarrier = - | { kind: "none" } - | { kind: "session"; accountId: string } - | { kind: "header"; accountId: string } - | { kind: "misplaced-header" }; - -/** The selector header, or null when the request did not send one. */ -async function readSelectorHeader(): Promise { - try { - const headerList = await headers(); - return headerList.get(ACCOUNT_SELECTOR_HEADER); - } catch { - // Outside a Next.js request scope (direct unit invocation of a legacy - // route) there is no header to read. Same posture as the Bearer branch of - // `authenticateCaller`. - return null; - } -} +export { ACCOUNT_SELECTOR_HEADER } from "./auth/acting-carrier"; /** * The acting-account carrier for this request. @@ -677,32 +638,11 @@ async function readSelectorHeader(): Promise { * next request rather than from the next login. */ async function readActingCarrier(auth: AuthContext): Promise { - const header = await readSelectorHeader(); - - if (auth.authMethod === "bearer") { - // No session row exists on this transport, so the header is the only - // carrier there could be. - return header === null - ? { kind: "none" } - : { kind: "header", accountId: header }; - } - - if (header !== null) return { kind: "misplaced-header" }; - - const stamped = auth.session.actingAsUserId ?? null; - return stamped === null - ? { kind: "none" } - : { kind: "session", accountId: stamped }; -} - -/** Could this value name an account at all? */ -function selectorNamesAnAccount(value: string): boolean { - return value.length > 0 && value.length <= MAX_SELECTOR_LENGTH; -} - -/** The account a carrier names, or null when it names nothing. */ -function carrierTarget(carrier: ActingCarrier): string | null { - return "accountId" in carrier ? carrier.accountId : null; + return decideActingCarrier({ + transport: auth.authMethod === "bearer" ? "bearer" : "cookie", + stamped: auth.session.actingAsUserId ?? null, + header: await readSelectorHeader(), + }); } /** diff --git a/src/lib/auth/acting-carrier.ts b/src/lib/auth/acting-carrier.ts new file mode 100644 index 000000000..9be1e116d --- /dev/null +++ b/src/lib/auth/acting-carrier.ts @@ -0,0 +1,105 @@ +/** + * What a request says it is acting as. + * + * It lives apart from `requireRecordAuth`, which acts on the answer, because + * asking the question and deciding what it permits are different jobs and the + * asking is about to have more than one caller. + * + * The claim is a SELECTOR and never an authorisation. Nothing in this file + * looks at a grant, and no caller may treat a carrier as permission: the + * account named here is an id to be checked. What it decides is only which + * account is being named, and by which transport — the question `grants.ts` + * warns must have exactly one answer, because a second implementation of it + * drifts and the copy that drifts is the one nobody reads. + * + * The transport rules, unchanged from where they were first written: + * + * * The cookie transport carries the claim on the session ROW. A browser + * cannot address that row, so the value is server state written only by + * the switch endpoint after it validated a grant. + * * The Bearer transport carries it in a per-request HEADER. A long-lived + * token must not accumulate switch state. + * * The header on a cookie request is `misplaced`, not ignored — a client + * that believes it is selecting an account and is not produces a support + * case about data that looks wrong. + */ +import { headers } from "next/headers"; + +/** + * The per-request account selector, on the Bearer transport only. + * + * Cross-origin JavaScript cannot set it: a custom request header forces a CORS + * preflight and this app sends no CORS headers at all, so the preflight is + * never answered. It is a selector regardless — the grant table is what + * authorises, and the header is treated as hostile input everywhere it is read. + */ +export const ACCOUNT_SELECTOR_HEADER = "x-healthlog-account"; + +/** + * Longest selector value worth a database round trip. Account ids are 25-char + * cuids; anything past this names no account that could exist and is refused + * as such, on the same path and with the same bytes as an unknown account. + */ +export const MAX_SELECTOR_LENGTH = 64; + +/** + * What, if anything, this request says it is acting as. + * + * `misplaced-header` is its own case rather than an error thrown at read time + * so that every caller decides for itself — the callers disagree about what to + * do with a misplaced claim and must not disagree about detecting one. + */ +export type ActingCarrier = + | { kind: "none" } + | { kind: "session"; accountId: string } + | { kind: "header"; accountId: string } + | { kind: "misplaced-header" }; + +/** The selector header, or null when the request did not send one. */ +export async function readSelectorHeader(): Promise { + try { + const headerList = await headers(); + return headerList.get(ACCOUNT_SELECTOR_HEADER); + } catch { + // Outside a Next.js request scope (direct unit invocation of a legacy + // route) there is no header to read. Same posture as the Bearer branch of + // `authenticateCaller`. + return null; + } +} + +/** + * The carrier decision itself, as a pure function of what the transport + * carried. Pure on purpose: it is the one statement of the rule, and a rule + * that needs a request context to be read is a rule that gets re-implemented. + */ +export function decideActingCarrier(input: { + transport: "cookie" | "bearer"; + /** The session row's stamp. Ignored on the Bearer transport, which has none. */ + stamped: string | null; + header: string | null; +}): ActingCarrier { + if (input.transport === "bearer") { + // No session row exists on this transport, so the header is the only + // carrier there could be. + return input.header === null + ? { kind: "none" } + : { kind: "header", accountId: input.header }; + } + + if (input.header !== null) return { kind: "misplaced-header" }; + + return input.stamped === null + ? { kind: "none" } + : { kind: "session", accountId: input.stamped }; +} + +/** The account a carrier names, or null when it names nothing. */ +export function carrierTarget(carrier: ActingCarrier): string | null { + return "accountId" in carrier ? carrier.accountId : null; +} + +/** Could this value name an account at all? */ +export function selectorNamesAnAccount(value: string): boolean { + return value.length > 0 && value.length <= MAX_SELECTOR_LENGTH; +} From ca3a654e47c99fe25153ca9449946d772622f700 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 10:14:06 +0200 Subject: [PATCH 7/9] Say what a grant is now that it can carry two levels The module header still described a permission to read, and the grantee field still said "invited to read it". Both are half the sentence once the level is a choice. --- src/lib/sharing/grants.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/src/lib/sharing/grants.ts b/src/lib/sharing/grants.ts index c3765a3be..7c984ab91 100644 --- a/src/lib/sharing/grants.ts +++ b/src/lib/sharing/grants.ts @@ -1,12 +1,12 @@ /** * v1.36.0 — the account-grant state machine. One file, one set of rules. * - * A grant is one account's standing permission to read another account's - * record, and since v1.36.x to add to it as well, and the row is also the - * durable record of the consent that created it (there is no second receipt - * table — see the model docblock in `prisma/schema.prisma`). Everything that - * decides what a grant currently MEANS lives here, and nothing else in the - * tree is allowed to decide it. + * A grant is one account's standing permission to open another account's + * record — to read it, and where the grant says so to add to it. The row is + * also the durable record of the consent that created it (there is no second + * receipt table — see the model docblock in `prisma/schema.prisma`). + * Everything that decides what a grant currently MEANS lives here, and nothing + * else in the tree is allowed to decide it. * * Why one file rather than the rules at the call sites: two consumers read * these rows for different reasons — the acting-account resolver on every @@ -168,7 +168,7 @@ export function grantAllows( export interface InviteGrantInput { /** The account whose record is being shared. */ grantorId: string; - /** The account being invited to read it. */ + /** The account being invited to it. */ granteeId: string; /** * What the invitation offers. Required, with no default in this module: a From c7960d726d964e119b8b4da7d6cd9b36a52de5cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 10:18:27 +0200 Subject: [PATCH 8/9] Scope the idempotency replay cell to the record a request claims MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A delegate retrying with one `Idempotency-Key` across two people's records met itself in one cell. The cache tuple was `(caller, key, method, path)`, so the second record's request replayed the first record's cached 201: the handler never ran, no row was written for the second person, and the caller was told it had been — with the other person's id in the body. Nothing errored and nothing logged a conflict, and five of the write verbs about to become delegable are wrapped here. Fold the claimed record into the `key` component, centrally, once. Not into `userId`: that column is a foreign key to `users` with a cascade behind it and cannot hold a composite. It keeps naming the caller, which is what makes two delegates on one record two cells for free. The separator is a byte the key validator refuses, so a caller cannot hand-craft a key into a cell belonging to a request they are not making. A request with no acting account keys byte-for-byte as it did before, so a client that never heard of sharing cannot be affected. Accepted and pinned by test: the fold uses the CLAIM, before the grant check, so within the 24h window a delegate whose access has ended can still replay a key they had already completed. They receive bytes they already had, nothing new is written, and a fresh key from them is refused because that request reaches the handler. Both frozen surfaces move in this diff. The claim reader gains a guard leg freezing its consumers, since an unchecked claim is exactly the value a future caller must not mistake for permission. --- .../acting-account-boundary-guard.test.ts | 25 ++ .../bearer-scope-enforcement-guard.test.ts | 10 +- .../__tests__/idempotency-conflict.test.ts | 9 + src/lib/__tests__/idempotency.test.ts | 139 ++++++++- src/lib/auth/acting-carrier.ts | 55 +++- src/lib/idempotency.ts | 66 ++++- .../idempotency-record-scoped-cell.test.ts | 267 ++++++++++++++++++ 7 files changed, 563 insertions(+), 8 deletions(-) create mode 100644 tests/integration/idempotency-record-scoped-cell.test.ts diff --git a/src/__tests__/acting-account-boundary-guard.test.ts b/src/__tests__/acting-account-boundary-guard.test.ts index c842443b8..bf83b9a2b 100644 --- a/src/__tests__/acting-account-boundary-guard.test.ts +++ b/src/__tests__/acting-account-boundary-guard.test.ts @@ -74,6 +74,9 @@ describe("the acting-account carrier is read in one place", () => { const CARRIER_ALLOWLIST = [ // Projects it off the session row it already loaded. "lib/auth/session.ts", + // Reads it. The one statement of "what does this request say it is acting + // as", for both transports and for every caller that has to ask. + "lib/auth/acting-carrier.ts", // The resolver. The only thing that acts on the value. "lib/api-handler.ts", // v1.36.0 — the only thing that WRITES it: the switch endpoint sets it @@ -117,6 +120,28 @@ describe("the acting-account carrier is read in one place", () => { "lib/openapi/routes/account-sharing.ts", ]); }); + + /** + * Who may ask "which record does this request claim" without an auth + * context in hand. + * + * `readClaimedActingAccount()` answers before any grant check has run, so a + * caller that treats its answer as permission has skipped the only thing + * that grants any. Exactly one caller exists, beside the file that declares + * it: the idempotency wrapper, which uses the claim to pick a cache cell and + * leaves the check to the handler it wraps. Everything else that needs the + * acting account has an auth context by then and goes through + * `requireRecordAuth`, which checks a live grant before returning. A second + * caller here is a design question, not a patch. + */ + it("the unchecked claim has exactly two consumers", () => { + const consumers = filesMatching(/readClaimedActingAccount/); + expect(consumers.length).toBeGreaterThan(0); + expect(consumers).toEqual([ + "lib/auth/acting-carrier.ts", + "lib/idempotency.ts", + ]); + }); }); describe("the cookie-only helpers cannot see the acting account", () => { diff --git a/src/__tests__/bearer-scope-enforcement-guard.test.ts b/src/__tests__/bearer-scope-enforcement-guard.test.ts index ffd5175ee..669ed5020 100644 --- a/src/__tests__/bearer-scope-enforcement-guard.test.ts +++ b/src/__tests__/bearer-scope-enforcement-guard.test.ts @@ -65,9 +65,13 @@ describe("T1 — the Bearer resolution set is frozen", () => { // `defaultUserIdResolver` — resolves the Bearer token to a user id so an // idempotency key files under its owner. It authorises nothing: the // handler still runs its own `requireAuth()`, and this resolver only - // decides which user's cache cell a replay lands in. Narrow in reach and - // deliberate, but it is a raw Bearer value becoming a user id, so it - // belongs on this list rather than beside it. + // decides which cache cell a replay lands in. Since delegated writes it + // decides that from two values — the caller resolved here, and the record + // the request claims, read through the one acting-account carrier reader + // and folded into the key. Both are unchecked selectors; neither widens + // what the handler will do. Narrow in reach and deliberate, but it is a + // raw Bearer value becoming a user id, so it belongs on this list rather + // than beside it. "lib/idempotency.ts", ].sort(); diff --git a/src/app/api/anamnesis/facts/__tests__/idempotency-conflict.test.ts b/src/app/api/anamnesis/facts/__tests__/idempotency-conflict.test.ts index 435f35ef2..adb31bbdc 100644 --- a/src/app/api/anamnesis/facts/__tests__/idempotency-conflict.test.ts +++ b/src/app/api/anamnesis/facts/__tests__/idempotency-conflict.test.ts @@ -75,7 +75,16 @@ function request(): NextRequest { beforeEach(() => { vi.clearAllMocks(); + // The whole shape `getSession()` returns. The session half carries the + // acting-account carrier, which the idempotency wrapper reads to decide + // whose record a cell belongs to — a fake that omitted it made the wrapper + // fail on a field production always has. mocks.getSession.mockResolvedValue({ + session: { + id: "session-1", + expiresAt: new Date(Date.now() + 60_000), + actingAsUserId: null, + }, user: { id: "user-1", role: "USER" }, }); mocks.findUnique.mockResolvedValue(null); diff --git a/src/lib/__tests__/idempotency.test.ts b/src/lib/__tests__/idempotency.test.ts index 5d15ba7db..f5cce86e6 100644 --- a/src/lib/__tests__/idempotency.test.ts +++ b/src/lib/__tests__/idempotency.test.ts @@ -219,7 +219,11 @@ describe("withIdempotency", () => { ); const wrapped = withIdempotency<[NextRequest]>(handler); await wrapped(makeRequest("POST", { "idempotency-key": "abc-12345678" })); - expect(getSession).toHaveBeenCalledTimes(1); + // Twice: once to resolve who is calling, once to read which record they + // say they are acting on. The second read is what a delegated write costs + // here, and it is a single indexed lookup on a request that is about to + // write anyway. + expect(getSession).toHaveBeenCalledTimes(2); expect(prisma.idempotencyKey.create).toHaveBeenCalledTimes(1); const persisted = vi.mocked(prisma.idempotencyKey.create).mock .calls[0][0] as { data: { userId: string } }; @@ -536,3 +540,136 @@ describe("isCachableStatus do-not-cache rules (V3 audit)", () => { expect(stored.length).toBeGreaterThan(0); }); }); + +/** + * The cell is scoped to the record the request claims, not only to the caller. + * + * `tests/integration/idempotency-record-scoped-cell.test.ts` proves the rows. + * These prove the key the wrapper composes, which is what decides whether two + * requests meet in one cell — including the two cases a row-level test cannot + * show, because both end in no row at all. + */ +describe("withIdempotency — record-scoped cells", () => { + const CLIENT_KEY = "abc-12345678"; + + /** Present these headers to the wrapper for the next call. */ + function present(values: Record): void { + vi.mocked(headers).mockResolvedValue({ + get: (name: string) => values[name.toLowerCase()] ?? null, + } as unknown as ReturnType extends Promise + ? T + : never); + } + + /** Sign the caller in over the cookie transport, optionally switched. */ + function cookieSession(actingAsUserId: string | null): void { + vi.mocked(getSession).mockResolvedValue({ + session: { + id: "s-1", + expiresAt: new Date(Date.now() + 60_000), + actingAsUserId, + }, + user: { id: "u-1", role: "USER" }, + } as Awaited>); + } + + async function run(key = CLIENT_KEY): Promise { + const handler = vi.fn(async () => + NextResponse.json({ data: { ok: true }, error: null }, { status: 201 }), + ); + const wrapped = withIdempotency<[NextRequest]>(handler, async () => "u-1"); + await wrapped(makeRequest("POST", { "idempotency-key": key })); + } + + /** The key the wrapper looked the cell up under. */ + function lookedUpKey(): string { + const call = vi.mocked(prisma.idempotencyKey.findUnique).mock + .calls[0][0] as { + where: { userId_key_method_path: { userId: string; key: string } }; + }; + return call.where.userId_key_method_path.key; + } + + /** The row the wrapper claimed. */ + function claimed(): { userId: string; key: string } { + return ( + vi.mocked(prisma.idempotencyKey.create).mock.calls[0][0] as { + data: { userId: string; key: string }; + } + ).data; + } + + it("folds the account named by the Bearer selector into the key", async () => { + vi.mocked(getSession).mockResolvedValue(null); + present({ "x-healthlog-account": "owner-9" }); + + await run(); + + expect(lookedUpKey()).toBe(`owner-9|${CLIENT_KEY}`); + // The owner column stays the ACTOR. It is a foreign key to `users` with a + // cascade behind it, so it can only ever hold a real account id — which is + // why the record moved into the key and not into here. + expect(claimed()).toMatchObject({ + userId: "u-1", + key: `owner-9|${CLIENT_KEY}`, + }); + }); + + it("folds the account the cookie session is switched to", async () => { + cookieSession("owner-7"); + present({}); + + await run(); + + expect(claimed().key).toBe(`owner-7|${CLIENT_KEY}`); + }); + + it("keys a request with no acting account byte-for-byte as the client sent it", async () => { + cookieSession(null); + present({}); + + await run(); + + expect(lookedUpKey()).toBe(CLIENT_KEY); + expect(claimed()).toMatchObject({ userId: "u-1", key: CLIENT_KEY }); + }); + + it("ignores a selector sent over the cookie transport", async () => { + // The resolver refuses that request outright (`misplaced_selector`). The + // cell must not pretend it named a record either — a claim the request was + // never going to be served must not move where its answer is filed. + cookieSession(null); + present({ "x-healthlog-account": "owner-9" }); + + await run(); + + expect(claimed().key).toBe(CLIENT_KEY); + }); + + it("refuses a client key that carries the separator", async () => { + // The one way a caller could aim at somebody else's cell: send + // `owner-9|abc-12345678` as the key from their own un-switched session. The + // key validator does not admit the byte, so the header is malformed and the + // wrapper does not cache at all. + vi.mocked(getSession).mockResolvedValue(null); + present({}); + + await run(`owner-9|${CLIENT_KEY}`); + + expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled(); + expect(prisma.idempotencyKey.create).not.toHaveBeenCalled(); + }); + + it("skips the cache when the claimed account names nothing that could exist", async () => { + // Longer than any account id. The request is refused downstream; caching it + // would let a caller write an arbitrarily long key into the table on the + // way to a 403. + vi.mocked(getSession).mockResolvedValue(null); + present({ "x-healthlog-account": "x".repeat(65) }); + + await run(); + + expect(prisma.idempotencyKey.findUnique).not.toHaveBeenCalled(); + expect(prisma.idempotencyKey.create).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/auth/acting-carrier.ts b/src/lib/auth/acting-carrier.ts index 9be1e116d..1d958b0b8 100644 --- a/src/lib/auth/acting-carrier.ts +++ b/src/lib/auth/acting-carrier.ts @@ -1,9 +1,14 @@ /** * What a request says it is acting as. * - * It lives apart from `requireRecordAuth`, which acts on the answer, because - * asking the question and deciding what it permits are different jobs and the - * asking is about to have more than one caller. + * Two things in the app have to answer that question, and they have to answer + * it the same way: + * + * * `requireRecordAuth` (`src/lib/api-handler.ts`), which turns the claim + * into a data scope after checking a live grant. + * * `withIdempotency` (`src/lib/idempotency.ts`), which files the replay cell + * under the record the request claims — before any handler runs, so it + * cannot wait for the grant check the handler performs. * * The claim is a SELECTOR and never an authorisation. Nothing in this file * looks at a grant, and no caller may treat a carrier as permission: the @@ -25,6 +30,8 @@ */ import { headers } from "next/headers"; +import { getSession } from "@/lib/auth/session"; + /** * The per-request account selector, on the Bearer transport only. * @@ -103,3 +110,45 @@ export function carrierTarget(carrier: ActingCarrier): string | null { export function selectorNamesAnAccount(value: string): boolean { return value.length > 0 && value.length <= MAX_SELECTOR_LENGTH; } + +/** + * The account this request CLAIMS, resolved without an auth context in hand. + * + * For callers that run BEFORE authentication has been resolved into a context — + * today that is the idempotency wrapper, which has to know which record a + * request is aimed at before it decides whether the answer is already cached. + * It resolves the session itself for exactly one reason: the transport decides + * which carrier counts, and "there is a valid session" is what the transport + * is. Guessing it from the presence of a cookie or an `Authorization` header + * would be a second, weaker answer to a question this file exists to answer + * once — and the case it gets wrong (a stale session cookie beside a live + * Bearer token) is precisely a delegated write landing in the wrong cell. + * + * The cost is one indexed session lookup on a request that is already about to + * write. It is not shared with the caller's own auth resolution because the + * resolver a route passes to `withIdempotency` is pluggable, and threading a + * session through that seam would make every custom resolver responsible for a + * question none of them asks. + * + * Returns the CLAIMED account, unverified and unchecked. The grant check + * happens later, inside the handler, on every request. + */ +export async function readClaimedActingAccount(): Promise { + const header = await readSelectorHeader(); + let session: Awaited> = null; + try { + session = await getSession(); + } catch { + // A session that cannot be resolved is not a switched one. The request + // still has to be authenticated by whatever wraps this call, and an + // unauthenticated request claims nothing. + session = null; + } + return carrierTarget( + decideActingCarrier({ + transport: session ? "cookie" : "bearer", + stamped: session?.session.actingAsUserId ?? null, + header, + }), + ); +} diff --git a/src/lib/idempotency.ts b/src/lib/idempotency.ts index 04d92f8d6..42374bc13 100644 --- a/src/lib/idempotency.ts +++ b/src/lib/idempotency.ts @@ -6,12 +6,21 @@ * retry within the TTL (24h) for the same `(userId, key, method, path)` * tuple returns the cached envelope as a 200 (carrying the original * status inside the JSON if needed) — no second side-effect. + * + * `userId` is the CALLER. Since a caller can act on somebody else's record, + * the record the request claims is folded into `key` (see `cellKey`), so the + * tuple identifies a request to one person's record rather than a request by + * one person. A caller acting only as themselves is unaffected, byte for byte. */ import type { NextRequest } from "next/server"; import { headers } from "next/headers"; import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; +import { + readClaimedActingAccount, + selectorNamesAnAccount, +} from "@/lib/auth/acting-carrier"; import { hashToken } from "@/lib/auth/hmac"; import { annotate } from "@/lib/logging/context"; import { isP2002 } from "@/lib/prisma-errors"; @@ -37,6 +46,34 @@ const PENDING_STATUS = 0; const KEY_REGEX = /^[A-Za-z0-9_\-:.]{8,128}$/; +/** + * Separates the record from the client's key inside a cell's `key` column. + * + * `KEY_REGEX` above does not admit it, which is the whole reason it was + * chosen: a caller cannot hand-craft a key that lands in the cell of a request + * they are not making. Changing either one without the other reopens that. + */ +const RECORD_SEPARATOR = "|"; + +/** + * The `key` column for a request, given the record it claims to act on. + * + * Why the key and not the owner column: `IdempotencyKey.userId` is a foreign + * key to `users` with a cascade behind it (`prisma/schema.prisma`), so it can + * hold one real account id and nothing composite. It keeps holding the ACTOR — + * which is what makes "two delegates, one owner, one key" two cells without + * anything extra — and the record joins the other half of the composite unique + * index instead. + * + * No carrier means the key the client sent, byte for byte. Every request that + * is nobody's delegate therefore files exactly where it filed before, and the + * fold cannot change the behaviour of a client that never heard of sharing. + */ +function cellKey(clientKey: string, actingAccountId: string | null): string { + if (actingAccountId === null) return clientKey; + return `${actingAccountId}${RECORD_SEPARATOR}${clientKey}`; +} + export interface IdempotencyContext { userId: string; key: string; @@ -288,6 +325,11 @@ export function isCachableStatus(status: number): boolean { * /external clients — without it, every Bearer-authed retry was running * the handler again and creating duplicate measurements (audit C-4). * + * It resolves the CALLER and only the caller — which record they are aiming at + * is a separate question, answered by `readClaimedActingAccount()` above and + * folded into the key. Neither answer authorises anything: the handler runs + * its own `requireAuth()` / `requireRecordAuth()` either way. + * * Exported for unit testing; production callers should let * `withIdempotency()` pick this up automatically via its default arg. */ @@ -374,10 +416,32 @@ export function withIdempotency< const userId = await userIdResolver(...args); if (!userId) return handler(...args); + // Which RECORD this request is aimed at, as claimed by the transport and + // not yet checked against a grant — the check belongs to the handler, and + // this runs before it. One delegate posting the same key first for one + // person and then for another must not meet themselves in a single cell: + // that replays the first person's response, writes nothing to the second + // person's record, and reports success. Nothing errors and nothing logs a + // conflict, which is why it is folded here rather than left to call sites. + // + // The consequence of using the CLAIM: inside the 24h window a delegate + // whose grant has since ended can still replay a key they already + // completed, without the grant check running. They receive bytes they + // already had and nothing new is written — a fresh key from them is + // refused, because that request reaches the handler. + const claimedRecord = await readClaimedActingAccount(); + if (claimedRecord !== null && !selectorNamesAnAccount(claimedRecord)) { + // A claim that names no account that could exist. The request is refused + // downstream; skipping the cache keeps a caller from writing an + // arbitrarily long key into the table on the way to that refusal, and + // never lets one fall back onto the un-delegated cell. + return handler(...args); + } + const url = new URL(request.url); const ctx: IdempotencyContext = { userId, - key, + key: cellKey(key, claimedRecord), method: request.method, path: url.pathname, }; diff --git a/tests/integration/idempotency-record-scoped-cell.test.ts b/tests/integration/idempotency-record-scoped-cell.test.ts new file mode 100644 index 000000000..bdb4ec1fc --- /dev/null +++ b/tests/integration/idempotency-record-scoped-cell.test.ts @@ -0,0 +1,267 @@ +/** + * The replay cache is scoped to the RECORD, not only to the caller. + * + * One delegate, two people's records, one `Idempotency-Key`. Before this was + * fixed the cell was `(caller, key, method, path)`, so the second record's + * request replayed the first record's cached 201: no handler, no row, and a + * success response carrying the other person's id. Nothing errored and nothing + * logged a conflict, which is why it needed a test rather than a bug report. + * + * Everything under the test is real — the shipped wrapper, the shipped + * resolver, the shipped grant module's tables, the composite unique index and + * the `user_id` foreign key. The handler is assembled here rather than imported + * because what is under test is the cell, not any one domain's write path; a + * shipped route would bind this file to that route's schema and its migration + * to a delegable mode. + */ +import { NextRequest, NextResponse } from "next/server"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => { + cookieJar.set(name, value); + }, + delete: (name: string) => { + cookieJar.delete(name); + }, + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +import { + ACCOUNT_SELECTOR_HEADER, + apiHandler, + requireRecordAuth, +} from "@/lib/api-handler"; +import { hashToken } from "@/lib/auth/hmac"; +import { prisma } from "@/lib/db"; +import { withIdempotency } from "@/lib/idempotency"; + +const KEY = "retry-0f3a1c9d"; + +let counter = 0; + +async function makeUser(label: string) { + const suffix = `${label}-${counter++}`; + return getPrismaClient().user.create({ + data: { + username: `cell-${suffix}`, + email: `cell-${suffix}@example.test`, + role: "USER", + }, + }); +} + +async function mintToken(userId: string): Promise { + const raw = `hlk_${userId}-${counter++}`.padEnd(20, "0"); + await getPrismaClient().apiToken.create({ + data: { + userId, + name: "idempotency-cell-test", + tokenHash: hashToken(raw), + permissions: ["*"], + }, + }); + return raw; +} + +/** + * An accepted WRITE grant, written directly. + * + * `inviteGrant()` mints READ today; the level is Chunk B's to open. What this + * file needs is a row in the state the resolver reads, and that state is the + * two columns below. + */ +async function writeGrant(grantorId: string, granteeId: string) { + return getPrismaClient().accountGrant.create({ + data: { + grantorId, + granteeId, + access: "WRITE", + acceptedAt: new Date(Date.now() - 60_000), + }, + }); +} + +let handlerRuns = 0; + +/** + * A delegated write, wrapped exactly as the shipped routes wrap one: + * `apiHandler(withIdempotency(handler))`. + */ +const delegableWrite: (request: NextRequest) => Promise = apiHandler( + withIdempotency<[NextRequest]>(async () => { + handlerRuns += 1; + const { user } = await requireRecordAuth("write"); + const row = await prisma.measurement.create({ + data: { + userId: user.id, + type: "WEIGHT", + value: 80 + handlerRuns, + unit: "kg", + measuredAt: new Date(), + source: "MANUAL", + }, + }); + return NextResponse.json( + { data: { id: row.id, userId: row.userId }, error: null }, + { status: 201 }, + ); + }), +); + +function post(key: string | null = KEY): Promise { + const headers: Record = { + "content-type": "application/json", + }; + if (key) headers["idempotency-key"] = key; + return delegableWrite( + new NextRequest("http://localhost/api/test/delegable-write", { + method: "POST", + headers, + body: JSON.stringify({ ok: true }), + }), + ); +} + +/** Point the next request at a record. */ +function actAs(ownerId: string | null): void { + if (ownerId === null) headerJar.delete(ACCOUNT_SELECTOR_HEADER); + else headerJar.set(ACCOUNT_SELECTOR_HEADER, ownerId); +} + +async function measurementsOf(userId: string) { + return getPrismaClient().measurement.findMany({ where: { userId } }); +} + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); + handlerRuns = 0; +}); + +describe("the replay cell is per record", () => { + it("writes to BOTH records when one key is reused across two owners", async () => { + const delegate = await makeUser("delegate"); + const ownerA = await makeUser("owner-a"); + const ownerB = await makeUser("owner-b"); + await writeGrant(ownerA.id, delegate.id); + await writeGrant(ownerB.id, delegate.id); + headerJar.set("authorization", `Bearer ${await mintToken(delegate.id)}`); + + actAs(ownerA.id); + const first = await post(); + expect(first.status).toBe(201); + expect((await first.json()).data.userId).toBe(ownerA.id); + + // Same caller, same key, same path — a different person's record. The + // handler must run again, because nothing about this request was answered + // by the previous one. + actAs(ownerB.id); + const second = await post(); + expect(second.status).toBe(201); + const secondBody = await second.json(); + + // Asserted first because it is the damage: without a per-record cell this + // is an empty list, and the caller was told 201. + expect(await measurementsOf(ownerB.id)).toHaveLength(1); + expect(secondBody.data.userId).toBe(ownerB.id); + expect(second.headers.get("X-Idempotent-Replay")).not.toBe("true"); + expect(handlerRuns).toBe(2); + expect(await measurementsOf(ownerA.id)).toHaveLength(1); + // Two cells under the one caller: the actor's id still owns both rows, so + // the foreign key and its cascade are untouched by the fold. + const cells = await getPrismaClient().idempotencyKey.findMany({ + where: { userId: delegate.id }, + }); + expect(cells).toHaveLength(2); + }); + + it("still replays a genuine retry on the SAME record", async () => { + const delegate = await makeUser("delegate"); + const owner = await makeUser("owner"); + await writeGrant(owner.id, delegate.id); + headerJar.set("authorization", `Bearer ${await mintToken(delegate.id)}`); + actAs(owner.id); + + const first = await post(); + const firstBody = await first.json(); + const replay = await post(); + + expect(replay.status).toBe(201); + expect(replay.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(await replay.json()).toEqual(firstBody); + expect(handlerRuns).toBe(1); + expect(await measurementsOf(owner.id)).toHaveLength(1); + }); + + it("keys a request with no acting account exactly as the client sent it", async () => { + const caller = await makeUser("self"); + headerJar.set("authorization", `Bearer ${await mintToken(caller.id)}`); + + const response = await post(); + expect(response.status).toBe(201); + + // The self path is the pre-existing path and must stay byte-identical: + // same owner column, same key column, no separator, no prefix. + const cells = await getPrismaClient().idempotencyKey.findMany(); + expect(cells).toHaveLength(1); + expect(cells[0]).toMatchObject({ userId: caller.id, key: KEY }); + expect(await measurementsOf(caller.id)).toHaveLength(1); + }); + + it("replays inside the TTL after the grant is revoked, and writes nothing", async () => { + // The accepted consequence of folding the CLAIMED account rather than a + // verified one: within the 24h window a revoked delegate's retry is + // answered from the cache without the grant check running again. Pinned + // here so it stays a decision. What matters is the second half — the + // response is one they already received, and no new row appears. + const delegate = await makeUser("delegate"); + const owner = await makeUser("owner"); + const grant = await writeGrant(owner.id, delegate.id); + headerJar.set("authorization", `Bearer ${await mintToken(delegate.id)}`); + actAs(owner.id); + + const first = await post(); + const firstBody = await first.json(); + expect(first.status).toBe(201); + + // The owner withdraws access. Both revocation columns move together — + // the database refuses one without the other. + await getPrismaClient().accountGrant.update({ + where: { id: grant.id }, + data: { revokedAt: new Date(), revokedBy: "GRANTOR" }, + }); + + const afterRevoke = await post(); + expect(afterRevoke.status).toBe(201); + expect(afterRevoke.headers.get("X-Idempotent-Replay")).toBe("true"); + expect(await afterRevoke.json()).toEqual(firstBody); + expect(handlerRuns).toBe(1); + expect(await measurementsOf(owner.id)).toHaveLength(1); + + // A request the cache does NOT answer is refused, which is what keeps the + // window above bounded to keys the delegate had already completed. + const fresh = await post("retry-11119999"); + expect(fresh.status).toBe(403); + expect(await measurementsOf(owner.id)).toHaveLength(1); + }); +}); From fe142bbd3af1275d70a71a7053f298e424116a98 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Mon, 3 Aug 2026 10:20:26 +0200 Subject: [PATCH 9/9] Correct the entry type's account of what a level is The payload type still said the level is read throughout and `canWrite` false. Both were true of the shipped release and neither survives the invitation gaining a choice. Also says what `canWrite` does not mean, since the word invites a larger reading than the thing it grants. --- src/lib/sharing/account-access-view.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lib/sharing/account-access-view.ts b/src/lib/sharing/account-access-view.ts index 264e3693c..de0d06d0d 100644 --- a/src/lib/sharing/account-access-view.ts +++ b/src/lib/sharing/account-access-view.ts @@ -20,9 +20,13 @@ export interface AccountAccessEntry { accountId: string; username: string; displayName: string | null; - /** The grant's level. `"read"` throughout v1. */ + /** The grant's level, as the owner offered it and the caller accepted it. */ access: "read" | "write"; - /** May this caller change that record. Resolved server-side; `false` in v1. */ + /** + * May this caller ADD to that record. Resolved server-side. It never means + * edit or delete: those stay with the owner at both levels, and no field + * here offers them. + */ canWrite: boolean; }