Publish the idempotency contract the server has honoured all along

`withIdempotency` has made writes safe to retry since v1.5.x. It was described
in route comments and in no schema, so an app generated against
`docs/api/openapi.yaml` could not learn that a retry was safe. The iOS side
asked for it; the mechanism does not change, only what it says about itself.

34 operations now declare `Idempotency-Key` and the `X-Idempotent-Replay`
header, plus the 409 that refuses a duplicate still in flight. The description
leads with the one silent failure in the mechanism: a key that misses
`^[A-Za-z0-9_\-:.]{8,128}$` is IGNORED rather than refused, so the write
proceeds unprotected and looks exactly like a request that carried no key.

## What the work turned up

Three things the contract had wrong or missing, none of them the thing being
built:

- `/api/insights/chat` describes `withIdempotency()` in its header comment and
  applies it nowhere. The Coach chat is not retry-safe. Its prose says it is.
- `/api/measurements` exports GET, POST, PUT and DELETE and wraps only POST.
  Publishing the header per PATH would have promised a safe retry on two
  methods that do not honour one — the dangerous direction, so the guard works
  per OPERATION.
- `/api/insights/feedback` and `/api/admin/backups/{id}/restore` are not in the
  document at all. Both are named in the guard's exemption list with a reason,
  not filtered out silently.

## The guard

`idempotency-contract-publication.test.ts` reads the expected set out of the
route tree on every run — nothing is hand-listed, so it cannot fall behind the
code. It fails in both directions: a new wrapped route that forgets to publish,
and a published claim no handler honours.

A fourth assertion checks the exemption list itself is still true. It earned
its place immediately: the first draft exempted the whole `/api/medications`
family on the strength of a grep for path literals that came back empty. The
paths are not written as literals there; the emitted document carries 19 of
them. The test went red, the claim was wrong, the list is two entries now.
Measure the artefact, not the source that builds it.
This commit is contained in:
Marc-André Bombeck committed 2026-08-21 18:32:29 +02:00
1 parent b8a0aead7f
commit eddb67975d
22 files changed
+753 -237

No files matched your search

+355 -230
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,185 @@
/**
* Every idempotent route says so in the contract, and only those do.
*
* `withIdempotency` has wrapped writes since v1.5.x. `Idempotency-Key` appeared
* in a dozen route comments and in no published schema, so an app generated
* against `docs/api/openapi.yaml` could not tell that a retry was safe — which
* is exactly what the iOS side asked for. Publishing it once is easy; keeping
* it published as routes come and go is what this guard is for.
*
* Both directions matter, and for different reasons:
*
* - A route that gains `withIdempotency` and forgets to publish it leaves the
* capability invisible, which is today's defect repeating itself.
* - A route that publishes the parameter and drops the wrapper is worse: the
* contract then promises a retry is safe when it is not, and a client that
* believes it writes twice.
*
* The wrapper is the source of truth here, not this file. Nothing is
* hand-listed: the expected set is read out of the route tree on every run, so
* the guard cannot fall behind the code it watches.
*/
import { readFileSync, readdirSync } from "node:fs";
import { join, relative, sep } from "node:path";
import { describe, expect, it } from "vitest";
import { buildOpenApiDocument } from "@/lib/openapi/registry";
const API_ROOT = join(process.cwd(), "src", "app", "api");
/** Every `route.ts` under `src/app/api`, tests excluded. */
function routeFiles(dir: string): string[] {
const found: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "__tests__") continue;
found.push(...routeFiles(full));
continue;
}
if (entry.name === "route.ts") found.push(full);
}
return found;
}
/** `src/app/api/medications/[id]/intake/route.ts` -> `/api/medications/{id}/intake` */
function toOpenApiPath(file: string): string {
const rel = relative(API_ROOT, file).split(sep).slice(0, -1);
const segments = rel.map((s) =>
s.startsWith("[") && s.endsWith("]") ? `{${s.slice(1, -1)}}` : s,
);
return `/api/${segments.join("/")}`;
}
/**
* Method-level, not path-level, and the difference is the whole point.
*
* `/api/measurements` exports GET, POST, PUT and DELETE, and only POST is
* wrapped:
*
* export const POST = apiHandler(withIdempotency<[NextRequest]>(postMeasurement));
* export const PUT = apiHandler(putMeasurement);
*
* A path-level check would go green on that path the moment ANY operation
* declared the header, and publishing it on PUT as well would promise a safe
* retry the server does not honour. So the unit here is `POST /api/x`, and the
* matcher requires the wrapper in the same assignment as the export.
*/
function idempotentOperationsFromSource(): Set<string> {
const found = new Set<string>();
// `export const POST = ...withIdempotency...` up to the end of that
// statement. Non-greedy to the next top-level `export const`, so a wrapped
// POST cannot lend its wrapper to the PUT declared below it.
const pattern =
/export\s+const\s+(POST|PUT|PATCH|DELETE)\s*=\s*([\s\S]*?)(?=\n(?:export\s+const|export\s+async\s+function|$))/g;
for (const file of routeFiles(API_ROOT)) {
const source = readFileSync(file, "utf8");
if (!source.includes("withIdempotency")) continue;
for (const match of source.matchAll(pattern)) {
if (!/\bwithIdempotency\s*[(<]/.test(match[2])) continue;
found.add(`${match[1].toLowerCase()} ${toOpenApiPath(file)}`);
}
}
return found;
}
/** Operations in the emitted document that declare the `Idempotency-Key`. */
function idempotentOperationsFromContract(): Set<string> {
const doc = buildOpenApiDocument() as {
paths?: Record<string, Record<string, unknown>>;
};
const found = new Set<string>();
for (const [path, item] of Object.entries(doc.paths ?? {})) {
for (const [method, operation] of Object.entries(item)) {
const parameters = (operation as { parameters?: unknown })?.parameters;
if (!Array.isArray(parameters)) continue;
const declares = parameters.some(
(p) => (p as { name?: string })?.name === "Idempotency-Key",
);
if (declares) found.add(`${method.toLowerCase()} ${path}`);
}
}
return found;
}
/**
* Idempotent operations that cannot be published, because the PATH is not in
* the document at all. A header cannot hang on a path that does not exist.
*
* Two entries, and the list was four until the test below refused it. The first
* draft claimed the whole `/api/medications` family was missing, on the
* strength of a grep for path literals in `src/lib/openapi/routes/` that came
* back empty. It came back empty because those paths are not written as
* searchable literals there — the emitted document carries 19 of them,
* including every one the iOS client actually argues about. Measure the
* artefact, not the source that builds it.
*
* Listed rather than filtered silently, so the exclusion stays visible in the
* file that would otherwise claim full coverage.
*/
const UNPUBLISHED_PATHS: Readonly<Record<string, string>> = {
"/api/insights/feedback":
"Coach feedback write; never added to the route table. A real gap, small.",
"/api/admin/backups/{id}/restore":
"Admin surface: cookie-only by construction, so arguably out of scope for a client contract.",
};
describe("idempotency — the contract matches the wrapper", () => {
it("finds idempotent operations in the source tree at all", () => {
// Without this the two comparisons below could both pass on empty sets,
// which is the failure mode this whole file exists to prevent elsewhere.
const fromSource = idempotentOperationsFromSource();
expect(fromSource.size).toBeGreaterThan(20);
});
it("publishes the header on every route that wraps withIdempotency", () => {
const fromSource = idempotentOperationsFromSource();
const fromContract = idempotentOperationsFromContract();
const unpublished = [...fromSource]
.filter((p) => !fromContract.has(p))
.filter((p) => !(p.split(" ")[1] in UNPUBLISHED_PATHS))
.sort();
expect(
unpublished,
`These routes accept an Idempotency-Key and do not say so in the ` +
`contract, so a generated client cannot know a retry is safe. Add ` +
`\`idempotencyKeyParameter\` to the write operation and ` +
`\`...idempotentWrite()\` to its responses.`,
).toEqual([]);
});
it("keeps the unpublished list honest — every entry is still absent", () => {
const doc = buildOpenApiDocument() as { paths?: Record<string, unknown> };
const present = Object.keys(doc.paths ?? {});
const nowPublished = Object.keys(UNPUBLISHED_PATHS)
.filter((p) => present.includes(p))
.sort();
expect(
nowPublished,
`These paths are in the contract now, so their exemption above is stale. ` +
`Delete the entry and add \`idempotencyKeyParameter\` to the operation ` +
`— an exemption nobody removes is how a gap becomes permanent.`,
).toEqual([]);
});
it("does not promise idempotency on a route that dropped the wrapper", () => {
const fromSource = idempotentOperationsFromSource();
const fromContract = idempotentOperationsFromContract();
const overclaimed = [...fromContract]
.filter((p) => !fromSource.has(p))
.sort();
expect(
overclaimed,
`The contract offers an Idempotency-Key on these paths and no handler ` +
`honours it. A client that trusts this writes twice on a retry — the ` +
`more dangerous of the two directions.`,
).toEqual([]);
});
});
+4
View File
@@ -27,6 +27,8 @@ import {
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
@@ -102,11 +104,13 @@ export const allergyPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Record an allergy (v1.25)",
description:
"Creates one allergy/intolerance record for the caller. The free-text reaction + note are AES-256-GCM encrypted before write. Audits as `allergy.create`.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: allergyCreateSchema } },
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Allergy created.",
+4
View File
@@ -22,6 +22,8 @@ import {
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
@@ -101,11 +103,13 @@ export const biomarkerPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Define a biomarker",
description:
"Creates a user-scoped biomarker. The optional context note is AES-256-GCM encrypted before write. Audits as `biomarker.create`.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: createBiomarkerSchema } },
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Created biomarker.",
+4
View File
@@ -50,6 +50,8 @@ import {
conflictResponse409,
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
invalidBaseTokenResponse,
recordRefusal,
stdResponses,
@@ -1285,6 +1287,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Create an anamnesis fact",
description:
"Creates the first current revision for one closed fact kind. The answer is encrypted before persistence. A second current value for the same kind returns 409; corrections use the revision endpoint.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: {
@@ -1292,6 +1295,7 @@ export const coachPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
...idempotentWrite(),
"201": {
description: "The newly created current revision.",
content: {
+6
View File
@@ -24,6 +24,8 @@ import {
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
@@ -159,11 +161,13 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Define a custom metric",
description:
"Creates a user-scoped custom metric. Re-creating a name that was previously soft-deleted revives that definition. Audits as `customMetric.create`.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: createCustomMetricSchema } },
},
responses: {
...idempotentWrite(),
"201": {
description: "Created custom metric.",
content: {
@@ -288,6 +292,7 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description:
"Records a value against the custom metric, snapshotting the metric's unit. Audits as `customMetricEntry.create`.",
requestParams: { path: z.object({ id: z.string() }) },
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: {
@@ -295,6 +300,7 @@ export const customMetricPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
...idempotentWrite(),
"201": {
description: "Created value.",
content: {
+8
View File
@@ -27,6 +27,8 @@ import {
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
@@ -461,11 +463,13 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Capture a single cycle day-log (v1.15.0)",
description:
"Upserts on `(userId, source, externalId)` when externalId present, else `(userId, date)`. `note` encrypts at rest. 201 on insert, 200 on update. Gated: `cycle.disabled` 403 when the feature is off.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: cycleDayLogInputSchema } },
},
responses: {
...idempotentWrite(),
"200": {
description: "Existing day-log updated.",
content: {
@@ -541,11 +545,13 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Bulk drain cycle day-logs (Outbox / HealthKit) (v1.15.0)",
description:
"Up to 500 entries; `withIdempotency`; rate-limited `cycle:day-logs:bulk:<userId>` 60/min. Per-entry status: inserted | duplicate | updated | skipped. Always 200.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: cycleBulkSchema } },
},
responses: {
...idempotentWrite(),
"200": {
description: "Batch processed (per-entry results).",
content: {
@@ -565,11 +571,13 @@ export const cyclePaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Period-boundary shortcut (v1.15.0)",
description:
"One-tap started/ended period. `start` opens a new cycle (closing the prior); `end` stamps periodEndDate. Writes the boundary day-log. Gated.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: cyclePeriodSchema } },
},
responses: {
...idempotentWrite(),
"200": {
description: "Cycle + boundary day-log.",
content: {
+6
View File
@@ -31,6 +31,8 @@ import {
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
@@ -359,6 +361,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
post: {
parameters: [idempotencyKeyParameter],
tags: ["Documents"],
summary: "Store a document (no extraction)",
description:
@@ -397,6 +400,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
...idempotentWrite(),
"200": {
description:
"Duplicate — the same bytes are already stored: the existing row, with `meta.duplicate: true` at the envelope level.",
@@ -1096,6 +1100,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description:
"The ONLY write path out of staging. Commits the user's approvals into the structured stores (labs / conditions / medications) and discards rejections. A low-confidence fact that was never edited cannot be approved (fail-closed). Idempotent (Idempotency-Key). Audits as `documents.inbound.confirm`.",
parameters: [
idempotencyKeyParameter,
{
name: "id",
in: "path",
@@ -1108,6 +1113,7 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: inboundConfirmSchema } },
},
responses: {
...idempotentWrite(),
"200": {
description: "Per-decision outcome.",
content: {
+6
View File
@@ -35,6 +35,8 @@ import {
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
@@ -246,6 +248,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
post: {
parameters: [idempotencyKeyParameter],
tags: ["Records"],
summary: "File or book a visit",
description:
@@ -255,6 +258,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: encounterCreateSchema } },
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Visit filed.",
@@ -467,6 +471,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
post: {
parameters: [idempotencyKeyParameter],
tags: ["Records"],
summary: "Add a practitioner",
description:
@@ -476,6 +481,7 @@ export const encounterPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: practitionerCreateSchema } },
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Practitioner added.",
+4
View File
@@ -24,6 +24,8 @@ import {
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
@@ -98,6 +100,7 @@ export const familyHistoryPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Record a family-history entry (v1.25)",
description:
"Creates one condition-by-relative record for the caller. The free-text note is AES-256-GCM encrypted before write. Audits as `family-history.create`.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: {
@@ -105,6 +108,7 @@ export const familyHistoryPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Entry created.",
+6
View File
@@ -32,6 +32,8 @@ import {
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
@@ -318,6 +320,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Open an illness episode (v1.18.1)",
description:
"Creates one episode for the caller. `onsetAt` defaults to 'now' when omitted; the optional note is AES-256-GCM encrypted before write. A `parentConditionId` must reference an owned, live episode. Audits as `illness.episode.create`. Born-gated.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: {
@@ -325,6 +328,7 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Episode created.",
@@ -422,7 +426,9 @@ export const illnessPaths: NonNullable<ZodOpenApiObject["paths"]> = {
description:
"Clears `deletedAt`, bringing the episode and its preserved day-logs + encrypted note back into every read. A soft-delete keeps the row and children intact, so restore is a pure flag flip — nothing is re-created (the delete-Undo affordance). Idempotent — restoring a live episode is a no-op. Audits as `illness.episode.restore`. Owner-scoped + born-gated.",
requestParams: { path: z.object({ id: z.string() }) },
parameters: [idempotencyKeyParameter],
responses: {
...idempotentWrite(),
...recordRefusal(),
"200": {
description: "Episode restored.",
+6
View File
@@ -19,6 +19,8 @@ import {
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
@@ -139,11 +141,13 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Record a lab result",
description:
'Creates a single lab result for the caller. The optional `source` marks provenance (`"MANUAL"` | `"OCR"`); it defaults to `"MANUAL"` when omitted, so the legacy hand-entry contract is unchanged. The on-device-OCR client posts `"OCR"` after the human confirms the parsed values — the raw image never touches the server on this path. The optional note is AES-256-GCM encrypted before write. Audits as `labResult.create`.',
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: { "application/json": { schema: createLabResultSchema } },
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Created lab result.",
@@ -232,6 +236,7 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Restore soft-deleted lab results",
description:
"Un-tombstones 1..200 of the caller's soft-deleted lab results in one pass (the delete-Undo affordance). A foreign / live id is a silent no-op. Audits via the wide-event ledger.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: {
@@ -243,6 +248,7 @@ export const labsPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"200": {
description: "Restore count.",
@@ -21,6 +21,8 @@ import {
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
@@ -61,6 +63,7 @@ export const measurementReminderPaths: NonNullable<ZodOpenApiObject["paths"]> =
summary: "Create a Vorsorge reminder (v1.17.1)",
description:
"Creates a reminder and computes its server-authoritative nextDueAt. Exactly one of intervalDays (rolling) or rrule (RFC-5545) is required. Wrapped in withIdempotency. 201 on insert.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: {
@@ -68,6 +71,7 @@ export const measurementReminderPaths: NonNullable<ZodOpenApiObject["paths"]> =
},
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Reminder created.",
+10
View File
@@ -20,6 +20,8 @@ import {
MODULE_DISABLED_DESCRIPTION,
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
@@ -764,6 +766,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Create one measurement (or a small array)",
description:
"Single ingest. The body may also be a bare ARRAY of the same objects — the mode the iOS client uses for a combined blood-pressure + pulse or dual-value glucose write — in which case `data` is the array of created rows in request order. Use `/api/measurements/batch` for Apple Health upload streams.",
parameters: [idempotencyKeyParameter],
requestBody: {
required: true,
content: {
@@ -776,6 +779,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description:
@@ -889,6 +893,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"/api/measurements/batch": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Measurements"],
summary: "Apple Health batch ingest",
description:
@@ -898,6 +903,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: batchPayloadSchema } },
},
responses: {
...idempotentWrite(),
"200": {
description: "Batch processed.",
content: {
@@ -940,6 +946,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"/api/measurements/bulk-delete": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Measurements"],
summary: "Bulk soft-delete measurements",
description:
@@ -951,6 +958,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"200": {
description: "Bulk delete processed.",
@@ -969,6 +977,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"/api/measurements/restore": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Measurements"],
summary: "Restore soft-deleted measurements",
description:
@@ -980,6 +989,7 @@ export const measurementPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"200": {
description: "Restore processed.",
@@ -27,6 +27,8 @@ import {
conflictResponse409,
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
invalidBaseTokenResponse,
recordRefusal,
stdResponses,
@@ -171,6 +173,7 @@ const medicationIntakeImportJobStatusEnvelope = dataEnvelope(
export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/medications/intake/bulk": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Medications"],
summary: "Bulk medication-intake backfill (iOS SyncMode)",
description:
@@ -180,6 +183,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: bulkIntakePayload } },
},
responses: {
...idempotentWrite(),
"200": {
description: "Batch processed (always 200 on a well-formed body).",
content: {
@@ -410,6 +414,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"/api/medications/{id}/intake": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Medications"],
summary: "Log an intake event for a medication",
description:
@@ -422,6 +427,7 @@ export const medicationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: intakeSchema } },
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Intake event created.",
+9 -1
View File
@@ -19,7 +19,13 @@ import {
listAssessmentsSchema,
} from "@/lib/validations/mental-health";
import { dataEnvelope, recordRefusal, stdResponses } from "./shared";
import {
dataEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
createAssessmentSchema.meta({
id: "CreateMentalHealthAssessmentRequest",
@@ -127,12 +133,14 @@ export const mentalHealthPaths: NonNullable<ZodOpenApiObject["paths"]> = {
summary: "Record a completed screener",
description:
"Stores one administration (item answers encrypted) and writes the derived total as a server-owned PHQ9_SCORE / GAD7_SCORE / WHO5_SCORE / SCI_SCORE measurement (source COMPUTED — never client-writable). WHO-5 and SCI totals run HIGHER = better; their follow-up thresholds point downwards (≤ 50 / ≤ 16). On a positive PHQ-9 item-9 the response carries the locale-aware crisis-resource set.",
parameters: [idempotencyKeyParameter],
requestBody: {
content: {
"application/json": { schema: createAssessmentSchema },
},
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Assessment recorded.",
+15 -5
View File
@@ -46,15 +46,17 @@ import {
WORK_STATUS_KEYS,
} from "@/lib/mood/context-vocabulary";
import {
dataEnvelope,
errorEnvelope,
recordRefusal,
stdResponses,
MODULE_DISABLED_DESCRIPTION,
baseUpdatedAtField,
updatedAtTokenField,
conflictResponse409,
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
invalidBaseTokenResponse,
recordRefusal,
stdResponses,
updatedAtTokenField,
} from "./shared";
// ── Request schemas — annotated for spec emission ────────────────────
@@ -762,6 +764,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"/api/mood-entries/bulk": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Mood"],
summary: "Bulk mood backfill (iOS SyncMode)",
description:
@@ -771,6 +774,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: bulkMoodPayload } },
},
responses: {
...idempotentWrite(),
"200": {
description: "Batch processed (always 200 on a well-formed body).",
content: {
@@ -1080,6 +1084,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
post: {
parameters: [idempotencyKeyParameter],
tags: ["Mood"],
summary: "Create a mood entry",
description:
@@ -1089,6 +1094,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: createMoodEntryRequest } },
},
responses: {
...idempotentWrite(),
"201": {
description:
"Entry created (or upserted in place via `externalId`), with its persisted tag keys, rated factors and context.",
@@ -1165,6 +1171,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"/api/mood-entries/restore": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Mood"],
summary: "Restore soft-deleted mood entries",
description:
@@ -1174,6 +1181,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: moodEntryIdsPayload } },
},
responses: {
...idempotentWrite(),
"200": {
description: "`restored` counts the rows actually un-tombstoned.",
content: {
@@ -1192,6 +1200,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"/api/mood-entries/bulk-delete": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Mood"],
summary: "Soft-delete mood entries in bulk",
description:
@@ -1201,6 +1210,7 @@ export const moodPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: moodEntryIdsPayload } },
},
responses: {
...idempotentWrite(),
"200": {
description: "`deleted` counts the rows actually tombstoned.",
content: {
+6
View File
@@ -24,6 +24,8 @@ import {
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
@@ -50,6 +52,7 @@ const catalogTable = NUTRIENT_DEFINITIONS.map(
export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"/api/nutrients/batch": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Nutrients"],
summary: "Ingest micronutrient day totals (v1.28)",
description:
@@ -60,6 +63,7 @@ export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: nutrientBatchSchema } },
},
responses: {
...idempotentWrite(),
"200": {
description:
"Batch processed; per-entry statuses inserted | updated | skipped.",
@@ -127,6 +131,7 @@ export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"/api/nutrients/water": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Nutrients"],
summary: "Manual water quick-add (v1.29)",
description:
@@ -138,6 +143,7 @@ export const nutrientPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
responses: {
...idempotentWrite(),
"200": {
description: "The MANUAL water row after the write.",
content: {
+8 -1
View File
@@ -17,7 +17,12 @@ import { z } from "zod/v4";
import { ocrCommitSchema } from "@/lib/validations/labs-ocr";
import { dataEnvelope, stdResponses } from "./shared";
import {
dataEnvelope,
idempotencyKeyParameter,
idempotentWrite,
stdResponses,
} from "./shared";
ocrCommitSchema.meta({
id: "OcrCommitRequest",
@@ -197,6 +202,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"/api/labs/ocr/commit": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Labs"],
summary: "Commit confirmed Lab-OCR rows",
description:
@@ -206,6 +212,7 @@ export const ocrPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: ocrCommitSchema } },
},
responses: {
...idempotentWrite(),
"200": {
description: "Inserted + skipped rows.",
content: {
+93
View File
@@ -177,6 +177,99 @@ export const stdResponses = {
},
};
// ── Idempotent writes ────────────────────────────────────────────────
/**
* What a route wrapped in `withIdempotency` accepts and answers.
*
* The mechanism has worked since long before it was published: send
* `Idempotency-Key` on a write, and a retry of the same key replays the first
* response instead of doing the work twice. It was described in route comments
* and nowhere a client could read it, so an app generated against the contract
* had no way to know a retry was safe. This publishes it.
*
* Three things a client cannot infer and has to be told:
*
* 1. **A malformed key is ignored, not refused.** `getIdempotencyKey` tests
* `^[A-Za-z0-9_\-:.]{8,128}$` and returns null on a miss, so a key that is
* too short or carries a stray character is indistinguishable from sending
* none at all — the write proceeds, unprotected, with a 200. This is the
* one failure here that is silent, which is why it leads.
* 2. **The replay is marked.** A response served from the cache carries
* `X-Idempotent-Replay: true`. Without reading it a client cannot tell a
* fresh 201 from a replayed one, which matters when the body reports what
* was created.
* 3. **A concurrent duplicate is refused, not queued.** While the first
* request is still running, a second one under the same key gets 409 with
* `X-Idempotent-Replay: false` — the header is present in both directions
* precisely so the two cases are told apart. Retry after a delay.
*
* Scope, so nobody reads more into it than is there: only POST, PUT, PATCH and
* DELETE are covered (`SUPPORTED_METHODS`), the key is scoped per user and per
* `(method, path)`, and `isCachableStatus` refuses to cache 5xx or the
* transient 401 / 403 / 408 / 429 — a retry after one of those runs for real.
*
* `src/__tests__/idempotency-contract-publication.test.ts` holds this to the
* routes that actually wrap `withIdempotency`, in both directions, so a new
* idempotent route that forgets to say so fails, and so does a claim here for
* a route that dropped the wrapper.
*/
export const idempotencyKeyParameter = {
name: "Idempotency-Key",
in: "header" as const,
required: false,
schema: {
type: "string" as const,
pattern: "^[A-Za-z0-9_\\-:.]{8,128}$",
minLength: 8,
maxLength: 128,
},
description:
"Makes this write safe to retry. Send the same key again and the first response is replayed rather than the work repeated, marked with `X-Idempotent-Replay: true`. A key that does not match the pattern is IGNORED rather than refused — the write then proceeds unprotected and looks exactly like a request that carried no key, so validate the key on your side. Scoped per user and per (method, path): the same key on a different route is a different key. While a first request under this key is still in flight, a second is refused with 409 rather than queued.",
};
/**
* Response headers are Zod here, not an OpenAPI literal — `zod-openapi` types
* `headers` as a `ZodObject` and emits the schema from it. Writing the literal
* shape by hand typechecks nowhere and was the first thing this file got wrong.
*/
const idempotentReplayHeaders = z.object({
"X-Idempotent-Replay": z.enum(["true", "false"]).meta({
description:
"`true` when this response was replayed from a stored one rather than produced now; `false` on the 409 that refuses a duplicate still in flight. Absent when the request carried no usable key.",
}),
});
interface IdempotentConflictResponse {
"409": {
description: string;
content: { "application/json": { schema: typeof errorEnvelope } };
headers: typeof idempotentReplayHeaders;
};
}
/**
* The 409 an idempotent route answers while an earlier request under the same
* key is still running, plus the replay header on the way back.
*
* Cached by identity like {@link recordRefusal} above, for the same reason: a
* fresh object per call site would print this paragraph thirty-seven times.
*/
let idempotentConflictCache: IdempotentConflictResponse | null = null;
export function idempotentWrite(): IdempotentConflictResponse {
if (idempotentConflictCache) return idempotentConflictCache;
idempotentConflictCache = {
"409": {
description:
"A request with this `Idempotency-Key` is already in progress. Nothing was written by this call. Retry after a short delay; the response carries `X-Idempotent-Replay: false` to separate it from a replay.",
content: { "application/json": { schema: errorEnvelope } },
headers: idempotentReplayHeaders,
},
};
return idempotentConflictCache;
}
// ── The delegated-record refusal (v1.37.0) ───────────────────────────
/**
+4
View File
@@ -35,6 +35,8 @@ import { measurementReminderDto } from "@/lib/validations/measurement-reminders"
import {
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
recordRefusal,
stdResponses,
} from "./shared";
@@ -237,6 +239,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
post: {
parameters: [idempotencyKeyParameter],
tags: ["Records"],
summary: "Log a vaccination",
description:
@@ -246,6 +249,7 @@ export const vaccinationPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: vaccinationCreateSchema } },
},
responses: {
...idempotentWrite(),
...recordRefusal(),
"201": {
description: "Dose logged.",
+4
View File
@@ -13,6 +13,8 @@ import {
MODULE_DISABLED_DESCRIPTION,
dataEnvelope,
errorEnvelope,
idempotencyKeyParameter,
idempotentWrite,
moduleDisabledResponse,
recordRefusal,
stdResponses,
@@ -309,6 +311,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"/api/workouts/batch": {
post: {
parameters: [idempotencyKeyParameter],
tags: ["Measurements"],
summary: "Typed workout batch ingest (v1.4.25 W16b)",
description:
@@ -318,6 +321,7 @@ export const workoutPaths: NonNullable<ZodOpenApiObject["paths"]> = {
content: { "application/json": { schema: createBatchWorkoutSchema } },
},
responses: {
...idempotentWrite(),
"200": {
description: "Batch processed.",
content: {