/** * OAuth route mounting for external integrations. * * Provides OAuth2 authorize/callback/disconnect flows for: * - Notion (workspace-level integration) * - Google (user-level, with token refresh) * - ClickUp (workspace-level, task sync) * * Also provides: * - GET /status?space=X — check connection status (no tokens) * - GET /sharing?space=X — get sharing config (which spaces each provider shares to) * - POST /sharing — update sharing config * * Tokens are stored in Automerge docs per space via SyncServer. */ import { Hono } from 'hono'; import * as Automerge from '@automerge/automerge'; import { notionOAuthRoutes } from './notion'; import { googleOAuthRoutes } from './google'; import { clickupOAuthRoutes } from './clickup'; import { connectionsDocId } from '../../modules/rnotes/schemas'; import { clickupConnectionDocId } from '../../modules/rtasks/schemas'; import type { ConnectionsDoc } from '../../modules/rnotes/schemas'; import type { ClickUpConnectionDoc } from '../../modules/rtasks/schemas'; import type { SyncServer } from '../local-first/sync-server'; const oauthRouter = new Hono(); let _syncServer: SyncServer | null = null; export function setOAuthStatusSyncServer(ss: SyncServer) { _syncServer = ss; } oauthRouter.route('/notion', notionOAuthRoutes); oauthRouter.route('/google', googleOAuthRoutes); oauthRouter.route('/clickup', clickupOAuthRoutes); // GET /status?space=X — return connection status for all providers (no tokens) oauthRouter.get('/status', (c) => { const space = c.req.query('space'); if (!space) return c.json({ error: 'space query param required' }, 400); if (!_syncServer) return c.json({ error: 'SyncServer not initialized' }, 500); // Read rNotes connections doc (Google + Notion) const connDoc = _syncServer.getDoc(connectionsDocId(space)); // Read rTasks ClickUp connection doc const clickupDoc = _syncServer.getDoc(clickupConnectionDocId(space)); const status: Record = {}; // Google if (connDoc?.google) { status.google = { connected: true, connectedAt: connDoc.google.connectedAt, email: connDoc.google.email, }; } else { status.google = { connected: false }; } // Notion if (connDoc?.notion) { status.notion = { connected: true, connectedAt: connDoc.notion.connectedAt, workspaceName: connDoc.notion.workspaceName, }; } else { status.notion = { connected: false }; } // ClickUp if (clickupDoc?.clickup) { status.clickup = { connected: true, connectedAt: clickupDoc.clickup.connectedAt, teamName: clickupDoc.clickup.teamName, }; } else { status.clickup = { connected: false }; } return c.json(status); }); // ── Sharing config ── // Stored as an Automerge doc: {userSpace}:oauth:sharing interface SharingDoc { meta: { module: string; collection: string; version: number; spaceSlug: string; createdAt: number }; sharing?: Record; // e.g. { google: { spaces: ['team-x', 'dao-y'] } } } function sharingDocId(userSpace: string) { return `${userSpace}:oauth:sharing` as const; } function ensureSharingDoc(userSpace: string): SharingDoc { if (!_syncServer) throw new Error('SyncServer not initialized'); const docId = sharingDocId(userSpace); let doc = _syncServer.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init oauth sharing', (d) => { d.meta = { module: 'oauth', collection: 'sharing', version: 1, spaceSlug: userSpace, createdAt: Date.now() }; d.sharing = {} as any; }); _syncServer.setDoc(docId, doc); } return doc; } // GET /sharing?space=X — get sharing config for user's personal space oauthRouter.get('/sharing', (c) => { const space = c.req.query('space'); if (!space) return c.json({ error: 'space query param required' }, 400); if (!_syncServer) return c.json({ error: 'SyncServer not initialized' }, 500); const doc = _syncServer.getDoc(sharingDocId(space)); return c.json(doc?.sharing || {}); }); // POST /sharing — update sharing config // Body: { space: string, provider: string, sharedSpaces: string[] } oauthRouter.post('/sharing', async (c) => { const body = await c.req.json<{ space: string; provider: string; sharedSpaces: string[] }>(); if (!body.space || !body.provider || !Array.isArray(body.sharedSpaces)) { return c.json({ error: 'space, provider, and sharedSpaces[] required' }, 400); } if (!_syncServer) return c.json({ error: 'SyncServer not initialized' }, 500); ensureSharingDoc(body.space); const docId = sharingDocId(body.space); _syncServer.changeDoc(docId, `Update ${body.provider} sharing`, (d) => { if (!d.sharing) d.sharing = {} as any; if (!d.sharing![body.provider]) d.sharing![body.provider] = { spaces: [] } as any; d.sharing![body.provider].spaces = body.sharedSpaces as any; }); return c.json({ ok: true }); }); export { oauthRouter };