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:
Marc-André Bombeck committed 2026-08-07 04:42:51 +02:00
1 parent 26fceca219
commit ec9d93aa24
5 files changed
+640 -28

No files matched your search

+377 -1
View File
@@ -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: