mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 06:52:04 +00:00
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:
1 parent
feb9d25e87
commit
ab14fdb6f3
33 files changed
+2273
-155
No files matched your search
+128
-15
@@ -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
|
||||
|
||||
Reference in new issue
Block a user