@opengeni/api-router 2.5.0 → 2.6.4-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.d.ts +2 -1
- package/dist/app.js +3 -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-UOI7KAP3.js} +3240 -791
- package/dist/chunk-UOI7KAP3.js.map +1 -0
- package/dist/http/sse.d.ts +9 -0
- package/dist/index.js +117 -5
- 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/scheduled-task-view.d.ts +3 -3
- 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/model-catalog.d.ts +5 -31
- 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 +206 -29
- 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/index.ts +13 -1
- package/src/integrations/oauth-client.ts +8 -1
- package/src/integrations/provider-oauth.ts +12 -2
- package/src/integrations/slack-interactions.ts +77 -37
- 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 +328 -27
- package/src/mcp/session-wait.ts +56 -8
- package/src/model-catalog.ts +45 -337
- package/src/routes/api-integrations.ts +2 -2
- package/src/routes/api-keys.ts +149 -7
- package/src/routes/automations.ts +186 -19
- 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/connections.ts +271 -16
- package/src/routes/github.ts +0 -8
- 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/pr-review.ts +80 -4
- package/src/routes/scheduled-tasks.ts +47 -11
- package/src/routes/sessions.ts +75 -53
- package/src/routes/skills.ts +3 -3
- package/src/routes/supergrok.ts +3 -2
- package/src/routes/workspaces.ts +665 -56
- 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,73 +1,119 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import {
|
|
2
3
|
AddWorkspaceMemberRequest,
|
|
3
4
|
CreateWorkspaceRequest,
|
|
5
|
+
EnsureWorkspaceRequest,
|
|
6
|
+
EnsureWorkspaceResponse,
|
|
7
|
+
ListWorkspaceMemberCandidatesResponse,
|
|
4
8
|
ListWorkspaceMembersResponse,
|
|
5
9
|
SetWorkspaceDefaultRigRequest,
|
|
6
10
|
UpdateWorkspaceMemberRequest,
|
|
11
|
+
CreateWorkspaceGatewayCustomModelRequest,
|
|
12
|
+
CreateWorkspaceOpenRouterCustomModelRequest,
|
|
13
|
+
DeleteWorkspaceGatewayCustomModelRequest,
|
|
14
|
+
DeleteWorkspaceOpenRouterCustomModelRequest,
|
|
7
15
|
UpdateWorkspaceModelPolicyRequest,
|
|
8
16
|
UpdateWorkspaceRequest,
|
|
9
17
|
UpdateWorkspaceSettingsRequest,
|
|
10
18
|
WORKSPACE_CONTROL_ACTOR_MAX_BYTES,
|
|
11
19
|
WorkspaceModelCatalogResponse,
|
|
20
|
+
WorkspaceGatewayCustomModel,
|
|
21
|
+
WorkspaceGatewayCustomModelsResponse,
|
|
22
|
+
WorkspaceOpenRouterCustomModel,
|
|
23
|
+
WorkspaceOpenRouterCustomModelsResponse,
|
|
12
24
|
WorkspaceRealtimeModelCatalogResponse,
|
|
13
25
|
WorkspaceInferenceControlRequest,
|
|
14
26
|
Workspace,
|
|
15
27
|
WorkspaceMember,
|
|
16
28
|
workspaceControlUtf8Bytes,
|
|
29
|
+
stableJson,
|
|
17
30
|
type AccessContext,
|
|
18
31
|
type Permission,
|
|
32
|
+
type WorkspaceMemberCandidate,
|
|
19
33
|
type WorkspaceMember as WorkspaceMemberValue,
|
|
20
34
|
} from "@opengeni/contracts";
|
|
21
35
|
import {
|
|
22
36
|
allWorkspacePermissions,
|
|
23
37
|
createWorkspace,
|
|
24
38
|
deleteWorkspaceIfQuiescent,
|
|
25
|
-
|
|
39
|
+
ensureWorkspaceByExternalIdentity,
|
|
40
|
+
findWorkspaceByExternalIdentity,
|
|
41
|
+
getManagedUserProfilesByIds,
|
|
26
42
|
getWorkspaceModelPolicy,
|
|
27
43
|
grantWorkspaceAccess,
|
|
28
44
|
listWorkspaceMembers,
|
|
45
|
+
listWorkspaceMemberManagementCandidates,
|
|
29
46
|
normalizeWorkspaceMembershipPermissions,
|
|
30
47
|
listWorkspaceControlEvents,
|
|
48
|
+
listSharedWorkspacesForAccount,
|
|
49
|
+
listWorkspaceGatewayCustomModels,
|
|
50
|
+
listWorkspaceOpenRouterCustomModels,
|
|
51
|
+
createWorkspaceGatewayCustomModel,
|
|
52
|
+
createWorkspaceOpenRouterCustomModel,
|
|
53
|
+
deleteWorkspaceGatewayCustomModel,
|
|
54
|
+
deleteWorkspaceOpenRouterCustomModel,
|
|
55
|
+
replayWorkspaceGatewayCustomModelCreate,
|
|
56
|
+
replayWorkspaceOpenRouterCustomModelCreate,
|
|
31
57
|
listWorkspacesForSubject,
|
|
58
|
+
nestedPostgresSqlState,
|
|
32
59
|
removeWorkspaceMember,
|
|
33
60
|
requireWorkspace,
|
|
34
61
|
getRig,
|
|
35
62
|
setWorkspaceDefaultRig,
|
|
36
63
|
updateWorkspace,
|
|
37
64
|
updateWorkspaceSettings,
|
|
65
|
+
upsertWorkspaceMemberAsWorkspaceManager,
|
|
38
66
|
upsertWorkspaceModelPolicy,
|
|
39
67
|
workspaceCodexSubscriptionActive,
|
|
40
68
|
workspaceControlRequestLockTimeoutMs,
|
|
41
69
|
workspaceXaiSubscriptionActive,
|
|
42
70
|
workspaceVercelAiGatewayConnectionActive,
|
|
71
|
+
workspaceOpenRouterConnectionActive,
|
|
72
|
+
WorkspaceGatewayCustomModelHistoryLimitError,
|
|
73
|
+
WorkspaceOpenRouterCustomModelHistoryLimitError,
|
|
74
|
+
WorkspaceExternalIdentityConflictError,
|
|
75
|
+
WorkspaceGatewayCustomModelLimitError,
|
|
76
|
+
WorkspaceOpenRouterCustomModelLimitError,
|
|
77
|
+
WorkspaceLimitExceededError,
|
|
43
78
|
} from "@opengeni/db";
|
|
44
79
|
import { boundWorkspaceControlHttpPage } from "@opengeni/events";
|
|
45
80
|
import type { Hono } from "hono";
|
|
46
81
|
import { HTTPException } from "hono/http-exception";
|
|
47
82
|
import {
|
|
48
83
|
getManagedAuthRequestActorEpoch,
|
|
84
|
+
accountScopedApiKeyWorkspaceAuthority,
|
|
49
85
|
hasPermission,
|
|
50
86
|
requireAccessContext,
|
|
51
87
|
requireAccessGrant,
|
|
52
88
|
requireFreshAccessGrant,
|
|
89
|
+
resolveWorkspaceCatalogSettings,
|
|
53
90
|
} from "@opengeni/core";
|
|
54
91
|
import { requireLimit } from "@opengeni/core";
|
|
55
92
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
56
93
|
import {
|
|
57
94
|
assertWorkspaceMemberRemovable,
|
|
95
|
+
assertWorkspaceMemberUpdateAllowed,
|
|
58
96
|
controlHumanWorkspace,
|
|
59
|
-
resolveMemberSubjectId,
|
|
60
97
|
} from "@opengeni/core";
|
|
61
98
|
import { boundedLimit } from "../http/common";
|
|
62
99
|
import { ApiHttpError } from "../http/api-error";
|
|
63
|
-
import { sseWorkspaceControlStream } from "../http/sse";
|
|
100
|
+
import { browserSseDeliveryOptions, sseWorkspaceControlStream } from "../http/sse";
|
|
64
101
|
import { buildWorkspaceModelCatalog } from "../model-catalog";
|
|
65
102
|
import { processTemporalScheduleCleanupClaims } from "../temporal-schedule-cleanup";
|
|
103
|
+
import { workspaceDeleteObserver } from "../workspace-delete-observability";
|
|
66
104
|
import {
|
|
67
105
|
AI_GATEWAY_REALTIME_MODELS,
|
|
68
106
|
CODEX_REALTIME_MODEL_ID,
|
|
69
107
|
SUPERGROK_REALTIME_MODEL_ID,
|
|
70
108
|
canonicalizeConfiguredModelId,
|
|
109
|
+
configuredStaticUsageLimits,
|
|
110
|
+
configuredGatewayUpstreamModelIds,
|
|
111
|
+
configuredGatewayWorkspaceProductModelIds,
|
|
112
|
+
configuredModelInputIdentities,
|
|
113
|
+
configuredOpenRouterUpstreamModelIds,
|
|
114
|
+
configuredOpenRouterWorkspaceProductModelIds,
|
|
115
|
+
WORKSPACE_GATEWAY_MODEL_ID_PREFIX,
|
|
116
|
+
WORKSPACE_OPENROUTER_MODEL_ID_PREFIX,
|
|
71
117
|
type Settings,
|
|
72
118
|
} from "@opengeni/config";
|
|
73
119
|
|
|
@@ -81,6 +127,46 @@ export function canonicalWorkspacePolicyModelIds(
|
|
|
81
127
|
return [...new Set(modelIds.map((modelId) => canonicalizeConfiguredModelId(settings, modelId)))];
|
|
82
128
|
}
|
|
83
129
|
|
|
130
|
+
function projectWorkspaceGatewayCustomModel(model: {
|
|
131
|
+
id: string;
|
|
132
|
+
upstreamModelId: string;
|
|
133
|
+
label: string | null;
|
|
134
|
+
version: number;
|
|
135
|
+
createdAt: Date;
|
|
136
|
+
updatedAt: Date;
|
|
137
|
+
}) {
|
|
138
|
+
return WorkspaceGatewayCustomModel.parse({
|
|
139
|
+
id: model.id,
|
|
140
|
+
upstreamModelId: model.upstreamModelId,
|
|
141
|
+
label: model.label,
|
|
142
|
+
version: model.version,
|
|
143
|
+
createdAt: model.createdAt.toISOString(),
|
|
144
|
+
updatedAt: model.updatedAt.toISOString(),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function projectWorkspaceOpenRouterCustomModel(model: {
|
|
149
|
+
id: string;
|
|
150
|
+
upstreamModelId: string;
|
|
151
|
+
label: string | null;
|
|
152
|
+
version: number;
|
|
153
|
+
createdAt: Date;
|
|
154
|
+
updatedAt: Date;
|
|
155
|
+
}) {
|
|
156
|
+
return WorkspaceOpenRouterCustomModel.parse({
|
|
157
|
+
id: model.id,
|
|
158
|
+
upstreamModelId: model.upstreamModelId,
|
|
159
|
+
label: model.label,
|
|
160
|
+
version: model.version,
|
|
161
|
+
createdAt: model.createdAt.toISOString(),
|
|
162
|
+
updatedAt: model.updatedAt.toISOString(),
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function workspaceCustomModelRequestHash(value: unknown): string {
|
|
167
|
+
return createHash("sha256").update(stableJson(value), "utf8").digest("hex");
|
|
168
|
+
}
|
|
169
|
+
|
|
84
170
|
type WorkspaceMemberProjectionInput = Omit<WorkspaceMemberValue, "permissions"> & {
|
|
85
171
|
permissions: unknown;
|
|
86
172
|
};
|
|
@@ -114,6 +200,17 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
114
200
|
|
|
115
201
|
app.get("/v1/workspaces", async (c) => {
|
|
116
202
|
const context = await requireAccessContext(c, deps);
|
|
203
|
+
const accountScopedAuthority = accountScopedApiKeyWorkspaceAuthority(context);
|
|
204
|
+
if (
|
|
205
|
+
accountScopedAuthority &&
|
|
206
|
+
hasPermission(accountScopedAuthority.permissions, "workspace:read")
|
|
207
|
+
) {
|
|
208
|
+
return c.json(
|
|
209
|
+
(await listSharedWorkspacesForAccount(deps.db, accountScopedAuthority.accountId)).map(
|
|
210
|
+
(workspace) => Workspace.parse(workspace),
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
}
|
|
117
214
|
const readableWorkspaceIds = [
|
|
118
215
|
...new Set(
|
|
119
216
|
context.workspaceGrants
|
|
@@ -134,34 +231,103 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
134
231
|
);
|
|
135
232
|
});
|
|
136
233
|
|
|
234
|
+
app.put("/v1/workspaces/external", async (c) => {
|
|
235
|
+
const context = await requireAccessContext(c, deps);
|
|
236
|
+
const payload = EnsureWorkspaceRequest.parse(await c.req.json());
|
|
237
|
+
requireAccountPermission(context, payload.accountId, "workspace:create");
|
|
238
|
+
try {
|
|
239
|
+
const existing = await findWorkspaceByExternalIdentity(deps.db, {
|
|
240
|
+
externalSource: payload.externalSource,
|
|
241
|
+
externalId: payload.externalId,
|
|
242
|
+
});
|
|
243
|
+
if (existing) {
|
|
244
|
+
if (existing.accountId !== payload.accountId || existing.kind !== "shared") {
|
|
245
|
+
throw new WorkspaceExternalIdentityConflictError();
|
|
246
|
+
}
|
|
247
|
+
return c.json(
|
|
248
|
+
EnsureWorkspaceResponse.parse({
|
|
249
|
+
workspace: existing,
|
|
250
|
+
created: false,
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
await requireLimit(deps, {
|
|
255
|
+
accountId: payload.accountId,
|
|
256
|
+
action: "workspace:create",
|
|
257
|
+
quantity: 1,
|
|
258
|
+
});
|
|
259
|
+
const result = await ensureWorkspaceByExternalIdentity(deps.db, {
|
|
260
|
+
accountId: payload.accountId,
|
|
261
|
+
externalSource: payload.externalSource,
|
|
262
|
+
externalId: payload.externalId,
|
|
263
|
+
name: payload.name,
|
|
264
|
+
slug: payload.slug ?? null,
|
|
265
|
+
...(payload.agentInstructions !== undefined
|
|
266
|
+
? {
|
|
267
|
+
agentInstructions: normalizeAgentInstructions(payload.agentInstructions),
|
|
268
|
+
}
|
|
269
|
+
: {}),
|
|
270
|
+
maxWorkspacesPerAccount: workspaceLimit(deps),
|
|
271
|
+
});
|
|
272
|
+
const response = EnsureWorkspaceResponse.parse(result);
|
|
273
|
+
return result.created ? c.json(response, 201) : c.json(response);
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (error instanceof WorkspaceExternalIdentityConflictError) {
|
|
276
|
+
throw new HTTPException(409, {
|
|
277
|
+
message: "external workspace identity is already in use",
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
if (error instanceof WorkspaceLimitExceededError) {
|
|
281
|
+
throw new HTTPException(429, { message: error.message });
|
|
282
|
+
}
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
137
287
|
app.post("/v1/workspaces", async (c) => {
|
|
138
288
|
const context = await requireAccessContext(c, deps);
|
|
139
289
|
const payload = CreateWorkspaceRequest.parse(await c.req.json());
|
|
140
290
|
const accountId = payload.accountId ?? context.defaultAccountId;
|
|
141
291
|
if (!accountId) {
|
|
142
|
-
throw new HTTPException(409, {
|
|
292
|
+
throw new HTTPException(409, {
|
|
293
|
+
message: "account selection is required",
|
|
294
|
+
});
|
|
143
295
|
}
|
|
144
296
|
requireAccountPermission(context, accountId, "workspace:create");
|
|
145
|
-
await requireLimit(deps, {
|
|
146
|
-
const workspace = await createWorkspace(deps.db, {
|
|
147
|
-
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
|
-
: {}),
|
|
155
|
-
});
|
|
156
|
-
await grantWorkspaceAccess(deps.db, {
|
|
297
|
+
await requireLimit(deps, {
|
|
157
298
|
accountId,
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
role: "owner",
|
|
161
|
-
permissions: allWorkspacePermissions,
|
|
162
|
-
...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
|
|
299
|
+
action: "workspace:create",
|
|
300
|
+
quantity: 1,
|
|
163
301
|
});
|
|
164
|
-
|
|
302
|
+
try {
|
|
303
|
+
const workspace = await createWorkspace(deps.db, {
|
|
304
|
+
accountId,
|
|
305
|
+
name: payload.name.trim(),
|
|
306
|
+
slug: payload.slug?.trim() || null,
|
|
307
|
+
externalSource: payload.externalSource ?? null,
|
|
308
|
+
externalId: payload.externalId ?? null,
|
|
309
|
+
...(payload.agentInstructions !== undefined
|
|
310
|
+
? {
|
|
311
|
+
agentInstructions: normalizeAgentInstructions(payload.agentInstructions),
|
|
312
|
+
}
|
|
313
|
+
: {}),
|
|
314
|
+
maxWorkspacesPerAccount: workspaceLimit(deps),
|
|
315
|
+
});
|
|
316
|
+
await grantWorkspaceAccess(deps.db, {
|
|
317
|
+
accountId,
|
|
318
|
+
workspaceId: workspace.id,
|
|
319
|
+
subjectId: context.subjectId,
|
|
320
|
+
role: "owner",
|
|
321
|
+
permissions: allWorkspacePermissions,
|
|
322
|
+
...(context.subjectLabel ? { subjectLabel: context.subjectLabel } : {}),
|
|
323
|
+
});
|
|
324
|
+
return c.json(Workspace.parse(workspace), 201);
|
|
325
|
+
} catch (error) {
|
|
326
|
+
if (error instanceof WorkspaceLimitExceededError) {
|
|
327
|
+
throw new HTTPException(429, { message: error.message });
|
|
328
|
+
}
|
|
329
|
+
throw error;
|
|
330
|
+
}
|
|
165
331
|
});
|
|
166
332
|
|
|
167
333
|
app.get("/v1/workspaces/:workspaceId", async (c) => {
|
|
@@ -189,7 +355,9 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
189
355
|
...(payload.name !== undefined ? { name: payload.name.trim() } : {}),
|
|
190
356
|
...(payload.slug !== undefined ? { slug: payload.slug?.trim() || null } : {}),
|
|
191
357
|
...(payload.agentInstructions !== undefined
|
|
192
|
-
? {
|
|
358
|
+
? {
|
|
359
|
+
agentInstructions: normalizeAgentInstructions(payload.agentInstructions),
|
|
360
|
+
}
|
|
193
361
|
: {}),
|
|
194
362
|
});
|
|
195
363
|
return c.json(Workspace.parse(workspace));
|
|
@@ -202,7 +370,9 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
202
370
|
await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
203
371
|
const parsed = UpdateWorkspaceSettingsRequest.safeParse(await c.req.json());
|
|
204
372
|
if (!parsed.success) {
|
|
205
|
-
throw new HTTPException(400, {
|
|
373
|
+
throw new HTTPException(400, {
|
|
374
|
+
message: "invalid workspace settings patch",
|
|
375
|
+
});
|
|
206
376
|
}
|
|
207
377
|
// Request-scoped: bound the exclusive control-prefix wait so a busy
|
|
208
378
|
// workspace yields the retryable 503 instead of parking this request.
|
|
@@ -220,30 +390,323 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
220
390
|
const workspaceId = c.req.param("workspaceId");
|
|
221
391
|
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
222
392
|
const [
|
|
393
|
+
resolvedCatalog,
|
|
223
394
|
policy,
|
|
224
395
|
codexSubscriptionActive,
|
|
225
396
|
xaiSubscriptionActive,
|
|
226
397
|
workspaceGatewayConnectionActive,
|
|
398
|
+
workspaceGatewayCustomModels,
|
|
399
|
+
openRouterConnectionActive,
|
|
400
|
+
workspaceOpenRouterCustomModels,
|
|
227
401
|
] = await Promise.all([
|
|
402
|
+
deps.resolveCatalogSettings(),
|
|
228
403
|
getWorkspaceModelPolicy(deps.db, workspaceId),
|
|
229
404
|
workspaceCodexSubscriptionActive(deps.db, deps.settings, workspaceId),
|
|
230
405
|
workspaceXaiSubscriptionActive(deps.db, deps.settings, workspaceId, grant.subjectId),
|
|
231
406
|
workspaceVercelAiGatewayConnectionActive(deps.db, workspaceId),
|
|
407
|
+
listWorkspaceGatewayCustomModels(deps.db, {
|
|
408
|
+
accountId: grant.accountId,
|
|
409
|
+
workspaceId,
|
|
410
|
+
}),
|
|
411
|
+
workspaceOpenRouterConnectionActive(deps.db, workspaceId),
|
|
412
|
+
listWorkspaceOpenRouterCustomModels(deps.db, {
|
|
413
|
+
accountId: grant.accountId,
|
|
414
|
+
workspaceId,
|
|
415
|
+
}),
|
|
232
416
|
]);
|
|
233
417
|
c.header("cache-control", "private, no-store");
|
|
234
418
|
return c.json(
|
|
235
419
|
WorkspaceModelCatalogResponse.parse(
|
|
236
420
|
buildWorkspaceModelCatalog({
|
|
237
|
-
settings:
|
|
421
|
+
settings: resolvedCatalog.settings,
|
|
238
422
|
policy,
|
|
239
423
|
codexSubscriptionActive,
|
|
240
424
|
xaiSubscriptionActive,
|
|
241
425
|
workspaceGatewayConnectionActive,
|
|
426
|
+
workspaceGatewayCustomModels,
|
|
427
|
+
workspaceOpenRouterConnectionActive: openRouterConnectionActive,
|
|
428
|
+
workspaceOpenRouterCustomModels,
|
|
242
429
|
}),
|
|
243
430
|
),
|
|
244
431
|
);
|
|
245
432
|
});
|
|
246
433
|
|
|
434
|
+
app.get("/v1/workspaces/:workspaceId/gateway-custom-models", async (c) => {
|
|
435
|
+
const workspaceId = c.req.param("workspaceId");
|
|
436
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
437
|
+
const models = await listWorkspaceGatewayCustomModels(deps.db, {
|
|
438
|
+
accountId: grant.accountId,
|
|
439
|
+
workspaceId,
|
|
440
|
+
});
|
|
441
|
+
c.header("cache-control", "private, no-store");
|
|
442
|
+
return c.json(
|
|
443
|
+
WorkspaceGatewayCustomModelsResponse.parse({
|
|
444
|
+
models: models.map(projectWorkspaceGatewayCustomModel),
|
|
445
|
+
}),
|
|
446
|
+
);
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
app.post("/v1/workspaces/:workspaceId/gateway-custom-models", async (c) => {
|
|
450
|
+
const workspaceId = c.req.param("workspaceId");
|
|
451
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
452
|
+
const parsed = CreateWorkspaceGatewayCustomModelRequest.safeParse(
|
|
453
|
+
await c.req.json().catch(() => null),
|
|
454
|
+
);
|
|
455
|
+
if (!parsed.success) {
|
|
456
|
+
throw new HTTPException(422, { message: "invalid Gateway custom model" });
|
|
457
|
+
}
|
|
458
|
+
const requestHash = workspaceCustomModelRequestHash({
|
|
459
|
+
action: "create",
|
|
460
|
+
upstreamModelId: parsed.data.upstreamModelId,
|
|
461
|
+
label: parsed.data.label ?? null,
|
|
462
|
+
});
|
|
463
|
+
const replay = await replayWorkspaceGatewayCustomModelCreate(deps.db, {
|
|
464
|
+
accountId: grant.accountId,
|
|
465
|
+
workspaceId,
|
|
466
|
+
operationId: parsed.data.operationId,
|
|
467
|
+
requestHash,
|
|
468
|
+
});
|
|
469
|
+
if (replay.outcome === "conflict") {
|
|
470
|
+
throw new HTTPException(409, {
|
|
471
|
+
message: "Gateway custom model operation conflicts with current state",
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
if (replay.outcome === "success") {
|
|
475
|
+
return c.json(projectWorkspaceGatewayCustomModel(replay.model), 201);
|
|
476
|
+
}
|
|
477
|
+
const catalog = await deps.resolveCatalogSettings();
|
|
478
|
+
if (configuredGatewayUpstreamModelIds(catalog.settings).includes(parsed.data.upstreamModelId)) {
|
|
479
|
+
throw new HTTPException(422, {
|
|
480
|
+
message: "Gateway model is already included in the deployment catalog",
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
const customProductId = `${WORKSPACE_GATEWAY_MODEL_ID_PREFIX}${parsed.data.upstreamModelId}`;
|
|
484
|
+
const deploymentProductIds = new Set([
|
|
485
|
+
...configuredModelInputIdentities(catalog.settings),
|
|
486
|
+
...configuredGatewayWorkspaceProductModelIds(catalog.settings),
|
|
487
|
+
]);
|
|
488
|
+
if (deploymentProductIds.has(customProductId)) {
|
|
489
|
+
throw new HTTPException(422, {
|
|
490
|
+
message: "Gateway model product id conflicts with the deployment catalog",
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
try {
|
|
494
|
+
const model = await createWorkspaceGatewayCustomModel(deps.db, {
|
|
495
|
+
accountId: grant.accountId,
|
|
496
|
+
workspaceId,
|
|
497
|
+
upstreamModelId: parsed.data.upstreamModelId,
|
|
498
|
+
label: parsed.data.label ?? null,
|
|
499
|
+
operationId: parsed.data.operationId,
|
|
500
|
+
requestHash,
|
|
501
|
+
createdBySubjectId: grant.subjectId,
|
|
502
|
+
});
|
|
503
|
+
if (!model || model.retiredAt) {
|
|
504
|
+
throw new HTTPException(409, {
|
|
505
|
+
message: "Gateway custom model operation conflicts with current state",
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
return c.json(projectWorkspaceGatewayCustomModel(model), 201);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
if (
|
|
511
|
+
error instanceof WorkspaceGatewayCustomModelLimitError ||
|
|
512
|
+
error instanceof WorkspaceGatewayCustomModelHistoryLimitError
|
|
513
|
+
) {
|
|
514
|
+
throw new HTTPException(422, { message: error.message });
|
|
515
|
+
}
|
|
516
|
+
if (nestedPostgresSqlState(error) === "23505") {
|
|
517
|
+
throw new HTTPException(422, {
|
|
518
|
+
message: "Gateway custom model already exists",
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
throw error;
|
|
522
|
+
}
|
|
523
|
+
});
|
|
524
|
+
|
|
525
|
+
app.delete("/v1/workspaces/:workspaceId/gateway-custom-models/:customModelId", async (c) => {
|
|
526
|
+
const workspaceId = c.req.param("workspaceId");
|
|
527
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
528
|
+
const customModelId = c.req.param("customModelId");
|
|
529
|
+
if (
|
|
530
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
|
|
531
|
+
customModelId,
|
|
532
|
+
)
|
|
533
|
+
) {
|
|
534
|
+
throw new HTTPException(422, {
|
|
535
|
+
message: "invalid Gateway custom model id",
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
const parsed = DeleteWorkspaceGatewayCustomModelRequest.safeParse(
|
|
539
|
+
await c.req.json().catch(() => null),
|
|
540
|
+
);
|
|
541
|
+
if (!parsed.success) {
|
|
542
|
+
throw new HTTPException(422, {
|
|
543
|
+
message: "invalid Gateway custom model deletion",
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
const removed = await deleteWorkspaceGatewayCustomModel(deps.db, {
|
|
547
|
+
accountId: grant.accountId,
|
|
548
|
+
workspaceId,
|
|
549
|
+
customModelId,
|
|
550
|
+
expectedVersion: parsed.data.expectedVersion,
|
|
551
|
+
operationId: parsed.data.operationId,
|
|
552
|
+
requestHash: workspaceCustomModelRequestHash({
|
|
553
|
+
action: "delete",
|
|
554
|
+
customModelId,
|
|
555
|
+
expectedVersion: parsed.data.expectedVersion,
|
|
556
|
+
}),
|
|
557
|
+
});
|
|
558
|
+
if (removed.outcome === "not_found") {
|
|
559
|
+
throw new HTTPException(404, {
|
|
560
|
+
message: "Gateway custom model not found",
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
if (removed.outcome === "conflict") {
|
|
564
|
+
throw new HTTPException(409, {
|
|
565
|
+
message: "Gateway custom model changed; reload and retry",
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
return c.body(null, 204);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
app.get("/v1/workspaces/:workspaceId/openrouter-custom-models", async (c) => {
|
|
572
|
+
const workspaceId = c.req.param("workspaceId");
|
|
573
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
574
|
+
const models = await listWorkspaceOpenRouterCustomModels(deps.db, {
|
|
575
|
+
accountId: grant.accountId,
|
|
576
|
+
workspaceId,
|
|
577
|
+
});
|
|
578
|
+
c.header("cache-control", "private, no-store");
|
|
579
|
+
return c.json(
|
|
580
|
+
WorkspaceOpenRouterCustomModelsResponse.parse({
|
|
581
|
+
models: models.map(projectWorkspaceOpenRouterCustomModel),
|
|
582
|
+
}),
|
|
583
|
+
);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
app.post("/v1/workspaces/:workspaceId/openrouter-custom-models", async (c) => {
|
|
587
|
+
const workspaceId = c.req.param("workspaceId");
|
|
588
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
589
|
+
const parsed = CreateWorkspaceOpenRouterCustomModelRequest.safeParse(
|
|
590
|
+
await c.req.json().catch(() => null),
|
|
591
|
+
);
|
|
592
|
+
if (!parsed.success) {
|
|
593
|
+
throw new HTTPException(422, { message: "invalid OpenRouter custom model" });
|
|
594
|
+
}
|
|
595
|
+
const requestHash = workspaceCustomModelRequestHash({
|
|
596
|
+
action: "create",
|
|
597
|
+
upstreamModelId: parsed.data.upstreamModelId,
|
|
598
|
+
label: parsed.data.label ?? null,
|
|
599
|
+
});
|
|
600
|
+
const replay = await replayWorkspaceOpenRouterCustomModelCreate(deps.db, {
|
|
601
|
+
accountId: grant.accountId,
|
|
602
|
+
workspaceId,
|
|
603
|
+
operationId: parsed.data.operationId,
|
|
604
|
+
requestHash,
|
|
605
|
+
});
|
|
606
|
+
if (replay.outcome === "conflict") {
|
|
607
|
+
throw new HTTPException(409, {
|
|
608
|
+
message: "OpenRouter custom model operation conflicts with current state",
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
if (replay.outcome === "success") {
|
|
612
|
+
return c.json(projectWorkspaceOpenRouterCustomModel(replay.model), 201);
|
|
613
|
+
}
|
|
614
|
+
const catalog = await deps.resolveCatalogSettings();
|
|
615
|
+
if (
|
|
616
|
+
configuredOpenRouterUpstreamModelIds(catalog.settings).includes(parsed.data.upstreamModelId)
|
|
617
|
+
) {
|
|
618
|
+
throw new HTTPException(422, {
|
|
619
|
+
message: "OpenRouter model is already included in the deployment catalog",
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
const customProductId = `${WORKSPACE_OPENROUTER_MODEL_ID_PREFIX}${parsed.data.upstreamModelId}`;
|
|
623
|
+
const deploymentProductIds = new Set([
|
|
624
|
+
...configuredModelInputIdentities(catalog.settings),
|
|
625
|
+
...configuredOpenRouterWorkspaceProductModelIds(catalog.settings),
|
|
626
|
+
]);
|
|
627
|
+
if (deploymentProductIds.has(customProductId)) {
|
|
628
|
+
throw new HTTPException(422, {
|
|
629
|
+
message: "OpenRouter model product id conflicts with the deployment catalog",
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
const model = await createWorkspaceOpenRouterCustomModel(deps.db, {
|
|
634
|
+
accountId: grant.accountId,
|
|
635
|
+
workspaceId,
|
|
636
|
+
upstreamModelId: parsed.data.upstreamModelId,
|
|
637
|
+
label: parsed.data.label ?? null,
|
|
638
|
+
operationId: parsed.data.operationId,
|
|
639
|
+
requestHash,
|
|
640
|
+
createdBySubjectId: grant.subjectId,
|
|
641
|
+
});
|
|
642
|
+
if (!model || model.retiredAt) {
|
|
643
|
+
throw new HTTPException(409, {
|
|
644
|
+
message: "OpenRouter custom model operation conflicts with current state",
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
return c.json(projectWorkspaceOpenRouterCustomModel(model), 201);
|
|
648
|
+
} catch (error) {
|
|
649
|
+
if (
|
|
650
|
+
error instanceof WorkspaceOpenRouterCustomModelLimitError ||
|
|
651
|
+
error instanceof WorkspaceOpenRouterCustomModelHistoryLimitError
|
|
652
|
+
) {
|
|
653
|
+
throw new HTTPException(422, { message: error.message });
|
|
654
|
+
}
|
|
655
|
+
if (nestedPostgresSqlState(error) === "23505") {
|
|
656
|
+
throw new HTTPException(422, {
|
|
657
|
+
message: "OpenRouter custom model already exists",
|
|
658
|
+
});
|
|
659
|
+
}
|
|
660
|
+
throw error;
|
|
661
|
+
}
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
app.delete("/v1/workspaces/:workspaceId/openrouter-custom-models/:customModelId", async (c) => {
|
|
665
|
+
const workspaceId = c.req.param("workspaceId");
|
|
666
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
667
|
+
const customModelId = c.req.param("customModelId");
|
|
668
|
+
if (
|
|
669
|
+
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu.test(
|
|
670
|
+
customModelId,
|
|
671
|
+
)
|
|
672
|
+
) {
|
|
673
|
+
throw new HTTPException(422, {
|
|
674
|
+
message: "invalid OpenRouter custom model id",
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
const parsed = DeleteWorkspaceOpenRouterCustomModelRequest.safeParse(
|
|
678
|
+
await c.req.json().catch(() => null),
|
|
679
|
+
);
|
|
680
|
+
if (!parsed.success) {
|
|
681
|
+
throw new HTTPException(422, {
|
|
682
|
+
message: "invalid OpenRouter custom model deletion",
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
const removed = await deleteWorkspaceOpenRouterCustomModel(deps.db, {
|
|
686
|
+
accountId: grant.accountId,
|
|
687
|
+
workspaceId,
|
|
688
|
+
customModelId,
|
|
689
|
+
expectedVersion: parsed.data.expectedVersion,
|
|
690
|
+
operationId: parsed.data.operationId,
|
|
691
|
+
requestHash: workspaceCustomModelRequestHash({
|
|
692
|
+
action: "delete",
|
|
693
|
+
customModelId,
|
|
694
|
+
expectedVersion: parsed.data.expectedVersion,
|
|
695
|
+
}),
|
|
696
|
+
});
|
|
697
|
+
if (removed.outcome === "not_found") {
|
|
698
|
+
throw new HTTPException(404, {
|
|
699
|
+
message: "OpenRouter custom model not found",
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
if (removed.outcome === "conflict") {
|
|
703
|
+
throw new HTTPException(409, {
|
|
704
|
+
message: "OpenRouter custom model changed; reload and retry",
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
return c.body(null, 204);
|
|
708
|
+
});
|
|
709
|
+
|
|
247
710
|
app.get("/v1/workspaces/:workspaceId/realtime-model-catalog", async (c) => {
|
|
248
711
|
const workspaceId = c.req.param("workspaceId");
|
|
249
712
|
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
@@ -320,11 +783,15 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
320
783
|
const workspaceId = c.req.param("workspaceId");
|
|
321
784
|
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
322
785
|
const payload = UpdateWorkspaceModelPolicyRequest.parse(await c.req.json());
|
|
786
|
+
const catalog = await resolveWorkspaceCatalogSettings(deps.db, deps.settings, {
|
|
787
|
+
accountId: grant.accountId,
|
|
788
|
+
workspaceId,
|
|
789
|
+
});
|
|
323
790
|
const policy = await upsertWorkspaceModelPolicy(deps.db, {
|
|
324
791
|
accountId: grant.accountId,
|
|
325
792
|
workspaceId,
|
|
326
793
|
allowedProviders: payload.allowedProviders ?? null,
|
|
327
|
-
allowedModels: canonicalWorkspacePolicyModelIds(
|
|
794
|
+
allowedModels: canonicalWorkspacePolicyModelIds(catalog.settings, payload.allowedModels),
|
|
328
795
|
});
|
|
329
796
|
return c.json(policy);
|
|
330
797
|
});
|
|
@@ -333,11 +800,15 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
333
800
|
const workspaceId = c.req.param("workspaceId");
|
|
334
801
|
const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:admin");
|
|
335
802
|
if (workspaceControlUtf8Bytes(grant.subjectId) > WORKSPACE_CONTROL_ACTOR_MAX_BYTES) {
|
|
336
|
-
throw new HTTPException(400, {
|
|
803
|
+
throw new HTTPException(400, {
|
|
804
|
+
message: "workspace-control actor is too large",
|
|
805
|
+
});
|
|
337
806
|
}
|
|
338
807
|
const parsed = WorkspaceInferenceControlRequest.safeParse(await c.req.json().catch(() => null));
|
|
339
808
|
if (!parsed.success) {
|
|
340
|
-
throw new HTTPException(400, {
|
|
809
|
+
throw new HTTPException(400, {
|
|
810
|
+
message: "invalid workspace inference-control request",
|
|
811
|
+
});
|
|
341
812
|
}
|
|
342
813
|
return c.json(
|
|
343
814
|
await controlHumanWorkspace(
|
|
@@ -383,6 +854,7 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
383
854
|
after,
|
|
384
855
|
c.req.raw.signal,
|
|
385
856
|
{
|
|
857
|
+
...browserSseDeliveryOptions(c.req.query("transport")),
|
|
386
858
|
observability: deps.observability,
|
|
387
859
|
actorEpoch: getManagedAuthRequestActorEpoch(c.req.raw) ?? undefined,
|
|
388
860
|
reauthorize: async () => {
|
|
@@ -399,7 +871,9 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
399
871
|
if (payload.rigId) {
|
|
400
872
|
const rig = await getRig(deps.db, workspaceId, payload.rigId);
|
|
401
873
|
if (!rig) {
|
|
402
|
-
throw new HTTPException(422, {
|
|
874
|
+
throw new HTTPException(422, {
|
|
875
|
+
message: `unknown rigId: ${payload.rigId}`,
|
|
876
|
+
});
|
|
403
877
|
}
|
|
404
878
|
}
|
|
405
879
|
const workspace = await setWorkspaceDefaultRig(deps.db, workspaceId, payload.rigId);
|
|
@@ -413,15 +887,22 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
413
887
|
// session/lease before checking runtime quiescence, then returns the exact
|
|
414
888
|
// external schedules removed by the cascade. A racing cold->warm transition
|
|
415
889
|
// can therefore never erase the only provider/capture ownership receipt.
|
|
890
|
+
const deleteObserver = workspaceDeleteObserver(deps.observability, {
|
|
891
|
+
accountId: grant.accountId,
|
|
892
|
+
workspaceId,
|
|
893
|
+
});
|
|
416
894
|
const deleted = await deleteWorkspaceIfQuiescent(deps.db, {
|
|
417
895
|
accountId: grant.accountId,
|
|
418
896
|
workspaceId,
|
|
897
|
+
...(deleteObserver ? { observer: deleteObserver } : {}),
|
|
419
898
|
});
|
|
420
899
|
if (deleted.status === "not_found") {
|
|
421
900
|
throw new HTTPException(404, { message: "workspace not found" });
|
|
422
901
|
}
|
|
423
902
|
if (deleted.status === "only_workspace") {
|
|
424
|
-
throw new HTTPException(409, {
|
|
903
|
+
throw new HTTPException(409, {
|
|
904
|
+
message: "cannot delete the account's only workspace",
|
|
905
|
+
});
|
|
425
906
|
}
|
|
426
907
|
if (deleted.status === "active_sessions") {
|
|
427
908
|
throw new HTTPException(409, {
|
|
@@ -453,7 +934,9 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
453
934
|
{
|
|
454
935
|
db: deps.db,
|
|
455
936
|
deleteSchedule: async (temporalScheduleId) => {
|
|
456
|
-
await deps.workflowClient.deleteScheduledTaskSchedule({
|
|
937
|
+
await deps.workflowClient.deleteScheduledTaskSchedule({
|
|
938
|
+
temporalScheduleId,
|
|
939
|
+
});
|
|
457
940
|
},
|
|
458
941
|
...(deps.observability ? { observability: deps.observability } : {}),
|
|
459
942
|
},
|
|
@@ -467,28 +950,73 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
467
950
|
app.get("/v1/workspaces/:workspaceId/members", async (c) => {
|
|
468
951
|
const workspaceId = c.req.param("workspaceId");
|
|
469
952
|
await requireAccessGrant(c, deps, workspaceId, "workspace:read");
|
|
470
|
-
const members = await
|
|
953
|
+
const members = await listWorkspacePeople(deps, workspaceId);
|
|
471
954
|
return c.json(workspaceMembersResponse(members));
|
|
472
955
|
});
|
|
473
956
|
|
|
957
|
+
app.get("/v1/workspaces/:workspaceId/member-candidates", async (c) => {
|
|
958
|
+
const workspaceId = c.req.param("workspaceId");
|
|
959
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
|
|
960
|
+
try {
|
|
961
|
+
return c.json(
|
|
962
|
+
ListWorkspaceMemberCandidatesResponse.parse({
|
|
963
|
+
members: await listWorkspaceMemberManagementCandidates(deps.db, {
|
|
964
|
+
accountId: grant.accountId,
|
|
965
|
+
workspaceId,
|
|
966
|
+
actorSubjectId: grant.subjectId,
|
|
967
|
+
}),
|
|
968
|
+
}),
|
|
969
|
+
);
|
|
970
|
+
} catch (error) {
|
|
971
|
+
rethrowWorkspaceMemberCandidateError(error);
|
|
972
|
+
}
|
|
973
|
+
});
|
|
974
|
+
|
|
474
975
|
app.post("/v1/workspaces/:workspaceId/members", async (c) => {
|
|
475
976
|
const workspaceId = c.req.param("workspaceId");
|
|
476
977
|
const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
|
|
477
978
|
const payload = AddWorkspaceMemberRequest.parse(await c.req.json());
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
979
|
+
let candidates: WorkspaceMemberCandidate[];
|
|
980
|
+
try {
|
|
981
|
+
candidates = await listWorkspaceMemberManagementCandidates(deps.db, {
|
|
982
|
+
accountId: grant.accountId,
|
|
983
|
+
workspaceId,
|
|
984
|
+
actorSubjectId: grant.subjectId,
|
|
985
|
+
});
|
|
986
|
+
} catch (error) {
|
|
987
|
+
rethrowWorkspaceMemberCandidateError(error);
|
|
988
|
+
}
|
|
989
|
+
const candidate = candidates.find(
|
|
990
|
+
(entry) => entry.organizationMembershipId === payload.organizationMembershipId,
|
|
991
|
+
);
|
|
992
|
+
if (!candidate) {
|
|
993
|
+
throw new HTTPException(404, {
|
|
994
|
+
message: "this organization member is not available to add",
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
const subjectId = candidate.subjectId;
|
|
998
|
+
const existing = await listWorkspaceMembers(deps.db, workspaceId);
|
|
999
|
+
if (existing.some((member) => member.subjectId === subjectId)) {
|
|
1000
|
+
throw new HTTPException(409, {
|
|
1001
|
+
message: "this person already has access to the workspace",
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
await upsertWorkspaceMemberAsWorkspaceManager(deps.db, {
|
|
1006
|
+
accountId: grant.accountId,
|
|
1007
|
+
workspaceId,
|
|
1008
|
+
actorSubjectId: grant.subjectId,
|
|
1009
|
+
targetSubjectId: subjectId,
|
|
1010
|
+
mode: "add",
|
|
1011
|
+
subjectLabel: candidate.name?.trim() || candidate.email || subjectId,
|
|
1012
|
+
role: payload.role ?? "member",
|
|
1013
|
+
permissions: payload.permissions,
|
|
1014
|
+
});
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
rethrowWorkspaceMemberCandidateError(error);
|
|
1017
|
+
}
|
|
1018
|
+
const members = await listWorkspacePeople(deps, workspaceId);
|
|
1019
|
+
const member = members.find((addedMember) => addedMember.subjectId === subjectId);
|
|
492
1020
|
if (!member) {
|
|
493
1021
|
throw new HTTPException(500, { message: "failed to add member" });
|
|
494
1022
|
}
|
|
@@ -500,20 +1028,32 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
500
1028
|
const grant = await requireAccessGrant(c, deps, workspaceId, "members:manage");
|
|
501
1029
|
const subjectId = decodeURIComponent(c.req.param("subjectId"));
|
|
502
1030
|
const payload = UpdateWorkspaceMemberRequest.parse(await c.req.json());
|
|
503
|
-
const existing = await
|
|
1031
|
+
const existing = await listWorkspacePeople(deps, workspaceId);
|
|
504
1032
|
const current = existing.find((member) => member.subjectId === subjectId);
|
|
505
1033
|
if (!current) {
|
|
506
1034
|
throw new HTTPException(404, { message: "member not found" });
|
|
507
1035
|
}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
workspaceId,
|
|
1036
|
+
assertWorkspaceMemberUpdateAllowed({
|
|
1037
|
+
members: existing,
|
|
511
1038
|
subjectId,
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
permissions: payload.permissions,
|
|
1039
|
+
callerSubjectId: grant.subjectId,
|
|
1040
|
+
nextPermissions: payload.permissions,
|
|
515
1041
|
});
|
|
516
|
-
|
|
1042
|
+
try {
|
|
1043
|
+
await upsertWorkspaceMemberAsWorkspaceManager(deps.db, {
|
|
1044
|
+
accountId: grant.accountId,
|
|
1045
|
+
workspaceId,
|
|
1046
|
+
actorSubjectId: grant.subjectId,
|
|
1047
|
+
targetSubjectId: subjectId,
|
|
1048
|
+
mode: "update",
|
|
1049
|
+
...(current.subjectLabel ? { subjectLabel: current.subjectLabel } : {}),
|
|
1050
|
+
role: payload.role ?? current.role,
|
|
1051
|
+
permissions: payload.permissions,
|
|
1052
|
+
});
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
rethrowWorkspaceMemberCandidateError(error);
|
|
1055
|
+
}
|
|
1056
|
+
const members = await listWorkspacePeople(deps, workspaceId);
|
|
517
1057
|
const member = members.find((candidate) => candidate.subjectId === subjectId);
|
|
518
1058
|
if (!member) {
|
|
519
1059
|
throw new HTTPException(500, { message: "failed to update member" });
|
|
@@ -528,7 +1068,11 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
528
1068
|
const members = await listWorkspaceMembers(deps.db, workspaceId);
|
|
529
1069
|
// Never remove yourself, and never remove the last administering member.
|
|
530
1070
|
// The fenced removal command re-enforces both guards fail-closed.
|
|
531
|
-
assertWorkspaceMemberRemovable({
|
|
1071
|
+
assertWorkspaceMemberRemovable({
|
|
1072
|
+
members,
|
|
1073
|
+
subjectId,
|
|
1074
|
+
callerSubjectId: grant.subjectId,
|
|
1075
|
+
});
|
|
532
1076
|
await removeWorkspaceMember(deps.db, {
|
|
533
1077
|
accountId: grant.accountId,
|
|
534
1078
|
workspaceId,
|
|
@@ -539,6 +1083,69 @@ export function registerWorkspaceRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
539
1083
|
});
|
|
540
1084
|
}
|
|
541
1085
|
|
|
1086
|
+
function workspaceLimit(deps: ApiRouteDeps): number | null {
|
|
1087
|
+
if (deps.settings.usageLimitsMode !== "static" && deps.settings.usageLimitsMode !== "managed") {
|
|
1088
|
+
return null;
|
|
1089
|
+
}
|
|
1090
|
+
return configuredStaticUsageLimits(deps.settings).maxWorkspacesPerAccount ?? null;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async function listWorkspacePeople(
|
|
1094
|
+
deps: ApiRouteDeps,
|
|
1095
|
+
workspaceId: string,
|
|
1096
|
+
): Promise<WorkspaceMemberValue[]> {
|
|
1097
|
+
const members = await listWorkspaceMembers(deps.db, workspaceId);
|
|
1098
|
+
const userIds = members.flatMap((member) =>
|
|
1099
|
+
member.subjectId.startsWith("user:") ? [member.subjectId.slice("user:".length)] : [],
|
|
1100
|
+
);
|
|
1101
|
+
const profiles = await getManagedUserProfilesByIds(deps.db, userIds);
|
|
1102
|
+
const profileBySubject = new Map(
|
|
1103
|
+
profiles.map((profile) => [`user:${profile.id}`, profile.name?.trim() || profile.email]),
|
|
1104
|
+
);
|
|
1105
|
+
return members.map((member) => ({
|
|
1106
|
+
...member,
|
|
1107
|
+
subjectLabel: profileBySubject.get(member.subjectId) ?? member.subjectLabel,
|
|
1108
|
+
}));
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function rethrowWorkspaceMemberCandidateError(error: unknown): never {
|
|
1112
|
+
const managementCode =
|
|
1113
|
+
error && typeof error === "object" && "code" in error ? error.code : undefined;
|
|
1114
|
+
if (managementCode === "WORKSPACE_MEMBER_ALREADY_EXISTS") {
|
|
1115
|
+
throw new HTTPException(409, {
|
|
1116
|
+
message: "this person already has access to the workspace",
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
if (managementCode === "WORKSPACE_MEMBER_NOT_FOUND") {
|
|
1120
|
+
throw new HTTPException(404, { message: "member not found" });
|
|
1121
|
+
}
|
|
1122
|
+
if (managementCode === "WORKSPACE_MEMBER_SELF_UPDATE") {
|
|
1123
|
+
throw new HTTPException(409, {
|
|
1124
|
+
message: "you cannot change your own workspace access",
|
|
1125
|
+
});
|
|
1126
|
+
}
|
|
1127
|
+
if (managementCode === "WORKSPACE_MEMBER_LAST_ADMIN") {
|
|
1128
|
+
throw new HTTPException(409, {
|
|
1129
|
+
message: "the workspace must keep at least one administrator",
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
const sqlState = nestedPostgresSqlState(error);
|
|
1133
|
+
if (sqlState === "P0002") {
|
|
1134
|
+
throw new HTTPException(404, { message: "workspace not found" });
|
|
1135
|
+
}
|
|
1136
|
+
if (sqlState === "42501") {
|
|
1137
|
+
throw new HTTPException(403, {
|
|
1138
|
+
message: "workspace member management is not allowed",
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
if (sqlState === "54000") {
|
|
1142
|
+
throw new HTTPException(409, {
|
|
1143
|
+
message: "this organization has too many members to show here",
|
|
1144
|
+
});
|
|
1145
|
+
}
|
|
1146
|
+
throw error;
|
|
1147
|
+
}
|
|
1148
|
+
|
|
542
1149
|
// A persona override that is null or trims to empty collapses to null (use the
|
|
543
1150
|
// deployment default). Otherwise the template is stored verbatim so the runtime
|
|
544
1151
|
// can substitute the non-bypassable CORE at its {{core}} marker.
|
|
@@ -560,6 +1167,8 @@ function requireAccountPermission(
|
|
|
560
1167
|
!grant ||
|
|
561
1168
|
(!grant.permissions.includes(permission) && !grant.permissions.includes("account:admin"))
|
|
562
1169
|
) {
|
|
563
|
-
throw new HTTPException(403, {
|
|
1170
|
+
throw new HTTPException(403, {
|
|
1171
|
+
message: `missing permission: ${permission}`,
|
|
1172
|
+
});
|
|
564
1173
|
}
|
|
565
1174
|
}
|