diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index c62664f25..cb4298b0b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2731,11 +2731,13 @@ paths: Three things the name understates. The list is not limited to tokens a person minted from the settings surface: a native login mints a wildcard access token as an `ApiToken` row, so a signed-in phone shows up here too. There - is no mint on this path any more — the generic POST that issued `["medication:ingest"]` was removed, because - that scope reached no ingest route while the pre-fail-closed default let it reach everything else; the working - credential comes from the per-medication API-endpoint toggle. And unlike the revoke, this read is NOT gated on - the operator's instance-wide API switch: that switch governs the surfaces a token is for, not a token's ability - to authenticate, so tokens stay live while it is off and their owner has to be able to see them. + is no GENERIC mint any more — the POST at this exact path issued `["medication:ingest"]` and was removed, + because that scope reached no ingest route while the pre-fail-closed default let it reach everything else. The + credentials that work are minted where they are scoped: the per-medication API-endpoint toggle, `POST + /api/mcp/tokens` for a connector, and the sibling `POST /api/tokens/measurements` for third-party measurement + ingest. And unlike the revoke, this read is NOT gated on the operator's instance-wide API switch: that switch + governs the surfaces a token is for, not a token's ability to authenticate, so tokens stay live while it is off + and their owner has to be able to see them. responses: "200": description: The caller's tokens, newest first. @@ -2746,6 +2748,86 @@ paths: "401": *a1 "422": *a4 "429": *a5 + /api/tokens/measurements: + post: + tags: + - Auth + summary: Mint a measurement-ingest token + description: >- + Mints a Bearer scoped to exactly `measurements:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — + the only place it ever exists; it is stored as an HMAC and no path re-reveals it. + + + **What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record, + attributing `source: MANUAL`. An entry naming `APPLE_HEALTH` is refused 422 rather than relabelled: that source + is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what + decides the Apple Health card may claim a sync happened. For the same reason a write through this credential + does not move the native client's sync checkpoint. + + + **What it cannot do.** Everything else, including the measurement reads on the same paths, the edit and delete + legs, and the export — a scope grants what it names and nothing adjacent. It cannot be pointed at a shared + record: a request carrying the account selector is refused 403 before any grant is read, whatever grants its + holder actually has. And it cannot mint another token, this endpoint included. + + + Minting requires a COOKIE SESSION. No Bearer credential reaches this endpoint at any scope, wildcard included — + not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day + and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a + credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; + 10 mints per user per minute. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` + like any other. + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMeasurementTokenRequest" + responses: + "201": + description: Token minted. The `token` field is the only copy of the secret. + content: + application/json: + schema: + $ref: "#/components/schemas/CreateMeasurementTokenEnvelope" + "400": + description: Body is not parseable JSON. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "401": *a1 + "403": + description: The operator has switched the API off instance-wide (`AppSettings.apiGlobal`). Nothing was minted. A Bearer + caller does not reach this arm — it is refused 401 before the switch is read. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "413": + description: Body exceeds 16 KiB. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "415": + description: Content-Type is not `application/json`. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "422": + description: Validation failed. The envelope carries every offending issue, not just the first. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" + "429": + description: More than 10 mints from this account in a minute. Nothing was minted. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/tokens/{id}: delete: tags: @@ -3564,9 +3646,18 @@ paths: tags: - Measurements summary: Create one measurement (or a small array) - description: Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a + description: >- + Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams. + + + Also reachable with a narrow `measurements:write` Bearer, which is the supported way to push readings from a + scale, a watch bridge or a home-automation rule. That credential is confined to its owner's own record — a + request carrying the account selector is refused 403 before any sharing grant is read, however the record is + actually shared — and to `MANUAL` attribution, so an explicit `APPLE_HEALTH` source is refused 422 + (`measurement.create.source_not_permitted`). The GET on this same path, and the PUT and DELETE beside it, name + no scope and so refuse it. parameters: - *a6 requestBody: @@ -3595,7 +3686,14 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a4 + "422": + description: Validation failed, or (`measurement.create.source_not_permitted`) a narrow `measurements:write` credential + named `APPLE_HEALTH` as the source. Refused rather than relabelled, so the caller can fix the payload + instead of discovering rows it did not ask for. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/measurements/{id}: get: @@ -3715,10 +3813,18 @@ paths: tags: - Measurements summary: Apple Health batch ingest - description: Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). - Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional - `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break - Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients. + description: >- + Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry + status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per + entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; + null/absent stays backward-compatible with v1.4.23 clients. + + + Also reachable with a narrow `measurements:write` Bearer, for a bridge that is not the phone. Such a credential + writes only on its owner's own record; its entries are attributed `MANUAL` whatever they omit, an entry naming + `APPLE_HEALTH` is refused 422 (`measurement.batch.source_not_permitted`), and the call does not advance the + native sync checkpoint — `lastSyncedAt` means the CLIENT delivered what it was holding, and a third-party push + is not that. parameters: - *a6 requestBody: @@ -3736,7 +3842,14 @@ paths: $ref: "#/components/schemas/BatchMeasurementsResponse" "401": *a1 "409": *a7 - "422": *a4 + "422": + description: The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation, or + (`measurement.batch.source_not_permitted`) carried an entry naming `APPLE_HEALTH` under a narrow + `measurements:write` credential. Nothing was written in any of the three cases. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "429": *a5 /api/measurements/by-external-ids: delete: @@ -20732,6 +20845,23 @@ components: required: - refreshToken description: Exchange a one-time-use refresh token for a fresh access + refresh pair. + CreateMeasurementTokenRequest: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 100 + expiresInDays: + type: integer + minimum: 1 + maximum: 365 + required: + - name + description: "Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the + permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it + after the thing you are pasting it into, because that is what you will be reading when you decide whether to + revoke it. `expiresInDays` defaults to 365." RegisterRequest: type: object properties: @@ -36378,6 +36508,43 @@ components: - createdAt - revoked additionalProperties: false + CreateMeasurementTokenEnvelope: + type: object + properties: + data: + $ref: "#/components/schemas/CreateMeasurementTokenResponse" + error: + type: "null" + meta: + type: object + properties: + requestId: + type: string + additionalProperties: false + required: + - data + - error + additionalProperties: false + CreateMeasurementTokenResponse: + type: object + properties: + token: + type: string + description: The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash, so there is no way + to retrieve it later; a client that does not capture it here has to mint a replacement. Treat it as a secret + in logs, crash reports and analytics. + name: + type: string + expiresAt: + 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)))$ + required: + - token + - name + - expiresAt + additionalProperties: false + description: The one response that carries a usable measurement-ingest Bearer value. ApiTokenRevokeEnvelope: type: object properties: