mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 21:32:18 +00:00
Merge branch 'main' into release/v1.39.1
This commit is contained in:
commit
702a13873d
23 files changed
+692
-92
No files matched your search
@@ -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,
|
||||
)
|
||||
) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user