Mint a default Idempotency-Key on every client POST and PATCH

Every apiHandler route accepts the header and the wrapped write routes
dedupe on it, yet almost no client call site sent one — a replayed
create could land a duplicate dose or vaccination and shift the series
index and booster-due date behind it. apiPost and apiPatch now mint a
per-call crypto.randomUUID() key unless the caller set its own; reads
stay bare.
This commit is contained in:
Marc-André Bombeck committed 2026-08-14 02:19:59 +02:00
1 parent 3973af69d4
commit d75034a9f4
2 files changed
+72 -2

No files matched your search

+45
View File
@@ -312,6 +312,51 @@ describe("verb helpers", () => {
expect(new Headers(bodylessInit.headers).get("Content-Type")).toBeNull();
});
// Watched red: with the `withDefaultIdempotencyKey` wrap removed from
// apiPost/apiPatch the first two tests fail with a null header — the
// pre-fix client sent no key, so a transport-level replay of a create
// could land a duplicate row on every unwrapped write surface.
describe("default Idempotency-Key", () => {
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
it.each([
["apiPost", apiPost],
["apiPatch", apiPatch],
] as const)("%s mints a per-call UUID key", async (_name, helper) => {
fetchMock.mockResolvedValueOnce(
jsonResponse({ data: null, error: null }),
);
await helper("/api/foo", { a: 1 });
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(new Headers(init.headers).get("Idempotency-Key")).toMatch(UUID_RE);
});
it("a caller-supplied key wins", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({ data: null, error: null }),
);
await apiPost(
"/api/foo",
{ a: 1 },
{ headers: { "Idempotency-Key": "natural-key-1" } },
);
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(new Headers(init.headers).get("Idempotency-Key")).toBe(
"natural-key-1",
);
});
it("reads mint no key", async () => {
fetchMock.mockResolvedValueOnce(
jsonResponse({ data: null, error: null }),
);
await apiGet("/api/foo");
const [, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(new Headers(init.headers).get("Idempotency-Key")).toBeNull();
});
});
it("merges caller headers with the JSON Content-Type", async () => {
fetchMock.mockResolvedValueOnce(jsonResponse({ data: null, error: null }));
+27 -2
View File
@@ -195,13 +195,35 @@ export function apiGet<T = unknown>(
return apiFetch<T>(path, { ...init, method: "GET" });
}
/**
* Attach a per-call `Idempotency-Key` unless the caller already set one.
*
* Every `apiHandler` route accepts the header, and the write routes
* wrapped in `withIdempotency` dedupe on it — but almost no client call
* site sent one, so a transport-level replay of a create (a retried
* request, a re-sent form submission) could land a duplicate dose or
* vaccination and shift every derived series. Minting the key here
* protects every `apiPost` / `apiPatch` surface at once; a caller with a
* real natural key still wins by setting the header itself.
*/
function withDefaultIdempotencyKey(init: RequestInit): RequestInit {
const headers = new Headers(init.headers);
if (!headers.has("Idempotency-Key")) {
headers.set("Idempotency-Key", crypto.randomUUID());
}
return { ...init, headers };
}
/** POST with optional JSON body — resolves with the unwrapped `data`. */
export function apiPost<T = unknown>(
path: string,
body?: unknown,
init?: RequestInit,
): Promise<T> {
return apiFetch<T>(path, withJsonBody("POST", body, init));
return apiFetch<T>(
path,
withDefaultIdempotencyKey(withJsonBody("POST", body, init)),
);
}
/** PUT with optional JSON body — resolves with the unwrapped `data`. */
@@ -219,7 +241,10 @@ export function apiPatch<T = unknown>(
body?: unknown,
init?: RequestInit,
): Promise<T> {
return apiFetch<T>(path, withJsonBody("PATCH", body, init));
return apiFetch<T>(
path,
withDefaultIdempotencyKey(withJsonBody("PATCH", body, init)),
);
}
/** DELETE with optional JSON body — resolves with the unwrapped `data`. */