mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 06:06:10 +00:00
fix(ingest): refuse an external id that cannot be a stable identity
An external id is half of every ingest dedup key — (userId, source, externalId), (userId, externalSource, externalId), (userId, type, source, externalId). Re-posting the same triple is idempotent and returns the existing row, which holds only while the id is stable across client launches. A client that serialised an opaque platform object with its default object description sent a per-allocation memory address instead, so no sweep ever matched its own earlier row and each one minted a fresh record: a live instance grew 23 phantom medications in a day, none of which could collect a dose, because the dose side resolved through the same rotating string. Add one shared classifier for identifiers that provably cannot be an identity — an angle-bracketed object description carrying a hex pointer, a bare 0x address, and a blank value — and wire it at every surface that accepts a client-supplied externalId. Single-entry creates refuse through the schema, so the client gets the standard 422 multi-issue envelope with a message naming the shape it sent and what to send instead. Batch and bulk surfaces classify per entry and report the offending row as skipped with reason unstable_external_id, leaving the rest of the payload to land: one bad row must not stop a sync. Every refusal carries pinned wide-event meta (count, shape, surface) and never the id itself. A structural guard freezes both routes into the floor: a request-side externalId declaration either applies the field check or carries a marker naming the file that checks it per entry, and the guard verifies that file really does. The backup-restore payload is the one exemption, with its reason recorded — it replays ids the server itself wrote.
This commit is contained in:
1 parent
ef9891fec5
commit
2ab66ab2e3
26 files changed
+1232
-64
No files matched your search
@@ -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)
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
@@ -128,6 +134,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 +328,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 +356,27 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The `(userId, externalId)` dose-event dedup below is only
|
||||
// idempotent while the id is STABLE across client launches. An id
|
||||
// that rotates per process (an object description carrying a memory
|
||||
// address) never matches its own earlier row, so every re-sync mints
|
||||
// another intake event. Refused per entry — one bad row must not
|
||||
// stop the rest of the batch landing.
|
||||
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,
|
||||
});
|
||||
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
|
||||
@@ -803,6 +835,9 @@ async function postBulk(request: NextRequest): Promise<Response> {
|
||||
updated,
|
||||
duplicates,
|
||||
skipped: skipped.length,
|
||||
...(unstableShapes.length > 0
|
||||
? unstableExternalIdMeta("medication.intake.bulk", unstableShapes)
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 +
|
||||
|
||||
Reference in new issue
Block a user