@opengeni/api-router 0.30.3 → 2.1.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-QKDFBBUE.js} +26385 -15573
  5. package/dist/chunk-QKDFBBUE.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 +149 -9
  32. package/dist/integrations/slack-interactions.d.ts +4 -3
  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 +153 -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 +337 -57
  102. package/src/integrations/slack-interactions.ts +1265 -208
  103. package/src/integrations/slack-routing.ts +324 -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 +221 -3
  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
@@ -4,17 +4,26 @@ import {
4
4
  CreateKnowledgeMemoryRequest,
5
5
  CreateDocumentBaseRequest,
6
6
  Document,
7
+ DocumentAuthorityReclassification,
7
8
  DocumentBase,
9
+ DocumentDefaultCollectionBackfill,
8
10
  DocumentSearchRequest,
9
11
  DocumentSearchResponse,
12
+ FileAsset,
13
+ FileDownloadUrlResponse,
10
14
  KnowledgeMemory,
11
15
  KnowledgeMemorySearchRequest,
16
+ ListDocumentAuthorityReclassificationsQuery,
17
+ ListDocumentAuthorityReclassificationsResponse,
12
18
  MoveDocumentRequest,
19
+ ReclassifyDocumentAuthorityRequest,
20
+ RunDocumentDefaultCollectionBackfillRequest,
13
21
  UpdateKnowledgeMemoryRequest,
14
22
  WorkspaceMemorySearchRequest,
15
23
  WorkspaceMemorySearchResponse,
16
24
  } from "@opengeni/contracts";
17
25
  import {
26
+ recordAuditEvent,
18
27
  completeFileUpload,
19
28
  createFileUpload,
20
29
  createKnowledgeMemory,
@@ -29,11 +38,16 @@ import {
29
38
  deleteDocumentFromBase,
30
39
  ensureDefaultBase,
31
40
  getDocument,
41
+ getDocumentOriginalFile,
32
42
  getDocumentBase,
43
+ listAccessibleDocuments,
44
+ listDocumentAuthorityReclassifications,
33
45
  listDocumentBasesEnsuringDefault,
34
46
  listDocuments,
35
47
  moveDocumentToBase,
36
48
  queueDocumentForReindex,
49
+ reclassifyDocumentAuthority,
50
+ runDocumentDefaultCollectionBackfill,
37
51
  searchEffectiveDocuments,
38
52
  } from "@opengeni/documents";
39
53
  import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
@@ -41,6 +55,7 @@ import type { Context, Hono } from "hono";
41
55
  import { HTTPException } from "hono/http-exception";
42
56
  import {
43
57
  requireAccessGrant,
58
+ requireAccountAdminAuthorizationStamp,
44
59
  requireAccessGrantAuthorization,
45
60
  saveWorkspaceMemoryWithSlackPublication,
46
61
  } from "@opengeni/core";
@@ -87,6 +102,36 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
87
102
  return c.json(DocumentBase.parse(base));
88
103
  });
89
104
 
105
+ app.post("/v1/workspaces/:workspaceId/document-default-collection-backfills", async (c) => {
106
+ const workspaceId = c.req.param("workspaceId");
107
+ const authorization = await requireAccessGrantAuthorization(
108
+ c,
109
+ deps,
110
+ workspaceId,
111
+ "documents:manage",
112
+ );
113
+ if (!hasAccountAdminAuthority(authorization)) {
114
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
115
+ }
116
+ const accountAdminAuthorization = requireAccountAdminAuthorizationStamp(authorization);
117
+ const payload = RunDocumentDefaultCollectionBackfillRequest.parse(await c.req.json());
118
+ try {
119
+ return c.json(
120
+ DocumentDefaultCollectionBackfill.parse(
121
+ await runDocumentDefaultCollectionBackfill(db, {
122
+ ...payload,
123
+ accountId: authorization.grant.accountId,
124
+ workspaceId,
125
+ actorSubjectId: authorization.grant.subjectId,
126
+ accountAdminAuthorization,
127
+ }),
128
+ ),
129
+ );
130
+ } catch (error) {
131
+ throw documentHttpException(error);
132
+ }
133
+ });
134
+
90
135
  app.post("/v1/workspaces/:workspaceId/document-bases/:baseId/documents", async (c) => {
91
136
  const workspaceId = c.req.param("workspaceId");
92
137
  const access = await requireAccessGrantAuthorization(c, deps, workspaceId, "documents:manage");
@@ -161,6 +206,148 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
161
206
  );
162
207
  });
163
208
 
209
+ app.get("/v1/workspaces/:workspaceId/documents", async (c) => {
210
+ const workspaceId = c.req.param("workspaceId");
211
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
212
+ return c.json(
213
+ (
214
+ await listAccessibleDocuments(db, workspaceId, {
215
+ viewerSubjectId: grant.subjectId,
216
+ })
217
+ ).map((document) => Document.parse(document)),
218
+ );
219
+ });
220
+
221
+ app.post(
222
+ "/v1/workspaces/:workspaceId/documents/:documentId/authority-reclassifications",
223
+ async (c) => {
224
+ const workspaceId = c.req.param("workspaceId");
225
+ const authorization = await requireAccessGrantAuthorization(
226
+ c,
227
+ deps,
228
+ workspaceId,
229
+ "documents:manage",
230
+ );
231
+ const payload = ReclassifyDocumentAuthorityRequest.parse(await c.req.json());
232
+ const accountAdminAuthorized = hasAccountAdminAuthority(authorization);
233
+ const accountAdminAuthorization = accountAdminAuthorized
234
+ ? requireAccountAdminAuthorizationStamp(authorization)
235
+ : null;
236
+ try {
237
+ const document = await getDocument(db, workspaceId, c.req.param("documentId"), {
238
+ viewerSubjectId: authorization.grant.subjectId,
239
+ });
240
+ if (!document) throw new HTTPException(404, { message: "document not found" });
241
+ if (
242
+ (document.authorityKind === "organization" ||
243
+ payload.targetAuthorityKind === "organization") &&
244
+ !accountAdminAuthorization
245
+ ) {
246
+ throw new HTTPException(403, { message: "missing permission: account:admin" });
247
+ }
248
+ return c.json(
249
+ DocumentAuthorityReclassification.parse(
250
+ await reclassifyDocumentAuthority(db, {
251
+ ...payload,
252
+ accountId: authorization.grant.accountId,
253
+ workspaceId,
254
+ documentId: document.id,
255
+ actorSubjectId: authorization.grant.subjectId,
256
+ accountAdminAuthorization,
257
+ }),
258
+ ),
259
+ );
260
+ } catch (error) {
261
+ if (error instanceof HTTPException) throw error;
262
+ throw documentHttpException(error);
263
+ }
264
+ },
265
+ );
266
+
267
+ app.get(
268
+ "/v1/workspaces/:workspaceId/documents/:documentId/authority-reclassifications",
269
+ async (c) => {
270
+ const workspaceId = c.req.param("workspaceId");
271
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:manage");
272
+ try {
273
+ const query = ListDocumentAuthorityReclassificationsQuery.parse(c.req.query());
274
+ return c.json(
275
+ ListDocumentAuthorityReclassificationsResponse.parse(
276
+ await listDocumentAuthorityReclassifications(db, {
277
+ accountId: grant.accountId,
278
+ workspaceId,
279
+ documentId: c.req.param("documentId"),
280
+ actorSubjectId: grant.subjectId,
281
+ ...query,
282
+ }),
283
+ ),
284
+ );
285
+ } catch (error) {
286
+ throw documentHttpException(error);
287
+ }
288
+ },
289
+ );
290
+
291
+ app.get("/v1/workspaces/:workspaceId/documents/:documentId/original-file", async (c) => {
292
+ const workspaceId = c.req.param("workspaceId");
293
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
294
+ await requireAccessGrant(c, deps, workspaceId, "files:read");
295
+ const file = await getDocumentOriginalFile(db, {
296
+ accountId: grant.accountId,
297
+ workspaceId,
298
+ documentId: c.req.param("documentId"),
299
+ access: { viewerSubjectId: grant.subjectId },
300
+ });
301
+ if (!file) {
302
+ throw new HTTPException(404, { message: "document file not found" });
303
+ }
304
+ return c.json(FileAsset.parse(file));
305
+ });
306
+
307
+ app.post(
308
+ "/v1/workspaces/:workspaceId/documents/:documentId/original-file/download-url",
309
+ async (c) => {
310
+ const workspaceId = c.req.param("workspaceId");
311
+ const grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
312
+ await requireAccessGrant(c, deps, workspaceId, "files:read");
313
+ if (!objectStorage) {
314
+ throw new HTTPException(503, { message: "object storage is not configured" });
315
+ }
316
+ const file = await getDocumentOriginalFile(db, {
317
+ accountId: grant.accountId,
318
+ workspaceId,
319
+ documentId: c.req.param("documentId"),
320
+ access: { viewerSubjectId: grant.subjectId },
321
+ });
322
+ if (!file) {
323
+ throw new HTTPException(404, { message: "document file not found" });
324
+ }
325
+ if (file.status !== "ready") {
326
+ throw new HTTPException(409, { message: `file is ${file.status}` });
327
+ }
328
+ const signed = await objectStorage.createGetUrl({ key: file.objectKey });
329
+ await recordAuditEvent(db, {
330
+ accountId: grant.accountId,
331
+ workspaceId,
332
+ subjectId: grant.subjectId,
333
+ action: "file.signed_url.issued",
334
+ targetType: "workspace_document",
335
+ targetId: c.req.param("documentId"),
336
+ metadata: {
337
+ fileId: file.id,
338
+ kind: "document_original",
339
+ expiresAt: signed.expiresAt.toISOString(),
340
+ },
341
+ });
342
+ return c.json(
343
+ FileDownloadUrlResponse.parse({
344
+ url: signed.url,
345
+ expiresAt: signed.expiresAt.toISOString(),
346
+ }),
347
+ );
348
+ },
349
+ );
350
+
164
351
  app.delete(
165
352
  "/v1/workspaces/:workspaceId/document-bases/:baseId/documents/:documentId",
166
353
  async (c) => {
@@ -212,7 +399,9 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
212
399
  const { grant } = authorization;
213
400
  const organizationAuthorityGranted = hasAccountAdminAuthority(authorization);
214
401
  if (!objectStorage) {
215
- throw new HTTPException(503, { message: "object storage is not configured" });
402
+ throw new HTTPException(503, {
403
+ message: "object storage is not configured",
404
+ });
216
405
  }
217
406
  await requireLimit(deps, {
218
407
  accountId: grant.accountId,
@@ -246,7 +435,9 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
246
435
  const indexed =
247
436
  (await documentIndexer.indexDocument({
248
437
  accountId: grant.accountId,
249
- workspaceId,
438
+ // The requested workspace authorizes the human operation; the
439
+ // immutable ingestion workspace remains the indexing authority.
440
+ workspaceId: queued.workspaceId,
250
441
  documentId: document.id,
251
442
  authorityKind: document.authorityKind,
252
443
  authorityWorkspaceId: document.authorityWorkspaceId,
@@ -651,13 +842,19 @@ export function registerDocumentRoutes(app: Hono, deps: ApiRouteDeps): void {
651
842
  const grant = await requireAccessGrant(c, deps, workspaceId, "documents:search");
652
843
  const sessionId =
653
844
  typeof grant.metadata?.sessionId === "string" ? grant.metadata.sessionId : undefined;
845
+ const attemptId =
846
+ typeof grant.metadata?.attemptId === "string" ? grant.metadata.attemptId : undefined;
654
847
  const transport = new WebStandardStreamableHTTPServerTransport({ enableJsonResponse: true });
655
848
  const server = buildDocumentsMcpServer(
656
849
  db,
657
850
  grant.accountId,
658
851
  workspaceId,
659
852
  getDocumentServices(),
660
- { createdBySessionId: sessionId, initiatingSubjectId: grant.subjectId },
853
+ {
854
+ createdBySessionId: sessionId,
855
+ attemptId,
856
+ initiatingSubjectId: grant.subjectId,
857
+ },
661
858
  );
662
859
  await server.connect(transport);
663
860
  return await transport.handleRequest(c.req.raw);
@@ -692,6 +889,27 @@ function documentHttpException(error: unknown): HTTPException {
692
889
  if (message.includes("already exists")) {
693
890
  return new HTTPException(409, { message });
694
891
  }
892
+ if (
893
+ message.includes("operation id was reused") ||
894
+ message.includes("authority changed before reclassification") ||
895
+ message.includes("run identity mismatch")
896
+ ) {
897
+ return new HTTPException(409, { message });
898
+ }
899
+ if (
900
+ message.includes("reclassification requires") ||
901
+ message.includes("backfill requires organization administration")
902
+ ) {
903
+ return new HTTPException(403, { message });
904
+ }
905
+ if (
906
+ message.includes("reclassification input is invalid") ||
907
+ message.includes("reclassification authority is invalid") ||
908
+ message.includes("invalid document authority receipt cursor") ||
909
+ message.includes("document authority receipt limit")
910
+ ) {
911
+ return new HTTPException(400, { message });
912
+ }
695
913
  if (message.includes("no suggested base")) {
696
914
  return new HTTPException(422, { message });
697
915
  }
@@ -78,9 +78,13 @@ const ListEditableArtifactsQuery = z
78
78
  const MintEditableArtifactLiveTicketRequest = z
79
79
  .object({
80
80
  replicaId: ReplicaId,
81
- protocolVersion: PositiveProtocolVersion,
81
+ modality: z.enum(["document", "spreadsheet", "presentation"]),
82
+ liveProtocolVersion: PositiveProtocolVersion,
82
83
  kernelVersion: VersionName,
83
84
  modelSchemaVersion: PositiveModelSchemaVersion,
85
+ snapshotVersion: PositiveProtocolVersion,
86
+ commandProtocolVersion: PositiveProtocolVersion,
87
+ committedTransactionProtocolVersion: PositiveProtocolVersion,
84
88
  })
85
89
  .strict();
86
90
 
@@ -482,9 +486,13 @@ export function registerEditableArtifactRoutes(
482
486
  scope,
483
487
  actor,
484
488
  artifactId,
485
- protocolVersion: body.protocolVersion,
489
+ modality: body.modality,
490
+ liveProtocolVersion: body.liveProtocolVersion,
486
491
  kernelVersion: body.kernelVersion,
487
492
  modelSchemaVersion: body.modelSchemaVersion,
493
+ snapshotVersion: body.snapshotVersion,
494
+ commandProtocolVersion: body.commandProtocolVersion,
495
+ committedTransactionProtocolVersion: body.committedTransactionProtocolVersion,
488
496
  allowEdit: hasPermission(grant.permissions, "artifacts:publish"),
489
497
  });
490
498
  } catch (error) {
@@ -496,7 +504,7 @@ export function registerEditableArtifactRoutes(
496
504
  !parsedTicket.success ||
497
505
  parsedTicket.data.artifactId !== artifactId ||
498
506
  parsedTicket.data.replicaId !== actor.replicaId ||
499
- parsedTicket.data.protocolVersion !== body.protocolVersion ||
507
+ parsedTicket.data.protocolVersion !== body.liveProtocolVersion ||
500
508
  Date.parse(parsedTicket.data.expiresAt) <= Date.now()
501
509
  ) {
502
510
  throw new Error("Editable artifact application returned an invalid live ticket");
@@ -50,7 +50,11 @@ import {
50
50
  } from "@opengeni/db";
51
51
  import type { Context, Hono } from "hono";
52
52
  import { HTTPException } from "hono/http-exception";
53
- import { requireAccessGrant } from "@opengeni/core";
53
+ import {
54
+ requireAccessGrant,
55
+ requireAccessGrantAuthorization,
56
+ requirePermission,
57
+ } from "@opengeni/core";
54
58
  import type { ApiRouteDeps } from "@opengeni/core";
55
59
  import {
56
60
  approveDeviceEnrollment,
@@ -88,7 +92,10 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
88
92
  const lookupLimiter = new TokenBucket({ capacity: 30, refillPerSecond: 1 });
89
93
  // The headless token exchange (UNAUTHENTICATED — the token is the auth). Bounded
90
94
  // against an enroll-token brute force; the `oget_` HMAC is the real boundary.
91
- const exchangeLimiter = new TokenBucket({ capacity: 20, refillPerSecond: 0.5 });
95
+ const exchangeLimiter = new TokenBucket({
96
+ capacity: 20,
97
+ refillPerSecond: 0.5,
98
+ });
92
99
 
93
100
  function rateLimit(c: Context, limiter: TokenBucket): void {
94
101
  const ip = clientIp(c);
@@ -123,8 +130,10 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
123
130
  machineName: body.machineName ?? null,
124
131
  canOfferDisplay: body.canOfferDisplay,
125
132
  requestsScreenControl: body.requestsScreenControl,
126
- // The approve page is served at the SAME origin as this request.
127
- verificationOrigin: new URL(c.req.url).origin,
133
+ // Prefer the externally configured deployment origin. Behind a TLS-
134
+ // terminating proxy c.req.url can carry the internal http scheme, which
135
+ // must never leak into the device-flow command shown to the operator.
136
+ verificationOrigin: settings.publicBaseUrl ?? new URL(c.req.url).origin,
128
137
  },
129
138
  );
130
139
  return c.json(result, 201);
@@ -156,7 +165,9 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
156
165
  rateLimit(c, lookupLimiter);
157
166
  const parsed = DeviceEnrollmentLookupRequest.safeParse(await c.req.json().catch(() => null));
158
167
  if (!parsed.success) {
159
- throw new HTTPException(400, { message: "invalid device-lookup request" });
168
+ throw new HTTPException(400, {
169
+ message: "invalid device-lookup request",
170
+ });
160
171
  }
161
172
  const record = await lookupDeviceEnrollment(
162
173
  { db, settings },
@@ -165,7 +176,9 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
165
176
  if (!record) {
166
177
  // Unknown / terminal / expired code → 404 (indistinguishable from an
167
178
  // unauthorized one below, by design).
168
- throw new HTTPException(404, { message: "no pending enrollment for that code" });
179
+ throw new HTTPException(404, {
180
+ message: "no pending enrollment for that code",
181
+ });
169
182
  }
170
183
  // Authorize the caller against the RESOLVED workspace. A missing grant throws
171
184
  // 403/404 from requireAccessGrant; we normalize that to 404 so a caller cannot
@@ -173,7 +186,9 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
173
186
  try {
174
187
  await requireAccessGrant(c, deps, record.workspaceId, "enrollments:read");
175
188
  } catch {
176
- throw new HTTPException(404, { message: "no pending enrollment for that code" });
189
+ throw new HTTPException(404, {
190
+ message: "no pending enrollment for that code",
191
+ });
177
192
  }
178
193
  return c.json(DeviceEnrollmentLookupResponse.parse(toLookupResponse(record)), 200);
179
194
  });
@@ -189,7 +204,9 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
189
204
  rateLimit(c, exchangeLimiter);
190
205
  const parsed = EnrollTokenExchangeRequest.safeParse(await c.req.json().catch(() => null));
191
206
  if (!parsed.success) {
192
- throw new HTTPException(400, { message: "invalid enroll-token-exchange request" });
207
+ throw new HTTPException(400, {
208
+ message: "invalid enroll-token-exchange request",
209
+ });
193
210
  }
194
211
  const body = parsed.data;
195
212
  const result = await exchangeEnrollToken(
@@ -206,10 +223,14 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
206
223
  if (!result.ok) {
207
224
  if (result.reason === "disabled") {
208
225
  // The credential plane is off for this deployment (no signing secret).
209
- throw new HTTPException(503, { message: "enrollment credential plane is not configured" });
226
+ throw new HTTPException(503, {
227
+ message: "enrollment credential plane is not configured",
228
+ });
210
229
  }
211
230
  // An invalid / expired / wrong-typ token — the token is the auth, so 401.
212
- throw new HTTPException(401, { message: "invalid or expired enroll token" });
231
+ throw new HTTPException(401, {
232
+ message: "invalid or expired enroll token",
233
+ });
213
234
  }
214
235
  return c.json(EnrollTokenExchangeResponse.parse({ credentials: result.credentials }), 201);
215
236
  });
@@ -218,18 +239,48 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
218
239
  // The LOUD CONSENT step. requireAccessGrant BEFORE the Zod parse.
219
240
  app.post("/v1/workspaces/:workspaceId/enrollments/device/approve", async (c) => {
220
241
  const workspaceId = c.req.param("workspaceId");
221
- const grant = await requireAccessGrant(c, deps, workspaceId, "enrollments:manage");
242
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
243
+ const grant = authorization.grant;
244
+ requirePermission(grant, "enrollments:manage");
222
245
  assertSelfhostedEnabled();
223
- const parsed = DeviceEnrollmentApproveRequest.safeParse(await c.req.json().catch(() => null));
246
+ const rawBody = await c.req.json().catch(() => null);
247
+ const parsed = DeviceEnrollmentApproveRequest.safeParse(rawBody);
224
248
  if (!parsed.success) {
225
- throw new HTTPException(400, { message: "invalid device-approve request" });
249
+ throw new HTTPException(400, {
250
+ message: "invalid device-approve request",
251
+ });
226
252
  }
227
253
  const body = parsed.data;
254
+ const scopeWasExplicit =
255
+ typeof rawBody === "object" &&
256
+ rawBody !== null &&
257
+ Object.prototype.hasOwnProperty.call(rawBody, "scope");
258
+ // Managed browser humans get the personal default. Legacy/delegated
259
+ // principals predate organization-user ownership and retain the compatible
260
+ // workspace default when the caller omitted scope; an explicit scope is
261
+ // never silently rewritten.
262
+ const effectiveScope =
263
+ scopeWasExplicit ||
264
+ (grant.principalKind === "human_session" &&
265
+ authorization.contextIntegrity &&
266
+ grant.metadata?.delegated !== true)
267
+ ? body.scope
268
+ : undefined;
269
+ const allowOrganization =
270
+ effectiveScope === "organization" &&
271
+ authorization.accountGrant?.permissions.includes("account:admin") === true;
272
+ if (effectiveScope === "organization" && !allowOrganization) {
273
+ throw new HTTPException(403, {
274
+ message: "missing permission: account:admin",
275
+ });
276
+ }
228
277
  const approved = await approveDeviceEnrollment(
229
278
  { db, settings },
230
279
  {
231
280
  accountId: grant.accountId,
232
281
  workspaceId,
282
+ ...(effectiveScope ? { scope: effectiveScope } : {}),
283
+ allowOrganization,
233
284
  userCode: body.userCode,
234
285
  allowScreenControl: body.allowScreenControl,
235
286
  // The LOUD consent record: WHO consented (the authenticated subject + label).
@@ -239,7 +290,9 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
239
290
  );
240
291
  if (!approved) {
241
292
  // An unknown / expired / already-terminal user_code in this workspace.
242
- throw new HTTPException(404, { message: "no pending enrollment for that code" });
293
+ throw new HTTPException(404, {
294
+ message: "no pending enrollment for that code",
295
+ });
243
296
  }
244
297
  return c.json(
245
298
  DeviceEnrollmentApproveResponse.parse({
@@ -286,7 +339,9 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
286
339
  // allowScreenControl=false). Coalesce a missing/empty body to {} before parse.
287
340
  const parsed = MintEnrollTokenRequest.safeParse(await c.req.json().catch(() => ({})));
288
341
  if (!parsed.success) {
289
- throw new HTTPException(400, { message: "invalid mint-enroll-token request" });
342
+ throw new HTTPException(400, {
343
+ message: "invalid mint-enroll-token request",
344
+ });
290
345
  }
291
346
  const minted = await mintEnrollToken(
292
347
  { db, settings },
@@ -298,7 +353,9 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
298
353
  );
299
354
  if (!minted) {
300
355
  // The credential plane is off (no signing secret) — mirror poll's disabled path.
301
- throw new HTTPException(503, { message: "enrollment credential plane is not configured" });
356
+ throw new HTTPException(503, {
357
+ message: "enrollment credential plane is not configured",
358
+ });
302
359
  }
303
360
  return c.json(MintEnrollTokenResponse.parse(minted), 201);
304
361
  });
@@ -306,13 +363,15 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
306
363
  // ── GET /workspaces/:workspaceId/enrollments (user-authed) ──────────────────
307
364
  app.get("/v1/workspaces/:workspaceId/enrollments", async (c) => {
308
365
  const workspaceId = c.req.param("workspaceId");
309
- await requireAccessGrant(c, deps, workspaceId, "enrollments:read");
366
+ const grant = await requireAccessGrant(c, deps, workspaceId, "enrollments:read");
310
367
  assertSelfhostedEnabled();
311
368
  const statusFilter = c.req.query("status");
312
369
  if (statusFilter !== undefined && statusFilter !== "active" && statusFilter !== "revoked") {
313
- throw new HTTPException(400, { message: "status must be active or revoked" });
370
+ throw new HTTPException(400, {
371
+ message: "status must be active or revoked",
372
+ });
314
373
  }
315
- const rows = await listEnrollments(db, workspaceId, {
374
+ const rows = await listEnrollments(db, grant, {
316
375
  status: statusFilter === "revoked" ? "revoked" : "active",
317
376
  });
318
377
  return c.json(
@@ -320,6 +379,8 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
320
379
  enrollments: rows.map((row) =>
321
380
  EnrollmentSummary.parse({
322
381
  id: row.id,
382
+ scope: row.scope,
383
+ generation: row.generation,
323
384
  pubkey: row.pubkey,
324
385
  exposure: row.exposure,
325
386
  hasDisplay: row.hasDisplay,
@@ -340,19 +401,40 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
340
401
  // ── POST /workspaces/:workspaceId/enrollments/:id/revoke (user-authed) ──────
341
402
  app.post("/v1/workspaces/:workspaceId/enrollments/:enrollmentId/revoke", async (c) => {
342
403
  const workspaceId = c.req.param("workspaceId");
343
- const grant = await requireAccessGrant(c, deps, workspaceId, "enrollments:manage");
404
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
405
+ const grant = authorization.grant;
406
+ requirePermission(grant, "enrollments:manage");
344
407
  assertSelfhostedEnabled();
345
408
  const parsed = RemoveEnrollmentRequest.safeParse(await c.req.json().catch(() => ({})));
346
409
  if (!parsed.success) {
347
- throw new HTTPException(400, { message: "invalid machine removal request" });
410
+ throw new HTTPException(400, {
411
+ message: "invalid machine removal request",
412
+ });
348
413
  }
349
414
  const idempotencyKey =
350
415
  parsed.data.idempotencyKey?.trim() || c.req.header("idempotency-key")?.trim() || randomUUID();
351
416
  try {
417
+ const enrollmentId = c.req.param("enrollmentId");
418
+ const enrollment = (await listEnrollments(db, grant, { status: "active" })).find(
419
+ (candidate) => candidate.id === enrollmentId,
420
+ );
421
+ if (!enrollment) {
422
+ throw new HTTPException(404, {
423
+ message: "machine enrollment not found",
424
+ });
425
+ }
426
+ if (
427
+ enrollment.scope === "organization" &&
428
+ authorization.accountGrant?.permissions.includes("account:admin") !== true
429
+ ) {
430
+ throw new HTTPException(403, {
431
+ message: "missing permission: account:admin",
432
+ });
433
+ }
352
434
  const result = await removeEnrollment(db, {
353
435
  accountId: grant.accountId,
354
- workspaceId,
355
- enrollmentId: c.req.param("enrollmentId"),
436
+ workspaceId: enrollment.workspaceId,
437
+ enrollmentId,
356
438
  operationKey: idempotencyKey,
357
439
  ...(parsed.data.expectedUpdatedAt
358
440
  ? { expectedUpdatedAt: parsed.data.expectedUpdatedAt }
@@ -360,7 +442,9 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
360
442
  subjectId: grant.subjectId,
361
443
  });
362
444
  if (!result) {
363
- throw new HTTPException(404, { message: "machine enrollment not found" });
445
+ throw new HTTPException(404, {
446
+ message: "machine enrollment not found",
447
+ });
364
448
  }
365
449
  return c.json(
366
450
  RevokeEnrollmentResponse.parse({
@@ -376,7 +460,10 @@ export function registerEnrollmentRoutes(app: Hono, deps: ApiRouteDeps): void {
376
460
  if (error instanceof MachineRemovalRevisionConflictError) {
377
461
  throw new HTTPException(409, {
378
462
  message: error.message,
379
- cause: { code: "stale_revision", currentUpdatedAt: error.currentUpdatedAt },
463
+ cause: {
464
+ code: "stale_revision",
465
+ currentUpdatedAt: error.currentUpdatedAt,
466
+ },
380
467
  });
381
468
  }
382
469
  throw error;
@@ -410,7 +497,10 @@ class TokenBucket {
410
497
  }
411
498
 
412
499
  take(key: string, now = Date.now()): boolean {
413
- const bucket = this.buckets.get(key) ?? { tokens: this.capacity, updatedAt: now };
500
+ const bucket = this.buckets.get(key) ?? {
501
+ tokens: this.capacity,
502
+ updatedAt: now,
503
+ };
414
504
  const elapsedSeconds = Math.max(0, (now - bucket.updatedAt) / 1000);
415
505
  bucket.tokens = Math.min(this.capacity, bucket.tokens + elapsedSeconds * this.refillPerSecond);
416
506
  bucket.updatedAt = now;