From 7ad4da51b1abe0a3337aeb88b2be9e9e153554c1 Mon Sep 17 00:00:00 2001 From: ygd58 Date: Sun, 30 Aug 2026 21:38:40 +0200 Subject: [PATCH] fix(fetch): preserve __sapiom metadata across internal Request cloning Fixes #690 sapiomFetch reconstructs the request via new Request(input, init) before reading __sapiom. Native Request cloning does not preserve custom properties, so a __sapiom override set on the original Request was dropped before it could be read - the documented per-request bypass (e.g. request.__sapiom = { enabled: false }) silently had no effect. __sapiom is now captured from the original input before cloning and re-attached to the clone, including after the identity-header re-wrap later in the same function (the same class of loss, one function down). Deliberately scoped to the reported reproduction and this identical pattern within sapiomFetch itself. handleAuthorization (interceptors.ts) does its own internal Request re-wrap before returning, which could still drop __sapiom for a caller relying on it past the authorization step (e.g. at payment-retry time) - left as a separate, deeper propagation concern rather than folded into this fix. Added a regression test verifying the enabled:false bypass calls globalThis.fetch directly (skipping authorization) when __sapiom is set on a Request input. Verified it fails against the pre-fix code (hits the unmocked authorization path and throws) and passes against the fix. pnpm --filter @sapiom/fetch test - 44/44 passing (full package). Changeset added (patch, @sapiom/fetch). --- .changeset/sapiom-metadata-clone-loss.md | 23 ++++++++++++++++++++++ packages/fetch/src/fetch.test.ts | 25 ++++++++++++++++++++++++ packages/fetch/src/fetch.ts | 14 +++++++++++++ 3 files changed, 62 insertions(+) create mode 100644 .changeset/sapiom-metadata-clone-loss.md diff --git a/.changeset/sapiom-metadata-clone-loss.md b/.changeset/sapiom-metadata-clone-loss.md new file mode 100644 index 000000000..e1fcef1c4 --- /dev/null +++ b/.changeset/sapiom-metadata-clone-loss.md @@ -0,0 +1,23 @@ +--- +"@sapiom/fetch": patch +--- + +Fixed `__sapiom` per-request metadata (e.g. `{ enabled: false }`) being +silently lost when a `Request` instance was passed as `fetch`'s `input`. + +`sapiomFetch` reconstructs the request via `new Request(input, init)` +before reading `__sapiom`. Native `Request` cloning does not preserve +custom properties, so a `__sapiom` override set on the original `Request` +was dropped before it could be read - the documented per-request bypass +(e.g. `request.__sapiom = { enabled: false }`) silently had no effect. + +`__sapiom` is now captured from the original `input` before cloning and +re-attached to the clone, including after the identity-header re-wrap +later in the same function (the same class of loss, one function down). + +Deliberately scoped to the reproduction reported in the issue and this +identical pattern within `sapiomFetch` itself. `handleAuthorization` +(`interceptors.ts`) does its own internal `Request` re-wrap before +returning, which could still drop `__sapiom` for a caller relying on it +past the authorization step (e.g. at payment-retry time) - left as a +separate, deeper propagation concern rather than folded into this fix. diff --git a/packages/fetch/src/fetch.test.ts b/packages/fetch/src/fetch.test.ts index e6a63f253..d381fd1d0 100644 --- a/packages/fetch/src/fetch.test.ts +++ b/packages/fetch/src/fetch.test.ts @@ -47,4 +47,29 @@ describe("createFetch", () => { expect(typeof fetch).toBe("function"); }); + + it("should honor __sapiom = { enabled: false } on a Request input, surviving the internal clone", async () => { + // sapiomFetch internally does `new Request(input, init)` before reading + // __sapiom. Native Request cloning drops custom properties, so this + // regression-tests that the metadata set on the *original* Request is + // still honored after that clone (issue #690). + const globalFetchSpy = jest + .spyOn(globalThis, "fetch") + .mockResolvedValue(new Response("ok")); + + const fetch = createFetch({ sapiomClient: mockSapiomClient }); + + const request = new Request("https://example.test/public"); + (request as any).__sapiom = { enabled: false }; + + await fetch(request as any); + + // The enabled:false bypass calls globalThis.fetch directly, skipping + // authorization entirely. If __sapiom were lost, this would instead + // fall through into the (unmocked) authorization path. + expect(globalFetchSpy).toHaveBeenCalledTimes(1); + expect(mockSapiomClient.transactions.create).not.toHaveBeenCalled(); + + globalFetchSpy.mockRestore(); + }); }); diff --git a/packages/fetch/src/fetch.ts b/packages/fetch/src/fetch.ts index ea97ee485..1b6cacac7 100644 --- a/packages/fetch/src/fetch.ts +++ b/packages/fetch/src/fetch.ts @@ -120,7 +120,17 @@ export function createFetch(config?: SapiomFetchConfig): typeof fetch { input: string | URL | Request, init?: RequestInit, ): Promise => { + // Native Request cloning (`new Request(existingRequest, ...)`) does not + // preserve custom properties, so a `__sapiom` override set on a Request + // passed in as `input` would otherwise be silently dropped here. Capture + // it before cloning and re-attach it to the clone so the documented + // per-request override keeps working. + const inputMetadata = + input instanceof Request ? (input as any).__sapiom : undefined; let request = new Request(input, init); + if (inputMetadata !== undefined) { + (request as any).__sapiom = inputMetadata; + } const requestMetadata = (request as any).__sapiom || {}; const userMetadata = { ...defaultMetadata, ...requestMetadata }; @@ -137,7 +147,11 @@ export function createFetch(config?: SapiomFetchConfig): typeof fetch { if (identityHeaders["Sapiom-Identity"]) { const headers = new Headers(request.headers); headers.set("Sapiom-Identity", identityHeaders["Sapiom-Identity"]); + const metadataBeforeRewrap = (request as any).__sapiom; request = new Request(request, { headers }); + if (metadataBeforeRewrap !== undefined) { + (request as any).__sapiom = metadataBeforeRewrap; + } } }