diff --git a/src/services/premium-fetch.ts b/src/services/premium-fetch.ts index 64f944e9c..1b7ff85d8 100644 --- a/src/services/premium-fetch.ts +++ b/src/services/premium-fetch.ts @@ -12,6 +12,7 @@ */ let _testProviders: { getTesterKey?: () => string; + getTesterKeys?: () => string[]; getClerkToken?: () => Promise; } | null = null; @@ -21,6 +22,36 @@ export function _setTestProviders( _testProviders = p; } +function uniqueNonEmptyKeys(keys: Array): string[] { + const seen = new Set(); + const result: string[] = []; + for (const raw of keys) { + const key = raw?.trim(); + if (!key || seen.has(key)) continue; + seen.add(key); + result.push(key); + } + return result; +} + +async function loadTesterKeys(): Promise { + try { + if (_testProviders?.getTesterKeys) { + return uniqueNonEmptyKeys(_testProviders.getTesterKeys()); + } + if (_testProviders?.getTesterKey) { + return uniqueNonEmptyKeys([_testProviders.getTesterKey()]); + } + const { getProWidgetKey, getWidgetAgentKey } = await import('@/services/widget-store'); + return uniqueNonEmptyKeys([ + getProWidgetKey(), + getWidgetAgentKey(), + ]); + } catch { + return []; + } +} + export async function premiumFetch( input: RequestInfo | URL, init?: RequestInit, @@ -41,32 +72,22 @@ export async function premiumFetch( } } catch { /* not available — fall through */ } - // 2. Tester / widget key from localStorage (wm-pro-key or wm-widget-key). + // 2. Tester / widget keys from localStorage. // Must run BEFORE Clerk to prevent a free Clerk session from intercepting the // request and returning 403 before the tester key is ever checked. - // If the gateway returns 401 (key not in WORLDMONITOR_VALID_KEYS), fall through - // to Clerk JWT rather than surfacing the error — widget relay keys and gateway - // API keys can be different sets. - let testerKey: string | null = null; - try { - if (_testProviders?.getTesterKey) { - testerKey = _testProviders.getTesterKey(); - } else { - const { getProWidgetKey, getWidgetAgentKey } = await import('@/services/widget-store'); - testerKey = getProWidgetKey() || getWidgetAgentKey(); - } - } catch { /* widget-store not available — fall through */ } - - if (testerKey) { + // Try wm-pro-key first, then wm-widget-key. A relay-only pro key can be invalid + // for the gateway even when the widget key is valid for premium RPC access. + const testerKeys = await loadTesterKeys(); + for (const testerKey of testerKeys) { const testerHeaders = new Headers(existing); testerHeaders.set('X-WorldMonitor-Key', testerKey); const res = await globalThis.fetch(input, { ...init, headers: testerHeaders }); if (res.status !== 401) return res; - // 401 → tester key not in WORLDMONITOR_VALID_KEYS; fall through to Clerk. + // 401 → try the next tester key, then fall through to Clerk if none work. } - // 3. Clerk Pro session token (fallback for users without a tester key, or when - // the tester key is not in WORLDMONITOR_VALID_KEYS). + // 3. Clerk Pro session token (fallback for users without tester keys, or when + // none of the tester keys are in WORLDMONITOR_VALID_KEYS). try { let token: string | null = null; if (_testProviders?.getClerkToken) { diff --git a/tests/premium-fetch.test.mts b/tests/premium-fetch.test.mts index 5fe669455..ce78526b6 100644 --- a/tests/premium-fetch.test.mts +++ b/tests/premium-fetch.test.mts @@ -5,10 +5,11 @@ * - Passthrough when caller already sets auth header * - Tester key: valid key → returns response immediately (no second fetch) * - Tester key: 401 → falls through to Clerk JWT + * - wm-pro-key 401 → retries with wm-widget-key before Clerk * - Tester key: non-401 returned immediately (no fallback) * - Tester key: network error / AbortError propagates to caller (not swallowed) * - No keys, no Clerk → unauthenticated request forwarded - * - wm-widget-key / wm-pro-key precedence + * - wm-pro-key / wm-widget-key order is deterministic and deduped */ import assert from 'node:assert/strict'; @@ -47,11 +48,12 @@ describe('premiumFetch', () => { function setup(opts: { testerKey?: string; + testerKeys?: string[]; clerkToken?: string | null; fetchImpl?: () => Promise; } = {}) { _setTestProviders({ - getTesterKey: () => opts.testerKey ?? '', + getTesterKeys: () => opts.testerKeys ?? (opts.testerKey ? [opts.testerKey] : []), getClerkToken: async () => opts.clerkToken ?? null, }); fetchMock.mock.resetCalls(); @@ -100,6 +102,23 @@ describe('premiumFetch', () => { assert.equal(sentHeaders(1).get('X-WorldMonitor-Key'), null); }); + it('wm-pro-key 401 retries with wm-widget-key before Clerk', async () => { + let n = 0; + setup({ + testerKeys: ['relay-only-pro-key', 'valid-widget-key'], + clerkToken: 'clerk-jwt-should-not-be-used', + fetchImpl: () => Promise.resolve(fakeRes(n++ === 0 ? 401 : 200)), + }); + + const res = await premiumFetch(TARGET); + assert.equal(res.status, 200); + assert.equal(fetchMock.mock.calls.length, 2, 'Expected pro-key attempt then widget-key retry'); + assert.equal(sentHeaders(0).get('X-WorldMonitor-Key'), 'relay-only-pro-key'); + assert.equal(sentHeaders(0).get('Authorization'), null); + assert.equal(sentHeaders(1).get('X-WorldMonitor-Key'), 'valid-widget-key'); + assert.equal(sentHeaders(1).get('Authorization'), null); + }); + it('tester key: 403 returned immediately, no Clerk fallback', async () => { setup({ testerKey: 'widget-only-key', clerkToken: 'clerk-jwt' }); fetchMock.mock.mockImplementation(() => Promise.resolve(fakeRes(403)));