@opengeni/api-router 0.12.1 → 0.12.5
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-S2N4252E.js → chunk-3BHOMOSD.js} +631 -169
- package/dist/chunk-3BHOMOSD.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/app.ts +13 -5
- package/src/http/auth.ts +2 -1
- package/src/integrations/oauth-client.ts +94 -8
- package/src/integrations/slack-bot.ts +102 -9
- package/src/mcp/server.ts +20 -0
- package/src/mcp/toolspace.ts +57 -7
- package/src/routes/connections.ts +448 -103
- package/dist/chunk-S2N4252E.js.map +0 -1
|
@@ -1,14 +1,17 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import {
|
|
2
|
-
ConnectOpenGeniSlackBotRequest,
|
|
3
3
|
ConnectionResponse,
|
|
4
4
|
CreateConnectionRequest,
|
|
5
5
|
IntegrationClientMetadata,
|
|
6
6
|
ListConnectionsResponse,
|
|
7
|
+
OpenGeniSlackBotInstallRequest,
|
|
8
|
+
OpenGeniSlackBotInstallStart,
|
|
7
9
|
OAuthStartRequest,
|
|
8
10
|
OAuthStartResponse,
|
|
9
11
|
UpdateConnectionRequest,
|
|
10
12
|
} from "@opengeni/contracts";
|
|
11
13
|
import {
|
|
14
|
+
hasPermission,
|
|
12
15
|
hasReservedOpenGeniSlackBotMetadata,
|
|
13
16
|
isOpenGeniSlackBotConnection,
|
|
14
17
|
openGeniSlackBotMetadata,
|
|
@@ -16,13 +19,21 @@ import {
|
|
|
16
19
|
requireEnvironmentEncryption,
|
|
17
20
|
} from "@opengeni/core";
|
|
18
21
|
import {
|
|
22
|
+
consumeIntegrationOAuthStateNonce,
|
|
19
23
|
createConnection,
|
|
24
|
+
createConnectionWithSlackBotSuccessAudit,
|
|
20
25
|
encryptEnvironmentValue,
|
|
21
26
|
getConnectionMetadata,
|
|
27
|
+
getWorkspaceGrant,
|
|
22
28
|
listConnectionsMetadata,
|
|
23
|
-
|
|
29
|
+
recordSlackBotInstallCallbackFailure,
|
|
24
30
|
revokeConnection,
|
|
31
|
+
revokeConnectionWithSlackBotSuccessAudit,
|
|
32
|
+
SlackBotLifecycleSuccessAuditError,
|
|
25
33
|
updateConnection,
|
|
34
|
+
updateConnectionWithSlackBotSuccessAudit,
|
|
35
|
+
type SlackBotInstallCallbackFailureReason,
|
|
36
|
+
type SlackBotInstallCallbackFailureStage,
|
|
26
37
|
} from "@opengeni/db";
|
|
27
38
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
28
39
|
import type { Hono } from "hono";
|
|
@@ -33,11 +44,29 @@ import {
|
|
|
33
44
|
startMcpOAuth,
|
|
34
45
|
} from "../integrations/oauth-client";
|
|
35
46
|
import { canonicalProviderDomain } from "../integrations/provider-domain";
|
|
36
|
-
import {
|
|
47
|
+
import {
|
|
48
|
+
exchangeOpenGeniSlackAuthorizationCode,
|
|
49
|
+
SlackBotCredentialVerificationError,
|
|
50
|
+
verifyOpenGeniSlackBotCredential,
|
|
51
|
+
} from "../integrations/slack-bot";
|
|
37
52
|
import {
|
|
38
53
|
OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
|
|
39
54
|
OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
55
|
+
OPENGENI_SLACK_BOT_REQUIRED_SCOPES,
|
|
40
56
|
} from "@opengeni/contracts";
|
|
57
|
+
import { createSignedState, readSignedState } from "@opengeni/github";
|
|
58
|
+
import { oauthStateTtlMs, requireIntegrationsStateSecret } from "../integrations/oauth-client";
|
|
59
|
+
|
|
60
|
+
type OpenGeniSlackInstallState = {
|
|
61
|
+
accountId: string;
|
|
62
|
+
workspaceId: string;
|
|
63
|
+
subjectId: string;
|
|
64
|
+
returnPath: string;
|
|
65
|
+
connectionId?: string;
|
|
66
|
+
connectionVersion?: number;
|
|
67
|
+
nonce: string;
|
|
68
|
+
iat: number;
|
|
69
|
+
};
|
|
41
70
|
|
|
42
71
|
export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
43
72
|
const { db, settings, observability } = deps;
|
|
@@ -65,11 +94,13 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
65
94
|
assertNotReservedSlackBotMetadata(payload.metadata);
|
|
66
95
|
const key = requireEnvironmentEncryption(settings);
|
|
67
96
|
const subjectId = writableSubjectId(payload.subjectId, grant.subjectId);
|
|
97
|
+
const providerDomain = canonicalProviderDomain(payload.providerDomain);
|
|
98
|
+
assertNotDirectPersonalSlackOAuth(providerDomain, payload.kind);
|
|
68
99
|
const connection = await createConnection(db, {
|
|
69
100
|
accountId: grant.accountId,
|
|
70
101
|
workspaceId,
|
|
71
102
|
subjectId,
|
|
72
|
-
providerDomain
|
|
103
|
+
providerDomain,
|
|
73
104
|
kind: payload.kind,
|
|
74
105
|
credentialEncrypted: encryptCredentialBundle(key, payload.credential),
|
|
75
106
|
grantedScopes: payload.grantedScopes,
|
|
@@ -80,19 +111,11 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
80
111
|
return c.json(ConnectionResponse.parse({ connection }), 201);
|
|
81
112
|
});
|
|
82
113
|
|
|
83
|
-
app.post("/v1/workspaces/:workspaceId/connections/slack-bot", async (c) => {
|
|
114
|
+
app.post("/v1/workspaces/:workspaceId/connections/slack-bot/install", async (c) => {
|
|
84
115
|
const workspaceId = c.req.param("workspaceId");
|
|
85
116
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
86
|
-
const payload =
|
|
87
|
-
const
|
|
88
|
-
payload.token,
|
|
89
|
-
deps.slackFetch ?? fetch,
|
|
90
|
-
);
|
|
91
|
-
const key = requireEnvironmentEncryption(settings);
|
|
92
|
-
const credentialEncrypted = encryptCredentialBundle(
|
|
93
|
-
key,
|
|
94
|
-
slackBotCredentialBundle(payload.token),
|
|
95
|
-
);
|
|
117
|
+
const payload = OpenGeniSlackBotInstallRequest.parse(await c.req.json());
|
|
118
|
+
const slack = requireOpenGeniSlackOAuthSettings(settings);
|
|
96
119
|
const existing = payload.connectionId
|
|
97
120
|
? await getConnectionMetadata(db, workspaceId, payload.connectionId, grant.subjectId)
|
|
98
121
|
: null;
|
|
@@ -104,76 +127,120 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
104
127
|
message: "connectionId is not an OpenGeni Slack bot connection",
|
|
105
128
|
});
|
|
106
129
|
}
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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, {
|
|
130
|
+
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
|
|
131
|
+
const redirectUri = `${baseUrl}/v1/integrations/slack/callback`;
|
|
132
|
+
const returnPath = `/workspaces/${workspaceId}/capabilities`;
|
|
133
|
+
const state = createSignedState(requireIntegrationsStateSecret(settings), {
|
|
162
134
|
accountId: grant.accountId,
|
|
163
135
|
workspaceId,
|
|
164
136
|
subjectId: grant.subjectId,
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
},
|
|
137
|
+
returnPath,
|
|
138
|
+
...(existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}),
|
|
175
139
|
});
|
|
176
|
-
|
|
140
|
+
const authorizationUrl = new URL("https://slack.com/oauth/v2/authorize");
|
|
141
|
+
authorizationUrl.searchParams.set("client_id", slack.clientId);
|
|
142
|
+
authorizationUrl.searchParams.set("scope", OPENGENI_SLACK_BOT_REQUIRED_SCOPES.join(","));
|
|
143
|
+
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
|
|
144
|
+
authorizationUrl.searchParams.set("state", state);
|
|
145
|
+
return c.json(
|
|
146
|
+
OpenGeniSlackBotInstallStart.parse({
|
|
147
|
+
authorizationUrl: authorizationUrl.toString(),
|
|
148
|
+
expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString(),
|
|
149
|
+
}),
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
app.get("/v1/integrations/slack/callback", async (c) => {
|
|
154
|
+
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
|
|
155
|
+
let state: OpenGeniSlackInstallState | null = null;
|
|
156
|
+
let stage: SlackBotInstallCallbackFailureStage = "permission_check";
|
|
157
|
+
try {
|
|
158
|
+
state = readOpenGeniSlackInstallState(c.req.query("state"), settings);
|
|
159
|
+
await requireSlackInstallCallbackGrant(db, state);
|
|
160
|
+
stage = "nonce_consume";
|
|
161
|
+
const consumed = await consumeIntegrationOAuthStateNonce(db, {
|
|
162
|
+
accountId: state.accountId,
|
|
163
|
+
workspaceId: state.workspaceId,
|
|
164
|
+
subjectId: state.subjectId,
|
|
165
|
+
nonce: state.nonce,
|
|
166
|
+
expiresAt: new Date(state.iat * 1000 + oauthStateTtlMs),
|
|
167
|
+
now: new Date(),
|
|
168
|
+
});
|
|
169
|
+
if (!consumed) {
|
|
170
|
+
throw new SlackInstallCallbackError(
|
|
171
|
+
400,
|
|
172
|
+
"state_replayed",
|
|
173
|
+
"Slack installation state has already been used",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (c.req.query("error")) {
|
|
177
|
+
stage = "provider_denial";
|
|
178
|
+
throw new SlackInstallCallbackError(
|
|
179
|
+
400,
|
|
180
|
+
"provider_denied",
|
|
181
|
+
"Slack installation authorization was denied",
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
stage = "code_exchange";
|
|
185
|
+
const code = c.req.query("code");
|
|
186
|
+
if (!code) {
|
|
187
|
+
throw new SlackInstallCallbackError(
|
|
188
|
+
400,
|
|
189
|
+
"missing_code",
|
|
190
|
+
"Slack installation callback is missing code",
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const slack = requireOpenGeniSlackOAuthSettings(settings);
|
|
194
|
+
const redirectUri = `${baseUrl}/v1/integrations/slack/callback`;
|
|
195
|
+
const token = await exchangeOpenGeniSlackAuthorizationCode(
|
|
196
|
+
{
|
|
197
|
+
code,
|
|
198
|
+
clientId: slack.clientId,
|
|
199
|
+
clientSecret: slack.clientSecret,
|
|
200
|
+
redirectUri,
|
|
201
|
+
},
|
|
202
|
+
deps.slackFetch ?? fetch,
|
|
203
|
+
);
|
|
204
|
+
stage = "credential_verification";
|
|
205
|
+
const verified = await verifyOpenGeniSlackBotCredential(token, deps.slackFetch ?? fetch);
|
|
206
|
+
stage = "permission_recheck";
|
|
207
|
+
await requireSlackInstallCallbackGrant(db, state);
|
|
208
|
+
stage = "persistence";
|
|
209
|
+
const connection = await persistOpenGeniSlackBotConnection({
|
|
210
|
+
deps,
|
|
211
|
+
state,
|
|
212
|
+
token,
|
|
213
|
+
verified,
|
|
214
|
+
});
|
|
215
|
+
return c.redirect(
|
|
216
|
+
slackInstallReturnUrl(baseUrl, state.returnPath, "connected", connection.id),
|
|
217
|
+
302,
|
|
218
|
+
);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (state) {
|
|
221
|
+
const failure = slackInstallCallbackFailure(stage, error);
|
|
222
|
+
try {
|
|
223
|
+
await recordSlackBotInstallCallbackFailure(db, {
|
|
224
|
+
accountId: state.accountId,
|
|
225
|
+
workspaceId: state.workspaceId,
|
|
226
|
+
subjectId: state.subjectId,
|
|
227
|
+
callbackDigest: createHash("sha256").update(state.nonce).digest("hex"),
|
|
228
|
+
installMode: state.connectionId ? "reinstall" : "connect",
|
|
229
|
+
...failure,
|
|
230
|
+
});
|
|
231
|
+
} catch {
|
|
232
|
+
return c.redirect(
|
|
233
|
+
slackInstallReturnUrl(baseUrl, state.returnPath, "error", "installation_failed"),
|
|
234
|
+
302,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const reason = slackInstallErrorReason(error);
|
|
239
|
+
return c.redirect(
|
|
240
|
+
slackInstallReturnUrl(baseUrl, state?.returnPath ?? "/integrations", "error", reason),
|
|
241
|
+
302,
|
|
242
|
+
);
|
|
243
|
+
}
|
|
177
244
|
});
|
|
178
245
|
|
|
179
246
|
app.get("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
@@ -207,6 +274,12 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
207
274
|
message: "use the dedicated OpenGeni Slack bot reinstall flow to update this connection",
|
|
208
275
|
});
|
|
209
276
|
}
|
|
277
|
+
if (existing) {
|
|
278
|
+
assertNotDirectPersonalSlackOAuth(
|
|
279
|
+
canonicalProviderDomain(payload.providerDomain ?? existing.providerDomain),
|
|
280
|
+
payload.kind ?? existing.kind,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
210
283
|
// Status is not a free-form field: revocation goes through DELETE, and the
|
|
211
284
|
// broker owns needs_reauth/error. Reactivating a connection is only
|
|
212
285
|
// meaningful together with a fresh credential bundle — otherwise a PATCH
|
|
@@ -256,33 +329,26 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
256
329
|
|
|
257
330
|
app.delete("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
258
331
|
const workspaceId = c.req.param("workspaceId");
|
|
332
|
+
const connectionId = c.req.param("connectionId");
|
|
259
333
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
260
|
-
const
|
|
261
|
-
|
|
262
|
-
workspaceId,
|
|
263
|
-
c.req.param("connectionId"),
|
|
264
|
-
grant.subjectId,
|
|
265
|
-
);
|
|
266
|
-
if (!connection) {
|
|
334
|
+
const existing = await getConnectionMetadata(db, workspaceId, connectionId, grant.subjectId);
|
|
335
|
+
if (!existing) {
|
|
267
336
|
throw new HTTPException(404, { message: "connection not found" });
|
|
268
337
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
targetType: "connection",
|
|
277
|
-
targetId: connection.id,
|
|
278
|
-
metadata: {
|
|
338
|
+
const connection = isOpenGeniSlackBotConnection(existing)
|
|
339
|
+
? await revokeConnectionWithSlackBotSuccessAudit(db, {
|
|
340
|
+
accountId: grant.accountId,
|
|
341
|
+
workspaceId,
|
|
342
|
+
subjectId: grant.subjectId,
|
|
343
|
+
connectionId,
|
|
344
|
+
expectedVersion: existing.version,
|
|
279
345
|
credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
280
346
|
credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
});
|
|
347
|
+
slackTeamId: openGeniSlackBotMetadata(existing.metadata)!.slackTeamId,
|
|
348
|
+
})
|
|
349
|
+
: await revokeConnection(db, workspaceId, connectionId, grant.subjectId);
|
|
350
|
+
if (!connection) {
|
|
351
|
+
throw new HTTPException(409, { message: "connection changed during disconnect; try again" });
|
|
286
352
|
}
|
|
287
353
|
return c.json(ConnectionResponse.parse({ connection }));
|
|
288
354
|
});
|
|
@@ -340,6 +406,285 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
340
406
|
});
|
|
341
407
|
}
|
|
342
408
|
|
|
409
|
+
async function persistOpenGeniSlackBotConnection(input: {
|
|
410
|
+
deps: ApiRouteDeps;
|
|
411
|
+
state: OpenGeniSlackInstallState;
|
|
412
|
+
token: string;
|
|
413
|
+
verified: Awaited<ReturnType<typeof verifyOpenGeniSlackBotCredential>>;
|
|
414
|
+
}) {
|
|
415
|
+
const { db, settings } = input.deps;
|
|
416
|
+
const key = requireEnvironmentEncryption(settings);
|
|
417
|
+
const credentialEncrypted = encryptCredentialBundle(key, slackBotCredentialBundle(input.token));
|
|
418
|
+
const existing = input.state.connectionId
|
|
419
|
+
? await getConnectionMetadata(
|
|
420
|
+
db,
|
|
421
|
+
input.state.workspaceId,
|
|
422
|
+
input.state.connectionId,
|
|
423
|
+
input.state.subjectId,
|
|
424
|
+
)
|
|
425
|
+
: null;
|
|
426
|
+
if (input.state.connectionId && !existing) {
|
|
427
|
+
throw new SlackInstallCallbackError(
|
|
428
|
+
404,
|
|
429
|
+
"connection_conflict",
|
|
430
|
+
"connection not found",
|
|
431
|
+
"principal_validation",
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
if (existing && !isOpenGeniSlackBotConnection(existing)) {
|
|
435
|
+
throw new SlackInstallCallbackError(
|
|
436
|
+
422,
|
|
437
|
+
"connection_conflict",
|
|
438
|
+
"connectionId is not an OpenGeni Slack bot connection",
|
|
439
|
+
"principal_validation",
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
if (existing?.version !== input.state.connectionVersion) {
|
|
443
|
+
throw new SlackInstallCallbackError(
|
|
444
|
+
409,
|
|
445
|
+
"connection_conflict",
|
|
446
|
+
"Slack bot connection changed during reinstall; start again",
|
|
447
|
+
"principal_validation",
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
const existingMetadata = existing ? openGeniSlackBotMetadata(existing.metadata) : null;
|
|
451
|
+
if (existingMetadata && existingMetadata.slackTeamId !== input.verified.metadata.slackTeamId) {
|
|
452
|
+
throw new SlackInstallCallbackError(
|
|
453
|
+
409,
|
|
454
|
+
"principal_mismatch",
|
|
455
|
+
"a Slack bot connection can only be reinstalled for its original Slack workspace",
|
|
456
|
+
"principal_validation",
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
if (
|
|
460
|
+
existingMetadata &&
|
|
461
|
+
(existingMetadata.botId !== input.verified.metadata.botId ||
|
|
462
|
+
existingMetadata.botUserId !== input.verified.metadata.botUserId)
|
|
463
|
+
) {
|
|
464
|
+
throw new SlackInstallCallbackError(
|
|
465
|
+
409,
|
|
466
|
+
"principal_mismatch",
|
|
467
|
+
"a different Slack bot requires a new connection and explicit scheduled-task rebinding",
|
|
468
|
+
"principal_validation",
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
const verifiedInstallAt = new Date(input.verified.metadata.verifiedAt);
|
|
472
|
+
const lifecycleAudit = {
|
|
473
|
+
accountId: input.state.accountId,
|
|
474
|
+
workspaceId: input.state.workspaceId,
|
|
475
|
+
subjectId: input.state.subjectId,
|
|
476
|
+
credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
477
|
+
credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
|
|
478
|
+
slackTeamId: input.verified.metadata.slackTeamId,
|
|
479
|
+
};
|
|
480
|
+
const connection = existing
|
|
481
|
+
? await updateConnectionWithSlackBotSuccessAudit(db, {
|
|
482
|
+
...lifecycleAudit,
|
|
483
|
+
connection: {
|
|
484
|
+
workspaceId: input.state.workspaceId,
|
|
485
|
+
connectionId: existing.id,
|
|
486
|
+
visibleToSubjectId: input.state.subjectId,
|
|
487
|
+
expectedVersion: existing.version,
|
|
488
|
+
subjectId: null,
|
|
489
|
+
providerDomain: "slack.com",
|
|
490
|
+
kind: "app_install",
|
|
491
|
+
status: "active",
|
|
492
|
+
credentialEncrypted,
|
|
493
|
+
grantedScopes: input.verified.grantedScopes,
|
|
494
|
+
expiresAt: null,
|
|
495
|
+
verifiedInstallAt,
|
|
496
|
+
verifiedInstallVersion: existing.version + 1,
|
|
497
|
+
metadata: input.verified.metadata,
|
|
498
|
+
updatedBySubjectId: input.state.subjectId,
|
|
499
|
+
},
|
|
500
|
+
})
|
|
501
|
+
: await createConnectionWithSlackBotSuccessAudit(db, {
|
|
502
|
+
...lifecycleAudit,
|
|
503
|
+
connection: {
|
|
504
|
+
accountId: input.state.accountId,
|
|
505
|
+
workspaceId: input.state.workspaceId,
|
|
506
|
+
subjectId: null,
|
|
507
|
+
providerDomain: "slack.com",
|
|
508
|
+
kind: "app_install",
|
|
509
|
+
credentialEncrypted,
|
|
510
|
+
grantedScopes: input.verified.grantedScopes,
|
|
511
|
+
expiresAt: null,
|
|
512
|
+
verifiedInstallAt,
|
|
513
|
+
verifiedInstallVersion: 1,
|
|
514
|
+
metadata: input.verified.metadata,
|
|
515
|
+
createdBySubjectId: input.state.subjectId,
|
|
516
|
+
},
|
|
517
|
+
});
|
|
518
|
+
if (!connection) {
|
|
519
|
+
throw new SlackInstallCallbackError(
|
|
520
|
+
409,
|
|
521
|
+
"connection_conflict",
|
|
522
|
+
"Slack bot connection changed during reinstall; start again",
|
|
523
|
+
"principal_validation",
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
return connection;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function requireOpenGeniSlackOAuthSettings(settings: ApiRouteDeps["settings"]): {
|
|
530
|
+
clientId: string;
|
|
531
|
+
clientSecret: string;
|
|
532
|
+
} {
|
|
533
|
+
const clientId = settings.slackClientId?.trim();
|
|
534
|
+
const clientSecret = settings.slackClientSecret?.trim();
|
|
535
|
+
if (!clientId || !clientSecret) {
|
|
536
|
+
throw new HTTPException(503, {
|
|
537
|
+
message:
|
|
538
|
+
"OpenGeni Slack installation requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
return { clientId, clientSecret };
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
function readOpenGeniSlackInstallState(
|
|
545
|
+
rawState: string | undefined,
|
|
546
|
+
settings: ApiRouteDeps["settings"],
|
|
547
|
+
): OpenGeniSlackInstallState {
|
|
548
|
+
if (!rawState) {
|
|
549
|
+
throw new HTTPException(400, { message: "missing Slack installation state" });
|
|
550
|
+
}
|
|
551
|
+
const payload = readSignedState(rawState, requireIntegrationsStateSecret(settings)) as Record<
|
|
552
|
+
string,
|
|
553
|
+
unknown
|
|
554
|
+
> | null;
|
|
555
|
+
if (!payload) {
|
|
556
|
+
throw new HTTPException(400, { message: "invalid or expired Slack installation state" });
|
|
557
|
+
}
|
|
558
|
+
const requiredString = (value: unknown, label: string): string => {
|
|
559
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
560
|
+
throw new HTTPException(400, { message: `invalid Slack installation ${label}` });
|
|
561
|
+
}
|
|
562
|
+
return value;
|
|
563
|
+
};
|
|
564
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
565
|
+
if (
|
|
566
|
+
typeof payload.iat !== "number" ||
|
|
567
|
+
nowSeconds < payload.iat ||
|
|
568
|
+
nowSeconds - payload.iat > oauthStateTtlMs / 1000
|
|
569
|
+
) {
|
|
570
|
+
throw new HTTPException(400, { message: "invalid or expired Slack installation state" });
|
|
571
|
+
}
|
|
572
|
+
const accountId = requiredString(payload.accountId, "account");
|
|
573
|
+
const workspaceId = requiredString(payload.workspaceId, "workspace");
|
|
574
|
+
const subjectId = requiredString(payload.subjectId, "subject");
|
|
575
|
+
const returnPath = requiredString(payload.returnPath, "return path");
|
|
576
|
+
if (returnPath !== `/workspaces/${workspaceId}/capabilities`) {
|
|
577
|
+
throw new HTTPException(400, { message: "invalid Slack installation return path" });
|
|
578
|
+
}
|
|
579
|
+
const connectionId = typeof payload.connectionId === "string" ? payload.connectionId : undefined;
|
|
580
|
+
const connectionVersion =
|
|
581
|
+
typeof payload.connectionVersion === "number" && Number.isInteger(payload.connectionVersion)
|
|
582
|
+
? payload.connectionVersion
|
|
583
|
+
: undefined;
|
|
584
|
+
if (Boolean(connectionId) !== Boolean(connectionVersion)) {
|
|
585
|
+
throw new HTTPException(400, { message: "invalid Slack reinstall state" });
|
|
586
|
+
}
|
|
587
|
+
return {
|
|
588
|
+
accountId,
|
|
589
|
+
workspaceId,
|
|
590
|
+
subjectId,
|
|
591
|
+
returnPath,
|
|
592
|
+
...(connectionId ? { connectionId, connectionVersion: connectionVersion! } : {}),
|
|
593
|
+
nonce: requiredString(payload.nonce, "nonce"),
|
|
594
|
+
iat:
|
|
595
|
+
typeof payload.iat === "number"
|
|
596
|
+
? payload.iat
|
|
597
|
+
: (() => {
|
|
598
|
+
throw new HTTPException(400, { message: "invalid Slack installation timestamp" });
|
|
599
|
+
})(),
|
|
600
|
+
};
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function slackInstallReturnUrl(
|
|
604
|
+
baseUrl: string,
|
|
605
|
+
returnPath: string,
|
|
606
|
+
status: "connected" | "error",
|
|
607
|
+
detail: string,
|
|
608
|
+
): string {
|
|
609
|
+
const url = new URL(returnPath, `${baseUrl}/`);
|
|
610
|
+
url.searchParams.set("slack", status);
|
|
611
|
+
url.searchParams.set(status === "connected" ? "connectionId" : "reason", detail.slice(0, 128));
|
|
612
|
+
return url.toString();
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
class SlackInstallCallbackError extends HTTPException {
|
|
616
|
+
constructor(
|
|
617
|
+
status: 400 | 403 | 404 | 409 | 422,
|
|
618
|
+
readonly failureReason: SlackBotInstallCallbackFailureReason,
|
|
619
|
+
message: string,
|
|
620
|
+
readonly failureStage?: SlackBotInstallCallbackFailureStage,
|
|
621
|
+
) {
|
|
622
|
+
super(status, { message });
|
|
623
|
+
this.name = "SlackInstallCallbackError";
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function slackInstallCallbackFailure(
|
|
628
|
+
stage: SlackBotInstallCallbackFailureStage,
|
|
629
|
+
error: unknown,
|
|
630
|
+
): {
|
|
631
|
+
stage: SlackBotInstallCallbackFailureStage;
|
|
632
|
+
reason: SlackBotInstallCallbackFailureReason;
|
|
633
|
+
} {
|
|
634
|
+
if (error instanceof SlackInstallCallbackError) {
|
|
635
|
+
return { stage: error.failureStage ?? stage, reason: error.failureReason };
|
|
636
|
+
}
|
|
637
|
+
if (error instanceof SlackBotCredentialVerificationError) {
|
|
638
|
+
return { stage: "credential_verification", reason: error.failureReason };
|
|
639
|
+
}
|
|
640
|
+
if (error instanceof SlackBotLifecycleSuccessAuditError) {
|
|
641
|
+
return { stage: "persistence", reason: "success_audit_failed" };
|
|
642
|
+
}
|
|
643
|
+
if (stage === "code_exchange") {
|
|
644
|
+
return { stage, reason: "exchange_failed" };
|
|
645
|
+
}
|
|
646
|
+
if (stage === "credential_verification") {
|
|
647
|
+
return { stage, reason: "credential_verification_failed" };
|
|
648
|
+
}
|
|
649
|
+
return { stage, reason: "persistence_failed" };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function slackInstallErrorReason(error: unknown): string {
|
|
653
|
+
if (error instanceof SlackInstallCallbackError && error.failureReason === "provider_denied") {
|
|
654
|
+
return "provider_denied";
|
|
655
|
+
}
|
|
656
|
+
if (error instanceof HTTPException) {
|
|
657
|
+
return `http_${error.status}`;
|
|
658
|
+
}
|
|
659
|
+
return "installation_failed";
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
async function requireSlackInstallCallbackGrant(
|
|
663
|
+
db: ApiRouteDeps["db"],
|
|
664
|
+
state: OpenGeniSlackInstallState,
|
|
665
|
+
): Promise<void> {
|
|
666
|
+
const grant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
|
|
667
|
+
if (
|
|
668
|
+
!grant ||
|
|
669
|
+
grant.accountId !== state.accountId ||
|
|
670
|
+
!hasPermission(grant.permissions, "connections:write")
|
|
671
|
+
) {
|
|
672
|
+
throw new SlackInstallCallbackError(
|
|
673
|
+
403,
|
|
674
|
+
"permission_lost",
|
|
675
|
+
"Slack installation subject no longer has permission for this workspace",
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function assertNotDirectPersonalSlackOAuth(providerDomain: string, kind: string): void {
|
|
681
|
+
if (providerDomain === "slack.com" && kind === "oauth2") {
|
|
682
|
+
throw new HTTPException(422, {
|
|
683
|
+
message: "personal Slack credentials must use the hosted MCP OAuth flow",
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
343
688
|
function assertNotReservedSlackBotMetadata(metadata: Record<string, unknown> | undefined): void {
|
|
344
689
|
if (hasReservedOpenGeniSlackBotMetadata(metadata)) {
|
|
345
690
|
throw new HTTPException(422, {
|