fix(api): accept an Idempotency-Key on the titration step and the custom symptom

Two more creates the client replays out of its offline outbox ran the
handler again on the replay. A titration step carries no natural key at
all, so a lost success response left the medication with two dose
changes on the same day and the runway estimate reading off the wrong
one. A custom cycle symptom is unique on its label, so the replay did
not duplicate — it answered the 409 that uniqueness raises, reporting a
conflict the client cannot act on for a row it has no record of
creating.

Both wrap in `withIdempotency`, the composition their siblings already
ship, and both operations publish the header and the in-flight 409 so a
client generated against the contract can tell a retry is safe.

The integration file that drives the medication creates twice against
real Postgres gains the titration case: one dose-change row after the
replay, and a replayed 201 carrying `X-Idempotent-Replay: true`.

`POST /api/mcp/tokens` is deliberately not on this list. Its response
carries a freshly minted `hlk_` credential, and the wrapper refuses to
persist a secret-shaped body — publishing the header there would
promise a replay the mechanism will never perform.
This commit is contained in:
Marc-André Bombeck committed 2026-09-10 07:40:05 +02:00
1 parent 3e44d68518
commit 040b430e73
6 files changed
+211 -140

No files matched your search

+14 -2
View File
@@ -26,6 +26,7 @@ import {
} from "@/lib/api-response";
import { apiHandler, requireRecordAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { withIdempotency } from "@/lib/idempotency";
import { auditLog } from "@/lib/auth/audit";
import { checkRateLimit } from "@/lib/rate-limit";
import { requireCycleEnabled } from "@/lib/cycle/gate";
@@ -67,7 +68,18 @@ export const GET = apiHandler(async () => {
return apiSuccess({ symptoms });
});
export const POST = apiHandler(async (request: NextRequest) => {
/**
* Wrapped in `withIdempotency`: a custom symptom is minted from the client's
* offline outbox under the same `Idempotency-Key`, and a replay after a lost
* success response would otherwise answer the 409 the label's unique key
* raises rather than the 201 the first attempt produced — a create the client
* has no record of, reported as a conflict it cannot act on.
*/
export const POST = apiHandler(
withIdempotency<[NextRequest]>(postCustomSymptom),
);
async function postCustomSymptom(request: NextRequest): Promise<Response> {
// v1.37.0 — MANAGE. The record's own symptom vocabulary, which the day-log
// writes the level admits need in order to say anything.
const { user, actor } = await requireRecordAuth("manage", "cycle");
@@ -151,4 +163,4 @@ export const POST = apiHandler(async (request: NextRequest) => {
},
201,
);
});
}
+113 -101
View File
@@ -28,6 +28,7 @@ import {
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
import { glp1PostBodySchema } from "@/lib/validations/medication";
import { assertMedicationOwnership } from "@/lib/medications/route-guards";
import { withIdempotency } from "@/lib/idempotency";
import {
estimateDailyDoseCount,
estimateRunwayDays,
@@ -236,103 +237,71 @@ export const GET = apiHandler(
* Rate-limit: 30/min/user (same as sibling POST routes).
* Audit: `medication.glp1.update` for every successful mutation.
*/
/**
* Wrapped in `withIdempotency`: a titration step is replayed out of the
* client's offline outbox under the same `Idempotency-Key`, and a replay after
* a lost success response would otherwise write a second dose change or a
* second supply delta. Neither row carries a natural key, so the wrapper is the
* only thing that can collapse the retry.
*/
export const POST = apiHandler(
async (request: NextRequest, { params }: RouteParams) => {
// v1.37.0 — MANAGE. A titration step is record data and the dose is what
// the server acts on; administering one is the case the level names.
const { user, actor } = await requireRecordAuth("manage", "medications");
const { id } = await params;
withIdempotency<[NextRequest, RouteParams]>(postGlp1Update),
);
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
async function postGlp1Update(
request: NextRequest,
{ params }: RouteParams,
): Promise<Response> {
// v1.37.0 — MANAGE. A titration step is record data and the dose is what
// the server acts on; administering one is the case the level names.
const { user, actor } = await requireRecordAuth("manage", "medications");
const { id } = await params;
// Per-caller POST rate-limit — matches the 30/min sibling routes
// (inventory, side-effects). Generous for a hand-driven session,
// tight enough to cut off the spam case.
//
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own
// allowance and cannot collect a fresh one by switching records.
const rl = await checkRateLimit(
`medication-glp1:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
}
const guard = await assertMedicationOwnership(id, user.id);
if (guard) return guard;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
// Per-caller POST rate-limit — matches the 30/min sibling routes
// (inventory, side-effects). Generous for a hand-driven session,
// tight enough to cut off the spam case.
//
// v1.37.0 — C1: keyed on the ACTOR, so a manager burns their own
// allowance and cannot collect a fresh one by switching records.
const rl = await checkRateLimit(
`medication-glp1:post:${actor.id}`,
POST_RATE_LIMIT,
POST_WINDOW_MS,
);
if (!rl.allowed) {
return apiError("Too many requests", 429, {
headers: rateLimitHeaders(rl),
});
if (jsonError) return jsonError;
}
const parsed = glp1PostBodySchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const ip = getClientIp(request);
const parsed = glp1PostBodySchema.safeParse(body);
if (!parsed.success) {
// v1.4.43 W6 — multi-issue 422.
return returnAllZodIssues(parsed.error, 422);
}
if (parsed.data.doseChange) {
const { effectiveFrom, doseValue, doseUnit, note } =
parsed.data.doseChange;
const created = await prisma.medicationDoseChange.create({
data: {
medicationId: id,
effectiveFrom,
doseValue,
doseUnit,
// Encrypt the titration note at rest; the plaintext column stays null.
noteEncrypted: encryptNote(note),
note: null,
},
});
const ip = getClientIp(request);
await auditLog("medication.glp1.update", {
userId: user.id,
ipAddress: ip,
details: {
medicationId: id,
kind: "doseChange",
doseChangeId: created.id,
doseValue,
doseUnit,
},
});
annotate({
action: {
name: "medication.glp1.doseChange.create",
entity_type: "medication_dose_change",
entity_id: created.id,
},
meta: { medication_id: id, doseValue, doseUnit },
});
// A dose change shifts the daily-dose estimate the list payload's
// runway derivation uses. Hard-evict the medications + compliance
// buckets so the card reflects the new runway on the next read.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201);
}
// The schema's XOR refinement guarantees `inventory` is present
// when `doseChange` is not, so the non-null assertion is safe.
//
// DEPRECATED branch (v1.16.10): this writes the legacy
// MedicationInventoryEvent running-sum ledger. The per-item
// endpoints (`POST /api/medications/[id]/inventory`,
// `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it;
// GET above and the Coach snapshot read the ledger only while the
// medication has zero inventory items. New callers must register
// containers instead of posting deltas.
const { delta, reason } = parsed.data.inventory!;
const created = await prisma.medicationInventoryEvent.create({
data: { medicationId: id, delta, reason },
if (parsed.data.doseChange) {
const { effectiveFrom, doseValue, doseUnit, note } = parsed.data.doseChange;
const created = await prisma.medicationDoseChange.create({
data: {
medicationId: id,
effectiveFrom,
doseValue,
doseUnit,
// Encrypt the titration note at rest; the plaintext column stays null.
noteEncrypted: encryptNote(note),
note: null,
},
});
await auditLog("medication.glp1.update", {
@@ -340,27 +309,70 @@ export const POST = apiHandler(
ipAddress: ip,
details: {
medicationId: id,
kind: "inventory",
inventoryEventId: created.id,
delta,
reason,
kind: "doseChange",
doseChangeId: created.id,
doseValue,
doseUnit,
},
});
annotate({
action: {
name: "medication.glp1.inventory.create",
entity_type: "medication_inventory_event",
name: "medication.glp1.doseChange.create",
entity_type: "medication_dose_change",
entity_id: created.id,
},
meta: { medication_id: id, delta },
meta: { medication_id: id, doseValue, doseUnit },
});
// A legacy ledger delta changes the stock the list payload reports
// for a medication with no per-item containers. Hard-evict so the
// card reflects it on the next read.
// A dose change shifts the daily-dose estimate the list payload's
// runway derivation uses. Hard-evict the medications + compliance
// buckets so the card reflects the new runway on the next read.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ inventory: created }, 201);
},
);
return apiSuccess({ doseChange: shapeDoseChangeNote(created) }, 201);
}
// The schema's XOR refinement guarantees `inventory` is present
// when `doseChange` is not, so the non-null assertion is safe.
//
// DEPRECATED branch (v1.16.10): this writes the legacy
// MedicationInventoryEvent running-sum ledger. The per-item
// endpoints (`POST /api/medications/[id]/inventory`,
// `PATCH /api/medications/[id]/inventory/[itemId]`) replaced it;
// GET above and the Coach snapshot read the ledger only while the
// medication has zero inventory items. New callers must register
// containers instead of posting deltas.
const { delta, reason } = parsed.data.inventory!;
const created = await prisma.medicationInventoryEvent.create({
data: { medicationId: id, delta, reason },
});
await auditLog("medication.glp1.update", {
userId: user.id,
ipAddress: ip,
details: {
medicationId: id,
kind: "inventory",
inventoryEventId: created.id,
delta,
reason,
},
});
annotate({
action: {
name: "medication.glp1.inventory.create",
entity_type: "medication_inventory_event",
entity_id: created.id,
},
meta: { medication_id: id, delta },
});
// A legacy ledger delta changes the stock the list payload reports
// for a medication with no per-item containers. Hard-evict so the
// card reflects it on the next read.
invalidateUserMedications(user.id, { evict: true });
return apiSuccess({ inventory: created }, 201);
}