diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index e43e59801..d1a29a746 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -13,8 +13,8 @@ import { } from "@/lib/api-response"; import { updateMeasurementSchema, + USER_CORRECTABLE_MEASUREMENT_SOURCES, validateMeasurementRange, - WRITABLE_MEASUREMENT_SOURCES, } from "@/lib/validations/measurement"; import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; @@ -120,12 +120,22 @@ export const PUT = apiHandler( if (data.value !== undefined && data.value !== existing.value) { // Server-owned rows first: a value attributed to a connector / import / // computed engine is the provider's reading — editing the number would - // forge a source-attributed row the server never received. Mirrors the - // write-side classification (`WRITABLE_MEASUREMENT_SOURCES`): only - // MANUAL and APPLE_HEALTH rows are client-owned. Timestamp and note - // edits stay allowed — annotating a Withings reading is legitimate. + // forge a source-attributed row the server never received. Timestamp + // and note edits stay allowed — annotating a Withings reading is + // legitimate. + // + // v1.38.x — gated on `USER_CORRECTABLE_MEASUREMENT_SOURCES` rather than + // the write-side `WRITABLE_MEASUREMENT_SOURCES` the two once shared, + // because `EXTERNAL` separates the questions. A client may not NAME that + // source (it is resolved from the ingest credential, and the write + // allowlist is published as `ingest.writeAllowlist`), but the hardware + // behind the token is the user's own scale, so the rationale above — + // "the value is the provider's" — does not reach it. Locking those rows + // would also break the correctability the settings card and the Home + // Assistant guide promise. This is the only site that reads the wider + // set; re-merging the two constants silently value-locks bridge rows. if ( - !(WRITABLE_MEASUREMENT_SOURCES as readonly string[]).includes( + !(USER_CORRECTABLE_MEASUREMENT_SOURCES as readonly string[]).includes( existing.source, ) ) { diff --git a/src/lib/measurements/external-source.ts b/src/lib/measurements/external-source.ts new file mode 100644 index 000000000..95b6ccea9 --- /dev/null +++ b/src/lib/measurements/external-source.ts @@ -0,0 +1,24 @@ +/** + * The `MeasurementSource` value rows written through the ingest token carry. + * + * A leaf module with no imports, for the same reason `scopes.ts` next door is + * one: the two write routes, the edit gate, the Zod source enum and the + * integration suites all read this string, and none of them should inherit an + * import graph to get it. `MCP_SOURCE` (`@/lib/mcp/writes.ts`) is module-local + * because it has exactly one writer; this one does not, and two sibling + * declarations of the same literal is the drift a shared leaf exists to avoid. + * + * Not folded into `scopes.ts`. A provenance label living in a file named + * `scopes.ts` is one somebody later imports "because it was there", and the + * two answer different questions: one is what a credential may do, the other + * is what a row came from. + * + * No client may name it. It is deliberately absent from + * `WRITABLE_MEASUREMENT_SOURCES` and from the batch route's `batchSourceEnum`, + * so the only way a row acquires it is the server resolving it from the + * credential that carried the write — which is the whole point: a source a + * client could assert would prove nothing about where the reading came from. + */ + +/** Provenance for a measurement pushed in under `measurements:write`. */ +export const EXTERNAL_SOURCE = "EXTERNAL" as const; diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 104043716..0fe13f33d 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -1,4 +1,5 @@ import { z } from "zod/v4"; +import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source"; import { validateEntryInstant } from "./entry-instant"; export const measurementTypeEnum = z.enum([ @@ -222,6 +223,16 @@ export const measurementSourceEnum = z.enum([ // from `WRITABLE_MEASUREMENT_SOURCES` + the batch allowlist so a client can // never forge a STRAVA-attributed row. "STRAVA", + // v1.38.x — measurements pushed in under a `measurements:write` Bearer + // token (a home-automation bridge, a scale's own uploader). Server-resolved + // from the credential rather than named by the caller, so it is deliberately + // absent from `WRITABLE_MEASUREMENT_SOURCES` and the batch allowlist — a + // source a client could assert would prove nothing about where the reading + // came from. Part of this enum so the read/response shapes (and the iOS + // decoder) can decode the rows it surfaces. Unlike the server-owned sources + // above these rows stay value-editable; see + // `USER_CORRECTABLE_MEASUREMENT_SOURCES`. + "EXTERNAL", ]); /** @@ -251,6 +262,35 @@ export const writableMeasurementSourceEnum = z.enum( WRITABLE_MEASUREMENT_SOURCES, ); +/** + * v1.38.x — the subset of `MeasurementSource` whose VALUE the owner may still + * edit after the fact. A strictly different question from the one + * `WRITABLE_MEASUREMENT_SOURCES` answers, and the two are kept apart on + * purpose: + * + * - writable = "may a client ASSERT this source on a write?" + * - correctable = "is the number this row carries the user's own to fix?" + * + * `EXTERNAL` is no on the first and yes on the second. A client naming it + * would be forging provenance, so it stays out of the write allowlist — which + * `/api/meta/capabilities` publishes as `ingest.writeAllowlist`, so widening + * that constant would tell every client the source is theirs to claim. But the + * hardware behind an ingest token is the user's own scale, not a provider's + * reading: the 409 that protects a Withings row ("the value is the provider's, + * editing it would forge a row the server never received") has no force here, + * and locking these rows would contradict what the settings card and the + * Home Assistant guide both promise. + * + * Read at exactly one call site — the value-edit gate in + * `PUT /api/measurements/[id]`. Everything else keeps reading + * `WRITABLE_MEASUREMENT_SOURCES`; folding the two back together re-conflates + * the questions above. + */ +export const USER_CORRECTABLE_MEASUREMENT_SOURCES = [ + ...WRITABLE_MEASUREMENT_SOURCES, + EXTERNAL_SOURCE, +] as const; + const unitMap: Record = { WEIGHT: "kg", BLOOD_PRESSURE_SYS: "mmHg",