@opengeni/api-router 0.11.8 → 0.12.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/{chunk-DQWFAIPE.js → chunk-S2N4252E.js} +1526 -453
- package/dist/chunk-S2N4252E.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/app.ts +34 -24
- package/src/http/auth.ts +4 -1
- package/src/integrations/slack-bot.ts +694 -0
- package/src/mcp/server.ts +120 -0
- package/src/routes/connections.ts +155 -1
- package/src/routes/sessions.ts +10 -0
- package/src/routes/workspace-instruction-policies.ts +243 -0
- package/src/sandbox/channel-a.ts +131 -70
- package/dist/chunk-DQWFAIPE.js.map +0 -1
package/src/mcp/server.ts
CHANGED
|
@@ -144,6 +144,10 @@ import {
|
|
|
144
144
|
} from "./session-view";
|
|
145
145
|
import type { ToolspaceMcpSurface } from "./toolspace";
|
|
146
146
|
import { ensureSessionGroupReady as ensureViewerSessionGroupReady } from "../sandbox/viewer";
|
|
147
|
+
import {
|
|
148
|
+
createOpenGeniSlackBotClient,
|
|
149
|
+
resolveSlackBotConnectionForTool,
|
|
150
|
+
} from "../integrations/slack-bot";
|
|
147
151
|
|
|
148
152
|
export type McpServerOptions = {
|
|
149
153
|
// Origin of the HTTP request that reached the MCP route. Browser-oriented
|
|
@@ -219,6 +223,7 @@ export function buildOpenGeniMcpServer(
|
|
|
219
223
|
}
|
|
220
224
|
if (!toolspaceMode) {
|
|
221
225
|
registerRigTools(server, deps, grant, can, sessionId, json);
|
|
226
|
+
registerSlackBotTools(server, deps, grant, sessionId, json);
|
|
222
227
|
}
|
|
223
228
|
|
|
224
229
|
// Orchestration, variableSet, and GitHub status/token tools are permission-gated
|
|
@@ -652,6 +657,121 @@ export function buildOpenGeniMcpServer(
|
|
|
652
657
|
return server;
|
|
653
658
|
}
|
|
654
659
|
|
|
660
|
+
function registerSlackBotTools(
|
|
661
|
+
server: McpServer,
|
|
662
|
+
deps: ApiRouteDeps,
|
|
663
|
+
grant: AccessGrant,
|
|
664
|
+
sessionId: string | null,
|
|
665
|
+
json: JsonResult,
|
|
666
|
+
): void {
|
|
667
|
+
const clientFor = async (connectionId?: string) => {
|
|
668
|
+
const resolved = await resolveSlackBotConnectionForTool({
|
|
669
|
+
db: deps.db,
|
|
670
|
+
grant,
|
|
671
|
+
sessionId,
|
|
672
|
+
...(connectionId ? { requestedConnectionId: connectionId } : {}),
|
|
673
|
+
});
|
|
674
|
+
return createOpenGeniSlackBotClient(deps, resolved);
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
server.registerTool(
|
|
678
|
+
"slack_bot_list_channels",
|
|
679
|
+
{
|
|
680
|
+
description:
|
|
681
|
+
"List public and bot-visible private Slack channels through the workspace-shared OpenGeni bot. isMember identifies channels the bot may read/post in; the bot never joins channels automatically.",
|
|
682
|
+
inputSchema: {
|
|
683
|
+
connectionId: z4.string().uuid().optional(),
|
|
684
|
+
cursor: z4.string().max(1024).optional(),
|
|
685
|
+
limit: z4.number().int().min(1).max(200).optional(),
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
async ({ connectionId, cursor, limit }) =>
|
|
689
|
+
json(
|
|
690
|
+
await (
|
|
691
|
+
await clientFor(connectionId)
|
|
692
|
+
).listChannels({
|
|
693
|
+
...(cursor ? { cursor } : {}),
|
|
694
|
+
...(limit !== undefined ? { limit } : {}),
|
|
695
|
+
}),
|
|
696
|
+
),
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
server.registerTool(
|
|
700
|
+
"slack_bot_channel_history",
|
|
701
|
+
{
|
|
702
|
+
description:
|
|
703
|
+
"Read Slack channel history as the workspace-shared OpenGeni bot. Public and private channels both require bot membership; invite the bot to private channels first.",
|
|
704
|
+
inputSchema: {
|
|
705
|
+
connectionId: z4.string().uuid().optional(),
|
|
706
|
+
channelId: z4.string().min(1).max(64),
|
|
707
|
+
cursor: z4.string().max(1024).optional(),
|
|
708
|
+
limit: z4.number().int().min(1).max(100).optional(),
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
async ({ connectionId, channelId, cursor, limit }) =>
|
|
712
|
+
json(
|
|
713
|
+
await (
|
|
714
|
+
await clientFor(connectionId)
|
|
715
|
+
).channelHistory({
|
|
716
|
+
channelId,
|
|
717
|
+
...(cursor ? { cursor } : {}),
|
|
718
|
+
...(limit !== undefined ? { limit } : {}),
|
|
719
|
+
}),
|
|
720
|
+
),
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
server.registerTool(
|
|
724
|
+
"slack_bot_list_users",
|
|
725
|
+
{
|
|
726
|
+
description: "List Slack workspace users through the workspace-shared OpenGeni bot.",
|
|
727
|
+
inputSchema: {
|
|
728
|
+
connectionId: z4.string().uuid().optional(),
|
|
729
|
+
cursor: z4.string().max(1024).optional(),
|
|
730
|
+
limit: z4.number().int().min(1).max(200).optional(),
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
async ({ connectionId, cursor, limit }) =>
|
|
734
|
+
json(
|
|
735
|
+
await (
|
|
736
|
+
await clientFor(connectionId)
|
|
737
|
+
).listUsers({
|
|
738
|
+
...(cursor ? { cursor } : {}),
|
|
739
|
+
...(limit !== undefined ? { limit } : {}),
|
|
740
|
+
}),
|
|
741
|
+
),
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
server.registerTool(
|
|
745
|
+
"slack_bot_post_message",
|
|
746
|
+
{
|
|
747
|
+
description:
|
|
748
|
+
"Post as the workspace-shared OpenGeni bot. Pass channelId for a channel where the bot is already a member, or userId to open/post a DM; pass exactly one. Generate one operationId UUID per intended message and reuse that same UUID on every retry.",
|
|
749
|
+
inputSchema: {
|
|
750
|
+
connectionId: z4.string().uuid().optional(),
|
|
751
|
+
operationId: z4.string().uuid(),
|
|
752
|
+
channelId: z4.string().min(1).max(64).optional(),
|
|
753
|
+
userId: z4.string().min(1).max(64).optional(),
|
|
754
|
+
text: z4.string().min(1).max(40_000),
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
async ({ connectionId, operationId, channelId, userId, text }) => {
|
|
758
|
+
if (Boolean(channelId) === Boolean(userId)) {
|
|
759
|
+
throw new Error("exactly one of channelId or userId is required");
|
|
760
|
+
}
|
|
761
|
+
return json(
|
|
762
|
+
await (
|
|
763
|
+
await clientFor(connectionId)
|
|
764
|
+
).postMessage({
|
|
765
|
+
operationId,
|
|
766
|
+
...(channelId ? { channelId } : {}),
|
|
767
|
+
...(userId ? { userId } : {}),
|
|
768
|
+
text,
|
|
769
|
+
}),
|
|
770
|
+
);
|
|
771
|
+
},
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
655
775
|
function registerToolspaceProxyTools(server: McpServer, surface: ToolspaceMcpSurface | null): void {
|
|
656
776
|
if (!surface) {
|
|
657
777
|
return;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ConnectOpenGeniSlackBotRequest,
|
|
2
3
|
ConnectionResponse,
|
|
3
4
|
CreateConnectionRequest,
|
|
4
5
|
IntegrationClientMetadata,
|
|
@@ -7,12 +8,19 @@ import {
|
|
|
7
8
|
OAuthStartResponse,
|
|
8
9
|
UpdateConnectionRequest,
|
|
9
10
|
} from "@opengeni/contracts";
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
hasReservedOpenGeniSlackBotMetadata,
|
|
13
|
+
isOpenGeniSlackBotConnection,
|
|
14
|
+
openGeniSlackBotMetadata,
|
|
15
|
+
requireAccessGrant,
|
|
16
|
+
requireEnvironmentEncryption,
|
|
17
|
+
} from "@opengeni/core";
|
|
11
18
|
import {
|
|
12
19
|
createConnection,
|
|
13
20
|
encryptEnvironmentValue,
|
|
14
21
|
getConnectionMetadata,
|
|
15
22
|
listConnectionsMetadata,
|
|
23
|
+
recordAuditEvent,
|
|
16
24
|
revokeConnection,
|
|
17
25
|
updateConnection,
|
|
18
26
|
} from "@opengeni/db";
|
|
@@ -25,6 +33,11 @@ import {
|
|
|
25
33
|
startMcpOAuth,
|
|
26
34
|
} from "../integrations/oauth-client";
|
|
27
35
|
import { canonicalProviderDomain } from "../integrations/provider-domain";
|
|
36
|
+
import { verifyOpenGeniSlackBotCredential } from "../integrations/slack-bot";
|
|
37
|
+
import {
|
|
38
|
+
OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
|
|
39
|
+
OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
40
|
+
} from "@opengeni/contracts";
|
|
28
41
|
|
|
29
42
|
export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
30
43
|
const { db, settings, observability } = deps;
|
|
@@ -49,6 +62,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
49
62
|
const workspaceId = c.req.param("workspaceId");
|
|
50
63
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
51
64
|
const payload = CreateConnectionRequest.parse(await c.req.json());
|
|
65
|
+
assertNotReservedSlackBotMetadata(payload.metadata);
|
|
52
66
|
const key = requireEnvironmentEncryption(settings);
|
|
53
67
|
const subjectId = writableSubjectId(payload.subjectId, grant.subjectId);
|
|
54
68
|
const connection = await createConnection(db, {
|
|
@@ -66,6 +80,102 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
66
80
|
return c.json(ConnectionResponse.parse({ connection }), 201);
|
|
67
81
|
});
|
|
68
82
|
|
|
83
|
+
app.post("/v1/workspaces/:workspaceId/connections/slack-bot", async (c) => {
|
|
84
|
+
const workspaceId = c.req.param("workspaceId");
|
|
85
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
86
|
+
const payload = ConnectOpenGeniSlackBotRequest.parse(await c.req.json());
|
|
87
|
+
const verified = await verifyOpenGeniSlackBotCredential(
|
|
88
|
+
payload.token,
|
|
89
|
+
deps.slackFetch ?? fetch,
|
|
90
|
+
);
|
|
91
|
+
const key = requireEnvironmentEncryption(settings);
|
|
92
|
+
const credentialEncrypted = encryptCredentialBundle(
|
|
93
|
+
key,
|
|
94
|
+
slackBotCredentialBundle(payload.token),
|
|
95
|
+
);
|
|
96
|
+
const existing = payload.connectionId
|
|
97
|
+
? await getConnectionMetadata(db, workspaceId, payload.connectionId, grant.subjectId)
|
|
98
|
+
: null;
|
|
99
|
+
if (payload.connectionId && !existing) {
|
|
100
|
+
throw new HTTPException(404, { message: "connection not found" });
|
|
101
|
+
}
|
|
102
|
+
if (existing && !isOpenGeniSlackBotConnection(existing)) {
|
|
103
|
+
throw new HTTPException(422, {
|
|
104
|
+
message: "connectionId is not an OpenGeni Slack bot connection",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const existingMetadata = existing ? openGeniSlackBotMetadata(existing.metadata) : null;
|
|
108
|
+
if (existingMetadata && existingMetadata.slackTeamId !== verified.metadata.slackTeamId) {
|
|
109
|
+
throw new HTTPException(409, {
|
|
110
|
+
message: "a Slack bot connection can only be reinstalled for its original Slack workspace",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (
|
|
114
|
+
existingMetadata &&
|
|
115
|
+
(existingMetadata.botId !== verified.metadata.botId ||
|
|
116
|
+
existingMetadata.botUserId !== verified.metadata.botUserId)
|
|
117
|
+
) {
|
|
118
|
+
throw new HTTPException(409, {
|
|
119
|
+
message:
|
|
120
|
+
"a different Slack bot requires a new connection and explicit scheduled-task rebinding",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const verifiedInstallAt = new Date(verified.metadata.verifiedAt);
|
|
124
|
+
const connection = existing
|
|
125
|
+
? await updateConnection(db, {
|
|
126
|
+
workspaceId,
|
|
127
|
+
connectionId: existing.id,
|
|
128
|
+
visibleToSubjectId: grant.subjectId,
|
|
129
|
+
expectedVersion: existing.version,
|
|
130
|
+
subjectId: null,
|
|
131
|
+
providerDomain: "slack.com",
|
|
132
|
+
kind: "app_install",
|
|
133
|
+
status: "active",
|
|
134
|
+
credentialEncrypted,
|
|
135
|
+
grantedScopes: verified.grantedScopes,
|
|
136
|
+
expiresAt: null,
|
|
137
|
+
verifiedInstallAt,
|
|
138
|
+
verifiedInstallVersion: existing.version + 1,
|
|
139
|
+
metadata: verified.metadata,
|
|
140
|
+
updatedBySubjectId: grant.subjectId,
|
|
141
|
+
})
|
|
142
|
+
: await createConnection(db, {
|
|
143
|
+
accountId: grant.accountId,
|
|
144
|
+
workspaceId,
|
|
145
|
+
subjectId: null,
|
|
146
|
+
providerDomain: "slack.com",
|
|
147
|
+
kind: "app_install",
|
|
148
|
+
credentialEncrypted,
|
|
149
|
+
grantedScopes: verified.grantedScopes,
|
|
150
|
+
expiresAt: null,
|
|
151
|
+
verifiedInstallAt,
|
|
152
|
+
verifiedInstallVersion: 1,
|
|
153
|
+
metadata: verified.metadata,
|
|
154
|
+
createdBySubjectId: grant.subjectId,
|
|
155
|
+
});
|
|
156
|
+
if (!connection) {
|
|
157
|
+
throw new HTTPException(409, {
|
|
158
|
+
message: "Slack bot connection changed during reinstall; retry with the current connection",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
await recordAuditEvent(db, {
|
|
162
|
+
accountId: grant.accountId,
|
|
163
|
+
workspaceId,
|
|
164
|
+
subjectId: grant.subjectId,
|
|
165
|
+
action: existing ? "slack_bot.reinstalled" : "slack_bot.connected",
|
|
166
|
+
targetType: "connection",
|
|
167
|
+
targetId: connection.id,
|
|
168
|
+
metadata: {
|
|
169
|
+
credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
170
|
+
credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
|
|
171
|
+
connectionId: connection.id,
|
|
172
|
+
slackTeamId: verified.metadata.slackTeamId,
|
|
173
|
+
outcome: "succeeded",
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
return c.json(ConnectionResponse.parse({ connection }), existing ? 200 : 201);
|
|
177
|
+
});
|
|
178
|
+
|
|
69
179
|
app.get("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
70
180
|
const workspaceId = c.req.param("workspaceId");
|
|
71
181
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
|
|
@@ -85,6 +195,18 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
85
195
|
const workspaceId = c.req.param("workspaceId");
|
|
86
196
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
87
197
|
const payload = UpdateConnectionRequest.parse(await c.req.json());
|
|
198
|
+
assertNotReservedSlackBotMetadata(payload.metadata);
|
|
199
|
+
const existing = await getConnectionMetadata(
|
|
200
|
+
db,
|
|
201
|
+
workspaceId,
|
|
202
|
+
c.req.param("connectionId"),
|
|
203
|
+
grant.subjectId,
|
|
204
|
+
);
|
|
205
|
+
if (existing && isOpenGeniSlackBotConnection(existing)) {
|
|
206
|
+
throw new HTTPException(422, {
|
|
207
|
+
message: "use the dedicated OpenGeni Slack bot reinstall flow to update this connection",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
88
210
|
// Status is not a free-form field: revocation goes through DELETE, and the
|
|
89
211
|
// broker owns needs_reauth/error. Reactivating a connection is only
|
|
90
212
|
// meaningful together with a fresh credential bundle — otherwise a PATCH
|
|
@@ -144,6 +266,24 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
144
266
|
if (!connection) {
|
|
145
267
|
throw new HTTPException(404, { message: "connection not found" });
|
|
146
268
|
}
|
|
269
|
+
if (isOpenGeniSlackBotConnection(connection)) {
|
|
270
|
+
const metadata = openGeniSlackBotMetadata(connection.metadata)!;
|
|
271
|
+
await recordAuditEvent(db, {
|
|
272
|
+
accountId: grant.accountId,
|
|
273
|
+
workspaceId,
|
|
274
|
+
subjectId: grant.subjectId,
|
|
275
|
+
action: "slack_bot.disconnected",
|
|
276
|
+
targetType: "connection",
|
|
277
|
+
targetId: connection.id,
|
|
278
|
+
metadata: {
|
|
279
|
+
credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
280
|
+
credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
|
|
281
|
+
connectionId: connection.id,
|
|
282
|
+
slackTeamId: metadata.slackTeamId,
|
|
283
|
+
outcome: "succeeded",
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
147
287
|
return c.json(ConnectionResponse.parse({ connection }));
|
|
148
288
|
});
|
|
149
289
|
|
|
@@ -200,6 +340,14 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
200
340
|
});
|
|
201
341
|
}
|
|
202
342
|
|
|
343
|
+
function assertNotReservedSlackBotMetadata(metadata: Record<string, unknown> | undefined): void {
|
|
344
|
+
if (hasReservedOpenGeniSlackBotMetadata(metadata)) {
|
|
345
|
+
throw new HTTPException(422, {
|
|
346
|
+
message: "OpenGeni Slack bot metadata is reserved for the dedicated connection flow",
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
203
351
|
function writableSubjectId(
|
|
204
352
|
requested: string | null | undefined,
|
|
205
353
|
grantSubjectId: string,
|
|
@@ -216,3 +364,9 @@ function writableSubjectId(
|
|
|
216
364
|
function encryptCredentialBundle(key: Uint8Array, credential: Record<string, unknown>): string {
|
|
217
365
|
return encryptEnvironmentValue(key, JSON.stringify(credential));
|
|
218
366
|
}
|
|
367
|
+
|
|
368
|
+
function slackBotCredentialBundle(token: string): Record<string, unknown> {
|
|
369
|
+
const headerName = ["author", "ization"].join("");
|
|
370
|
+
const scheme = ["Bear", "er"].join("");
|
|
371
|
+
return { headers: { [headerName]: `${scheme} ${token}` } };
|
|
372
|
+
}
|
package/src/routes/sessions.ts
CHANGED
|
@@ -191,6 +191,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
191
191
|
handle: ChannelAHandle,
|
|
192
192
|
pty: SandboxOpenPtySessionRow,
|
|
193
193
|
): Promise<SandboxRetainedProcess> => {
|
|
194
|
+
if (!handle.lease) {
|
|
195
|
+
throw new HTTPException(409, {
|
|
196
|
+
message: "durable interactive terminals require a session-home provider lease",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
194
199
|
const process = await getRetainedProcess(db, {
|
|
195
200
|
workspaceId: ctx.workspaceId,
|
|
196
201
|
sessionId: ctx.session.id,
|
|
@@ -2183,6 +2188,11 @@ export function registerSessionRoutes(app: Hono, deps: SessionRouteDeps): void {
|
|
|
2183
2188
|
}
|
|
2184
2189
|
const ptyId = crypto.randomUUID();
|
|
2185
2190
|
const out = await withChannelA({ db, settings, bus }, ctx, async (handle) => {
|
|
2191
|
+
if (!handle.lease) {
|
|
2192
|
+
throw new HTTPException(409, {
|
|
2193
|
+
message: "durable interactive terminals require a session-home provider lease",
|
|
2194
|
+
});
|
|
2195
|
+
}
|
|
2186
2196
|
const { service } = handle;
|
|
2187
2197
|
const opened = await service.ptyOpen(req, ptyId);
|
|
2188
2198
|
const execSessionId = opened.execSessionId;
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ActivateWorkspaceInstructionPolicyRequest,
|
|
3
|
+
CreateWorkspaceInstructionPolicyDraftRequest,
|
|
4
|
+
ImportLegacyWorkspaceInstructionPolicyDraftRequest,
|
|
5
|
+
RollbackWorkspaceInstructionPolicyRequest,
|
|
6
|
+
WorkspaceInstructionPolicyActivationResponse,
|
|
7
|
+
WorkspaceInstructionPolicyConflictResponse,
|
|
8
|
+
WorkspaceInstructionPolicyDiffRequest,
|
|
9
|
+
WorkspaceInstructionPolicyDiffResponse,
|
|
10
|
+
WorkspaceInstructionPolicyListQuery,
|
|
11
|
+
WorkspaceInstructionPolicyListResponse,
|
|
12
|
+
WorkspaceInstructionPolicyRevision,
|
|
13
|
+
} from "@opengeni/contracts";
|
|
14
|
+
import { requireAccessGrant, type ApiRouteDeps } from "@opengeni/core";
|
|
15
|
+
import {
|
|
16
|
+
activateWorkspaceInstructionPolicyRevision,
|
|
17
|
+
createWorkspaceInstructionPolicyDraft,
|
|
18
|
+
diffWorkspaceInstructionPolicyRevisions,
|
|
19
|
+
getWorkspaceInstructionPolicyRevision,
|
|
20
|
+
importLegacyWorkspaceInstructionPolicyDraft,
|
|
21
|
+
listWorkspaceInstructionPolicyRevisions,
|
|
22
|
+
rollbackWorkspaceInstructionPolicyRevision,
|
|
23
|
+
WorkspaceInstructionPolicyConflictError,
|
|
24
|
+
WorkspaceInstructionPolicyInvalidOperationError,
|
|
25
|
+
WorkspaceInstructionPolicyLegacyUnavailableError,
|
|
26
|
+
WorkspaceInstructionPolicyNotFoundError,
|
|
27
|
+
} from "@opengeni/db";
|
|
28
|
+
import type { Context, Hono } from "hono";
|
|
29
|
+
import { HTTPException } from "hono/http-exception";
|
|
30
|
+
import { z } from "zod";
|
|
31
|
+
|
|
32
|
+
const WorkspaceInstructionPolicyRevisionId = z.string().uuid();
|
|
33
|
+
|
|
34
|
+
async function parseBody<S extends z.ZodType>(context: Context, schema: S): Promise<z.infer<S>> {
|
|
35
|
+
const parsed = schema.safeParse(await context.req.json().catch(() => null));
|
|
36
|
+
if (!parsed.success) {
|
|
37
|
+
throw new HTTPException(422, { message: "Invalid workspace instruction-policy request" });
|
|
38
|
+
}
|
|
39
|
+
return parsed.data;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function policyErrorResponse(context: Context, error: unknown): Response {
|
|
43
|
+
if (error instanceof WorkspaceInstructionPolicyConflictError) {
|
|
44
|
+
return context.json(
|
|
45
|
+
WorkspaceInstructionPolicyConflictResponse.parse({
|
|
46
|
+
code: error.code,
|
|
47
|
+
message: error.message,
|
|
48
|
+
currentHead: error.currentHead,
|
|
49
|
+
}),
|
|
50
|
+
409,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
if (error instanceof WorkspaceInstructionPolicyNotFoundError) {
|
|
54
|
+
return context.json(
|
|
55
|
+
{ code: "WORKSPACE_INSTRUCTION_POLICY_NOT_FOUND", message: error.message },
|
|
56
|
+
404,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
if (error instanceof WorkspaceInstructionPolicyLegacyUnavailableError) {
|
|
60
|
+
return context.json(
|
|
61
|
+
{ code: "WORKSPACE_INSTRUCTION_POLICY_LEGACY_UNAVAILABLE", message: error.message },
|
|
62
|
+
409,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (error instanceof WorkspaceInstructionPolicyInvalidOperationError) {
|
|
66
|
+
return context.json(
|
|
67
|
+
{ code: "INVALID_WORKSPACE_INSTRUCTION_POLICY_OPERATION", message: error.message },
|
|
68
|
+
422,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function assertBoundedActor(subjectId: string): void {
|
|
75
|
+
if (subjectId.trim().length < 1 || subjectId.length > 1_024) {
|
|
76
|
+
throw new HTTPException(400, { message: "Workspace instruction-policy actor is invalid" });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseRevisionId(context: Context): string {
|
|
81
|
+
const parsed = WorkspaceInstructionPolicyRevisionId.safeParse(context.req.param("revisionId"));
|
|
82
|
+
if (!parsed.success) {
|
|
83
|
+
throw new HTTPException(422, { message: "Invalid workspace instruction-policy revision id" });
|
|
84
|
+
}
|
|
85
|
+
return parsed.data;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function registerWorkspaceInstructionPolicyRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
89
|
+
const base = "/v1/workspaces/:workspaceId/instruction-policies";
|
|
90
|
+
|
|
91
|
+
app.get(base, async (context) => {
|
|
92
|
+
const workspaceId = context.req.param("workspaceId");
|
|
93
|
+
await requireAccessGrant(context, deps, workspaceId, "workspace:read");
|
|
94
|
+
const parsed = WorkspaceInstructionPolicyListQuery.safeParse({
|
|
95
|
+
kind: context.req.query("kind"),
|
|
96
|
+
scope: context.req.query("scope"),
|
|
97
|
+
roleKey: context.req.query("roleKey"),
|
|
98
|
+
afterRevision: context.req.query("afterRevision"),
|
|
99
|
+
limit: context.req.query("limit"),
|
|
100
|
+
});
|
|
101
|
+
if (!parsed.success) {
|
|
102
|
+
throw new HTTPException(422, { message: "Invalid workspace instruction-policy query" });
|
|
103
|
+
}
|
|
104
|
+
return context.json(
|
|
105
|
+
WorkspaceInstructionPolicyListResponse.parse(
|
|
106
|
+
await listWorkspaceInstructionPolicyRevisions(deps.db, workspaceId, parsed.data),
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
app.post(`${base}/drafts`, async (context) => {
|
|
112
|
+
const workspaceId = context.req.param("workspaceId");
|
|
113
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
|
|
114
|
+
assertBoundedActor(grant.subjectId);
|
|
115
|
+
const request = await parseBody(context, CreateWorkspaceInstructionPolicyDraftRequest);
|
|
116
|
+
try {
|
|
117
|
+
return context.json(
|
|
118
|
+
WorkspaceInstructionPolicyRevision.parse(
|
|
119
|
+
await createWorkspaceInstructionPolicyDraft(deps.db, {
|
|
120
|
+
accountId: grant.accountId,
|
|
121
|
+
workspaceId,
|
|
122
|
+
createdBySubjectId: grant.subjectId,
|
|
123
|
+
kind: request.kind,
|
|
124
|
+
scope: request.scope,
|
|
125
|
+
roleKey: request.roleKey,
|
|
126
|
+
content: request.content,
|
|
127
|
+
provenanceSource: request.provenanceSource,
|
|
128
|
+
provenanceSourceId: request.provenanceSourceId,
|
|
129
|
+
supersedesRevisionId: request.supersedesRevisionId,
|
|
130
|
+
}),
|
|
131
|
+
),
|
|
132
|
+
201,
|
|
133
|
+
);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
return policyErrorResponse(context, error);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
app.post(`${base}/import-legacy`, async (context) => {
|
|
140
|
+
const workspaceId = context.req.param("workspaceId");
|
|
141
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
|
|
142
|
+
assertBoundedActor(grant.subjectId);
|
|
143
|
+
const request = await parseBody(context, ImportLegacyWorkspaceInstructionPolicyDraftRequest);
|
|
144
|
+
try {
|
|
145
|
+
return context.json(
|
|
146
|
+
WorkspaceInstructionPolicyRevision.parse(
|
|
147
|
+
await importLegacyWorkspaceInstructionPolicyDraft(deps.db, {
|
|
148
|
+
accountId: grant.accountId,
|
|
149
|
+
workspaceId,
|
|
150
|
+
createdBySubjectId: grant.subjectId,
|
|
151
|
+
supersedesRevisionId: request.supersedesRevisionId,
|
|
152
|
+
}),
|
|
153
|
+
),
|
|
154
|
+
201,
|
|
155
|
+
);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
return policyErrorResponse(context, error);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
app.get(`${base}/diff`, async (context) => {
|
|
162
|
+
const workspaceId = context.req.param("workspaceId");
|
|
163
|
+
await requireAccessGrant(context, deps, workspaceId, "workspace:read");
|
|
164
|
+
const parsed = WorkspaceInstructionPolicyDiffRequest.safeParse({
|
|
165
|
+
fromRevisionId: context.req.query("fromRevisionId"),
|
|
166
|
+
toRevisionId: context.req.query("toRevisionId"),
|
|
167
|
+
});
|
|
168
|
+
if (!parsed.success) {
|
|
169
|
+
throw new HTTPException(422, { message: "Invalid workspace instruction-policy diff query" });
|
|
170
|
+
}
|
|
171
|
+
try {
|
|
172
|
+
return context.json(
|
|
173
|
+
WorkspaceInstructionPolicyDiffResponse.parse(
|
|
174
|
+
await diffWorkspaceInstructionPolicyRevisions(deps.db, workspaceId, parsed.data),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
} catch (error) {
|
|
178
|
+
return policyErrorResponse(context, error);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
app.post(`${base}/rollback`, async (context) => {
|
|
183
|
+
const workspaceId = context.req.param("workspaceId");
|
|
184
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
|
|
185
|
+
assertBoundedActor(grant.subjectId);
|
|
186
|
+
const request = await parseBody(context, RollbackWorkspaceInstructionPolicyRequest);
|
|
187
|
+
try {
|
|
188
|
+
return context.json(
|
|
189
|
+
WorkspaceInstructionPolicyActivationResponse.parse(
|
|
190
|
+
await rollbackWorkspaceInstructionPolicyRevision(deps.db, {
|
|
191
|
+
accountId: grant.accountId,
|
|
192
|
+
workspaceId,
|
|
193
|
+
targetRevisionId: request.targetRevisionId,
|
|
194
|
+
expectedCurrentRevisionId: request.expectedCurrentRevisionId,
|
|
195
|
+
actorSubjectId: grant.subjectId,
|
|
196
|
+
reason: request.reason,
|
|
197
|
+
}),
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
} catch (error) {
|
|
201
|
+
return policyErrorResponse(context, error);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
app.get(`${base}/:revisionId`, async (context) => {
|
|
206
|
+
const workspaceId = context.req.param("workspaceId");
|
|
207
|
+
await requireAccessGrant(context, deps, workspaceId, "workspace:read");
|
|
208
|
+
const revisionId = parseRevisionId(context);
|
|
209
|
+
try {
|
|
210
|
+
return context.json(
|
|
211
|
+
WorkspaceInstructionPolicyRevision.parse(
|
|
212
|
+
await getWorkspaceInstructionPolicyRevision(deps.db, workspaceId, revisionId),
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
return policyErrorResponse(context, error);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
app.post(`${base}/:revisionId/activate`, async (context) => {
|
|
221
|
+
const workspaceId = context.req.param("workspaceId");
|
|
222
|
+
const grant = await requireAccessGrant(context, deps, workspaceId, "workspace:admin");
|
|
223
|
+
assertBoundedActor(grant.subjectId);
|
|
224
|
+
const revisionId = parseRevisionId(context);
|
|
225
|
+
const request = await parseBody(context, ActivateWorkspaceInstructionPolicyRequest);
|
|
226
|
+
try {
|
|
227
|
+
return context.json(
|
|
228
|
+
WorkspaceInstructionPolicyActivationResponse.parse(
|
|
229
|
+
await activateWorkspaceInstructionPolicyRevision(deps.db, {
|
|
230
|
+
accountId: grant.accountId,
|
|
231
|
+
workspaceId,
|
|
232
|
+
revisionId,
|
|
233
|
+
expectedCurrentRevisionId: request.expectedCurrentRevisionId,
|
|
234
|
+
actorSubjectId: grant.subjectId,
|
|
235
|
+
reason: request.reason,
|
|
236
|
+
}),
|
|
237
|
+
),
|
|
238
|
+
);
|
|
239
|
+
} catch (error) {
|
|
240
|
+
return policyErrorResponse(context, error);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
}
|