@opengeni/api-router 0.12.2 → 0.12.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app.js +1 -1
- package/dist/{chunk-3JR6L6NJ.js → chunk-3BHOMOSD.js} +558 -138
- package/dist/chunk-3BHOMOSD.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +10 -10
- package/src/app.ts +6 -2
- package/src/http/auth.ts +2 -1
- package/src/integrations/oauth-client.ts +94 -8
- package/src/integrations/slack-bot.ts +102 -9
- package/src/mcp/toolspace.ts +13 -4
- package/src/routes/connections.ts +448 -103
- package/dist/chunk-3JR6L6NJ.js.map +0 -1
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@opengeni/api-router",
|
|
3
|
-
"version": "0.12.
|
|
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.
|
|
48
|
-
"@opengeni/contracts": "^0.
|
|
49
|
-
"@opengeni/core": "^0.12.
|
|
50
|
-
"@opengeni/db": "^0.13.
|
|
51
|
-
"@opengeni/documents": "^0.2.
|
|
52
|
-
"@opengeni/events": "^0.3.
|
|
53
|
-
"@opengeni/github": "^0.3.
|
|
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.
|
|
57
|
-
"@opengeni/storage": "^0.2.
|
|
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
|
|
143
|
-
|
|
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:
|
|
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
|
-
|
|
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 ===
|
|
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
|
|
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
|
|
104
|
-
|
|
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
|
-
|
|
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
|
|
565
|
-
|
|
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
|
|
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(
|
package/src/mcp/toolspace.ts
CHANGED
|
@@ -885,8 +885,17 @@ export function connectionBrokerFetch(
|
|
|
885
885
|
if (!connectionRef) {
|
|
886
886
|
return baseFetch;
|
|
887
887
|
}
|
|
888
|
-
const
|
|
889
|
-
?
|
|
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:
|
|
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:
|
|
939
|
+
...(resolverSubjectId ? { subjectId: resolverSubjectId } : {}),
|
|
931
940
|
});
|
|
932
941
|
if (refreshed.status === "auth_needed") {
|
|
933
942
|
return await authNeededFetchResponse(input, request, refreshed);
|