feat(02-12.1): refuse a record request whose context has moved

- both record resolvers check the fence after authentication and before
  the carrier, off the one session row that also decides the scope
- POST /api/account/switch takes an optional expectedEpoch and writes
  conditionally, so concurrent switchers resolve to one outcome
- GET /api/auth/me publishes the context a browser bootstraps from
- every response that resolved a record scope echoes it back
- regenerate the API contract
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 18:21:40 +02:00
1 parent 59359738c8
commit 45fec0fc1b
15 files changed
+1631 -98

No files matched your search

+208 -83
View File
@@ -12750,7 +12750,11 @@ paths:
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."
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:
@@ -12764,6 +12768,15 @@ paths:
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
responses:
@@ -12781,6 +12794,17 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"401": *a1
"403": *a20
"409":
description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is
`sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records —
so do not leave the record and do not clear the session. Re-read GET /api/auth/me with a network-only
request, adopt the `recordSession` it returns, and retry. The body deliberately carries no epoch or scope: a
client may adopt a context from GET /api/auth/me and POST /api/account/switch only, and a refusal is not a
third source of truth."
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422":
description: Validation failed.
content:
@@ -36687,10 +36711,15 @@ components:
type: string
accountAccess:
$ref: "#/components/schemas/AccountAccess"
recordSession:
anyOf:
- $ref: "#/components/schemas/RecordSessionState"
- type: "null"
required:
- id
- username
- accountAccess
- recordSession
additionalProperties: {}
description: "The signed-in account: its identity, its preferences, and (since v1.36.0) what account sharing lets it do.
Additional properties are the preference fields this spec does not yet enumerate. Under an active switch this
@@ -36702,26 +36731,127 @@ components:
accounts:
type: array
items:
$ref: "#/components/schemas/AccountAccessEntry"
description: Every record this caller may open, newest grant first.
type: object
properties:
accountId:
type: string
minLength: 1
username:
type: string
minLength: 1
displayName:
anyOf:
- type: string
- type: "null"
access:
type: string
enum:
- read
- write
level:
type: string
enum:
- read
- write
- manage
recordKind:
type: string
enum:
- shared
- managed
sections:
anyOf:
- type: array
items:
type: string
enum:
- measurements
- medications
- labs
- profile
- illness
- mind
- cycle
- documents
- type: "null"
canWrite:
type: boolean
required:
- accountId
- username
- displayName
- access
- level
- recordKind
- sections
- canWrite
additionalProperties: false
active:
anyOf:
- $ref: "#/components/schemas/AccountAccessEntry"
- type: object
properties:
accountId:
type: string
minLength: 1
username:
type: string
minLength: 1
displayName:
anyOf:
- type: string
- type: "null"
access:
type: string
enum:
- read
- write
level:
type: string
enum:
- read
- write
- manage
recordKind:
type: string
enum:
- shared
- managed
sections:
anyOf:
- type: array
items:
type: string
enum:
- measurements
- medications
- labs
- profile
- illness
- mind
- cycle
- documents
- type: "null"
canWrite:
type: boolean
required:
- accountId
- username
- displayName
- access
- level
- recordKind
- sections
- canWrite
additionalProperties: false
- type: "null"
description: "The record this session is inside right now, resolved to a full entry rather than an id to look up — so a
banner can name the person without joining two fields. Null when the caller is in their own record, and
ALWAYS null on the Bearer transport: a token carries its selector per request and this endpoint refuses
one."
recordKind:
type: string
enum:
- self
- shared
- managed
description: The server-resolved kind of the record currently in view. `self` applies when `active` is null.
canSwitch:
type: boolean
description: Whether there is anywhere to switch to. Bind it directly.
required:
- accounts
- active
@@ -36733,86 +36863,30 @@ components:
be a second program deciding one person's access to a health record, and the two answer differently the first
time an expiry or a revocation appears. Always present — an account nobody has shared with gets an empty list,
not a missing field."
AccountAccessEntry:
RecordSessionState:
type: object
properties:
accountId:
type: string
description: The account whose record it is — the value POST /api/account/switch takes, and the value the
X-HealthLog-Account header carries.
username:
type: string
displayName:
epoch:
type: integer
minimum: -9007199254740991
maximum: 9007199254740991
description: How many times this browser session's record selector has moved. Monotonic, server-issued, and scoped to
this session. Send it back as the `X-HealthLog-Record-Epoch` header on every subsequent same-origin request,
and as `expectedEpoch` when switching.
scope:
anyOf:
- type: string
- type: "null"
access:
type: string
enum:
- read
- write
description: The legacy grant level that shipped clients decode. MANAGE serializes as `write` here; new code reads
canonical `level`.
level:
type: string
enum:
- read
- write
- manage
description: The grant's resolved level. `manage` additionally admits changing and removing entries on the delegable
surfaces; it never reaches the identity surfaces (settings, connections, tokens, consent, grant management),
which stay refused on an invited record at every level. Render it; never derive it from `canWrite`.
recordKind:
type: string
enum:
- self
- shared
- managed
description: Server-resolved presentation metadata for the record. It is never accepted as an authorization input.
sections:
anyOf:
- type: array
items:
$ref: "#/components/schemas/ShareSection"
- type: "null"
description: The sections of that record this caller may open, or null for the entire record. Null is first-class and is
what every pre-v1.37.0 grant carries; a MANAGE grant always carries it too. A non-null array names the open
sections and NOTHING else answers — a client that switches into a scoped record without honouring this gets
the standard 403 on every out-of-scope route, fail-closed and byte-identical to every other sharing refusal,
which is safe but reads as a wall of errors. An empty array means the grant opens nothing. Ordered by the
consent screen's reading order.
canWrite:
type: boolean
description: "Whether this caller may ADD to that record: true for an accepted WRITE or MANAGE grant, false for a READ
one. On its own it never means edit or delete — a WRITE grant adds and does nothing more. What MANAGE
additionally admits is said by `level`. Resolved server-side. Render it; never derive it."
description: The account this session is pointed at, or null for its own record. Send it back as the
`X-HealthLog-Record-Scope` header, with `self` standing for null.
required:
- accountId
- username
- displayName
- access
- level
- recordKind
- sections
- canWrite
- epoch
- scope
additionalProperties: false
description: "One account this caller may act on. No avatar is published: the avatar bytes are owner-scoped, so a URL
here would resolve to a refusal. Clients paint their initials fallback."
ShareSection:
type: string
enum:
- measurements
- medications
- labs
- profile
- illness
- mind
- cycle
- documents
description: "One section of a health record, as a grant may name it. A closed vocabulary of eight, derived by
clustering the delegable read surfaces rather than from the module list — the two answer different questions and
never share a spelling. The whole-record case is `null` on the field that carries these, never a member of this
enum: a scope that named the whole record would be a narrowing that means its own absence."
description: Where the record-session fence says this browser session is. Cookie transport only — null on Bearer, which
carries its selector per request and accumulates no session state to be stale about. A client adopts this and
then asserts it on every same-origin request, so a request formed under a context that has since moved is
refused rather than served against the wrong record.
RecordActivityEnvelope:
type: object
properties:
@@ -37000,6 +37074,21 @@ components:
accepted, when and by whom it ended — so ended grants stay in the list with `state: REVOKED` rather than
disappearing. `revokedBy` distinguishes the owner withdrawing access from the delegate handing it back.
`lastUsedAt` mirrors API-token semantics: the last time the grant was actually exercised."
ShareSection:
type: string
enum:
- measurements
- medications
- labs
- profile
- illness
- mind
- cycle
- documents
description: "One section of a health record, as a grant may name it. A closed vocabulary of eight, derived by
clustering the delegable read surfaces rather than from the module list — the two answer different questions and
never share a spelling. The whole-record case is `null` on the field that carries these, never a member of this
enum: a scope that named the whole record would be a narrowing that means its own absence."
AccountGrantState:
type: string
enum:
@@ -37172,6 +37261,13 @@ components:
AccountSwitchState:
type: object
properties:
recordSession:
anyOf:
- $ref: "#/components/schemas/RecordSessionState"
- type: "null"
description: "The context this session is now in, when the request supplied `expectedEpoch`. Null when it did not: an
unconditional switch does not read the new epoch back, and a number computed client-side would be wrong the
moment the target equalled the current selector. Adopt this value; do not derive it."
actingAs:
anyOf:
- type: object
@@ -37198,6 +37294,7 @@ components:
additionalProperties: false
- type: "null"
required:
- recordSession
- actingAs
additionalProperties: false
description: The account this session is now acting on, or null when it is back in its own. The stamp is a selector and
@@ -37638,6 +37735,34 @@ components:
sync engine, device or notification registration, or any auth/refresh call: those are about the person holding
the token, not the record being read, and the account payload refuses the header outright for exactly that
reason."
RecordEpoch:
name: X-HealthLog-Record-Epoch
in: header
required: false
schema:
type: string
maxLength: 15
description: "The record-session epoch this client believes it is on, taken from `recordSession.epoch` on GET
/api/auth/me or POST /api/account/switch. Cookie transport only; a Bearer client neither sends nor receives it.
Send the literal `bootstrap` before the account payload has resolved. A browser session that has never been
pointed at another record is served without either header, which is what keeps a tab open across the deploy
working. Once a session HAS entered a shared record it stays fenced permanently, in both directions: a
mismatching epoch is refused with 409 `sharing.session.changed` (reconcile through GET /api/auth/me and retry),
and an absent header is refused with 403 `sharing.access.denied` (a client predating the fence, which recovers
by leaving the record and reloading)."
RecordScope:
name: X-HealthLog-Record-Scope
in: header
required: false
schema:
type: string
maxLength: 64
description: The record this client believes it is inside, taken from `recordSession.scope`, with the literal `self`
standing for one's own record. Sent alongside `X-HealthLog-Record-Epoch` on every same-origin request; one
without the other is treated as a stale assertion, not as an absent one. Cookie transport only. Both headers are
echoed back on any response that actually resolved a record scope, and a client discards a response whose echo
contradicts the context it is now on — a response carrying no echo resolved no record scope and is served
normally.
securitySchemes:
bearerAuth:
type: http