google-drive-connector.mjs
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