From d75034a9f458ad1ffec7771ed2fca19c6d095fec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 14 Aug 2026 02:19:59 +0200 Subject: [PATCH] Mint a default Idempotency-Key on every client POST and PATCH MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/lib/api/__tests__/api-fetch.test.ts | 45 +++++++++++++++++++++++++ src/lib/api/api-fetch.ts | 29 ++++++++++++++-- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/src/lib/api/__tests__/api-fetch.test.ts b/src/lib/api/__tests__/api-fetch.test.ts index 2671b31cc..148635b07 100644 --- a/src/lib/api/__tests__/api-fetch.test.ts +++ b/src/lib/api/__tests__/api-fetch.test.ts @@ -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 })); diff --git a/src/lib/api/api-fetch.ts b/src/lib/api/api-fetch.ts index 55529169a..80e1b6500 100644 --- a/src/lib/api/api-fetch.ts +++ b/src/lib/api/api-fetch.ts @@ -195,13 +195,35 @@ export function apiGet( return apiFetch(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( path: string, body?: unknown, init?: RequestInit, ): Promise { - return apiFetch(path, withJsonBody("POST", body, init)); + return apiFetch( + path, + withDefaultIdempotencyKey(withJsonBody("POST", body, init)), + ); } /** PUT with optional JSON body — resolves with the unwrapped `data`. */ @@ -219,7 +241,10 @@ export function apiPatch( body?: unknown, init?: RequestInit, ): Promise { - return apiFetch(path, withJsonBody("PATCH", body, init)); + return apiFetch( + path, + withDefaultIdempotencyKey(withJsonBody("PATCH", body, init)), + ); } /** DELETE with optional JSON body — resolves with the unwrapped `data`. */