From 15b5f6f92f658cdc539956ef73b78e6b84ff9875 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 25 Jul 2026 18:07:13 +0200 Subject: [PATCH] fix(ingest): hold the stability floor for identities no request schema sees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dedup key on the medication intake surfaces is not only `externalId`. `MedicationIntakeEvent.idempotency_key` is a `@unique` column persisted with the dose row forever, with no TTL and no sweep, matched by plain equality on the per-medication POST, the bulk drain and the external ingest bridge. That makes it an identity, not a replay token, and it carries the same failure mode: a key that rotates per client launch never matches its own earlier post, so every retry logs the dose again. The blank case is sharper still — the column is unique table-wide, so an empty key resolves to whichever unrelated row stored it first. Refuse both shapes at the schema on the single-entry paths and per entry on the bulk drain. The MCP write path hashes a host-supplied key into the `externalId` half of its dedup key, so the shape is laundered before anything downstream could see it and the check has to run on the input. That closes a live one: every tool handler falls back to `""` when the host omits the argument, and `""` digests to a constant, so each keyless write after the first reported `already_logged` and was silently dropped. The HTTP `Idempotency-Key` header stays out, and now says why in the guard: `IdempotencyKey.expiresAt` bounds it and the cleanup job sweeps it, so it is a windowed replay token and rotation is its design. Extend the structural guard with a provenance rule for identities that never pass a Zod request schema — a spreadsheet column, an MCP tool argument. Inside the import and MCP trees, a module handling an identity must apply the floor itself or import a module that does. That is a real chain rather than a list of known offenders: a new importer that mints an `externalId` from file input and imports nothing checked fails it, with no allowlist to escape into. --- .../external-id-stability-guard.test.ts | 160 ++++++++++++- .../cycle/__tests__/external-id-floor.test.ts | 213 +++++++++++++++++ .../day-logs/bulk/__tests__/route.test.ts | 109 +++++++++ src/app/api/import/__tests__/route.test.ts | 120 ++++++++++ .../ingest/medication/__tests__/route.test.ts | 84 +++++++ src/app/api/ingest/medication/route.ts | 10 +- .../[id]/intake/__tests__/route.test.ts | 92 ++++++++ src/app/api/medications/[id]/intake/route.ts | 13 +- .../intake/bulk/__tests__/route.test.ts | 128 ++++++++++ src/app/api/medications/intake/bulk/route.ts | 25 +- .../assessments/__tests__/route.test.ts | 51 ++++ .../api/mood-entries/__tests__/route.test.ts | 68 ++++++ .../__tests__/unstable-external-id.test.ts | 220 ++++++++++++++++++ .../import/__tests__/csv-measurements.test.ts | 56 +++++ src/lib/mcp/__tests__/writes.test.ts | 76 ++++++ src/lib/mcp/write-tools.ts | 21 ++ src/lib/mcp/writes.ts | 59 +++++ src/lib/validations/external-id.ts | 88 +++++-- src/lib/validations/medication/intake.ts | 24 +- 19 files changed, 1574 insertions(+), 43 deletions(-) create mode 100644 src/app/api/cycle/__tests__/external-id-floor.test.ts create mode 100644 src/app/api/workouts/batch/__tests__/unstable-external-id.test.ts diff --git a/src/__tests__/external-id-stability-guard.test.ts b/src/__tests__/external-id-stability-guard.test.ts index 47f942f04..88b279fb1 100644 --- a/src/__tests__/external-id-stability-guard.test.ts +++ b/src/__tests__/external-id-stability-guard.test.ts @@ -58,14 +58,23 @@ function body(rel: string): string { } /** - * A REQUEST-side declaration of a client-supplied external identifier: - * `externalId: z.…` / `externalSourceId: z.…` in a Zod object shape. + * A REQUEST-side declaration of a client-supplied identity string: + * `externalId: z.…` / `externalSourceId: z.…` / `idempotencyKey: z.…` in a + * Zod object shape. * - * Response DTOs under `lib/openapi/` are excluded below — those echo an - * id the server already stored and validating them would be validating + * `idempotencyKey` is here because `MedicationIntakeEvent.idempotency_key` + * is NOT a windowed replay token — it is a `@unique` column persisted with + * the dose row forever and matched by plain equality, which makes it an + * identity with the same failure mode. The HTTP `Idempotency-Key` HEADER + * is a different thing and stays out: `IdempotencyKey.expiresAt` bounds it + * and `lib/jobs/idempotency-cleanup.ts` sweeps it. + * + * Response DTOs under `lib/openapi/` are excluded below — those echo a + * value the server already stored and validating them would be validating * our own output. */ -const DECLARATION = /^\s*(externalId|externalSourceId)\s*:\s*z\b/gm; +const DECLARATION = + /^\s*(externalId|externalSourceId|idempotencyKey)\s*:\s*z\b/gm; /** `// @external-id-checked-per-entry: ` */ const MARKER = /@external-id-checked-per-entry:\s*(\S+)/; @@ -83,6 +92,59 @@ const EXEMPT: Record = { "restore payload is a server-written backup; refusing a stored id would block a restore", }; +/** + * Trees where a stored identity is derived from input that never passes + * through a Zod request schema — an uploaded file's row, or an MCP host's + * tool argument. The declaration rule above cannot see those, so this one + * works on provenance instead: inside these trees, any module that touches + * an identity must either apply the floor itself or import a module that + * does. + * + * That is a real chain, not a list of known offenders: a NEW importer that + * mints an `externalId` from file input and imports nothing checked fails + * here, with no allowlist to add itself to. + */ +const DERIVED_IDENTITY_TREES = ["lib/import/", "app/api/import/", "lib/mcp/"]; + +const IDENTITY_MENTION = /\b(externalId|idempotencyKey)\b/; + +/** Floor symbols that count as "this module applies the check itself". */ +const FLOOR_SYMBOLS = [ + "classifyExternalId", + "isStableExternalId", + "assertStableExternalId", + "assertStableIdempotencyKey", +]; + +/** Local `@/…` and relative modules this file imports, as src-relative paths. */ +function importedLocalModules(rel: string): string[] { + const source = read(rel); + const dir = rel.split("/").slice(0, -1).join("/"); + const out: string[] = []; + for (const m of source.matchAll(/from\s+"([^"]+)"/g)) { + const spec = m[1]; + let target: string | null = null; + if (spec.startsWith("@/")) target = spec.slice(2); + else if (spec.startsWith("./")) target = `${dir}/${spec.slice(2)}`; + else if (spec.startsWith("../")) { + const parts = dir.split("/"); + let rest = spec; + while (rest.startsWith("../")) { + parts.pop(); + rest = rest.slice(3); + } + target = [...parts, rest].join("/"); + } + if (target) out.push(target.replace(/\.tsx?$/, "")); + } + return out; +} + +function appliesFloor(rel: string): boolean { + const b = body(rel); + return FLOOR_SYMBOLS.some((sym) => b.includes(sym)); +} + /** Lines of `rel` (1-based) on which a request-side declaration appears. */ function declarationLines(rel: string): number[] { const source = read(rel); @@ -121,13 +183,15 @@ describe("every client-supplied external id routes through the stability floor", const usesFieldCheck = read(rel).includes(VALIDATOR_MODULE) && - body(rel).includes("assertStableExternalId"); + (body(rel).includes("assertStableExternalId") || + body(rel).includes("assertStableIdempotencyKey")); const marksPerEntry = MARKER.test(read(rel)); expect( usesFieldCheck || marksPerEntry, - `${rel} declares a client-supplied external id but neither applies ` + - "`assertStableExternalId` to its request schema nor marks the declaration " + - "`@external-id-checked-per-entry: ` naming the route that checks it per entry.", + `${rel} declares a client-supplied identity string but neither applies ` + + "`assertStableExternalId` / `assertStableIdempotencyKey` to its request schema " + + "nor marks the declaration `@external-id-checked-per-entry: ` naming the " + + "route that checks it per entry.", ).toBe(true); }, ); @@ -156,6 +220,84 @@ describe("every client-supplied external id routes through the stability floor", ); }); +describe("identities derived outside a Zod schema stay inside the floor", () => { + /** + * The CSV importer builds an `externalId` from a spreadsheet column, and + * the MCP write path hashes a host-supplied key into one. Neither goes + * through a Zod request field, so the declaration rule above is blind to + * both — which is exactly the gap this rule closes. + */ + function derivedIdentityFiles(): string[] { + return sourceFiles() + .filter((rel) => DERIVED_IDENTITY_TREES.some((t) => rel.startsWith(t))) + .filter((rel) => IDENTITY_MENTION.test(read(rel))); + } + + it("finds the derived-identity modules at all (the matcher still matches)", () => { + const files = derivedIdentityFiles(); + expect(files).toContain("lib/import/csv-measurements.ts"); + expect(files).toContain("lib/mcp/writes.ts"); + }); + + it.each(derivedIdentityFiles())( + "%s applies the floor itself or imports a module that does", + (rel) => { + if (appliesFloor(rel)) return; + const sources = importedLocalModules(rel).filter((target) => { + try { + return appliesFloor(`${target}.ts`); + } catch { + return false; + } + }); + expect( + sources.length, + `${rel} handles an external identity but neither applies the stability floor ` + + "nor imports a module that does. It derives an identity from input no Zod " + + "request schema sees (a file row, an MCP tool argument), so nothing else " + + "will catch an unstable value here.", + ).toBeGreaterThan(0); + }, + ); + + it("the CSV importer refuses an unstable id with its own row reason", () => { + // The importer has no per-entry Zod field to hang a refine on; its + // contract is a per-row `reason` code. Pin that the floor is wired to + // that contract and not merely imported. + const b = body("lib/import/csv-measurements.ts"); + expect(b).toContain("isStableExternalId"); + expect(b).toContain('skip("unstable_external_id")'); + expect(read("lib/import/csv-measurements.ts")).toContain( + '| "unstable_external_id"', + ); + }); + + it("the MCP write path checks the key BEFORE it is hashed into an identity", () => { + // A SHA-256 of a pointer address is clean hex, so the externalId floor + // downstream can never see the shape. The check has to run on the input. + const b = body("lib/mcp/writes.ts"); + const guardAt = b.indexOf("checkMcpIdempotencyKey"); + const hashAt = b.indexOf("mcpExternalId"); + expect(guardAt).toBeGreaterThan(-1); + expect(hashAt).toBeGreaterThan(-1); + expect(b).toContain("classifyExternalId"); + // Every entry point returns the refusal before reaching the hash. + for (const fn of [ + "logMcpMeasurement", + "logMcpMood", + "logMcpBloodPressure", + ]) { + const start = b.indexOf(`export async function ${fn}`); + expect(start, `${fn} not found`).toBeGreaterThan(-1); + const scope = b.slice(start, start + 2000); + expect( + scope.indexOf("checkMcpIdempotencyKey"), + `${fn} does not check the idempotency key before writing`, + ).toBeGreaterThan(-1); + } + }); +}); + describe("the batch surfaces refuse per entry, never whole-batch", () => { // Each of these drains a client sync queue. A schema-level refusal here // would stop a whole batch of good rows because of one bad one, which is diff --git a/src/app/api/cycle/__tests__/external-id-floor.test.ts b/src/app/api/cycle/__tests__/external-id-floor.test.ts new file mode 100644 index 000000000..9d313003b --- /dev/null +++ b/src/app/api/cycle/__tests__/external-id-floor.test.ts @@ -0,0 +1,213 @@ +/** + * The two single-entry cycle writes — `POST /api/cycle/day-logs` and + * `POST /api/cycle/period` — against the external-id stability floor. + * + * Both upsert on `(userId, source, externalId)`, so an id that changes + * between two launches of the same client never matches its own earlier + * row: the mirror mints a fresh day log (or a fresh period marker) on + * every sweep. Unlike their bulk twin these are single-entry, so the + * refusal is the standard 422 multi-issue envelope and nothing is written. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + auditLog: { create: vi.fn() }, + cycleDayLog: { + findUniqueOrThrow: vi.fn(), + findFirst: vi.fn(), + findFirstOrThrow: vi.fn(), + }, + menstrualCycle: { + findMany: vi.fn().mockResolvedValue([]), + findUniqueOrThrow: vi.fn(), + }, + $transaction: vi.fn(), + }, +})); + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: + (fn: (...args: Args) => Promise) => + (...args: Args) => + fn(...args), +})); +vi.mock("@/lib/cycle/gate", () => ({ + requireCycleEnabled: vi.fn(), +})); +vi.mock("@/lib/cycle/day-log-write", () => ({ + upsertCycleDayLog: vi.fn(), +})); +vi.mock("@/lib/cycle/cycle-attribution", () => ({ + findOwningCycleId: vi.fn().mockResolvedValue(null), +})); +vi.mock("@/lib/cycle/dto", async (importOriginal) => ({ + ...(await importOriginal()), + toCycleDayLogDTO: vi.fn(() => ({ id: "day-1" })), + toMenstrualCycleDTO: vi.fn(() => ({ id: "cycle-1" })), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST as postDayLog } from "../day-logs/route"; +import { POST as postPeriod } from "../period/route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { requireCycleEnabled } from "@/lib/cycle/gate"; +import { upsertCycleDayLog } from "@/lib/cycle/day-log-write"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function req(path: string, body: unknown): NextRequest { + return new NextRequest(`http://localhost${path}`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const POISONED = ""; +/** What the HealthKit cycle mirror really writes (`import-accumulator.ts`). */ +const REAL_ID = "hkcycle:2026-01-01"; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(requireCycleEnabled).mockResolvedValue({ + enabled: true, + profile: { sensitiveCategoryEncryption: false }, + } as never); + vi.mocked(upsertCycleDayLog).mockResolvedValue({ + id: "day-1", + existed: false, + changed: true, + } as never); + vi.mocked(prisma.cycleDayLog.findUniqueOrThrow).mockResolvedValue({ + id: "day-1", + symptoms: [], + } as never); + vi.mocked(prisma.cycleDayLog.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.cycleDayLog.findFirstOrThrow).mockResolvedValue({ + id: "day-1", + symptoms: [], + } as never); + vi.mocked(prisma.menstrualCycle.findUniqueOrThrow).mockResolvedValue({ + id: "cycle-1", + startDate: "2026-01-01", + } as never); +}); + +describe("POST /api/cycle/day-logs — external-id stability floor", () => { + it("422s on an object-description externalId and writes nothing", async () => { + const res = await postDayLog( + req("/api/cycle/day-logs", { + date: "2026-01-01", + loggedAt: "2026-01-01T08:00:00.000Z", + source: "APPLE_HEALTH", + externalId: POISONED, + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ path: string; message: string }> }; + meta?: { errorCode?: string }; + }; + const issue = body.details.issues.find((i) => i.path === "externalId"); + expect(issue).toBeDefined(); + expect(issue!.message).toContain("stable across app restarts"); + expect(body.meta?.errorCode).toBe("cycle.day-log.invalid"); + expect(upsertCycleDayLog).not.toHaveBeenCalled(); + }); + + it("422s on a bare memory-address externalId", async () => { + const res = await postDayLog( + req("/api/cycle/day-logs", { + date: "2026-01-01", + loggedAt: "2026-01-01T08:00:00.000Z", + externalId: "0x126b25160", + }), + ); + expect(res.status).toBe(422); + expect(upsertCycleDayLog).not.toHaveBeenCalled(); + }); + + it("accepts the HealthKit cycle-day id the mirror really sends", async () => { + const res = await postDayLog( + req("/api/cycle/day-logs", { + date: "2026-01-01", + loggedAt: "2026-01-01T08:00:00.000Z", + source: "APPLE_HEALTH", + externalId: REAL_ID, + }), + ); + expect(res.status).toBe(201); + expect(upsertCycleDayLog).toHaveBeenCalledTimes(1); + }); +}); + +describe("POST /api/cycle/period — external-id stability floor", () => { + it("422s on an object-description externalId and opens no cycle", async () => { + const res = await postPeriod( + req("/api/cycle/period", { + action: "start", + date: "2026-01-01", + loggedAt: "2026-01-01T08:00:00.000Z", + externalId: POISONED, + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ path: string; message: string }> }; + meta?: { errorCode?: string }; + }; + expect( + body.details.issues.some( + (i) => + i.path === "externalId" && + i.message.includes("stable across app restarts"), + ), + ).toBe(true); + expect(body.meta?.errorCode).toBe("cycle.period.invalid"); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("accepts the real HealthKit id and reaches the write transaction", async () => { + vi.mocked(prisma.$transaction).mockResolvedValue({ + cycleId: "cycle-1", + } as never); + const res = await postPeriod( + req("/api/cycle/period", { + action: "start", + date: "2026-01-01", + loggedAt: "2026-01-01T08:00:00.000Z", + externalId: REAL_ID, + }), + ); + expect(res.status).toBeLessThan(400); + expect(prisma.$transaction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/cycle/day-logs/bulk/__tests__/route.test.ts b/src/app/api/cycle/day-logs/bulk/__tests__/route.test.ts index bc177e247..d75526e8e 100644 --- a/src/app/api/cycle/day-logs/bulk/__tests__/route.test.ts +++ b/src/app/api/cycle/day-logs/bulk/__tests__/route.test.ts @@ -11,6 +11,7 @@ import { NextRequest } from "next/server"; vi.mock("@/lib/db", () => ({ prisma: { auditLog: { create: vi.fn() }, + menstrualCycle: { findMany: vi.fn().mockResolvedValue([]) }, }, })); @@ -48,6 +49,7 @@ vi.mock("next/headers", () => ({ })); import { POST } from "../route"; +import { prisma } from "@/lib/db"; import { getSession } from "@/lib/auth/session"; import { checkRateLimit } from "@/lib/rate-limit"; import { requireCycleEnabled } from "@/lib/cycle/gate"; @@ -72,7 +74,9 @@ beforeEach(() => { vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); vi.mocked(requireCycleEnabled).mockResolvedValue({ enabled: true, + profile: { sensitiveCategoryEncryption: false }, } as never); + vi.mocked(prisma.menstrualCycle.findMany).mockResolvedValue([] as never); }); describe("POST /api/cycle/day-logs/bulk — body cap", () => { @@ -94,3 +98,108 @@ describe("POST /api/cycle/day-logs/bulk — body cap", () => { expect(upsertCycleDayLog).not.toHaveBeenCalled(); }); }); + +describe("POST /api/cycle/day-logs/bulk — external-id stability floor", () => { + /** + * The bulk drain upserts on `(userId, source, externalId)`. A per-launch + * id never matches its own earlier row, so every re-post mints another + * day log. Refused PER ENTRY — the client's other days must still land. + */ + beforeEach(() => { + let n = 0; + vi.mocked(upsertCycleDayLog).mockImplementation( + async () => + ({ id: `day-${++n}`, existed: false, changed: true }) as never, + ); + }); + + it("skips the poisoned entry and still writes the two good ones", async () => { + const res = await POST( + postReq({ + entries: [ + { + date: "2026-01-01", + loggedAt: "2026-01-01T08:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "hkcycle:2026-01-01", + }, + { + date: "2026-01-02", + loggedAt: "2026-01-02T08:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "", + }, + { + date: "2026-01-03", + loggedAt: "2026-01-03T08:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + processed: number; + inserted: number; + skipped: number; + entries: Array<{ + index: number; + status: string; + reason?: string; + externalId?: string; + }>; + }; + }; + expect(body.data.processed).toBe(3); + expect(body.data.inserted).toBe(2); + expect(body.data.skipped).toBe(1); + expect(body.data.entries[0].status).toBe("inserted"); + expect(body.data.entries[1]).toMatchObject({ + index: 1, + status: "skipped", + reason: "unstable_external_id", + externalId: "", + }); + expect(body.data.entries[2].status).toBe("inserted"); + // Only the two good entries reached the writer. + expect(upsertCycleDayLog).toHaveBeenCalledTimes(2); + }); + + it("never turns one poisoned entry into a whole-batch 422", async () => { + const res = await POST( + postReq({ + entries: [ + { + date: "2026-01-01", + loggedAt: "2026-01-01T08:00:00.000Z", + externalId: "0xdeadbeef", + }, + ], + }), + ); + expect(res.status).toBe(200); + }); + + it("accepts the HealthKit cycle-day id shape the mirror really sends", async () => { + const res = await POST( + postReq({ + entries: [ + { + date: "2026-01-01", + loggedAt: "2026-01-01T08:00:00.000Z", + source: "APPLE_HEALTH", + externalId: "hkcycle:2026-01-01", + }, + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { entries: Array<{ status: string }> }; + }; + expect(body.data.entries[0].status).toBe("inserted"); + expect(upsertCycleDayLog).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/import/__tests__/route.test.ts b/src/app/api/import/__tests__/route.test.ts index f616d3c12..096b72f8c 100644 --- a/src/app/api/import/__tests__/route.test.ts +++ b/src/app/api/import/__tests__/route.test.ts @@ -1163,3 +1163,123 @@ describe("POST /api/import — write failure classification", () => { expect(auditLog).toHaveBeenCalledTimes(1); }); }); + +describe("POST /api/import — external-id stability floor", () => { + /** + * The importer upserts on `(userId, type, source=IMPORT, externalId)` so + * a re-import of the same export converges instead of duplicating. An id + * that cannot be stable between two exports defeats that. The importer + * has no per-entry status envelope — its vocabulary is a skipped COUNT — + * so the offending row lands there and every other row still imports. + */ + beforeEach(() => { + vi.mocked(checkRateLimit).mockResolvedValue({ + allowed: true, + remaining: 4, + resetAt: new Date(Date.now() + 1000), + } as never); + vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue([ + { id: "m-1", type: "WEIGHT", measuredAt: new Date() }, + ] as never); + vi.mocked(prisma.measurement.create).mockResolvedValue({ + id: "m-2", + type: "WEIGHT", + measuredAt: new Date(), + } as never); + vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({} as never); + vi.mocked(prisma.moodEntry.create).mockResolvedValue({} as never); + }); + + function importReq(body: unknown) { + return new NextRequest("http://localhost/api/import", { + method: "POST", + body: JSON.stringify(body), + headers: { "content-type": "application/json" }, + }); + } + + function measurement(externalId?: string) { + return { + type: "WEIGHT", + value: 80, + unit: "kg", + measuredAt: "2026-05-01T08:00:00.000Z", + ...(externalId ? { externalId } : {}), + }; + } + + it("skips the poisoned row, imports the two good ones, and never writes the bad id", async () => { + const response = await POST( + importReq({ + measurements: [ + measurement("scale-row-42"), + measurement(""), + measurement("scale-row-43"), + ], + }), + ); + expect(response.status).toBeLessThan(400); + const body = (await response.json()) as { + data: { measurements: number; skipped: number }; + }; + expect(body.data.measurements).toBe(2); + expect(body.data.skipped).toBe(1); + const written = vi + .mocked(prisma.measurement.createManyAndReturn) + .mock.calls.flatMap( + (call) => (call[0] as { data: Array<{ externalId?: string }> }).data, + ); + expect(written).toHaveLength(2); + expect(written.map((r) => r.externalId)).toEqual([ + "scale-row-42", + "scale-row-43", + ]); + }); + + it("skips a poisoned mood row and still imports the good one", async () => { + const response = await POST( + importReq({ + moodEntries: [ + { + date: "2026-05-01", + mood: "GUT", + score: 4, + loggedAt: "2026-05-01T08:00:00.000Z", + externalId: "daylio-1041", + }, + { + date: "2026-05-02", + mood: "OKAY", + score: 3, + loggedAt: "2026-05-02T08:00:00.000Z", + externalId: "0xdeadbeef", + }, + ], + }), + ); + expect(response.status).toBeLessThan(400); + const body = (await response.json()) as { + data: { moodEntries: number; skipped: number }; + }; + expect(body.data.moodEntries).toBe(1); + expect(body.data.skipped).toBe(1); + expect(vi.mocked(prisma.moodEntry.upsert)).toHaveBeenCalledTimes(1); + }); + + it("accepts the exporter row ids a real re-import carries", async () => { + const response = await POST( + importReq({ + measurements: [ + measurement("scale-row-42"), + measurement("8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30"), + ], + }), + ); + expect(response.status).toBeLessThan(400); + const body = (await response.json()) as { + data: { measurements: number; skipped: number }; + }; + expect(body.data.measurements).toBe(2); + expect(body.data.skipped).toBe(0); + }); +}); diff --git a/src/app/api/ingest/medication/__tests__/route.test.ts b/src/app/api/ingest/medication/__tests__/route.test.ts index 1b79f4226..cee53c4a1 100644 --- a/src/app/api/ingest/medication/__tests__/route.test.ts +++ b/src/app/api/ingest/medication/__tests__/route.test.ts @@ -349,3 +349,87 @@ describe("POST /api/ingest/medication — slot attribution + convergence (v1.16. expect(consumeForIntake).not.toHaveBeenCalled(); }); }); + +describe("POST /api/ingest/medication — idempotency-key stability floor", () => { + /** + * `MedicationIntakeEvent.idempotency_key` is not a windowed replay + * token: it is a `@unique` column persisted with the dose row forever + * and matched by plain equality, which makes it an identity. A bridge + * that mints a per-process key never matches its own earlier post, so + * every retry logs the dose again. + * + * The blank case is the sharper one here — the column is unique + * table-wide, so an empty key would resolve to whichever unrelated row + * stored "" first rather than to this caller's own earlier post. + */ + it("422s on an object-description key and logs nothing", async () => { + const res = await POST( + postReq({ + medicationName: "Metformin", + idempotencyKey: "", + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ path: string; message: string }> }; + }; + const issue = body.details.issues.find((i) => i.path === "idempotencyKey"); + expect(issue).toBeDefined(); + expect(issue!.message).toContain("idempotency key"); + expect(issue!.message).toContain("stable across app restarts"); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + }); + + it("422s on a whitespace-only key rather than matching a stranger's row", async () => { + const res = await POST( + postReq({ medicationName: "Metformin", idempotencyKey: " " }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ path: string; message: string }> }; + }; + expect( + body.details.issues.some( + (i) => + i.path === "idempotencyKey" && + i.message.includes("must not be empty"), + ), + ).toBe(true); + expect(prisma.medicationIntakeEvent.create).not.toHaveBeenCalled(); + }); + + it("accepts the timestamped key the documented bridge example sends", async () => { + // Ad-hoc take: no schedules on the medication, so the write goes + // straight to a create instead of converging onto a slot row. + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + timezone: "Europe/Berlin", + } as never); + vi.mocked(prisma.medication.findFirst).mockResolvedValue({ + id: "med-1", + name: "Metformin", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date("2026-01-01T00:00:00.000Z"), + schedules: [], + scheduleRevisions: [], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue(null); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "evt-new", + } as never); + + const res = await POST( + postReq({ + medicationName: "Metformin", + takenAt: "2026-06-10T06:42:00.000Z", + idempotencyKey: "intake-202602191230", + }), + ); + expect(res.status).toBe(201); + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/ingest/medication/route.ts b/src/app/api/ingest/medication/route.ts index 30e6c77b7..4e1845184 100644 --- a/src/app/api/ingest/medication/route.ts +++ b/src/app/api/ingest/medication/route.ts @@ -13,6 +13,10 @@ import { import { hashToken } from "@/lib/auth/hmac"; import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; import { externalIntakeSchema } from "@/lib/validations/medication"; +import { + unstableExternalIdMeta, + unstableExternalIdShape, +} from "@/lib/validations/external-id"; import { isApiGloballyEnabled } from "@/lib/app-settings"; import { recomputeMedicationComplianceForEvent } from "@/lib/rollups/medication-compliance-rollups"; import { @@ -108,9 +112,13 @@ export const POST = apiHandler(async (request: NextRequest) => { // integration sees the full validation shape at once. Audit // breadcrumb keyed `ingest.medication.validation-failed`. const issues = sanitiseZodIssues(parsed.error.issues); + const shape = unstableExternalIdShape(body, "idempotencyKey"); annotate({ action: { name: "ingest.medication.validation-failed" }, - meta: { issue_count: issues.length }, + meta: { + issue_count: issues.length, + ...(shape ? unstableExternalIdMeta("medication.ingest", [shape]) : {}), + }, }); // v1.4.49 — strip `message` from the audit-ledger row; external // ingest carries free-text `medicationName` + opaque diff --git a/src/app/api/medications/[id]/intake/__tests__/route.test.ts b/src/app/api/medications/[id]/intake/__tests__/route.test.ts index f47647000..06dbfa686 100644 --- a/src/app/api/medications/[id]/intake/__tests__/route.test.ts +++ b/src/app/api/medications/[id]/intake/__tests__/route.test.ts @@ -698,3 +698,95 @@ describe("POST /api/medications/[id]/intake — as-needed (schedule-less) medica expect(restoreForIntake).toHaveBeenCalledTimes(1); }); }); + +describe("POST /api/medications/[id]/intake — idempotency-key stability floor", () => { + function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/medications/med-1/intake", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + /** + * `MedicationIntakeEvent.idempotency_key` is a `@unique` column + * persisted with the dose row forever and matched by plain equality — + * an identity, not a windowed replay token. A caller minting a + * per-process key never matches its own earlier post, so a retried + * submission logs the dose a second time. + */ + it("422s on an object-description key and records no dose", async () => { + const res = await POST( + postReq({ + takenAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + idempotencyKey: "", + }), + ROUTE_PARAMS, + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ path: string; message: string }> }; + }; + const issue = body.details.issues.find((i) => i.path === "idempotencyKey"); + expect(issue).toBeDefined(); + expect(issue!.message).toContain("idempotency key"); + expect(issue!.message).toContain("stable across app restarts"); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("422s on a bare memory-address key", async () => { + const res = await POST( + postReq({ + takenAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), + idempotencyKey: "0x126b25160", + }), + ROUTE_PARAMS, + ); + expect(res.status).toBe(422); + expect(prisma.$transaction).not.toHaveBeenCalled(); + }); + + it("accepts a stable caller-issued key and records the dose", async () => { + const takenAt = new Date(Date.now() - 60 * 60 * 1000); + vi.mocked(prisma.$transaction).mockResolvedValue([ + { + id: "evt-idem-1", + userId: "user-1", + medicationId: "med-1", + scheduledFor: takenAt, + takenAt, + skipped: false, + }, + ] as never); + vi.mocked(prisma.medication.findFirst).mockResolvedValueOnce({ + id: "med-1", + startsOn: null, + endsOn: null, + oneShot: false, + createdAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + schedules: [], + } as never); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValueOnce( + null as never, + ); + vi.mocked(prisma.medication.findUnique).mockResolvedValue({ + id: "med-1", + userId: "user-1", + oneShot: false, + active: true, + } as never); + vi.mocked(prisma.medication.updateMany).mockResolvedValue({ + count: 0, + } as never); + + const res = await POST( + postReq({ + takenAt: takenAt.toISOString(), + idempotencyKey: "ios-outbox-8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30", + }), + ROUTE_PARAMS, + ); + expect(res.status).toBe(201); + expect(prisma.$transaction).toHaveBeenCalled(); + }); +}); diff --git a/src/app/api/medications/[id]/intake/route.ts b/src/app/api/medications/[id]/intake/route.ts index 5fe7aed25..99d027529 100644 --- a/src/app/api/medications/[id]/intake/route.ts +++ b/src/app/api/medications/[id]/intake/route.ts @@ -14,6 +14,10 @@ import { intakeSchema, listIntakeEventsSchema, } from "@/lib/validations/medication"; +import { + unstableExternalIdMeta, + unstableExternalIdShape, +} from "@/lib/validations/external-id"; import { resolveInjectionSiteForWrite } from "@/lib/medications/injection-site-write"; import type { InjectionSiteKey } from "@/lib/medications/injection-sites"; import { withIdempotency } from "@/lib/idempotency"; @@ -72,9 +76,16 @@ async function postIntake(request: NextRequest, { params }: RouteParams) { // v1.4.43 W6 — per-med intake POST hot path; multi-issue 422 + // audit breadcrumb keyed `medications.intake.create.validation-failed`. const issues = sanitiseZodIssues(parsed.error.issues); + const shape = unstableExternalIdShape(body, "idempotencyKey"); annotate({ action: { name: "medications.intake.create.validation-failed" }, - meta: { issue_count: issues.length, medication_id: id }, + meta: { + issue_count: issues.length, + medication_id: id, + ...(shape + ? unstableExternalIdMeta("medication.intake.create", [shape]) + : {}), + }, }); // v1.4.49 — strip `message` from the audit-ledger row; the // intake payload carries `idempotencyKey` (opaque caller string). diff --git a/src/app/api/medications/intake/bulk/__tests__/route.test.ts b/src/app/api/medications/intake/bulk/__tests__/route.test.ts index 84fd54a0e..8f1632bde 100644 --- a/src/app/api/medications/intake/bulk/__tests__/route.test.ts +++ b/src/app/api/medications/intake/bulk/__tests__/route.test.ts @@ -1358,3 +1358,131 @@ describe("POST /api/medications/intake/bulk — v1.28 Apple Health dose events", expect("externalId" in createArgs.data).toBe(false); }); }); + +describe("POST /api/medications/intake/bulk — identity stability floor", () => { + /** + * Two dedup keys ride this route and BOTH are identities matched by + * equality forever: `(userId, externalId)` for the Apple dose event, and + * the `@unique` `idempotency_key` column for the replay probe. A value + * that rotates per client launch matches neither, so every re-sync logs + * the dose again. Refused PER ENTRY — a client draining an offline queue + * must not lose its good doses to one bad row. + */ + beforeEach(() => { + vi.mocked(prisma.medication.findMany).mockResolvedValue([ + { id: "med-1", externalSource: "APPLE_HEALTH" }, + ] as never); + vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never); + vi.mocked(prisma.medicationIntakeEvent.findMany).mockResolvedValue( + [] as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findFirst).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medicationIntakeEvent.findUnique).mockResolvedValue( + null as never, + ); + vi.mocked(prisma.medicationIntakeEvent.create).mockResolvedValue({ + id: "evt-new", + } as never); + }); + + function entry(overrides: Record) { + return { + medicationId: "med-1", + takenAt: "2026-06-15T05:01:00.000Z", + ...overrides, + }; + } + + it("skips the poisoned dose event and still logs the two good ones", async () => { + const res = await POST( + postReq({ + entries: [ + entry({ + source: "APPLE_HEALTH", + externalId: "8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30", + }), + entry({ + source: "APPLE_HEALTH", + externalId: "", + }), + entry({ + source: "APPLE_HEALTH", + externalId: "3f0c4e4d-9c1e-4b7e-2a1d-6f30ad2a9cb1", + }), + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + processed: number; + skipped: Array<{ index: number; reason: string }>; + entries: Array<{ index: number; status: string; reason?: string }>; + }; + }; + expect(body.data.processed).toBe(3); + expect(body.data.entries[0].status).not.toBe("skipped"); + expect(body.data.entries[1]).toMatchObject({ + index: 1, + status: "skipped", + reason: "unstable_external_id", + }); + expect(body.data.entries[2].status).not.toBe("skipped"); + expect(body.data.skipped).toEqual([ + { index: 1, reason: "unstable_external_id" }, + ]); + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(2); + }); + + it("skips a rotating idempotencyKey — the column is a permanent identity, not a windowed token", async () => { + const res = await POST( + postReq({ + entries: [ + entry({ idempotencyKey: "intake-2026-06-15T05:01:00Z-med-1" }), + entry({ idempotencyKey: "" }), + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + entries: Array<{ index: number; status: string; reason?: string }>; + }; + }; + expect(body.data.entries[0].status).not.toBe("skipped"); + expect(body.data.entries[1]).toMatchObject({ + index: 1, + status: "skipped", + reason: "unstable_external_id", + }); + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); + }); + + it("never turns one poisoned entry into a whole-batch 422", async () => { + const res = await POST( + postReq({ entries: [entry({ idempotencyKey: "0xdeadbeef" })] }), + ); + expect(res.status).toBe(200); + }); + + it("accepts the HealthKit dose-event UUID the mirror really sends", async () => { + const res = await POST( + postReq({ + entries: [ + entry({ + source: "APPLE_HEALTH", + externalId: "8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30", + }), + ], + }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { entries: Array<{ status: string }> }; + }; + expect(body.data.entries[0].status).not.toBe("skipped"); + expect(prisma.medicationIntakeEvent.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/medications/intake/bulk/route.ts b/src/app/api/medications/intake/bulk/route.ts index 4dcacf96c..c969f17da 100644 --- a/src/app/api/medications/intake/bulk/route.ts +++ b/src/app/api/medications/intake/bulk/route.ts @@ -101,6 +101,11 @@ const bulkEntrySchema = z // 5-year floor), matching the single-intake create + edit paths. takenAt: boundedTakenAtSchema.optional(), skipped: z.boolean().optional().default(false), + // Persisted to the `@unique` `idempotency_key` column and matched by + // equality forever, so it is an identity and carries the same + // stability floor. Checked per entry for the same reason as + // `externalId` above. + // @external-id-checked-per-entry: src/app/api/medications/intake/bulk/route.ts idempotencyKey: z.string().min(1).max(128).optional(), // v1.8.5 — optional per-entry injection site. Validated against the // medication's effective allowed set; a disallowed site marks the @@ -356,16 +361,20 @@ async function postBulk(request: NextRequest): Promise { continue; } - // The `(userId, externalId)` dose-event dedup below is only - // idempotent while the id is STABLE across client launches. An id - // that rotates per process (an object description carrying a memory - // address) never matches its own earlier row, so every re-sync mints - // another intake event. Refused per entry — one bad row must not - // stop the rest of the batch landing. + // Both dedup keys on this route are only idempotent while the value + // is STABLE across client launches: `(userId, externalId)` for the + // Apple dose event, and the `@unique` `idempotency_key` column for + // the replay probe below. A value that rotates per process (an + // object description carrying a memory address) never matches its + // own earlier row, so every re-sync logs the dose again. Refused per + // entry — one bad row must not stop the rest of the batch landing. const unstable = - entry.externalId === undefined + (entry.externalId === undefined ? null - : classifyExternalId(entry.externalId); + : classifyExternalId(entry.externalId)) ?? + (entry.idempotencyKey === undefined + ? null + : classifyExternalId(entry.idempotencyKey)); if (unstable) { unstableShapes.push(unstable); skipped.push({ index: i, reason: UNSTABLE_EXTERNAL_ID_REASON }); diff --git a/src/app/api/mental-health/assessments/__tests__/route.test.ts b/src/app/api/mental-health/assessments/__tests__/route.test.ts index 24f2ba941..1d780f051 100644 --- a/src/app/api/mental-health/assessments/__tests__/route.test.ts +++ b/src/app/api/mental-health/assessments/__tests__/route.test.ts @@ -397,3 +397,54 @@ describe("POST /api/mental-health/assessments", () => { expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled(); }); }); + +describe("POST /api/mental-health/assessments — external-id stability floor", () => { + /** + * `externalId` is the durable replay anchor for the native client's + * outbox — the only thing standing between a retried submission and a + * second recorded administration of the same screener. It only holds + * while the value survives an app restart. + */ + it("422s on an object-description externalId and records nothing", async () => { + const res = await callPost( + makeReq({ + instrument: "PHQ9", + items: [1, 1, 1, 1, 1, 1, 1, 1, 0], + externalId: "", + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ path: string; message: string }> }; + }; + const issue = body.details.issues.find((i) => i.path === "externalId"); + expect(issue).toBeDefined(); + expect(issue!.message).toContain("stable across app restarts"); + expect(prisma.mentalHealthAssessment.create).not.toHaveBeenCalled(); + }); + + it("accepts the outbox UUID the native client actually sends", async () => { + vi.mocked(prisma.mentalHealthAssessment.create).mockResolvedValue({ + id: "mha_2", + instrument: "PHQ9", + locale: "en", + version: "standard", + totalScore: 8, + severityBand: "mild", + item9Flagged: false, + crisisShownAt: null, + takenAt: new Date("2026-06-28T00:00:00.000Z"), + createdAt: new Date("2026-06-28T00:00:00.000Z"), + } as never); + + const res = await callPost( + makeReq({ + instrument: "PHQ9", + items: [1, 1, 1, 1, 1, 1, 1, 1, 0], + externalId: "8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30", + }), + ); + expect(res.status).toBe(201); + expect(prisma.mentalHealthAssessment.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/mood-entries/__tests__/route.test.ts b/src/app/api/mood-entries/__tests__/route.test.ts index 2ddf87a90..1b54d3e0c 100644 --- a/src/app/api/mood-entries/__tests__/route.test.ts +++ b/src/app/api/mood-entries/__tests__/route.test.ts @@ -563,3 +563,71 @@ describe("POST /api/mood-entries — externalId idempotent re-import (v1.12.1)", expect(txClient.moodEntry.create).not.toHaveBeenCalled(); }); }); + +describe("POST /api/mood-entries — external-id stability floor", () => { + const BASE = { + mood: "GUT", + moodLoggedAt: "2026-06-01T08:00:00.000Z", + }; + + /** + * The create upserts on `(userId, source, externalId)` so a re-post with + * the same id updates in place. A per-launch id defeats that and mints a + * new entry every sync. + */ + it("422s on an object-description externalId and writes nothing", async () => { + const res = await POST( + postReq({ + ...BASE, + externalId: "", + }), + ); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ path: string; message: string }> }; + meta?: { errorCode?: string }; + }; + const issue = body.details.issues.find((i) => i.path === "externalId"); + expect(issue).toBeDefined(); + expect(issue!.message).toContain("stable across app restarts"); + expect(issue!.message).toContain("object description"); + expect(body.meta?.errorCode).toBe("mood.create.invalid"); + expect(txClient.moodEntry.upsert).not.toHaveBeenCalled(); + expect(txClient.moodEntry.create).not.toHaveBeenCalled(); + }); + + it("422s on a bare memory-address externalId", async () => { + const res = await POST(postReq({ ...BASE, externalId: "0x126b25160" })); + expect(res.status).toBe(422); + const body = (await res.json()) as { + details: { issues: Array<{ path: string; message: string }> }; + }; + expect( + body.details.issues.some( + (i) => i.path === "externalId" && i.message.includes("memory address"), + ), + ).toBe(true); + expect(txClient.moodEntry.upsert).not.toHaveBeenCalled(); + }); + + it("accepts the SwiftData row UUID the iOS client actually sends", async () => { + txClient.moodEntry.upsert.mockResolvedValue({ + id: "mood-ext-1", + tags: null, + moodLoggedAt: new Date(BASE.moodLoggedAt), + mood: "GUT", + note: null, + source: "MANUAL", + date: "2026-06-01", + }); + + const res = await POST( + postReq({ + ...BASE, + externalId: "8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30", + }), + ); + expect(res.status).toBe(201); + expect(txClient.moodEntry.upsert).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/app/api/workouts/batch/__tests__/unstable-external-id.test.ts b/src/app/api/workouts/batch/__tests__/unstable-external-id.test.ts new file mode 100644 index 000000000..1e2cd9cd1 --- /dev/null +++ b/src/app/api/workouts/batch/__tests__/unstable-external-id.test.ts @@ -0,0 +1,220 @@ +/** + * `POST /api/workouts/batch` — external-id stability floor. + * + * `(userId, source, externalId)` is idempotent only while the id survives + * an app restart. A per-process value — an object description carrying a + * memory address — never matches its own earlier row, so every re-sync + * mints another workout for the same session. + * + * The refusal is PER ENTRY. A cold-start HealthKit backfill is "every + * workout I have ever recorded"; losing the whole batch to one bad row + * would be worse than the duplicate it prevents. + */ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn() }, + workout: { findMany: vi.fn(), createManyAndReturn: vi.fn() }, + $transaction: vi.fn(async (fn: unknown) => { + if (typeof fn === "function") { + return (fn as (tx: unknown) => unknown)(txClient); + } + }), + }, +})); + +const txClient = { + workout: { createManyAndReturn: vi.fn() }, + workoutRoute: { createMany: vi.fn() }, + workoutSamples: { createMany: vi.fn() }, +}; + +vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() })); +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/rate-limit", () => ({ + checkRateLimit: vi.fn(), + rateLimitHeaders: () => ({}), +})); +vi.mock("@/lib/idempotency", () => ({ + withIdempotency: + (fn: (...args: Args) => Promise) => + (...args: Args) => + fn(...args), +})); +vi.mock("@/lib/jobs/pr-detection", () => ({ + enqueuePrDetection: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/cache/invalidate", () => ({ + invalidateUserMeasurements: vi.fn(), +})); +vi.mock("@/lib/arrivals/emit-shared", () => ({ + emitDataArrival: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); +vi.mock("@/lib/logging/context", async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, annotate: vi.fn() }; +}); +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { getSession } from "@/lib/auth/session"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; + +const SESSION_OK = { + session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) }, + user: { id: "user-1", username: "tester", role: "USER" as const }, +}; + +function postReq(body: unknown): NextRequest { + return new NextRequest("http://localhost/api/workouts/batch", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +/** A minimal well-formed run; only the id varies per case. */ +function workout(externalId: string, offsetMin = 0) { + const start = new Date(Date.UTC(2026, 5, 15, 6, offsetMin, 0)); + const end = new Date(start.getTime() + 30 * 60 * 1000); + return { + sportType: "running", + startedAt: start.toISOString(), + endedAt: end.toISOString(), + source: "APPLE_HEALTH", + externalId, + }; +} + +async function entriesFor(workouts: unknown[]) { + const res = await POST(postReq({ workouts })); + expect(res.status).toBe(200); + const body = (await res.json()) as { + data: { + entries: Array<{ index: number; status: string; reason?: string }>; + skipped: Array<{ index: number; reason: string }>; + }; + }; + return body.data; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(getSession).mockResolvedValue(SESSION_OK as never); + vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true } as never); + vi.mocked(prisma.user.findUnique).mockResolvedValue({ + sourcePriorityJson: null, + } as never); + vi.mocked(prisma.workout.findMany).mockResolvedValue([] as never); + // Every row handed to the writer comes back inserted, keyed by its id. + txClient.workout.createManyAndReturn.mockImplementation( + async (args: { + data: Array> | Record; + }) => { + const rows = Array.isArray(args.data) ? args.data : [args.data]; + return rows.map((row, i) => ({ + id: `w-${i}-${String(row.externalId ?? "null")}`, + source: row.source, + externalId: row.externalId ?? null, + })); + }, + ); + txClient.workoutRoute.createMany.mockResolvedValue({ count: 0 }); + txClient.workoutSamples.createMany.mockResolvedValue({ count: 0 }); +}); + +describe("POST /api/workouts/batch — unstable external ids", () => { + it("skips the poisoned workout and still writes the two good ones", async () => { + const data = await entriesFor([ + workout("8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30", 0), + workout("", 40), + workout("3f0c4e4d-9c1e-4b7e-2a1d-6f30ad2a9cb1", 80), + ]); + expect(data.entries[0].status).toBe("inserted"); + expect(data.entries[1]).toMatchObject({ + index: 1, + status: "skipped", + reason: "unstable_external_id", + }); + expect(data.entries[2].status).toBe("inserted"); + expect(data.skipped).toEqual([ + { index: 1, reason: "unstable_external_id" }, + ]); + // The writer only ever saw the two good rows. + const written = txClient.workout.createManyAndReturn.mock.calls.flatMap( + (call) => { + const arg = call[0] as { data: unknown }; + return Array.isArray(arg.data) ? arg.data : [arg.data]; + }, + ) as Array<{ externalId: string | null }>; + expect(written).toHaveLength(2); + expect(written.map((r) => r.externalId)).not.toContain( + "", + ); + }); + + it("skips a bare memory-address id", async () => { + const data = await entriesFor([workout("0x126b25160")]); + expect(data.entries[0]).toMatchObject({ + status: "skipped", + reason: "unstable_external_id", + }); + expect(txClient.workout.createManyAndReturn).not.toHaveBeenCalled(); + }); + + it("never turns one poisoned entry into a whole-batch refusal", async () => { + const res = await POST( + postReq({ workouts: [workout("0xdeadbeef"), workout("hk-uuid-41", 40)] }), + ); + expect(res.status).toBe(200); + }); + + it("accepts the HealthKit workout UUID the iOS sync really sends", async () => { + const data = await entriesFor([ + workout("8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30"), + ]); + expect(data.entries[0].status).toBe("inserted"); + expect(txClient.workout.createManyAndReturn).toHaveBeenCalled(); + }); + + it("leaves a workout with no externalId alone — NULL is legitimately distinct", async () => { + const noId: Record = { ...workout("unused") }; + delete noId.externalId; + const data = await entriesFor([noId]); + expect(data.entries[0].status).toBe("inserted"); + }); + + it("annotates the refusal with the shape, never the id", async () => { + await entriesFor([ + workout("", 0), + workout("0xdeadbeef", 40), + ]); + const calls = vi.mocked(annotate).mock.calls.map(([c]) => c); + const rejection = calls.find( + (c) => c?.meta && "external_id_rejected" in c.meta, + ); + expect(rejection?.meta).toMatchObject({ + external_id_rejected: 2, + external_id_shapes: "object_description,pointer_address", + external_id_surface: "workout.batch", + }); + expect(JSON.stringify(rejection)).not.toContain("0x12568db80"); + }); +}); diff --git a/src/lib/import/__tests__/csv-measurements.test.ts b/src/lib/import/__tests__/csv-measurements.test.ts index bfc1f7104..e161af279 100644 --- a/src/lib/import/__tests__/csv-measurements.test.ts +++ b/src/lib/import/__tests__/csv-measurements.test.ts @@ -202,3 +202,59 @@ describe("parseCsvMeasurements — optional columns", () => { expect(out.rows[0].row?.externalId).toBeUndefined(); }); }); + +describe("parseCsvMeasurements — external-id stability floor", () => { + /** + * The CSV route upserts on `(userId, type, source=IMPORT, externalId)`, + * so an id that cannot be stable between two exports re-imports as a + * fresh row every time instead of converging on the one it already + * wrote. This importer has no Zod field to hang a refine on — its + * contract is a per-row `reason`, so the refusal lands there and every + * other row still parses. + */ + it("skips a row whose externalId is an object description", () => { + const out = parse([ + "WEIGHT,80,kg,2026-05-01T08:00:00Z,,,", + ]); + expect(out.rows[0]).toMatchObject({ + status: "skipped", + reason: "unstable_external_id", + }); + expect(out.rows[0].row).toBeUndefined(); + }); + + it("skips a row whose externalId is a bare memory address", () => { + expect( + parse(["WEIGHT,80,kg,2026-05-01T08:00:00Z,,,0x126b25160"]).rows[0], + ).toMatchObject({ status: "skipped", reason: "unstable_external_id" }); + }); + + it("skips only the offending row — the rest of the file still imports", () => { + const out = parse([ + "WEIGHT,80,kg,2026-05-01T08:00:00Z,,,scale-row-42", + "WEIGHT,81,kg,2026-05-02T08:00:00Z,,,", + "WEIGHT,82,kg,2026-05-03T08:00:00Z,,,scale-row-43", + ]); + expect(out.rows.map((r) => r.status)).toEqual(["ok", "skipped", "ok"]); + expect(out.rows[1].reason).toBe("unstable_external_id"); + expect(out.rows[0].row?.externalId).toBe("scale-row-42"); + expect(out.rows[2].row?.externalId).toBe("scale-row-43"); + expect(out.fatal).toBeUndefined(); + }); + + it("accepts the spreadsheet / meter-export id shapes a real file carries", () => { + const out = parse([ + "WEIGHT,80,kg,2026-05-01T08:00:00Z,,,scale-row-42", + "WEIGHT,81,kg,2026-05-02T08:00:00Z,,,8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30", + "WEIGHT,82,kg,2026-05-03T08:00:00Z,,,stats:HKQuantityTypeIdentifierBodyMass:2026-05-03", + "WEIGHT,83,kg,2026-05-04T08:00:00Z,,,Morning Weigh-In 4", + ]); + expect(out.rows.every((r) => r.status === "ok")).toBe(true); + }); + + it("leaves a row with no externalId alone — an absent id is not a blank one", () => { + const out = parse(["WEIGHT,80,kg,2026-05-01T08:00:00Z,,,"]); + expect(out.rows[0].status).toBe("ok"); + expect(out.rows[0].row?.externalId).toBeUndefined(); + }); +}); diff --git a/src/lib/mcp/__tests__/writes.test.ts b/src/lib/mcp/__tests__/writes.test.ts index 78f7e7213..6772d4f77 100644 --- a/src/lib/mcp/__tests__/writes.test.ts +++ b/src/lib/mcp/__tests__/writes.test.ts @@ -389,3 +389,79 @@ describe("logMcpBloodPressure", () => { } }); }); + +describe("MCP writes — idempotency-key stability floor", () => { + /** + * The MCP key is hashed into the `externalId` half of + * `(userId, type, MCP, externalId)` and matched forever, so it is an + * identity. The hash launders the shape — a pointer address and a real + * key both digest to clean hex — so the check has to run on the INPUT, + * before `mcpExternalId`. + * + * The blank case is the live one: every tool handler falls back to `""` + * when the host omits the argument, and `""` digests to a CONSTANT, so + * without this floor every keyless write after the first would report + * `already_logged` and be silently dropped. + */ + it.each([ + ["blank", ""], + ["whitespace", " "], + ["pointer address", "0x12568db80"], + ["object description", ""], + ])("refuses a %s measurement key before it is hashed", async (_l, key) => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: key, + }); + expect(result.status).toBe("unstable_idempotency_key"); + expect(measurement.findUnique).not.toHaveBeenCalled(); + expect(measurement.create).not.toHaveBeenCalled(); + }); + + it("refuses a blank mood key instead of collapsing onto one row", async () => { + const result = await logMcpMood({ + userId: "u-1", + score: 4, + idempotencyKey: "", + }); + expect(result.status).toBe("unstable_idempotency_key"); + expect(moodEntry.create).not.toHaveBeenCalled(); + }); + + it("refuses a blank blood-pressure key instead of collapsing onto one row", async () => { + const result = await logMcpBloodPressure({ + userId: "u-1", + systolic: 120, + diastolic: 80, + idempotencyKey: "", + }); + expect(result.status).toBe("unstable_idempotency_key"); + expect($transaction).not.toHaveBeenCalled(); + }); + + it("carries a message naming the field a host has to fix", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: "", + }); + expect(result).toMatchObject({ status: "unstable_idempotency_key" }); + if (result.status !== "unstable_idempotency_key") return; + expect(result.reason).toContain("idempotency key"); + expect(result.reason).toContain("stable across app restarts"); + }); + + it("still accepts a normal host-chosen key", async () => { + const result = await logMcpMeasurement({ + userId: "u-1", + type: "WEIGHT", + value: 80, + idempotencyKey: "weight-2026-07-25-morning", + }); + expect(result.status).toBe("written"); + expect(measurement.create).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/mcp/write-tools.ts b/src/lib/mcp/write-tools.ts index 76e1e49c0..f6ae24c4e 100644 --- a/src/lib/mcp/write-tools.ts +++ b/src/lib/mcp/write-tools.ts @@ -268,6 +268,13 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [ idempotencyKey, }); + if (result.status === "unstable_idempotency_key") { + return { + written: false, + error: "unstable_idempotency_key", + reason: result.reason, + }; + } if (result.status === "unsupported_type") { return { written: false, error: "unsupported_type" }; } @@ -344,6 +351,13 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [ idempotencyKey, }); + if (result.status === "unstable_idempotency_key") { + return { + written: false, + error: "unstable_idempotency_key", + reason: result.reason, + }; + } if (result.status === "invalid_score") { return { written: false, error: "invalid_score" }; } @@ -429,6 +443,13 @@ const WRITE_TOOL_DEFINITIONS: McpToolDefinition[] = [ idempotencyKey, }); + if (result.status === "unstable_idempotency_key") { + return { + written: false, + error: "unstable_idempotency_key", + reason: result.reason, + }; + } if (result.status === "out_of_range") { return { written: false, error: "out_of_range", reason: result.reason }; } diff --git a/src/lib/mcp/writes.ts b/src/lib/mcp/writes.ts index f5ef982cb..83183d2db 100644 --- a/src/lib/mcp/writes.ts +++ b/src/lib/mcp/writes.ts @@ -41,6 +41,12 @@ import { recomputeMoodBucketsForEntry } from "@/lib/rollups/mood-rollups"; import { MOOD_ENUM_BY_SCORE } from "@/lib/mood/labels"; import { getScoreForMood } from "@/lib/validations/mood"; import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key"; +import { + classifyExternalId, + unstableExternalIdMessage, + unstableExternalIdMeta, + IDEMPOTENCY_KEY_LABEL, +} from "@/lib/validations/external-id"; /** The MCP write provenance — distinct from MANUAL / TELEGRAM. */ const MCP_SOURCE = "MCP" as const; @@ -52,6 +58,41 @@ const MCP_SOURCE = "MCP" as const; * `(userId, type, MCP, externalId)` so a key can never collide with a * Telegram or manual row. */ +/** + * The MCP write key is an IDENTITY, not a windowed replay token: it is + * hashed into the `externalId` half of `(userId, type, MCP, externalId)` + * and matched forever. A key that changes between the preview call and + * the commit call — or between two commits of the same logical write — + * mints a second row instead of converging. + * + * The hash launders the shape, so the floor has to run on the INPUT: a + * pointer-shaped key and a stable one both digest to clean hex, and the + * `externalId` guard downstream could never tell them apart. The blank + * case is the live one — every tool handler falls back to `""` when the + * host omits the argument, and `""` digests to a CONSTANT, so every + * keyless write after the first would report `already_logged` and be + * silently dropped. + */ +function checkMcpIdempotencyKey( + tool: string, + idempotencyKey: string, +): { status: "unstable_idempotency_key"; reason: string } | null { + const shape = classifyExternalId(idempotencyKey); + if (shape === null) return null; + annotate({ + action: { name: "mcp.tool.write" }, + meta: { + tool, + status: "unstable_idempotency_key", + ...unstableExternalIdMeta(`mcp.${tool}`, [shape]), + }, + }); + return { + status: "unstable_idempotency_key", + reason: unstableExternalIdMessage(shape, IDEMPOTENCY_KEY_LABEL), + }; +} + function mcpExternalId( prefix: "measure" | "mood" | "bp", idempotencyKey: string, @@ -186,6 +227,7 @@ export type McpMeasurementResult = | { status: "unsupported_type" } | { status: "unsupported_unit" } | { status: "out_of_range"; reason: string } + | { status: "unstable_idempotency_key"; reason: string } | { status: "written"; measurement: McpMeasurementRecord } | { status: "already_logged"; measurement: McpMeasurementRecord }; @@ -205,6 +247,12 @@ export async function logMcpMeasurement(input: { measuredAt?: Date; idempotencyKey: string; }): Promise { + const unstableKey = checkMcpIdempotencyKey( + "log_measurement", + input.idempotencyKey, + ); + if (unstableKey) return unstableKey; + // Normalise the caller's unit hint to canonical (converting the value) or // refuse it — a non-canonical unit must never be persisted verbatim. Runs // BEFORE the range check so the plausibility gate sees the canonical value @@ -337,6 +385,7 @@ export interface McpBloodPressureRecord { export type McpBloodPressureResult = | { status: "out_of_range"; reason: string } + | { status: "unstable_idempotency_key"; reason: string } | { status: "written"; bloodPressure: McpBloodPressureRecord } | { status: "already_logged"; bloodPressure: McpBloodPressureRecord }; @@ -359,6 +408,12 @@ export async function logMcpBloodPressure(input: { measuredAt?: Date; idempotencyKey: string; }): Promise { + const unstableKey = checkMcpIdempotencyKey( + "log_blood_pressure", + input.idempotencyKey, + ); + if (unstableKey) return unstableKey; + // Same validation the confirm-gate preview runs (both ranges, the systolic > // diastolic guard, and the measuredAt instant bound) — one verdict for both. const check = checkMcpBloodPressure( @@ -514,6 +569,7 @@ export interface McpMoodRecord { export type McpMoodResult = | { status: "invalid_score" } + | { status: "unstable_idempotency_key"; reason: string } | { status: "written"; moodEntry: McpMoodRecord } | { status: "already_logged"; moodEntry: McpMoodRecord }; @@ -531,6 +587,9 @@ export async function logMcpMood(input: { tz?: string | null; idempotencyKey: string; }): Promise { + const unstableKey = checkMcpIdempotencyKey("log_mood", input.idempotencyKey); + if (unstableKey) return unstableKey; + const mood = MOOD_ENUM_BY_SCORE[input.score]; if (!mood) { annotate({ diff --git a/src/lib/validations/external-id.ts b/src/lib/validations/external-id.ts index baee1f8a1..f4331085c 100644 --- a/src/lib/validations/external-id.ts +++ b/src/lib/validations/external-id.ts @@ -96,19 +96,43 @@ export function isStableExternalId(value: string): boolean { return classifyExternalId(value) === null; } -const SHAPE_MESSAGES: Record = { - blank: "external identifier must not be empty", - pointer_address: - "external identifier must be stable across app restarts; received a memory address (0x…), which is a new value on every launch and mints a duplicate record on every sync", - object_description: - "external identifier must be stable across app restarts; received an object description (), which embeds a memory address and is a new value on every launch — serialise the identity itself, not the object's description", -}; +/** + * How the offending field is named back to the caller. The rule is about + * the VALUE, so it applies to every client-supplied string this system + * later matches on as an identity — not only `externalId`. + */ +export const EXTERNAL_ID_LABEL = "external identifier"; +export const IDEMPOTENCY_KEY_LABEL = "idempotency key"; /** The 422 message a client developer reads. Names the shape received. */ export function unstableExternalIdMessage( shape: UnstableExternalIdShape, + label: string = EXTERNAL_ID_LABEL, ): string { - return SHAPE_MESSAGES[shape]; + switch (shape) { + case "blank": + return `${label} must not be empty`; + case "pointer_address": + return `${label} must be stable across app restarts; received a memory address (0x…), which is a new value on every launch and mints a duplicate record on every sync`; + case "object_description": + return `${label} must be stable across app restarts; received an object description (), which embeds a memory address and is a new value on every launch — serialise the identity itself, not the object's description`; + } +} + +function assertStableIdentityField( + raw: unknown, + ctx: z.RefinementCtx, + field: string, + label: string, +): void { + if (typeof raw !== "string") return; + const shape = classifyExternalId(raw); + if (shape === null) return; + ctx.addIssue({ + code: "custom", + path: [field], + message: unstableExternalIdMessage(shape, label), + }); } /** @@ -127,15 +151,42 @@ export function assertStableExternalId( value: { externalId?: string | null }, ctx: z.RefinementCtx, ): void { - const raw = value.externalId; - if (typeof raw !== "string") return; - const shape = classifyExternalId(raw); - if (shape === null) return; - ctx.addIssue({ - code: "custom", - path: ["externalId"], - message: unstableExternalIdMessage(shape), - }); + assertStableIdentityField( + value.externalId, + ctx, + "externalId", + EXTERNAL_ID_LABEL, + ); +} + +/** + * The same floor for a client-supplied `idempotencyKey`. + * + * The HTTP `Idempotency-Key` header is a replay token inside a TTL and is + * deliberately out of scope — `IdempotencyKey.expiresAt` bounds it and a + * cron sweeps it. `MedicationIntakeEvent.idempotency_key` is NOT that: it + * is a `@unique` column persisted with the dose row forever, with no TTL + * and no sweep, matched by plain equality on the per-medication intake + * POST, the bulk intake ingest and the external ingest surface. That makes + * it an identity, and it carries the same failure mode — a value that + * rotates per client launch never matches its own earlier row, so every + * re-sync logs the dose again. + * + * The blank arm matters more here than anywhere else: the column is + * `@unique` across the table, so a second row carrying "" cannot exist, + * and an empty key would otherwise resolve to whichever unrelated row got + * there first. + */ +export function assertStableIdempotencyKey( + value: { idempotencyKey?: string | null }, + ctx: z.RefinementCtx, +): void { + assertStableIdentityField( + value.idempotencyKey, + ctx, + "idempotencyKey", + IDEMPOTENCY_KEY_LABEL, + ); } /** @@ -146,9 +197,10 @@ export function assertStableExternalId( */ export function unstableExternalIdShape( body: unknown, + field: "externalId" | "idempotencyKey" = "externalId", ): UnstableExternalIdShape | null { if (typeof body !== "object" || body === null) return null; - const raw = (body as { externalId?: unknown }).externalId; + const raw = (body as Record)[field]; if (typeof raw !== "string") return null; return classifyExternalId(raw); } diff --git a/src/lib/validations/medication/intake.ts b/src/lib/validations/medication/intake.ts index 5e3a03a49..5a4e1fc3f 100644 --- a/src/lib/validations/medication/intake.ts +++ b/src/lib/validations/medication/intake.ts @@ -1,6 +1,7 @@ import { z } from "zod/v4"; import { boundedTakenAtSchema, injectionSiteEnum } from "./base"; +import { assertStableIdempotencyKey } from "@/lib/validations/external-id"; export const intakeSchema = z .object({ @@ -82,18 +83,29 @@ export const intakeSchema = z "Dose actually consumed for THIS intake when it deviates from (or documents) the medication's configured dose, e.g. a half tablet or a titration step. Free text, max 50 characters. Omit to record the take under the medication's configured dose.", ), }) + // `MedicationIntakeEvent.idempotency_key` is a `@unique` column persisted + // with the dose row forever and matched by equality — an identity, not a + // windowed replay token. A key that rotates per client launch never + // matches its own earlier row, so every re-sync logs the dose again. + .superRefine(assertStableIdempotencyKey) .meta({ id: "MedicationIntakeRequest", description: "Per-medication intake log body. Idempotent via `idempotencyKey`; the server also dedupes by a 60-second sliding window when the key is absent. Non-skipped intakes auto-decrement pen inventory (best-effort), refresh the per-day compliance rollup, and — for one-shot medications — flip `active` to false. The optional `injectionSite` is persisted only for an INJECTION medication with site-tracking enabled and is validated against the medication's effective allowed set (422 on a disallowed value).", }); -export const externalIntakeSchema = z.object({ - medicationName: z.string().min(1).max(200), - // v1.16.9 — same plausibility bounds as the interactive create paths. - takenAt: boundedTakenAtSchema.optional(), - idempotencyKey: z.string().max(128), -}); +export const externalIntakeSchema = z + .object({ + medicationName: z.string().min(1).max(200), + // v1.16.9 — same plausibility bounds as the interactive create paths. + takenAt: boundedTakenAtSchema.optional(), + idempotencyKey: z.string().max(128), + }) + // Same identity floor as the interactive twin. The blank arm carries real + // weight on this surface: the column is `@unique` table-wide, so an empty + // key resolves to whichever unrelated row happened to store "" first + // instead of to this caller's own earlier post. + .superRefine(assertStableIdempotencyKey); export const listIntakeEventsSchema = z.object({ limit: z.coerce.number().int().min(1).max(500).optional().default(25),