Merge branch 'main' into release/v1.39.1

This commit is contained in:
Marc-André Bombeck committed 2026-09-25 07:21:34 +02:00
commit 702a13873d
23 files changed
+692 -92

No files matched your search

+16 -6
View File
@@ -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,
)
) {
+32 -20
View File
@@ -39,6 +39,7 @@ import { z } from "zod/v4";
import { prisma } from "@/lib/db";
import { apiHandler, isScopedCredential, requireAuth } from "@/lib/api-handler";
import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
import { annotate } from "@/lib/logging/context";
import { auditLog } from "@/lib/auth/audit";
@@ -283,25 +284,34 @@ async function postBatch(request: NextRequest): Promise<Response> {
);
}
// A scoped credential may attribute MANUAL and nothing else. Refused loudly
// rather than rewritten quietly: silently relabelling a client's explicit
// assertion hands it rows it did not ask for and no way to notice, while a
// 422 is fixable on the first call.
// A scoped credential may attribute nothing at all — the source of its rows
// is `EXTERNAL`, resolved from the credential below. Refused loudly rather
// than rewritten quietly: silently relabelling a client's explicit assertion
// hands it rows it did not ask for and no way to notice, while a 422 is
// fixable on the first call.
//
// `APPLE_HEALTH` is the label that matters, and forging it is not merely
// mislabelling. It is half the `(userId, type, source, externalId)` dedup key,
// so a bridge's rows would land in the phone's namespace; it is a mergeable
// source, so a forged row joins the cross-source merge and can suppress a
// genuine one; and it is what `healthKitSyncSucceeded` reads to decide the
// Apple Health card may claim the phone synced.
if (scoped && parsed.data.entries.some((e) => e.source === "APPLE_HEALTH")) {
// `APPLE_HEALTH` is still the label that matters most, and forging it is not
// merely mislabelling. It is half the `(userId, type, source, externalId)`
// dedup key, so a bridge's rows would land in the phone's namespace; it is a
// mergeable source, so a forged row joins the cross-source merge and can
// suppress a genuine one; and it is what `healthKitSyncSucceeded` reads to
// decide the Apple Health card may claim the phone synced.
//
// v1.38.x — `MANUAL` joins it in being refused. It was permitted only
// because it was the value the route forced anyway, so nothing was ever
// overridden; now that bridge rows carry their own provenance, honouring it
// would defeat the point and overriding it would be the quiet relabelling
// the paragraph above rules out. Status and `errorCode` are unchanged.
if (scoped && parsed.data.entries.some((e) => e.source !== undefined)) {
annotate({
action: { name: "measurement.batch.ingest" },
meta: { outcome: "source_not_permitted" },
});
return apiError("This credential may only attribute MANUAL readings", 422, {
errorCode: "measurement.batch.source_not_permitted",
});
return apiError(
"This credential resolves the source itself; omit the field",
422,
{ errorCode: "measurement.batch.source_not_permitted" },
);
}
const { entries, syncTrigger } = parsed.data;
@@ -446,12 +456,14 @@ async function postBatch(request: NextRequest): Promise<Response> {
// v1.8.6 W6 — honour the per-entry source tag, defaulting to
// `APPLE_HEALTH` when absent so legacy callers are unchanged.
//
// A scoped credential defaults to `MANUAL` instead, and can hold no
// other value: the guard above already refused an explicit
// `APPLE_HEALTH`, so this is the only remaining arm for it. The default
// has to move with the credential rather than with the route, because
// the route serves both the phone and a third-party bridge.
source: scoped ? "MANUAL" : (entry.source ?? "APPLE_HEALTH"),
// v1.38.x — a scoped credential gets `EXTERNAL`, and can hold no other
// value: the guard above refused any body that named one, so nothing
// asserted is being discarded here. The value has to move with the
// credential rather than with the route, because the route serves both
// the phone and a third-party bridge. It also moves the dedup key to
// `(userId, type, EXTERNAL, externalId)`, which is the isolated
// namespace this source exists to give.
source: scoped ? EXTERNAL_SOURCE : (entry.source ?? "APPLE_HEALTH"),
measuredAt: mapped.takenAt,
externalId: entry.externalId,
externalSourceVersion: entry.externalSourceVersion ?? null,
+57 -19
View File
@@ -4,6 +4,7 @@ import {
isScopedCredential,
requireRecordAuth,
} from "@/lib/api-handler";
import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source";
import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes";
import { annotate } from "@/lib/logging/context";
import { fireAndForget } from "@/lib/logging/fire-and-forget";
@@ -678,19 +679,29 @@ async function sleepListResponse(
}
/**
* Does this body attribute any reading to `APPLE_HEALTH`?
* Does this body attribute a source to any reading — any source at all?
*
* Read off the raw body rather than the parsed result because the answer has to
* be the same for the object arm and the array arm, and those parse against
* different schemas further down. Anything that is not a recognisable
* measurement shape answers false and falls through to the validation error it
* has coming — this function decides attribution, not validity.
* Read off the RAW body rather than the parsed result, and that is load-bearing
* twice over. It has to give the same answer for the object arm and the array
* arm, which parse against different schemas further down; and
* `createMeasurementSchema.source` carries `.default("MANUAL")`, so after
* parsing "said MANUAL" and "said nothing" are the same value and the
* distinction this predicate exists to draw is already gone.
*
* v1.38.x — widened from "names APPLE_HEALTH" to "names anything". Under a
* scoped credential the row's source is now resolved by the server, so an
* explicit `MANUAL` is an assertion the route would have to override — which
* is the silent relabelling the refusal below refuses to do.
*
* Anything that is not a recognisable measurement shape answers false and
* falls through to the validation error it has coming: this function decides
* attribution, not validity.
*/
function namesAppleHealthSource(body: unknown): boolean {
function namesAnySource(body: unknown): boolean {
const names = (entry: unknown): boolean =>
typeof entry === "object" &&
entry !== null &&
(entry as { source?: unknown }).source === "APPLE_HEALTH";
(entry as { source?: unknown }).source !== undefined;
return Array.isArray(body) ? body.some(names) : names(body);
}
@@ -730,16 +741,28 @@ async function postMeasurement(request: NextRequest) {
if (jsonError) return jsonError;
// A scoped credential may attribute MANUAL and nothing else — the schema's
// own default, so only an explicit `APPLE_HEALTH` needs refusing. Checked
// before validation branches so the object and array arms share one answer,
// and refused rather than rewritten for the reason the batch route gives:
// relabelling a client's explicit assertion hands it rows it cannot see.
if (scoped && namesAppleHealthSource(body)) {
// A scoped credential may attribute nothing: the source of its rows is
// `EXTERNAL`, decided here from the credential rather than taken from the
// body. Checked before the validation branches so the object and array arms
// share one answer, and refused rather than rewritten for the reason the
// batch route gives: relabelling a client's explicit assertion hands it rows
// it did not ask for and no way to notice.
//
// v1.38.x — this used to refuse only an explicit `APPLE_HEALTH`, because the
// forced value was `MANUAL` and so a body naming `MANUAL` was never actually
// overridden. Now that bridge rows carry their own provenance, honouring
// such a body would defeat the point (a bridge that kept sending `MANUAL`
// would stay indistinguishable from a typed reading) and overriding it
// silently is what the paragraph above forbids. Status and `errorCode` are
// unchanged, so client error handling is untouched; the documented Home
// Assistant payload sends no `source` at all.
if (scoped && namesAnySource(body)) {
annotate({ action: { name: "measurements.create.source-not-permitted" } });
return apiError("This credential may only attribute MANUAL readings", 422, {
errorCode: "measurement.create.source_not_permitted",
});
return apiError(
"This credential resolves the source itself; omit the field",
422,
{ errorCode: "measurement.create.source_not_permitted" },
);
}
// Batch mode (array of measurements, e.g. combined BP + Pulse)
@@ -791,7 +814,16 @@ async function postMeasurement(request: NextRequest) {
type: m.type as MeasurementType,
value: m.value,
unit: getUnitForType(m.type),
source: (m.source ?? "MANUAL") as MeasurementSource,
// v1.38.x — the array arm too, not just the single create
// below. This is the combined BP + Pulse path a bridge uses for
// a two-value reading; missing it here would leave exactly those
// rows attributed MANUAL and invisible to the badge and the
// source filter. `scoped` already implies `m.source` is
// undefined — the refusal above returned otherwise — so this is
// the resolution, not an override.
source: (scoped
? EXTERNAL_SOURCE
: (m.source ?? "MANUAL")) as MeasurementSource,
measuredAt: m.measuredAt,
// v1.23 — encrypt the note at rest; the legacy plaintext column is
// written null for new rows.
@@ -946,7 +978,13 @@ async function postMeasurement(request: NextRequest) {
type: type as MeasurementType,
value,
unit: getUnitForType(type),
source: (source ?? "MANUAL") as MeasurementSource,
// v1.38.x — server-resolved under a scoped credential. `source` is
// necessarily the schema default here (the refusal above returned on
// any body that named one), so nothing a caller asserted is being
// discarded.
source: (scoped
? EXTERNAL_SOURCE
: (source ?? "MANUAL")) as MeasurementSource,
measuredAt,
// v1.23 — encrypt the note at rest; legacy plaintext column nulled.
notes: null,
@@ -515,3 +515,112 @@ describe("pickCanonicalSourceRows — v1.4.25 W8c two-axis device-type picker",
expect(out.canonicalRows[0].source).toBe("WITHINGS");
});
});
/**
* v1.38.x — what the EXTERNAL source does to a contested day.
*
* `EXTERNAL` is on no priority ladder, deliberately: the `steps` and `spo2`
* defaults already sit at `metricSourceLadder`'s `.max(8)`, so a ninth entry
* would come back from `GET /api/auth/me/source-priority`, be PUT back whole
* by the settings page, and 422 — breaking ladder saves for everyone.
*
* That omission is not inert. It decides a real behaviour, and one that
* CHANGED when ingested rows stopped being labelled `MANUAL`: before, a typed
* reading and a bridged one were the same source and both survived a day; now
* `MANUAL` ranks, `EXTERNAL` does not, and the bridged row leaves the
* canonical set for that day. Seven call sites consume this picker (daily
* totals, the health score, the doctor report, correlations, mood crosstabs,
* PR detection), so the effect is user-visible and is written up in the
* changelog.
*
* Pinned here because it is emergent from a rank ladder rather than stated
* anywhere in code: put `EXTERNAL` on a ladder and the first case below goes
* red, which is the review anybody adding it deserves.
*/
describe("pickCanonicalSourceRows — the unranked EXTERNAL source", () => {
const day = "2026-09-02";
function weightRow(
source: "MANUAL" | "EXTERNAL",
value: number,
hour: string,
) {
return {
measuredAt: new Date(`${day}T${hour}:00Z`),
source,
deviceType: null,
type: "WEIGHT" as const,
value,
};
}
it("keeps the typed reading and drops the bridged one on a contested day", () => {
// The behaviour change itself. `MANUAL` is on the weight ladder and
// `EXTERNAL` is not, so the day resolves to the reading the person
// entered — a hand correction still wins where one number is needed.
const out = pickCanonicalSourceRows(
[
weightRow("EXTERNAL", 82.4, "06:30"),
weightRow("MANUAL", 81.9, "08:00"),
],
"weight",
null,
isoDayKey,
);
expect(out.canonicalRows).toHaveLength(1);
expect(out.canonicalRows[0].source).toBe("MANUAL");
expect(out.canonicalRows[0].value).toBe(81.9);
expect(out.pickedByDay.get(day)).toBe("MANUAL");
});
it("keeps a bridged-only day, since nothing on the ladder is present", () => {
// The `!picked` pass-through. Without this arm an account that ONLY ever
// pushes readings would go dark on every surface listed above, which
// would be a far worse regression than the one above is a change.
const out = pickCanonicalSourceRows(
[
weightRow("EXTERNAL", 82.4, "06:30"),
weightRow("EXTERNAL", 82.1, "20:00"),
],
"weight",
null,
isoDayKey,
);
expect(out.canonicalRows).toHaveLength(2);
expect(out.canonicalRows.every((r) => r.source === "EXTERNAL")).toBe(true);
// No ladder source won, so the day names no pick.
expect(out.pickedByDay.has(day)).toBe(false);
});
it("kept both when the bridged row was still labelled MANUAL", () => {
// The pre-change shape, asserted so the diff between old and new
// behaviour lives in the suite rather than only in the changelog. Two
// `MANUAL` rows are indistinguishable to the picker and both survive —
// which for a cumulative metric is the double-count the new labelling
// removes.
const out = pickCanonicalSourceRows(
[weightRow("MANUAL", 82.4, "06:30"), weightRow("MANUAL", 81.9, "08:00")],
"weight",
null,
isoDayKey,
);
expect(out.canonicalRows).toHaveLength(2);
});
it("lets a user who ranks EXTERNAL themselves have it win", () => {
// Not reachable from the settings UI today (the ladder cap keeps it off
// the page), but the picker resolves whatever ladder it is handed, and a
// stored ladder naming it must be honoured rather than ignored.
const out = pickCanonicalSourceRows(
[
weightRow("EXTERNAL", 82.4, "06:30"),
weightRow("MANUAL", 81.9, "08:00"),
],
"weight",
{ metricPriority: { weight: ["EXTERNAL", "MANUAL"] } },
isoDayKey,
);
expect(out.canonicalRows).toHaveLength(1);
expect(out.canonicalRows[0].source).toBe("EXTERNAL");
});
});
+5
View File
@@ -41,6 +41,10 @@ export const MEASUREMENT_SOURCE_SETTINGS_LABEL_KEYS: Record<
MCP: "settings.sections.sources.sourceLabels.MCP",
GOOGLE_HEALTH: "settings.sections.sources.sourceLabels.GOOGLE_HEALTH",
STRAVA: "settings.sections.sources.sourceLabels.STRAVA",
// v1.38.x — readings pushed in under a `measurements:write` token.
// Never offered in the ladder (it is on no default priority list), but
// the map is keyed by the enum, so the entry is not optional.
EXTERNAL: "settings.sections.sources.sourceLabels.EXTERNAL",
};
/**
@@ -66,6 +70,7 @@ export const MEASUREMENT_SOURCE_LIST_LABEL_KEYS: Record<
MCP: "measurements.sourceMcp",
GOOGLE_HEALTH: "measurements.sourceGoogleHealth",
STRAVA: "measurements.sourceStrava",
EXTERNAL: "measurements.sourceExternal",
};
/**
+24
View File
@@ -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;
+1 -1
View File
@@ -1559,7 +1559,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Mint a measurement-ingest token",
description:
"Mints a Bearer scoped to exactly `measurements:write` and audits the mint. THE RESPONSE CARRIES THE RAW TOKEN — the only place it ever exists; it is stored as an HMAC and no path re-reveals it.\n\n" +
"**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record, attributing `source: MANUAL`. An entry naming `APPLE_HEALTH` is refused 422 rather than relabelled: that source is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what decides the Apple Health card may claim a sync happened. For the same reason a write through this credential does not move the native client's sync checkpoint.\n\n" +
"**What it can do.** `POST /api/measurements` and `POST /api/measurements/batch`, on its owner's own record. Rows it writes carry `source: EXTERNAL`, resolved from the credential rather than taken from the body, so a reading pushed by a bridge stays distinguishable from one typed in by hand and can be picked out with the source filter. A body naming ANY source is refused 422 rather than relabelled — honouring it would defeat the label, overriding it silently would hand the caller rows it did not ask for. `APPLE_HEALTH` is the one that would do real damage: it is half a dedup key the phone also writes into, it participates in the cross-source merge, and it is what decides the Apple Health card may claim a sync happened. For the same reason a write through this credential does not move the native client's sync checkpoint. The rows stay editable by their owner, unlike a connected provider's — the hardware behind the token is the user's own.\n\n" +
"**What it cannot do.** Everything else, including the measurement reads on the same paths, the edit and delete legs, and the export — a scope grants what it names and nothing adjacent. It cannot be pointed at a shared record: a request carrying the account selector is refused 403 before any grant is read, whatever grants its holder actually has. And it cannot mint another token, this endpoint included.\n\n" +
"Minting requires a COOKIE SESSION. No Bearer credential reaches this endpoint at any scope, wildcard included — not because a wildcard lacks the reach, but because of the lifetimes involved: a native access token lives a day and what it could mint here lives a year, so admitting one would let a short-lived compromise leave behind a credential that outlives revoking it. Gated by the operator's instance-wide API switch. Body capped at 16 KiB; 10 mints per user per minute, and at most 10 live tokens held at once. Tokens appear in `GET /api/tokens` and are revoked at `DELETE /api/tokens/{id}` like any other — revoking frees a slot against the ceiling.",
requestBody: {
+4 -4
View File
@@ -850,7 +850,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Create one measurement (or a small array)",
description:
"Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams.\n\n" +
"Also reachable with a narrow `measurements:write` Bearer, which is the supported way to push readings from a scale, a watch bridge or a home-automation rule. That credential is confined to its owner's own record — a request carrying the account selector is refused 403 before any sharing grant is read, however the record is actually shared — and to `MANUAL` attribution, so an explicit `APPLE_HEALTH` source is refused 422 (`measurement.create.source_not_permitted`). The GET on this same path, and the PUT and DELETE beside it, name no scope and so refuse it.",
"Also reachable with a narrow `measurements:write` Bearer, which is the supported way to push readings from a scale, a watch bridge or a home-automation rule. That credential is confined to its owner's own record — a request carrying the account selector is refused 403 before any sharing grant is read, however the record is actually shared — and it may not attribute a source at all: rows it writes carry `source: EXTERNAL`, resolved from the credential, so a bridged reading stays distinguishable from a hand-typed one. A body naming ANY source, `MANUAL` included, is refused 422 (`measurement.create.source_not_permitted`). The GET on this same path, and the PUT and DELETE beside it, name no scope and so refuse it — though the rows themselves stay editable by their owner, unlike a connected provider's.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
@@ -889,7 +889,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...recordWriteRateLimitResponse,
"422": {
description:
"Validation failed, or (`measurement.create.source_not_permitted`) a narrow `measurements:write` credential named `APPLE_HEALTH` as the source. Refused rather than relabelled, so the caller can fix the payload instead of discovering rows it did not ask for.",
"Validation failed, or (`measurement.create.source_not_permitted`) a narrow `measurements:write` credential named a source — any source. Refused rather than relabelled, so the caller can fix the payload instead of discovering rows it did not ask for.",
content: { "application/json": { schema: errorEnvelope } },
},
},
@@ -991,7 +991,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Apple Health batch ingest",
description:
"Up to 500 HealthKit entries per call. Idempotent via the `Idempotency-Key` header (replay window 24h). Per-entry status lets the iOS client advance its sync cursor accurately. v1.4.25 W8c adds an optional `deviceType` per entry — feed it from `HKDevice.model` so the cross-source canonical picker can break Apple-Watch-vs-iPhone ties; null/absent stays backward-compatible with v1.4.23 clients.\n\n" +
"Also reachable with a narrow `measurements:write` Bearer, for a bridge that is not the phone. Such a credential writes only on its owner's own record; its entries are attributed `MANUAL` whatever they omit, an entry naming `APPLE_HEALTH` is refused 422 (`measurement.batch.source_not_permitted`), and the call does not advance the native sync checkpoint — `lastSyncedAt` means the CLIENT delivered what it was holding, and a third-party push is not that.",
"Also reachable with a narrow `measurements:write` Bearer, for a bridge that is not the phone. Such a credential writes only on its owner's own record; its entries are attributed `EXTERNAL` and it may not name a source itself, so an entry carrying one — `MANUAL` and `APPLE_HEALTH` alike — is refused 422 (`measurement.batch.source_not_permitted`). `EXTERNAL` also gives those writes their own `(userId, type, source, externalId)` namespace, so a bridge's ids can never collide with the phone's. The call does not advance the native sync checkpoint — `lastSyncedAt` means the CLIENT delivered what it was holding, and a third-party push is not that.",
requestBody: {
required: true,
content: { "application/json": { schema: batchPayloadSchema } },
@@ -1011,7 +1011,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
...stdResponses,
"422": {
description:
"The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation, or (`measurement.batch.source_not_permitted`) carried an entry naming `APPLE_HEALTH` under a narrow `measurements:write` credential. Nothing was written in any of the three cases.",
"The batch exceeded the 500-entry limit (`measurement.batch.too_large`), failed validation, or (`measurement.batch.source_not_permitted`) carried an entry naming a source — any source — under a narrow `measurements:write` credential. Nothing was written in any of the three cases.",
content: { "application/json": { schema: errorEnvelope } },
},
},
+1 -1
View File
@@ -144,7 +144,7 @@ export const measurementTypeEnum = measurementTypeEnumBase.meta({
export const measurementSourceEnum = measurementSourceEnumBase.meta({
id: "MeasurementSource",
description:
"Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path.",
"Origin of the measurement. v1.4.23 added APPLE_HEALTH for the iOS HealthKit batch ingest path. v1.38.x added EXTERNAL for rows pushed in under a narrow `measurements:write` Bearer — a scale, a watch bridge, a home-automation rule. No client may name that value on a write; the server resolves it from the credential.",
});
export const loginPasswordSchema = loginPasswordSchemaBase.meta({
+40
View File
@@ -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<string, string> = {
WEIGHT: "kg",
BLOOD_PRESSURE_SYS: "mmHg",