Merge branch 'main' into fix/v1396-small-fixes

This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-30 23:27:25 +02:00
commit 85d11bc364
39 files changed
+2981 -155

No files matched your search

+7 -1
View File
@@ -66,7 +66,13 @@ before treating a warning as a regression.
`health:write`), audience-bound to `/mcp` and refused on REST writes;
- `POST /api/tokens/measurements` mints `measurements:write`, accepted by
`POST /api/measurements` and `POST /api/measurements/batch` only, on the
holder's own record only.
holder's own record only;
- `POST /api/tokens/workouts` mints `workouts:write`, accepted by
`POST /api/workouts/batch` only, on the holder's own record only; the
workouts it writes carry `source: EXTERNAL`;
- `POST /api/tokens/documents` mints `documents:write`, accepted by
`POST /api/documents/inbound` and `GET /api/documents/inbound/source`
only.
`POST /api/tokens` is **not** among them — that generic mint was removed in
v1.30.17. The path still lists (`GET`) and revokes (`DELETE /api/tokens/{id}`)
+175 -18
View File
@@ -2134,10 +2134,10 @@ paths:
description: >-
Stamps the calling browser session as recently re-proved, so for the next five minutes it may run the actions
that ask for a fresh proof: GET /api/export/full-backup, POST /api/export/encrypted, POST /api/share-links, POST
/api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/documents, and the admin backup download,
upload and restore, the admin data wipe and the admin password reset. Those answer 401 `auth.reproof.required`
with `meta.methods` (the proofs this account can give) when the session signed in or re-proved more than five
minutes ago.
/api/mcp/tokens, POST /api/tokens/measurements, POST /api/tokens/workouts, POST /api/tokens/documents, and the
admin backup download, upload and restore, the admin data wipe and the admin password reset. Those answer 401
`auth.reproof.required` with `meta.methods` (the proofs this account can give) when the session signed in or
re-proved more than five minutes ago.
The body takes the same shapes as POST /api/auth/step-up. On an account with a second factor only `totp`,
@@ -2977,10 +2977,10 @@ paths:
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, the sibling `POST /api/tokens/measurements` for third-party measurement
ingest, and `POST /api/tokens/documents` for document upload from another system. 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.
ingest, `POST /api/tokens/workouts` for workout ingest from a bridge, and `POST /api/tokens/documents` for
document upload from another system. 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.
@@ -3088,6 +3088,93 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
/api/tokens/workouts:
post:
tags:
- Auth
summary: Mint a workout-ingest token
description: >-
Mints a Bearer scoped to exactly `workouts:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN, once;
it is stored as an HMAC and no path re-reveals it.
**What it can do.** `POST /api/workouts/batch`, on its owner's own record, and nothing else. It is the door a
workout bridge pushes through: a watch vendor's sync relayed by a small service, or a script replaying an
exported history. Workouts it writes carry `source: EXTERNAL`, resolved from the credential rather than taken
from the body, and a batch in which any entry names a source is refused 422
(`workout.batch.source_not_permitted`) rather than relabelled. `EXTERNAL` is on no default source ladder, so
when the phone also recorded the same session the phone's copy is the one the workout list shows.
**What it cannot do.** List or read a workout, reach the measurement or document ingest routes, act on a shared
record, or mint another token. It is a separate scope from `measurements:write` on purpose: a token already
handed to a scale does not start accepting workouts, and a `measurements:write` token is refused 403 on the
workout batch. Every other route names no scope and refuses it 403.
Minting requires a COOKIE SESSION with a fresh proof, as for the measurement mint, for the reason it gives: a
day-lived native access token must not be able to leave behind a year-lived credential. Gated by the operator's
instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live workout
tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}`.
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateWorkoutTokenRequest"
responses:
"201":
description: Token minted. The `token` field is the only copy of the secret.
content:
application/json:
schema:
$ref: "#/components/schemas/CreateWorkoutTokenEnvelope"
"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. Nothing was minted. A Bearer caller is refused 401
before the switch is read.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"409":
description: The account already holds 10 live workout tokens (`meta.errorCode` = `tokens.workouts.ceiling_reached`).
Nothing was minted. Revoke one at `DELETE /api/tokens/{id}`; only live tokens carrying `workouts:write`
count.
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.
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/documents:
post:
tags:
@@ -4579,16 +4666,26 @@ paths:
tags:
- Measurements
summary: Typed workout batch ingest (v1.4.25 W16b)
description: 'Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up
to 100 workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a
1:1 `WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h).
description: >-
Server-side ingest endpoint for HKWorkout records (iOS) and Withings activity rows (server-to-server). Up to 100
workouts per call; nested route geometry (GeoJSON LineString) is capped at 20 000 points and stored in a 1:1
`WorkoutRoute` row keyed by `workoutId`. Idempotent via the `Idempotency-Key` header (replay window 24h).
Per-entry status (`inserted | duplicate | enriched | skipped`) lets the iOS sync cursor checkpoint accurately.
An entry matching a stored workout is first-write-wins for the workout row; when it carries a `samples` array
and the stored workout has no heart-rate series, the series is attached and the entry reports `enriched`
(repeatable: a workout that already has a series is a no-op). An entry whose `samples` array fails validation is
refused on its own with `reason: "invalid_samples"` — the rest of the batch still lands. The request body
ceiling is 5 MB enforced at the HTTP layer — clients above the ceiling receive a 413 with
`workout.batch.payload_too_large`.'
`workout.batch.payload_too_large`.
**Narrow token.** Also reachable with a `workouts:write` Bearer (minted at `POST /api/tokens/workouts`), the
supported way to push workouts from a bridge that is not the phone. This route is the scope's only one. Such a
credential writes only on its owner's own record; its workouts are attributed `EXTERNAL`, resolved from the
credential, and it may not name a source itself, so a batch in which any entry carries one, `MANUAL` and
`APPLE_HEALTH` alike, is refused 422 (`workout.batch.source_not_permitted`) and nothing is written. `EXTERNAL`
gives those writes their own `(userId, source, externalId)` namespace, so a bridge's ids can never collide with
the phone's. Every other narrow token, `measurements:write` included, is refused 403.
parameters:
- *a6
requestBody:
@@ -4618,7 +4715,14 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"422":
description: A narrow `workouts:write` credential sent a batch in which an entry names a source, any source
(`workout.batch.source_not_permitted`). Nothing was written; resending the batch unchanged is refused the
same way. Omit `source` and the server attributes the rows `EXTERNAL`.
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorEnvelope"
"429": *a5
/api/devices:
post:
@@ -22919,6 +23023,21 @@ components:
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."
CreateWorkoutTokenRequest:
type: object
properties:
name:
type: string
minLength: 1
maxLength: 100
expiresInDays:
type: integer
minimum: 1
maximum: 365
required:
- name
description: "Mint a workout-ingest Bearer. No `scope` field: the endpoint mints one shape. `name` is what the token is
listed under; name it after the bridge it is pasted into. `expiresInDays` defaults to 365."
CreateDocumentTokenRequest:
type: object
properties:
@@ -31636,10 +31755,11 @@ components:
sharing.revoke.not_found, sharing.session.changed, sharing.switch.invalid,
sharing.switch.wrong_transport; source-priority — source-priority.body.invalid_json,
source-priority.body.invalid_shape; tokens — tokens.documents.ceiling_reached,
tokens.measurements.ceiling_reached; vaccination — vaccination.booster-invalid,
vaccination.booster-no-antigen, vaccination.booster-out-of-scope, vaccination.encounter-not-found,
vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached; vaccination —
vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
vaccination.encounter-not-found, vaccination.identity-required, vaccination.invalid,
vaccination.link.invalid, vaccination.not-found, vaccination.practitioner-not-found; workout —
workout.batch.invalid, workout.batch.payload_too_large, workout.batch.source_not_permitted,
workout.batch.too_large; (unprefixed) — about_me_conflict, ai_response_truncated, coach_prefs_conflict,
dashboard_layout_conflict, empty_file, health_score_config_conflict, insights_layout_conflict,
invalid_base_updated_at, json_carries_no_intake_time, json_not_an_array, medication_layout_conflict,
@@ -39650,6 +39770,42 @@ components:
- expiresAt
additionalProperties: false
description: The one response that carries a usable measurement-ingest Bearer value.
CreateWorkoutTokenEnvelope:
type: object
properties:
data:
$ref: "#/components/schemas/CreateWorkoutTokenResponse"
error:
type: "null"
meta:
type: object
properties:
requestId:
type: string
additionalProperties: false
required:
- data
- error
additionalProperties: false
CreateWorkoutTokenResponse:
type: object
properties:
token:
type: string
description: The raw `hlk_` Bearer, RETURNED EXACTLY ONCE. It is stored only as an HMAC-SHA256 hash and cannot be
retrieved later. Treat it as a secret.
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 workout-ingest Bearer value.
CreateDocumentTokenEnvelope:
type: object
properties:
@@ -40443,7 +40599,8 @@ components:
- EXTERNAL
description: Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path. v1.38.x
added EXTERNAL for rows pushed in under a narrow `measurements:write` Bearer — a scale, a watch bridge, a
home-automation rule. No client may name that value on a write; the server resolves it from the credential.
home-automation rule — and workouts pushed in under a narrow `workouts:write` Bearer carry it too. No client may
name that value on a write; the server resolves it from the credential.
SyncMeasurementTombstone:
type: object
properties: