feat(sharing): invite, accept, switch, revoke, renounce

The lifecycle surface under /api/account. An owner offers read access to
somebody already on the instance, the delegate accepts, either side ends
it, and a browser session points itself at a record it has been granted.

Ending a grant clears the acting-account carrier on every session of the
delegate pointing at that record, in the same transaction that stamps the
row. Split apart, a revocation would leave a browser inside a record it
can no longer read: every request refused, the banner still naming the
owner, nothing on screen able to explain it.

Revocation has no step-up and no confirmation step. Reducing access must
never be harder than granting it was.

Grant management is non-delegable by construction rather than by a check:
every route here resolves through bare requireAuth(), which refuses while
the caller is acting on another account, so no code path through them
runs as somebody else. The switch endpoint is the one actor surface, and
it is the way back out. A delegate already inside a record renounces
after switching out, one request, rather than the fail-closed default
growing an exception that later gets widened.

The state machine is not re-implemented anywhere: routes call the grant
module's predicate and its transitions, and the session carrier has one
writer.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 13:04:25 +02:00
1 parent 843c74c98c
commit 56447a9457
14 files changed
+2519 -1

No files matched your search

+549
View File
@@ -12432,6 +12432,259 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/account/grants:
get:
tags:
- Account sharing
summary: List grants in both directions
description: Every grant this account has given or received, ended ones included. Refused while acting on another account.
responses:
"200":
description: Grants given and received.
content:
application/json:
schema:
$ref: "#/components/schemas/AccountGrantListEnvelope"
"401": *a1
"403": &a20
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.access.denied` when the caller may not act as the
account they named. The latter is byte-identical for an account that does not exist and one that granted
nothing — the refusal is not an enumeration oracle.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
post:
tags:
- Account sharing
summary: Invite an account to read this record
description: Offers read access to somebody already registered on this instance; the grant confers nothing until they
accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller
on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a
mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor
re-delegate.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
identifier:
type: string
minLength: 1
maxLength: 255
expiresAt:
anyOf:
- type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
- type: "null"
required:
- identifier
responses:
"201":
description: The pending grant.
content:
application/json:
schema:
$ref: "#/components/schemas/AccountGrantEnvelope"
"401": *a1
"403": *a20
"404":
description: No account on this instance carries that identifier.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: "That account already holds a live grant on this record (`meta.errorCode: sharing.invite.duplicate`).
Re-inviting somebody whose access was revoked succeeds and mints a new row; the old one stays as history."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422":
description: "Validation failed, or the invitation named the caller's own account (`meta.errorCode:
sharing.invite.self`)."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429":
description: Invitation rate limit exceeded.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/account/grants/{id}/accept:
post:
tags:
- Account sharing
summary: Accept an invitation
description: "The delegate's own act, and the consent record: it stamps the acceptance time and the accepting IP on the
grant row. One-shot and delegate-only — the invited account comes from the session, so an invitation addressed
to somebody else answers 404 rather than confirming it exists."
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: The now-active grant.
content:
application/json:
schema:
$ref: "#/components/schemas/AcceptedAccountGrantEnvelope"
"401": *a1
"403": *a20
"404":
description: No such invitation for this account.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: Already accepted.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"410":
description: The invitation lapsed or was withdrawn.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/account/grants/{id}:
delete:
tags:
- Account sharing
summary: Withdraw access (owner)
description: "Ends a grant on the caller's own record. No step-up and no confirmation step — reducing access must never
be harder than granting it. Enforcement is the delegate's next request, not their next login, and the same
transaction puts every browser session of theirs that was inside the record back into their own account. Nothing
is deleted: the row survives with `revokedAt` and `revokedBy: GRANTOR`."
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: The ended grant, and what the session cleanup did.
content:
application/json:
schema:
$ref: "#/components/schemas/RevokedAccountGrantEnvelope"
"401": *a1
"403": *a20
"404":
description: No such grant on this account's record.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: That access had already ended.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/account/grants/{id}/renounce:
post:
tags:
- Account sharing
summary: Hand access back (delegate)
description: "The same transition as the owner's revoke, attributed the other way (`revokedBy: GRANTEE`) so the record
can tell a withdrawal from a handover. Same session cleanup. Refused while acting on the record being renounced
— switch back first (one request), which keeps grant management non-delegable without exceptions."
parameters:
- in: path
name: id
schema:
type: string
required: true
responses:
"200":
description: The ended grant, and what the session cleanup did.
content:
application/json:
schema:
$ref: "#/components/schemas/RenouncedAccountGrantEnvelope"
"401": *a1
"403": *a20
"404":
description: No such grant held by this account.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: That access had already ended.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a3
"429": *a4
/api/account/switch:
post:
tags:
- Account sharing
summary: Act on a granted record, or return to your own
description: "Cookie transport only; a Bearer caller gets 400 (`meta.errorCode: sharing.switch.wrong_transport`) and
should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated
here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every
following request re-checks the grant. `accountId: null` clears the switch and always works, including from
inside a switched session: this endpoint is the way out."
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
accountId:
anyOf:
- type: string
minLength: 1
maxLength: 64
- type: "null"
required:
- accountId
responses:
"200":
description: The account this session is now acting on, or null.
content:
application/json:
schema:
$ref: "#/components/schemas/AccountSwitchEnvelope"
"400":
description: Sent over the Bearer transport, which carries its selector per request instead.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
"403": *a20
"422":
description: Validation failed.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a4
components:
schemas:
EncryptedExportRequest:
@@ -36305,6 +36558,302 @@ components:
- data
- error
additionalProperties: false
AccountGrantListEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/AccountGrantList"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AccountGrantList:
type: object
properties:
given:
type: array
items:
$ref: "#/components/schemas/AccountGrant"
received:
type: array
items:
$ref: "#/components/schemas/AccountGrant"
required:
- given
- received
additionalProperties: false
description: "Both directions at once: `given` are the grants this account has offered on its own record, `received` are
the ones offered to it. Capped at 100 rows per direction, newest first."
AccountGrant:
type: object
properties:
id:
type: string
account:
$ref: "#/components/schemas/AccountGrantParty"
access:
type: string
enum:
- READ
- WRITE
state:
$ref: "#/components/schemas/AccountGrantState"
invitedAt:
type: string
acceptedAt:
anyOf:
- type: string
- type: "null"
expiresAt:
anyOf:
- type: string
- type: "null"
lastUsedAt:
anyOf:
- type: string
- type: "null"
revokedAt:
anyOf:
- type: string
- type: "null"
revokedBy:
anyOf:
- type: string
enum:
- GRANTOR
- GRANTEE
- type: "null"
required:
- id
- account
- access
- state
- invitedAt
- acceptedAt
- expiresAt
- lastUsedAt
- revokedAt
- revokedBy
additionalProperties: false
description: "One grant, from either side. The row is also the consent record — who offered the access, when it was
accepted, when and by whom it ended — so ended grants stay in the list with `state: REVOKED` rather than
disappearing. `revokedBy` distinguishes the owner withdrawing access from the delegate handing it back.
`lastUsedAt` mirrors API-token semantics: the last time the grant was actually exercised."
AccountGrantParty:
type: object
properties:
id:
type: string
username:
type: string
displayName:
anyOf:
- type: string
- type: "null"
required:
- id
- username
- displayName
additionalProperties: false
description: "The account on the other end of a grant. E-mail addresses are deliberately not published: a grant is not a
way to collect the other party's contact details."
AccountGrantState:
type: string
enum:
- PENDING
- ACTIVE
- EXPIRED
- REVOKED
description: "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."
AccountGrantEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/AccountGrant"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AcceptedAccountGrantEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/AccountGrant"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
RevokedAccountGrantEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/EndedAccountGrant"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
EndedAccountGrant:
type: object
properties:
id:
type: string
account:
$ref: "#/components/schemas/AccountGrantParty"
access:
type: string
enum:
- READ
- WRITE
state:
$ref: "#/components/schemas/AccountGrantState"
invitedAt:
type: string
acceptedAt:
anyOf:
- type: string
- type: "null"
expiresAt:
anyOf:
- type: string
- type: "null"
lastUsedAt:
anyOf:
- type: string
- type: "null"
revokedAt:
anyOf:
- type: string
- type: "null"
revokedBy:
anyOf:
- type: string
enum:
- GRANTOR
- GRANTEE
- type: "null"
sessionsCleared:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: How many of the delegate's browser sessions were sitting inside the record and were put back into their own
account, in the same transaction that ended the grant.
required:
- id
- account
- access
- state
- invitedAt
- acceptedAt
- expiresAt
- lastUsedAt
- revokedAt
- revokedBy
- sessionsCleared
additionalProperties: false
description: "A grant that has just been ended, plus what the cleanup did. Nothing is deleted: the row survives as the
consent record and DELETE names the act, not the storage."
RenouncedAccountGrantEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/EndedAccountGrant"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AccountSwitchEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/AccountSwitchState"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
AccountSwitchState:
type: object
properties:
actingAs:
anyOf:
- type: object
properties:
accountId:
type: string
username:
type: string
displayName:
anyOf:
- type: string
- type: "null"
access:
type: string
enum:
- READ
- WRITE
required:
- accountId
- username
- displayName
- access
additionalProperties: false
- type: "null"
required:
- actingAs
additionalProperties: false
description: The account this session is now acting on, or null when it is back in its own. The stamp is a selector and
not a permission — the grant is re-checked on every subsequent request, so a revocation lands on the next one
rather than at the next login.
MedicationScheduleOutput:
type: object
properties: