@opengeni/api-router 2.5.0 → 2.6.4

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 (59) hide show
  1. package/dist/app.js +1 -1
  2. package/dist/auth/managed-auth-attempt-context.d.ts +5 -1
  3. package/dist/auth/managed-auth.d.ts +24 -1
  4. package/dist/{chunk-QESX7HDK.js → chunk-XTLI3CBH.js} +2236 -387
  5. package/dist/chunk-XTLI3CBH.js.map +1 -0
  6. package/dist/http/sse.d.ts +9 -0
  7. package/dist/index.js +104 -4
  8. package/dist/index.js.map +1 -1
  9. package/dist/interaction-metrics.d.ts +2 -0
  10. package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
  11. package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
  12. package/dist/mcp/remember.d.ts +2 -2
  13. package/dist/mcp/server.d.ts +1 -0
  14. package/dist/mcp/session-view.d.ts +1 -0
  15. package/dist/mcp/session-wait.d.ts +19 -0
  16. package/dist/routes/api-keys.d.ts +2 -0
  17. package/dist/routes/browser-sessions.d.ts +1 -0
  18. package/dist/routes/computer-sessions.d.ts +12 -0
  19. package/dist/routes/managed-auth-session-sets.d.ts +7 -0
  20. package/dist/routes/workspaces.d.ts +1 -1
  21. package/dist/sandbox/metrics-ingestion.d.ts +16 -0
  22. package/dist/workspace-delete-observability.d.ts +10 -0
  23. package/package.json +15 -15
  24. package/src/app.ts +172 -20
  25. package/src/auth/managed-auth-attempt-context.ts +40 -3
  26. package/src/auth/managed-auth-session-adapter.ts +1 -0
  27. package/src/auth/managed-auth.ts +164 -4
  28. package/src/http/sse.ts +279 -45
  29. package/src/integrations/oauth-client.ts +8 -1
  30. package/src/integrations/provider-oauth.ts +12 -2
  31. package/src/interaction-metrics.ts +30 -0
  32. package/src/mcp/company-brain-governed-writes.ts +38 -23
  33. package/src/mcp/company-profile-agent-admin.ts +7 -7
  34. package/src/mcp/remember.ts +19 -8
  35. package/src/mcp/server.ts +318 -26
  36. package/src/mcp/session-wait.ts +56 -8
  37. package/src/routes/api-integrations.ts +2 -2
  38. package/src/routes/api-keys.ts +149 -7
  39. package/src/routes/browser-sessions.ts +10 -2
  40. package/src/routes/capabilities.ts +3 -3
  41. package/src/routes/codex.ts +483 -74
  42. package/src/routes/company-profile.ts +64 -0
  43. package/src/routes/computer-sessions.ts +103 -1
  44. package/src/routes/integration-facets.ts +8 -5
  45. package/src/routes/interaction-resources.ts +7 -1
  46. package/src/routes/managed-auth-session-sets.ts +199 -2
  47. package/src/routes/organization-memberships.ts +28 -8
  48. package/src/routes/packs.ts +5 -5
  49. package/src/routes/plugins.ts +2 -2
  50. package/src/routes/scheduled-tasks.ts +9 -0
  51. package/src/routes/sessions.ts +3 -6
  52. package/src/routes/skills.ts +3 -3
  53. package/src/routes/workspaces.ts +293 -54
  54. package/src/sandbox/channel-a.ts +10 -4
  55. package/src/sandbox/machines.ts +13 -6
  56. package/src/sandbox/metrics-ingestion.ts +157 -3
  57. package/src/sandbox/viewer.ts +20 -1
  58. package/src/workspace-delete-observability.ts +75 -0
  59. package/dist/chunk-QESX7HDK.js.map +0 -1
@@ -1,6 +1,9 @@
1
1
  import {
2
2
  AddWorkspaceMemberRequest,
3
3
  CreateWorkspaceRequest,
4
+ EnsureWorkspaceRequest,
5
+ EnsureWorkspaceResponse,
6
+ ListWorkspaceMemberCandidatesResponse,
4
7
  ListWorkspaceMembersResponse,
5
8
  SetWorkspaceDefaultRigRequest,
6
9
  UpdateWorkspaceMemberRequest,
@@ -16,36 +19,46 @@ import {
16
19
  workspaceControlUtf8Bytes,
17
20
  type AccessContext,
18
21
  type Permission,
22
+ type WorkspaceMemberCandidate,
19
23
  type WorkspaceMember as WorkspaceMemberValue,
20
24
  } from "@opengeni/contracts";
21
25
  import {
22
26
  allWorkspacePermissions,
23
27
  createWorkspace,
24
28
  deleteWorkspaceIfQuiescent,
25
- getManagedUserByEmail,
29
+ ensureWorkspaceByExternalIdentity,
30
+ findWorkspaceByExternalIdentity,
31
+ getManagedUserProfilesByIds,
26
32
  getWorkspaceModelPolicy,
27
33
  grantWorkspaceAccess,
28
34
  listWorkspaceMembers,
35
+ listWorkspaceMemberManagementCandidates,
29
36
  normalizeWorkspaceMembershipPermissions,
30
37
  listWorkspaceControlEvents,
38
+ listSharedWorkspacesForAccount,
31
39
  listWorkspacesForSubject,
40
+ nestedPostgresSqlState,
32
41
  removeWorkspaceMember,
33
42
  requireWorkspace,
34
43
  getRig,
35
44
  setWorkspaceDefaultRig,
36
45
  updateWorkspace,
37
46
  updateWorkspaceSettings,
47
+ upsertWorkspaceMemberAsWorkspaceManager,
38
48
  upsertWorkspaceModelPolicy,
39
49
  workspaceCodexSubscriptionActive,
40
50
  workspaceControlRequestLockTimeoutMs,
41
51
  workspaceXaiSubscriptionActive,
42
52
  workspaceVercelAiGatewayConnectionActive,
53
+ WorkspaceExternalIdentityConflictError,
54
+ WorkspaceLimitExceededError,
43
55
  } from "@opengeni/db";
44
56
  import { boundWorkspaceControlHttpPage } from "@opengeni/events";
45
57
  import type { Hono } from "hono";
46
58
  import { HTTPException } from "hono/http-exception";
47
59
  import {
48
60
  getManagedAuthRequestActorEpoch,
61
+ accountScopedApiKeyWorkspaceAuthority,
49
62
  hasPermission,
50
63
  requireAccessContext,
51
64
  requireAccessGrant,
@@ -55,19 +68,21 @@ import { requireLimit } from "@opengeni/core";
55
68
  import type { ApiRouteDeps } from "@opengeni/core";
56
69
  import {
57
70
  assertWorkspaceMemberRemovable,
71
+ assertWorkspaceMemberUpdateAllowed,
58
72
  controlHumanWorkspace,
59
- resolveMemberSubjectId,
60
73
  } from "@opengeni/core";
61
74
  import { boundedLimit } from "../http/common";
62
75
  import { ApiHttpError } from "../http/api-error";
63
- import { sseWorkspaceControlStream } from "../http/sse";
76
+ import { browserSseDeliveryOptions, sseWorkspaceControlStream } from "../http/sse";
64
77
  import { buildWorkspaceModelCatalog } from "../model-catalog";
65
78
  import { processTemporalScheduleCleanupClaims } from "../temporal-schedule-cleanup";
79
+ import { workspaceDeleteObserver } from "../workspace-delete-observability";
66
80
  import {
67
81
  AI_GATEWAY_REALTIME_MODELS,
68
82
  CODEX_REALTIME_MODEL_ID,
69
83
  SUPERGROK_REALTIME_MODEL_ID,
70
84
  canonicalizeConfiguredModelId,
85
+ configuredStaticUsageLimits,
71
86
  type Settings,
72
87
  } from "@opengeni/config";
73
88
 
@@ -114,6 +129,17 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
114
129
 
115
130
  app.get("/v1/workspaces", async (c) => {
116
131
  const context = await requireAccessContext(c, deps);
132
+ const accountScopedAuthority = accountScopedApiKeyWorkspaceAuthority(context);
133
+ if (
134
+ accountScopedAuthority &&
135
+ hasPermission(accountScopedAuthority.permissions, "workspace:read")
136
+ ) {
137
+ return c.json(
138
+ (await listSharedWorkspacesForAccount(deps.db, accountScopedAuthority.accountId)).map(
139
+ (workspace) => Workspace.parse(workspace),
140
+ ),
141
+ );
142
+ }
117
143
  const readableWorkspaceIds = [
118
144
  ...new Set(
119
145
  context.workspaceGrants
@@ -134,34 +160,101 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
134
160
  );
135
161
  });
136
162
 
163
+ app.put("/v1/workspaces/external", async (c) => {
164
+ const context = await requireAccessContext(c, deps);
165
+ const payload = EnsureWorkspaceRequest.parse(await c.req.json());
166
+ requireAccountPermission(context, payload.accountId, "workspace:create");
167
+ try {
168
+ const existing = await findWorkspaceByExternalIdentity(deps.db, {
169
+ externalSource: payload.externalSource,
170
+ externalId: payload.externalId,
171
+ });
172
+ if (existing) {
173
+ if (existing.accountId !== payload.accountId || existing.kind !== "shared") {
174
+ throw new WorkspaceExternalIdentityConflictError();
175
+ }
176
+ return c.json(
177
+ EnsureWorkspaceResponse.parse({
178
+ workspace: existing,
179
+ created: false,
180
+ }),
181
+ );
182
+ }
183
+ await requireLimit(deps, {
184
+ accountId: payload.accountId,
185
+ action: "workspace:create",
186
+ quantity: 1,
187
+ });
188
+ const result = await ensureWorkspaceByExternalIdentity(deps.db, {
189
+ accountId: payload.accountId,
190
+ externalSource: payload.externalSource,
191
+ externalId: payload.externalId,
192
+ name: payload.name,
193
+ slug: payload.slug ?? null,
194
+ ...(payload.agentInstructions !== undefined
195
+ ? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) }
196
+ : {}),
197
+ maxWorkspacesPerAccount: workspaceLimit(deps),
198
+ });
199
+ const response = EnsureWorkspaceResponse.parse(result);
200
+ return result.created ? c.json(response, 201) : c.json(response);
201
+ } catch (error) {
202
+ if (error instanceof WorkspaceExternalIdentityConflictError) {
203
+ throw new HTTPException(409, {
204
+ message: "external workspace identity is already in use",
205
+ });
206
+ }
207
+ if (error instanceof WorkspaceLimitExceededError) {
208
+ throw new HTTPException(429, { message: error.message });
209
+ }
210
+ throw error;
211
+ }
212
+ });
213
+
137
214
  app.post("/v1/workspaces", async (c) => {
138
215
  const context = await requireAccessContext(c, deps);
139
216
  const payload = CreateWorkspaceRequest.parse(await c.req.json());
140
217
  const accountId = payload.accountId ?? context.defaultAccountId;
141
218
  if (!accountId) {
142
- throw new HTTPException(409, { message: "account selection is required" });
219
+ throw new HTTPException(409, {
220
+ message: "account selection is required",
221
+ });
143
222
  }
144
223
  requireAccountPermission(context, accountId, "workspace:create");
145
- await requireLimit(deps, { accountId, action: "workspace:create", quantity: 1 });
146
- const workspace = await createWorkspace(deps.db, {
224
+ await requireLimit(deps, {
147
225
  accountId,
148
- name: payload.name.trim(),
149
- slug: payload.slug?.trim() || null,
150
- externalSource: payload.externalSource ?? null,
151
- externalId: payload.externalId ?? null,
152
- ...(payload.agentInstructions !== undefined
153
- ? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) }
154
- : {}),
226
+ action: "workspace:create",
227
+ quantity: 1,
155
228
  });
156
- await grantWorkspaceAccess(deps.db, {
157
- accountId,
158
- workspaceId: workspace.id,
159
- subjectId: context.subjectId,
160
- role: "owner",
161
- permissions: allWorkspacePermissions,
162
- ...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
163
- });
164
- return c.json(Workspace.parse(workspace), 201);
229
+ try {
230
+ const workspace = await createWorkspace(deps.db, {
231
+ accountId,
232
+ name: payload.name.trim(),
233
+ slug: payload.slug?.trim() || null,
234
+ externalSource: payload.externalSource ?? null,
235
+ externalId: payload.externalId ?? null,
236
+ ...(payload.agentInstructions !== undefined
237
+ ? {
238
+ agentInstructions: normalizeAgentInstructions(payload.agentInstructions),
239
+ }
240
+ : {}),
241
+ maxWorkspacesPerAccount: workspaceLimit(deps),
242
+ });
243
+ await grantWorkspaceAccess(deps.db, {
244
+ accountId,
245
+ workspaceId: workspace.id,
246
+ subjectId: context.subjectId,
247
+ role: "owner",
248
+ permissions: allWorkspacePermissions,
249
+ ...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
250
+ });
251
+ return c.json(Workspace.parse(workspace), 201);
252
+ } catch (error) {
253
+ if (error instanceof WorkspaceLimitExceededError) {
254
+ throw new HTTPException(429, { message: error.message });
255
+ }
256
+ throw error;
257
+ }
165
258
  });
166
259
 
167
260
  app.get("/v1/workspaces/:workspaceId", async (c) => {
@@ -189,7 +282,9 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
189
282
  ...(payload.name !== undefined ? { name: payload.name.trim() } : {}),
190
283
  ...(payload.slug !== undefined ? { slug: payload.slug?.trim() || null } : {}),
191
284
  ...(payload.agentInstructions !== undefined
192
- ? { agentInstructions: normalizeAgentInstructions(payload.agentInstructions) }
285
+ ? {
286
+ agentInstructions: normalizeAgentInstructions(payload.agentInstructions),
287
+ }
193
288
  : {}),
194
289
  });
195
290
  return c.json(Workspace.parse(workspace));
@@ -202,7 +297,9 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
202
297
  await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
203
298
  const parsed = UpdateWorkspaceSettingsRequest.safeParse(await c.req.json());
204
299
  if (!parsed.success) {
205
- throw new HTTPException(400, { message: "invalid workspace settings patch" });
300
+ throw new HTTPException(400, {
301
+ message: "invalid workspace settings patch",
302
+ });
206
303
  }
207
304
  // Request-scoped: bound the exclusive control-prefix wait so a busy
208
305
  // workspace yields the retryable 503 instead of parking this request.
@@ -333,11 +430,15 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
333
430
  const workspaceId = c.req.param("workspaceId");
334
431
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
335
432
  if (workspaceControlUtf8Bytes(grant.subjectId) > WORKSPACE_CONTROL_ACTOR_MAX_BYTES) {
336
- throw new HTTPException(400, { message: "workspace-control actor is too large" });
433
+ throw new HTTPException(400, {
434
+ message: "workspace-control actor is too large",
435
+ });
337
436
  }
338
437
  const parsed = WorkspaceInferenceControlRequest.safeParse(await c.req.json().catch(() => null));
339
438
  if (!parsed.success) {
340
- throw new HTTPException(400, { message: "invalid workspace inference-control request" });
439
+ throw new HTTPException(400, {
440
+ message: "invalid workspace inference-control request",
441
+ });
341
442
  }
342
443
  return c.json(
343
444
  await controlHumanWorkspace(
@@ -383,6 +484,7 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
383
484
  after,
384
485
  c.req.raw.signal,
385
486
  {
487
+ ...browserSseDeliveryOptions(c.req.query("transport")),
386
488
  observability: deps.observability,
387
489
  actorEpoch: getManagedAuthRequestActorEpoch(c.req.raw) ?? undefined,
388
490
  reauthorize: async () => {
@@ -399,7 +501,9 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
399
501
  if (payload.rigId) {
400
502
  const rig = await getRig(deps.db, workspaceId, payload.rigId);
401
503
  if (!rig) {
402
- throw new HTTPException(422, { message: `unknown rigId: ${payload.rigId}` });
504
+ throw new HTTPException(422, {
505
+ message: `unknown rigId: ${payload.rigId}`,
506
+ });
403
507
  }
404
508
  }
405
509
  const workspace = await setWorkspaceDefaultRig(deps.db, workspaceId, payload.rigId);
@@ -413,15 +517,22 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
413
517
  // session/lease before checking runtime quiescence, then returns the exact
414
518
  // external schedules removed by the cascade. A racing cold->warm transition
415
519
  // can therefore never erase the only provider/capture ownership receipt.
520
+ const deleteObserver = workspaceDeleteObserver(deps.observability, {
521
+ accountId: grant.accountId,
522
+ workspaceId,
523
+ });
416
524
  const deleted = await deleteWorkspaceIfQuiescent(deps.db, {
417
525
  accountId: grant.accountId,
418
526
  workspaceId,
527
+ ...(deleteObserver ? { observer: deleteObserver } : {}),
419
528
  });
420
529
  if (deleted.status === "not_found") {
421
530
  throw new HTTPException(404, { message: "workspace not found" });
422
531
  }
423
532
  if (deleted.status === "only_workspace") {
424
- throw new HTTPException(409, { message: "cannot delete the account's only workspace" });
533
+ throw new HTTPException(409, {
534
+ message: "cannot delete the account's only workspace",
535
+ });
425
536
  }
426
537
  if (deleted.status === "active_sessions") {
427
538
  throw new HTTPException(409, {
@@ -453,7 +564,9 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
453
564
  {
454
565
  db: deps.db,
455
566
  deleteSchedule: async (temporalScheduleId) => {
456
- await deps.workflowClient.deleteScheduledTaskSchedule({ temporalScheduleId });
567
+ await deps.workflowClient.deleteScheduledTaskSchedule({
568
+ temporalScheduleId,
569
+ });
457
570
  },
458
571
  ...(deps.observability ? { observability: deps.observability } : {}),
459
572
  },
@@ -467,28 +580,73 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
467
580
  app.get("/v1/workspaces/:workspaceId/members", async (c) => {
468
581
  const workspaceId = c.req.param("workspaceId");
469
582
  await requireAccessGrant(c, deps, workspaceId, "workspace:read");
470
- const members = await listWorkspaceMembers(deps.db, workspaceId);
583
+ const members = await listWorkspacePeople(deps, workspaceId);
471
584
  return c.json(workspaceMembersResponse(members));
472
585
  });
473
586
 
587
+ app.get("/v1/workspaces/:workspaceId/member-candidates", async (c) => {
588
+ const workspaceId = c.req.param("workspaceId");
589
+ const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
590
+ try {
591
+ return c.json(
592
+ ListWorkspaceMemberCandidatesResponse.parse({
593
+ members: await listWorkspaceMemberManagementCandidates(deps.db, {
594
+ accountId: grant.accountId,
595
+ workspaceId,
596
+ actorSubjectId: grant.subjectId,
597
+ }),
598
+ }),
599
+ );
600
+ } catch (error) {
601
+ rethrowWorkspaceMemberCandidateError(error);
602
+ }
603
+ });
604
+
474
605
  app.post("/v1/workspaces/:workspaceId/members", async (c) => {
475
606
  const workspaceId = c.req.param("workspaceId");
476
607
  const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
477
608
  const payload = AddWorkspaceMemberRequest.parse(await c.req.json());
478
- const email = payload.email.trim();
479
- // Email invites for not-yet-registered users are deferred: an unknown email
480
- // resolves to null, which resolveMemberSubjectId turns into a 404.
481
- const subjectId = resolveMemberSubjectId(await getManagedUserByEmail(deps.db, email));
482
- await grantWorkspaceAccess(deps.db, {
483
- accountId: grant.accountId,
484
- workspaceId,
485
- subjectId,
486
- subjectLabel: email,
487
- ...(payload.role !== undefined ? { role: payload.role } : {}),
488
- permissions: payload.permissions,
489
- });
490
- const members = await listWorkspaceMembers(deps.db, workspaceId);
491
- const member = members.find((candidate) => candidate.subjectId === subjectId);
609
+ let candidates: WorkspaceMemberCandidate[];
610
+ try {
611
+ candidates = await listWorkspaceMemberManagementCandidates(deps.db, {
612
+ accountId: grant.accountId,
613
+ workspaceId,
614
+ actorSubjectId: grant.subjectId,
615
+ });
616
+ } catch (error) {
617
+ rethrowWorkspaceMemberCandidateError(error);
618
+ }
619
+ const candidate = candidates.find(
620
+ (entry) => entry.organizationMembershipId === payload.organizationMembershipId,
621
+ );
622
+ if (!candidate) {
623
+ throw new HTTPException(404, {
624
+ message: "this organization member is not available to add",
625
+ });
626
+ }
627
+ const subjectId = candidate.subjectId;
628
+ const existing = await listWorkspaceMembers(deps.db, workspaceId);
629
+ if (existing.some((member) => member.subjectId === subjectId)) {
630
+ throw new HTTPException(409, {
631
+ message: "this person already has access to the workspace",
632
+ });
633
+ }
634
+ try {
635
+ await upsertWorkspaceMemberAsWorkspaceManager(deps.db, {
636
+ accountId: grant.accountId,
637
+ workspaceId,
638
+ actorSubjectId: grant.subjectId,
639
+ targetSubjectId: subjectId,
640
+ mode: "add",
641
+ subjectLabel: candidate.name?.trim() || candidate.email || subjectId,
642
+ role: payload.role ?? "member",
643
+ permissions: payload.permissions,
644
+ });
645
+ } catch (error) {
646
+ rethrowWorkspaceMemberCandidateError(error);
647
+ }
648
+ const members = await listWorkspacePeople(deps, workspaceId);
649
+ const member = members.find((addedMember) => addedMember.subjectId === subjectId);
492
650
  if (!member) {
493
651
  throw new HTTPException(500, { message: "failed to add member" });
494
652
  }
@@ -500,20 +658,32 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
500
658
  const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
501
659
  const subjectId = decodeURIComponent(c.req.param("subjectId"));
502
660
  const payload = UpdateWorkspaceMemberRequest.parse(await c.req.json());
503
- const existing = await listWorkspaceMembers(deps.db, workspaceId);
661
+ const existing = await listWorkspacePeople(deps, workspaceId);
504
662
  const current = existing.find((member) => member.subjectId === subjectId);
505
663
  if (!current) {
506
664
  throw new HTTPException(404, { message: "member not found" });
507
665
  }
508
- await grantWorkspaceAccess(deps.db, {
509
- accountId: grant.accountId,
510
- workspaceId,
666
+ assertWorkspaceMemberUpdateAllowed({
667
+ members: existing,
511
668
  subjectId,
512
- ...(current.subjectLabel ? { subjectLabel: current.subjectLabel } : {}),
513
- role: payload.role ?? current.role,
514
- permissions: payload.permissions,
669
+ callerSubjectId: grant.subjectId,
670
+ nextPermissions: payload.permissions,
515
671
  });
516
- const members = await listWorkspaceMembers(deps.db, workspaceId);
672
+ try {
673
+ await upsertWorkspaceMemberAsWorkspaceManager(deps.db, {
674
+ accountId: grant.accountId,
675
+ workspaceId,
676
+ actorSubjectId: grant.subjectId,
677
+ targetSubjectId: subjectId,
678
+ mode: "update",
679
+ ...(current.subjectLabel ? { subjectLabel: current.subjectLabel } : {}),
680
+ role: payload.role ?? current.role,
681
+ permissions: payload.permissions,
682
+ });
683
+ } catch (error) {
684
+ rethrowWorkspaceMemberCandidateError(error);
685
+ }
686
+ const members = await listWorkspacePeople(deps, workspaceId);
517
687
  const member = members.find((candidate) => candidate.subjectId === subjectId);
518
688
  if (!member) {
519
689
  throw new HTTPException(500, { message: "failed to update member" });
@@ -528,7 +698,11 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
528
698
  const members = await listWorkspaceMembers(deps.db, workspaceId);
529
699
  // Never remove yourself, and never remove the last administering member.
530
700
  // The fenced removal command re-enforces both guards fail-closed.
531
- assertWorkspaceMemberRemovable({ members, subjectId, callerSubjectId: grant.subjectId });
701
+ assertWorkspaceMemberRemovable({
702
+ members,
703
+ subjectId,
704
+ callerSubjectId: grant.subjectId,
705
+ });
532
706
  await removeWorkspaceMember(deps.db, {
533
707
  accountId: grant.accountId,
534
708
  workspaceId,
@@ -539,6 +713,69 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
539
713
  });
540
714
  }
541
715
 
716
+ function workspaceLimit(deps: ApiRouteDeps): number | null {
717
+ if (deps.settings.usageLimitsMode !== "static" && deps.settings.usageLimitsMode !== "managed") {
718
+ return null;
719
+ }
720
+ return configuredStaticUsageLimits(deps.settings).maxWorkspacesPerAccount ?? null;
721
+ }
722
+
723
+ async function listWorkspacePeople(
724
+ deps: ApiRouteDeps,
725
+ workspaceId: string,
726
+ ): Promise<WorkspaceMemberValue[]> {
727
+ const members = await listWorkspaceMembers(deps.db, workspaceId);
728
+ const userIds = members.flatMap((member) =>
729
+ member.subjectId.startsWith("user:") ? [member.subjectId.slice("user:".length)] : [],
730
+ );
731
+ const profiles = await getManagedUserProfilesByIds(deps.db, userIds);
732
+ const profileBySubject = new Map(
733
+ profiles.map((profile) => [`user:${profile.id}`, profile.name?.trim() || profile.email]),
734
+ );
735
+ return members.map((member) => ({
736
+ ...member,
737
+ subjectLabel: profileBySubject.get(member.subjectId) ?? member.subjectLabel,
738
+ }));
739
+ }
740
+
741
+ function rethrowWorkspaceMemberCandidateError(error: unknown): never {
742
+ const managementCode =
743
+ error && typeof error === "object" && "code" in error ? error.code : undefined;
744
+ if (managementCode === "WORKSPACE_MEMBER_ALREADY_EXISTS") {
745
+ throw new HTTPException(409, {
746
+ message: "this person already has access to the workspace",
747
+ });
748
+ }
749
+ if (managementCode === "WORKSPACE_MEMBER_NOT_FOUND") {
750
+ throw new HTTPException(404, { message: "member not found" });
751
+ }
752
+ if (managementCode === "WORKSPACE_MEMBER_SELF_UPDATE") {
753
+ throw new HTTPException(409, {
754
+ message: "you cannot change your own workspace access",
755
+ });
756
+ }
757
+ if (managementCode === "WORKSPACE_MEMBER_LAST_ADMIN") {
758
+ throw new HTTPException(409, {
759
+ message: "the workspace must keep at least one administrator",
760
+ });
761
+ }
762
+ const sqlState = nestedPostgresSqlState(error);
763
+ if (sqlState === "P0002") {
764
+ throw new HTTPException(404, { message: "workspace not found" });
765
+ }
766
+ if (sqlState === "42501") {
767
+ throw new HTTPException(403, {
768
+ message: "workspace member management is not allowed",
769
+ });
770
+ }
771
+ if (sqlState === "54000") {
772
+ throw new HTTPException(409, {
773
+ message: "this organization has too many members to show here",
774
+ });
775
+ }
776
+ throw error;
777
+ }
778
+
542
779
  // A persona override that is null or trims to empty collapses to null (use the
543
780
  // deployment default). Otherwise the template is stored verbatim so the runtime
544
781
  // can substitute the non-bypassable CORE at its {{core}} marker.
@@ -560,6 +797,8 @@ function requireAccountPermission(
560
797
  !grant ||
561
798
  (!grant.permissions.includes(permission) && !grant.permissions.includes("account:admin"))
562
799
  ) {
563
- throw new HTTPException(403, { message: `missing permission: ${permission}` });
800
+ throw new HTTPException(403, {
801
+ message: `missing permission: ${permission}`,
802
+ });
564
803
  }
565
804
  }
@@ -86,6 +86,8 @@ import {
86
86
  type RoutingSandboxSession,
87
87
  } from "@opengeni/runtime/sandbox";
88
88
  import {
89
+ managedSessionGroupBackend,
90
+ managedSessionGroupOs,
89
91
  providerSettingsForSessionSandboxRuntime,
90
92
  relayConfigFromSettings,
91
93
  resolveSessionSandboxRuntime,
@@ -643,15 +645,19 @@ async function withChannelAOperation<T>(
643
645
  // A machine-home session with no selected machine uses the deployment's
644
646
  // managed group box, exactly like the worker turn path. Keep the durable
645
647
  // home label honest; only this request's effective backend changes.
646
- if (settings.sandboxBackend !== "none" && settings.sandboxBackend !== "selfhosted") {
648
+ const groupBackend = managedSessionGroupBackend(
649
+ settings.sandboxBackend,
650
+ session.sandboxBackend,
651
+ );
652
+ if (groupBackend) {
647
653
  return await withChannelAOperation(
648
654
  services,
649
655
  {
650
656
  ...ctx,
651
657
  session: {
652
658
  ...session,
653
- sandboxBackend: settings.sandboxBackend,
654
- sandboxOs: "linux",
659
+ sandboxBackend: groupBackend,
660
+ sandboxOs: managedSessionGroupOs(session.sandboxBackend, session.sandboxOs),
655
661
  },
656
662
  },
657
663
  readOnly,
@@ -659,7 +665,7 @@ async function withChannelAOperation<T>(
659
665
  );
660
666
  }
661
667
  throw new HTTPException(409, {
662
- message: "machine-home session has no active Connected Machine",
668
+ message: "machine-home session has no active Connected Machine or managed sandbox",
663
669
  });
664
670
  }
665
671
  const sandbox = await getSandbox(
@@ -9,8 +9,9 @@
9
9
  // heartbeat), projected to the contract's MetricSample;
10
10
  // * sharedSessionCount — the lease refcount (how many sessions share this one
11
11
  // whole machine, the maxSandboxes:1 disclosure).
12
- // PLUS, when a session context is supplied, the session's synthetic Modal group
13
- // box (isSessionGroup:true) + the active-sandbox pointer (activeSandboxId/Epoch).
12
+ // PLUS, when a session context is supplied, the session's synthetic managed
13
+ // group box (isSessionGroup:true) + the active-sandbox pointer
14
+ // (activeSandboxId/Epoch).
14
15
  //
15
16
  // This is workspace-scoped (perm enrollments:read) and flag-gated upstream
16
17
  // (sandboxSelfhostedEnabled). It deliberately does NOT depend on a FleetContext
@@ -32,6 +33,7 @@ import {
32
33
  type MachineMetricsRow,
33
34
  } from "@opengeni/db";
34
35
  import { MachineView, MetricSample, type MachinesResponse } from "@opengeni/contracts";
36
+ import { managedSessionGroupBackend } from "@opengeni/core";
35
37
  import { selfhostedHeartbeatLiveness } from "@opengeni/runtime/sandbox";
36
38
 
37
39
  export type MachinesServices = {
@@ -244,10 +246,15 @@ export async function listMachines(
244
246
  }
245
247
 
246
248
  const machines: MachineView[] = [];
249
+ const groupBackend = session
250
+ ? managedSessionGroupBackend(services.settings.sandboxBackend, session.sandboxBackend)
251
+ : null;
247
252
 
248
- // The session's own group box (synthetic): the default/home sandbox a null
249
- // active pointer routes to. A backend:none session has no home box.
250
- if (session && session.sandboxBackend !== "none") {
253
+ // The session's own managed group box (synthetic): the sandbox a null active
254
+ // pointer routes to. A machine-home session uses the deployment's managed
255
+ // backend only after an explicit switch; none/selfhosted-only deployments do
256
+ // not invent a group they cannot establish.
257
+ if (session && groupBackend) {
251
258
  const groupActive = activeSandboxId === null;
252
259
  const groupLease = await readLease(db, workspaceId, session.sandboxGroupId);
253
260
  machines.push(
@@ -255,7 +262,7 @@ export async function listMachines(
255
262
  sandboxId: session.sandboxGroupId,
256
263
  enrollmentId: null,
257
264
  name: "session sandbox",
258
- kind: session.sandboxBackend === "selfhosted" ? "selfhosted" : "modal",
265
+ kind: groupBackend === "opensandbox" ? "opensandbox" : "modal",
259
266
  state: "online",
260
267
  active: groupActive,
261
268
  isSessionGroup: true,