Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
157 changes: 144 additions & 13 deletions docs/api/openapi.yaml

Large diffs are not rendered by default.

31 changes: 23 additions & 8 deletions docs/integrations/home-assistant.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions messages/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -5057,7 +5058,8 @@
"TELEGRAM": "Telegram",
"MCP": "MCP",
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava"
"STRAVA": "Strava",
"EXTERNAL": "Externes Gerät"
},
"deviceLabels": {
"watch": "Uhr",
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 4 additions & 2 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -5057,7 +5058,8 @@
"TELEGRAM": "Telegram",
"MCP": "MCP",
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava"
"STRAVA": "Strava",
"EXTERNAL": "External device"
},
"deviceLabels": {
"watch": "Watch",
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 4 additions & 2 deletions messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -5057,7 +5058,8 @@
"TELEGRAM": "Telegram",
"MCP": "MCP",
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava"
"STRAVA": "Strava",
"EXTERNAL": "Dispositivo externo"
},
"deviceLabels": {
"watch": "Reloj",
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 4 additions & 2 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -5057,7 +5058,8 @@
"TELEGRAM": "Telegram",
"MCP": "MCP",
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava"
"STRAVA": "Strava",
"EXTERNAL": "Appareil externe"
},
"deviceLabels": {
"watch": "Montre",
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 4 additions & 2 deletions messages/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -5057,7 +5058,8 @@
"TELEGRAM": "Telegram",
"MCP": "MCP",
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava"
"STRAVA": "Strava",
"EXTERNAL": "Dispositivo esterno"
},
"deviceLabels": {
"watch": "Orologio",
Expand Down Expand Up @@ -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.",
Expand Down
6 changes: 4 additions & 2 deletions messages/ko.json
Original file line number Diff line number Diff line change
Expand Up @@ -745,6 +745,7 @@
"sourceMcp": "MCP",
"sourceGoogleHealth": "Google Health",
"sourceStrava": "Strava",
"sourceExternal": "외부 기기",
"bulkDeleteSuccess": "{count}개 측정을 삭제했어요",
"bulkDeleteSuccessOne": "측정을 삭제했어요",
"bulkDeleteError": "선택한 측정을 삭제하지 못했어요",
Expand Down Expand Up @@ -5057,7 +5058,8 @@
"TELEGRAM": "Telegram",
"MCP": "MCP",
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava"
"STRAVA": "Strava",
"EXTERNAL": "외부 기기"
},
"deviceLabels": {
"watch": "워치",
Expand Down Expand Up @@ -6070,7 +6072,7 @@
"title": "측정 토큰",
"description": "체중계나 시계, 홈 오토메이션이 측정값을 기록할 수 있도록 토큰을 만들어요.",
"detail": "토큰은 한 번만 표시돼요.",
"scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 직접 입력한 것처럼 들어오니 언제든 고칠 수 있어요.",
"scopeNote": "본인 기록에 새 측정값을 추가하는 것만 할 수 있어요. 데이터를 읽거나, 무언가를 고치거나 지우거나, 다른 토큰을 만들 수는 없어요. 측정값은 외부 기기에서 온 것으로 표시되니 나중에 구분할 수 있고, 언제든 고칠 수 있어요.",
"createFailed": "토큰을 만들지 못했어요. 다시 시도해 주세요."
},
"tokenMintMovedDescription": "토큰은 쓰이는 곳에서 발급돼요. 측정값은 위에서, 특정 약은 그 약의 API 엔드포인트에서 만들 수 있어요.",
Expand Down
6 changes: 4 additions & 2 deletions messages/pl.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -5057,7 +5058,8 @@
"TELEGRAM": "Telegram",
"MCP": "MCP",
"GOOGLE_HEALTH": "Google Health",
"STRAVA": "Strava"
"STRAVA": "Strava",
"EXTERNAL": "Urządzenie zewnętrzne"
},
"deviceLabels": {
"watch": "Zegarek",
Expand Down Expand Up @@ -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.",
Expand Down
27 changes: 27 additions & 0 deletions prisma/migrations/0342_measurement_source_external/migration.sql
Original file line number Diff line number Diff line change
@@ -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';
15 changes: 15 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
22 changes: 16 additions & 6 deletions src/app/api/measurements/[id]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
)
) {
Expand Down
Loading
Loading