Let an invitation name its sections and offer management

An owner can now narrow a grant to the parts of their record they mean to
share, and can offer the third level. Both decisions are made once, on the
invitation, and neither widens afterwards.

The invite schema takes `scope`: absent or null is the entire record, which
is what every stored grant means and what an owner who does not narrow still
gets. An empty array, a key outside the vocabulary and the whole-record
sentinel are each refused with a 422 naming the field; `inviteGrant` refuses
the same three underneath, and its `invalid_scope` now has an HTTP mapping
instead of falling into the generic refusal.

Offering MANAGE asks for a fresh second factor when the account has one. That
gate resolves through the session cookie and has no Bearer fall-through, so a
token caller is refused first with `sharing.invite.manage_browser_only` rather
than being told it is not authenticated by a route that had just
authenticated it. The consequence is deliberate: management of a health
record is offered from a browser.

The account payload gains `sections` and `level` beside `access` and
`canWrite`, resolved server-side and ordered as the consent screen reads.
`access` was `grant.access === "WRITE" ? "write" : "read"`, which would have
published a grant that can delete entries as read-only; it is a total map
now, so the next level cannot ship silent. The capabilities hook carries both
facts and turns neither into an affordance yet — the edit and delete controls
come back in the release where the routes behind them answer.

The consent surface asks two questions in order rather than laying out a
nineteen-cell grid. The level is three radios, narrowest preselected. What
they can open is two radios, the entire record preselected, and only the
narrow branch expands the eight sections with a sentence each. Both honest
limits are stated above the checkboxes, where the decision is being made: a
section shares everything written in it, and the cross-record surfaces do not
appear for a narrowed grant. Documents carries its own warning, cycle appears
only for an owner who tracks it, and Manage collapses the question to the one
line that answers it.

The Manage sentence says the other person can add, change and remove entries,
including ones the owner entered themselves, and says in the same breath what
it does not reach. The acceptance side gets its own responsibility line rather
than the write one wearing a stronger adjective.

Six locales.
This commit is contained in:
Marc-André Bombeck committed 2026-08-05 23:37:00 +02:00
1 parent feb9d25e87
commit ab14fdb6f3
33 files changed
+2273 -155

No files matched your search

+128 -15
View File
@@ -12508,11 +12508,15 @@ paths:
tags:
- Account sharing
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.
description: "Offers access to somebody already registered on this instance, at READ, WRITE or MANAGE and over the whole
record or named sections; 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. Offering MANAGE is step-up gated
(`requireFreshMfaIfEnrolled`, cookie-only): an enrolled account without a fresh factor gets 401
`auth.stepup.required`, and a Bearer caller gets 403 `sharing.invite.manage_browser_only` before anything else
happens, because the gate it would hit resolves through the session cookie and would otherwise answer 'not
authenticated' to a caller it had just authenticated."
requestBody:
required: true
content:
@@ -12534,13 +12538,38 @@ paths:
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."
MANAGE can additionally change and remove entries, including ones the owner wrote, and read the
generated insights; it never reaches the identity surfaces (login, second factor, connections,
tokens, settings, or who else has access). 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
- MANAGE
scope:
description: "The sections of the record this invitation opens. Omitted or null means the entire record, which is what
every grant written before v1.37.0 means and what an owner who does not narrow still gets. A
non-null value is a non-empty array of section keys from the closed vocabulary; an empty array, an
unknown key and the whole-record sentinel `record` are each refused. A stored scope never grows: a
section added by a later release is not in a set an owner ticked before it existed."
anyOf:
- minItems: 1
type: array
items:
type: string
enum:
- measurements
- medications
- labs
- profile
- illness
- mind
- cycle
- documents
- type: "null"
required:
- identifier
responses:
@@ -12550,8 +12579,22 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/AccountGrantEnvelope"
"401": *a1
"403": *a20
"401":
description: "Offering MANAGE from an MFA-enrolled account without a recent second-factor proof (`meta.errorCode:
auth.stepup.required`). Re-prove the factor and retry; READ and WRITE invitations are never gated."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"403":
description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another
account (grant management is never delegable), or `sharing.invite.manage_browser_only` when a Bearer caller
tried to offer MANAGE — the step-up that gates it is cookie-only by construction, so the native client
cannot mint it and should route the person to a browser rather than surface an error.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"404":
description: No account on this instance carries that identifier.
content:
@@ -12566,8 +12609,10 @@ paths:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422":
description: "Validation failed, or the invitation named the caller's own account (`meta.errorCode:
sharing.invite.self`)."
description: "Validation failed, the invitation named the caller's own account (`meta.errorCode: sharing.invite.self`),
or the scope cannot mean anything (`meta.errorCode: sharing.invite.invalid_scope` from the domain module;
the request-shape refusal for an empty array, an unknown section or a scope beside MANAGE arrives as the
ordinary multi-issue envelope naming the `scope` field)."
content:
application/json:
schema:
@@ -36698,20 +36743,61 @@ components:
enum:
- read
- write
- manage
description: The grant's resolved level, under the name v1.36.0 published. Identical to `level` — both come from one
server-side expression — and kept because shipped clients read it. New code reads `level`.
level:
type: string
enum:
- read
- write
- manage
description: The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable
surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management),
which stay refused on an invited record at every level. Render it; never derive it from `canWrite`.
sections:
anyOf:
- type: array
items:
$ref: "#/components/schemas/ShareSection"
- type: "null"
description: The sections of that record this caller may open, or null for the entire record. Null is first-class and is
what every pre-v1.37.0 grant carries; a MANAGE grant always carries it too. A non-null array names the open
sections and NOTHING else answers — a client that switches into a scoped record without honouring this gets
the standard 403 on every out-of-scope route, fail-closed and byte-identical to every other sharing refusal,
which is safe but reads as a wall of errors. An empty array means the grant opens nothing. Ordered by the
consent screen's reading order.
canWrite:
type: boolean
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."
description: "Whether this caller may ADD to that record: true for an accepted WRITE or MANAGE grant, false for a READ
one. On its own it never means edit or delete — a WRITE grant adds and does nothing more. What MANAGE
additionally admits is said by `level`. Resolved server-side. Render it; never derive it."
required:
- accountId
- username
- displayName
- access
- level
- sections
- canWrite
additionalProperties: false
description: "One account this caller may act on. No avatar is published: the avatar bytes are owner-scoped, so a URL
here would resolve to a refusal. Clients paint their initials fallback."
ShareSection:
type: string
enum:
- measurements
- medications
- labs
- profile
- illness
- mind
- cycle
- documents
description: "One section of a health record, as a grant may name it. A closed vocabulary of eight, derived by
clustering the delegable read surfaces rather than from the module list — the two answer different questions and
never share a spelling. The whole-record case is `null` on the field that carries these, never a member of this
enum: a scope that named the whole record would be a narrowing that means its own absence."
RecordActivityEnvelope:
type: object
properties:
@@ -36843,6 +36929,18 @@ components:
enum:
- READ
- WRITE
- MANAGE
scope:
anyOf:
- type: array
items:
$ref: "#/components/schemas/ShareSection"
- type: "null"
description: The sections this grant opens, or null for the entire record. Null is a first-class value and the one every
grant written before v1.37.0 carries; render it as the whole record, never as a legacy or unknown state. A
MANAGE grant always carries null. An EMPTY array means the grant opens nothing — the fail-closed reading of
a stored scope the server cannot parse — and the server will refuse every section for it, so render it as
nothing rather than as everything. Ordered by the consent screen's own reading order.
state:
$ref: "#/components/schemas/AccountGrantState"
invitedAt:
@@ -36874,6 +36972,7 @@ components:
- id
- account
- access
- scope
- state
- invitedAt
- acceptedAt
@@ -36960,6 +37059,18 @@ components:
enum:
- READ
- WRITE
- MANAGE
scope:
anyOf:
- type: array
items:
$ref: "#/components/schemas/ShareSection"
- type: "null"
description: The sections this grant opens, or null for the entire record. Null is a first-class value and the one every
grant written before v1.37.0 carries; render it as the whole record, never as a legacy or unknown state. A
MANAGE grant always carries null. An EMPTY array means the grant opens nothing — the fail-closed reading of
a stored scope the server cannot parse — and the server will refuse every section for it, so render it as
nothing rather than as everything. Ordered by the consent screen's own reading order.
state:
$ref: "#/components/schemas/AccountGrantState"
invitedAt:
@@ -36997,6 +37108,7 @@ components:
- id
- account
- access
- scope
- state
- invitedAt
- acceptedAt
@@ -37062,6 +37174,7 @@ components:
enum:
- READ
- WRITE
- MANAGE
required:
- accountId
- username
+56 -1
View File
@@ -9418,6 +9418,10 @@
"row": {
"readOnly": "Darf die Akte lesen",
"canWrite": "Darf lesen und Einträge anlegen",
"canManage": "Darf lesen, Einträge anlegen sowie Einträge ändern und löschen",
"entireRecord": "Umfasst die gesamte Akte",
"sections": "Umfasst: {sections}",
"noSections": "Umfasst nichts: dieser Zugriff öffnet keinen Teil der Akte",
"invited": "Eingeladen",
"accepted": "Angenommen",
"expires": "Läuft ab",
@@ -9439,6 +9443,19 @@
"accessReadCapability": "Sie kann alles lesen, was von dieser Akte geteilt wird. Hinzufügen oder ändern kann sie nichts.",
"accessWriteLabel": "Darf lesen und hinzufügen",
"accessWriteCapability": "Sie kann außerdem etwas zur Akte hinzufügen: Messwerte, Laborergebnisse, einen neuen Laborwert, einen Krankheitseintrag, eine Nebenwirkung zu einem Medikament, ein neues Medikament und eine Dosis als genommen oder ausgelassen markieren. Ändern oder löschen kann sie nichts, auch nicht das, was sie selbst eingetragen hat, und eine deiner Erinnerungen verschieben kann sie auch nicht. Das kannst nur du.",
"accessManageLabel": "Darf verwalten",
"accessManageCapability": "Sie kann Einträge in deiner Akte anlegen, ändern und löschen, auch solche, die du selbst erfasst hast, und sie kann die daraus erzeugten Auswertungen lesen. Deine Anmeldung, deine Sicherheitseinstellungen, deine Verbindungen und den Zugriff anderer Personen kann sie nicht ändern. Du kannst das jederzeit beenden.",
"manageStepUpNote": "Dafür wird dein zweiter Faktor abgefragt, deshalb geht das nur im Browser.",
"scopeLegend": "Was die Person öffnen darf",
"scopeAllLabel": "Gesamte Akte",
"scopeAllCapability": "Jeder Teil davon, so wie das Teilen bisher funktioniert hat.",
"scopeSomeLabel": "Nur die Bereiche, die du auswählst",
"scopeSomeCapability": "Alles, was du nicht auswählst, bleibt für sie verschlossen.",
"scopeManageNote": "Verwaltender Zugriff umfasst immer die gesamte Akte.",
"scopeLeakNote": "Ein Bereich kann in seinen Notizen und Bezeichnungen auch den Rest deiner Gesundheit erwähnen. Wer einen Bereich bekommt, bekommt alles, was darin steht.",
"scopeAggregateNote": "Die Übersicht auf dem Dashboard, der Health Score und die Tageszusammenfassung erscheinen nur, wenn die gesamte Akte geteilt ist.",
"scopeDocumentsNote": "Ein Dokument kann alles enthalten. Teile diesen Bereich nur, wenn die Person alles davon lesen darf.",
"scopeEmptyHint": "Wähle mindestens einen Bereich aus oder teile wieder die gesamte Akte.",
"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",
@@ -9449,7 +9466,10 @@
"errorSelf": "Das ist dein eigenes Konto.",
"errorInvalid": "Bitte einen Benutzernamen oder eine E-Mail-Adresse eingeben.",
"errorRateLimited": "Zu viele Einladungen. Bitte später noch einmal versuchen.",
"errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen."
"errorFailed": "Die Einladung konnte nicht gesendet werden. Bitte noch einmal versuchen.",
"errorScope": "So lassen sich diese Bereiche nicht teilen. Wähle mindestens einen Bereich aus oder teile die gesamte Akte.",
"errorManageBrowserOnly": "Verwaltenden Zugriff kannst du nur im Browser vergeben, weil dabei dein zweiter Faktor abgefragt wird.",
"errorStepUp": "Bestätige deinen zweiten Faktor und versuche es erneut."
},
"given": {
"title": "Wer meine Akte öffnen kann",
@@ -9474,6 +9494,7 @@
"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}.",
"manageConsent": "Wenn du annimmst, kannst du die Akte von {name} ergänzen und außerdem ändern oder löschen, was schon darin steht, auch Einträge von {name} selbst. Alles, was du tust, steht mit deinem Namen in der Aktivitätsübersicht, und {name} kann deinen Zugriff jederzeit beenden.",
"open": "Öffnen",
"renounce": "Zurückgeben",
"renounceTitle": "Akte von {name} zurückgeben?",
@@ -9516,6 +9537,40 @@
"medicationCreate": "{name} hat ein Medikament angelegt",
"medicationIntake": "{name} hat eine Dosis eingetragen",
"medicationIntakeRefused": "{name} wollte eine Dosis ändern oder eine Erinnerung verschieben und wurde abgewiesen"
},
"section": {
"measurements": {
"label": "Messwerte",
"detail": "Gewicht, Blutdruck, Puls, Glukose, Schlaf, Training und die Werte, die du selbst verfolgst."
},
"medications": {
"label": "Medikamente",
"detail": "Die Medikamentenliste, ihre Zeitpläne, eingenommene Dosen, Nebenwirkungen und der Bestand."
},
"labs": {
"label": "Laborwerte",
"detail": "Laborergebnisse und die Liste der Kennwerte dahinter."
},
"profile": {
"label": "Gesundheitliche Vorgeschichte",
"detail": "Allergien, Familienanamnese und die Angaben in deinem Gesundheitsprofil."
},
"illness": {
"label": "Krankheiten",
"detail": "Krankheitsverläufe und die Tageseinträge darunter."
},
"mind": {
"label": "Stimmung und Psyche",
"detail": "Stimmungseinträge und der Verlauf der psychologischen Fragebögen."
},
"cycle": {
"label": "Zyklus",
"detail": "Die Zyklusaufzeichnung."
},
"documents": {
"label": "Dokumente",
"detail": "Alles, was im Dokumententresor liegt."
}
}
}
}
+56 -1
View File
@@ -9418,6 +9418,10 @@
"row": {
"readOnly": "Can read the record",
"canWrite": "Can read and add entries",
"canManage": "Can read the record, add to it, and change or remove what is in it",
"entireRecord": "Covers the entire record",
"sections": "Covers: {sections}",
"noSections": "Covers nothing — this access opens no part of the record",
"invited": "Invited",
"accepted": "Accepted",
"expires": "Lapses",
@@ -9439,6 +9443,19 @@
"accessReadCapability": "They can read everything this record shares. They cannot add or change anything.",
"accessWriteLabel": "Can view and add",
"accessWriteCapability": "They can also add to the record: readings, lab results, a new analyte to track, an illness entry, a side effect against a drug, a new medication, and marking a dose taken or skipped. They cannot edit or delete anything, not even what they added, and they cannot defer one of your reminders. Only you can.",
"accessManageLabel": "Can manage",
"accessManageCapability": "They can add, change and remove entries in your record, including ones you entered yourself, and they can read the insights generated from it. They cannot change your login, your security settings, your connections, or who else has access. You can end this at any time.",
"manageStepUpNote": "Offering this asks for your second factor, so it can only be done in a browser.",
"scopeLegend": "What they can open",
"scopeAllLabel": "Entire record",
"scopeAllCapability": "Every part of it, the way sharing has always worked.",
"scopeSomeLabel": "Only the sections you pick",
"scopeSomeCapability": "Everything you do not pick stays closed to them.",
"scopeManageNote": "Manage access always covers the entire record.",
"scopeLeakNote": "A section can mention the rest of your health in its notes and names. Sharing a section shares everything written in it.",
"scopeAggregateNote": "The dashboard overview, health score and daily digest only appear when the entire record is shared.",
"scopeDocumentsNote": "A document can contain anything. Share this only if they may read all of it.",
"scopeEmptyHint": "Pick at least one section, or go back to sharing the entire record.",
"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",
@@ -9449,7 +9466,10 @@
"errorSelf": "That is your own account.",
"errorInvalid": "Please enter a username or e-mail address.",
"errorRateLimited": "Too many invitations. Please try again later.",
"errorFailed": "The invitation could not be sent. Please try again."
"errorFailed": "The invitation could not be sent. Please try again.",
"errorScope": "Those sections cannot be shared like that. Pick at least one section, or share the entire record.",
"errorManageBrowserOnly": "Manage access can only be offered in a browser, because it asks for your second factor.",
"errorStepUp": "Confirm your second factor, then try again."
},
"given": {
"title": "Who can open my record",
@@ -9474,6 +9494,7 @@
"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.",
"manageConsent": "If you accept, you can add to {name}'s record and also change or remove what is already in it, including entries {name} made themselves. Everything you do is recorded under your name in their activity view, and {name} can end your access at any time.",
"open": "Open",
"renounce": "Hand back",
"renounceTitle": "Hand {name}'s record back?",
@@ -9516,6 +9537,40 @@
"medicationCreate": "{name} added a medication",
"medicationIntake": "{name} marked a dose",
"medicationIntakeRefused": "{name} tried to change a dose or defer a reminder, and was refused"
},
"section": {
"measurements": {
"label": "Readings",
"detail": "Weight, blood pressure, pulse, glucose, sleep, workouts and the values you track."
},
"medications": {
"label": "Medications",
"detail": "The medication list, its schedules, the doses taken, side effects and stock."
},
"labs": {
"label": "Lab results",
"detail": "Lab results and the list of analytes behind them."
},
"profile": {
"label": "Health background",
"detail": "Allergies, family history and the facts in your health profile."
},
"illness": {
"label": "Illness",
"detail": "Illness episodes and the day logs kept under them."
},
"mind": {
"label": "Mood and mind",
"detail": "Mood entries and the history of the mental-health questionnaires."
},
"cycle": {
"label": "Cycle",
"detail": "Cycle tracking."
},
"documents": {
"label": "Documents",
"detail": "Everything stored in the document vault."
}
}
}
}
+56 -1
View File
@@ -9418,6 +9418,10 @@
"row": {
"readOnly": "Puede leer el historial",
"canWrite": "Puede leer y añadir entradas",
"canManage": "Puede leer el historial, añadir entradas y cambiar o eliminar lo que hay en él",
"entireRecord": "Cubre todo el historial",
"sections": "Cubre: {sections}",
"noSections": "No cubre nada: este acceso no abre ninguna parte del historial",
"invited": "Invitado",
"accepted": "Aceptado",
"expires": "Caduca",
@@ -9439,6 +9443,19 @@
"accessReadCapability": "Puede leer todo lo que este historial comparte. No puede añadir ni cambiar nada.",
"accessWriteLabel": "Puede ver y añadir",
"accessWriteCapability": "También puede añadir a la ficha: mediciones, resultados de laboratorio, un nuevo analito a seguir, una entrada de enfermedad, un efecto secundario de un medicamento, un medicamento nuevo, y marcar una dosis como tomada u omitida. No puede editar ni borrar nada, ni siquiera lo que añadió, ni aplazar uno de tus recordatorios. Solo tú puedes.",
"accessManageLabel": "Puede gestionar",
"accessManageCapability": "Puede añadir, cambiar y eliminar entradas de tu ficha, incluidas las que has escrito tú, y puede leer los análisis generados a partir de ella. No puede cambiar tu inicio de sesión, tus ajustes de seguridad, tus servicios conectados ni quién más tiene acceso. Puedes terminarlo cuando quieras.",
"manageStepUpNote": "Ofrecerlo pide tu segundo factor, así que solo se puede hacer en un navegador.",
"scopeLegend": "Qué puede abrir",
"scopeAllLabel": "Todo el historial",
"scopeAllCapability": "Todas sus partes, tal y como ha funcionado siempre el uso compartido.",
"scopeSomeLabel": "Solo las secciones que elijas",
"scopeSomeCapability": "Todo lo que no elijas le queda cerrado.",
"scopeManageNote": "El acceso de gestión siempre cubre todo el historial.",
"scopeLeakNote": "Una sección puede mencionar el resto de tu salud en sus notas y en sus nombres. Compartir una sección comparte todo lo que hay escrito en ella.",
"scopeAggregateNote": "El resumen del panel, la puntuación de salud y el resumen diario solo aparecen cuando se comparte todo el historial.",
"scopeDocumentsNote": "Un documento puede contener cualquier cosa. Comparte esta sección solo si esa persona puede leerlo todo.",
"scopeEmptyHint": "Elige al menos una sección o vuelve a compartir todo el historial.",
"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",
@@ -9449,7 +9466,10 @@
"errorSelf": "Esa es tu propia cuenta.",
"errorInvalid": "Introduce un usuario o una dirección de correo.",
"errorRateLimited": "Demasiadas invitaciones. Inténtalo más tarde.",
"errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo."
"errorFailed": "No se pudo enviar la invitación. Inténtalo de nuevo.",
"errorScope": "Esas secciones no se pueden compartir así. Elige al menos una sección o comparte todo el historial.",
"errorManageBrowserOnly": "El acceso de gestión solo se puede ofrecer desde un navegador, porque pide tu segundo factor.",
"errorStepUp": "Confirma tu segundo factor y vuelve a intentarlo."
},
"given": {
"title": "Quién puede abrir mi historial",
@@ -9474,6 +9494,7 @@
"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.",
"manageConsent": "Si aceptas, podrás añadir cosas al historial de {name} y también cambiar o eliminar lo que ya hay, incluidas las entradas del propio {name}. Todo lo que hagas queda registrado con tu nombre en su vista de actividad, y {name} puede terminar tu acceso cuando quiera.",
"open": "Abrir",
"renounce": "Devolver",
"renounceTitle": "¿Devolver el historial de {name}?",
@@ -9516,6 +9537,40 @@
"medicationCreate": "{name} añadió un medicamento",
"medicationIntake": "{name} registró una dosis",
"medicationIntakeRefused": "{name} intentó cambiar una dosis o aplazar un recordatorio, y fue rechazado"
},
"section": {
"measurements": {
"label": "Mediciones",
"detail": "Peso, tensión arterial, pulso, glucosa, sueño, entrenamientos y los valores que sigues."
},
"medications": {
"label": "Medicamentos",
"detail": "La lista de medicamentos, sus pautas, las dosis tomadas, los efectos secundarios y las existencias."
},
"labs": {
"label": "Resultados de laboratorio",
"detail": "Los resultados de laboratorio y la lista de analitos que hay detrás."
},
"profile": {
"label": "Antecedentes de salud",
"detail": "Alergias, antecedentes familiares y los datos de tu perfil de salud."
},
"illness": {
"label": "Enfermedades",
"detail": "Los episodios de enfermedad y los registros diarios asociados."
},
"mind": {
"label": "Ánimo y bienestar mental",
"detail": "Las entradas de ánimo y el historial de los cuestionarios de salud mental."
},
"cycle": {
"label": "Ciclo",
"detail": "El seguimiento del ciclo."
},
"documents": {
"label": "Documentos",
"detail": "Todo lo que está guardado en el archivo de documentos."
}
}
}
}
+56 -1
View File
@@ -9418,6 +9418,10 @@
"row": {
"readOnly": "Peut lire le dossier",
"canWrite": "Peut lire et ajouter des entrées",
"canManage": "Peut lire le dossier, y ajouter des entrées et modifier ou supprimer ce qui s'y trouve",
"entireRecord": "Couvre l'intégralité du dossier",
"sections": "Couvre : {sections}",
"noSections": "Ne couvre rien : cet accès n'ouvre aucune partie du dossier",
"invited": "Invité le",
"accepted": "Accepté le",
"expires": "Expire le",
@@ -9439,6 +9443,19 @@
"accessReadCapability": "Elle peut lire tout ce que ce dossier partage. Elle ne peut rien ajouter ni rien modifier.",
"accessWriteLabel": "Peut consulter et ajouter",
"accessWriteCapability": "Elle peut aussi ajouter au dossier : des mesures, des résultats de laboratoire, un nouvel analyte à suivre, une entrée de maladie, un effet indésirable lié à un médicament, un nouveau médicament, et marquer une dose comme prise ou sautée. Elle ne peut rien modifier ni supprimer, pas même ce qu'elle a ajouté, et elle ne peut pas reporter un de vos rappels. Vous seul le pouvez.",
"accessManageLabel": "Peut gérer",
"accessManageCapability": "Elle peut ajouter, modifier et supprimer des entrées de votre dossier, y compris celles que vous avez saisies vous-même, et lire les analyses qui en sont tirées. Elle ne peut modifier ni votre connexion, ni vos paramètres de sécurité, ni vos services connectés, ni la liste des personnes qui ont accès. Vous pouvez y mettre fin à tout moment.",
"manageStepUpNote": "Cette offre demande votre deuxième facteur, elle n'est donc possible que dans un navigateur.",
"scopeLegend": "Ce que cette personne peut ouvrir",
"scopeAllLabel": "Dossier complet",
"scopeAllCapability": "Toutes ses parties, comme le partage a toujours fonctionné.",
"scopeSomeLabel": "Seulement les rubriques que vous choisissez",
"scopeSomeCapability": "Tout ce que vous ne choisissez pas lui reste fermé.",
"scopeManageNote": "Un accès de gestion couvre toujours l'intégralité du dossier.",
"scopeLeakNote": "Une rubrique peut évoquer le reste de votre santé dans ses notes et ses intitulés. Partager une rubrique, c'est partager tout ce qui y est écrit.",
"scopeAggregateNote": "La vue d'ensemble du tableau de bord, le score de santé et le résumé quotidien n'apparaissent que si le dossier entier est partagé.",
"scopeDocumentsNote": "Un document peut contenir n'importe quoi. Ne partagez cette rubrique que si la personne peut tout lire.",
"scopeEmptyHint": "Choisissez au moins une rubrique, ou revenez au partage du dossier complet.",
"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",
@@ -9449,7 +9466,10 @@
"errorSelf": "C'est votre propre compte.",
"errorInvalid": "Saisissez un nom d'utilisateur ou une adresse e-mail.",
"errorRateLimited": "Trop d'invitations. Veuillez réessayer plus tard.",
"errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer."
"errorFailed": "L'invitation n'a pas pu être envoyée. Veuillez réessayer.",
"errorScope": "Ces rubriques ne peuvent pas être partagées ainsi. Choisissez au moins une rubrique, ou partagez le dossier complet.",
"errorManageBrowserOnly": "Un accès de gestion ne peut être proposé que depuis un navigateur, car il demande votre deuxième facteur.",
"errorStepUp": "Confirmez votre deuxième facteur, puis réessayez."
},
"given": {
"title": "Qui peut ouvrir mon dossier",
@@ -9474,6 +9494,7 @@
"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.",
"manageConsent": "Si vous acceptez, vous pourrez compléter le dossier de {name} mais aussi modifier ou supprimer ce qui s'y trouve déjà, y compris les entrées de {name}. Tout ce que vous faites apparaît à votre nom dans son journal d'activité, et {name} peut mettre fin à votre accès à tout moment.",
"open": "Ouvrir",
"renounce": "Rendre",
"renounceTitle": "Rendre le dossier de {name} ?",
@@ -9516,6 +9537,40 @@
"medicationCreate": "{name} a ajouté un médicament",
"medicationIntake": "{name} a enregistré une prise",
"medicationIntakeRefused": "{name} a tenté de modifier une dose ou de reporter un rappel, et a été refusé"
},
"section": {
"measurements": {
"label": "Mesures",
"detail": "Poids, tension artérielle, pouls, glycémie, sommeil, séances et les valeurs que vous suivez."
},
"medications": {
"label": "Médicaments",
"detail": "La liste des médicaments, leurs horaires, les prises, les effets indésirables et les stocks."
},
"labs": {
"label": "Résultats de laboratoire",
"detail": "Les résultats de laboratoire et la liste des analytes correspondants."
},
"profile": {
"label": "Antécédents de santé",
"detail": "Allergies, antécédents familiaux et les informations de votre profil de santé."
},
"illness": {
"label": "Maladies",
"detail": "Les épisodes de maladie et les journaux quotidiens qui s'y rattachent."
},
"mind": {
"label": "Humeur et santé mentale",
"detail": "Les entrées d'humeur et l'historique des questionnaires de santé mentale."
},
"cycle": {
"label": "Cycle",
"detail": "Le suivi du cycle."
},
"documents": {
"label": "Documents",
"detail": "Tout ce qui est conservé dans le coffre à documents."
}
}
}
}
+56 -1
View File
@@ -9418,6 +9418,10 @@
"row": {
"readOnly": "Può leggere la cartella",
"canWrite": "Può leggere e aggiungere voci",
"canManage": "Può leggere la cartella, aggiungere voci e modificare o eliminare ciò che contiene",
"entireRecord": "Copre l'intera cartella",
"sections": "Copre: {sections}",
"noSections": "Non copre nulla: questo accesso non apre alcuna parte della cartella",
"invited": "Invitato il",
"accepted": "Accettato il",
"expires": "Scade il",
@@ -9439,6 +9443,19 @@
"accessReadCapability": "Può leggere tutto ciò che questa cartella condivide. Non può aggiungere né modificare nulla.",
"accessWriteLabel": "Può vedere e aggiungere",
"accessWriteCapability": "Può anche aggiungere alla cartella: misurazioni, risultati di laboratorio, un nuovo analita da seguire, una voce di malattia, un effetto collaterale di un farmaco, un nuovo farmaco e segnare una dose come presa o saltata. Non può modificare né eliminare nulla, nemmeno ciò che ha aggiunto, e non può rimandare uno dei tuoi promemoria. Solo tu puoi.",
"accessManageLabel": "Può gestire",
"accessManageCapability": "Può aggiungere, modificare ed eliminare voci della tua cartella, comprese quelle inserite da te, e può leggere le analisi generate a partire da essa. Non può cambiare il tuo accesso, le tue impostazioni di sicurezza, i tuoi servizi collegati né chi altro ha accesso. Puoi terminare tutto questo in qualsiasi momento.",
"manageStepUpNote": "Per offrirlo viene richiesto il tuo secondo fattore, quindi si può fare solo da browser.",
"scopeLegend": "Che cosa può aprire",
"scopeAllLabel": "Intera cartella",
"scopeAllCapability": "Ogni sua parte, come la condivisione ha sempre funzionato.",
"scopeSomeLabel": "Solo le sezioni che scegli",
"scopeSomeCapability": "Tutto ciò che non scegli resta chiuso per lei.",
"scopeManageNote": "L'accesso di gestione copre sempre l'intera cartella.",
"scopeLeakNote": "Una sezione può menzionare il resto della tua salute nelle note e nei nomi. Condividere una sezione significa condividere tutto ciò che vi è scritto.",
"scopeAggregateNote": "La panoramica della dashboard, il punteggio di salute e il riepilogo giornaliero compaiono solo se viene condivisa l'intera cartella.",
"scopeDocumentsNote": "Un documento può contenere qualsiasi cosa. Condividi questa sezione solo se quella persona può leggere tutto.",
"scopeEmptyHint": "Scegli almeno una sezione oppure torna a condividere l'intera cartella.",
"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",
@@ -9449,7 +9466,10 @@
"errorSelf": "Questo è il tuo account.",
"errorInvalid": "Inserisci un nome utente o un indirizzo e-mail.",
"errorRateLimited": "Troppi inviti. Riprova più tardi.",
"errorFailed": "Non è stato possibile inviare l'invito. Riprova."
"errorFailed": "Non è stato possibile inviare l'invito. Riprova.",
"errorScope": "Queste sezioni non si possono condividere così. Scegli almeno una sezione oppure condividi l'intera cartella.",
"errorManageBrowserOnly": "L'accesso di gestione si può offrire solo da un browser, perché richiede il tuo secondo fattore.",
"errorStepUp": "Conferma il tuo secondo fattore e riprova."
},
"given": {
"title": "Chi può aprire la mia cartella",
@@ -9474,6 +9494,7 @@
"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}.",
"manageConsent": "Se accetti, potrai aggiungere voci alla cartella di {name} e anche modificare o eliminare quelle già presenti, comprese quelle inserite da {name}. Tutto ciò che fai viene registrato con il tuo nome nella sua vista attività, e {name} può terminare il tuo accesso in qualsiasi momento.",
"open": "Apri",
"renounce": "Restituisci",
"renounceTitle": "Restituire la cartella di {name}?",
@@ -9516,6 +9537,40 @@
"medicationCreate": "{name} ha aggiunto un farmaco",
"medicationIntake": "{name} ha registrato una dose",
"medicationIntakeRefused": "{name} ha provato a modificare una dose o a rimandare un promemoria, ed è stato rifiutato"
},
"section": {
"measurements": {
"label": "Misurazioni",
"detail": "Peso, pressione, polso, glicemia, sonno, allenamenti e i valori che monitori."
},
"medications": {
"label": "Farmaci",
"detail": "L'elenco dei farmaci, gli orari, le dosi assunte, gli effetti collaterali e le scorte."
},
"labs": {
"label": "Esami di laboratorio",
"detail": "I risultati di laboratorio e l'elenco degli analiti che li compongono."
},
"profile": {
"label": "Anamnesi",
"detail": "Allergie, storia familiare e i dati del tuo profilo di salute."
},
"illness": {
"label": "Malattie",
"detail": "Gli episodi di malattia e i diari giornalieri collegati."
},
"mind": {
"label": "Umore e benessere mentale",
"detail": "Le voci sull'umore e lo storico dei questionari sulla salute mentale."
},
"cycle": {
"label": "Ciclo",
"detail": "Il monitoraggio del ciclo."
},
"documents": {
"label": "Documenti",
"detail": "Tutto ciò che è conservato nell'archivio documenti."
}
}
}
}
+56 -1
View File
@@ -9418,6 +9418,10 @@
"row": {
"readOnly": "Może czytać kartę",
"canWrite": "Może czytać i dodawać wpisy",
"canManage": "Może czytać kartotekę, dodawać wpisy oraz zmieniać i usuwać to, co w niej jest",
"entireRecord": "Obejmuje całą kartotekę",
"sections": "Obejmuje: {sections}",
"noSections": "Nie obejmuje niczego: ten dostęp nie otwiera żadnej części kartoteki",
"invited": "Zaproszono",
"accepted": "Zaakceptowano",
"expires": "Wygasa",
@@ -9439,6 +9443,19 @@
"accessReadCapability": "Może czytać wszystko, co ta karta udostępnia. Nie może niczego dodać ani zmienić.",
"accessWriteLabel": "Może przeglądać i dodawać",
"accessWriteCapability": "Może też dodawać do kartoteki: pomiary, wyniki badań, nowy analit do śledzenia, wpis o chorobie, działanie niepożądane leku, nowy lek oraz oznaczyć dawkę jako przyjętą lub pominiętą. Nie może niczego edytować ani usuwać, nawet tego, co sama dodała, ani odłożyć żadnego z Twoich przypomnień. Tylko Ty możesz.",
"accessManageLabel": "Może zarządzać",
"accessManageCapability": "Może dodawać, zmieniać i usuwać wpisy w Twojej kartotece, także te, które wprowadziłeś samodzielnie, oraz czytać wygenerowane na jej podstawie analizy. Nie może zmienić Twojego logowania, ustawień bezpieczeństwa, połączonych usług ani tego, kto jeszcze ma dostęp. Możesz to zakończyć w dowolnym momencie.",
"manageStepUpNote": "Przy takim zaproszeniu prosimy o drugi składnik logowania, dlatego można to zrobić tylko w przeglądarce.",
"scopeLegend": "Co ta osoba może otworzyć",
"scopeAllLabel": "Cała kartoteka",
"scopeAllCapability": "Każda jej część, tak jak udostępnianie działało do tej pory.",
"scopeSomeLabel": "Tylko wybrane przez Ciebie sekcje",
"scopeSomeCapability": "Wszystko, czego nie wybierzesz, pozostaje dla niej zamknięte.",
"scopeManageNote": "Dostęp zarządzający zawsze obejmuje całą kartotekę.",
"scopeLeakNote": "Sekcja może wspominać o reszcie Twojego zdrowia w notatkach i nazwach. Udostępnienie sekcji oznacza udostępnienie wszystkiego, co w niej zapisano.",
"scopeAggregateNote": "Przegląd na pulpicie, wynik zdrowia i dzienne podsumowanie pojawiają się tylko wtedy, gdy udostępniona jest cała kartoteka.",
"scopeDocumentsNote": "Dokument może zawierać wszystko. Udostępnij tę sekcję tylko wtedy, gdy ta osoba może przeczytać całość.",
"scopeEmptyHint": "Wybierz co najmniej jedną sekcję albo wróć do udostępniania całej kartoteki.",
"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",
@@ -9449,7 +9466,10 @@
"errorSelf": "To twoje własne konto.",
"errorInvalid": "Podaj nazwę użytkownika lub adres e-mail.",
"errorRateLimited": "Zbyt wiele zaproszeń. Spróbuj później.",
"errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie."
"errorFailed": "Nie udało się wysłać zaproszenia. Spróbuj ponownie.",
"errorScope": "Tych sekcji nie da się udostępnić w ten sposób. Wybierz co najmniej jedną sekcję albo udostępnij całą kartotekę.",
"errorManageBrowserOnly": "Dostęp zarządzający można przyznać tylko w przeglądarce, ponieważ wymaga drugiego składnika logowania.",
"errorStepUp": "Potwierdź drugi składnik logowania i spróbuj ponownie."
},
"given": {
"title": "Kto może otworzyć moją kartę",
@@ -9474,6 +9494,7 @@
"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}.",
"manageConsent": "Jeśli zaakceptujesz, będziesz mieć możliwość uzupełniania kartoteki użytkownika {name}, a także zmieniania i usuwania tego, co już w niej jest, łącznie z wpisami samego {name}. Wszystko, co zrobisz, zostanie zapisane pod Twoim imieniem w widoku aktywności, a {name} może zakończyć Twój dostęp w dowolnej chwili.",
"open": "Otwórz",
"renounce": "Zwróć",
"renounceTitle": "Zwrócić kartę użytkownika {name}?",
@@ -9516,6 +9537,40 @@
"medicationCreate": "{name} dodał lek",
"medicationIntake": "{name} zapisał przyjęcie dawki",
"medicationIntakeRefused": "{name} próbował(a) zmienić dawkę lub odłożyć przypomnienie i został(a) odrzucony(-a)"
},
"section": {
"measurements": {
"label": "Pomiary",
"detail": "Waga, ciśnienie, tętno, glukoza, sen, treningi i śledzone przez Ciebie wartości."
},
"medications": {
"label": "Leki",
"detail": "Lista leków, harmonogramy, przyjęte dawki, działania niepożądane i stan zapasów."
},
"labs": {
"label": "Wyniki badań",
"detail": "Wyniki laboratoryjne i lista analitów, które za nimi stoją."
},
"profile": {
"label": "Historia zdrowia",
"detail": "Alergie, wywiad rodzinny i dane z Twojego profilu zdrowia."
},
"illness": {
"label": "Choroby",
"detail": "Epizody chorobowe i prowadzone przy nich wpisy dzienne."
},
"mind": {
"label": "Nastrój i zdrowie psychiczne",
"detail": "Wpisy o nastroju i historia kwestionariuszy zdrowia psychicznego."
},
"cycle": {
"label": "Cykl",
"detail": "Śledzenie cyklu."
},
"documents": {
"label": "Dokumenty",
"detail": "Wszystko, co jest przechowywane w sejfie dokumentów."
}
}
}
}
@@ -0,0 +1,290 @@
/**
* `POST /api/account/grants` — the two things v1.37.0 adds to an invitation.
*
* The sections it opens, and the step-up that gates the level which can delete
* things. Both are asserted on what the route DID rather than on what it
* answered with: the scope on the `data` object handed to `accountGrant.create`
* (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 dropped on the
* way to the database), and the step-up on the gate having been CALLED, plus
* the absence of a row when it refuses.
*
* What lives where, asserted rather than assumed: an absent `scope` becomes
* NULL in the schema and nowhere else, and `inviteGrant` writes `Prisma.DbNull`
* for it — a bare `null` on a nullable Json column stores the JSON value
* `null`, which the fail-closed normaliser reads as the empty set. That is a
* grant opening NOTHING written by the path meant to open everything, one
* keystroke away, so it is pinned.
*
* Mutation checks, run and recorded in the stream's report:
* - drop the enum from the `scope` schema (`z.array(z.string())`) → the
* unknown-section case stops being refused and goes red;
* - make the step-up branch unconditional (`if (true)`) → "a read invitation
* is not gated" goes red; remove the branch entirely → the MANAGE legs go
* red.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/api-handler", async () => {
const actual =
await vi.importActual<typeof import("@/lib/api-handler")>(
"@/lib/api-handler",
);
return {
...actual,
apiHandler: <T extends (...args: never[]) => Promise<Response>>(h: T): T =>
h,
requireAuth: vi.fn(),
requireFreshMfaIfEnrolled: 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,
requireFreshMfaIfEnrolled,
StepUpRequiredError,
} from "@/lib/api-handler";
import { auditLog } from "@/lib/auth/audit";
import { prisma } from "@/lib/db";
import { Prisma } from "@/generated/prisma/client";
const OWNER = { id: "owner-1" };
const INVITEE = { id: "invitee-1", username: "housemate", displayName: null };
function request(body: Record<string, unknown>): 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<string, unknown> {
const calls = vi.mocked(prisma.accountGrant.create).mock.calls;
expect(calls).toHaveLength(1);
return (calls[0][0] as { data: Record<string, unknown> }).data;
}
/** Resolve the caller on the transport the argument names. */
function callerOn(transport: "cookie" | "bearer") {
vi.mocked(requireAuth).mockResolvedValue({
user: OWNER,
authMethod: transport,
} as unknown as Awaited<ReturnType<typeof requireAuth>>);
}
beforeEach(() => {
vi.clearAllMocks();
callerOn("cookie");
vi.mocked(requireFreshMfaIfEnrolled).mockResolvedValue({
user: OWNER,
} as unknown as Awaited<ReturnType<typeof requireFreshMfaIfEnrolled>>);
vi.mocked(prisma.user.findFirst).mockResolvedValue(
INVITEE as never as Awaited<ReturnType<typeof prisma.user.findFirst>>,
);
vi.mocked(prisma.accountGrant.create).mockImplementation((async (args: {
data: Record<string, unknown>;
}) => ({
id: "grant-1",
grantorId: OWNER.id,
granteeId: INVITEE.id,
acceptedAt: null,
revokedAt: null,
revokedBy: null,
lastUsedAt: null,
expiresAt: null,
scopeJson: null,
...args.data,
})) as never);
});
describe("the sections an invitation opens", () => {
it("stores the set the owner picked", async () => {
const res = await POST(
request({ identifier: "housemate", scope: ["labs", "medications"] }),
);
expect(res.status).toBe(201);
expect(writtenRow().scopeJson).toEqual(["labs", "medications"]);
});
it("stores the whole record as DbNull, not as a JSON null", async () => {
// The one-keystroke inversion: a bare `null` on a nullable Json column is
// the stored JSON value `null`, which the fail-closed normaliser resolves
// to the empty set — the entire record turned into nothing at all.
const res = await POST(request({ identifier: "housemate" }));
expect(res.status).toBe(201);
expect(writtenRow().scopeJson).toBe(Prisma.DbNull);
});
it("treats an explicit null the same as an absent field", async () => {
const res = await POST(request({ identifier: "housemate", scope: null }));
expect(res.status).toBe(201);
expect(writtenRow().scopeJson).toBe(Prisma.DbNull);
});
it("refuses a section outside the vocabulary, without writing a row", async () => {
const res = await POST(
request({ identifier: "housemate", scope: ["labs", "bank_details"] }),
);
expect(res.status).toBe(422);
expect(prisma.accountGrant.create).not.toHaveBeenCalled();
});
it("refuses the whole-record sentinel as a section", async () => {
// `record` is deliberately not a member of the vocabulary: a narrowing
// that names the whole record is a narrowing that means its own absence,
// and the resolver would refuse every domain for it.
const res = await POST(
request({ identifier: "housemate", scope: ["record"] }),
);
expect(res.status).toBe(422);
expect(prisma.accountGrant.create).not.toHaveBeenCalled();
});
it("refuses an empty set, without writing a row", async () => {
const res = await POST(request({ identifier: "housemate", scope: [] }));
expect(res.status).toBe(422);
expect(prisma.accountGrant.create).not.toHaveBeenCalled();
});
it("refuses a scope beside MANAGE, without writing a row", async () => {
// Management is whole-record by construction. "They can do anything, but
// only to part of you" promises a boundary an edit cannot keep.
const res = await POST(
request({ identifier: "housemate", access: "MANAGE", scope: ["labs"] }),
);
expect(res.status).toBe(422);
expect(prisma.accountGrant.create).not.toHaveBeenCalled();
});
it("records the sections on the audit row", async () => {
// "Who was given what" is the question this row exists to answer, and an
// entry naming the level without the sections answers half of it.
await POST(request({ identifier: "housemate", scope: ["mind"] }));
expect(auditLog).toHaveBeenCalledWith(
"sharing.grant.invited",
expect.objectContaining({
userId: OWNER.id,
details: expect.objectContaining({ scope: ["mind"] }),
}),
);
});
it("records the whole record as an answer rather than an omission", async () => {
await POST(request({ identifier: "housemate" }));
const call = vi.mocked(auditLog).mock.calls[0];
const details = (call[1] as { details: Record<string, unknown> }).details;
expect(details).toHaveProperty("scope", null);
});
});
describe("offering MANAGE", () => {
it("mints the row once the step-up is satisfied", async () => {
const res = await POST(
request({ identifier: "housemate", access: "MANAGE" }),
);
expect(res.status).toBe(201);
expect(writtenRow().access).toBe("MANAGE");
expect(writtenRow().scopeJson).toBe(Prisma.DbNull);
expect(requireFreshMfaIfEnrolled).toHaveBeenCalledWith(expect.any(Number));
});
it("refuses without a fresh second factor, and writes nothing", async () => {
// The refusal CODE, not merely the absence of a row: a route that threw
// for any other reason would also leave no row, and this leg is meant to
// prove the GATE ran.
vi.mocked(requireFreshMfaIfEnrolled).mockRejectedValue(
new StepUpRequiredError(),
);
await expect(
POST(request({ identifier: "housemate", access: "MANAGE" })),
).rejects.toMatchObject({ errorCode: "auth.stepup.required" });
expect(prisma.accountGrant.create).not.toHaveBeenCalled();
});
it("does not gate a read or a write invitation", async () => {
// The friction belongs to the level that can delete things. An owner
// sharing a reading with their partner re-proves nothing.
for (const access of ["READ", "WRITE"] as const) {
vi.clearAllMocks();
callerOn("cookie");
const res = await POST(request({ identifier: "housemate", access }));
expect(res.status).toBe(201);
expect(requireFreshMfaIfEnrolled).not.toHaveBeenCalled();
}
});
it("refuses a Bearer caller with a code it can act on", async () => {
// The decided consequence of a cookie-only step-up, said comprehensibly.
// Left to fall through, the gate resolves the session cookie, finds none
// and answers "Not authenticated" to a caller the route had just
// authenticated — an obscure failure on the one transport that cannot
// ever succeed here.
callerOn("bearer");
const res = await POST(
request({ identifier: "housemate", access: "MANAGE" }),
);
expect(res.status).toBe(403);
const body = (await res.json()) as { meta?: { errorCode?: string } };
expect(body.meta?.errorCode).toBe("sharing.invite.manage_browser_only");
expect(requireFreshMfaIfEnrolled).not.toHaveBeenCalled();
expect(prisma.accountGrant.create).not.toHaveBeenCalled();
});
it("refuses the Bearer caller before it discloses whether the account exists", async () => {
// The identifier lookup answers 404 for a name nobody carries, which is a
// deliberate disclosure to an authenticated caller — but there is no
// reason to hand it out on a request that cannot succeed.
callerOn("bearer");
await POST(request({ identifier: "housemate", access: "MANAGE" }));
expect(prisma.user.findFirst).not.toHaveBeenCalled();
});
it("lets a Bearer caller keep minting the two levels it always could", async () => {
callerOn("bearer");
const res = await POST(
request({ identifier: "housemate", access: "WRITE" }),
);
expect(res.status).toBe(201);
expect(writtenRow().access).toBe("WRITE");
});
});
+82 -17
View File
@@ -7,9 +7,19 @@
* client that has to call twice will eventually render half an answer.
*
* `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.
* the level the owner chose and over the sections they chose. There is no
* route that raises a live grant: both are settled when the invitation is
* written and the only way to a wider one is a fresh invitation the delegate
* accepts again.
*
* v1.37.0 — offering MANAGE is the one act on this surface with friction on
* it. It asks for a fresh second factor when the account has one, which makes
* it cookie-only by construction: the step-up gate resolves through the
* session cookie and a token transport carries nothing that could satisfy it.
* That consequence is deliberate rather than an oversight — the native client
* cannot offer management of a health record, and the refusal below says so in
* a code rather than leaving a token caller with an authentication error on a
* request that authenticated fine.
*
* **Neither is delegable, and that is structural.** Both resolve through bare
* `requireAuth()`, which refuses outright while the caller is acting on
@@ -31,7 +41,12 @@
*/
import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import {
apiHandler,
requireAuth,
requireFreshMfaIfEnrolled,
MFA_STEP_UP_MAX_AGE_SECONDS,
} from "@/lib/api-handler";
import {
apiError,
apiSuccess,
@@ -91,7 +106,8 @@ export const GET = apiHandler(async () => {
});
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const auth = await requireAuth();
const user = auth.user;
const rl = await checkRateLimit(
`sharing:invite:${user.id}`,
@@ -114,7 +130,38 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
}
const { identifier, expiresAt, access } = parsed.data;
const { identifier, expiresAt, access, scope } = parsed.data;
// ── The step-up, and only on the level that needs one ───────────────────
//
// MANAGE is the level that can rewrite and delete somebody's health record,
// so offering it is gated on a fresh second factor the way account deletion
// and the encrypted export are. READ and WRITE are untouched: an owner
// sharing a reading with their partner should not have to re-prove anything.
//
// It runs BEFORE the invitee lookup on purpose. That lookup answers 404 for
// an identifier nobody carries, which is a deliberate disclosure to an
// authenticated caller — but there is no reason to hand it out on a request
// that is about to be refused anyway.
//
// The transport is checked first and separately. `requireFreshMfaIfEnrolled`
// escalates to `requireFreshMfa`, which resolves through the session cookie
// and has no Bearer fall-through; a token caller would therefore be told
// "Not authenticated" by a route that had just authenticated them, which is
// the obscure failure this branch exists to prevent. The consequence is
// deliberate and documented (the native client cannot mint MANAGE): it is
// said here in a code a client can branch on, so the app can send the person
// to a browser instead of showing them a login error.
if (access === "MANAGE") {
if (auth.authMethod !== "cookie") {
return apiError(
"Manage access can only be offered from a browser session",
403,
{ errorCode: "sharing.invite.manage_browser_only" },
);
}
await requireFreshMfaIfEnrolled(MFA_STEP_UP_MAX_AGE_SECONDS);
}
// 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.
@@ -142,26 +189,29 @@ export const POST = apiHandler(async (request: NextRequest) => {
grantorId: user.id,
granteeId: invitee.id,
access,
// The entire record, which is what this endpoint has always offered and
// what every stored grant means. Named rather than defaulted, because
// the domain module takes the scope as a required argument for the same
// reason it takes the level as one: a scope nobody chose is not a scope.
// The consent surface that lets an owner narrow it lands beside this
// line, in this release.
scope: null,
// The sections the owner ticked, or null for the entire record. The
// schema is where an absent field becomes null, the same way it is where
// an absent level becomes READ; the domain module takes both as required
// arguments so neither is decided twice. It re-refuses an empty set, an
// unknown key and a scope beside MANAGE underneath this line — the
// schema is the legible refusal, that one is the floor.
scope: scope ?? null,
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.
// The level AND the sections are 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
// either would answer half of it. `null` is written as null rather than
// omitted, because "the entire record" is an answer and a missing key
// would read as a row from before the question was asked.
await auditLog("sharing.grant.invited", {
userId: user.id,
details: {
grantId: grant.id,
granteeId: invitee.id,
access: grant.access,
scope: scope ?? null,
},
ipAddress: getClientIp(request),
}).catch(() => {});
@@ -171,6 +221,11 @@ export const POST = apiHandler(async (request: NextRequest) => {
meta: {
grant_id: grant.id,
access: grant.access,
// The count rather than the keys: a dashboard wants to know how often
// owners narrow, and which sections one household shares is not a
// thing to spread across the observability pipeline. Zero means the
// entire record.
scope_sections: scope?.length ?? 0,
expires: expiresAt !== null && expiresAt !== undefined,
},
});
@@ -199,6 +254,16 @@ function grantErrorResponse(err: GrantError): Response {
return apiError("That account already has access", 409, {
errorCode: "sharing.invite.duplicate",
});
// The request shape refuses the same three cases earlier and more
// legibly (a 422 naming the `scope` field). Reaching this arm means a
// caller got past the schema with a scope the domain module will not
// store — an empty set, a key outside the vocabulary, or any scope beside
// MANAGE. Same status, its own code, so a client can tell "your sections
// are wrong" from "that is your own account".
case "invalid_scope":
return apiError("Those sections cannot be shared", 422, {
errorCode: "sharing.invite.invalid_scope",
});
default:
return apiError("Invitation refused", 422, {
errorCode: "sharing.invite.refused",
@@ -54,6 +54,8 @@ const OWNER = {
username: "owner",
displayName: "Margarethe",
access: "read" as const,
level: "read" as const,
sections: null,
canWrite: false,
};
@@ -69,7 +71,7 @@ const READ_ONLY: AccountAccess = {
};
const WRITABLE: AccountAccess = {
accounts: [OWNER],
active: { ...OWNER, access: "write", canWrite: true },
active: { ...OWNER, access: "write", level: "write", canWrite: true },
canSwitch: true,
};
@@ -37,6 +37,8 @@ const OWNER = {
username: "owner",
displayName: "Margarethe",
access: "read" as const,
level: "read" as const,
sections: null,
canWrite: false,
};
@@ -52,7 +54,7 @@ const READ_ONLY: AccountAccess = {
};
const WRITABLE: AccountAccess = {
accounts: [OWNER],
active: { ...OWNER, access: "write", canWrite: true },
active: { ...OWNER, access: "write", level: "write", canWrite: true },
canSwitch: true,
};
@@ -8,6 +8,8 @@ const OWNER = {
username: "owner",
displayName: "Margarethe",
access: "write" as const,
level: "write" as const,
sections: null,
canWrite: true,
};
@@ -95,7 +97,12 @@ describe("<ChartOverlayControls>", () => {
for (const access of ["read", "write"] as const) {
mockAccessRef.value = {
accounts: [OWNER],
active: { ...OWNER, access, canWrite: access === "write" },
active: {
...OWNER,
access,
level: access,
canWrite: access === "write",
},
canSwitch: true,
};
const html = renderToStaticMarkup(
@@ -25,6 +25,8 @@ const GRANDMOTHER = {
username: "gran",
displayName: "Margarethe",
access: "read" as const,
level: "read" as const,
sections: null,
canWrite: false,
};
const FATHER = {
@@ -32,6 +34,8 @@ const FATHER = {
username: "dad",
displayName: null,
access: "write" as const,
level: "write" as const,
sections: null,
canWrite: true,
};
@@ -114,7 +118,9 @@ describe("<LookingAfterCard>", () => {
// about it this second. A grant that lapsed, or a level this build does
// not honour, must not promise an add the next request would refuse.
const html = render({
accounts: [{ ...FATHER, access: "write", canWrite: false }],
accounts: [
{ ...FATHER, access: "write", level: "write", canWrite: false },
],
active: null,
canSwitch: true,
});
@@ -115,11 +115,14 @@ const OWNER_READ: AccountAccessEntry = {
username: "grandma",
displayName: "Margarethe",
access: "read" as const,
level: "read" as const,
sections: null,
canWrite: false,
};
const OWNER_WRITE: AccountAccessEntry = {
...OWNER_READ,
access: "write",
level: "write",
canWrite: true,
};
@@ -57,6 +57,8 @@ const OWNER = {
username: "grandma",
displayName: "Margarethe",
access: "read" as const,
level: "read" as const,
sections: null,
canWrite: false,
};
@@ -117,7 +119,12 @@ describe("<SharedRecordBanner>", () => {
it("drops the read-only line when the server says the grant can write", () => {
// Resolved server-side. The banner never decides what a grant permits; the
// day a WRITE grant exists this line stops claiming otherwise on its own.
const writable = { ...OWNER, access: "write" as const, canWrite: true };
const writable = {
...OWNER,
access: "write" as const,
level: "write" as const,
canWrite: true,
};
const html = render(
{ accounts: [writable], active: writable, canSwitch: true },
<SharedRecordBanner />,
@@ -63,6 +63,7 @@ const BASE: GrantRow = {
id: "g1",
account: { id: "acct-1", username: "housemate", displayName: "Jo" },
access: "READ",
scope: null,
state: "ACTIVE",
invitedAt: "2026-01-02T10:00:00.000Z",
acceptedAt: "2026-01-02T11:00:00.000Z",
@@ -65,6 +65,7 @@ const BASE: GrantRow = {
id: "g1",
account: { id: "acct-1", username: "housemate", displayName: "Jo" },
access: "READ",
scope: null,
state: "PENDING",
invitedAt: "2026-01-02T10:00:00.000Z",
acceptedAt: null,
@@ -75,33 +76,57 @@ const BASE: GrantRow = {
};
describe("offering a level", () => {
it("offers both levels and preselects the narrower one", () => {
it("offers all three levels and preselects the narrowest", () => {
const html = render(<GrantInviteCard />);
expect(html).toContain('data-slot="grant-invite-access"');
expect(html).toContain('data-access="READ"');
expect(html).toContain('data-access="WRITE"');
expect(html).toContain('data-access="MANAGE"');
// 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"');
expect(html).toContain('data-access="MANAGE" data-selected="false"');
});
it("shows keyboard focus on the option the radio is hidden inside", () => {
it("shows keyboard focus on every option the radio is hidden inside", () => {
// The radio is `sr-only`, so the browser's own ring lands on a zero-size
// box and tabbing through this fieldset moved an invisible selection —
// WCAG 2.4.7, on the screen where somebody grants write access to their
// health record. The label wears the ring instead, and `has-[:focus-visible]`
// is what carries it from the input to the label.
//
// Both fieldsets are checked, not only the level one: v1.37.0 added a
// second group of radios in the same idiom, and a group that lost the ring
// would be the same defect shipped again one section further down.
const html = render(<GrantInviteCard />);
const options = html.match(
const levels = html.match(
/<label[^>]*data-slot="grant-invite-access-option"[^>]*>/g,
);
expect(options).toHaveLength(2);
for (const option of options ?? []) {
const scopes = html.match(
/<label[^>]*data-slot="grant-invite-scope-option"[^>]*>/g,
);
expect(levels).toHaveLength(3);
expect(scopes).toHaveLength(2);
for (const option of [...(levels ?? []), ...(scopes ?? [])]) {
expect(option, option).toContain("has-[:focus-visible]:ring-2");
expect(option, option).toContain("has-[:focus-visible]:ring-ring/50");
}
});
it("says plainly that manage can change and delete what the owner wrote", () => {
const html = render(<GrantInviteCard />);
// The clause that separates this level from the one below it. A rewrite
// that keeps "manage" and drops this is a rewrite that stops describing
// a delete button.
expect(html).toContain("add, change and remove entries in your record");
expect(html).toContain("including ones you entered yourself");
// And the fence, in the same breath. Consent to management is only
// meaningful beside what management does not reach.
expect(html).toContain("They cannot change your login");
expect(html).toContain("who else has access");
expect(html).toContain("You can end this at any time.");
});
it("says what the wider level actually does, and what it does not", () => {
const html = render(<GrantInviteCard />);
// The thing a person reading "write access" gets wrong.
@@ -144,6 +169,24 @@ describe("accepting a level", () => {
);
});
it("asks a manage invitation for its own, heavier consent", () => {
grantsRef.value = { given: [], received: [{ ...BASE, access: "MANAGE" }] };
const html = render(<GrantsReceivedCard />);
expect(html).toContain('data-grant-access="MANAGE"');
// The row states the level in its own words, not the write level's.
expect(html).toContain("change or remove what is in it");
expect(html).toContain('data-slot="grant-manage-consent"');
// Not the write sentence wearing a stronger adjective: a different
// responsibility gets a different sentence.
expect(html).not.toContain('data-slot="grant-write-consent"');
expect(html).toContain("change or remove what is already in it");
expect(html).toContain("recorded under your name");
expect(html.indexOf('data-slot="grant-manage-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(<GrantsReceivedCard />);
@@ -196,6 +239,21 @@ describe("ending an access, and what it says about what was entered", () => {
expect(body).toContain('revokeAttribution({"days":90})');
});
it("tells a manage owner the same, because the question is sharper there", () => {
// The condition is "not READ" rather than "is WRITE". A manager put things
// in the record AND changed things in it, so "what happens to what they
// did" is the sentence this owner most needs, and a level check that
// listed WRITE by name would have dropped it exactly where it matters.
const body = revokeBody({
t,
access: "MANAGE",
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.
@@ -0,0 +1,211 @@
/**
* The consent surface for a narrowed grant.
*
* What an SSR render can prove and what it cannot, stated up front because it
* shapes every assertion below. It can prove that a control exists, that a
* sentence sits beside it, that a default is the default, and that a level
* that must not offer scope does not render one. It CANNOT prove that the
* sections somebody ticks reach the server: there is no click here, so the
* submit path never runs. That half is `e2e/account-sharing.spec.ts` (which
* reads the posted body) and `src/app/api/account/grants/__tests__/` (which
* reads the written row).
*
* The interactive states are reached by rendering the card with the state
* already in it — React's `useState` initialiser is not reachable from a
* static render, so the expanded state is asserted through the one branch a
* static render CAN see: Manage, whose collapse is unconditional. Everything
* the expanded panel says is pinned in the bundle test at the bottom, which is
* where a dropped honesty sentence would actually be dropped.
*
* Mutation checks, run and recorded in the stream's report:
* - render the eight checkboxes unconditionally (drop the `narrowed` gate) →
* "the entire record is the default and the sections stay closed" goes red;
* - render the scope fieldset for MANAGE too → "manage collapses the whole
* question" goes red.
*/
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { I18nProvider } from "@/lib/i18n/context";
vi.mock("@/lib/queries/use-account-grants", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/lib/queries/use-account-grants")>();
return {
...actual,
useInviteGrant: () => ({ mutate: vi.fn(), isPending: false }),
};
});
import { GrantInviteCard, resolveInviteScope } from "../grant-invite-card";
function render(node: React.ReactNode): string {
return renderToStaticMarkup(
<I18nProvider initialLocale="en">{node}</I18nProvider>,
);
}
describe("choosing what a grant opens", () => {
it("asks the scope question for the two levels it applies to", () => {
const html = render(<GrantInviteCard />);
expect(html).toContain('data-slot="grant-invite-scope"');
expect(html).toContain('data-scope="all"');
expect(html).toContain('data-scope="some"');
});
it("makes the entire record the default and leaves the sections closed", () => {
// The shape of the whole design: narrowing is an act somebody chooses, so
// the default costs nothing and the eight checkboxes are not on screen
// until they are wanted. A form that showed them all at once would make
// the common case look like unfinished work.
const html = render(<GrantInviteCard />);
expect(html).toContain('data-scope="all" data-selected="true"');
expect(html).toContain('data-scope="some" data-selected="false"');
expect(html).not.toContain('data-slot="grant-invite-sections"');
expect(html).not.toContain('data-slot="grant-invite-section"');
});
it("says nothing about sections before the level is decided", () => {
// The honesty sentences belong to the decision, not to the page. Rendered
// unconditionally they would be a disclaimer somebody scrolls past; they
// appear where the sections do.
const html = render(<GrantInviteCard />);
expect(html).not.toContain('data-slot="grant-invite-scope-leak"');
expect(html).not.toContain('data-slot="grant-invite-scope-aggregates"');
});
});
describe("the bundle behind the expanded state", () => {
// What the panel says is copy, and copy is where this silently rots. The
// strings are asserted against the shipped English bundle rather than
// against a render, so a key that stops being written is caught here and a
// key that stops being READ is caught by the i18n reverse-coverage guard.
const en = JSON.parse(
readFileSync(join(process.cwd(), "messages/en.json"), "utf8"),
) as { recordSharing: Record<string, Record<string, unknown>> };
const invite = en.recordSharing.invite as Record<string, string>;
const sections = en.recordSharing.section as Record<
string,
{ label: string; detail: string }
>;
it("names all eight sections with a sentence each", () => {
// Eight, not seven and not nine: the vocabulary is closed, and a section
// in the enum without copy would render a blank line on a consent screen.
expect(Object.keys(sections).sort()).toEqual(
[
"cycle",
"documents",
"illness",
"labs",
"measurements",
"medications",
"mind",
"profile",
].sort(),
);
for (const [key, entry] of Object.entries(sections)) {
expect(entry.label.length, key).toBeGreaterThan(2);
// A sentence, not a repeat of the label: the names are the product's
// own boundaries and mean nothing to somebody who did not draw them.
expect(entry.detail.length, key).toBeGreaterThan(entry.label.length);
}
});
it("keeps both honest limits, in the words that make them honest", () => {
// Leakage: a section shares everything written inside it, notes included.
expect(invite.scopeLeakNote).toContain(
"Sharing a section shares everything written in it",
);
// And the aggregates a narrowed grant simply does not get. Saying this
// after the fact is a support ticket; saying it here is consent.
expect(invite.scopeAggregateNote).toContain(
"only appear when the entire record is shared",
);
});
it("warns about the one section whose name does not bound its contents", () => {
expect(invite.scopeDocumentsNote).toContain("can contain anything");
});
it("states the manage fence in the invitation copy itself", () => {
// Not in a tooltip and not in the docs: on the label, where somebody is
// deciding.
expect(invite.accessManageCapability).toContain(
"add, change and remove entries",
);
expect(invite.accessManageCapability).toContain(
"including ones you entered yourself",
);
expect(invite.accessManageCapability).toContain("They cannot change your");
});
});
describe("what the form would send", () => {
// The interactive states a static render cannot reach, exercised through
// the pure function the form itself calls. This is the half that matters:
// the copy above says what a person is agreeing to, this says what leaves
// the browser.
it("sends nothing scoped for the whole record", () => {
expect(
resolveInviteScope({ access: "READ", narrowed: false, sections: [] }),
).toEqual({ mode: "all", scope: null, blocked: false });
});
it("sends the picked sections in reading order, not clicking order", () => {
// The state is a click log. Two owners who picked the same three sections
// in a different sequence must write rows that look the same, or a diff of
// two grants means nothing.
const choice = resolveInviteScope({
access: "WRITE",
narrowed: true,
sections: ["documents", "labs", "measurements"],
});
expect(choice.mode).toBe("pick");
expect(choice.scope).toEqual(["measurements", "labs", "documents"]);
expect(choice.blocked).toBe(false);
});
it("refuses to send a narrowed invitation with nothing picked", () => {
// A grant that opens nothing is not a narrow grant, it is a mistake
// wearing one. The server refuses it; the form does not ask.
const choice = resolveInviteScope({
access: "READ",
narrowed: true,
sections: [],
});
expect(choice.blocked).toBe(true);
expect(choice.scope).toEqual([]);
});
it("never sends a scope with manage, whatever is still ticked", () => {
// The property that needs a test rather than a comment. The checkbox state
// survives a trip through the Manage option on purpose — somebody who
// narrows, reads the Manage sentence and goes back should find their
// sections intact — so "not on screen" is NOT "not in state". The level is
// what decides, and it decides here.
const choice = resolveInviteScope({
access: "MANAGE",
narrowed: true,
sections: ["medications", "labs"],
});
expect(choice.mode).toBe("manage");
expect(choice.scope).toBeNull();
expect(choice.blocked).toBe(false);
});
it("carries the sentence that says why manage has no sections", () => {
const en = JSON.parse(
readFileSync(join(process.cwd(), "messages/en.json"), "utf8"),
) as { recordSharing: { invite: Record<string, string> } };
expect(en.recordSharing.invite.scopeManageNote).toBe(
"Manage access always covers the entire record.",
);
// And the consequence of gating it on a second factor, said where
// somebody meets it rather than in an error message afterwards.
expect(en.recordSharing.invite.manageStepUpNote).toContain("browser");
});
});
@@ -6,33 +6,63 @@ import { UserPlus } from "lucide-react";
import { SettingsCard } from "@/components/settings/settings-card";
import { SettingsCardHeader } from "@/components/settings/_card-header";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useModuleEnabled } from "@/hooks/use-module-enabled";
import { ApiError } from "@/lib/api/api-fetch";
import { useTranslations } from "@/lib/i18n/context";
import { useInviteGrant } from "@/lib/queries/use-account-grants";
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
/**
* v1.36.0 — offering somebody access to this record.
* v1.37.0 — offering somebody access to this record.
*
* 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.
* Three decisions now: who, what they may do, and how much of the record they
* may open. All three are made once, here — a grant is fixed at the level and
* over the sections it was offered with, 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
* every label on it has 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.
* ## Why this shape and not the obvious one
*
* 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
* part somebody needs to know BEFORE they type a name: nothing has been shared
* yet, and nothing will be until the other side agrees.
* The obvious shape is three levels times eight sections laid out at once:
* nineteen controls, all visible, all equal. It reads as a permissions matrix
* and it makes the common case — "my partner can see everything" — look like
* work somebody has to finish. So the form is two questions asked in order,
* and the second one is a door rather than a grid:
*
* * **What they may do** stays what it was, one radio per level, narrowest
* preselected, each with the sentence that says what it actually does
* rather than the word for it.
* * **What they may open** is two radios, whole record preselected. Narrowing
* is the branch somebody chooses, and only then do the eight sections
* appear. A person who wants the default reads two lines and presses send;
* a person who wants to narrow sees at most eight named subjects with a
* sentence each, which is a thing a person can hold in their head. Nobody
* ever sees nineteen checkboxes.
*
* The two honest limits are stated inside the expanded state, where the
* decision is actually being made, rather than in help text somebody would
* have to go looking for: a section leaks through its own notes and names, and
* the cross-record surfaces (dashboard, score, digest) do not appear at all for
* a narrowed grant. Both are said before the checkboxes, not after them.
*
* Manage collapses the whole second question to one line, because management
* is whole-record by construction. That is a design decision made elsewhere
* (a manager who could edit part of a record could still write anything into
* that part, so a boundary drawn there would be a promise the product cannot
* keep) and this card states its consequence rather than re-deciding it.
*
* ## The Manage sentence
*
* It says the other person can add, change and remove entries, including ones
* the owner entered themselves. That clause is the whole of what separates
* this level from the one below it, and softening it — "manage the record",
* "help keep it up to date" — would be describing a delete button as
* housekeeping. What Manage does NOT reach is said in the same breath, because
* a fence nobody is told about is not reassurance, it is a surprise waiting.
*
* Errors are named, not generic. "No account with that name" and "that person
* already has access" are different situations with different next steps, and
@@ -47,22 +77,57 @@ export function GrantInviteCard() {
// READ preselected. The narrower level is the one somebody should have to
// choose their way out of, not into.
const [access, setAccess] = useState<GrantAccessLevel>("READ");
// The entire record preselected, for the same reason and with the opposite
// sign: narrowing is the deliberate act, so it is the one that costs a click.
const [narrowed, setNarrowed] = useState(false);
const [sections, setSections] = useState<ShareDomain[]>([]);
const [error, setError] = useState<string | null>(null);
const [invited, setInvited] = useState<string | null>(null);
// Cycle tracking is offered as a section only to somebody who tracks it.
// Listing it otherwise would put a subject on the consent screen that has no
// data behind it and, for many accounts, no relevance either.
const tracksCycle = useModuleEnabled("cycle");
const offered = SHARE_DOMAINS.filter(
(domain) => domain !== "cycle" || tracksCycle,
);
const choice = resolveInviteScope({ access, narrowed, sections });
const chooseAccess = (level: GrantAccessLevel) => {
setAccess(level);
setError(null);
};
const toggleSection = (domain: ShareDomain) => {
setSections((current) =>
current.includes(domain)
? current.filter((d) => d !== domain)
: [...current, domain],
);
setError(null);
};
const submit = (event: React.FormEvent) => {
event.preventDefault();
const value = identifier.trim();
if (value.length === 0) return;
if (value.length === 0 || choice.blocked) return;
setError(null);
setInvited(null);
invite.mutate(
{ identifier: value, access, expiresAt: endOfDayIso(expiresOn) },
{
identifier: value,
access,
scope: choice.scope,
expiresAt: endOfDayIso(expiresOn),
},
{
onSuccess: (grant) => {
setIdentifier("");
setExpiresOn("");
setAccess("READ");
setNarrowed(false);
setSections([]);
setInvited(grant.account.username);
},
onError: (err) => setError(t(inviteErrorKey(err))),
@@ -106,19 +171,120 @@ export function GrantInviteCard() {
<AccessOption
level="READ"
selected={access === "READ"}
onSelect={() => setAccess("READ")}
onSelect={() => chooseAccess("READ")}
label={t("recordSharing.invite.accessReadLabel")}
capability={t("recordSharing.invite.accessReadCapability")}
/>
<AccessOption
level="WRITE"
selected={access === "WRITE"}
onSelect={() => setAccess("WRITE")}
onSelect={() => chooseAccess("WRITE")}
label={t("recordSharing.invite.accessWriteLabel")}
capability={t("recordSharing.invite.accessWriteCapability")}
/>
<AccessOption
level="MANAGE"
selected={access === "MANAGE"}
onSelect={() => chooseAccess("MANAGE")}
label={t("recordSharing.invite.accessManageLabel")}
capability={t("recordSharing.invite.accessManageCapability")}
/>
</div>
{choice.mode === "manage" && (
<p
data-slot="grant-invite-manage-stepup"
className="text-muted-foreground pl-1 text-xs"
>
{t("recordSharing.invite.manageStepUpNote")}
</p>
)}
</fieldset>
{choice.mode === "manage" ? (
// Not a hidden section and not a disabled one: the question does not
// apply at this level, so what is rendered is the answer to it.
<p
data-slot="grant-invite-scope-manage"
className="text-muted-foreground pl-1 text-xs"
>
{t("recordSharing.invite.scopeManageNote")}
</p>
) : (
<fieldset className="space-y-1.5" data-slot="grant-invite-scope">
<legend className="pl-1 text-sm leading-none font-medium after:content-[':']">
{t("recordSharing.invite.scopeLegend")}
</legend>
<div className="space-y-2">
<ScopeOption
mode="all"
selected={!narrowed}
onSelect={() => {
setNarrowed(false);
setError(null);
}}
label={t("recordSharing.invite.scopeAllLabel")}
capability={t("recordSharing.invite.scopeAllCapability")}
/>
<ScopeOption
mode="some"
selected={narrowed}
onSelect={() => {
setNarrowed(true);
setError(null);
}}
label={t("recordSharing.invite.scopeSomeLabel")}
capability={t("recordSharing.invite.scopeSomeCapability")}
/>
</div>
{choice.mode === "pick" && (
<div
data-slot="grant-invite-sections"
className="border-border space-y-3 rounded-md border p-3"
>
{/* Both limits are stated ABOVE the checkboxes. A caveat under
a control somebody has already ticked is a disclaimer; the
same sentence above it is part of the decision. */}
<div className="text-muted-foreground space-y-1 text-xs">
<p data-slot="grant-invite-scope-leak">
{t("recordSharing.invite.scopeLeakNote")}
</p>
<p data-slot="grant-invite-scope-aggregates">
{t("recordSharing.invite.scopeAggregateNote")}
</p>
</div>
<div className="space-y-2">
{offered.map((domain) => (
<SectionOption
key={domain}
domain={domain}
checked={sections.includes(domain)}
onToggle={() => toggleSection(domain)}
label={t(`recordSharing.section.${domain}.label`)}
detail={t(`recordSharing.section.${domain}.detail`)}
// A document can hold a letter, a scan, a whole file
// somebody else wrote. It is the one section whose name
// does not bound what is inside it, so it says so.
warning={
domain === "documents"
? t("recordSharing.invite.scopeDocumentsNote")
: undefined
}
/>
))}
</div>
{choice.blocked && (
<p
data-slot="grant-invite-scope-empty"
className="text-muted-foreground text-xs"
>
{t("recordSharing.invite.scopeEmptyHint")}
</p>
)}
</div>
)}
</fieldset>
)}
<div className="space-y-1.5">
<Label htmlFor="grant-invite-expires">
{t("recordSharing.invite.expiresLabel")}
@@ -138,7 +304,13 @@ export function GrantInviteCard() {
<Button
type="submit"
data-slot="grant-invite-submit"
disabled={invite.isPending || identifier.trim().length === 0}
// A narrowed invitation with nothing ticked would be a grant that
// opens nothing, which the server refuses. The button is inert and
// the sentence above says why, rather than sending a request whose
// only purpose is to come back as an error.
disabled={
invite.isPending || identifier.trim().length === 0 || choice.blocked
}
>
{invite.isPending
? t("recordSharing.invite.submitting")
@@ -159,8 +331,63 @@ export function GrantInviteCard() {
);
}
/** The two levels an invitation can carry, as the client names them. */
export type GrantAccessLevel = "READ" | "WRITE";
/** The three levels an invitation can carry, as the client names them. */
export type GrantAccessLevel = "READ" | "WRITE" | "MANAGE";
/** What the form is asking, and what it would send if asked to send now. */
export interface InviteScopeChoice {
/**
* Which of the three shapes the second question takes: the collapsed line
* that answers it for Manage, the whole record, or the section list.
*/
mode: "manage" | "all" | "pick";
/** Exactly what goes on the wire as `scope`. */
scope: ShareDomain[] | null;
/** Is the form in a state that must not be sent. */
blocked: boolean;
}
/**
* The whole of what the form decides, as one pure function.
*
* Exported and pure for the same reason `endOfDayIso` and `revokeBody` are:
* the component's interactive states are not reachable from a static render,
* so a click-free suite can prove the copy exists and nothing about what the
* copy leads to. This is the part worth proving.
*
* Two properties it holds, and both are safety rather than tidiness:
*
* * **A Manage invitation never carries a scope, whatever is ticked.** The
* checkbox state deliberately survives a trip through the Manage option —
* somebody who picked three sections, read the Manage sentence and went
* back should find their three sections still ticked — so "the sections
* are not on screen" is not the same as "the sections are not in state".
* The level is what decides, and it decides here rather than at the call
* site. The server refuses the pair too; this is what stops the form ever
* asking.
* * **The sections travel in reading order.** The state is a click log, and
* two owners who ticked the same three sections in a different sequence
* would otherwise write rows that look different and mean the same.
*/
export function resolveInviteScope({
access,
narrowed,
sections,
}: {
access: GrantAccessLevel;
narrowed: boolean;
sections: ShareDomain[];
}): InviteScopeChoice {
if (access === "MANAGE") {
return { mode: "manage", scope: null, blocked: false };
}
if (!narrowed) return { mode: "all", scope: null, blocked: false };
const picked = SHARE_DOMAINS.filter((domain) => sections.includes(domain));
// A narrowed invitation with nothing ticked is not a narrow grant, it is a
// grant that opens nothing. The server refuses it; the form does not offer
// to send it, and says why instead.
return { mode: "pick", scope: picked, blocked: picked.length === 0 };
}
/**
* One level, with the sentence that says what it actually does.
@@ -189,19 +416,7 @@ function AccessOption({
data-slot="grant-invite-access-option"
data-access={level}
data-selected={selected ? "true" : "false"}
className={[
"flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors",
// The radio itself is `sr-only`, so the browser's own focus ring lands
// on a zero-size box: keyboard focus moved through this fieldset with
// nothing visible moving with it. WCAG 2.4.7, on the screen where
// somebody decides who may write into their health record. The label
// wears the ring instead, on the same `ring-ring/50 ring-2` token the
// capture picker's options use.
"has-[:focus-visible]:ring-ring/50 has-[:focus-visible]:ring-2",
selected
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ")}
className={optionClass(selected)}
>
<input
type="radio"
@@ -223,6 +438,133 @@ function AccessOption({
);
}
/**
* Whole record, or the sections you pick.
*
* Deliberately the same control as the level above it: the two questions are
* asked in the same voice, so nothing about the second one reads as advanced
* or as an exception. The radio is what makes narrowing a branch rather than a
* checkbox somebody might leave half-answered.
*/
function ScopeOption({
mode,
selected,
onSelect,
label,
capability,
}: {
mode: "all" | "some";
selected: boolean;
onSelect: () => void;
label: string;
capability: string;
}) {
return (
<label
data-slot="grant-invite-scope-option"
data-scope={mode}
data-selected={selected ? "true" : "false"}
className={optionClass(selected)}
>
<input
type="radio"
name="grant-invite-scope"
value={mode}
checked={selected}
onChange={onSelect}
className="sr-only"
/>
<span className="space-y-1">
<span className="text-foreground block text-sm font-medium">
{label}
</span>
<span className="text-muted-foreground block text-xs">
{capability}
</span>
</span>
</label>
);
}
/**
* One section of the record, with the sentence that says what is in it.
*
* A real checkbox rather than a chip or a toggle: eight independent yes/no
* answers are what a checkbox list is for, and the eight sentences are what
* make the names mean something to somebody who did not draw the boundaries.
*/
function SectionOption({
domain,
checked,
onToggle,
label,
detail,
warning,
}: {
domain: ShareDomain;
checked: boolean;
onToggle: () => void;
label: string;
detail: string;
warning?: string;
}) {
// Explicit association rather than a wrapping `<label>`: the primitive is a
// Radix checkbox, which renders a `<button>`. A button is labelable, so an
// implicit label would forward its own click to it and a tap on the box
// itself could toggle twice. `htmlFor` gives the sentence a hit target
// without that.
const id = `grant-invite-section-${domain}`;
return (
<div
data-slot="grant-invite-section"
data-section={domain}
data-checked={checked ? "true" : "false"}
className="hover:bg-muted/40 has-[:focus-visible]:ring-ring/50 flex min-h-11 items-start gap-3 rounded-md p-1.5 transition-colors has-[:focus-visible]:ring-2"
>
<Checkbox
id={id}
checked={checked}
onCheckedChange={onToggle}
className="mt-0.5"
/>
<label htmlFor={id} className="cursor-pointer space-y-0.5">
<span className="text-foreground block text-sm font-medium">
{label}
</span>
<span className="text-muted-foreground block text-xs">{detail}</span>
{warning && (
<span
data-slot="grant-invite-section-warning"
className="text-muted-foreground block text-xs"
>
{warning}
</span>
)}
</label>
</div>
);
}
/**
* The shared look of a choosable option row.
*
* The radio itself is `sr-only`, so the browser's own focus ring lands on a
* zero-size box: keyboard focus moved through the fieldset with nothing
* visible moving with it. WCAG 2.4.7, on the screen where somebody decides who
* may write into their health record. The label wears the ring instead, on the
* same `ring-ring/50 ring-2` token the capture picker's options use — and both
* fieldsets share this function so the second one cannot lose it.
*/
function optionClass(selected: boolean): string {
return [
"flex min-h-11 cursor-pointer items-start gap-3 rounded-md border p-3 transition-colors",
"has-[:focus-visible]:ring-ring/50 has-[:focus-visible]:ring-2",
selected
? "border-primary bg-primary/5"
: "border-border hover:bg-muted/40",
].join(" ");
}
/**
* The chosen day, as the instant access stops.
*
@@ -254,7 +596,7 @@ function tomorrowIso(): string {
* themselves, and telling those two apart is the difference between "fix the
* field" and "you already have your own record".
*/
function inviteErrorKey(err: unknown): string {
export function inviteErrorKey(err: unknown): string {
const code =
err instanceof ApiError && typeof err.meta?.errorCode === "string"
? err.meta.errorCode
@@ -266,6 +608,15 @@ function inviteErrorKey(err: unknown): string {
return "recordSharing.invite.errorDuplicate";
case "sharing.invite.invalid":
return "recordSharing.invite.errorInvalid";
case "sharing.invite.invalid_scope":
return "recordSharing.invite.errorScope";
// The decided consequence of gating Manage on a fresh second factor: the
// step-up is cookie-only, so this reaches a native caller and nobody else.
// Named rather than folded into the generic failure, because "use a
// browser for this one" is a next step and "the invitation could not be
// sent" is not.
case "sharing.invite.manage_browser_only":
return "recordSharing.invite.errorManageBrowserOnly";
default:
break;
}
@@ -275,5 +626,12 @@ function inviteErrorKey(err: unknown): string {
if (err instanceof ApiError && err.status === 429) {
return "recordSharing.invite.errorRateLimited";
}
// A 401 on a request that was authenticated enough to reach validation is
// the step-up gate, not a lapsed session: the owner has a second factor and
// has not proved it recently enough to hand somebody management of their
// record. Said as the thing to do next.
if (err instanceof ApiError && err.status === 401) {
return "recordSharing.invite.errorStepUp";
}
return "recordSharing.invite.errorFailed";
}
+53 -3
View File
@@ -74,6 +74,11 @@ export function GrantRowItem({
// invitation form is the level the server stored, and asserting on
// viewport text would pin the copy instead of the value.
data-grant-access={grant.access}
// Whole record or a named set, as an attribute for the same reason: the
// journey has to be able to prove the sections somebody ticked are the
// sections the row carries, and asserting on a comma-joined sentence
// would pin the copy instead of the value.
data-grant-scope={grant.scope === null ? "record" : grant.scope.join(",")}
className="flex flex-wrap items-start justify-between gap-3 py-3"
>
<div className="min-w-0 flex-1 space-y-1">
@@ -86,9 +91,17 @@ export function GrantRowItem({
</Badge>
</div>
<p className="text-muted-foreground text-xs">
{grant.access === "WRITE"
? t("recordSharing.row.canWrite")
: t("recordSharing.row.readOnly")}
{t(LEVEL_KEY[grant.access])}
</p>
{/* What the level reaches, said beside what the level is. A row that
named only the level would answer half of "what can this person
see" for every narrowed grant, and the half it left out is the one
the owner narrowed on purpose. */}
<p
data-slot="grant-row-scope"
className="text-muted-foreground text-xs"
>
{scopeLine(grant.scope, t)}
</p>
<dl className="text-muted-foreground space-y-0.5 text-xs">
<div className="flex flex-wrap gap-x-1">
@@ -137,6 +150,43 @@ function stateKey(state: GrantState): string {
return state.toLowerCase();
}
/**
* What the level lets somebody do, in the row's own voice.
*
* A total map rather than a ternary, and the reason is the bug the ternary
* had: `access === "WRITE" ? canWrite : readOnly` called anything it did not
* recognise read-only, so the release that added a third level would have
* described a grant that can delete entries as one that can only look at them.
* An unlisted level does not typecheck here.
*/
const LEVEL_KEY: Record<GrantRow["access"], string> = {
READ: "recordSharing.row.readOnly",
WRITE: "recordSharing.row.canWrite",
MANAGE: "recordSharing.row.canManage",
};
/**
* What the grant reaches: the whole record, a named set, or nothing.
*
* The third case is real and is not a display bug. A stored scope this build
* cannot read resolves fail-closed to the empty set, and the server will
* refuse every section for that grant on the next request — so the row says
* so, rather than rendering an empty list that looks like a rendering fault
* and reads like "everything".
*/
function scopeLine(
scope: GrantRow["scope"],
t: (key: string, params?: Record<string, string | number>) => string,
): string {
if (scope === null) return t("recordSharing.row.entireRecord");
if (scope.length === 0) return t("recordSharing.row.noSections");
return t("recordSharing.row.sections", {
sections: scope
.map((domain) => t(`recordSharing.section.${domain}.label`))
.join(", "),
});
}
/**
* "You withdrew it" versus "she handed it back" — the same column, read from
* two sides. `revokedBy` is what makes the distinction possible; conflating
@@ -128,10 +128,13 @@ export function GrantsGivenCard() {
* 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
* Two extra sentences for a grant that could put something in the record, and
* only for one: a read grant never did, so telling its owner that "anything
* they entered stays" would describe something that cannot have happened. The
* condition is "not READ" rather than "is WRITE", so the level that can also
* change and remove entries carries both sentences too — for a manage grant
* the question "what happens to what they did" is the sharper one, not the
* softer. 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.
@@ -149,7 +152,7 @@ export function revokeBody({
retentionDays?: number;
}): string {
const lines = [t("recordSharing.given.revokeBody", { name })];
if (access === "WRITE") {
if (access !== "READ") {
lines.push(t("recordSharing.given.revokeEntriesStay"));
if (retentionDays !== undefined) {
lines.push(
@@ -93,13 +93,28 @@ export function GrantsReceivedCard() {
*
* 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.
* name, permanently, with no way to take one back. A manage invitation asks
* for more again — that they change and remove what the owner wrote — and it
* gets its own sentence rather than a stronger adjective on the write one,
* because the two are different responsibilities and the difference is the
* whole reason the third level exists. Both are 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.
* and the sections are the row's own lines and this would be nagging.
*/
function renderConsent(grant: GrantRow) {
if (grant.state !== "PENDING" || grant.access !== "WRITE") return null;
if (grant.state !== "PENDING") return null;
if (grant.access === "MANAGE") {
return (
<p data-slot="grant-manage-consent" className="text-foreground text-sm">
{t("recordSharing.received.manageConsent", {
name: accountLabel(grant.account),
})}
</p>
);
}
if (grant.access !== "WRITE") return null;
return (
<p data-slot="grant-write-consent" className="text-foreground text-sm">
{t("recordSharing.received.writeConsent", {
@@ -25,15 +25,30 @@ const READ_ONLY: AccountAccessEntry = {
username: "owner",
displayName: "Margarethe",
access: "read",
level: "read",
sections: null,
canWrite: false,
};
const WRITABLE: AccountAccessEntry = {
...READ_ONLY,
access: "write",
level: "write",
canWrite: true,
};
const MANAGING: AccountAccessEntry = {
...READ_ONLY,
access: "manage",
level: "manage",
canWrite: true,
};
const SCOPED: AccountAccessEntry = {
...READ_ONLY,
sections: ["medications", "labs"],
};
describe("resolveRecordCapabilities", () => {
it("the caller's own record keeps everything", () => {
// `null` is not "no access", it is "this is mine". Both an unswitched
@@ -44,6 +59,11 @@ describe("resolveRecordCapabilities", () => {
canWrite: false,
canAdd: true,
canManage: true,
// No grant, so no level and no narrowing — not a fabricated "manage"
// over all eight sections, which a consumer would then have to tell
// apart from a real one.
level: null,
sections: null,
});
expect(resolveRecordCapabilities(undefined)).toEqual(
resolveRecordCapabilities(null),
@@ -56,6 +76,8 @@ describe("resolveRecordCapabilities", () => {
canWrite: false,
canAdd: false,
canManage: false,
level: "read",
sections: null,
});
});
@@ -67,6 +89,38 @@ describe("resolveRecordCapabilities", () => {
canWrite: true,
canAdd: true,
canManage: false,
level: "write",
sections: null,
});
});
it("carries the level and the sections through untouched", () => {
// The two facts v1.37.0 adds are bound, not interpreted. `sections` is
// the server's list in the server's order; `level` is the server's word.
// Nothing here filters, sorts or re-spells either, because the moment
// this file starts deriving from them it becomes the second program
// deciding what a delegation covers.
expect(resolveRecordCapabilities(SCOPED).sections).toEqual([
"medications",
"labs",
]);
expect(resolveRecordCapabilities(MANAGING).level).toBe("manage");
});
it("does not hand a manage grant the affordances its routes cannot answer yet", () => {
// The deliberate gap, pinned so it cannot close by accident. MANAGE will
// bring the edit and delete controls back, and it does so in the release
// where the routes behind them answer — not in the one that merely
// publishes the level. A `canManage: true` here today would paint
// controls that 403, which is the exact failure these booleans exist to
// prevent.
expect(resolveRecordCapabilities(MANAGING)).toEqual({
inSharedRecord: true,
canWrite: true,
canAdd: true,
canManage: false,
level: "manage",
sections: null,
});
});
+40
View File
@@ -47,6 +47,19 @@ import {
* only which class of affordance a resolved level covers, and that mapping is
* a property of the release, not of the caller.
*
* ## The two facts this hook carries but does not yet act on
*
* v1.37.0 gives a grant a section set and a third level, and both arrive here
* (`sections`, `level`) as resolved values beside the booleans. Nothing in
* this file turns either into a capability, and that is the state of the
* release rather than an omission: the nav narrowing that reads `sections`
* and the return of the edit and delete affordances that reads `level` are
* the shared-session UX work, which lands after the routes that answer for a
* MANAGE grant exist. Publishing the facts here first is what lets those be
* a rendering change rather than a second access decision — and a control
* that appeared before its route answered would be the failure the two
* booleans above were written to end.
*
* ## Absent, not disabled
*
* The consuming rule, stated once here because it belongs to the whole sweep:
@@ -66,6 +79,25 @@ export interface RecordCapabilities {
canAdd: boolean;
/** May the caller change what exists, or add what the delegation excludes. */
canManage: boolean;
/**
* v1.37.0 — the resolved level of the grant on screen, or `null` in one's
* own record (which is not a grant and has no level).
*
* Bound from `accountAccess.active.level`, never derived. `canManage` is
* deliberately NOT computed from it in this release: the edit and delete
* affordances come back for `"manage"` when the routes that answer them
* land, and a control that appears before its route answers is the exact
* failure the two booleans above exist to prevent.
*/
level: AccountAccessEntry["level"] | null;
/**
* v1.37.0 — the sections the grant opens, or `null` for the entire record
* (which is also what one's own record answers).
*
* Bound from `accountAccess.sections`. An empty array means the grant opens
* nothing; it is a real answer and reads as one.
*/
sections: AccountAccessEntry["sections"];
}
/**
@@ -84,6 +116,12 @@ export function resolveRecordCapabilities(
canWrite: false,
canAdd: true,
canManage: true,
// One's own record has no grant, so it has no level, and it is open in
// full. Null on both counts rather than a fabricated "manage" over all
// eight sections: the caller is not a delegate, and a consumer that had
// to tell those apart would have to guess.
level: null,
sections: null,
};
}
return {
@@ -91,6 +129,8 @@ export function resolveRecordCapabilities(
canWrite: active.canWrite,
canAdd: active.canWrite,
canManage: false,
level: active.level,
sections: active.sections,
};
}
@@ -30,6 +30,8 @@ const OWNER = {
username: "owner",
displayName: "Margarethe",
access: "write" as const,
level: "write" as const,
sections: null,
canWrite: true,
};
@@ -113,7 +115,12 @@ describe("CoachLaunchProvider", () => {
for (const access of ["read", "write"] as const) {
mockAccessRef.value = {
accounts: [OWNER],
active: { ...OWNER, access, canWrite: access === "write" },
active: {
...OWNER,
access,
level: access,
canWrite: access === "write",
},
canSwitch: true,
};
const output: string[] = [];
+50 -8
View File
@@ -23,6 +23,7 @@
import type { ZodOpenApiObject } from "zod-openapi";
import { z } from "zod/v4";
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
import {
inviteGrantSchema,
switchAccountSchema,
@@ -32,7 +33,7 @@ import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
inviteGrantSchema.meta({
id: "AccountGrantInvite",
description:
"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.",
"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, and MANAGE can additionally change and remove entries, including ones the owner wrote, and read the generated insights — never the identity surfaces (login, second factor, connections, tokens, settings, or who else has access). `scope` narrows a READ or WRITE grant to named sections; omitted or null is the entire record, which is what every grant written before v1.37.0 means. An empty array, an unknown key, and any `scope` at all beside `access: MANAGE` are each 422 — management is whole-record by construction. Offering MANAGE additionally requires a fresh second factor when the account has one enrolled, which makes it cookie-only: a Bearer caller is refused with 403 `sharing.invite.manage_browser_only` and should send the person to a browser. The level and the scope are 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({
@@ -59,11 +60,23 @@ const grantState = z.enum(["PENDING", "ACTIVE", "EXPIRED", "REVOKED"]).meta({
"What the grant is right now, resolved server-side against the request clock. Only ACTIVE confers anything. Ordering is stated rather than implied: a revoked grant reads REVOKED even if it also sat past its expiry, and an unaccepted invitation past its expiry reads EXPIRED rather than PENDING because it can no longer be accepted. Clients render this value and never re-derive it from the timestamps.",
});
const shareSection = z.enum(SHARE_DOMAINS).meta({
id: "ShareSection",
description:
"One section of a health record, as a grant may name it. A closed vocabulary of eight, derived by clustering the delegable read surfaces rather than from the module list — the two answer different questions and never share a spelling. The whole-record case is `null` on the field that carries these, never a member of this enum: a scope that named the whole record would be a narrowing that means its own absence.",
});
const grantView = z
.object({
id: z.string(),
account: grantParty,
access: z.enum(["READ", "WRITE"]),
access: z.enum(["READ", "WRITE", "MANAGE"]),
scope: z
.array(shareSection)
.nullable()
.describe(
"The sections this grant opens, or null for the entire record. Null is a first-class value and the one every grant written before v1.37.0 carries; render it as the whole record, never as a legacy or unknown state. A MANAGE grant always carries null. An EMPTY array means the grant opens nothing — the fail-closed reading of a stored scope the server cannot parse — and the server will refuse every section for it, so render it as nothing rather than as everything. Ordered by the consent screen's own reading order.",
),
state: grantState,
invitedAt: z.string(),
acceptedAt: z.string().nullable(),
@@ -111,7 +124,12 @@ const switchResponse = z
accountId: z.string(),
username: z.string(),
displayName: z.string().nullable(),
access: z.enum(["READ", "WRITE"]),
// The stored spelling, straight off the grant row the switch resolved
// — not the lower-case resolved level the account payload publishes.
// Two spellings of one fact is a wart this endpoint has carried since
// v1.36.0; it is documented rather than papered over, because a client
// reading either has to know which one it is holding.
access: z.enum(["READ", "WRITE", "MANAGE"]),
})
.nullable(),
})
@@ -201,11 +219,26 @@ const accountAccessEntry = z
),
username: z.string(),
displayName: z.string().nullable(),
access: z.enum(["read", "write"]),
access: z
.enum(["read", "write", "manage"])
.describe(
"The grant's resolved level, under the name v1.36.0 published. Identical to `level` — both come from one server-side expression — and kept because shipped clients read it. New code reads `level`.",
),
level: z
.enum(["read", "write", "manage"])
.describe(
"The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management), which stay refused on an invited record at every level. Render it; never derive it from `canWrite`.",
),
sections: z
.array(shareSection)
.nullable()
.describe(
"The sections of that record this caller may open, or null for the entire record. Null is first-class and is what every pre-v1.37.0 grant carries; a MANAGE grant always carries it too. A non-null array names the open sections and NOTHING else answers — a client that switches into a scoped record without honouring this gets the standard 403 on every out-of-scope route, fail-closed and byte-identical to every other sharing refusal, which is safe but reads as a wall of errors. An empty array means the grant opens nothing. Ordered by the consent screen's reading order.",
),
canWrite: z
.boolean()
.describe(
"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.",
"Whether this caller may ADD to that record: true for an accepted WRITE or MANAGE grant, false for a READ one. On its own it never means edit or delete — a WRITE grant adds and does nothing more. What MANAGE additionally admits is said by `level`. Resolved server-side. Render it; never derive it.",
),
})
.meta({
@@ -329,7 +362,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
tags: ["Account sharing"],
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.",
"Offers access to somebody already registered on this instance, at READ, WRITE or MANAGE and over the whole record or named sections; 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. Offering MANAGE is step-up gated (`requireFreshMfaIfEnrolled`, cookie-only): an enrolled account without a fresh factor gets 401 `auth.stepup.required`, and a Bearer caller gets 403 `sharing.invite.manage_browser_only` before anything else happens, because the gate it would hit resolves through the session cookie and would otherwise answer 'not authenticated' to a caller it had just authenticated.",
requestBody: {
required: true,
content: { "application/json": { schema: inviteGrantSchema } },
@@ -344,7 +377,16 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"403": sharingRefusal,
"401": {
description:
"Offering MANAGE from an MFA-enrolled account without a recent second-factor proof (`meta.errorCode: auth.stepup.required`). Re-prove the factor and retry; READ and WRITE invitations are never gated.",
content: { "application/json": { schema: errorEnvelope } },
},
"403": {
description:
"Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.invite.manage_browser_only` when a Bearer caller tried to offer MANAGE — the step-up that gates it is cookie-only by construction, so the native client cannot mint it and should route the person to a browser rather than surface an error.",
content: { "application/json": { schema: errorEnvelope } },
},
"404": {
description: "No account on this instance carries that identifier.",
content: { "application/json": { schema: errorEnvelope } },
@@ -356,7 +398,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"422": {
description:
"Validation failed, or the invitation named the caller's own account (`meta.errorCode: sharing.invite.self`).",
"Validation failed, the invitation named the caller's own account (`meta.errorCode: sharing.invite.self`), or the scope cannot mean anything (`meta.errorCode: sharing.invite.invalid_scope` from the domain module; the request-shape refusal for an empty array, an unknown section or a scope beside MANAGE arrives as the ordinary multi-issue envelope naming the `scope` field).",
content: { "application/json": { schema: errorEnvelope } },
},
"429": {
+22 -2
View File
@@ -26,6 +26,7 @@ import {
import { apiDelete, apiGet, apiPost } from "@/lib/api/api-fetch";
import { grantDependentKeys, queryKeys } from "@/lib/query-keys";
import type { ShareDomain } from "@/lib/sharing/scope";
/** The other party to a grant, as far as anyone is told about them. */
export interface GrantParty {
@@ -36,10 +37,20 @@ export interface GrantParty {
export type GrantState = "PENDING" | "ACTIVE" | "EXPIRED" | "REVOKED";
/** The three levels an invitation can carry, spelled as the wire spells them. */
export type GrantAccess = "READ" | "WRITE" | "MANAGE";
export interface GrantRow {
id: string;
account: GrantParty;
access: "READ" | "WRITE";
access: GrantAccess;
/**
* The sections the grant opens, or null for the entire record. Resolved by
* the server in the consent screen's reading order; an empty array means the
* grant opens nothing, which is the fail-closed reading of a stored scope
* the server could not parse.
*/
scope: ShareDomain[] | null;
state: GrantState;
invitedAt: string;
acceptedAt: string | null;
@@ -126,7 +137,16 @@ export interface InviteInput {
* level has not decided it, and this is the one moment the level can be
* decided at all.
*/
access: "READ" | "WRITE";
access: GrantAccess;
/**
* Which sections it opens, or null for the entire record.
*
* Required for the same reason as `access` and stated as `null` rather than
* omitted, so that "the whole record" is something a caller says out loud.
* The server refuses an empty array, an unknown key, and any scope at all
* beside MANAGE.
*/
scope: ShareDomain[] | null;
}
export function useInviteGrant() {
+47 -6
View File
@@ -1,18 +1,27 @@
/**
* v1.36.0 — the shape of the sharing block, and nothing else.
*
* Types only, with no imports at all, because both ends read them: the route
* Types only, with no VALUE imports, because both ends read them: the route
* that builds the block (`account-access.ts`, which talks to Postgres) and the
* client that renders it. Declaring them beside the server code would put a
* module that imports Prisma on the client's import graph — erased at compile
* today, one careless value export away from being bundled tomorrow. The
* boundary is cheaper to keep than to rediscover.
*
* The one import below is `import type` from `sharing/scope.ts`, which is a
* closed list of string literals and imports nothing itself. It is erased
* whole, so the property above still holds: nothing this file names can pull
* a server module into a bundle. A value import from anywhere would break it.
*
* What these types are FOR is stated once here and holds everywhere they are
* used: they carry resolved answers. `canWrite` and `canSwitch` are decisions
* the server has already made; a client that recomputed either from the rest
* of the payload would be the second program deciding one person's access.
*/
import type { ShareDomain } from "@/lib/sharing/scope";
/** What a grant lets its holder do, resolved. */
export type AccountAccessLevel = "read" | "write" | "manage";
/** One account this caller may act on. */
export interface AccountAccessEntry {
@@ -20,12 +29,44 @@ export interface AccountAccessEntry {
accountId: string;
username: string;
displayName: string | null;
/** The grant's level, as the owner offered it and the caller accepted it. */
access: "read" | "write";
/**
* 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.
* The grant's level, as the owner offered it and the caller accepted it.
*
* The same value as {@link AccountAccessEntry.level}, under the name the
* v1.36.0 contract published and shipped clients already read. Both are
* assigned from one expression in `account-access.ts`, so they cannot come
* apart; `level` is the name to write new code against.
*/
access: AccountAccessLevel;
/**
* v1.37.0 — the same resolved level, under the name the three-level
* contract speaks. Read it; never derive it from `canWrite`, and never
* derive `canWrite` from it.
*/
level: AccountAccessLevel;
/**
* v1.37.0 — which sections of the record this grant opens, or `null` for
* the entire record.
*
* `null` is a first-class answer rather than a missing one: it is what every
* grant written before this release means, what an owner who does not narrow
* still gets, and what a MANAGE grant always carries. Render it as "the
* entire record", never as a legacy state.
*
* An EMPTY array is a real answer too, and it means the grant opens nothing:
* the fail-closed reading of a stored scope this build cannot parse. Render
* that as nothing. The server will refuse every section for that grant on
* the next request, and agreeing with it is the only honest option.
*
* Resolved server-side, in the consent screen's own reading order.
*/
sections: ShareDomain[] | null;
/**
* May this caller ADD to that record. Resolved server-side. It does not by
* itself mean edit or delete — a WRITE grant adds and does nothing more, and
* that stayed true when a third level arrived. A MANAGE grant answers true
* here as well, and what it additionally admits is said by `level` rather
* than smuggled into this boolean.
*/
canWrite: boolean;
}
+48 -10
View File
@@ -17,9 +17,17 @@
* `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.
* started answering true the moment one could exist, and an accepted MANAGE
* grant did the same one release later. It answers "may add to this record"
* and nothing beyond that; what a MANAGE grant additionally admits is carried
* by `level`, which is a value the client renders and never a flag it reasons
* from.
*
* v1.37.0 adds `sections` on the same terms. It is the stored scope run
* through the one resolver (`resolveGrantSections`), not the column: a client
* never sees a blob it would have to interpret, and the fail-closed reading of
* an unparseable one reaches the payload as the empty list rather than as
* silence.
*
* 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
@@ -32,8 +40,29 @@ import { prisma } from "@/lib/db";
import type {
AccountAccess,
AccountAccessEntry,
AccountAccessLevel,
} from "@/lib/sharing/account-access-view";
import { resolveGrantSections } from "@/lib/sharing/grant-view";
import { grantAllows, isGrantActive } from "@/lib/sharing/grants";
import type { AccountGrantAccess } from "@/generated/prisma/client";
/**
* The stored level, as the contract names it.
*
* A total map rather than a chain of ternaries, and the difference is the
* failure it removes: the shipped expression was
* `access === "WRITE" ? "write" : "read"`, which answered "read" for anything
* it did not recognise. When a third level arrived that would have published
* a MANAGE grant as read-only — the payload understating access is the
* direction that reads as safe and is not, because the client renders what it
* is told and the server would have gone on admitting the writes. An unlisted
* enum member does not typecheck here, so the next level cannot ship silent.
*/
const LEVEL: Record<AccountGrantAccess, AccountAccessLevel> = {
READ: "read",
WRITE: "write",
MANAGE: "manage",
};
/**
* Resolve the sharing block for one caller.
@@ -81,13 +110,22 @@ export async function resolveAccountAccess(auth: {
const accounts: AccountAccessEntry[] = rows
.filter((grant) => isGrantActive(grant, now))
.map((grant) => ({
accountId: grant.grantor.id,
username: grant.grantor.username,
displayName: grant.grantor.displayName,
access: grant.access === "WRITE" ? "write" : "read",
canWrite: grantAllows(grant, "write", now),
}));
.map((grant) => {
// One expression, two names. `access` is what v1.36.0 published and
// what shipped clients read; `level` is what the three-level contract
// calls it. Deriving both from one lookup is what stops them saying
// different things about the same row.
const level = LEVEL[grant.access];
return {
accountId: grant.grantor.id,
username: grant.grantor.username,
displayName: grant.grantor.displayName,
access: level,
level,
sections: resolveGrantSections(grant.scopeJson),
canWrite: grantAllows(grant, "write", now),
};
});
const active =
stamped === null
+47 -1
View File
@@ -17,8 +17,14 @@
* never asked for the owner's. Handing every party to a grant a durable
* address for every other party is reach the grant was never meant to confer.
*/
import { grantState, type GrantState } from "@/lib/sharing/grants";
import {
grantState,
normaliseScope,
type GrantState,
} from "@/lib/sharing/grants";
import { SHARE_DOMAINS, type ShareDomain } from "@/lib/sharing/scope";
import type { AccountGrant } from "@/generated/prisma/client";
import type { Prisma } from "@/generated/prisma/client";
/** The other party to a grant, as far as anyone is told about them. */
export interface GrantParty {
@@ -27,11 +33,50 @@ export interface GrantParty {
displayName: string | null;
}
/**
* v1.37.0 — the stored scope as a client can read it.
*
* `null` is the entire record and stays null all the way to the screen: it is
* the answer every grant carries unless somebody narrowed one, and flattening
* it into "all eight sections" would turn a first-class value into a list that
* looks like a choice nobody made.
*
* The order is {@link SHARE_DOMAINS}' own, which is the consent screen's
* reading order — not the order the keys happened to be stored in. Two grants
* over the same sections therefore serialise identically, so a diff of two
* payloads means what it looks like.
*
* Everything else about the value is decided by `normaliseScope`, including
* the fail-closed reading of a blob this build cannot parse: that resolves to
* the empty set, and an empty array is published rather than papered over.
* `[]` on the wire says "this grant opens nothing", which is exactly what the
* resolver will tell the delegate on their next request, and a client that
* rendered it as "everything" would be the second program deciding somebody's
* access.
*
* The one place a stored scope becomes a published list, so the grant panel
* and the account payload cannot disagree about what a grant opens.
*/
export function resolveGrantSections(
scopeJson: Prisma.JsonValue | null,
): ShareDomain[] | null {
const scope = normaliseScope(scopeJson);
if (scope === null) return null;
return SHARE_DOMAINS.filter((domain) => scope.has(domain));
}
export interface GrantView {
id: string;
/** The account on the other end: the delegate on a given grant, the owner on a received one. */
account: GrantParty;
access: AccountGrant["access"];
/**
* Which sections of the record the grant opens, or null for the whole of
* it. Published so the delegate can read what they are accepting BEFORE the
* accept button, and so the owner's panel can say what each live grant
* reaches without opening it.
*/
scope: ShareDomain[] | null;
state: GrantState;
invitedAt: string;
acceptedAt: string | null;
@@ -57,6 +102,7 @@ export function toGrantView(
id: grant.id,
account,
access: grant.access,
scope: resolveGrantSections(grant.scopeJson),
state: grantState(grant, now),
invitedAt: grant.invitedAt.toISOString(),
acceptedAt: grant.acceptedAt?.toISOString() ?? null,
+63 -26
View File
@@ -12,6 +12,8 @@
*/
import { z } from "zod/v4";
import { SHARE_DOMAINS } from "@/lib/sharing/scope";
/**
* How long a value naming an account may be.
*
@@ -31,32 +33,67 @@ const MAX_ACCOUNT_ID_LENGTH = 64;
* The 255 bound is the e-mail column's practical ceiling; anything past it is
* refused before it reaches a query.
*/
export const inviteGrantSchema = z.object({
identifier: z.string().trim().min(1).max(255),
/**
* Optional lapse date. Absent or null means the grant runs until somebody
* ends it — the common household case, where an expiry the owner has to
* 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 const inviteGrantSchema = z
.object({
identifier: z.string().trim().min(1).max(255),
/**
* Optional lapse date. Absent or null means the grant runs until somebody
* ends it — the common household case, where an expiry the owner has to
* 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 carries one of the three
* levels 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", "MANAGE"])
.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. MANAGE can additionally change and remove entries, including ones the owner wrote, and read the generated insights; it never reaches the identity surfaces (login, second factor, connections, tokens, settings, or who else has access). 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.",
),
/**
* v1.37.0 — which sections of the record the invitation opens.
*
* Absent or null is the entire record: the shape every invitation had
* before this field existed, and the one an owner who does not narrow keeps
* getting. A client that predates the field therefore mints exactly what it
* used to mint, which is why this is optional where `access` is defaulted —
* there is no third state to default to.
*
* `.min(1)` rather than a bare array, because an empty selection is not a
* narrow grant, it is a grant that opens nothing: a row nobody could use,
* written by somebody who thought they were sharing something. The enum is
* the closed vocabulary itself, so an unknown key and the distinguished
* `record` value (which is deliberately not a member) are both refused here
* as a 422 naming the field, rather than reaching the domain module as an
* opaque refusal.
*/
scope: z
.array(z.enum(SHARE_DOMAINS))
.min(1)
.nullable()
.optional()
.describe(
"The sections of the record this invitation opens. Omitted or null means the entire record, which is what every grant written before v1.37.0 means and what an owner who does not narrow still gets. A non-null value is a non-empty array of section keys from the closed vocabulary; an empty array, an unknown key and the whole-record sentinel `record` are each refused. A stored scope never grows: a section added by a later release is not in a set an owner ticked before it existed.",
),
})
.refine((body) => !(body.access === "MANAGE" && body.scope != null), {
// Whole-record by construction, and refused at the request shape as well as
// in the domain module. "They can do anything, but only to part of you"
// promises a boundary an edit cannot keep — a note in one section can concern
// any other — so the product does not offer it and neither end accepts it.
path: ["scope"],
message: "Manage access always covers the entire record",
});
export type InviteGrantBody = z.infer<typeof inviteGrantSchema>;
@@ -70,7 +70,9 @@ interface AccountAccessEntry {
accountId: string;
username: string;
displayName: string | null;
access: "read" | "write";
access: "read" | "write" | "manage";
level: "read" | "write" | "manage";
sections: string[] | null;
canWrite: boolean;
}
@@ -101,17 +103,25 @@ async function readMe(): Promise<{
async function grantAccess(
grantorId: string,
granteeId: string,
extra: { expiresAt?: Date | null; acceptedAt?: Date | null } = {},
extra: {
expiresAt?: Date | null;
acceptedAt?: Date | null;
access?: "READ" | "WRITE" | "MANAGE";
scopeJson?: unknown;
} = {},
) {
return getPrismaClient().accountGrant.create({
data: {
grantorId,
granteeId,
access: "READ",
access: extra.access ?? "READ",
invitedAt: new Date(),
acceptedAt:
extra.acceptedAt === undefined ? new Date() : extra.acceptedAt,
expiresAt: extra.expiresAt ?? null,
...(extra.scopeJson === undefined
? {}
: { scopeJson: extra.scopeJson as never }),
},
});
}
@@ -154,6 +164,8 @@ describe("accountAccess — what the payload publishes", () => {
username: owner.username,
displayName: owner.displayName,
access: "read",
level: "read",
sections: null,
canWrite: false,
},
]);
@@ -201,6 +213,71 @@ describe("accountAccess — what the payload publishes", () => {
expect(accountAccess.accounts).toEqual([]);
});
it("publishes the sections a scoped grant opens, exactly as stored", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await grantAccess(owner.id, delegate.id, {
// Stored out of the consent screen's order on purpose: what the payload
// publishes is a resolved value in the reading order, not the column.
scopeJson: ["labs", "medications"],
});
await signIn(delegate.id);
const { accountAccess } = await readMe();
expect(accountAccess.accounts[0].sections).toEqual(["medications", "labs"]);
expect(accountAccess.accounts[0].level).toBe("read");
});
it("publishes the whole record as null, never as a list of eight", async () => {
// Null is the answer every pre-v1.37.0 grant carries and the one an owner
// who does not narrow still gives. Expanding it into all eight sections
// would turn a first-class value into a choice nobody made, and would
// start growing by release — a grant would quietly gain a section the
// owner never agreed to.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await grantAccess(owner.id, delegate.id);
await signIn(delegate.id);
const { accountAccess } = await readMe();
expect(accountAccess.accounts[0].sections).toBeNull();
});
it("publishes nothing at all for a scope it cannot read", async () => {
// Fail-closed, end to end. A blob this build cannot parse resolves to the
// empty set in the resolver, so the payload says the same: the grant
// opens nothing. A payload that reported null here would paint a full
// switcher entry for a grant the server refuses on every request.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await grantAccess(owner.id, delegate.id, { scopeJson: "garbage" });
await signIn(delegate.id);
const { accountAccess } = await readMe();
expect(accountAccess.accounts[0].sections).toEqual([]);
});
it("publishes a manage grant as manage, and as writable", async () => {
// The shipped expression was `access === "WRITE" ? "write" : "read"`,
// which would have published a grant that can delete entries as read-only
// — the payload understating access is the direction that reads as safe
// and is not, because the server would have gone on admitting the writes.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await grantAccess(owner.id, delegate.id, { access: "MANAGE" });
await signIn(delegate.id);
const { accountAccess } = await readMe();
expect(accountAccess.accounts[0].level).toBe("manage");
expect(accountAccess.accounts[0].access).toBe("manage");
expect(accountAccess.accounts[0].canWrite).toBe(true);
expect(accountAccess.accounts[0].sections).toBeNull();
});
it("never lists a grant this account GAVE — the block is about what it may open", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
@@ -236,6 +313,8 @@ describe("accountAccess — the active record", () => {
username: owner.username,
displayName: owner.displayName,
access: "read",
level: "read",
sections: null,
canWrite: false,
});
// The active entry is one of the listed ones, by construction. A banner
+193
View File
@@ -105,12 +105,14 @@ async function listGrants(): Promise<Response> {
async function invite(
identifier: string,
expiresAt?: string | null,
extra: Record<string, unknown> = {},
): Promise<Response> {
const { POST } = await import("@/app/api/account/grants/route");
return POST(
jsonRequest("http://localhost/api/account/grants", "POST", {
identifier,
...(expiresAt === undefined ? {} : { expiresAt }),
...extra,
}),
);
}
@@ -462,6 +464,197 @@ describe("who may do what to a grant", () => {
});
});
describe("what an invitation opens, over the real route", () => {
/**
* The sections and the third level, from the posted body to the stored
* column. The unit suite reads the `data` object handed to Prisma; this
* reads the ROW, which is the only thing that proves the two nulls did not
* swap places on the way down — a JSON `null` in that column resolves
* fail-closed to "opens nothing", one keystroke from the value that means
* "opens everything".
*/
it("stores the picked sections and hands them back on the grant view", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await signIn(owner.id);
const response = await invite(delegate.username, undefined, {
scope: ["labs", "medications"],
});
expect(response.status).toBe(201);
expect((await response.json()).data.scope).toEqual(["medications", "labs"]);
const row = await getPrismaClient().accountGrant.findFirstOrThrow({
where: { grantorId: owner.id },
});
expect(row.scopeJson).toEqual(["labs", "medications"]);
});
it("stores the whole record as a SQL null, not as a JSON null", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await signIn(owner.id);
expect((await invite(delegate.username)).status).toBe(201);
// Read through raw SQL: the Prisma client surfaces both nulls as `null`
// in JS, so the only way to tell "no scope" from "the JSON value null" is
// to ask the column itself. The distinction is the whole difference
// between a grant that opens everything and one that opens nothing.
const rows = await getPrismaClient().$queryRaw<
{ is_null: boolean }[]
>`SELECT scope_json IS NULL AS is_null FROM account_grants WHERE grantor_id = ${owner.id}`;
expect(rows).toHaveLength(1);
expect(rows[0].is_null).toBe(true);
});
it("refuses an unknown section and writes nothing", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await signIn(owner.id);
const response = await invite(delegate.username, undefined, {
scope: ["labs", "bank_details"],
});
expect(response.status).toBe(422);
expect(await getPrismaClient().accountGrant.count()).toBe(0);
});
it("refuses a scope beside MANAGE and writes nothing", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await signIn(owner.id);
const response = await invite(delegate.username, undefined, {
access: "MANAGE",
scope: ["labs"],
});
expect(response.status).toBe(422);
expect(await getPrismaClient().accountGrant.count()).toBe(0);
});
it("refuses MANAGE over Bearer with a code the client can act on", async () => {
// The decided consequence of a cookie-only step-up. Not a bug to work
// around: the native client cannot offer management of a health record,
// and it is told so in a code rather than by an authentication error on a
// request that authenticated fine.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const token = await mintToken(owner.id);
cookieJar.clear();
headerJar.set("authorization", `Bearer ${token}`);
const response = await invite(delegate.username, undefined, {
access: "MANAGE",
});
expect(response.status).toBe(403);
expect((await response.json()).meta.errorCode).toBe(
"sharing.invite.manage_browser_only",
);
expect(await getPrismaClient().accountGrant.count()).toBe(0);
});
it("lets the same Bearer caller keep minting the two levels it always could", async () => {
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
const token = await mintToken(owner.id);
cookieJar.clear();
headerJar.set("authorization", `Bearer ${token}`);
const response = await invite(delegate.username, undefined, {
access: "WRITE",
scope: ["measurements"],
});
expect(response.status).toBe(201);
expect((await response.json()).data.access).toBe("WRITE");
});
it("mints MANAGE from a browser session that carries no second factor", async () => {
// `requireFreshMfaIfEnrolled` gates the enrolled cohort only. An account
// with no second factor cannot produce a fresh-factor proof, so gating it
// would lock it out of a level it is entitled to offer — the same posture
// account deletion and the encrypted export already take.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await signIn(owner.id);
const response = await invite(delegate.username, undefined, {
access: "MANAGE",
});
expect(response.status).toBe(201);
const row = await getPrismaClient().accountGrant.findFirstOrThrow({
where: { grantorId: owner.id },
});
expect(row.access).toBe("MANAGE");
expect(row.scopeJson).toBeNull();
});
it("refuses MANAGE from an enrolled session whose factor has gone stale", async () => {
// The step-up itself, over the real gate. The session below has a
// confirmed TOTP secret and a `mfaVerifiedAt` well outside the window, so
// the only thing standing between it and a manage grant is the gate.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await getPrismaClient().user.update({
where: { id: owner.id },
data: { totpConfirmedAt: new Date() },
});
const session = await signIn(owner.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { mfaVerifiedAt: new Date(Date.now() - 60 * 60 * 1000) },
});
const response = await invite(delegate.username, undefined, {
access: "MANAGE",
});
expect(response.status).toBe(401);
expect((await response.json()).meta.errorCode).toBe("auth.stepup.required");
expect(await getPrismaClient().accountGrant.count()).toBe(0);
});
it("mints MANAGE once that same session has proved the factor", async () => {
// The other half, so the leg above proves a GATE rather than a broken
// route: same account, same enrolment, a fresh stamp.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await getPrismaClient().user.update({
where: { id: owner.id },
data: { totpConfirmedAt: new Date() },
});
const session = await signIn(owner.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { mfaVerifiedAt: new Date() },
});
const response = await invite(delegate.username, undefined, {
access: "MANAGE",
});
expect(response.status).toBe(201);
expect(await getPrismaClient().accountGrant.count()).toBe(1);
});
it("does not ask a read invitation for a factor at all", async () => {
// Same stale-factor account. Reducing the friction to the level that
// needs it is the point; an owner sharing a reading re-proves nothing.
const owner = await makeUser("owner");
const delegate = await makeUser("delegate");
await getPrismaClient().user.update({
where: { id: owner.id },
data: { totpConfirmedAt: new Date() },
});
const session = await signIn(owner.id);
await getPrismaClient().session.update({
where: { id: session.id },
data: { mfaVerifiedAt: new Date(Date.now() - 60 * 60 * 1000) },
});
expect((await invite(delegate.username)).status).toBe(201);
});
});
describe("grant management cannot be reached from inside a switch", () => {
/**
* The property the design states as "a delegate must never grant, widen, or