@opengeni/api-router 0.12.1 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,8 @@ import {
3
3
  canonicalizeConfiguredModelId as canonicalizeConfiguredModelId2,
4
4
  configuredAllowedModels,
5
5
  configuredAllowedReasoningEfforts,
6
- configuredModels as configuredModels2
6
+ configuredModels as configuredModels2,
7
+ withCodexCatalogProvider as withCodexCatalogProvider2
7
8
  } from "@opengeni/config";
8
9
  import {
9
10
  ClientConfig,
@@ -26,7 +27,7 @@ import { bodyLimit } from "hono/body-limit";
26
27
  import { cors } from "hono/cors";
27
28
  import { HTTPException as HTTPException26 } from "hono/http-exception";
28
29
  import {
29
- hasPermission as hasPermission7,
30
+ hasPermission as hasPermission9,
30
31
  requireAccessGrant as requireAccessGrant18,
31
32
  requirePermission,
32
33
  requireSessionAuthorization as requireSessionAuthorization3,
@@ -2018,7 +2019,8 @@ import {
2018
2019
  completeSlackBotPostOperation,
2019
2020
  getSession,
2020
2021
  recordAuditEvent,
2021
- releaseSlackBotPostOperationClaim
2022
+ releaseSlackBotPostOperationClaim,
2023
+ setConnectionStatus
2022
2024
  } from "@opengeni/db";
2023
2025
  import { readResponseJsonBounded } from "@opengeni/network";
2024
2026
  import { HTTPException as HTTPException2 } from "hono/http-exception";
@@ -2030,6 +2032,46 @@ var MAX_HISTORY_PAGE = 100;
2030
2032
  var MAX_USER_PAGE = 200;
2031
2033
  var MAX_PROJECTED_TEXT = 4e3;
2032
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
+ }
2033
2075
  var SlackBotProviderError = class extends Error {
2034
2076
  constructor(code) {
2035
2077
  super(`Slack bot request failed: ${safeSlackCode(code)}`);
@@ -2037,6 +2079,23 @@ var SlackBotProviderError = class extends Error {
2037
2079
  this.name = "SlackBotProviderError";
2038
2080
  }
2039
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
+ };
2040
2099
  async function verifyOpenGeniSlackBotCredential(token, fetchImpl = fetch, now = /* @__PURE__ */ new Date()) {
2041
2100
  const authResponse = await slackApiFetch(fetchImpl, "auth.test", token, {});
2042
2101
  const grantedScopes2 = parseGrantedScopes(authResponse.response.headers.get("x-oauth-scopes"));
@@ -2051,14 +2110,18 @@ async function verifyOpenGeniSlackBotCredential(token, fetchImpl = fetch, now =
2051
2110
  });
2052
2111
  const user = slackRecord(userResponse.payload.user);
2053
2112
  if (!user || user.is_bot !== true || user.deleted === true) {
2054
- 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
+ );
2055
2117
  }
2056
2118
  const profile = slackRecord(user.profile);
2057
2119
  const displayName = slackString(profile?.display_name) || slackString(profile?.real_name);
2058
2120
  if (displayName !== "OpenGeni") {
2059
- throw new HTTPException2(422, {
2060
- message: 'Slack bot display name must be exactly "OpenGeni"'
2061
- });
2121
+ throw new SlackBotCredentialVerificationError(
2122
+ "identity_mismatch",
2123
+ 'Slack bot display name must be exactly "OpenGeni"'
2124
+ );
2062
2125
  }
2063
2126
  return {
2064
2127
  grantedScopes: grantedScopes2,
@@ -2269,7 +2332,18 @@ var OpenGeniSlackBotClient = class {
2269
2332
  return projected;
2270
2333
  }
2271
2334
  async call(headers, method, params) {
2272
- 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
+ }
2273
2347
  }
2274
2348
  async withAudit(operation, run) {
2275
2349
  try {
@@ -2425,14 +2499,18 @@ function assertExactOpenGeniSlackBotScopes(grantedScopes2) {
2425
2499
  ...forbidden.length ? [`forbidden: ${forbidden.join(", ")}`] : [],
2426
2500
  ...unsupported.length ? [`unsupported: ${unsupported.join(", ")}`] : []
2427
2501
  ];
2428
- throw new HTTPException2(422, {
2429
- message: `Slack bot scopes must exactly match the OpenGeni manifest (${facts.join("; ")})`
2430
- });
2502
+ throw new SlackBotCredentialVerificationError(
2503
+ "scope_mismatch",
2504
+ `Slack bot scopes must exactly match the OpenGeni manifest (${facts.join("; ")})`
2505
+ );
2431
2506
  }
2432
2507
  }
2433
2508
  function parseGrantedScopes(header) {
2434
2509
  if (!header) {
2435
- 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
+ );
2436
2514
  }
2437
2515
  return [
2438
2516
  ...new Set(
@@ -3049,6 +3127,19 @@ function registerToolspaceProxyTools(server, surface) {
3049
3127
  if (!surface) {
3050
3128
  return;
3051
3129
  }
3130
+ if (surface.tools.length === 0) {
3131
+ server.registerTool(
3132
+ "__opengeni_empty_toolspace_surface__",
3133
+ {
3134
+ description: "Internal disabled placeholder for an empty Toolspace surface.",
3135
+ inputSchema: z4.object({})
3136
+ },
3137
+ async () => ({
3138
+ content: [{ type: "text", text: '{"unavailable":true}' }]
3139
+ })
3140
+ ).disable();
3141
+ return;
3142
+ }
3052
3143
  for (const tool of surface.tools) {
3053
3144
  server.registerTool(
3054
3145
  tool.name,
@@ -4887,13 +4978,25 @@ async function resolveToolListing(input) {
4887
4978
  sessionId,
4888
4979
  rootSessionId,
4889
4980
  turn: activeTurn
4890
- }).catch(() => null);
4981
+ }).catch((error) => {
4982
+ deps.observability?.warn("toolspace upstream connection failed", {
4983
+ serverId,
4984
+ ...toolspaceErrorAttributes(error)
4985
+ });
4986
+ return null;
4987
+ });
4891
4988
  if (!connection) {
4892
4989
  aggregateBudget.replace(serverId, []);
4893
4990
  return;
4894
4991
  }
4895
4992
  try {
4896
- const listed = await connection.client.listTools(void 0, toolspaceRequestOptions(config)).catch(() => ({ tools: [] }));
4993
+ const listed = await connection.client.listTools(void 0, toolspaceRequestOptions(config)).catch((error) => {
4994
+ deps.observability?.warn("toolspace upstream tool list failed", {
4995
+ serverId,
4996
+ ...toolspaceErrorAttributes(error)
4997
+ });
4998
+ return { tools: [] };
4999
+ });
4897
5000
  let boundedTools;
4898
5001
  try {
4899
5002
  boundedTools = assertMcpToolListWithinBounds(listed.tools);
@@ -4980,7 +5083,11 @@ async function settingsWithSessionMcpServersForToolspace(deps, workspaceId, sess
4980
5083
  };
4981
5084
  }
4982
5085
  async function connectToolspaceServer(input) {
4983
- const guardedFetch = guardedMcpFetch(input.deps.settings, undiciFetch);
5086
+ const useBunNativeFetch = !!process.versions.bun;
5087
+ const mcpFetchImpl = useBunNativeFetch ? globalThis.fetch.bind(globalThis) : undiciFetch;
5088
+ const guardedFetch = guardedMcpFetch(input.deps.settings, mcpFetchImpl, {
5089
+ ...useBunNativeFetch ? { pinResolvedDestination: false } : {}
5090
+ });
4984
5091
  const baseFetch = input.config.connectionRef ? connectionBrokerFetch(guardedFetch, input) : guardedFetch;
4985
5092
  const client = new Client(
4986
5093
  { name: `opengeni-toolspace-${input.config.id}`, version: "1.0.0" },
@@ -5186,6 +5293,17 @@ function toolspaceAuditSummary(value) {
5186
5293
  sha256: createHash2("sha256").update(serialized).digest("hex")
5187
5294
  };
5188
5295
  }
5296
+ function toolspaceErrorAttributes(error) {
5297
+ if (!(error instanceof Error)) {
5298
+ return { errorClass: typeof error };
5299
+ }
5300
+ const code = error.code;
5301
+ return {
5302
+ errorClass: error.name,
5303
+ ...typeof code === "string" ? { errorCode: code } : {},
5304
+ ...error.message ? { errorMessage: error.message.replaceAll(/[\r\n]+/gu, " ").slice(0, 512) } : {}
5305
+ };
5306
+ }
5189
5307
  async function reserveExactAttemptCall(deps, grant, authority) {
5190
5308
  const reservation = await reserveToolspaceCallForAttempt(deps.db, {
5191
5309
  accountId: grant.accountId,
@@ -5249,7 +5367,15 @@ function connectionBrokerFetch(baseFetch, input) {
5249
5367
  if (!connectionRef) {
5250
5368
  return baseFetch;
5251
5369
  }
5252
- 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, {
5253
5379
  accountId: input.grant.accountId,
5254
5380
  workspaceId: input.grant.workspaceId,
5255
5381
  sessionId: input.sessionId,
@@ -5271,7 +5397,7 @@ function connectionBrokerFetch(baseFetch, input) {
5271
5397
  destinationUrl,
5272
5398
  forceRefresh: false,
5273
5399
  ...request.toolName ? { toolName: request.toolName } : {},
5274
- subjectId: input.grant.subjectId
5400
+ ...resolverSubjectId ? { subjectId: resolverSubjectId } : {}
5275
5401
  });
5276
5402
  if (first.status === "auth_needed") {
5277
5403
  return await authNeededFetchResponse(input, request, first);
@@ -5289,7 +5415,7 @@ function connectionBrokerFetch(baseFetch, input) {
5289
5415
  destinationUrl,
5290
5416
  forceRefresh: true,
5291
5417
  ...request.toolName ? { toolName: request.toolName } : {},
5292
- subjectId: input.grant.subjectId
5418
+ ...resolverSubjectId ? { subjectId: resolverSubjectId } : {}
5293
5419
  });
5294
5420
  if (refreshed.status === "auth_needed") {
5295
5421
  return await authNeededFetchResponse(input, request, refreshed);
@@ -5573,7 +5699,7 @@ function isAuthExempt(c, settings) {
5573
5699
  if (path === "/v1/github/setup" || path === "/v1/github/install/callback" || path === "/v1/github/oauth/callback" || path === "/v1/github/app-manifest/callback") {
5574
5700
  return true;
5575
5701
  }
5576
- 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") {
5577
5703
  return true;
5578
5704
  }
5579
5705
  if (path.startsWith("/v1/catalog-assets/")) {
@@ -6991,17 +7117,20 @@ function registerCodexRoutes(app, deps) {
6991
7117
  }
6992
7118
 
6993
7119
  // src/routes/connections.ts
7120
+ import { createHash as createHash4 } from "crypto";
6994
7121
  import {
6995
- ConnectOpenGeniSlackBotRequest,
6996
7122
  ConnectionResponse,
6997
7123
  CreateConnectionRequest,
6998
7124
  IntegrationClientMetadata,
6999
7125
  ListConnectionsResponse,
7126
+ OpenGeniSlackBotInstallRequest,
7127
+ OpenGeniSlackBotInstallStart,
7000
7128
  OAuthStartRequest,
7001
7129
  OAuthStartResponse as OAuthStartResponse2,
7002
7130
  UpdateConnectionRequest
7003
7131
  } from "@opengeni/contracts";
7004
7132
  import {
7133
+ hasPermission as hasPermission6,
7005
7134
  hasReservedOpenGeniSlackBotMetadata,
7006
7135
  isOpenGeniSlackBotConnection,
7007
7136
  openGeniSlackBotMetadata as openGeniSlackBotMetadata2,
@@ -7009,13 +7138,19 @@ import {
7009
7138
  requireEnvironmentEncryption as requireEnvironmentEncryption2
7010
7139
  } from "@opengeni/core";
7011
7140
  import {
7141
+ consumeIntegrationOAuthStateNonce as consumeIntegrationOAuthStateNonce2,
7012
7142
  createConnection as createConnection2,
7143
+ createConnectionWithSlackBotSuccessAudit,
7013
7144
  encryptEnvironmentValue as encryptEnvironmentValue3,
7014
7145
  getConnectionMetadata as getConnectionMetadata2,
7146
+ getWorkspaceGrant as getWorkspaceGrant2,
7015
7147
  listConnectionsMetadata as listConnectionsMetadata2,
7016
- recordAuditEvent as recordAuditEvent2,
7148
+ recordSlackBotInstallCallbackFailure,
7017
7149
  revokeConnection,
7018
- updateConnection as updateConnection2
7150
+ revokeConnectionWithSlackBotSuccessAudit,
7151
+ SlackBotLifecycleSuccessAuditError,
7152
+ updateConnection as updateConnection2,
7153
+ updateConnectionWithSlackBotSuccessAudit
7019
7154
  } from "@opengeni/db";
7020
7155
  import { HTTPException as HTTPException9 } from "hono/http-exception";
7021
7156
 
@@ -7024,13 +7159,14 @@ import { Client as Client2 } from "@modelcontextprotocol/sdk/client/index.js";
7024
7159
  import { StreamableHTTPClientTransport as StreamableHTTPClientTransport2 } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7025
7160
  import { parseIntegrationsOauthClientsJson } from "@opengeni/config";
7026
7161
  import { OAuthStartResponse } from "@opengeni/contracts";
7027
- import { requireEnvironmentEncryption } from "@opengeni/core";
7162
+ import { hasPermission as hasPermission5, requireEnvironmentEncryption } from "@opengeni/core";
7028
7163
  import {
7029
7164
  consumeIntegrationOAuthStateNonce,
7030
7165
  createConnection,
7031
7166
  decryptEnvironmentValue,
7032
7167
  encryptEnvironmentValue as encryptEnvironmentValue2,
7033
7168
  getConnectionMetadata,
7169
+ getWorkspaceGrant,
7034
7170
  listConnectionsMetadata,
7035
7171
  loadIntegrationOAuthClient,
7036
7172
  normalizeBearerScheme,
@@ -7063,6 +7199,8 @@ function canonicalProviderDomain(value) {
7063
7199
  // src/integrations/oauth-client.ts
7064
7200
  import { OAUTH_MAX_RESPONSE_BYTES as OAUTH_MAX_RESPONSE_BYTES2 } from "@opengeni/network";
7065
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";
7066
7204
  var OAuthCallbackStageError = class extends Error {
7067
7205
  constructor(stage, reason, cause) {
7068
7206
  super(errorMessage(cause));
@@ -7075,9 +7213,10 @@ var OAuthCallbackStageError = class extends Error {
7075
7213
  async function startMcpOAuth(deps, context) {
7076
7214
  const { db, settings } = deps;
7077
7215
  const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
7078
- const providerDomain = canonicalProviderDomain(
7079
- context.payload.providerDomain ?? new URL(mcpUrl).hostname
7080
- );
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);
7081
7220
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
7082
7221
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
7083
7222
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
@@ -7092,6 +7231,9 @@ async function startMcpOAuth(deps, context) {
7092
7231
  throw new HTTPException8(404, { message: "connection not found" });
7093
7232
  }
7094
7233
  const discovery = await discoverMcpOAuth(mcpUrl, settings);
7234
+ if (personalSlack && !isLocalTestEnvironment(settings.environment)) {
7235
+ assertSlackAuthorizationServer(discovery.as);
7236
+ }
7095
7237
  const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
7096
7238
  const verifier = randomPkceVerifier();
7097
7239
  const authorizeScopes = chooseAuthorizeScopes(
@@ -7159,6 +7301,7 @@ async function completeMcpOAuthCallback(deps, input) {
7159
7301
  }
7160
7302
  try {
7161
7303
  state = readOAuthState(input.state, settings);
7304
+ await requireOAuthCallbackGrant(db, state);
7162
7305
  if (!input.code) {
7163
7306
  return {
7164
7307
  redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" })
@@ -7217,6 +7360,7 @@ async function completeMcpOAuthCallback(deps, input) {
7217
7360
  ...verification.tools ? { mcpTools: verification.tools } : {}
7218
7361
  };
7219
7362
  const credentialEncrypted = encryptEnvironmentValue2(key, JSON.stringify(credential));
7363
+ await requireOAuthCallbackGrant(db, state);
7220
7364
  const connection = await runCallbackStage(
7221
7365
  "persist",
7222
7366
  "persist_failed",
@@ -7225,6 +7369,7 @@ async function completeMcpOAuthCallback(deps, input) {
7225
7369
  connectionId: state.connectionId,
7226
7370
  visibleToSubjectId: state.subjectId,
7227
7371
  expectedVersion: state.connectionVersion,
7372
+ subjectId: state.subjectId,
7228
7373
  providerDomain: state.providerDomain,
7229
7374
  kind: "oauth2",
7230
7375
  status: "active",
@@ -7236,7 +7381,7 @@ async function completeMcpOAuthCallback(deps, input) {
7236
7381
  }) : createConnection(db, {
7237
7382
  accountId: state.accountId,
7238
7383
  workspaceId: state.workspaceId,
7239
- subjectId: null,
7384
+ subjectId: state.subjectId,
7240
7385
  providerDomain: state.providerDomain,
7241
7386
  kind: "oauth2",
7242
7387
  credentialEncrypted,
@@ -7276,6 +7421,43 @@ function requireIntegrationsStateSecret(settings) {
7276
7421
  }
7277
7422
  return secret;
7278
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
+ }
7279
7461
  async function discoverMcpOAuth(resource, settings) {
7280
7462
  const challenge = await probeMcpChallenge(resource, settings);
7281
7463
  const prm = await discoverProtectedResourceMetadata(
@@ -7512,6 +7694,14 @@ function operatorClientForAs(settings, as) {
7512
7694
  };
7513
7695
  }
7514
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
+ }
7515
7705
  const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
7516
7706
  const exactKeys = uniqueStrings(
7517
7707
  candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)])
@@ -7522,7 +7712,6 @@ function operatorClientEntryFor(settings, candidates) {
7522
7712
  return entry;
7523
7713
  }
7524
7714
  }
7525
- const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
7526
7715
  for (const [key, entry] of Object.entries(configured)) {
7527
7716
  if (normalizedCandidates.has(normalizedIssuerKey(key))) {
7528
7717
  return entry;
@@ -7583,11 +7772,17 @@ async function dynamicClientRegistration(settings, as, redirectUri, scopes) {
7583
7772
  }
7584
7773
  async function existingOAuthConnectionForStart(db, input) {
7585
7774
  if (input.connectionId) {
7586
- 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;
7587
7782
  }
7588
7783
  const visible = await listConnectionsMetadata(db, input.workspaceId, input.subjectId);
7589
7784
  return visible.find(
7590
- (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
7591
7786
  ) ?? null;
7592
7787
  }
7593
7788
  function buildAuthorizationUrl(input) {
@@ -7654,6 +7849,9 @@ function readOAuthState(state, settings) {
7654
7849
  };
7655
7850
  const connectionId = stringValue(payload.connectionId);
7656
7851
  const connectionVersion = numberValue(payload.connectionVersion);
7852
+ if (Boolean(connectionId) !== Boolean(connectionVersion)) {
7853
+ throw new HTTPException8(400, { message: "invalid OAuth reconnect state" });
7854
+ }
7657
7855
  return {
7658
7856
  ...parsed,
7659
7857
  ...connectionId ? { connectionId } : {},
@@ -8140,8 +8338,10 @@ function requiredString(value, field) {
8140
8338
  // src/routes/connections.ts
8141
8339
  import {
8142
8340
  OPENGENI_SLACK_BOT_CREDENTIAL_LABEL as OPENGENI_SLACK_BOT_CREDENTIAL_LABEL2,
8143
- 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
8144
8343
  } from "@opengeni/contracts";
8344
+ import { createSignedState as createSignedState4, readSignedState as readSignedState3 } from "@opengeni/github";
8145
8345
  function registerConnectionRoutes(app, deps) {
8146
8346
  const { db, settings, observability } = deps;
8147
8347
  function assertIntegrationsEnabled() {
@@ -8165,11 +8365,13 @@ function registerConnectionRoutes(app, deps) {
8165
8365
  assertNotReservedSlackBotMetadata(payload.metadata);
8166
8366
  const key = requireEnvironmentEncryption2(settings);
8167
8367
  const subjectId = writableSubjectId(payload.subjectId, grant.subjectId);
8368
+ const providerDomain = canonicalProviderDomain(payload.providerDomain);
8369
+ assertNotDirectPersonalSlackOAuth(providerDomain, payload.kind);
8168
8370
  const connection = await createConnection2(db, {
8169
8371
  accountId: grant.accountId,
8170
8372
  workspaceId,
8171
8373
  subjectId,
8172
- providerDomain: canonicalProviderDomain(payload.providerDomain),
8374
+ providerDomain,
8173
8375
  kind: payload.kind,
8174
8376
  credentialEncrypted: encryptCredentialBundle(key, payload.credential),
8175
8377
  grantedScopes: payload.grantedScopes,
@@ -8179,19 +8381,11 @@ function registerConnectionRoutes(app, deps) {
8179
8381
  });
8180
8382
  return c.json(ConnectionResponse.parse({ connection }), 201);
8181
8383
  });
8182
- app.post("/v1/workspaces/:workspaceId/connections/slack-bot", async (c) => {
8384
+ app.post("/v1/workspaces/:workspaceId/connections/slack-bot/install", async (c) => {
8183
8385
  const workspaceId = c.req.param("workspaceId");
8184
8386
  const grant = await requireAccessGrant3(c, deps, workspaceId, "connections:write");
8185
- const payload = ConnectOpenGeniSlackBotRequest.parse(await c.req.json());
8186
- const verified = await verifyOpenGeniSlackBotCredential(
8187
- payload.token,
8188
- deps.slackFetch ?? fetch
8189
- );
8190
- const key = requireEnvironmentEncryption2(settings);
8191
- const credentialEncrypted = encryptCredentialBundle(
8192
- key,
8193
- slackBotCredentialBundle(payload.token)
8194
- );
8387
+ const payload = OpenGeniSlackBotInstallRequest.parse(await c.req.json());
8388
+ const slack = requireOpenGeniSlackOAuthSettings(settings);
8195
8389
  const existing = payload.connectionId ? await getConnectionMetadata2(db, workspaceId, payload.connectionId, grant.subjectId) : null;
8196
8390
  if (payload.connectionId && !existing) {
8197
8391
  throw new HTTPException9(404, { message: "connection not found" });
@@ -8201,69 +8395,119 @@ function registerConnectionRoutes(app, deps) {
8201
8395
  message: "connectionId is not an OpenGeni Slack bot connection"
8202
8396
  });
8203
8397
  }
8204
- const existingMetadata = existing ? openGeniSlackBotMetadata2(existing.metadata) : null;
8205
- if (existingMetadata && existingMetadata.slackTeamId !== verified.metadata.slackTeamId) {
8206
- throw new HTTPException9(409, {
8207
- message: "a Slack bot connection can only be reinstalled for its original Slack workspace"
8208
- });
8209
- }
8210
- if (existingMetadata && (existingMetadata.botId !== verified.metadata.botId || existingMetadata.botUserId !== verified.metadata.botUserId)) {
8211
- throw new HTTPException9(409, {
8212
- message: "a different Slack bot requires a new connection and explicit scheduled-task rebinding"
8213
- });
8214
- }
8215
- const verifiedInstallAt = new Date(verified.metadata.verifiedAt);
8216
- const connection = existing ? await updateConnection2(db, {
8217
- workspaceId,
8218
- connectionId: existing.id,
8219
- visibleToSubjectId: grant.subjectId,
8220
- expectedVersion: existing.version,
8221
- subjectId: null,
8222
- providerDomain: "slack.com",
8223
- kind: "app_install",
8224
- status: "active",
8225
- credentialEncrypted,
8226
- grantedScopes: verified.grantedScopes,
8227
- expiresAt: null,
8228
- verifiedInstallAt,
8229
- verifiedInstallVersion: existing.version + 1,
8230
- metadata: verified.metadata,
8231
- updatedBySubjectId: grant.subjectId
8232
- }) : 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), {
8233
8402
  accountId: grant.accountId,
8234
8403
  workspaceId,
8235
- subjectId: null,
8236
- providerDomain: "slack.com",
8237
- kind: "app_install",
8238
- credentialEncrypted,
8239
- grantedScopes: verified.grantedScopes,
8240
- expiresAt: null,
8241
- verifiedInstallAt,
8242
- verifiedInstallVersion: 1,
8243
- metadata: verified.metadata,
8244
- createdBySubjectId: grant.subjectId
8404
+ subjectId: grant.subjectId,
8405
+ returnPath,
8406
+ ...existing ? { connectionId: existing.id, connectionVersion: existing.version } : {}
8245
8407
  });
8246
- if (!connection) {
8247
- throw new HTTPException9(409, {
8248
- 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()
8249
8435
  });
8250
- }
8251
- await recordAuditEvent2(db, {
8252
- accountId: grant.accountId,
8253
- workspaceId,
8254
- subjectId: grant.subjectId,
8255
- action: existing ? "slack_bot.reinstalled" : "slack_bot.connected",
8256
- targetType: "connection",
8257
- targetId: connection.id,
8258
- metadata: {
8259
- credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE2,
8260
- credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL2,
8261
- connectionId: connection.id,
8262
- slackTeamId: verified.metadata.slackTeamId,
8263
- outcome: "succeeded"
8436
+ if (!consumed) {
8437
+ throw new SlackInstallCallbackError(
8438
+ 400,
8439
+ "state_replayed",
8440
+ "Slack installation state has already been used"
8441
+ );
8264
8442
  }
8265
- });
8266
- 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
+ }
8267
8511
  });
8268
8512
  app.get("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
8269
8513
  const workspaceId = c.req.param("workspaceId");
@@ -8295,6 +8539,12 @@ function registerConnectionRoutes(app, deps) {
8295
8539
  message: "use the dedicated OpenGeni Slack bot reinstall flow to update this connection"
8296
8540
  });
8297
8541
  }
8542
+ if (existing) {
8543
+ assertNotDirectPersonalSlackOAuth(
8544
+ canonicalProviderDomain(payload.providerDomain ?? existing.providerDomain),
8545
+ payload.kind ?? existing.kind
8546
+ );
8547
+ }
8298
8548
  if (payload.status !== void 0) {
8299
8549
  if (payload.status !== "active") {
8300
8550
  throw new HTTPException9(400, {
@@ -8330,33 +8580,24 @@ function registerConnectionRoutes(app, deps) {
8330
8580
  });
8331
8581
  app.delete("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
8332
8582
  const workspaceId = c.req.param("workspaceId");
8583
+ const connectionId = c.req.param("connectionId");
8333
8584
  const grant = await requireAccessGrant3(c, deps, workspaceId, "connections:write");
8334
- const connection = await revokeConnection(
8335
- db,
8336
- workspaceId,
8337
- c.req.param("connectionId"),
8338
- grant.subjectId
8339
- );
8340
- if (!connection) {
8585
+ const existing = await getConnectionMetadata2(db, workspaceId, connectionId, grant.subjectId);
8586
+ if (!existing) {
8341
8587
  throw new HTTPException9(404, { message: "connection not found" });
8342
8588
  }
8343
- if (isOpenGeniSlackBotConnection(connection)) {
8344
- const metadata = openGeniSlackBotMetadata2(connection.metadata);
8345
- await recordAuditEvent2(db, {
8346
- accountId: grant.accountId,
8347
- workspaceId,
8348
- subjectId: grant.subjectId,
8349
- action: "slack_bot.disconnected",
8350
- targetType: "connection",
8351
- targetId: connection.id,
8352
- metadata: {
8353
- credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE2,
8354
- credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL2,
8355
- connectionId: connection.id,
8356
- slackTeamId: metadata.slackTeamId,
8357
- outcome: "succeeded"
8358
- }
8359
- });
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" });
8360
8601
  }
8361
8602
  return c.json(ConnectionResponse.parse({ connection }));
8362
8603
  });
@@ -8410,6 +8651,222 @@ function registerConnectionRoutes(app, deps) {
8410
8651
  );
8411
8652
  });
8412
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
+ }
8413
8870
  function assertNotReservedSlackBotMetadata(metadata) {
8414
8871
  if (hasReservedOpenGeniSlackBotMetadata(metadata)) {
8415
8872
  throw new HTTPException9(422, {
@@ -11195,7 +11652,7 @@ import {
11195
11652
  authorizeGitHubInstallationBinding,
11196
11653
  buildGitHubAppManifest,
11197
11654
  convertGitHubAppManifest,
11198
- createSignedState as createSignedState4,
11655
+ createSignedState as createSignedState5,
11199
11656
  envLinesFromGitHubManifestConversion,
11200
11657
  GitHubAppApiError,
11201
11658
  GitHubAppConfigurationError as GitHubAppConfigurationError2,
@@ -11204,13 +11661,13 @@ import {
11204
11661
  githubOAuthAuthorizeUrl,
11205
11662
  organizationAppManifestUrl,
11206
11663
  personalAppManifestUrl,
11207
- readSignedState as readSignedState3,
11664
+ readSignedState as readSignedState4,
11208
11665
  stateMaxAgeSeconds,
11209
11666
  verifySignedState
11210
11667
  } from "@opengeni/github";
11211
11668
  import { deleteCookie, setCookie } from "hono/cookie";
11212
11669
  import { HTTPException as HTTPException17 } from "hono/http-exception";
11213
- import { hasPermission as hasPermission5, requireAccessGrant as requireAccessGrant10 } from "@opengeni/core";
11670
+ import { hasPermission as hasPermission7, requireAccessGrant as requireAccessGrant10 } from "@opengeni/core";
11214
11671
  var githubStateCookie = "opengeni_github_state";
11215
11672
  var githubBindingStateMaxAgeSeconds = 10 * 60;
11216
11673
  var legacyInstallationChooserDisabledMessage = "The legacy repository-admin GitHub installation chooser is disabled; use the GitHub owner-consent connect flow";
@@ -11223,8 +11680,8 @@ function registerGitHubRoutes(app, deps) {
11223
11680
  const slug = settings.githubAppSlug?.trim() || null;
11224
11681
  const installations = missing.length === 0 ? await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId) : [];
11225
11682
  const status = githubBindingStatus(missing.length === 0, installations);
11226
- const canManage = hasPermission5(grant.permissions, "github:manage");
11227
- 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, {
11228
11685
  accountId: grant.accountId,
11229
11686
  workspaceId: grant.workspaceId,
11230
11687
  intent: "installation_authority",
@@ -11249,7 +11706,7 @@ function registerGitHubRoutes(app, deps) {
11249
11706
  if (!state) {
11250
11707
  throw new HTTPException17(400, { message: "missing GitHub installation state" });
11251
11708
  }
11252
- const statePayload = readSignedState3(state, githubStateSecret);
11709
+ const statePayload = readSignedState4(state, githubStateSecret);
11253
11710
  if (!statePayload || statePayload.intent !== "installation_authority" || statePayload.workspaceId !== workspaceId || typeof statePayload.accountId !== "string" || !isFreshGitHubBindingState(statePayload)) {
11254
11711
  throw new HTTPException17(400, { message: "invalid or expired GitHub installation state" });
11255
11712
  }
@@ -11324,7 +11781,7 @@ function registerGitHubRoutes(app, deps) {
11324
11781
  /\/+$/,
11325
11782
  ""
11326
11783
  );
11327
- const state = createSignedState4(githubStateSecret, {
11784
+ const state = createSignedState5(githubStateSecret, {
11328
11785
  accountId: grant.accountId,
11329
11786
  workspaceId: grant.workspaceId
11330
11787
  });
@@ -11368,7 +11825,7 @@ function registerGitHubRoutes(app, deps) {
11368
11825
  if (!state) {
11369
11826
  throw new HTTPException17(400, { message: "missing GitHub installation state" });
11370
11827
  }
11371
- const statePayload = readSignedState3(state, githubStateSecret);
11828
+ const statePayload = readSignedState4(state, githubStateSecret);
11372
11829
  if (!statePayload || statePayload.intent !== "installation_authority" || typeof statePayload.accountId !== "string" || typeof statePayload.workspaceId !== "string" || !isFreshGitHubBindingState(statePayload)) {
11373
11830
  throw new HTTPException17(400, { message: "invalid or expired GitHub installation state" });
11374
11831
  }
@@ -11399,7 +11856,7 @@ function registerGitHubRoutes(app, deps) {
11399
11856
  })
11400
11857
  });
11401
11858
  }
11402
- const oauthState = createSignedState4(githubStateSecret, {
11859
+ const oauthState = createSignedState5(githubStateSecret, {
11403
11860
  accountId: grant.accountId,
11404
11861
  workspaceId: grant.workspaceId,
11405
11862
  installationId,
@@ -11426,7 +11883,7 @@ function registerGitHubRoutes(app, deps) {
11426
11883
  if (!state) {
11427
11884
  throw new HTTPException17(400, { message: "missing GitHub OAuth state" });
11428
11885
  }
11429
- const statePayload = readSignedState3(state, githubStateSecret);
11886
+ const statePayload = readSignedState4(state, githubStateSecret);
11430
11887
  if (!statePayload || statePayload.intent !== "installation_authority_oauth" || typeof statePayload.accountId !== "string" || typeof statePayload.workspaceId !== "string" || !isFreshGitHubBindingState(statePayload)) {
11431
11888
  throw new HTTPException17(400, { message: "invalid or expired GitHub OAuth state" });
11432
11889
  }
@@ -11505,7 +11962,7 @@ function registerGitHubRoutes(app, deps) {
11505
11962
  if (!state) {
11506
11963
  throw new HTTPException17(400, { message: "missing GitHub OAuth state" });
11507
11964
  }
11508
- const statePayload = readSignedState3(state, githubStateSecret);
11965
+ const statePayload = readSignedState4(state, githubStateSecret);
11509
11966
  if (!statePayload || typeof statePayload.accountId !== "string" || statePayload.accountId.length === 0 || statePayload.workspaceId !== workspaceId) {
11510
11967
  throw new HTTPException17(400, { message: "invalid or expired GitHub OAuth state" });
11511
11968
  }
@@ -13270,34 +13727,34 @@ function registerSessionRoutes(app, deps) {
13270
13727
  message: "durable interactive terminals require a session-home provider lease"
13271
13728
  });
13272
13729
  }
13273
- const process = await getRetainedProcess(db, {
13730
+ const process2 = await getRetainedProcess(db, {
13274
13731
  workspaceId: ctx.workspaceId,
13275
13732
  sessionId: ctx.session.id,
13276
13733
  processId: pty.retainedProcessId
13277
13734
  });
13278
- if (!process || process.state !== "active" || process.ownerActorKind !== "direct" || process.accountId !== ctx.accountId || process.leaseId !== pty.leaseId || process.sandboxGroupId !== pty.sandboxGroupId || process.parentAdmissionId !== pty.openAdmissionId || process.leaseEpoch !== pty.leaseEpoch || process.providerBackend !== pty.providerBackend || process.providerInstanceId !== pty.providerInstanceId || process.routeKind !== pty.routeKind || process.routeTargetId !== pty.routeTargetId || process.routeEpoch !== pty.routeEpoch || process.providerSessionId !== pty.execSessionId || // Only a persistable home backend can currently be reconstructed by an
13735
+ if (!process2 || process2.state !== "active" || process2.ownerActorKind !== "direct" || process2.accountId !== ctx.accountId || process2.leaseId !== pty.leaseId || process2.sandboxGroupId !== pty.sandboxGroupId || process2.parentAdmissionId !== pty.openAdmissionId || process2.leaseEpoch !== pty.leaseEpoch || process2.providerBackend !== pty.providerBackend || process2.providerInstanceId !== pty.providerInstanceId || process2.routeKind !== pty.routeKind || process2.routeTargetId !== pty.routeTargetId || process2.routeEpoch !== pty.routeEpoch || process2.providerSessionId !== pty.execSessionId || // Only a persistable home backend can currently be reconstructed by an
13279
13736
  // API request without consulting the mutable active pointer.
13280
- process.routeTargetId !== null || handle.lease.id !== process.leaseId || handle.lease.sandboxGroupId !== process.sandboxGroupId || handle.lease.leaseEpoch !== process.leaseEpoch || handle.lease.backend !== process.providerBackend || handle.lease.instanceId !== process.providerInstanceId) {
13737
+ process2.routeTargetId !== null || handle.lease.id !== process2.leaseId || handle.lease.sandboxGroupId !== process2.sandboxGroupId || handle.lease.leaseEpoch !== process2.leaseEpoch || handle.lease.backend !== process2.providerBackend || handle.lease.instanceId !== process2.providerInstanceId) {
13281
13738
  throw new HTTPException22(409, {
13282
13739
  message: "pty retained-process identity is stale; reopen the terminal"
13283
13740
  });
13284
13741
  }
13285
13742
  handle.routingSession.adoptRetainedProcess({
13286
- process: { id: process.id, providerSessionId: process.providerSessionId },
13743
+ process: { id: process2.id, providerSessionId: process2.providerSessionId },
13287
13744
  backend: {
13288
13745
  sandboxId: null,
13289
- leaseEpoch: process.leaseEpoch,
13290
- providerInstanceId: process.providerInstanceId,
13291
- activeEpoch: process.routeEpoch
13746
+ leaseEpoch: process2.leaseEpoch,
13747
+ providerInstanceId: process2.providerInstanceId,
13748
+ activeEpoch: process2.routeEpoch
13292
13749
  }
13293
13750
  });
13294
- return process;
13751
+ return process2;
13295
13752
  };
13296
- const emitPtyExited = async (ctx, ptyId, process) => {
13753
+ const emitPtyExited = async (ctx, ptyId, process2) => {
13297
13754
  const exited = {
13298
13755
  ptyId,
13299
- exitCode: process.exitCode,
13300
- reason: process.state === "exited" ? "exit" : "lost"
13756
+ exitCode: process2.exitCode,
13757
+ reason: process2.state === "exited" ? "exit" : "lost"
13301
13758
  };
13302
13759
  await appendAndPublishEvents5(db, bus, ctx.workspaceId, ctx.session.id, [
13303
13760
  { type: "terminal.pty.exited", payload: exited }
@@ -14919,12 +15376,12 @@ function registerSessionRoutes(app, deps) {
14919
15376
  const opened = await service.ptyOpen(req, ptyId);
14920
15377
  const execSessionId = opened.execSessionId;
14921
15378
  const retained = execSessionId === null ? null : handle.routingSession.retainedProcessIdentity(execSessionId);
14922
- const process = retained ? await getRetainedProcess(db, {
15379
+ const process2 = retained ? await getRetainedProcess(db, {
14923
15380
  workspaceId: ctx.workspaceId,
14924
15381
  sessionId: ctx.session.id,
14925
15382
  processId: retained.id
14926
15383
  }) : null;
14927
- if (execSessionId === null || !retained || !process || process.state !== "active" || process.ownerActorKind !== "direct" || process.providerSessionId !== execSessionId || process.routeTargetId !== null || process.leaseId !== handle.lease.id || process.sandboxGroupId !== handle.lease.sandboxGroupId || process.leaseEpoch !== handle.lease.leaseEpoch || process.providerBackend !== handle.lease.backend || process.providerInstanceId !== handle.lease.instanceId) {
15384
+ if (execSessionId === null || !retained || !process2 || process2.state !== "active" || process2.ownerActorKind !== "direct" || process2.providerSessionId !== execSessionId || process2.routeTargetId !== null || process2.leaseId !== handle.lease.id || process2.sandboxGroupId !== handle.lease.sandboxGroupId || process2.leaseEpoch !== handle.lease.leaseEpoch || process2.providerBackend !== handle.lease.backend || process2.providerInstanceId !== handle.lease.instanceId) {
14928
15385
  if (execSessionId !== null && handle.routingSession.hasRetainedProcess(execSessionId)) {
14929
15386
  await drainOpenedPty(handle, execSessionId);
14930
15387
  }
@@ -14933,17 +15390,17 @@ function registerSessionRoutes(app, deps) {
14933
15390
  });
14934
15391
  }
14935
15392
  const identity = {
14936
- leaseId: process.leaseId,
14937
- sandboxGroupId: process.sandboxGroupId,
14938
- retainedProcessId: process.id,
14939
- openAdmissionId: process.parentAdmissionId,
14940
- execSessionId: process.providerSessionId,
14941
- leaseEpoch: process.leaseEpoch,
14942
- providerBackend: process.providerBackend,
14943
- providerInstanceId: process.providerInstanceId,
14944
- routeKind: process.routeKind,
14945
- routeTargetId: process.routeTargetId,
14946
- routeEpoch: process.routeEpoch
15393
+ leaseId: process2.leaseId,
15394
+ sandboxGroupId: process2.sandboxGroupId,
15395
+ retainedProcessId: process2.id,
15396
+ openAdmissionId: process2.parentAdmissionId,
15397
+ execSessionId: process2.providerSessionId,
15398
+ leaseEpoch: process2.leaseEpoch,
15399
+ providerBackend: process2.providerBackend,
15400
+ providerInstanceId: process2.providerInstanceId,
15401
+ routeKind: process2.routeKind,
15402
+ routeTargetId: process2.routeTargetId,
15403
+ routeEpoch: process2.routeEpoch
14947
15404
  };
14948
15405
  try {
14949
15406
  await insertPtySession(db, {
@@ -15531,7 +15988,7 @@ import {
15531
15988
  } from "@opengeni/db";
15532
15989
  import { boundWorkspaceControlHttpPage } from "@opengeni/events";
15533
15990
  import { HTTPException as HTTPException24 } from "hono/http-exception";
15534
- 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";
15535
15992
  import { requireLimit as requireLimit7 } from "@opengeni/core";
15536
15993
  import {
15537
15994
  assertWorkspaceDeletable,
@@ -15778,7 +16235,7 @@ function registerWorkspaceRoutes(app, deps) {
15778
16235
  const context = await requireAccessContext2(c, deps);
15779
16236
  const readableWorkspaceIds = [
15780
16237
  ...new Set(
15781
- 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)
15782
16239
  )
15783
16240
  ];
15784
16241
  if (readableWorkspaceIds.length > 0) {
@@ -16489,18 +16946,19 @@ function createApp(deps) {
16489
16946
  );
16490
16947
  app.get("/v1/config/client", (c) => {
16491
16948
  c.header("cache-control", "no-store");
16949
+ const catalogSettings = deps.settings.codexSubscriptionEnabled ? withCodexCatalogProvider2(deps.settings) : deps.settings;
16492
16950
  return c.json(
16493
16951
  ClientConfig.parse({
16494
16952
  deploymentRevision: deps.settings.deploymentRevision,
16495
16953
  apiContractRevision: OPENGENI_API_CONTRACT_REVISION,
16496
16954
  ...deps.settings.serverVersion ? { serverVersion: deps.settings.serverVersion } : {},
16497
- defaultModel: canonicalizeConfiguredModelId2(deps.settings, deps.settings.openaiModel),
16498
- allowedModels: configuredAllowedModels(deps.settings),
16955
+ defaultModel: canonicalizeConfiguredModelId2(catalogSettings, catalogSettings.openaiModel),
16956
+ allowedModels: configuredAllowedModels(catalogSettings),
16499
16957
  // Provider-grouped model list for the picker. configuredModels() carries the
16500
16958
  // union of the built-in allow-list and every registry provider's models, in
16501
16959
  // selection order (default model first); project each to the client-safe
16502
16960
  // ClientModel shape (ConfiguredModel.providerId → ClientModel.provider).
16503
- models: configuredModels2(deps.settings).map(projectClientModel),
16961
+ models: configuredModels2(catalogSettings).map(projectClientModel),
16504
16962
  defaultReasoningEffort: deps.settings.openaiReasoningEffort,
16505
16963
  allowedReasoningEfforts: configuredAllowedReasoningEfforts(deps.settings),
16506
16964
  mcpServers: deps.settings.mcpServers.map((server) => ({
@@ -16641,7 +17099,7 @@ function createApp(deps) {
16641
17099
  }
16642
17100
  async function requireMcpAccessGrant(c, deps, workspaceId) {
16643
17101
  const grant = await requireAccessGrant18(c, deps, workspaceId);
16644
- if (hasPermission7(grant.permissions, "workspace:read")) {
17102
+ if (hasPermission9(grant.permissions, "workspace:read")) {
16645
17103
  return grant;
16646
17104
  }
16647
17105
  if (isToolspaceGrant(deps.settings, grant)) {
@@ -17079,8 +17537,8 @@ var routeLabelPatterns = [
17079
17537
  label: "/v1/workspaces/:workspaceId/connections/oauth/start"
17080
17538
  },
17081
17539
  {
17082
- pattern: /^\/v1\/workspaces\/[^/]+\/connections\/slack-bot$/,
17083
- label: "/v1/workspaces/:workspaceId/connections/slack-bot"
17540
+ pattern: /^\/v1\/workspaces\/[^/]+\/connections\/slack-bot\/install$/,
17541
+ label: "/v1/workspaces/:workspaceId/connections/slack-bot/install"
17084
17542
  },
17085
17543
  {
17086
17544
  pattern: /^\/v1\/workspaces\/[^/]+\/connections\/[^/]+$/,
@@ -17095,6 +17553,10 @@ var routeLabelPatterns = [
17095
17553
  pattern: /^\/v1\/integrations\/oauth\/client-metadata\.json$/,
17096
17554
  label: "/v1/integrations/oauth/client-metadata.json"
17097
17555
  },
17556
+ {
17557
+ pattern: /^\/v1\/integrations\/slack\/callback$/,
17558
+ label: "/v1/integrations/slack/callback"
17559
+ },
17098
17560
  {
17099
17561
  pattern: /^\/v1\/enrollments\/device\/start$/,
17100
17562
  label: "/v1/enrollments/device/start"
@@ -17179,4 +17641,4 @@ export {
17179
17641
  withDefaultEnabledCapabilityMcpTools,
17180
17642
  workflowIdForSession2 as workflowIdForSession
17181
17643
  };
17182
- //# sourceMappingURL=chunk-S2N4252E.js.map
17644
+ //# sourceMappingURL=chunk-3BHOMOSD.js.map