google-drive-connector.mjs
780 lines 30.9 KB
Raw
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago
1 /**
2 * Google Drive readonly OAuth connector.
3 *
4 * Gated by DOCS_OAUTH_GOOGLE_AUTHORIZED (compile-time; Tier 3 flip 2026-08-17).
5 * Tests may inject `authorizedOverride`; production routes must never pass it.
6 */
7
8 import crypto from 'crypto';
9 import {
10 createOAuthState,
11 createPkcePair,
12 constantTimeEqual,
13 PKCE_METHOD_S256,
14 validateAuthorizationResponse,
15 validateTokenResponse,
16 } from '../companion-oauth-pkce.mjs';
17 import { docxBytesToMarkdown } from '../importers/docx.mjs';
18 import { pdfBytesToMarkdown } from '../importers/pdf.mjs';
19 import {
20 connectorForClient,
21 findPendingByState,
22 getConnector,
23 listConnectors,
24 newConnectorId,
25 saveConnector,
26 } from './docs-connector-store.mjs';
27 import { proposeDocsImports } from './docs-import-propose.mjs';
28 import {
29 buildDriveNameContainsQuery,
30 DRIVE_FILE_ID_RE,
31 isImportableMime,
32 LIST_Q_RE,
33 } from './google-drive-normalizer.mjs';
34 import {
35 deleteOAuthTokenVault,
36 readOAuthTokenVault,
37 writeOAuthTokenVault,
38 } from './oauth-token-vault.mjs';
39
40 /** Tier 3 compile-time gate — flipped 2026-08-17 (operator-authorized Drive OAuth). */
41 export const DOCS_OAUTH_GOOGLE_AUTHORIZED = true;
42 export const GOOGLE_DRIVE_OAUTH_SCOPES = Object.freeze([
43 'openid',
44 'https://www.googleapis.com/auth/drive.readonly',
45 ]);
46 export const DOCS_CONNECTOR_SYNC_RATE_LIMIT_MS = 60_000;
47 export const DOCS_OAUTH_STATE_TTL_MS = 10 * 60_000;
48 export const DOCS_LIST_PAGE_SIZE = 50;
49
50 const AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/v2/auth';
51 const TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token';
52 const REVOKE_ENDPOINT = 'https://oauth2.googleapis.com/revoke';
53 const USERINFO_ENDPOINT = 'https://openidconnect.googleapis.com/v1/userinfo';
54 const DRIVE_API = 'https://www.googleapis.com/drive/v3';
55 const activeSyncs = new Set();
56
57 export function isDocsGoogleOAuthEnabled({ authorizedOverride } = {}) {
58 if (authorizedOverride === true) return true;
59 if (authorizedOverride === false) return false;
60 return DOCS_OAUTH_GOOGLE_AUTHORIZED === true;
61 }
62
63 export function readDocsGoogleOAuthEnv(env = process.env) {
64 const value = (name) => typeof env?.[name] === 'string' ? env[name].trim() : '';
65 return {
66 clientId: value('GOOGLE_DRIVE_OAUTH_CLIENT_ID'),
67 clientSecret: value('GOOGLE_DRIVE_OAUTH_CLIENT_SECRET'),
68 vaultSecret: value('KNOWTATION_DOCS_OAUTH_SECRET'),
69 redirectUri: value('DOCS_OAUTH_REDIRECT_URI'),
70 returnAllowlist: value('SCOOLING_RETURN_URL_ALLOWLIST')
71 .split(',')
72 .map((row) => row.trim())
73 .filter(Boolean),
74 };
75 }
76
77 function envComplete(env) {
78 return Boolean(env.clientId && env.clientSecret && env.vaultSecret && env.redirectUri);
79 }
80
81 function result(status, code) {
82 return { ok: false, status, code };
83 }
84
85 function notAuthorized() {
86 return result(501, 'NOT_AUTHORIZED');
87 }
88
89 async function sleep(ms) {
90 if (ms > 0) await new Promise((resolve) => setTimeout(resolve, ms));
91 }
92
93 async function providerCall(ctx, operation) {
94 for (let attempt = 0; attempt < 3; attempt++) {
95 const response = await operation();
96 if (response?.status !== 429) return response;
97 if (attempt < 2) {
98 const wait = Number.isFinite(response.retryAfterMs)
99 ? Math.max(0, Math.min(response.retryAfterMs, 60_000))
100 : 0;
101 await (ctx.sleepFn ?? sleep)(wait);
102 }
103 }
104 return { status: 429 };
105 }
106
107 function isReturnUrlAllowed(returnUrl, allowlist) {
108 return typeof returnUrl === 'string'
109 && returnUrl.length > 0
110 && allowlist.some((allowed) => constantTimeEqual(returnUrl, allowed));
111 }
112
113 export function buildDocsOAuthStateBinding(vaultId, connectorId, returnUrl) {
114 return crypto.createHash('sha256')
115 .update(`${vaultId}:${connectorId}:${returnUrl}`, 'utf8')
116 .digest('base64url');
117 }
118
119 export function buildDocsGoogleAuthorizationUrl({ clientId, redirectUri, state, codeChallenge }) {
120 const url = new URL(AUTH_ENDPOINT);
121 url.searchParams.set('response_type', 'code');
122 url.searchParams.set('client_id', clientId);
123 url.searchParams.set('redirect_uri', redirectUri);
124 url.searchParams.set('scope', GOOGLE_DRIVE_OAUTH_SCOPES.join(' '));
125 url.searchParams.set('state', state);
126 url.searchParams.set('code_challenge', codeChallenge);
127 url.searchParams.set('code_challenge_method', PKCE_METHOD_S256);
128 url.searchParams.set('access_type', 'offline');
129 url.searchParams.set('prompt', 'consent');
130 return url.toString();
131 }
132
133 /**
134 * Begin a confidential-web-client + PKCE S256 consent.
135 */
136 export function handleBeginDocsConnector(ctx) {
137 if (!isDocsGoogleOAuthEnabled({ authorizedOverride: ctx.authorizedOverride })) return notAuthorized();
138 const env = readDocsGoogleOAuthEnv(ctx.env);
139 if (!envComplete(env)) return result(503, 'NOT_CONFIGURED');
140 const body = ctx.body && typeof ctx.body === 'object' && !Array.isArray(ctx.body) ? ctx.body : null;
141 if (!body) return result(400, 'BAD_REQUEST');
142 const allowedKeys = new Set(['provider', 'display_name', 'return_url']);
143 if (Object.keys(body).some((key) => !allowedKeys.has(key))) return result(400, 'BAD_REQUEST');
144 if (body.provider !== 'google-drive') return result(400, 'PROVIDER_DENIED');
145 const returnUrl = typeof body.return_url === 'string' ? body.return_url.trim() : '';
146 if (!isReturnUrlAllowed(returnUrl, env.returnAllowlist)) return result(400, 'RETURN_URL_DENIED');
147
148 const now = ctx.now ?? Date.now();
149 const connectorId = newConnectorId();
150 const pkce = createPkcePair();
151 const state = createOAuthState();
152 const expiresAt = new Date(now + DOCS_OAUTH_STATE_TTL_MS).toISOString();
153 saveConnector(ctx.dataDir, ctx.vaultId, {
154 connector_id: connectorId,
155 provider: 'google-drive',
156 display_name: typeof body.display_name === 'string' && body.display_name.trim()
157 ? body.display_name.trim().slice(0, 128)
158 : 'Google Drive',
159 status: 'pending',
160 account_sub: null,
161 oauth_ref: null,
162 sync_cursor: null,
163 last_sync_at: null,
164 last_sync_error: 'none',
165 file_count: 0,
166 revoked_at: null,
167 oauth_pending: {
168 state,
169 code_verifier: pkce.codeVerifier,
170 return_url: returnUrl,
171 state_binding: buildDocsOAuthStateBinding(ctx.vaultId, connectorId, returnUrl),
172 expires_at: expiresAt,
173 },
174 });
175 return {
176 ok: true,
177 status: 200,
178 payload: {
179 connector_id: connectorId,
180 authorization_url: buildDocsGoogleAuthorizationUrl({
181 clientId: env.clientId,
182 redirectUri: env.redirectUri,
183 state,
184 codeChallenge: pkce.codeChallenge,
185 }),
186 expires_at: expiresAt,
187 },
188 };
189 }
190
191 function redirectResult(returnUrl, reason, code) {
192 if (typeof returnUrl !== 'string' || !returnUrl) {
193 return { ok: false, status: 400, redirect: null, code };
194 }
195 const url = new URL(returnUrl);
196 url.searchParams.set('connect', 'error');
197 url.searchParams.set('reason', reason);
198 return { ok: false, status: 302, redirect: url.toString(), code };
199 }
200
201 async function exchangeCode(client, env, code, verifier) {
202 const response = await client.fetch({
203 url: TOKEN_ENDPOINT,
204 method: 'POST',
205 headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
206 body: new URLSearchParams({
207 grant_type: 'authorization_code',
208 code,
209 redirect_uri: env.redirectUri,
210 client_id: env.clientId,
211 client_secret: env.clientSecret,
212 code_verifier: verifier,
213 }).toString(),
214 });
215 if (!response.ok) return null;
216 const validated = validateTokenResponse(await response.json());
217 return validated.ok ? validated : null;
218 }
219
220 async function fetchAccountSub(client, accessToken) {
221 const response = await client.fetch({
222 url: USERINFO_ENDPOINT,
223 headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
224 });
225 if (!response.ok) return null;
226 const body = await response.json();
227 return body && typeof body.sub === 'string' && body.sub ? body.sub : null;
228 }
229
230 /**
231 * Complete consent. Every located state is consumed exactly once.
232 */
233 export async function handleDocsConnectorCallback(ctx) {
234 if (!isDocsGoogleOAuthEnabled({ authorizedOverride: ctx.authorizedOverride })) {
235 return { ...notAuthorized(), redirect: null };
236 }
237 const env = readDocsGoogleOAuthEnv(ctx.env);
238 const fallback = env.returnAllowlist[0];
239 if (!envComplete(env)) {
240 return fallback
241 ? redirectResult(fallback, 'not_configured', 'NOT_CONFIGURED')
242 : { ...result(503, 'NOT_CONFIGURED'), redirect: null };
243 }
244 const state = typeof ctx.query?.state === 'string' ? ctx.query.state : '';
245 const located = findPendingByState(ctx.dataDir, state);
246 if (!located) return redirectResult(fallback, 'state_invalid', 'STATE_INVALID');
247 const { vaultId, connector } = located;
248 const pending = connector.oauth_pending;
249 connector.oauth_pending = null;
250 saveConnector(ctx.dataDir, vaultId, connector);
251
252 const now = ctx.now ?? Date.now();
253 const validExpiry = typeof pending?.expires_at === 'string' && Date.parse(pending.expires_at) > now;
254 const validBinding = constantTimeEqual(
255 pending?.state_binding,
256 buildDocsOAuthStateBinding(vaultId, connector.connector_id, pending?.return_url ?? ''),
257 );
258 if (!validExpiry || !validBinding || !isReturnUrlAllowed(pending?.return_url, env.returnAllowlist)) {
259 return redirectResult(
260 isReturnUrlAllowed(pending?.return_url, env.returnAllowlist) ? pending.return_url : fallback,
261 'state_invalid',
262 'STATE_INVALID',
263 );
264 }
265 const auth = validateAuthorizationResponse({ params: ctx.query, expectedState: pending.state });
266 if (!auth.ok) {
267 const denied = auth.errorCode === 'access_denied';
268 return redirectResult(pending.return_url, denied ? 'denied' : 'provider_error', denied ? 'PROVIDER_DENIED' : 'PROVIDER_ERROR');
269 }
270 const tokens = await exchangeCode(ctx.googleClient, env, auth.code, pending.code_verifier);
271 if (!tokens || !tokens.refreshToken) {
272 connector.status = 'needs_reauth';
273 connector.last_sync_error = 'provider_error';
274 saveConnector(ctx.dataDir, vaultId, connector);
275 return redirectResult(pending.return_url, 'provider_error', 'PROVIDER_ERROR');
276 }
277 const accountSub = await fetchAccountSub(ctx.googleClient, tokens.accessToken);
278 if (!accountSub) {
279 connector.status = 'needs_reauth';
280 connector.last_sync_error = 'provider_error';
281 saveConnector(ctx.dataDir, vaultId, connector);
282 return redirectResult(pending.return_url, 'provider_error', 'PROVIDER_ERROR');
283 }
284 writeOAuthTokenVault(ctx.dataDir, connector.connector_id, env.vaultSecret, {
285 refresh_token: tokens.refreshToken,
286 scope: tokens.scope ?? GOOGLE_DRIVE_OAUTH_SCOPES.join(' '),
287 token_type: tokens.tokenType,
288 obtained_at: new Date(now).toISOString(),
289 account_sub: accountSub,
290 });
291 connector.status = 'connected';
292 connector.oauth_ref = connector.connector_id;
293 connector.account_sub = accountSub;
294 connector.last_sync_error = 'none';
295 saveConnector(ctx.dataDir, vaultId, connector);
296 const url = new URL(pending.return_url);
297 url.searchParams.set('connect', 'ok');
298 return { ok: true, status: 302, redirect: url.toString(), code: 'OK' };
299 }
300
301 async function refreshAccess(ctx, connector, env) {
302 let token;
303 try {
304 token = readOAuthTokenVault(ctx.dataDir, connector.connector_id, env.vaultSecret);
305 } catch {
306 connector.status = 'needs_reauth';
307 connector.last_sync_error = 'auth_expired';
308 saveConnector(ctx.dataDir, ctx.vaultId, connector);
309 return { ok: false, response: result(409, 'NEEDS_REAUTH') };
310 }
311 const response = await ctx.googleClient.fetch({
312 url: TOKEN_ENDPOINT,
313 method: 'POST',
314 headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
315 body: new URLSearchParams({
316 grant_type: 'refresh_token',
317 refresh_token: token.refresh_token,
318 client_id: env.clientId,
319 client_secret: env.clientSecret,
320 }).toString(),
321 });
322 const json = await response.json();
323 const validated = response.ok ? validateTokenResponse(json) : { ok: false, errorCode: json?.error };
324 if (!validated.ok) {
325 if (validated.errorCode === 'invalid_grant' || json?.error === 'invalid_grant') {
326 connector.status = 'needs_reauth';
327 connector.last_sync_error = 'auth_expired';
328 saveConnector(ctx.dataDir, ctx.vaultId, connector);
329 return { ok: false, response: result(409, 'NEEDS_REAUTH') };
330 }
331 connector.last_sync_error = response.status === 429 ? 'rate_limited' : 'provider_error';
332 saveConnector(ctx.dataDir, ctx.vaultId, connector);
333 return {
334 ok: false,
335 response: response.status === 429 ? result(429, 'RATE_LIMITED') : result(502, 'PROVIDER_ERROR'),
336 };
337 }
338 return { ok: true, accessToken: validated.accessToken };
339 }
340
341 function connectedConnector(ctx) {
342 const connector = getConnector(ctx.dataDir, ctx.vaultId, ctx.connectorId);
343 if (!connector || connector.status === 'revoked') return { response: result(404, 'CONNECTOR_NOT_FOUND') };
344 if (connector.status === 'needs_reauth') return { response: result(409, 'NEEDS_REAUTH') };
345 if (connector.status !== 'connected') return { response: result(400, 'BAD_REQUEST') };
346 if (connector.provider !== 'google-drive') return { response: result(400, 'PROVIDER_DENIED') };
347 return { connector };
348 }
349
350 function normalizeFile(row) {
351 const size = Number.parseInt(row?.size ?? '0', 10);
352 return {
353 file_id: typeof row?.id === 'string' ? row.id : '',
354 name: typeof row?.name === 'string' ? row.name.slice(0, 512) : 'Untitled',
355 mime: typeof row?.mimeType === 'string' ? row.mimeType : '',
356 modified: typeof row?.modifiedTime === 'string' ? row.modifiedTime : null,
357 size: Number.isFinite(size) && size >= 0 ? size : 0,
358 importable: isImportableMime(row?.mimeType),
359 };
360 }
361
362 export function handleListDocsConnectors(ctx) {
363 if (!isDocsGoogleOAuthEnabled({ authorizedOverride: ctx.authorizedOverride })) return notAuthorized();
364 return {
365 ok: true,
366 status: 200,
367 payload: {
368 schema: 'knowtation.docs_connectors/v0',
369 connectors: listConnectors(ctx.dataDir, ctx.vaultId)
370 .filter((connector) => connector.provider === 'google-drive')
371 .map(connectorForClient),
372 },
373 };
374 }
375
376 export async function handleListDocsConnectorFiles(ctx) {
377 if (!isDocsGoogleOAuthEnabled({ authorizedOverride: ctx.authorizedOverride })) return notAuthorized();
378 const found = connectedConnector(ctx);
379 if (found.response) return found.response;
380 const q = ctx.query?.q;
381 if (q !== undefined && (typeof q !== 'string' || !LIST_Q_RE.test(q))) return result(400, 'BAD_REQUEST');
382 const pageToken = ctx.query?.page_token;
383 if (pageToken !== undefined && (typeof pageToken !== 'string' || pageToken.length > 2048)) {
384 return result(400, 'BAD_REQUEST');
385 }
386 const env = readDocsGoogleOAuthEnv(ctx.env);
387 if (!envComplete(env)) return result(503, 'NOT_CONFIGURED');
388 const token = await refreshAccess(ctx, found.connector, env);
389 if (!token.ok) return token.response;
390 const listed = await providerCall(ctx, () => ctx.googleClient.filesList({
391 accessToken: token.accessToken,
392 ...(pageToken ? { pageToken } : {}),
393 ...(q ? { q: buildDriveNameContainsQuery(q) } : {}),
394 }));
395 if (listed.status === 429) return result(429, 'RATE_LIMITED');
396 if (listed.status && listed.status >= 400) return result(502, 'PROVIDER_ERROR');
397 const files = (listed.files ?? listed.items ?? []).slice(0, DOCS_LIST_PAGE_SIZE).map(normalizeFile);
398 found.connector.file_count = files.length;
399 found.connector.last_sync_error = 'none';
400 saveConnector(ctx.dataDir, ctx.vaultId, found.connector);
401 return {
402 ok: true,
403 status: 200,
404 payload: {
405 files,
406 ...(listed.nextPageToken ? { next_page_token: listed.nextPageToken } : {}),
407 },
408 };
409 }
410
411 function bytesOf(value) {
412 if (Buffer.isBuffer(value)) return value;
413 if (value instanceof Uint8Array) return Buffer.from(value);
414 if (value && Buffer.isBuffer(value.bytes)) return value.bytes;
415 if (value?.bytes instanceof Uint8Array) return Buffer.from(value.bytes);
416 return Buffer.from(typeof value === 'string' ? value : value?.body ?? '', 'utf8');
417 }
418
419 async function driveFileMarkdown(client, accessToken, meta) {
420 const mime = meta.mimeType;
421 if (!isImportableMime(mime)) return { ok: false, reason: 'unsupported_mime' };
422 let bytes;
423 if (mime === 'application/vnd.google-apps.document') {
424 try {
425 bytes = bytesOf(await client.filesExport({ accessToken, fileId: meta.id, mimeType: 'text/markdown' }));
426 if (!bytes.toString('utf8').trim()) {
427 bytes = bytesOf(await client.filesExport({ accessToken, fileId: meta.id, mimeType: 'text/plain' }));
428 }
429 } catch {
430 return { ok: false, reason: 'provider_error' };
431 }
432 const markdown = bytes.toString('utf8').trim();
433 return markdown ? { ok: true, markdown, size: bytes.length } : { ok: false, reason: 'empty_extract' };
434 }
435 try {
436 bytes = bytesOf(await client.filesDownload({ accessToken, fileId: meta.id }));
437 } catch {
438 return { ok: false, reason: 'provider_error' };
439 }
440 if (bytes.length > 25_000_000) return { ok: false, reason: 'too_large' };
441 if (mime === 'application/pdf') {
442 const converted = await pdfBytesToMarkdown(bytes);
443 return converted.ok ? { ok: true, markdown: converted.markdown, size: bytes.length } : converted;
444 }
445 if (mime === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
446 const converted = await docxBytesToMarkdown(bytes);
447 return converted.ok ? { ok: true, markdown: converted.markdown, size: bytes.length } : converted;
448 }
449 const markdown = bytes.toString('utf8').trim();
450 return markdown ? { ok: true, markdown, size: bytes.length } : { ok: false, reason: 'empty_extract' };
451 }
452
453 async function fetchImportItems(ctx, accessToken, fileIds) {
454 const items = [];
455 const skips = [];
456 let batchBytes = 0;
457 for (const fileId of fileIds) {
458 let meta;
459 try {
460 meta = await providerCall(ctx, () => ctx.googleClient.filesGet({ accessToken, fileId }));
461 } catch {
462 skips.push({ source_id: fileId, reason: 'not_found' });
463 continue;
464 }
465 if (meta?.status === 429) {
466 throw Object.assign(new Error('provider rate limited'), { code: 'RATE_LIMITED' });
467 }
468 if (!meta || meta.status === 404 || typeof meta.id !== 'string') {
469 skips.push({ source_id: fileId, reason: 'not_found' });
470 continue;
471 }
472 if (!isImportableMime(meta.mimeType)) {
473 skips.push({ source_id: fileId, reason: 'unsupported_mime' });
474 continue;
475 }
476 const declaredSize = Number.parseInt(meta.size ?? '0', 10);
477 if (Number.isFinite(declaredSize) && declaredSize > 25_000_000) {
478 skips.push({ source_id: fileId, reason: 'too_large' });
479 continue;
480 }
481 const content = await driveFileMarkdown(ctx.googleClient, accessToken, meta);
482 if (!content.ok) {
483 skips.push({ source_id: fileId, reason: content.reason });
484 continue;
485 }
486 batchBytes += content.size;
487 if (batchBytes > 80_000_000) throw Object.assign(new TypeError('import batch exceeds byte cap'), { code: 'BAD_REQUEST' });
488 items.push({
489 source_id: fileId,
490 name: typeof meta.name === 'string' ? meta.name : fileId,
491 markdown: content.markdown,
492 size: content.size,
493 });
494 }
495 return { items, skips };
496 }
497
498 export async function handleImportDocsConnectorFiles(ctx) {
499 if (!isDocsGoogleOAuthEnabled({ authorizedOverride: ctx.authorizedOverride })) return notAuthorized();
500 const found = connectedConnector(ctx);
501 if (found.response) return found.response;
502 const body = ctx.body && typeof ctx.body === 'object' && !Array.isArray(ctx.body) ? ctx.body : null;
503 if (!body || Object.keys(body).some((key) => key !== 'file_ids')) return result(400, 'BAD_REQUEST');
504 const fileIds = body.file_ids;
505 if (!Array.isArray(fileIds) || fileIds.length < 1 || fileIds.length > 20 || !fileIds.every((id) => DRIVE_FILE_ID_RE.test(id))) {
506 return result(400, 'BAD_REQUEST');
507 }
508 const env = readDocsGoogleOAuthEnv(ctx.env);
509 if (!envComplete(env)) return result(503, 'NOT_CONFIGURED');
510 const token = await refreshAccess(ctx, found.connector, env);
511 if (!token.ok) return token.response;
512 let fetched;
513 try {
514 fetched = await fetchImportItems(ctx, token.accessToken, fileIds);
515 } catch (error) {
516 if (error?.code === 'BAD_REQUEST') return result(400, 'BAD_REQUEST');
517 if (error?.code === 'RATE_LIMITED') return result(429, 'RATE_LIMITED');
518 return result(502, 'PROVIDER_ERROR');
519 }
520 let proposed = { proposed: 0, skipped: 0, proposal_ids: [], skip_details: [] };
521 if (fetched.items.length) {
522 proposed = proposeDocsImports({
523 dataDir: ctx.dataDir,
524 vaultPath: ctx.vaultPath,
525 vaultId: ctx.vaultId,
526 connectorId: ctx.connectorId,
527 provider: 'google-drive',
528 items: fetched.items,
529 now: ctx.now,
530 createProposalFn: ctx.createProposalFn,
531 loadProposalsFn: ctx.loadProposalsFn,
532 listMarkdownFilesFn: ctx.listMarkdownFilesFn,
533 readNoteFn: ctx.readNoteFn,
534 });
535 }
536 const skipDetails = [...fetched.skips, ...proposed.skip_details];
537 return {
538 ok: true,
539 status: 200,
540 payload: {
541 proposed: proposed.proposed,
542 skipped: skipDetails.length,
543 proposal_ids: proposed.proposal_ids,
544 skip_details: skipDetails,
545 },
546 };
547 }
548
549 async function syncRows(ctx, connector, accessToken) {
550 let rows;
551 let nextCursor;
552 if (!connector.sync_cursor) {
553 const listed = await providerCall(ctx, () => ctx.googleClient.filesList({ accessToken }));
554 if (listed.status === 429) return { response: result(429, 'RATE_LIMITED') };
555 rows = listed.files ?? listed.items ?? [];
556 const start = await providerCall(ctx, () => ctx.googleClient.changesGetStartPageToken({ accessToken }));
557 if (start.status === 429) return { response: result(429, 'RATE_LIMITED') };
558 nextCursor = start.startPageToken ?? start.token ?? null;
559 } else {
560 let changes = await providerCall(
561 ctx,
562 () => ctx.googleClient.changesList({ accessToken, pageToken: connector.sync_cursor }),
563 );
564 if (changes.status === 410) {
565 const listed = await providerCall(ctx, () => ctx.googleClient.filesList({ accessToken }));
566 rows = listed.files ?? listed.items ?? [];
567 const start = await providerCall(ctx, () => ctx.googleClient.changesGetStartPageToken({ accessToken }));
568 if (start.status === 429) return { response: result(429, 'RATE_LIMITED') };
569 nextCursor = start.startPageToken ?? start.token ?? null;
570 } else {
571 if (changes.status === 429) return { response: result(429, 'RATE_LIMITED') };
572 rows = (changes.changes ?? []).map((change) => change.file).filter(Boolean);
573 nextCursor = changes.newStartPageToken ?? changes.nextPageToken ?? connector.sync_cursor;
574 }
575 }
576 return { rows: rows.slice(0, 20), nextCursor };
577 }
578
579 export async function handleSyncDocsConnector(ctx) {
580 if (!isDocsGoogleOAuthEnabled({ authorizedOverride: ctx.authorizedOverride })) return notAuthorized();
581 const found = connectedConnector(ctx);
582 if (found.response) return found.response;
583 const now = ctx.now ?? Date.now();
584 const last = found.connector.last_sync_at ? Date.parse(found.connector.last_sync_at) : 0;
585 if (activeSyncs.has(ctx.connectorId) || (Number.isFinite(last) && now - last < DOCS_CONNECTOR_SYNC_RATE_LIMIT_MS)) {
586 return result(429, 'RATE_LIMITED');
587 }
588 const env = readDocsGoogleOAuthEnv(ctx.env);
589 if (!envComplete(env)) return result(503, 'NOT_CONFIGURED');
590 activeSyncs.add(ctx.connectorId);
591 try {
592 const token = await refreshAccess(ctx, found.connector, env);
593 if (!token.ok) return token.response;
594 const sync = await syncRows(ctx, found.connector, token.accessToken);
595 if (sync.response) {
596 found.connector.last_sync_error = 'rate_limited';
597 saveConnector(ctx.dataDir, ctx.vaultId, found.connector);
598 return sync.response;
599 }
600 const ids = sync.rows
601 .map((row) => row?.id)
602 .filter((id) => typeof id === 'string' && DRIVE_FILE_ID_RE.test(id));
603 const fetched = await fetchImportItems(ctx, token.accessToken, ids);
604 const proposed = fetched.items.length
605 ? proposeDocsImports({
606 dataDir: ctx.dataDir,
607 vaultPath: ctx.vaultPath,
608 vaultId: ctx.vaultId,
609 connectorId: ctx.connectorId,
610 provider: 'google-drive',
611 items: fetched.items,
612 now,
613 createProposalFn: ctx.createProposalFn,
614 loadProposalsFn: ctx.loadProposalsFn,
615 listMarkdownFilesFn: ctx.listMarkdownFilesFn,
616 readNoteFn: ctx.readNoteFn,
617 })
618 : { proposed: 0, skipped: 0, proposal_ids: [], skip_details: [] };
619 found.connector.sync_cursor = typeof sync.nextCursor === 'string' ? sync.nextCursor : found.connector.sync_cursor;
620 found.connector.last_sync_at = new Date(now).toISOString();
621 found.connector.last_sync_error = 'none';
622 found.connector.file_count = sync.rows.length;
623 saveConnector(ctx.dataDir, ctx.vaultId, found.connector);
624 return {
625 ok: true,
626 status: 200,
627 payload: {
628 proposed: proposed.proposed,
629 skipped: fetched.skips.length + proposed.skipped,
630 last_sync_at: found.connector.last_sync_at,
631 },
632 };
633 } catch (error) {
634 const rateLimited = error?.code === 'RATE_LIMITED';
635 found.connector.last_sync_error = rateLimited ? 'rate_limited' : 'provider_error';
636 saveConnector(ctx.dataDir, ctx.vaultId, found.connector);
637 return rateLimited ? result(429, 'RATE_LIMITED') : result(502, 'PROVIDER_ERROR');
638 } finally {
639 activeSyncs.delete(ctx.connectorId);
640 }
641 }
642
643 export async function handleRevokeDocsConnector(ctx) {
644 if (!isDocsGoogleOAuthEnabled({ authorizedOverride: ctx.authorizedOverride })) return notAuthorized();
645 const connector = getConnector(ctx.dataDir, ctx.vaultId, ctx.connectorId);
646 if (!connector || connector.status === 'revoked') return result(404, 'CONNECTOR_NOT_FOUND');
647 if (connector.provider !== 'google-drive') return result(400, 'PROVIDER_DENIED');
648 const env = readDocsGoogleOAuthEnv(ctx.env);
649 if (env.vaultSecret) {
650 try {
651 const token = readOAuthTokenVault(ctx.dataDir, ctx.connectorId, env.vaultSecret);
652 await ctx.googleClient.fetch({
653 url: REVOKE_ENDPOINT,
654 method: 'POST',
655 headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
656 body: new URLSearchParams({ token: token.refresh_token }).toString(),
657 });
658 } catch {
659 // Remote revoke is best-effort; local custody deletion is mandatory.
660 }
661 }
662 deleteOAuthTokenVault(ctx.dataDir, ctx.connectorId);
663 connector.status = 'revoked';
664 connector.revoked_at = new Date(ctx.now ?? Date.now()).toISOString();
665 connector.oauth_ref = null;
666 connector.oauth_pending = null;
667 connector.sync_cursor = null;
668 saveConnector(ctx.dataDir, ctx.vaultId, connector);
669 return { ok: true, status: 200, payload: { revoked: true } };
670 }
671
672 /**
673 * Deterministic fake client. Fixtures may provide functions or static values.
674 */
675 export function createFakeGoogleDriveClient(fixtures = {}) {
676 const call = async (name, args, fallback) => typeof fixtures[name] === 'function'
677 ? fixtures[name](args)
678 : fixtures[name] ?? fallback;
679 return {
680 fetch: async (input) => {
681 if (typeof fixtures.fetch === 'function') return fixtures.fetch(input);
682 if (input.url.includes('/token')) {
683 return {
684 ok: true,
685 status: 200,
686 json: async () => fixtures.tokenResponse ?? {
687 access_token: 'drive_access_test',
688 refresh_token: 'drive_refresh_test',
689 token_type: 'Bearer',
690 expires_in: 3600,
691 scope: GOOGLE_DRIVE_OAUTH_SCOPES.join(' '),
692 },
693 };
694 }
695 if (input.url.includes('userinfo')) {
696 return { ok: true, status: 200, json: async () => fixtures.userinfo ?? { sub: 'drive-sub-test' } };
697 }
698 return { ok: true, status: 200, json: async () => ({}) };
699 },
700 filesList: (args) => call('filesList', args, { files: fixtures.files ?? [], nextPageToken: fixtures.nextPageToken }),
701 filesGet: (args) => call(
702 'filesGet',
703 args,
704 (fixtures.files ?? []).find((row) => row.id === args.fileId) ?? { status: 404 },
705 ),
706 filesExport: (args) => call('filesExport', args, fixtures.contents?.[args.fileId] ?? ''),
707 filesDownload: (args) => call('filesDownload', args, fixtures.contents?.[args.fileId] ?? ''),
708 changesGetStartPageToken: (args) => call('changesGetStartPageToken', args, { startPageToken: 'start-page-token' }),
709 changesList: (args) => call('changesList', args, { changes: [], newStartPageToken: 'next-page-token' }),
710 };
711 }
712
713 export function createProductionGoogleDriveClient() {
714 const fetchImpl = globalThis.fetch.bind(globalThis);
715 const authHeaders = (token) => ({ Authorization: `Bearer ${token}`, Accept: 'application/json' });
716 async function jsonRequest(url, accessToken) {
717 const response = await fetchImpl(url, { headers: authHeaders(accessToken) });
718 const body = await response.json();
719 const retryAfter = Number.parseFloat(response.headers.get('retry-after') ?? '');
720 return {
721 ...body,
722 status: response.status,
723 ...(Number.isFinite(retryAfter) ? { retryAfterMs: retryAfter * 1000 } : {}),
724 };
725 }
726 async function bytesRequest(url, accessToken) {
727 const response = await fetchImpl(url, { headers: { Authorization: `Bearer ${accessToken}` } });
728 if (!response.ok) throw new Error('Drive content request failed');
729 return Buffer.from(await response.arrayBuffer());
730 }
731 return {
732 fetch: async (input) => {
733 const response = await fetchImpl(input.url, {
734 method: input.method ?? 'GET',
735 headers: input.headers,
736 ...(input.body !== undefined ? { body: input.body } : {}),
737 });
738 return {
739 ok: response.ok,
740 status: response.status,
741 json: () => response.json(),
742 headers: { get: (name) => response.headers.get(name) },
743 };
744 },
745 filesList: async ({ accessToken, pageToken, q }) => {
746 const url = new URL(`${DRIVE_API}/files`);
747 url.searchParams.set('pageSize', String(DOCS_LIST_PAGE_SIZE));
748 url.searchParams.set('fields', 'nextPageToken,files(id,name,mimeType,modifiedTime,size)');
749 if (pageToken) url.searchParams.set('pageToken', pageToken);
750 if (q) url.searchParams.set('q', q);
751 return jsonRequest(url, accessToken);
752 },
753 filesGet: async ({ accessToken, fileId }) => {
754 const url = new URL(`${DRIVE_API}/files/${encodeURIComponent(fileId)}`);
755 url.searchParams.set('fields', 'id,name,mimeType,modifiedTime,size');
756 return jsonRequest(url, accessToken);
757 },
758 filesExport: async ({ accessToken, fileId, mimeType }) => {
759 const url = new URL(`${DRIVE_API}/files/${encodeURIComponent(fileId)}/export`);
760 url.searchParams.set('mimeType', mimeType);
761 return bytesRequest(url, accessToken);
762 },
763 filesDownload: async ({ accessToken, fileId }) => {
764 const url = new URL(`${DRIVE_API}/files/${encodeURIComponent(fileId)}`);
765 url.searchParams.set('alt', 'media');
766 return bytesRequest(url, accessToken);
767 },
768 changesGetStartPageToken: ({ accessToken }) => jsonRequest(
769 new URL(`${DRIVE_API}/changes/startPageToken`),
770 accessToken,
771 ),
772 changesList: async ({ accessToken, pageToken }) => {
773 const url = new URL(`${DRIVE_API}/changes`);
774 url.searchParams.set('pageToken', pageToken);
775 url.searchParams.set('pageSize', String(DOCS_LIST_PAGE_SIZE));
776 url.searchParams.set('fields', 'nextPageToken,newStartPageToken,changes(file(id,name,mimeType,modifiedTime,size),removed)');
777 return jsonRequest(url, accessToken);
778 },
779 };
780 }
File History 1 commit
sha256:700fafdd1afa490919f9515d660ca6e75456bcd5bb67513abcd8757a634c01f6 docs: record AIP-b SD-21 land (KN #308) Human 11 days ago