146 lines
4.9 KiB
TypeScript
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 };
|