@opengeni/api-router 0.23.1 → 0.26.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/dist/app.d.ts +2 -2
  2. package/dist/app.js +1 -1
  3. package/dist/browser-controller-authority.d.ts +43 -0
  4. package/dist/browser-state-authority.d.ts +27 -0
  5. package/dist/{chunk-T4T2PGU4.js → chunk-JIKNR5YL.js} +26181 -14408
  6. package/dist/chunk-JIKNR5YL.js.map +1 -0
  7. package/dist/codemode.d.ts +23 -0
  8. package/dist/editable-artifact-live-hints.d.ts +11 -0
  9. package/dist/editable-artifact-native-kernel.d.ts +37 -0
  10. package/dist/editable-artifact-office-import.d.ts +22 -0
  11. package/dist/editable-artifact-production.d.ts +29 -0
  12. package/dist/editable-artifact-websocket.d.ts +49 -0
  13. package/dist/editable-artifact-workspace-files.d.ts +23 -0
  14. package/dist/github-browser-flow.d.ts +6 -0
  15. package/dist/http/cors.d.ts +1 -0
  16. package/dist/index.d.ts +3 -2
  17. package/dist/index.js +1796 -22
  18. package/dist/index.js.map +1 -1
  19. package/dist/integrations/api-integrations.d.ts +24 -0
  20. package/dist/integrations/atlassian.d.ts +176 -0
  21. package/dist/integrations/github-skill-source.d.ts +5 -0
  22. package/dist/integrations/google-drive.d.ts +85 -0
  23. package/dist/integrations/oauth-client.d.ts +22 -1
  24. package/dist/integrations/provider-oauth.d.ts +19 -0
  25. package/dist/integrations/slack-bot.d.ts +4 -0
  26. package/dist/integrations/slack-interactions.d.ts +8 -2
  27. package/dist/integrations/social-api.d.ts +2 -1
  28. package/dist/mcp/editable-artifact-query-schema.d.ts +4 -0
  29. package/dist/mcp/editable-artifacts.d.ts +13 -0
  30. package/dist/mcp/scheduled-task-view.d.ts +168 -0
  31. package/dist/mcp/server.d.ts +14 -3
  32. package/dist/memory-slack-delivery.d.ts +9 -0
  33. package/dist/routes/api-integrations.d.ts +8 -0
  34. package/dist/routes/browser-identities.d.ts +5 -0
  35. package/dist/routes/browser-sessions.d.ts +6 -0
  36. package/dist/routes/company-profile.d.ts +3 -0
  37. package/dist/routes/computer-sessions.d.ts +6 -0
  38. package/dist/routes/editable-artifacts.d.ts +44 -0
  39. package/dist/routes/integration-features.d.ts +3 -0
  40. package/dist/routes/memory-slack-publications.d.ts +6 -0
  41. package/dist/routes/plugins.d.ts +8 -0
  42. package/dist/routes/sessions.d.ts +17 -2
  43. package/dist/routes/skills.d.ts +6 -0
  44. package/dist/routes/video-generation.d.ts +3 -0
  45. package/dist/sandbox/channel-a.d.ts +55 -2
  46. package/dist/sandbox/metrics-ingestion.d.ts +6 -1
  47. package/dist/sandbox/viewer.d.ts +4 -2
  48. package/dist/temporal-schedule-cleanup.d.ts +26 -0
  49. package/package.json +19 -14
  50. package/src/app.ts +274 -37
  51. package/src/browser-controller-authority.ts +137 -0
  52. package/src/browser-state-authority.ts +236 -0
  53. package/src/codemode.ts +186 -0
  54. package/src/editable-artifact-live-hints.ts +64 -0
  55. package/src/editable-artifact-native-kernel.ts +659 -0
  56. package/src/editable-artifact-office-import.ts +230 -0
  57. package/src/editable-artifact-production.ts +419 -0
  58. package/src/editable-artifact-websocket.ts +311 -0
  59. package/src/editable-artifact-workspace-files.ts +186 -0
  60. package/src/github-browser-flow.ts +35 -6
  61. package/src/http/auth.ts +2 -0
  62. package/src/http/cors.ts +3 -0
  63. package/src/index.ts +119 -20
  64. package/src/integrations/api-integrations.ts +350 -0
  65. package/src/integrations/atlassian.ts +1621 -0
  66. package/src/integrations/github-skill-source.ts +142 -0
  67. package/src/integrations/google-drive.ts +1000 -64
  68. package/src/integrations/oauth-client.ts +123 -33
  69. package/src/integrations/provider-oauth.ts +777 -0
  70. package/src/integrations/slack-bot.ts +31 -2
  71. package/src/integrations/slack-interactions.ts +487 -27
  72. package/src/integrations/social-api.ts +11 -0
  73. package/src/mcp/documents.ts +32 -1
  74. package/src/mcp/editable-artifact-query-schema.ts +236 -0
  75. package/src/mcp/editable-artifacts.ts +448 -0
  76. package/src/mcp/scheduled-task-view.ts +34 -0
  77. package/src/mcp/server.ts +923 -145
  78. package/src/memory-slack-delivery.ts +209 -0
  79. package/src/routes/api-integrations.ts +407 -0
  80. package/src/routes/browser-identities.ts +136 -0
  81. package/src/routes/browser-sessions.ts +2543 -0
  82. package/src/routes/company-profile.ts +255 -0
  83. package/src/routes/computer-sessions.ts +1247 -0
  84. package/src/routes/connections.ts +284 -99
  85. package/src/routes/documents.ts +22 -3
  86. package/src/routes/editable-artifacts.ts +1159 -0
  87. package/src/routes/files.ts +106 -4
  88. package/src/routes/github.ts +18 -2
  89. package/src/routes/install.ts +7 -1
  90. package/src/routes/integration-features.ts +258 -0
  91. package/src/routes/memory-slack-publications.ts +216 -0
  92. package/src/routes/packs.ts +437 -7
  93. package/src/routes/plugins.ts +751 -0
  94. package/src/routes/rigs.ts +77 -20
  95. package/src/routes/scheduled-tasks.ts +67 -40
  96. package/src/routes/sessions.ts +421 -245
  97. package/src/routes/skills.ts +174 -0
  98. package/src/routes/video-generation.ts +132 -0
  99. package/src/routes/workspaces.ts +46 -24
  100. package/src/sandbox/channel-a.ts +723 -116
  101. package/src/sandbox/metrics-ingestion.ts +121 -3
  102. package/src/sandbox/rematerialize.ts +35 -47
  103. package/src/sandbox/viewer.ts +29 -9
  104. package/src/temporal-schedule-cleanup.ts +135 -0
  105. package/dist/chunk-T4T2PGU4.js.map +0 -1
  106. package/dist/mcp/toolspace.d.ts +0 -71
  107. package/src/mcp/toolspace.ts +0 -1190
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import type { Settings } from "@opengeni/config";
3
+ import type { AccessGrant, ScheduledTask, ScheduledTaskScheduleSpec } from "@opengeni/contracts";
3
4
  import {
4
5
  bindConnectorDocumentDestination,
5
6
  type ConnectorDocumentDestinationSelection,
@@ -9,11 +10,13 @@ import {
9
10
  GOOGLE_DRIVE_CREDENTIAL_ROLE,
10
11
  GOOGLE_DRIVE_PROVIDER_DOMAIN,
11
12
  GOOGLE_DRIVE_READONLY_SCOPE,
13
+ GoogleDriveKnowledgeSourceConfig,
12
14
  GoogleDriveBrowseItem,
13
15
  GoogleDriveBrowseResponse,
14
16
  GoogleDriveConnectionLifecycle,
15
17
  GoogleDriveConnectionMetadata,
16
18
  GoogleDriveOAuthStartResponse,
19
+ SaveGoogleDriveIntegrationSourceRequest,
17
20
  SaveGoogleDriveSourceRequest,
18
21
  googleDriveOAuthScopeDecision,
19
22
  googleDriveScopesAllowCapability,
@@ -21,10 +24,27 @@ import {
21
24
  type GoogleDriveLifecycleActionRequest,
22
25
  type GoogleDriveOAuthStartRequest,
23
26
  } from "@opengeni/contracts/google-drive";
24
- import { hasPermission, requireEnvironmentEncryption } from "@opengeni/core";
27
+ import {
28
+ API_INTEGRATION_OAUTH_CREDENTIAL_ROLE,
29
+ ApiIntegrationOAuthConnectionMetadata,
30
+ IntegrationFeatureMutationResult,
31
+ } from "@opengeni/contracts";
32
+ import { GOOGLE_DRIVE_PRESET, providerDomainForPreset } from "@opengeni/capabilities";
33
+ import {
34
+ captureScheduledTaskRestoreState,
35
+ createValidatedScheduledTask,
36
+ hasPermission,
37
+ manualScheduledTaskTriggerWorkflowId,
38
+ requireEnvironmentEncryption,
39
+ syncCreatedScheduledTask,
40
+ syncUpdatedScheduledTask,
41
+ } from "@opengeni/core";
25
42
  import type { ApiRouteDeps } from "@opengeni/core";
26
43
  import {
27
44
  buildConnectionTokenResolver,
45
+ configureIntegrationFeature,
46
+ appendKnowledgeSourceAclVersion,
47
+ deauthorizeKnowledgeSourceRetrieval,
28
48
  ConnectionDisconnectGenerationError,
29
49
  ConnectionDisconnectIdempotencyError,
30
50
  consumeIntegrationOAuthStateNonce,
@@ -33,12 +53,21 @@ import {
33
53
  disconnectConnectionIdempotently,
34
54
  encryptEnvironmentValue,
35
55
  getConnectionMetadata,
56
+ getKnowledgeSourceByExternalIdentityForSyncAuthority,
57
+ getKnowledgeSourceForSyncAuthority,
36
58
  getWorkspaceGrant,
59
+ listKnowledgeSourceSyncTasksForConnection,
37
60
  loadConnectionCredentialForBroker,
61
+ listIntegrationInstanceFeatures,
38
62
  transitionConnectionState,
39
63
  updateConnection,
64
+ updateScheduledTask,
65
+ upsertKnowledgeProvider,
66
+ upsertKnowledgeSource,
67
+ recordKnowledgeLifecycleEvent,
40
68
  type PermanentConnectionRefreshFailure,
41
69
  } from "@opengeni/db";
70
+ import { googleDriveKnowledgeSourceIdentity } from "@opengeni/documents/google-drive";
42
71
  import { createSignedState, readSignedState } from "@opengeni/github";
43
72
  import { readResponseJsonBounded, type FetchLike } from "@opengeni/network";
44
73
  import { HTTPException } from "hono/http-exception";
@@ -75,6 +104,74 @@ type GoogleDriveOAuthState = {
75
104
  iat: number;
76
105
  };
77
106
 
107
+ export async function wakeGoogleDriveSourcesFromWorkspaceEvent(
108
+ deps: ApiRouteDeps,
109
+ input: {
110
+ accountId: string;
111
+ workspaceId: string;
112
+ connectionId: string;
113
+ connectionOwnerSubjectId: string;
114
+ eventId: string;
115
+ driveId: string | null;
116
+ },
117
+ ): Promise<{ enabled: boolean; triggered: number }> {
118
+ if (deps.settings.googleDriveWorkspaceEventsEnabled !== true) {
119
+ return { enabled: false, triggered: 0 };
120
+ }
121
+ const eventId = input.eventId.trim();
122
+ if (eventId.length < 1 || eventId.length > 1024) {
123
+ throw new Error("google_drive_workspace_event_id_invalid");
124
+ }
125
+ const connection = await getConnectionMetadata(
126
+ deps.db,
127
+ input.workspaceId,
128
+ input.connectionId,
129
+ input.connectionOwnerSubjectId,
130
+ );
131
+ if (!connection || connection.accountId !== input.accountId) {
132
+ throw new Error("google_drive_workspace_event_connection_not_found");
133
+ }
134
+ const metadata = requireGoogleDriveConnection(connection, input.connectionOwnerSubjectId);
135
+ if (effectiveGoogleDriveLifecycle(connection, metadata).state !== "active") {
136
+ return { enabled: true, triggered: 0 };
137
+ }
138
+ const selectedSources =
139
+ metadata.selectedSources ?? (metadata.selectedSource ? [metadata.selectedSource] : []);
140
+ const selectedById = new Map(selectedSources.map((source) => [source.id, source]));
141
+ const tasks = await listKnowledgeSourceSyncTasksForConnection(
142
+ deps.db,
143
+ input.workspaceId,
144
+ input.connectionId,
145
+ );
146
+ let triggered = 0;
147
+ for (const task of tasks) {
148
+ if (task.action.kind !== "knowledge_source_sync" || task.status !== "active") continue;
149
+ const externalSourceId =
150
+ typeof task.metadata.externalSourceId === "string" ? task.metadata.externalSourceId : null;
151
+ const selectedSource = externalSourceId ? selectedById.get(externalSourceId) : null;
152
+ if (
153
+ !selectedSource ||
154
+ !selectedSource.syncEnabled ||
155
+ selectedSource.driveId !== input.driveId
156
+ ) {
157
+ continue;
158
+ }
159
+ const token = createHash("sha256")
160
+ .update(`google-drive-workspace-event:${task.id}:${eventId}`)
161
+ .digest("hex")
162
+ .slice(0, 48);
163
+ await deps.workflowClient.triggerScheduledTask({
164
+ task,
165
+ agentRunUsageIdempotencyKey: `knowledge-source-sync:provider-event:${task.id}:${token}`,
166
+ triggerWorkflowId: manualScheduledTaskTriggerWorkflowId(task.id, `provider-event-${token}`),
167
+ initiator: { kind: "service", subjectId: "google-drive-workspace-events" },
168
+ triggerType: "provider_event",
169
+ });
170
+ triggered += 1;
171
+ }
172
+ return { enabled: true, triggered };
173
+ }
174
+
78
175
  type GoogleTokenResponse = {
79
176
  accessToken: string;
80
177
  refreshToken?: string;
@@ -85,6 +182,18 @@ type GoogleTokenResponse = {
85
182
 
86
183
  type GoogleDriveConnectionRecord = NonNullable<Awaited<ReturnType<typeof getConnectionMetadata>>>;
87
184
 
185
+ type GoogleDriveApiConnection =
186
+ | {
187
+ kind: "legacy";
188
+ connection: GoogleDriveConnectionRecord;
189
+ metadata: GoogleDriveConnectionMetadata;
190
+ }
191
+ | {
192
+ kind: "integration";
193
+ connection: GoogleDriveConnectionRecord;
194
+ metadata: ApiIntegrationOAuthConnectionMetadata;
195
+ };
196
+
88
197
  export async function startGoogleDriveOAuth(
89
198
  deps: ApiRouteDeps,
90
199
  input: {
@@ -337,6 +446,21 @@ export async function transitionGoogleDriveLifecycle(
337
446
  // Natural convergence makes retried pause/resume requests idempotent even if
338
447
  // the caller still carries the pre-transition version.
339
448
  if (existing.status === "active" && lifecycle.state === targetState) {
449
+ if (targetState === "paused") {
450
+ await deauthorizeGoogleDriveConnectionSources(
451
+ deps,
452
+ existing,
453
+ input.subjectId,
454
+ "connection_paused",
455
+ existing.version,
456
+ );
457
+ }
458
+ await setGoogleDriveScheduleStatus(
459
+ deps,
460
+ input.workspaceId,
461
+ input.connectionId,
462
+ targetState === "active" ? "active" : "paused",
463
+ );
340
464
  return existing;
341
465
  }
342
466
  if (existing.status === "revoked") {
@@ -358,6 +482,16 @@ export async function transitionGoogleDriveLifecycle(
358
482
  throw new HTTPException(409, { message: "Google Drive connection changed; try again" });
359
483
  }
360
484
 
485
+ if (input.payload.action === "pause") {
486
+ await deauthorizeGoogleDriveConnectionSources(
487
+ deps,
488
+ existing,
489
+ input.subjectId,
490
+ "connection_paused",
491
+ existing.version + 1,
492
+ );
493
+ }
494
+
361
495
  const updated = await transitionConnectionState(deps.db, {
362
496
  workspaceId: input.workspaceId,
363
497
  connectionId: existing.id,
@@ -381,11 +515,23 @@ export async function transitionGoogleDriveLifecycle(
381
515
  if (converged?.status === "active") {
382
516
  const convergedMetadata = requireGoogleDriveConnection(converged, input.subjectId);
383
517
  if (effectiveGoogleDriveLifecycle(converged, convergedMetadata).state === targetState) {
518
+ await setGoogleDriveScheduleStatus(
519
+ deps,
520
+ input.workspaceId,
521
+ input.connectionId,
522
+ targetState === "active" ? "active" : "paused",
523
+ );
384
524
  return converged;
385
525
  }
386
526
  }
387
527
  throw new HTTPException(409, { message: "Google Drive connection changed; try again" });
388
528
  }
529
+ await setGoogleDriveScheduleStatus(
530
+ deps,
531
+ input.workspaceId,
532
+ input.connectionId,
533
+ targetState === "active" ? "active" : "paused",
534
+ );
389
535
  return updated;
390
536
  }
391
537
 
@@ -400,7 +546,14 @@ export async function disconnectGoogleDrive(
400
546
  ) {
401
547
  const metadata = requireGoogleDriveConnection(input.connection, input.subjectId);
402
548
  try {
403
- return await disconnectConnectionIdempotently(deps.db, {
549
+ await deauthorizeGoogleDriveConnectionSources(
550
+ deps,
551
+ input.connection,
552
+ input.subjectId,
553
+ "connection_disconnected",
554
+ input.connection.version + 1,
555
+ );
556
+ const disconnected = await disconnectConnectionIdempotently(deps.db, {
404
557
  accountId: input.connection.accountId,
405
558
  workspaceId: input.workspaceId,
406
559
  subjectId: input.subjectId,
@@ -414,6 +567,8 @@ export async function disconnectGoogleDrive(
414
567
  lastError: null,
415
568
  updatedBySubjectId: input.subjectId,
416
569
  });
570
+ await setGoogleDriveScheduleStatus(deps, input.workspaceId, input.connection.id, "paused");
571
+ return disconnected;
417
572
  } catch (error) {
418
573
  if (error instanceof ConnectionDisconnectIdempotencyError) {
419
574
  throw new HTTPException(409, {
@@ -429,6 +584,82 @@ export async function disconnectGoogleDrive(
429
584
  }
430
585
  }
431
586
 
587
+ async function setGoogleDriveScheduleStatus(
588
+ deps: ApiRouteDeps,
589
+ workspaceId: string,
590
+ connectionId: string,
591
+ status: "active" | "paused",
592
+ ): Promise<void> {
593
+ await forEachGoogleDriveConnectionTask(deps, workspaceId, connectionId, async (task) => {
594
+ if (knowledgeSourceScheduleControl(task).connectionPaused === (status === "paused")) return;
595
+ const previous = await captureScheduledTaskRestoreState(deps.db, task);
596
+ const updated = await updateScheduledTask(deps.db, workspaceId, task.id, {
597
+ metadata: {
598
+ ...task.metadata,
599
+ knowledgeSourceSync: {
600
+ ...knowledgeSourceScheduleControl(task),
601
+ connectionPaused: status === "paused",
602
+ },
603
+ },
604
+ });
605
+ await syncUpdatedScheduledTask({
606
+ db: deps.db,
607
+ workflowClient: deps.workflowClient,
608
+ previous,
609
+ task: updated,
610
+ });
611
+ });
612
+ }
613
+
614
+ async function deauthorizeGoogleDriveConnectionSources(
615
+ deps: ApiRouteDeps,
616
+ connection: GoogleDriveConnectionRecord,
617
+ subjectId: string,
618
+ reasonCode: string,
619
+ authorityVersion: number,
620
+ ): Promise<void> {
621
+ await forEachGoogleDriveConnectionTask(
622
+ deps,
623
+ connection.workspaceId,
624
+ connection.id,
625
+ async (task) => {
626
+ if (task.action.initiatingSubjectId !== subjectId) return;
627
+ const resolved = await getKnowledgeSourceForSyncAuthority(deps.db, {
628
+ accountId: task.accountId,
629
+ workspaceId: task.workspaceId,
630
+ sourceId: task.action.sourceId,
631
+ initiatingSubjectId: subjectId,
632
+ });
633
+ if (!resolved || resolved.source.lifecycleState !== "active") return;
634
+ await deauthorizeKnowledgeSourceRetrieval(deps.db, {
635
+ accountId: task.accountId,
636
+ workspaceId: task.workspaceId,
637
+ sourceId: task.action.sourceId,
638
+ audience: task.action.destination,
639
+ operationId: `google-drive-deauthorize:${connection.id}:${authorityVersion}:${task.action.sourceId}:${reasonCode}`,
640
+ reasonCode,
641
+ actor: {
642
+ kind: "human",
643
+ subjectId,
644
+ initiatingHumanSubjectId: subjectId,
645
+ },
646
+ });
647
+ },
648
+ );
649
+ }
650
+
651
+ async function forEachGoogleDriveConnectionTask(
652
+ deps: ApiRouteDeps,
653
+ workspaceId: string,
654
+ connectionId: string,
655
+ fn: (task: ScheduledTask & { action: { kind: "knowledge_source_sync" } }) => Promise<void>,
656
+ ): Promise<void> {
657
+ const tasks = await listKnowledgeSourceSyncTasksForConnection(deps.db, workspaceId, connectionId);
658
+ for (const task of tasks) {
659
+ await fn(task);
660
+ }
661
+ }
662
+
432
663
  export async function browseGoogleDrive(
433
664
  deps: ApiRouteDeps,
434
665
  input: {
@@ -448,7 +679,7 @@ export async function browseGoogleDrive(
448
679
  if (!connection) {
449
680
  throw new HTTPException(404, { message: "Google Drive connection not found" });
450
681
  }
451
- await requireGoogleDriveSourceConnection(deps, connection, input.subjectId);
682
+ await requireGoogleDriveApiConnection(deps, connection, input.subjectId);
452
683
  const parentId = validDriveId(input.parentId, "parentId");
453
684
  const currentItem = await resolveGoogleDriveBoundaryItem(deps, {
454
685
  workspaceId: input.workspaceId,
@@ -500,6 +731,105 @@ export async function browseGoogleDrive(
500
731
  });
501
732
  }
502
733
 
734
+ export async function browseGoogleDriveIntegrationSource(
735
+ deps: ApiRouteDeps,
736
+ input: {
737
+ workspaceId: string;
738
+ subjectId: string;
739
+ capabilityId: string;
740
+ instanceKey: string;
741
+ featureKey: string;
742
+ parentId: string;
743
+ pageToken?: string | undefined;
744
+ },
745
+ ) {
746
+ const context = await requireGoogleDriveIntegrationFeature(deps, input);
747
+ return await browseGoogleDrive(deps, {
748
+ workspaceId: input.workspaceId,
749
+ subjectId: input.subjectId,
750
+ connectionId: context.connection.id,
751
+ parentId: input.parentId,
752
+ ...(input.pageToken ? { pageToken: input.pageToken } : {}),
753
+ });
754
+ }
755
+
756
+ export async function saveGoogleDriveIntegrationSource(
757
+ deps: ApiRouteDeps,
758
+ input: {
759
+ accountId: string;
760
+ workspaceId: string;
761
+ subjectId: string;
762
+ capabilityId: string;
763
+ instanceKey: string;
764
+ featureKey: string;
765
+ payload: unknown;
766
+ canManageOrganizationDestination: boolean;
767
+ canManageWorkspaceDestination: boolean;
768
+ canManagePersonalDestination: boolean;
769
+ },
770
+ ): Promise<IntegrationFeatureMutationResult> {
771
+ const parsedPayload = SaveGoogleDriveIntegrationSourceRequest.safeParse(input.payload);
772
+ if (!parsedPayload.success) {
773
+ throw new HTTPException(400, { message: "invalid Google Drive source selection" });
774
+ }
775
+ const payload = parsedPayload.data;
776
+ const context = await requireGoogleDriveIntegrationFeature(deps, input);
777
+ const documentDestination = googleDriveDocumentDestination(input, payload);
778
+ const verifiedSources = await verifyGoogleDriveSources(deps, {
779
+ workspaceId: input.workspaceId,
780
+ subjectId: input.subjectId,
781
+ connectionId: context.connection.id,
782
+ sources: payload.sources,
783
+ });
784
+ const config = GoogleDriveKnowledgeSourceConfig.parse({
785
+ sources: verifiedSources.map((verified) => ({
786
+ id: verified.id,
787
+ name: verified.name,
788
+ mimeType: verified.mimeType,
789
+ ...(verified.driveId ? { driveId: verified.driveId } : {}),
790
+ sourceKind:
791
+ verified.id === "root"
792
+ ? "my_drive"
793
+ : verified.driveId === verified.id
794
+ ? "shared_drive"
795
+ : "folder",
796
+ includeDescendants: true,
797
+ })),
798
+ destination: {
799
+ authorityKind: documentDestination.authorityKind,
800
+ authorityAccountId: documentDestination.authorityAccountId,
801
+ ...(documentDestination.authorityWorkspaceId
802
+ ? { authorityWorkspaceId: documentDestination.authorityWorkspaceId }
803
+ : {}),
804
+ ...(documentDestination.authoritySubjectId
805
+ ? { authoritySubjectId: documentDestination.authoritySubjectId }
806
+ : {}),
807
+ ...(documentDestination.collectionId
808
+ ? { collectionId: documentDestination.collectionId }
809
+ : {}),
810
+ },
811
+ syncCadence: payload.syncCadence,
812
+ readPolicy: payload.readPolicy,
813
+ });
814
+ return IntegrationFeatureMutationResult.parse(
815
+ await configureIntegrationFeature(deps.db, {
816
+ accountId: input.accountId,
817
+ workspaceId: input.workspaceId,
818
+ subjectId: input.subjectId,
819
+ capabilityId: input.capabilityId,
820
+ instanceKey: input.instanceKey,
821
+ featureKey: input.featureKey,
822
+ displayName:
823
+ context.feature.binding?.displayName ?? `${input.instanceKey} — Google Drive content`,
824
+ config,
825
+ ...(payload.expectedVersion !== undefined
826
+ ? { expectedVersion: payload.expectedVersion }
827
+ : {}),
828
+ idempotencyKey: payload.idempotencyKey,
829
+ }),
830
+ );
831
+ }
832
+
503
833
  export async function saveGoogleDriveSource(
504
834
  deps: ApiRouteDeps,
505
835
  input: {
@@ -507,6 +837,7 @@ export async function saveGoogleDriveSource(
507
837
  workspaceId: string;
508
838
  subjectId: string;
509
839
  connectionId: string;
840
+ grant: AccessGrant;
510
841
  payload: unknown;
511
842
  canManageOrganizationDestination: boolean;
512
843
  canManageWorkspaceDestination: boolean;
@@ -531,6 +862,133 @@ export async function saveGoogleDriveSource(
531
862
  throw new HTTPException(403, { message: "Google Drive connection authority mismatch" });
532
863
  }
533
864
  await requireGoogleDriveSourceConnection(deps, existing, input.subjectId);
865
+ const documentDestination = googleDriveDocumentDestination(input, payload);
866
+ const verifiedSources = await verifyGoogleDriveSources(deps, {
867
+ workspaceId: input.workspaceId,
868
+ subjectId: input.subjectId,
869
+ connectionId: input.connectionId,
870
+ sources: payload.sources,
871
+ });
872
+ const latest =
873
+ (await getConnectionMetadata(
874
+ deps.db,
875
+ input.workspaceId,
876
+ input.connectionId,
877
+ input.subjectId,
878
+ )) ?? existing;
879
+ const latestMetadata = await requireGoogleDriveSourceConnection(deps, latest, input.subjectId);
880
+ const previousSources =
881
+ latestMetadata.selectedSources ??
882
+ (latestMetadata.selectedSource ? [latestMetadata.selectedSource] : []);
883
+ const updated = await transitionConnectionState(deps.db, {
884
+ workspaceId: input.workspaceId,
885
+ connectionId: latest.id,
886
+ visibleToSubjectId: input.subjectId,
887
+ expectedVersion: latest.version,
888
+ metadata: GoogleDriveConnectionMetadata.parse({
889
+ ...latestMetadata,
890
+ documentDestination,
891
+ selectedSource: null,
892
+ selectedSources: verifiedSources.map((verified) => {
893
+ const previous = previousSources.find((source) => source.id === verified.id);
894
+ return {
895
+ id: verified.id,
896
+ name: verified.name,
897
+ mimeType: verified.mimeType,
898
+ driveId: verified.driveId,
899
+ destination: documentDestination,
900
+ syncCadence: payload.syncCadence,
901
+ syncEnabled: payload.syncEnabled,
902
+ configGeneration: (previous?.configGeneration ?? 0) + 1,
903
+ readPolicy: payload.readPolicy,
904
+ selectedAt: new Date().toISOString(),
905
+ };
906
+ }),
907
+ }),
908
+ updatedBySubjectId: input.subjectId,
909
+ });
910
+ if (!updated) {
911
+ throw new HTTPException(409, { message: "Google Drive connection changed; try again" });
912
+ }
913
+ await materializeGoogleDriveKnowledgeSchedules(deps, {
914
+ ...input,
915
+ connection: updated,
916
+ metadata: GoogleDriveConnectionMetadata.parse(updated.metadata),
917
+ previouslySelectedSourceIds: new Set(
918
+ (
919
+ latestMetadata.selectedSources ??
920
+ (latestMetadata.selectedSource ? [latestMetadata.selectedSource] : [])
921
+ ).map((source) => source.id),
922
+ ),
923
+ previouslyEnabledSourceIds: new Set(
924
+ (
925
+ latestMetadata.selectedSources ??
926
+ (latestMetadata.selectedSource ? [latestMetadata.selectedSource] : [])
927
+ )
928
+ .filter((source) => source.syncEnabled)
929
+ .map((source) => source.id),
930
+ ),
931
+ });
932
+ return updated;
933
+ }
934
+
935
+ async function requireGoogleDriveIntegrationFeature(
936
+ deps: ApiRouteDeps,
937
+ input: {
938
+ workspaceId: string;
939
+ subjectId: string;
940
+ capabilityId: string;
941
+ instanceKey: string;
942
+ featureKey: string;
943
+ },
944
+ ) {
945
+ const instance = await listIntegrationInstanceFeatures(
946
+ deps.db,
947
+ input.workspaceId,
948
+ input.subjectId,
949
+ input.capabilityId,
950
+ input.instanceKey,
951
+ );
952
+ const feature = instance.features.find(
953
+ (candidate) => candidate.definition.featureKey === input.featureKey,
954
+ );
955
+ if (
956
+ !feature ||
957
+ feature.definition.kind !== "knowledge_source" ||
958
+ feature.definition.capabilities.provider !== "google-drive"
959
+ ) {
960
+ throw new HTTPException(404, { message: "Google Drive knowledge source not found" });
961
+ }
962
+ if (!instance.connectionId) {
963
+ throw new HTTPException(422, { message: "Google Drive Integration has no Connection" });
964
+ }
965
+ const connection = await getConnectionMetadata(
966
+ deps.db,
967
+ input.workspaceId,
968
+ instance.connectionId,
969
+ input.subjectId,
970
+ );
971
+ if (!connection) {
972
+ throw new HTTPException(404, { message: "Google Drive Connection not found" });
973
+ }
974
+ await requireGoogleDriveApiConnection(deps, connection, input.subjectId);
975
+ return { instance, feature, connection };
976
+ }
977
+
978
+ function googleDriveDocumentDestination(
979
+ input: {
980
+ accountId: string;
981
+ workspaceId: string;
982
+ subjectId: string;
983
+ canManageOrganizationDestination: boolean;
984
+ canManageWorkspaceDestination: boolean;
985
+ canManagePersonalDestination: boolean;
986
+ },
987
+ payload: {
988
+ destination?: ConnectorDocumentDestinationSelection | undefined;
989
+ targetScope?: "user" | "workspace" | "organization" | undefined;
990
+ },
991
+ ) {
534
992
  const destinationSelection: ConnectorDocumentDestinationSelection = payload.destination ?? {
535
993
  authorityKind: "workspace",
536
994
  collectionId: null,
@@ -547,13 +1005,24 @@ export async function saveGoogleDriveSource(
547
1005
  if (destinationSelection.authorityKind === "personal" && !input.canManagePersonalDestination) {
548
1006
  throw new HTTPException(403, { message: "personal destination requires the exact actor" });
549
1007
  }
550
- const documentDestination = bindConnectorDocumentDestination(destinationSelection, {
1008
+ return bindConnectorDocumentDestination(destinationSelection, {
551
1009
  accountId: input.accountId,
552
1010
  workspaceId: input.workspaceId,
553
1011
  initiatingSubjectId: input.subjectId,
554
1012
  });
555
- const verifiedSources = [];
556
- for (const source of payload.sources) {
1013
+ }
1014
+
1015
+ async function verifyGoogleDriveSources(
1016
+ deps: ApiRouteDeps,
1017
+ input: {
1018
+ workspaceId: string;
1019
+ subjectId: string;
1020
+ connectionId: string;
1021
+ sources: Array<{ id: string; name: string; mimeType: string; driveId: string | null }>;
1022
+ },
1023
+ ): Promise<GoogleDriveBrowseItem[]> {
1024
+ const verifiedSources: GoogleDriveBrowseItem[] = [];
1025
+ for (const source of input.sources) {
557
1026
  const sourceId = validDriveId(source.id, "source.id");
558
1027
  const verified = await resolveGoogleDriveBoundaryItem(deps, {
559
1028
  workspaceId: input.workspaceId,
@@ -572,40 +1041,380 @@ export async function saveGoogleDriveSource(
572
1041
  }
573
1042
  verifiedSources.push(verified);
574
1043
  }
575
- const latest =
576
- (await getConnectionMetadata(
577
- deps.db,
578
- input.workspaceId,
579
- input.connectionId,
580
- input.subjectId,
581
- )) ?? existing;
582
- const latestMetadata = await requireGoogleDriveSourceConnection(deps, latest, input.subjectId);
583
- const updated = await transitionConnectionState(deps.db, {
584
- workspaceId: input.workspaceId,
585
- connectionId: latest.id,
586
- visibleToSubjectId: input.subjectId,
587
- expectedVersion: latest.version,
588
- metadata: GoogleDriveConnectionMetadata.parse({
589
- ...latestMetadata,
590
- documentDestination,
591
- selectedSource: null,
592
- selectedSources: verifiedSources.map((verified) => ({
593
- id: verified.id,
594
- name: verified.name,
595
- mimeType: verified.mimeType,
596
- driveId: verified.driveId,
597
- destination: documentDestination,
598
- syncCadence: payload.syncCadence,
599
- readPolicy: payload.readPolicy,
600
- selectedAt: new Date().toISOString(),
601
- })),
602
- }),
603
- updatedBySubjectId: input.subjectId,
1044
+ return verifiedSources;
1045
+ }
1046
+ async function materializeGoogleDriveKnowledgeSchedules(
1047
+ deps: ApiRouteDeps,
1048
+ input: {
1049
+ accountId: string;
1050
+ workspaceId: string;
1051
+ subjectId: string;
1052
+ connectionId: string;
1053
+ grant: AccessGrant;
1054
+ connection: GoogleDriveConnectionRecord;
1055
+ metadata: ReturnType<typeof GoogleDriveConnectionMetadata.parse>;
1056
+ previouslySelectedSourceIds: Set<string>;
1057
+ previouslyEnabledSourceIds: Set<string>;
1058
+ },
1059
+ ): Promise<void> {
1060
+ const selectedSources = input.metadata.selectedSources ?? [];
1061
+ const enabledSources = selectedSources.filter((source) => source.syncEnabled);
1062
+ const enabledIds = new Set(enabledSources.map((source) => source.id));
1063
+ const connectionTasks = await listKnowledgeSourceSyncTasksForConnection(
1064
+ deps.db,
1065
+ input.workspaceId,
1066
+ input.connectionId,
1067
+ );
1068
+ const actor = {
1069
+ kind: "human" as const,
1070
+ subjectId: input.subjectId,
1071
+ initiatingHumanSubjectId: input.subjectId,
1072
+ };
1073
+
1074
+ for (const selectedSource of enabledSources) {
1075
+ const explicitlyEnabled = !input.previouslyEnabledSourceIds.has(selectedSource.id);
1076
+ const identity = googleDriveKnowledgeSourceIdentity({
1077
+ googlePermissionId: input.metadata.googlePermissionId,
1078
+ googleEmail: input.metadata.googleEmail,
1079
+ source: selectedSource,
1080
+ accountId: input.accountId,
1081
+ workspaceId: input.workspaceId,
1082
+ connectionSubjectId: input.subjectId,
1083
+ });
1084
+ let source = null as Awaited<ReturnType<typeof upsertKnowledgeSource>> | null;
1085
+ let existingTask = null as (typeof connectionTasks)[number] | null;
1086
+ for (const task of connectionTasks) {
1087
+ if (task.action.kind !== "knowledge_source_sync") continue;
1088
+ const resolved = await getKnowledgeSourceForSyncAuthority(deps.db, {
1089
+ accountId: input.accountId,
1090
+ workspaceId: input.workspaceId,
1091
+ sourceId: task.action.sourceId,
1092
+ initiatingSubjectId: input.subjectId,
1093
+ });
1094
+ if (resolved?.source.externalSourceId !== identity.externalSourceId) continue;
1095
+ source = resolved.source;
1096
+ existingTask = task;
1097
+ break;
1098
+ }
1099
+ if (!source) {
1100
+ const provider = await upsertKnowledgeProvider(deps.db, {
1101
+ accountId: input.accountId,
1102
+ workspaceId: input.workspaceId,
1103
+ scope: identity.scope,
1104
+ providerKey: identity.providerKey,
1105
+ externalTenantId: identity.externalTenantId,
1106
+ operationId: `google-drive-provider:${input.connectionId}`,
1107
+ actor,
1108
+ });
1109
+ source =
1110
+ (await getKnowledgeSourceByExternalIdentityForSyncAuthority(deps.db, {
1111
+ accountId: input.accountId,
1112
+ workspaceId: input.workspaceId,
1113
+ providerId: provider.id,
1114
+ externalSourceId: identity.externalSourceId,
1115
+ initiatingSubjectId: input.subjectId,
1116
+ })) ??
1117
+ (await upsertKnowledgeSource(deps.db, {
1118
+ accountId: input.accountId,
1119
+ workspaceId: input.workspaceId,
1120
+ scope: identity.scope,
1121
+ providerId: provider.id,
1122
+ externalSourceId: identity.externalSourceId,
1123
+ sourceKind: identity.sourceKind,
1124
+ sourceUri: identity.sourceUri,
1125
+ operationId: `google-drive-source:${input.connectionId}:${identity.externalSourceId}`,
1126
+ actor,
1127
+ }));
1128
+ }
1129
+ if (source.lifecycleState !== "active") {
1130
+ if (!explicitlyEnabled) continue;
1131
+ const restored = await recordKnowledgeLifecycleEvent(deps.db, {
1132
+ accountId: input.accountId,
1133
+ workspaceId: input.workspaceId,
1134
+ targetKind: "source",
1135
+ targetId: source.id,
1136
+ eventType: "restored",
1137
+ expectedGeneration: source.lifecycleGeneration,
1138
+ operationId: `google-drive-source-restore:${source.id}:${input.connection.version}`,
1139
+ reasonCode: "source_explicitly_reenabled",
1140
+ actor,
1141
+ });
1142
+ source = {
1143
+ ...source,
1144
+ lifecycleState: "active",
1145
+ lifecycleGeneration: restored.lifecycleGeneration,
1146
+ };
1147
+ }
1148
+ if (!source.currentAclGeneration) {
1149
+ await appendKnowledgeSourceAclVersion(deps.db, {
1150
+ accountId: input.accountId,
1151
+ workspaceId: input.workspaceId,
1152
+ sourceId: source.id,
1153
+ audience: identity.scope,
1154
+ expectedSourceLifecycleGeneration: source.lifecycleGeneration,
1155
+ expectedAclGeneration: 0,
1156
+ aclVersion: `google-drive-destination:${input.connection.version}`,
1157
+ agentAccess: false,
1158
+ operationId: `google-drive-source-acl:${source.id}`,
1159
+ reasonCode: "source_selected",
1160
+ actor,
1161
+ });
1162
+ }
1163
+ const action = {
1164
+ kind: "knowledge_source_sync" as const,
1165
+ sourceId: source.id,
1166
+ sourceGeneration: source.syncGeneration,
1167
+ sourceLifecycleGeneration: source.lifecycleGeneration,
1168
+ sourceConfigGeneration: selectedSource.configGeneration,
1169
+ controlWorkspaceId: input.workspaceId,
1170
+ providerCoordinationKey: `${identity.providerKey}:${identity.externalTenantId}:${selectedSource.driveId ?? "my-drive"}`,
1171
+ destination: identity.scope,
1172
+ initiatingSubjectId: input.subjectId,
1173
+ allDescendants: true,
1174
+ connection: {
1175
+ connectionId: input.connectionId,
1176
+ ownerSubjectId: input.subjectId,
1177
+ connectionVersion: input.connection.version,
1178
+ providerDomain: input.connection.providerDomain,
1179
+ kind: input.connection.kind,
1180
+ },
1181
+ limits: {
1182
+ maxItems: 500,
1183
+ maxBytes: 500_000_000,
1184
+ maxFileBytes: 100_000_000,
1185
+ maxProviderRequests: 1_000,
1186
+ maxElapsedSeconds: 300,
1187
+ maxConcurrency: 4,
1188
+ maxFailureDetails: 25,
1189
+ },
1190
+ };
1191
+ const schedule = googleDriveSchedule(selectedSource.syncCadence);
1192
+ let task;
1193
+ if (existingTask) {
1194
+ const previous = await captureScheduledTaskRestoreState(deps.db, existingTask);
1195
+ task = await updateScheduledTask(deps.db, input.workspaceId, existingTask.id, {
1196
+ name: `Sync Google Drive: ${selectedSource.name}`,
1197
+ overlapPolicy: "buffer_one",
1198
+ action,
1199
+ metadata: {
1200
+ ...existingTask.metadata,
1201
+ connectorKind: "google_drive",
1202
+ connectionId: input.connectionId,
1203
+ externalSourceId: selectedSource.id,
1204
+ knowledgeSourceSync: {
1205
+ ...knowledgeSourceScheduleControl(existingTask),
1206
+ sourceEnabled: true,
1207
+ connectionPaused: false,
1208
+ },
1209
+ },
1210
+ });
1211
+ await syncUpdatedScheduledTask({
1212
+ db: deps.db,
1213
+ workflowClient: deps.workflowClient,
1214
+ previous,
1215
+ task,
1216
+ });
1217
+ } else {
1218
+ task = await createValidatedScheduledTask({
1219
+ settings: deps.settings,
1220
+ db: deps.db,
1221
+ objectStorage: deps.objectStorage,
1222
+ grant: input.grant,
1223
+ authorizationSurface: "http",
1224
+ sessionAuthorization: deps.sessionAuthorization,
1225
+ payload: {
1226
+ name: `Sync Google Drive: ${selectedSource.name}`,
1227
+ status: "active",
1228
+ schedule,
1229
+ overlapPolicy: "buffer_one",
1230
+ action,
1231
+ runMode: "new_session_per_run",
1232
+ targetSessionId: null,
1233
+ agentConfig: {
1234
+ prompt: "Knowledge source synchronization",
1235
+ resources: [],
1236
+ tools: [],
1237
+ metadata: {},
1238
+ },
1239
+ variableSetId: null,
1240
+ environmentId: null,
1241
+ rigId: null,
1242
+ metadata: {
1243
+ connectorKind: "google_drive",
1244
+ connectionId: input.connectionId,
1245
+ externalSourceId: selectedSource.id,
1246
+ knowledgeSourceSync: { sourceEnabled: true, connectionPaused: false },
1247
+ },
1248
+ },
1249
+ });
1250
+ await syncCreatedScheduledTask({ db: deps.db, workflowClient: deps.workflowClient, task });
1251
+ }
1252
+ const triggerToken = `source-save-${input.connection.version}`;
1253
+ await deps.workflowClient.triggerScheduledTask({
1254
+ task,
1255
+ agentRunUsageIdempotencyKey: `knowledge-source-sync:initial:${task.id}:${triggerToken}`,
1256
+ triggerWorkflowId: manualScheduledTaskTriggerWorkflowId(task.id, triggerToken),
1257
+ initiator: { kind: "subject", subjectId: input.subjectId },
1258
+ triggerType: existingTask ? "repair" : "initial",
1259
+ });
1260
+ }
1261
+
1262
+ for (const task of connectionTasks) {
1263
+ if (task.action.kind !== "knowledge_source_sync") continue;
1264
+ const resolved = await getKnowledgeSourceForSyncAuthority(deps.db, {
1265
+ accountId: input.accountId,
1266
+ workspaceId: input.workspaceId,
1267
+ sourceId: task.action.sourceId,
1268
+ initiatingSubjectId: input.subjectId,
1269
+ });
1270
+ if (!resolved || enabledIds.has(resolved.source.externalSourceId)) continue;
1271
+ if (
1272
+ input.previouslySelectedSourceIds.has(resolved.source.externalSourceId) &&
1273
+ resolved.source.lifecycleState === "active"
1274
+ ) {
1275
+ await recordKnowledgeLifecycleEvent(deps.db, {
1276
+ accountId: input.accountId,
1277
+ workspaceId: input.workspaceId,
1278
+ targetKind: "source",
1279
+ targetId: resolved.source.id,
1280
+ eventType: "deleted",
1281
+ expectedGeneration: resolved.source.lifecycleGeneration,
1282
+ operationId: `google-drive-source-deselect:${resolved.source.id}:${input.connection.version}`,
1283
+ reasonCode: "source_deselected",
1284
+ actor,
1285
+ });
1286
+ }
1287
+ const previous = await captureScheduledTaskRestoreState(deps.db, task);
1288
+ const disabled = await updateScheduledTask(deps.db, input.workspaceId, task.id, {
1289
+ metadata: {
1290
+ ...task.metadata,
1291
+ knowledgeSourceSync: {
1292
+ ...knowledgeSourceScheduleControl(task),
1293
+ sourceEnabled: false,
1294
+ },
1295
+ },
1296
+ });
1297
+ await syncUpdatedScheduledTask({
1298
+ db: deps.db,
1299
+ workflowClient: deps.workflowClient,
1300
+ previous,
1301
+ task: disabled,
1302
+ });
1303
+ }
1304
+ }
1305
+
1306
+ export async function revokeKnowledgeSourceScheduleAuthorization(
1307
+ deps: ApiRouteDeps,
1308
+ input: { task: ScheduledTask; subjectId: string },
1309
+ ): Promise<void> {
1310
+ const { task } = input;
1311
+ if (task.action.kind !== "knowledge_source_sync") return;
1312
+ if (
1313
+ task.action.initiatingSubjectId !== input.subjectId ||
1314
+ task.action.connection.ownerSubjectId !== input.subjectId
1315
+ ) {
1316
+ throw new HTTPException(403, {
1317
+ message: "knowledge source schedule requires the exact initiating subject",
1318
+ });
1319
+ }
1320
+ if (task.metadata.connectorKind !== "google_drive") {
1321
+ throw new HTTPException(409, {
1322
+ message: "knowledge source schedule connector cannot be durably disabled",
1323
+ });
1324
+ }
1325
+ const externalSourceId =
1326
+ typeof task.metadata.externalSourceId === "string" ? task.metadata.externalSourceId.trim() : "";
1327
+ if (!externalSourceId) {
1328
+ throw new HTTPException(409, { message: "knowledge source schedule identity is incomplete" });
1329
+ }
1330
+ const connection = await getConnectionMetadata(
1331
+ deps.db,
1332
+ task.workspaceId,
1333
+ task.action.connection.connectionId,
1334
+ input.subjectId,
1335
+ );
1336
+ if (!connection) {
1337
+ throw new HTTPException(409, {
1338
+ message: "knowledge source connection is unavailable for durable disable",
1339
+ });
1340
+ }
1341
+ const metadata = requireGoogleDriveConnection(connection, input.subjectId);
1342
+ const sources =
1343
+ metadata.selectedSources ?? (metadata.selectedSource ? [metadata.selectedSource] : []);
1344
+ const selected = sources.find((source) => source.id === externalSourceId);
1345
+ let revocationVersion = connection.version;
1346
+ if (selected?.syncEnabled) {
1347
+ const updated = await transitionConnectionState(deps.db, {
1348
+ workspaceId: task.workspaceId,
1349
+ connectionId: connection.id,
1350
+ visibleToSubjectId: input.subjectId,
1351
+ expectedVersion: connection.version,
1352
+ metadata: GoogleDriveConnectionMetadata.parse({
1353
+ ...metadata,
1354
+ selectedSource: null,
1355
+ selectedSources: sources.map((source) =>
1356
+ source.id === externalSourceId
1357
+ ? {
1358
+ ...source,
1359
+ syncEnabled: false,
1360
+ configGeneration: source.configGeneration + 1,
1361
+ }
1362
+ : source,
1363
+ ),
1364
+ }),
1365
+ updatedBySubjectId: input.subjectId,
1366
+ });
1367
+ if (!updated) {
1368
+ throw new HTTPException(409, {
1369
+ message: "knowledge source connection changed; retry schedule deletion",
1370
+ });
1371
+ }
1372
+ revocationVersion = updated.version;
1373
+ }
1374
+ const resolved = await getKnowledgeSourceForSyncAuthority(deps.db, {
1375
+ accountId: task.accountId,
1376
+ workspaceId: task.workspaceId,
1377
+ sourceId: task.action.sourceId,
1378
+ initiatingSubjectId: input.subjectId,
604
1379
  });
605
- if (!updated) {
606
- throw new HTTPException(409, { message: "Google Drive connection changed; try again" });
1380
+ if (resolved?.source.lifecycleState === "active") {
1381
+ await recordKnowledgeLifecycleEvent(deps.db, {
1382
+ accountId: task.accountId,
1383
+ workspaceId: task.workspaceId,
1384
+ targetKind: "source",
1385
+ targetId: resolved.source.id,
1386
+ eventType: "deleted",
1387
+ expectedGeneration: resolved.source.lifecycleGeneration,
1388
+ operationId: `knowledge-schedule-delete:${task.id}:${revocationVersion}`,
1389
+ reasonCode: "schedule_deleted",
1390
+ actor: {
1391
+ kind: "human",
1392
+ subjectId: input.subjectId,
1393
+ initiatingHumanSubjectId: input.subjectId,
1394
+ },
1395
+ });
607
1396
  }
608
- return updated;
1397
+ }
1398
+
1399
+ function knowledgeSourceScheduleControl(task: ScheduledTask): {
1400
+ sourceEnabled: boolean;
1401
+ connectionPaused: boolean;
1402
+ } {
1403
+ const value = task.metadata.knowledgeSourceSync;
1404
+ if (!value || typeof value !== "object") {
1405
+ return { sourceEnabled: true, connectionPaused: false };
1406
+ }
1407
+ const record = value as Record<string, unknown>;
1408
+ return {
1409
+ sourceEnabled: record.sourceEnabled !== false,
1410
+ connectionPaused: record.connectionPaused === true,
1411
+ };
1412
+ }
1413
+
1414
+ function googleDriveSchedule(cadence: "manual" | "hourly" | "daily"): ScheduledTaskScheduleSpec {
1415
+ if (cadence === "manual") return { type: "manual" };
1416
+ if (cadence === "hourly") return { type: "interval", everySeconds: 3_600 };
1417
+ return { type: "calendar", timeZone: "UTC", hour: 0, minute: 0 };
609
1418
  }
610
1419
 
611
1420
  function googleDriveFileMetadataUrl(fileId: string): URL {
@@ -715,6 +1524,60 @@ function requireGoogleDriveConnection(connection: GoogleDriveConnectionRecord, s
715
1524
  return parsed.data;
716
1525
  }
717
1526
 
1527
+ async function requireGoogleDriveApiConnection(
1528
+ deps: ApiRouteDeps,
1529
+ connection: GoogleDriveConnectionRecord,
1530
+ subjectId: string,
1531
+ ): Promise<GoogleDriveApiConnection> {
1532
+ const legacy = GoogleDriveConnectionMetadata.safeParse(connection.metadata);
1533
+ const integration = ApiIntegrationOAuthConnectionMetadata.safeParse(connection.metadata);
1534
+ const genericProviderDomain = providerDomainForPreset(GOOGLE_DRIVE_PRESET);
1535
+ const authority: GoogleDriveApiConnection | null =
1536
+ connection.kind === "oauth2" &&
1537
+ connection.subjectId === subjectId &&
1538
+ connection.providerDomain === GOOGLE_DRIVE_PROVIDER_DOMAIN &&
1539
+ legacy.success
1540
+ ? { kind: "legacy", connection, metadata: legacy.data }
1541
+ : connection.kind === "oauth2" &&
1542
+ (connection.subjectId === null || connection.subjectId === subjectId) &&
1543
+ connection.providerDomain === genericProviderDomain &&
1544
+ integration.success &&
1545
+ integration.data.credentialRole === API_INTEGRATION_OAUTH_CREDENTIAL_ROLE &&
1546
+ integration.data.providerFamily === "google" &&
1547
+ integration.data.authorizedPresetIds.includes(GOOGLE_DRIVE_PRESET.id)
1548
+ ? { kind: "integration", connection, metadata: integration.data }
1549
+ : null;
1550
+ if (!authority) {
1551
+ throw new HTTPException(422, { message: "Connection is not compatible with Google Drive" });
1552
+ }
1553
+ if (authority.kind === "legacy") {
1554
+ await requireGoogleDriveSourceConnection(deps, connection, subjectId);
1555
+ return authority;
1556
+ }
1557
+ if (connection.status === "revoked") {
1558
+ throw new HTTPException(409, { message: "Google Drive is disconnected" });
1559
+ }
1560
+ if (connection.status !== "active") {
1561
+ throw new HTTPException(401, { message: "Google Drive needs to be reconnected" });
1562
+ }
1563
+ if (!googleDriveScopesAllowCapability(connection.grantedScopes, "recursive_source_sync")) {
1564
+ await transitionConnectionState(deps.db, {
1565
+ workspaceId: connection.workspaceId,
1566
+ connectionId: connection.id,
1567
+ visibleToSubjectId: subjectId,
1568
+ expectedVersion: connection.version,
1569
+ status: "needs_reauth",
1570
+ metadata: connection.metadata,
1571
+ lastError: "google_drive_reconsent_required",
1572
+ updatedBySubjectId: subjectId,
1573
+ });
1574
+ throw new HTTPException(401, {
1575
+ message: "Google Drive needs permission re-consent for selected-source read access",
1576
+ });
1577
+ }
1578
+ return authority;
1579
+ }
1580
+
718
1581
  function googleDriveLifecycle(
719
1582
  state: GoogleDriveConnectionLifecycle["state"],
720
1583
  ): GoogleDriveConnectionLifecycle {
@@ -744,11 +1607,21 @@ async function transitionGoogleDriveConnectionLifecycle(
744
1607
  lastError: string | null,
745
1608
  ) {
746
1609
  const metadata = requireGoogleDriveConnection(connection, subjectId);
747
- if (
1610
+ const alreadyCurrent =
748
1611
  connection.status === status &&
749
1612
  metadata.lifecycle?.state === lifecycle.state &&
750
- metadata.lifecycle.recoverable === lifecycle.recoverable
751
- ) {
1613
+ metadata.lifecycle.recoverable === lifecycle.recoverable;
1614
+ if (lifecycle.state !== "active") {
1615
+ await deauthorizeGoogleDriveConnectionSources(
1616
+ deps,
1617
+ connection,
1618
+ subjectId,
1619
+ `connection_${lifecycle.state}`,
1620
+ connection.version + (alreadyCurrent ? 0 : 1),
1621
+ );
1622
+ await setGoogleDriveScheduleStatus(deps, connection.workspaceId, connection.id, "paused");
1623
+ }
1624
+ if (alreadyCurrent) {
752
1625
  return connection;
753
1626
  }
754
1627
  return await transitionConnectionState(deps.db, {
@@ -821,21 +1694,31 @@ function readGoogleDriveOAuthState(
821
1694
  if (iat === undefined || now < iat || now - iat > oauthStateTtlMs / 1000) {
822
1695
  throw new HTTPException(400, { message: "invalid or expired Google Drive OAuth state" });
823
1696
  }
1697
+ const accountId = requiredString(payload.accountId, "state.accountId");
1698
+ const workspaceId = requiredString(payload.workspaceId, "state.workspaceId");
1699
+ const subjectId = requiredString(payload.subjectId, "state.subjectId");
1700
+ const returnPath = requiredString(payload.returnPath, "state.returnPath");
1701
+ if (returnPath !== GOOGLE_DRIVE_RETURN_PATH(workspaceId)) {
1702
+ throw new HTTPException(400, { message: "invalid Google Drive OAuth return path" });
1703
+ }
1704
+ const connectionId = optionalString(payload.connectionId) ?? undefined;
1705
+ const connectionVersion = numberValue(payload.connectionVersion);
1706
+ if (
1707
+ (connectionVersion !== undefined && !Number.isInteger(connectionVersion)) ||
1708
+ Boolean(connectionId) !== Boolean(connectionVersion)
1709
+ ) {
1710
+ throw new HTTPException(400, { message: "invalid Google Drive reconnect state" });
1711
+ }
824
1712
  return {
825
- accountId: requiredString(payload.accountId, "state.accountId"),
826
- workspaceId: requiredString(payload.workspaceId, "state.workspaceId"),
827
- subjectId: requiredString(payload.subjectId, "state.subjectId"),
828
- returnPath: requiredString(payload.returnPath, "state.returnPath"),
1713
+ accountId,
1714
+ workspaceId,
1715
+ subjectId,
1716
+ returnPath,
829
1717
  encryptedPkceVerifier: requiredString(
830
1718
  payload.encryptedPkceVerifier,
831
1719
  "state.encryptedPkceVerifier",
832
1720
  ),
833
- ...(optionalString(payload.connectionId)
834
- ? { connectionId: optionalString(payload.connectionId)! }
835
- : {}),
836
- ...(numberValue(payload.connectionVersion) !== undefined
837
- ? { connectionVersion: numberValue(payload.connectionVersion)! }
838
- : {}),
1721
+ ...(connectionId ? { connectionId, connectionVersion: connectionVersion! } : {}),
839
1722
  nonce: requiredString(payload.nonce, "state.nonce"),
840
1723
  iat,
841
1724
  };
@@ -1052,11 +1935,15 @@ async function googleDriveApiRequest(
1052
1935
  if (!current) {
1053
1936
  throw new HTTPException(404, { message: "Google Drive connection not found" });
1054
1937
  }
1055
- await requireGoogleDriveSourceConnection(deps, current, input.subjectId);
1938
+ const authority = await requireGoogleDriveApiConnection(deps, current, input.subjectId);
1056
1939
  const resolver = buildConnectionTokenResolver(deps.db, deps.settings, undefined, {
1057
1940
  ...(deps.googleDriveFetch ? { refreshTransport: { fetchImpl: deps.googleDriveFetch } } : {}),
1058
- transitionPermanentRefreshFailure: async (failure) =>
1059
- await transitionGoogleDrivePermanentRefreshFailure(deps, failure),
1941
+ ...(authority.kind === "legacy"
1942
+ ? {
1943
+ transitionPermanentRefreshFailure: async (failure: PermanentConnectionRefreshFailure) =>
1944
+ await transitionGoogleDrivePermanentRefreshFailure(deps, failure),
1945
+ }
1946
+ : {}),
1060
1947
  });
1061
1948
  const resolve = async (forceRefresh: boolean) =>
1062
1949
  await resolver({
@@ -1065,14 +1952,26 @@ async function googleDriveApiRequest(
1065
1952
  serverId: "google-drive-source-browser",
1066
1953
  toolName: input.label,
1067
1954
  connectionRef: {
1068
- providerDomain: GOOGLE_DRIVE_PROVIDER_DOMAIN,
1955
+ providerDomain: current.providerDomain,
1069
1956
  connectionId: input.connectionId,
1070
1957
  kind: "oauth2",
1071
- subjectScope: "subject",
1958
+ subjectScope: current.subjectId ? "subject" : "workspace",
1072
1959
  },
1073
1960
  destinationUrl: input.url.toString(),
1074
1961
  forceRefresh,
1075
1962
  });
1963
+ const requireResolvedConnection = async (connectionVersion: number) => {
1964
+ const resolved = await getConnectionMetadata(
1965
+ deps.db,
1966
+ input.workspaceId,
1967
+ input.connectionId,
1968
+ input.subjectId,
1969
+ );
1970
+ if (!resolved || resolved.version !== connectionVersion) {
1971
+ throw new HTTPException(409, { message: "Google Drive connection changed; try again" });
1972
+ }
1973
+ await requireGoogleDriveSourceConnection(deps, resolved, input.subjectId);
1974
+ };
1076
1975
  let credential = await resolve(false);
1077
1976
  if (credential.status !== "ok") {
1078
1977
  throw new HTTPException(401, { message: "Google Drive needs to be reconnected" });
@@ -1081,6 +1980,7 @@ async function googleDriveApiRequest(
1081
1980
  if (providerConnectionVersion === undefined) {
1082
1981
  throw new Error("Google Drive credential resolver omitted the connection version");
1083
1982
  }
1983
+ await requireResolvedConnection(providerConnectionVersion);
1084
1984
  const fetchImpl = deps.googleDriveFetch ?? fetch;
1085
1985
  let response = await providerFetch(fetchImpl, input.url, {
1086
1986
  headers: { ...credential.headers, accept: "application/json" },
@@ -1095,6 +1995,7 @@ async function googleDriveApiRequest(
1095
1995
  if (providerConnectionVersion === undefined) {
1096
1996
  throw new Error("Google Drive credential resolver omitted the connection version");
1097
1997
  }
1998
+ await requireResolvedConnection(providerConnectionVersion);
1098
1999
  response = await providerFetch(fetchImpl, input.url, {
1099
2000
  headers: { ...credential.headers, accept: "application/json" },
1100
2001
  });
@@ -1102,13 +2003,10 @@ async function googleDriveApiRequest(
1102
2003
  if (!response.ok) {
1103
2004
  if (response.status === 401) {
1104
2005
  await response.body?.cancel().catch(() => undefined);
1105
- await transitionGoogleDriveProviderResponseFailure(deps, {
1106
- workspaceId: input.workspaceId,
2006
+ await transitionGoogleDriveApiFailure(deps, authority, {
1107
2007
  subjectId: input.subjectId,
1108
- connectionId: input.connectionId,
1109
2008
  connectionVersion: providerConnectionVersion,
1110
2009
  lifecycle: googleDriveLifecycle("reconnect_required"),
1111
- status: "needs_reauth",
1112
2010
  lastError: "google_drive_reconnect_required",
1113
2011
  });
1114
2012
  throw new HTTPException(401, { message: "Google Drive needs to be reconnected" });
@@ -1123,13 +2021,10 @@ async function googleDriveApiRequest(
1123
2021
  providerErrorCode &&
1124
2022
  GOOGLE_DRIVE_RECONSENT_ERROR_CODES.has(providerErrorCode)
1125
2023
  ) {
1126
- await transitionGoogleDriveProviderResponseFailure(deps, {
1127
- workspaceId: input.workspaceId,
2024
+ await transitionGoogleDriveApiFailure(deps, authority, {
1128
2025
  subjectId: input.subjectId,
1129
- connectionId: input.connectionId,
1130
2026
  connectionVersion: providerConnectionVersion,
1131
2027
  lifecycle: googleDriveLifecycle("reconsent_required"),
1132
- status: "needs_reauth",
1133
2028
  lastError: "google_drive_reconsent_required",
1134
2029
  });
1135
2030
  }
@@ -1143,6 +2038,47 @@ async function googleDriveApiRequest(
1143
2038
  return await readResponseJsonBounded<unknown>(response, GOOGLE_RESPONSE_MAX_BYTES, input.label);
1144
2039
  }
1145
2040
 
2041
+ async function transitionGoogleDriveApiFailure(
2042
+ deps: ApiRouteDeps,
2043
+ authority: GoogleDriveApiConnection,
2044
+ input: {
2045
+ subjectId: string;
2046
+ connectionVersion: number;
2047
+ lifecycle: GoogleDriveConnectionLifecycle;
2048
+ lastError: string;
2049
+ },
2050
+ ): Promise<void> {
2051
+ if (authority.kind === "legacy") {
2052
+ await transitionGoogleDriveProviderResponseFailure(deps, {
2053
+ workspaceId: authority.connection.workspaceId,
2054
+ subjectId: input.subjectId,
2055
+ connectionId: authority.connection.id,
2056
+ connectionVersion: input.connectionVersion,
2057
+ lifecycle: input.lifecycle,
2058
+ status: "needs_reauth",
2059
+ lastError: input.lastError,
2060
+ });
2061
+ return;
2062
+ }
2063
+ const latest = await getConnectionMetadata(
2064
+ deps.db,
2065
+ authority.connection.workspaceId,
2066
+ authority.connection.id,
2067
+ input.subjectId,
2068
+ );
2069
+ if (!latest || latest.version !== input.connectionVersion) return;
2070
+ await transitionConnectionState(deps.db, {
2071
+ workspaceId: latest.workspaceId,
2072
+ connectionId: latest.id,
2073
+ visibleToSubjectId: input.subjectId,
2074
+ expectedVersion: latest.version,
2075
+ status: "needs_reauth",
2076
+ metadata: latest.metadata,
2077
+ lastError: input.lastError,
2078
+ updatedBySubjectId: input.subjectId,
2079
+ });
2080
+ }
2081
+
1146
2082
  async function providerFetch(
1147
2083
  fetchImpl: FetchLike,
1148
2084
  url: string | URL,