mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
docs: publish the managed-profile lifecycle routes
The contract already pointed at them: the Guardian roster names
`DELETE /api/managed-profiles/{id}/guardians/{grantId}` as the route its
`grantId` is for, and that route was not in the spec. Creation, deletion,
invitation and removal now are, with the two bodies moved to
`src/lib/validations` so the wire shape has one source.
Three things are stated rather than left to be discovered:
- the two refusals on the invitation that carry no `meta.errorCode`, and the
order a client must resolve in because of them
- the last-Guardian 409 on the removal route, which nothing can elicit through
that route today, published with the reason
- that the sentences on the ordinary invitation describe an invited grant only,
so a client cannot carry its MANAGE rules across to a Guardian grant
This commit is contained in:
1 parent
26fceca219
commit
ec9d93aa24
5 files changed
+640
-28
No files matched your search
+377
-1
@@ -12743,6 +12743,115 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/managed-profiles:
|
||||
post:
|
||||
tags:
|
||||
- Account sharing
|
||||
summary: Create a record somebody looks after
|
||||
description: "The one way a managed profile comes into existence. It has no password, no passkey and no second factor of
|
||||
its own, and it never gains one: an account with no self is reachable only through its Guardians, which is what
|
||||
makes the step-up below a boundary rather than a formality. The creator's own Guardian grant is minted in the
|
||||
same transaction, so the record is switchable immediately and the caller is never left holding a profile nobody
|
||||
administers. Cookie transport only — `requireFreshMfa` resolves through the session cookie, so a Bearer caller
|
||||
is refused as unauthenticated; a native client sends the person to a browser rather than surfacing that as an
|
||||
error."
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
displayName:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 80
|
||||
dateOfBirth:
|
||||
anyOf:
|
||||
- type: string
|
||||
format: date
|
||||
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])))$
|
||||
- type: "null"
|
||||
locale:
|
||||
type: string
|
||||
enum:
|
||||
- de
|
||||
- en
|
||||
- es
|
||||
- fr
|
||||
- it
|
||||
- pl
|
||||
timezone:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 64
|
||||
required:
|
||||
- displayName
|
||||
- locale
|
||||
- timezone
|
||||
additionalProperties: false
|
||||
responses:
|
||||
"201":
|
||||
description: The new record.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ManagedProfileEnvelope"
|
||||
"401":
|
||||
description: "No fresh second-factor proof (`meta.errorCode: auth.stepup.required`), or none enrolled at all
|
||||
(`meta.errorCode: auth.stepup.mfa_not_enrolled`) — this gate is unconditional, so an account without a
|
||||
second factor cannot create a managed profile until it enrols one. The ordinary unauthenticated 401 arrives
|
||||
here too, including for a Bearer caller, whose transport this gate does not read."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429":
|
||||
description: More than ten profiles from one caller within the hour. No error code — rate-limit refusals carry none
|
||||
anywhere in this API; branch on the status. Nothing was created.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/managed-profiles/{id}:
|
||||
delete:
|
||||
tags:
|
||||
- Account sharing
|
||||
summary: Delete a managed record
|
||||
description: "Ends the record and everything in it. This is the documented way OUT of the last-Guardian floor, which is
|
||||
why it carries no last-Guardian refusal of its own: a Guardian may not strand a record with nobody looking after
|
||||
it, but they may end the record. Any active Guardian may do it, from a cookie session with a fresh second
|
||||
factor."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: The record is gone.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/DeletedManagedProfileEnvelope"
|
||||
"401":
|
||||
description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404":
|
||||
description: "No such managed profile, or the caller is not one of its Guardians (`meta.errorCode:
|
||||
managed_profile.not_found`). The two are byte-identical, so the refusal is not an enumeration oracle — and
|
||||
an ordinary record whose owner granted somebody MANAGE is not a managed profile, so it answers here too."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/managed-profiles/{id}/guardians:
|
||||
get:
|
||||
tags:
|
||||
@@ -12780,6 +12889,147 @@ paths:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
post:
|
||||
tags:
|
||||
- Account sharing
|
||||
summary: Invite another Guardian
|
||||
description: "Offers somebody else a share in looking after this profile, at MANAGE over the whole record. Cookie-only
|
||||
and step-up gated, like every act in this family — the read beside it is not. **Resolve refusals on
|
||||
`meta.errorCode` first and fall back to the status only for the two arms that carry none**, because this route
|
||||
answers 404 twice and the codeless one is the less serious of the two: an identifier that matches no account
|
||||
carries no code, while `managed_profile.not_found` means the caller is not a Guardian of that profile at all. A
|
||||
client branching on the status first would tell somebody to check a spelling when the real answer is that the
|
||||
record is no longer theirs to administer."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
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
|
||||
additionalProperties: false
|
||||
responses:
|
||||
"201":
|
||||
description: The pending Guardian, in the shape the roster publishes.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ManagedProfileGuardianEnvelope"
|
||||
"401":
|
||||
description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404":
|
||||
description: "Two refusals share this status and only one carries a code. **No `meta.errorCode`**: no account on this
|
||||
instance carries that identifier — the same deliberate disclosure to an authenticated caller that `POST
|
||||
/api/account/grants` makes, and codeless there too, so this family does not answer one way here and another
|
||||
way one page over. `meta.errorCode: managed_profile.not_found`: no such managed profile, or the caller is
|
||||
not one of its Guardians."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: "That account already holds a live grant on this record (`meta.errorCode:
|
||||
managed_profile.guardian.duplicate`). Inviting YOURSELF lands here rather than on a refusal of its own: a
|
||||
Guardian grant's grantor is the profile, never the caller, so naming yourself names an account that already
|
||||
has access — which is both true and the useful sentence."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422":
|
||||
description: "Validation failed (the multi-issue envelope; a bare `YYYY-MM-DD` expiry arrives here), or the invitee is
|
||||
itself a managed profile (`meta.errorCode: managed_profile.guardian.managed_invitee`) — a record with no
|
||||
self cannot look after another one."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"429":
|
||||
description: More than ten invitations from one caller within the hour. No error code, as above; branch on the status.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
/api/managed-profiles/{id}/guardians/{grantId}:
|
||||
delete:
|
||||
tags:
|
||||
- Account sharing
|
||||
summary: End another Guardian's access
|
||||
description: "Ends one Guardian grant on this profile — a pending invitation or an accepted one. Another person's, never
|
||||
the caller's own: the service refuses a target grant whose grantee is the caller, and that refusal is the same
|
||||
404 an unknown profile gets, so a self-removal control would be a button that always fails with a sentence
|
||||
describing something else. A Guardian steps away through `POST /api/account/grants/{id}/renounce` instead, which
|
||||
carries the last-Guardian refusal properly. Count the ACTIVE rows on the roster BEFORE offering this: the floor
|
||||
is stated ahead of the act rather than discovered as a refusal, and on this route it cannot arrive as one — see
|
||||
the 409."
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
- in: path
|
||||
name: grantId
|
||||
schema:
|
||||
type: string
|
||||
required: true
|
||||
responses:
|
||||
"200":
|
||||
description: What ended, and when.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/EndedManagedProfileGuardianEnvelope"
|
||||
"401":
|
||||
description: No fresh second-factor proof, or none enrolled. Same unconditional gate as creation.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"404":
|
||||
description: "`meta.errorCode: managed_profile.not_found`, for four situations the caller cannot tell apart: no such
|
||||
profile, no such grant on it, the caller is not one of its Guardians, or the target grant is the caller's
|
||||
OWN. The last is the one to design around — a client must not offer this control on the caller's own row,
|
||||
because the refusal it earns describes a missing profile rather than what actually happened."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"409":
|
||||
description: "The record would be left with nobody looking after it (`meta.errorCode:
|
||||
managed_profile.guardian.required`). **Not reachable through this route today**, and published anyway rather
|
||||
than silently: the caller must already be one active Guardian and the target is never the caller, so an
|
||||
active target is always a second one — and two is never the one the floor refuses. The same code IS emitted
|
||||
by the renounce route, by Guardian account deletion and by a data wipe, so a shared client resolver keeps
|
||||
this arm. It is documented here so that a change making it reachable is a change somebody notices."
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorEnvelope"
|
||||
"422": *a3
|
||||
"429": *a4
|
||||
/api/account/switch:
|
||||
post:
|
||||
tags:
|
||||
@@ -37295,6 +37545,82 @@ components:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
ManagedProfileEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/ManagedProfile"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
ManagedProfile:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
description: The profile's account id — the value `POST /api/account/switch` and the `X-HealthLog-Account` selector
|
||||
take, and the `{id}` in this family's paths.
|
||||
displayName:
|
||||
type: string
|
||||
dateOfBirth:
|
||||
anyOf:
|
||||
- type: string
|
||||
- type: "null"
|
||||
locale:
|
||||
type: string
|
||||
timezone:
|
||||
type: string
|
||||
recordKind:
|
||||
type: string
|
||||
const: managed
|
||||
description: Always `managed` here. Published so a client can file the new record without re-deriving what it just
|
||||
created; the same value arrives on the account payload's entry for it.
|
||||
required:
|
||||
- id
|
||||
- displayName
|
||||
- dateOfBirth
|
||||
- locale
|
||||
- timezone
|
||||
- recordKind
|
||||
additionalProperties: false
|
||||
description: "A record somebody looks after, as its creation answers with it. There is deliberately no list endpoint:
|
||||
the profiles a Guardian looks after are the `managed` entries of `accountAccess.accounts` on GET /api/auth/me,
|
||||
because a Guardian's relationship to a profile IS a MANAGE grant. Re-read the account payload after any change
|
||||
here; a client that refreshed only its own panel would leave the new record out of the switcher and the banner
|
||||
until the next boot."
|
||||
DeletedManagedProfileEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
type: object
|
||||
properties:
|
||||
deleted:
|
||||
type: boolean
|
||||
const: true
|
||||
required:
|
||||
- deleted
|
||||
additionalProperties: false
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
ManagedProfileGuardianListEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
@@ -37345,7 +37671,57 @@ components:
|
||||
- acceptedAt
|
||||
additionalProperties: false
|
||||
description: "One person who looks after a managed profile. Ended grants — revoked or expired — are absent rather than
|
||||
listed with a state: the question is who looks after this record now."
|
||||
listed with a state: the question is who looks after this record now. The invitation route answers with this
|
||||
same shape, so a Guardian is described one way everywhere; it carries no `expiresAt`, because an expiry bounds
|
||||
one invitation rather than describing who is looking after the record."
|
||||
ManagedProfileGuardianEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/ManagedProfileGuardian"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
EndedManagedProfileGuardianEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
data:
|
||||
$ref: "#/components/schemas/EndedManagedProfileGuardian"
|
||||
error:
|
||||
type: "null"
|
||||
meta:
|
||||
type: object
|
||||
properties:
|
||||
requestId:
|
||||
type: string
|
||||
additionalProperties: false
|
||||
required:
|
||||
- data
|
||||
- error
|
||||
additionalProperties: false
|
||||
EndedManagedProfileGuardian:
|
||||
type: object
|
||||
properties:
|
||||
grantId:
|
||||
type: string
|
||||
revokedAt:
|
||||
type: string
|
||||
required:
|
||||
- grantId
|
||||
- revokedAt
|
||||
additionalProperties: false
|
||||
description: "What ended, and when. Two fields on purpose: who held it is on the roster the caller re-reads, and the
|
||||
grant row's remaining columns are storage rather than contract. Nothing is deleted — the row survives as the
|
||||
consent record with `revokedBy: GRANTOR`, the managed profile being the grantor of every Guardian grant."
|
||||
AccountSwitchEnvelope:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
Reference in new issue
Block a user