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
30 changes: 29 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -8,4 +8,32 @@
# event is rejected with a 401.
NEXT_PUBLIC_ALGOLIA_APP_ID=OOK48W9UCL
NEXT_PUBLIC_ALGOLIA_SEARCH_KEY=2d64ec1106519cbc672d863b0d200782
NEXT_PUBLIC_SENTRY_DSN=
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
# Optional for a protected Vercel preview or local development. Without Redis,
# signed conversation tokens stay in page memory; no extra secrets are needed.
# Required for production, shared limits, feedback export, and direct handoff.
# No silent fallback if either value is configured but Redis is unavailable.
FIN_REDIS_REST_URL=
FIN_REDIS_REST_TOKEN=
# Applied only when Redis is configured.
FIN_DAILY_TURN_LIMIT=100
# Optional later: feedback pipeline (requires Redis).
# 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=
53 changes: 53 additions & 0 deletions app/api/ask-ai/events/route.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof import('sentry-docs/lib/askAi/store')>()),
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');
});
});
45 changes: 45 additions & 0 deletions app/api/ask-ai/events/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
160 changes: 160 additions & 0 deletions app/api/ask-ai/preview.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';

vi.mock('server-only', () => ({}));

import {
createPreviewSession,
updatePreviewSession,
} from 'sentry-docs/lib/askAi/previewSession';
import {readEventStream} from 'sentry-docs/lib/askAi/types';

import {POST} from './route';

function request(body: object, token?: string) {
return new Request('https://preview.example/api/ask-ai/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Origin: 'https://preview.example',
...(token ? {Authorization: `Bearer ${token}`} : {}),
},
body: JSON.stringify(body),
});
}

beforeEach(() => {
vi.stubEnv('ASK_AI_PROVIDER', 'fin');
vi.stubEnv('FIN_PILOT_ENABLED', 'true');
vi.stubEnv('FIN_AGENT_API_KEY', 'private-fin-key');
vi.stubEnv('FIN_PUBLIC_ORIGIN', 'https://preview.example');
vi.stubEnv('FIN_REDIS_REST_URL', '');
vi.stubEnv('FIN_REDIS_REST_TOKEN', '');
vi.stubEnv('FIN_HANDOFF_ENABLED', 'true');
vi.stubEnv('FIN_REGION', 'us');
vi.stubEnv('VERCEL_ENV', 'preview');
vi.stubEnv('NODE_ENV', 'production');
});
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
});

describe('Fin preview without Redis', () => {
it('starts and continues with the same identity using only Fin and a page-memory token', async () => {
const calls: {url: string; body: {conversation_id: string; user: {id: string}}}[] =
[];
let conversationId = '';
const fetchMock = vi.spyOn(globalThis, 'fetch').mockImplementation((url, init) => {
if (String(url).startsWith('https://api.intercom.io/fin/')) {
const body = JSON.parse(init?.body as string);
calls.push({url: String(url), body});
conversationId = body.conversation_id;
return Promise.resolve(
Response.json({
intercom_conversation_id: 'intercom-123',
sse_subscription_url:
'https://primary-realtime.intercom-messenger.com/event-stream?accessToken=private-stream-token',
})
);
}
const events = [
{event_name: 'fin_replied', message: {id: 'answer-1', body: '<p>Answer</p>'}},
{event_name: 'fin_status_updated', status: 'awaiting_user_reply'},
];
return Promise.resolve(
new Response(
events
.map(
event =>
`data: ${JSON.stringify({conversation_id: conversationId, ...event})}\n\n`
)
.join(''),
{headers: {'Content-Type': 'text/event-stream'}}
)
);
});
const opened = await POST(
request({action: 'session', path: '/platforms/javascript/'})
);
const session = await opened.json();
expect(opened.status).toBe(200);
expect(opened.headers.get('set-cookie')).toBeNull();
expect(session.capabilities).toEqual({feedback: false, handoff: false});
expect(fetchMock).not.toHaveBeenCalled();

const response = await POST(
request({action: 'message', question: 'Set up Sentry'}, session.token)
);
const events: Record<string, unknown>[] = [];
for await (const event of readEventStream(response.body!)) events.push(event);
const refreshed = events.find(event => event.type === 'session')!.token as string;
expect(refreshed).not.toBe(session.token);
expect(events.at(-1)).toEqual({type: 'status', status: 'awaiting_user_reply'});
expect(JSON.stringify(events)).not.toMatch(/private-fin-key|private-stream-token/);
expect(response.headers.get('set-cookie')).toBeNull();

vi.resetModules();
const freshServer = await import('./route');
const followup = await freshServer.POST(
request({action: 'message', question: 'And Python?'}, refreshed)
);
await followup.text();
expect(calls.map(call => call.url)).toEqual([
'https://api.intercom.io/fin/start',
'https://api.intercom.io/fin/reply',
]);
expect(calls[1].body.conversation_id).toBe(calls[0].body.conversation_id);
expect(calls[1].body.user).toEqual(calls[0].body.user);
expect(fetchMock).toHaveBeenCalledTimes(4);
});

it('does not silently accept feedback or create support requests without storage', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch');
const {token} = createPreviewSession('/');
for (const action of ['feedback', 'handoff']) {
expect((await POST(request({action}, token))).status).toBe(503);
}
expect(fetchMock).not.toHaveBeenCalled();
});

it('rejects production, disabled pilots, closed conversations, and the next turn after 20', async () => {
const fetchMock = vi.spyOn(globalThis, 'fetch');
const {token, session} = createPreviewSession('/');
for (const state of [
{state: 'closed' as const},
{state: 'ready' as const, turns: 20},
]) {
const updated = updatePreviewSession(token, {...session, ...state});
const response = await POST(
request({action: 'message', question: 'Hello'}, updated)
);
expect([409, 429]).toContain(response.status);
}
vi.stubEnv('VERCEL_ENV', 'production');
expect((await POST(request({action: 'session'}))).status).toBe(503);
vi.stubEnv('VERCEL_ENV', 'preview');
vi.stubEnv('FIN_PILOT_ENABLED', 'false');
expect((await POST(request({action: 'session'}))).status).toBe(503);
expect(fetchMock).not.toHaveBeenCalled();
});

it('reports an incomplete stream without issuing a continuation token or retrying', async () => {
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValueOnce(
Response.json({
sse_subscription_url:
'https://primary-realtime.intercom-messenger.com/event-stream',
})
)
.mockResolvedValueOnce(
new Response('', {headers: {'Content-Type': 'text/event-stream'}})
);
const {token} = createPreviewSession('/');
const response = await POST(request({action: 'message', question: 'Hello'}, token));
const body = await response.text();
expect(body).toContain('interrupted');
expect(body).not.toContain('"type":"session"');
expect(fetchMock).toHaveBeenCalledTimes(2);
});
});
Loading
Loading