mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 14:32:03 +00:00
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:
1 parent
843c74c98c
commit
56447a9457
14 files changed
+2519
-1
No files matched your search
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user