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
29 changes: 29 additions & 0 deletions e2e/widget-builder.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
4 changes: 2 additions & 2 deletions scripts/ais-relay.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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' }));
}
}
}
Expand Down
37 changes: 31 additions & 6 deletions src/components/WidgetChatModal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ type WidgetAgentHealth = {
widgetKeyConfigured?: boolean;
anthropicConfigured?: boolean;
proKeyConfigured?: boolean;
errorCode?: 'invalid_widget_key' | 'invalid_pro_key';
error?: string;
};

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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<WidgetAgentHealth | null> {
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;
Expand Down
38 changes: 38 additions & 0 deletions tests/widget-builder.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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'),
Expand Down
Loading