Merge pull request #641 from MBombeck/fix/reject-unstable-external-ids

v1.32.37 — refuse an identity that cannot be stable, and scope the intake replay key to its owner
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-25 19:27:04 +02:00
commit 019ad52c0d
49 files changed
+3330 -78

No files matched your search

+30
View File
@@ -2,6 +2,36 @@
## [Unreleased]
## [1.32.37] — 2026-07-25
Two ways a record could quietly lose data, both closed.
An identifier that a client sends along with a reading is meant to say "this is
the same reading you already have". One client was sending a value that changed
every time the app restarted, so nothing ever matched, and every sync created a
fresh copy. On one instance that produced 23 medications nobody had ever taken,
none of them with a dose or a single logged intake, because the doses were
looking for a medication under an identifier that no longer existed.
The server now refuses an identifier that cannot possibly be stable, with a 422
that says what is wrong instead of quietly making duplicates. A batch rejects
only the offending entry and still accepts the rest, so one bad row cannot stop
a sync. Twelve entry points check it. The identifier shapes that real apps and
devices actually send are pinned by tests, so a future rule cannot get greedy
and start dropping valid data.
The second one was worse and had no symptom at all. The repeat-protection key
on a medication intake was unique across the whole database rather than per
account. Two people generating the same key independently meant the second one
could not save their dose at all. The database refused it, and the intake was
gone. The key is now unique per account, and every lookup asks who it belongs
to. Recording an intake through the automation interface without sending a key
had a related problem: the first one saved and every one after it was discarded
as a repeat. Also fixed.
The migration does not touch any existing intake. It relaxes a rule rather than
tightening one, so nothing that was allowed before becomes disallowed now.
## [1.32.36] — 2026-07-25
Nothing here changes what you see. This release is about the checks that run
+16 -4
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.32.36
version: 1.32.37
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -12091,6 +12091,10 @@ components:
{00,10,20,30,40,50}, trailing `Z` (e.g. `2026-06-21T14:10:00.000Z`). A re-post OVERWRITES the row and
returns `updated`, so iOS uploads ~144 rows/day instead of one per raw HR sample. A row that targets this
prefix with a malformed bucket-start suffix is `skipped` with reason `malformed_hr_bucket_id`.
The id MUST be stable across app restarts. An object description or memory address (`<Class: 0x…>`,
`0x12ab34cd`) is a new value on every launch, so it can never match its own earlier row; such an entry is
`skipped` with reason `unstable_external_id` while the rest of the batch still lands.
externalSourceVersion:
type: string
minLength: 1
@@ -12294,7 +12298,10 @@ components:
type: string
const: APPLE_HEALTH
externalId:
description: The HealthKit dose-event UUID. Drives the idempotent re-sync dedup (first-write-wins per Apple dose).
description: "The HealthKit dose-event UUID. Drives the idempotent re-sync dedup (first-write-wins per Apple dose). Must
be stable across app restarts — an object description or memory address (`<Class: 0x…>`, `0x12ab34cd`) is a
new value on every launch; such an entry is `skipped` with reason `unstable_external_id` while the rest of
the batch still lands."
type: string
minLength: 1
maxLength: 128
@@ -12411,7 +12418,9 @@ components:
externalId:
description: "Stable external identifier of the mirrored medication (the HealthKit `medicationConceptIdentifier`, max
128 chars). Must be supplied together with `externalSource`. Idempotent: re-posting the same pair returns
the existing medication with status 200 instead of minting a duplicate."
the existing medication with status 200 instead of minting a duplicate. The value MUST be identical across
app restarts: an object description or memory address (`<Class: 0x…>`, `0x12ab34cd`) is a new value on every
launch, never matches its own earlier row, and is refused with 422 rather than minting a duplicate mirror."
type: string
minLength: 1
maxLength: 128
@@ -13667,7 +13676,10 @@ components:
- TELEGRAM
- DAYLIO
externalId:
description: iOS-side source-stable id for idempotent dedup on the `(userId, source, externalId)` key.
description: "iOS-side source-stable id for idempotent dedup on the `(userId, source, externalId)` key. Must be stable
across app restarts — an object description or memory address (`<Class: 0x…>`, `0x12ab34cd`) is a new value
on every launch; such an entry is `skipped` with reason `unstable_external_id` while the rest of the batch
still lands."
type: string
minLength: 1
maxLength: 120
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.32.36",
"version": "1.32.37",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,47 @@
-- Scope the medication-intake idempotency key per user.
--
-- `medication_intake_events.idempotency_key` has carried a GLOBAL unique
-- index since 0001_init. The key is client-supplied, so a global unique
-- means one user's key can block another user's: the second user's write
-- is refused by the constraint and the dose is silently lost, and the
-- bulk route's unscoped replay probe resolves the key to the FIRST user's
-- row and hands its id back to the second user.
--
-- The correct grain is per user, matching every other client-supplied
-- identity on this table (`(user_id, external_id)`, migration 0239).
--
-- On existing data this is a RELAXATION: the old constraint is strictly
-- stronger than the new one — if no two rows anywhere share a key, then
-- no two rows of the SAME user share one either — so no row can violate
-- the new index and nothing needs reconciling. Step 1 below is a safety
-- net for a database whose global index was dropped by hand at some
-- point; against an intact schema it matches zero rows.
--
-- Step 1 never deletes or rewrites a dose. It only clears the dedup HINT
-- on the strictly later row of a colliding pair, keeping every intake
-- event, its timestamps, its attribution and its inventory stamp intact.
-- The cost is that one row loses replay protection it never really had.
-- 1. Safety net: break any pre-existing same-user key collision by
-- clearing the key on every row but the earliest of its group.
UPDATE "medication_intake_events" AS e
SET "idempotency_key" = NULL
WHERE e."idempotency_key" IS NOT NULL
AND EXISTS (
SELECT 1
FROM "medication_intake_events" AS earlier
WHERE earlier."user_id" = e."user_id"
AND earlier."idempotency_key" = e."idempotency_key"
AND (
earlier."created_at" < e."created_at"
OR (earlier."created_at" = e."created_at" AND earlier."id" < e."id")
)
);
-- 2. Create the per-user unique BEFORE dropping the global one, so a
-- failure here aborts the migration with the old protection intact.
CREATE UNIQUE INDEX "medication_intake_events_user_id_idempotency_key_key"
ON "medication_intake_events" ("user_id", "idempotency_key");
-- 3. Retire the global unique.
DROP INDEX IF EXISTS "medication_intake_events_idempotency_key_key";
+7 -1
View File
@@ -2757,7 +2757,12 @@ model MedicationIntakeEvent {
/// nightly dedup leaves both standalone.
attributionSource IntakeAttributionSource @default(AUTO) @map("attribution_source")
source IntakeSource @default(WEB)
idempotencyKey String? @unique @map("idempotency_key")
/// v1.32.37 — client-supplied replay anchor, unique PER USER (migration
/// 0273). It carried a GLOBAL unique from 0001_init, which let one
/// user's key refuse another user's write outright and let an unscoped
/// lookup resolve to a stranger's row. Same grain as `(user_id,
/// external_id)` below: every read must key on `(userId, idempotencyKey)`.
idempotencyKey String? @map("idempotency_key")
createdAt DateTime @default(now()) @map("created_at")
/// v1.4.25 W4d — only populated for GLP-1 intake events (the picker
/// is the only surface that writes this column). Existing rows stay
@@ -2829,6 +2834,7 @@ model MedicationIntakeEvent {
/// unique above, migration 0121), so the schema keeps the full
/// `@@unique` for the `userId_externalId` compound where-key.
@@unique([userId, externalId])
@@unique([userId, idempotencyKey])
@@index([userId, medicationId, scheduledFor])
// v1.7.0 sync — keyset support for the `/api/sync/changes` intake feed
// (`where userId` ordered by `(updatedAt asc, id asc)`).
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.32.36";
/* @sw-version-fallback */ "v1.32.37";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
@@ -0,0 +1,404 @@
/**
* Structural guard on the external-id stability floor.
*
* A client-supplied `externalId` is half of every ingest dedup key. An id
* that rotates between client launches never matches its own earlier row,
* so each sync sweep mints a fresh record — the failure mode that filled a
* live instance with phantom medications, one per sweep, none of which
* could ever collect a dose.
*
* The fix is only durable if it cannot be bypassed by the NEXT route.
* These guards freeze the two legal ways to accept the field:
*
* 1. the request schema routes it through `assertStableExternalId`, so
* the refusal rides the standard 422 multi-issue envelope; or
* 2. the field is deliberately permissive because a schema-level refusal
* would fail a whole batch on one bad row — in which case the
* declaration carries an `@external-id-checked-per-entry: <path>`
* marker naming the file that runs `classifyExternalId` per entry,
* and this guard verifies that file really does.
*
* They are tripwires, not proofs. A reviewer who waves through a bad
* addition defeats them, and no test substitutes for that review.
*/
import { describe, it, expect } from "vitest";
import { readFileSync } from "node:fs";
import { join, sep } from "node:path";
import { globSync } from "node:fs";
const SRC = join(process.cwd(), "src");
const VALIDATOR_MODULE = "@/lib/validations/external-id";
function sourceFiles(): string[] {
return globSync("**/*.{ts,tsx}", { cwd: SRC })
.filter(
(p) => !p.startsWith(`generated${sep}`) && !p.startsWith("generated/"),
)
.filter((p) => !p.includes("__tests__"))
.filter((p) => !p.endsWith(".test.ts") && !p.endsWith(".test.tsx"))
.map((p) => p.split(sep).join("/"))
.sort();
}
function read(rel: string): string {
return readFileSync(join(SRC, rel), "utf8");
}
/**
* The file with its `import … from "…";` statements removed.
*
* A guard that greps the raw source cannot tell "this file APPLIES the
* check" from "this file merely imports it" — deleting the call while
* leaving the import would slip straight through. Every usage assertion
* below therefore runs against the body, and the module import is
* asserted separately where it matters.
*/
function body(rel: string): string {
return read(rel).replace(/import\s+[\s\S]*?from\s+"[^"]+";/g, "");
}
/**
* A REQUEST-side declaration of a client-supplied identity string:
* `externalId: z.…` / `externalSourceId: z.…` / `idempotencyKey: z.…` in a
* Zod object shape.
*
* `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|idempotencyKey)\s*:\s*z\b/gm;
/** `// @external-id-checked-per-entry: <path under repo root>` */
const MARKER = /@external-id-checked-per-entry:\s*(\S+)/;
/**
* Files that declare the field but are deliberately outside the floor.
* Each entry states WHY; an addition here is a decision, not a shortcut.
*/
const EXEMPT: Record<string, string> = {
// Backup restore reads a payload the SERVER itself wrote and encrypted.
// It has to reproduce the stored rows byte-for-byte — including ids that
// predate this floor — or a self-hoster with an already-poisoned export
// could never restore it. Nothing client-authored reaches this schema.
"lib/validations/backup.ts":
"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);
const lines: number[] = [];
DECLARATION.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = DECLARATION.exec(source)) !== null) {
lines.push(source.slice(0, m.index).split("\n").length);
}
return lines;
}
function declaringFiles(): string[] {
return sourceFiles()
.filter((rel) => !rel.startsWith("lib/openapi/"))
.filter((rel) => declarationLines(rel).length > 0);
}
describe("every client-supplied external id routes through the stability floor", () => {
it("finds the declaring files at all (the matcher still matches)", () => {
// A guard whose matcher silently stops matching passes forever. Pin the
// known set so a rename of the field or of the Zod idiom trips here.
const files = declaringFiles();
expect(files.length).toBeGreaterThanOrEqual(8);
expect(files).toContain("app/api/measurements/batch/route.ts");
expect(files).toContain("lib/validations/medication/create-update.ts");
});
it.each(declaringFiles())(
"%s either applies the field check or names the per-entry checker",
(rel) => {
if (rel in EXEMPT) {
expect(EXEMPT[rel].length).toBeGreaterThan(20);
return;
}
const usesFieldCheck =
read(rel).includes(VALIDATOR_MODULE) &&
(body(rel).includes("assertStableExternalId") ||
body(rel).includes("assertStableIdempotencyKey"));
const marksPerEntry = MARKER.test(read(rel));
expect(
usesFieldCheck || marksPerEntry,
`${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);
},
);
it.each(declaringFiles())(
"%s — every per-entry marker points at a file that really checks per entry",
(rel) => {
if (rel in EXEMPT) return;
const source = read(rel);
MARKER.lastIndex = 0;
const targets = [...source.matchAll(new RegExp(MARKER, "g"))].map(
(m) => m[1],
);
for (const target of targets) {
expect(
target.startsWith("src/"),
`${rel}: marker target "${target}" must be a repo-root-relative src/ path`,
).toBe(true);
const checker = body(target.replace(/^src\//, ""));
expect(
checker.includes("classifyExternalId"),
`${rel} points its per-entry marker at ${target}, but that file never calls classifyExternalId.`,
).toBe(true);
}
},
);
});
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 intake idempotency key is never read across users", () => {
/**
* `medication_intake_events.idempotency_key` carried a GLOBAL unique
* from 0001_init until migration 0273. Client-supplied keys on a global
* unique meant one user's key blocked another user's write outright,
* and an unscoped lookup resolved a stranger's row and handed back its
* id. The column is now unique per user; every read has to key on
* `(userId, idempotencyKey)` or the leak comes straight back.
*/
function intakeQueriesFilteringOnTheKey(): Array<{
file: string;
line: number;
scoped: boolean;
}> {
const out: Array<{ file: string; line: number; scoped: boolean }> = [];
for (const rel of sourceFiles()) {
const source = read(rel);
if (!source.includes("medicationIntakeEvent.")) continue;
const call = /medicationIntakeEvent\.(\w+)\(\s*\{/g;
let m: RegExpExecArray | null;
while ((m = call.exec(source)) !== null) {
// Walk to the matching brace so the window is the call, not a
// fixed slice that could bleed into the next statement.
let depth = 0;
let j = source.indexOf("{", m.index);
for (; j < source.length; j++) {
if (source[j] === "{") depth++;
else if (source[j] === "}" && --depth === 0) break;
}
const body = source.slice(m.index, j + 1);
const whereAt = body.indexOf("where");
if (whereAt === -1) continue;
const where = body.slice(whereAt, whereAt + 400);
if (!where.includes("idempotencyKey")) continue;
out.push({
file: rel,
line: source.slice(0, m.index).split("\n").length,
// Keyed on the row's own primary id is scoped by construction —
// that is an update-by-id that happens to WRITE the key.
scoped: where.includes("userId") || /where:\s*\{\s*id:/.test(where),
});
}
}
return out;
}
it("finds the reads at all (the matcher still matches)", () => {
const found = intakeQueriesFilteringOnTheKey();
expect(found.length).toBeGreaterThanOrEqual(6);
expect(found.map((f) => f.file)).toContain(
"app/api/medications/intake/bulk/route.ts",
);
});
it("every query filtering on the key also narrows by userId", () => {
const unscoped = intakeQueriesFilteringOnTheKey().filter((f) => !f.scoped);
expect(
unscoped,
"a medicationIntakeEvent query filters on idempotencyKey without a userId. " +
"The key is client-supplied and unique per USER (migration 0273); an " +
"unscoped read resolves another user's row.",
).toEqual([]);
});
it("the schema keeps the key unique per user, not globally", () => {
const schema = readFileSync(
join(process.cwd(), "prisma", "schema.prisma"),
"utf8",
);
expect(schema).toContain("@@unique([userId, idempotencyKey])");
expect(schema).not.toMatch(
/idempotencyKey\s+String\?\s+@unique\s+@map\("idempotency_key"\)/,
);
});
});
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
// worse than the duplicate it prevents.
const BATCH_ROUTES = [
"app/api/measurements/batch/route.ts",
"app/api/mood-entries/bulk/route.ts",
"app/api/medications/intake/bulk/route.ts",
"app/api/workouts/batch/route.ts",
"app/api/cycle/day-logs/bulk/route.ts",
"app/api/import/route.ts",
];
it.each(BATCH_ROUTES)("%s classifies each entry itself", (rel) => {
const source = body(rel);
expect(source).toContain("classifyExternalId");
expect(source).toContain("unstableExternalIdMeta");
});
it.each(BATCH_ROUTES.filter((r) => r !== "app/api/import/route.ts"))(
"%s reports the shared per-entry reason code",
(rel) => {
// The JSON importer has no per-entry status envelope — it reports a
// skipped COUNT — so it is the one batch surface without the code.
expect(body(rel)).toContain("UNSTABLE_EXTERNAL_ID_REASON");
},
);
});
@@ -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);
});
});
+33
View File
@@ -32,6 +32,12 @@ import {
cycleBulkSchema,
MAX_CYCLE_BULK_ENTRIES,
} from "@/lib/validations/cycle";
import {
classifyExternalId,
unstableExternalIdMeta,
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
@@ -121,9 +127,33 @@ async function postBulk(request: NextRequest): Promise<Response> {
let updated = 0;
let duplicates = 0;
let skipped = 0;
const unstableShapes: UnstableExternalIdShape[] = [];
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
// The `(userId, source, externalId)` upsert key 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-post mints another
// day log. Refused per entry — one bad row must not stop the rest of
// the batch landing.
const unstable =
entry.externalId === undefined
? null
: classifyExternalId(entry.externalId);
if (unstable) {
unstableShapes.push(unstable);
skipped += 1;
results.push({
index: i,
status: "skipped",
reason: UNSTABLE_EXTERNAL_ID_REASON,
externalId: entry.externalId,
});
continue;
}
try {
const cycleId = owningCycleId(entry.date);
const r = await upsertCycleDayLog(
@@ -206,6 +236,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
updated,
duplicates,
skipped,
...(unstableShapes.length > 0
? unstableExternalIdMeta("cycle.day_log.bulk", unstableShapes)
: {}),
},
});
+13 -3
View File
@@ -27,9 +27,13 @@ import {
import { withIdempotency } from "@/lib/idempotency";
import { requireCycleEnabled } from "@/lib/cycle/gate";
import {
cycleDayLogInputSchema,
cycleDayLogCreateSchema,
cycleDayLogQuerySchema,
} from "@/lib/validations/cycle";
import {
unstableExternalIdMeta,
unstableExternalIdShape,
} from "@/lib/validations/external-id";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import { findOwningCycleId } from "@/lib/cycle/cycle-attribution";
import { toCycleDayLogDTO, dayLogSymptomInclude } from "@/lib/cycle/dto";
@@ -82,11 +86,17 @@ async function postDayLog(request: NextRequest): Promise<Response> {
});
if (jsonError) return jsonError;
const parsed = cycleDayLogInputSchema.safeParse(rawBody);
const parsed = cycleDayLogCreateSchema.safeParse(rawBody);
if (!parsed.success) {
const shape = unstableExternalIdShape(rawBody);
annotate({
action: { name: "cycle.day-log.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
meta: {
issue_count: parsed.error.issues.length,
...(shape
? unstableExternalIdMeta("cycle.day_log.create", [shape])
: {}),
},
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.day-log.invalid",
+9 -1
View File
@@ -24,6 +24,10 @@ import {
import { requireCycleEnabled } from "@/lib/cycle/gate";
import { withIdempotency } from "@/lib/idempotency";
import { cyclePeriodSchema } from "@/lib/validations/cycle";
import {
unstableExternalIdMeta,
unstableExternalIdShape,
} from "@/lib/validations/external-id";
import { upsertCycleDayLog } from "@/lib/cycle/day-log-write";
import type { FlowLevel } from "@/lib/cycle/types";
import {
@@ -49,9 +53,13 @@ async function postPeriod(request: NextRequest): Promise<Response> {
const parsed = cyclePeriodSchema.safeParse(rawBody);
if (!parsed.success) {
const shape = unstableExternalIdShape(rawBody);
annotate({
action: { name: "cycle.period.validation-failed" },
meta: { issue_count: parsed.error.issues.length },
meta: {
issue_count: parsed.error.issues.length,
...(shape ? unstableExternalIdMeta("cycle.period", [shape]) : {}),
},
});
return returnAllZodIssues(parsed.error, 422, {
errorCode: "cycle.period.invalid",
+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);
});
});
+42 -1
View File
@@ -23,6 +23,11 @@ import {
isPlausibleEntryInstant,
validateEntryInstant,
} from "@/lib/validations/entry-instant";
import {
classifyExternalId,
unstableExternalIdMeta,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { recomputeUserMoodRollups } from "@/lib/rollups/mood-rollups";
import {
@@ -60,6 +65,10 @@ const measurementSchema = z
// export is idempotent rather than minting a duplicate set — mirrors the
// mood path. Absent → first-write-wins create with a NULL externalId
// (distinct in the unique key), which DOES duplicate on re-import.
// Stability is enforced per entry in the import loop, not at the
// field: a schema-level refusal would fail the whole file on one bad
// row.
// @external-id-checked-per-entry: src/app/api/import/route.ts
externalId: z.string().min(1).max(120).optional(),
})
.refine(
@@ -92,6 +101,10 @@ const moodEntrySchema = z
// present, the import upserts on `(userId, source, externalId)` so a
// re-import of the same export is idempotent rather than minting a
// duplicate; absent → the legacy first-write-wins create path.
// Stability is enforced per entry in the import loop, not at the
// field: a schema-level refusal would fail the whole file on one bad
// row.
// @external-id-checked-per-entry: src/app/api/import/route.ts
externalId: z.string().min(1).max(120).optional(),
})
.superRefine((entry, ctx) => {
@@ -170,6 +183,7 @@ export const POST = apiHandler(async (request: NextRequest) => {
const userId = user.id;
const data = parsed.data;
const stats = { measurements: 0, moodEntries: 0, skipped: 0 };
const unstableShapes: UnstableExternalIdShape[] = [];
let writeFailed = false;
// Import measurements
@@ -189,6 +203,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
}> = [];
if (data.measurements?.length) {
for (const m of data.measurements) {
// The `(userId, type, source, externalId)` upsert key is only
// idempotent while the id is STABLE; an id that rotates between
// exports re-imports as a fresh row every time. Skipped per entry,
// like every other rejected row in this importer.
const unstable =
m.externalId === undefined ? null : classifyExternalId(m.externalId);
if (unstable) {
unstableShapes.push(unstable);
stats.skipped++;
continue;
}
try {
// `measuredAt` is already a `Date` (validateEntryInstant transform).
const measuredAt = m.measuredAt;
@@ -299,6 +324,15 @@ export const POST = apiHandler(async (request: NextRequest) => {
const fallbackOccurrences = new Map<string, number>();
const reservedFallbackInstants = new Set<number>();
for (const e of data.moodEntries) {
// Same stability floor as the measurement loop above — the mood
// upsert keys on `(userId, source, externalId)`.
const unstableMood =
e.externalId === undefined ? null : classifyExternalId(e.externalId);
if (unstableMood) {
unstableShapes.push(unstableMood);
stats.skipped++;
continue;
}
const identity = e.loggedAt ? null : fallbackMoodIdentity(e);
const occurrence =
identity === null ? 0 : (fallbackOccurrences.get(identity) ?? 0);
@@ -455,7 +489,14 @@ export const POST = apiHandler(async (request: NextRequest) => {
}
}
annotate({ meta: { import_stats: stats } });
annotate({
meta: {
import_stats: stats,
...(unstableShapes.length > 0
? unstableExternalIdMeta("import.json", unstableShapes)
: {}),
},
});
try {
await auditLog("import.upload", {
@@ -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
@@ -0,0 +1,239 @@
/**
* `POST /api/measurements/batch` — external-id stability floor.
*
* `(user_id, type, source, external_id)` is idempotent only 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 sync sweep mints a fresh measurement.
*
* The refusal is PER ENTRY, matching the range / timestamp guards on this
* route: a client draining a HealthKit queue must not lose 499 good rows
* because one carried a bad id.
*/
import { describe, expect, it, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
vi.mock("@/lib/db", () => ({
prisma: {
user: { update: vi.fn() },
measurement: {
findMany: vi.fn(),
createManyAndReturn: vi.fn(),
updateMany: vi.fn(),
},
$transaction: vi.fn(async (fn: unknown) => {
if (typeof fn === "function") {
return (fn as (tx: unknown) => unknown)(prisma);
}
}),
},
}));
vi.mock("@/lib/measurements/reconcile-external-measurement", () => ({
reconcileExternalMeasurement: vi.fn(
async (_tx: unknown, input: Record<string, unknown>) => ({
status: "inserted",
row: { id: "row-1", ...input },
}),
),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: 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/rate-limit", () => ({
checkRateLimit: vi.fn(),
}));
vi.mock("@/lib/jobs/pr-detection", () => ({
enqueuePrDetection: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/jobs/reminder-satisfy", () => ({
enqueueReminderSatisfy: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/cache/invalidate", () => ({
invalidateUserMeasurements: vi.fn(),
}));
vi.mock("@/lib/insights/comprehensive-generate", () => ({
invalidateStatusInsightsForTypes: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/rollups/measurement-rollups", () => ({
recomputeBucketsForMeasurement: vi.fn().mockResolvedValue(undefined),
collapseToTypeDayKeys: vi.fn(() => []),
}));
vi.mock("@/lib/daily/morning-refresh-trigger", () => ({
maybeEnqueueMorningRefresh: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/arrivals/emit-shared", () => ({
emitDataArrival: 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";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: { id: "user-1", username: "testuser", role: "USER" as const },
};
const DAY_MS = 24 * 60 * 60 * 1000;
function makeRequest(body: unknown): NextRequest {
return new NextRequest("http://localhost/api/measurements/batch", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
/** A plain step entry anchored a day back so it never date-bombs. */
function stepEntry(externalId: string) {
const at = new Date(Date.now() - DAY_MS).toISOString();
return {
hkIdentifier: "HKQuantityTypeIdentifierStepCount",
value: 1200,
unit: "count",
startDate: at,
endDate: at,
externalId,
};
}
async function statusesFor(entries: unknown[]) {
const res = await POST(makeRequest({ entries }));
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
entries: Array<{ index: number; status: string; reason?: string }>;
};
};
return body.data.entries;
}
beforeEach(() => {
vi.clearAllMocks();
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
vi.mocked(checkRateLimit).mockResolvedValue({
allowed: true,
remaining: 60,
resetAt: Date.now() + 60_000,
});
vi.mocked(prisma.measurement.findMany).mockResolvedValue([]);
vi.mocked(prisma.measurement.createManyAndReturn).mockResolvedValue([
{ id: "row-1" },
] as never);
vi.mocked(prisma.measurement.updateMany).mockResolvedValue({ count: 0 });
});
describe("POST /api/measurements/batch — unstable external ids", () => {
it("skips an object-description id instead of minting a row", async () => {
const results = await statusesFor([
stepEntry("<HKHealthConceptIdentifier: 0x12568db80>"),
]);
expect(results[0]).toMatchObject({
index: 0,
status: "skipped",
reason: "unstable_external_id",
});
expect(reconcileExternalMeasurement).not.toHaveBeenCalled();
});
it("skips a bare memory-address id", async () => {
const results = await statusesFor([stepEntry("0x126b25160")]);
expect(results[0]).toMatchObject({
status: "skipped",
reason: "unstable_external_id",
});
});
it("skips ONLY the offending entries — the rest of the batch still lands", async () => {
const results = await statusesFor([
stepEntry("hk-uuid-1"),
stepEntry("<HKHealthConceptIdentifier: 0x12568db80>"),
stepEntry("stats:HKQuantityTypeIdentifierStepCount:2026-07-25"),
stepEntry("0xdeadbeef"),
]);
expect(results[0]!.status).not.toBe("skipped");
expect(results[1]).toMatchObject({ reason: "unstable_external_id" });
expect(results[2]!.status).not.toBe("skipped");
expect(results[3]).toMatchObject({ reason: "unstable_external_id" });
// Two good rows still reached the writer.
expect(reconcileExternalMeasurement).toHaveBeenCalledTimes(2);
});
it("never turns one bad row into a whole-batch 422", async () => {
const res = await POST(
makeRequest({
entries: [stepEntry("hk-uuid-1"), stepEntry("0xdeadbeef")],
}),
);
expect(res.status).toBe(200);
});
it("accepts the real id shapes this route receives", async () => {
const results = await statusesFor([
stepEntry("8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30"),
stepEntry("stats:HKQuantityTypeIdentifierStepCount:2026-07-25"),
stepEntry("hk-uuid-41"),
]);
for (const r of results) expect(r.status).not.toBe("skipped");
});
it("annotates the refusal with the shape, never the id", async () => {
await statusesFor([
stepEntry("<HKHealthConceptIdentifier: 0x12568db80>"),
stepEntry("0xdeadbeef"),
]);
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: "measurement.batch",
});
expect(JSON.stringify(rejection)).not.toContain("0x12568db80");
});
it("emits no rejection annotation on a clean batch", async () => {
await statusesFor([stepEntry("hk-uuid-1")]);
const calls = vi.mocked(annotate).mock.calls.map(([c]) => c);
expect(calls.some((c) => c?.meta && "external_id_rejected" in c.meta)).toBe(
false,
);
});
});
+35
View File
@@ -62,6 +62,12 @@ import {
} from "@/lib/measurements/cross-source-merge";
import { reconcileExternalMeasurement } from "@/lib/measurements/reconcile-external-measurement";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
import {
classifyExternalId,
unstableExternalIdMeta,
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { validateMeasurementRange } from "@/lib/validations/measurement";
import { deviceTypeEnum } from "@/lib/validations/source-priority";
import { checkRateLimit } from "@/lib/rate-limit";
@@ -139,6 +145,9 @@ const batchEntrySchema = z.object({
// iOS build omits them and the bucket keeps the avg-only contract.
valueMin: z.number().finite().optional(),
valueMax: z.number().finite().optional(),
// Stability is enforced per entry in the loop below, not at the field:
// a schema-level refusal would fail the whole batch on one bad row.
// @external-id-checked-per-entry: src/app/api/measurements/batch/route.ts
externalId: z.string().min(1).max(120),
externalSourceVersion: z.string().min(1).max(120).optional(),
// v1.8.6 W6 — optional per-entry source tag. Defaults to
@@ -268,9 +277,29 @@ async function postBatch(request: NextRequest): Promise<Response> {
};
const prepared: Prepared[] = [];
const results: EntryResult[] = new Array(entries.length);
const unstableShapes: UnstableExternalIdShape[] = [];
for (let index = 0; index < entries.length; index++) {
const entry = entries[index];
// The `(user_id, type, source, external_id)` dedup key above 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 sync sweep
// mints a fresh measurement. Refused per entry, like the range and
// timestamp guards below — one malformed row must not stop the other
// 499 from landing.
const unstable = classifyExternalId(entry.externalId);
if (unstable) {
unstableShapes.push(unstable);
results[index] = {
index,
status: "skipped",
reason: UNSTABLE_EXTERNAL_ID_REASON,
};
continue;
}
const mapped = mapAppleHealthEntry({
hkIdentifier: entry.hkIdentifier,
value: entry.value,
@@ -396,6 +425,12 @@ async function postBatch(request: NextRequest): Promise<Response> {
});
}
if (unstableShapes.length > 0) {
annotate({
meta: unstableExternalIdMeta("measurement.batch", unstableShapes),
});
}
let insertedCount = 0;
let updatedCount = 0;
let duplicateCount = 0;
@@ -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).
@@ -622,6 +622,65 @@ describe("POST /api/medications — Apple Health mirror (v1.28)", () => {
expect(prisma.medication.create).not.toHaveBeenCalled();
});
/**
* The mirror's idempotency is only as good as the stability of the
* concept id. A client that serialised an opaque platform object with
* its default description sent `<HKHealthConceptIdentifier: 0x…>` — a
* memory address that changes on every launch — and a live instance
* grew 23 phantom medications in a day, one per sync sweep, none of
* which could ever collect a dose. The create refuses that shape now.
*/
it("422s on an object-description externalId instead of minting a phantom mirror", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
const res = await POST(
postReq({
...MIRROR_BODY,
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();
// The client developer has to be able to act on this without asking.
expect(issue!.message).toContain("stable across app restarts");
expect(issue!.message).toContain("object description");
expect(prisma.medication.create).not.toHaveBeenCalled();
});
it("422s on a bare memory-address externalId", async () => {
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
const res = await POST(
postReq({ ...MIRROR_BODY, externalId: "0xdeadbeef" }),
);
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(prisma.medication.create).not.toHaveBeenCalled();
});
it("still accepts the stable concept-id shapes the mirror really sends", async () => {
for (const externalId of [
"hk-concept-1",
"8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30",
"a3f5c9e1b7d2486fa0c3e5b7d9f1a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4f6",
]) {
vi.mocked(prisma.medication.create).mockClear();
vi.mocked(prisma.medication.findFirst).mockResolvedValue(null as never);
const res = await POST(postReq({ ...MIRROR_BODY, externalId }));
expect(res.status).toBe(201);
expect(lastCreateData().externalId).toBe(externalId);
}
});
it("a plain create keeps today's behavior — no external columns forwarded", async () => {
const res = await POST(
postReq({
@@ -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);
});
});
+59 -2
View File
@@ -75,6 +75,12 @@ import {
injectionSiteEnum,
type InjectionSiteValue,
} from "@/lib/validations/medication";
import {
classifyExternalId,
unstableExternalIdMeta,
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import type { InjectionSiteKey } from "@/lib/medications/injection-sites";
import { queueMedicationIntakeSync } from "@/lib/notifications/medication-intake-sync";
import { dispatchMedicationIntakeWebClearBulk } from "@/lib/notifications/web-push-clear";
@@ -95,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
@@ -128,6 +139,10 @@ const bulkEntrySchema = z
// v1.28 — the HealthKit `HKMedicationDoseEvent` UUID. Drives the
// idempotent re-sync: an entry whose `(userId, externalId)` already
// exists live reports `duplicate` (first-write-wins per Apple dose).
// Stability is enforced per entry in the loop below, not at the
// field: a schema-level refusal would fail the whole batch on one
// bad row.
// @external-id-checked-per-entry: src/app/api/medications/intake/bulk/route.ts
externalId: z.string().trim().min(1).max(128).optional(),
})
.refine(
@@ -318,6 +333,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
let updated = 0;
let duplicates = 0;
const skipped: Array<{ index: number; reason: string }> = [];
const unstableShapes: UnstableExternalIdShape[] = [];
// v1.4.39 W-MED — collect distinct `(medicationId, dayKey)` pairs
// touched by the batch so one rollup recompute fires per pair after
// all inserts complete. Per-row recompute would balloon a 500-entry
@@ -345,6 +361,31 @@ async function postBulk(request: NextRequest): Promise<Response> {
continue;
}
// 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
? null
: classifyExternalId(entry.externalId)) ??
(entry.idempotencyKey === undefined
? null
: classifyExternalId(entry.idempotencyKey));
if (unstable) {
unstableShapes.push(unstable);
skipped.push({ index: i, reason: UNSTABLE_EXTERNAL_ID_REASON });
results.push({
index: i,
status: "skipped",
reason: UNSTABLE_EXTERNAL_ID_REASON,
});
continue;
}
// v1.28 — Apple dose-event replay: the batch pre-check found a live
// row already carrying this external UUID (or an earlier entry in
// THIS batch inserted it). First-write-wins per Apple dose — report
@@ -575,8 +616,16 @@ async function postBulk(request: NextRequest): Promise<Response> {
// by `source`. The partial unique index carries `source`, so it
// cannot catch this duplicate; the probe must.
if (entry.idempotencyKey) {
// Scoped to the caller. The key is client-supplied, so an
// unscoped read would resolve another user's row and hand its
// id back here (migration 0273 made the unique per user).
const replay = await prisma.medicationIntakeEvent.findUnique({
where: { idempotencyKey: entry.idempotencyKey },
where: {
userId_idempotencyKey: {
userId: user.id,
idempotencyKey: entry.idempotencyKey,
},
},
select: { id: true },
});
if (replay) {
@@ -752,7 +801,12 @@ async function postBulk(request: NextRequest): Promise<Response> {
) {
const existing = entry.idempotencyKey
? await prisma.medicationIntakeEvent.findUnique({
where: { idempotencyKey: entry.idempotencyKey },
where: {
userId_idempotencyKey: {
userId: user.id,
idempotencyKey: entry.idempotencyKey,
},
},
select: { id: true },
})
: // v1.28 — an Apple entry that raced the partial
@@ -803,6 +857,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
updated,
duplicates,
skipped: skipped.length,
...(unstableShapes.length > 0
? unstableExternalIdMeta("medication.intake.bulk", unstableShapes)
: {}),
},
});
+14
View File
@@ -10,6 +10,10 @@ import {
safeJson,
} from "@/lib/api-response";
import { createMedicationSchema } from "@/lib/validations/medication";
import {
unstableExternalIdMeta,
unstableExternalIdShape,
} from "@/lib/validations/external-id";
import {
getMedicationCategories,
setMedicationCategory,
@@ -98,6 +102,16 @@ export const POST = apiHandler(async (request: NextRequest) => {
if (jsonError) return jsonError;
const parsed = createMedicationSchema.safeParse(body);
if (!parsed.success) {
// Surface an unstable concept id as its own wide event: the refusal
// is the visible half of the phantom-mirror class, and a silent 422
// in the generic bucket is how it stayed invisible the first time.
const shape = unstableExternalIdShape(body);
if (shape) {
annotate({
action: { name: "medication.external_id.rejected" },
meta: unstableExternalIdMeta("medication.create", [shape]),
});
}
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
@@ -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);
});
});
+11 -1
View File
@@ -36,6 +36,10 @@ import {
createAssessmentSchema,
listAssessmentsSchema,
} from "@/lib/validations/mental-health";
import {
unstableExternalIdMeta,
unstableExternalIdShape,
} from "@/lib/validations/external-id";
import {
INSTRUMENTS,
INSTRUMENT_MEASUREMENT_TYPE,
@@ -183,9 +187,15 @@ async function postAssessment(request: NextRequest): Promise<Response> {
const parsed = createAssessmentSchema.safeParse(body);
if (!parsed.success) {
const shape = unstableExternalIdShape(body);
annotate({
action: { name: "mental-health.create.validation-failed" },
meta: { issue_count: sanitiseZodIssues(parsed.error.issues).length },
meta: {
issue_count: sanitiseZodIssues(parsed.error.issues).length,
...(shape
? unstableExternalIdMeta("mental_health.assessment.create", [shape])
: {}),
},
});
return returnAllZodIssues(parsed.error, 422);
}
@@ -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);
});
});
@@ -461,6 +461,92 @@ describe("POST /api/mood-entries/bulk — structured tagKeys (v1.12.0)", () => {
});
});
describe("POST /api/mood-entries/bulk — unstable external ids", () => {
/**
* `(userId, source, externalId)` dedups only while the id is stable
* across client launches. A per-process value — an object description
* carrying a memory address — never matches its own earlier row, so
* every re-post mints another entry. The refusal is PER ENTRY: a client
* draining an offline queue must not lose its good rows to one bad one.
*/
beforeEach(() => {
vi.mocked(prisma.moodEntry.findMany).mockResolvedValue([]);
vi.mocked(prisma.moodEntry.upsert).mockResolvedValue({
id: "entry-1",
} as never);
});
it("skips the offending entry and still writes the rest of the batch", async () => {
const res = await POST(
postReq({
entries: [
{
mood: "GUT",
moodLoggedAt: "2026-05-16T08:00:00.000Z",
externalId: "8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30",
},
{
mood: "OKAY",
moodLoggedAt: "2026-05-16T09:00:00.000Z",
externalId: "<HKHealthConceptIdentifier: 0x12568db80>",
},
{
mood: "SUPER_GUT",
moodLoggedAt: "2026-05-16T10:00:00.000Z",
externalId: "0xdeadbeef",
},
],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: {
processed: number;
inserted: number;
skipped: Array<{ index: number; reason: string }>;
entries: Array<{
index: number;
status: string;
reason?: string;
externalId?: string;
}>;
};
};
expect(body.data.processed).toBe(3);
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]).toMatchObject({
index: 2,
status: "skipped",
reason: "unstable_external_id",
});
expect(body.data.skipped).toEqual([
{ index: 1, reason: "unstable_external_id" },
{ index: 2, reason: "unstable_external_id" },
]);
// Only the good entry reached the writer.
expect(prisma.moodEntry.upsert).toHaveBeenCalledTimes(1);
});
it("leaves an entry without an externalId untouched", async () => {
const res = await POST(
postReq({
entries: [{ mood: "OKAY", moodLoggedAt: "2026-05-16T08:00:00.000Z" }],
}),
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
data: { entries: Array<{ status: string }> };
};
expect(body.data.entries[0].status).toBe("inserted");
});
});
describe("POST /api/mood-entries/bulk — tombstone suppression", () => {
it("keeps a tombstoned match deleted: true no-op reported as duplicate", async () => {
// A soft-deleted row under the batched pre-read: the sync feed already
+35
View File
@@ -43,6 +43,12 @@ import {
moodLevelEnum,
moodSourceEnum,
} from "@/lib/validations/mood";
import {
classifyExternalId,
unstableExternalIdMeta,
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { moodDateKey, DEFAULT_TIMEZONE } from "@/lib/mood/date-key";
import { encryptNote } from "@/lib/crypto/note-cipher";
import { invalidateUserMood } from "@/lib/cache/invalidate";
@@ -95,6 +101,10 @@ const bulkEntrySchema = z.object({
* the measurements batch endpoint. NULL = no dedup hint; the
* existing `(userId, date, moodLoggedAt)` unique index still
* protects against straight-up duplicates.
*
* Stability is enforced per entry in the loop below, not at the field:
* a schema-level refusal would fail the whole batch on one bad row.
* @external-id-checked-per-entry: src/app/api/mood-entries/bulk/route.ts
*/
externalId: z.string().min(1).max(120).optional(),
});
@@ -187,6 +197,7 @@ async function postBulk(request: NextRequest): Promise<Response> {
let inserted = 0;
let duplicates = 0;
const skipped: Array<{ index: number; reason: string }> = [];
const unstableShapes: UnstableExternalIdShape[] = [];
// Perf — a single indexed existence read replaces the per-entry
// `findUnique` probe (up to 500 sequential round trips pre-fix). Every
@@ -241,6 +252,27 @@ async function postBulk(request: NextRequest): Promise<Response> {
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
// The `(userId, source, externalId)` dedup key only holds while the
// id is STABLE across client launches; a per-process value (an object
// description carrying a memory address) mints a fresh entry on every
// re-post. Per-entry refusal, never a whole-batch 422.
const unstable =
entry.externalId === undefined
? null
: classifyExternalId(entry.externalId);
if (unstable) {
unstableShapes.push(unstable);
skipped.push({ index: i, reason: UNSTABLE_EXTERNAL_ID_REASON });
results.push({
index: i,
status: "skipped",
reason: UNSTABLE_EXTERNAL_ID_REASON,
externalId: entry.externalId,
});
continue;
}
const date = moodDateKey(entry.moodLoggedAt, tz);
const score = getScoreForMood(entry.mood);
@@ -415,6 +447,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
inserted,
duplicates,
skipped: skipped.length,
...(unstableShapes.length > 0
? unstableExternalIdMeta("mood.bulk", unstableShapes)
: {}),
},
});
+9 -1
View File
@@ -13,6 +13,10 @@ import {
listMoodEntriesSchema,
getScoreForMood,
} from "@/lib/validations/mood";
import {
unstableExternalIdMeta,
unstableExternalIdShape,
} from "@/lib/validations/external-id";
import { NextRequest } from "next/server";
import { Prisma } from "@/generated/prisma/client";
import { apiHandler, requireAuth } from "@/lib/api-handler";
@@ -160,9 +164,13 @@ async function postMoodEntry(request: NextRequest) {
// v1.4.43 W6 — iOS mood log hot path; multi-issue 422 + audit
// breadcrumb keyed `mood-entries.create.validation-failed`.
const issues = sanitiseZodIssues(parsed.error.issues);
const shape = unstableExternalIdShape(body);
annotate({
action: { name: "mood-entries.create.validation-failed" },
meta: { issue_count: issues.length },
meta: {
issue_count: issues.length,
...(shape ? unstableExternalIdMeta("mood.create", [shape]) : {}),
},
});
// v1.4.49 — strip `message` from the audit-ledger row; the
// mood-create schema's free-text `note` + `tags` could land in a
@@ -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");
});
});
+43 -10
View File
@@ -70,6 +70,12 @@ import {
createBatchWorkoutSchema,
MAX_WORKOUTS_PER_BATCH,
} from "@/lib/validations/workout";
import {
classifyExternalId,
unstableExternalIdMeta,
UNSTABLE_EXTERNAL_ID_REASON,
type UnstableExternalIdShape,
} from "@/lib/validations/external-id";
import { dedupeWorkoutBatch } from "@/lib/workouts/canonical-rows";
import { Prisma, type MeasurementSource } from "@/generated/prisma/client";
@@ -281,7 +287,26 @@ async function postBatch(request: NextRequest): Promise<Response> {
};
const results: EntryResult[] = new Array(workouts.length);
const prepared: Prepared[] = workouts.map((w, index) => {
const unstableShapes: UnstableExternalIdShape[] = [];
const prepared: Prepared[] = workouts.flatMap((w, index) => {
// The `(userId, source, externalId)` dedup key 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
// workout. Refused per entry — one bad row must not stop the rest of
// the batch landing.
const unstable =
w.externalId === undefined ? null : classifyExternalId(w.externalId);
if (unstable) {
unstableShapes.push(unstable);
results[index] = {
index,
status: "skipped",
reason: UNSTABLE_EXTERNAL_ID_REASON,
};
return [];
}
const durationSec = Math.max(
0,
Math.round((w.endedAt.getTime() - w.startedAt.getTime()) / 1000),
@@ -319,17 +344,25 @@ async function postBatch(request: NextRequest): Promise<Response> {
? (w.metadata as Prisma.InputJsonValue)
: Prisma.JsonNull,
};
return {
index,
row,
route,
samples,
dedupKey: w.externalId
? { source: w.source, externalId: w.externalId }
: null,
};
return [
{
index,
row,
route,
samples,
dedupKey: w.externalId
? { source: w.source, externalId: w.externalId }
: null,
},
];
});
if (unstableShapes.length > 0) {
annotate({
meta: unstableExternalIdMeta("workout.batch", unstableShapes),
});
}
// v1.4.42 W5 — write-time cross-source dedup. The v1.5 iOS app
// drains a HealthKit observer queue that frequently carries the
// SAME logical workout from two paired sensors (Apple Watch +
@@ -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();
});
});
+10
View File
@@ -40,6 +40,7 @@ import {
MEASUREMENT_NOTES_MAX_LENGTH,
} from "@/lib/validations/measurement";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
import { isStableExternalId } from "@/lib/validations/external-id";
import { resolveToCanonicalUnit } from "@/lib/measurements/unit-aliases";
/** A normalised, write-ready row in the `/api/import` measurement shape. */
@@ -68,6 +69,7 @@ export type CsvRowReason =
| "invalid_glucose_context"
| "notes_too_long"
| "external_id_too_long"
| "unstable_external_id"
| "wrong_column_count";
export type CsvRowStatus = "ok" | "skipped";
@@ -304,6 +306,14 @@ export function parseCsvMeasurements(
continue;
}
// The import upserts on `(userId, type, source, externalId)`, so an
// id that cannot be stable across exports re-imports as a fresh row
// every time instead of converging on the one it already wrote.
if (rawExternalId.length > 0 && !isStableExternalId(rawExternalId)) {
skip("unstable_external_id");
continue;
}
rows.push({
line,
status: "ok",
+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({
+2 -1
View File
@@ -81,7 +81,8 @@ const batchEntrySchema = z
"Dedup key for the `(userId, type, source, externalId)` composite index. Three shapes:\n" +
"• `HKSample.uuid` — a per-sample reading. First-write-wins: a re-post returns `duplicate` and the stored row is immutable.\n" +
"• `stats:<HKQuantityTypeIdentifier>:<YYYY-MM-DD>` — a per-day cumulative total (Steps, Active Energy, Sleep Duration, Walking/Running Distance, Flights Climbed). A re-post OVERWRITES the row and returns `updated`.\n" +
"• `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` — v1.19.0 (iOS #34), refined to 10-minute granularity in v1.30.7, heart-rate bucket carrying the bucket's AVERAGE bpm as one PULSE row. `<bucket-start>` is the ISO-8601 UTC 10-minute boundary — zeroed seconds/millis, minutes ∈ {00,10,20,30,40,50}, trailing `Z` (e.g. `2026-06-21T14:10:00.000Z`). A re-post OVERWRITES the row and returns `updated`, so iOS uploads ~144 rows/day instead of one per raw HR sample. A row that targets this prefix with a malformed bucket-start suffix is `skipped` with reason `malformed_hr_bucket_id`.",
"• `stats:HKQuantityTypeIdentifierHeartRate:<bucket-start>` — v1.19.0 (iOS #34), refined to 10-minute granularity in v1.30.7, heart-rate bucket carrying the bucket's AVERAGE bpm as one PULSE row. `<bucket-start>` is the ISO-8601 UTC 10-minute boundary — zeroed seconds/millis, minutes ∈ {00,10,20,30,40,50}, trailing `Z` (e.g. `2026-06-21T14:10:00.000Z`). A re-post OVERWRITES the row and returns `updated`, so iOS uploads ~144 rows/day instead of one per raw HR sample. A row that targets this prefix with a malformed bucket-start suffix is `skipped` with reason `malformed_hr_bucket_id`.\n" +
"The id MUST be stable across app restarts. An object description or memory address (`<Class: 0x…>`, `0x12ab34cd`) is a new value on every launch, so it can never match its own earlier row; such an entry is `skipped` with reason `unstable_external_id` while the rest of the batch still lands.",
),
externalSourceVersion: z.string().min(1).max(120).optional(),
// v1.8.6 W6 — optional per-entry source tag. Defaults to
+1 -1
View File
@@ -104,7 +104,7 @@ const bulkIntakeEntry = z
.max(128)
.optional()
.describe(
"The HealthKit dose-event UUID. Drives the idempotent re-sync dedup (first-write-wins per Apple dose).",
"The HealthKit dose-event UUID. Drives the idempotent re-sync dedup (first-write-wins per Apple dose). Must be stable across app restarts — an object description or memory address (`<Class: 0x…>`, `0x12ab34cd`) is a new value on every launch; such an entry is `skipped` with reason `unstable_external_id` while the rest of the batch still lands.",
),
})
.meta({
+1 -1
View File
@@ -221,7 +221,7 @@ const bulkMoodEntry = z
.max(120)
.optional()
.describe(
"iOS-side source-stable id for idempotent dedup on the `(userId, source, externalId)` key.",
"iOS-side source-stable id for idempotent dedup on the `(userId, source, externalId)` key. Must be stable across app restarts — an object description or memory address (`<Class: 0x…>`, `0x12ab34cd`) is a new value on every launch; such an entry is `skipped` with reason `unstable_external_id` while the rest of the batch still lands.",
),
})
.meta({
@@ -0,0 +1,182 @@
/**
* External-id stability floor.
*
* The rejected cases are the shapes that provably cannot be an identity.
* The accepted cases are every external-id shape this repository actually
* receives — grepped out of the ingest routes, the batch contracts, the
* OpenAPI examples and the existing fixtures. A false positive here costs
* a working client its sync, so the accepted list is the real guard.
*/
import { describe, it, expect } from "vitest";
import {
assertStableExternalId,
classifyExternalId,
isStableExternalId,
unstableExternalIdMessage,
unstableExternalIdMeta,
unstableExternalIdShape,
UNSTABLE_EXTERNAL_ID_REASON,
} from "@/lib/validations/external-id";
import { z } from "zod/v4";
describe("classifyExternalId — refused shapes", () => {
it("refuses the object description that caused the live incident", () => {
expect(classifyExternalId("<HKHealthConceptIdentifier: 0x12568db80>")).toBe(
"object_description",
);
});
it("refuses any <Class: 0xHEX> description, not just the one class", () => {
for (const value of [
"<NSObject: 0x600000c1a2b0>",
"<SomeVendorType: 0X7FFEE3B0>",
"<Foo: 0x1>",
"<HKObject: 0x126b25160; type=bar>",
"apple:<HKHealthConceptIdentifier: 0x11f47e3e0>",
]) {
expect(classifyExternalId(value)).toBe("object_description");
}
});
it("refuses a bare pointer value", () => {
expect(classifyExternalId("0x12568db80")).toBe("pointer_address");
expect(classifyExternalId("0XDEADBEEF")).toBe("pointer_address");
});
it("refuses an empty or whitespace-only id", () => {
expect(classifyExternalId("")).toBe("blank");
expect(classifyExternalId(" ")).toBe("blank");
expect(classifyExternalId("\t\n ")).toBe("blank");
});
it("classifies a padded description after trimming", () => {
expect(classifyExternalId(" <HKFoo: 0xabc123> ")).toBe(
"object_description",
);
});
});
describe("classifyExternalId — the id shapes this system actually receives", () => {
// Every entry below is a shape that exists in this repository today:
// the iOS batch contract, the Apple Health mirror, the Withings /
// WHOOP / Oura / Fitbit / Polar sync paths, the CSV importer, and the
// web client's own UUID mints.
const accepted = [
// HealthKit sample UUIDs (upper + lower case, hyphenated)
"8AD2A9CB-3F0C-4E4D-9C1E-4B7E2A1D6F30",
"3f0c4e4d-9c1e-4b7e-2a1d-6f30ad2a9cb1",
// Un-hyphenated / compact ids
"8AD2A9CB3F0C4E4D9C1E4B7E2A1D6F30",
// The `stats:` aggregate contract (per-day + hourly bucket)
"stats:HKQuantityTypeIdentifierStepCount:2026-07-25",
"stats:HKQuantityTypeIdentifierActiveEnergyBurned:2026-07-25",
"stats:HKQuantityTypeIdentifierHeartRate:2026-07-25T08:10:00Z",
// Other structured prefix forms minted in this tree today
"assessment:clx1a2b3c4d5e6f7g8h9",
"withings:activity:clx1a2b3c4d5e6f7g8h9:2026-07-25:steps",
"withings:sleep:clx1a2b3c4d5e6f7g8h9:998877:hr_average",
"cycle:123456:strain",
"sleep:0f3a8c2e-2b1d-4f5a-9c8e-1d2f3a4b5c6d:hrv_rmssd",
"spo2:2026-07-25T03:12:00Z:unknown",
"hkcycle:2026-07-25",
"stats:steps:2026-07-25",
"retired:clx1a2b3c4d5e6f7g8h9:hk-uuid-41",
"hk-uuid-41",
// Opaque vendor identifiers
"1234567890",
"clx1a2b3c4d5e6f7g8h9",
"a3f5c9e1b7d2486fa0c3e5b7d9f1a2c4e6b8d0f2a4c6e8b0d2f4a6c8e0b2d4f6",
// Base64-ish blob
"aGVhbHRobG9nOnNhbXBleQ==",
// Platform type identifiers
"HKQuantityTypeIdentifierStepCount",
// Values that merely CONTAIN hex-looking text but are not pointers
"0xygen-sample-1",
"sample-0x12ab-tail",
"not<angle>brackets",
"<no-pointer-here>",
];
it.each(accepted)("accepts %s", (value) => {
expect(classifyExternalId(value)).toBeNull();
expect(isStableExternalId(value)).toBe(true);
});
it("does not reject on length, spaces, or unusual characters alone", () => {
expect(isStableExternalId("My Glucose Meter Export Row 41")).toBe(true);
expect(isStableExternalId("a")).toBe(true);
expect(isStableExternalId("ärztliche-messung-2026-07-25")).toBe(true);
});
});
describe("messages", () => {
it("names what is wrong and what to send instead", () => {
expect(unstableExternalIdMessage("object_description")).toContain(
"stable across app restarts",
);
expect(unstableExternalIdMessage("object_description")).toContain(
"object description",
);
expect(unstableExternalIdMessage("pointer_address")).toContain(
"memory address",
);
expect(unstableExternalIdMessage("blank")).toContain("must not be empty");
});
});
describe("assertStableExternalId — object-level Zod check", () => {
const schema = z
.object({ externalId: z.string().max(128).optional() })
.superRefine(assertStableExternalId);
it("rejects an object description with the actionable message on the field", () => {
const parsed = schema.safeParse({
externalId: "<HKHealthConceptIdentifier: 0x12568db80>",
});
expect(parsed.success).toBe(false);
if (parsed.success) return;
expect(parsed.error.issues).toHaveLength(1);
expect(parsed.error.issues[0].path).toEqual(["externalId"]);
expect(parsed.error.issues[0].message).toBe(
unstableExternalIdMessage("object_description"),
);
});
it("accepts a stable id and an absent id alike", () => {
expect(
schema.safeParse({ externalId: "stats:HKFoo:2026-07-25" }).success,
).toBe(true);
expect(schema.safeParse({}).success).toBe(true);
});
});
describe("wide-event helpers", () => {
it("reads a top-level externalId off an unparsed body", () => {
expect(unstableExternalIdShape({ externalId: "0xdeadbeef" })).toBe(
"pointer_address",
);
expect(unstableExternalIdShape({ externalId: "uuid-1" })).toBeNull();
expect(unstableExternalIdShape({ externalId: 42 })).toBeNull();
expect(unstableExternalIdShape(null)).toBeNull();
expect(unstableExternalIdShape("not-an-object")).toBeNull();
});
it("pins the meta shape and never carries the id itself", () => {
expect(
unstableExternalIdMeta("measurement.batch", [
"object_description",
"object_description",
"pointer_address",
]),
).toEqual({
external_id_rejected: 3,
external_id_shapes: "object_description,pointer_address",
external_id_surface: "measurement.batch",
});
});
it("exposes one per-entry reason code for every batch surface", () => {
expect(UNSTABLE_EXTERNAL_ID_REASON).toBe("unstable_external_id");
});
});
+26 -6
View File
@@ -11,6 +11,7 @@
*/
import { z } from "zod/v4";
import { isPlausibleEntryInstant } from "@/lib/validations/entry-instant";
import { assertStableExternalId } from "@/lib/validations/external-id";
/**
* v1.17 W1b — `loggedAt` instant on the cycle wire stays a string (the iOS
@@ -138,11 +139,28 @@ export const cycleDayLogInputSchema = z.object({
note: z.string().max(500).optional(),
loggedAt: boundedLoggedAt,
source: cycleSourceEnum.optional().default("MANUAL"),
// Shared with the bulk ingest, which enforces external-id stability per
// entry rather than at the field (a schema-level refusal would fail the
// whole batch on one bad row). The single-entry POST parses through
// `cycleDayLogCreateSchema` below, which adds the field check.
// @external-id-checked-per-entry: src/app/api/cycle/day-logs/bulk/route.ts
externalId: z.string().min(1).max(120).optional(),
});
export type CycleDayLogInput = z.infer<typeof cycleDayLogInputSchema>;
/**
* Single-entry POST body. Same shape as the bulk entry plus the
* external-id stability floor: a rotating id defeats the
* `(userId, source, externalId)` upsert and mints a fresh day log on every
* re-post. The bulk path deliberately keeps the permissive entry schema —
* it reports the offending entry as `skipped` rather than failing the
* whole batch — and applies the same rule per entry in the route.
*/
export const cycleDayLogCreateSchema = cycleDayLogInputSchema.superRefine(
assertStableExternalId,
);
/** PATCH body — every field optional; `date`/`source`/`externalId` are immutable on update. */
export const cycleDayLogPatchSchema = z.object({
flow: flowLevelEnum.nullable().optional(),
@@ -180,12 +198,14 @@ export const cycleBulkSchema = z.object({
/* ── period-boundary shortcut ────────────────────────────────────── */
export const cyclePeriodSchema = z.object({
action: z.enum(["start", "end"]),
date: dateString,
externalId: z.string().min(1).max(120).optional(),
loggedAt: boundedLoggedAt,
});
export const cyclePeriodSchema = z
.object({
action: z.enum(["start", "end"]),
date: dateString,
externalId: z.string().min(1).max(120).optional(),
loggedAt: boundedLoggedAt,
})
.superRefine(assertStableExternalId);
/* ── calendar + history queries ──────────────────────────────────── */
+241
View File
@@ -0,0 +1,241 @@
/**
* Client-supplied external identifiers — stability floor.
*
* Every ingest surface that accepts an `externalId` uses it as one half of
* a dedup key: `(userId, source, externalId)`, `(userId, externalSource,
* externalId)`, `(userId, type, source, externalId)`. Re-posting the same
* triple is idempotent and returns the existing row. That idempotency is
* only as good as the STABILITY of the identifier — an id that changes
* between two runs of the same client never matches its own earlier row,
* so every sync sweep mints a fresh record and the user's history fills
* with phantom rows that carry no doses, no schedule, no follow-up data.
*
* That is not hypothetical. A native client serialised an opaque platform
* object with the language's default object description and sent the
* result as `externalId`. The default description of an object with no
* description contract is `<ClassName: 0xADDRESS>` — a per-allocation
* MEMORY ADDRESS. A live instance accumulated 23 duplicate medications in
* a single day, one per sync sweep, each with a different address in its
* id, and the dose events that should have attached to them resolved
* through the same rotating string and were never stored at all.
*
* This module is the one place that recognises an identifier which CANNOT
* be a stable identity. It is deliberately narrow: the cost of a false
* negative is a duplicate row, the cost of a false positive is a
* legitimate client losing the ability to sync at all. Only shapes that
* are provably not identities are rejected:
*
* 1. `blank` — empty or whitespace-only after trimming.
* Carries no identity by construction.
* 2. `pointer_address` — a bare `0x…` hex value and nothing else.
* A raw address, valid only inside the
* process that printed it.
* 3. `object_description` — an angle-bracketed description containing
* an `0x…` hex token, e.g. `<Foo: 0x12568db80>`
* or `<Foo: 0x1; bar=2>`. This is the general
* form of a default object description, not a
* match on one platform class: no identity
* format wraps itself in angle brackets around
* a hex pointer.
*
* Everything else passes. In particular the identifier shapes this system
* actually receives all survive:
*
* - UUIDs, upper or lower case, hyphenated or not
* (`8AD2A9CB-...`, HealthKit sample UUIDs, SwiftData row ids)
* - structured prefix forms
* (`stats:HKQuantityTypeIdentifierStepCount:2026-07-25`,
* `stats:HKQuantityTypeIdentifierHeartRate:2026-07-25T08:10:00Z`,
* `assessment:<id>`, `whoop:<id>`, `oura:<id>`)
* - opaque vendor identifiers (numeric ids, base64-ish blobs, hashes —
* including a 64-char SHA-256 hex digest, which has no `0x` prefix)
* - platform type identifiers (`HKQuantityTypeIdentifierStepCount`)
*
* There is deliberately NO rule on length, on the presence of spaces, on
* character class, or on "looks random enough". Those would reject real
* vendor identifiers, and a rule that cannot be justified against a shape
* this system actually receives does not belong here.
*/
import { z } from "zod/v4";
/** The closed set of shapes that cannot be a stable identity. */
export type UnstableExternalIdShape =
"blank" | "pointer_address" | "object_description";
/**
* A bare hex pointer and nothing else — `0x12568db80`. Anchored on both
* ends so an opaque vendor id that merely CONTAINS "0x" is untouched.
*/
const BARE_POINTER = /^0x[0-9a-f]+$/i;
/**
* An angle-bracketed description carrying a hex pointer, searched
* anywhere in the value so a client that prefixes its own namespace
* (`apple:<Foo: 0x1234>`) is caught too. `[^<>]*` keeps the bracket pair
* adjacent — the token has to be one description, not two unrelated
* angle brackets spanning half the string.
*/
const POINTER_DESCRIPTION = /<[^<>]*0x[0-9a-f]+[^<>]*>/i;
/**
* Classify an external identifier. Returns `null` when the value is
* usable as an identity, or the shape that disqualifies it.
*/
export function classifyExternalId(
value: string,
): UnstableExternalIdShape | null {
const trimmed = value.trim();
if (trimmed.length === 0) return "blank";
if (BARE_POINTER.test(trimmed)) return "pointer_address";
if (POINTER_DESCRIPTION.test(trimmed)) return "object_description";
return null;
}
/** Convenience predicate over {@link classifyExternalId}. */
export function isStableExternalId(value: string): boolean {
return classifyExternalId(value) === null;
}
/**
* 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 {
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),
});
}
/**
* Object-level Zod check for a single-entry request schema that carries a
* top-level `externalId`. Used as `schema.superRefine(assertStableExternalId)`
* so the refusal rides the standard 422 multi-issue envelope
* (`returnAllZodIssues` / `sanitiseZodIssues`) with `path: ["externalId"]`
* instead of a hand-rolled error.
*
* Batch / bulk surfaces deliberately do NOT use this: a schema-level
* refusal fails the whole payload, and one bad row must not stop the other
* 499 from landing. Those routes call {@link classifyExternalId} per entry
* and report the entry through their own `skipped` contract.
*/
export function assertStableExternalId(
value: { externalId?: string | null },
ctx: z.RefinementCtx,
): void {
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,
);
}
/**
* Read a top-level `externalId` off an unparsed request body and classify
* it. Single-entry routes use this on the 422 path to annotate the wide
* event with the SHAPE that was refused — never the id itself, which is
* client free-text and can carry anything.
*/
export function unstableExternalIdShape(
body: unknown,
field: "externalId" | "idempotencyKey" = "externalId",
): UnstableExternalIdShape | null {
if (typeof body !== "object" || body === null) return null;
const raw = (body as Record<string, unknown>)[field];
if (typeof raw !== "string") return null;
return classifyExternalId(raw);
}
/**
* Pinned wide-event meta for a refusal. Keys are fixed so dashboards stay
* stable; the payload carries the count, the classification, and the
* surface — never the identifier itself, which is client free-text.
*
* `external_id_shapes` is the sorted, de-duplicated shape set as a single
* comma-joined string — a scalar rather than an array so it groups
* cleanly in the event store.
*
* The surface rides the META rather than only the action name because a
* wide event carries exactly one action, and most of these refusals
* happen on a path that already names its own (`…​.validation-failed`,
* `measurement.batch.ingest`). Overwriting that action would cost more
* than it buys, so `external_id_rejected > 0` is the one query that finds
* every occurrence on every surface; routes whose failure path claims no
* action of its own additionally set `<surface>.external_id.rejected`.
*/
export function unstableExternalIdMeta(
surface: string,
shapes: UnstableExternalIdShape[],
): {
external_id_rejected: number;
external_id_shapes: string;
external_id_surface: string;
} {
return {
external_id_rejected: shapes.length,
external_id_shapes: [...new Set(shapes)].sort().join(","),
external_id_surface: surface,
};
}
/** Per-entry `reason` code every batch / bulk surface reports. */
export const UNSTABLE_EXTERNAL_ID_REASON = "unstable_external_id";
@@ -12,6 +12,7 @@ import {
rxNormCodeField,
} from "./base";
import { scheduleSchema } from "./schedule";
import { assertStableExternalId } from "@/lib/validations/external-id";
/**
* v1.5 — medication-level course window + one-shot flag. The fields
@@ -187,7 +188,7 @@ export const createMedicationSchema = z
.max(128)
.optional()
.describe(
"Stable external identifier of the mirrored medication (the HealthKit `medicationConceptIdentifier`, max 128 chars). Must be supplied together with `externalSource`. Idempotent: re-posting the same pair returns the existing medication with status 200 instead of minting a duplicate.",
"Stable external identifier of the mirrored medication (the HealthKit `medicationConceptIdentifier`, max 128 chars). Must be supplied together with `externalSource`. Idempotent: re-posting the same pair returns the existing medication with status 200 instead of minting a duplicate. The value MUST be identical across app restarts: an object description or memory address (`<Class: 0x…>`, `0x12ab34cd`) is a new value on every launch, never matches its own earlier row, and is refused with 422 rather than minting a duplicate mirror.",
),
...courseWindowFields,
/**
@@ -206,6 +207,10 @@ export const createMedicationSchema = z
path: ["externalId"],
},
)
// The concept id has to be STABLE across client launches or the
// idempotent-mirror pre-query above never matches its own earlier row
// and every sync sweep mints another phantom medication.
.superRefine(assertStableExternalId)
.refine((b) => b.oneShot !== true || !!b.startsOn, {
message: "startsOn is required when oneShot is true",
path: ["startsOn"],
+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),
+5 -1
View File
@@ -11,6 +11,7 @@ import {
INSTRUMENTS,
type InstrumentId,
} from "@/lib/mental-health/instruments";
import { assertStableExternalId } from "@/lib/validations/external-id";
export const assessmentInstrumentEnum = z.enum(["PHQ9", "GAD7", "WHO5", "SCI"]);
@@ -69,7 +70,10 @@ export const createAssessmentSchema = z
});
}
});
});
})
// The replay anchor only protects against a duplicate administration
// while the id is stable across client launches.
.superRefine(assertStableExternalId);
export const listAssessmentsSchema = z.object({
instrument: assessmentInstrumentEnum.optional(),
+40 -33
View File
@@ -8,6 +8,7 @@
*/
import { z } from "zod/v4";
import { validateEntryInstant } from "@/lib/validations/entry-instant";
import { assertStableExternalId } from "@/lib/validations/external-id";
// --- CRUD schemas for mood entries ---
@@ -68,39 +69,45 @@ const ratedFactor = z.object({
});
const ratedFactors = z.array(ratedFactor).max(30);
export const createMoodEntrySchema = z.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
// v1.8.5 — structured-tag keys from the taxonomy. Server resolves each
// key to a `MoodTag` row and writes the `MoodEntryTagLink` join;
// unknown keys are dropped silently (the catalog is the source of
// truth, a stale client can't mint a tag).
tagKeys: structuredTagKeys.optional(),
// v1.12.0 — rated factors scored 1..5 (or the factor's own scale).
// Parallel to the binary `tagKeys`; persisted on
// `MoodEntryTagLink.rating`. Out-of-scale or non-RATED keys are
// rejected (422) / dropped server-side per the catalog.
ratedFactors: ratedFactors.optional(),
// v1.4.30 H-5 — first-class free-text note. Replaces the
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
// Coach evidence shelf renders cleanly without truncating chips.
note: z.string().max(500).optional(),
// v1.17 W1b — plausibility bound (shared `validateEntryInstant`): no
// future instants beyond a 5-min clock-skew tolerance, no instant before
// 1900. Mirrors the measurement + medication-intake bound.
moodLoggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
),
source: moodSourceEnum.optional().default("MANUAL"),
// v1.12.1 — optional source-stable id (e.g. an iOS SwiftData row UUID).
// When present, the create upserts on `(userId, source, externalId)`
// so a re-post with the same id updates the existing row in place
// instead of either 409-ing or minting a duplicate — the idempotent
// re-import iOS drives over Bearer. NULL keeps the legacy
// `(userId, date, moodLoggedAt)` behaviour. Bound matches the bulk
// `externalId` so one path can't accept an id the other rejects.
externalId: z.string().min(1).max(120).optional(),
});
export const createMoodEntrySchema = z
.object({
mood: moodLevelEnum,
tags: z.array(z.string().max(50)).max(20).optional(),
// v1.8.5 — structured-tag keys from the taxonomy. Server resolves each
// key to a `MoodTag` row and writes the `MoodEntryTagLink` join;
// unknown keys are dropped silently (the catalog is the source of
// truth, a stale client can't mint a tag).
tagKeys: structuredTagKeys.optional(),
// v1.12.0 — rated factors scored 1..5 (or the factor's own scale).
// Parallel to the binary `tagKeys`; persisted on
// `MoodEntryTagLink.rating`. Out-of-scale or non-RATED keys are
// rejected (422) / dropped server-side per the catalog.
ratedFactors: ratedFactors.optional(),
// v1.4.30 H-5 — first-class free-text note. Replaces the
// `tags: ["note:<text>"]` workaround. Capped at 500 chars so the
// Coach evidence shelf renders cleanly without truncating chips.
note: z.string().max(500).optional(),
// v1.17 W1b — plausibility bound (shared `validateEntryInstant`): no
// future instants beyond a 5-min clock-skew tolerance, no instant before
// 1900. Mirrors the measurement + medication-intake bound.
moodLoggedAt: validateEntryInstant(
z.iso.datetime({ offset: true }).transform((s) => new Date(s)),
),
source: moodSourceEnum.optional().default("MANUAL"),
// v1.12.1 — optional source-stable id (e.g. an iOS SwiftData row UUID).
// When present, the create upserts on `(userId, source, externalId)`
// so a re-post with the same id updates the existing row in place
// instead of either 409-ing or minting a duplicate — the idempotent
// re-import iOS drives over Bearer. NULL keeps the legacy
// `(userId, date, moodLoggedAt)` behaviour. Bound matches the bulk
// `externalId` so one path can't accept an id the other rejects.
externalId: z.string().min(1).max(120).optional(),
})
// The upsert key is only idempotent while the id is stable; an id that
// rotates per client launch mints a fresh entry on every re-post. The
// bulk twin carries the same rule per entry (it cannot fail the whole
// batch on one bad row).
.superRefine(assertStableExternalId);
export const updateMoodEntrySchema = z.object({
mood: moodLevelEnum.optional(),
+4
View File
@@ -173,6 +173,10 @@ export const createWorkoutSchema = z
// schema, so a client cannot forge a row attributed to an integration it
// does not own.
source: writableMeasurementSourceEnum.optional().default("MANUAL"),
// The only writer of this schema is the batch ingest, which enforces
// external-id stability per entry rather than at the field: a
// schema-level refusal would fail the whole batch on one bad row.
// @external-id-checked-per-entry: src/app/api/workouts/batch/route.ts
externalId: z.string().max(128).optional(),
externalSourceVersion: z.string().max(64).optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
@@ -483,4 +483,287 @@ describe("POST /api/medications/intake/bulk (real Postgres)", () => {
});
expect(stored).toHaveLength(1);
});
/**
* Migration 0273 — the idempotency key is unique PER USER.
*
* It carried a GLOBAL unique from 0001_init. Because the key is
* client-supplied, two people running the same client could mint the
* same one, and then the global constraint refused the second person's
* write outright: their dose was silently lost. The unscoped replay
* probe made it worse by resolving the key to the FIRST user's row and
* handing that stranger's row id back.
*
* These two cases are the whole contract: different users must not
* collide, and one user replaying their own key must still dedup.
*/
it("lets two different users post the SAME idempotency key — both doses land", async () => {
const { POST } = await import("@/app/api/medications/intake/bulk/route");
const prisma = getPrismaClient();
const SHARED_KEY = "ios-outbox-2026-05-16T08:00";
// User A posts first.
const first = await POST(
makeRequest({
entries: [
{
medicationId: ownedMedId,
scheduledFor: "2026-05-16T08:00:00.000Z",
takenAt: "2026-05-16T08:02:00.000Z",
idempotencyKey: SHARED_KEY,
},
],
}),
);
expect(first.status).toBe(200);
expect(
((await first.json()) as { data: { inserted: number } }).data.inserted,
).toBe(1);
// Now the OTHER user, with their own session and their own medication,
// posts the very same key.
const otherSession = await prisma.session.create({
data: {
userId: OTHER_USER_ID,
expiresAt: new Date(Date.now() + 365 * 24 * 60 * 60 * 1000),
},
});
cookieJar.set("healthlog_session", otherSession.id);
const second = await POST(
makeRequest({
entries: [
{
medicationId: foreignMedId,
scheduledFor: "2026-05-16T08:00:00.000Z",
takenAt: "2026-05-16T08:03:00.000Z",
idempotencyKey: SHARED_KEY,
},
],
}),
);
expect(second.status).toBe(200);
const secondJson = (await second.json()) as {
data: {
inserted: number;
duplicates: number;
entries: Array<{ status: string; id?: string }>;
};
};
// The second user's dose LANDS. Pre-0273 it was reported `duplicate`
// against the first user's row and never written.
expect(secondJson.data.inserted).toBe(1);
expect(secondJson.data.duplicates).toBe(0);
const mine = await prisma.medicationIntakeEvent.findMany({
where: { userId: OTHER_USER_ID },
select: { id: true, userId: true, idempotencyKey: true },
});
const theirs = await prisma.medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID },
select: { id: true },
});
expect(mine).toHaveLength(1);
expect(theirs).toHaveLength(1);
expect(mine[0].idempotencyKey).toBe(SHARED_KEY);
// And no id belonging to the first user was echoed to the second.
const echoed = secondJson.data.entries[0]?.id;
if (echoed) expect(echoed).not.toBe(theirs[0].id);
});
it("still dedups when the SAME user replays their own key", async () => {
const { POST } = await import("@/app/api/medications/intake/bulk/route");
const prisma = getPrismaClient();
const KEY = "ios-outbox-replay-001";
const body = {
entries: [
{
medicationId: ownedMedId,
scheduledFor: "2026-05-18T08:00:00.000Z",
takenAt: "2026-05-18T08:02:00.000Z",
idempotencyKey: KEY,
},
],
};
const first = await POST(makeRequest(body));
expect(
((await first.json()) as { data: { inserted: number } }).data.inserted,
).toBe(1);
const replay = await POST(makeRequest(body));
const replayJson = (await replay.json()) as {
data: {
inserted: number;
duplicates: number;
entries: Array<{ status: string; id?: string }>;
};
};
expect(replayJson.data.inserted).toBe(0);
expect(replayJson.data.duplicates).toBe(1);
const stored = await prisma.medicationIntakeEvent.findMany({
where: { userId: TEST_USER_ID, idempotencyKey: KEY },
select: { id: true },
});
expect(stored).toHaveLength(1);
// The replay resolves to the caller's OWN row.
expect(replayJson.data.entries[0]?.id).toBe(stored[0].id);
});
it("holds the per-user unique at the database, not only in the route", async () => {
const prisma = getPrismaClient();
const KEY = "db-level-shared-key";
await prisma.medicationIntakeEvent.create({
data: {
userId: TEST_USER_ID,
medicationId: ownedMedId,
scheduledFor: new Date("2026-05-19T08:00:00.000Z"),
takenAt: new Date("2026-05-19T08:00:00.000Z"),
source: "WEB",
idempotencyKey: KEY,
},
});
// Same key, different user — allowed now, refused before 0273.
await expect(
prisma.medicationIntakeEvent.create({
data: {
userId: OTHER_USER_ID,
medicationId: foreignMedId,
scheduledFor: new Date("2026-05-19T08:00:00.000Z"),
takenAt: new Date("2026-05-19T08:00:00.000Z"),
source: "WEB",
idempotencyKey: KEY,
},
}),
).resolves.toBeTruthy();
// Same key, SAME user — still refused.
await expect(
prisma.medicationIntakeEvent.create({
data: {
userId: TEST_USER_ID,
medicationId: ownedMedId,
scheduledFor: new Date("2026-05-20T08:00:00.000Z"),
takenAt: new Date("2026-05-20T08:00:00.000Z"),
source: "WEB",
idempotencyKey: KEY,
},
}),
).rejects.toMatchObject({ code: "P2002" });
});
/**
* Migration 0273 step 1 — the reconciliation safety net.
*
* Against an intact database it can never fire: the global unique it
* replaces is strictly stronger than the per-user one, so no colliding
* pair can exist. It is there for a database whose global index was
* dropped by hand at some point. That means nothing else in this suite
* exercises it, so this case builds the collision on purpose and runs
* the SHIPPED SQL — read from the migration file, not a copy that could
* drift — against it.
*
* The whole thing runs inside a transaction that is rolled back, so the
* index surgery never escapes into the rest of the run.
*/
it("migration 0273 reconciles a pre-existing same-user collision without losing a dose", async () => {
const prisma = getPrismaClient();
const { readFileSync } = await import("node:fs");
const { join } = await import("node:path");
const sql = readFileSync(
join(
process.cwd(),
"prisma",
"migrations",
"0273_scope_intake_idempotency_key_per_user",
"migration.sql",
),
"utf8",
);
// Strip comments BEFORE splitting: the prose contains semicolons of
// its own, and splitting first would slice a statement in half.
const statements = sql
.split("\n")
.filter((line) => !line.trim().startsWith("--"))
.join("\n")
.split(";")
.map((part) => part.trim())
.filter(Boolean);
const safetyNet = statements.find((st) => st.startsWith("UPDATE"));
const createIndex = statements.find((st) =>
st.startsWith("CREATE UNIQUE INDEX"),
);
expect(
safetyNet,
"migration must carry the reconciliation UPDATE",
).toBeTruthy();
expect(createIndex, "migration must carry the per-user index").toBeTruthy();
const COLLIDING_KEY = "legacy-collision-key";
let observed: Array<{ id: string; idempotencyKey: string | null }> = [];
class Rollback extends Error {}
await expect(
prisma.$transaction(async (tx) => {
// Simulate the only database on which the collision is possible:
// one whose unique index was dropped by hand.
await tx.$executeRawUnsafe(
'DROP INDEX "medication_intake_events_user_id_idempotency_key_key"',
);
const earlier = await tx.medicationIntakeEvent.create({
data: {
id: "collide-earlier",
userId: TEST_USER_ID,
medicationId: ownedMedId,
scheduledFor: new Date("2026-05-21T08:00:00.000Z"),
takenAt: new Date("2026-05-21T08:00:00.000Z"),
source: "WEB",
idempotencyKey: COLLIDING_KEY,
createdAt: new Date("2026-05-21T08:00:00.000Z"),
},
});
const later = await tx.medicationIntakeEvent.create({
data: {
id: "collide-later",
userId: TEST_USER_ID,
medicationId: ownedMedId,
scheduledFor: new Date("2026-05-22T08:00:00.000Z"),
takenAt: new Date("2026-05-22T08:00:00.000Z"),
source: "WEB",
idempotencyKey: COLLIDING_KEY,
createdAt: new Date("2026-05-22T08:00:00.000Z"),
},
});
// Run the migration's own reconciliation, then its own index.
await tx.$executeRawUnsafe(safetyNet!);
await tx.$executeRawUnsafe(createIndex!);
observed = await tx.medicationIntakeEvent.findMany({
where: { id: { in: [earlier.id, later.id] } },
orderBy: { createdAt: "asc" },
select: { id: true, idempotencyKey: true },
});
throw new Rollback();
}),
).rejects.toBeInstanceOf(Rollback);
// Both doses survive — the migration never deletes or rewrites a row.
expect(observed).toHaveLength(2);
expect(observed[0]).toEqual({
id: "collide-earlier",
idempotencyKey: COLLIDING_KEY,
});
// Only the dedup HINT on the later row is cleared, which is what makes
// the unique index creatable.
expect(observed[1]).toEqual({
id: "collide-later",
idempotencyKey: null,
});
});
});