@opengeni/api-router 0.12.2 → 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-3JR6L6NJ.js → chunk-3BHOMOSD.js} +558 -138
- package/dist/chunk-3BHOMOSD.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/app.ts +6 -2
- 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/toolspace.ts +13 -4
- package/src/routes/connections.ts +448 -103
- package/dist/chunk-3JR6L6NJ.js.map +0 -1
|
@@ -27,7 +27,7 @@ import { bodyLimit } from "hono/body-limit";
|
|
|
27
27
|
import { cors } from "hono/cors";
|
|
28
28
|
import { HTTPException as HTTPException26 } from "hono/http-exception";
|
|
29
29
|
import {
|
|
30
|
-
hasPermission as
|
|
30
|
+
hasPermission as hasPermission9,
|
|
31
31
|
requireAccessGrant as requireAccessGrant18,
|
|
32
32
|
requirePermission,
|
|
33
33
|
requireSessionAuthorization as requireSessionAuthorization3,
|
|
@@ -2019,7 +2019,8 @@ import {
|
|
|
2019
2019
|
completeSlackBotPostOperation,
|
|
2020
2020
|
getSession,
|
|
2021
2021
|
recordAuditEvent,
|
|
2022
|
-
releaseSlackBotPostOperationClaim
|
|
2022
|
+
releaseSlackBotPostOperationClaim,
|
|
2023
|
+
setConnectionStatus
|
|
2023
2024
|
} from "@opengeni/db";
|
|
2024
2025
|
import { readResponseJsonBounded } from "@opengeni/network";
|
|
2025
2026
|
import { HTTPException as HTTPException2 } from "hono/http-exception";
|
|
@@ -2031,6 +2032,46 @@ var MAX_HISTORY_PAGE = 100;
|
|
|
2031
2032
|
var MAX_USER_PAGE = 200;
|
|
2032
2033
|
var MAX_PROJECTED_TEXT = 4e3;
|
|
2033
2034
|
var SLACK_POST_CLAIM_LEASE_MS = 3e4;
|
|
2035
|
+
async function exchangeOpenGeniSlackAuthorizationCode(input, fetchImpl = fetch) {
|
|
2036
|
+
const body = new URLSearchParams({
|
|
2037
|
+
code: input.code,
|
|
2038
|
+
client_id: input.clientId,
|
|
2039
|
+
client_secret: input.clientSecret,
|
|
2040
|
+
redirect_uri: input.redirectUri
|
|
2041
|
+
});
|
|
2042
|
+
let response;
|
|
2043
|
+
try {
|
|
2044
|
+
response = await fetchImpl(`${SLACK_API_BASE}oauth.v2.access`, {
|
|
2045
|
+
method: "POST",
|
|
2046
|
+
headers: {
|
|
2047
|
+
accept: "application/json",
|
|
2048
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
2049
|
+
},
|
|
2050
|
+
body: body.toString(),
|
|
2051
|
+
redirect: "error",
|
|
2052
|
+
signal: AbortSignal.timeout(SLACK_TIMEOUT_MS)
|
|
2053
|
+
});
|
|
2054
|
+
} catch {
|
|
2055
|
+
throw new HTTPException2(502, { message: "Slack installation token exchange failed" });
|
|
2056
|
+
}
|
|
2057
|
+
if (!response.ok) {
|
|
2058
|
+
throw new HTTPException2(502, { message: "Slack installation token exchange failed" });
|
|
2059
|
+
}
|
|
2060
|
+
const payload = await readResponseJsonBounded(
|
|
2061
|
+
response,
|
|
2062
|
+
SLACK_RESPONSE_MAX_BYTES,
|
|
2063
|
+
"Slack OAuth response"
|
|
2064
|
+
);
|
|
2065
|
+
const record3 = slackRecord(payload);
|
|
2066
|
+
if (!record3 || record3.ok !== true) {
|
|
2067
|
+
throw new SlackBotProviderError(slackString(record3?.error) || "oauth_exchange_failed");
|
|
2068
|
+
}
|
|
2069
|
+
const accessToken = slackString(record3.access_token);
|
|
2070
|
+
if (!accessToken?.startsWith("xoxb-")) {
|
|
2071
|
+
throw new HTTPException2(502, { message: "Slack installation did not return a bot token" });
|
|
2072
|
+
}
|
|
2073
|
+
return accessToken;
|
|
2074
|
+
}
|
|
2034
2075
|
var SlackBotProviderError = class extends Error {
|
|
2035
2076
|
constructor(code) {
|
|
2036
2077
|
super(`Slack bot request failed: ${safeSlackCode(code)}`);
|
|
@@ -2038,6 +2079,23 @@ var SlackBotProviderError = class extends Error {
|
|
|
2038
2079
|
this.name = "SlackBotProviderError";
|
|
2039
2080
|
}
|
|
2040
2081
|
};
|
|
2082
|
+
var SLACK_CREDENTIAL_REJECTION_CODES = /* @__PURE__ */ new Set([
|
|
2083
|
+
"account_inactive",
|
|
2084
|
+
"invalid_auth",
|
|
2085
|
+
"not_authed",
|
|
2086
|
+
"token_expired",
|
|
2087
|
+
"token_revoked"
|
|
2088
|
+
]);
|
|
2089
|
+
function slackCredentialRejected(error) {
|
|
2090
|
+
return error instanceof SlackBotProviderError && SLACK_CREDENTIAL_REJECTION_CODES.has(error.code);
|
|
2091
|
+
}
|
|
2092
|
+
var SlackBotCredentialVerificationError = class extends HTTPException2 {
|
|
2093
|
+
constructor(failureReason, message) {
|
|
2094
|
+
super(422, { message });
|
|
2095
|
+
this.failureReason = failureReason;
|
|
2096
|
+
this.name = "SlackBotCredentialVerificationError";
|
|
2097
|
+
}
|
|
2098
|
+
};
|
|
2041
2099
|
async function verifyOpenGeniSlackBotCredential(token, fetchImpl = fetch, now = /* @__PURE__ */ new Date()) {
|
|
2042
2100
|
const authResponse = await slackApiFetch(fetchImpl, "auth.test", token, {});
|
|
2043
2101
|
const grantedScopes2 = parseGrantedScopes(authResponse.response.headers.get("x-oauth-scopes"));
|
|
@@ -2052,14 +2110,18 @@ async function verifyOpenGeniSlackBotCredential(token, fetchImpl = fetch, now =
|
|
|
2052
2110
|
});
|
|
2053
2111
|
const user = slackRecord(userResponse.payload.user);
|
|
2054
2112
|
if (!user || user.is_bot !== true || user.deleted === true) {
|
|
2055
|
-
throw new
|
|
2113
|
+
throw new SlackBotCredentialVerificationError(
|
|
2114
|
+
"identity_mismatch",
|
|
2115
|
+
"Slack credential must identify an active bot user"
|
|
2116
|
+
);
|
|
2056
2117
|
}
|
|
2057
2118
|
const profile = slackRecord(user.profile);
|
|
2058
2119
|
const displayName = slackString(profile?.display_name) || slackString(profile?.real_name);
|
|
2059
2120
|
if (displayName !== "OpenGeni") {
|
|
2060
|
-
throw new
|
|
2061
|
-
|
|
2062
|
-
|
|
2121
|
+
throw new SlackBotCredentialVerificationError(
|
|
2122
|
+
"identity_mismatch",
|
|
2123
|
+
'Slack bot display name must be exactly "OpenGeni"'
|
|
2124
|
+
);
|
|
2063
2125
|
}
|
|
2064
2126
|
return {
|
|
2065
2127
|
grantedScopes: grantedScopes2,
|
|
@@ -2270,7 +2332,18 @@ var OpenGeniSlackBotClient = class {
|
|
|
2270
2332
|
return projected;
|
|
2271
2333
|
}
|
|
2272
2334
|
async call(headers, method, params) {
|
|
2273
|
-
|
|
2335
|
+
try {
|
|
2336
|
+
return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload;
|
|
2337
|
+
} catch (error) {
|
|
2338
|
+
if (slackCredentialRejected(error)) {
|
|
2339
|
+
await setConnectionStatus(this.db, this.context.workspaceId, "needs_reauth", error.code, {
|
|
2340
|
+
id: this.connection.id,
|
|
2341
|
+
version: this.connection.version,
|
|
2342
|
+
subjectId: null
|
|
2343
|
+
}).catch(() => false);
|
|
2344
|
+
}
|
|
2345
|
+
throw error;
|
|
2346
|
+
}
|
|
2274
2347
|
}
|
|
2275
2348
|
async withAudit(operation, run) {
|
|
2276
2349
|
try {
|
|
@@ -2426,14 +2499,18 @@ function assertExactOpenGeniSlackBotScopes(grantedScopes2) {
|
|
|
2426
2499
|
...forbidden.length ? [`forbidden: ${forbidden.join(", ")}`] : [],
|
|
2427
2500
|
...unsupported.length ? [`unsupported: ${unsupported.join(", ")}`] : []
|
|
2428
2501
|
];
|
|
2429
|
-
throw new
|
|
2430
|
-
|
|
2431
|
-
|
|
2502
|
+
throw new SlackBotCredentialVerificationError(
|
|
2503
|
+
"scope_mismatch",
|
|
2504
|
+
`Slack bot scopes must exactly match the OpenGeni manifest (${facts.join("; ")})`
|
|
2505
|
+
);
|
|
2432
2506
|
}
|
|
2433
2507
|
}
|
|
2434
2508
|
function parseGrantedScopes(header) {
|
|
2435
2509
|
if (!header) {
|
|
2436
|
-
throw new
|
|
2510
|
+
throw new SlackBotCredentialVerificationError(
|
|
2511
|
+
"scope_mismatch",
|
|
2512
|
+
"Slack did not report granted bot scopes"
|
|
2513
|
+
);
|
|
2437
2514
|
}
|
|
2438
2515
|
return [
|
|
2439
2516
|
...new Set(
|
|
@@ -5290,7 +5367,15 @@ function connectionBrokerFetch(baseFetch, input) {
|
|
|
5290
5367
|
if (!connectionRef) {
|
|
5291
5368
|
return baseFetch;
|
|
5292
5369
|
}
|
|
5293
|
-
const
|
|
5370
|
+
const credentialSubjectId = input.turn.initiator.kind === "subject" ? input.turn.initiator.subjectId : void 0;
|
|
5371
|
+
if (connectionRef.subjectScope === "subject" && !credentialSubjectId) {
|
|
5372
|
+
throw new Error(
|
|
5373
|
+
`subject-owned connection for MCP server ${input.config.id} requires a human turn initiator`
|
|
5374
|
+
);
|
|
5375
|
+
}
|
|
5376
|
+
const hostCredentialPort = input.deps.connectionCredentials?.mcpCredentials;
|
|
5377
|
+
const resolverSubjectId = hostCredentialPort ? input.grant.subjectId : credentialSubjectId;
|
|
5378
|
+
const resolveCredential = hostCredentialPort ? buildHostConnectionTokenResolver(hostCredentialPort, {
|
|
5294
5379
|
accountId: input.grant.accountId,
|
|
5295
5380
|
workspaceId: input.grant.workspaceId,
|
|
5296
5381
|
sessionId: input.sessionId,
|
|
@@ -5312,7 +5397,7 @@ function connectionBrokerFetch(baseFetch, input) {
|
|
|
5312
5397
|
destinationUrl,
|
|
5313
5398
|
forceRefresh: false,
|
|
5314
5399
|
...request.toolName ? { toolName: request.toolName } : {},
|
|
5315
|
-
subjectId:
|
|
5400
|
+
...resolverSubjectId ? { subjectId: resolverSubjectId } : {}
|
|
5316
5401
|
});
|
|
5317
5402
|
if (first.status === "auth_needed") {
|
|
5318
5403
|
return await authNeededFetchResponse(input, request, first);
|
|
@@ -5330,7 +5415,7 @@ function connectionBrokerFetch(baseFetch, input) {
|
|
|
5330
5415
|
destinationUrl,
|
|
5331
5416
|
forceRefresh: true,
|
|
5332
5417
|
...request.toolName ? { toolName: request.toolName } : {},
|
|
5333
|
-
subjectId:
|
|
5418
|
+
...resolverSubjectId ? { subjectId: resolverSubjectId } : {}
|
|
5334
5419
|
});
|
|
5335
5420
|
if (refreshed.status === "auth_needed") {
|
|
5336
5421
|
return await authNeededFetchResponse(input, request, refreshed);
|
|
@@ -5614,7 +5699,7 @@ function isAuthExempt(c, settings) {
|
|
|
5614
5699
|
if (path === "/v1/github/setup" || path === "/v1/github/install/callback" || path === "/v1/github/oauth/callback" || path === "/v1/github/app-manifest/callback") {
|
|
5615
5700
|
return true;
|
|
5616
5701
|
}
|
|
5617
|
-
if (path === "/v1/integrations/oauth/callback" || path === "/v1/integrations/oauth/client-metadata.json") {
|
|
5702
|
+
if (path === "/v1/integrations/oauth/callback" || path === "/v1/integrations/oauth/client-metadata.json" || path === "/v1/integrations/slack/callback") {
|
|
5618
5703
|
return true;
|
|
5619
5704
|
}
|
|
5620
5705
|
if (path.startsWith("/v1/catalog-assets/")) {
|
|
@@ -7032,17 +7117,20 @@ function registerCodexRoutes(app, deps) {
|
|
|
7032
7117
|
}
|
|
7033
7118
|
|
|
7034
7119
|
// src/routes/connections.ts
|
|
7120
|
+
import { createHash as createHash4 } from "crypto";
|
|
7035
7121
|
import {
|
|
7036
|
-
ConnectOpenGeniSlackBotRequest,
|
|
7037
7122
|
ConnectionResponse,
|
|
7038
7123
|
CreateConnectionRequest,
|
|
7039
7124
|
IntegrationClientMetadata,
|
|
7040
7125
|
ListConnectionsResponse,
|
|
7126
|
+
OpenGeniSlackBotInstallRequest,
|
|
7127
|
+
OpenGeniSlackBotInstallStart,
|
|
7041
7128
|
OAuthStartRequest,
|
|
7042
7129
|
OAuthStartResponse as OAuthStartResponse2,
|
|
7043
7130
|
UpdateConnectionRequest
|
|
7044
7131
|
} from "@opengeni/contracts";
|
|
7045
7132
|
import {
|
|
7133
|
+
hasPermission as hasPermission6,
|
|
7046
7134
|
hasReservedOpenGeniSlackBotMetadata,
|
|
7047
7135
|
isOpenGeniSlackBotConnection,
|
|
7048
7136
|
openGeniSlackBotMetadata as openGeniSlackBotMetadata2,
|
|
@@ -7050,13 +7138,19 @@ import {
|
|
|
7050
7138
|
requireEnvironmentEncryption as requireEnvironmentEncryption2
|
|
7051
7139
|
} from "@opengeni/core";
|
|
7052
7140
|
import {
|
|
7141
|
+
consumeIntegrationOAuthStateNonce as consumeIntegrationOAuthStateNonce2,
|
|
7053
7142
|
createConnection as createConnection2,
|
|
7143
|
+
createConnectionWithSlackBotSuccessAudit,
|
|
7054
7144
|
encryptEnvironmentValue as encryptEnvironmentValue3,
|
|
7055
7145
|
getConnectionMetadata as getConnectionMetadata2,
|
|
7146
|
+
getWorkspaceGrant as getWorkspaceGrant2,
|
|
7056
7147
|
listConnectionsMetadata as listConnectionsMetadata2,
|
|
7057
|
-
|
|
7148
|
+
recordSlackBotInstallCallbackFailure,
|
|
7058
7149
|
revokeConnection,
|
|
7059
|
-
|
|
7150
|
+
revokeConnectionWithSlackBotSuccessAudit,
|
|
7151
|
+
SlackBotLifecycleSuccessAuditError,
|
|
7152
|
+
updateConnection as updateConnection2,
|
|
7153
|
+
updateConnectionWithSlackBotSuccessAudit
|
|
7060
7154
|
} from "@opengeni/db";
|
|
7061
7155
|
import { HTTPException as HTTPException9 } from "hono/http-exception";
|
|
7062
7156
|
|
|
@@ -7065,13 +7159,14 @@ import { Client as Client2 } from "@modelcontextprotocol/sdk/client/index.js";
|
|
|
7065
7159
|
import { StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
7066
7160
|
import { parseIntegrationsOauthClientsJson } from "@opengeni/config";
|
|
7067
7161
|
import { OAuthStartResponse } from "@opengeni/contracts";
|
|
7068
|
-
import { requireEnvironmentEncryption } from "@opengeni/core";
|
|
7162
|
+
import { hasPermission as hasPermission5, requireEnvironmentEncryption } from "@opengeni/core";
|
|
7069
7163
|
import {
|
|
7070
7164
|
consumeIntegrationOAuthStateNonce,
|
|
7071
7165
|
createConnection,
|
|
7072
7166
|
decryptEnvironmentValue,
|
|
7073
7167
|
encryptEnvironmentValue as encryptEnvironmentValue2,
|
|
7074
7168
|
getConnectionMetadata,
|
|
7169
|
+
getWorkspaceGrant,
|
|
7075
7170
|
listConnectionsMetadata,
|
|
7076
7171
|
loadIntegrationOAuthClient,
|
|
7077
7172
|
normalizeBearerScheme,
|
|
@@ -7104,6 +7199,8 @@ function canonicalProviderDomain(value) {
|
|
|
7104
7199
|
// src/integrations/oauth-client.ts
|
|
7105
7200
|
import { OAUTH_MAX_RESPONSE_BYTES as OAUTH_MAX_RESPONSE_BYTES2 } from "@opengeni/network";
|
|
7106
7201
|
var oauthStateTtlMs = 10 * 60 * 1e3;
|
|
7202
|
+
var OFFICIAL_SLACK_MCP_URL = "https://mcp.slack.com/mcp";
|
|
7203
|
+
var SLACK_OAUTH_ORIGIN = "https://slack.com";
|
|
7107
7204
|
var OAuthCallbackStageError = class extends Error {
|
|
7108
7205
|
constructor(stage, reason, cause) {
|
|
7109
7206
|
super(errorMessage(cause));
|
|
@@ -7116,9 +7213,10 @@ var OAuthCallbackStageError = class extends Error {
|
|
|
7116
7213
|
async function startMcpOAuth(deps, context) {
|
|
7117
7214
|
const { db, settings } = deps;
|
|
7118
7215
|
const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
|
|
7119
|
-
const
|
|
7120
|
-
|
|
7121
|
-
|
|
7216
|
+
const officialSlackResource = mcpUrl === OFFICIAL_SLACK_MCP_URL;
|
|
7217
|
+
const providerDomain = officialSlackResource ? "slack.com" : canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
|
|
7218
|
+
const personalSlack = officialSlackResource || providerDomain === "slack.com";
|
|
7219
|
+
assertPersonalSlackOAuthStart(settings, context.payload, mcpUrl, personalSlack);
|
|
7122
7220
|
const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
|
|
7123
7221
|
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
|
|
7124
7222
|
const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
|
|
@@ -7133,6 +7231,9 @@ async function startMcpOAuth(deps, context) {
|
|
|
7133
7231
|
throw new HTTPException8(404, { message: "connection not found" });
|
|
7134
7232
|
}
|
|
7135
7233
|
const discovery = await discoverMcpOAuth(mcpUrl, settings);
|
|
7234
|
+
if (personalSlack && !isLocalTestEnvironment(settings.environment)) {
|
|
7235
|
+
assertSlackAuthorizationServer(discovery.as);
|
|
7236
|
+
}
|
|
7136
7237
|
const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
|
|
7137
7238
|
const verifier = randomPkceVerifier();
|
|
7138
7239
|
const authorizeScopes = chooseAuthorizeScopes(
|
|
@@ -7200,6 +7301,7 @@ async function completeMcpOAuthCallback(deps, input) {
|
|
|
7200
7301
|
}
|
|
7201
7302
|
try {
|
|
7202
7303
|
state = readOAuthState(input.state, settings);
|
|
7304
|
+
await requireOAuthCallbackGrant(db, state);
|
|
7203
7305
|
if (!input.code) {
|
|
7204
7306
|
return {
|
|
7205
7307
|
redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" })
|
|
@@ -7258,6 +7360,7 @@ async function completeMcpOAuthCallback(deps, input) {
|
|
|
7258
7360
|
...verification.tools ? { mcpTools: verification.tools } : {}
|
|
7259
7361
|
};
|
|
7260
7362
|
const credentialEncrypted = encryptEnvironmentValue2(key, JSON.stringify(credential));
|
|
7363
|
+
await requireOAuthCallbackGrant(db, state);
|
|
7261
7364
|
const connection = await runCallbackStage(
|
|
7262
7365
|
"persist",
|
|
7263
7366
|
"persist_failed",
|
|
@@ -7266,6 +7369,7 @@ async function completeMcpOAuthCallback(deps, input) {
|
|
|
7266
7369
|
connectionId: state.connectionId,
|
|
7267
7370
|
visibleToSubjectId: state.subjectId,
|
|
7268
7371
|
expectedVersion: state.connectionVersion,
|
|
7372
|
+
subjectId: state.subjectId,
|
|
7269
7373
|
providerDomain: state.providerDomain,
|
|
7270
7374
|
kind: "oauth2",
|
|
7271
7375
|
status: "active",
|
|
@@ -7277,7 +7381,7 @@ async function completeMcpOAuthCallback(deps, input) {
|
|
|
7277
7381
|
}) : createConnection(db, {
|
|
7278
7382
|
accountId: state.accountId,
|
|
7279
7383
|
workspaceId: state.workspaceId,
|
|
7280
|
-
subjectId:
|
|
7384
|
+
subjectId: state.subjectId,
|
|
7281
7385
|
providerDomain: state.providerDomain,
|
|
7282
7386
|
kind: "oauth2",
|
|
7283
7387
|
credentialEncrypted,
|
|
@@ -7317,6 +7421,43 @@ function requireIntegrationsStateSecret(settings) {
|
|
|
7317
7421
|
}
|
|
7318
7422
|
return secret;
|
|
7319
7423
|
}
|
|
7424
|
+
async function requireOAuthCallbackGrant(db, state) {
|
|
7425
|
+
const grant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
|
|
7426
|
+
if (!grant || grant.accountId !== state.accountId || !hasPermission5(grant.permissions, "connections:write")) {
|
|
7427
|
+
throw new HTTPException8(403, {
|
|
7428
|
+
message: "OAuth subject no longer has permission to write this workspace connection"
|
|
7429
|
+
});
|
|
7430
|
+
}
|
|
7431
|
+
}
|
|
7432
|
+
function assertPersonalSlackOAuthStart(settings, payload, mcpUrl, personalSlack) {
|
|
7433
|
+
if (!personalSlack) return;
|
|
7434
|
+
if (payload.oauthClient) {
|
|
7435
|
+
throw new HTTPException8(422, {
|
|
7436
|
+
message: "Slack OAuth client credentials are deployment-managed"
|
|
7437
|
+
});
|
|
7438
|
+
}
|
|
7439
|
+
if (payload.providerDomain && canonicalProviderDomain(payload.providerDomain) !== "slack.com") {
|
|
7440
|
+
throw new HTTPException8(422, { message: "Slack provider identity does not match slack.com" });
|
|
7441
|
+
}
|
|
7442
|
+
if (!isLocalTestEnvironment(settings.environment) && mcpUrl !== OFFICIAL_SLACK_MCP_URL) {
|
|
7443
|
+
throw new HTTPException8(422, {
|
|
7444
|
+
message: `personal Slack OAuth must use ${OFFICIAL_SLACK_MCP_URL}`
|
|
7445
|
+
});
|
|
7446
|
+
}
|
|
7447
|
+
if (!settings.slackClientId?.trim() || !settings.slackClientSecret?.trim()) {
|
|
7448
|
+
throw new HTTPException8(503, {
|
|
7449
|
+
message: "personal Slack OAuth requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET"
|
|
7450
|
+
});
|
|
7451
|
+
}
|
|
7452
|
+
}
|
|
7453
|
+
function assertSlackAuthorizationServer(as) {
|
|
7454
|
+
const urls = [as.issuer, as.authorizationServer, as.authorizationEndpoint, as.tokenEndpoint];
|
|
7455
|
+
if (urls.some((value) => new URL(value).origin !== SLACK_OAUTH_ORIGIN)) {
|
|
7456
|
+
throw new HTTPException8(422, {
|
|
7457
|
+
message: "Slack MCP authorization metadata did not remain bound to slack.com"
|
|
7458
|
+
});
|
|
7459
|
+
}
|
|
7460
|
+
}
|
|
7320
7461
|
async function discoverMcpOAuth(resource, settings) {
|
|
7321
7462
|
const challenge = await probeMcpChallenge(resource, settings);
|
|
7322
7463
|
const prm = await discoverProtectedResourceMetadata(
|
|
@@ -7553,6 +7694,14 @@ function operatorClientForAs(settings, as) {
|
|
|
7553
7694
|
};
|
|
7554
7695
|
}
|
|
7555
7696
|
function operatorClientEntryFor(settings, candidates) {
|
|
7697
|
+
const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
|
|
7698
|
+
if (normalizedCandidates.has(SLACK_OAUTH_ORIGIN) && settings.slackClientId?.trim() && settings.slackClientSecret?.trim()) {
|
|
7699
|
+
return {
|
|
7700
|
+
clientId: settings.slackClientId.trim(),
|
|
7701
|
+
clientSecret: settings.slackClientSecret.trim(),
|
|
7702
|
+
tokenEndpointAuthMethod: "client_secret_post"
|
|
7703
|
+
};
|
|
7704
|
+
}
|
|
7556
7705
|
const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
|
|
7557
7706
|
const exactKeys = uniqueStrings(
|
|
7558
7707
|
candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)])
|
|
@@ -7563,7 +7712,6 @@ function operatorClientEntryFor(settings, candidates) {
|
|
|
7563
7712
|
return entry;
|
|
7564
7713
|
}
|
|
7565
7714
|
}
|
|
7566
|
-
const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
|
|
7567
7715
|
for (const [key, entry] of Object.entries(configured)) {
|
|
7568
7716
|
if (normalizedCandidates.has(normalizedIssuerKey(key))) {
|
|
7569
7717
|
return entry;
|
|
@@ -7624,11 +7772,17 @@ async function dynamicClientRegistration(settings, as, redirectUri, scopes) {
|
|
|
7624
7772
|
}
|
|
7625
7773
|
async function existingOAuthConnectionForStart(db, input) {
|
|
7626
7774
|
if (input.connectionId) {
|
|
7627
|
-
|
|
7775
|
+
const connection = await getConnectionMetadata(
|
|
7776
|
+
db,
|
|
7777
|
+
input.workspaceId,
|
|
7778
|
+
input.connectionId,
|
|
7779
|
+
input.subjectId
|
|
7780
|
+
);
|
|
7781
|
+
return connection?.subjectId === input.subjectId && connection.kind === "oauth2" && connection.providerDomain === input.providerDomain ? connection : null;
|
|
7628
7782
|
}
|
|
7629
7783
|
const visible = await listConnectionsMetadata(db, input.workspaceId, input.subjectId);
|
|
7630
7784
|
return visible.find(
|
|
7631
|
-
(connection) => connection.subjectId ===
|
|
7785
|
+
(connection) => connection.subjectId === input.subjectId && connection.kind === "oauth2" && connection.status === "active" && connection.providerDomain === input.providerDomain
|
|
7632
7786
|
) ?? null;
|
|
7633
7787
|
}
|
|
7634
7788
|
function buildAuthorizationUrl(input) {
|
|
@@ -7695,6 +7849,9 @@ function readOAuthState(state, settings) {
|
|
|
7695
7849
|
};
|
|
7696
7850
|
const connectionId = stringValue(payload.connectionId);
|
|
7697
7851
|
const connectionVersion = numberValue(payload.connectionVersion);
|
|
7852
|
+
if (Boolean(connectionId) !== Boolean(connectionVersion)) {
|
|
7853
|
+
throw new HTTPException8(400, { message: "invalid OAuth reconnect state" });
|
|
7854
|
+
}
|
|
7698
7855
|
return {
|
|
7699
7856
|
...parsed,
|
|
7700
7857
|
...connectionId ? { connectionId } : {},
|
|
@@ -8181,8 +8338,10 @@ function requiredString(value, field) {
|
|
|
8181
8338
|
// src/routes/connections.ts
|
|
8182
8339
|
import {
|
|
8183
8340
|
OPENGENI_SLACK_BOT_CREDENTIAL_LABEL as OPENGENI_SLACK_BOT_CREDENTIAL_LABEL2,
|
|
8184
|
-
OPENGENI_SLACK_BOT_CREDENTIAL_ROLE as OPENGENI_SLACK_BOT_CREDENTIAL_ROLE2
|
|
8341
|
+
OPENGENI_SLACK_BOT_CREDENTIAL_ROLE as OPENGENI_SLACK_BOT_CREDENTIAL_ROLE2,
|
|
8342
|
+
OPENGENI_SLACK_BOT_REQUIRED_SCOPES as OPENGENI_SLACK_BOT_REQUIRED_SCOPES2
|
|
8185
8343
|
} from "@opengeni/contracts";
|
|
8344
|
+
import { createSignedState as createSignedState4, readSignedState as readSignedState3 } from "@opengeni/github";
|
|
8186
8345
|
function registerConnectionRoutes(app, deps) {
|
|
8187
8346
|
const { db, settings, observability } = deps;
|
|
8188
8347
|
function assertIntegrationsEnabled() {
|
|
@@ -8206,11 +8365,13 @@ function registerConnectionRoutes(app, deps) {
|
|
|
8206
8365
|
assertNotReservedSlackBotMetadata(payload.metadata);
|
|
8207
8366
|
const key = requireEnvironmentEncryption2(settings);
|
|
8208
8367
|
const subjectId = writableSubjectId(payload.subjectId, grant.subjectId);
|
|
8368
|
+
const providerDomain = canonicalProviderDomain(payload.providerDomain);
|
|
8369
|
+
assertNotDirectPersonalSlackOAuth(providerDomain, payload.kind);
|
|
8209
8370
|
const connection = await createConnection2(db, {
|
|
8210
8371
|
accountId: grant.accountId,
|
|
8211
8372
|
workspaceId,
|
|
8212
8373
|
subjectId,
|
|
8213
|
-
providerDomain
|
|
8374
|
+
providerDomain,
|
|
8214
8375
|
kind: payload.kind,
|
|
8215
8376
|
credentialEncrypted: encryptCredentialBundle(key, payload.credential),
|
|
8216
8377
|
grantedScopes: payload.grantedScopes,
|
|
@@ -8220,19 +8381,11 @@ function registerConnectionRoutes(app, deps) {
|
|
|
8220
8381
|
});
|
|
8221
8382
|
return c.json(ConnectionResponse.parse({ connection }), 201);
|
|
8222
8383
|
});
|
|
8223
|
-
app.post("/v1/workspaces/:workspaceId/connections/slack-bot", async (c) => {
|
|
8384
|
+
app.post("/v1/workspaces/:workspaceId/connections/slack-bot/install", async (c) => {
|
|
8224
8385
|
const workspaceId = c.req.param("workspaceId");
|
|
8225
8386
|
const grant = await requireAccessGrant3(c, deps, workspaceId, "connections:write");
|
|
8226
|
-
const payload =
|
|
8227
|
-
const
|
|
8228
|
-
payload.token,
|
|
8229
|
-
deps.slackFetch ?? fetch
|
|
8230
|
-
);
|
|
8231
|
-
const key = requireEnvironmentEncryption2(settings);
|
|
8232
|
-
const credentialEncrypted = encryptCredentialBundle(
|
|
8233
|
-
key,
|
|
8234
|
-
slackBotCredentialBundle(payload.token)
|
|
8235
|
-
);
|
|
8387
|
+
const payload = OpenGeniSlackBotInstallRequest.parse(await c.req.json());
|
|
8388
|
+
const slack = requireOpenGeniSlackOAuthSettings(settings);
|
|
8236
8389
|
const existing = payload.connectionId ? await getConnectionMetadata2(db, workspaceId, payload.connectionId, grant.subjectId) : null;
|
|
8237
8390
|
if (payload.connectionId && !existing) {
|
|
8238
8391
|
throw new HTTPException9(404, { message: "connection not found" });
|
|
@@ -8242,69 +8395,119 @@ function registerConnectionRoutes(app, deps) {
|
|
|
8242
8395
|
message: "connectionId is not an OpenGeni Slack bot connection"
|
|
8243
8396
|
});
|
|
8244
8397
|
}
|
|
8245
|
-
const
|
|
8246
|
-
|
|
8247
|
-
|
|
8248
|
-
|
|
8249
|
-
});
|
|
8250
|
-
}
|
|
8251
|
-
if (existingMetadata && (existingMetadata.botId !== verified.metadata.botId || existingMetadata.botUserId !== verified.metadata.botUserId)) {
|
|
8252
|
-
throw new HTTPException9(409, {
|
|
8253
|
-
message: "a different Slack bot requires a new connection and explicit scheduled-task rebinding"
|
|
8254
|
-
});
|
|
8255
|
-
}
|
|
8256
|
-
const verifiedInstallAt = new Date(verified.metadata.verifiedAt);
|
|
8257
|
-
const connection = existing ? await updateConnection2(db, {
|
|
8258
|
-
workspaceId,
|
|
8259
|
-
connectionId: existing.id,
|
|
8260
|
-
visibleToSubjectId: grant.subjectId,
|
|
8261
|
-
expectedVersion: existing.version,
|
|
8262
|
-
subjectId: null,
|
|
8263
|
-
providerDomain: "slack.com",
|
|
8264
|
-
kind: "app_install",
|
|
8265
|
-
status: "active",
|
|
8266
|
-
credentialEncrypted,
|
|
8267
|
-
grantedScopes: verified.grantedScopes,
|
|
8268
|
-
expiresAt: null,
|
|
8269
|
-
verifiedInstallAt,
|
|
8270
|
-
verifiedInstallVersion: existing.version + 1,
|
|
8271
|
-
metadata: verified.metadata,
|
|
8272
|
-
updatedBySubjectId: grant.subjectId
|
|
8273
|
-
}) : await createConnection2(db, {
|
|
8398
|
+
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
|
|
8399
|
+
const redirectUri = `${baseUrl}/v1/integrations/slack/callback`;
|
|
8400
|
+
const returnPath = `/workspaces/${workspaceId}/capabilities`;
|
|
8401
|
+
const state = createSignedState4(requireIntegrationsStateSecret(settings), {
|
|
8274
8402
|
accountId: grant.accountId,
|
|
8275
8403
|
workspaceId,
|
|
8276
|
-
subjectId:
|
|
8277
|
-
|
|
8278
|
-
|
|
8279
|
-
credentialEncrypted,
|
|
8280
|
-
grantedScopes: verified.grantedScopes,
|
|
8281
|
-
expiresAt: null,
|
|
8282
|
-
verifiedInstallAt,
|
|
8283
|
-
verifiedInstallVersion: 1,
|
|
8284
|
-
metadata: verified.metadata,
|
|
8285
|
-
createdBySubjectId: grant.subjectId
|
|
8404
|
+
subjectId: grant.subjectId,
|
|
8405
|
+
returnPath,
|
|
8406
|
+
...existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}
|
|
8286
8407
|
});
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8408
|
+
const authorizationUrl = new URL("https://slack.com/oauth/v2/authorize");
|
|
8409
|
+
authorizationUrl.searchParams.set("client_id", slack.clientId);
|
|
8410
|
+
authorizationUrl.searchParams.set("scope", OPENGENI_SLACK_BOT_REQUIRED_SCOPES2.join(","));
|
|
8411
|
+
authorizationUrl.searchParams.set("redirect_uri", redirectUri);
|
|
8412
|
+
authorizationUrl.searchParams.set("state", state);
|
|
8413
|
+
return c.json(
|
|
8414
|
+
OpenGeniSlackBotInstallStart.parse({
|
|
8415
|
+
authorizationUrl: authorizationUrl.toString(),
|
|
8416
|
+
expiresAt: new Date(Date.now() + oauthStateTtlMs).toISOString()
|
|
8417
|
+
})
|
|
8418
|
+
);
|
|
8419
|
+
});
|
|
8420
|
+
app.get("/v1/integrations/slack/callback", async (c) => {
|
|
8421
|
+
const baseUrl = integrationBaseUrl(settings.publicBaseUrl, c.req.url);
|
|
8422
|
+
let state = null;
|
|
8423
|
+
let stage = "permission_check";
|
|
8424
|
+
try {
|
|
8425
|
+
state = readOpenGeniSlackInstallState(c.req.query("state"), settings);
|
|
8426
|
+
await requireSlackInstallCallbackGrant(db, state);
|
|
8427
|
+
stage = "nonce_consume";
|
|
8428
|
+
const consumed = await consumeIntegrationOAuthStateNonce2(db, {
|
|
8429
|
+
accountId: state.accountId,
|
|
8430
|
+
workspaceId: state.workspaceId,
|
|
8431
|
+
subjectId: state.subjectId,
|
|
8432
|
+
nonce: state.nonce,
|
|
8433
|
+
expiresAt: new Date(state.iat * 1e3 + oauthStateTtlMs),
|
|
8434
|
+
now: /* @__PURE__ */ new Date()
|
|
8290
8435
|
});
|
|
8291
|
-
|
|
8292
|
-
|
|
8293
|
-
|
|
8294
|
-
|
|
8295
|
-
|
|
8296
|
-
|
|
8297
|
-
targetType: "connection",
|
|
8298
|
-
targetId: connection.id,
|
|
8299
|
-
metadata: {
|
|
8300
|
-
credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE2,
|
|
8301
|
-
credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL2,
|
|
8302
|
-
connectionId: connection.id,
|
|
8303
|
-
slackTeamId: verified.metadata.slackTeamId,
|
|
8304
|
-
outcome: "succeeded"
|
|
8436
|
+
if (!consumed) {
|
|
8437
|
+
throw new SlackInstallCallbackError(
|
|
8438
|
+
400,
|
|
8439
|
+
"state_replayed",
|
|
8440
|
+
"Slack installation state has already been used"
|
|
8441
|
+
);
|
|
8305
8442
|
}
|
|
8306
|
-
|
|
8307
|
-
|
|
8443
|
+
if (c.req.query("error")) {
|
|
8444
|
+
stage = "provider_denial";
|
|
8445
|
+
throw new SlackInstallCallbackError(
|
|
8446
|
+
400,
|
|
8447
|
+
"provider_denied",
|
|
8448
|
+
"Slack installation authorization was denied"
|
|
8449
|
+
);
|
|
8450
|
+
}
|
|
8451
|
+
stage = "code_exchange";
|
|
8452
|
+
const code = c.req.query("code");
|
|
8453
|
+
if (!code) {
|
|
8454
|
+
throw new SlackInstallCallbackError(
|
|
8455
|
+
400,
|
|
8456
|
+
"missing_code",
|
|
8457
|
+
"Slack installation callback is missing code"
|
|
8458
|
+
);
|
|
8459
|
+
}
|
|
8460
|
+
const slack = requireOpenGeniSlackOAuthSettings(settings);
|
|
8461
|
+
const redirectUri = `${baseUrl}/v1/integrations/slack/callback`;
|
|
8462
|
+
const token = await exchangeOpenGeniSlackAuthorizationCode(
|
|
8463
|
+
{
|
|
8464
|
+
code,
|
|
8465
|
+
clientId: slack.clientId,
|
|
8466
|
+
clientSecret: slack.clientSecret,
|
|
8467
|
+
redirectUri
|
|
8468
|
+
},
|
|
8469
|
+
deps.slackFetch ?? fetch
|
|
8470
|
+
);
|
|
8471
|
+
stage = "credential_verification";
|
|
8472
|
+
const verified = await verifyOpenGeniSlackBotCredential(token, deps.slackFetch ?? fetch);
|
|
8473
|
+
stage = "permission_recheck";
|
|
8474
|
+
await requireSlackInstallCallbackGrant(db, state);
|
|
8475
|
+
stage = "persistence";
|
|
8476
|
+
const connection = await persistOpenGeniSlackBotConnection({
|
|
8477
|
+
deps,
|
|
8478
|
+
state,
|
|
8479
|
+
token,
|
|
8480
|
+
verified
|
|
8481
|
+
});
|
|
8482
|
+
return c.redirect(
|
|
8483
|
+
slackInstallReturnUrl(baseUrl, state.returnPath, "connected", connection.id),
|
|
8484
|
+
302
|
|
8485
|
+
);
|
|
8486
|
+
} catch (error) {
|
|
8487
|
+
if (state) {
|
|
8488
|
+
const failure = slackInstallCallbackFailure(stage, error);
|
|
8489
|
+
try {
|
|
8490
|
+
await recordSlackBotInstallCallbackFailure(db, {
|
|
8491
|
+
accountId: state.accountId,
|
|
8492
|
+
workspaceId: state.workspaceId,
|
|
8493
|
+
subjectId: state.subjectId,
|
|
8494
|
+
callbackDigest: createHash4("sha256").update(state.nonce).digest("hex"),
|
|
8495
|
+
installMode: state.connectionId ? "reinstall" : "connect",
|
|
8496
|
+
...failure
|
|
8497
|
+
});
|
|
8498
|
+
} catch {
|
|
8499
|
+
return c.redirect(
|
|
8500
|
+
slackInstallReturnUrl(baseUrl, state.returnPath, "error", "installation_failed"),
|
|
8501
|
+
302
|
|
8502
|
+
);
|
|
8503
|
+
}
|
|
8504
|
+
}
|
|
8505
|
+
const reason = slackInstallErrorReason(error);
|
|
8506
|
+
return c.redirect(
|
|
8507
|
+
slackInstallReturnUrl(baseUrl, state?.returnPath ?? "/integrations", "error", reason),
|
|
8508
|
+
302
|
|
8509
|
+
);
|
|
8510
|
+
}
|
|
8308
8511
|
});
|
|
8309
8512
|
app.get("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
8310
8513
|
const workspaceId = c.req.param("workspaceId");
|
|
@@ -8336,6 +8539,12 @@ function registerConnectionRoutes(app, deps) {
|
|
|
8336
8539
|
message: "use the dedicated OpenGeni Slack bot reinstall flow to update this connection"
|
|
8337
8540
|
});
|
|
8338
8541
|
}
|
|
8542
|
+
if (existing) {
|
|
8543
|
+
assertNotDirectPersonalSlackOAuth(
|
|
8544
|
+
canonicalProviderDomain(payload.providerDomain ?? existing.providerDomain),
|
|
8545
|
+
payload.kind ?? existing.kind
|
|
8546
|
+
);
|
|
8547
|
+
}
|
|
8339
8548
|
if (payload.status !== void 0) {
|
|
8340
8549
|
if (payload.status !== "active") {
|
|
8341
8550
|
throw new HTTPException9(400, {
|
|
@@ -8371,33 +8580,24 @@ function registerConnectionRoutes(app, deps) {
|
|
|
8371
8580
|
});
|
|
8372
8581
|
app.delete("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
8373
8582
|
const workspaceId = c.req.param("workspaceId");
|
|
8583
|
+
const connectionId = c.req.param("connectionId");
|
|
8374
8584
|
const grant = await requireAccessGrant3(c, deps, workspaceId, "connections:write");
|
|
8375
|
-
const
|
|
8376
|
-
|
|
8377
|
-
workspaceId,
|
|
8378
|
-
c.req.param("connectionId"),
|
|
8379
|
-
grant.subjectId
|
|
8380
|
-
);
|
|
8381
|
-
if (!connection) {
|
|
8585
|
+
const existing = await getConnectionMetadata2(db, workspaceId, connectionId, grant.subjectId);
|
|
8586
|
+
if (!existing) {
|
|
8382
8587
|
throw new HTTPException9(404, { message: "connection not found" });
|
|
8383
8588
|
}
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
|
|
8389
|
-
|
|
8390
|
-
|
|
8391
|
-
|
|
8392
|
-
|
|
8393
|
-
|
|
8394
|
-
|
|
8395
|
-
|
|
8396
|
-
connectionId: connection.id,
|
|
8397
|
-
slackTeamId: metadata.slackTeamId,
|
|
8398
|
-
outcome: "succeeded"
|
|
8399
|
-
}
|
|
8400
|
-
});
|
|
8589
|
+
const connection = isOpenGeniSlackBotConnection(existing) ? await revokeConnectionWithSlackBotSuccessAudit(db, {
|
|
8590
|
+
accountId: grant.accountId,
|
|
8591
|
+
workspaceId,
|
|
8592
|
+
subjectId: grant.subjectId,
|
|
8593
|
+
connectionId,
|
|
8594
|
+
expectedVersion: existing.version,
|
|
8595
|
+
credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE2,
|
|
8596
|
+
credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL2,
|
|
8597
|
+
slackTeamId: openGeniSlackBotMetadata2(existing.metadata).slackTeamId
|
|
8598
|
+
}) : await revokeConnection(db, workspaceId, connectionId, grant.subjectId);
|
|
8599
|
+
if (!connection) {
|
|
8600
|
+
throw new HTTPException9(409, { message: "connection changed during disconnect; try again" });
|
|
8401
8601
|
}
|
|
8402
8602
|
return c.json(ConnectionResponse.parse({ connection }));
|
|
8403
8603
|
});
|
|
@@ -8451,6 +8651,222 @@ function registerConnectionRoutes(app, deps) {
|
|
|
8451
8651
|
);
|
|
8452
8652
|
});
|
|
8453
8653
|
}
|
|
8654
|
+
async function persistOpenGeniSlackBotConnection(input) {
|
|
8655
|
+
const { db, settings } = input.deps;
|
|
8656
|
+
const key = requireEnvironmentEncryption2(settings);
|
|
8657
|
+
const credentialEncrypted = encryptCredentialBundle(key, slackBotCredentialBundle(input.token));
|
|
8658
|
+
const existing = input.state.connectionId ? await getConnectionMetadata2(
|
|
8659
|
+
db,
|
|
8660
|
+
input.state.workspaceId,
|
|
8661
|
+
input.state.connectionId,
|
|
8662
|
+
input.state.subjectId
|
|
8663
|
+
) : null;
|
|
8664
|
+
if (input.state.connectionId && !existing) {
|
|
8665
|
+
throw new SlackInstallCallbackError(
|
|
8666
|
+
404,
|
|
8667
|
+
"connection_conflict",
|
|
8668
|
+
"connection not found",
|
|
8669
|
+
"principal_validation"
|
|
8670
|
+
);
|
|
8671
|
+
}
|
|
8672
|
+
if (existing && !isOpenGeniSlackBotConnection(existing)) {
|
|
8673
|
+
throw new SlackInstallCallbackError(
|
|
8674
|
+
422,
|
|
8675
|
+
"connection_conflict",
|
|
8676
|
+
"connectionId is not an OpenGeni Slack bot connection",
|
|
8677
|
+
"principal_validation"
|
|
8678
|
+
);
|
|
8679
|
+
}
|
|
8680
|
+
if (existing?.version !== input.state.connectionVersion) {
|
|
8681
|
+
throw new SlackInstallCallbackError(
|
|
8682
|
+
409,
|
|
8683
|
+
"connection_conflict",
|
|
8684
|
+
"Slack bot connection changed during reinstall; start again",
|
|
8685
|
+
"principal_validation"
|
|
8686
|
+
);
|
|
8687
|
+
}
|
|
8688
|
+
const existingMetadata = existing ? openGeniSlackBotMetadata2(existing.metadata) : null;
|
|
8689
|
+
if (existingMetadata && existingMetadata.slackTeamId !== input.verified.metadata.slackTeamId) {
|
|
8690
|
+
throw new SlackInstallCallbackError(
|
|
8691
|
+
409,
|
|
8692
|
+
"principal_mismatch",
|
|
8693
|
+
"a Slack bot connection can only be reinstalled for its original Slack workspace",
|
|
8694
|
+
"principal_validation"
|
|
8695
|
+
);
|
|
8696
|
+
}
|
|
8697
|
+
if (existingMetadata && (existingMetadata.botId !== input.verified.metadata.botId || existingMetadata.botUserId !== input.verified.metadata.botUserId)) {
|
|
8698
|
+
throw new SlackInstallCallbackError(
|
|
8699
|
+
409,
|
|
8700
|
+
"principal_mismatch",
|
|
8701
|
+
"a different Slack bot requires a new connection and explicit scheduled-task rebinding",
|
|
8702
|
+
"principal_validation"
|
|
8703
|
+
);
|
|
8704
|
+
}
|
|
8705
|
+
const verifiedInstallAt = new Date(input.verified.metadata.verifiedAt);
|
|
8706
|
+
const lifecycleAudit = {
|
|
8707
|
+
accountId: input.state.accountId,
|
|
8708
|
+
workspaceId: input.state.workspaceId,
|
|
8709
|
+
subjectId: input.state.subjectId,
|
|
8710
|
+
credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE2,
|
|
8711
|
+
credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL2,
|
|
8712
|
+
slackTeamId: input.verified.metadata.slackTeamId
|
|
8713
|
+
};
|
|
8714
|
+
const connection = existing ? await updateConnectionWithSlackBotSuccessAudit(db, {
|
|
8715
|
+
...lifecycleAudit,
|
|
8716
|
+
connection: {
|
|
8717
|
+
workspaceId: input.state.workspaceId,
|
|
8718
|
+
connectionId: existing.id,
|
|
8719
|
+
visibleToSubjectId: input.state.subjectId,
|
|
8720
|
+
expectedVersion: existing.version,
|
|
8721
|
+
subjectId: null,
|
|
8722
|
+
providerDomain: "slack.com",
|
|
8723
|
+
kind: "app_install",
|
|
8724
|
+
status: "active",
|
|
8725
|
+
credentialEncrypted,
|
|
8726
|
+
grantedScopes: input.verified.grantedScopes,
|
|
8727
|
+
expiresAt: null,
|
|
8728
|
+
verifiedInstallAt,
|
|
8729
|
+
verifiedInstallVersion: existing.version + 1,
|
|
8730
|
+
metadata: input.verified.metadata,
|
|
8731
|
+
updatedBySubjectId: input.state.subjectId
|
|
8732
|
+
}
|
|
8733
|
+
}) : await createConnectionWithSlackBotSuccessAudit(db, {
|
|
8734
|
+
...lifecycleAudit,
|
|
8735
|
+
connection: {
|
|
8736
|
+
accountId: input.state.accountId,
|
|
8737
|
+
workspaceId: input.state.workspaceId,
|
|
8738
|
+
subjectId: null,
|
|
8739
|
+
providerDomain: "slack.com",
|
|
8740
|
+
kind: "app_install",
|
|
8741
|
+
credentialEncrypted,
|
|
8742
|
+
grantedScopes: input.verified.grantedScopes,
|
|
8743
|
+
expiresAt: null,
|
|
8744
|
+
verifiedInstallAt,
|
|
8745
|
+
verifiedInstallVersion: 1,
|
|
8746
|
+
metadata: input.verified.metadata,
|
|
8747
|
+
createdBySubjectId: input.state.subjectId
|
|
8748
|
+
}
|
|
8749
|
+
});
|
|
8750
|
+
if (!connection) {
|
|
8751
|
+
throw new SlackInstallCallbackError(
|
|
8752
|
+
409,
|
|
8753
|
+
"connection_conflict",
|
|
8754
|
+
"Slack bot connection changed during reinstall; start again",
|
|
8755
|
+
"principal_validation"
|
|
8756
|
+
);
|
|
8757
|
+
}
|
|
8758
|
+
return connection;
|
|
8759
|
+
}
|
|
8760
|
+
function requireOpenGeniSlackOAuthSettings(settings) {
|
|
8761
|
+
const clientId = settings.slackClientId?.trim();
|
|
8762
|
+
const clientSecret = settings.slackClientSecret?.trim();
|
|
8763
|
+
if (!clientId || !clientSecret) {
|
|
8764
|
+
throw new HTTPException9(503, {
|
|
8765
|
+
message: "OpenGeni Slack installation requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET"
|
|
8766
|
+
});
|
|
8767
|
+
}
|
|
8768
|
+
return { clientId, clientSecret };
|
|
8769
|
+
}
|
|
8770
|
+
function readOpenGeniSlackInstallState(rawState, settings) {
|
|
8771
|
+
if (!rawState) {
|
|
8772
|
+
throw new HTTPException9(400, { message: "missing Slack installation state" });
|
|
8773
|
+
}
|
|
8774
|
+
const payload = readSignedState3(rawState, requireIntegrationsStateSecret(settings));
|
|
8775
|
+
if (!payload) {
|
|
8776
|
+
throw new HTTPException9(400, { message: "invalid or expired Slack installation state" });
|
|
8777
|
+
}
|
|
8778
|
+
const requiredString2 = (value, label) => {
|
|
8779
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
8780
|
+
throw new HTTPException9(400, { message: `invalid Slack installation ${label}` });
|
|
8781
|
+
}
|
|
8782
|
+
return value;
|
|
8783
|
+
};
|
|
8784
|
+
const nowSeconds = Math.floor(Date.now() / 1e3);
|
|
8785
|
+
if (typeof payload.iat !== "number" || nowSeconds < payload.iat || nowSeconds - payload.iat > oauthStateTtlMs / 1e3) {
|
|
8786
|
+
throw new HTTPException9(400, { message: "invalid or expired Slack installation state" });
|
|
8787
|
+
}
|
|
8788
|
+
const accountId = requiredString2(payload.accountId, "account");
|
|
8789
|
+
const workspaceId = requiredString2(payload.workspaceId, "workspace");
|
|
8790
|
+
const subjectId = requiredString2(payload.subjectId, "subject");
|
|
8791
|
+
const returnPath = requiredString2(payload.returnPath, "return path");
|
|
8792
|
+
if (returnPath !== `/workspaces/${workspaceId}/capabilities`) {
|
|
8793
|
+
throw new HTTPException9(400, { message: "invalid Slack installation return path" });
|
|
8794
|
+
}
|
|
8795
|
+
const connectionId = typeof payload.connectionId === "string" ? payload.connectionId : void 0;
|
|
8796
|
+
const connectionVersion = typeof payload.connectionVersion === "number" && Number.isInteger(payload.connectionVersion) ? payload.connectionVersion : void 0;
|
|
8797
|
+
if (Boolean(connectionId) !== Boolean(connectionVersion)) {
|
|
8798
|
+
throw new HTTPException9(400, { message: "invalid Slack reinstall state" });
|
|
8799
|
+
}
|
|
8800
|
+
return {
|
|
8801
|
+
accountId,
|
|
8802
|
+
workspaceId,
|
|
8803
|
+
subjectId,
|
|
8804
|
+
returnPath,
|
|
8805
|
+
...connectionId ? { connectionId, connectionVersion } : {},
|
|
8806
|
+
nonce: requiredString2(payload.nonce, "nonce"),
|
|
8807
|
+
iat: typeof payload.iat === "number" ? payload.iat : (() => {
|
|
8808
|
+
throw new HTTPException9(400, { message: "invalid Slack installation timestamp" });
|
|
8809
|
+
})()
|
|
8810
|
+
};
|
|
8811
|
+
}
|
|
8812
|
+
function slackInstallReturnUrl(baseUrl, returnPath, status, detail) {
|
|
8813
|
+
const url = new URL(returnPath, `${baseUrl}/`);
|
|
8814
|
+
url.searchParams.set("slack", status);
|
|
8815
|
+
url.searchParams.set(status === "connected" ? "connectionId" : "reason", detail.slice(0, 128));
|
|
8816
|
+
return url.toString();
|
|
8817
|
+
}
|
|
8818
|
+
var SlackInstallCallbackError = class extends HTTPException9 {
|
|
8819
|
+
constructor(status, failureReason, message, failureStage) {
|
|
8820
|
+
super(status, { message });
|
|
8821
|
+
this.failureReason = failureReason;
|
|
8822
|
+
this.failureStage = failureStage;
|
|
8823
|
+
this.name = "SlackInstallCallbackError";
|
|
8824
|
+
}
|
|
8825
|
+
};
|
|
8826
|
+
function slackInstallCallbackFailure(stage, error) {
|
|
8827
|
+
if (error instanceof SlackInstallCallbackError) {
|
|
8828
|
+
return { stage: error.failureStage ?? stage, reason: error.failureReason };
|
|
8829
|
+
}
|
|
8830
|
+
if (error instanceof SlackBotCredentialVerificationError) {
|
|
8831
|
+
return { stage: "credential_verification", reason: error.failureReason };
|
|
8832
|
+
}
|
|
8833
|
+
if (error instanceof SlackBotLifecycleSuccessAuditError) {
|
|
8834
|
+
return { stage: "persistence", reason: "success_audit_failed" };
|
|
8835
|
+
}
|
|
8836
|
+
if (stage === "code_exchange") {
|
|
8837
|
+
return { stage, reason: "exchange_failed" };
|
|
8838
|
+
}
|
|
8839
|
+
if (stage === "credential_verification") {
|
|
8840
|
+
return { stage, reason: "credential_verification_failed" };
|
|
8841
|
+
}
|
|
8842
|
+
return { stage, reason: "persistence_failed" };
|
|
8843
|
+
}
|
|
8844
|
+
function slackInstallErrorReason(error) {
|
|
8845
|
+
if (error instanceof SlackInstallCallbackError && error.failureReason === "provider_denied") {
|
|
8846
|
+
return "provider_denied";
|
|
8847
|
+
}
|
|
8848
|
+
if (error instanceof HTTPException9) {
|
|
8849
|
+
return `http_${error.status}`;
|
|
8850
|
+
}
|
|
8851
|
+
return "installation_failed";
|
|
8852
|
+
}
|
|
8853
|
+
async function requireSlackInstallCallbackGrant(db, state) {
|
|
8854
|
+
const grant = await getWorkspaceGrant2(db, state.subjectId, state.workspaceId);
|
|
8855
|
+
if (!grant || grant.accountId !== state.accountId || !hasPermission6(grant.permissions, "connections:write")) {
|
|
8856
|
+
throw new SlackInstallCallbackError(
|
|
8857
|
+
403,
|
|
8858
|
+
"permission_lost",
|
|
8859
|
+
"Slack installation subject no longer has permission for this workspace"
|
|
8860
|
+
);
|
|
8861
|
+
}
|
|
8862
|
+
}
|
|
8863
|
+
function assertNotDirectPersonalSlackOAuth(providerDomain, kind) {
|
|
8864
|
+
if (providerDomain === "slack.com" && kind === "oauth2") {
|
|
8865
|
+
throw new HTTPException9(422, {
|
|
8866
|
+
message: "personal Slack credentials must use the hosted MCP OAuth flow"
|
|
8867
|
+
});
|
|
8868
|
+
}
|
|
8869
|
+
}
|
|
8454
8870
|
function assertNotReservedSlackBotMetadata(metadata) {
|
|
8455
8871
|
if (hasReservedOpenGeniSlackBotMetadata(metadata)) {
|
|
8456
8872
|
throw new HTTPException9(422, {
|
|
@@ -11236,7 +11652,7 @@ import {
|
|
|
11236
11652
|
authorizeGitHubInstallationBinding,
|
|
11237
11653
|
buildGitHubAppManifest,
|
|
11238
11654
|
convertGitHubAppManifest,
|
|
11239
|
-
createSignedState as
|
|
11655
|
+
createSignedState as createSignedState5,
|
|
11240
11656
|
envLinesFromGitHubManifestConversion,
|
|
11241
11657
|
GitHubAppApiError,
|
|
11242
11658
|
GitHubAppConfigurationError as GitHubAppConfigurationError2,
|
|
@@ -11245,13 +11661,13 @@ import {
|
|
|
11245
11661
|
githubOAuthAuthorizeUrl,
|
|
11246
11662
|
organizationAppManifestUrl,
|
|
11247
11663
|
personalAppManifestUrl,
|
|
11248
|
-
readSignedState as
|
|
11664
|
+
readSignedState as readSignedState4,
|
|
11249
11665
|
stateMaxAgeSeconds,
|
|
11250
11666
|
verifySignedState
|
|
11251
11667
|
} from "@opengeni/github";
|
|
11252
11668
|
import { deleteCookie, setCookie } from "hono/cookie";
|
|
11253
11669
|
import { HTTPException as HTTPException17 } from "hono/http-exception";
|
|
11254
|
-
import { hasPermission as
|
|
11670
|
+
import { hasPermission as hasPermission7, requireAccessGrant as requireAccessGrant10 } from "@opengeni/core";
|
|
11255
11671
|
var githubStateCookie = "opengeni_github_state";
|
|
11256
11672
|
var githubBindingStateMaxAgeSeconds = 10 * 60;
|
|
11257
11673
|
var legacyInstallationChooserDisabledMessage = "The legacy repository-admin GitHub installation chooser is disabled; use the GitHub owner-consent connect flow";
|
|
@@ -11264,8 +11680,8 @@ function registerGitHubRoutes(app, deps) {
|
|
|
11264
11680
|
const slug = settings.githubAppSlug?.trim() || null;
|
|
11265
11681
|
const installations = missing.length === 0 ? await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId) : [];
|
|
11266
11682
|
const status = githubBindingStatus(missing.length === 0, installations);
|
|
11267
|
-
const canManage =
|
|
11268
|
-
const connectState = missing.length === 0 && slug && canManage ?
|
|
11683
|
+
const canManage = hasPermission7(grant.permissions, "github:manage");
|
|
11684
|
+
const connectState = missing.length === 0 && slug && canManage ? createSignedState5(githubStateSecret, {
|
|
11269
11685
|
accountId: grant.accountId,
|
|
11270
11686
|
workspaceId: grant.workspaceId,
|
|
11271
11687
|
intent: "installation_authority",
|
|
@@ -11290,7 +11706,7 @@ function registerGitHubRoutes(app, deps) {
|
|
|
11290
11706
|
if (!state) {
|
|
11291
11707
|
throw new HTTPException17(400, { message: "missing GitHub installation state" });
|
|
11292
11708
|
}
|
|
11293
|
-
const statePayload =
|
|
11709
|
+
const statePayload = readSignedState4(state, githubStateSecret);
|
|
11294
11710
|
if (!statePayload || statePayload.intent !== "installation_authority" || statePayload.workspaceId !== workspaceId || typeof statePayload.accountId !== "string" || !isFreshGitHubBindingState(statePayload)) {
|
|
11295
11711
|
throw new HTTPException17(400, { message: "invalid or expired GitHub installation state" });
|
|
11296
11712
|
}
|
|
@@ -11365,7 +11781,7 @@ function registerGitHubRoutes(app, deps) {
|
|
|
11365
11781
|
/\/+$/,
|
|
11366
11782
|
""
|
|
11367
11783
|
);
|
|
11368
|
-
const state =
|
|
11784
|
+
const state = createSignedState5(githubStateSecret, {
|
|
11369
11785
|
accountId: grant.accountId,
|
|
11370
11786
|
workspaceId: grant.workspaceId
|
|
11371
11787
|
});
|
|
@@ -11409,7 +11825,7 @@ function registerGitHubRoutes(app, deps) {
|
|
|
11409
11825
|
if (!state) {
|
|
11410
11826
|
throw new HTTPException17(400, { message: "missing GitHub installation state" });
|
|
11411
11827
|
}
|
|
11412
|
-
const statePayload =
|
|
11828
|
+
const statePayload = readSignedState4(state, githubStateSecret);
|
|
11413
11829
|
if (!statePayload || statePayload.intent !== "installation_authority" || typeof statePayload.accountId !== "string" || typeof statePayload.workspaceId !== "string" || !isFreshGitHubBindingState(statePayload)) {
|
|
11414
11830
|
throw new HTTPException17(400, { message: "invalid or expired GitHub installation state" });
|
|
11415
11831
|
}
|
|
@@ -11440,7 +11856,7 @@ function registerGitHubRoutes(app, deps) {
|
|
|
11440
11856
|
})
|
|
11441
11857
|
});
|
|
11442
11858
|
}
|
|
11443
|
-
const oauthState =
|
|
11859
|
+
const oauthState = createSignedState5(githubStateSecret, {
|
|
11444
11860
|
accountId: grant.accountId,
|
|
11445
11861
|
workspaceId: grant.workspaceId,
|
|
11446
11862
|
installationId,
|
|
@@ -11467,7 +11883,7 @@ function registerGitHubRoutes(app, deps) {
|
|
|
11467
11883
|
if (!state) {
|
|
11468
11884
|
throw new HTTPException17(400, { message: "missing GitHub OAuth state" });
|
|
11469
11885
|
}
|
|
11470
|
-
const statePayload =
|
|
11886
|
+
const statePayload = readSignedState4(state, githubStateSecret);
|
|
11471
11887
|
if (!statePayload || statePayload.intent !== "installation_authority_oauth" || typeof statePayload.accountId !== "string" || typeof statePayload.workspaceId !== "string" || !isFreshGitHubBindingState(statePayload)) {
|
|
11472
11888
|
throw new HTTPException17(400, { message: "invalid or expired GitHub OAuth state" });
|
|
11473
11889
|
}
|
|
@@ -11546,7 +11962,7 @@ function registerGitHubRoutes(app, deps) {
|
|
|
11546
11962
|
if (!state) {
|
|
11547
11963
|
throw new HTTPException17(400, { message: "missing GitHub OAuth state" });
|
|
11548
11964
|
}
|
|
11549
|
-
const statePayload =
|
|
11965
|
+
const statePayload = readSignedState4(state, githubStateSecret);
|
|
11550
11966
|
if (!statePayload || typeof statePayload.accountId !== "string" || statePayload.accountId.length === 0 || statePayload.workspaceId !== workspaceId) {
|
|
11551
11967
|
throw new HTTPException17(400, { message: "invalid or expired GitHub OAuth state" });
|
|
11552
11968
|
}
|
|
@@ -15572,7 +15988,7 @@ import {
|
|
|
15572
15988
|
} from "@opengeni/db";
|
|
15573
15989
|
import { boundWorkspaceControlHttpPage } from "@opengeni/events";
|
|
15574
15990
|
import { HTTPException as HTTPException24 } from "hono/http-exception";
|
|
15575
|
-
import { hasPermission as
|
|
15991
|
+
import { hasPermission as hasPermission8, requireAccessContext as requireAccessContext2, requireAccessGrant as requireAccessGrant16 } from "@opengeni/core";
|
|
15576
15992
|
import { requireLimit as requireLimit7 } from "@opengeni/core";
|
|
15577
15993
|
import {
|
|
15578
15994
|
assertWorkspaceDeletable,
|
|
@@ -15819,7 +16235,7 @@ function registerWorkspaceRoutes(app, deps) {
|
|
|
15819
16235
|
const context = await requireAccessContext2(c, deps);
|
|
15820
16236
|
const readableWorkspaceIds = [
|
|
15821
16237
|
...new Set(
|
|
15822
|
-
context.workspaceGrants.filter((grant) =>
|
|
16238
|
+
context.workspaceGrants.filter((grant) => hasPermission8(grant.permissions, "workspace:read")).map((grant) => grant.workspaceId)
|
|
15823
16239
|
)
|
|
15824
16240
|
];
|
|
15825
16241
|
if (readableWorkspaceIds.length > 0) {
|
|
@@ -16683,7 +17099,7 @@ function createApp(deps) {
|
|
|
16683
17099
|
}
|
|
16684
17100
|
async function requireMcpAccessGrant(c, deps, workspaceId) {
|
|
16685
17101
|
const grant = await requireAccessGrant18(c, deps, workspaceId);
|
|
16686
|
-
if (
|
|
17102
|
+
if (hasPermission9(grant.permissions, "workspace:read")) {
|
|
16687
17103
|
return grant;
|
|
16688
17104
|
}
|
|
16689
17105
|
if (isToolspaceGrant(deps.settings, grant)) {
|
|
@@ -17121,8 +17537,8 @@ var routeLabelPatterns = [
|
|
|
17121
17537
|
label: "/v1/workspaces/:workspaceId/connections/oauth/start"
|
|
17122
17538
|
},
|
|
17123
17539
|
{
|
|
17124
|
-
pattern: /^\/v1\/workspaces\/[^/]+\/connections\/slack-bot$/,
|
|
17125
|
-
label: "/v1/workspaces/:workspaceId/connections/slack-bot"
|
|
17540
|
+
pattern: /^\/v1\/workspaces\/[^/]+\/connections\/slack-bot\/install$/,
|
|
17541
|
+
label: "/v1/workspaces/:workspaceId/connections/slack-bot/install"
|
|
17126
17542
|
},
|
|
17127
17543
|
{
|
|
17128
17544
|
pattern: /^\/v1\/workspaces\/[^/]+\/connections\/[^/]+$/,
|
|
@@ -17137,6 +17553,10 @@ var routeLabelPatterns = [
|
|
|
17137
17553
|
pattern: /^\/v1\/integrations\/oauth\/client-metadata\.json$/,
|
|
17138
17554
|
label: "/v1/integrations/oauth/client-metadata.json"
|
|
17139
17555
|
},
|
|
17556
|
+
{
|
|
17557
|
+
pattern: /^\/v1\/integrations\/slack\/callback$/,
|
|
17558
|
+
label: "/v1/integrations/slack/callback"
|
|
17559
|
+
},
|
|
17140
17560
|
{
|
|
17141
17561
|
pattern: /^\/v1\/enrollments\/device\/start$/,
|
|
17142
17562
|
label: "/v1/enrollments/device/start"
|
|
@@ -17221,4 +17641,4 @@ export {
|
|
|
17221
17641
|
withDefaultEnabledCapabilityMcpTools,
|
|
17222
17642
|
workflowIdForSession2 as workflowIdForSession
|
|
17223
17643
|
};
|
|
17224
|
-
//# sourceMappingURL=chunk-
|
|
17644
|
+
//# sourceMappingURL=chunk-3BHOMOSD.js.map
|