From 5ec361abec749c0aad3b7aa943661e3169a7a8ba Mon Sep 17 00:00:00 2001 From: Antiheld86 <4201585+Antiheld86@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:14:36 +0000 Subject: [PATCH] feat(measurements): mirror EXTERNAL in Zod, keep those rows correctable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the `EXTERNAL_SOURCE` leaf beside `scopes.ts` and mirrors the value in `measurementSourceEnum` so the read shapes and the sync decoder see it. It stays out of `WRITABLE_MEASUREMENT_SOURCES`: a source a client could assert would prove nothing about where a reading came from, and that constant is published to clients as `ingest.writeAllowlist`. The value-edit gate in `PUT /api/measurements/[id]` needs the other question answered, though — whether the number is the user's own to fix — so it now reads a separate `USER_CORRECTABLE_MEASUREMENT_SOURCES`. The 409 that protects a Withings row exists because the value is the provider's reading; the hardware behind an ingest token is the user's own scale, and locking those rows would contradict what the settings card and the Home Assistant guide promise. That is the only site reading the wider set. --- src/app/api/measurements/[id]/route.ts | 22 ++++++++++---- src/lib/measurements/external-source.ts | 24 +++++++++++++++ src/lib/validations/measurement.ts | 40 +++++++++++++++++++++++++ 3 files changed, 80 insertions(+), 6 deletions(-) create mode 100644 src/lib/measurements/external-source.ts 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",