@opengeni/api-router 0.11.2 → 0.12.0
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.d.ts +3 -2
- package/dist/app.js +3 -1
- package/dist/{chunk-BFWSDESE.js → chunk-S2N4252E.js} +3731 -2009
- package/dist/chunk-S2N4252E.js.map +1 -0
- package/dist/index.js +21 -3
- package/dist/index.js.map +1 -1
- package/package.json +10 -10
- package/src/app.ts +142 -26
- package/src/github-access.ts +117 -9
- package/src/github-browser-flow.ts +4 -4
- package/src/http/auth.ts +6 -4
- package/src/index.ts +20 -1
- package/src/integrations/slack-bot.ts +694 -0
- package/src/mcp/documents.ts +19 -5
- package/src/mcp/server.ts +158 -12
- package/src/routes/connections.ts +155 -1
- package/src/routes/documents.ts +191 -9
- package/src/routes/files.ts +1 -1
- package/src/routes/github.ts +331 -23
- package/src/routes/sessions.ts +37 -0
- package/src/routes/workspace-instruction-policies.ts +243 -0
- package/src/sandbox/channel-a.ts +131 -70
- package/dist/chunk-BFWSDESE.js.map +0 -1
package/src/mcp/documents.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
getDocumentChunk,
|
|
3
3
|
listDocumentBases,
|
|
4
4
|
searchDocuments,
|
|
5
|
+
type DocumentAccessFilter,
|
|
5
6
|
type DocumentServices,
|
|
6
7
|
} from "@opengeni/documents";
|
|
7
8
|
import { createKnowledgeMemory, listKnowledgeMemories, type Database } from "@opengeni/db";
|
|
@@ -44,12 +45,23 @@ export function buildDocumentsMcpServer(
|
|
|
44
45
|
accountId: string,
|
|
45
46
|
workspaceId: string,
|
|
46
47
|
documentServices: DocumentServices,
|
|
47
|
-
options: {
|
|
48
|
+
options: {
|
|
49
|
+
createdBySessionId?: string | undefined;
|
|
50
|
+
/** The human subject whose agent is making this retrieval request. */
|
|
51
|
+
viewerSubjectId?: string | undefined;
|
|
52
|
+
} = {},
|
|
48
53
|
): McpServer {
|
|
49
54
|
const server = new McpServer({
|
|
50
55
|
name: "opengeni-documents",
|
|
51
56
|
version: "1.0.0",
|
|
52
57
|
});
|
|
58
|
+
// This server is the agent retrieval surface. Agent-disabled documents are
|
|
59
|
+
// never reachable. Workspace-visible documents are shared; private
|
|
60
|
+
// documents are available only to the creating subject's agent.
|
|
61
|
+
const agentAccess: DocumentAccessFilter = {
|
|
62
|
+
agentOnly: true,
|
|
63
|
+
...(options.viewerSubjectId ? { viewerSubjectId: options.viewerSubjectId } : {}),
|
|
64
|
+
};
|
|
53
65
|
|
|
54
66
|
server.registerTool(
|
|
55
67
|
"list_document_bases",
|
|
@@ -68,7 +80,7 @@ export function buildDocumentsMcpServer(
|
|
|
68
80
|
description: "Search indexed documents with hybrid, vector, or keyword retrieval.",
|
|
69
81
|
inputSchema: SearchInputSchema,
|
|
70
82
|
},
|
|
71
|
-
async (input) => searchContent(db, workspaceId, documentServices, input),
|
|
83
|
+
async (input) => searchContent(db, workspaceId, documentServices, input, agentAccess),
|
|
72
84
|
);
|
|
73
85
|
|
|
74
86
|
server.registerTool(
|
|
@@ -78,7 +90,7 @@ export function buildDocumentsMcpServer(
|
|
|
78
90
|
"Search company knowledge sources with optional base, source-kind, ACL, and retrieval-mode filters.",
|
|
79
91
|
inputSchema: SearchInputSchema,
|
|
80
92
|
},
|
|
81
|
-
async (input) => searchContent(db, workspaceId, documentServices, input),
|
|
93
|
+
async (input) => searchContent(db, workspaceId, documentServices, input, agentAccess),
|
|
82
94
|
);
|
|
83
95
|
|
|
84
96
|
server.registerTool(
|
|
@@ -90,7 +102,7 @@ export function buildDocumentsMcpServer(
|
|
|
90
102
|
},
|
|
91
103
|
},
|
|
92
104
|
async ({ chunkId }) => {
|
|
93
|
-
const found = await getDocumentChunk(db, workspaceId, chunkId);
|
|
105
|
+
const found = await getDocumentChunk(db, workspaceId, chunkId, agentAccess);
|
|
94
106
|
return {
|
|
95
107
|
content: [
|
|
96
108
|
{ type: "text", text: found ? JSON.stringify(found) : `chunk not found: ${chunkId}` },
|
|
@@ -109,7 +121,7 @@ export function buildDocumentsMcpServer(
|
|
|
109
121
|
},
|
|
110
122
|
},
|
|
111
123
|
async ({ chunkId }) => {
|
|
112
|
-
const found = await getDocumentChunk(db, workspaceId, chunkId);
|
|
124
|
+
const found = await getDocumentChunk(db, workspaceId, chunkId, agentAccess);
|
|
113
125
|
return {
|
|
114
126
|
content: [
|
|
115
127
|
{ type: "text", text: found ? JSON.stringify(found) : `chunk not found: ${chunkId}` },
|
|
@@ -214,6 +226,7 @@ async function searchContent(
|
|
|
214
226
|
| undefined;
|
|
215
227
|
aclTags?: string[] | undefined;
|
|
216
228
|
},
|
|
229
|
+
access: DocumentAccessFilter,
|
|
217
230
|
) {
|
|
218
231
|
return {
|
|
219
232
|
content: [
|
|
@@ -230,6 +243,7 @@ async function searchContent(
|
|
|
230
243
|
...(input.mode ? { mode: input.mode } : {}),
|
|
231
244
|
...(input.sourceKinds ? { sourceKinds: input.sourceKinds } : {}),
|
|
232
245
|
...(input.aclTags ? { aclTags: input.aclTags } : {}),
|
|
246
|
+
access,
|
|
233
247
|
},
|
|
234
248
|
documentServices,
|
|
235
249
|
),
|
package/src/mcp/server.ts
CHANGED
|
@@ -68,6 +68,7 @@ import {
|
|
|
68
68
|
} from "@opengeni/db";
|
|
69
69
|
import { appendAndPublishEvents, publishDurableSessionEvents } from "@opengeni/events";
|
|
70
70
|
import {
|
|
71
|
+
createSignedState,
|
|
71
72
|
createGitHubAppInstallationToken,
|
|
72
73
|
GitHubAppConfigurationError,
|
|
73
74
|
githubAppMissingSettings,
|
|
@@ -82,7 +83,12 @@ import {
|
|
|
82
83
|
} from "@opengeni/core";
|
|
83
84
|
import { recordWorkspaceUsage, requireLimit } from "@opengeni/core";
|
|
84
85
|
import type { ApiRouteDeps } from "@opengeni/core";
|
|
85
|
-
import {
|
|
86
|
+
import {
|
|
87
|
+
githubBindingStatus,
|
|
88
|
+
listWorkspaceGitHubInstallationBindings,
|
|
89
|
+
listWorkspaceGitHubRepositories,
|
|
90
|
+
} from "../github-access";
|
|
91
|
+
import { githubBrowserBaseUrl, githubBrowserGrantClaims } from "../github-browser-flow";
|
|
86
92
|
import {
|
|
87
93
|
promoteVerifiedDefinitionEditChangeForApi,
|
|
88
94
|
proposeRigChangeForApi,
|
|
@@ -138,11 +144,14 @@ import {
|
|
|
138
144
|
} from "./session-view";
|
|
139
145
|
import type { ToolspaceMcpSurface } from "./toolspace";
|
|
140
146
|
import { ensureSessionGroupReady as ensureViewerSessionGroupReady } from "../sandbox/viewer";
|
|
147
|
+
import {
|
|
148
|
+
createOpenGeniSlackBotClient,
|
|
149
|
+
resolveSlackBotConnectionForTool,
|
|
150
|
+
} from "../integrations/slack-bot";
|
|
141
151
|
|
|
142
152
|
export type McpServerOptions = {
|
|
143
|
-
// Origin of the HTTP request that reached the MCP route.
|
|
144
|
-
//
|
|
145
|
-
// it or mint state while new installation binding is disabled.
|
|
153
|
+
// Origin of the HTTP request that reached the MCP route. Browser-oriented
|
|
154
|
+
// tools use it only when no configured public base URL is available.
|
|
146
155
|
requestOrigin?: string | null;
|
|
147
156
|
toolspace?: ToolspaceMcpSurface | null;
|
|
148
157
|
workspaceMemoryEnabled?: boolean | undefined;
|
|
@@ -214,6 +223,7 @@ export function buildOpenGeniMcpServer(
|
|
|
214
223
|
}
|
|
215
224
|
if (!toolspaceMode) {
|
|
216
225
|
registerRigTools(server, deps, grant, can, sessionId, json);
|
|
226
|
+
registerSlackBotTools(server, deps, grant, sessionId, json);
|
|
217
227
|
}
|
|
218
228
|
|
|
219
229
|
// Orchestration, variableSet, and GitHub status/token tools are permission-gated
|
|
@@ -229,7 +239,7 @@ export function buildOpenGeniMcpServer(
|
|
|
229
239
|
registerWorkspaceOrchestrationTools(server, deps, grant, can, sessionId, toolspaceMode, json);
|
|
230
240
|
registerVariableSetTools(server, deps, grant, can, json);
|
|
231
241
|
if (can("github:use")) {
|
|
232
|
-
registerGitHubConnectTool(server, deps, json);
|
|
242
|
+
registerGitHubConnectTool(server, deps, grant, options, json);
|
|
233
243
|
// TOKEN-BROKER (B1): the agent-refreshable git token. Session-scoped (keys off the
|
|
234
244
|
// worker-signed sessionId claim so it mints for THIS session's repos), gated on
|
|
235
245
|
// the same github:use capability as github_connect_link.
|
|
@@ -647,6 +657,121 @@ export function buildOpenGeniMcpServer(
|
|
|
647
657
|
return server;
|
|
648
658
|
}
|
|
649
659
|
|
|
660
|
+
function registerSlackBotTools(
|
|
661
|
+
server: McpServer,
|
|
662
|
+
deps: ApiRouteDeps,
|
|
663
|
+
grant: AccessGrant,
|
|
664
|
+
sessionId: string | null,
|
|
665
|
+
json: JsonResult,
|
|
666
|
+
): void {
|
|
667
|
+
const clientFor = async (connectionId?: string) => {
|
|
668
|
+
const resolved = await resolveSlackBotConnectionForTool({
|
|
669
|
+
db: deps.db,
|
|
670
|
+
grant,
|
|
671
|
+
sessionId,
|
|
672
|
+
...(connectionId ? { requestedConnectionId: connectionId } : {}),
|
|
673
|
+
});
|
|
674
|
+
return createOpenGeniSlackBotClient(deps, resolved);
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
server.registerTool(
|
|
678
|
+
"slack_bot_list_channels",
|
|
679
|
+
{
|
|
680
|
+
description:
|
|
681
|
+
"List public and bot-visible private Slack channels through the workspace-shared OpenGeni bot. isMember identifies channels the bot may read/post in; the bot never joins channels automatically.",
|
|
682
|
+
inputSchema: {
|
|
683
|
+
connectionId: z4.string().uuid().optional(),
|
|
684
|
+
cursor: z4.string().max(1024).optional(),
|
|
685
|
+
limit: z4.number().int().min(1).max(200).optional(),
|
|
686
|
+
},
|
|
687
|
+
},
|
|
688
|
+
async ({ connectionId, cursor, limit }) =>
|
|
689
|
+
json(
|
|
690
|
+
await (
|
|
691
|
+
await clientFor(connectionId)
|
|
692
|
+
).listChannels({
|
|
693
|
+
...(cursor ? { cursor } : {}),
|
|
694
|
+
...(limit !== undefined ? { limit } : {}),
|
|
695
|
+
}),
|
|
696
|
+
),
|
|
697
|
+
);
|
|
698
|
+
|
|
699
|
+
server.registerTool(
|
|
700
|
+
"slack_bot_channel_history",
|
|
701
|
+
{
|
|
702
|
+
description:
|
|
703
|
+
"Read Slack channel history as the workspace-shared OpenGeni bot. Public and private channels both require bot membership; invite the bot to private channels first.",
|
|
704
|
+
inputSchema: {
|
|
705
|
+
connectionId: z4.string().uuid().optional(),
|
|
706
|
+
channelId: z4.string().min(1).max(64),
|
|
707
|
+
cursor: z4.string().max(1024).optional(),
|
|
708
|
+
limit: z4.number().int().min(1).max(100).optional(),
|
|
709
|
+
},
|
|
710
|
+
},
|
|
711
|
+
async ({ connectionId, channelId, cursor, limit }) =>
|
|
712
|
+
json(
|
|
713
|
+
await (
|
|
714
|
+
await clientFor(connectionId)
|
|
715
|
+
).channelHistory({
|
|
716
|
+
channelId,
|
|
717
|
+
...(cursor ? { cursor } : {}),
|
|
718
|
+
...(limit !== undefined ? { limit } : {}),
|
|
719
|
+
}),
|
|
720
|
+
),
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
server.registerTool(
|
|
724
|
+
"slack_bot_list_users",
|
|
725
|
+
{
|
|
726
|
+
description: "List Slack workspace users through the workspace-shared OpenGeni bot.",
|
|
727
|
+
inputSchema: {
|
|
728
|
+
connectionId: z4.string().uuid().optional(),
|
|
729
|
+
cursor: z4.string().max(1024).optional(),
|
|
730
|
+
limit: z4.number().int().min(1).max(200).optional(),
|
|
731
|
+
},
|
|
732
|
+
},
|
|
733
|
+
async ({ connectionId, cursor, limit }) =>
|
|
734
|
+
json(
|
|
735
|
+
await (
|
|
736
|
+
await clientFor(connectionId)
|
|
737
|
+
).listUsers({
|
|
738
|
+
...(cursor ? { cursor } : {}),
|
|
739
|
+
...(limit !== undefined ? { limit } : {}),
|
|
740
|
+
}),
|
|
741
|
+
),
|
|
742
|
+
);
|
|
743
|
+
|
|
744
|
+
server.registerTool(
|
|
745
|
+
"slack_bot_post_message",
|
|
746
|
+
{
|
|
747
|
+
description:
|
|
748
|
+
"Post as the workspace-shared OpenGeni bot. Pass channelId for a channel where the bot is already a member, or userId to open/post a DM; pass exactly one. Generate one operationId UUID per intended message and reuse that same UUID on every retry.",
|
|
749
|
+
inputSchema: {
|
|
750
|
+
connectionId: z4.string().uuid().optional(),
|
|
751
|
+
operationId: z4.string().uuid(),
|
|
752
|
+
channelId: z4.string().min(1).max(64).optional(),
|
|
753
|
+
userId: z4.string().min(1).max(64).optional(),
|
|
754
|
+
text: z4.string().min(1).max(40_000),
|
|
755
|
+
},
|
|
756
|
+
},
|
|
757
|
+
async ({ connectionId, operationId, channelId, userId, text }) => {
|
|
758
|
+
if (Boolean(channelId) === Boolean(userId)) {
|
|
759
|
+
throw new Error("exactly one of channelId or userId is required");
|
|
760
|
+
}
|
|
761
|
+
return json(
|
|
762
|
+
await (
|
|
763
|
+
await clientFor(connectionId)
|
|
764
|
+
).postMessage({
|
|
765
|
+
operationId,
|
|
766
|
+
...(channelId ? { channelId } : {}),
|
|
767
|
+
...(userId ? { userId } : {}),
|
|
768
|
+
text,
|
|
769
|
+
}),
|
|
770
|
+
);
|
|
771
|
+
},
|
|
772
|
+
);
|
|
773
|
+
}
|
|
774
|
+
|
|
650
775
|
function registerToolspaceProxyTools(server: McpServer, surface: ToolspaceMcpSurface | null): void {
|
|
651
776
|
if (!surface) {
|
|
652
777
|
return;
|
|
@@ -2031,15 +2156,18 @@ function registerVariableSetTools(
|
|
|
2031
2156
|
}
|
|
2032
2157
|
}
|
|
2033
2158
|
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2159
|
+
function registerGitHubConnectTool(
|
|
2160
|
+
server: McpServer,
|
|
2161
|
+
deps: ApiRouteDeps,
|
|
2162
|
+
grant: AccessGrant,
|
|
2163
|
+
options: McpServerOptions,
|
|
2164
|
+
json: JsonResult,
|
|
2165
|
+
): void {
|
|
2038
2166
|
server.registerTool(
|
|
2039
2167
|
"github_connect_link",
|
|
2040
2168
|
{
|
|
2041
2169
|
description:
|
|
2042
|
-
"Report GitHub App
|
|
2170
|
+
"Report truthful GitHub App workspace binding status and, for a human grant with github:manage, return the fresh GitHub owner-consent link. Server App configuration alone is never reported as a usable binding.",
|
|
2043
2171
|
inputSchema: {},
|
|
2044
2172
|
},
|
|
2045
2173
|
async () => {
|
|
@@ -2049,17 +2177,35 @@ function registerGitHubConnectTool(server: McpServer, deps: ApiRouteDeps, json:
|
|
|
2049
2177
|
if (missing.length > 0 || !slug) {
|
|
2050
2178
|
return json({
|
|
2051
2179
|
configured: false,
|
|
2180
|
+
status: "disabled",
|
|
2052
2181
|
appSlug: slug,
|
|
2053
2182
|
installUrl: null,
|
|
2054
2183
|
linkUrl: null,
|
|
2055
2184
|
missing,
|
|
2056
2185
|
});
|
|
2057
2186
|
}
|
|
2187
|
+
const installations = await listWorkspaceGitHubInstallationBindings(deps, grant.workspaceId);
|
|
2188
|
+
const status = githubBindingStatus(true, installations);
|
|
2189
|
+
const baseUrl = githubBrowserBaseUrl(settings, options.requestOrigin);
|
|
2190
|
+
const state =
|
|
2191
|
+
baseUrl && hasPermission(grant.permissions, "github:manage")
|
|
2192
|
+
? createSignedState(deps.githubStateSecret, {
|
|
2193
|
+
accountId: grant.accountId,
|
|
2194
|
+
workspaceId: grant.workspaceId,
|
|
2195
|
+
intent: "installation_authority",
|
|
2196
|
+
...githubBrowserGrantClaims(settings, grant),
|
|
2197
|
+
})
|
|
2198
|
+
: null;
|
|
2199
|
+
const connectUrl = state
|
|
2200
|
+
? `${baseUrl}/v1/workspaces/${grant.workspaceId}/github/connect?state=${encodeURIComponent(state)}`
|
|
2201
|
+
: null;
|
|
2058
2202
|
return json({
|
|
2059
2203
|
configured: true,
|
|
2204
|
+
status,
|
|
2060
2205
|
appSlug: slug,
|
|
2061
|
-
installUrl:
|
|
2062
|
-
linkUrl:
|
|
2206
|
+
installUrl: connectUrl,
|
|
2207
|
+
linkUrl: connectUrl,
|
|
2208
|
+
installations,
|
|
2063
2209
|
missing: [],
|
|
2064
2210
|
});
|
|
2065
2211
|
},
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ConnectOpenGeniSlackBotRequest,
|
|
2
3
|
ConnectionResponse,
|
|
3
4
|
CreateConnectionRequest,
|
|
4
5
|
IntegrationClientMetadata,
|
|
@@ -7,12 +8,19 @@ import {
|
|
|
7
8
|
OAuthStartResponse,
|
|
8
9
|
UpdateConnectionRequest,
|
|
9
10
|
} from "@opengeni/contracts";
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
hasReservedOpenGeniSlackBotMetadata,
|
|
13
|
+
isOpenGeniSlackBotConnection,
|
|
14
|
+
openGeniSlackBotMetadata,
|
|
15
|
+
requireAccessGrant,
|
|
16
|
+
requireEnvironmentEncryption,
|
|
17
|
+
} from "@opengeni/core";
|
|
11
18
|
import {
|
|
12
19
|
createConnection,
|
|
13
20
|
encryptEnvironmentValue,
|
|
14
21
|
getConnectionMetadata,
|
|
15
22
|
listConnectionsMetadata,
|
|
23
|
+
recordAuditEvent,
|
|
16
24
|
revokeConnection,
|
|
17
25
|
updateConnection,
|
|
18
26
|
} from "@opengeni/db";
|
|
@@ -25,6 +33,11 @@ import {
|
|
|
25
33
|
startMcpOAuth,
|
|
26
34
|
} from "../integrations/oauth-client";
|
|
27
35
|
import { canonicalProviderDomain } from "../integrations/provider-domain";
|
|
36
|
+
import { verifyOpenGeniSlackBotCredential } from "../integrations/slack-bot";
|
|
37
|
+
import {
|
|
38
|
+
OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
|
|
39
|
+
OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
40
|
+
} from "@opengeni/contracts";
|
|
28
41
|
|
|
29
42
|
export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
30
43
|
const { db, settings, observability } = deps;
|
|
@@ -49,6 +62,7 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
49
62
|
const workspaceId = c.req.param("workspaceId");
|
|
50
63
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
51
64
|
const payload = CreateConnectionRequest.parse(await c.req.json());
|
|
65
|
+
assertNotReservedSlackBotMetadata(payload.metadata);
|
|
52
66
|
const key = requireEnvironmentEncryption(settings);
|
|
53
67
|
const subjectId = writableSubjectId(payload.subjectId, grant.subjectId);
|
|
54
68
|
const connection = await createConnection(db, {
|
|
@@ -66,6 +80,102 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
66
80
|
return c.json(ConnectionResponse.parse({ connection }), 201);
|
|
67
81
|
});
|
|
68
82
|
|
|
83
|
+
app.post("/v1/workspaces/:workspaceId/connections/slack-bot", async (c) => {
|
|
84
|
+
const workspaceId = c.req.param("workspaceId");
|
|
85
|
+
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
86
|
+
const payload = ConnectOpenGeniSlackBotRequest.parse(await c.req.json());
|
|
87
|
+
const verified = await verifyOpenGeniSlackBotCredential(
|
|
88
|
+
payload.token,
|
|
89
|
+
deps.slackFetch ?? fetch,
|
|
90
|
+
);
|
|
91
|
+
const key = requireEnvironmentEncryption(settings);
|
|
92
|
+
const credentialEncrypted = encryptCredentialBundle(
|
|
93
|
+
key,
|
|
94
|
+
slackBotCredentialBundle(payload.token),
|
|
95
|
+
);
|
|
96
|
+
const existing = payload.connectionId
|
|
97
|
+
? await getConnectionMetadata(db, workspaceId, payload.connectionId, grant.subjectId)
|
|
98
|
+
: null;
|
|
99
|
+
if (payload.connectionId && !existing) {
|
|
100
|
+
throw new HTTPException(404, { message: "connection not found" });
|
|
101
|
+
}
|
|
102
|
+
if (existing && !isOpenGeniSlackBotConnection(existing)) {
|
|
103
|
+
throw new HTTPException(422, {
|
|
104
|
+
message: "connectionId is not an OpenGeni Slack bot connection",
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
const existingMetadata = existing ? openGeniSlackBotMetadata(existing.metadata) : null;
|
|
108
|
+
if (existingMetadata && existingMetadata.slackTeamId !== verified.metadata.slackTeamId) {
|
|
109
|
+
throw new HTTPException(409, {
|
|
110
|
+
message: "a Slack bot connection can only be reinstalled for its original Slack workspace",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
if (
|
|
114
|
+
existingMetadata &&
|
|
115
|
+
(existingMetadata.botId !== verified.metadata.botId ||
|
|
116
|
+
existingMetadata.botUserId !== verified.metadata.botUserId)
|
|
117
|
+
) {
|
|
118
|
+
throw new HTTPException(409, {
|
|
119
|
+
message:
|
|
120
|
+
"a different Slack bot requires a new connection and explicit scheduled-task rebinding",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const verifiedInstallAt = new Date(verified.metadata.verifiedAt);
|
|
124
|
+
const connection = existing
|
|
125
|
+
? await updateConnection(db, {
|
|
126
|
+
workspaceId,
|
|
127
|
+
connectionId: existing.id,
|
|
128
|
+
visibleToSubjectId: grant.subjectId,
|
|
129
|
+
expectedVersion: existing.version,
|
|
130
|
+
subjectId: null,
|
|
131
|
+
providerDomain: "slack.com",
|
|
132
|
+
kind: "app_install",
|
|
133
|
+
status: "active",
|
|
134
|
+
credentialEncrypted,
|
|
135
|
+
grantedScopes: verified.grantedScopes,
|
|
136
|
+
expiresAt: null,
|
|
137
|
+
verifiedInstallAt,
|
|
138
|
+
verifiedInstallVersion: existing.version + 1,
|
|
139
|
+
metadata: verified.metadata,
|
|
140
|
+
updatedBySubjectId: grant.subjectId,
|
|
141
|
+
})
|
|
142
|
+
: await createConnection(db, {
|
|
143
|
+
accountId: grant.accountId,
|
|
144
|
+
workspaceId,
|
|
145
|
+
subjectId: null,
|
|
146
|
+
providerDomain: "slack.com",
|
|
147
|
+
kind: "app_install",
|
|
148
|
+
credentialEncrypted,
|
|
149
|
+
grantedScopes: verified.grantedScopes,
|
|
150
|
+
expiresAt: null,
|
|
151
|
+
verifiedInstallAt,
|
|
152
|
+
verifiedInstallVersion: 1,
|
|
153
|
+
metadata: verified.metadata,
|
|
154
|
+
createdBySubjectId: grant.subjectId,
|
|
155
|
+
});
|
|
156
|
+
if (!connection) {
|
|
157
|
+
throw new HTTPException(409, {
|
|
158
|
+
message: "Slack bot connection changed during reinstall; retry with the current connection",
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
await recordAuditEvent(db, {
|
|
162
|
+
accountId: grant.accountId,
|
|
163
|
+
workspaceId,
|
|
164
|
+
subjectId: grant.subjectId,
|
|
165
|
+
action: existing ? "slack_bot.reinstalled" : "slack_bot.connected",
|
|
166
|
+
targetType: "connection",
|
|
167
|
+
targetId: connection.id,
|
|
168
|
+
metadata: {
|
|
169
|
+
credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
170
|
+
credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
|
|
171
|
+
connectionId: connection.id,
|
|
172
|
+
slackTeamId: verified.metadata.slackTeamId,
|
|
173
|
+
outcome: "succeeded",
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
return c.json(ConnectionResponse.parse({ connection }), existing ? 200 : 201);
|
|
177
|
+
});
|
|
178
|
+
|
|
69
179
|
app.get("/v1/workspaces/:workspaceId/connections/:connectionId", async (c) => {
|
|
70
180
|
const workspaceId = c.req.param("workspaceId");
|
|
71
181
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:read");
|
|
@@ -85,6 +195,18 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
85
195
|
const workspaceId = c.req.param("workspaceId");
|
|
86
196
|
const grant = await requireAccessGrant(c, deps, workspaceId, "connections:write");
|
|
87
197
|
const payload = UpdateConnectionRequest.parse(await c.req.json());
|
|
198
|
+
assertNotReservedSlackBotMetadata(payload.metadata);
|
|
199
|
+
const existing = await getConnectionMetadata(
|
|
200
|
+
db,
|
|
201
|
+
workspaceId,
|
|
202
|
+
c.req.param("connectionId"),
|
|
203
|
+
grant.subjectId,
|
|
204
|
+
);
|
|
205
|
+
if (existing && isOpenGeniSlackBotConnection(existing)) {
|
|
206
|
+
throw new HTTPException(422, {
|
|
207
|
+
message: "use the dedicated OpenGeni Slack bot reinstall flow to update this connection",
|
|
208
|
+
});
|
|
209
|
+
}
|
|
88
210
|
// Status is not a free-form field: revocation goes through DELETE, and the
|
|
89
211
|
// broker owns needs_reauth/error. Reactivating a connection is only
|
|
90
212
|
// meaningful together with a fresh credential bundle — otherwise a PATCH
|
|
@@ -144,6 +266,24 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
144
266
|
if (!connection) {
|
|
145
267
|
throw new HTTPException(404, { message: "connection not found" });
|
|
146
268
|
}
|
|
269
|
+
if (isOpenGeniSlackBotConnection(connection)) {
|
|
270
|
+
const metadata = openGeniSlackBotMetadata(connection.metadata)!;
|
|
271
|
+
await recordAuditEvent(db, {
|
|
272
|
+
accountId: grant.accountId,
|
|
273
|
+
workspaceId,
|
|
274
|
+
subjectId: grant.subjectId,
|
|
275
|
+
action: "slack_bot.disconnected",
|
|
276
|
+
targetType: "connection",
|
|
277
|
+
targetId: connection.id,
|
|
278
|
+
metadata: {
|
|
279
|
+
credentialRole: OPENGENI_SLACK_BOT_CREDENTIAL_ROLE,
|
|
280
|
+
credentialLabel: OPENGENI_SLACK_BOT_CREDENTIAL_LABEL,
|
|
281
|
+
connectionId: connection.id,
|
|
282
|
+
slackTeamId: metadata.slackTeamId,
|
|
283
|
+
outcome: "succeeded",
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}
|
|
147
287
|
return c.json(ConnectionResponse.parse({ connection }));
|
|
148
288
|
});
|
|
149
289
|
|
|
@@ -200,6 +340,14 @@ export function registerConnectionRoutes(app: Hono, deps: ApiRouteDeps): void {
|
|
|
200
340
|
});
|
|
201
341
|
}
|
|
202
342
|
|
|
343
|
+
function assertNotReservedSlackBotMetadata(metadata: Record<string, unknown> | undefined): void {
|
|
344
|
+
if (hasReservedOpenGeniSlackBotMetadata(metadata)) {
|
|
345
|
+
throw new HTTPException(422, {
|
|
346
|
+
message: "OpenGeni Slack bot metadata is reserved for the dedicated connection flow",
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
203
351
|
function writableSubjectId(
|
|
204
352
|
requested: string | null | undefined,
|
|
205
353
|
grantSubjectId: string,
|
|
@@ -216,3 +364,9 @@ function writableSubjectId(
|
|
|
216
364
|
function encryptCredentialBundle(key: Uint8Array, credential: Record<string, unknown>): string {
|
|
217
365
|
return encryptEnvironmentValue(key, JSON.stringify(credential));
|
|
218
366
|
}
|
|
367
|
+
|
|
368
|
+
function slackBotCredentialBundle(token: string): Record<string, unknown> {
|
|
369
|
+
const headerName = ["author", "ization"].join("");
|
|
370
|
+
const scheme = ["Bear", "er"].join("");
|
|
371
|
+
return { headers: { [headerName]: `${scheme} ${token}` } };
|
|
372
|
+
}
|