activity.ts
63 lines · browse/src/routes/activity.ts at 3f79d81
1/**
2 * Activity feed routes: the SSE session cookie mint, the activity SSE stream
3 * and the REST history. None reset the idle timer.
4 */
6import { json, type RouteEntry } from './table';
7import { subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from '../activity';
8import { createSseEndpoint } from '../sse-helpers';
9import { mintSseSessionToken, buildSseSetCookie, SSE_COOKIE_NAME } from '../sse-session-cookie';
11export const activityRoutes: RouteEntry[] = [
12 // ─── SSE session cookie mint (auth required) ──────────────────
13 //
14 // Issues a short-lived view-only token in an HttpOnly SameSite=Strict
15 // cookie so EventSource calls can authenticate without putting the
16 // root token in a URL. It is not a scoped token and cannot be used
17 // against /command. The extension calls this once at bootstrap with the
18 // root Bearer header, then opens EventSource with `withCredentials: true`
19 // which sends the cookie back automatically.
20 {
21 method: 'POST', path: '/sse-session', auth: 'root-bearer', surfaces: ['local'],
22 handler: () => {
23 const minted = mintSseSessionToken();
24 return json({
25 expiresAt: minted.expiresAt,
26 cookie: SSE_COOKIE_NAME,
27 }, { headers: { 'Set-Cookie': buildSseSetCookie(minted.token) } });
28 },
29 },
31 // Activity stream — SSE. Auth: Bearer header OR view-only SSE session
32 // cookie (EventSource can't send Authorization headers). The ?token= query
33 // param is NO LONGER accepted — URLs leak to logs/referer/history.
34 {
35 method: '*', path: '/activity/stream', auth: 'root-or-sse-cookie', surfaces: ['local'],
36 handler: (req, { url }) => {
37 const afterId = parseInt(url.searchParams.get('after') || '0', 10);
38 // Cleanup contract (abort + enqueue-fail + heartbeat-fail, all
39 // idempotent) lives in createSseEndpoint; sanitizeReplacer is applied
40 // to every JSON.stringify inside the helper, so page-content-derived
41 // fields stay surrogate-safe per the CLAUDE.md egress invariant.
42 return createSseEndpoint(req, {
43 initialReplay: (send) => {
44 const { entries, gap, gapFrom, availableFrom } = getActivityAfter(afterId);
45 if (gap) send('gap', { gapFrom, availableFrom });
46 for (const entry of entries) send('activity', entry);
47 },
48 subscribe,
49 liveEventName: 'activity',
50 });
51 },
52 },
54 // Activity history — REST
55 {
56 method: '*', path: '/activity/history', auth: 'root-bearer', surfaces: ['local'],
57 handler: (_req, { url }) => {
58 const limit = parseInt(url.searchParams.get('limit') || '50', 10);
59 const { entries, totalAdded } = getActivityHistory(limit);
60 return json({ entries, totalAdded, subscribers: getSubscriberCount() });
61 },
62 },
63];