From dfbf33d3854103a153977be7fb336c3c5268aee8 Mon Sep 17 00:00:00 2001 From: MO Thibault <103271673+MO-Thibault@users.noreply.github.com> Date: Sat, 9 May 2026 17:15:15 -0400 Subject: [PATCH 1/4] feat: add reportMisconfiguration flag to detect 403/404 on site config When enabled, catches 403/404 errors from the Site() call during SDK initialization and fires a best-effort profile() to the default-sdk catch-all source with { error_403: hostname } or { error_404: hostname }. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- lib/config.ts | 5 ++ lib/core/network.ts | 13 +++- lib/sdk.test.ts | 148 ++++++++++++++++++++++++++++++++++++++++++++ lib/sdk.ts | 14 ++++- 4 files changed, 177 insertions(+), 3 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 68d30750..9937a34b 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -76,6 +76,9 @@ type InitConfig = { // When true, a 'pageview' event is fired once after passport init with full page context. // Implies pageContext: true when no pageContext is explicitly configured. initContextual?: boolean; + // When true, detects 403/404 responses from the Site config call and fires a + // best-effort profile() call with { error_403: hostname } or { error_404: hostname }. + reportMisconfiguration?: boolean; }; type ResolvedConfig = { @@ -97,6 +100,7 @@ type ResolvedConfig = { abTests?: ABTestConfig[]; additionalTargetingSignals?: TargetingSignals; timeout?: string; + reportMisconfiguration?: boolean; }; const DCN_DEFAULTS = { @@ -133,6 +137,7 @@ function getConfig(init: InitConfig): ResolvedConfig { abTests: init.abTests, additionalTargetingSignals: init.additionalTargetingSignals, timeout: init.timeout, + reportMisconfiguration: init.reportMisconfiguration, }; if (init.consent?.static) { diff --git a/lib/core/network.ts b/lib/core/network.ts index a6b2aadd..3684e375 100644 --- a/lib/core/network.ts +++ b/lib/core/network.ts @@ -2,6 +2,15 @@ import type { ResolvedConfig } from "../config"; import { default as buildInfo } from "../build.json"; import { LocalStorage } from "./storage"; +class FetchError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.name = "FetchError"; + this.status = status; + } +} + function buildRequest(path: string, config: ResolvedConfig, init?: RequestInit): Request { const { host, cookies } = config; @@ -74,7 +83,7 @@ async function fetch(path: string, config: ResolvedConfig, init?: RequestInit const data = contentType?.startsWith("application/json") ? await response.json() : await response.text(); if (!response.ok) { - throw new Error(data.error); + throw new FetchError(data?.error ?? response.statusText, response.status); } if (data.passport) { @@ -92,5 +101,5 @@ async function fetch(path: string, config: ResolvedConfig, init?: RequestInit return data; } -export { fetch, buildRequest }; +export { fetch, buildRequest, FetchError }; export default fetch; diff --git a/lib/sdk.test.ts b/lib/sdk.test.ts index 5a4153f2..a1cbe01e 100644 --- a/lib/sdk.test.ts +++ b/lib/sdk.test.ts @@ -3,6 +3,8 @@ import { OptableSDK, normalizeTargetingRequest } from "./sdk"; import { TEST_BASE_URL, TEST_HOST, TEST_SITE } from "./test/mocks"; import { DCN_DEFAULTS } from "./config"; import { waitFor } from "./test/utils"; +import { server } from "./test/server"; +import { http, HttpResponse } from "msw"; const defaultConsent = DCN_DEFAULTS.consent; @@ -603,3 +605,149 @@ describe("normalizeTargetingRequest", () => { expect(() => normalizeTargetingRequest(3)).toThrowError(/Expected string or object/); }); }); + +describe("reportMisconfiguration", () => { + beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); + }); + + test("when reportMisconfiguration: true and Site() returns 403, profile is called with error_403", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + // Mock 403 response for config endpoint + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Forbidden" }, { status: 403 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: true, + }); + + await sdk["init"]; + + // Should have called profile with error_403 and o=default-sdk + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("profile"), + url: expect.stringContaining("o=default-sdk"), + _bodyText: expect.stringContaining('"error_403":"localhost"'), + }) + ); + }); + }); + + test("when reportMisconfiguration: true and Site() returns 404, profile is called with error_404", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + // Mock 404 response for config endpoint + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Not Found" }, { status: 404 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: true, + }); + + await sdk["init"]; + + // Should have called profile with error_404 and o=default-sdk + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("profile"), + url: expect.stringContaining("o=default-sdk"), + _bodyText: expect.stringContaining('"error_404":"localhost"'), + }) + ); + }); + }); + + test("when reportMisconfiguration: false and Site() returns 403, profile is NOT called", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + // Mock 403 response for config endpoint + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Forbidden" }, { status: 403 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: false, + }); + + await sdk["init"]; + + // Should NOT have called profile + expect(fetchSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining("profile"), + }) + ); + }); + + test("when reportMisconfiguration not set and Site() returns 403, profile is NOT called", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + // Mock 403 response for config endpoint + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Forbidden" }, { status: 403 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + }); + + await sdk["init"]; + + // Should NOT have called profile + expect(fetchSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining("profile"), + }) + ); + }); + + test("when reportMisconfiguration: true and Site() returns 500, profile is NOT called", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + // Mock 500 response for config endpoint + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Internal Server Error" }, { status: 500 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: true, + }); + + await sdk["init"]; + + // Should NOT have called profile (only 403/404 trigger it) + expect(fetchSpy).not.toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining("profile"), + }) + ); + }); +}); diff --git a/lib/sdk.ts b/lib/sdk.ts index 3f4ba409..75658f70 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -24,6 +24,7 @@ import { Profile } from "./edge/profile"; import { sha256 } from "js-sha256"; import { Tokenize, TokenizeResponse } from "./edge/tokenize"; import { LocalStorage } from "./core/storage"; +import { FetchError } from "./core/network"; class OptableSDK { public static version = buildInfo.version; @@ -47,7 +48,18 @@ class OptableSDK { async initialize(): Promise { if (this.dcn.initPassport) { - await Site(this.dcn).catch(() => {}); + await Site(this.dcn).catch((err) => { + if ( + this.dcn.reportMisconfiguration && + err instanceof FetchError && + (err.status === 403 || err.status === 404) + ) { + Profile( + { ...this.dcn, site: 'default-sdk' }, + { [`error_${err.status}`]: window.location.hostname } + ).catch(() => {}); + } + }); } if (this.dcn.initTargeting) { From e0517b1a11ef8915ddab04de4b6bc1d292808a36 Mon Sep 17 00:00:00 2001 From: MO Thibault <103271673+MO-Thibault@users.noreply.github.com> Date: Sat, 9 May 2026 17:37:56 -0400 Subject: [PATCH 2/4] fix: fire reportMisconfiguration on any Site() error, not just FetchError Network/CORS errors throw TypeError, not FetchError, so the instanceof check was silently swallowing misconfiguration signals. Now fires on any Site() failure: named keys for 403/404, error_config for network errors. --- lib/sdk.test.ts | 69 +++++++++++++++++++++++++++++++++++++++++++++++-- lib/sdk.ts | 12 ++++----- 2 files changed, 72 insertions(+), 9 deletions(-) diff --git a/lib/sdk.test.ts b/lib/sdk.test.ts index a1cbe01e..90f5f319 100644 --- a/lib/sdk.test.ts +++ b/lib/sdk.test.ts @@ -725,7 +725,7 @@ describe("reportMisconfiguration", () => { ); }); - test("when reportMisconfiguration: true and Site() returns 500, profile is NOT called", async () => { + test("when reportMisconfiguration: true and Site() returns 500, profile is called with error_500", async () => { const fetchSpy = jest.spyOn(window, "fetch"); // Mock 500 response for config endpoint @@ -743,11 +743,76 @@ describe("reportMisconfiguration", () => { await sdk["init"]; - // Should NOT have called profile (only 403/404 trigger it) + // Should have called profile with error_500 and o=default-sdk + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("profile"), + url: expect.stringContaining("o=default-sdk"), + _bodyText: expect.stringContaining('"error_500":"localhost"'), + }) + ); + }); + }); + + test("when reportMisconfiguration: true and Site() throws TypeError (network error), profile is called with error_config", async () => { + // Mock fetch to throw TypeError for config endpoint, but allow profile call through + const originalFetch = window.fetch; + const fetchSpy = jest.spyOn(window, "fetch").mockImplementation((input: any) => { + if (typeof input === "string" && input.includes("/config")) { + return Promise.reject(new TypeError("Failed to fetch")); + } + return originalFetch(input); + }); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: true, + }); + + await sdk["init"]; + + // Should have called profile with error_config and o=default-sdk + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("profile"), + url: expect.stringContaining("o=default-sdk"), + _bodyText: expect.stringContaining('"error_config":"localhost"'), + }) + ); + }); + + fetchSpy.mockRestore(); + }); + + test("when reportMisconfiguration not set and Site() throws TypeError, profile is NOT called", async () => { + // Mock fetch to throw TypeError for config endpoint + const originalFetch = window.fetch; + const fetchSpy = jest.spyOn(window, "fetch").mockImplementation((input: any) => { + if (typeof input === "string" && input.includes("/config")) { + return Promise.reject(new TypeError("Failed to fetch")); + } + return originalFetch(input); + }); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + }); + + await sdk["init"]; + + // Should NOT have called profile expect(fetchSpy).not.toHaveBeenCalledWith( expect.objectContaining({ url: expect.stringContaining("profile"), }) ); + + fetchSpy.mockRestore(); }); }); diff --git a/lib/sdk.ts b/lib/sdk.ts index 75658f70..ed91169f 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -49,14 +49,12 @@ class OptableSDK { async initialize(): Promise { if (this.dcn.initPassport) { await Site(this.dcn).catch((err) => { - if ( - this.dcn.reportMisconfiguration && - err instanceof FetchError && - (err.status === 403 || err.status === 404) - ) { + if (this.dcn.reportMisconfiguration) { + const status = err instanceof FetchError ? err.status : null; + const key = status ? `error_${status}` : "error_config"; Profile( - { ...this.dcn, site: 'default-sdk' }, - { [`error_${err.status}`]: window.location.hostname } + { ...this.dcn, site: "default-sdk" }, + { [key]: window.location.hostname } ).catch(() => {}); } }); From 49a99272910e2791192f8d02459a849612277ea2 Mon Sep 17 00:00:00 2001 From: MO <103271673+MO-Thibault@users.noreply.github.com> Date: Sat, 9 May 2026 17:46:12 -0400 Subject: [PATCH 3/4] Refactor Profile call to remove unnecessary lines --- lib/sdk.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/lib/sdk.ts b/lib/sdk.ts index ed91169f..1e20139c 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -52,10 +52,7 @@ class OptableSDK { if (this.dcn.reportMisconfiguration) { const status = err instanceof FetchError ? err.status : null; const key = status ? `error_${status}` : "error_config"; - Profile( - { ...this.dcn, site: "default-sdk" }, - { [key]: window.location.hostname } - ).catch(() => {}); + Profile({ ...this.dcn, site: "default-sdk" }, { [key]: window.location.hostname }).catch(() => {}); } }); } From 326699d1bd06af0fd8d149dccdfc4e33bdddad06 Mon Sep 17 00:00:00 2001 From: MO Thibault <103271673+MO-Thibault@users.noreply.github.com> Date: Sun, 10 May 2026 09:30:44 -0400 Subject: [PATCH 4/4] feat: allow reportMisconfiguration to override the target source slug Co-Authored-By: Claude Sonnet 4.6 (1M context) --- lib/config.ts | 7 +++--- lib/sdk.test.ts | 58 +++++++++++++++++++++++++++++++++++++++++++++++++ lib/sdk.ts | 4 +++- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/lib/config.ts b/lib/config.ts index 9937a34b..43682e85 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -76,9 +76,10 @@ type InitConfig = { // When true, a 'pageview' event is fired once after passport init with full page context. // Implies pageContext: true when no pageContext is explicitly configured. initContextual?: boolean; - // When true, detects 403/404 responses from the Site config call and fires a + // When enabled, detects 403/404 responses from the Site config call and fires a // best-effort profile() call with { error_403: hostname } or { error_404: hostname }. - reportMisconfiguration?: boolean; + // Pass true to use the "default-sdk" catch-all source, or { site: "slug" } to override the target source. + reportMisconfiguration?: boolean | { site?: string }; }; type ResolvedConfig = { @@ -100,7 +101,7 @@ type ResolvedConfig = { abTests?: ABTestConfig[]; additionalTargetingSignals?: TargetingSignals; timeout?: string; - reportMisconfiguration?: boolean; + reportMisconfiguration?: boolean | { site?: string }; }; const DCN_DEFAULTS = { diff --git a/lib/sdk.test.ts b/lib/sdk.test.ts index 90f5f319..73b39557 100644 --- a/lib/sdk.test.ts +++ b/lib/sdk.test.ts @@ -815,4 +815,62 @@ describe("reportMisconfiguration", () => { fetchSpy.mockRestore(); }); + + test("when reportMisconfiguration: { site } and Site() returns 403, profile is called with the custom site slug", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Forbidden" }, { status: 403 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: { site: "my-error-source" }, + }); + + await sdk["init"]; + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("profile"), + url: expect.stringContaining("o=my-error-source"), + _bodyText: expect.stringContaining('"error_403":"localhost"'), + }) + ); + }); + }); + + test("when reportMisconfiguration: {} and Site() returns 403, profile falls back to default-sdk", async () => { + const fetchSpy = jest.spyOn(window, "fetch"); + + server.use( + http.get(`${TEST_BASE_URL}/config`, async ({}) => { + return HttpResponse.json({ error: "Forbidden" }, { status: 403 }); + }) + ); + + const sdk = new OptableSDK({ + ...defaultConfig, + initPassport: true, + reportMisconfiguration: {}, + }); + + await sdk["init"]; + + await waitFor(() => { + expect(fetchSpy).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: expect.stringContaining("profile"), + url: expect.stringContaining("o=default-sdk"), + _bodyText: expect.stringContaining('"error_403":"localhost"'), + }) + ); + }); + }); }); diff --git a/lib/sdk.ts b/lib/sdk.ts index 1e20139c..da229004 100644 --- a/lib/sdk.ts +++ b/lib/sdk.ts @@ -52,7 +52,9 @@ class OptableSDK { if (this.dcn.reportMisconfiguration) { const status = err instanceof FetchError ? err.status : null; const key = status ? `error_${status}` : "error_config"; - Profile({ ...this.dcn, site: "default-sdk" }, { [key]: window.location.hostname }).catch(() => {}); + const cfg = this.dcn.reportMisconfiguration; + const reportSite = typeof cfg === "object" ? (cfg.site ?? "default-sdk") : "default-sdk"; + Profile({ ...this.dcn, site: reportSite }, { [key]: window.location.hostname }).catch(() => {}); } }); }