Skip to content

Commit 484d613

Browse files
committed
fix(widget-modal): disambiguate widget and pro key auth errors
1 parent 550583e commit 484d613

4 files changed

Lines changed: 100 additions & 8 deletions

File tree

e2e/widget-builder.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -628,4 +628,33 @@ test.describe('AI widget builder — PRO tier', () => {
628628
await expect(modal).not.toBeVisible();
629629
await expect(page.locator('#panelsGrid .ai-widget-block-pro')).toBeVisible();
630630
});
631+
632+
test('health 403 in PRO mode shows widget key guidance instead of PRO key guidance', async ({ page }) => {
633+
await page.route('**/widget-agent/health', async (route) => {
634+
expect(route.request().headers()['x-widget-key']).toBe(widgetKey);
635+
expect(route.request().headers()['x-pro-key']).toBe(proWidgetKey);
636+
await route.fulfill({
637+
status: 403,
638+
contentType: 'application/json',
639+
body: JSON.stringify({
640+
ok: false,
641+
agentEnabled: true,
642+
widgetKeyConfigured: true,
643+
anthropicConfigured: true,
644+
proKeyConfigured: true,
645+
error: 'Forbidden',
646+
}),
647+
});
648+
});
649+
650+
await page.goto('/');
651+
await expect(page.locator('#panelsGrid .ai-widget-block-pro')).toBeVisible({ timeout: 30000 });
652+
await page.locator('#panelsGrid .ai-widget-block-pro').click();
653+
654+
const modal = page.locator('.widget-chat-modal');
655+
await expect(modal).toBeVisible();
656+
await expect(modal.locator('.widget-chat-readiness')).toContainText('Widget key rejected', { timeout: 15000 });
657+
await expect(modal.locator('.widget-chat-readiness')).not.toContainText('PRO key rejected');
658+
await expect(modal.locator('.widget-chat-send')).toBeDisabled();
659+
});
631660
});

scripts/ais-relay.cjs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8315,7 +8315,7 @@ function requireWidgetAgentAccess(req, res) {
83158315

83168316
const providedKey = getWidgetAgentProvidedKey(req);
83178317
if (!providedKey || providedKey !== WIDGET_AGENT_KEY) {
8318-
safeEnd(res, 403, { 'Content-Type': 'application/json' }, JSON.stringify({ ...status, error: 'Forbidden' }));
8318+
safeEnd(res, 403, { 'Content-Type': 'application/json' }, JSON.stringify({ ...status, error: 'Forbidden', errorCode: 'invalid_widget_key' }));
83198319
return null;
83208320
}
83218321

@@ -8390,7 +8390,7 @@ async function handleWidgetAgentRequest(req, res) {
83908390
}
83918391
const providedProKey = getWidgetAgentProvidedProKey(req);
83928392
if (!providedProKey || providedProKey !== PRO_WIDGET_KEY) {
8393-
return safeEnd(res, 403, { 'Content-Type': 'application/json' }, JSON.stringify({ error: 'Forbidden' }));
8393+
return safeEnd(res, 403, { 'Content-Type': 'application/json' }, JSON.stringify({ error: 'Forbidden', errorCode: 'invalid_pro_key' }));
83948394
}
83958395
}
83968396

src/components/WidgetChatModal.ts

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ type WidgetAgentHealth = {
2020
widgetKeyConfigured?: boolean;
2121
anthropicConfigured?: boolean;
2222
proKeyConfigured?: boolean;
23+
errorCode?: 'invalid_widget_key' | 'invalid_pro_key';
2324
error?: string;
2425
};
2526

@@ -141,11 +142,10 @@ export function openWidgetChatModal(options: WidgetChatOptions): void {
141142
const headers: Record<string, string> = { 'X-Widget-Key': getWidgetAgentKey() };
142143
if (isPro) headers['X-Pro-Key'] = getProWidgetKey();
143144
const res = await fetch(widgetAgentHealthUrl(), { headers });
144-
let payload: WidgetAgentHealth | null = null;
145-
try { payload = await res.json() as WidgetAgentHealth; } catch { /* ignore */ }
145+
const payload = await parseWidgetAgentJson(res);
146146

147147
if (!res.ok) {
148-
const message = resolvePreflightMessage(res.status, payload, isPro);
148+
const message = resolvePreflightMessage(res.status, payload);
149149
preflightReady = false;
150150
setReadinessState(readinessEl, 'error', message);
151151
setFooterStatus(footerStatusEl, message, 'error');
@@ -234,7 +234,8 @@ export function openWidgetChatModal(options: WidgetChatOptions): void {
234234
});
235235

236236
if (!res.ok || !res.body) {
237-
throw new Error(t('widgets.serverError', { status: res.status }));
237+
const payload = await parseWidgetAgentJson(res);
238+
throw new Error(resolveRequestErrorMessage(res.status, payload));
238239
}
239240

240241
let resultHtml = '';
@@ -367,13 +368,37 @@ function renderExampleChips(container: HTMLElement, inputEl: HTMLTextAreaElement
367368
}
368369
}
369370

370-
function resolvePreflightMessage(status: number, payload: WidgetAgentHealth | null, isPro: boolean): string {
371-
if (status === 403) return isPro ? t('widgets.preflightInvalidProKey') : t('widgets.preflightInvalidKey');
371+
async function parseWidgetAgentJson(res: Response): Promise<WidgetAgentHealth | null> {
372+
try {
373+
return await res.json() as WidgetAgentHealth;
374+
} catch {
375+
return null;
376+
}
377+
}
378+
379+
function resolveWidgetAgentFailureMessage(status: number, payload: WidgetAgentHealth | null): string | null {
380+
if (status === 403) {
381+
return payload?.errorCode === 'invalid_pro_key'
382+
? t('widgets.preflightInvalidProKey')
383+
: t('widgets.preflightInvalidKey');
384+
}
372385
if (status === 503 && payload?.proKeyConfigured === false) return t('widgets.preflightProUnavailable');
373386
if (payload?.anthropicConfigured === false) return t('widgets.preflightAiUnavailable');
387+
return null;
388+
}
389+
390+
function resolvePreflightMessage(status: number, payload: WidgetAgentHealth | null): string {
391+
const message = resolveWidgetAgentFailureMessage(status, payload);
392+
if (message) return message;
374393
return t('widgets.preflightUnavailable');
375394
}
376395

396+
function resolveRequestErrorMessage(status: number, payload: WidgetAgentHealth | null): string {
397+
const message = resolveWidgetAgentFailureMessage(status, payload);
398+
if (message) return message;
399+
return t('widgets.serverError', { status });
400+
}
401+
377402
function setReadinessState(container: HTMLElement, tone: 'checking' | 'ready' | 'error', text: string): void {
378403
container.className = `widget-chat-readiness is-${tone}`;
379404
container.textContent = text;

tests/widget-builder.test.mjs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ describe('widget-agent relay — security', () => {
8080
assert.ok(authCheckIdx < sseHeaderIdx, 'Auth check must come before SSE headers');
8181
});
8282

83+
it('widget-key auth 403 includes invalid_widget_key error code', () => {
84+
assert.ok(
85+
relay.includes("errorCode: 'invalid_widget_key'") || relay.includes('errorCode: "invalid_widget_key"'),
86+
'Widget-key 403 responses must identify the invalid widget key cause',
87+
);
88+
});
89+
8390
it('body size limit is enforced (160KB for PRO, covers basic too)', () => {
8491
assert.ok(
8592
relay.includes('163840'),
@@ -937,6 +944,7 @@ describe('PRO widget — relay auth and configuration', () => {
937944
assert.ok(keyCompareIdx !== -1, 'PRO key comparison must be present');
938945
const region = relay.slice(keyCompareIdx, keyCompareIdx + 200);
939946
assert.ok(region.includes('403'), 'Wrong PRO key must return 403');
947+
assert.ok(region.includes('invalid_pro_key'), 'Wrong PRO key must return an invalid_pro_key error code');
940948
});
941949

942950
it('invalid tier value rejected with 400', () => {
@@ -1184,6 +1192,36 @@ describe('PRO widget — modal and layout integration', () => {
11841192
);
11851193
});
11861194

1195+
it('modal treats preflight 403 as a widget key failure even in PRO mode', () => {
1196+
const resolverIdx = modal.indexOf('function resolveWidgetAgentFailureMessage');
1197+
assert.ok(resolverIdx !== -1, 'Modal must define resolveWidgetAgentFailureMessage');
1198+
const resolverRegion = modal.slice(resolverIdx, resolverIdx + 500);
1199+
assert.ok(
1200+
resolverRegion.includes("status === 403") && resolverRegion.includes("preflightInvalidKey"),
1201+
'Preflight 403 must map to the widget key guidance',
1202+
);
1203+
});
1204+
1205+
it('modal maps invalid_pro_key failures to PRO key guidance', () => {
1206+
const resolverIdx = modal.indexOf('function resolveWidgetAgentFailureMessage');
1207+
assert.ok(resolverIdx !== -1, 'Modal must define resolveWidgetAgentFailureMessage');
1208+
const resolverRegion = modal.slice(resolverIdx, resolverIdx + 500);
1209+
assert.ok(
1210+
resolverRegion.includes("invalid_pro_key") && resolverRegion.includes('preflightInvalidProKey'),
1211+
'Modal must surface PRO key guidance when the relay reports invalid_pro_key',
1212+
);
1213+
});
1214+
1215+
it('modal parses JSON request errors before falling back to generic serverError', () => {
1216+
const submitErrorIdx = modal.indexOf('if (!res.ok || !res.body)');
1217+
assert.ok(submitErrorIdx !== -1, 'Modal must check non-OK widget-agent responses');
1218+
const submitErrorRegion = modal.slice(submitErrorIdx, submitErrorIdx + 250);
1219+
assert.ok(
1220+
submitErrorRegion.includes('parseWidgetAgentJson') && submitErrorRegion.includes('resolveRequestErrorMessage'),
1221+
'Modal must inspect JSON error payloads before falling back to generic server errors',
1222+
);
1223+
});
1224+
11871225
it('pendingSaveSpec includes tier field', () => {
11881226
assert.ok(
11891227
modal.includes('pendingSaveSpec'),

0 commit comments

Comments
 (0)