diff --git a/e2e/widget-builder.spec.ts b/e2e/widget-builder.spec.ts index 8f8efa6360..7077fae083 100644 --- a/e2e/widget-builder.spec.ts +++ b/e2e/widget-builder.spec.ts @@ -628,4 +628,33 @@ test.describe('AI widget builder — PRO tier', () => { await expect(modal).not.toBeVisible(); await expect(page.locator('#panelsGrid .ai-widget-block-pro')).toBeVisible(); }); + + test('health 403 in PRO mode shows widget key guidance instead of PRO key guidance', async ({ page }) => { + await page.route('**/widget-agent/health', async (route) => { + expect(route.request().headers()['x-widget-key']).toBe(widgetKey); + expect(route.request().headers()['x-pro-key']).toBe(proWidgetKey); + await route.fulfill({ + status: 403, + contentType: 'application/json', + body: JSON.stringify({ + ok: false, + agentEnabled: true, + widgetKeyConfigured: true, + anthropicConfigured: true, + proKeyConfigured: true, + error: 'Forbidden', + }), + }); + }); + + await page.goto('/'); + await expect(page.locator('#panelsGrid .ai-widget-block-pro')).toBeVisible({ timeout: 30000 }); + await page.locator('#panelsGrid .ai-widget-block-pro').click(); + + const modal = page.locator('.widget-chat-modal'); + await expect(modal).toBeVisible(); + await expect(modal.locator('.widget-chat-readiness')).toContainText('Widget key rejected', { timeout: 15000 }); + await expect(modal.locator('.widget-chat-readiness')).not.toContainText('PRO key rejected'); + await expect(modal.locator('.widget-chat-send')).toBeDisabled(); + }); }); diff --git a/scripts/ais-relay.cjs b/scripts/ais-relay.cjs index 8c97fce4e6..524a527c2c 100644 --- a/scripts/ais-relay.cjs +++ b/scripts/ais-relay.cjs @@ -9116,7 +9116,7 @@ function requireWidgetAgentAccess(req, res) { const hasValidWidgetKey = status.widgetKeyConfigured && providedKey && providedKey === WIDGET_AGENT_KEY; const hasValidProKey = status.proKeyConfigured && providedProKey && providedProKey === PRO_WIDGET_KEY; if (!hasValidWidgetKey && !hasValidProKey) { - safeEnd(res, 403, { 'Content-Type': 'application/json' }, JSON.stringify({ ...status, error: 'Forbidden' })); + safeEnd(res, 403, { 'Content-Type': 'application/json' }, JSON.stringify({ ...status, error: 'Forbidden', errorCode: 'invalid_widget_key' })); return null; } @@ -9194,7 +9194,7 @@ async function handleWidgetAgentRequest(req, res) { if (status.admittedAs !== 'pro') { const providedProKey = getWidgetAgentProvidedProKey(req); if (!providedProKey || providedProKey !== PRO_WIDGET_KEY) { - return safeEnd(res, 403, { 'Content-Type': 'application/json' }, JSON.stringify({ error: 'Forbidden' })); + return safeEnd(res, 403, { 'Content-Type': 'application/json' }, JSON.stringify({ error: 'Forbidden', errorCode: 'invalid_pro_key' })); } } } diff --git a/src/components/WidgetChatModal.ts b/src/components/WidgetChatModal.ts index 6dea9cfb3d..738a15cd6f 100644 --- a/src/components/WidgetChatModal.ts +++ b/src/components/WidgetChatModal.ts @@ -23,6 +23,7 @@ type WidgetAgentHealth = { widgetKeyConfigured?: boolean; anthropicConfigured?: boolean; proKeyConfigured?: boolean; + errorCode?: 'invalid_widget_key' | 'invalid_pro_key'; error?: string; }; @@ -161,11 +162,10 @@ export function openWidgetChatModal(options: WidgetChatOptions): void { try { const headers = await buildWidgetAuthHeaders(isPro); const res = await fetch(widgetAgentHealthUrl(), { headers }); - let payload: WidgetAgentHealth | null = null; - try { payload = await res.json() as WidgetAgentHealth; } catch { /* ignore */ } + const payload = await parseWidgetAgentJson(res); if (!res.ok) { - const message = resolvePreflightMessage(res.status, payload, isPro); + const message = resolvePreflightMessage(res.status, payload); preflightReady = false; setReadinessState(readinessEl, 'error', message); setFooterStatus(footerStatusEl, message, 'error'); @@ -253,7 +253,8 @@ export function openWidgetChatModal(options: WidgetChatOptions): void { }); if (!res.ok || !res.body) { - throw new Error(t('widgets.serverError', { status: res.status })); + const payload = await parseWidgetAgentJson(res); + throw new Error(resolveRequestErrorMessage(res.status, payload)); } let resultHtml = ''; @@ -386,13 +387,37 @@ function renderExampleChips(container: HTMLElement, inputEl: HTMLTextAreaElement } } -function resolvePreflightMessage(status: number, payload: WidgetAgentHealth | null, isPro: boolean): string { - if (status === 403) return isPro ? t('widgets.preflightInvalidProKey') : t('widgets.preflightInvalidKey'); +async function parseWidgetAgentJson(res: Response): Promise { + try { + return await res.json() as WidgetAgentHealth; + } catch { + return null; + } +} + +function resolveWidgetAgentFailureMessage(status: number, payload: WidgetAgentHealth | null): string | null { + if (status === 403) { + return payload?.errorCode === 'invalid_pro_key' + ? t('widgets.preflightInvalidProKey') + : t('widgets.preflightInvalidKey'); + } if (status === 503 && payload?.proKeyConfigured === false) return t('widgets.preflightProUnavailable'); if (payload?.anthropicConfigured === false) return t('widgets.preflightAiUnavailable'); + return null; +} + +function resolvePreflightMessage(status: number, payload: WidgetAgentHealth | null): string { + const message = resolveWidgetAgentFailureMessage(status, payload); + if (message) return message; return t('widgets.preflightUnavailable'); } +function resolveRequestErrorMessage(status: number, payload: WidgetAgentHealth | null): string { + const message = resolveWidgetAgentFailureMessage(status, payload); + if (message) return message; + return t('widgets.serverError', { status }); +} + function setReadinessState(container: HTMLElement, tone: 'checking' | 'ready' | 'error', text: string): void { container.className = `widget-chat-readiness is-${tone}`; container.textContent = text; diff --git a/tests/widget-builder.test.mjs b/tests/widget-builder.test.mjs index 1d88433bb7..efa5081eec 100644 --- a/tests/widget-builder.test.mjs +++ b/tests/widget-builder.test.mjs @@ -80,6 +80,13 @@ describe('widget-agent relay — security', () => { assert.ok(authCheckIdx < sseHeaderIdx, 'Auth check must come before SSE headers'); }); + it('widget-key auth 403 includes invalid_widget_key error code', () => { + assert.ok( + relay.includes("errorCode: 'invalid_widget_key'") || relay.includes('errorCode: "invalid_widget_key"'), + 'Widget-key 403 responses must identify the invalid widget key cause', + ); + }); + it('body size limit is enforced (160KB for PRO, covers basic too)', () => { assert.ok( relay.includes('163840'), @@ -939,6 +946,7 @@ describe('PRO widget — relay auth and configuration', () => { assert.ok(keyCompareIdx !== -1, 'PRO key comparison must be present'); const region = relay.slice(keyCompareIdx, keyCompareIdx + 200); assert.ok(region.includes('403'), 'Wrong PRO key must return 403'); + assert.ok(region.includes('invalid_pro_key'), 'Wrong PRO key must return an invalid_pro_key error code'); }); it('invalid tier value rejected with 400', () => { @@ -1200,6 +1208,36 @@ describe('PRO widget — modal and layout integration', () => { ); }); + it('modal treats preflight 403 as a widget key failure even in PRO mode', () => { + const resolverIdx = modal.indexOf('function resolveWidgetAgentFailureMessage'); + assert.ok(resolverIdx !== -1, 'Modal must define resolveWidgetAgentFailureMessage'); + const resolverRegion = modal.slice(resolverIdx, resolverIdx + 500); + assert.ok( + resolverRegion.includes("status === 403") && resolverRegion.includes("preflightInvalidKey"), + 'Preflight 403 must map to the widget key guidance', + ); + }); + + it('modal maps invalid_pro_key failures to PRO key guidance', () => { + const resolverIdx = modal.indexOf('function resolveWidgetAgentFailureMessage'); + assert.ok(resolverIdx !== -1, 'Modal must define resolveWidgetAgentFailureMessage'); + const resolverRegion = modal.slice(resolverIdx, resolverIdx + 500); + assert.ok( + resolverRegion.includes("invalid_pro_key") && resolverRegion.includes('preflightInvalidProKey'), + 'Modal must surface PRO key guidance when the relay reports invalid_pro_key', + ); + }); + + it('modal parses JSON request errors before falling back to generic serverError', () => { + const submitErrorIdx = modal.indexOf('if (!res.ok || !res.body)'); + assert.ok(submitErrorIdx !== -1, 'Modal must check non-OK widget-agent responses'); + const submitErrorRegion = modal.slice(submitErrorIdx, submitErrorIdx + 250); + assert.ok( + submitErrorRegion.includes('parseWidgetAgentJson') && submitErrorRegion.includes('resolveRequestErrorMessage'), + 'Modal must inspect JSON error payloads before falling back to generic server errors', + ); + }); + it('pendingSaveSpec includes tier field', () => { assert.ok( modal.includes('pendingSaveSpec'),