Mapped connections UI and API.
UnderstandImplemented disconnect endpoint and management page.
BuildMade sidebar connected section clickable with icons.
EditRan tests and typechecks; ensured no regressions.
VerifyFixed auth middleware to protect connections page.
Edit// GET /api/connections - the current tenant's source connection status, item// counts, and last sync time. Powers the sidebar live dots and the onboarding// indexing progress / unlock gate. Read-only; user_id scoped first (RLS second).
export interface ConnectionStatus { source: string status: string // 'initiated' | 'active' | 'error' updatedAt: string | null itemCount: number lastSyncedAt: string | null}
export async function GET(req: NextRequest): Promise<Response> { let userId: string try { userId = await getUserId(req) } catch (err) { if (err instanceof UnauthorizedError) return new Response('Unauthorized', { status: 401 }) throw err }
try { const db = createServiceClient() const { data: rows, error } = await db .from('source_connection') .select('source, status, updated_at') .eq('user_id', userId) .order('updated_at', { ascending: false }) if (error) throw new Error(error.message)
const connections = rows ?? [] const syncRows = await db .from('sync_state') .select('source, last_successful_sync_at') .eq('user_id', userId) const lastSyncBySource = new Map( (syncRows.data ?? []).map((r) => [r.source, r.last_successful_sync_at]), )
// Item counts only for active connections (the only ones that can have data), // each a cheap head-count scoped by user_id + source. <=5 sources, so the // parallel fan-out is trivial. const active = connections.filter((c) => c.status === 'active') const counts = await Promise.all( active.map(async (c) => { const { count } = await db .from('context_item') .select('id', { count: 'exact', head: true }) .eq('user_id', userId) .eq('source', c.source) .eq('is_deleted', false) return [c.source, count ?? 0] as const }), ) const countBySource = new Map(counts)
const payload: ConnectionStatus[] = connections.map((c) => ({ source: c.source, status: c.status, updatedAt: c.updated_at, itemCount: countBySource.get(c.source) ?? 0, lastSyncedAt: lastSyncBySource.get(c.source) ?? null, }))
return Response.json({ connections: payload }) } catch (err) { captureError('connections', err, { userId }) return new Response('Failed to load connections', { status: 500 }) }}// POST /api/connect/[source] - start a Composio OAuth connection for the signed// in user. Returns the redirectUrl to send the user to. Persists an 'initiated'// source_connection row so the callback can finalize it. Fast (no ingestion).
async function reconcileActive(userId: string, source: SourceName): Promise<boolean> { const list = (await composio().connectedAccounts.list({ userIds: [userId], authConfigIds: [authConfigId(source)], statuses: ['ACTIVE'], })) as { items?: Array<{ id: string; status?: string }> } const active = list.items?.find((i) => (i.status ?? '').toUpperCase() === 'ACTIVE') if (!active) return false
const db = createServiceClient() const { error } = await db.from('source_connection').upsert( { user_id: userId, source, connected_account_id: active.id, status: 'active', updated_at: new Date().toISOString(), }, { onConflict: 'user_id,source' }, ) if (error) throw new Error(error.message) await enqueueLoad(userId, source) return true}
export async function POST( req: NextRequest, { params }: { params: { source: string } },): Promise<Response> { const source = params.source if (!isConnectable(source)) { return new Response(`Source not connectable: ${source}`, { status: 400 }) }
let userId: string try { userId = await getUserId(req) } catch (err) { if (err instanceof UnauthorizedError) return new Response('Unauthorized', { status: 401 }) throw err }
try { const callbackUrl = `${process.env.NEXT_PUBLIC_APP_URL}/api/oauth/callback` const connRequest = (await composio().connectedAccounts.link(userId, authConfigId(source), { callbackUrl, })) as { id: string; redirectUrl?: string }
const db = createServiceClient() const { error } = await db.from('source_connection').upsert( { user_id: userId, source, connected_account_id: connRequest.id, status: 'initiated', updated_at: new Date().toISOString(), }, { onConflict: 'user_id,source' }, ) if (error) throw new Error(error.message)
return Response.json({ redirectUrl: connRequest.redirectUrl, connectedAccountId: connRequest.id, }) } catch (err) { if (err instanceof ComposioMultipleConnectedAccountsError) { try { if (await reconcileActive(userId, source)) { return Response.json({ alreadyConnected: true }) } } catch (reconcileErr) { captureError('connect', reconcileErr, { userId, source, stage: 'reconcile' }) return new Response('Failed to reconcile existing connection', { status: 502 }) } } captureError('connect', err, { userId, source }) return new Response('Failed to start connection', { status: 502 }) }}// GET /api/oauth/callback - Composio redirects here after the user grants// consent. We finalize any 'initiated' connections for this user (verify ACTIVE// via Composio), then ENQUEUE the first 90-day load on Trigger.dev.
export async function GET(req: NextRequest): Promise<Response> { let userId: string try { userId = await getUserId(req) } catch (err) { if (err instanceof UnauthorizedError) return new Response('Unauthorized', { status: 401 }) throw err }
const db = createServiceClient() const { data: pending, error } = await db .from('source_connection') .select('source, connected_account_id') .eq('user_id', userId) .eq('status', 'initiated') if (error) { captureError('oauth/callback', new Error(error.message), { userId, stage: 'list-pending' }) return Response.redirect(`${process.env.NEXT_PUBLIC_APP_URL}/onboarding?error=1`, 302) }
for (const conn of pending ?? []) { try { const account = (await composio().connectedAccounts.get(conn.connected_account_id)) as { status?: string } if ((account.status ?? '').toUpperCase() === 'ACTIVE') { await db .from('source_connection') .update({ status: 'active', updated_at: new Date().toISOString() }) .eq('user_id', userId) .eq('source', conn.source) await enqueueLoad(userId, conn.source) } } catch (err) { captureError('oauth/callback', err, { userId, source: conn.source, stage: 'finalize' }) } }
return Response.redirect(`${process.env.NEXT_PUBLIC_APP_URL}/onboarding?connected=1`, 302)}// Composio client + helpers. Supplies OAuth + fetch inside the connectors. The// Connector contract is ours (CLAUDE.md), so a Nango swap is a one-file change.
import { Composio } from '@composio/core'import type { SourceName } from './types'
let client: Composio | null = null
export function composio(): Composio { if (!client) client = new Composio({ apiKey: requireEnv('COMPOSIO_API_KEY') }) return client}
// Auth config id per source (set after creating the auth config in Composio).const AUTH_CONFIG_ENV: Partial<Record<SourceName, string>> = { gmail: 'COMPOSIO_GMAIL_AUTH_CONFIG_ID', calendar: 'COMPOSIO_CALENDAR_AUTH_CONFIG_ID', linear: 'COMPOSIO_LINEAR_AUTH_CONFIG_ID', slack: 'COMPOSIO_SLACK_AUTH_CONFIG_ID', notion: 'COMPOSIO_NOTION_AUTH_CONFIG_ID', github: 'COMPOSIO_GITHUB_AUTH_CONFIG_ID',}
export function authConfigId(source: SourceName): string { const envName = AUTH_CONFIG_ENV[source] if (!envName) throw new Error(`No auth config mapping for source: ${source}`) return requireEnv(envName)}
export async function executeTool( slug: string, userId: string, args: Record<string, unknown>,): Promise<Record<string, unknown>> { // ... executes Composio tools with userId as tenant identifier}// Source + tone presentation maps. Keep the visual language (icon, label, tint)// consistent across the sidebar, Today cards, Search results, and the graph.
import type { IconName } from '@/components/icons'import type { TagTone, CardKind } from '@/lib/api/today-schema'
interface Tint { bg: string color: string}
const SOURCE_META: Record<string, { label: string; icon: IconName; tint: Tint }> = { gmail: { label: 'Gmail', icon: 'mail', tint: { bg: 'rgba(0,113,227,.10)', color: '#0071e3' } }, calendar: { label: 'Calendar', icon: 'calendar', tint: { bg: 'rgba(0,113,227,.10)', color: '#0071e3' }, }, slack: { label: 'Slack', icon: 'slack', tint: { bg: 'rgba(107,63,212,.10)', color: '#6b3fd4' } }, linear: { label: 'Linear', icon: 'linear', tint: { bg: 'rgba(26,127,55,.10)', color: '#1a7f37' }, }, notion: { label: 'Notion', icon: 'notion', tint: { bg: '#f0f0f2', color: '#6e6e73' } }, github: { label: 'GitHub', icon: 'github', tint: { bg: '#f0f0f2', color: '#6e6e73' } }, sentry: { label: 'Sentry', icon: 'alert', tint: { bg: 'rgba(227,89,0,.10)', color: '#c2540a' } }, drive: { label: 'Drive', icon: 'notion', tint: { bg: '#f0f0f2', color: '#6e6e73' } }, voice_memo: { label: 'Voice', icon: 'mic', tint: { bg: '#f0f0f2', color: '#6e6e73' } },}
const FALLBACK = { label: 'Source', icon: 'layers' as IconName, tint: { bg: '#f0f0f2', color: '#6e6e73' },}
export function sourceMeta(source: string) { return SOURCE_META[source] ?? FALLBACK}
export function sourceLabel(source: string): string { return sourceMeta(source).label}
export function sourceIcon(source: string): IconName { return sourceMeta(source).icon}
export function sourceTint(source: string): Tint { return sourceMeta(source).tint}
// Tag pill colors (matches tagStyle() in the mockup).const TONE: Record<TagTone, Tint> = { blue: { bg: 'rgba(0,113,227,.10)', color: '#0071e3' }, warn: { bg: 'rgba(227,89,0,.10)', color: '#c2540a' }, calm: { bg: 'rgba(0,0,0,.05)', color: '#6e6e73' }, green: { bg: 'rgba(26,127,55,.12)', color: '#1a7f37' }, purple: { bg: 'rgba(107,63,212,.10)', color: '#6b3fd4' },}
export function toneTint(tone: TagTone): Tint { return TONE[tone] ?? TONE.blue}
// Map a Today card kind to an icon. Source-shaped kinds reuse the source icon.const KIND_ICON: Record<CardKind, IconName> = { email: 'mail', calendar: 'calendar', slack: 'slack', linear: 'linear', notion: 'notion', github: 'github', sentry: 'alert', person: 'user', company: 'building', project: 'layers', generic: 'layers',}
export function kindIcon(kind: CardKind): IconName { return KIND_ICON[kind] ?? 'layers'}
// Entity type -> color/icon for the relationships graph + detail panel.export function entityColor(type: string): string { if (type === 'company') return '#6b3fd4' if (type === 'project') return '#1a7f37' return '#0071e3' // person / you / default}
export function entityIcon(type: string): IconName { if (type === 'company') return 'building' if (type === 'project') return 'layers' return 'user'}// A search result card: source-tinted icon tile, title + relative time, snippet// with highlighted match terms, source tag, author, and match percent.
export function ResultCard({ result }: { result: SearchResult }) { const tint = sourceTint(result.source) const body = ( <article className="flex gap-[13px] rounded-[14px] border border-hairline bg-white px-4 py-[15px] shadow-flat transition-colors hover:border-hairline-strong"> <div className="grid h-[34px] w-[34px] flex-none place-items-center rounded-[9px]" style={{ background: tint.bg, color: tint.color }} > <Icon name={sourceIcon(result.source)} size={16} /> </div> {/* ... rest of card ... */} <span className="inline-flex items-center rounded-pill px-[9px] py-[3px] text-[11px] font-semibold" style={{ background: tint.bg, color: tint.color }} > {sourceLabel(result.source)} </span> </article> ) // ...}-- Tracks each tenant's connected source accounts (the Composio connected-account-- handle). One row per (user, source). The scheduled poller iterates the active-- rows; the OAuth callback flips status to 'active' and kicks the first load.
create table source_connection ( user_id uuid not null, source text not null, connected_account_id text not null, status text not null default 'initiated', -- 'initiated' | 'active' | 'error' metadata jsonb default '{}', created_at timestamptz not null default now(), updated_at timestamptz not null default now(), primary key (user_id, source));
create index source_connection_active_idx on source_connection (status) where status = 'active';
alter table source_connection enable row level security;create policy source_connection_tenant_isolation on source_connection using (user_id = auth.uid()) with check (user_id = auth.uid());// Example from SourceDots.tsxuseEffect(() => { let alive = true async function poll() { try { const res = await fetch('/api/connections') if (!res.ok) return const data = (await res.json()) as { connections: ConnectionStatus[] } if (alive) setConnections(data.connections) } catch { // keep last state } } void poll() const id = setInterval(poll, 8000) // or 3000 for onboarding return () => { alive = false clearInterval(id) }}, []){ connections: [ { source: string status: 'initiated' | 'active' | 'error' updatedAt: string | null itemCount: number lastSyncedAt: string | null } ]}export async function DELETE( req: NextRequest, { params }: { params: { source: string } },): Promise<Response> { const source = params.source if (!isConnectable(source)) { return new Response(`Source not connectable: ${source}`, { status: 400 }) } let userId: string try { userId = await getUserId(req) } catch (err) { if (err instanceof UnauthorizedError) return new Response('Unauthorized', { status: 401 }) throw err } try { const db = createServiceClient() const { data: row, error: readErr } = await db .from('source_connection') .select('connected_account_id') .eq('user_id', userId).eq('source', source).maybeSingle() if (readErr) throw new Error(readErr.message) if (row?.connected_account_id) { try { await composio().connectedAccounts.delete(row.connected_account_id) } catch (revokeErr) { captureError('connect', revokeErr, { userId, source, stage: 'revoke' }) } } const { error: delErr } = await db .from('source_connection').delete() .eq('user_id', userId).eq('source', source) if (delErr) throw new Error(delErr.message) return Response.json({ disconnected: true }) } catch (err) { captureError('connect', err, { userId, source }) return new Response('Failed to disconnect', { status: 502 }) }}<Link href="/connections" title="Manage connections" className="mt-6 flex items-center justify-between px-[10px] pb-2 text-[11px] font-semibold tracking-[.05em] text-hint transition-colors hover:text-ink"> <span>CONNECTED</span> <Icon name="settings" size={13} /></Link><SourceDots />