docs(api): publish the sharing request bodies as named schemas

Zod 4's .meta() is immutable: it returns a new schema carrying the
metadata and registers that one. Four request bodies in the sharing
family called it as a statement and dropped the result, so the route
table went on referencing the bare schema, the bodies were emitted
inline and anonymous, and the object-level descriptions never reached
docs/api/openapi.yaml. One of them carries the clause that scopes every
rule on the invitation endpoint to an invited grant, which is the
sentence that stops a reader carrying the step-up and the refusals
across to a guardian grant.

Bind each to a const, reference the const, and correct the level
boundary in the invitation's description while it is being published:
it still carried the blanket denial that stopped being true when the
anamnesis surface opened to a manage delegate.

The wire format does not move. Four named components appear and the four
bodies point at them; the fields, the types and the requiredness are
what they were. A new guard asserts every id this route table declares
reaches the built document, because the failing form looks exactly like
the working one.
This commit is contained in:
Marc-André Bombeck committed 2026-08-07 08:10:48 +02:00
1 parent 13701ff2b3
commit 6bb0360695
3 files changed
+291 -129

No files matched your search

+160 -116
View File
@@ -12711,58 +12711,7 @@ paths:
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"
access:
default: READ
description: "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.
MANAGE can additionally change and remove entries, including ones the owner wrote, read the
generated insights, and record the record's own health background (allergies, family history). The
boundary is record content versus account configuration: MANAGE never reaches login, the second
factor, provider connections, API tokens, notification routing, module and threshold configuration,
or who else has access. Omitted means READ, so a client that predates the field keeps working. The
level is 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."
type: string
enum:
- READ
- WRITE
- MANAGE
scope:
description: "The sections of the record this invitation opens. Omitted or null means the entire record, which is what
every grant written before v1.37.0 means and what an owner who does not narrow still gets. A
non-null value is a non-empty array of section keys from the closed vocabulary; an empty array, an
unknown key and the whole-record sentinel `record` are each refused. A stored scope never grows: a
section added by a later release is not in a set an owner ticked before it existed."
anyOf:
- minItems: 1
type: array
items:
type: string
enum:
- measurements
- medications
- labs
- profile
- illness
- mind
- cycle
- documents
- type: "null"
required:
- identifier
$ref: "#/components/schemas/AccountGrantInvite"
responses:
"201":
description: The pending grant.
@@ -12957,36 +12906,7 @@ paths:
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
$ref: "#/components/schemas/CreateManagedProfileRequest"
responses:
"201":
description: The new record.
@@ -13108,21 +13028,7 @@ paths:
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
$ref: "#/components/schemas/InviteManagedProfileGuardianRequest"
responses:
"201":
description: The pending Guardian, in the shape the roster publishes.
@@ -13248,25 +13154,7 @@ paths:
content:
application/json:
schema:
type: object
properties:
accountId:
anyOf:
- type: string
minLength: 1
maxLength: 64
- type: "null"
expectedEpoch:
description: "The record epoch the caller believes this browser session is at, as published by GET /api/auth/me or a
previous switch. Present makes the write conditional: if another tab moved the selector first the
switch is refused with 409 sharing.session.changed and the caller reconciles through /api/auth/me.
Omitted keeps the unconditional write, which is what a client predating the record-session fence
sends. Cookie transport only."
type: integer
minimum: 0
maximum: 9007199254740991
required:
- accountId
$ref: "#/components/schemas/AccountSwitchRequest"
responses:
"200":
description: The account this session is now acting on, or null.
@@ -16173,6 +16061,162 @@ components:
- version
description: PUT body for the dashboard layout — the layout fields plus the optional optimistic-concurrency base token
(`baseUpdatedAt`). Omit the token for the legacy unconditional write.
AccountGrantInvite:
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"
access:
default: READ
description: "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. MANAGE
can additionally change and remove entries, including ones the owner wrote, read the generated insights, and
record the record's own health background (allergies, family history). The boundary is record content versus
account configuration: MANAGE never reaches login, the second factor, provider connections, API tokens,
notification routing, module and threshold configuration, or who else has access. Omitted means READ, so a
client that predates the field keeps working. The level is 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."
type: string
enum:
- READ
- WRITE
- MANAGE
scope:
description: "The sections of the record this invitation opens. Omitted or null means the entire record, which is what
every grant written before v1.37.0 means and what an owner who does not narrow still gets. A non-null value
is a non-empty array of section keys from the closed vocabulary; an empty array, an unknown key and the
whole-record sentinel `record` are each refused. A stored scope never grows: a section added by a later
release is not in a set an owner ticked before it existed."
anyOf:
- minItems: 1
type: array
items:
type: string
enum:
- measurements
- medications
- labs
- profile
- illness
- mind
- cycle
- documents
- type: "null"
required:
- identifier
description: "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, read the generated insights, and record the record's own health background
(allergies, family history). The boundary is record content versus account configuration: MANAGE never reaches
login, the second factor, provider connections, API tokens, notification routing, module and threshold
configuration, 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."
CreateManagedProfileRequest:
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
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."
InviteManagedProfileGuardianRequest:
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
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.
AccountSwitchRequest:
type: object
properties:
accountId:
anyOf:
- type: string
minLength: 1
maxLength: 64
- type: "null"
expectedEpoch:
description: "The record epoch the caller believes this browser session is at, as published by GET /api/auth/me or a
previous switch. Present makes the write conditional: if another tab moved the selector first the switch is
refused with 409 sharing.session.changed and the caller reconciles through /api/auth/me. Omitted keeps the
unconditional write, which is what a client predating the record-session fence sends. Cookie transport
only."
type: integer
minimum: 0
maximum: 9007199254740991
required:
- accountId
description: "Point this browser session at an account, or (with `accountId: null`) back at its own. Cookie transport only."
MedicationTreatmentClass:
type: string
enum:
@@ -0,0 +1,98 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
/**
* Every schema id the sharing route table declares reaches the published
* document.
*
* The failure this exists for looks like nothing at all. `.meta()` in Zod 4 is
* immutable: it returns a NEW schema carrying the metadata and registers that
* one, so `schema.meta({ id, description })` written as a statement registers
* an instance no route references. The generator then emits the request body
* inline and anonymous, the id never becomes a component, and the object-level
* description never leaves the source file. Nothing throws, nothing drifts,
* `openapi:check` stays green, and the sentence somebody wrote for the client
* team is readable only by someone already reading the code.
*
* Four of the sharing bodies were written that way, and one of them carried the
* clause scoping every rule on the invitation endpoint to an INVITED grant —
* the exact sentence a reader needs in order not to carry the step-up and the
* refusals across to a Guardian grant. It was found by reading the artifact
* instead of the source, which is not a repeatable way to find things.
*
* ## What is checked
*
* The ids are read out of the route table's own text rather than listed here,
* so a body added tomorrow is covered without anybody remembering this file.
* The set is asserted non-empty first: a matcher that silently stopped matching
* would otherwise report a green run over nothing, which is the same class of
* defect as the one under test.
*
* The document is BUILT rather than read off disk. `scripts/check-openapi.ts`
* already pins `docs/api/openapi.yaml` to exactly this document and CI fails on
* drift, so building it here checks the same artifact without parsing a
* megabyte of YAML.
*
* ## What it cannot see
*
* Only this route module. The idiom is long-standing across the registry and
* fifty-seven other ids are declared the same way in modules this file does not
* read; widening the sweep is a separate change with a separate diff, recorded
* in the post-release inventory.
*/
const ROUTE_TABLE = join(
process.cwd(),
"src/lib/openapi/routes/account-sharing.ts",
);
/** Every `id: "…"` the route table declares, in source order. */
function declaredIds(): string[] {
const source = readFileSync(ROUTE_TABLE, "utf8");
return [...source.matchAll(/\bid:\s*"([A-Za-z0-9_]+)"/g)].map(
(match) => match[1]!,
);
}
describe("the sharing route table's schema ids reach the document", () => {
const ids = declaredIds();
const schemas = buildOpenApiDocument().components?.schemas ?? {};
it("has ids to check", () => {
// The non-zero control. Rename the file, change the quoting style, drop the
// `id` key convention, and this leg fails instead of the file passing
// vacuously.
expect(ids.length).toBeGreaterThan(10);
expect(ids).toContain("AccountGrantInvite");
expect(ids).toContain("CreateManagedProfileRequest");
expect(ids).toContain("InviteManagedProfileGuardianRequest");
expect(ids).toContain("AccountSwitchRequest");
});
it("publishes every declared id as a named component", () => {
const missing = ids.filter((id) => !(id in schemas));
expect(missing).toEqual([]);
});
it("publishes the invited-grant scoping clause with the request body", () => {
// The sentence that was lost, checked where a client would read it rather
// than where it was written.
const invite = schemas["AccountGrantInvite"] as
{ description?: string } | undefined;
expect(invite?.description ?? "").toMatch(
/Every sentence here describes an INVITED grant on this endpoint/,
);
// And the boundary it states is the corrected one. The stale wording denied
// every settings surface, which stopped being true when the anamnesis
// surface opened to a manage delegate.
expect(invite?.description ?? "").not.toMatch(
/never reaches[^.]*\bsettings\b/i,
);
expect(invite?.description ?? "").toMatch(
/record content versus account configuration/i,
);
});
});
+33 -13
View File
@@ -35,25 +35,45 @@ import {
} from "@/lib/validations/managed-profiles";
import { dataEnvelope, errorEnvelope, stdResponses } from "./shared";
inviteGrantSchema.meta({
/**
* The four request bodies this table publishes, and the one thing to know about
* writing another.
*
* `.meta()` does not annotate the schema it is called on. Zod 4 returns a NEW
* schema carrying the metadata and registers THAT one, so a bare
* `schema.meta({ id, description })` whose return value is dropped registers an
* instance nothing references: the id never becomes a component, the
* description never reaches the document, and the call site looks exactly like
* a working one. These four were written that way, which is how the clause
* below — the one scoping every rule on this endpoint to an INVITED grant —
* came to be recorded in the source and absent from the artifact the native
* client reads.
*
* So each one is bound to a const and the const is what the route references.
* `openapi-account-sharing-components.test.ts` asserts every id declared in
* this file reaches the built document, which is the assertion that would have
* caught it.
*/
const accountGrantInvite = inviteGrantSchema.meta({
id: "AccountGrantInvite",
description:
"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.",
"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, read the generated insights, and record the record's own health background (allergies, family history). The boundary is record content versus account configuration: MANAGE never reaches login, the second factor, provider connections, API tokens, notification routing, module and threshold configuration, 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({
const createManagedProfileRequest = 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.",
});
const inviteManagedProfileGuardianRequest =
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({
const accountSwitchRequest = switchAccountSchema.meta({
id: "AccountSwitchRequest",
description:
"Point this browser session at an account, or (with `accountId: null`) back at its own. Cookie transport only.",
@@ -490,7 +510,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Offers access to somebody already registered on this instance, at READ, WRITE or MANAGE and over the whole record or named sections; the grant confers nothing until they accept it. An identifier that names no account answers 404 — a deliberate disclosure to an authenticated caller on a household instance, rate-limited to 10 invitations an hour, and the alternative (a silent pending row for a mistyped username) is worse. Refused while acting on another account, so a delegate can neither invite nor re-delegate. Offering MANAGE is step-up gated (`requireFreshMfaIfEnrolled`, cookie-only): an enrolled account without a fresh factor gets 401 `auth.stepup.required`, and a Bearer caller gets 403 `sharing.invite.manage_browser_only` before anything else happens, because the gate it would hit resolves through the session cookie and would otherwise answer 'not authenticated' to a caller it had just authenticated.",
requestBody: {
required: true,
content: { "application/json": { schema: inviteGrantSchema } },
content: { "application/json": { schema: accountGrantInvite } },
},
responses: {
...stdResponses,
@@ -647,7 +667,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
requestBody: {
required: true,
content: {
"application/json": { schema: createManagedProfileSchema },
"application/json": { schema: createManagedProfileRequest },
},
},
responses: {
@@ -752,7 +772,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
required: true,
content: {
"application/json": {
schema: inviteManagedProfileGuardianSchema,
schema: inviteManagedProfileGuardianRequest,
},
},
},
@@ -846,7 +866,7 @@ export const accountSharingPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"Cookie transport only; a Bearer caller gets 400 (`meta.errorCode: sharing.switch.wrong_transport`) and should send the `X-HealthLog-Account` header on the requests it wants scoped instead. The grant is validated here so the client gets an honest refusal immediately, but the stamp authorises nothing on its own — every following request re-checks the grant. `accountId: null` clears the switch and always works, including from inside a switched session: this endpoint is the way out, and it is deliberately not fenced for that reason — a session whose context cannot be proved must still be able to leave. Supply `expectedEpoch` to make the write conditional: two tabs switching at once resolve to one monotonic outcome, and the loser is refused with 409 `sharing.session.changed` rather than silently overwriting the winner. Reconcile through GET /api/auth/me and retry once against the reconciled epoch.",
requestBody: {
required: true,
content: { "application/json": { schema: switchAccountSchema } },
content: { "application/json": { schema: accountSwitchRequest } },
},
responses: {
...stdResponses,