/** * ClickUp OAuth2 flow. * * GET /authorize?space=X → redirect to ClickUp * GET /callback → exchange code, store token, redirect back * POST /disconnect?space=X → revoke token */ import { Hono } from 'hono'; import * as Automerge from '@automerge/automerge'; import { clickupConnectionDocId } from '../../modules/rtasks/schemas'; import type { ClickUpConnectionDoc } from '../../modules/rtasks/schemas'; import type { SyncServer } from '../local-first/sync-server'; const clickupOAuthRoutes = new Hono(); const CLICKUP_CLIENT_ID = process.env.CLICKUP_CLIENT_ID || ''; const CLICKUP_CLIENT_SECRET = process.env.CLICKUP_CLIENT_SECRET || ''; const CLICKUP_REDIRECT_URI = process.env.CLICKUP_REDIRECT_URI || ''; let _syncServer: SyncServer | null = null; export function setClickUpOAuthSyncServer(ss: SyncServer) { _syncServer = ss; } function ensureConnectionDoc(space: string): ClickUpConnectionDoc { if (!_syncServer) throw new Error('SyncServer not initialized'); const docId = clickupConnectionDocId(space); let doc = _syncServer.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init clickup connection', (d) => { d.meta = { module: 'tasks', collection: 'clickup-connection', version: 1, spaceSlug: space, createdAt: Date.now(), }; }); _syncServer.setDoc(docId, doc); } return doc; } // GET /authorize — redirect to ClickUp OAuth clickupOAuthRoutes.get('/authorize', (c) => { const space = c.req.query('space'); if (!space) return c.json({ error: 'space query param required' }, 400); if (!CLICKUP_CLIENT_ID) return c.json({ error: 'ClickUp OAuth not configured' }, 500); const state = Buffer.from(JSON.stringify({ space })).toString('base64url'); const url = `https://app.clickup.com/api?client_id=${CLICKUP_CLIENT_ID}&redirect_uri=${encodeURIComponent(CLICKUP_REDIRECT_URI)}&state=${state}`; return c.redirect(url); }); // GET /callback — exchange code for token clickupOAuthRoutes.get('/callback', async (c) => { const code = c.req.query('code'); const stateParam = c.req.query('state'); if (!code || !stateParam) return c.json({ error: 'Missing code or state' }, 400); let state: { space: string }; try { state = JSON.parse(Buffer.from(stateParam, 'base64url').toString()); } catch { return c.json({ error: 'Invalid state parameter' }, 400); } // Exchange code for access token const tokenRes = await fetch('https://api.clickup.com/api/v2/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ client_id: CLICKUP_CLIENT_ID, client_secret: CLICKUP_CLIENT_SECRET, code, }), }); if (!tokenRes.ok) { const err = await tokenRes.text(); return c.json({ error: `Token exchange failed: ${err}` }, 502); } const tokenData = await tokenRes.json() as any; const accessToken = tokenData.access_token; // Fetch workspace info to store team ID const teamsRes = await fetch('https://api.clickup.com/api/v2/team', { headers: { 'Authorization': accessToken }, }); let teamId = ''; let teamName = 'ClickUp Workspace'; if (teamsRes.ok) { const teamsData = await teamsRes.json() as any; if (teamsData.teams?.length > 0) { teamId = teamsData.teams[0].id; teamName = teamsData.teams[0].name; } } // Generate webhook secret const secretBuf = new Uint8Array(32); crypto.getRandomValues(secretBuf); const webhookSecret = Array.from(secretBuf).map(b => b.toString(16).padStart(2, '0')).join(''); // Store token in Automerge connections doc ensureConnectionDoc(state.space); const docId = clickupConnectionDocId(state.space); _syncServer!.changeDoc(docId, 'Connect ClickUp', (d) => { d.clickup = { accessToken, teamId, teamName, connectedAt: Date.now(), webhookSecret, }; }); // Redirect back to rTasks const redirectUrl = `/${state.space}/rtasks?connected=clickup`; return c.redirect(redirectUrl); }); // POST /disconnect — remove token clickupOAuthRoutes.post('/disconnect', async (c) => { const space = c.req.query('space'); if (!space) return c.json({ error: 'space query param required' }, 400); const docId = clickupConnectionDocId(space); const doc = _syncServer?.getDoc(docId); if (doc?.clickup) { _syncServer!.changeDoc(docId, 'Disconnect ClickUp', (d) => { delete d.clickup; }); } return c.json({ ok: true }); }); export { clickupOAuthRoutes };