From 8d10e5ab945e39c7d503676294991fe90aebea0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 23 Oct 2026 19:27:52 +0200 Subject: [PATCH] feat(measurements): resolve each reading's Apple Health write-back A reading sent with a measurements:write token keeps its EXTERNAL source and now points at the token that carried it (measurements.ingest_token_id). Every stored row answers a server-resolved appleHealthMirror: true for WITHINGS, IMPORT and MANUAL, the token's choice for EXTERNAL, false otherwise. The plain list accepts appleHealthMirror=true so a client can page exactly those rows. Migration 0476 adds the columns and links existing EXTERNAL readings only where one measurement token provably sent them; 0477 builds the index concurrently. Backups carry the link and a restore keeps it only for a token the account still holds. A guard holds every EXTERNAL writer to setting the link. --- .../migration.sql | 80 ++++++++++ .../migration.sql | 8 + prisma/schema.prisma | 47 ++++-- .../external-source-token-link-guard.test.ts | 84 +++++++++++ src/app/api/measurements/[id]/route.ts | 140 ++++++++++-------- src/app/api/measurements/batch/route.ts | 2 + src/app/api/measurements/route.ts | 16 +- src/lib/export/measurement-bulk-insert.ts | 3 + src/lib/export/measurements-backup-schema.ts | 3 + src/lib/export/measurements-backup.ts | 2 + src/lib/export/measurements-restore.ts | 29 +++- .../__tests__/apple-health-mirror.test.ts | 84 +++++++++++ src/lib/measurements/apple-health-mirror.ts | 96 ++++++++++++ src/lib/measurements/batch-ingest/entries.ts | 5 + src/lib/measurements/batch-ingest/prepare.ts | 17 ++- .../batch-ingest/write-measurements.ts | 5 + .../measurements/create/create-readings.ts | 17 ++- .../measurements/create/create-sleep-stage.ts | 17 ++- src/lib/measurements/list/measurement-list.ts | 27 +++- src/lib/validations/measurement.ts | 21 +++ .../export-disaster-recovery.json | 10 ++ .../restore-disaster-recovery.json | 20 ++- .../backup-parity/restore-portable.json | 12 +- .../backup-parity/restore-skips.json | 3 +- .../backup-parity/restore-version-1.json | 16 +- .../backup-parity/seeded-account.json | 15 +- .../backup-token-link-restore.test.ts | 131 ++++++++++++++++ .../measurements.get-01-page.json.snap | 33 +++-- .../measurements.get-02-type-sorted.json.snap | 6 +- .../measurements.get-03-filtered.json.snap | 6 +- .../measurements.get-05-drill-down.json.snap | 3 +- .../measurements.post-01-single.json.snap | 7 +- ...surements.post-04-single-glucose.json.snap | 7 +- .../measurements.post-05-batch.json.snap | 21 ++- ...measurements.post-08-sleep-stage.json.snap | 1 + ...ements.post-09-sleep-stage-again.json.snap | 1 + ...ments.post-10-sleep-stage-mirror.json.snap | 1 + .../measurements.post-13-bridge.json.snap | 21 ++- .../measurements.post-14-delegated.json.snap | 7 +- .../token-link-backfill-migration.test.ts | 132 +++++++++++++++++ 40 files changed, 1014 insertions(+), 142 deletions(-) create mode 100644 prisma/migrations/0476_token_apple_health_mirror/migration.sql create mode 100644 prisma/migrations/0477_measurements_ingest_token_index/migration.sql create mode 100644 src/__tests__/external-source-token-link-guard.test.ts create mode 100644 src/lib/measurements/__tests__/apple-health-mirror.test.ts create mode 100644 src/lib/measurements/apple-health-mirror.ts create mode 100644 tests/integration/backup-token-link-restore.test.ts create mode 100644 tests/integration/token-link-backfill-migration.test.ts diff --git a/prisma/migrations/0476_token_apple_health_mirror/migration.sql b/prisma/migrations/0476_token_apple_health_mirror/migration.sql new file mode 100644 index 000000000..52ce04a08 --- /dev/null +++ b/prisma/migrations/0476_token_apple_health_mirror/migration.sql @@ -0,0 +1,80 @@ +-- Per-token Apple Health write-back (#1272). +-- +-- `api_tokens.mirror_to_apple_health` is the person's choice for a +-- `measurements:write` token; `mirror_changed_at` is when it last changed and +-- keys the `measurementMirror` domain of `/api/sync/changes`. Rows written by +-- such a token keep their honest `EXTERNAL` source and point at the token +-- through `measurements.ingest_token_id`; the server resolves the row's +-- `appleHealthMirror` from the token when it answers it. +-- +-- Each step is a `DO` block with a bounded lock wait, and each is safe to run +-- again, so the file is marked for the boot's lock-timeout retry: +-- +-- 1. the columns, and the foreign key added NOT VALID (no scan under the +-- table lock); +-- 2. the backfill: an existing EXTERNAL row is linked only where the +-- account has exactly one measurement token (revoked ones counted) and +-- the row is no older than that token, because only then is the token +-- provably the one that carried it. Every other existing row stays +-- unlinked and is not mirrored. The statement sets nothing but +-- `ingest_token_id`, so `updated_at` and `sync_version` are untouched; +-- 3. the foreign key validated, which scans without blocking writes. +-- +-- The index the key and the feed walk use is built CONCURRENTLY in 0477, on +-- its own, because a concurrent build cannot share a file. +-- +-- @retry-on-lock-timeout + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + ALTER TABLE "api_tokens" + ADD COLUMN IF NOT EXISTS "mirror_to_apple_health" BOOLEAN NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "mirror_changed_at" TIMESTAMP(3); + ALTER TABLE "measurements" + ADD COLUMN IF NOT EXISTS "ingest_token_id" TEXT; + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint + WHERE conname = 'measurements_ingest_token_id_fkey' + ) THEN + ALTER TABLE "measurements" + ADD CONSTRAINT "measurements_ingest_token_id_fkey" + FOREIGN KEY ("ingest_token_id") REFERENCES "api_tokens"("id") + ON DELETE SET NULL ON UPDATE CASCADE NOT VALID; + END IF; +END +$migration$; + +DO $migration$ +DECLARE + linked bigint; +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + WITH sole_token AS ( + SELECT t."user_id", + (array_agg(t."id"))[1] AS token_id, + (array_agg(t."created_at"))[1] AS created_at + FROM "api_tokens" t + WHERE t."permissions" = ARRAY['measurements:write']::text[] + GROUP BY t."user_id" + HAVING count(*) = 1 + ) + UPDATE "measurements" m + SET "ingest_token_id" = s.token_id + FROM sole_token s + WHERE m."user_id" = s."user_id" + AND m."source" = 'EXTERNAL' + AND m."ingest_token_id" IS NULL + AND m."created_at" >= s.created_at; + GET DIAGNOSTICS linked = ROW_COUNT; + RAISE NOTICE '0476: linked % EXTERNAL readings to their sole measurement token', linked; +END +$migration$; + +DO $migration$ +BEGIN + PERFORM set_config('lock_timeout', '5s', true); + ALTER TABLE "measurements" + VALIDATE CONSTRAINT "measurements_ingest_token_id_fkey"; +END +$migration$; diff --git a/prisma/migrations/0477_measurements_ingest_token_index/migration.sql b/prisma/migrations/0477_measurements_ingest_token_index/migration.sql new file mode 100644 index 000000000..82c16c7b9 --- /dev/null +++ b/prisma/migrations/0477_measurements_ingest_token_index/migration.sql @@ -0,0 +1,8 @@ +-- The rows a measurement token carried (#1272): walked by the +-- `measurementMirror` domain of `/api/sync/changes` in id order, and looked up +-- by the foreign key's SET NULL. Built CONCURRENTLY so writes to +-- `measurements` carry on during the build; a concurrent build must be the +-- only statement in its file. Most rows have no token, so the index stays +-- small. +CREATE INDEX CONCURRENTLY IF NOT EXISTS "measurements_ingest_token_id_id_idx" + ON "measurements" ("ingest_token_id", "id"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index e78805032..915db7acd 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2118,8 +2118,15 @@ model Measurement { deletedAt DateTime? @map("deleted_at") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") + /// The `measurements:write` token that carried this reading, set on every + /// `EXTERNAL` row (#1272). Read through to the token's `mirrorToAppleHealth` + /// when the row is answered; never on the wire itself. SET NULL if the token + /// row is ever removed (a revoke keeps it), and the row then stops being + /// mirrored. + ingestTokenId String? @map("ingest_token_id") user User @relation(fields: [userId], references: [id], onDelete: Cascade) + ingestToken ApiToken? @relation(fields: [ingestTokenId], references: [id], onDelete: SetNull) // v1.19.0 — optional back-relation to the full ECG waveform recording(s) // paired with this EVENT row. SET NULL on the FK side keeps the trace if // the EVENT row is re-keyed / removed. @@ -2175,6 +2182,9 @@ model Measurement { // changes nothing else, so those stay HOT; the table also carries // `fillfactor = 85` and a 5 % autovacuum threshold from the same migration. @@index([userId, updatedAt, id]) + // The rows a token carried, walked by the `measurementMirror` feed domain + // in id order and by the FK's SET NULL (migration 0477). + @@index([ingestTokenId, id]) // `measurements_legacy_step_consolidation_idx` is a partial index // over `type = 'ACTIVITY_STEPS' AND deleted_at IS NULL` — see // migration 0087. It accelerates the v1.5.6 legacy step-consolidation @@ -3936,26 +3946,37 @@ model TelegramPromptContext { // ─── API Tokens (for external ingest) ────────────────────── model ApiToken { - id String @id @default(cuid()) - userId String @map("user_id") - name String - tokenHash String @unique @map("token_hash") // SHA-256 of the token - permissions String[] @default(["medication:ingest"]) - lastUsedAt DateTime? @map("last_used_at") - expiresAt DateTime? @map("expires_at") - createdAt DateTime @default(now()) @map("created_at") - revoked Boolean @default(false) + id String @id @default(cuid()) + userId String @map("user_id") + name String + tokenHash String @unique @map("token_hash") // SHA-256 of the token + permissions String[] @default(["medication:ingest"]) + lastUsedAt DateTime? @map("last_used_at") + expiresAt DateTime? @map("expires_at") + createdAt DateTime @default(now()) @map("created_at") + revoked Boolean @default(false) // The MCP OAuth connection this access token belongs to, when it was minted // through the `/api/mcp/oauth` bridge. Null for manually-minted tokens and // native-client tokens. Lets a connection revoke kill every access row it // ever issued (H2), and lets the connector token list exclude the transient // 60-minute access rows so it does not flood (M2). - mcpConnectionId String? @map("mcp_connection_id") + mcpConnectionId String? @map("mcp_connection_id") + /// Whether the HealthLog app writes the readings this `measurements:write` + /// token carried into Apple Health (#1272). The person decides per token; + /// the server resolves it onto every row as `appleHealthMirror` + /// (`src/lib/measurements/apple-health-mirror.ts`). False for every other + /// kind of token, which `PATCH /api/tokens/{id}` refuses to set. + mirrorToAppleHealth Boolean @default(false) @map("mirror_to_apple_health") + /// When `mirrorToAppleHealth` last changed. The keyset of the + /// `measurementMirror` domain of `/api/sync/changes`, which re-delivers the + /// token's rows with their new value without touching the rows themselves. + mirrorChangedAt DateTime? @map("mirror_changed_at") - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + mcpConnection McpOAuthConnection? @relation(fields: [mcpConnectionId], references: [id], onDelete: SetNull) + ingestedMeasurements Measurement[] // v1.30.34 — step-up elevations bound to this token. Cascade-deleted with it. - stepUpElevations StepUpElevation[] + stepUpElevations StepUpElevation[] @@index([userId]) @@index([mcpConnectionId]) diff --git a/src/__tests__/external-source-token-link-guard.test.ts b/src/__tests__/external-source-token-link-guard.test.ts new file mode 100644 index 000000000..3c2eab454 --- /dev/null +++ b/src/__tests__/external-source-token-link-guard.test.ts @@ -0,0 +1,84 @@ +/** + * Every write that labels a measurement `EXTERNAL` also links it to the token + * that carried it (#1272). + * + * The label and the link answer one question together: whether the HealthLog + * app writes the reading into Apple Health is the choice its person made for + * that token (`apple-health-mirror.ts`). An EXTERNAL row written without the + * link resolves to false forever, whatever the token says, and nothing in the + * write path would notice. So the files that resolve `EXTERNAL_SOURCE` are an + * inventory: a measurement writer must set `ingestTokenId` at least as often + * as it assigns the label, and a file that starts using the constant fails + * here until it is classified. + * + * A tripwire, not a proof: it counts, it does not trace data flow. The + * behaviour is pinned by `tests/integration/apple-health-mirror.test.ts`. + */ +import { describe, expect, it } from "vitest"; +import { join } from "node:path"; + +import { + readSourceText, + stripComments, + walkSourceFiles, +} from "./helpers/source-files"; + +const SRC = join(process.cwd(), "src"); + +/** Files that write measurement rows labelled with the constant. */ +const MEASUREMENT_WRITERS = [ + "lib/measurements/batch-ingest/prepare.ts", + "lib/measurements/create/create-readings.ts", + "lib/measurements/create/create-sleep-stage.ts", +]; + +/** Files that use the constant for something other than a measurement write. */ +const NOT_MEASUREMENT_WRITES: Record = { + "app/api/tokens/measurements/route.ts": + "counts the readings that belong to no token (a read)", + "lib/validations/measurement.ts": "the source enum and the edit allowlist", + "lib/workouts/batch-ingest/entries.ts": + "labels workout rows, which carry no token link", +}; + +function usesOfConstant(rel: string): { + assignments: number; + links: number; +} { + const code = stripComments(readSourceText(join(SRC, rel))); + const assignments = (code.match(/\?\s*EXTERNAL_SOURCE\b/g) ?? []).length; + // A value written under the key, or the shorthand. Not a type member + // (`ingestTokenId: string | null`) and not a read (`writer.ingestTokenId`), + // which write nothing. + const links = ( + code.match(/(? { + const files = walkSourceFiles(SRC, { floor: 3000 }) + .filter((p) => !p.startsWith("generated/")) + .filter((p) => !p.includes("__tests__")) + .filter((p) => !/\.test\.tsx?$/.test(p)) + .filter((p) => p !== "lib/measurements/external-source.ts") + .filter((p) => + /\bEXTERNAL_SOURCE\b/.test(stripComments(readSourceText(join(SRC, p)))), + ); + + it("knows every file that uses the constant", () => { + expect(files.length).toBeGreaterThan(0); + expect(files).toEqual( + [...MEASUREMENT_WRITERS, ...Object.keys(NOT_MEASUREMENT_WRITES)].sort(), + ); + }); + + it.each(MEASUREMENT_WRITERS)( + "%s links the token wherever it assigns the label", + (rel) => { + const { assignments, links } = usesOfConstant(rel); + expect(assignments).toBeGreaterThan(0); + expect(links).toBeGreaterThanOrEqual(assignments); + }, + ); +}); diff --git a/src/app/api/measurements/[id]/route.ts b/src/app/api/measurements/[id]/route.ts index e0fd951f8..84e18c624 100644 --- a/src/app/api/measurements/[id]/route.ts +++ b/src/app/api/measurements/[id]/route.ts @@ -17,9 +17,13 @@ import { validateMeasurementRange, } from "@/lib/validations/measurement"; import { encryptNote, shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; +import { + APPLE_HEALTH_MIRROR_INCLUDE, + withAppleHealthMirror, +} from "@/lib/measurements/apple-health-mirror"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { afterMeasurementMutation } from "@/lib/rollups/after-measurement-mutation"; -import { Prisma } from "@/generated/prisma/client"; +import { Prisma, type MeasurementType } from "@/generated/prisma/client"; import { NextRequest } from "next/server"; import { z } from "zod"; @@ -37,6 +41,7 @@ export const GET = apiHandler( // is not part of a unique index. const measurement = await prisma.measurement.findFirst({ where: { id, deletedAt: null }, + include: APPLE_HEALTH_MIRROR_INCLUDE, }); if (!measurement || measurement.userId !== user.id) { @@ -51,7 +56,9 @@ export const GET = apiHandler( }, }); - return apiSuccess(shapeMeasurementNotes(measurement)); + return apiSuccess( + withAppleHealthMirror(shapeMeasurementNotes(measurement)), + ); }, ); @@ -113,62 +120,8 @@ export const PUT = apiHandler( // implausible edited value flowed unchecked into rollups, the health // score, the BP gates and the Coach snapshot. 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. 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 ( - !(USER_CORRECTABLE_MEASUREMENT_SOURCES as readonly string[]).includes( - existing.source, - ) - ) { - annotate({ - action: { name: "measurements.update.server-owned-source" }, - meta: { measurement_id: id, source: existing.source }, - }); - return apiError( - "Values from a connected source cannot be edited", - 409, - { errorCode: "measurement.update.server_owned_source" }, - ); - } - - // Range check against the row's OWN type (the edit body carries no - // type). Returned through the standard multi-issue 422 envelope so the - // edit sheet renders it like any other field error. - const rangeCheck = z - .object({ - value: z.number().superRefine((value, ctx) => { - const rangeError = validateMeasurementRange(existing.type, value); - if (rangeError) { - ctx.addIssue({ code: "custom", message: rangeError }); - } - }), - }) - .safeParse({ value: data.value }); - if (!rangeCheck.success) { - annotate({ - action: { name: "measurements.update.validation-failed" }, - meta: { - issue_count: rangeCheck.error.issues.length, - measurement_id: id, - reason: "value_out_of_range", - }, - }); - return returnAllZodIssues(rangeCheck.error, 422); - } + const refused = refuseValueEdit(existing, data.value, id); + if (refused) return refused; } let measurement; @@ -185,6 +138,7 @@ export const PUT = apiHandler( notesEncrypted: encryptNote(data.notes), }), }, + include: APPLE_HEALTH_MIRROR_INCLUDE, }); } catch (err) { // v1.4.28 — re-pointing `measuredAt` onto an existing @@ -252,7 +206,9 @@ export const PUT = apiHandler( { source: "manual" }, ); - return apiSuccess(shapeMeasurementNotes(measurement)); + return apiSuccess( + withAppleHealthMirror(shapeMeasurementNotes(measurement)), + ); }, ); @@ -320,3 +276,69 @@ export const DELETE = apiHandler( return apiSuccess({ deleted: true }); }, ); + +/** + * Whether a changed value may be written over `existing`, as the refusal to + * answer when it may not; null when it may. + */ +function refuseValueEdit( + existing: { source: string; type: MeasurementType }, + value: number, + id: string, +): Response | null { + // 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. 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 ( + !(USER_CORRECTABLE_MEASUREMENT_SOURCES as readonly string[]).includes( + existing.source, + ) + ) { + annotate({ + action: { name: "measurements.update.server-owned-source" }, + meta: { measurement_id: id, source: existing.source }, + }); + return apiError("Values from a connected source cannot be edited", 409, { + errorCode: "measurement.update.server_owned_source", + }); + } + + // Range check against the row's OWN type (the edit body carries no + // type). Returned through the standard multi-issue 422 envelope so the + // edit sheet renders it like any other field error. + const rangeCheck = z + .object({ + value: z.number().superRefine((value, ctx) => { + const rangeError = validateMeasurementRange(existing.type, value); + if (rangeError) { + ctx.addIssue({ code: "custom", message: rangeError }); + } + }), + }) + .safeParse({ value: value }); + if (!rangeCheck.success) { + annotate({ + action: { name: "measurements.update.validation-failed" }, + meta: { + issue_count: rangeCheck.error.issues.length, + measurement_id: id, + reason: "value_out_of_range", + }, + }); + return returnAllZodIssues(rangeCheck.error, 422); + } + return null; +} diff --git a/src/app/api/measurements/batch/route.ts b/src/app/api/measurements/batch/route.ts index 2f3b785e0..921774089 100644 --- a/src/app/api/measurements/batch/route.ts +++ b/src/app/api/measurements/batch/route.ts @@ -261,6 +261,8 @@ async function postBatch(request: NextRequest): Promise { const result = await ingestMeasurementBatch({ user: { id: user.id, timezone: user.timezone }, scoped, + // On the Bearer path the token's id rides `session.id`. + ingestTokenId: scoped ? auth.session.id : null, entries, syncTrigger: parsed.data.syncTrigger, ipAddress: getClientIp(request), diff --git a/src/app/api/measurements/route.ts b/src/app/api/measurements/route.ts index 1a142980f..e138554cb 100644 --- a/src/app/api/measurements/route.ts +++ b/src/app/api/measurements/route.ts @@ -21,6 +21,10 @@ import { } from "@/lib/validations/measurement"; import { withIdempotency } from "@/lib/idempotency"; import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; +import { + type MeasurementWithMirrorInput, + withAppleHealthMirror, +} from "@/lib/measurements/apple-health-mirror"; import { measurementListResponse } from "@/lib/measurements/list/measurement-list"; import { createReading, @@ -154,6 +158,8 @@ async function postMeasurement(request: NextRequest) { const writer: ReadingWriter = { user: { id: user.id, timezone: user.timezone }, scoped, + // On the Bearer path the token's id rides `session.id`. + ingestTokenId: scoped ? auth.session.id : null, ipAddress: getClientIp(request), }; @@ -202,7 +208,7 @@ async function postMeasurement(request: NextRequest) { if ("duplicate" in created) { return apiError(DUPLICATE_MESSAGE, 409, DUPLICATE_META); } - return apiSuccess(created.rows.map(shapeMeasurementNotes), 201); + return apiSuccess(created.rows.map(answerRow), 201); } // Single mode (existing behavior) @@ -237,7 +243,12 @@ async function postMeasurement(request: NextRequest) { if ("duplicate" in created) { return apiError(DUPLICATE_MESSAGE, 409, DUPLICATE_META); } - return apiSuccess(shapeMeasurementNotes(created.row), 201); + return apiSuccess(answerRow(created.row), 201); +} + +/** A written row as it is answered: the note readable, the write-back resolved. */ +function answerRow(row: MeasurementWithMirrorInput) { + return withAppleHealthMirror(shapeMeasurementNotes(row)); } /** @@ -262,6 +273,7 @@ async function postNamedSleepStage( { userId: writer.user.id, scoped: writer.scoped, + ingestTokenId: writer.ingestTokenId, ipAddress: writer.ipAddress, }, parsed.data, diff --git a/src/lib/export/measurement-bulk-insert.ts b/src/lib/export/measurement-bulk-insert.ts index adbb848ae..440d0bac8 100644 --- a/src/lib/export/measurement-bulk-insert.ts +++ b/src/lib/export/measurement-bulk-insert.ts @@ -66,6 +66,8 @@ export interface MeasurementInsertRow { sleepStage: string | null; rhythmClassification: string | null; deviceType: string | null; + /** The token an EXTERNAL row came from (#1272); null when absent. */ + ingestTokenId?: string | null; syncVersion: number; deletedAt: Date | null; createdAt?: Date; @@ -98,6 +100,7 @@ const COLUMNS: { [K in keyof MeasurementInsertRow]-?: [string, Kind] } = { sleepStage: ["sleep_stage", "enum:sleep_stage"], rhythmClassification: ["rhythm_classification", "enum:rhythm_classification"], deviceType: ["device_type", "text"], + ingestTokenId: ["ingest_token_id", "text"], syncVersion: ["sync_version", "int4"], deletedAt: ["deleted_at", "timestamp"], createdAt: ["created_at", "timestamp"], diff --git a/src/lib/export/measurements-backup-schema.ts b/src/lib/export/measurements-backup-schema.ts index 1a5335b0d..3bddc120b 100644 --- a/src/lib/export/measurements-backup-schema.ts +++ b/src/lib/export/measurements-backup-schema.ts @@ -41,6 +41,9 @@ export const measurementSchema = z sleepStage: z.enum(SleepStage).nullable().optional(), rhythmClassification: z.enum(RhythmClassification).nullable().optional(), deviceType: z.string().nullable().optional(), + // The token that carried an EXTERNAL reading (#1272). Kept on restore + // only when the restoring account still holds that token. + ingestTokenId: z.string().max(64).nullable().optional(), syncVersion: z.number().int().optional(), deletedAt: isoDateTime.nullable().optional(), createdAt: isoDateTime.optional(), diff --git a/src/lib/export/measurements-backup.ts b/src/lib/export/measurements-backup.ts index 63b17597d..2dca35e37 100644 --- a/src/lib/export/measurements-backup.ts +++ b/src/lib/export/measurements-backup.ts @@ -42,6 +42,7 @@ const MEASUREMENT_BACKUP_SELECT = { sleepStage: true, rhythmClassification: true, deviceType: true, + ingestTokenId: true, syncVersion: true, deletedAt: true, createdAt: true, @@ -86,6 +87,7 @@ function serialiseMeasurement( sleepStage: measurement.sleepStage, rhythmClassification: measurement.rhythmClassification, deviceType: measurement.deviceType, + ingestTokenId: measurement.ingestTokenId, syncVersion: measurement.syncVersion, deletedAt: measurement.deletedAt?.toISOString() ?? null, createdAt: measurement.createdAt.toISOString(), diff --git a/src/lib/export/measurements-restore.ts b/src/lib/export/measurements-restore.ts index a6947d4be..bf08ef31c 100644 --- a/src/lib/export/measurements-restore.ts +++ b/src/lib/export/measurements-restore.ts @@ -156,7 +156,10 @@ export async function clearMeasurementRollups( await ctx.tx.measurementRollup.deleteMany({ where: { userId: ctx.ownerId } }); } -function toRestoredMeasurementData(measurement: BackupMeasurement) { +function toRestoredMeasurementData( + measurement: BackupMeasurement, + ownTokenIds: ReadonlySet, +) { return { type: isLegacyRmssdRow(measurement) ? ("HRV_RMSSD" as const) @@ -204,6 +207,13 @@ function toRestoredMeasurementData(measurement: BackupMeasurement) { sleepStage: (measurement.sleepStage ?? null) as never, rhythmClassification: (measurement.rhythmClassification ?? null) as never, deviceType: measurement.deviceType ?? null, + // The token link (#1272) survives only onto the account that still holds + // the token: a backup restored elsewhere, or after the token was deleted, + // would point at a row that is not there or not this account's. + ingestTokenId: + measurement.ingestTokenId && ownTokenIds.has(measurement.ingestTokenId) + ? measurement.ingestTokenId + : null, syncVersion: measurement.syncVersion ?? 1, deletedAt: measurement.deletedAt ? new Date(measurement.deletedAt) : null, ...(measurement.createdAt @@ -343,8 +353,9 @@ async function reconcileWithoutId( tx: Prisma.TransactionClient, ownerId: string, measurement: BackupMeasurement, + ownTokenIds: ReadonlySet, ): Promise { - const restoredData = toRestoredMeasurementData(measurement); + const restoredData = toRestoredMeasurementData(measurement, ownTokenIds); const existing = await tx.measurement.findFirst({ where: { userId: ownerId, @@ -378,6 +389,16 @@ export async function restoreMeasurements(ctx: RestoreContext): Promise { const measurementBatchSize = 1_000; const isExpiredTombstone = await expiredTombstoneFilter(ctx); const { isShadowedLegacyRmssd } = ctx.measurementPrepass; + // Tokens are not part of a backup; the ones the account holds now decide + // which token links come back. + const ownTokenIds = new Set( + ( + await tx.apiToken.findMany({ + where: { userId: ownerId }, + select: { id: true }, + }) + ).map((token) => token.id), + ); ctx.reportMeasurements(0); await streamed.forEachMeasurementBatch( measurementBatchSize, @@ -395,7 +416,7 @@ export async function restoreMeasurements(ctx: RestoreContext): Promise { { id: measurement.id, userId: ownerId, - ...toRestoredMeasurementData(measurement), + ...toRestoredMeasurementData(measurement, ownTokenIds), }, ] : [], @@ -412,7 +433,7 @@ export async function restoreMeasurements(ctx: RestoreContext): Promise { } for (const measurement of batch) { if (measurement.id) continue; - await reconcileWithoutId(tx, ownerId, measurement); + await reconcileWithoutId(tx, ownerId, measurement, ownTokenIds); } }, ); diff --git a/src/lib/measurements/__tests__/apple-health-mirror.test.ts b/src/lib/measurements/__tests__/apple-health-mirror.test.ts new file mode 100644 index 000000000..0e11e099a --- /dev/null +++ b/src/lib/measurements/__tests__/apple-health-mirror.test.ts @@ -0,0 +1,84 @@ +/** + * The Apple Health write-back rule (#1272), over every source the enum has. + * + * WITHINGS, IMPORT and MANUAL are true whatever the row's token says, the + * app's own rule as it stood; EXTERNAL follows its token; everything else is + * false. The Prisma filter the list's `appleHealthMirror=true` uses is the + * same rule written as a `where`, checked here against the resolver for each + * source and token state (the database agrees in + * `tests/integration/apple-health-mirror.test.ts`). + */ +import { describe, expect, it } from "vitest"; + +import { MEASUREMENT_SOURCES } from "@/lib/measurements/sources"; +import { + APPLE_HEALTH_MIRROR_WHERE, + resolveAppleHealthMirror, + withAppleHealthMirror, +} from "../apple-health-mirror"; + +const TOKEN_STATES = [ + undefined, + null, + { mirrorToAppleHealth: false }, + { mirrorToAppleHealth: true }, +] as const; + +/** The filter's two arms, evaluated against one row. */ +function matchesWhere(row: { + source: string; + ingestToken?: { mirrorToAppleHealth: boolean } | null; +}): boolean { + const [always, external] = APPLE_HEALTH_MIRROR_WHERE.OR; + return ( + (always.source.in as readonly string[]).includes(row.source) || + (row.source === external.source && + row.ingestToken?.mirrorToAppleHealth === + external.ingestToken.mirrorToAppleHealth) + ); +} + +describe("resolveAppleHealthMirror", () => { + it("is true for WITHINGS, IMPORT and MANUAL and an opted-in EXTERNAL", () => { + const truthy = MEASUREMENT_SOURCES.flatMap((source) => + TOKEN_STATES.filter((ingestToken) => + resolveAppleHealthMirror({ source, ingestToken }), + ).map((ingestToken) => `${source}:${JSON.stringify(ingestToken)}`), + ); + expect(truthy.sort()).toEqual( + [ + ...["IMPORT", "MANUAL", "WITHINGS"].flatMap((source) => + TOKEN_STATES.map((s) => `${source}:${JSON.stringify(s)}`), + ), + 'EXTERNAL:{"mirrorToAppleHealth":true}', + ].sort(), + ); + }); + + it("agrees with the list filter for every source and token state", () => { + for (const source of MEASUREMENT_SOURCES) { + for (const ingestToken of TOKEN_STATES) { + expect( + matchesWhere({ source, ingestToken }), + `${source} ${JSON.stringify(ingestToken)}`, + ).toBe(resolveAppleHealthMirror({ source, ingestToken })); + } + } + }); +}); + +describe("withAppleHealthMirror", () => { + it("adds the decision and drops the token and its id", () => { + const row = withAppleHealthMirror({ + id: "m1", + source: "EXTERNAL", + ingestTokenId: "tok-1", + ingestToken: { mirrorToAppleHealth: true }, + }); + expect(row).toEqual({ + id: "m1", + source: "EXTERNAL", + appleHealthMirror: true, + }); + }); +}); diff --git a/src/lib/measurements/apple-health-mirror.ts b/src/lib/measurements/apple-health-mirror.ts new file mode 100644 index 000000000..0dc3e801b --- /dev/null +++ b/src/lib/measurements/apple-health-mirror.ts @@ -0,0 +1,96 @@ +/** + * Whether the HealthLog app writes a server measurement into Apple Health + * (#1272). Resolved here, once, and published on every measurement row as + * `appleHealthMirror`; the app mirrors exactly the rows that say true instead + * of deriving the decision from `source`. + * + * The rule: + * + * - WITHINGS, IMPORT and MANUAL: true. This is the app's own rule as it + * stands (`MeasurementSource.isServerMirrorEligible`), so publishing the + * field changes nothing for an existing row. MANUAL is deliberate: a + * reading typed on the web or on another device is stored as MANUAL and + * never reached Apple Health otherwise, and a manual row the app already + * wrote at create time is recognised by the app's own dedup (the sample + * carries the server id), not by its source. + * - EXTERNAL: what the person chose for the `measurements:write` token that + * carried the reading (`ApiToken.mirrorToAppleHealth`). The source stays + * EXTERNAL either way; a token never claims another provenance. A row with + * no token (written before the link existed and not attributable to one) + * is not mirrored. + * - Everything else (APPLE_HEALTH, the provider syncs, COMPUTED, TELEGRAM, + * MCP, …): false. Apple Health rows came from Apple Health, and the + * provider and server-written rows are never authored into it. + */ +import type { MeasurementSource, Prisma } from "@/generated/prisma/client"; + +/** Sources the app mirrors whatever the token says. */ +const ALWAYS_MIRRORED: ReadonlySet = new Set([ + "WITHINGS", + "IMPORT", + "MANUAL", +]); + +/** What a row read needs to resolve the decision. */ +export const APPLE_HEALTH_MIRROR_SELECT = { + source: true, + ingestToken: { select: { mirrorToAppleHealth: true } }, +} as const satisfies Prisma.MeasurementSelect; + +/** The same, for a read or write that returns the whole row. */ +export const APPLE_HEALTH_MIRROR_INCLUDE = { + ingestToken: APPLE_HEALTH_MIRROR_SELECT.ingestToken, +} as const satisfies Prisma.MeasurementInclude; + +/** A whole row read or written with `APPLE_HEALTH_MIRROR_INCLUDE`. */ +export type MeasurementWithMirrorInput = Prisma.MeasurementGetPayload<{ + include: typeof APPLE_HEALTH_MIRROR_INCLUDE; +}>; + +export function resolveAppleHealthMirror(row: { + source: MeasurementSource | string; + ingestToken?: { mirrorToAppleHealth: boolean } | null; +}): boolean { + if (ALWAYS_MIRRORED.has(row.source as MeasurementSource)) return true; + if (row.source === "EXTERNAL") { + return row.ingestToken?.mirrorToAppleHealth === true; + } + return false; +} + +/** + * The rows that resolve true, as a Prisma filter: the list's + * `appleHealthMirror=true` filter, which the app's historical backfill pages + * instead of one fetch per source. Kept beside the rule so the two cannot + * disagree (pinned by `apple-health-mirror.test.ts`). + */ +export const APPLE_HEALTH_MIRROR_WHERE = { + OR: [ + { source: { in: [...ALWAYS_MIRRORED] } }, + { source: "EXTERNAL", ingestToken: { mirrorToAppleHealth: true } }, + ], +} as const satisfies Prisma.MeasurementWhereInput; + +/** + * A row read with `APPLE_HEALTH_MIRROR_SELECT` (or `_INCLUDE`), answered: the + * resolved `appleHealthMirror` added, the token relation and the token id + * dropped, so neither ever reaches the wire. + */ +export function withAppleHealthMirror< + T extends { + source: MeasurementSource | string; + ingestToken?: { mirrorToAppleHealth: boolean } | null; + ingestTokenId?: string | null; + }, +>( + row: T, +): Omit & { appleHealthMirror: boolean } { + const { ingestToken, ingestTokenId: _ingestTokenId, ...rest } = row; + return { + ...rest, + appleHealthMirror: resolveAppleHealthMirror({ + source: row.source, + ingestToken, + }), + }; +} diff --git a/src/lib/measurements/batch-ingest/entries.ts b/src/lib/measurements/batch-ingest/entries.ts index f247682e2..5b07c4bc2 100644 --- a/src/lib/measurements/batch-ingest/entries.ts +++ b/src/lib/measurements/batch-ingest/entries.ts @@ -34,6 +34,11 @@ export interface BatchIngestInput { user: { id: string; timezone: string | null }; /** A narrow single-purpose Bearer (a bridge), not the native client. */ scoped: boolean; + /** + * The bridge token that carried the batch, set exactly when `scoped`: each + * row it writes points at it (`Measurement.ingestTokenId`, #1272). + */ + ingestTokenId: string | null; entries: BatchIngestEntry[]; syncTrigger: HealthKitRequestSyncTrigger | undefined; ipAddress: string | null; diff --git a/src/lib/measurements/batch-ingest/prepare.ts b/src/lib/measurements/batch-ingest/prepare.ts index fc5eb9d2b..d9f7aa4a3 100644 --- a/src/lib/measurements/batch-ingest/prepare.ts +++ b/src/lib/measurements/batch-ingest/prepare.ts @@ -44,9 +44,13 @@ export interface PreparedBatch { } export async function prepareBatch( - input: Pick, + input: Pick< + BatchIngestInput, + "user" | "scoped" | "ingestTokenId" | "entries" + >, ): Promise { const { user, scoped, entries } = input; + const ingestTokenId = scoped ? input.ingestTokenId : null; const batch: PreparedBatch = { prepared: [], results: new Array(entries.length), @@ -94,7 +98,13 @@ export async function prepareBatch( continue; } - const prepared = prepareMeasurementEntry(index, entry, user.id, scoped); + const prepared = prepareMeasurementEntry( + index, + entry, + user.id, + scoped, + ingestTokenId, + ); if ("row" in prepared) batch.prepared.push(prepared); else batch.results[index] = prepared; } @@ -183,6 +193,7 @@ function prepareMeasurementEntry( entry: BatchIngestEntry, userId: string, scoped: boolean, + ingestTokenId: string | null, ): PreparedEntry | EntryResult { const mapped = mapAppleHealthEntry({ hkIdentifier: entry.hkIdentifier, @@ -293,6 +304,8 @@ function prepareMeasurementEntry( // `(userId, type, EXTERNAL, externalId)`, which is the isolated // namespace this source exists to give. source: scoped ? EXTERNAL_SOURCE : (entry.source ?? "APPLE_HEALTH"), + // The token an EXTERNAL row came from (#1272); null for the phone. + ingestTokenId, measuredAt: mapped.takenAt, externalId: entry.externalId, externalSourceVersion: entry.externalSourceVersion ?? null, diff --git a/src/lib/measurements/batch-ingest/write-measurements.ts b/src/lib/measurements/batch-ingest/write-measurements.ts index 225e78587..96bd57060 100644 --- a/src/lib/measurements/batch-ingest/write-measurements.ts +++ b/src/lib/measurements/batch-ingest/write-measurements.ts @@ -92,6 +92,11 @@ function desiredFor(userId: string, p: PreparedEntry) { .rhythmClassification as Prisma.MeasurementUncheckedCreateInput["rhythmClassification"], deviceType: p.row.deviceType as string | null, aggregationProvenance: statsRow ? ("HEALTHKIT_STATISTICS" as const) : null, + // Only a bridge row names its token (#1272); a phone row leaves the + // column as it is. + ...(p.row.ingestTokenId + ? { ingestTokenId: p.row.ingestTokenId as string } + : {}), }; } diff --git a/src/lib/measurements/create/create-readings.ts b/src/lib/measurements/create/create-readings.ts index c89a2fc5f..900f7cd96 100644 --- a/src/lib/measurements/create/create-readings.ts +++ b/src/lib/measurements/create/create-readings.ts @@ -10,6 +10,10 @@ import type { z } from "zod/v4"; import { prisma } from "@/lib/db"; import { EXTERNAL_SOURCE } from "@/lib/measurements/external-source"; +import { + APPLE_HEALTH_MIRROR_INCLUDE, + type MeasurementWithMirrorInput, +} from "@/lib/measurements/apple-health-mirror"; import { annotate } from "@/lib/logging/context"; import { fireAndForget } from "@/lib/logging/fire-and-forget"; import { auditLog } from "@/lib/auth/audit"; @@ -39,6 +43,11 @@ export interface ReadingWriter { user: { id: string; timezone: string | null }; /** A narrow bridge credential: its rows are `EXTERNAL`. */ scoped: boolean; + /** + * The bridge token, set exactly when `scoped`: each row it writes points + * at it (`Measurement.ingestTokenId`, #1272). + */ + ingestTokenId: string | null; ipAddress: string | null; } @@ -52,7 +61,7 @@ export const DUPLICATE_READING = { duplicate: true } as const; export async function createReadings( writer: ReadingWriter, input: z.output, -): Promise<{ rows: Measurement[] } | typeof DUPLICATE_READING> { +): Promise<{ rows: MeasurementWithMirrorInput[] } | typeof DUPLICATE_READING> { const { user, scoped } = writer; // The caller has to be told which condition the batch hit. Untouched, a // duplicate escaped as an unhandled error and the batch answered 500, @@ -77,6 +86,7 @@ export async function createReadings( source: (scoped ? EXTERNAL_SOURCE : (m.source ?? "MANUAL")) as MeasurementSource, + ingestTokenId: scoped ? writer.ingestTokenId : null, measuredAt: m.measuredAt, // The note is sealed at rest; the legacy plaintext column is // written null for new rows. @@ -88,6 +98,7 @@ export async function createReadings( // `deviceType` instead of silently dropping it. deviceType: m.deviceType ?? null, }, + include: APPLE_HEALTH_MIRROR_INCLUDE, }), ), ); @@ -123,7 +134,7 @@ export async function createReadings( export async function createReading( writer: ReadingWriter, input: z.output, -): Promise<{ row: Measurement } | typeof DUPLICATE_READING> { +): Promise<{ row: MeasurementWithMirrorInput } | typeof DUPLICATE_READING> { const { user, scoped } = writer; const { type, @@ -150,6 +161,7 @@ export async function createReading( source: (scoped ? EXTERNAL_SOURCE : (source ?? "MANUAL")) as MeasurementSource, + ingestTokenId: scoped ? writer.ingestTokenId : null, measuredAt, // The note is sealed at rest; the legacy plaintext column is nulled. notes: null, @@ -160,6 +172,7 @@ export async function createReading( // silently dropping it. deviceType: deviceType ?? null, }, + include: APPLE_HEALTH_MIRROR_INCLUDE, }); } catch (err) { // `isP2002` rather than `instanceof Prisma.PrismaClientKnownRequestError`. diff --git a/src/lib/measurements/create/create-sleep-stage.ts b/src/lib/measurements/create/create-sleep-stage.ts index 79a7da6f8..b363a1f37 100644 --- a/src/lib/measurements/create/create-sleep-stage.ts +++ b/src/lib/measurements/create/create-sleep-stage.ts @@ -23,6 +23,10 @@ import { auditLog } from "@/lib/auth/audit"; import { apiError, apiSuccess } from "@/lib/api-response"; import { getUnitForType } from "@/lib/validations/measurement"; import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; +import { + APPLE_HEALTH_MIRROR_INCLUDE, + withAppleHealthMirror, +} from "@/lib/measurements/apple-health-mirror"; import { invalidateUserMeasurements } from "@/lib/cache/invalidate"; import { emitInsertedMeasurementArrivals } from "@/lib/arrivals/measurement-emit"; import { maybeEnqueueMorningRefresh } from "@/lib/daily/morning-refresh-trigger"; @@ -51,6 +55,8 @@ export async function createNamedSleepStage( args: { userId: string; scoped: boolean; + /** The bridge token, set exactly when `scoped` (#1272). */ + ingestTokenId: string | null; ipAddress: string | null; }, entry: z.output, @@ -113,6 +119,10 @@ export async function createNamedSleepStage( externalSourceVersion: null, sleepStage: segment.stage, deviceType: entry.deviceType ?? null, + // The token an EXTERNAL row came from (#1272). + ...(scoped && args.ingestTokenId + ? { ingestTokenId: args.ingestTokenId } + : {}), }, { exactExternalMatch: "duplicate" }, ), @@ -190,9 +200,12 @@ async function namedSleepStageResponse( cross_source_merge: meta.crossSource, }, }); - const row = await prisma.measurement.findUniqueOrThrow({ where: { id } }); + const row = await prisma.measurement.findUniqueOrThrow({ + where: { id }, + include: APPLE_HEALTH_MIRROR_INCLUDE, + }); return apiSuccess( - { ...shapeMeasurementNotes(row), status }, + { ...withAppleHealthMirror(shapeMeasurementNotes(row)), status }, status === "inserted" ? 201 : 200, ); } diff --git a/src/lib/measurements/list/measurement-list.ts b/src/lib/measurements/list/measurement-list.ts index 2aea645b1..56e793670 100644 --- a/src/lib/measurements/list/measurement-list.ts +++ b/src/lib/measurements/list/measurement-list.ts @@ -20,6 +20,11 @@ import { import { CUMULATIVE_HK_TYPES } from "@/lib/measurements/apple-health-mapping"; import { localDayWindow } from "@/lib/tz/local-day"; import { shapeMeasurementNotes } from "@/lib/crypto/note-cipher"; +import { + APPLE_HEALTH_MIRROR_SELECT, + APPLE_HEALTH_MIRROR_WHERE, + withAppleHealthMirror, +} from "@/lib/measurements/apple-health-mirror"; import { loadUserSourcePriority } from "@/lib/rollups/measurement-read"; import { readDailySeries, @@ -59,8 +64,18 @@ const MEASUREMENT_WIRE_SELECT = { deletedAt: true, createdAt: true, updatedAt: true, + // Resolved onto `appleHealthMirror` and dropped by `withAppleHealthMirror`. + ingestToken: APPLE_HEALTH_MIRROR_SELECT.ingestToken, } as const satisfies Prisma.MeasurementSelect; +/** One row as it is answered: the note readable, the write-back resolved. */ +function wireRow< + T extends Parameters[0] & + Parameters[0], +>(row: T) { + return withAppleHealthMirror(shapeMeasurementNotes(row)); +} + type ListQuery = z.output; type ListUser = { id: string; timezone: string | null }; @@ -202,7 +217,7 @@ async function drillDownResponse( return apiSuccess({ // The same row shape as the plain page: the note decrypted onto `notes`, // the ciphertext never on the wire. - measurements: samples.map(shapeMeasurementNotes), + measurements: samples.map(wireRow), meta: { total: samples.length, limit, offset: 0, dayKey }, }); } @@ -281,7 +296,7 @@ async function plainPageResponse( query: ListQuery, ): Promise { const { type, from, to, limit, offset, sortBy, sortDir } = query; - const { sourceEq, valueMin, valueMax } = query; + const { sourceEq, valueMin, valueMax, appleHealthMirror } = query; const where = { userId, // Soft-deleted readings are invisible to every list / count / aggregate @@ -294,6 +309,9 @@ async function plainPageResponse( // opt-in (which selects the rollup read tier); `sourceEq` narrows the // plain list to one ingest source. ...(sourceEq && { source: sourceEq }), + // The rows the app mirrors into Apple Health, by the same rule the + // field is resolved with (`apple-health-mirror.ts`). + ...(appleHealthMirror === "true" && { AND: [APPLE_HEALTH_MIRROR_WHERE] }), // Value-range filter. Open-ended on either side; the validator has // already rejected an inverted `valueMin > valueMax`. ...(valueMin != null || valueMax != null @@ -330,6 +348,9 @@ async function plainPageResponse( orderBy: [{ [sortBy]: sortDir }, { id: sortDir }], take: limit, skip: offset, + // Named columns, so a column added to the model (a token link) reaches + // the wire only through this select. + select: MEASUREMENT_WIRE_SELECT, }), prisma.measurement.count({ where }), ]); @@ -339,7 +360,7 @@ async function plainPageResponse( return apiSuccess({ // Decrypt `notesEncrypted` onto `notes` and strip the ciphertext before // it leaves the server (iOS MeasurementWireDTO contract). - measurements: measurements.map(shapeMeasurementNotes), + measurements: measurements.map(wireRow), meta: { total, limit, offset }, }); } diff --git a/src/lib/validations/measurement.ts b/src/lib/validations/measurement.ts index 93f9322de..0117ad3a6 100644 --- a/src/lib/validations/measurement.ts +++ b/src/lib/validations/measurement.ts @@ -394,6 +394,10 @@ export const listMeasurementsSchema = z // threaded into the plain-list `where`. Backed by the `(userId, source, // measuredAt)` index (migration 0136). sourceEq: measurementSourceEnum.optional(), + // Only the rows the HealthLog app writes into Apple Health + // (`appleHealthMirror`, #1272): the app's historical backfill pages this + // instead of one fetch per source. A plain-list filter. + appleHealthMirror: z.enum(["true"]).optional(), // v1.18.5 — value-range FILTER (backlog G). Narrows the plain list to // readings whose `value` falls within an optional `[valueMin, valueMax]` // band. Either bound may be omitted for an open-ended range. Coerced @@ -478,6 +482,23 @@ export const listMeasurementsSchema = z message: "valueMin must be <= valueMax", path: ["valueMin"], }, + ) + // `appleHealthMirror` narrows the plain row list. The other modes answer + // something else (night rows, day buckets, a drill-down, a series), where + // a row filter would either do nothing or change what the mode means. + .refine( + ({ appleHealthMirror, type, dayKey, groupBy, aggregate, source }) => + appleHealthMirror === undefined || + (type !== "SLEEP_DURATION" && + dayKey === undefined && + groupBy === undefined && + source === undefined && + (aggregate === undefined || aggregate === "raw")), + { + message: + "appleHealthMirror filters the plain row list only; combine it with type, from, to, sourceEq and paging", + path: ["appleHealthMirror"], + }, ); export const createBatchMeasurementSchema = z.object({ diff --git a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json index e34d5552b..b14db8aff 100644 --- a/tests/integration/__golden__/backup-parity/export-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/export-disaster-recovery.json @@ -194,6 +194,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:03.000Z", @@ -217,6 +218,7 @@ "sleepStage": "DEEP", "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:04.000Z", @@ -240,6 +242,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 4, "deletedAt": "", "createdAt": "2026-05-01T08:20:01.000Z", @@ -263,6 +266,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": "rt-Measurement-deviceType", + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:00.000Z", @@ -286,6 +290,7 @@ "sleepStage": null, "rhythmClassification": "IRREGULAR", "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:02.000Z", @@ -2629,6 +2634,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:03.000Z", @@ -2652,6 +2658,7 @@ "sleepStage": "DEEP", "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:04.000Z", @@ -2675,6 +2682,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 4, "deletedAt": "", "createdAt": "2026-05-01T08:20:01.000Z", @@ -2698,6 +2706,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": "rt-Measurement-deviceType", + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:00.000Z", @@ -2721,6 +2730,7 @@ "sleepStage": null, "rhythmClassification": "IRREGULAR", "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:02.000Z", diff --git a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json index 7c7b4eb59..f8e15f9f9 100644 --- a/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json +++ b/tests/integration/__golden__/backup-parity/restore-disaster-recovery.json @@ -428,7 +428,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:03.000Z", - "updatedAt": "2026-05-01T08:21:03.000Z" + "updatedAt": "2026-05-01T08:21:03.000Z", + "ingestTokenId": null }, { "id": "", @@ -452,7 +453,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:02.000Z", - "updatedAt": "2026-05-01T08:21:02.000Z" + "updatedAt": "2026-05-01T08:21:02.000Z", + "ingestTokenId": null }, { "id": "", @@ -476,7 +478,8 @@ "syncVersion": 4, "deletedAt": "", "createdAt": "2026-05-01T08:20:01.000Z", - "updatedAt": "2026-05-01T08:21:01.000Z" + "updatedAt": "2026-05-01T08:21:01.000Z", + "ingestTokenId": null }, { "id": "", @@ -500,7 +503,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:04.000Z", - "updatedAt": "2026-05-01T08:21:04.000Z" + "updatedAt": "2026-05-01T08:21:04.000Z", + "ingestTokenId": null }, { "id": "", @@ -524,7 +528,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:00.000Z", - "updatedAt": "2026-05-01T08:21:00.000Z" + "updatedAt": "2026-05-01T08:21:00.000Z", + "ingestTokenId": null } ], "IntradayCumulativeProfile": [ @@ -3091,6 +3096,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:03.000Z", @@ -3114,6 +3120,7 @@ "sleepStage": "DEEP", "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:04.000Z", @@ -3137,6 +3144,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 4, "deletedAt": "", "createdAt": "2026-05-01T08:20:01.000Z", @@ -3160,6 +3168,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": "rt-Measurement-deviceType", + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:00.000Z", @@ -3183,6 +3192,7 @@ "sleepStage": null, "rhythmClassification": "IRREGULAR", "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:02.000Z", diff --git a/tests/integration/__golden__/backup-parity/restore-portable.json b/tests/integration/__golden__/backup-parity/restore-portable.json index 5b81b82e2..b7d8e05c3 100644 --- a/tests/integration/__golden__/backup-parity/restore-portable.json +++ b/tests/integration/__golden__/backup-parity/restore-portable.json @@ -520,7 +520,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null }, { "id": "", @@ -544,7 +545,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null }, { "id": "", @@ -568,7 +570,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null }, { "id": "", @@ -592,7 +595,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null } ], "IntradayCumulativeProfile": [ diff --git a/tests/integration/__golden__/backup-parity/restore-skips.json b/tests/integration/__golden__/backup-parity/restore-skips.json index 0861a6e8b..d050c0382 100644 --- a/tests/integration/__golden__/backup-parity/restore-skips.json +++ b/tests/integration/__golden__/backup-parity/restore-skips.json @@ -550,7 +550,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null } ], "IntradayCumulativeProfile": [], diff --git a/tests/integration/__golden__/backup-parity/restore-version-1.json b/tests/integration/__golden__/backup-parity/restore-version-1.json index 7128d4c26..7c6570625 100644 --- a/tests/integration/__golden__/backup-parity/restore-version-1.json +++ b/tests/integration/__golden__/backup-parity/restore-version-1.json @@ -426,7 +426,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null }, { "id": "", @@ -450,7 +451,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null }, { "id": "", @@ -474,7 +476,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null }, { "id": "", @@ -498,7 +501,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null } ], "IntradayCumulativeProfile": [], @@ -1289,6 +1293,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "", @@ -1312,6 +1317,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "", @@ -1335,6 +1341,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "", @@ -1358,6 +1365,7 @@ "sleepStage": null, "rhythmClassification": null, "deviceType": null, + "ingestTokenId": null, "syncVersion": 1, "deletedAt": null, "createdAt": "", diff --git a/tests/integration/__golden__/backup-parity/seeded-account.json b/tests/integration/__golden__/backup-parity/seeded-account.json index 0a7bc1df0..c4d46b2c9 100644 --- a/tests/integration/__golden__/backup-parity/seeded-account.json +++ b/tests/integration/__golden__/backup-parity/seeded-account.json @@ -26,7 +26,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:03.000Z", - "updatedAt": "2026-05-01T08:21:03.000Z" + "updatedAt": "2026-05-01T08:21:03.000Z", + "ingestTokenId": null }, { "id": "", @@ -50,7 +51,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:02.000Z", - "updatedAt": "2026-05-01T08:21:02.000Z" + "updatedAt": "2026-05-01T08:21:02.000Z", + "ingestTokenId": null }, { "id": "", @@ -74,7 +76,8 @@ "syncVersion": 4, "deletedAt": "", "createdAt": "2026-05-01T08:20:01.000Z", - "updatedAt": "2026-05-01T08:21:01.000Z" + "updatedAt": "2026-05-01T08:21:01.000Z", + "ingestTokenId": null }, { "id": "", @@ -98,7 +101,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:04.000Z", - "updatedAt": "2026-05-01T08:21:04.000Z" + "updatedAt": "2026-05-01T08:21:04.000Z", + "ingestTokenId": null }, { "id": "", @@ -122,7 +126,8 @@ "syncVersion": 1, "deletedAt": null, "createdAt": "2026-05-01T08:20:00.000Z", - "updatedAt": "2026-05-01T08:21:00.000Z" + "updatedAt": "2026-05-01T08:21:00.000Z", + "ingestTokenId": null } ], "IntradayCumulativeProfile": [ diff --git a/tests/integration/backup-token-link-restore.test.ts b/tests/integration/backup-token-link-restore.test.ts new file mode 100644 index 000000000..f5881593b --- /dev/null +++ b/tests/integration/backup-token-link-restore.test.ts @@ -0,0 +1,131 @@ +/** + * A backup carries the token link of an EXTERNAL reading, and a restore keeps + * it only where it still means something (#1272). + * + * Tokens are not part of a backup. A reading linked to a token the restoring + * account still holds comes back linked, so its Apple Health write-back is + * still that token's setting; a reading whose token is gone comes back with no + * link rather than a dangling id. The file is built by the real writer. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; + +import { encrypt } from "@/lib/crypto"; +import { buildFullBackupPayload } from "@/lib/export/full-backup-payload"; +import { parseBackupPayload } from "@/lib/validations/backup"; +import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; +import { POST as RESTORE } from "./restore-job-driver"; + +import { makeToken } from "../kit/factories"; +import { cookieJar, headerJar } from "./mock-next-headers"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +vi.mock("next/headers", async () => { + const { cookieJar, headerJar } = await import("./mock-next-headers"); + return { + headers: vi.fn(async () => ({ + get: (name: string) => headerJar.get(name.toLowerCase()) ?? null, + })), + cookies: vi.fn(async () => ({ + get: (name: string) => { + const value = cookieJar.get(name); + return value ? { name, value } : undefined; + }, + set: (name: string, value: string) => cookieJar.set(name, value), + delete: (name: string) => cookieJar.delete(name), + })), + }; +}); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/cache/invalidate", async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, invalidateUserData: vi.fn() }; +}); + +beforeEach(async () => { + await truncateAllTables(getPrismaClient()); + cookieJar.clear(); + headerJar.clear(); +}); + +describe("restoring EXTERNAL readings", () => { + it("keeps the link to a token the account holds and drops the rest", async () => { + const prisma = getPrismaClient(); + const owner = await prisma.user.create({ + data: { + username: "token-link-owner", + email: "token-link-owner@example.test", + role: "ADMIN", + }, + }); + const session = await prisma.session.create({ + data: { userId: owner.id, expiresAt: new Date(Date.now() + 60_000) }, + }); + cookieJar.set("healthlog_session", session.id); + + const kept = await makeToken(owner, { + permissions: [MEASUREMENTS_WRITE_SCOPE], + }); + const gone = await makeToken(owner, { + permissions: [MEASUREMENTS_WRITE_SCOPE], + }); + const reading = (day: string, ingestTokenId: string | null) => ({ + userId: owner.id, + type: "WEIGHT" as const, + source: "EXTERNAL" as const, + value: 80, + unit: "kg", + measuredAt: new Date(`${day}T06:00:00.000Z`), + ingestTokenId, + }); + await prisma.measurement.createMany({ + data: [ + reading("2026-10-01", kept.tokenId), + reading("2026-10-02", gone.tokenId), + reading("2026-10-03", null), + ], + }); + + const { payload: built } = await buildFullBackupPayload(prisma, owner.id, { + purpose: "disaster-recovery", + }); + const parsed = parseBackupPayload(JSON.stringify(built)); + const backup = await prisma.dataBackup.create({ + data: { + userId: owner.id, + type: "TOKEN_LINK_ROUND_TRIP", + data: encrypt(JSON.stringify(parsed)), + }, + }); + await prisma.measurement.deleteMany({ where: { userId: owner.id } }); + await prisma.apiToken.delete({ where: { id: gone.tokenId } }); + + const response = await RESTORE( + new Request(`http://localhost/api/admin/backups/${backup.id}/restore`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ confirm: "RESTORE" }), + }) as unknown as Parameters[0], + { params: Promise.resolve({ id: backup.id }) }, + ); + expect(response.status).toBe(200); + + const restored = await prisma.measurement.findMany({ + where: { userId: owner.id }, + select: { measuredAt: true, ingestTokenId: true }, + orderBy: { measuredAt: "asc" }, + }); + expect(restored.map((r) => r.ingestTokenId)).toEqual([ + kept.tokenId, + null, + null, + ]); + }); +}); diff --git a/tests/integration/routes/parity/__snapshots__/measurements.get-01-page.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.get-01-page.json.snap index 9c445be46..d9d783512 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.get-01-page.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.get-01-page.json.snap @@ -24,7 +24,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -47,7 +48,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": false }, { "id": "", @@ -70,7 +72,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": false }, { "id": "", @@ -93,7 +96,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -116,7 +120,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -139,7 +144,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -162,7 +168,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -185,7 +192,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -208,7 +216,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -231,7 +240,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": false }, { "id": "", @@ -254,7 +264,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": false } ], "meta": { diff --git a/tests/integration/routes/parity/__snapshots__/measurements.get-02-type-sorted.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.get-02-type-sorted.json.snap index 825225714..616f38074 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.get-02-type-sorted.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.get-02-type-sorted.json.snap @@ -24,7 +24,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -47,7 +48,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true } ], "meta": { diff --git a/tests/integration/routes/parity/__snapshots__/measurements.get-03-filtered.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.get-03-filtered.json.snap index 41d0feadc..ff7169b01 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.get-03-filtered.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.get-03-filtered.json.snap @@ -24,7 +24,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -47,7 +48,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true } ], "meta": { diff --git a/tests/integration/routes/parity/__snapshots__/measurements.get-05-drill-down.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.get-05-drill-down.json.snap index 9867be855..9200d8751 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.get-05-drill-down.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.get-05-drill-down.json.snap @@ -24,7 +24,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": false } ], "meta": { diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-01-single.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-01-single.json.snap index 28e0b3bfc..a7bbb2a73 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-01-single.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-01-single.json.snap @@ -22,7 +22,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, "error": null }, @@ -73,7 +74,9 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null, + "ingestToken": null } ], "manual" diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-04-single-glucose.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-04-single-glucose.json.snap index 29f7eb5da..faa1d778d 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-04-single-glucose.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-04-single-glucose.json.snap @@ -22,7 +22,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, "error": null }, @@ -73,7 +74,9 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null, + "ingestToken": null } ], "manual" diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-05-batch.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-05-batch.json.snap index 4227a1ed3..47fe5b03b 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-05-batch.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-05-batch.json.snap @@ -23,7 +23,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -46,7 +47,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, { "id": "", @@ -69,7 +71,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true } ], "error": null @@ -126,7 +129,9 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null, + "ingestToken": null }, { "id": "", @@ -150,7 +155,9 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null, + "ingestToken": null }, { "id": "", @@ -174,7 +181,9 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null, + "ingestToken": null } ], "manual" diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-08-sleep-stage.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-08-sleep-stage.json.snap index 8dd26af54..6c9eb9385 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-08-sleep-stage.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-08-sleep-stage.json.snap @@ -23,6 +23,7 @@ "deletedAt": null, "createdAt": "", "updatedAt": "", + "appleHealthMirror": true, "status": "inserted" }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-09-sleep-stage-again.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-09-sleep-stage-again.json.snap index 8c9e12f6d..43ba4abc3 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-09-sleep-stage-again.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-09-sleep-stage-again.json.snap @@ -23,6 +23,7 @@ "deletedAt": null, "createdAt": "", "updatedAt": "", + "appleHealthMirror": true, "status": "duplicate" }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-10-sleep-stage-mirror.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-10-sleep-stage-mirror.json.snap index 1e51a4d35..b1e4c0e4e 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-10-sleep-stage-mirror.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-10-sleep-stage-mirror.json.snap @@ -23,6 +23,7 @@ "deletedAt": null, "createdAt": "", "updatedAt": "", + "appleHealthMirror": true, "status": "duplicate" }, "error": null diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-13-bridge.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-13-bridge.json.snap index 1b16eb525..72345bc88 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-13-bridge.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-13-bridge.json.snap @@ -22,7 +22,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": false }, "error": null }, @@ -73,7 +74,11 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": "", + "ingestToken": { + "mirrorToAppleHealth": false + } } ], "manual" @@ -111,7 +116,7 @@ ], "stored": [ { - "id": "", + "id": "", "type": "WEIGHT", "value": 80.4, "unit": "kg", @@ -141,7 +146,7 @@ "sealedNote": false }, { - "id": "", + "id": "", "type": "BLOOD_PRESSURE_SYS", "value": 128, "unit": "mmHg", @@ -156,7 +161,7 @@ "sealedNote": false }, { - "id": "", + "id": "", "type": "BLOOD_PRESSURE_DIA", "value": 82, "unit": "mmHg", @@ -171,7 +176,7 @@ "sealedNote": false }, { - "id": "", + "id": "", "type": "PULSE", "value": 66, "unit": "bpm", @@ -186,7 +191,7 @@ "sealedNote": true }, { - "id": "", + "id": "", "type": "SLEEP_DURATION", "value": 90, "unit": "minutes", @@ -201,7 +206,7 @@ "sealedNote": false }, { - "id": "", + "id": "", "type": "BLOOD_GLUCOSE", "value": 104, "unit": "mg/dL", diff --git a/tests/integration/routes/parity/__snapshots__/measurements.post-14-delegated.json.snap b/tests/integration/routes/parity/__snapshots__/measurements.post-14-delegated.json.snap index acf18c4fc..df04db8bf 100644 --- a/tests/integration/routes/parity/__snapshots__/measurements.post-14-delegated.json.snap +++ b/tests/integration/routes/parity/__snapshots__/measurements.post-14-delegated.json.snap @@ -22,7 +22,8 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "appleHealthMirror": true }, "error": null }, @@ -73,7 +74,9 @@ "syncVersion": "", "deletedAt": null, "createdAt": "", - "updatedAt": "" + "updatedAt": "", + "ingestTokenId": null, + "ingestToken": null } ], "manual" diff --git a/tests/integration/token-link-backfill-migration.test.ts b/tests/integration/token-link-backfill-migration.test.ts new file mode 100644 index 000000000..19a0c46bc --- /dev/null +++ b/tests/integration/token-link-backfill-migration.test.ts @@ -0,0 +1,132 @@ +/** + * Migration 0476 links existing EXTERNAL readings to the token that carried + * them only where that is provable (#1272): the account holds exactly one + * measurement token, revoked ones counted, and the reading is no older than + * the token. Everything else stays unlinked. The pass writes nothing but the + * link, so no reading's `updatedAt` or `syncVersion` moves, and a rerun + * changes nothing. + */ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { beforeEach, describe, expect, it } from "vitest"; + +import { MEASUREMENTS_WRITE_SCOPE } from "@/lib/measurements/scopes"; + +import { makeToken, makeUser } from "../kit/factories"; +import { getPrismaClient, truncateAllTables } from "./setup"; + +const prisma = getPrismaClient(); + +const MIGRATION = readFileSync( + resolve( + process.cwd(), + "prisma", + "migrations", + "0476_token_apple_health_mirror", + "migration.sql", + ), + "utf8", +); + +/** The file is three `DO` blocks; each goes through as its own statement. */ +async function runMigration(): Promise { + const blocks = MIGRATION.match(/DO \$migration\$[\s\S]*?\$migration\$;/g); + expect(blocks).toHaveLength(3); + for (const block of blocks!) await prisma.$executeRawUnsafe(block); +} + +const TOKEN_AT = new Date("2026-06-01T00:00:00.000Z"); + +async function externalReading( + userId: string, + createdAt: Date, + source: "EXTERNAL" | "MANUAL" = "EXTERNAL", +) { + return prisma.measurement.create({ + data: { + userId, + type: "WEIGHT", + value: 80, + unit: "kg", + source, + measuredAt: createdAt, + createdAt, + updatedAt: new Date("2026-07-01T00:00:00.000Z"), + }, + select: { id: true }, + }); +} + +async function measurementToken(userId: string, revoked = false) { + const token = await makeToken( + { id: userId }, + { permissions: [MEASUREMENTS_WRITE_SCOPE] }, + ); + await prisma.apiToken.update({ + where: { id: token.tokenId }, + data: { createdAt: TOKEN_AT, revoked }, + }); + return token.tokenId; +} + +beforeEach(async () => { + await truncateAllTables(prisma); +}); + +describe("migration 0476 backfill", () => { + it("links only what one token provably carried", async () => { + const sole = await makeUser({ timezone: "UTC" }); + const soleToken = await measurementToken(sole.id, true); + // A wildcard sign-in token does not make the measurement token ambiguous. + await makeToken(sole, { permissions: ["*"] }); + const after = await externalReading( + sole.id, + new Date("2026-06-02T08:00:00.000Z"), + ); + const before = await externalReading( + sole.id, + new Date("2026-05-30T08:00:00.000Z"), + ); + const manual = await externalReading( + sole.id, + new Date("2026-06-03T08:00:00.000Z"), + "MANUAL", + ); + + const two = await makeUser({ timezone: "UTC" }); + await measurementToken(two.id); + await measurementToken(two.id); + const ambiguous = await externalReading( + two.id, + new Date("2026-06-02T08:00:00.000Z"), + ); + + const stamps = await prisma.measurement.findMany({ + select: { id: true, updatedAt: true, syncVersion: true }, + orderBy: { id: "asc" }, + }); + + await runMigration(); + await runMigration(); + + const links = Object.fromEntries( + ( + await prisma.measurement.findMany({ + select: { id: true, ingestTokenId: true }, + }) + ).map((r) => [r.id, r.ingestTokenId]), + ); + expect(links).toEqual({ + [after.id]: soleToken, + [before.id]: null, + [manual.id]: null, + [ambiguous.id]: null, + }); + expect( + await prisma.measurement.findMany({ + select: { id: true, updatedAt: true, syncVersion: true }, + orderBy: { id: "asc" }, + }), + ).toEqual(stamps); + }); +});