From bc83804d51b5386f7b68f49514269567852af4ab Mon Sep 17 00:00:00 2001 From: Rahul Chhabria Date: Sun, 18 Oct 2026 20:24:09 -0700 Subject: [PATCH 1/6] feat(docs): Add a cookie-free Fin Ask AI pilot Preserve Ask AI entry points with a first-party streaming dialog and server-only Fin calls. Add support handoff, durable feedback events, pilot limits, tests, and the access checklist. --- .env.example | 25 +- app/api/ask-ai/events/route.spec.ts | 53 +++ app/api/ask-ai/events/route.ts | 45 +++ app/api/ask-ai/route.spec.ts | 263 +++++++++++++ app/api/ask-ai/route.ts | 276 ++++++++++++++ app/api/ask-ai/webhook/route.spec.ts | 68 ++++ app/api/ask-ai/webhook/route.ts | 53 +++ app/layout.tsx | 48 ++- specs/fin-ask-ai-pilot.md | 101 +++++ src/components/askAi/answer.tsx | 53 +++ src/components/askAi/dialog.module.scss | 284 ++++++++++++++ src/components/askAi/dialog.tsx | 471 ++++++++++++++++++++++++ src/components/askAi/messages.spec.ts | 58 +++ src/components/askAi/messages.ts | 50 +++ src/components/askAi/provider.tsx | 59 +++ src/components/askAiSearchParams.tsx | 11 +- src/components/header.tsx | 12 +- src/components/search/index.tsx | 29 +- src/lib/askAi/fin.spec.ts | 112 ++++++ src/lib/askAi/fin.ts | 116 ++++++ src/lib/askAi/http.ts | 79 ++++ src/lib/askAi/store.ts | 153 ++++++++ src/lib/askAi/types.ts | 50 +++ 23 files changed, 2430 insertions(+), 39 deletions(-) create mode 100644 app/api/ask-ai/events/route.spec.ts create mode 100644 app/api/ask-ai/events/route.ts create mode 100644 app/api/ask-ai/route.spec.ts create mode 100644 app/api/ask-ai/route.ts create mode 100644 app/api/ask-ai/webhook/route.spec.ts create mode 100644 app/api/ask-ai/webhook/route.ts create mode 100644 specs/fin-ask-ai-pilot.md create mode 100644 src/components/askAi/answer.tsx create mode 100644 src/components/askAi/dialog.module.scss create mode 100644 src/components/askAi/dialog.tsx create mode 100644 src/components/askAi/messages.spec.ts create mode 100644 src/components/askAi/messages.ts create mode 100644 src/components/askAi/provider.tsx create mode 100644 src/lib/askAi/fin.spec.ts create mode 100644 src/lib/askAi/fin.ts create mode 100644 src/lib/askAi/http.ts create mode 100644 src/lib/askAi/store.ts create mode 100644 src/lib/askAi/types.ts diff --git a/.env.example b/.env.example index 0c940ba50d2545..c4eb7119b7b430 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,27 @@ # event is rejected with a 401. NEXT_PUBLIC_ALGOLIA_APP_ID=OOK48W9UCL NEXT_PUBLIC_ALGOLIA_SEARCH_KEY=2d64ec1106519cbc672d863b0d200782 -NEXT_PUBLIC_SENTRY_DSN= \ No newline at end of file +NEXT_PUBLIC_SENTRY_DSN= + +# Fin Ask AI pilot. All values below are server-only; never add NEXT_PUBLIC_. +# Kapa remains the default until the deployment is deliberately switched. +ASK_AI_PROVIDER=kapa +FIN_PILOT_ENABLED=false +FIN_PUBLIC_ORIGIN=http://localhost:3000 +FIN_AGENT_API_KEY= +FIN_REGION=us +# Optional Fin for Platforms environment ID (not a Messenger installation). +FIN_ENVIRONMENT= +# Enable SSE + streaming in Fin, then verify its exact regional hostname. +FIN_SSE_HOSTS=primary-realtime.intercom-messenger.com +# Dedicated REST-compatible Redis database (e.g. Upstash); no browser access. +FIN_REDIS_REST_URL= +FIN_REDIS_REST_TOKEN= +FIN_DAILY_TURN_LIMIT=100 +# Fin Agent API webhook secret, distinct from a standard Intercom app secret. +FIN_WEBHOOK_SECRET= +# Random 32+ character bearer token for the internal event export endpoint. +FIN_EVENTS_READ_TOKEN= +# Set only after testing the actual support handoff and follow-up workflow. +FIN_HANDOFF_ENABLED=false +ASK_AI_SUPPORT_URL= diff --git a/app/api/ask-ai/events/route.spec.ts b/app/api/ask-ai/events/route.spec.ts new file mode 100644 index 00000000000000..20bb655aabf331 --- /dev/null +++ b/app/api/ask-ai/events/route.spec.ts @@ -0,0 +1,53 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +vi.mock('server-only', () => ({})); +const redis = vi.hoisted(() => vi.fn()); +vi.mock('sentry-docs/lib/askAi/store', async importOriginal => ({ + ...(await importOriginal()), + redis, +})); + +import {GET} from './route'; + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('FIN_EVENTS_READ_TOKEN', 'r'.repeat(32)); +}); + +describe('internal agent event export', () => { + it('requires the separate internal token, not an anonymous conversation token', async () => { + const response = await GET( + new Request('https://docs.example/api/ask-ai/events/', { + headers: {Authorization: `Bearer ${'a'.repeat(64)}`}, + }) + ); + expect(response.status).toBe(401); + expect(redis).not.toHaveBeenCalled(); + }); + + it('returns an exclusive cursor page without exposing storage credentials', async () => { + redis.mockResolvedValueOnce([ + ['123-2', ['event', JSON.stringify({id: 'event-1', type: 'feedback'})]], + ]); + const response = await GET( + new Request( + 'https://docs.example/api/ask-ai/events/?date=2026-10-19&cursor=123-1', + {headers: {Authorization: `Bearer ${'r'.repeat(32)}`}} + ) + ); + expect(redis).toHaveBeenCalledWith( + 'XRANGE', + 'docs-fin-pilot:events:2026-10-19', + '(123-1', + '+', + 'COUNT', + 100 + ); + expect(await response.json()).toEqual({ + date: '2026-10-19', + events: [{cursor: '123-2', id: 'event-1', type: 'feedback'}], + nextCursor: '123-2', + }); + expect(response.headers.get('cache-control')).toContain('no-store'); + }); +}); diff --git a/app/api/ask-ai/events/route.ts b/app/api/ask-ai/events/route.ts new file mode 100644 index 00000000000000..aa28cfa40a370f --- /dev/null +++ b/app/api/ask-ai/events/route.ts @@ -0,0 +1,45 @@ +import {timingSafeEqual} from 'node:crypto'; + +import {bearerToken, errorResponse, privateHeaders} from 'sentry-docs/lib/askAi/http'; +import {AskAiError, key, redis} from 'sentry-docs/lib/askAi/store'; + +export const runtime = 'nodejs'; + +// Internal agent pulls an append-only page, persists its cursor only after +// processing, and deduplicates by conversationId + event.id. +export async function GET(request: Request) { + try { + const expected = process.env.FIN_EVENTS_READ_TOKEN; + const actual = bearerToken(request); + if ( + !expected || + expected.length < 32 || + Buffer.byteLength(actual) !== Buffer.byteLength(expected) || + !timingSafeEqual(Buffer.from(actual), Buffer.from(expected)) + ) + throw new AskAiError(401, 'Unauthorized.'); + const params = new URL(request.url).searchParams; + const date = params.get('date') || new Date().toISOString().slice(0, 10); + const cursor = params.get('cursor'); + if (!/^\d{4}-\d{2}-\d{2}$/.test(date) || (cursor && !/^\d+-\d+$/.test(cursor))) + throw new AskAiError(400, 'Invalid date or cursor.'); + const entries = await redis<[string, string[]][]>( + 'XRANGE', + key(`events:${date}`), + cursor ? `(${cursor}` : '-', + '+', + 'COUNT', + 100 + ); + return Response.json( + { + date, + events: entries.map(([id, fields]) => ({cursor: id, ...JSON.parse(fields[1])})), + nextCursor: entries.at(-1)?.[0] ?? cursor, + }, + {headers: privateHeaders} + ); + } catch (error) { + return errorResponse(error); + } +} diff --git a/app/api/ask-ai/route.spec.ts b/app/api/ask-ai/route.spec.ts new file mode 100644 index 00000000000000..9730a5a646c4f8 --- /dev/null +++ b/app/api/ask-ai/route.spec.ts @@ -0,0 +1,263 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +vi.mock('server-only', () => ({})); + +const store = vi.hoisted(() => ({ + session: { + conversationId: 'docs-test', + userId: 'docs-anonymous-test', + path: '/platforms/javascript/', + state: 'new', + turns: 0, + intercomId: undefined as string | undefined, + handedOff: false, + }, + release: vi.fn(), + record: vi.fn(), + redis: vi.fn(), + budget: vi.fn(), +})); + +vi.mock('sentry-docs/lib/askAi/store', async importOriginal => { + const original = await importOriginal(); + return { + ...original, + createSession: vi.fn(() => + Promise.resolve({token: 'a'.repeat(64), session: store.session}) + ), + getSession: vi.fn(() => Promise.resolve(store.session)), + saveSession: vi.fn((_token: string, session: typeof store.session) => { + store.session = session; + return Promise.resolve(); + }), + lockSession: vi.fn(() => Promise.resolve(store.release)), + recordEvent: store.record, + redis: store.redis, + takeBudget: store.budget, + }; +}); + +import {POST} from './route'; + +function request(body: object, origin = 'https://docs.example') { + return new Request('https://docs.example/api/ask-ai/', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: origin, + Authorization: `Bearer ${'a'.repeat(64)}`, + }, + body: JSON.stringify(body), + }); +} + +function finStream(events: object[]) { + return new Response( + events + .map( + event => `data: ${JSON.stringify({conversation_id: 'docs-test', ...event})}\n\n` + ) + .join(''), + {headers: {'Content-Type': 'text/event-stream'}} + ); +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.clearAllMocks(); + vi.stubEnv('ASK_AI_PROVIDER', 'fin'); + vi.stubEnv('FIN_PILOT_ENABLED', 'true'); + vi.stubEnv('FIN_PUBLIC_ORIGIN', 'https://docs.example'); + vi.stubEnv('FIN_AGENT_API_KEY', 'private-fin-key'); + vi.stubEnv('FIN_HANDOFF_ENABLED', 'false'); + vi.stubEnv('FIN_REGION', 'us'); + store.session = { + conversationId: 'docs-test', + userId: 'docs-anonymous-test', + path: '/platforms/javascript/', + state: 'new', + turns: 0, + intercomId: undefined, + handedOff: false, + }; + store.redis.mockResolvedValue('OK'); + store.release.mockResolvedValue(undefined); + store.budget.mockResolvedValue(undefined); +}); + +describe('first-party Fin proxy', () => { + it('streams a multi-part answer, preserves the Intercom join key, and waits for awaiting_user_reply', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + Response.json({ + intercom_conversation_id: 'intercom-123', + sse_subscription_url: + 'https://primary-realtime.intercom-messenger.com/event-stream?accessToken=never-send-to-browser', + }) + ) + .mockResolvedValueOnce( + finStream([ + { + event_name: 'fin_reply_chunk', + stream_id: 's1', + chunk_index: 0, + chunk_text: 'Setup', + }, + { + event_name: 'fin_replied', + stream_id: 's1', + status: 'replying', + message: { + id: 'm1', + body: '

Setup [1]

', + sources: [{title: 'Docs', url: 'https://docs.sentry.io/'}], + }, + }, + { + event_name: 'fin_replied', + status: 'replying', + message: {id: 'm2', body: '

Next step

'}, + }, + { + event_name: 'fin_status_updated', + status: 'awaiting_user_reply', + created_at_ms: '2026-10-19T10:00:00.000Z', + }, + ]) + ); + const response = await POST( + request({action: 'message', question: 'How do I set up Sentry?'}) + ); + const body = await response.text(); + expect(response.status).toBe(200); + expect(response.headers.get('set-cookie')).toBeNull(); + expect(response.headers.get('cache-control')).toContain('no-store'); + expect(body).toContain('intercom-123'); + expect(body).toContain('Next step'); + expect(body).not.toContain('never-send-to-browser'); + expect(body).not.toContain('private-fin-key'); + expect(store.session.state).toBe('ready'); + expect(store.record).toHaveBeenCalledWith('docs-test', 'linked', { + intercomConversationId: 'intercom-123', + metadataAccepted: true, + }); + const call = JSON.parse(fetchMock.mock.calls[0][1]?.body as string); + expect(call.user).toEqual({id: 'docs-anonymous-test'}); + expect(call.conversation_metadata.attributes).toEqual({ + docs_origin: 'docs_ask_ai', + docs_path: '/platforms/javascript/', + }); + expect(fetchMock.mock.calls[1][1]?.headers).toBeUndefined(); + expect(store.release).toHaveBeenCalledOnce(); + }); + + it('continues the same conversation on follow-up', async () => { + store.session.state = 'ready'; + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + Response.json({ + sse_subscription_url: + 'https://primary-realtime.intercom-messenger.com/event-stream', + }) + ) + .mockResolvedValueOnce( + finStream([{event_name: 'fin_status_updated', status: 'awaiting_user_reply'}]) + ); + const response = await POST( + request({action: 'message', question: 'And for Python?'}) + ); + await response.text(); + expect(fetchMock.mock.calls[0][0]).toBe('https://api.intercom.io/fin/reply'); + expect(JSON.parse(fetchMock.mock.calls[0][1]?.body as string).conversation_id).toBe( + 'docs-test' + ); + }); + + it('fails closed when streaming access is missing and does not retry Fin', async () => { + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(Response.json({intercom_conversation_id: '123'})); + expect((await POST(request({action: 'message', question: 'Hello'}))).status).toBe( + 503 + ); + expect(store.session.state).toBe('failed'); + expect((await POST(request({action: 'message', question: 'Hello'}))).status).toBe( + 409 + ); + expect(fetchMock).toHaveBeenCalledOnce(); + }); + + it('shows an explicit interruption if the stream ends before the terminal event', async () => { + vi.spyOn(globalThis, 'fetch') + .mockResolvedValueOnce( + Response.json({ + sse_subscription_url: + 'https://primary-realtime.intercom-messenger.com/event-stream', + }) + ) + .mockResolvedValueOnce( + finStream([ + {event_name: 'fin_replied', message: {id: 'm1', body: '

Partial

'}}, + ]) + ); + const response = await POST(request({action: 'message', question: 'Hello'})); + expect(await response.text()).toContain('interrupted'); + expect(store.session.state).toBe('failed'); + }); + + it('rejects cross-origin requests, oversized questions, and a disabled pilot before contacting Fin', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch'); + expect( + (await POST(request({action: 'session'}, 'https://evil.example'))).status + ).toBe(403); + expect( + (await POST(request({action: 'message', question: 'x'.repeat(4001)}))).status + ).toBe(400); + vi.stubEnv('FIN_PILOT_ENABLED', 'false'); + expect((await POST(request({action: 'session'}))).status).toBe(503); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('never sends a query after the budget is exhausted or storage fails', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch'); + store.budget.mockRejectedValueOnce(new Error('Store unavailable')); + expect((await POST(request({action: 'message', question: 'Hello'}))).status).toBe( + 503 + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects feedback for another conversation', async () => { + store.redis.mockResolvedValueOnce(null); + expect( + (await POST(request({action: 'feedback', messageId: 'other', rating: 'helpful'}))) + .status + ).toBe(400); + expect(store.record).not.toHaveBeenCalled(); + }); + + it('only hands off when configured and retains both conversation references', async () => { + store.session.intercomId = 'original'; + store.session.state = 'ready'; + expect( + (await POST(request({action: 'handoff', email: 'user@example.com'}))).status + ).toBe(503); + vi.stubEnv('FIN_HANDOFF_ENABLED', 'true'); + const fetchMock = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(Response.json({intercom_conversation_id: 'support'})); + expect( + (await POST(request({action: 'handoff', email: 'user@example.com'}))).status + ).toBe(200); + expect(fetchMock.mock.calls[0][0]).toBe('https://api.intercom.io/fin/escalate'); + expect(store.record).toHaveBeenCalledWith('docs-test', 'support_handoff', { + intercomConversationId: 'original', + supportConversationId: 'support', + }); + expect( + (await POST(request({action: 'handoff', email: 'user@example.com'}))).status + ).toBe(409); + }); +}); diff --git a/app/api/ask-ai/route.ts b/app/api/ask-ai/route.ts new file mode 100644 index 00000000000000..5e9faeffd77dc0 --- /dev/null +++ b/app/api/ask-ai/route.ts @@ -0,0 +1,276 @@ +import {callFin, normalizeFinEvent, validateStreamUrl} from 'sentry-docs/lib/askAi/fin'; +import { + bearerToken, + errorResponse, + privateHeaders, + readJson, + requirePilot, + requireSameOrigin, +} from 'sentry-docs/lib/askAi/http'; +import { + AskAiError, + createSession, + getSession, + key, + lockSession, + recordEvent, + redis, + saveSession, + takeBudget, +} from 'sentry-docs/lib/askAi/store'; +import { + AskAiEvent, + MAX_QUESTION_LENGTH, + readEventStream, +} from 'sentry-docs/lib/askAi/types'; + +export const runtime = 'nodejs'; +export const maxDuration = 120; + +export async function POST(request: Request) { + try { + requirePilot(); + requireSameOrigin(request); + const body = await readJson(request); + if (body.action === 'session') { + const path = typeof body.path === 'string' ? body.path.split(/[?#]/)[0] : '/'; + if ( + !/^\/[a-zA-Z0-9/_\.~-]*$/.test(path) || + path.length > 500 || + path.startsWith('//') + ) + throw new AskAiError(400, 'Invalid docs path.'); + const {token} = await createSession(path); + return Response.json({token}, {headers: privateHeaders}); + } + + const token = bearerToken(request); + const release = await lockSession(token); + let streaming = false; + try { + const session = await getSession(token); + if (body.action === 'feedback') { + if ( + !['helpful', 'unhelpful'].includes(String(body.rating)) || + typeof body.messageId !== 'string' || + body.messageId.length > 200 + ) + throw new AskAiError(400, 'Invalid feedback.'); + const exists = await redis( + 'GET', + key(`answer:${session.conversationId}:${body.messageId}`) + ); + if (!exists) throw new AskAiError(400, 'Answer not found.'); + await recordEvent( + session.conversationId, + 'feedback', + {messageId: body.messageId, rating: body.rating}, + `feedback:${body.messageId}` + ); + return Response.json({ok: true}, {headers: privateHeaders}); + } + + if (body.action === 'handoff') { + if (process.env.FIN_HANDOFF_ENABLED !== 'true') + throw new AskAiError(503, 'use the support link.'); + if (!session.intercomId || session.state === 'busy' || session.handedOff) + throw new AskAiError(409, 'This conversation cannot be handed off again.'); + if ( + typeof body.email !== 'string' || + body.email.length > 254 || + !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(body.email) + ) + throw new AskAiError(400, 'Enter a valid email address.'); + // Persist before the external side effect; never automatically retry an + // ambiguous result, which could create duplicate support conversations. + session.handedOff = true; + await saveSession(token, session); + await recordEvent(session.conversationId, 'support_requested', { + intercomConversationId: session.intercomId, + path: session.path, + }); + const result = await callFin('escalate', { + conversation_id: session.conversationId, + context: `Docs Ask AI pilot. Page: ${session.path}. Original Intercom conversation: ${session.intercomId}. User supplied this email for follow-up (unverified): ${body.email}. Do not use this email as proof of account ownership.`, + }); + session.state = 'closed'; + await saveSession(token, session); + await recordEvent(session.conversationId, 'support_handoff', { + intercomConversationId: session.intercomId, + supportConversationId: result.intercom_conversation_id ?? null, + }); + return Response.json({ok: true}, {headers: privateHeaders}); + } + + if ( + body.action !== 'message' || + typeof body.question !== 'string' || + !body.question.trim() || + body.question.length > MAX_QUESTION_LENGTH + ) + throw new AskAiError(400, 'Enter a question of 4,000 characters or fewer.'); + if (!['new', 'ready'].includes(session.state)) + throw new AskAiError(409, 'start a new conversation.'); + if (session.turns >= 20) throw new AskAiError(429, 'start a new conversation.'); + const configuredLimit = Number(process.env.FIN_DAILY_TURN_LIMIT || '100'); + if (!Number.isInteger(configuredLimit) || configuredLimit < 1) + throw new AskAiError(503, 'Ask AI is not configured.'); + await takeBudget('turns', configuredLimit); + const start = session.state === 'new'; + session.state = 'busy'; + session.turns += 1; + await saveSession(token, session); + await recordEvent(session.conversationId, 'question', { + question: body.question.trim(), + path: session.path, + turn: session.turns, + }); + + try { + const result = await callFin(start ? 'start' : 'reply', { + conversation_id: session.conversationId, + user: {id: session.userId}, + message: { + author: 'user', + body: body.question.trim(), + timestamp: new Date().toISOString(), + }, + ...(start + ? { + conversation_metadata: { + attributes: {docs_origin: 'docs_ask_ai', docs_path: session.path}, + }, + ...(process.env.FIN_ENVIRONMENT + ? {environment: process.env.FIN_ENVIRONMENT} + : {}), + } + : {}), + }); + session.intercomId = result.intercom_conversation_id || session.intercomId; + await saveSession(token, session); + await recordEvent(session.conversationId, 'linked', { + intercomConversationId: session.intercomId ?? null, + metadataAccepted: !result.errors, + }); + const streamUrl = validateStreamUrl(result.sse_subscription_url); + const abort = new AbortController(); + const timeout = setTimeout(() => abort.abort(), 90_000); + const upstream = await fetch(streamUrl, { + cache: 'no-store', + redirect: 'error', + signal: abort.signal, + }).catch(error => { + clearTimeout(timeout); + throw error; + }); + if ( + !upstream.ok || + !upstream.body || + !upstream.headers.get('content-type')?.includes('text/event-stream') + ) { + clearTimeout(timeout); + abort.abort(); + throw new AskAiError( + 502, + 'Ask AI could not connect. Start a new conversation.' + ); + } + + const encoder = new TextEncoder(); + let cancelled = false; + const stream = new ReadableStream({ + async start(controller) { + const send = (event: AskAiEvent) => { + if (!cancelled) + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + }; + let finished = false; + let closed = false; + try { + send({ + type: 'reference', + reference: session.intercomId || session.conversationId, + }); + for await (const raw of readEventStream(upstream.body!)) { + const event = normalizeFinEvent(raw, session.conversationId); + if (!event) continue; + if (event.type === 'answer') { + await recordEvent( + session.conversationId, + 'answer', + {...event}, + `answer:${event.id}` + ); + await redis( + 'SET', + key(`answer:${session.conversationId}:${event.id}`), + '1', + 'EX', + 3600 + ); + } + if (event.type === 'status') { + if (event.status === 'escalated') session.handedOff = true; + await recordEvent( + session.conversationId, + 'status', + {status: event.status}, + `status:${event.status}:${raw.created_at_ms}` + ); + closed ||= + event.status === 'escalated' || + event.status === 'resolved' || + event.status === 'complete'; + } + send(event); + if ( + event.type === 'status' && + ['awaiting_user_reply', 'complete'].includes(event.status) + ) { + session.state = closed ? 'closed' : 'ready'; + await saveSession(token, session); + finished = true; + break; + } + } + if (!finished) throw new Error('Incomplete Fin stream'); + } catch { + session.state = 'failed'; + await saveSession(token, session).catch(() => {}); + send({ + type: 'error', + message: + 'The answer was interrupted. Start a new conversation or contact support.', + }); + } finally { + clearTimeout(timeout); + abort.abort(); + await release().catch(() => {}); + if (!cancelled) controller.close(); + } + }, + cancel() { + cancelled = true; + abort.abort(); + }, + }); + streaming = true; + return new Response(stream, { + headers: { + ...privateHeaders, + 'Content-Type': 'text/event-stream', + 'X-Accel-Buffering': 'no', + }, + }); + } catch (error) { + session.state = 'failed'; + await saveSession(token, session).catch(() => {}); + throw error; + } + } finally { + if (!streaming) await release(); + } + } catch (error) { + return errorResponse(error); + } +} diff --git a/app/api/ask-ai/webhook/route.spec.ts b/app/api/ask-ai/webhook/route.spec.ts new file mode 100644 index 00000000000000..ee171ec166bf02 --- /dev/null +++ b/app/api/ask-ai/webhook/route.spec.ts @@ -0,0 +1,68 @@ +import {createHmac} from 'node:crypto'; + +import {beforeEach, describe, expect, it, vi} from 'vitest'; + +vi.mock('server-only', () => ({})); +const store = vi.hoisted(() => ({record: vi.fn(), redis: vi.fn()})); +vi.mock('sentry-docs/lib/askAi/store', async importOriginal => ({ + ...(await importOriginal()), + recordEvent: store.record, + redis: store.redis, +})); + +import {POST} from './route'; + +const conversationId = 'docs-12345678-1234-1234-1234-123456789abc'; +const event = { + event_name: 'fin_replied', + conversation_id: conversationId, + message: {id: 'answer-1', body: '

A useful answer

'}, +}; +function request(body: object, signature?: string) { + const raw = JSON.stringify(body); + return new Request('https://docs.example/api/ask-ai/webhook/', { + method: 'POST', + body: raw, + headers: { + 'X-Fin-Agent-API-Webhook-Signature': + signature ?? + `sha256=${createHmac('sha256', 'webhook-secret').update(raw).digest('hex')}`, + }, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('FIN_WEBHOOK_SECRET', 'webhook-secret'); + store.redis.mockResolvedValue('1'); + store.record.mockResolvedValue(undefined); +}); + +describe('Fin webhook', () => { + it('validates the raw body and uses the same dedupe ID as SSE', async () => { + expect((await POST(request(event))).status).toBe(200); + expect(store.record).toHaveBeenCalledWith( + conversationId, + 'answer', + {type: 'answer', id: 'answer-1', html: '

A useful answer

', sources: []}, + 'answer:answer-1' + ); + }); + + it('rejects forged signatures before accessing storage', async () => { + expect((await POST(request(event, 'sha256=' + '0'.repeat(64)))).status).toBe(401); + expect(store.redis).not.toHaveBeenCalled(); + expect(store.record).not.toHaveBeenCalled(); + }); + + it('ignores signed events outside this pilot', async () => { + store.redis.mockResolvedValueOnce(null); + expect((await POST(request(event))).status).toBe(200); + expect(store.record).not.toHaveBeenCalled(); + }); + + it('returns a retriable failure if durable persistence fails', async () => { + store.record.mockRejectedValueOnce(new Error('Storage unavailable')); + expect((await POST(request(event))).status).toBe(503); + }); +}); diff --git a/app/api/ask-ai/webhook/route.ts b/app/api/ask-ai/webhook/route.ts new file mode 100644 index 00000000000000..93f4f8e41d23c4 --- /dev/null +++ b/app/api/ask-ai/webhook/route.ts @@ -0,0 +1,53 @@ +import {createHmac, timingSafeEqual} from 'node:crypto'; + +import {normalizeFinEvent} from 'sentry-docs/lib/askAi/fin'; +import {errorResponse, privateHeaders, readBody} from 'sentry-docs/lib/askAi/http'; +import {AskAiError, key, recordEvent, redis} from 'sentry-docs/lib/askAi/store'; + +export const runtime = 'nodejs'; + +export async function POST(request: Request) { + try { + const secret = process.env.FIN_WEBHOOK_SECRET; + if (!secret) throw new AskAiError(503, 'Webhook is not configured.'); + const body = await readBody(request, 512_000); + const signature = request.headers + .get('x-fin-agent-api-webhook-signature') + ?.replace(/^sha256=/, ''); + const digest = createHmac('sha256', secret).update(body).digest(); + if ( + !signature || + !/^[a-fA-F0-9]{64}$/.test(signature) || + !timingSafeEqual(digest, Buffer.from(signature, 'hex')) + ) + throw new AskAiError(401, 'Invalid signature.'); + let raw: Record; + try { + raw = JSON.parse(body); + } catch { + throw new AskAiError(400, 'Invalid JSON.'); + } + if ( + !raw || + typeof raw.conversation_id !== 'string' || + !/^docs-[a-f0-9-]{36}$/.test(raw.conversation_id) + ) + return Response.json({ok: true}, {headers: privateHeaders}); + const conversationId = raw.conversation_id; + if (!(await redis('GET', key(`conversation:${conversationId}`)))) + return Response.json({ok: true}, {headers: privateHeaders}); + const event = normalizeFinEvent(raw, conversationId); + if (event?.type === 'answer') + await recordEvent(conversationId, 'answer', {...event}, `answer:${event.id}`); + if (event?.type === 'status') + await recordEvent( + conversationId, + 'status', + {status: event.status}, + `status:${event.status}:${raw.created_at_ms}` + ); + return Response.json({ok: true}, {headers: privateHeaders}); + } catch (error) { + return errorResponse(error); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 1306fb4f3374c1..88f2f04a325f7f 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -5,9 +5,11 @@ import type {Metadata} from 'next'; import {Rubik} from 'next/font/google'; import Script from 'next/script'; import PlausibleProvider from 'next-plausible'; +import {AskAiProvider} from 'sentry-docs/components/askAi/provider'; import {HashNavigation} from 'sentry-docs/components/hashNavigation'; import {SkipToContent} from 'sentry-docs/components/skipToContent'; import {ThemeProvider} from 'sentry-docs/components/theme-provider'; +import {safeSourceUrl} from 'sentry-docs/lib/askAi/types'; const rubik = Rubik({ weight: ['400', '500', '700'], @@ -32,6 +34,7 @@ export const metadata: Metadata = { }; export default function RootLayout({children}: {children: React.ReactNode}) { + const askAiProvider = process.env.ASK_AI_PROVIDER === 'fin' ? 'fin' : 'kapa'; return ( @@ -45,27 +48,34 @@ export default function RootLayout({children}: {children: React.ReactNode}) { disableTransitionOnChange > - - - {children} + + + + {children} + -