diff --git a/src/adapters/http/http-execution-v3.ts b/src/adapters/http/http-execution-v3.ts index bf563b8..190d642 100644 --- a/src/adapters/http/http-execution-v3.ts +++ b/src/adapters/http/http-execution-v3.ts @@ -468,6 +468,16 @@ export function createContractHttpExecutor( // A missing credential never downgrades into an anonymous request. return finish(unauthenticated("NOT_STARTED", isCommand), "NOT_STARTED"); } + if ( + Object.keys(patch.headers).some( + (name) => name.toLowerCase() === "idempotency-key", + ) + ) { + return finish( + violation("UNEXPECTED_IDEMPOTENCY_KEY", "REQUEST", "NOT_STARTED"), + "NOT_STARTED", + ); + } const headers: Record = { Accept: "application/json", diff --git a/tests/unit/http-execution-v3.test.ts b/tests/unit/http-execution-v3.test.ts index 76e9ad9..5882adf 100644 --- a/tests/unit/http-execution-v3.test.ts +++ b/tests/unit/http-execution-v3.test.ts @@ -144,6 +144,52 @@ describe("descriptor-driven HTTP execution lifetime", () => { }, ); + it.each([ + { + label: "query with the canonical reserved header", + operation: installed, + input: { limit: 20 }, + context: { scope }, + headerName: "Idempotency-Key", + }, + { + label: "valid KEYED command with a case-variant reserved header", + operation: createInstalled, + input: { name: "created" }, + context: { scope, intent: mutationIntent() }, + headerName: "iDeMpOtEnCy-KeY", + }, + ])( + "rejects a credential patch for $label before fetch", + async ({ operation, input, context, headerName }) => { + const attachCredentials = vi.fn(() => ({ + kind: "READY" as const, + headers: { [headerName]: "credential-owned-key" }, + credentials: "omit" as const, + })); + const fetcher = vi.fn(); + const executor = createContractHttpExecutor({ + baseUrl: "https://api.example/", + maxRetryAttempts: 0, + attachCredentials, + fetcher, + }); + + await expect( + executor.execute(operation, input, context), + ).resolves.toMatchObject({ + kind: "CONTRACT_VIOLATION", + violation: { + kind: "UNEXPECTED_IDEMPOTENCY_KEY", + operation: "REQUEST", + }, + effect: "NOT_STARTED", + }); + expect(attachCredentials).toHaveBeenCalledOnce(); + expect(fetcher).not.toHaveBeenCalled(); + }, + ); + it("reuses one supplied idempotency key across every physical retry", async () => { const observedKeys: Array = []; let attempt = 0;