mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-14 14:40:52 +00:00
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:
1 parent
13701ff2b3
commit
6bb0360695
3 files changed
+291
-129
No files matched your search
+160
-116
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user