Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/sapiom-metadata-clone-loss.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions packages/fetch/src/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
14 changes: 14 additions & 0 deletions packages/fetch/src/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,17 @@ export function createFetch(config?: SapiomFetchConfig): typeof fetch {
input: string | URL | Request,
init?: RequestInit,
): Promise<Response> => {
// 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 };
Expand All @@ -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;
}
}
}

Expand Down
Loading