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; + } } }