@opengeni/api-router 0.12.2 → 0.12.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createApp
3
- } from "./chunk-3JR6L6NJ.js";
3
+ } from "./chunk-3BHOMOSD.js";
4
4
 
5
5
  // src/index.ts
6
6
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "0.12.2",
3
+ "version": "0.12.5",
4
4
  "description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -44,17 +44,17 @@
44
44
  "@modelcontextprotocol/sdk": "^1.29.0",
45
45
  "@opengeni/agent-proto": "^0.3.0",
46
46
  "@opengeni/codex": "^0.2.7",
47
- "@opengeni/config": "^0.7.8",
48
- "@opengeni/contracts": "^0.20.1",
49
- "@opengeni/core": "^0.12.2",
50
- "@opengeni/db": "^0.13.1",
51
- "@opengeni/documents": "^0.2.38",
52
- "@opengeni/events": "^0.3.29",
53
- "@opengeni/github": "^0.3.20",
47
+ "@opengeni/config": "^0.7.10",
48
+ "@opengeni/contracts": "^0.21.0",
49
+ "@opengeni/core": "^0.12.5",
50
+ "@opengeni/db": "^0.13.4",
51
+ "@opengeni/documents": "^0.2.41",
52
+ "@opengeni/events": "^0.3.32",
53
+ "@opengeni/github": "^0.3.22",
54
54
  "@opengeni/network": "^0.1.1",
55
55
  "@opengeni/observability": "^0.3.0",
56
- "@opengeni/runtime": "^0.13.13",
57
- "@opengeni/storage": "^0.2.32",
56
+ "@opengeni/runtime": "^0.14.0",
57
+ "@opengeni/storage": "^0.2.34",
58
58
  "@temporalio/client": "^1.17.0",
59
59
  "better-auth": "^1.6.14",
60
60
  "hono": "^4.12.18",
package/src/app.ts CHANGED
@@ -961,8 +961,8 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
961
961
  label: "/v1/workspaces/:workspaceId/connections/oauth/start",
962
962
  },
963
963
  {
964
- pattern: /^\/v1\/workspaces\/[^/]+\/connections\/slack-bot$/,
965
- label: "/v1/workspaces/:workspaceId/connections/slack-bot",
964
+ pattern: /^\/v1\/workspaces\/[^/]+\/connections\/slack-bot\/install$/,
965
+ label: "/v1/workspaces/:workspaceId/connections/slack-bot/install",
966
966
  },
967
967
  {
968
968
  pattern: /^\/v1\/workspaces\/[^/]+\/connections\/[^/]+$/,
@@ -977,6 +977,10 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
977
977
  pattern: /^\/v1\/integrations\/oauth\/client-metadata\.json$/,
978
978
  label: "/v1/integrations/oauth/client-metadata.json",
979
979
  },
980
+ {
981
+ pattern: /^\/v1\/integrations\/slack\/callback$/,
982
+ label: "/v1/integrations/slack/callback",
983
+ },
980
984
  {
981
985
  pattern: /^\/v1\/enrollments\/device\/start$/,
982
986
  label: "/v1/enrollments/device/start",
package/src/http/auth.ts CHANGED
@@ -49,7 +49,8 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
49
49
  }
50
50
  if (
51
51
  path === "/v1/integrations/oauth/callback" ||
52
- path === "/v1/integrations/oauth/client-metadata.json"
52
+ path === "/v1/integrations/oauth/client-metadata.json" ||
53
+ path === "/v1/integrations/slack/callback"
53
54
  ) {
54
55
  return true;
55
56
  }
@@ -3,7 +3,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
3
3
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
4
  import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config";
5
5
  import { OAuthStartResponse, type OAuthStartRequest } from "@opengeni/contracts";
6
- import { requireEnvironmentEncryption } from "@opengeni/core";
6
+ import { hasPermission, requireEnvironmentEncryption } from "@opengeni/core";
7
7
  import type { Observability } from "@opengeni/observability";
8
8
  import {
9
9
  consumeIntegrationOAuthStateNonce,
@@ -11,6 +11,7 @@ import {
11
11
  decryptEnvironmentValue,
12
12
  encryptEnvironmentValue,
13
13
  getConnectionMetadata,
14
+ getWorkspaceGrant,
14
15
  listConnectionsMetadata,
15
16
  loadIntegrationOAuthClient,
16
17
  normalizeBearerScheme,
@@ -33,6 +34,8 @@ import { HTTPException } from "hono/http-exception";
33
34
  import { canonicalProviderDomain } from "./provider-domain";
34
35
 
35
36
  export const oauthStateTtlMs = 10 * 60 * 1000;
37
+ export const OFFICIAL_SLACK_MCP_URL = "https://mcp.slack.com/mcp";
38
+ const SLACK_OAUTH_ORIGIN = "https://slack.com";
36
39
  export { OAUTH_MAX_RESPONSE_BYTES } from "@opengeni/network";
37
40
 
38
41
  type OAuthClientDeps = {
@@ -139,9 +142,12 @@ export async function startMcpOAuth(
139
142
  ): Promise<OAuthStartResponse> {
140
143
  const { db, settings } = deps;
141
144
  const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
142
- const providerDomain = canonicalProviderDomain(
143
- context.payload.providerDomain ?? new URL(mcpUrl).hostname,
144
- );
145
+ const officialSlackResource = mcpUrl === OFFICIAL_SLACK_MCP_URL;
146
+ const providerDomain = officialSlackResource
147
+ ? "slack.com"
148
+ : canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
149
+ const personalSlack = officialSlackResource || providerDomain === "slack.com";
150
+ assertPersonalSlackOAuthStart(settings, context.payload, mcpUrl, personalSlack);
145
151
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
146
152
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
147
153
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
@@ -157,6 +163,9 @@ export async function startMcpOAuth(
157
163
  }
158
164
 
159
165
  const discovery = await discoverMcpOAuth(mcpUrl, settings);
166
+ if (personalSlack && !isLocalTestEnvironment(settings.environment)) {
167
+ assertSlackAuthorizationServer(discovery.as);
168
+ }
160
169
  const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
161
170
  const verifier = randomPkceVerifier();
162
171
  const authorizeScopes = chooseAuthorizeScopes(
@@ -230,6 +239,7 @@ export async function completeMcpOAuthCallback(
230
239
  }
231
240
  try {
232
241
  state = readOAuthState(input.state, settings);
242
+ await requireOAuthCallbackGrant(db, state);
233
243
  if (!input.code) {
234
244
  return {
235
245
  redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }),
@@ -287,6 +297,7 @@ export async function completeMcpOAuthCallback(
287
297
  ...(verification.tools ? { mcpTools: verification.tools } : {}),
288
298
  };
289
299
  const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
300
+ await requireOAuthCallbackGrant(db, state);
290
301
  const connection = await runCallbackStage("persist", "persist_failed", () =>
291
302
  state.connectionId
292
303
  ? updateConnection(db, {
@@ -294,6 +305,7 @@ export async function completeMcpOAuthCallback(
294
305
  connectionId: state.connectionId,
295
306
  visibleToSubjectId: state.subjectId,
296
307
  expectedVersion: state.connectionVersion,
308
+ subjectId: state.subjectId,
297
309
  providerDomain: state.providerDomain,
298
310
  kind: "oauth2",
299
311
  status: "active",
@@ -306,7 +318,7 @@ export async function completeMcpOAuthCallback(
306
318
  : createConnection(db, {
307
319
  accountId: state.accountId,
308
320
  workspaceId: state.workspaceId,
309
- subjectId: null,
321
+ subjectId: state.subjectId,
310
322
  providerDomain: state.providerDomain,
311
323
  kind: "oauth2",
312
324
  credentialEncrypted,
@@ -356,6 +368,56 @@ export function requireIntegrationsStateSecret(settings: Settings): string {
356
368
  return secret;
357
369
  }
358
370
 
371
+ async function requireOAuthCallbackGrant(db: Database, state: OAuthStatePayload): Promise<void> {
372
+ const grant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
373
+ if (
374
+ !grant ||
375
+ grant.accountId !== state.accountId ||
376
+ !hasPermission(grant.permissions, "connections:write")
377
+ ) {
378
+ throw new HTTPException(403, {
379
+ message: "OAuth subject no longer has permission to write this workspace connection",
380
+ });
381
+ }
382
+ }
383
+
384
+ function assertPersonalSlackOAuthStart(
385
+ settings: Settings,
386
+ payload: OAuthStartRequest,
387
+ mcpUrl: string,
388
+ personalSlack: boolean,
389
+ ): void {
390
+ if (!personalSlack) return;
391
+ if (payload.oauthClient) {
392
+ throw new HTTPException(422, {
393
+ message: "Slack OAuth client credentials are deployment-managed",
394
+ });
395
+ }
396
+ if (payload.providerDomain && canonicalProviderDomain(payload.providerDomain) !== "slack.com") {
397
+ throw new HTTPException(422, { message: "Slack provider identity does not match slack.com" });
398
+ }
399
+ if (!isLocalTestEnvironment(settings.environment) && mcpUrl !== OFFICIAL_SLACK_MCP_URL) {
400
+ throw new HTTPException(422, {
401
+ message: `personal Slack OAuth must use ${OFFICIAL_SLACK_MCP_URL}`,
402
+ });
403
+ }
404
+ if (!settings.slackClientId?.trim() || !settings.slackClientSecret?.trim()) {
405
+ throw new HTTPException(503, {
406
+ message:
407
+ "personal Slack OAuth requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
408
+ });
409
+ }
410
+ }
411
+
412
+ export function assertSlackAuthorizationServer(as: AuthorizationServerMetadata): void {
413
+ const urls = [as.issuer, as.authorizationServer, as.authorizationEndpoint, as.tokenEndpoint];
414
+ if (urls.some((value) => new URL(value).origin !== SLACK_OAUTH_ORIGIN)) {
415
+ throw new HTTPException(422, {
416
+ message: "Slack MCP authorization metadata did not remain bound to slack.com",
417
+ });
418
+ }
419
+ }
420
+
359
421
  async function discoverMcpOAuth(
360
422
  resource: string,
361
423
  settings: Settings,
@@ -656,6 +718,18 @@ function operatorClientEntryFor(
656
718
  settings: Settings,
657
719
  candidates: string[],
658
720
  ): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
721
+ const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
722
+ if (
723
+ normalizedCandidates.has(SLACK_OAUTH_ORIGIN) &&
724
+ settings.slackClientId?.trim() &&
725
+ settings.slackClientSecret?.trim()
726
+ ) {
727
+ return {
728
+ clientId: settings.slackClientId.trim(),
729
+ clientSecret: settings.slackClientSecret.trim(),
730
+ tokenEndpointAuthMethod: "client_secret_post",
731
+ };
732
+ }
659
733
  const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
660
734
  const exactKeys = uniqueStrings(
661
735
  candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)]),
@@ -666,7 +740,6 @@ function operatorClientEntryFor(
666
740
  return entry;
667
741
  }
668
742
  }
669
- const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
670
743
  for (const [key, entry] of Object.entries(configured)) {
671
744
  if (normalizedCandidates.has(normalizedIssuerKey(key))) {
672
745
  return entry;
@@ -743,13 +816,23 @@ async function existingOAuthConnectionForStart(
743
816
  },
744
817
  ) {
745
818
  if (input.connectionId) {
746
- return await getConnectionMetadata(db, input.workspaceId, input.connectionId, input.subjectId);
819
+ const connection = await getConnectionMetadata(
820
+ db,
821
+ input.workspaceId,
822
+ input.connectionId,
823
+ input.subjectId,
824
+ );
825
+ return connection?.subjectId === input.subjectId &&
826
+ connection.kind === "oauth2" &&
827
+ connection.providerDomain === input.providerDomain
828
+ ? connection
829
+ : null;
747
830
  }
748
831
  const visible = await listConnectionsMetadata(db, input.workspaceId, input.subjectId);
749
832
  return (
750
833
  visible.find(
751
834
  (connection) =>
752
- connection.subjectId === null &&
835
+ connection.subjectId === input.subjectId &&
753
836
  connection.kind === "oauth2" &&
754
837
  connection.status === "active" &&
755
838
  connection.providerDomain === input.providerDomain,
@@ -836,6 +919,9 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
836
919
  };
837
920
  const connectionId = stringValue(payload.connectionId);
838
921
  const connectionVersion = numberValue(payload.connectionVersion);
922
+ if (Boolean(connectionId) !== Boolean(connectionVersion)) {
923
+ throw new HTTPException(400, { message: "invalid OAuth reconnect state" });
924
+ }
839
925
  return {
840
926
  ...parsed,
841
927
  ...(connectionId ? { connectionId } : {}),
@@ -22,6 +22,7 @@ import {
22
22
  getSession,
23
23
  recordAuditEvent,
24
24
  releaseSlackBotPostOperationClaim,
25
+ setConnectionStatus,
25
26
  type Database,
26
27
  } from "@opengeni/db";
27
28
  import { readResponseJsonBounded, type FetchLike } from "@opengeni/network";
@@ -43,6 +44,55 @@ export type VerifiedOpenGeniSlackBot = {
43
44
  metadata: OpenGeniSlackBotConnectionMetadata;
44
45
  };
45
46
 
47
+ export async function exchangeOpenGeniSlackAuthorizationCode(
48
+ input: {
49
+ code: string;
50
+ clientId: string;
51
+ clientSecret: string;
52
+ redirectUri: string;
53
+ },
54
+ fetchImpl: FetchLike = fetch,
55
+ ): Promise<string> {
56
+ const body = new URLSearchParams({
57
+ code: input.code,
58
+ client_id: input.clientId,
59
+ client_secret: input.clientSecret,
60
+ redirect_uri: input.redirectUri,
61
+ });
62
+ let response: Response;
63
+ try {
64
+ response = await fetchImpl(`${SLACK_API_BASE}oauth.v2.access`, {
65
+ method: "POST",
66
+ headers: {
67
+ accept: "application/json",
68
+ "content-type": "application/x-www-form-urlencoded",
69
+ },
70
+ body: body.toString(),
71
+ redirect: "error",
72
+ signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
73
+ });
74
+ } catch {
75
+ throw new HTTPException(502, { message: "Slack installation token exchange failed" });
76
+ }
77
+ if (!response.ok) {
78
+ throw new HTTPException(502, { message: "Slack installation token exchange failed" });
79
+ }
80
+ const payload = await readResponseJsonBounded<unknown>(
81
+ response,
82
+ SLACK_RESPONSE_MAX_BYTES,
83
+ "Slack OAuth response",
84
+ );
85
+ const record = slackRecord(payload);
86
+ if (!record || record.ok !== true) {
87
+ throw new SlackBotProviderError(slackString(record?.error) || "oauth_exchange_failed");
88
+ }
89
+ const accessToken = slackString(record.access_token);
90
+ if (!accessToken?.startsWith("xoxb-")) {
91
+ throw new HTTPException(502, { message: "Slack installation did not return a bot token" });
92
+ }
93
+ return accessToken;
94
+ }
95
+
46
96
  export type SlackBotReceipt = {
47
97
  credentialRole: typeof OPENGENI_SLACK_BOT_CREDENTIAL_ROLE;
48
98
  credentialLabel: typeof OPENGENI_SLACK_BOT_CREDENTIAL_LABEL;
@@ -70,6 +120,30 @@ export class SlackBotProviderError extends Error {
70
120
  }
71
121
  }
72
122
 
123
+ const SLACK_CREDENTIAL_REJECTION_CODES = new Set([
124
+ "account_inactive",
125
+ "invalid_auth",
126
+ "not_authed",
127
+ "token_expired",
128
+ "token_revoked",
129
+ ]);
130
+
131
+ function slackCredentialRejected(error: unknown): error is SlackBotProviderError {
132
+ return error instanceof SlackBotProviderError && SLACK_CREDENTIAL_REJECTION_CODES.has(error.code);
133
+ }
134
+
135
+ export type SlackBotCredentialVerificationFailureReason = "scope_mismatch" | "identity_mismatch";
136
+
137
+ export class SlackBotCredentialVerificationError extends HTTPException {
138
+ constructor(
139
+ readonly failureReason: SlackBotCredentialVerificationFailureReason,
140
+ message: string,
141
+ ) {
142
+ super(422, { message });
143
+ this.name = "SlackBotCredentialVerificationError";
144
+ }
145
+ }
146
+
73
147
  /**
74
148
  * Validates a write-only xoxb credential before it can enter encrypted storage.
75
149
  * Slack does not expose the app's display_information name to this scope set;
@@ -95,14 +169,18 @@ export async function verifyOpenGeniSlackBotCredential(
95
169
  });
96
170
  const user = slackRecord(userResponse.payload.user);
97
171
  if (!user || user.is_bot !== true || user.deleted === true) {
98
- throw new HTTPException(422, { message: "Slack credential must identify an active bot user" });
172
+ throw new SlackBotCredentialVerificationError(
173
+ "identity_mismatch",
174
+ "Slack credential must identify an active bot user",
175
+ );
99
176
  }
100
177
  const profile = slackRecord(user.profile);
101
178
  const displayName = slackString(profile?.display_name) || slackString(profile?.real_name);
102
179
  if (displayName !== "OpenGeni") {
103
- throw new HTTPException(422, {
104
- message: 'Slack bot display name must be exactly "OpenGeni"',
105
- });
180
+ throw new SlackBotCredentialVerificationError(
181
+ "identity_mismatch",
182
+ 'Slack bot display name must be exactly "OpenGeni"',
183
+ );
106
184
  }
107
185
 
108
186
  return {
@@ -355,7 +433,18 @@ export class OpenGeniSlackBotClient {
355
433
  method: string,
356
434
  params: Record<string, string>,
357
435
  ): Promise<SlackPayload> {
358
- return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload;
436
+ try {
437
+ return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload;
438
+ } catch (error) {
439
+ if (slackCredentialRejected(error)) {
440
+ await setConnectionStatus(this.db, this.context.workspaceId, "needs_reauth", error.code, {
441
+ id: this.connection.id,
442
+ version: this.connection.version,
443
+ subjectId: null,
444
+ }).catch(() => false);
445
+ }
446
+ throw error;
447
+ }
359
448
  }
360
449
 
361
450
  private async withAudit<T extends Record<string, unknown>>(
@@ -561,15 +650,19 @@ function assertExactOpenGeniSlackBotScopes(grantedScopes: string[]): void {
561
650
  ...(forbidden.length ? [`forbidden: ${forbidden.join(", ")}`] : []),
562
651
  ...(unsupported.length ? [`unsupported: ${unsupported.join(", ")}`] : []),
563
652
  ];
564
- throw new HTTPException(422, {
565
- message: `Slack bot scopes must exactly match the OpenGeni manifest (${facts.join("; ")})`,
566
- });
653
+ throw new SlackBotCredentialVerificationError(
654
+ "scope_mismatch",
655
+ `Slack bot scopes must exactly match the OpenGeni manifest (${facts.join("; ")})`,
656
+ );
567
657
  }
568
658
  }
569
659
 
570
660
  function parseGrantedScopes(header: string | null): string[] {
571
661
  if (!header) {
572
- throw new HTTPException(422, { message: "Slack did not report granted bot scopes" });
662
+ throw new SlackBotCredentialVerificationError(
663
+ "scope_mismatch",
664
+ "Slack did not report granted bot scopes",
665
+ );
573
666
  }
574
667
  return [
575
668
  ...new Set(
@@ -885,8 +885,17 @@ export function connectionBrokerFetch(
885
885
  if (!connectionRef) {
886
886
  return baseFetch;
887
887
  }
888
- const resolveCredential = input.deps.connectionCredentials?.mcpCredentials
889
- ? buildHostConnectionTokenResolver(input.deps.connectionCredentials.mcpCredentials, {
888
+ const credentialSubjectId =
889
+ input.turn.initiator.kind === "subject" ? input.turn.initiator.subjectId : undefined;
890
+ if (connectionRef.subjectScope === "subject" && !credentialSubjectId) {
891
+ throw new Error(
892
+ `subject-owned connection for MCP server ${input.config.id} requires a human turn initiator`,
893
+ );
894
+ }
895
+ const hostCredentialPort = input.deps.connectionCredentials?.mcpCredentials;
896
+ const resolverSubjectId = hostCredentialPort ? input.grant.subjectId : credentialSubjectId;
897
+ const resolveCredential = hostCredentialPort
898
+ ? buildHostConnectionTokenResolver(hostCredentialPort, {
890
899
  accountId: input.grant.accountId,
891
900
  workspaceId: input.grant.workspaceId,
892
901
  sessionId: input.sessionId,
@@ -909,7 +918,7 @@ export function connectionBrokerFetch(
909
918
  destinationUrl,
910
919
  forceRefresh: false,
911
920
  ...(request.toolName ? { toolName: request.toolName } : {}),
912
- subjectId: input.grant.subjectId,
921
+ ...(resolverSubjectId ? { subjectId: resolverSubjectId } : {}),
913
922
  });
914
923
  if (first.status === "auth_needed") {
915
924
  return await authNeededFetchResponse(input, request, first);
@@ -927,7 +936,7 @@ export function connectionBrokerFetch(
927
936
  destinationUrl,
928
937
  forceRefresh: true,
929
938
  ...(request.toolName ? { toolName: request.toolName } : {}),
930
- subjectId: input.grant.subjectId,
939
+ ...(resolverSubjectId ? { subjectId: resolverSubjectId } : {}),
931
940
  });
932
941
  if (refreshed.status === "auth_needed") {
933
942
  return await authNeededFetchResponse(input, request, refreshed);