rspace-online/server/oauth/index.ts

146 lines
4.9 KiB
TypeScript

/**
* 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<ConnectionsDoc>(connectionsDocId(space));
// Read rTasks ClickUp connection doc
const clickupDoc = _syncServer.getDoc<ClickUpConnectionDoc>(clickupConnectionDocId(space));
const status: Record<string, { connected: boolean; connectedAt?: number; email?: string; workspaceName?: string; teamName?: string }> = {};
// 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<string, { spaces: string[] }>; // 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<SharingDoc>(docId);
if (!doc) {
doc = Automerge.change(Automerge.init<SharingDoc>(), '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<SharingDoc>(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<SharingDoc>(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 };