notion-hub-connector.mjs
368 lines 13.8 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago
1 /**
2 * Process-wide Hub-key Notion connector.
3 *
4 * Gated by DOCS_NOTION_HUB_KEY_AUTHORIZED (compile-time; Tier 3 flip 2026-08-22).
5 * There is no OAuth flow and connector records never contain NOTION_API_KEY.
6 */
7
8 import { fetchNotionPageMarkdown } from '../importers/notion.mjs';
9 import {
10 connectorForClient,
11 getConnector,
12 listConnectors,
13 newConnectorId,
14 saveConnector,
15 } from './docs-connector-store.mjs';
16 import { proposeDocsImports } from './docs-import-propose.mjs';
17
18 export const DOCS_NOTION_HUB_KEY_AUTHORIZED = true;
19 export const NOTION_PAGE_ID_RE = /^(?:[A-Fa-f0-9]{32}|[A-Fa-f0-9]{8}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{4}-[A-Fa-f0-9]{12})$/;
20 const NOTION_API_BASE = 'https://api.notion.com/v1';
21 const NOTION_VERSION = '2022-06-28';
22 const activeSyncs = new Set();
23
24 export function isDocsNotionHubKeyEnabled({ authorizedOverride } = {}) {
25 if (authorizedOverride === true) return true;
26 if (authorizedOverride === false) return false;
27 return DOCS_NOTION_HUB_KEY_AUTHORIZED === true;
28 }
29
30 function result(status, code) {
31 return { ok: false, status, code };
32 }
33
34 function notAuthorized() {
35 return result(501, 'NOT_AUTHORIZED');
36 }
37
38 async function sleep(ms) {
39 if (ms > 0) await new Promise((resolve) => setTimeout(resolve, ms));
40 }
41
42 async function searchWithBackoff(ctx, client, params) {
43 for (let attempt = 0; attempt < 3; attempt++) {
44 const response = await client.search(params);
45 if (response?.status !== 429) return response;
46 if (attempt < 2) {
47 const wait = Number.isFinite(response.retryAfterMs)
48 ? Math.max(0, Math.min(response.retryAfterMs, 60_000))
49 : 0;
50 await (ctx.sleepFn ?? sleep)(wait);
51 }
52 }
53 return { status: 429 };
54 }
55
56 function apiKeyFrom(env = process.env) {
57 return typeof env?.NOTION_API_KEY === 'string' ? env.NOTION_API_KEY.trim() : '';
58 }
59
60 function titleFromPage(page) {
61 const properties = page?.properties && typeof page.properties === 'object' ? page.properties : {};
62 for (const property of Object.values(properties)) {
63 if (property?.type !== 'title' || !Array.isArray(property.title)) continue;
64 const title = property.title.map((part) => part?.plain_text ?? '').join('').trim();
65 if (title) return title.slice(0, 512);
66 }
67 return 'Untitled Notion page';
68 }
69
70 function normalizeNotionResult(row) {
71 return {
72 file_id: typeof row?.id === 'string' ? row.id : '',
73 name: row?.object === 'page' ? titleFromPage(row) : 'Notion database',
74 mime: row?.object === 'page' ? 'application/vnd.notion.page' : 'application/vnd.notion.database',
75 modified: typeof row?.last_edited_time === 'string' ? row.last_edited_time : null,
76 size: 0,
77 importable: row?.object === 'page',
78 };
79 }
80
81 export function handleBeginNotionConnector(ctx) {
82 if (!isDocsNotionHubKeyEnabled({ authorizedOverride: ctx.authorizedOverride })) return notAuthorized();
83 const body = ctx.body && typeof ctx.body === 'object' && !Array.isArray(ctx.body) ? ctx.body : null;
84 if (!body || Object.keys(body).some((key) => !['provider', 'display_name', 'return_url'].includes(key))) {
85 return result(400, 'BAD_REQUEST');
86 }
87 if (body.provider !== 'notion') return result(400, 'PROVIDER_DENIED');
88 const connector = {
89 connector_id: newConnectorId(),
90 provider: 'notion',
91 display_name: typeof body.display_name === 'string' && body.display_name.trim()
92 ? body.display_name.trim().slice(0, 128)
93 : 'Notion',
94 status: apiKeyFrom(ctx.env) ? 'connected' : 'needs_reauth',
95 account_sub: null,
96 oauth_ref: null,
97 sync_cursor: null,
98 last_sync_at: null,
99 last_sync_error: 'none',
100 file_count: 0,
101 revoked_at: null,
102 oauth_pending: null,
103 };
104 saveConnector(ctx.dataDir, ctx.vaultId, connector);
105 return {
106 ok: true,
107 status: 200,
108 payload: { connector_id: connector.connector_id, status: connector.status },
109 };
110 }
111
112 export function handleListNotionConnectors(ctx) {
113 if (!isDocsNotionHubKeyEnabled({ authorizedOverride: ctx.authorizedOverride })) return notAuthorized();
114 return {
115 ok: true,
116 status: 200,
117 payload: {
118 schema: 'knowtation.docs_connectors/v0',
119 connectors: listConnectors(ctx.dataDir, ctx.vaultId)
120 .filter((connector) => connector.provider === 'notion')
121 .map(connectorForClient),
122 },
123 };
124 }
125
126 function connectedNotion(ctx) {
127 const connector = getConnector(ctx.dataDir, ctx.vaultId, ctx.connectorId);
128 if (!connector || connector.status === 'revoked') return { response: result(404, 'CONNECTOR_NOT_FOUND') };
129 if (connector.provider !== 'notion') return { response: result(400, 'PROVIDER_DENIED') };
130 const key = apiKeyFrom(ctx.env);
131 if (!key || connector.status === 'needs_reauth') {
132 if (connector.status !== 'needs_reauth') {
133 connector.status = 'needs_reauth';
134 connector.last_sync_error = 'auth_expired';
135 saveConnector(ctx.dataDir, ctx.vaultId, connector);
136 }
137 return { response: result(409, 'NEEDS_REAUTH') };
138 }
139 if (connector.status !== 'connected') return { response: result(400, 'BAD_REQUEST') };
140 return { connector, apiKey: key };
141 }
142
143 export function createProductionNotionClient({ fetchImpl = globalThis.fetch } = {}) {
144 if (typeof fetchImpl !== 'function') throw new TypeError('fetch implementation is required');
145 const headers = (apiKey) => ({
146 Authorization: `Bearer ${apiKey}`,
147 'Notion-Version': NOTION_VERSION,
148 'Content-Type': 'application/json',
149 Accept: 'application/json',
150 });
151 return {
152 search: async ({ apiKey, startCursor }) => {
153 const response = await fetchImpl(`${NOTION_API_BASE}/search`, {
154 method: 'POST',
155 headers: headers(apiKey),
156 body: JSON.stringify({
157 page_size: 50,
158 ...(startCursor ? { start_cursor: startCursor } : {}),
159 sort: { direction: 'descending', timestamp: 'last_edited_time' },
160 }),
161 });
162 const body = await response.json();
163 const retryAfter = Number.parseFloat(response.headers.get('retry-after') ?? '');
164 return {
165 ...body,
166 status: response.status,
167 ...(Number.isFinite(retryAfter) ? { retryAfterMs: retryAfter * 1000 } : {}),
168 };
169 },
170 fetchPageMarkdown: ({ pageId, apiKey }) => fetchNotionPageMarkdown(pageId, { apiKey, fetchImpl }),
171 };
172 }
173
174 export function createFakeNotionClient(fixtures = {}) {
175 return {
176 search: async (args) => typeof fixtures.search === 'function'
177 ? fixtures.search(args)
178 : { results: fixtures.results ?? [], next_cursor: fixtures.next_cursor ?? null, has_more: false, status: 200 },
179 fetchPageMarkdown: async (args) => typeof fixtures.fetchPageMarkdown === 'function'
180 ? fixtures.fetchPageMarkdown(args)
181 : fixtures.markdownByPage?.[args.pageId] ?? '',
182 };
183 }
184
185 function notionClient(ctx) {
186 return ctx.notionClient ?? createProductionNotionClient({ fetchImpl: ctx.fetchImpl });
187 }
188
189 export async function handleListNotionConnectorFiles(ctx) {
190 if (!isDocsNotionHubKeyEnabled({ authorizedOverride: ctx.authorizedOverride })) return notAuthorized();
191 const found = connectedNotion(ctx);
192 if (found.response) return found.response;
193 const pageToken = ctx.query?.page_token;
194 if (pageToken !== undefined && (typeof pageToken !== 'string' || pageToken.length > 2048)) {
195 return result(400, 'BAD_REQUEST');
196 }
197 const client = notionClient(ctx);
198 const response = await searchWithBackoff(
199 ctx,
200 client,
201 { apiKey: found.apiKey, ...(pageToken ? { startCursor: pageToken } : {}) },
202 );
203 if (response.status === 429) return result(429, 'RATE_LIMITED');
204 if (response.status && response.status >= 400) return result(502, 'PROVIDER_ERROR');
205 const files = (Array.isArray(response.results) ? response.results : []).slice(0, 50).map(normalizeNotionResult);
206 found.connector.file_count = files.length;
207 found.connector.last_sync_error = 'none';
208 saveConnector(ctx.dataDir, ctx.vaultId, found.connector);
209 return {
210 ok: true,
211 status: 200,
212 payload: {
213 files,
214 ...(typeof response.next_cursor === 'string' && response.next_cursor
215 ? { next_page_token: response.next_cursor }
216 : {}),
217 },
218 };
219 }
220
221 async function fetchNotionItems(ctx, found, pageIds) {
222 const client = notionClient(ctx);
223 const items = [];
224 const skips = [];
225 let batchBytes = 0;
226 for (const pageId of pageIds) {
227 let markdown;
228 try {
229 markdown = await client.fetchPageMarkdown({ pageId, apiKey: found.apiKey });
230 } catch {
231 skips.push({ source_id: pageId, reason: 'not_found' });
232 continue;
233 }
234 const size = Buffer.byteLength(typeof markdown === 'string' ? markdown : '', 'utf8');
235 if (size > 25_000_000) {
236 skips.push({ source_id: pageId, reason: 'too_large' });
237 continue;
238 }
239 if (!markdown.trim()) {
240 skips.push({ source_id: pageId, reason: 'empty_extract' });
241 continue;
242 }
243 batchBytes += size;
244 if (batchBytes > 80_000_000) throw Object.assign(new TypeError('import batch exceeds byte cap'), { code: 'BAD_REQUEST' });
245 items.push({ source_id: pageId, name: pageId, markdown, size });
246 }
247 return { items, skips };
248 }
249
250 export async function handleImportNotionConnectorFiles(ctx) {
251 if (!isDocsNotionHubKeyEnabled({ authorizedOverride: ctx.authorizedOverride })) return notAuthorized();
252 const found = connectedNotion(ctx);
253 if (found.response) return found.response;
254 const body = ctx.body && typeof ctx.body === 'object' && !Array.isArray(ctx.body) ? ctx.body : null;
255 if (!body || Object.keys(body).some((key) => key !== 'file_ids')) return result(400, 'BAD_REQUEST');
256 const pageIds = body.file_ids;
257 if (!Array.isArray(pageIds) || pageIds.length < 1 || pageIds.length > 20 || !pageIds.every((id) => NOTION_PAGE_ID_RE.test(id))) {
258 return result(400, 'BAD_REQUEST');
259 }
260 let fetched;
261 try {
262 fetched = await fetchNotionItems(ctx, found, pageIds);
263 } catch (error) {
264 return error?.code === 'BAD_REQUEST' ? result(400, 'BAD_REQUEST') : result(502, 'PROVIDER_ERROR');
265 }
266 const proposed = fetched.items.length
267 ? proposeDocsImports({
268 dataDir: ctx.dataDir,
269 vaultPath: ctx.vaultPath,
270 vaultId: ctx.vaultId,
271 connectorId: ctx.connectorId,
272 provider: 'notion',
273 items: fetched.items,
274 now: ctx.now,
275 createProposalFn: ctx.createProposalFn,
276 loadProposalsFn: ctx.loadProposalsFn,
277 listMarkdownFilesFn: ctx.listMarkdownFilesFn,
278 readNoteFn: ctx.readNoteFn,
279 })
280 : { proposed: 0, skipped: 0, proposal_ids: [], skip_details: [] };
281 const skipDetails = [...fetched.skips, ...proposed.skip_details];
282 return {
283 ok: true,
284 status: 200,
285 payload: {
286 proposed: proposed.proposed,
287 skipped: skipDetails.length,
288 proposal_ids: proposed.proposal_ids,
289 skip_details: skipDetails,
290 },
291 };
292 }
293
294 export async function handleSyncNotionConnector(ctx) {
295 if (!isDocsNotionHubKeyEnabled({ authorizedOverride: ctx.authorizedOverride })) return notAuthorized();
296 const found = connectedNotion(ctx);
297 if (found.response) return found.response;
298 const now = ctx.now ?? Date.now();
299 const last = found.connector.last_sync_at ? Date.parse(found.connector.last_sync_at) : 0;
300 if (activeSyncs.has(ctx.connectorId) || (Number.isFinite(last) && now - last < 60_000)) {
301 return result(429, 'RATE_LIMITED');
302 }
303 activeSyncs.add(ctx.connectorId);
304 try {
305 const client = notionClient(ctx);
306 const response = await searchWithBackoff(ctx, client, {
307 apiKey: found.apiKey,
308 ...(found.connector.sync_cursor ? { startCursor: found.connector.sync_cursor } : {}),
309 });
310 if (response.status === 429) return result(429, 'RATE_LIMITED');
311 if (response.status && response.status >= 400) return result(502, 'PROVIDER_ERROR');
312 const pages = (response.results ?? []).filter((row) => row?.object === 'page').slice(0, 20);
313 const ids = pages.map((row) => row.id).filter((id) => NOTION_PAGE_ID_RE.test(id));
314 const fetched = await fetchNotionItems(ctx, found, ids);
315 const proposed = fetched.items.length
316 ? proposeDocsImports({
317 dataDir: ctx.dataDir,
318 vaultPath: ctx.vaultPath,
319 vaultId: ctx.vaultId,
320 connectorId: ctx.connectorId,
321 provider: 'notion',
322 items: fetched.items,
323 now,
324 createProposalFn: ctx.createProposalFn,
325 loadProposalsFn: ctx.loadProposalsFn,
326 listMarkdownFilesFn: ctx.listMarkdownFilesFn,
327 readNoteFn: ctx.readNoteFn,
328 })
329 : { proposed: 0, skipped: 0 };
330 found.connector.sync_cursor = typeof response.next_cursor === 'string' ? response.next_cursor : null;
331 found.connector.last_sync_at = new Date(now).toISOString();
332 found.connector.last_sync_error = 'none';
333 found.connector.file_count = pages.length;
334 saveConnector(ctx.dataDir, ctx.vaultId, found.connector);
335 return {
336 ok: true,
337 status: 200,
338 payload: {
339 proposed: proposed.proposed,
340 skipped: fetched.skips.length + proposed.skipped,
341 last_sync_at: found.connector.last_sync_at,
342 },
343 };
344 } catch {
345 found.connector.last_sync_error = 'network_error';
346 saveConnector(ctx.dataDir, ctx.vaultId, found.connector);
347 return result(502, 'PROVIDER_ERROR');
348 } finally {
349 activeSyncs.delete(ctx.connectorId);
350 }
351 }
352
353 export function handleRevokeNotionConnector(ctx) {
354 if (!isDocsNotionHubKeyEnabled({ authorizedOverride: ctx.authorizedOverride })) return notAuthorized();
355 const connector = getConnector(ctx.dataDir, ctx.vaultId, ctx.connectorId);
356 if (!connector || connector.status === 'revoked') return result(404, 'CONNECTOR_NOT_FOUND');
357 if (connector.provider !== 'notion') return result(400, 'PROVIDER_DENIED');
358 connector.status = 'revoked';
359 connector.revoked_at = new Date(ctx.now ?? Date.now()).toISOString();
360 connector.sync_cursor = null;
361 connector.oauth_ref = null;
362 connector.oauth_pending = null;
363 saveConnector(ctx.dataDir, ctx.vaultId, connector);
364 return { ok: true, status: 200, payload: { revoked: true } };
365 }
366
367 export const handleListNotionConnectorPages = handleListNotionConnectorFiles;
368 export const handleImportNotionConnectorPages = handleImportNotionConnectorFiles;
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago