mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +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:
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
Reference in new issue
Block a user