From 9fc1413181d48d5fd38bb8c8c4c839291da3e82a Mon Sep 17 00:00:00 2001 From: Antiheld86 <4201585+Antiheld86@users.noreply.github.com> Date: Wed, 2 Sep 2026 10:46:07 +0000 Subject: [PATCH] docs(measurements): label EXTERNAL and update the guide and contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The measurements list already badges any row whose source is not MANUAL and already builds its source filter from the shared enum, so both pick the new value up on their own. What it needs is a name: `EXTERNAL` reads as a leaked enum constant, and it is the only new value a user is expected to operate, since it is an option in the filter and a badge on every bridged row. Both label catalogues gain an entry. They are keyed by the enum, so a new source without a label is a type error and the coverage guard fails the suite — which is exactly what happened here, and is the reason this carries a settings label too even though EXTERNAL sits on no priority ladder and so never renders there. The settings card's scope note and the Home Assistant guide both claimed readings arrive "as if you had entered them by hand", which is no longer true. Both now say the rows are marked as coming from an external device and are still yours to correct, and the guide spells out that the payload must not name a source at all. The changelog entries go under Unreleased as Changed: v1.38.1 shipped the token, so these describe a change to something people already have. They say what that means in practice — rows written before this keep the old label and cannot be sorted out afterwards, a batch re-push can duplicate because the dedup key now carries the source, and an automation sending an explicit `source` field has to drop it. --- CHANGELOG.md | 32 +++++ docs/api/openapi.yaml | 157 +++++++++++++++++++++++-- docs/integrations/home-assistant.md | 31 +++-- messages/de.json | 6 +- messages/en.json | 6 +- messages/es.json | 6 +- messages/fr.json | 6 +- messages/it.json | 6 +- messages/ko.json | 6 +- messages/pl.json | 6 +- src/lib/i18n/source-labels.ts | 5 + src/lib/openapi/routes/auth.ts | 2 +- src/lib/openapi/routes/measurements.ts | 8 +- src/lib/openapi/routes/shared.ts | 2 +- 14 files changed, 238 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62190effa..27ed48420 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1532,6 +1532,38 @@ scanning the two largest tables, and several settings now do what they say. The shared AI key reaches the provider its address names, and the image carries no flagged dependency versions. +### Changed + +- **Readings pushed in with a measurement token are now marked as coming + from an external device**, instead of being recorded as if you had typed + them in by hand. They show a badge in the measurements list and can be + picked out with the source filter there, so months later you can still + tell which readings came off the scale and which are your own. You can + still correct one if a sensor sends something wrong — readings owned by a + connected provider stay read-only, because the number is the provider's, + but these come off your own hardware. + + Readings pushed in before this change stay marked as manually entered. + There is no way to sort them out after the fact: nothing recorded which + ones arrived through a token, and that is the gap this closes. If you want + a clean split, the readings from here on will have it. + + One thing to check if you push through the batch endpoint: a re-push of + entries you had already sent may land a second copy, because a reading's + identity now includes where it came from. Delete the older pair if you see + duplicates. + +- **A push must not claim a source of its own.** Saying where a reading came + from is the server's job now, so a payload that names one — Apple Health, + manual, anything — is refused with a 422 rather than quietly relabelled, + and you find out on the first call instead of discovering later that your + rows say something you did not ask for. **If your automation sends a + `source` field, drop it**; the example in the Home Assistant guide never + did, so a setup copied from there needs no change. Apple Health is the one + that would have done real damage: that label is half of how the iOS app + recognises its own rows, and a bridge borrowing it corrupts the phone's + sync instead of merely mislabelling a reading. + ### Fixed - **The shared AI key reaches the provider its address names.** If you diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 009d20da6..5a4b1cc7b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2823,11 +2823,15 @@ paths: 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 + **What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record. Rows + it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, so a reading + pushed by a bridge stays distinguishable from one typed in by hand and can be picked out with the source filter. + A body naming ANY source is refused 422 rather than relabelled — honouring it would defeat the label, overriding + it silently would hand the caller rows it did not ask for. `APPLE_HEALTH` is the one that would do real damage: + it 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. + does not move the native client's sync checkpoint. The rows stay editable by their owner, unlike a connected + provider's — the hardware behind the token is the user's own. **What it cannot do.** Everything else, including the measurement reads on the same paths, the edit and delete @@ -3705,6 +3709,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL - in: query name: valueMin schema: @@ -3748,9 +3753,11 @@ paths: 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. + actually shared — and it may not attribute a source at all: rows it writes carry `source: EXTERNAL`, resolved + from the credential, so a bridged reading stays distinguishable from a hand-typed one. A body naming ANY source, + `MANUAL` included, 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 — though the rows themselves stay editable by their + owner, unlike a connected provider's. parameters: - *a6 requestBody: @@ -3781,8 +3788,8 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "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. + named a source — any 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: @@ -3914,8 +3921,10 @@ paths: 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 + writes only on its owner's own record; its entries are attributed `EXTERNAL` and it may not name a source + itself, so an entry carrying one — `MANUAL` and `APPLE_HEALTH` alike — is refused 422 + (`measurement.batch.source_not_permitted`). `EXTERNAL` also gives those writes their own `(userId, type, source, + externalId)` namespace, so a bridge's ids can never collide with the phone's. 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: @@ -3937,7 +3946,7 @@ paths: "409": *a7 "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 + (`measurement.batch.source_not_permitted`) carried an entry naming a source — any source — under a narrow `measurements:write` credential. Nothing was written in any of the three cases. content: application/json: @@ -6820,6 +6829,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL activeEnergy: maxItems: 8 type: array @@ -6840,6 +6850,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL walkingRunningDistance: maxItems: 8 type: array @@ -6860,6 +6871,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL flightsClimbed: maxItems: 8 type: array @@ -6880,6 +6892,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleep: maxItems: 8 type: array @@ -6900,6 +6913,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL weight: maxItems: 8 type: array @@ -6920,6 +6934,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bloodPressure: maxItems: 8 type: array @@ -6940,6 +6955,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL pulse: maxItems: 8 type: array @@ -6960,6 +6976,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyFat: maxItems: 8 type: array @@ -6980,6 +6997,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyTemperature: maxItems: 8 type: array @@ -7000,6 +7018,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL spo2: maxItems: 8 type: array @@ -7020,6 +7039,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL hrv: maxItems: 8 type: array @@ -7040,6 +7060,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL restingHeartRate: maxItems: 8 type: array @@ -7060,6 +7081,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL vo2Max: maxItems: 8 type: array @@ -7080,6 +7102,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL skinTemperature: maxItems: 8 type: array @@ -7100,6 +7123,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL respiratoryRate: maxItems: 8 type: array @@ -7120,6 +7144,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL recovery: maxItems: 8 type: array @@ -7140,6 +7165,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL stress: maxItems: 8 type: array @@ -7160,6 +7186,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleepDebt: maxItems: 8 type: array @@ -7180,6 +7207,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL metricPriority: type: object properties: @@ -7203,6 +7231,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL activeEnergy: maxItems: 8 type: array @@ -7223,6 +7252,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL walkingRunningDistance: maxItems: 8 type: array @@ -7243,6 +7273,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL flightsClimbed: maxItems: 8 type: array @@ -7263,6 +7294,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleep: maxItems: 8 type: array @@ -7283,6 +7315,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL weight: maxItems: 8 type: array @@ -7303,6 +7336,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bloodPressure: maxItems: 8 type: array @@ -7323,6 +7357,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL pulse: maxItems: 8 type: array @@ -7343,6 +7378,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyFat: maxItems: 8 type: array @@ -7363,6 +7399,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyTemperature: maxItems: 8 type: array @@ -7383,6 +7420,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL spo2: maxItems: 8 type: array @@ -7403,6 +7441,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL hrv: maxItems: 8 type: array @@ -7423,6 +7462,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL restingHeartRate: maxItems: 8 type: array @@ -7443,6 +7483,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL vo2Max: maxItems: 8 type: array @@ -7463,6 +7504,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL skinTemperature: maxItems: 8 type: array @@ -7483,6 +7525,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL respiratoryRate: maxItems: 8 type: array @@ -7503,6 +7546,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL recovery: maxItems: 8 type: array @@ -7523,6 +7567,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL stress: maxItems: 8 type: array @@ -7543,6 +7588,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleepDebt: maxItems: 8 type: array @@ -7563,6 +7609,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL deviceTypePriority: type: object properties: @@ -32676,6 +32723,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL notes: anyOf: - type: string @@ -33954,6 +34002,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL externalId: anyOf: - type: string @@ -34638,6 +34687,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL externalId: anyOf: - type: string @@ -36558,6 +36608,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL externalId: anyOf: - type: string @@ -36739,6 +36790,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL externalRecordingId: type: string minLength: 1 @@ -38832,7 +38884,10 @@ components: - MCP - GOOGLE_HEALTH - STRAVA - description: Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path. + - 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. SyncMeasurementTombstone: type: object properties: @@ -45386,6 +45441,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL activeEnergy: maxItems: 8 type: array @@ -45406,6 +45462,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL walkingRunningDistance: maxItems: 8 type: array @@ -45426,6 +45483,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL flightsClimbed: maxItems: 8 type: array @@ -45446,6 +45504,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleep: maxItems: 8 type: array @@ -45466,6 +45525,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL weight: maxItems: 8 type: array @@ -45486,6 +45546,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bloodPressure: maxItems: 8 type: array @@ -45506,6 +45567,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL pulse: maxItems: 8 type: array @@ -45526,6 +45588,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyFat: maxItems: 8 type: array @@ -45546,6 +45609,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyTemperature: maxItems: 8 type: array @@ -45566,6 +45630,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL spo2: maxItems: 8 type: array @@ -45586,6 +45651,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL hrv: maxItems: 8 type: array @@ -45606,6 +45672,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL restingHeartRate: maxItems: 8 type: array @@ -45626,6 +45693,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL vo2Max: maxItems: 8 type: array @@ -45646,6 +45714,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL skinTemperature: maxItems: 8 type: array @@ -45666,6 +45735,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL respiratoryRate: maxItems: 8 type: array @@ -45686,6 +45756,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL recovery: maxItems: 8 type: array @@ -45706,6 +45777,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL stress: maxItems: 8 type: array @@ -45726,6 +45798,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleepDebt: maxItems: 8 type: array @@ -45746,6 +45819,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL metricPriority: type: object properties: @@ -45769,6 +45843,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL activeEnergy: maxItems: 8 type: array @@ -45789,6 +45864,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL walkingRunningDistance: maxItems: 8 type: array @@ -45809,6 +45885,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL flightsClimbed: maxItems: 8 type: array @@ -45829,6 +45906,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleep: maxItems: 8 type: array @@ -45849,6 +45927,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL weight: maxItems: 8 type: array @@ -45869,6 +45948,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bloodPressure: maxItems: 8 type: array @@ -45889,6 +45969,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL pulse: maxItems: 8 type: array @@ -45909,6 +45990,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyFat: maxItems: 8 type: array @@ -45929,6 +46011,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyTemperature: maxItems: 8 type: array @@ -45949,6 +46032,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL spo2: maxItems: 8 type: array @@ -45969,6 +46053,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL hrv: maxItems: 8 type: array @@ -45989,6 +46074,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL restingHeartRate: maxItems: 8 type: array @@ -46009,6 +46095,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL vo2Max: maxItems: 8 type: array @@ -46029,6 +46116,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL skinTemperature: maxItems: 8 type: array @@ -46049,6 +46137,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL respiratoryRate: maxItems: 8 type: array @@ -46069,6 +46158,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL recovery: maxItems: 8 type: array @@ -46089,6 +46179,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL stress: maxItems: 8 type: array @@ -46109,6 +46200,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleepDebt: maxItems: 8 type: array @@ -46129,6 +46221,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL additionalProperties: false deviceTypePriority: type: object @@ -46198,6 +46291,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL activeEnergy: maxItems: 8 type: array @@ -46218,6 +46312,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL walkingRunningDistance: maxItems: 8 type: array @@ -46238,6 +46333,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL flightsClimbed: maxItems: 8 type: array @@ -46258,6 +46354,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleep: maxItems: 8 type: array @@ -46278,6 +46375,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL weight: maxItems: 8 type: array @@ -46298,6 +46396,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bloodPressure: maxItems: 8 type: array @@ -46318,6 +46417,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL pulse: maxItems: 8 type: array @@ -46338,6 +46438,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyFat: maxItems: 8 type: array @@ -46358,6 +46459,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyTemperature: maxItems: 8 type: array @@ -46378,6 +46480,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL spo2: maxItems: 8 type: array @@ -46398,6 +46501,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL hrv: maxItems: 8 type: array @@ -46418,6 +46522,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL restingHeartRate: maxItems: 8 type: array @@ -46438,6 +46543,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL vo2Max: maxItems: 8 type: array @@ -46458,6 +46564,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL skinTemperature: maxItems: 8 type: array @@ -46478,6 +46585,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL respiratoryRate: maxItems: 8 type: array @@ -46498,6 +46606,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL recovery: maxItems: 8 type: array @@ -46518,6 +46627,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL stress: maxItems: 8 type: array @@ -46538,6 +46648,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleepDebt: maxItems: 8 type: array @@ -46558,6 +46669,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL metricPriority: type: object properties: @@ -46581,6 +46693,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL activeEnergy: maxItems: 8 type: array @@ -46601,6 +46714,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL walkingRunningDistance: maxItems: 8 type: array @@ -46621,6 +46735,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL flightsClimbed: maxItems: 8 type: array @@ -46641,6 +46756,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleep: maxItems: 8 type: array @@ -46661,6 +46777,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL weight: maxItems: 8 type: array @@ -46681,6 +46798,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bloodPressure: maxItems: 8 type: array @@ -46701,6 +46819,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL pulse: maxItems: 8 type: array @@ -46721,6 +46840,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyFat: maxItems: 8 type: array @@ -46741,6 +46861,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyTemperature: maxItems: 8 type: array @@ -46761,6 +46882,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL spo2: maxItems: 8 type: array @@ -46781,6 +46903,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL hrv: maxItems: 8 type: array @@ -46801,6 +46924,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL restingHeartRate: maxItems: 8 type: array @@ -46821,6 +46945,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL vo2Max: maxItems: 8 type: array @@ -46841,6 +46966,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL skinTemperature: maxItems: 8 type: array @@ -46861,6 +46987,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL respiratoryRate: maxItems: 8 type: array @@ -46881,6 +47008,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL recovery: maxItems: 8 type: array @@ -46901,6 +47029,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL stress: maxItems: 8 type: array @@ -46921,6 +47050,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleepDebt: maxItems: 8 type: array @@ -46941,6 +47071,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL additionalProperties: false deviceTypePriority: type: object diff --git a/docs/integrations/home-assistant.md b/docs/integrations/home-assistant.md index b1b92e337..c521a8c74 100644 --- a/docs/integrations/home-assistant.md +++ b/docs/integrations/home-assistant.md @@ -47,21 +47,36 @@ It also cannot be pointed at somebody else's record. If an account has shared their record with you, this credential still will not write to it; use the web app for that. -## Readings arrive as manually entered +## Readings are marked as coming from an external device -Rows written with this token carry `source: MANUAL`, the same label a reading -you type in by hand gets. Two consequences worth knowing: +Rows written with this token carry `source: EXTERNAL`. They show a badge in the +measurements list and can be picked out with the source filter there, so months +later you can still tell which readings came off the scale and which you typed +in yourself. Three consequences worth knowing: - You can correct them in the UI. Readings owned by a connected provider are - read-only; these are not. -- They do not claim to be from Apple Health. An entry that explicitly names - `APPLE_HEALTH` is refused with a 422 rather than quietly relabelled — that - source is half the deduplication key the iOS app writes into, and a bridge - borrowing it corrupts the phone's sync rather than merely mislabelling a row. + read-only, because the number is the provider's; these are your own hardware's + and stay editable. +- **The payload must not name a source at all.** The server decides it from the + token. A body carrying `"source": "MANUAL"`, `"source": "APPLE_HEALTH"` or + anything else is refused with a 422 rather than quietly relabelled — you get + told on the first call instead of discovering later that your rows say + something you did not ask for. The example below sends no `source`, which is + all you need. +- They do not claim to be from Apple Health. That source is half the + deduplication key the iOS app writes into, and a bridge borrowing it corrupts + the phone's sync rather than merely mislabelling a row. `EXTERNAL` gives these + writes their own namespace instead, so a bridge's ids can never collide with + the phone's. For the same reason, a push through this token does not move the native sync checkpoint. Your phone's own sync window is unaffected by your scale. +If you were already pushing readings with an older build, those rows stay +`MANUAL` — there is no way to tell them apart retroactively, which is the whole +reason for this label. If a re-push of the same entries produces duplicates, +delete the older pair. + ## Home Assistant example Add a `rest_command` to `configuration.yaml`. Keep the token in diff --git a/messages/de.json b/messages/de.json index 3af8f13ca..f29fee828 100644 --- a/messages/de.json +++ b/messages/de.json @@ -745,6 +745,7 @@ "sourceMcp": "MCP", "sourceGoogleHealth": "Google Health", "sourceStrava": "Strava", + "sourceExternal": "Externes Gerät", "bulkDeleteSuccess": "{count} Messwerte gelöscht", "bulkDeleteSuccessOne": "Messwert gelöscht", "bulkDeleteError": "Fehler beim Löschen der ausgewählten Messungen", @@ -5072,7 +5073,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "Externes Gerät" }, "deviceLabels": { "watch": "Uhr", @@ -6110,7 +6112,7 @@ "title": "Messwert-Tokens", "description": "Erzeuge ein Token, damit eine Waage, eine Uhr oder deine Hausautomatisierung Werte eintragen kann.", "detail": "Das Token wird nur einmal angezeigt.", - "scopeNote": "Es kann ausschließlich neue Werte in deine eigene Akte eintragen — deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Werte erscheinen wie von Hand eingetragen und lassen sich jederzeit korrigieren.", + "scopeNote": "Es kann ausschließlich neue Werte in deine eigene Akte eintragen — deine Daten nicht lesen, nichts ändern oder löschen und kein weiteres Token erstellen. Werte werden als von einem externen Gerät gekennzeichnet, damit du sie später erkennst, und lassen sich jederzeit korrigieren.", "createFailed": "Das Token konnte nicht erstellt werden. Bitte versuche es erneut." }, "tokenMintMovedDescription": "Tokens werden dort ausgestellt, wo sie gelten: oben für Messwerte, oder im API-Endpoint eines Medikaments für genau dieses Medikament.", diff --git a/messages/en.json b/messages/en.json index 9cd82bd03..dcc370e0a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -745,6 +745,7 @@ "sourceMcp": "MCP", "sourceGoogleHealth": "Google Health", "sourceStrava": "Strava", + "sourceExternal": "External device", "bulkDeleteSuccess": "{count} measurements deleted", "bulkDeleteSuccessOne": "Measurement deleted", "bulkDeleteError": "Error deleting the selected measurements", @@ -5072,7 +5073,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "External device" }, "deviceLabels": { "watch": "Watch", @@ -6110,7 +6112,7 @@ "title": "Measurement tokens", "description": "Mint a token so a scale, a watch bridge or your home automation can add readings.", "detail": "The token is shown once.", - "scopeNote": "It can only add new readings to your own record — it cannot read your data, change or delete anything, or create another token. Readings arrive as if you had entered them by hand, so you can correct them yourself.", + "scopeNote": "It can only add new readings to your own record — it cannot read your data, change or delete anything, or create another token. Readings are marked as coming from an external device, so you can tell them apart later, and you can still correct them yourself.", "createFailed": "The token could not be created. Please try again." }, "tokenMintMovedDescription": "Tokens are issued where they apply: above for measurements, or in a medication's own API endpoint for that one medication.", diff --git a/messages/es.json b/messages/es.json index b69c2d44f..a86ae25fa 100644 --- a/messages/es.json +++ b/messages/es.json @@ -745,6 +745,7 @@ "sourceMcp": "MCP", "sourceGoogleHealth": "Google Health", "sourceStrava": "Strava", + "sourceExternal": "Dispositivo externo", "bulkDeleteSuccess": "{count} mediciones eliminadas", "bulkDeleteSuccessOne": "Medición eliminada", "bulkDeleteError": "Error al eliminar las mediciones seleccionadas", @@ -5072,7 +5073,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "Dispositivo externo" }, "deviceLabels": { "watch": "Reloj", @@ -6110,7 +6112,7 @@ "title": "Tokens de mediciones", "description": "Crea un token para que una báscula, un reloj o tu domótica añadan mediciones.", "detail": "El token se muestra una sola vez.", - "scopeNote": "Solo puede añadir mediciones nuevas a tu propio historial: no puede leer tus datos, ni modificar o borrar nada, ni crear otro token. Las mediciones llegan como si las hubieras introducido a mano, así que puedes corregirlas.", + "scopeNote": "Solo puede añadir mediciones nuevas a tu propio historial: no puede leer tus datos, ni modificar o borrar nada, ni crear otro token. Las mediciones se marcan como procedentes de un dispositivo externo, así las distingues más tarde, y puedes corregirlas igualmente.", "createFailed": "No se pudo crear el token. Inténtalo de nuevo." }, "tokenMintMovedDescription": "Los tokens se emiten donde se aplican: arriba para las mediciones, o en el endpoint de API de un medicamento para ese medicamento concreto.", diff --git a/messages/fr.json b/messages/fr.json index e832e094e..65b3fe092 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -745,6 +745,7 @@ "sourceMcp": "MCP", "sourceGoogleHealth": "Google Health", "sourceStrava": "Strava", + "sourceExternal": "Appareil externe", "bulkDeleteSuccess": "{count} mesures supprimées", "bulkDeleteSuccessOne": "Mesure supprimée", "bulkDeleteError": "Erreur lors de la suppression des mesures sélectionnées", @@ -5072,7 +5073,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "Appareil externe" }, "deviceLabels": { "watch": "Montre", @@ -6110,7 +6112,7 @@ "title": "Jetons de mesures", "description": "Créez un jeton pour qu'une balance, une montre ou votre domotique ajoutent des mesures.", "detail": "Le jeton n'est affiché qu'une fois.", - "scopeNote": "Il peut uniquement ajouter de nouvelles mesures à votre propre dossier : il ne peut pas lire vos données, ni modifier ou supprimer quoi que ce soit, ni créer un autre jeton. Les mesures arrivent comme si vous les aviez saisies à la main, vous pouvez donc les corriger.", + "scopeNote": "Il peut uniquement ajouter de nouvelles mesures à votre propre dossier : il ne peut pas lire vos données, ni modifier ou supprimer quoi que ce soit, ni créer un autre jeton. Les mesures sont signalées comme provenant d'un appareil externe, ce qui vous permet de les reconnaître plus tard, et vous pouvez toujours les corriger.", "createFailed": "Le jeton n'a pas pu être créé. Veuillez réessayer." }, "tokenMintMovedDescription": "Les jetons sont émis là où ils s'appliquent : ci-dessus pour les mesures, ou dans le point de terminaison API d'un médicament pour ce seul médicament.", diff --git a/messages/it.json b/messages/it.json index 237f44644..6df3cbfde 100644 --- a/messages/it.json +++ b/messages/it.json @@ -745,6 +745,7 @@ "sourceMcp": "MCP", "sourceGoogleHealth": "Google Health", "sourceStrava": "Strava", + "sourceExternal": "Dispositivo esterno", "bulkDeleteSuccess": "{count} misurazioni eliminate", "bulkDeleteSuccessOne": "Misurazione eliminata", "bulkDeleteError": "Errore durante l'eliminazione delle misurazioni selezionate", @@ -5072,7 +5073,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "Dispositivo esterno" }, "deviceLabels": { "watch": "Orologio", @@ -6110,7 +6112,7 @@ "title": "Token per misurazioni", "description": "Crea un token perché una bilancia, un orologio o la tua domotica aggiungano misurazioni.", "detail": "Il token viene mostrato una sola volta.", - "scopeNote": "Può soltanto aggiungere nuove misurazioni alla tua cartella: non può leggere i tuoi dati, modificare o eliminare nulla, né creare un altro token. Le misurazioni arrivano come se le avessi inserite a mano, quindi puoi correggerle.", + "scopeNote": "Può soltanto aggiungere nuove misurazioni alla tua cartella: non può leggere i tuoi dati, modificare o eliminare nulla, né creare un altro token. Le misurazioni vengono contrassegnate come provenienti da un dispositivo esterno, così le riconosci in seguito, e puoi comunque correggerle.", "createFailed": "Non è stato possibile creare il token. Riprova." }, "tokenMintMovedDescription": "I token vengono emessi dove si applicano: qui sopra per le misurazioni, oppure nell'endpoint API di un farmaco per quel singolo farmaco.", diff --git a/messages/ko.json b/messages/ko.json index 91b12f477..66e7f1a16 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -745,6 +745,7 @@ "sourceMcp": "MCP", "sourceGoogleHealth": "Google Health", "sourceStrava": "Strava", + "sourceExternal": "외부 기기", "bulkDeleteSuccess": "{count}개 측정을 삭제했어요", "bulkDeleteSuccessOne": "측정을 삭제했어요", "bulkDeleteError": "선택한 측정을 삭제하지 못했어요", @@ -5072,7 +5073,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "외부 기기" }, "deviceLabels": { "watch": "워치", @@ -6110,7 +6112,7 @@ "title": "측정 토큰", "description": "체중계나 시계, 홈 오토메이션이 측정값을 기록할 수 있도록 토큰을 만들어요.", "detail": "토큰은 한 번만 표시돼요.", - "scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 직접 입력한 것처럼 들어오니 언제든 고칠 수 있어요.", + "scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 외부 기기에서 온 것으로 표시되니 나중에 구분할 수 있고, 언제든 고칠 수 있어요.", "createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요." }, "tokenMintMovedDescription": "토큰은 쓰이는 곳에서 발급돼요. 측정값은 위에서, 특정 약은 그 약의 API 엔드포인트에서 만들 수 있어요.", diff --git a/messages/pl.json b/messages/pl.json index ad7f00f51..c22df00b5 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -745,6 +745,7 @@ "sourceMcp": "MCP", "sourceGoogleHealth": "Google Health", "sourceStrava": "Strava", + "sourceExternal": "Urządzenie zewnętrzne", "bulkDeleteSuccess": "Usunięto pomiary: {count}", "bulkDeleteSuccessOne": "Usunięto pomiar", "bulkDeleteError": "Błąd podczas usuwania wybranych pomiarów", @@ -5072,7 +5073,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "Urządzenie zewnętrzne" }, "deviceLabels": { "watch": "Zegarek", @@ -6110,7 +6112,7 @@ "title": "Tokeny pomiarów", "description": "Utwórz token, aby waga, zegarek lub automatyka domowa mogły dodawać pomiary.", "detail": "Token pokazujemy tylko raz.", - "scopeNote": "Może wyłącznie dodawać nowe pomiary do Twojej dokumentacji — nie odczyta danych, niczego nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Pomiary trafiają tak, jakbyś wpisał je ręcznie, więc możesz je poprawić.", + "scopeNote": "Może wyłącznie dodawać nowe pomiary do Twojej dokumentacji — nie odczyta danych, niczego nie zmieni ani nie usunie i nie utworzy kolejnego tokenu. Pomiary są oznaczane jako pochodzące z urządzenia zewnętrznego, dzięki czemu później je rozpoznasz, i nadal możesz je poprawić.", "createFailed": "Nie udało się utworzyć tokenu. Spróbuj ponownie." }, "tokenMintMovedDescription": "Tokeny wydajemy tam, gdzie działają: powyżej dla pomiarów albo w punkcie końcowym API danego leku dla tego jednego leku.", diff --git a/src/lib/i18n/source-labels.ts b/src/lib/i18n/source-labels.ts index 9416d5cc1..a202dee11 100644 --- a/src/lib/i18n/source-labels.ts +++ b/src/lib/i18n/source-labels.ts @@ -41,6 +41,10 @@ export const MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS: Record< MCP: "settings.sections.sources.sourceLabels.MCP", GOOGLE_HEALTH: "settings.sections.sources.sourceLabels.GOOGLE_HEALTH", STRAVA: "settings.sections.sources.sourceLabels.STRAVA", + // v1.38.x — readings pushed in under a `measurements:write` token. + // Never offered in the ladder (it is on no default priority list), but + // the map is keyed by the enum, so the entry is not optional. + EXTERNAL: "settings.sections.sources.sourceLabels.EXTERNAL", }; /** @@ -66,6 +70,7 @@ export const MEASUREMENT_SOURCE_LIST_LABEL_KEYS: Record< MCP: "measurements.sourceMcp", GOOGLE_HEALTH: "measurements.sourceGoogleHealth", STRAVA: "measurements.sourceStrava", + EXTERNAL: "measurements.sourceExternal", }; /** diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index 26a80fa82..a5c80ad19 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -1559,7 +1559,7 @@ export const authPaths: NonNullable = { 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.\n\n" + - "**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.\n\n" + + "**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record. Rows it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, so a reading pushed by a bridge stays distinguishable from one typed in by hand and can be picked out with the source filter. A body naming ANY source is refused 422 rather than relabelled — honouring it would defeat the label, overriding it silently would hand the caller rows it did not ask for. `APPLE_HEALTH` is the one that would do real damage: it 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. The rows stay editable by their owner, unlike a connected provider's — the hardware behind the token is the user's own.\n\n" + "**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.\n\n" + "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, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.", requestBody: { diff --git a/src/lib/openapi/routes/measurements.ts b/src/lib/openapi/routes/measurements.ts index ffd097081..e6dc81dff 100644 --- a/src/lib/openapi/routes/measurements.ts +++ b/src/lib/openapi/routes/measurements.ts @@ -850,7 +850,7 @@ export const measurementPaths: NonNullable = { 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 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.\n\n" + - "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.", + "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 it may not attribute a source at all: rows it writes carry `source: EXTERNAL`, resolved from the credential, so a bridged reading stays distinguishable from a hand-typed one. A body naming ANY source, `MANUAL` included, 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 — though the rows themselves stay editable by their owner, unlike a connected provider's.", parameters: [idempotencyKeyParameter], requestBody: { required: true, @@ -889,7 +889,7 @@ export const measurementPaths: NonNullable = { ...recordWriteRateLimitResponse, "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.", + "Validation failed, or (`measurement.create.source_not_permitted`) a narrow `measurements:write` credential named a source — any 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: errorEnvelope } }, }, }, @@ -991,7 +991,7 @@ export const measurementPaths: NonNullable = { 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.\n\n" + - "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.", + "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 `EXTERNAL` and it may not name a source itself, so an entry carrying one — `MANUAL` and `APPLE_HEALTH` alike — is refused 422 (`measurement.batch.source_not_permitted`). `EXTERNAL` also gives those writes their own `(userId, type, source, externalId)` namespace, so a bridge's ids can never collide with the phone's. 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.", requestBody: { required: true, content: { "application/json": { schema: batchPayloadSchema } }, @@ -1011,7 +1011,7 @@ export const measurementPaths: NonNullable = { ...stdResponses, "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.", + "The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation, or (`measurement.batch.source_not_permitted`) carried an entry naming a source — any source — under a narrow `measurements:write` credential. Nothing was written in any of the three cases.", content: { "application/json": { schema: errorEnvelope } }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 82b608d1c..6df23e8f4 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -144,7 +144,7 @@ export const measurementTypeEnum = measurementTypeEnumBase.meta({ export const measurementSourceEnum = measurementSourceEnumBase.meta({ id: "MeasurementSource", description: - "Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path.", + "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.", }); export const loginPasswordSchema = loginPasswordSchemaBase.meta({