@opengeni/api-router 2.5.0 → 2.6.3-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.
- package/dist/app.js +1 -1
- package/dist/auth/managed-auth-attempt-context.d.ts +5 -1
- package/dist/auth/managed-auth.d.ts +24 -1
- package/dist/{chunk-QESX7HDK.js → chunk-XTLI3CBH.js} +2236 -387
- package/dist/chunk-XTLI3CBH.js.map +1 -0
- package/dist/http/sse.d.ts +9 -0
- package/dist/index.js +104 -4
- package/dist/index.js.map +1 -1
- package/dist/interaction-metrics.d.ts +2 -0
- package/dist/mcp/company-brain-governed-writes.d.ts +1 -1
- package/dist/mcp/company-profile-agent-admin.d.ts +6 -6
- package/dist/mcp/remember.d.ts +2 -2
- package/dist/mcp/server.d.ts +1 -0
- package/dist/mcp/session-view.d.ts +1 -0
- package/dist/mcp/session-wait.d.ts +19 -0
- package/dist/routes/api-keys.d.ts +2 -0
- package/dist/routes/browser-sessions.d.ts +1 -0
- package/dist/routes/computer-sessions.d.ts +12 -0
- package/dist/routes/managed-auth-session-sets.d.ts +7 -0
- package/dist/routes/workspaces.d.ts +1 -1
- package/dist/sandbox/metrics-ingestion.d.ts +16 -0
- package/dist/workspace-delete-observability.d.ts +10 -0
- package/package.json +19 -19
- package/src/app.ts +172 -20
- package/src/auth/managed-auth-attempt-context.ts +40 -3
- package/src/auth/managed-auth-session-adapter.ts +1 -0
- package/src/auth/managed-auth.ts +164 -4
- package/src/http/sse.ts +279 -45
- package/src/integrations/oauth-client.ts +8 -1
- package/src/integrations/provider-oauth.ts +12 -2
- package/src/interaction-metrics.ts +30 -0
- package/src/mcp/company-brain-governed-writes.ts +38 -23
- package/src/mcp/company-profile-agent-admin.ts +7 -7
- package/src/mcp/remember.ts +19 -8
- package/src/mcp/server.ts +318 -26
- package/src/mcp/session-wait.ts +56 -8
- package/src/routes/api-integrations.ts +2 -2
- package/src/routes/api-keys.ts +149 -7
- package/src/routes/browser-sessions.ts +10 -2
- package/src/routes/capabilities.ts +3 -3
- package/src/routes/codex.ts +483 -74
- package/src/routes/company-profile.ts +64 -0
- package/src/routes/computer-sessions.ts +103 -1
- package/src/routes/integration-facets.ts +8 -5
- package/src/routes/interaction-resources.ts +7 -1
- package/src/routes/managed-auth-session-sets.ts +199 -2
- package/src/routes/organization-memberships.ts +28 -8
- package/src/routes/packs.ts +5 -5
- package/src/routes/plugins.ts +2 -2
- package/src/routes/scheduled-tasks.ts +9 -0
- package/src/routes/sessions.ts +3 -6
- package/src/routes/skills.ts +3 -3
- package/src/routes/workspaces.ts +293 -54
- package/src/sandbox/channel-a.ts +10 -4
- package/src/sandbox/machines.ts +13 -6
- package/src/sandbox/metrics-ingestion.ts +157 -3
- package/src/sandbox/viewer.ts +20 -1
- package/src/workspace-delete-observability.ts +75 -0
- package/dist/chunk-QESX7HDK.js.map +0 -1
package/src/routes/workspaces.ts
CHANGED
|
@@ -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
|
-
|
|
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, {
|
|
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, {
|
|
146
|
-
const workspace = await createWorkspace(deps.db, {
|
|
224
|
+
await requireLimit(deps, {
|
|
147
225
|
accountId,
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
? {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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, {
|
|
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({
|
|
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
|
|
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
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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
|
|
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
|
-
|
|
509
|
-
|
|
510
|
-
workspaceId,
|
|
666
|
+
assertWorkspaceMemberUpdateAllowed({
|
|
667
|
+
members: existing,
|
|
511
668
|
subjectId,
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
permissions: payload.permissions,
|
|
669
|
+
callerSubjectId: grant.subjectId,
|
|
670
|
+
nextPermissions: payload.permissions,
|
|
515
671
|
});
|
|
516
|
-
|
|
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({
|
|
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, {
|
|
800
|
+
throw new HTTPException(403, {
|
|
801
|
+
message: `missing permission: ${permission}`,
|
|
802
|
+
});
|
|
564
803
|
}
|
|
565
804
|
}
|
package/src/sandbox/channel-a.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
654
|
-
sandboxOs:
|
|
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(
|
package/src/sandbox/machines.ts
CHANGED
|
@@ -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
|
|
13
|
-
// box (isSessionGroup:true) + the active-sandbox pointer
|
|
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
|
|
249
|
-
//
|
|
250
|
-
|
|
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:
|
|
265
|
+
kind: groupBackend === "opensandbox" ? "opensandbox" : "modal",
|
|
259
266
|
state: "online",
|
|
260
267
|
active: groupActive,
|
|
261
268
|
isSessionGroup: true,
|