@opengeni/api-router 0.12.12 → 0.14.3

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 (87) hide show
  1. package/dist/app.d.ts +18 -38
  2. package/dist/app.js +3 -1
  3. package/dist/auth/managed-auth.d.ts +35 -0
  4. package/dist/{chunk-5TULDPWX.js → chunk-36PW33ND.js} +4111 -1277
  5. package/dist/chunk-36PW33ND.js.map +1 -0
  6. package/dist/codex-redemption-security.d.ts +15 -0
  7. package/dist/github-access.d.ts +12 -0
  8. package/dist/github-browser-flow.d.ts +14 -0
  9. package/dist/http/auth.d.ts +3 -0
  10. package/dist/http/common.d.ts +3 -0
  11. package/dist/http/sse.d.ts +70 -0
  12. package/dist/index.d.ts +11 -13
  13. package/dist/index.js +1 -1
  14. package/dist/integrations/google-drive.d.ts +76 -0
  15. package/dist/integrations/oauth-client.d.ts +42 -0
  16. package/dist/integrations/provider-domain.d.ts +7 -0
  17. package/dist/integrations/slack-bot.d.ts +343 -0
  18. package/dist/mcp/documents.d.ts +8 -0
  19. package/dist/mcp/files.d.ts +5 -0
  20. package/dist/mcp/server.d.ts +107 -0
  21. package/dist/mcp/session-view.d.ts +191 -0
  22. package/dist/mcp/toolspace.d.ts +61 -0
  23. package/dist/model-catalog.d.ts +39 -0
  24. package/dist/observability.d.ts +3 -0
  25. package/dist/routes/api-keys.d.ts +3 -0
  26. package/dist/routes/billing.d.ts +16 -0
  27. package/dist/routes/capabilities.d.ts +3 -0
  28. package/dist/routes/catalog-assets.d.ts +5 -0
  29. package/dist/routes/codex.d.ts +10 -0
  30. package/dist/routes/connections.d.ts +3 -0
  31. package/dist/routes/documents.d.ts +3 -0
  32. package/dist/routes/enrollments.d.ts +3 -0
  33. package/dist/routes/environments.d.ts +5 -0
  34. package/dist/routes/files.d.ts +4 -0
  35. package/dist/routes/github.d.ts +3 -0
  36. package/dist/routes/insights.d.ts +3 -0
  37. package/dist/routes/install.d.ts +5 -0
  38. package/dist/routes/machines.d.ts +3 -0
  39. package/dist/routes/packs.d.ts +3 -0
  40. package/dist/routes/preference-registry.d.ts +5 -0
  41. package/dist/routes/rigs.d.ts +3 -0
  42. package/dist/routes/scheduled-tasks.d.ts +3 -0
  43. package/dist/routes/sessions.d.ts +15 -0
  44. package/dist/routes/social.d.ts +3 -0
  45. package/dist/routes/transcriptions.d.ts +3 -0
  46. package/dist/routes/workspace-capture.d.ts +59 -0
  47. package/dist/routes/workspace-instruction-policies.d.ts +3 -0
  48. package/dist/routes/workspace-state.d.ts +3 -0
  49. package/dist/routes/workspaces.d.ts +5 -0
  50. package/dist/sandbox/access.d.ts +21 -0
  51. package/dist/sandbox/auth-callout.d.ts +30 -0
  52. package/dist/sandbox/channel-a.d.ts +37 -0
  53. package/dist/sandbox/enrollment.d.ts +120 -0
  54. package/dist/sandbox/machines.d.ts +29 -0
  55. package/dist/sandbox/metrics-ingestion.d.ts +128 -0
  56. package/dist/sandbox/rematerialize.d.ts +24 -0
  57. package/dist/sandbox/viewer.d.ts +251 -0
  58. package/dist/transcription/providers/azure-openai.d.ts +9 -0
  59. package/dist/transcription/providers/codex-subscription.d.ts +9 -0
  60. package/dist/transcription/providers/openai.d.ts +9 -0
  61. package/dist/transcription/service.d.ts +10 -0
  62. package/dist/workspace-state-projection.d.ts +16 -0
  63. package/package.json +13 -13
  64. package/src/app.ts +67 -5
  65. package/src/integrations/google-drive.ts +869 -0
  66. package/src/integrations/oauth-client.ts +41 -6
  67. package/src/integrations/slack-bot.ts +756 -26
  68. package/src/mcp/server.ts +200 -3
  69. package/src/mcp/session-view.ts +1 -0
  70. package/src/mcp/toolspace.ts +17 -9
  71. package/src/routes/connections.ts +137 -7
  72. package/src/routes/documents.ts +13 -2
  73. package/src/routes/github.ts +7 -5
  74. package/src/routes/insights.ts +30 -0
  75. package/src/routes/preference-registry.ts +482 -0
  76. package/src/routes/sessions.ts +10 -0
  77. package/src/routes/transcriptions.ts +155 -0
  78. package/src/routes/workspace-state.ts +61 -0
  79. package/src/sandbox/channel-a.ts +2 -0
  80. package/src/sandbox/rematerialize.ts +111 -4
  81. package/src/sandbox/viewer.ts +29 -12
  82. package/src/transcription/providers/azure-openai.ts +45 -0
  83. package/src/transcription/providers/codex-subscription.ts +100 -0
  84. package/src/transcription/providers/openai.ts +93 -0
  85. package/src/transcription/service.ts +121 -0
  86. package/src/workspace-state-projection.ts +244 -0
  87. package/dist/chunk-5TULDPWX.js.map +0 -1
package/src/mcp/server.ts CHANGED
@@ -36,6 +36,8 @@ import {
36
36
  getSessionGoal,
37
37
  getSessionQueueSnapshot,
38
38
  getSessionTurn,
39
+ getOrCreatePreferenceRegistrySnapshot,
40
+ getPreferenceRegistryFullContent,
39
41
  getVariableSet,
40
42
  getVariableSetByName,
41
43
  areGitHubRepositoriesAllowedForWorkspace,
@@ -186,6 +188,8 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
186
188
  memory_search: { sessionRequired: true, allOf: ["documents:search"] },
187
189
  memory_save: { sessionRequired: true, allOf: ["documents:search"] },
188
190
  memory_correct: { sessionRequired: true, allOf: ["documents:search"] },
191
+ preference_registry_summary: { sessionRequired: true, allOf: ["workspace:read"] },
192
+ preference_registry_get: { sessionRequired: true, allOf: ["workspace:read"] },
189
193
  sandboxes_list: { sessionRequired: true, allOf: ["sessions:read"] },
190
194
  sandbox_attach: { sessionRequired: true, allOf: ["sessions:control"] },
191
195
  sandbox_swap: { sessionRequired: true, allOf: ["sessions:control"] },
@@ -226,8 +230,13 @@ const FIRST_PARTY_TOOL_AUTHORIZATION = {
226
230
  scheduled_task_runs_list: { anyOf: ["scheduled_tasks:manage", "scheduled_tasks:run"] },
227
231
  slack_bot_list_channels: { allOf: ["connections:read"] },
228
232
  slack_bot_channel_history: { allOf: ["connections:read"] },
233
+ slack_bot_thread_replies: { allOf: ["connections:read"] },
229
234
  slack_bot_list_users: { allOf: ["connections:read"] },
235
+ slack_bot_list_files: { allOf: ["connections:read"] },
236
+ slack_bot_file_info: { allOf: ["connections:read"] },
237
+ slack_bot_file_content: { allOf: ["connections:read"] },
230
238
  slack_bot_post_message: { allOf: ["connections:read"] },
239
+ slack_bot_delete_message: { allOf: ["connections:read"] },
231
240
  } satisfies Record<FirstPartyMcpToolName, FirstPartyToolAuthorization>;
232
241
 
233
242
  const FIRST_PARTY_MCP_TOOL_NAME_SET = new Set<string>(FIRST_PARTY_MCP_TOOL_NAMES);
@@ -366,6 +375,9 @@ export function buildOpenGeniMcpServer(
366
375
  if (!toolspaceMode && sessionId !== null && options.workspaceMemoryEnabled === true) {
367
376
  registerMemoryTools(server, deps, grant, sessionId, json);
368
377
  }
378
+ if (!toolspaceMode && sessionId !== null && preferenceAttemptClaims(grant) !== null) {
379
+ registerPreferenceRegistryTools(server, deps, grant, json);
380
+ }
369
381
 
370
382
  // Fleet tools (M7 bring-your-own-compute): list / attach / swap / run_on /
371
383
  // provision over the session's Modal box + the workspace's enrolled machines.
@@ -838,6 +850,32 @@ function registerSlackBotTools(
838
850
  ),
839
851
  );
840
852
 
853
+ server.registerTool(
854
+ "slack_bot_thread_replies",
855
+ {
856
+ description:
857
+ "Read a Slack thread as the workspace-shared OpenGeni bot. Pass the channel ID and the parent message timestamp returned by channel history. The result includes the parent followed by its replies.",
858
+ inputSchema: {
859
+ connectionId: z4.string().uuid().optional(),
860
+ channelId: z4.string().min(1).max(64),
861
+ threadTimestamp: z4.string().min(1).max(64),
862
+ cursor: z4.string().max(1024).optional(),
863
+ limit: z4.number().int().min(1).max(100).optional(),
864
+ },
865
+ },
866
+ async ({ connectionId, channelId, threadTimestamp, cursor, limit }) =>
867
+ json(
868
+ await (
869
+ await clientFor(connectionId)
870
+ ).threadReplies({
871
+ channelId,
872
+ threadTimestamp,
873
+ ...(cursor ? { cursor } : {}),
874
+ ...(limit !== undefined ? { limit } : {}),
875
+ }),
876
+ ),
877
+ );
878
+
841
879
  server.registerTool(
842
880
  "slack_bot_list_users",
843
881
  {
@@ -859,20 +897,95 @@ function registerSlackBotTools(
859
897
  ),
860
898
  );
861
899
 
900
+ server.registerTool(
901
+ "slack_bot_list_files",
902
+ {
903
+ description:
904
+ "List Slack files and canvases shared with a channel where the workspace-shared OpenGeni bot is already a member.",
905
+ inputSchema: {
906
+ connectionId: z4.string().uuid().optional(),
907
+ channelId: z4.string().min(1).max(64),
908
+ cursor: z4.string().max(1024).optional(),
909
+ limit: z4.number().int().min(1).max(200).optional(),
910
+ },
911
+ },
912
+ async ({ connectionId, channelId, cursor, limit }) =>
913
+ json(
914
+ await (
915
+ await clientFor(connectionId)
916
+ ).listFiles({
917
+ channelId,
918
+ ...(cursor ? { cursor } : {}),
919
+ ...(limit !== undefined ? { limit } : {}),
920
+ }),
921
+ ),
922
+ );
923
+
924
+ server.registerTool(
925
+ "slack_bot_file_info",
926
+ {
927
+ description:
928
+ "Read safe metadata for a Slack file or canvas shared with a channel where the workspace-shared OpenGeni bot is already a member. For an embedded huddle transcript, also pass the shared canvas file ID as parentFileId so OpenGeni can verify the indirect share.",
929
+ inputSchema: {
930
+ connectionId: z4.string().uuid().optional(),
931
+ channelId: z4.string().min(1).max(64),
932
+ fileId: z4.string().min(1).max(64),
933
+ parentFileId: z4.string().min(1).max(64).optional(),
934
+ },
935
+ },
936
+ async ({ connectionId, channelId, fileId, parentFileId }) =>
937
+ json(
938
+ await (
939
+ await clientFor(connectionId)
940
+ ).fileInfo({
941
+ channelId,
942
+ fileId,
943
+ ...(parentFileId ? { parentFileId } : {}),
944
+ }),
945
+ ),
946
+ );
947
+
948
+ server.registerTool(
949
+ "slack_bot_file_content",
950
+ {
951
+ description:
952
+ "Read a bounded page of UTF-8 text from a Slack file or canvas shared with a channel where the workspace-shared OpenGeni bot is already a member. For an embedded huddle transcript, also pass the shared canvas file ID as parentFileId so OpenGeni can verify the channel-to-canvas-to-transcript chain. Slack may still restrict a huddle transcript body to participants; that returns huddle_transcript_requires_participant_access. Private Slack URLs and credentials are never returned. Continue with nextOffset when truncated is true.",
953
+ inputSchema: {
954
+ connectionId: z4.string().uuid().optional(),
955
+ channelId: z4.string().min(1).max(64),
956
+ fileId: z4.string().min(1).max(64),
957
+ parentFileId: z4.string().min(1).max(64).optional(),
958
+ offset: z4.number().int().min(0).max(4_000_000).optional(),
959
+ },
960
+ },
961
+ async ({ connectionId, channelId, fileId, parentFileId, offset }) =>
962
+ json(
963
+ await (
964
+ await clientFor(connectionId)
965
+ ).fileContent({
966
+ channelId,
967
+ fileId,
968
+ ...(parentFileId ? { parentFileId } : {}),
969
+ ...(offset !== undefined ? { offset } : {}),
970
+ }),
971
+ ),
972
+ );
973
+
862
974
  server.registerTool(
863
975
  "slack_bot_post_message",
864
976
  {
865
977
  description:
866
- "Post as the workspace-shared OpenGeni bot. Pass channelId for a channel where the bot is already a member, or userId to open/post a DM; pass exactly one. Generate one operationId UUID per intended message and reuse that same UUID on every retry.",
978
+ "Post as the workspace-shared OpenGeni bot. Pass channelId for a channel where the bot is already a member, or userId to open/post a DM; pass exactly one. To reply in a thread, also pass its parent message timestamp as threadTimestamp. Generate one operationId UUID per intended message and reuse that same UUID on every retry.",
867
979
  inputSchema: {
868
980
  connectionId: z4.string().uuid().optional(),
869
981
  operationId: z4.string().uuid(),
870
982
  channelId: z4.string().min(1).max(64).optional(),
871
983
  userId: z4.string().min(1).max(64).optional(),
984
+ threadTimestamp: z4.string().min(1).max(64).optional(),
872
985
  text: z4.string().min(1).max(40_000),
873
986
  },
874
987
  },
875
- async ({ connectionId, operationId, channelId, userId, text }) => {
988
+ async ({ connectionId, operationId, channelId, userId, threadTimestamp, text }) => {
876
989
  if (Boolean(channelId) === Boolean(userId)) {
877
990
  throw new Error("exactly one of channelId or userId is required");
878
991
  }
@@ -883,11 +996,30 @@ function registerSlackBotTools(
883
996
  operationId,
884
997
  ...(channelId ? { channelId } : {}),
885
998
  ...(userId ? { userId } : {}),
999
+ ...(threadTimestamp ? { threadTimestamp } : {}),
886
1000
  text,
887
1001
  }),
888
1002
  );
889
1003
  },
890
1004
  );
1005
+
1006
+ server.registerTool(
1007
+ "slack_bot_delete_message",
1008
+ {
1009
+ description:
1010
+ "Delete a message authored by the workspace-shared OpenGeni bot. Pass the channel ID and exact message timestamp returned by a prior post or channel/thread read. Generate one operationId UUID per intended deletion and reuse it on every retry, including after an unknown outcome. Slack refuses deletion of messages not authored by this bot.",
1011
+ inputSchema: {
1012
+ connectionId: z4.string().uuid().optional(),
1013
+ operationId: z4.string().uuid(),
1014
+ channelId: z4.string().min(1).max(64),
1015
+ timestamp: z4.string().min(1).max(64),
1016
+ },
1017
+ },
1018
+ async ({ connectionId, operationId, channelId, timestamp }) =>
1019
+ json(
1020
+ await (await clientFor(connectionId)).deleteMessage({ operationId, channelId, timestamp }),
1021
+ ),
1022
+ );
891
1023
  }
892
1024
 
893
1025
  function registerToolspaceProxyTools(server: McpServer, surface: ToolspaceMcpSurface | null): void {
@@ -1150,6 +1282,70 @@ async function authorizeFirstPartySession(
1150
1282
  });
1151
1283
  }
1152
1284
 
1285
+ function preferenceAttemptClaims(grant: AccessGrant): {
1286
+ sessionId: string;
1287
+ turnId: string;
1288
+ attemptId: string;
1289
+ executionGeneration: number;
1290
+ } | null {
1291
+ const metadata = grant.metadata ?? {};
1292
+ if (
1293
+ typeof metadata["sessionId"] !== "string" ||
1294
+ typeof metadata["turnId"] !== "string" ||
1295
+ typeof metadata["attemptId"] !== "string" ||
1296
+ typeof metadata["executionGeneration"] !== "number" ||
1297
+ !Number.isSafeInteger(metadata["executionGeneration"]) ||
1298
+ metadata["executionGeneration"] < 1 ||
1299
+ metadata["executionGeneration"] > 2_147_483_647
1300
+ ) {
1301
+ return null;
1302
+ }
1303
+ return {
1304
+ sessionId: metadata["sessionId"],
1305
+ turnId: metadata["turnId"],
1306
+ attemptId: metadata["attemptId"],
1307
+ executionGeneration: metadata["executionGeneration"],
1308
+ };
1309
+ }
1310
+
1311
+ function registerPreferenceRegistryTools(
1312
+ server: McpServer,
1313
+ deps: ApiRouteDeps,
1314
+ grant: AccessGrant,
1315
+ json: JsonResult,
1316
+ ): void {
1317
+ const attemptClaims = () => {
1318
+ const resolved = preferenceAttemptClaims(grant);
1319
+ if (!resolved) throw new Error("Exact signed preference attempt authority is required.");
1320
+ return {
1321
+ accountId: grant.accountId,
1322
+ workspaceId: grant.workspaceId,
1323
+ ...resolved,
1324
+ };
1325
+ };
1326
+
1327
+ server.registerTool(
1328
+ "preference_registry_summary",
1329
+ {
1330
+ description:
1331
+ "List bounded deterministic descriptors for organization, workspace, and immutable initiating-human preferences frozen to this exact attempt. Full content is omitted; retrieve only a relevant returned handle.",
1332
+ inputSchema: {},
1333
+ },
1334
+ async () => json(await getOrCreatePreferenceRegistrySnapshot(deps.db, attemptClaims())),
1335
+ );
1336
+
1337
+ server.registerTool(
1338
+ "preference_registry_get",
1339
+ {
1340
+ description:
1341
+ "Retrieve full content for one preference in this exact attempt snapshot. Handles from another account, workspace, human, or attempt are rejected.",
1342
+ inputSchema: { retrievalHandle: z4.string().min(1).max(512) },
1343
+ },
1344
+ async ({ retrievalHandle }) =>
1345
+ json(await getPreferenceRegistryFullContent(deps.db, attemptClaims(), retrievalHandle)),
1346
+ );
1347
+ }
1348
+
1153
1349
  const MemoryKindSchema = z4.enum(["preference", "semantic", "procedural", "decision", "episodic"]);
1154
1350
 
1155
1351
  function memoryPreview(text: string): string {
@@ -1349,7 +1545,7 @@ function registerFleetTools(
1349
1545
  "run_on",
1350
1546
  {
1351
1547
  description:
1352
- "Run a ONE-OFF op on a SPECIFIC enrolled selfhosted machine WITHOUT changing this session's active sandbox (a side-channel to another machine). Ops: exec (run a command), read (read a file), write (write a file). `target` = a selfhosted sandboxes_list `id`. To make a machine the active sandbox instead, use sandbox_swap.",
1548
+ "Run a ONE-OFF op on a SPECIFIC enrolled selfhosted machine WITHOUT changing this session's active sandbox (a side-channel to another machine). Ops: exec (run a command), read (read a file), write (write a file). Exec returns exact exitCode plus typed timedOut and the effective deadlineMs; a deadline kill or missing exit proof is never ok:true, and an ambiguous transport loss is not replayed. `target` = a selfhosted sandboxes_list `id`. To make a machine the active sandbox instead, use sandbox_swap.",
1353
1549
  inputSchema: {
1354
1550
  target: z4.string().min(1),
1355
1551
  op: z4.discriminatedUnion("kind", [
@@ -1825,6 +2021,7 @@ function registerWorkspaceOrchestrationTools(
1825
2021
  rigId: z4.string().uuid().optional(),
1826
2022
  model: z4.string().min(1).optional(),
1827
2023
  reasoningEffort: z4.string().optional(),
2024
+ latencyMode: z4.enum(["standard", "priority", "fast"]).optional(),
1828
2025
  sandboxBackend: z4.string().optional(),
1829
2026
  // Create-time machine targeting: an enrolled sandbox id (from
1830
2027
  // sandboxes_list) to run the spawned session on. Seeds the active-sandbox
@@ -564,6 +564,7 @@ export function boundSessionDetailMcp(
564
564
  lastSequence: session.lastSequence,
565
565
  codexPinnedCredentialId: session.codexPinnedCredentialId,
566
566
  codexLastCredentialId: session.codexLastCredentialId,
567
+ codexCompactionMode: session.codexCompactionMode,
567
568
  pinned: session.pinned,
568
569
  pinnedAt: session.pinnedAt,
569
570
  pinVersion: session.pinVersion,
@@ -76,9 +76,12 @@ type McpTool = {
76
76
  };
77
77
 
78
78
  const APPROVAL_REQUIRED_MESSAGE = "requires approval - invoke via the agent";
79
- const TOOLSPACE_AUTH_NEEDED_ERROR_CODE = -32001;
80
- const TOOLSPACE_AUTH_NEEDED_MESSAGE =
81
- "Authentication required - a connection link was posted to the session.";
79
+ const TOOLSPACE_AUTH_NEEDED_ERROR = {
80
+ // OpenGeni application-defined JSON-RPC code. Keep this positive so it cannot
81
+ // collide with MCP SDK transport errors such as RequestTimeout (-32001).
82
+ code: 40_101,
83
+ message: "Authentication required - a connection link was posted to the session.",
84
+ } as const;
82
85
  const TOOLSPACE_NO_ACTIVE_TURN_MESSAGE =
83
86
  "no active turn - toolspace calls require an in-flight turn";
84
87
  // First-party OpenGeni MCP proxies (files/docs) route back through the same
@@ -721,7 +724,7 @@ async function callRemoteTool(
721
724
  return mcpError("upstream tool result exceeded the safety limit");
722
725
  }
723
726
  if (isToolspaceAuthNeededError(error)) {
724
- return mcpError(TOOLSPACE_AUTH_NEEDED_MESSAGE);
727
+ return mcpError(TOOLSPACE_AUTH_NEEDED_ERROR.message);
725
728
  }
726
729
  // The raw upstream error can carry provider-specific detail; log it
727
730
  // server-side and return only a generic result to the sandbox so no header
@@ -1037,8 +1040,8 @@ async function authNeededFetchResponse(
1037
1040
  jsonrpc: "2.0",
1038
1041
  id: request.id ?? null,
1039
1042
  error: {
1040
- code: TOOLSPACE_AUTH_NEEDED_ERROR_CODE,
1041
- message: TOOLSPACE_AUTH_NEEDED_MESSAGE,
1043
+ code: TOOLSPACE_AUTH_NEEDED_ERROR.code,
1044
+ message: TOOLSPACE_AUTH_NEEDED_ERROR.message,
1042
1045
  },
1043
1046
  }),
1044
1047
  {
@@ -1110,9 +1113,14 @@ function fetchInputForAttempt(input: string | URL | Request): string | URL | Req
1110
1113
  }
1111
1114
 
1112
1115
  function isToolspaceAuthNeededError(error: unknown): boolean {
1116
+ if (!(error instanceof Error)) {
1117
+ return false;
1118
+ }
1119
+ const code = (error as { code?: unknown }).code;
1113
1120
  return (
1114
- error instanceof Error &&
1115
- ((error as { code?: unknown }).code === TOOLSPACE_AUTH_NEEDED_ERROR_CODE ||
1116
- error.message.includes(TOOLSPACE_AUTH_NEEDED_MESSAGE))
1121
+ code === TOOLSPACE_AUTH_NEEDED_ERROR.code &&
1122
+ (error.message === TOOLSPACE_AUTH_NEEDED_ERROR.message ||
1123
+ error.message ===
1124
+ `MCP error ${TOOLSPACE_AUTH_NEEDED_ERROR.code}: ${TOOLSPACE_AUTH_NEEDED_ERROR.message}`)
1117
1125
  );
1118
1126
  }
@@ -10,6 +10,12 @@ import {
10
10
  OAuthStartResponse,
11
11
  UpdateConnectionRequest,
12
12
  } from "@opengeni/contracts";
13
+ import {
14
+ GOOGLE_DRIVE_PROVIDER_DOMAIN,
15
+ GoogleDriveConnectionMetadata,
16
+ GoogleDriveOAuthStartRequest,
17
+ GoogleDriveOAuthStartResponse,
18
+ } from "@opengeni/contracts/google-drive";
13
19
  import {
14
20
  hasPermission,
15
21
  hasReservedOpenGeniSlackBotMetadata,
@@ -38,6 +44,12 @@ import {
38
44
  import type { ApiRouteDeps } from "@opengeni/core";
39
45
  import type { Hono } from "hono";
40
46
  import { HTTPException } from "hono/http-exception";
47
+ import {
48
+ browseGoogleDrive,
49
+ completeGoogleDriveOAuthCallback,
50
+ saveGoogleDriveSource,
51
+ startGoogleDriveOAuth,
52
+ } from "../integrations/google-drive";
41
53
  import {
42
54
  completeMcpOAuthCallback,
43
55
  integrationBaseUrl,
@@ -96,6 +108,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
96
108
  const subjectId = writableSubjectId(payload.subjectId, grant.subjectId);
97
109
  const providerDomain = canonicalProviderDomain(payload.providerDomain);
98
110
  assertNotDirectPersonalSlackOAuth(providerDomain, payload.kind);
111
+ assertNotDirectGoogleDriveOAuth(providerDomain, payload.kind, payload.metadata);
99
112
  const connection = await createConnection(db, {
100
113
  accountId: grant.accountId,
101
114
  workspaceId,
@@ -243,6 +256,72 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
243
256
  }
244
257
  });
245
258
 
259
+ app.post("/v1/workspaces/:workspaceId/connections/google-drive/install", async (c) => {
260
+ assertIntegrationsEnabled();
261
+ const workspaceId = c.req.param("workspaceId");
262
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
263
+ const parsed = GoogleDriveOAuthStartRequest.safeParse(await c.req.json());
264
+ if (!parsed.success) {
265
+ throw new HTTPException(400, { message: "invalid Google Drive install request" });
266
+ }
267
+ return c.json(
268
+ GoogleDriveOAuthStartResponse.parse(
269
+ await startGoogleDriveOAuth(deps, {
270
+ accountId: grant.accountId,
271
+ workspaceId,
272
+ subjectId: grant.subjectId,
273
+ requestUrl: c.req.url,
274
+ payload: parsed.data,
275
+ }),
276
+ ),
277
+ );
278
+ });
279
+
280
+ app.get("/v1/integrations/google-drive/callback", async (c) => {
281
+ assertIntegrationsEnabled();
282
+ const result = await completeGoogleDriveOAuthCallback(deps, {
283
+ ...(c.req.query("code") ? { code: c.req.query("code") } : {}),
284
+ ...(c.req.query("state") ? { state: c.req.query("state") } : {}),
285
+ ...(c.req.query("error") ? { error: c.req.query("error") } : {}),
286
+ requestUrl: c.req.url,
287
+ });
288
+ return c.redirect(result.redirectTo, 302);
289
+ });
290
+
291
+ app.get(
292
+ "/v1/workspaces/:workspaceId/connections/google-drive/:connectionId/browse",
293
+ async (c) => {
294
+ assertIntegrationsEnabled();
295
+ const workspaceId = c.req.param("workspaceId");
296
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
297
+ return c.json(
298
+ await browseGoogleDrive(deps, {
299
+ workspaceId,
300
+ subjectId: grant.subjectId,
301
+ connectionId: c.req.param("connectionId"),
302
+ parentId: c.req.query("parentId") ?? "root",
303
+ ...(c.req.query("pageToken") ? { pageToken: c.req.query("pageToken") } : {}),
304
+ }),
305
+ );
306
+ },
307
+ );
308
+
309
+ app.post(
310
+ "/v1/workspaces/:workspaceId/connections/google-drive/:connectionId/source",
311
+ async (c) => {
312
+ assertIntegrationsEnabled();
313
+ const workspaceId = c.req.param("workspaceId");
314
+ const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
315
+ const connection = await saveGoogleDriveSource(deps, {
316
+ workspaceId,
317
+ subjectId: grant.subjectId,
318
+ connectionId: c.req.param("connectionId"),
319
+ payload: await c.req.json(),
320
+ });
321
+ return c.json(ConnectionResponse.parse({ connection }));
322
+ },
323
+ );
324
+
246
325
  app.get("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
247
326
  const workspaceId = c.req.param("workspaceId");
248
327
  const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
@@ -274,11 +353,18 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
274
353
  message: "use the dedicated OpenGeni Slack bot reinstall flow to update this connection",
275
354
  });
276
355
  }
356
+ if (existing && GoogleDriveConnectionMetadata.safeParse(existing.metadata).success) {
357
+ throw new HTTPException(422, {
358
+ message: "use the dedicated Google Drive reconnect or source-selection flow",
359
+ });
360
+ }
277
361
  if (existing) {
278
- assertNotDirectPersonalSlackOAuth(
279
- canonicalProviderDomain(payload.providerDomain ?? existing.providerDomain),
280
- payload.kind ?? existing.kind,
362
+ const providerDomain = canonicalProviderDomain(
363
+ payload.providerDomain ?? existing.providerDomain,
281
364
  );
365
+ const kind = payload.kind ?? existing.kind;
366
+ assertNotDirectPersonalSlackOAuth(providerDomain, kind);
367
+ assertNotDirectGoogleDriveOAuth(providerDomain, kind, payload.metadata ?? existing.metadata);
282
368
  }
283
369
  // Status is not a free-form field: revocation goes through DELETE, and the
284
370
  // broker owns needs_reauth/error. Reactivating a connection is only
@@ -415,7 +501,7 @@ async function persistOpenGeniSlackBotConnection(input: {
415
501
  const { db, settings } = input.deps;
416
502
  const key = requireEnvironmentEncryption(settings);
417
503
  const credentialEncrypted = encryptCredentialBundle(key, slackBotCredentialBundle(input.token));
418
- const existing = input.state.connectionId
504
+ const requestedExisting = input.state.connectionId
419
505
  ? await getConnectionMetadata(
420
506
  db,
421
507
  input.state.workspaceId,
@@ -423,7 +509,7 @@ async function persistOpenGeniSlackBotConnection(input: {
423
509
  input.state.subjectId,
424
510
  )
425
511
  : null;
426
- if (input.state.connectionId && !existing) {
512
+ if (input.state.connectionId && !requestedExisting) {
427
513
  throw new SlackInstallCallbackError(
428
514
  404,
429
515
  "connection_conflict",
@@ -431,7 +517,7 @@ async function persistOpenGeniSlackBotConnection(input: {
431
517
  "principal_validation",
432
518
  );
433
519
  }
434
- if (existing && !isOpenGeniSlackBotConnection(existing)) {
520
+ if (requestedExisting && !isOpenGeniSlackBotConnection(requestedExisting)) {
435
521
  throw new SlackInstallCallbackError(
436
522
  422,
437
523
  "connection_conflict",
@@ -439,7 +525,7 @@ async function persistOpenGeniSlackBotConnection(input: {
439
525
  "principal_validation",
440
526
  );
441
527
  }
442
- if (existing?.version !== input.state.connectionVersion) {
528
+ if (requestedExisting && requestedExisting.version !== input.state.connectionVersion) {
443
529
  throw new SlackInstallCallbackError(
444
530
  409,
445
531
  "connection_conflict",
@@ -447,6 +533,13 @@ async function persistOpenGeniSlackBotConnection(input: {
447
533
  "principal_validation",
448
534
  );
449
535
  }
536
+ const existing =
537
+ requestedExisting ??
538
+ (await findMatchingOpenGeniSlackBotConnection(
539
+ db,
540
+ input.state.workspaceId,
541
+ input.verified.metadata,
542
+ ));
450
543
  const existingMetadata = existing ? openGeniSlackBotMetadata(existing.metadata) : null;
451
544
  if (existingMetadata && existingMetadata.slackTeamId !== input.verified.metadata.slackTeamId) {
452
545
  throw new SlackInstallCallbackError(
@@ -526,6 +619,28 @@ async function persistOpenGeniSlackBotConnection(input: {
526
619
  return connection;
527
620
  }
528
621
 
622
+ async function findMatchingOpenGeniSlackBotConnection(
623
+ db: ApiRouteDeps["db"],
624
+ workspaceId: string,
625
+ verified: Awaited<ReturnType<typeof verifyOpenGeniSlackBotCredential>>["metadata"],
626
+ ) {
627
+ const connections = await listConnectionsMetadata(db, workspaceId, null);
628
+ return (
629
+ connections.find((connection) => {
630
+ const metadata = openGeniSlackBotMetadata(connection.metadata);
631
+ return (
632
+ connection.subjectId === null &&
633
+ connection.providerDomain === "slack.com" &&
634
+ connection.kind === "app_install" &&
635
+ metadata?.credentialRole === OPENGENI_SLACK_BOT_CREDENTIAL_ROLE &&
636
+ metadata.slackTeamId === verified.slackTeamId &&
637
+ metadata.botId === verified.botId &&
638
+ metadata.botUserId === verified.botUserId
639
+ );
640
+ }) ?? null
641
+ );
642
+ }
643
+
529
644
  function requireOpenGeniSlackOAuthSettings(settings: ApiRouteDeps["settings"]): {
530
645
  clientId: string;
531
646
  clientSecret: string;
@@ -685,6 +800,21 @@ function assertNotDirectPersonalSlackOAuth(providerDomain: string, kind: string)
685
800
  }
686
801
  }
687
802
 
803
+ function assertNotDirectGoogleDriveOAuth(
804
+ providerDomain: string,
805
+ kind: string,
806
+ metadata: Record<string, unknown> | undefined,
807
+ ): void {
808
+ if (
809
+ (providerDomain === GOOGLE_DRIVE_PROVIDER_DOMAIN && kind === "oauth2") ||
810
+ GoogleDriveConnectionMetadata.safeParse(metadata).success
811
+ ) {
812
+ throw new HTTPException(422, {
813
+ message: "Google Drive credentials must use the dedicated OAuth connection flow",
814
+ });
815
+ }
816
+ }
817
+
688
818
  function assertNotReservedSlackBotMetadata(metadata: Record<string, unknown> | undefined): void {
689
819
  if (hasReservedOpenGeniSlackBotMetadata(metadata)) {
690
820
  throw new HTTPException(422, {
@@ -37,7 +37,7 @@ import {
37
37
  searchDocuments,
38
38
  } from "@opengeni/documents";
39
39
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
40
- import type { Hono } from "hono";
40
+ import type { Context, Hono } from "hono";
41
41
  import { HTTPException } from "hono/http-exception";
42
42
  import { requireAccessGrant } from "@opengeni/core";
43
43
  import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
@@ -252,7 +252,7 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
252
252
  app.post("/v1/workspaces/:workspaceId/knowledge/search", async (c) => {
253
253
  const workspaceId = c.req.param("workspaceId");
254
254
  const grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
255
- const payload = DocumentSearchRequest.parse(await c.req.json());
255
+ const payload = await parseDocumentSearchRequest(c, "invalid knowledge search request");
256
256
  return c.json({
257
257
  results: await searchDocuments(
258
258
  db,
@@ -567,6 +567,17 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
567
567
  });
568
568
  }
569
569
 
570
+ async function parseDocumentSearchRequest(
571
+ context: Context,
572
+ message: string,
573
+ ): Promise<DocumentSearchRequest> {
574
+ const parsed = DocumentSearchRequest.safeParse(await context.req.json().catch(() => null));
575
+ if (!parsed.success) {
576
+ throw new HTTPException(422, { message });
577
+ }
578
+ return parsed.data;
579
+ }
580
+
570
581
  /** Derive a .txt filename for a raw-text drop from its optional title/filename. */
571
582
  function dropFilename(preferred: string | undefined): string {
572
583
  const stem = (preferred ?? "").trim() || `note-${new Date().toISOString().slice(0, 10)}`;
@@ -522,10 +522,12 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
522
522
  );
523
523
  });
524
524
 
525
- app.post("/v1/workspaces/:workspaceId/github/installations/select", async (c) => {
525
+ // This is browser navigation, like /github/connect and the OAuth callbacks.
526
+ // Keep it GET: a native HTML form cannot attach the API contract header that
527
+ // protects product mutations, and no durable binding is written here.
528
+ app.get("/v1/workspaces/:workspaceId/github/installations/select", async (c) => {
526
529
  const workspaceId = c.req.param("workspaceId");
527
- const form = new URLSearchParams(await c.req.text());
528
- const state = form.get("state");
530
+ const state = c.req.query("state");
529
531
  if (!state) {
530
532
  throw new HTTPException(400, { message: "missing GitHub installation selection state" });
531
533
  }
@@ -549,7 +551,7 @@ export function registerGitHubRoutes(app: Hono, deps: ApiRouteDeps): void {
549
551
  message: "GitHub installation state does not match this workspace",
550
552
  });
551
553
  }
552
- const selected = form.get("installation_id");
554
+ const selected = c.req.query("installation_id");
553
555
  if (selected === "new") {
554
556
  return redirectToGitHubInstallation(c, deps, state);
555
557
  }
@@ -781,7 +783,7 @@ function githubInstallationChooserHtml(
781
783
  return `<label class="option"><input type="radio" name="installation_id" value="${installation.installationId}" required><span><strong>${account}</strong><small>${label}</small></span></label>`;
782
784
  })
783
785
  .join("");
784
- return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Choose GitHub installation</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:12px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px}p{margin:0 0 18px;color:#d4d4d8}.options{display:grid;gap:8px;margin-bottom:18px}.option{display:flex;align-items:center;gap:12px;border:1px solid #3f3f46;border-radius:8px;padding:12px;cursor:pointer}.option span{display:grid;gap:2px}.option small{color:#a1a1aa}button{min-height:38px;border-radius:7px;border:1px solid #3f3f46;padding:0 14px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;cursor:pointer}.secondary{margin-left:8px;background:transparent;color:#f4f4f5}</style></head><body><main><h1>Choose a GitHub account</h1><p>Only installations where GitHub proved you are the personal owner or an active organization owner are shown.</p><form method="post" action="${escapeHtml(action)}"><input type="hidden" name="state" value="${escapeHtml(state)}"><div class="options">${options}</div><button type="submit">Connect selected</button><button class="secondary" type="submit" name="installation_id" value="new" formnovalidate>Install on another account</button></form></main></body></html>`;
786
+ return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Choose GitHub installation</title><style>body{font-family:system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#0b0b0d;color:#f4f4f5}main{width:min(640px,calc(100vw - 32px));border:1px solid #27272a;border-radius:12px;padding:28px;background:#111114}h1{margin:0 0 10px;font-size:24px}p{margin:0 0 18px;color:#d4d4d8}.options{display:grid;gap:8px;margin-bottom:18px}.option{display:flex;align-items:center;gap:12px;border:1px solid #3f3f46;border-radius:8px;padding:12px;cursor:pointer}.option span{display:grid;gap:2px}.option small{color:#a1a1aa}button{min-height:38px;border-radius:7px;border:1px solid #3f3f46;padding:0 14px;background:#f4f4f5;color:#09090b;font:600 14px system-ui,sans-serif;cursor:pointer}.secondary{margin-left:8px;background:transparent;color:#f4f4f5}</style></head><body><main><h1>Choose a GitHub account</h1><p>Only installations where GitHub proved you are the personal owner or an active organization owner are shown.</p><form method="get" action="${escapeHtml(action)}"><input type="hidden" name="state" value="${escapeHtml(state)}"><div class="options">${options}</div><button type="submit">Connect selected</button><button class="secondary" type="submit" name="installation_id" value="new" formnovalidate>Install on another account</button></form></main></body></html>`;
785
787
  }
786
788
 
787
789
  function githubSetupPendingHtml(): string {