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:
@@ -1,5 +1,4 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import {
apiHandler,
@@ -23,6 +22,7 @@ import { ManagedProfileLifecycleError } from "@/lib/managed-profiles/lifecycle";
import { checkRateLimit } from "@/lib/rate-limit";
import { GRANT_PARTY_SELECT } from "@/lib/sharing/grant-view";
import { GrantError, inviteManagedProfileGuardian } from "@/lib/sharing/grants";
import { inviteManagedProfileGuardianSchema } from "@/lib/validations/managed-profiles";
type RouteParams = { params: Promise<{ id: string }> };
@@ -77,13 +77,6 @@ export const GET = apiHandler(
const INVITE_LIMIT = 10;
const INVITE_WINDOW_MS = 60 * 60 * 1000;
const guardianInviteSchema = z
.object({
identifier: z.string().trim().min(1).max(255),
expiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
})
.strict();
/** Invite another Guardian from a cookie-backed, freshly verified session. */
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
@@ -100,7 +93,7 @@ export const POST = apiHandler(
maxBytes: 8 * 1024,
});
if (jsonError) return jsonError;
const parsed = guardianInviteSchema.safeParse(body);
const parsed = inviteManagedProfileGuardianSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const { id } = await params;
+2 -16
View File
@@ -1,5 +1,4 @@
import { NextRequest } from "next/server";
import { z } from "zod/v4";
import {
apiHandler,
@@ -16,7 +15,7 @@ import { auditLog } from "@/lib/auth/audit";
import { createManagedProfile } from "@/lib/managed-profiles/create";
import { annotate } from "@/lib/logging/context";
import { checkRateLimit } from "@/lib/rate-limit";
import { isValidTimezone } from "@/lib/tz/format";
import { createManagedProfileSchema } from "@/lib/validations/managed-profiles";
/**
* The same ceiling its guardian sibling carries, and for the same reason.
@@ -30,19 +29,6 @@ import { isValidTimezone } from "@/lib/tz/format";
const CREATE_LIMIT = 10;
const CREATE_WINDOW_MS = 60 * 60 * 1000;
const managedProfileSchema = z
.object({
displayName: z.string().trim().min(1).max(80),
dateOfBirth: z.iso.date().nullable().optional(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]),
timezone: z
.string()
.min(1)
.max(64)
.refine(isValidTimezone, "Invalid IANA timezone"),
})
.strict();
/**
* Create a record a Guardian administers. This deliberately uses the
* cookie-only fresh-MFA gate: a Bearer token cannot mint a credential-less
@@ -65,7 +51,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
});
if (jsonError) return jsonError;
const parsed = managedProfileSchema.safeParse(body);
const parsed = createManagedProfileSchema.safeParse(body);
if (!parsed.success) return returnAllZodIssues(parsed.error, 422);
const { profile, creatorGrant } = await createManagedProfile({
+222 -2
View File
@@ -29,12 +29,28 @@ import {
inviteGrantSchema,
switchAccountSchema,
} from "@/lib/validations/account-sharing";
import {
createManagedProfileSchema,
inviteManagedProfileGuardianSchema,
} from "@/lib/validations/managed-profiles";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
inviteGrantSchema.meta({
id: "AccountGrantInvite",
description:
"Offer access to another account on this instance. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. `access` is READ when omitted, so a client that does not know about the field keeps working: 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, and MANAGE can additionally change and remove entries, including ones the owner wrote, and read the generated insights — never the identity surfaces (login, second factor, connections, tokens, settings, or who else has access). `scope` narrows a READ or WRITE grant to named sections; omitted or null is the entire record, which is what every grant written before v1.37.0 means. An empty array, an unknown key, and any `scope` at all beside `access: MANAGE` are each 422 — management is whole-record by construction. Offering MANAGE additionally requires a fresh second factor when the account has one enrolled, which makes it cookie-only: a Bearer caller is refused with 403 `sharing.invite.manage_browser_only` and should send the person to a browser. The level and the scope are 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.",
"Offer access to another account on this instance. Every sentence here describes an INVITED grant on this endpoint, and nothing else: a Guardian of a managed profile also holds MANAGE over a whole record, but that grant is minted by `POST /api/managed-profiles/{id}/guardians` under its own gate and answers with its own shape, so none of the rules below — the step-up, the transport, the refusals — may be carried across to it. `identifier` is the invitee's username or e-mail, matched case-insensitively — the same identifier they sign in with. `expiresAt` is optional; omitted or null means the grant runs until somebody ends it. `access` is READ when omitted, so a client that does not know about the field keeps working: 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, and MANAGE can additionally change and remove entries, including ones the owner wrote, and read the generated insights — never the identity surfaces (login, second factor, connections, tokens, settings, or who else has access). `scope` narrows a READ or WRITE grant to named sections; omitted or null is the entire record, which is what every grant written before v1.37.0 means. An empty array, an unknown key, and any `scope` at all beside `access: MANAGE` are each 422 — management is whole-record by construction. Offering MANAGE additionally requires a fresh second factor when the account has one enrolled, which makes it cookie-only: a Bearer caller is refused with 403 `sharing.invite.manage_browser_only` and should send the person to a browser. The level and the scope are 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.",
});
createManagedProfileSchema.meta({
id: "CreateManagedProfileRequest",
description:
"Create a health record for somebody who has no login of their own — a child, a dependent adult, an animal. The caller becomes its first Guardian in the same transaction, through an ordinary MANAGE grant with the new record as grantor, so the profile appears in `accountAccess.accounts` on the next `GET /api/auth/me` and is switched into like any other shared record. `displayName` is the only required text; `dateOfBirth` is an optional `YYYY-MM-DD` and is never synthesised from a year. `locale` and `timezone` belong to the RECORD and not to the Guardian reading it: they decide how the profile's own reminders are worded and when its day starts. Strict — an unexpected field is a 422 rather than an ignored key. Cookie transport only and step-up gated (`requireFreshMfa`, unconditional): a Bearer caller cannot mint a credential-less person and a permanent management relationship, and an account with no second factor enrolled is refused rather than waved through. Rate-limited to ten an hour per caller.",
});
inviteManagedProfileGuardianSchema.meta({
id: "InviteManagedProfileGuardianRequest",
description:
"Offer somebody else a share in looking after this profile. `identifier` is their username or e-mail, matched case-insensitively. `expiresAt` is an ISO instant WITH an offset — a bare `YYYY-MM-DD` is a 422 — or null for an invitation that runs until somebody ends it. The grant is created PENDING at MANAGE over the whole record; the invitee accepts it on their own invitations card exactly as they would any other, and until they do they do not satisfy the last-Guardian floor. Strict, cookie-only, step-up gated and rate-limited to ten an hour, like every act in this family.",
});
switchAccountSchema.meta({
@@ -137,7 +153,41 @@ const managedProfileGuardian = z
.meta({
id: "ManagedProfileGuardian",
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.",
"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. 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.",
});
const managedProfileCreated = z
.object({
id: z
.string()
.describe(
"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: z.string(),
dateOfBirth: z.string().nullable(),
locale: z.string(),
timezone: z.string(),
recordKind: z
.literal("managed")
.describe(
"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.",
),
})
.meta({
id: "ManagedProfile",
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.",
});
const endedGuardianGrant = z
.object({
grantId: z.string(),
revokedAt: z.string(),
})
.meta({
id: "EndedManagedProfileGuardian",
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.",
});
const grantListResponse = z
@@ -586,6 +636,79 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/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: createManagedProfileSchema },
},
},
responses: {
...stdResponses,
"201": {
description: "The new record.",
content: {
"application/json": {
schema: dataEnvelope(
managedProfileCreated,
"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: errorEnvelope } },
},
"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: 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.",
requestParams: {
path: z.object({ id: z.string() }),
},
responses: {
...stdResponses,
"200": {
description: "The record is gone.",
content: {
"application/json": {
schema: dataEnvelope(
z.object({ deleted: z.literal(true) }),
"DeletedManagedProfileEnvelope",
),
},
},
},
"401": {
description:
"No fresh second-factor proof, or none enrolled. Same unconditional gate as creation.",
content: { "application/json": { schema: 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: errorEnvelope } },
},
},
},
},
"/api/managed-profiles/{id}/guardians": {
get: {
tags: ["Account sharing"],
@@ -615,6 +738,103 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
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.",
requestParams: {
path: z.object({ id: z.string() }),
},
requestBody: {
required: true,
content: {
"application/json": {
schema: inviteManagedProfileGuardianSchema,
},
},
},
responses: {
...stdResponses,
"201": {
description:
"The pending Guardian, in the shape the roster publishes.",
content: {
"application/json": {
schema: dataEnvelope(
managedProfileGuardian,
"ManagedProfileGuardianEnvelope",
),
},
},
},
"401": {
description:
"No fresh second-factor proof, or none enrolled. Same unconditional gate as creation.",
content: { "application/json": { schema: 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: 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: 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: 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: 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.",
requestParams: {
path: z.object({ id: z.string(), grantId: z.string() }),
},
responses: {
...stdResponses,
"200": {
description: "What ended, and when.",
content: {
"application/json": {
schema: dataEnvelope(
endedGuardianGrant,
"EndedManagedProfileGuardianEnvelope",
),
},
},
},
"401": {
description:
"No fresh second-factor proof, or none enrolled. Same unconditional gate as creation.",
content: { "application/json": { schema: 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: 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: errorEnvelope } },
},
},
},
},
"/api/account/switch": {
post: {
+37
View File
@@ -0,0 +1,37 @@
/**
* v1.37.0 — the two bodies the managed-profile family accepts.
*
* Here rather than beside each route because the OpenAPI table publishes them,
* and a schema written twice is a contract that drifts on the third change.
* The route modules import these; nothing imports the route modules.
*
* Both are `.strict()`. A managed profile is a person's health record created
* by somebody else, so a field the sender did not mean to send is refused
* rather than ignored — and the client types name exactly these fields for the
* same reason (`src/lib/queries/use-managed-profiles.ts`).
*/
import { z } from "zod/v4";
import { isValidTimezone } from "@/lib/tz/format";
/** The body `POST /api/managed-profiles` accepts. */
export const createManagedProfileSchema = z
.object({
displayName: z.string().trim().min(1).max(80),
dateOfBirth: z.iso.date().nullable().optional(),
locale: z.enum(["de", "en", "es", "fr", "it", "pl"]),
timezone: z
.string()
.min(1)
.max(64)
.refine(isValidTimezone, "Invalid IANA timezone"),
})
.strict();
/** The body `POST /api/managed-profiles/{id}/guardians` accepts. */
export const inviteManagedProfileGuardianSchema = z
.object({
identifier: z.string().trim().min(1).max(255),
expiresAt: z.iso.datetime({ offset: true }).nullable().optional(),
})
.strict();