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

@@ -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",