fix(ingest): hold the stability floor for identities no request schema sees

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.
This commit is contained in:
Marc-André Bombeck committed 2026-07-25 18:07:13 +02:00
1 parent 6e4bcffadf
commit 15b5f6f92f
19 files changed
+1574 -43

No files matched your search

@@ -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: <path under repo root>` */
const MARKER = /@external-id-checked-per-entry:\s*(\S+)/;
@@ -83,6 +92,59 @@ const EXEMPT: Record<string, string> = {
"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: <path>` 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: <path>` 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
@@ -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:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...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<typeof import("@/lib/cycle/dto")>()),
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 = "<HKHealthConceptIdentifier: 0x12568db80>";
/** 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);
});
});
@@ -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: "<HKHealthConceptIdentifier: 0x12568db80>",
},
{
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: "<HKHealthConceptIdentifier: 0x12568db80>",
});
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);
});
});
+120
View File
@@ -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("<HKQuantitySample: 0x12568db80>"),
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);
});
});
@@ -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: "<NSObject: 0x600000c1a2b0>",
}),
);
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);
});
});
+9 -1
View File
@@ -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
@@ -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: "<NSObject: 0x600000c1a2b0>",
}),
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();
});
});
+12 -1
View File
@@ -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).
@@ -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<string, unknown>) {
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: "<HKHealthConceptIdentifier: 0x12568db80>",
}),
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: "<NSObject: 0x600000c1a2b0>" }),
],
}),
);
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);
});
});
+17 -8
View File
@@ -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<Response> {
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 });
@@ -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: "<HKHealthConceptIdentifier: 0x12568db80>",
}),
);
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);
});
});
@@ -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: "<HKHealthConceptIdentifier: 0x12568db80>",
}),
);
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);
});
});
@@ -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:
<Args extends unknown[]>(fn: (...args: Args) => Promise<Response>) =>
(...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<Record<string, unknown>>();
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<string, unknown>> | Record<string, unknown>;
}) => {
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("<HKWorkout: 0x12568db80>", 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(
"<HKWorkout: 0x12568db80>",
);
});
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<string, unknown> = { ...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("<HKWorkout: 0x12568db80>", 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");
});
});
@@ -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,,,<HKQuantitySample: 0x12568db80>",
]);
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,,,<HKQuantitySample: 0x12568db80>",
"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();
});
});
+76
View File
@@ -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", "<NSObject: 0x600000c1a2b0>"],
])("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: "<NSObject: 0x600000c1a2b0>",
});
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);
});
});
+21
View File
@@ -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 };
}
+59
View File
@@ -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<McpMeasurementResult> {
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<McpBloodPressureResult> {
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<McpMoodResult> {
const unstableKey = checkMcpIdempotencyKey("log_mood", input.idempotencyKey);
if (unstableKey) return unstableKey;
const mood = MOOD_ENUM_BY_SCORE[input.score];
if (!mood) {
annotate({
+70 -18
View File
@@ -96,19 +96,43 @@ export function isStableExternalId(value: string): boolean {
return classifyExternalId(value) === null;
}
const SHAPE_MESSAGES: Record<UnstableExternalIdShape, string> = {
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 (<Class: 0x…>), 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 (<Class: 0x…>), 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<string, unknown>)[field];
if (typeof raw !== "string") return null;
return classifyExternalId(raw);
}
+18 -6
View File
@@ -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),