mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 13:52:20 +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,
|
||||
|
||||
Reference in new issue
Block a user