rspace-online/server/oauth/notion.ts

130 lines
4.0 KiB
TypeScript

/**
* 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<ConnectionsDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<ConnectionsDoc>(), '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<ConnectionsDoc>(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<ConnectionsDoc>(docId);
if (doc?.notion) {
_syncServer!.changeDoc<ConnectionsDoc>(docId, 'Disconnect Notion', (d) => {
delete d.notion;
});
}
return c.json({ ok: true });
});
export { notionOAuthRoutes };