@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
@@ -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;
@@ -5,8 +5,6 @@ import {
5
5
  VariableSetVariableName,
6
6
  } from "@opengeni/contracts";
7
7
  import {
8
- countActiveSessionsUsingVariableSet,
9
- countScheduledTasksUsingVariableSet,
10
8
  countVariableSets,
11
9
  createVariableSet,
12
10
  decryptVariableSetValue,
@@ -18,10 +16,16 @@ import {
18
16
  readVariableSetSecretAtomically,
19
17
  setVariableSetVariable,
20
18
  updateVariableSet,
19
+ VariableSetAttachedError,
21
20
  } from "@opengeni/db";
22
21
  import type { Hono } from "hono";
23
22
  import { HTTPException } from "hono/http-exception";
24
- import { requireAccessGrant, requireLiteralPermission, requirePermission } from "@opengeni/core";
23
+ import {
24
+ requireAccessGrant,
25
+ requireAccessGrantAuthorization,
26
+ requireLiteralPermission,
27
+ requirePermission,
28
+ } from "@opengeni/core";
25
29
  import type { ApiRouteDeps } from "@opengeni/core";
26
30
  import {
27
31
  assertAllowedVariableSetVariableName,
@@ -45,15 +49,35 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
45
49
  const grant = await requireAccessGrant(c, deps, workspaceId);
46
50
  requirePermission(grant, "variable-sets:list");
47
51
  requirePermission(grant, "secrets:list");
48
- return c.json(await listVariableSets(db, workspaceId));
52
+ return c.json(
53
+ await listVariableSets(db, {
54
+ accountId: grant.accountId,
55
+ workspaceId,
56
+ subjectId: grant.subjectId,
57
+ }),
58
+ );
49
59
  });
50
60
 
51
61
  app.post(`${prefix}`, async (c) => {
52
62
  const workspaceId = c.req.param("workspaceId")!;
53
- const grant = await requireAccessGrant(c, deps, workspaceId);
63
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
64
+ const grant = authorization.grant;
54
65
  requirePermission(grant, "variable-sets:write");
55
66
  const key = requireVariableSetEncryption(settings);
56
67
  const payload = CreateVariableSetRequest.parse(await c.req.json());
68
+ if (prefix.endsWith("/environments") && payload.scope !== "workspace") {
69
+ throw new HTTPException(422, {
70
+ message: "the legacy environments route only creates workspace-scoped variable sets",
71
+ });
72
+ }
73
+ if (
74
+ payload.scope === "organization" &&
75
+ authorization.accountGrant?.permissions.includes("account:admin") !== true
76
+ ) {
77
+ throw new HTTPException(403, {
78
+ message: "missing permission: account:admin",
79
+ });
80
+ }
57
81
  if (payload.variables.length > 0) {
58
82
  requirePermission(grant, "secrets:write");
59
83
  }
@@ -73,12 +97,17 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
73
97
  }
74
98
  variableNames.add(variable.name);
75
99
  }
76
- if ((await countVariableSets(db, workspaceId)) >= MAX_ENVIRONMENTS_PER_WORKSPACE) {
100
+ const access = {
101
+ accountId: grant.accountId,
102
+ workspaceId,
103
+ subjectId: grant.subjectId,
104
+ };
105
+ if ((await countVariableSets(db, access, payload.scope)) >= MAX_ENVIRONMENTS_PER_WORKSPACE) {
77
106
  throw new HTTPException(422, {
78
107
  message: `a workspace supports at most ${MAX_ENVIRONMENTS_PER_WORKSPACE} variable sets`,
79
108
  });
80
109
  }
81
- if (await getVariableSetByName(db, workspaceId, name)) {
110
+ if (await getVariableSetByName(db, access, name, payload.scope)) {
82
111
  throw new HTTPException(409, {
83
112
  message: `variable set name is already in use: ${name}`,
84
113
  });
@@ -88,6 +117,9 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
88
117
  const created = await createVariableSet(db, {
89
118
  accountId: grant.accountId,
90
119
  workspaceId,
120
+ scope: payload.scope,
121
+ subjectId: grant.subjectId,
122
+ allowOrganization: payload.scope === "organization",
91
123
  name,
92
124
  description: payload.description ?? null,
93
125
  variables: payload.variables.map((variable) => ({
@@ -108,7 +140,7 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
108
140
  const grant = await requireAccessGrant(c, deps, workspaceId);
109
141
  requirePermission(grant, "variable-sets:read");
110
142
  requirePermission(grant, "secrets:list");
111
- return c.json(await requireVariableSetForApi(db, workspaceId, c.req.param("variableSetId")!));
143
+ return c.json(await requireVariableSetForApi(db, grant, c.req.param("variableSetId")!));
112
144
  });
113
145
 
114
146
  if (prefix.endsWith("/variable-sets")) {
@@ -139,27 +171,47 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
139
171
 
140
172
  app.patch(`${prefix}/:variableSetId`, async (c) => {
141
173
  const workspaceId = c.req.param("workspaceId")!;
142
- const grant = await requireAccessGrant(c, deps, workspaceId);
174
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
175
+ const grant = authorization.grant;
143
176
  requirePermission(grant, "variable-sets:write");
144
- const variableSet = await requireVariableSetForApi(
145
- db,
146
- workspaceId,
147
- c.req.param("variableSetId")!,
148
- );
177
+ const variableSet = await requireVariableSetForApi(db, grant, c.req.param("variableSetId")!);
178
+ const allowOrganization =
179
+ variableSet.scope === "organization" &&
180
+ authorization.accountGrant?.permissions.includes("account:admin") === true;
181
+ if (variableSet.scope === "organization" && !allowOrganization) {
182
+ throw new HTTPException(403, {
183
+ message: "missing permission: account:admin",
184
+ });
185
+ }
149
186
  const payload = UpdateVariableSetRequest.parse(await c.req.json());
150
187
  const name = payload.name !== undefined ? trimmedVariableSetName(payload.name) : undefined;
151
188
  if (name !== undefined && name !== variableSet.name) {
152
- const existing = await getVariableSetByName(db, workspaceId, name);
189
+ const existing = await getVariableSetByName(
190
+ db,
191
+ {
192
+ accountId: grant.accountId,
193
+ workspaceId,
194
+ subjectId: grant.subjectId,
195
+ },
196
+ name,
197
+ variableSet.scope,
198
+ );
153
199
  if (existing && existing.id !== variableSet.id) {
154
200
  throw new HTTPException(409, {
155
201
  message: `variable set name is already in use: ${name}`,
156
202
  });
157
203
  }
158
204
  }
159
- const updated = await updateVariableSet(db, workspaceId, variableSet.id, {
160
- ...(name !== undefined ? { name } : {}),
161
- ...(payload.description !== undefined ? { description: payload.description } : {}),
162
- });
205
+ const updated = await updateVariableSet(
206
+ db,
207
+ { accountId: grant.accountId, workspaceId, subjectId: grant.subjectId },
208
+ variableSet.id,
209
+ {
210
+ ...(name !== undefined ? { name } : {}),
211
+ ...(payload.description !== undefined ? { description: payload.description } : {}),
212
+ allowOrganization,
213
+ },
214
+ );
163
215
  await recordVariableSetAuditEvent(db, {
164
216
  grant,
165
217
  action: "variable_set.updated",
@@ -170,35 +222,36 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
170
222
 
171
223
  app.delete(`${prefix}/:variableSetId`, async (c) => {
172
224
  const workspaceId = c.req.param("workspaceId")!;
173
- const grant = await requireAccessGrant(c, deps, workspaceId);
225
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
226
+ const grant = authorization.grant;
174
227
  requirePermission(grant, "variable-sets:write");
175
228
  requirePermission(grant, "secrets:write");
176
- const variableSet = await requireVariableSetForApi(
177
- db,
178
- workspaceId,
179
- c.req.param("variableSetId")!,
180
- );
181
- const attachedTasks = await countScheduledTasksUsingVariableSet(
182
- db,
183
- workspaceId,
184
- variableSet.id,
185
- );
186
- if (attachedTasks > 0) {
187
- throw new HTTPException(409, {
188
- message: `variable set is attached to ${attachedTasks} scheduled task(s); detach first`,
229
+ const variableSet = await requireVariableSetForApi(db, grant, c.req.param("variableSetId")!);
230
+ const allowOrganization =
231
+ variableSet.scope === "organization" &&
232
+ authorization.accountGrant?.permissions.includes("account:admin") === true;
233
+ if (variableSet.scope === "organization" && !allowOrganization) {
234
+ throw new HTTPException(403, {
235
+ message: "missing permission: account:admin",
189
236
  });
190
237
  }
191
- const activeSessions = await countActiveSessionsUsingVariableSet(
192
- db,
193
- workspaceId,
194
- variableSet.id,
195
- );
196
- if (activeSessions > 0) {
197
- throw new HTTPException(409, {
198
- message: `variable set is attached to ${activeSessions} active session(s); wait for them to finish or cancel them first`,
199
- });
238
+ try {
239
+ await deleteVariableSet(
240
+ db,
241
+ {
242
+ accountId: grant.accountId,
243
+ workspaceId,
244
+ subjectId: grant.subjectId,
245
+ },
246
+ variableSet.id,
247
+ { allowOrganization },
248
+ );
249
+ } catch (error) {
250
+ if (error instanceof VariableSetAttachedError) {
251
+ throw new HTTPException(409, { message: error.message });
252
+ }
253
+ throw error;
200
254
  }
201
- await deleteVariableSet(db, workspaceId, variableSet.id);
202
255
  await recordVariableSetAuditEvent(db, {
203
256
  grant,
204
257
  action: "variable_set.deleted",
@@ -209,16 +262,21 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
209
262
 
210
263
  app.put(`${prefix}/:variableSetId/variables/:name`, async (c) => {
211
264
  const workspaceId = c.req.param("workspaceId")!;
212
- const grant = await requireAccessGrant(c, deps, workspaceId);
265
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
266
+ const grant = authorization.grant;
213
267
  requirePermission(grant, "variable-sets:write");
214
268
  requirePermission(grant, "secrets:write");
215
269
  const key = requireVariableSetEncryption(settings);
216
270
  const name = parseVariableName(c.req.param("name")!);
217
- const variableSet = await requireVariableSetForApi(
218
- db,
219
- workspaceId,
220
- c.req.param("variableSetId")!,
221
- );
271
+ const variableSet = await requireVariableSetForApi(db, grant, c.req.param("variableSetId")!);
272
+ const allowOrganization =
273
+ variableSet.scope === "organization" &&
274
+ authorization.accountGrant?.permissions.includes("account:admin") === true;
275
+ if (variableSet.scope === "organization" && !allowOrganization) {
276
+ throw new HTTPException(403, {
277
+ message: "missing permission: account:admin",
278
+ });
279
+ }
222
280
  const payload = SetVariableSetVariableRequest.parse(await c.req.json());
223
281
  const exists = variableSet.variables.some((variable) => variable.name === name);
224
282
  if (!exists && variableSet.variables.length >= MAX_VARIABLES_PER_ENVIRONMENT) {
@@ -229,9 +287,11 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
229
287
  const metadata = await setVariableSetVariable(db, {
230
288
  accountId: grant.accountId,
231
289
  workspaceId,
290
+ subjectId: grant.subjectId,
232
291
  variableSetId: variableSet.id,
233
292
  name,
234
293
  valueEncrypted: encryptVariableSetValue(key, payload.value),
294
+ allowOrganization,
235
295
  });
236
296
  await recordVariableSetAuditEvent(db, {
237
297
  grant,
@@ -244,16 +304,28 @@ export function registerVariableSetRoutes(app: Hono, deps: ApiRouteDeps): void {
244
304
 
245
305
  app.delete(`${prefix}/:variableSetId/variables/:name`, async (c) => {
246
306
  const workspaceId = c.req.param("workspaceId")!;
247
- const grant = await requireAccessGrant(c, deps, workspaceId);
307
+ const authorization = await requireAccessGrantAuthorization(c, deps, workspaceId);
308
+ const grant = authorization.grant;
248
309
  requirePermission(grant, "variable-sets:write");
249
310
  requirePermission(grant, "secrets:write");
250
311
  const name = parseVariableName(c.req.param("name")!);
251
- const variableSet = await requireVariableSetForApi(
252
- db,
312
+ const variableSet = await requireVariableSetForApi(db, grant, c.req.param("variableSetId")!);
313
+ const allowOrganization =
314
+ variableSet.scope === "organization" &&
315
+ authorization.accountGrant?.permissions.includes("account:admin") === true;
316
+ if (variableSet.scope === "organization" && !allowOrganization) {
317
+ throw new HTTPException(403, {
318
+ message: "missing permission: account:admin",
319
+ });
320
+ }
321
+ const deleted = await deleteVariableSetVariable(db, {
322
+ accountId: grant.accountId,
253
323
  workspaceId,
254
- c.req.param("variableSetId")!,
255
- );
256
- const deleted = await deleteVariableSetVariable(db, workspaceId, variableSet.id, name);
324
+ subjectId: grant.subjectId,
325
+ variableSetId: variableSet.id,
326
+ name,
327
+ allowOrganization,
328
+ });
257
329
  if (!deleted) {
258
330
  throw new HTTPException(404, {
259
331
  message: "variable set variable not found",