@opengeni/api-router 0.30.3 → 2.2.0-canary.0

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 (165) hide show
  1. package/dist/api-websocket.d.ts +18 -0
  2. package/dist/app.js +1 -1
  3. package/dist/auth/managed-auth.d.ts +6 -0
  4. package/dist/{chunk-WCXHI3FF.js → chunk-3TP54PPX.js} +26692 -15389
  5. package/dist/chunk-3TP54PPX.js.map +1 -0
  6. package/dist/codemode.d.ts +8 -1
  7. package/dist/company-brain-okf.d.ts +24 -0
  8. package/dist/connected-machine-computer-access.d.ts +11 -0
  9. package/dist/connection-authority-owner.d.ts +59 -0
  10. package/dist/connection-ownership.d.ts +47 -0
  11. package/dist/controller-data-plane.d.ts +16 -0
  12. package/dist/editable-artifact-websocket.d.ts +5 -15
  13. package/dist/editable-artifact-workspace-files.d.ts +1 -1
  14. package/dist/http/api-error.d.ts +10 -0
  15. package/dist/http/cors.d.ts +5 -0
  16. package/dist/http/interaction-control-error.d.ts +13 -0
  17. package/dist/http/sse.d.ts +2 -3
  18. package/dist/index.d.ts +5 -2
  19. package/dist/index.js +326 -82
  20. package/dist/index.js.map +1 -1
  21. package/dist/integrations/atlassian.d.ts +17 -0
  22. package/dist/integrations/fiken.d.ts +7 -2
  23. package/dist/integrations/google-drive.d.ts +18 -0
  24. package/dist/integrations/oauth-client.d.ts +22 -3
  25. package/dist/integrations/oauth-profiles.d.ts +195 -0
  26. package/dist/integrations/personal-github-repositories.d.ts +45 -0
  27. package/dist/integrations/personal-github.d.ts +40 -0
  28. package/dist/integrations/pr-review-provider.d.ts +22 -0
  29. package/dist/integrations/provider-oauth.d.ts +8 -0
  30. package/dist/integrations/slack-app-home.d.ts +24 -0
  31. package/dist/integrations/slack-bot.d.ts +157 -9
  32. package/dist/integrations/slack-interactions.d.ts +39 -5
  33. package/dist/integrations/slack-routing.d.ts +120 -0
  34. package/dist/integrations/social-oauth.d.ts +5 -0
  35. package/dist/interaction-frame-proxy.d.ts +68 -0
  36. package/dist/mcp/company-brain-governed-writes.d.ts +26 -0
  37. package/dist/mcp/company-profile-agent-admin.d.ts +44 -0
  38. package/dist/mcp/documents.d.ts +1 -0
  39. package/dist/mcp/remember.d.ts +28 -0
  40. package/dist/mcp/request-abort.d.ts +19 -0
  41. package/dist/mcp/scheduled-task-view.d.ts +4 -4
  42. package/dist/mcp/server.d.ts +15 -2
  43. package/dist/mcp/session-view.d.ts +4 -0
  44. package/dist/mcp/session-wait.d.ts +137 -0
  45. package/dist/routes/automations.d.ts +4 -0
  46. package/dist/routes/billing.d.ts +5 -0
  47. package/dist/routes/browser-sessions.d.ts +4 -1
  48. package/dist/routes/company-brain.d.ts +3 -0
  49. package/dist/routes/company-profile.d.ts +2 -1
  50. package/dist/routes/connection-authorities.d.ts +3 -0
  51. package/dist/routes/personal-github-git-broker.d.ts +29 -0
  52. package/dist/routes/personal-github.d.ts +3 -0
  53. package/dist/routes/pr-review.d.ts +3 -0
  54. package/dist/routes/sessions.d.ts +1 -0
  55. package/dist/routes/user-resource-authorities.d.ts +3 -0
  56. package/dist/routes/workspace-capture.d.ts +10 -2
  57. package/dist/routes/workspace-learning.d.ts +3 -0
  58. package/dist/routes/workspaces.d.ts +19 -0
  59. package/dist/sandbox/channel-a.d.ts +11 -1
  60. package/dist/sandbox/connection-authority.d.ts +3 -0
  61. package/dist/sandbox/enrollment.d.ts +2 -0
  62. package/dist/sandbox/machines.d.ts +2 -2
  63. package/dist/sandbox/metrics-ingestion.d.ts +10 -20
  64. package/dist/sandbox/viewer.d.ts +24 -1
  65. package/dist/sandbox-file-artifacts.d.ts +11 -0
  66. package/dist/scheduled-task-deletion.d.ts +14 -0
  67. package/dist/slack-reaction-files.d.ts +1 -1
  68. package/dist/temporal-schedule-cleanup.d.ts +1 -0
  69. package/package.json +20 -18
  70. package/src/api-websocket.ts +23 -0
  71. package/src/app.ts +161 -23
  72. package/src/auth/managed-auth.ts +25 -3
  73. package/src/codemode.ts +53 -23
  74. package/src/codex-realtime.ts +8 -2
  75. package/src/company-brain-okf.ts +340 -0
  76. package/src/connected-machine-computer-access.ts +33 -0
  77. package/src/connection-authority-owner.ts +61 -0
  78. package/src/connection-ownership.ts +180 -0
  79. package/src/controller-data-plane.ts +47 -0
  80. package/src/editable-artifact-native-kernel.ts +26 -15
  81. package/src/editable-artifact-office-import.ts +28 -3
  82. package/src/editable-artifact-production.ts +13 -7
  83. package/src/editable-artifact-websocket.ts +11 -18
  84. package/src/editable-artifact-workspace-files.ts +31 -6
  85. package/src/http/api-error.ts +28 -0
  86. package/src/http/auth.ts +4 -0
  87. package/src/http/cors.ts +35 -0
  88. package/src/http/interaction-control-error.ts +164 -0
  89. package/src/http/sse.ts +112 -66
  90. package/src/index.ts +46 -6
  91. package/src/integrations/atlassian.ts +146 -35
  92. package/src/integrations/fiken.ts +102 -22
  93. package/src/integrations/google-drive.ts +301 -92
  94. package/src/integrations/oauth-client.ts +272 -143
  95. package/src/integrations/oauth-profiles.ts +477 -0
  96. package/src/integrations/personal-github-repositories.ts +445 -0
  97. package/src/integrations/personal-github.ts +705 -0
  98. package/src/integrations/pr-review-provider.ts +246 -0
  99. package/src/integrations/provider-oauth.ts +121 -5
  100. package/src/integrations/slack-app-home.ts +300 -0
  101. package/src/integrations/slack-bot.ts +342 -57
  102. package/src/integrations/slack-interactions.ts +1898 -259
  103. package/src/integrations/slack-routing.ts +337 -0
  104. package/src/integrations/social-oauth.ts +25 -0
  105. package/src/interaction-frame-proxy.ts +409 -0
  106. package/src/mcp/company-brain-governed-writes.ts +262 -0
  107. package/src/mcp/company-profile-agent-admin.ts +205 -0
  108. package/src/mcp/documents.ts +37 -6
  109. package/src/mcp/files.ts +16 -1
  110. package/src/mcp/remember.ts +181 -0
  111. package/src/mcp/request-abort.ts +44 -0
  112. package/src/mcp/scheduled-task-view.ts +1 -0
  113. package/src/mcp/server.ts +719 -369
  114. package/src/mcp/session-view.ts +54 -0
  115. package/src/mcp/session-wait.ts +554 -0
  116. package/src/model-catalog.ts +20 -12
  117. package/src/routes/api-integrations.ts +4 -0
  118. package/src/routes/api-keys.ts +1 -0
  119. package/src/routes/automations.ts +534 -0
  120. package/src/routes/billing.ts +57 -1
  121. package/src/routes/browser-sessions.ts +329 -79
  122. package/src/routes/channels.ts +17 -1
  123. package/src/routes/codex.ts +50 -0
  124. package/src/routes/company-brain.ts +367 -0
  125. package/src/routes/company-profile.ts +1 -1
  126. package/src/routes/computer-sessions.ts +320 -93
  127. package/src/routes/connection-authorities.ts +139 -0
  128. package/src/routes/connections.ts +144 -34
  129. package/src/routes/documents.ts +353 -4
  130. package/src/routes/editable-artifacts.ts +11 -3
  131. package/src/routes/enrollments.ts +116 -26
  132. package/src/routes/environments.ts +127 -55
  133. package/src/routes/files.ts +72 -11
  134. package/src/routes/install.ts +82 -28
  135. package/src/routes/integration-facets.ts +29 -0
  136. package/src/routes/interaction-resources.ts +20 -2
  137. package/src/routes/machines.ts +275 -13
  138. package/src/routes/organization-memberships.ts +717 -28
  139. package/src/routes/packs.ts +4 -3
  140. package/src/routes/personal-github-git-broker.ts +785 -0
  141. package/src/routes/personal-github.ts +319 -0
  142. package/src/routes/pr-review.ts +531 -0
  143. package/src/routes/rigs.ts +104 -39
  144. package/src/routes/scheduled-tasks.ts +19 -21
  145. package/src/routes/sessions.ts +775 -38
  146. package/src/routes/social.ts +14 -2
  147. package/src/routes/supergrok.ts +23 -4
  148. package/src/routes/transcription-recordings.ts +8 -2
  149. package/src/routes/user-resource-authorities.ts +138 -0
  150. package/src/routes/workspace-artifacts.ts +4 -1
  151. package/src/routes/workspace-capture.ts +21 -0
  152. package/src/routes/workspace-learning.ts +228 -0
  153. package/src/routes/workspaces.ts +59 -6
  154. package/src/sandbox/auth-callout.ts +38 -12
  155. package/src/sandbox/channel-a.ts +242 -19
  156. package/src/sandbox/connection-authority.ts +3 -0
  157. package/src/sandbox/enrollment.ts +15 -3
  158. package/src/sandbox/machines.ts +186 -64
  159. package/src/sandbox/metrics-ingestion.ts +220 -58
  160. package/src/sandbox/viewer.ts +243 -19
  161. package/src/sandbox-file-artifacts.ts +329 -0
  162. package/src/scheduled-task-deletion.ts +127 -0
  163. package/src/slack-reaction-files.ts +16 -5
  164. package/src/temporal-schedule-cleanup.ts +13 -0
  165. package/dist/chunk-WCXHI3FF.js.map +0 -1
@@ -0,0 +1,329 @@
1
+ import { createHash } from "node:crypto";
2
+ import { posix } from "node:path";
3
+
4
+ import {
5
+ SANDBOX_FILE_ARTIFACT_MAX_BYTES,
6
+ SandboxFileArtifactReceipt,
7
+ retainedArtifactReferenceFromFile,
8
+ type AccessGrant,
9
+ type Session,
10
+ } from "@opengeni/contracts";
11
+ import { recordWorkspaceUsage, requireLimit, type ApiRouteDeps } from "@opengeni/core";
12
+ import { completeFileUpload, getFile, prepareGeneratedWorkspaceFile } from "@opengeni/db";
13
+ import { retryWhileMissing, type ObjectHead, type ObjectStorage } from "@opengeni/storage";
14
+ import { HTTPException } from "hono/http-exception";
15
+
16
+ import { withChannelARead } from "./sandbox/channel-a";
17
+ import { sanitizeFilename } from "./routes/files";
18
+
19
+ const FILE_READ_SENTINEL_BYTES = 1;
20
+ const UPLOAD_INTENT_TTL_MS = 60 * 60_000;
21
+ const SANDBOX_ARTIFACT_ABSOLUTE_PATH_MAX_CHARS = 4_096;
22
+ const SANDBOX_ARTIFACT_SAFE_FILENAME_MAX_CHARS = 200;
23
+
24
+ export async function publishSandboxFileArtifact(
25
+ deps: ApiRouteDeps,
26
+ input: {
27
+ grant: AccessGrant;
28
+ session: Session;
29
+ path: string;
30
+ signal?: AbortSignal | undefined;
31
+ },
32
+ ): Promise<SandboxFileArtifactReceipt> {
33
+ if (!deps.settings.sandboxOwnershipEnabled) {
34
+ throw new HTTPException(404, {
35
+ message: "sandbox ownership is not enabled for this deployment",
36
+ });
37
+ }
38
+ const storage = deps.objectStorage;
39
+ if (!storage) {
40
+ throw new HTTPException(503, { message: "object storage is not configured" });
41
+ }
42
+ if (!storage.headObject) {
43
+ throw new HTTPException(503, {
44
+ message: "object storage cannot verify immutable artifact files",
45
+ });
46
+ }
47
+
48
+ const path = sandboxArtifactRelativePath(input.path);
49
+ const maxArtifactBytes = Math.min(SANDBOX_FILE_ARTIFACT_MAX_BYTES, storage.maxSinglePutSizeBytes);
50
+ if (maxArtifactBytes < 1) {
51
+ throw new HTTPException(503, { message: "object storage cannot accept artifact files" });
52
+ }
53
+ const readLimit = Math.min(25 * 1024 * 1024, maxArtifactBytes + FILE_READ_SENTINEL_BYTES);
54
+ const read = await withChannelARead(
55
+ {
56
+ db: deps.db,
57
+ settings: deps.settings,
58
+ bus: deps.bus,
59
+ ...(deps.observability ? { observability: deps.observability } : {}),
60
+ },
61
+ {
62
+ accountId: input.grant.accountId,
63
+ workspaceId: input.grant.workspaceId,
64
+ session: input.session,
65
+ subjectId: input.grant.subjectId,
66
+ ...(input.signal ? { waitSignal: input.signal } : {}),
67
+ operation: "artifact.publish",
68
+ },
69
+ ({ service }) => service.fsRead({ path, encoding: "base64", maxBytes: readLimit }),
70
+ );
71
+ if (read.truncated || read.sizeBytes > maxArtifactBytes) {
72
+ throw new HTTPException(413, {
73
+ message: `sandbox file exceeds the ${maxArtifactBytes}-byte artifact publication limit`,
74
+ });
75
+ }
76
+ if (read.sizeBytes === 0) {
77
+ throw new HTTPException(422, { message: "sandbox artifact file is empty" });
78
+ }
79
+ if (read.encoding !== "base64") {
80
+ throw new HTTPException(502, { message: "sandbox returned an invalid binary file response" });
81
+ }
82
+ const bytes = Buffer.from(read.content, "base64");
83
+ if (bytes.byteLength !== read.sizeBytes) {
84
+ throw new HTTPException(502, { message: "sandbox returned an invalid file length" });
85
+ }
86
+
87
+ const filename = posix.basename(path);
88
+ if (filename.length > 1_024) {
89
+ throw new HTTPException(422, { message: "sandbox artifact filename is too long" });
90
+ }
91
+ const safeFilename = sandboxArtifactSafeFilename(filename);
92
+ const contentType = sandboxFileContentType(filename);
93
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
94
+ const identity = sandboxArtifactIdentity({
95
+ workspaceId: input.grant.workspaceId,
96
+ sessionId: input.session.id,
97
+ path,
98
+ sha256,
99
+ });
100
+ const objectKey = `workspaces/${input.grant.workspaceId}/files/${identity.fileId}/sandbox/${safeFilename}`;
101
+ const existing = await getFile(deps.db, input.grant.workspaceId, identity.fileId);
102
+ let file = existing;
103
+
104
+ if (!file) {
105
+ await requireLimit(deps, {
106
+ accountId: input.grant.accountId,
107
+ workspaceId: input.grant.workspaceId,
108
+ action: "file:upload",
109
+ quantity: bytes.byteLength,
110
+ });
111
+ const prepared = await prepareGeneratedWorkspaceFile(deps.db, {
112
+ accountId: input.grant.accountId,
113
+ workspaceId: input.grant.workspaceId,
114
+ fileId: identity.fileId,
115
+ uploadId: identity.uploadId,
116
+ filename,
117
+ safeFilename,
118
+ contentType,
119
+ sizeBytes: bytes.byteLength,
120
+ sha256,
121
+ bucket: storage.bucket,
122
+ objectKey,
123
+ expiresAt: new Date(Date.now() + UPLOAD_INTENT_TTL_MS),
124
+ });
125
+ file = prepared.file;
126
+ }
127
+
128
+ assertSandboxArtifactFile(file, {
129
+ filename,
130
+ safeFilename,
131
+ contentType,
132
+ sizeBytes: bytes.byteLength,
133
+ sha256,
134
+ bucket: storage.bucket,
135
+ objectKey,
136
+ });
137
+ if (file.status !== "ready") {
138
+ const existingObject = await storage.headObject(file.objectKey);
139
+ if (existingObject) {
140
+ assertStoredSandboxArtifact(existingObject, file);
141
+ } else {
142
+ const put = {
143
+ key: file.objectKey,
144
+ contentType: file.contentType,
145
+ body: bytes,
146
+ sha256,
147
+ };
148
+ if (storage.putObjectIfAbsent) {
149
+ const created = await storage.putObjectIfAbsent(put);
150
+ if (!created) await assertVisibleSandboxArtifact(storage, file);
151
+ } else {
152
+ await storage.putObject(put);
153
+ }
154
+ }
155
+ file = await completeFileUpload(deps.db, input.grant.workspaceId, identity.uploadId);
156
+ } else {
157
+ await assertVisibleSandboxArtifact(storage, file);
158
+ }
159
+
160
+ await recordWorkspaceUsage(deps, {
161
+ accountId: input.grant.accountId,
162
+ workspaceId: input.grant.workspaceId,
163
+ subjectId: input.grant.subjectId,
164
+ eventType: "file.uploaded",
165
+ quantity: file.sizeBytes,
166
+ unit: "byte",
167
+ sourceResourceType: "file",
168
+ sourceResourceId: file.id,
169
+ idempotencyKey: `file.uploaded:${input.grant.workspaceId}:${file.id}`,
170
+ });
171
+
172
+ const artifact = retainedArtifactReferenceFromFile(file, "file");
173
+ if (!artifact) {
174
+ throw new HTTPException(502, { message: "published sandbox artifact is not ready" });
175
+ }
176
+ return SandboxFileArtifactReceipt.parse({
177
+ type: "sandbox_file",
178
+ sandboxPath: `/workspace/${path}`,
179
+ filename,
180
+ artifact,
181
+ });
182
+ }
183
+
184
+ export function sandboxArtifactRelativePath(value: string): string {
185
+ let path = value.trim();
186
+ if (path.startsWith("sandbox:")) {
187
+ path = path.slice("sandbox:".length);
188
+ }
189
+ if (path === "/workspace" || path === "/workspace/") {
190
+ throw new HTTPException(400, { message: "sandbox artifact path must name a file" });
191
+ }
192
+ if (path.startsWith("/workspace/")) {
193
+ path = path.slice("/workspace/".length);
194
+ } else if (path.startsWith("/")) {
195
+ throw new HTTPException(400, {
196
+ message: "sandbox artifact path must be inside /workspace",
197
+ });
198
+ }
199
+ if (!path) {
200
+ throw new HTTPException(400, { message: "sandbox artifact path must name a file" });
201
+ }
202
+ if (path.includes("\0")) {
203
+ throw new HTTPException(400, { message: "sandbox artifact path is invalid" });
204
+ }
205
+ const normalized = posix.normalize(path);
206
+ if (
207
+ normalized === "." ||
208
+ normalized === ".." ||
209
+ normalized.startsWith("../") ||
210
+ normalized.endsWith("/")
211
+ ) {
212
+ throw new HTTPException(400, {
213
+ message: "sandbox artifact path must canonically name a file inside /workspace",
214
+ });
215
+ }
216
+ if (`/workspace/${normalized}`.length > SANDBOX_ARTIFACT_ABSOLUTE_PATH_MAX_CHARS) {
217
+ throw new HTTPException(400, { message: "sandbox artifact path is too long" });
218
+ }
219
+ return normalized;
220
+ }
221
+
222
+ export function sandboxArtifactSafeFilename(filename: string): string {
223
+ return sanitizeFilename(filename).slice(0, SANDBOX_ARTIFACT_SAFE_FILENAME_MAX_CHARS);
224
+ }
225
+
226
+ export function sandboxFileContentType(filename: string): string {
227
+ const extension = posix.extname(filename).toLowerCase();
228
+ return (
229
+ {
230
+ ".csv": "text/csv",
231
+ ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
232
+ ".gz": "application/gzip",
233
+ ".html": "text/html",
234
+ ".htm": "text/html",
235
+ ".jpeg": "image/jpeg",
236
+ ".jpg": "image/jpeg",
237
+ ".json": "application/json",
238
+ ".md": "text/markdown",
239
+ ".pdf": "application/pdf",
240
+ ".png": "image/png",
241
+ ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
242
+ ".tar": "application/x-tar",
243
+ ".txt": "text/plain",
244
+ ".webp": "image/webp",
245
+ ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
246
+ ".zip": "application/zip",
247
+ }[extension] ?? "application/octet-stream"
248
+ );
249
+ }
250
+
251
+ function sandboxArtifactIdentity(input: {
252
+ workspaceId: string;
253
+ sessionId: string;
254
+ path: string;
255
+ sha256: string;
256
+ }): { fileId: string; uploadId: string } {
257
+ const digest = createHash("sha256")
258
+ .update("opengeni-sandbox-file-artifact-v1\0")
259
+ .update(input.workspaceId)
260
+ .update("\0")
261
+ .update(input.sessionId)
262
+ .update("\0")
263
+ .update(input.path)
264
+ .update("\0")
265
+ .update(input.sha256)
266
+ .digest("hex");
267
+ return {
268
+ fileId: uuidFromDigest(digest, 0),
269
+ uploadId: uuidFromDigest(digest, 16),
270
+ };
271
+ }
272
+
273
+ function uuidFromDigest(digest: string, startByte: number): string {
274
+ const bytes = Buffer.from(digest, "hex").subarray(startByte, startByte + 16);
275
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x40;
276
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
277
+ const hex = bytes.toString("hex");
278
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
279
+ }
280
+
281
+ function assertSandboxArtifactFile(
282
+ file: NonNullable<Awaited<ReturnType<typeof getFile>>>,
283
+ expected: {
284
+ filename: string;
285
+ safeFilename: string;
286
+ contentType: string;
287
+ sizeBytes: number;
288
+ sha256: string;
289
+ bucket: string;
290
+ objectKey: string;
291
+ },
292
+ ): void {
293
+ if (
294
+ !["pending_upload", "ready"].includes(file.status) ||
295
+ file.filename !== expected.filename ||
296
+ file.safeFilename !== expected.safeFilename ||
297
+ file.contentType !== expected.contentType ||
298
+ file.sizeBytes !== expected.sizeBytes ||
299
+ file.sha256 !== expected.sha256 ||
300
+ file.bucket !== expected.bucket ||
301
+ file.objectKey !== expected.objectKey
302
+ ) {
303
+ throw new HTTPException(409, { message: "sandbox artifact identity is unavailable" });
304
+ }
305
+ }
306
+
307
+ async function assertVisibleSandboxArtifact(
308
+ storage: ObjectStorage,
309
+ file: NonNullable<Awaited<ReturnType<typeof getFile>>>,
310
+ ): Promise<void> {
311
+ const head = await retryWhileMissing(async () => await storage.headObject!(file.objectKey));
312
+ assertStoredSandboxArtifact(head, file);
313
+ }
314
+
315
+ function assertStoredSandboxArtifact(
316
+ head: ObjectHead | null,
317
+ file: NonNullable<Awaited<ReturnType<typeof getFile>>>,
318
+ ): void {
319
+ if (
320
+ !head ||
321
+ head.ContentLength !== file.sizeBytes ||
322
+ head.ContentType !== file.contentType ||
323
+ (file.sha256 !== null && head.Metadata?.sha256 !== file.sha256) ||
324
+ typeof head.VersionToken !== "string" ||
325
+ head.VersionToken.length < 1
326
+ ) {
327
+ throw new HTTPException(502, { message: "sandbox artifact failed storage verification" });
328
+ }
329
+ }
@@ -0,0 +1,127 @@
1
+ import type { ScheduledTask as ScheduledTaskValue } from "@opengeni/contracts";
2
+ import { deleteScheduledTaskLifecycle, type ApiRouteDeps } from "@opengeni/core";
3
+ import type { TemporalScheduleCleanupClaim } from "@opengeni/db";
4
+ import { HTTPException } from "hono/http-exception";
5
+ import * as z4 from "zod/v4";
6
+ import {
7
+ cleanupAtlassianScheduleAuthorization,
8
+ preflightAtlassianScheduleAuthorization,
9
+ revokeAtlassianScheduleAuthorization,
10
+ } from "./integrations/atlassian";
11
+ import {
12
+ cleanupKnowledgeSourceScheduleAuthorization,
13
+ preflightKnowledgeSourceScheduleAuthorization,
14
+ revokeKnowledgeSourceScheduleAuthorization,
15
+ } from "./integrations/google-drive";
16
+ import { processTemporalScheduleCleanupClaims } from "./temporal-schedule-cleanup";
17
+
18
+ const ScheduledConnectorCleanupV1 = z4.object({
19
+ version: z4.literal(1),
20
+ taskId: z4.string().uuid(),
21
+ accountId: z4.string().uuid(),
22
+ workspaceId: z4.string().uuid(),
23
+ connectorKind: z4.enum(["google_drive", "atlassian"]),
24
+ connectionId: z4.string().uuid(),
25
+ connectionVersion: z4.number().int().positive(),
26
+ sourceId: z4.string().uuid(),
27
+ sourceLifecycleGeneration: z4.number().int().positive(),
28
+ sourceConfigGeneration: z4.number().int().positive(),
29
+ externalSourceId: z4.string().min(1).max(512),
30
+ subjectId: z4.string().min(1).max(1024),
31
+ });
32
+
33
+ async function preflightConnectorAuthorization(
34
+ deps: ApiRouteDeps,
35
+ task: ScheduledTaskValue,
36
+ subjectId: string,
37
+ ): Promise<void> {
38
+ if (task.action.kind !== "knowledge_source_sync") return;
39
+ if (task.metadata.connectorKind === "atlassian") {
40
+ await preflightAtlassianScheduleAuthorization(deps, { task, subjectId });
41
+ return;
42
+ }
43
+ if (task.metadata.connectorKind === "google_drive") {
44
+ await preflightKnowledgeSourceScheduleAuthorization(deps, { task, subjectId });
45
+ return;
46
+ }
47
+ throw new HTTPException(409, {
48
+ message: "knowledge source schedule connector cannot be durably disabled",
49
+ });
50
+ }
51
+
52
+ async function revokeConnectorAuthorization(
53
+ deps: ApiRouteDeps,
54
+ task: ScheduledTaskValue,
55
+ subjectId: string,
56
+ ): Promise<void> {
57
+ if (task.action.kind !== "knowledge_source_sync") return;
58
+ if (task.metadata.connectorKind === "atlassian") {
59
+ await revokeAtlassianScheduleAuthorization(deps, { task, subjectId });
60
+ return;
61
+ }
62
+ if (task.metadata.connectorKind === "google_drive") {
63
+ await revokeKnowledgeSourceScheduleAuthorization(deps, { task, subjectId });
64
+ return;
65
+ }
66
+ throw new HTTPException(409, {
67
+ message: "knowledge source schedule connector cannot be durably disabled",
68
+ });
69
+ }
70
+
71
+ /** Execute one exact connector obligation frozen into the tombstone outbox row. */
72
+ export async function cleanupScheduledTaskConnectorAuthorization(
73
+ deps: ApiRouteDeps,
74
+ claim: TemporalScheduleCleanupClaim,
75
+ ): Promise<void> {
76
+ if (!claim.connectorCleanupSnapshot) return;
77
+ const cleanup = ScheduledConnectorCleanupV1.parse(claim.connectorCleanupSnapshot);
78
+ const subjectId = claim.connectorCleanupSubjectId;
79
+ if (
80
+ !subjectId ||
81
+ claim.scheduledTaskId !== cleanup.taskId ||
82
+ claim.accountId !== cleanup.accountId ||
83
+ claim.workspaceId !== cleanup.workspaceId ||
84
+ cleanup.subjectId !== subjectId
85
+ ) {
86
+ throw new Error("scheduled connector cleanup receipt is contradictory");
87
+ }
88
+ if (cleanup.connectorKind === "atlassian") {
89
+ await cleanupAtlassianScheduleAuthorization(deps, cleanup);
90
+ return;
91
+ }
92
+ if (cleanup.connectorKind === "google_drive") {
93
+ await cleanupKnowledgeSourceScheduleAuthorization(deps, cleanup);
94
+ return;
95
+ }
96
+ throw new Error("scheduled connector cleanup receipt has an unsupported connector");
97
+ }
98
+
99
+ /** Shared HTTP/MCP domain operation: authorize, tombstone, persist, then accelerate cleanup. */
100
+ export async function deleteScheduledTaskWithDurableCleanup(
101
+ deps: ApiRouteDeps,
102
+ input: { workspaceId: string; taskId: string; subjectId: string },
103
+ ): Promise<{ task: ScheduledTaskValue; changed: boolean }> {
104
+ const result = await deleteScheduledTaskLifecycle({
105
+ db: deps.db,
106
+ workspaceId: input.workspaceId,
107
+ taskId: input.taskId,
108
+ subjectId: input.subjectId,
109
+ preflightConnectorAuthorization: async (task) =>
110
+ await preflightConnectorAuthorization(deps, task, input.subjectId),
111
+ cleanupConnectorAuthorization: async (db, task) =>
112
+ await revokeConnectorAuthorization({ ...deps, db }, task, input.subjectId),
113
+ processCleanupClaims: async (claims) => {
114
+ await processTemporalScheduleCleanupClaims(
115
+ {
116
+ db: deps.db,
117
+ cleanupConnectorAuthorization: async (claim) =>
118
+ await cleanupScheduledTaskConnectorAuthorization(deps, claim),
119
+ deleteSchedule: async (temporalScheduleId) =>
120
+ await deps.workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId }),
121
+ },
122
+ claims,
123
+ );
124
+ },
125
+ });
126
+ return { task: result.task, changed: result.changed };
127
+ }
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { FileResourceRef } from "@opengeni/contracts";
3
3
  import { completeFileUpload, prepareGeneratedWorkspaceFile, type Database } from "@opengeni/db";
4
- import type { ObjectHead, ObjectStorage } from "@opengeni/storage";
4
+ import { retryWhileMissing, type ObjectHead, type ObjectStorage } from "@opengeni/storage";
5
5
  import type { DownloadedSlackReactionImage } from "./integrations/slack-bot";
6
6
 
7
7
  const IMPORT_IDENTITY_VERSION = "slack-reaction-image-v1";
@@ -77,13 +77,15 @@ export async function importSlackReactionImage(
77
77
  if (existing) {
78
78
  assertStoredSlackImage(existing, image, sha256);
79
79
  } else if (storage.putObjectIfAbsent) {
80
- await storage.putObjectIfAbsent({
80
+ const created = await storage.putObjectIfAbsent({
81
81
  key: objectKey,
82
82
  contentType: image.contentType,
83
83
  body: image.bytes,
84
84
  sha256,
85
85
  });
86
- assertStoredSlackImage(await storage.headObject(objectKey), image, sha256);
86
+ if (!created) {
87
+ await assertVisibleSlackImage(storage, objectKey, image, sha256);
88
+ }
87
89
  } else {
88
90
  await storage.putObject({
89
91
  key: objectKey,
@@ -91,7 +93,6 @@ export async function importSlackReactionImage(
91
93
  body: image.bytes,
92
94
  sha256,
93
95
  });
94
- assertStoredSlackImage(await storage.headObject(objectKey), image, sha256);
95
96
  }
96
97
  file = await (dependencies.completeFile ?? completeFileUpload)(
97
98
  dependencies.db,
@@ -99,7 +100,7 @@ export async function importSlackReactionImage(
99
100
  uploadId,
100
101
  );
101
102
  } else {
102
- assertStoredSlackImage(await storage.headObject(objectKey), image, sha256);
103
+ await assertVisibleSlackImage(storage, objectKey, image, sha256);
103
104
  }
104
105
 
105
106
  if (
@@ -153,6 +154,16 @@ export function safeSlackImageFilename(
153
154
  return `${stem}${extension}`;
154
155
  }
155
156
 
157
+ async function assertVisibleSlackImage(
158
+ storage: ObjectStorage,
159
+ objectKey: string,
160
+ image: DownloadedSlackReactionImage,
161
+ sha256: string,
162
+ ): Promise<void> {
163
+ const head = await retryWhileMissing(async () => await storage.headObject!(objectKey));
164
+ assertStoredSlackImage(head, image, sha256);
165
+ }
166
+
156
167
  function assertStoredSlackImage(
157
168
  head: ObjectHead | null,
158
169
  image: DownloadedSlackReactionImage,
@@ -1,6 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import {
3
3
  claimTemporalScheduleCleanups,
4
+ completeTemporalScheduleConnectorCleanup,
4
5
  settleTemporalScheduleCleanup,
5
6
  type Database,
6
7
  type TemporalScheduleCleanupClaim,
@@ -13,6 +14,7 @@ const CLEANUP_POLL_INTERVAL_MS = 1_000;
13
14
 
14
15
  type TemporalScheduleCleanupDependencies = {
15
16
  db: Database;
17
+ cleanupConnectorAuthorization?: (claim: TemporalScheduleCleanupClaim) => Promise<void>;
16
18
  deleteSchedule: (temporalScheduleId: string) => Promise<void>;
17
19
  observability?: Pick<Observability, "info" | "warn" | "error">;
18
20
  };
@@ -39,6 +41,17 @@ export async function processTemporalScheduleCleanupClaims(
39
41
  const results = await Promise.all(
40
42
  claims.map(async (claim): Promise<"deleted" | "failed" | "stale"> => {
41
43
  try {
44
+ if (claim.connectorCleanupSnapshot && !claim.connectorCleanupCompletedAt) {
45
+ if (!deps.cleanupConnectorAuthorization) {
46
+ throw new Error("scheduled connector cleanup dependency is unavailable");
47
+ }
48
+ await deps.cleanupConnectorAuthorization(claim);
49
+ const completed = await completeTemporalScheduleConnectorCleanup(deps.db, {
50
+ id: claim.id,
51
+ claimId: claim.claimId,
52
+ });
53
+ if (!completed) return "stale";
54
+ }
42
55
  await deps.deleteSchedule(claim.temporalScheduleId);
43
56
  const settled = await settleTemporalScheduleCleanup(deps.db, {
44
57
  id: claim.id,