mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
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:
1 parent
59359738c8
commit
45fec0fc1b
15 files changed
+1631
-98
No files matched your search
+208
-83
@@ -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
|
||||
|
||||
Reference in new issue
Block a user