diff --git a/docs/api/README.md b/docs/api/README.md
index 1d4ddd005..507e31faf 100644
--- a/docs/api/README.md
+++ b/docs/api/README.md
@@ -66,7 +66,11 @@ before treating a warning as a regression.
`health:write`), audience-bound to `/mcp` and refused on REST writes;
- `POST /api/tokens/measurements` mints `measurements:write`, accepted by
`POST /api/measurements` and `POST /api/measurements/batch` only, on the
- holder's own record only;
+ holder's own record only. The readings it writes carry `source: EXTERNAL`
+ and are linked to the token; whether the HealthLog app writes them into
+ Apple Health is the token's `mirrorToAppleHealth` setting (off by
+ default, changed with `PATCH /api/tokens/{id}`), resolved onto every
+ measurement row as `appleHealthMirror`;
- `POST /api/tokens/workouts` mints `workouts:write`, accepted by
`POST /api/workouts/batch` only, on the holder's own record only; the
workouts it writes carry `source: EXTERNAL`;
diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml
index ee9bd53e2..563d48eb1 100644
--- a/docs/api/openapi.yaml
+++ b/docs/api/openapi.yaml
@@ -3372,6 +3372,25 @@ paths:
"422": *a4
"429": *a5
/api/tokens/measurements:
+ get:
+ tags:
+ - Auth
+ summary: Readings no measurement token reaches
+ description: Counts the caller's live EXTERNAL readings that are linked to no token. Readings are linked to the token
+ that sent them since v1.46; older ones were linked by a one-time pass only when the account held exactly one
+ measurement token, so the rest belong to none and no token's Apple Health setting reaches them. Cookie session
+ only.
+ responses:
+ "200":
+ description: The count.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/MeasurementTokenSummaryEnvelope"
+ "401": *a1
+ "403": *a9
+ "422": *a4
+ "429": *a5
post:
tags:
- Auth
@@ -3690,6 +3709,53 @@ paths:
$ref: "#/components/schemas/ErrorEnvelope"
"422": *a4
"429": *a5
+ patch:
+ tags:
+ - Auth
+ summary: Change a measurement token's Apple Health setting
+ description: "Sets whether the HealthLog app writes the readings this measurement token sent into Apple Health. The
+ readings keep their `EXTERNAL` source and are not rewritten: every measurement row resolves `appleHealthMirror`
+ from the token at read time, and a paired client learns of the change through the `measurementMirror` domain of
+ `GET /api/sync/changes`. Turning it off stops further writes; samples already in Apple Health stay. A revoked
+ token is accepted, since its readings stay in the record. Cookie session only, the caller's own tokens only
+ (another account's answers 404). Audited as `tokens.measurements.mirror.update` when the value changes. 30
+ changes per account per minute. Not gated on the instance-wide API switch: no credential is created or
+ extended."
+ parameters:
+ - in: path
+ name: id
+ schema:
+ type: string
+ required: true
+ requestBody:
+ required: true
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpdateMeasurementTokenRequest"
+ responses:
+ "200":
+ description: The setting as it now stands.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/UpdateMeasurementTokenEnvelope"
+ "401": *a1
+ "403": *a9
+ "404":
+ description: Token not found or not owned by the caller.
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "422":
+ description: "Validation failed, or the token is not a measurement token (`meta.errorCode` =
+ `tokens.mirror.not_measurement_token`): a wildcard, connector or other narrow token has no such setting."
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/ErrorEnvelope"
+ "429": *a5
/api/auth/register:
post:
security: []
@@ -4224,10 +4290,11 @@ paths:
- Sync
summary: Multi-domain delta feed
description: "Incremental catch-up after the first-pair backfill (never a replacement for it). Pages over one opaque
- keyset cursor spanning five domains — measurements, mood, medication intakes, cycle day logs and cycles. Each
- domain carries `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts
- within a domain. Tombstone identity is `externalId` for measurements and the server `id` everywhere else.
- `cursorExpired: true` forces a clean re-init and the page is empty in every domain."
+ keyset cursor spanning six domains — measurements, mood, medication intakes, cycle day logs, cycles, and
+ `measurementMirror` (the Apple Health write-back of readings whose token setting changed). Each domain carries
+ `tombstones` (soft-deleted rows) and `upserts` (live rows); apply tombstones before upserts within a domain.
+ Tombstone identity is `externalId` for measurements and the server `id` everywhere else. `cursorExpired: true`
+ forces a clean re-init and the page is empty in every domain."
parameters:
- in: query
name: cursor
@@ -4554,6 +4621,12 @@ paths:
- STRAVA
- EXTERNAL
- HEALTH_CONNECT
+ - in: query
+ name: appleHealthMirror
+ schema:
+ type: string
+ enum:
+ - "true"
- in: query
name: valueMin
schema:
@@ -28142,12 +28215,15 @@ components:
type: integer
minimum: 1
maximum: 365
+ mirrorToAppleHealth:
+ type: boolean
required:
- name
description: "Mint a measurement-ingest Bearer. There is no `scope` field: this endpoint mints one shape and the
permission array is a literal, so no request can widen it. `name` is what the token is listed under — name it
after the thing you are pasting it into, because that is what you will be reading when you decide whether to
- revoke it. `expiresInDays` defaults to 365."
+ revoke it. `expiresInDays` defaults to 365. `mirrorToAppleHealth` (default false) lets the HealthLog app write
+ the token's readings into Apple Health; it can be changed later at `PATCH /api/tokens/{id}`."
CreateWorkoutTokenRequest:
type: object
properties:
@@ -28178,6 +28254,15 @@ components:
- name
description: "Mint a document-upload Bearer. No `scope` field: the endpoint mints one shape. `name` is what the token is
listed under; name it after the system it is pasted into. `expiresInDays` defaults to 365."
+ UpdateMeasurementTokenRequest:
+ type: object
+ properties:
+ mirrorToAppleHealth:
+ type: boolean
+ required:
+ - mirrorToAppleHealth
+ additionalProperties: false
+ description: "The one setting a measurement token has. Strict: any other key is refused 422."
RegisterRequest:
type: object
properties:
@@ -38032,11 +38117,12 @@ components:
source-priority.body.invalid_json, source-priority.body.invalid_shape; symptoms —
symptoms.definition.limitReached, symptoms.definition.notFound, symptoms.episode.notOpen,
symptoms.event.notFound, symptoms.invalid; timeline — timeline.invalid; tokens —
- tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached, tokens.workouts.ceiling_reached;
- vaccination — vaccination.booster-invalid, vaccination.booster-no-antigen,
- vaccination.booster-out-of-scope, vaccination.custom-vaccine-not-found, vaccination.custom.invalid,
- vaccination.custom.name-taken, vaccination.custom.not-found, vaccination.encounter-not-found,
- vaccination.identity-required, vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
+ tokens.documents.ceiling_reached, tokens.measurements.ceiling_reached,
+ tokens.mirror.not_measurement_token, tokens.workouts.ceiling_reached; vaccination —
+ vaccination.booster-invalid, vaccination.booster-no-antigen, vaccination.booster-out-of-scope,
+ vaccination.custom-vaccine-not-found, vaccination.custom.invalid, vaccination.custom.name-taken,
+ vaccination.custom.not-found, vaccination.encounter-not-found, vaccination.identity-required,
+ vaccination.invalid, vaccination.link.invalid, vaccination.not-found,
vaccination.practitioner-not-found; workout — workout.batch.invalid, workout.batch.payload_too_large,
workout.batch.source_not_permitted, workout.batch.too_large, workout.delete.synced_source; (unprefixed)
— about_me_conflict, ai_response_truncated, coach_prefs_conflict, dashboard_layout_conflict, empty_file,
@@ -40800,6 +40886,11 @@ components:
type:
- string
- "null"
+ ingestTokenId:
+ anyOf:
+ - type: string
+ maxLength: 64
+ - type: "null"
syncVersion:
type: integer
minimum: -9007199254740991
@@ -47391,6 +47482,10 @@ components:
type: boolean
description: Revoked tokens stay in the list rather than disappearing, so a client must filter on this rather than treat
presence as validity.
+ mirrorToAppleHealth:
+ type: boolean
+ description: "A measurement token's Apple Health setting: whether the HealthLog app writes the readings this token sent
+ into Apple Health. Changed with `PATCH /api/tokens/{id}`; false on every other token."
required:
- id
- name
@@ -47399,6 +47494,36 @@ components:
- expiresAt
- createdAt
- revoked
+ - mirrorToAppleHealth
+ additionalProperties: false
+ MeasurementTokenSummaryEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/MeasurementTokenSummaryResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ MeasurementTokenSummaryResponse:
+ type: object
+ properties:
+ unattributedReadings:
+ type: integer
+ minimum: 0
+ maximum: 9007199254740991
+ description: "Live EXTERNAL readings that belong to no token: sent before readings were linked to their token, by an
+ account that held more than one. No token's Apple Health setting reaches them."
+ required:
+ - unattributedReadings
additionalProperties: false
CreateMeasurementTokenEnvelope:
type: object
@@ -47532,6 +47657,34 @@ components:
- data
- error
additionalProperties: false
+ UpdateMeasurementTokenEnvelope:
+ type: object
+ properties:
+ data:
+ $ref: "#/components/schemas/UpdateMeasurementTokenResponse"
+ error:
+ type: "null"
+ meta:
+ type: object
+ properties:
+ requestId:
+ type: string
+ additionalProperties: false
+ required:
+ - data
+ - error
+ additionalProperties: false
+ UpdateMeasurementTokenResponse:
+ type: object
+ properties:
+ id:
+ type: string
+ mirrorToAppleHealth:
+ type: boolean
+ required:
+ - id
+ - mirrorToAppleHealth
+ additionalProperties: false
RegisterEnvelope:
type: object
properties:
@@ -48111,12 +48264,37 @@ components:
additionalProperties: false
description: Menstrual cycles, observed and forward-predicted. Present on every page, empty arrays when the account has
the cycle module off.
+ measurementMirror:
+ type: object
+ properties:
+ upserts:
+ type: array
+ items:
+ $ref: "#/components/schemas/SyncMeasurementMirrorUpsert"
+ tombstones:
+ maxItems: 0
+ type: array
+ items:
+ type: object
+ properties:
+ id:
+ type: string
+ required:
+ - id
+ additionalProperties: false
+ description: "Always empty: a deleted reading is a measurement tombstone."
+ required:
+ - upserts
+ - tombstones
+ additionalProperties: false
+ description: Apple Health write-back changes for readings that are otherwise unchanged. Apply after `measurements`.
required:
- measurements
- mood
- intakes
- cycleDays
- cycles
+ - measurementMirror
additionalProperties: false
required:
- serverNow
@@ -48125,10 +48303,11 @@ components:
- cursorExpired
- changes
additionalProperties: false
- description: "Multi-domain delta page: measurements, mood, intakes, cycle day logs and cycles. One opaque multi-domain
- keyset cursor; tombstones apply before upserts within each domain. Tombstone identity: measurements key on
- externalId, every other domain on server id. All five keys are present on every response, including the
- `cursorExpired` short-circuit — a domain with nothing to report sends empty arrays rather than being omitted."
+ description: "Multi-domain delta page: measurements, mood, intakes, cycle day logs, cycles, and the Apple Health
+ write-back changes of otherwise unchanged readings. One opaque multi-domain keyset cursor; tombstones apply
+ before upserts within each domain. Tombstone identity: measurements key on externalId, every other domain on
+ server id. All six keys are present on every response, including the `cursorExpired` short-circuit — a domain
+ with nothing to report sends empty arrays rather than being omitted."
SyncMeasurementUpsert:
type: object
properties:
@@ -48221,6 +48400,10 @@ components:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ appleHealthMirror:
+ type: boolean
+ description: Whether the HealthLog app writes this reading into Apple Health. Server-resolved; mirror exactly the rows
+ that say true.
dayKey:
type: string
sampleCount:
@@ -48280,6 +48463,7 @@ components:
- externalId
- syncVersion
- updatedAt
+ - appleHealthMirror
additionalProperties: false
MeasurementSource:
type: string
@@ -48582,6 +48766,21 @@ components:
- updatedAt
additionalProperties: false
description: A soft-deleted menstrual cycle, keyed on server `id`. Apply before upserts within the domain page.
+ SyncMeasurementMirrorUpsert:
+ type: object
+ properties:
+ id:
+ type: string
+ description: The measurement's server id.
+ appleHealthMirror:
+ type: boolean
+ description: The reading's write-back as it now stands. False means stop writing it; samples already in Apple Health stay.
+ required:
+ - id
+ - appleHealthMirror
+ additionalProperties: false
+ description: A reading whose `appleHealthMirror` changed because the account switched the Apple Health setting of the
+ token that sent it. The reading itself did not change and is not re-sent in `measurements`.
GetMeasurementsSeriesBatchResponse:
type: object
properties:
@@ -49017,6 +49216,11 @@ components:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ appleHealthMirror:
+ description: "Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored
+ row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that
+ sent it, false otherwise. Absent on the synthesised list rows."
+ type: boolean
dayKey:
type: string
sampleCount:
@@ -49258,6 +49462,11 @@ components:
type: string
format: date-time
pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z|([+-](?:[01]\d|2[0-3]):[0-5]\d)))$
+ appleHealthMirror:
+ description: "Whether the HealthLog app writes this reading into Apple Health. Resolved by the server on every stored
+ row: true for WITHINGS, IMPORT and MANUAL, for an EXTERNAL row whatever its account chose for the token that
+ sent it, false otherwise. Absent on the synthesised list rows."
+ type: boolean
dayKey:
type: string
sampleCount:
diff --git a/messages/de.json b/messages/de.json
index 52c9e2293..2a3d40ac2 100644
--- a/messages/de.json
+++ b/messages/de.json
@@ -6855,7 +6855,14 @@
"detail": "Das Token wird nur einmal angezeigt.",
"scopeNote": "Es kann ausschließlich neue Werte in deine eigene Akte eintragen. Es kann 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.",
- "namePlaceholder": "Name, z. B. Home Assistant"
+ "namePlaceholder": "Name, z. B. Home Assistant",
+ "appleHealth": {
+ "label": "Messwerte in Apple Health schreiben",
+ "createHint": "Die HealthLog-App auf deinem iPhone trägt die Werte dieses Tokens dann in Apple Health ein. Du kannst das später in der Token-Liste unten ändern.",
+ "toggleAria": "Werte von {name} in Apple Health schreiben",
+ "updateFailed": "Die Apple-Health-Einstellung konnte nicht geändert werden. Bitte versuche es erneut.",
+ "unattributed": "Werte, die vor dieser Einstellung gesendet wurden, lassen sich keinem Token zuordnen und bleiben deshalb außerhalb von Apple Health."
+ }
},
"workoutsToken": {
"title": "Training-Tokens",
diff --git a/messages/en.json b/messages/en.json
index 00826c3db..97234ceaf 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -6855,7 +6855,14 @@
"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 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.",
- "namePlaceholder": "Name, e.g. Home Assistant"
+ "namePlaceholder": "Name, e.g. Home Assistant",
+ "appleHealth": {
+ "label": "Write readings to Apple Health",
+ "createHint": "The HealthLog app on your iPhone then adds this token's readings to Apple Health. You can change this later in the token list below.",
+ "toggleAria": "Write readings from {name} to Apple Health",
+ "updateFailed": "The Apple Health setting could not be changed. Please try again.",
+ "unattributed": "Readings sent before this setting existed can't be matched to a token, so they stay out of Apple Health."
+ }
},
"workoutsToken": {
"title": "Workout tokens",
diff --git a/messages/es.json b/messages/es.json
index ecc6d0e7c..3ca0cf9fa 100644
--- a/messages/es.json
+++ b/messages/es.json
@@ -6855,7 +6855,14 @@
"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 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.",
- "namePlaceholder": "Nombre, p. ej. Home Assistant"
+ "namePlaceholder": "Nombre, p. ej. Home Assistant",
+ "appleHealth": {
+ "label": "Escribir mediciones en Apple Health",
+ "createHint": "La app HealthLog de tu iPhone añadirá entonces las mediciones de este token a Apple Health. Puedes cambiarlo más tarde en la lista de tokens de abajo.",
+ "toggleAria": "Escribir las mediciones de {name} en Apple Health",
+ "updateFailed": "No se pudo cambiar el ajuste de Apple Health. Inténtalo de nuevo.",
+ "unattributed": "Las mediciones enviadas antes de que existiera este ajuste no se pueden asociar a ningún token, así que quedan fuera de Apple Health."
+ }
},
"workoutsToken": {
"title": "Tokens de entrenamientos",
diff --git a/messages/fr.json b/messages/fr.json
index 69fbcdfb8..106d1d321 100644
--- a/messages/fr.json
+++ b/messages/fr.json
@@ -6855,7 +6855,14 @@
"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 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.",
- "namePlaceholder": "Nom, p. ex. Home Assistant"
+ "namePlaceholder": "Nom, p. ex. Home Assistant",
+ "appleHealth": {
+ "label": "Écrire les mesures dans Apple Health",
+ "createHint": "L'app HealthLog de votre iPhone ajoutera alors les mesures de ce jeton à Apple Health. Vous pourrez modifier ce choix plus tard dans la liste des jetons ci-dessous.",
+ "toggleAria": "Écrire les mesures de {name} dans Apple Health",
+ "updateFailed": "Le réglage Apple Health n'a pas pu être modifié. Veuillez réessayer.",
+ "unattributed": "Les mesures envoyées avant l'existence de ce réglage ne peuvent être rattachées à aucun jeton : elles restent donc hors d'Apple Health."
+ }
},
"workoutsToken": {
"title": "Jetons d'entraînements",
diff --git a/messages/it.json b/messages/it.json
index 211211b5a..ea4d45364 100644
--- a/messages/it.json
+++ b/messages/it.json
@@ -6855,7 +6855,14 @@
"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 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.",
- "namePlaceholder": "Nome, ad es. Home Assistant"
+ "namePlaceholder": "Nome, ad es. Home Assistant",
+ "appleHealth": {
+ "label": "Scrivi le misurazioni in Apple Health",
+ "createHint": "L'app HealthLog sul tuo iPhone aggiungerà quindi le misurazioni di questo token ad Apple Health. Puoi cambiarlo più tardi nell'elenco dei token qui sotto.",
+ "toggleAria": "Scrivi le misurazioni di {name} in Apple Health",
+ "updateFailed": "Non è stato possibile modificare l'impostazione di Apple Health. Riprova.",
+ "unattributed": "Le misurazioni inviate prima che esistesse questa impostazione non si possono associare a un token, quindi restano fuori da Apple Health."
+ }
},
"workoutsToken": {
"title": "Token per allenamenti",
diff --git a/messages/ko.json b/messages/ko.json
index 5069c1d45..6ec545b8e 100644
--- a/messages/ko.json
+++ b/messages/ko.json
@@ -6855,7 +6855,14 @@
"detail": "토큰은 한 번만 표시돼요.",
"scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 외부 기기에서 온 것으로 표시되니 나중에 구분할 수 있고, 언제든 고칠 수 있어요.",
"createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요.",
- "namePlaceholder": "이름 (예: Home Assistant)"
+ "namePlaceholder": "이름 (예: Home Assistant)",
+ "appleHealth": {
+ "label": "측정값을 Apple Health에 기록",
+ "createHint": "그러면 iPhone의 HealthLog 앱이 이 토큰의 측정값을 Apple Health에 추가해요. 나중에 아래 토큰 목록에서 바꿀 수 있어요.",
+ "toggleAria": "{name}의 측정값을 Apple Health에 기록",
+ "updateFailed": "Apple Health 설정을 바꾸지 못했어요. 다시 시도해 주세요.",
+ "unattributed": "이 설정이 생기기 전에 보낸 측정값은 토큰과 연결할 수 없어서 Apple Health에 기록되지 않아요."
+ }
},
"workoutsToken": {
"title": "운동 토큰",
diff --git a/messages/nl.json b/messages/nl.json
index f091c3c1d..6415f4489 100644
--- a/messages/nl.json
+++ b/messages/nl.json
@@ -6855,7 +6855,14 @@
"detail": "Het token wordt één keer getoond.",
"scopeNote": "Het kan alleen nieuwe metingen aan je eigen dossier toevoegen. Het kan je gegevens niet lezen, niets wijzigen of verwijderen en geen ander token aanmaken. Metingen worden gemarkeerd als afkomstig van een extern apparaat, zodat je ze later kunt onderscheiden, en je kunt ze nog steeds zelf corrigeren.",
"createFailed": "Het token kon niet worden aangemaakt. Probeer het opnieuw.",
- "namePlaceholder": "Naam, bijv. Home Assistant"
+ "namePlaceholder": "Naam, bijv. Home Assistant",
+ "appleHealth": {
+ "label": "Metingen naar Apple Health schrijven",
+ "createHint": "De HealthLog-app op je iPhone zet de metingen van dit token dan in Apple Health. Je kunt dit later wijzigen in de tokenlijst hieronder.",
+ "toggleAria": "Metingen van {name} naar Apple Health schrijven",
+ "updateFailed": "De Apple Health-instelling kon niet worden gewijzigd. Probeer het opnieuw.",
+ "unattributed": "Metingen die zijn verstuurd voordat deze instelling bestond, kunnen niet aan een token worden gekoppeld en blijven daarom buiten Apple Health."
+ }
},
"workoutsToken": {
"title": "Trainingstokens",
diff --git a/messages/pl.json b/messages/pl.json
index 947424424..cb5dc32b7 100644
--- a/messages/pl.json
+++ b/messages/pl.json
@@ -6855,7 +6855,14 @@
"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 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.",
- "namePlaceholder": "Nazwa, np. Home Assistant"
+ "namePlaceholder": "Nazwa, np. Home Assistant",
+ "appleHealth": {
+ "label": "Zapisuj pomiary w Apple Health",
+ "createHint": "Aplikacja HealthLog na Twoim iPhonie doda wtedy pomiary z tego tokenu do Apple Health. Możesz to później zmienić na liście tokenów poniżej.",
+ "toggleAria": "Zapisuj pomiary z {name} w Apple Health",
+ "updateFailed": "Nie udało się zmienić ustawienia Apple Health. Spróbuj ponownie.",
+ "unattributed": "Pomiarów wysłanych, zanim pojawiło się to ustawienie, nie da się przypisać do żadnego tokenu, więc nie trafiają do Apple Health."
+ }
},
"workoutsToken": {
"title": "Tokeny treningów",
diff --git a/messages/pt-BR.json b/messages/pt-BR.json
index 277297c97..aa102f900 100644
--- a/messages/pt-BR.json
+++ b/messages/pt-BR.json
@@ -6855,7 +6855,14 @@
"detail": "O token é exibido uma vez.",
"scopeNote": "Ele só pode adicionar novas leituras ao seu próprio registro. Não pode ler seus dados, alterar ou excluir nada, nem criar outro token. As leituras são marcadas como vindas de um dispositivo externo, para você distingui-las depois, e você ainda pode corrigi-las você mesmo.",
"createFailed": "O token não pôde ser criado. Tente novamente.",
- "namePlaceholder": "Nome, ex.: Home Assistant"
+ "namePlaceholder": "Nome, ex.: Home Assistant",
+ "appleHealth": {
+ "label": "Gravar medições no Apple Health",
+ "createHint": "O app HealthLog no seu iPhone passa a adicionar as medições deste token ao Apple Health. Você pode mudar isso depois na lista de tokens abaixo.",
+ "toggleAria": "Gravar as medições de {name} no Apple Health",
+ "updateFailed": "Não foi possível alterar a configuração do Apple Health. Tente novamente.",
+ "unattributed": "As medições enviadas antes de esta configuração existir não podem ser associadas a um token, por isso ficam fora do Apple Health."
+ }
},
"workoutsToken": {
"title": "Tokens de treino",
diff --git a/prisma/migrations/0476_token_apple_health_mirror/migration.sql b/prisma/migrations/0476_token_apple_health_mirror/migration.sql
new file mode 100644
index 000000000..52ce04a08
--- /dev/null
+++ b/prisma/migrations/0476_token_apple_health_mirror/migration.sql
@@ -0,0 +1,80 @@
+-- Per-token Apple Health write-back (#1272).
+--
+-- `api_tokens.mirror_to_apple_health` is the person's choice for a
+-- `measurements:write` token; `mirror_changed_at` is when it last changed and
+-- keys the `measurementMirror` domain of `/api/sync/changes`. Rows written by
+-- such a token keep their honest `EXTERNAL` source and point at the token
+-- through `measurements.ingest_token_id`; the server resolves the row's
+-- `appleHealthMirror` from the token when it answers it.
+--
+-- Each step is a `DO` block with a bounded lock wait, and each is safe to run
+-- again, so the file is marked for the boot's lock-timeout retry:
+--
+-- 1. the columns, and the foreign key added NOT VALID (no scan under the
+-- table lock);
+-- 2. the backfill: an existing EXTERNAL row is linked only where the
+-- account has exactly one measurement token (revoked ones counted) and
+-- the row is no older than that token, because only then is the token
+-- provably the one that carried it. Every other existing row stays
+-- unlinked and is not mirrored. The statement sets nothing but
+-- `ingest_token_id`, so `updated_at` and `sync_version` are untouched;
+-- 3. the foreign key validated, which scans without blocking writes.
+--
+-- The index the key and the feed walk use is built CONCURRENTLY in 0477, on
+-- its own, because a concurrent build cannot share a file.
+--
+-- @retry-on-lock-timeout
+
+DO $migration$
+BEGIN
+ PERFORM set_config('lock_timeout', '5s', true);
+ ALTER TABLE "api_tokens"
+ ADD COLUMN IF NOT EXISTS "mirror_to_apple_health" BOOLEAN NOT NULL DEFAULT false,
+ ADD COLUMN IF NOT EXISTS "mirror_changed_at" TIMESTAMP(3);
+ ALTER TABLE "measurements"
+ ADD COLUMN IF NOT EXISTS "ingest_token_id" TEXT;
+ IF NOT EXISTS (
+ SELECT 1 FROM pg_constraint
+ WHERE conname = 'measurements_ingest_token_id_fkey'
+ ) THEN
+ ALTER TABLE "measurements"
+ ADD CONSTRAINT "measurements_ingest_token_id_fkey"
+ FOREIGN KEY ("ingest_token_id") REFERENCES "api_tokens"("id")
+ ON DELETE SET NULL ON UPDATE CASCADE NOT VALID;
+ END IF;
+END
+$migration$;
+
+DO $migration$
+DECLARE
+ linked bigint;
+BEGIN
+ PERFORM set_config('lock_timeout', '5s', true);
+ WITH sole_token AS (
+ SELECT t."user_id",
+ (array_agg(t."id"))[1] AS token_id,
+ (array_agg(t."created_at"))[1] AS created_at
+ FROM "api_tokens" t
+ WHERE t."permissions" = ARRAY['measurements:write']::text[]
+ GROUP BY t."user_id"
+ HAVING count(*) = 1
+ )
+ UPDATE "measurements" m
+ SET "ingest_token_id" = s.token_id
+ FROM sole_token s
+ WHERE m."user_id" = s."user_id"
+ AND m."source" = 'EXTERNAL'
+ AND m."ingest_token_id" IS NULL
+ AND m."created_at" >= s.created_at;
+ GET DIAGNOSTICS linked = ROW_COUNT;
+ RAISE NOTICE '0476: linked % EXTERNAL readings to their sole measurement token', linked;
+END
+$migration$;
+
+DO $migration$
+BEGIN
+ PERFORM set_config('lock_timeout', '5s', true);
+ ALTER TABLE "measurements"
+ VALIDATE CONSTRAINT "measurements_ingest_token_id_fkey";
+END
+$migration$;
diff --git a/prisma/migrations/0477_measurements_ingest_token_index/migration.sql b/prisma/migrations/0477_measurements_ingest_token_index/migration.sql
new file mode 100644
index 000000000..82c16c7b9
--- /dev/null
+++ b/prisma/migrations/0477_measurements_ingest_token_index/migration.sql
@@ -0,0 +1,8 @@
+-- The rows a measurement token carried (#1272): walked by the
+-- `measurementMirror` domain of `/api/sync/changes` in id order, and looked up
+-- by the foreign key's SET NULL. Built CONCURRENTLY so writes to
+-- `measurements` carry on during the build; a concurrent build must be the
+-- only statement in its file. Most rows have no token, so the index stays
+-- small.
+CREATE INDEX CONCURRENTLY IF NOT EXISTS "measurements_ingest_token_id_id_idx"
+ ON "measurements" ("ingest_token_id", "id");
diff --git a/prisma/schema.prisma b/prisma/schema.prisma
index e78805032..915db7acd 100644
--- a/prisma/schema.prisma
+++ b/prisma/schema.prisma
@@ -2118,8 +2118,15 @@ model Measurement {
deletedAt DateTime? @map("deleted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
+ /// The `measurements:write` token that carried this reading, set on every
+ /// `EXTERNAL` row (#1272). Read through to the token's `mirrorToAppleHealth`
+ /// when the row is answered; never on the wire itself. SET NULL if the token
+ /// row is ever removed (a revoke keeps it), and the row then stops being
+ /// mirrored.
+ ingestTokenId String? @map("ingest_token_id")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ ingestToken ApiToken? @relation(fields: [ingestTokenId], references: [id], onDelete: SetNull)
// v1.19.0 — optional back-relation to the full ECG waveform recording(s)
// paired with this EVENT row. SET NULL on the FK side keeps the trace if
// the EVENT row is re-keyed / removed.
@@ -2175,6 +2182,9 @@ model Measurement {
// changes nothing else, so those stay HOT; the table also carries
// `fillfactor = 85` and a 5 % autovacuum threshold from the same migration.
@@index([userId, updatedAt, id])
+ // The rows a token carried, walked by the `measurementMirror` feed domain
+ // in id order and by the FK's SET NULL (migration 0477).
+ @@index([ingestTokenId, id])
// `measurements_legacy_step_consolidation_idx` is a partial index
// over `type = 'ACTIVITY_STEPS' AND deleted_at IS NULL` — see
// migration 0087. It accelerates the v1.5.6 legacy step-consolidation
@@ -3936,26 +3946,37 @@ model TelegramPromptContext {
// ─── API Tokens (for external ingest) ──────────────────────
model ApiToken {
- id String @id @default(cuid())
- userId String @map("user_id")
- name String
- tokenHash String @unique @map("token_hash") // SHA-256 of the token
- permissions String[] @default(["medication:ingest"])
- lastUsedAt DateTime? @map("last_used_at")
- expiresAt DateTime? @map("expires_at")
- createdAt DateTime @default(now()) @map("created_at")
- revoked Boolean @default(false)
+ id String @id @default(cuid())
+ userId String @map("user_id")
+ name String
+ tokenHash String @unique @map("token_hash") // SHA-256 of the token
+ permissions String[] @default(["medication:ingest"])
+ lastUsedAt DateTime? @map("last_used_at")
+ expiresAt DateTime? @map("expires_at")
+ createdAt DateTime @default(now()) @map("created_at")
+ revoked Boolean @default(false)
// The MCP OAuth connection this access token belongs to, when it was minted
// through the `/api/mcp/oauth` bridge. Null for manually-minted tokens and
// native-client tokens. Lets a connection revoke kill every access row it
// ever issued (H2), and lets the connector token list exclude the transient
// 60-minute access rows so it does not flood (M2).
- mcpConnectionId String? @map("mcp_connection_id")
+ mcpConnectionId String? @map("mcp_connection_id")
+ /// Whether the HealthLog app writes the readings this `measurements:write`
+ /// token carried into Apple Health (#1272). The person decides per token;
+ /// the server resolves it onto every row as `appleHealthMirror`
+ /// (`src/lib/measurements/apple-health-mirror.ts`). False for every other
+ /// kind of token, which `PATCH /api/tokens/{id}` refuses to set.
+ mirrorToAppleHealth Boolean @default(false) @map("mirror_to_apple_health")
+ /// When `mirrorToAppleHealth` last changed. The keyset of the
+ /// `measurementMirror` domain of `/api/sync/changes`, which re-delivers the
+ /// token's rows with their new value without touching the rows themselves.
+ mirrorChangedAt DateTime? @map("mirror_changed_at")
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
- mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+ mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull)
+ ingestedMeasurements Measurement[]
// v1.30.34 — step-up elevations bound to this token. Cascade-deleted with it.
- stepUpElevations StepUpElevation[]
+ stepUpElevations StepUpElevation[]
@@index([userId])
@@index([mcpConnectionId])
diff --git a/src/__tests__/baselines/source-function-lines.json b/src/__tests__/baselines/source-function-lines.json
index e6ccf5f2a..daf9621c7 100644
--- a/src/__tests__/baselines/source-function-lines.json
+++ b/src/__tests__/baselines/source-function-lines.json
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 355,
- "total": 114755,
+ "total": 114658,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 314,
@@ -200,7 +200,7 @@
"components/settings/ai/codex-provider-form.tsx :: CodexProviderForm": 280,
"components/settings/ai/fallback-chain-card.tsx :: FallbackChainCard": 228,
"components/settings/ai/runtime-actions-row.tsx :: RuntimeActionsRow": 282,
- "components/settings/api-section.tsx :: ApiTokensCard": 366,
+ "components/settings/api-section.tsx :: ApiTokensCard": 269,
"components/settings/coach-nudge-card.tsx :: CoachNudgeCard": 251,
"components/settings/coach-prefs-section.tsx :: CoachPrefsSection": 350,
"components/settings/environment-section.tsx :: EnvironmentSection": 666,
diff --git a/src/__tests__/baselines/version-stamp-comments.json b/src/__tests__/baselines/version-stamp-comments.json
index 3bc029651..2717c6546 100644
--- a/src/__tests__/baselines/version-stamp-comments.json
+++ b/src/__tests__/baselines/version-stamp-comments.json
@@ -1,7 +1,7 @@
{
"$comment": "Comment lines that open with a release version, per file under src/. Maintained by src/__tests__/version-stamp-ratchet.test.ts.",
"count": 2898,
- "total": 9296,
+ "total": 9295,
"entries": {
"__tests__/account-payload-consumer-guard.test.ts": 2,
"__tests__/acting-account-boundary-guard.test.ts": 7,
@@ -1359,7 +1359,7 @@
"components/settings/ai/response-timeout-card.tsx": 1,
"components/settings/ai/shared.ts": 4,
"components/settings/anamnesis-section.tsx": 2,
- "components/settings/api-section.tsx": 2,
+ "components/settings/api-section.tsx": 1,
"components/settings/coach-memory-section.tsx": 3,
"components/settings/coach-nudge-card.tsx": 4,
"components/settings/coach-prefs-section.tsx": 2,
diff --git a/src/__tests__/external-source-token-link-guard.test.ts b/src/__tests__/external-source-token-link-guard.test.ts
new file mode 100644
index 000000000..3c2eab454
--- /dev/null
+++ b/src/__tests__/external-source-token-link-guard.test.ts
@@ -0,0 +1,84 @@
+/**
+ * Every write that labels a measurement `EXTERNAL` also links it to the token
+ * that carried it (#1272).
+ *
+ * The label and the link answer one question together: whether the HealthLog
+ * app writes the reading into Apple Health is the choice its person made for
+ * that token (`apple-health-mirror.ts`). An EXTERNAL row written without the
+ * link resolves to false forever, whatever the token says, and nothing in the
+ * write path would notice. So the files that resolve `EXTERNAL_SOURCE` are an
+ * inventory: a measurement writer must set `ingestTokenId` at least as often
+ * as it assigns the label, and a file that starts using the constant fails
+ * here until it is classified.
+ *
+ * A tripwire, not a proof: it counts, it does not trace data flow. The
+ * behaviour is pinned by `tests/integration/apple-health-mirror.test.ts`.
+ */
+import { describe, expect, it } from "vitest";
+import { join } from "node:path";
+
+import {
+ readSourceText,
+ stripComments,
+ walkSourceFiles,
+} from "./helpers/source-files";
+
+const SRC = join(process.cwd(), "src");
+
+/** Files that write measurement rows labelled with the constant. */
+const MEASUREMENT_WRITERS = [
+ "lib/measurements/batch-ingest/prepare.ts",
+ "lib/measurements/create/create-readings.ts",
+ "lib/measurements/create/create-sleep-stage.ts",
+];
+
+/** Files that use the constant for something other than a measurement write. */
+const NOT_MEASUREMENT_WRITES: Record = {
+ "app/api/tokens/measurements/route.ts":
+ "counts the readings that belong to no token (a read)",
+ "lib/validations/measurement.ts": "the source enum and the edit allowlist",
+ "lib/workouts/batch-ingest/entries.ts":
+ "labels workout rows, which carry no token link",
+};
+
+function usesOfConstant(rel: string): {
+ assignments: number;
+ links: number;
+} {
+ const code = stripComments(readSourceText(join(SRC, rel)));
+ const assignments = (code.match(/\?\s*EXTERNAL_SOURCE\b/g) ?? []).length;
+ // A value written under the key, or the shorthand. Not a type member
+ // (`ingestTokenId: string | null`) and not a read (`writer.ingestTokenId`),
+ // which write nothing.
+ const links = (
+ code.match(/(? {
+ const files = walkSourceFiles(SRC, { floor: 3000 })
+ .filter((p) => !p.startsWith("generated/"))
+ .filter((p) => !p.includes("__tests__"))
+ .filter((p) => !/\.test\.tsx?$/.test(p))
+ .filter((p) => p !== "lib/measurements/external-source.ts")
+ .filter((p) =>
+ /\bEXTERNAL_SOURCE\b/.test(stripComments(readSourceText(join(SRC, p)))),
+ );
+
+ it("knows every file that uses the constant", () => {
+ expect(files.length).toBeGreaterThan(0);
+ expect(files).toEqual(
+ [...MEASUREMENT_WRITERS, ...Object.keys(NOT_MEASUREMENT_WRITES)].sort(),
+ );
+ });
+
+ it.each(MEASUREMENT_WRITERS)(
+ "%s links the token wherever it assigns the label",
+ (rel) => {
+ const { assignments, links } = usesOfConstant(rel);
+ expect(assignments).toBeGreaterThan(0);
+ expect(links).toBeGreaterThanOrEqual(assignments);
+ },
+ );
+});
diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts
index e0fd951f8..84e18c624 100644
--- a/src/app/api/measurements/[id]/route.ts
+++ b/src/app/api/measurements/[id]/route.ts
@@ -17,9 +17,13 @@ import {
validateMeasurementRange,
} from "@/lib/validations/measurement";
import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
+import {
+ APPLE_HEALTH_MIRROR_INCLUDE,
+ withAppleHealthMirror,
+} from "@/lib/measurements/apple-health-mirror";
import { invalidateUserMeasurements } from "@/lib/cache/invalidate";
import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation";
-import { Prisma } from "@/generated/prisma/client";
+import { Prisma, type MeasurementType } from "@/generated/prisma/client";
import { NextRequest } from "next/server";
import { z } from "zod";
@@ -37,6 +41,7 @@ export const GET = apiHandler(
// is not part of a unique index.
const measurement = await prisma.measurement.findFirst({
where: { id, deletedAt: null },
+ include: APPLE_HEALTH_MIRROR_INCLUDE,
});
if (!measurement || measurement.userId !== user.id) {
@@ -51,7 +56,9 @@ export const GET = apiHandler(
},
});
- return apiSuccess(shapeMeasurementNotes(measurement));
+ return apiSuccess(
+ withAppleHealthMirror(shapeMeasurementNotes(measurement)),
+ );
},
);
@@ -113,62 +120,8 @@ export const PUT = apiHandler(
// implausible edited value flowed unchecked into rollups, the health
// score, the BP gates and the Coach snapshot.
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. 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 (
- !(USER_CORRECTABLE_MEASUREMENT_SOURCES as readonly string[]).includes(
- existing.source,
- )
- ) {
- annotate({
- action: { name: "measurements.update.server-owned-source" },
- meta: { measurement_id: id, source: existing.source },
- });
- return apiError(
- "Values from a connected source cannot be edited",
- 409,
- { errorCode: "measurement.update.server_owned_source" },
- );
- }
-
- // Range check against the row's OWN type (the edit body carries no
- // type). Returned through the standard multi-issue 422 envelope so the
- // edit sheet renders it like any other field error.
- const rangeCheck = z
- .object({
- value: z.number().superRefine((value, ctx) => {
- const rangeError = validateMeasurementRange(existing.type, value);
- if (rangeError) {
- ctx.addIssue({ code: "custom", message: rangeError });
- }
- }),
- })
- .safeParse({ value: data.value });
- if (!rangeCheck.success) {
- annotate({
- action: { name: "measurements.update.validation-failed" },
- meta: {
- issue_count: rangeCheck.error.issues.length,
- measurement_id: id,
- reason: "value_out_of_range",
- },
- });
- return returnAllZodIssues(rangeCheck.error, 422);
- }
+ const refused = refuseValueEdit(existing, data.value, id);
+ if (refused) return refused;
}
let measurement;
@@ -185,6 +138,7 @@ export const PUT = apiHandler(
notesEncrypted: encryptNote(data.notes),
}),
},
+ include: APPLE_HEALTH_MIRROR_INCLUDE,
});
} catch (err) {
// v1.4.28 — re-pointing `measuredAt` onto an existing
@@ -252,7 +206,9 @@ export const PUT = apiHandler(
{ source: "manual" },
);
- return apiSuccess(shapeMeasurementNotes(measurement));
+ return apiSuccess(
+ withAppleHealthMirror(shapeMeasurementNotes(measurement)),
+ );
},
);
@@ -320,3 +276,69 @@ export const DELETE = apiHandler(
return apiSuccess({ deleted: true });
},
);
+
+/**
+ * Whether a changed value may be written over `existing`, as the refusal to
+ * answer when it may not; null when it may.
+ */
+function refuseValueEdit(
+ existing: { source: string; type: MeasurementType },
+ value: number,
+ id: string,
+): Response | null {
+ // 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. 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 (
+ !(USER_CORRECTABLE_MEASUREMENT_SOURCES as readonly string[]).includes(
+ existing.source,
+ )
+ ) {
+ annotate({
+ action: { name: "measurements.update.server-owned-source" },
+ meta: { measurement_id: id, source: existing.source },
+ });
+ return apiError("Values from a connected source cannot be edited", 409, {
+ errorCode: "measurement.update.server_owned_source",
+ });
+ }
+
+ // Range check against the row's OWN type (the edit body carries no
+ // type). Returned through the standard multi-issue 422 envelope so the
+ // edit sheet renders it like any other field error.
+ const rangeCheck = z
+ .object({
+ value: z.number().superRefine((value, ctx) => {
+ const rangeError = validateMeasurementRange(existing.type, value);
+ if (rangeError) {
+ ctx.addIssue({ code: "custom", message: rangeError });
+ }
+ }),
+ })
+ .safeParse({ value: value });
+ if (!rangeCheck.success) {
+ annotate({
+ action: { name: "measurements.update.validation-failed" },
+ meta: {
+ issue_count: rangeCheck.error.issues.length,
+ measurement_id: id,
+ reason: "value_out_of_range",
+ },
+ });
+ return returnAllZodIssues(rangeCheck.error, 422);
+ }
+ return null;
+}
diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts
index 2f3b785e0..921774089 100644
--- a/src/app/api/measurements/batch/route.ts
+++ b/src/app/api/measurements/batch/route.ts
@@ -261,6 +261,8 @@ async function postBatch(request: NextRequest): Promise {
const result = await ingestMeasurementBatch({
user: { id: user.id, timezone: user.timezone },
scoped,
+ // On the Bearer path the token's id rides `session.id`.
+ ingestTokenId: scoped ? auth.session.id : null,
entries,
syncTrigger: parsed.data.syncTrigger,
ipAddress: getClientIp(request),
diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts
index 1a142980f..e138554cb 100644
--- a/src/app/api/measurements/route.ts
+++ b/src/app/api/measurements/route.ts
@@ -21,6 +21,10 @@ import {
} from "@/lib/validations/measurement";
import { withIdempotency } from "@/lib/idempotency";
import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher";
+import {
+ type MeasurementWithMirrorInput,
+ withAppleHealthMirror,
+} from "@/lib/measurements/apple-health-mirror";
import { measurementListResponse } from "@/lib/measurements/list/measurement-list";
import {
createReading,
@@ -154,6 +158,8 @@ async function postMeasurement(request: NextRequest) {
const writer: ReadingWriter = {
user: { id: user.id, timezone: user.timezone },
scoped,
+ // On the Bearer path the token's id rides `session.id`.
+ ingestTokenId: scoped ? auth.session.id : null,
ipAddress: getClientIp(request),
};
@@ -202,7 +208,7 @@ async function postMeasurement(request: NextRequest) {
if ("duplicate" in created) {
return apiError(DUPLICATE_MESSAGE, 409, DUPLICATE_META);
}
- return apiSuccess(created.rows.map(shapeMeasurementNotes), 201);
+ return apiSuccess(created.rows.map(answerRow), 201);
}
// Single mode (existing behavior)
@@ -237,7 +243,12 @@ async function postMeasurement(request: NextRequest) {
if ("duplicate" in created) {
return apiError(DUPLICATE_MESSAGE, 409, DUPLICATE_META);
}
- return apiSuccess(shapeMeasurementNotes(created.row), 201);
+ return apiSuccess(answerRow(created.row), 201);
+}
+
+/** A written row as it is answered: the note readable, the write-back resolved. */
+function answerRow(row: MeasurementWithMirrorInput) {
+ return withAppleHealthMirror(shapeMeasurementNotes(row));
}
/**
@@ -262,6 +273,7 @@ async function postNamedSleepStage(
{
userId: writer.user.id,
scoped: writer.scoped,
+ ingestTokenId: writer.ingestTokenId,
ipAddress: writer.ipAddress,
},
parsed.data,
diff --git a/src/app/api/sync/changes/__tests__/module-gate.test.ts b/src/app/api/sync/changes/__tests__/module-gate.test.ts
index 5b5e004c9..e75cd7145 100644
--- a/src/app/api/sync/changes/__tests__/module-gate.test.ts
+++ b/src/app/api/sync/changes/__tests__/module-gate.test.ts
@@ -106,22 +106,25 @@ beforeEach(() => {
} as never);
// Non-cycle domains always return rows so a 403/empty-everything result
// cannot be mistaken for a passing "cycle omitted" assertion.
- vi.mocked(prisma.measurement.findMany).mockResolvedValue([
- {
- id: "m-1",
- externalId: "ext-m-1",
- type: "WEIGHT",
- value: 80,
- unit: "kg",
- measuredAt: NOW,
- source: "MANUAL",
- notes: null,
- notesEncrypted: null,
- syncVersion: 1,
- deletedAt: null,
- updatedAt: NOW,
- },
- ] as never);
+ // The `measurementMirror` walk reads measurements too (the readings of
+ // tokens whose Apple Health setting changed); it finds none here.
+ const measurementRow = {
+ id: "m-1",
+ externalId: "ext-m-1",
+ type: "WEIGHT",
+ value: 80,
+ unit: "kg",
+ measuredAt: NOW,
+ source: "MANUAL",
+ notes: null,
+ notesEncrypted: null,
+ syncVersion: 1,
+ deletedAt: null,
+ updatedAt: NOW,
+ };
+ vi.mocked(prisma.measurement.findMany).mockImplementation((async (args: {
+ where?: { ingestToken?: unknown };
+ }) => (args.where?.ingestToken ? [] : [measurementRow])) as never);
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue(
[] as never,
diff --git a/src/app/api/tokens/[id]/route.ts b/src/app/api/tokens/[id]/route.ts
index c6659d20d..9777d5aed 100644
--- a/src/app/api/tokens/[id]/route.ts
+++ b/src/app/api/tokens/[id]/route.ts
@@ -1,8 +1,12 @@
import { prisma } from "@/lib/db";
-import { apiHandler, requireAuth } from "@/lib/api-handler";
+import { apiHandler, requireAuth, requireCookieAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
-import { apiSuccess, apiError } from "@/lib/api-response";
+import { apiSuccess, apiError, getClientIp } from "@/lib/api-response";
import { isApiGloballyEnabled } from "@/lib/app-settings";
+import { parseJsonBody } from "@/lib/api/body";
+import { checkRateLimit } from "@/lib/rate-limit";
+import { updateMeasurementTokenSchema } from "@/lib/validations/tokens";
+import { setMeasurementTokenMirror } from "@/lib/measurements/token-mirror";
type RouteParams = { params: Promise<{ id: string }> };
@@ -38,3 +42,60 @@ export const DELETE = apiHandler(
return apiSuccess({ revoked: true });
},
);
+
+/**
+ * Change a measurement token's one setting: whether the HealthLog app writes
+ * its readings into Apple Health (#1272).
+ *
+ * - Cookie only. A Bearer, the token's own included, cannot change what its
+ * readings are allowed to become; the person decides, from the browser
+ * they are sitting at.
+ * - The caller's own tokens only: another account's answers 404.
+ * - Measurement tokens only. A wildcard, an MCP or any other narrow token has
+ * no such setting and is refused with 422 `tokens.mirror.not_measurement_token`.
+ * - A revoked token is accepted: its readings stay in the record.
+ *
+ * Not gated on the instance-wide API switch: no credential is created or
+ * extended, and the readings already in the record are what it governs.
+ */
+export const PATCH = apiHandler(
+ async (request: Request, { params }: RouteParams) => {
+ const { user } = await requireCookieAuth();
+ annotate({ action: { name: "tokens.measurements.mirror.update" } });
+
+ const rl = await checkRateLimit(
+ `tokens:mirror:${user.id}`,
+ MIRROR_RATE_LIMIT_MAX,
+ MIRROR_RATE_LIMIT_WINDOW_MS,
+ );
+ if (!rl.allowed) {
+ return apiError("Too many changes, try again later", 429);
+ }
+
+ const parsed = await parseJsonBody(request, updateMeasurementTokenSchema, {
+ maxBytes: 4 * 1024,
+ });
+ if (!parsed.ok) return parsed.error;
+
+ const { id } = await params;
+ const outcome = await setMeasurementTokenMirror({
+ userId: user.id,
+ tokenId: id,
+ mirrorToAppleHealth: parsed.data.mirrorToAppleHealth,
+ ipAddress: getClientIp(request),
+ });
+ if (outcome.kind === "not_found") {
+ return apiError("Token not found", 404);
+ }
+ if (outcome.kind === "not_measurement_token") {
+ return apiError("Only a measurement token has this setting", 422, {
+ errorCode: "tokens.mirror.not_measurement_token",
+ });
+ }
+ return apiSuccess({ id, mirrorToAppleHealth: outcome.mirrorToAppleHealth });
+ },
+);
+
+/** Changes per account per window: a settings switch, not an API. */
+const MIRROR_RATE_LIMIT_MAX = 30;
+const MIRROR_RATE_LIMIT_WINDOW_MS = 60 * 1000;
diff --git a/src/app/api/tokens/measurements/__tests__/route.test.ts b/src/app/api/tokens/measurements/__tests__/route.test.ts
index 3a584dd17..29beb8428 100644
--- a/src/app/api/tokens/measurements/__tests__/route.test.ts
+++ b/src/app/api/tokens/measurements/__tests__/route.test.ts
@@ -39,10 +39,10 @@ vi.mock("@/lib/logging/context", () => ({
}));
vi.mock("@/lib/db", () => ({
- prisma: { apiToken: { count: vi.fn() } },
+ prisma: { apiToken: { count: vi.fn() }, measurement: { count: vi.fn() } },
}));
-import { POST } from "../route";
+import { GET, POST } from "../route";
import { assertRecentCookieProof, requireCookieAuth } from "@/lib/api-handler";
import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
@@ -147,6 +147,27 @@ describe("what it mints", () => {
);
});
+ it("leaves the write-back off unless asked, and refuses a non-boolean", async () => {
+ const mint = (body: unknown) =>
+ POST(req(body) as Parameters[0]);
+ await mint({ name: "Scale" });
+ expect(vi.mocked(issueApiToken).mock.calls[0][0].mirrorToAppleHealth).toBe(
+ false,
+ );
+ await mint({ name: "Scale", mirrorToAppleHealth: true });
+ await mint({ name: "Scale", mirrorToAppleHealth: "yes" });
+ expect(issueApiToken).toHaveBeenCalledTimes(2);
+ expect(vi.mocked(issueApiToken).mock.calls[1][0].mirrorToAppleHealth).toBe(
+ true,
+ );
+ expect(auditLog).toHaveBeenLastCalledWith(
+ "tokens.measurements.create",
+ expect.objectContaining({
+ details: expect.objectContaining({ mirrorToAppleHealth: true }),
+ }),
+ );
+ });
+
it("counts only live tokens carrying this scope", async () => {
// The whole shape of the query, asserted rather than assumed. Counting
// every row would let the `["*"]` tokens a login mints consume the budget
@@ -239,3 +260,20 @@ describe("when it refuses", () => {
]);
});
});
+
+describe("the readings no token reaches", () => {
+ it("counts the caller's live EXTERNAL readings without a token", async () => {
+ vi.mocked(prisma.measurement.count).mockResolvedValue(4);
+ const res = await GET();
+ expect(res.status).toBe(200);
+ expect((await res.json()).data).toEqual({ unattributedReadings: 4 });
+ expect(prisma.measurement.count).toHaveBeenCalledWith({
+ where: {
+ userId: USER.id,
+ source: "EXTERNAL",
+ ingestTokenId: null,
+ deletedAt: null,
+ },
+ });
+ });
+});
diff --git a/src/app/api/tokens/measurements/route.ts b/src/app/api/tokens/measurements/route.ts
index 600b968db..2036ec183 100644
--- a/src/app/api/tokens/measurements/route.ts
+++ b/src/app/api/tokens/measurements/route.ts
@@ -21,8 +21,8 @@
* operate should not include making more credentials.
*
* No listing leg. `/api/tokens` already returns every token with its
- * `permissions`, so the settings card filters that one query rather than
- * duplicating it — the MCP module needs its own list only because it must hide
+ * `permissions` (and a measurement token's Apple Health setting), so the
+ * settings card filters that one query rather than duplicating it — the MCP module needs its own list only because it must hide
* transient OAuth access rows, and there is no such row here. No revoke-all
* either: `DELETE /api/tokens/[id]` is per-row and the card shows the rows. The
* per-medication toggle's bulk revoke exists because that surface is a boolean
@@ -44,6 +44,7 @@ import { issueApiToken } from "@/lib/auth/issue-token";
import { isApiGloballyEnabled } from "@/lib/app-settings";
import { checkRateLimit } from "@/lib/rate-limit";
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
+import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
import { createMeasurementTokenSchema } from "@/lib/validations/tokens";
/**
@@ -58,6 +59,28 @@ import { createMeasurementTokenSchema } from "@/lib/validations/tokens";
*/
const DEFAULT_EXPIRY_DAYS = 365;
+/**
+ * What the token list cannot say: how many of the account's EXTERNAL readings
+ * belong to no token (#1272). They were sent before readings were linked to
+ * the token that carried them, by an account holding more than one token, so
+ * no token's Apple Health setting reaches them and they stay out of Apple
+ * Health. The settings card shows a notice when the count is not zero.
+ */
+export const GET = apiHandler(async () => {
+ const { user } = await requireCookieAuth();
+ annotate({ action: { name: "tokens.measurements.summary" } });
+ const unattributedReadings = await prisma.measurement.count({
+ where: {
+ userId: user.id,
+ source: EXTERNAL_SOURCE,
+ ingestTokenId: null,
+ deletedAt: null,
+ },
+ });
+ annotate({ meta: { unattributed_readings: unattributedReadings } });
+ return apiSuccess({ unattributedReadings });
+});
+
/** Mints per user per minute. A credential mint is worth its own bucket. */
const MINT_RATE_LIMIT_MAX = 10;
const MINT_RATE_LIMIT_WINDOW_MS = 60 * 1000;
@@ -177,12 +200,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
// which is why the unit suite asserts this array and not merely the 201.
permissions: [MEASUREMENTS_WRITE_SCOPE],
expiresInDays: parsed.data.expiresInDays ?? DEFAULT_EXPIRY_DAYS,
+ mirrorToAppleHealth: parsed.data.mirrorToAppleHealth === true,
});
await auditLog("tokens.measurements.create", {
userId: user.id,
ipAddress: getClientIp(request),
- details: { tokenId: issued.tokenId, scope: MEASUREMENTS_WRITE_SCOPE },
+ details: {
+ tokenId: issued.tokenId,
+ scope: MEASUREMENTS_WRITE_SCOPE,
+ mirrorToAppleHealth: parsed.data.mirrorToAppleHealth === true,
+ },
});
// The raw token, once. It is stored as an HMAC and no path re-reveals it.
diff --git a/src/app/api/tokens/route.ts b/src/app/api/tokens/route.ts
index 0a653efb0..71d44c559 100644
--- a/src/app/api/tokens/route.ts
+++ b/src/app/api/tokens/route.ts
@@ -46,6 +46,9 @@ export const GET = apiHandler(async () => {
expiresAt: true,
createdAt: true,
revoked: true,
+ // A measurement token's Apple Health write-back (#1272); false for
+ // every other token.
+ mirrorToAppleHealth: true,
},
orderBy: { createdAt: "desc" },
});
diff --git a/src/components/settings/__tests__/apple-health-mirror-token.test.tsx b/src/components/settings/__tests__/apple-health-mirror-token.test.tsx
new file mode 100644
index 000000000..c06a93b50
--- /dev/null
+++ b/src/components/settings/__tests__/apple-health-mirror-token.test.tsx
@@ -0,0 +1,124 @@
+/**
+ * The Apple Health write-back controls on the API settings (#1272).
+ *
+ * SSR-only, as the rest of this directory: the render pins which token rows
+ * carry the switch and in which state, when the notice about readings that
+ * belong to no token shows, and the write the switch sends is pinned against
+ * a stubbed `fetch`.
+ */
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { renderToStaticMarkup } from "react-dom/server";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+
+import { I18nProvider } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+vi.mock("@/hooks/use-auth", () => ({
+ useAuth: () => ({ isAuthenticated: true }),
+}));
+
+import {
+ AppleHealthMirrorOptIn,
+ AppleHealthMirrorSwitch,
+ UnattributedReadingsNotice,
+ patchTokenMirror,
+} from "../apple-health-mirror-token";
+
+function render(node: React.ReactNode, client = new QueryClient()) {
+ return renderToStaticMarkup(
+
+ {node}
+ ,
+ );
+}
+
+const token = (permissions: string[], mirrorToAppleHealth = false) => ({
+ id: "tok-1",
+ name: "Scale",
+ permissions,
+ mirrorToAppleHealth,
+});
+
+describe("the switch on a token row", () => {
+ it("appears on a measurement token only", () => {
+ expect(
+ render(),
+ ).toContain('data-slot="apple-health-mirror-switch"');
+ for (const permissions of [["*"], ["health:read"], ["workouts:write"]]) {
+ expect(
+ render(),
+ ).toBe("");
+ }
+ });
+
+ it("shows the stored setting and names the token", () => {
+ const on = render(
+ ,
+ );
+ expect(on).toContain('aria-checked="true"');
+ expect(on).toContain("Write readings from Scale to Apple Health");
+ const off = render(
+ ,
+ );
+ expect(off).toContain('aria-checked="false"');
+ });
+});
+
+describe("the opt-in on the mint form", () => {
+ it("reads as the setting with its explanation", () => {
+ const html = render(
+ {}} />,
+ );
+ expect(html).toContain("Write readings to Apple Health");
+ expect(html).toContain("adds this token's readings to Apple Health");
+ });
+});
+
+describe("the notice about readings no token reaches", () => {
+ function withSummary(unattributedReadings: number) {
+ const client = new QueryClient();
+ client.setQueryData(queryKeys.measurementTokenSummary(), {
+ unattributedReadings,
+ });
+ return render(, client);
+ }
+
+ it("shows only when there are such readings", () => {
+ expect(withSummary(3)).toContain(
+ "Readings sent before this setting existed can't be matched to a token",
+ );
+ expect(withSummary(0)).toBe("");
+ expect(render()).toBe("");
+ });
+});
+
+describe("the write", () => {
+ let fetchMock: ReturnType;
+ beforeEach(() => {
+ fetchMock = vi.fn(
+ async () =>
+ new Response(
+ JSON.stringify({
+ data: { id: "tok 1", mirrorToAppleHealth: true },
+ error: null,
+ }),
+ { status: 200, headers: { "Content-Type": "application/json" } },
+ ),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ });
+ afterEach(() => vi.unstubAllGlobals());
+
+ it("patches the token with the one field", async () => {
+ await expect(patchTokenMirror("tok 1", true)).resolves.toEqual({
+ id: "tok 1",
+ mirrorToAppleHealth: true,
+ });
+ const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
+ expect(url).toBe("/api/tokens/tok%201");
+ expect(init.method).toBe("PATCH");
+ expect(JSON.parse(init.body as string)).toEqual({
+ mirrorToAppleHealth: true,
+ });
+ });
+});
diff --git a/src/components/settings/api-endpoints-card.tsx b/src/components/settings/api-endpoints-card.tsx
new file mode 100644
index 000000000..4f7889947
--- /dev/null
+++ b/src/components/settings/api-endpoints-card.tsx
@@ -0,0 +1,130 @@
+"use client";
+
+import { Key } from "lucide-react";
+
+import { Badge } from "@/components/ui/badge";
+import { SettingsCard } from "@/components/settings/settings-card";
+import { SettingsCardHeader } from "@/components/settings/_card-header";
+import { useTranslations } from "@/lib/i18n/context";
+
+/** The ingest endpoints a token reaches, with the header and a body sketch. */
+export function ApiEndpointsCard() {
+ const { t } = useTranslations();
+
+ // Dual-render: `. The endpoint catalogue is array-driven
+ // so adding a row remains a one-line edit on both surfaces — which is
+ // what the two measurement ingest rows below cost.
+ const endpoints = [
+ {
+ method: "POST",
+ path: "/api/ingest/medication",
+ auth: "Authorization: Bearer hlk_...",
+ example: `{ "medicationName": "...", "scheduledFor": "..." }`,
+ },
+ {
+ method: "POST",
+ path: "/api/measurements",
+ auth: "Authorization: Bearer hlk_...",
+ example: `{ "type": "WEIGHT", "value": 82.4, "measuredAt": "..." }`,
+ },
+ {
+ method: "POST",
+ path: "/api/measurements/batch",
+ auth: "Authorization: Bearer hlk_...",
+ example: `{ "entries": [ { "hkIdentifier": "...", "value": 1, ... } ] }`,
+ },
+ {
+ method: "POST",
+ path: "/api/workouts/batch",
+ auth: "Authorization: Bearer hlk_...",
+ example: `{ "workouts": [ { "sportType": "running", "startedAt": "...", "endedAt": "...", "externalId": "..." } ] }`,
+ },
+ {
+ method: "POST",
+ path: "/api/documents/inbound",
+ auth: "Authorization: Bearer hlk_...",
+ example: `multipart: file, title, documentDate, sourceSystem, sourceId`,
+ },
+ ];
+
+ return (
+
+
+
+ {/* Desktop table — verbatim layout for md+. */}
+
+
+
+
+
+ {t("settings.apiEndpointMethod")}
+
+
+ {t("settings.apiEndpointPath")}
+
+
+ {t("settings.apiEndpointAuth")}
+
+
+ {t("settings.apiEndpointExample")}
+
+
+
+
+ {endpoints.map((endpoint) => (
+
+
{endpoint.method}
+
{endpoint.path}
+
{endpoint.auth}
+
{endpoint.example}
+
+ ))}
+
+
+
+
+ {/* Mobile card list — each endpoint stacks its columns vertically
+ with a labelled meta row. No horizontal scroll. */}
+
+ {endpoints.map((endpoint) => (
+
+
+
+ {endpoint.method}
+
+ {endpoint.path}
+
+
+
+ {t("settings.apiEndpointAuth")}
+
+ {endpoint.auth}
+
+
+
+ {t("settings.apiEndpointExample")}
+
+
+ {endpoint.example}
+
+
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/settings/api-section.tsx b/src/components/settings/api-section.tsx
index 4279624c7..241d1f45f 100644
--- a/src/components/settings/api-section.tsx
+++ b/src/components/settings/api-section.tsx
@@ -31,6 +31,12 @@ import { formatDate, formatDateTime } from "@/lib/format";
import { useTranslations } from "@/lib/i18n/context";
import { queryKeys } from "@/lib/query-keys";
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
+import { ApiEndpointsCard } from "@/components/settings/api-endpoints-card";
+import {
+ AppleHealthMirrorOptIn,
+ AppleHealthMirrorSwitch,
+ UnattributedReadingsNotice,
+} from "@/components/settings/apple-health-mirror-token";
import {
recentProofErrorMessage,
throwIfReproofRequired,
@@ -45,6 +51,7 @@ interface ApiTokenInfo {
expiresAt: string | null;
createdAt: string;
revoked: boolean;
+ mirrorToAppleHealth: boolean;
}
export function ApiSection() {
@@ -78,6 +85,8 @@ function MeasurementTokensCard() {
icon={Upload}
slot="settings-measurements-token-created"
anchor="measurements-token"
+ mirrorOptIn
+ footer={}
copy={{
title: t("settings.measurementsToken.title"),
description: t("settings.measurementsToken.description"),
@@ -147,7 +156,12 @@ function IngestTokenCard({
slot,
anchor,
copy,
+ mirrorOptIn = false,
+ footer,
}: {
+ /** Offer the Apple Health write-back on the mint (measurement tokens). */
+ mirrorOptIn?: boolean;
+ footer?: React.ReactNode;
/** Deep-link anchor for the card (`/settings/api#`). */
anchor: string;
endpoint:
@@ -172,6 +186,7 @@ function IngestTokenCard({
const [creating, setCreating] = useState(false);
const [tokenMsg, setTokenMsg] = useState(null);
const [tokenCopied, setTokenCopied] = useState(false);
+ const [mirror, setMirror] = useState(false);
// The token outlives the session, so minting one asks for a fresh proof
// unless the session signed in or re-proved within five minutes.
const recentProof = useRecentProof();
@@ -184,7 +199,10 @@ function IngestTokenCard({
try {
// No scope field: the endpoint mints one shape, so there is nothing
// here for a caller to choose or for this form to get wrong.
- const body = JSON.stringify({ name: newName.trim() });
+ const body = JSON.stringify({
+ name: newName.trim(),
+ ...(mirrorOptIn && { mirrorToAppleHealth: mirror }),
+ });
const res = await recentProof.run(async () =>
throwIfReproofRequired(
await apiFetchRaw(endpoint, {
@@ -198,6 +216,7 @@ function IngestTokenCard({
if (res.ok) {
setNewToken(json.data.token);
setNewName("");
+ setMirror(false);
// The sibling card lists this token, so it has to hear about it.
queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
} else {
@@ -263,6 +282,12 @@ function IngestTokenCard({
{t("common.create")}
+ {mirrorOptIn && (
+
+ )}
{newToken && (
+ );
+}
diff --git a/src/components/settings/apple-health-mirror-token.tsx b/src/components/settings/apple-health-mirror-token.tsx
new file mode 100644
index 000000000..843d352bf
--- /dev/null
+++ b/src/components/settings/apple-health-mirror-token.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+/**
+ * A measurement token's one setting (#1272): whether the HealthLog app on the
+ * iPhone writes the readings that token sent into Apple Health.
+ *
+ * The readings keep their EXTERNAL source either way; the server resolves
+ * the decision per reading and the app follows it. Three pieces, used by the
+ * API settings: the switch on a measurement token's row, the opt-in on the
+ * mint form, and the notice about readings that belong to no token.
+ */
+import { useState } from "react";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+
+import { Checkbox } from "@/components/ui/checkbox";
+import { Switch } from "@/components/ui/switch";
+import { useAuth } from "@/hooks/use-auth";
+import { apiGet, apiPatch } from "@/lib/api/api-fetch";
+import { showErrorToast } from "@/lib/errors/error-toast";
+import { useTranslations } from "@/lib/i18n/context";
+import { queryKeys } from "@/lib/query-keys";
+
+/** The scope a measurement token carries, and nothing else. */
+const MEASUREMENTS_WRITE = "measurements:write";
+
+export function isMeasurementTokenInfo(permissions: readonly string[]) {
+ return permissions.length === 1 && permissions[0] === MEASUREMENTS_WRITE;
+}
+
+/** `PATCH /api/tokens/{id}`: the one write behind the switch. */
+export function patchTokenMirror(
+ tokenId: string,
+ mirrorToAppleHealth: boolean,
+) {
+ return apiPatch<{ id: string; mirrorToAppleHealth: boolean }>(
+ `/api/tokens/${encodeURIComponent(tokenId)}`,
+ { mirrorToAppleHealth },
+ );
+}
+
+/** The switch on a measurement token's row; nothing on any other token. */
+export function AppleHealthMirrorSwitch({
+ token,
+}: {
+ token: {
+ id: string;
+ name: string;
+ permissions: string[];
+ mirrorToAppleHealth: boolean;
+ };
+}) {
+ const { t } = useTranslations();
+ const queryClient = useQueryClient();
+ const [saving, setSaving] = useState(false);
+ if (!isMeasurementTokenInfo(token.permissions)) return null;
+
+ async function handleChange(next: boolean) {
+ setSaving(true);
+ try {
+ await patchTokenMirror(token.id, next);
+ await queryClient.invalidateQueries({ queryKey: queryKeys.tokens() });
+ } catch (failure) {
+ showErrorToast(t("settings.measurementsToken.appleHealth.updateFailed"), {
+ cause: failure,
+ });
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+ );
+}
+
+/** The opt-in on the measurement mint form. Off unless the person ticks it. */
+export function AppleHealthMirrorOptIn({
+ checked,
+ onCheckedChange,
+}: {
+ checked: boolean;
+ onCheckedChange: (next: boolean) => void;
+}) {
+ const { t } = useTranslations();
+ return (
+
+ );
+}
+
+/**
+ * Shown only when the account holds EXTERNAL readings no token reaches: sent
+ * before readings were linked to their token, by an account that held more
+ * than one, so no setting here can put them into Apple Health.
+ */
+export function UnattributedReadingsNotice() {
+ const { t } = useTranslations();
+ const { isAuthenticated } = useAuth();
+ const { data } = useQuery({
+ queryKey: queryKeys.measurementTokenSummary(),
+ queryFn: () =>
+ apiGet<{ unattributedReadings: number }>("/api/tokens/measurements"),
+ enabled: isAuthenticated,
+ });
+ if (!data || !(data.unattributedReadings > 0)) return null;
+ return (
+