@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.
@@ -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 hasPermission7,
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 HTTPException2(422, { message: "Slack credential must identify an active bot user" });
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 HTTPException2(422, {
2061
- message: 'Slack bot display name must be exactly "OpenGeni"'
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
- return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload;
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 HTTPException2(422, {
2430
- message: `Slack bot scopes must exactly match the OpenGeni manifest (${facts.join("; ")})`
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 HTTPException2(422, { message: "Slack did not report granted bot scopes" });
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 resolveCredential = input.deps.connectionCredentials?.mcpCredentials ? buildHostConnectionTokenResolver(input.deps.connectionCredentials.mcpCredentials, {
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: input.grant.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: input.grant.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
- recordAuditEvent as recordAuditEvent2,
7148
+ recordSlackBotInstallCallbackFailure,
7058
7149
  revokeConnection,
7059
- updateConnection as updateConnection2
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 providerDomain = canonicalProviderDomain(
7120
- context.payload.providerDomain ?? new URL(mcpUrl).hostname
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: null,
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
- return await getConnectionMetadata(db, input.workspaceId, input.connectionId, input.subjectId);
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 === null && connection.kind === "oauth2" && connection.status === "active" && connection.providerDomain === input.providerDomain
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: canonicalProviderDomain(payload.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 = ConnectOpenGeniSlackBotRequest.parse(await c.req.json());
8227
- const verified = await verifyOpenGeniSlackBotCredential(
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 existingMetadata = existing ? openGeniSlackBotMetadata2(existing.metadata) : null;
8246
- if (existingMetadata && existingMetadata.slackTeamId !== verified.metadata.slackTeamId) {
8247
- throw new HTTPException9(409, {
8248
- message: "a Slack bot connection can only be reinstalled for its original Slack workspace"
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: null,
8277
- providerDomain: "slack.com",
8278
- kind: "app_install",
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
- if (!connection) {
8288
- throw new HTTPException9(409, {
8289
- message: "Slack bot connection changed during reinstall; retry with the current connection"
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
- await recordAuditEvent2(db, {
8293
- accountId: grant.accountId,
8294
- workspaceId,
8295
- subjectId: grant.subjectId,
8296
- action: existing ? "slack_bot.reinstalled" : "slack_bot.connected",
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
- return c.json(ConnectionResponse.parse({ connection }), existing ? 200 : 201);
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 connection = await revokeConnection(
8376
- db,
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
- if (isOpenGeniSlackBotConnection(connection)) {
8385
- const metadata = openGeniSlackBotMetadata2(connection.metadata);
8386
- await recordAuditEvent2(db, {
8387
- accountId: grant.accountId,
8388
- workspaceId,
8389
- subjectId: grant.subjectId,
8390
- action: "slack_bot.disconnected",
8391
- targetType: "connection",
8392
- targetId: connection.id,
8393
- metadata: {
8394
- credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE2,
8395
- credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL2,
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 createSignedState4,
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 readSignedState3,
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 hasPermission5, requireAccessGrant as requireAccessGrant10 } from "@opengeni/core";
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 = hasPermission5(grant.permissions, "github:manage");
11268
- const connectState = missing.length === 0 && slug && canManage ? createSignedState4(githubStateSecret, {
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 = readSignedState3(state, githubStateSecret);
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 = createSignedState4(githubStateSecret, {
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 = readSignedState3(state, githubStateSecret);
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 = createSignedState4(githubStateSecret, {
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 = readSignedState3(state, githubStateSecret);
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 = readSignedState3(state, githubStateSecret);
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 hasPermission6, requireAccessContext as requireAccessContext2, requireAccessGrant as requireAccessGrant16 } from "@opengeni/core";
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) => hasPermission6(grant.permissions, "workspace:read")).map((grant) => grant.workspaceId)
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 (hasPermission7(grant.permissions, "workspace:read")) {
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-3JR6L6NJ.js.map
17644
+ //# sourceMappingURL=chunk-3BHOMOSD.js.map