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

@@ -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");
});
});