diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index a2de22e9a..ef6298874 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -2819,11 +2819,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 @@ -3701,6 +3705,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL - in: query name: valueMin schema: @@ -3744,9 +3749,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: @@ -3777,8 +3784,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: @@ -3910,8 +3917,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: @@ -3933,7 +3942,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: @@ -6783,6 +6792,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL activeEnergy: maxItems: 8 type: array @@ -6803,6 +6813,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL walkingRunningDistance: maxItems: 8 type: array @@ -6823,6 +6834,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL flightsClimbed: maxItems: 8 type: array @@ -6843,6 +6855,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleep: maxItems: 8 type: array @@ -6863,6 +6876,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL weight: maxItems: 8 type: array @@ -6883,6 +6897,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bloodPressure: maxItems: 8 type: array @@ -6903,6 +6918,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL pulse: maxItems: 8 type: array @@ -6923,6 +6939,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyFat: maxItems: 8 type: array @@ -6943,6 +6960,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyTemperature: maxItems: 8 type: array @@ -6963,6 +6981,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL spo2: maxItems: 8 type: array @@ -6983,6 +7002,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL hrv: maxItems: 8 type: array @@ -7003,6 +7023,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL restingHeartRate: maxItems: 8 type: array @@ -7023,6 +7044,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL vo2Max: maxItems: 8 type: array @@ -7043,6 +7065,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL skinTemperature: maxItems: 8 type: array @@ -7063,6 +7086,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL respiratoryRate: maxItems: 8 type: array @@ -7083,6 +7107,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL recovery: maxItems: 8 type: array @@ -7103,6 +7128,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL stress: maxItems: 8 type: array @@ -7123,6 +7149,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleepDebt: maxItems: 8 type: array @@ -7143,6 +7170,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL metricPriority: type: object properties: @@ -7166,6 +7194,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL activeEnergy: maxItems: 8 type: array @@ -7186,6 +7215,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL walkingRunningDistance: maxItems: 8 type: array @@ -7206,6 +7236,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL flightsClimbed: maxItems: 8 type: array @@ -7226,6 +7257,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleep: maxItems: 8 type: array @@ -7246,6 +7278,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL weight: maxItems: 8 type: array @@ -7266,6 +7299,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bloodPressure: maxItems: 8 type: array @@ -7286,6 +7320,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL pulse: maxItems: 8 type: array @@ -7306,6 +7341,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyFat: maxItems: 8 type: array @@ -7326,6 +7362,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyTemperature: maxItems: 8 type: array @@ -7346,6 +7383,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL spo2: maxItems: 8 type: array @@ -7366,6 +7404,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL hrv: maxItems: 8 type: array @@ -7386,6 +7425,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL restingHeartRate: maxItems: 8 type: array @@ -7406,6 +7446,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL vo2Max: maxItems: 8 type: array @@ -7426,6 +7467,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL skinTemperature: maxItems: 8 type: array @@ -7446,6 +7488,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL respiratoryRate: maxItems: 8 type: array @@ -7466,6 +7509,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL recovery: maxItems: 8 type: array @@ -7486,6 +7530,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL stress: maxItems: 8 type: array @@ -7506,6 +7551,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleepDebt: maxItems: 8 type: array @@ -7526,6 +7572,7 @@ paths: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL deviceTypePriority: type: object properties: @@ -32430,6 +32477,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL notes: anyOf: - type: string @@ -33708,6 +33756,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL externalId: anyOf: - type: string @@ -34392,6 +34441,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL externalId: anyOf: - type: string @@ -36312,6 +36362,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL externalId: anyOf: - type: string @@ -36493,6 +36544,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL externalRecordingId: type: string minLength: 1 @@ -38586,7 +38638,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: @@ -45051,6 +45106,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL activeEnergy: maxItems: 8 type: array @@ -45071,6 +45127,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL walkingRunningDistance: maxItems: 8 type: array @@ -45091,6 +45148,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL flightsClimbed: maxItems: 8 type: array @@ -45111,6 +45169,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleep: maxItems: 8 type: array @@ -45131,6 +45190,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL weight: maxItems: 8 type: array @@ -45151,6 +45211,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bloodPressure: maxItems: 8 type: array @@ -45171,6 +45232,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL pulse: maxItems: 8 type: array @@ -45191,6 +45253,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyFat: maxItems: 8 type: array @@ -45211,6 +45274,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyTemperature: maxItems: 8 type: array @@ -45231,6 +45295,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL spo2: maxItems: 8 type: array @@ -45251,6 +45316,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL hrv: maxItems: 8 type: array @@ -45271,6 +45337,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL restingHeartRate: maxItems: 8 type: array @@ -45291,6 +45358,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL vo2Max: maxItems: 8 type: array @@ -45311,6 +45379,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL skinTemperature: maxItems: 8 type: array @@ -45331,6 +45400,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL respiratoryRate: maxItems: 8 type: array @@ -45351,6 +45421,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL recovery: maxItems: 8 type: array @@ -45371,6 +45442,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL stress: maxItems: 8 type: array @@ -45391,6 +45463,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleepDebt: maxItems: 8 type: array @@ -45411,6 +45484,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL metricPriority: type: object properties: @@ -45434,6 +45508,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL activeEnergy: maxItems: 8 type: array @@ -45454,6 +45529,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL walkingRunningDistance: maxItems: 8 type: array @@ -45474,6 +45550,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL flightsClimbed: maxItems: 8 type: array @@ -45494,6 +45571,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleep: maxItems: 8 type: array @@ -45514,6 +45592,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL weight: maxItems: 8 type: array @@ -45534,6 +45613,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bloodPressure: maxItems: 8 type: array @@ -45554,6 +45634,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL pulse: maxItems: 8 type: array @@ -45574,6 +45655,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyFat: maxItems: 8 type: array @@ -45594,6 +45676,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyTemperature: maxItems: 8 type: array @@ -45614,6 +45697,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL spo2: maxItems: 8 type: array @@ -45634,6 +45718,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL hrv: maxItems: 8 type: array @@ -45654,6 +45739,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL restingHeartRate: maxItems: 8 type: array @@ -45674,6 +45760,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL vo2Max: maxItems: 8 type: array @@ -45694,6 +45781,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL skinTemperature: maxItems: 8 type: array @@ -45714,6 +45802,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL respiratoryRate: maxItems: 8 type: array @@ -45734,6 +45823,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL recovery: maxItems: 8 type: array @@ -45754,6 +45844,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL stress: maxItems: 8 type: array @@ -45774,6 +45865,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleepDebt: maxItems: 8 type: array @@ -45794,6 +45886,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL additionalProperties: false deviceTypePriority: type: object @@ -45863,6 +45956,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL activeEnergy: maxItems: 8 type: array @@ -45883,6 +45977,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL walkingRunningDistance: maxItems: 8 type: array @@ -45903,6 +45998,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL flightsClimbed: maxItems: 8 type: array @@ -45923,6 +46019,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleep: maxItems: 8 type: array @@ -45943,6 +46040,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL weight: maxItems: 8 type: array @@ -45963,6 +46061,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bloodPressure: maxItems: 8 type: array @@ -45983,6 +46082,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL pulse: maxItems: 8 type: array @@ -46003,6 +46103,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyFat: maxItems: 8 type: array @@ -46023,6 +46124,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyTemperature: maxItems: 8 type: array @@ -46043,6 +46145,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL spo2: maxItems: 8 type: array @@ -46063,6 +46166,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL hrv: maxItems: 8 type: array @@ -46083,6 +46187,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL restingHeartRate: maxItems: 8 type: array @@ -46103,6 +46208,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL vo2Max: maxItems: 8 type: array @@ -46123,6 +46229,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL skinTemperature: maxItems: 8 type: array @@ -46143,6 +46250,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL respiratoryRate: maxItems: 8 type: array @@ -46163,6 +46271,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL recovery: maxItems: 8 type: array @@ -46183,6 +46292,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL stress: maxItems: 8 type: array @@ -46203,6 +46313,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleepDebt: maxItems: 8 type: array @@ -46223,6 +46334,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL metricPriority: type: object properties: @@ -46246,6 +46358,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL activeEnergy: maxItems: 8 type: array @@ -46266,6 +46379,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL walkingRunningDistance: maxItems: 8 type: array @@ -46286,6 +46400,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL flightsClimbed: maxItems: 8 type: array @@ -46306,6 +46421,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleep: maxItems: 8 type: array @@ -46326,6 +46442,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL weight: maxItems: 8 type: array @@ -46346,6 +46463,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bloodPressure: maxItems: 8 type: array @@ -46366,6 +46484,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL pulse: maxItems: 8 type: array @@ -46386,6 +46505,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyFat: maxItems: 8 type: array @@ -46406,6 +46526,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL bodyTemperature: maxItems: 8 type: array @@ -46426,6 +46547,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL spo2: maxItems: 8 type: array @@ -46446,6 +46568,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL hrv: maxItems: 8 type: array @@ -46466,6 +46589,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL restingHeartRate: maxItems: 8 type: array @@ -46486,6 +46610,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL vo2Max: maxItems: 8 type: array @@ -46506,6 +46631,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL skinTemperature: maxItems: 8 type: array @@ -46526,6 +46652,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL respiratoryRate: maxItems: 8 type: array @@ -46546,6 +46673,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL recovery: maxItems: 8 type: array @@ -46566,6 +46694,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL stress: maxItems: 8 type: array @@ -46586,6 +46715,7 @@ components: - MCP - GOOGLE_HEALTH - STRAVA + - EXTERNAL sleepDebt: maxItems: 8 type: array @@ -46606,6 +46736,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 b6ef44809..3abd69ccb 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", @@ -5057,7 +5058,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "Externes Gerät" }, "deviceLabels": { "watch": "Uhr", @@ -6070,7 +6072,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 ee80a5ff9..fb2ae5d15 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", @@ -5057,7 +5058,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "External device" }, "deviceLabels": { "watch": "Watch", @@ -6070,7 +6072,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 42dd44b5e..9dfa7e0cf 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", @@ -5057,7 +5058,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "Dispositivo externo" }, "deviceLabels": { "watch": "Reloj", @@ -6070,7 +6072,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 894ef16da..63048f8fe 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", @@ -5057,7 +5058,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "Appareil externe" }, "deviceLabels": { "watch": "Montre", @@ -6070,7 +6072,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 f713b1e6a..a3e51a7af 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", @@ -5057,7 +5058,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "Dispositivo esterno" }, "deviceLabels": { "watch": "Orologio", @@ -6070,7 +6072,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 d33b4b5b1..e55476b5e 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": "선택한 측정을 삭제하지 못했어요", @@ -5057,7 +5058,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "외부 기기" }, "deviceLabels": { "watch": "워치", @@ -6070,7 +6072,7 @@ "title": "측정 토큰", "description": "체중계나 시계, 홈 오토메이션이 측정값을 기록할 수 있도록 토큰을 만들어요.", "detail": "토큰은 한 번만 표시돼요.", - "scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 직접 입력한 것처럼 들어오니 언제든 고칠 수 있어요.", + "scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 외부 기기에서 온 것으로 표시되니 나중에 구분할 수 있고, 언제든 고칠 수 있어요.", "createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요." }, "tokenMintMovedDescription": "토큰은 쓰이는 곳에서 발급돼요. 측정값은 위에서, 특정 약은 그 약의 API 엔드포인트에서 만들 수 있어요.", diff --git a/messages/pl.json b/messages/pl.json index e3b2ab22f..55f0f5509 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", @@ -5057,7 +5058,8 @@ "TELEGRAM": "Telegram", "MCP": "MCP", "GOOGLE_HEALTH": "Google Health", - "STRAVA": "Strava" + "STRAVA": "Strava", + "EXTERNAL": "Urządzenie zewnętrzne" }, "deviceLabels": { "watch": "Zegarek", @@ -6070,7 +6072,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/prisma/migrations/0342_measurement_source_external/migration.sql b/prisma/migrations/0342_measurement_source_external/migration.sql new file mode 100644 index 000000000..0a8601d7b --- /dev/null +++ b/prisma/migrations/0342_measurement_source_external/migration.sql @@ -0,0 +1,27 @@ +-- Ingest-token write provenance. +-- +-- The `measurements:write` Bearer scope mints a token a home-automation +-- bridge pastes into a rule; until now the rows it pushed were attributed +-- `MANUAL`, the same label a hand-typed reading gets, so afterwards nobody +-- could tell which readings came off the scale. Rows written under that +-- scope carry their own `MeasurementSource` value instead, so they are +-- distinguishable from MANUAL / TELEGRAM / MCP provenance and get an +-- isolated idempotency namespace `(userId, type, EXTERNAL, externalId)`. +-- +-- Purely-additive enum extension; no row touched. `ADD VALUE IF NOT EXISTS` +-- makes a rerun safe. The new value is NOT used elsewhere in this migration, +-- so it is safe to extend the enum in the same step (Postgres only forbids +-- USING a freshly-added value in the same transaction). Mirrors the 0189 +-- extension that added TELEGRAM and the 0207 one that added MCP. The enum is +-- `@@map`-ed to the snake_case type name `measurement_source`. +-- +-- No backfill, deliberately, and none is possible: `measurements` carries no +-- actor or token column and the audit trail never records the credential, so +-- a bridge-written MANUAL row is byte-identical to a hand-typed one. That +-- indistinguishability is the bug being fixed; any heuristic would relabel +-- real hand entries as machine ones. Existing rows stay MANUAL — still +-- editable, and correctly not badged. +-- +-- Reversibility: Postgres cannot remove an enum value, so `EXTERNAL` stays +-- (inert with no rows). +ALTER TYPE "measurement_source" ADD VALUE IF NOT EXISTS 'EXTERNAL'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 09d54ef02..8ac170d1e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1738,6 +1738,21 @@ enum MeasurementSource { // mirrors this exact spelling so the `/api/sync/changes` delta feed decodes // the rows. Feeds Workout rows only — Strava exposes no daily metrics. STRAVA + // v1.38.x — measurements pushed in through a `measurements:write` Bearer + // token: a home-automation bridge, a smart scale's own uploader, any + // third-party sender the user pointed at their own record. Like TELEGRAM + // and MCP this is a user-driven write rather than a server-owned + // integration, but it does not enter through the cookie/Bearer client + // write path, so it carries its own source label — a hand-typed reading + // and a bridged one were otherwise indistinguishable after the fact. Its + // own value also gives those writes an isolated idempotency namespace + // `(userId, type, EXTERNAL, externalId)`. No client may NAME it: the batch + // + single-POST surfaces refuse any body that asserts a source under this + // scope, and the value is resolved server-side. Unlike the server-owned + // sources above the rows stay value-editable, since the hardware is the + // user's own. iOS DTO must mirror this exact spelling so the + // /api/sync/changes delta feed decodes the rows. + EXTERNAL @@map("measurement_source") } diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index e43e59801..d1a29a746 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -13,8 +13,8 @@ import { } from "@/lib/api-response"; import { updateMeasurementSchema, + USER_CORRECTABLE_MEASUREMENT_SOURCES, validateMeasurementRange, - WRITABLE_MEASUREMENT_SOURCES, } from "@/lib/validations/measurement"; import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; @@ -120,12 +120,22 @@ export const PUT = apiHandler( if (data.value !== undefined && data.value !== existing.value) { // Server-owned rows first: a value attributed to a connector / import / // computed engine is the provider's reading — editing the number would - // forge a source-attributed row the server never received. Mirrors the - // write-side classification (`WRITABLE_MEASUREMENT_SOURCES`): only - // MANUAL and APPLE_HEALTH rows are client-owned. Timestamp and note - // edits stay allowed — annotating a Withings reading is legitimate. + // forge a source-attributed row the server never received. Timestamp + // and note edits stay allowed — annotating a Withings reading is + // legitimate. + // + // v1.38.x — gated on `USER_CORRECTABLE_MEASUREMENT_SOURCES` rather than + // the write-side `WRITABLE_MEASUREMENT_SOURCES` the two once shared, + // because `EXTERNAL` separates the questions. A client may not NAME that + // source (it is resolved from the ingest credential, and the write + // allowlist is published as `ingest.writeAllowlist`), but the hardware + // behind the token is the user's own scale, so the rationale above — + // "the value is the provider's" — does not reach it. Locking those rows + // would also break the correctability the settings card and the Home + // Assistant guide promise. This is the only site that reads the wider + // set; re-merging the two constants silently value-locks bridge rows. if ( - !(WRITABLE_MEASUREMENT_SOURCES as readonly string[]).includes( + !(USER_CORRECTABLE_MEASUREMENT_SOURCES as readonly string[]).includes( existing.source, ) ) { diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 776e870b7..278e4959a 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -39,6 +39,7 @@ import { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler"; +import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source"; import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; import { annotate } from "@/lib/logging/context"; import { auditLog } from "@/lib/auth/audit"; @@ -283,25 +284,34 @@ async function postBatch(request: NextRequest): Promise { ); } - // A scoped credential may attribute MANUAL and nothing else. Refused loudly - // rather than rewritten quietly: silently relabelling a client's explicit - // assertion hands it rows it did not ask for and no way to notice, while a - // 422 is fixable on the first call. + // A scoped credential may attribute nothing at all — the source of its rows + // is `EXTERNAL`, resolved from the credential below. Refused loudly rather + // than rewritten quietly: silently relabelling a client's explicit assertion + // hands it rows it did not ask for and no way to notice, while a 422 is + // fixable on the first call. // - // `APPLE_HEALTH` is the label that matters, and forging it is not merely - // mislabelling. It is half the `(userId, type, source, externalId)` dedup key, - // so a bridge's rows would land in the phone's namespace; it is a mergeable - // source, so a forged row joins the cross-source merge and can suppress a - // genuine one; and it is what `healthKitSyncSucceeded` reads to decide the - // Apple Health card may claim the phone synced. - if (scoped && parsed.data.entries.some((e) => e.source === "APPLE_HEALTH")) { + // `APPLE_HEALTH` is still the label that matters most, and forging it is not + // merely mislabelling. It is half the `(userId, type, source, externalId)` + // dedup key, so a bridge's rows would land in the phone's namespace; it is a + // mergeable source, so a forged row joins the cross-source merge and can + // suppress a genuine one; and it is what `healthKitSyncSucceeded` reads to + // decide the Apple Health card may claim the phone synced. + // + // v1.38.x — `MANUAL` joins it in being refused. It was permitted only + // because it was the value the route forced anyway, so nothing was ever + // overridden; now that bridge rows carry their own provenance, honouring it + // would defeat the point and overriding it would be the quiet relabelling + // the paragraph above rules out. Status and `errorCode` are unchanged. + if (scoped && parsed.data.entries.some((e) => e.source !== undefined)) { annotate({ action: { name: "measurement.batch.ingest" }, meta: { outcome: "source_not_permitted" }, }); - return apiError("This credential may only attribute MANUAL readings", 422, { - errorCode: "measurement.batch.source_not_permitted", - }); + return apiError( + "This credential resolves the source itself; omit the field", + 422, + { errorCode: "measurement.batch.source_not_permitted" }, + ); } const { entries, syncTrigger } = parsed.data; @@ -446,12 +456,14 @@ async function postBatch(request: NextRequest): Promise { // v1.8.6 W6 — honour the per-entry source tag, defaulting to // `APPLE_HEALTH` when absent so legacy callers are unchanged. // - // A scoped credential defaults to `MANUAL` instead, and can hold no - // other value: the guard above already refused an explicit - // `APPLE_HEALTH`, so this is the only remaining arm for it. The default - // has to move with the credential rather than with the route, because - // the route serves both the phone and a third-party bridge. - source: scoped ? "MANUAL" : (entry.source ?? "APPLE_HEALTH"), + // v1.38.x — a scoped credential gets `EXTERNAL`, and can hold no other + // value: the guard above refused any body that named one, so nothing + // asserted is being discarded here. The value has to move with the + // credential rather than with the route, because the route serves both + // the phone and a third-party bridge. It also moves the dedup key to + // `(userId, type, EXTERNAL, externalId)`, which is the isolated + // namespace this source exists to give. + source: scoped ? EXTERNAL_SOURCE : (entry.source ?? "APPLE_HEALTH"), measuredAt: mapped.takenAt, externalId: entry.externalId, externalSourceVersion: entry.externalSourceVersion ?? null, diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index ca5e59685..b3fef3527 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -4,6 +4,7 @@ import { isScopedCredential, requireRecordAuth, } from "@/lib/api-handler"; +import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source"; import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; import { annotate } from "@/lib/logging/context"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; @@ -766,19 +767,29 @@ async function sleepListResponse( } /** - * Does this body attribute any reading to `APPLE_HEALTH`? + * Does this body attribute a source to any reading — any source at all? * - * Read off the raw body rather than the parsed result because the answer has to - * be the same for the object arm and the array arm, and those parse against - * different schemas further down. Anything that is not a recognisable - * measurement shape answers false and falls through to the validation error it - * has coming — this function decides attribution, not validity. + * Read off the RAW body rather than the parsed result, and that is load-bearing + * twice over. It has to give the same answer for the object arm and the array + * arm, which parse against different schemas further down; and + * `createMeasurementSchema.source` carries `.default("MANUAL")`, so after + * parsing "said MANUAL" and "said nothing" are the same value and the + * distinction this predicate exists to draw is already gone. + * + * v1.38.x — widened from "names APPLE_HEALTH" to "names anything". Under a + * scoped credential the row's source is now resolved by the server, so an + * explicit `MANUAL` is an assertion the route would have to override — which + * is the silent relabelling the refusal below refuses to do. + * + * Anything that is not a recognisable measurement shape answers false and + * falls through to the validation error it has coming: this function decides + * attribution, not validity. */ -function namesAppleHealthSource(body: unknown): boolean { +function namesAnySource(body: unknown): boolean { const names = (entry: unknown): boolean => typeof entry === "object" && entry !== null && - (entry as { source?: unknown }).source === "APPLE_HEALTH"; + (entry as { source?: unknown }).source !== undefined; return Array.isArray(body) ? body.some(names) : names(body); } @@ -818,16 +829,28 @@ async function postMeasurement(request: NextRequest) { if (jsonError) return jsonError; - // A scoped credential may attribute MANUAL and nothing else — the schema's - // own default, so only an explicit `APPLE_HEALTH` needs refusing. Checked - // before validation branches so the object and array arms share one answer, - // and refused rather than rewritten for the reason the batch route gives: - // relabelling a client's explicit assertion hands it rows it cannot see. - if (scoped && namesAppleHealthSource(body)) { + // A scoped credential may attribute nothing: the source of its rows is + // `EXTERNAL`, decided here from the credential rather than taken from the + // body. Checked before the validation branches so the object and array arms + // share one answer, and refused rather than rewritten for the reason the + // batch route gives: relabelling a client's explicit assertion hands it rows + // it did not ask for and no way to notice. + // + // v1.38.x — this used to refuse only an explicit `APPLE_HEALTH`, because the + // forced value was `MANUAL` and so a body naming `MANUAL` was never actually + // overridden. Now that bridge rows carry their own provenance, honouring + // such a body would defeat the point (a bridge that kept sending `MANUAL` + // would stay indistinguishable from a typed reading) and overriding it + // silently is what the paragraph above forbids. Status and `errorCode` are + // unchanged, so client error handling is untouched; the documented Home + // Assistant payload sends no `source` at all. + if (scoped && namesAnySource(body)) { annotate({ action: { name: "measurements.create.source-not-permitted" } }); - return apiError("This credential may only attribute MANUAL readings", 422, { - errorCode: "measurement.create.source_not_permitted", - }); + return apiError( + "This credential resolves the source itself; omit the field", + 422, + { errorCode: "measurement.create.source_not_permitted" }, + ); } // Batch mode (array of measurements, e.g. combined BP + Pulse) @@ -879,7 +902,16 @@ async function postMeasurement(request: NextRequest) { type: m.type as MeasurementType, value: m.value, unit: getUnitForType(m.type), - source: (m.source ?? "MANUAL") as MeasurementSource, + // v1.38.x — the array arm too, not just the single create + // below. This is the combined BP + Pulse path a bridge uses for + // a two-value reading; missing it here would leave exactly those + // rows attributed MANUAL and invisible to the badge and the + // source filter. `scoped` already implies `m.source` is + // undefined — the refusal above returned otherwise — so this is + // the resolution, not an override. + source: (scoped + ? EXTERNAL_SOURCE + : (m.source ?? "MANUAL")) as MeasurementSource, measuredAt: m.measuredAt, // v1.23 — encrypt the note at rest; the legacy plaintext column is // written null for new rows. @@ -1034,7 +1066,13 @@ async function postMeasurement(request: NextRequest) { type: type as MeasurementType, value, unit: getUnitForType(type), - source: (source ?? "MANUAL") as MeasurementSource, + // v1.38.x — server-resolved under a scoped credential. `source` is + // necessarily the schema default here (the refusal above returned on + // any body that named one), so nothing a caller asserted is being + // discarded. + source: (scoped + ? EXTERNAL_SOURCE + : (source ?? "MANUAL")) as MeasurementSource, measuredAt, // v1.23 — encrypt the note at rest; legacy plaintext column nulled. notes: null, diff --git a/src/lib/analytics/__tests__/source-priority.test.ts b/src/lib/analytics/__tests__/source-priority.test.ts index 400153096..f886505e2 100644 --- a/src/lib/analytics/__tests__/source-priority.test.ts +++ b/src/lib/analytics/__tests__/source-priority.test.ts @@ -515,3 +515,112 @@ describe("pickCanonicalSourceRows — v1.4.25 W8c two-axis device-type picker", expect(out.canonicalRows[0].source).toBe("WITHINGS"); }); }); + +/** + * v1.38.x — what the EXTERNAL source does to a contested day. + * + * `EXTERNAL` is on no priority ladder, deliberately: the `steps` and `spo2` + * defaults already sit at `metricSourceLadder`'s `.max(8)`, so a ninth entry + * would come back from `GET /api/auth/me/source-priority`, be PUT back whole + * by the settings page, and 422 — breaking ladder saves for everyone. + * + * That omission is not inert. It decides a real behaviour, and one that + * CHANGED when ingested rows stopped being labelled `MANUAL`: before, a typed + * reading and a bridged one were the same source and both survived a day; now + * `MANUAL` ranks, `EXTERNAL` does not, and the bridged row leaves the + * canonical set for that day. Seven call sites consume this picker (daily + * totals, the health score, the doctor report, correlations, mood crosstabs, + * PR detection), so the effect is user-visible and is written up in the + * changelog. + * + * Pinned here because it is emergent from a rank ladder rather than stated + * anywhere in code: put `EXTERNAL` on a ladder and the first case below goes + * red, which is the review anybody adding it deserves. + */ +describe("pickCanonicalSourceRows — the unranked EXTERNAL source", () => { + const day = "2026-09-02"; + + function weightRow( + source: "MANUAL" | "EXTERNAL", + value: number, + hour: string, + ) { + return { + measuredAt: new Date(`${day}T${hour}:00Z`), + source, + deviceType: null, + type: "WEIGHT" as const, + value, + }; + } + + it("keeps the typed reading and drops the bridged one on a contested day", () => { + // The behaviour change itself. `MANUAL` is on the weight ladder and + // `EXTERNAL` is not, so the day resolves to the reading the person + // entered — a hand correction still wins where one number is needed. + const out = pickCanonicalSourceRows( + [ + weightRow("EXTERNAL", 82.4, "06:30"), + weightRow("MANUAL", 81.9, "08:00"), + ], + "weight", + null, + isoDayKey, + ); + expect(out.canonicalRows).toHaveLength(1); + expect(out.canonicalRows[0].source).toBe("MANUAL"); + expect(out.canonicalRows[0].value).toBe(81.9); + expect(out.pickedByDay.get(day)).toBe("MANUAL"); + }); + + it("keeps a bridged-only day, since nothing on the ladder is present", () => { + // The `!picked` pass-through. Without this arm an account that ONLY ever + // pushes readings would go dark on every surface listed above, which + // would be a far worse regression than the one above is a change. + const out = pickCanonicalSourceRows( + [ + weightRow("EXTERNAL", 82.4, "06:30"), + weightRow("EXTERNAL", 82.1, "20:00"), + ], + "weight", + null, + isoDayKey, + ); + expect(out.canonicalRows).toHaveLength(2); + expect(out.canonicalRows.every((r) => r.source === "EXTERNAL")).toBe(true); + // No ladder source won, so the day names no pick. + expect(out.pickedByDay.has(day)).toBe(false); + }); + + it("kept both when the bridged row was still labelled MANUAL", () => { + // The pre-change shape, asserted so the diff between old and new + // behaviour lives in the suite rather than only in the changelog. Two + // `MANUAL` rows are indistinguishable to the picker and both survive — + // which for a cumulative metric is the double-count the new labelling + // removes. + const out = pickCanonicalSourceRows( + [weightRow("MANUAL", 82.4, "06:30"), weightRow("MANUAL", 81.9, "08:00")], + "weight", + null, + isoDayKey, + ); + expect(out.canonicalRows).toHaveLength(2); + }); + + it("lets a user who ranks EXTERNAL themselves have it win", () => { + // Not reachable from the settings UI today (the ladder cap keeps it off + // the page), but the picker resolves whatever ladder it is handed, and a + // stored ladder naming it must be honoured rather than ignored. + const out = pickCanonicalSourceRows( + [ + weightRow("EXTERNAL", 82.4, "06:30"), + weightRow("MANUAL", 81.9, "08:00"), + ], + "weight", + { metricPriority: { weight: ["EXTERNAL", "MANUAL"] } }, + isoDayKey, + ); + expect(out.canonicalRows).toHaveLength(1); + expect(out.canonicalRows[0].source).toBe("EXTERNAL"); + }); +}); 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/measurements/external-source.ts b/src/lib/measurements/external-source.ts new file mode 100644 index 000000000..95b6ccea9 --- /dev/null +++ b/src/lib/measurements/external-source.ts @@ -0,0 +1,24 @@ +/** + * The `MeasurementSource` value rows written through the ingest token carry. + * + * A leaf module with no imports, for the same reason `scopes.ts` next door is + * one: the two write routes, the edit gate, the Zod source enum and the + * integration suites all read this string, and none of them should inherit an + * import graph to get it. `MCP_SOURCE` (`@/lib/mcp/writes.ts`) is module-local + * because it has exactly one writer; this one does not, and two sibling + * declarations of the same literal is the drift a shared leaf exists to avoid. + * + * Not folded into `scopes.ts`. A provenance label living in a file named + * `scopes.ts` is one somebody later imports "because it was there", and the + * two answer different questions: one is what a credential may do, the other + * is what a row came from. + * + * No client may name it. It is deliberately absent from + * `WRITABLE_MEASUREMENT_SOURCES` and from the batch route's `batchSourceEnum`, + * so the only way a row acquires it is the server resolving it from the + * credential that carried the write — which is the whole point: a source a + * client could assert would prove nothing about where the reading came from. + */ + +/** Provenance for a measurement pushed in under `measurements:write`. */ +export const EXTERNAL_SOURCE = "EXTERNAL" as const; 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 b0a6149b1..254224142 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -126,7 +126,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({ diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 104043716..0fe13f33d 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source"; import { validateEntryInstant } from "./entry-instant"; export const measurementTypeEnum = z.enum([ @@ -222,6 +223,16 @@ export const measurementSourceEnum = z.enum([ // from `WRITABLE_MEASUREMENT_SOURCES` + the batch allowlist so a client can // never forge a STRAVA-attributed row. "STRAVA", + // v1.38.x — measurements pushed in under a `measurements:write` Bearer + // token (a home-automation bridge, a scale's own uploader). Server-resolved + // from the credential rather than named by the caller, so it is deliberately + // absent from `WRITABLE_MEASUREMENT_SOURCES` and the batch allowlist — a + // source a client could assert would prove nothing about where the reading + // came from. Part of this enum so the read/response shapes (and the iOS + // decoder) can decode the rows it surfaces. Unlike the server-owned sources + // above these rows stay value-editable; see + // `USER_CORRECTABLE_MEASUREMENT_SOURCES`. + "EXTERNAL", ]); /** @@ -251,6 +262,35 @@ export const writableMeasurementSourceEnum = z.enum( WRITABLE_MEASUREMENT_SOURCES, ); +/** + * v1.38.x — the subset of `MeasurementSource` whose VALUE the owner may still + * edit after the fact. A strictly different question from the one + * `WRITABLE_MEASUREMENT_SOURCES` answers, and the two are kept apart on + * purpose: + * + * - writable = "may a client ASSERT this source on a write?" + * - correctable = "is the number this row carries the user's own to fix?" + * + * `EXTERNAL` is no on the first and yes on the second. A client naming it + * would be forging provenance, so it stays out of the write allowlist — which + * `/api/meta/capabilities` publishes as `ingest.writeAllowlist`, so widening + * that constant would tell every client the source is theirs to claim. But the + * hardware behind an ingest token is the user's own scale, not a provider's + * reading: the 409 that protects a Withings row ("the value is the provider's, + * editing it would forge a row the server never received") has no force here, + * and locking these rows would contradict what the settings card and the + * Home Assistant guide both promise. + * + * Read at exactly one call site — the value-edit gate in + * `PUT /api/measurements/[id]`. Everything else keeps reading + * `WRITABLE_MEASUREMENT_SOURCES`; folding the two back together re-conflates + * the questions above. + */ +export const USER_CORRECTABLE_MEASUREMENT_SOURCES = [ + ...WRITABLE_MEASUREMENT_SOURCES, + EXTERNAL_SOURCE, +] as const; + const unitMap: Record = { WEIGHT: "kg", BLOOD_PRESSURE_SYS: "mmHg", diff --git a/tests/integration/bearer-scope-enforcement.test.ts b/tests/integration/bearer-scope-enforcement.test.ts index c4e2d32dc..d8e082401 100644 --- a/tests/integration/bearer-scope-enforcement.test.ts +++ b/tests/integration/bearer-scope-enforcement.test.ts @@ -504,7 +504,7 @@ describe("B8 — the measurement-ingest scope reaches its two routes and no othe } as never); } - it("admits the scope on POST /api/measurements, attributed MANUAL", async () => { + it("admits the scope on POST /api/measurements, attributed EXTERNAL", async () => { await armToken(["measurements:write"], "mwrite1"); const { POST } = await import("@/app/api/measurements/route"); const res = await POST(postMeasurement()); @@ -513,7 +513,41 @@ describe("B8 — the measurement-ingest scope reaches its two routes and no othe const row = await getPrismaClient().measurement.findFirstOrThrow({ where: { userId: USER_ID, type: "WEIGHT" }, }); - expect(row.source).toBe("MANUAL"); + // v1.38.x — the body names no source (see `measurementBody`), and the + // route resolves one from the credential rather than falling back on the + // schema default. This assertion is the whole feature: a reading pushed + // by a bridge is distinguishable afterwards from one typed by hand. + expect(row.source).toBe("EXTERNAL"); + }); + + it("refuses a body naming MANUAL, rather than overriding it", async () => { + // The source is server-resolved now, so an explicit assertion has only + // two possible fates: honoured (and the row is indistinguishable from a + // typed one, defeating the point) or overridden in silence (which hands + // the caller a row it did not ask for and no way to notice). The route + // takes neither and refuses. MANUAL specifically, because it used to be + // the permitted value — the widening from "names APPLE_HEALTH" to "names + // anything" is exactly this case. + await armToken(["measurements:write"], "mwrite1b"); + const { POST } = await import("@/app/api/measurements/route"); + const res = await POST( + new NextRequest("https://health.example/api/measurements", { + method: "POST", + headers: { + authorization: headerJar.get("authorization")!, + "content-type": "application/json", + }, + body: JSON.stringify({ + type: "WEIGHT", + value: 74.2, + measuredAt: new Date().toISOString(), + source: "MANUAL", + }), + } as never), + ); + + expect(res.status).toBe(422); + expect(await getPrismaClient().measurement.count()).toBe(0); }); it("admits the scope on the batch route, attributed MANUAL", async () => { @@ -545,8 +579,11 @@ describe("B8 — the measurement-ingest scope reaches its two routes and no othe const row = await getPrismaClient().measurement.findFirstOrThrow({ where: { userId: USER_ID, externalId: "uuid-measurements-write-1" }, }); - // Absent `source` defaults to APPLE_HEALTH for the phone and MANUAL here. - expect(row.source).toBe("MANUAL"); + // Absent `source` defaults to APPLE_HEALTH for the phone and resolves to + // EXTERNAL here. That also moves the dedup key to + // `(userId, type, EXTERNAL, externalId)`, so a bridge's externalIds can + // never collide with the phone's. + expect(row.source).toBe("EXTERNAL"); }); it("does not move the native sync checkpoint", async () => { @@ -611,6 +648,83 @@ describe("B8 — the measurement-ingest scope reaches its two routes and no othe expect(await getPrismaClient().measurement.count()).toBe(0); }); + it("refuses a batch entry naming MANUAL too, not only APPLE_HEALTH", async () => { + // MANUAL was permitted here while it was also the value the route forced, + // so nothing was ever overridden. Now it would be, and the sibling case + // above says why that is refused instead. + await armToken(["measurements:write"], "mwrite4b"); + const { POST } = await import("@/app/api/measurements/batch/route"); + const res = await POST( + new NextRequest("https://health.example/api/measurements/batch", { + method: "POST", + headers: { + authorization: headerJar.get("authorization")!, + "content-type": "application/json", + }, + body: JSON.stringify({ + entries: [ + { + hkIdentifier: "HKQuantityTypeIdentifierBodyMass", + value: 73, + unit: "kg", + startDate: new Date().toISOString(), + endDate: new Date().toISOString(), + externalId: "uuid-measurements-write-3b", + source: "MANUAL", + }, + ], + }), + } as never), + ); + + expect(res.status).toBe(422); + expect(await getPrismaClient().measurement.count()).toBe(0); + }); + + it("leaves the row it wrote editable by its owner", async () => { + // The counterpart to the source split. `EXTERNAL` is outside + // `WRITABLE_MEASUREMENT_SOURCES` — no client may name it — but it is + // inside `USER_CORRECTABLE_MEASUREMENT_SOURCES`, because the hardware + // behind an ingest token is the user's own scale, not a provider. A later + // tidy-up that folds the two constants back together turns every bridge + // row value-locked with a 409, and this case is what notices. + await armToken(["measurements:write"], "mwrite1c"); + const { POST } = await import("@/app/api/measurements/route"); + expect((await POST(postMeasurement())).status).toBe(201); + const row = await getPrismaClient().measurement.findFirstOrThrow({ + where: { userId: USER_ID, type: "WEIGHT" }, + }); + expect(row.source).toBe("EXTERNAL"); + + // Edit as the owner: a cookie session, no Bearer. The scoped credential + // could not reach `PUT` at all — that is the point of a write-only scope. + headerJar.delete("authorization"); + const session = await getPrismaClient().session.create({ + data: { + userId: USER_ID, + expiresAt: new Date(Date.now() + 60 * 60 * 1000), + }, + }); + cookieJar.set("healthlog_session", session.id); + + const { PUT } = await import("@/app/api/measurements/[id]/route"); + const res = await PUT( + new NextRequest(`https://health.example/api/measurements/${row.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ value: 75.1 }), + } as never), + { params: Promise.resolve({ id: row.id }) }, + ); + + expect(res.status).toBe(200); + const reread = await getPrismaClient().measurement.findUniqueOrThrow({ + where: { id: row.id }, + }); + expect(reread.value).toBe(75.1); + expect(reread.source).toBe("EXTERNAL"); + }); + it("is refused on the read leg of the very same path", async () => { // What makes the scope write-only rather than write-named. await armToken(["measurements:write"], "mwrite5"); diff --git a/tests/integration/measurement-source-write-smoke.test.ts b/tests/integration/measurement-source-write-smoke.test.ts index ff4ac25b7..937100196 100644 --- a/tests/integration/measurement-source-write-smoke.test.ts +++ b/tests/integration/measurement-source-write-smoke.test.ts @@ -8,12 +8,14 @@ import type { import { POST as postCsvImport } from "@/app/api/import/csv/route"; import { POST as postAppleHealthBatch } from "@/app/api/measurements/batch/route"; import { POST as postManualMeasurement } from "@/app/api/measurements/route"; +import { hashToken } from "@/lib/auth/hmac"; import { encrypt } from "@/lib/crypto"; import { upsertFitbitMeasurements } from "@/lib/fitbit/sync-core"; import { upsertGoogleHealthMeasurements } from "@/lib/google-health/sync-core"; import { upsertScoreRow } from "@/lib/insights/score-row"; import { logMcpMeasurement } from "@/lib/mcp/writes"; import { logTelegramMeasurement } from "@/lib/measurements/create-from-telegram"; +import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement"; import { upsertNightscoutEntries } from "@/lib/nightscout/sync"; import { upsertOuraMeasurements } from "@/lib/oura/sync"; @@ -309,6 +311,50 @@ const SOURCE_CASES = { expect(verdict.status).toBe("inserted"); }, }, + // v1.38.x — the third-party ingest token. Written through the ordinary + // single-entry POST, because that IS the production path: the route reads + // the credential and resolves the source itself. The body names none — one + // that did would be refused 422. + EXTERNAL: { + expectedType: "PULSE", + expectedExternalId: null, + write: async () => { + // The trap in this suite: `beforeEach` seeds a session cookie and the + // caller resolver is cookie-first, so arming only the `authorization` + // header would resolve as that session and quietly write MANUAL — the + // case would then fail on a row it never actually tested. Clear the + // cookie for the duration, then put it back: this is the last case + // today, but nothing stops one being appended after it. + const prisma = getPrismaClient(); + const session = cookieJar.get("healthlog_session"); + cookieJar.clear(); + + const raw = `hlk_external_${"0".repeat(48)}`; + await prisma.apiToken.create({ + data: { + userId: USER_ID, + name: "source-smoke-external", + tokenHash: hashToken(raw), + permissions: [MEASUREMENTS_WRITE_SCOPE], + }, + }); + headerJar.set("authorization", `Bearer ${raw}`); + + try { + const response = await postManualMeasurement( + jsonRequest("http://localhost/api/measurements", { + type: "PULSE", + value: 61, + measuredAt: MEASURED_AT.toISOString(), + }), + ); + expect(response.status).toBe(201); + } finally { + headerJar.delete("authorization"); + if (session) cookieJar.set("healthlog_session", session); + } + }, + }, } satisfies Record; beforeEach(async () => { @@ -359,9 +405,9 @@ describe("MeasurementSource write paths against real Postgres", () => { }); } - expect(sourceCases).toHaveLength(14); + expect(sourceCases).toHaveLength(15); expect(await prisma.measurement.count({ where: { userId: USER_ID } })).toBe( - 14, + 15, ); }); });