/** * Notion OAuth2 flow. * * GET /authorize?space=X → redirect to Notion * 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 { connectionsDocId } from '../../modules/rnotes/schemas'; import type { ConnectionsDoc } from '../../modules/rnotes/schemas'; import type { SyncServer } from '../local-first/sync-server'; const notionOAuthRoutes = new Hono(); const NOTION_CLIENT_ID = process.env.NOTION_CLIENT_ID || ''; const NOTION_CLIENT_SECRET = process.env.NOTION_CLIENT_SECRET || ''; const NOTION_REDIRECT_URI = process.env.NOTION_REDIRECT_URI || ''; // We'll need a reference to the sync server — set externally let _syncServer: SyncServer | null = null; export function setNotionOAuthSyncServer(ss: SyncServer) { _syncServer = ss; } function ensureConnectionsDoc(space: string): ConnectionsDoc { if (!_syncServer) throw new Error('SyncServer not initialized'); const docId = connectionsDocId(space); let doc = _syncServer.getDoc(docId); if (!doc) { doc = Automerge.change(Automerge.init(), 'init connections', (d) => { d.meta = { module: 'notes', collection: 'connections', version: 1, spaceSlug: space, createdAt: Date.now(), }; }); _syncServer.setDoc(docId, doc); } return doc; } // GET /authorize — redirect to Notion OAuth notionOAuthRoutes.get('/authorize', (c) => { const space = c.req.query('space'); if (!space) return c.json({ error: 'space query param required' }, 400); if (!NOTION_CLIENT_ID) return c.json({ error: 'Notion OAuth not configured' }, 500); const state = Buffer.from(JSON.stringify({ space })).toString('base64url'); const url = `https://api.notion.com/v1/oauth/authorize?client_id=${NOTION_CLIENT_ID}&response_type=code&owner=user&redirect_uri=${encodeURIComponent(NOTION_REDIRECT_URI)}&state=${state}`; return c.redirect(url); }); // GET /callback — exchange code for token notionOAuthRoutes.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.notion.com/v1/oauth/token', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': `Basic ${Buffer.from(`${NOTION_CLIENT_ID}:${NOTION_CLIENT_SECRET}`).toString('base64')}`, }, body: JSON.stringify({ grant_type: 'authorization_code', code, redirect_uri: NOTION_REDIRECT_URI, }), }); if (!tokenRes.ok) { const err = await tokenRes.text(); return c.json({ error: `Token exchange failed: ${err}` }, 502); } const tokenData = await tokenRes.json() as any; // Store token in Automerge connections doc ensureConnectionsDoc(state.space); const docId = connectionsDocId(state.space); _syncServer!.changeDoc(docId, 'Connect Notion', (d) => { d.notion = { accessToken: tokenData.access_token, workspaceId: tokenData.workspace_id || '', workspaceName: tokenData.workspace_name || 'Notion Workspace', connectedAt: Date.now(), }; }); // Redirect back to rNotes const redirectUrl = `/${state.space}/rnotes?connected=notion`; return c.redirect(redirectUrl); }); // POST /disconnect — revoke and remove token notionOAuthRoutes.post('/disconnect', async (c) => { const space = c.req.query('space'); if (!space) return c.json({ error: 'space query param required' }, 400); const docId = connectionsDocId(space); const doc = _syncServer?.getDoc(docId); if (doc?.notion) { _syncServer!.changeDoc(docId, 'Disconnect Notion', (d) => { delete d.notion; }); } return c.json({ ok: true }); }); export { notionOAuthRoutes };