From fd21b60ee2c7909f244746ea54a846c25d850dd4 Mon Sep 17 00:00:00 2001 From: Mariusz Peplinski Date: Thu, 6 Aug 2026 10:04:01 +0200 Subject: [PATCH 1/2] feat(EL-1458): let the host supply the bearer token and extra headers per request --- src/configurator/ConfiguratorContext.spec.ts | 98 ++++++++++++++++++++ src/configurator/ConfiguratorContext.ts | 12 ++- src/configurator/IConfiguratorOptions.ts | 28 ++++++ 3 files changed, 137 insertions(+), 1 deletion(-) diff --git a/src/configurator/ConfiguratorContext.spec.ts b/src/configurator/ConfiguratorContext.spec.ts index bf3488d..9e1a0b8 100644 --- a/src/configurator/ConfiguratorContext.spec.ts +++ b/src/configurator/ConfiguratorContext.spec.ts @@ -217,6 +217,104 @@ describe("ConfiguratorContext", () => { }); }); + describe("accessTokenProvider", () => { + it("should send the provided token as the bearer instead of the anonymous id header", async () => { + const ctx = new ConfiguratorContext({ + apiUrl: API_URL, + tenantId: "", + accessTokenProvider: () => "provided-token", + }); + + mockNextFetchResponse({}); + await ctx.getSettings(); + + expect(lastRequest.headers.get("authorization")).toBe("Bearer provided-token"); + expect(lastRequest.headers.get("x-elfsquad-id")).toBeNull(); + }); + + it("should await an asynchronous provider", async () => { + const ctx = new ConfiguratorContext({ + apiUrl: API_URL, + tenantId: "", + accessTokenProvider: () => Promise.resolve("async-token"), + }); + + mockNextFetchResponse({}); + await ctx.getSettings(); + + expect(lastRequest.headers.get("authorization")).toBe("Bearer async-token"); + }); + + it("should fall back to the normal resolution when the provider returns null", async () => { + const ctx = new ConfiguratorContext({ + apiUrl: API_URL, + tenantId: "", + accessTokenProvider: () => null, + }); + + mockNextFetchResponse({}); + await ctx.getSettings(); + + expect(lastRequest.headers.get("authorization")).toBeNull(); + expect(lastRequest.headers.get("x-elfsquad-id")).toBe(""); + }); + }); + + describe("additionalHeaders", () => { + it("should add the resolved headers to requests", async () => { + const ctx = new ConfiguratorContext({ + apiUrl: API_URL, + tenantId: "", + additionalHeaders: () => ({ "x-elf-orgid": "", "x-elf-tenantid": "" }), + }); + + mockNextFetchResponse({}); + await ctx.getSettings(); + + expect(lastRequest.headers.get("x-elf-orgid")).toBe(""); + expect(lastRequest.headers.get("x-elf-tenantid")).toBe(""); + }); + + it("should be resolved per request", async () => { + let orgId = "first"; + const ctx = new ConfiguratorContext({ + apiUrl: API_URL, + tenantId: "", + additionalHeaders: () => ({ "x-elf-orgid": orgId }), + }); + + mockNextFetchResponse({}); + await ctx.getSettings(); + expect(lastRequest.headers.get("x-elf-orgid")).toBe("first"); + + orgId = "second"; + mockNextFetchResponse({}); + await ctx.getSettings(); + expect(lastRequest.headers.get("x-elf-orgid")).toBe("second"); + }); + + it("should overwrite headers this library sets itself", async () => { + const ctx = new ConfiguratorContext({ + apiUrl: API_URL, + tenantDomain: "test.example.com", + additionalHeaders: () => ({ "x-elfsquad-domain": "override.example.com" }), + }); + + mockNextFetchResponse({}); + await ctx.getSettings(); + + expect(lastRequest.headers.get("x-elfsquad-domain")).toBe("override.example.com"); + }); + + it("should leave requests untouched when no option is supplied", async () => { + mockNextFetchResponse({}); + await configuratorContext.getSettings(); + + expect(lastRequest.headers.get("x-elf-orgid")).toBeNull(); + expect(lastRequest.headers.get("x-elfsquad-id")).toBe(""); + }); + }); + describe("HTTP error handling", () => { it("should throw ConfiguratorHttpError on non-ok response with JSON body", async () => { mockNextFetchErrorResponse(401, JSON.stringify({ error: "Unauthorized" })); diff --git a/src/configurator/ConfiguratorContext.ts b/src/configurator/ConfiguratorContext.ts index b86db08..e438dad 100644 --- a/src/configurator/ConfiguratorContext.ts +++ b/src/configurator/ConfiguratorContext.ts @@ -423,7 +423,12 @@ export class ConfiguratorContext extends EventTarget { input.headers.append("x-elfsquad-domain", this.options.tenantDomain); } - if (await this.useElfsquadIdHeader()) { + // A host-supplied token stands in for a signed-in user, so it takes the authenticated + // path even when the configured method would otherwise fall back to anonymous. + const providedToken = await this.options.accessTokenProvider?.(); + if (providedToken) { + input.headers.set("authorization", `Bearer ${providedToken}`); + } else if (await this.useElfsquadIdHeader()) { if (this.options.tenantId) { input.headers.append("x-elfsquad-id", this.options.tenantId); } @@ -434,6 +439,11 @@ export class ConfiguratorContext extends EventTarget { ); } + const additionalHeaders = await this.options.additionalHeaders?.(); + for (const [name, value] of Object.entries(additionalHeaders ?? {})) { + input.headers.set(name, value); + } + let response: Response; try { response = await fetch(input); diff --git a/src/configurator/IConfiguratorOptions.ts b/src/configurator/IConfiguratorOptions.ts index 005bb0b..7490c0a 100644 --- a/src/configurator/IConfiguratorOptions.ts +++ b/src/configurator/IConfiguratorOptions.ts @@ -42,6 +42,34 @@ export interface IConfiguratorOptions { * api.elfsquad.io. */ apiUrl?: string | undefined; + + /** + * Optionally supply the bearer token for every request, instead of + * taking it from the @link{AuthenticationContext}. Use this when the + * host application receives a token by another route than the OAuth + * flow — for example a token handed over by the embedding + * application. + * + * Returning null or undefined falls back to the normal resolution, + * so the host decides per request whether its own token applies. + * + * Called on every request; return a cached value if resolving it is + * expensive. + */ + accessTokenProvider?: + | (() => string | null | undefined | Promise) + | undefined; + + /** + * Optional headers added to every request, resolved per request. + * Use for context the host resolves at runtime, such as the + * `x-elf-orgid` and `x-elf-tenantid` headers that select the selling + * organization and the tenant. + * + * These are applied last and overwrite headers this library sets + * itself. + */ + additionalHeaders?: (() => Record | Promise>) | undefined; } export type AuthenticationMethod = (typeof AuthenticationMethod)[keyof typeof AuthenticationMethod]; From f56aa574086291421d27fdd4a4d3524456e2468d Mon Sep 17 00:00:00 2001 From: Mariusz Peplinski Date: Thu, 6 Aug 2026 10:04:18 +0200 Subject: [PATCH 2/2] Update the version to 3.6.11 (EL-1458) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index b1d96ce..b26ea60 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@elfsquad/configurator", - "version": "3.6.10", + "version": "3.6.11", "description": "", "scripts": { "test": "jest",