@inkeep/agents-work-apps 0.0.0-dev-20260211191741 → 0.0.0-dev-20260211220939
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/env.d.ts +24 -2
- package/dist/env.js +13 -2
- package/dist/github/index.d.ts +3 -3
- package/dist/github/mcp/index.d.ts +2 -2
- package/dist/github/routes/setup.d.ts +2 -2
- package/dist/github/routes/tokenExchange.d.ts +2 -2
- package/dist/github/routes/webhooks.d.ts +2 -2
- package/dist/slack/i18n/index.d.ts +2 -0
- package/dist/slack/i18n/index.js +3 -0
- package/dist/slack/i18n/strings.d.ts +73 -0
- package/dist/slack/i18n/strings.js +67 -0
- package/dist/slack/index.d.ts +18 -0
- package/dist/slack/index.js +28 -0
- package/dist/slack/middleware/permissions.d.ts +31 -0
- package/dist/slack/middleware/permissions.js +159 -0
- package/dist/slack/routes/events.d.ts +10 -0
- package/dist/slack/routes/events.js +390 -0
- package/dist/slack/routes/index.d.ts +10 -0
- package/dist/slack/routes/index.js +47 -0
- package/dist/slack/routes/oauth.d.ts +20 -0
- package/dist/slack/routes/oauth.js +325 -0
- package/dist/slack/routes/users.d.ts +10 -0
- package/dist/slack/routes/users.js +358 -0
- package/dist/slack/routes/workspaces.d.ts +10 -0
- package/dist/slack/routes/workspaces.js +875 -0
- package/dist/slack/services/agent-resolution.d.ts +41 -0
- package/dist/slack/services/agent-resolution.js +99 -0
- package/dist/slack/services/blocks/index.d.ts +73 -0
- package/dist/slack/services/blocks/index.js +103 -0
- package/dist/slack/services/client.d.ts +105 -0
- package/dist/slack/services/client.js +220 -0
- package/dist/slack/services/commands/index.d.ts +19 -0
- package/dist/slack/services/commands/index.js +538 -0
- package/dist/slack/services/events/app-mention.d.ts +40 -0
- package/dist/slack/services/events/app-mention.js +234 -0
- package/dist/slack/services/events/block-actions.d.ts +40 -0
- package/dist/slack/services/events/block-actions.js +221 -0
- package/dist/slack/services/events/index.d.ts +6 -0
- package/dist/slack/services/events/index.js +7 -0
- package/dist/slack/services/events/modal-submission.d.ts +30 -0
- package/dist/slack/services/events/modal-submission.js +346 -0
- package/dist/slack/services/events/streaming.d.ts +26 -0
- package/dist/slack/services/events/streaming.js +228 -0
- package/dist/slack/services/events/utils.d.ts +146 -0
- package/dist/slack/services/events/utils.js +369 -0
- package/dist/slack/services/index.d.ts +16 -0
- package/dist/slack/services/index.js +16 -0
- package/dist/slack/services/modals.d.ts +86 -0
- package/dist/slack/services/modals.js +355 -0
- package/dist/slack/services/nango.d.ts +85 -0
- package/dist/slack/services/nango.js +462 -0
- package/dist/slack/services/security.d.ts +35 -0
- package/dist/slack/services/security.js +65 -0
- package/dist/slack/services/types.d.ts +26 -0
- package/dist/slack/services/types.js +1 -0
- package/dist/slack/services/workspace-tokens.d.ts +25 -0
- package/dist/slack/services/workspace-tokens.js +27 -0
- package/dist/slack/types.d.ts +10 -0
- package/dist/slack/types.js +1 -0
- package/package.json +10 -2
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { env } from "../../env.js";
|
|
2
|
+
import { getLogger } from "../../logger.js";
|
|
3
|
+
import runDbClient_default from "../../db/runDbClient.js";
|
|
4
|
+
import { clearWorkspaceConnectionCache, computeWorkspaceConnectionId, deleteWorkspaceInstallation, storeWorkspaceInstallation } from "../services/nango.js";
|
|
5
|
+
import { getSlackClient, getSlackTeamInfo, getSlackUserInfo } from "../services/client.js";
|
|
6
|
+
import { getBotTokenForTeam, setBotTokenForTeam } from "../services/workspace-tokens.js";
|
|
7
|
+
import "../services/index.js";
|
|
8
|
+
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
|
|
9
|
+
import { createWorkAppSlackWorkspace } from "@inkeep/agents-core";
|
|
10
|
+
import * as crypto$1 from "node:crypto";
|
|
11
|
+
|
|
12
|
+
//#region src/slack/routes/oauth.ts
|
|
13
|
+
/**
|
|
14
|
+
* Slack OAuth Routes
|
|
15
|
+
*
|
|
16
|
+
* Endpoints for Slack workspace installation via OAuth:
|
|
17
|
+
* - GET /install - Redirect to Slack OAuth page
|
|
18
|
+
* - GET /oauth_redirect - Handle OAuth callback
|
|
19
|
+
*/
|
|
20
|
+
const logger = getLogger("slack-oauth");
|
|
21
|
+
const STATE_TTL_MS = 600 * 1e3;
|
|
22
|
+
/**
|
|
23
|
+
* Allowed redirect domains for OAuth callbacks.
|
|
24
|
+
* Validates INKEEP_AGENTS_MANAGE_UI_URL to prevent open redirect attacks.
|
|
25
|
+
*/
|
|
26
|
+
const ALLOWED_REDIRECT_HOSTNAMES = new Set([
|
|
27
|
+
"localhost",
|
|
28
|
+
"127.0.0.1",
|
|
29
|
+
"agents.inkeep.com"
|
|
30
|
+
]);
|
|
31
|
+
function isAllowedRedirectUrl(url) {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = new URL(url);
|
|
34
|
+
if (ALLOWED_REDIRECT_HOSTNAMES.has(parsed.hostname)) return true;
|
|
35
|
+
if (parsed.hostname.endsWith(".inkeep.com")) return true;
|
|
36
|
+
return false;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
const manageUiUrl = env.INKEEP_AGENTS_MANAGE_UI_URL || "http://localhost:3000";
|
|
42
|
+
if (!isAllowedRedirectUrl(manageUiUrl)) throw new Error(`Invalid INKEEP_AGENTS_MANAGE_UI_URL: "${manageUiUrl}" is not in the allowed redirect domains. Allowed: localhost, 127.0.0.1, *.inkeep.com`);
|
|
43
|
+
function getStateSigningSecret() {
|
|
44
|
+
const secret = env.SLACK_SIGNING_SECRET;
|
|
45
|
+
if (!secret) {
|
|
46
|
+
if (env.ENVIRONMENT === "production") throw new Error("SLACK_SIGNING_SECRET is required in production for OAuth state signing");
|
|
47
|
+
logger.warn({}, "SLACK_SIGNING_SECRET not set, using insecure default. DO NOT USE IN PRODUCTION!");
|
|
48
|
+
return "insecure-dev-oauth-state-secret-change-in-production";
|
|
49
|
+
}
|
|
50
|
+
return secret;
|
|
51
|
+
}
|
|
52
|
+
function createOAuthState(tenantId) {
|
|
53
|
+
const state = {
|
|
54
|
+
nonce: crypto$1.randomBytes(16).toString("hex"),
|
|
55
|
+
tenantId: tenantId || "default",
|
|
56
|
+
timestamp: Date.now()
|
|
57
|
+
};
|
|
58
|
+
const data = Buffer.from(JSON.stringify(state)).toString("base64url");
|
|
59
|
+
return `${data}.${crypto$1.createHmac("sha256", getStateSigningSecret()).update(data).digest("base64url")}`;
|
|
60
|
+
}
|
|
61
|
+
function parseOAuthState(stateStr) {
|
|
62
|
+
try {
|
|
63
|
+
const [data, signature] = stateStr.split(".");
|
|
64
|
+
if (!data || !signature) {
|
|
65
|
+
logger.warn({}, "OAuth state missing signature");
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
const expectedSignature = crypto$1.createHmac("sha256", getStateSigningSecret()).update(data).digest("base64url");
|
|
69
|
+
if (signature.length !== expectedSignature.length || !crypto$1.timingSafeEqual(Buffer.from(signature), Buffer.from(expectedSignature))) {
|
|
70
|
+
logger.warn({}, "Invalid OAuth state signature");
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
const decoded = Buffer.from(data, "base64url").toString("utf-8");
|
|
74
|
+
const state = JSON.parse(decoded);
|
|
75
|
+
if (!state.nonce || !state.timestamp) return null;
|
|
76
|
+
if (Date.now() - state.timestamp > STATE_TTL_MS) {
|
|
77
|
+
logger.warn({ timestamp: state.timestamp }, "OAuth state expired");
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
return state;
|
|
81
|
+
} catch {
|
|
82
|
+
logger.warn({ stateStr: stateStr?.slice(0, 20) }, "Failed to parse OAuth state");
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
const app = new OpenAPIHono();
|
|
87
|
+
app.openapi(createRoute({
|
|
88
|
+
method: "get",
|
|
89
|
+
path: "/install",
|
|
90
|
+
summary: "Install Slack App",
|
|
91
|
+
description: "Redirects to Slack OAuth page for workspace installation",
|
|
92
|
+
operationId: "slack-install",
|
|
93
|
+
tags: [
|
|
94
|
+
"Work Apps",
|
|
95
|
+
"Slack",
|
|
96
|
+
"OAuth"
|
|
97
|
+
],
|
|
98
|
+
request: { query: z.object({ tenant_id: z.string().optional() }) },
|
|
99
|
+
responses: { 302: { description: "Redirect to Slack OAuth" } }
|
|
100
|
+
}), (c) => {
|
|
101
|
+
const { tenant_id: tenantId } = c.req.valid("query");
|
|
102
|
+
const clientId = env.SLACK_CLIENT_ID;
|
|
103
|
+
const redirectUri = `${env.SLACK_APP_URL}/work-apps/slack/oauth_redirect`;
|
|
104
|
+
const botScopes = [
|
|
105
|
+
"app_mentions:read",
|
|
106
|
+
"channels:history",
|
|
107
|
+
"channels:read",
|
|
108
|
+
"chat:write",
|
|
109
|
+
"chat:write.public",
|
|
110
|
+
"commands",
|
|
111
|
+
"groups:history",
|
|
112
|
+
"groups:read",
|
|
113
|
+
"im:history",
|
|
114
|
+
"im:read",
|
|
115
|
+
"im:write",
|
|
116
|
+
"team:read",
|
|
117
|
+
"users:read",
|
|
118
|
+
"users:read.email"
|
|
119
|
+
].join(",");
|
|
120
|
+
const state = createOAuthState(tenantId);
|
|
121
|
+
const slackAuthUrl = new URL("https://slack.com/oauth/v2/authorize");
|
|
122
|
+
slackAuthUrl.searchParams.set("client_id", clientId || "");
|
|
123
|
+
slackAuthUrl.searchParams.set("scope", botScopes);
|
|
124
|
+
slackAuthUrl.searchParams.set("redirect_uri", redirectUri);
|
|
125
|
+
slackAuthUrl.searchParams.set("state", state);
|
|
126
|
+
logger.info({
|
|
127
|
+
redirectUri,
|
|
128
|
+
tenantId: tenantId || "default"
|
|
129
|
+
}, "Redirecting to Slack OAuth");
|
|
130
|
+
return c.redirect(slackAuthUrl.toString());
|
|
131
|
+
});
|
|
132
|
+
app.openapi(createRoute({
|
|
133
|
+
method: "get",
|
|
134
|
+
path: "/oauth_redirect",
|
|
135
|
+
summary: "Slack OAuth Callback",
|
|
136
|
+
description: "Handles the OAuth callback from Slack after workspace installation",
|
|
137
|
+
operationId: "slack-oauth-redirect",
|
|
138
|
+
tags: [
|
|
139
|
+
"Work Apps",
|
|
140
|
+
"Slack",
|
|
141
|
+
"OAuth"
|
|
142
|
+
],
|
|
143
|
+
request: { query: z.object({
|
|
144
|
+
code: z.string().optional(),
|
|
145
|
+
error: z.string().optional(),
|
|
146
|
+
state: z.string().optional()
|
|
147
|
+
}) },
|
|
148
|
+
responses: { 302: { description: "Redirect to dashboard with workspace data" } }
|
|
149
|
+
}), async (c) => {
|
|
150
|
+
const { code, error, state: stateParam } = c.req.valid("query");
|
|
151
|
+
const parsedState = stateParam ? parseOAuthState(stateParam) : null;
|
|
152
|
+
const tenantId = parsedState?.tenantId || "default";
|
|
153
|
+
const dashboardUrl = `${manageUiUrl}/${tenantId}/work-apps/slack`;
|
|
154
|
+
if (!stateParam || !parsedState) {
|
|
155
|
+
logger.error({ hasState: !!stateParam }, "Invalid or missing OAuth state parameter");
|
|
156
|
+
return c.redirect(`${dashboardUrl}?error=invalid_state`);
|
|
157
|
+
}
|
|
158
|
+
if (error) {
|
|
159
|
+
logger.error({ error }, "Slack OAuth error");
|
|
160
|
+
return c.redirect(`${dashboardUrl}?error=${encodeURIComponent(error)}`);
|
|
161
|
+
}
|
|
162
|
+
if (!code) {
|
|
163
|
+
logger.error({}, "No code provided in OAuth callback");
|
|
164
|
+
return c.redirect(`${dashboardUrl}?error=no_code`);
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const controller = new AbortController();
|
|
168
|
+
const timeout = setTimeout(() => controller.abort(), 1e4);
|
|
169
|
+
let tokenResponse;
|
|
170
|
+
try {
|
|
171
|
+
tokenResponse = await fetch("https://slack.com/api/oauth.v2.access", {
|
|
172
|
+
method: "POST",
|
|
173
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
174
|
+
body: new URLSearchParams({
|
|
175
|
+
client_id: env.SLACK_CLIENT_ID || "",
|
|
176
|
+
client_secret: env.SLACK_CLIENT_SECRET || "",
|
|
177
|
+
code,
|
|
178
|
+
redirect_uri: `${env.SLACK_APP_URL}/work-apps/slack/oauth_redirect`
|
|
179
|
+
}),
|
|
180
|
+
signal: controller.signal
|
|
181
|
+
});
|
|
182
|
+
} catch (fetchErr) {
|
|
183
|
+
clearTimeout(timeout);
|
|
184
|
+
if (fetchErr.name === "AbortError") {
|
|
185
|
+
logger.error({}, "Slack token exchange timed out");
|
|
186
|
+
return c.redirect(`${dashboardUrl}?error=timeout`);
|
|
187
|
+
}
|
|
188
|
+
throw fetchErr;
|
|
189
|
+
} finally {
|
|
190
|
+
clearTimeout(timeout);
|
|
191
|
+
}
|
|
192
|
+
const tokenData = await tokenResponse.json();
|
|
193
|
+
if (!tokenData.ok) {
|
|
194
|
+
logger.error({ error: tokenData.error }, "Slack token exchange failed");
|
|
195
|
+
return c.redirect(`${dashboardUrl}?error=${encodeURIComponent(tokenData.error || "token_exchange_failed")}`);
|
|
196
|
+
}
|
|
197
|
+
const client = getSlackClient(tokenData.access_token);
|
|
198
|
+
const teamInfo = await getSlackTeamInfo(client);
|
|
199
|
+
logger.debug({ teamInfo }, "Retrieved Slack team info");
|
|
200
|
+
const installerUserId = tokenData.authed_user?.id;
|
|
201
|
+
let installerUserName;
|
|
202
|
+
if (installerUserId) try {
|
|
203
|
+
const userInfo = await getSlackUserInfo(client, installerUserId);
|
|
204
|
+
installerUserName = userInfo?.realName || userInfo?.name;
|
|
205
|
+
} catch {
|
|
206
|
+
logger.warn({ installerUserId }, "Could not fetch installer user info");
|
|
207
|
+
}
|
|
208
|
+
const workspaceData = {
|
|
209
|
+
ok: true,
|
|
210
|
+
teamId: tokenData.team?.id,
|
|
211
|
+
teamName: tokenData.team?.name,
|
|
212
|
+
teamDomain: teamInfo?.domain,
|
|
213
|
+
workspaceUrl: teamInfo?.url,
|
|
214
|
+
workspaceIconUrl: teamInfo?.icon,
|
|
215
|
+
enterpriseId: tokenData.enterprise?.id,
|
|
216
|
+
enterpriseName: tokenData.enterprise?.name,
|
|
217
|
+
isEnterpriseInstall: tokenData.is_enterprise_install || false,
|
|
218
|
+
botUserId: tokenData.bot_user_id,
|
|
219
|
+
botToken: tokenData.access_token,
|
|
220
|
+
botScopes: tokenData.scope,
|
|
221
|
+
installerUserId,
|
|
222
|
+
installerUserName,
|
|
223
|
+
appId: tokenData.app_id,
|
|
224
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
225
|
+
};
|
|
226
|
+
if (workspaceData.teamId && workspaceData.botToken) {
|
|
227
|
+
clearWorkspaceConnectionCache(workspaceData.teamId);
|
|
228
|
+
const nangoResult = await storeWorkspaceInstallation({
|
|
229
|
+
teamId: workspaceData.teamId,
|
|
230
|
+
teamName: workspaceData.teamName,
|
|
231
|
+
teamDomain: workspaceData.teamDomain,
|
|
232
|
+
workspaceUrl: workspaceData.workspaceUrl,
|
|
233
|
+
workspaceIconUrl: workspaceData.workspaceIconUrl,
|
|
234
|
+
enterpriseId: workspaceData.enterpriseId,
|
|
235
|
+
enterpriseName: workspaceData.enterpriseName,
|
|
236
|
+
botUserId: workspaceData.botUserId,
|
|
237
|
+
botToken: workspaceData.botToken,
|
|
238
|
+
botScopes: workspaceData.botScopes,
|
|
239
|
+
installerUserId: workspaceData.installerUserId,
|
|
240
|
+
installerUserName: workspaceData.installerUserName,
|
|
241
|
+
isEnterpriseInstall: workspaceData.isEnterpriseInstall,
|
|
242
|
+
appId: workspaceData.appId,
|
|
243
|
+
tenantId,
|
|
244
|
+
installationSource: "dashboard"
|
|
245
|
+
});
|
|
246
|
+
if (nangoResult.success && nangoResult.connectionId) {
|
|
247
|
+
logger.info({
|
|
248
|
+
teamId: workspaceData.teamId,
|
|
249
|
+
connectionId: nangoResult.connectionId
|
|
250
|
+
}, "Stored workspace installation in Nango");
|
|
251
|
+
try {
|
|
252
|
+
await createWorkAppSlackWorkspace(runDbClient_default)({
|
|
253
|
+
tenantId,
|
|
254
|
+
slackTeamId: workspaceData.teamId,
|
|
255
|
+
slackEnterpriseId: workspaceData.enterpriseId,
|
|
256
|
+
slackAppId: workspaceData.appId,
|
|
257
|
+
slackTeamName: workspaceData.teamName,
|
|
258
|
+
nangoConnectionId: nangoResult.connectionId,
|
|
259
|
+
status: "active"
|
|
260
|
+
});
|
|
261
|
+
logger.info({
|
|
262
|
+
teamId: workspaceData.teamId,
|
|
263
|
+
tenantId
|
|
264
|
+
}, "Persisted workspace installation to database");
|
|
265
|
+
} catch (dbError) {
|
|
266
|
+
const dbErrorMessage = dbError instanceof Error ? dbError.message : String(dbError);
|
|
267
|
+
if (dbErrorMessage.includes("duplicate key") || dbErrorMessage.includes("unique constraint")) logger.info({
|
|
268
|
+
teamId: workspaceData.teamId,
|
|
269
|
+
tenantId
|
|
270
|
+
}, "Workspace already exists in database");
|
|
271
|
+
else {
|
|
272
|
+
logger.error({
|
|
273
|
+
error: dbErrorMessage,
|
|
274
|
+
teamId: workspaceData.teamId
|
|
275
|
+
}, "Failed to persist workspace to database, rolling back Nango connection");
|
|
276
|
+
try {
|
|
277
|
+
await deleteWorkspaceInstallation(nangoResult.connectionId);
|
|
278
|
+
} catch (rollbackError) {
|
|
279
|
+
logger.error({
|
|
280
|
+
error: rollbackError,
|
|
281
|
+
connectionId: nangoResult.connectionId
|
|
282
|
+
}, "Failed to rollback Nango connection after DB failure");
|
|
283
|
+
}
|
|
284
|
+
return c.redirect(`${dashboardUrl}?error=installation_failed`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} else logger.warn({ teamId: workspaceData.teamId }, "Failed to store in Nango, falling back to memory");
|
|
288
|
+
setBotTokenForTeam(workspaceData.teamId, {
|
|
289
|
+
botToken: workspaceData.botToken,
|
|
290
|
+
teamName: workspaceData.teamName || "",
|
|
291
|
+
installedAt: workspaceData.installedAt
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
logger.info({
|
|
295
|
+
teamId: workspaceData.teamId,
|
|
296
|
+
teamName: workspaceData.teamName
|
|
297
|
+
}, "Slack workspace installation successful");
|
|
298
|
+
const safeWorkspaceData = {
|
|
299
|
+
ok: workspaceData.ok,
|
|
300
|
+
teamId: workspaceData.teamId,
|
|
301
|
+
teamName: workspaceData.teamName,
|
|
302
|
+
teamDomain: workspaceData.teamDomain,
|
|
303
|
+
enterpriseId: workspaceData.enterpriseId,
|
|
304
|
+
enterpriseName: workspaceData.enterpriseName,
|
|
305
|
+
isEnterpriseInstall: workspaceData.isEnterpriseInstall,
|
|
306
|
+
botUserId: workspaceData.botUserId,
|
|
307
|
+
botScopes: workspaceData.botScopes,
|
|
308
|
+
installerUserId: workspaceData.installerUserId,
|
|
309
|
+
installedAt: workspaceData.installedAt,
|
|
310
|
+
connectionId: workspaceData.teamId ? computeWorkspaceConnectionId({
|
|
311
|
+
teamId: workspaceData.teamId,
|
|
312
|
+
enterpriseId: workspaceData.enterpriseId
|
|
313
|
+
}) : void 0
|
|
314
|
+
};
|
|
315
|
+
const encodedData = encodeURIComponent(JSON.stringify(safeWorkspaceData));
|
|
316
|
+
return c.redirect(`${dashboardUrl}?success=true&workspace=${encodedData}`);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
logger.error({ error: err }, "Slack OAuth callback error");
|
|
319
|
+
return c.redirect(`${dashboardUrl}?error=callback_error`);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
var oauth_default = app;
|
|
323
|
+
|
|
324
|
+
//#endregion
|
|
325
|
+
export { createOAuthState, oauth_default as default, getBotTokenForTeam, getStateSigningSecret, parseOAuthState, setBotTokenForTeam };
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { WorkAppsVariables } from "../types.js";
|
|
2
|
+
import { OpenAPIHono } from "@hono/zod-openapi";
|
|
3
|
+
|
|
4
|
+
//#region src/slack/routes/users.d.ts
|
|
5
|
+
|
|
6
|
+
declare const app: OpenAPIHono<{
|
|
7
|
+
Variables: WorkAppsVariables;
|
|
8
|
+
}, {}, "/">;
|
|
9
|
+
//#endregion
|
|
10
|
+
export { app as default };
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import { getLogger } from "../../logger.js";
|
|
2
|
+
import runDbClient_default from "../../db/runDbClient.js";
|
|
3
|
+
import { createConnectSession } from "../services/nango.js";
|
|
4
|
+
import "../services/index.js";
|
|
5
|
+
import { OpenAPIHono, createRoute, z } from "@hono/zod-openapi";
|
|
6
|
+
import { createWorkAppSlackUserMapping, deleteWorkAppSlackUserMapping, findWorkAppSlackUserMapping, findWorkAppSlackUserMappingByInkeepUserId, verifySlackLinkToken } from "@inkeep/agents-core";
|
|
7
|
+
|
|
8
|
+
//#region src/slack/routes/users.ts
|
|
9
|
+
/**
|
|
10
|
+
* Slack User Routes
|
|
11
|
+
*
|
|
12
|
+
* Endpoints for user linking:
|
|
13
|
+
* - GET /link-status - Check link status
|
|
14
|
+
* - POST /link/verify-token - Verify JWT link token (primary linking method)
|
|
15
|
+
* - POST /connect - Create Nango session
|
|
16
|
+
* - POST /disconnect - Disconnect user
|
|
17
|
+
*/
|
|
18
|
+
const logger = getLogger("slack-users");
|
|
19
|
+
/**
|
|
20
|
+
* Verify the authenticated caller matches the requested userId.
|
|
21
|
+
* System tokens and API keys are allowed to act on behalf of any user.
|
|
22
|
+
*/
|
|
23
|
+
function isAuthorizedForUser(c, requestedUserId) {
|
|
24
|
+
const sessionUserId = c.get("userId");
|
|
25
|
+
if (!sessionUserId) return false;
|
|
26
|
+
if (sessionUserId === requestedUserId) return true;
|
|
27
|
+
if (sessionUserId === "system" || sessionUserId.startsWith("apikey:")) return true;
|
|
28
|
+
if (sessionUserId === "dev-user" && (process.env.ENVIRONMENT === "development" || process.env.ENVIRONMENT === "test")) return true;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
const app = new OpenAPIHono();
|
|
32
|
+
app.openapi(createRoute({
|
|
33
|
+
method: "get",
|
|
34
|
+
path: "/link-status",
|
|
35
|
+
summary: "Check Link Status",
|
|
36
|
+
description: "Check if a Slack user is linked to an Inkeep account",
|
|
37
|
+
operationId: "slack-link-status",
|
|
38
|
+
tags: [
|
|
39
|
+
"Work Apps",
|
|
40
|
+
"Slack",
|
|
41
|
+
"Users"
|
|
42
|
+
],
|
|
43
|
+
request: { query: z.object({
|
|
44
|
+
slackUserId: z.string(),
|
|
45
|
+
slackTeamId: z.string(),
|
|
46
|
+
tenantId: z.string().optional().default("default")
|
|
47
|
+
}) },
|
|
48
|
+
responses: { 200: {
|
|
49
|
+
description: "Link status",
|
|
50
|
+
content: { "application/json": { schema: z.object({
|
|
51
|
+
linked: z.boolean(),
|
|
52
|
+
linkId: z.string().optional(),
|
|
53
|
+
linkedAt: z.string().optional(),
|
|
54
|
+
slackUsername: z.string().optional()
|
|
55
|
+
}) } }
|
|
56
|
+
} }
|
|
57
|
+
}), async (c) => {
|
|
58
|
+
const { slackUserId, slackTeamId, tenantId } = c.req.valid("query");
|
|
59
|
+
const sessionTenantId = c.get("tenantId");
|
|
60
|
+
if (sessionTenantId && sessionTenantId !== tenantId) return c.json({ linked: false });
|
|
61
|
+
const link = await findWorkAppSlackUserMapping(runDbClient_default)(tenantId, slackUserId, slackTeamId, "work-apps-slack");
|
|
62
|
+
if (link) return c.json({
|
|
63
|
+
linked: true,
|
|
64
|
+
linkId: link.id,
|
|
65
|
+
linkedAt: link.linkedAt,
|
|
66
|
+
slackUsername: link.slackUsername || void 0
|
|
67
|
+
});
|
|
68
|
+
return c.json({ linked: false });
|
|
69
|
+
});
|
|
70
|
+
app.openapi(createRoute({
|
|
71
|
+
method: "post",
|
|
72
|
+
path: "/link/verify-token",
|
|
73
|
+
summary: "Verify Link Token",
|
|
74
|
+
description: "Verify a JWT link token and create user mapping",
|
|
75
|
+
operationId: "slack-verify-link-token",
|
|
76
|
+
tags: [
|
|
77
|
+
"Work Apps",
|
|
78
|
+
"Slack",
|
|
79
|
+
"Users"
|
|
80
|
+
],
|
|
81
|
+
request: { body: { content: { "application/json": { schema: z.object({
|
|
82
|
+
token: z.string().min(1),
|
|
83
|
+
userId: z.string().min(1),
|
|
84
|
+
userEmail: z.string().email().optional()
|
|
85
|
+
}) } } } },
|
|
86
|
+
responses: {
|
|
87
|
+
200: {
|
|
88
|
+
description: "Link successful",
|
|
89
|
+
content: { "application/json": { schema: z.object({
|
|
90
|
+
success: z.boolean(),
|
|
91
|
+
linkId: z.string().optional(),
|
|
92
|
+
slackUsername: z.string().optional(),
|
|
93
|
+
slackTeamId: z.string().optional(),
|
|
94
|
+
tenantId: z.string().optional()
|
|
95
|
+
}) } }
|
|
96
|
+
},
|
|
97
|
+
400: { description: "Invalid or expired token" },
|
|
98
|
+
409: { description: "Account already linked" }
|
|
99
|
+
}
|
|
100
|
+
}), async (c) => {
|
|
101
|
+
const body = c.req.valid("json");
|
|
102
|
+
try {
|
|
103
|
+
const verifyResult = await verifySlackLinkToken(body.token);
|
|
104
|
+
if (!verifyResult.valid || !verifyResult.payload) {
|
|
105
|
+
logger.warn({ error: verifyResult.error }, "Invalid link token");
|
|
106
|
+
return c.json({ error: verifyResult.error || "Invalid or expired link token. Please run /inkeep link in Slack to get a new one." }, 400);
|
|
107
|
+
}
|
|
108
|
+
const { tenantId, slack } = verifyResult.payload;
|
|
109
|
+
const { teamId, userId: slackUserId, enterpriseId, username } = slack;
|
|
110
|
+
const sessionUserId = c.get("userId");
|
|
111
|
+
const inkeepUserId = sessionUserId && sessionUserId !== "dev-user" && !sessionUserId.startsWith("apikey:") && sessionUserId !== "system" ? sessionUserId : body.userId;
|
|
112
|
+
const existingLink = await findWorkAppSlackUserMapping(runDbClient_default)(tenantId, slackUserId, teamId, "work-apps-slack");
|
|
113
|
+
if (existingLink && existingLink.inkeepUserId === inkeepUserId) {
|
|
114
|
+
logger.info({
|
|
115
|
+
slackUserId,
|
|
116
|
+
tenantId,
|
|
117
|
+
inkeepUserId: body.userId
|
|
118
|
+
}, "Slack user already linked to same account");
|
|
119
|
+
return c.json({
|
|
120
|
+
success: true,
|
|
121
|
+
linkId: existingLink.id,
|
|
122
|
+
slackUsername: existingLink.slackUsername || void 0,
|
|
123
|
+
slackTeamId: teamId,
|
|
124
|
+
tenantId
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (existingLink) logger.info({
|
|
128
|
+
slackUserId,
|
|
129
|
+
existingUserId: existingLink.inkeepUserId,
|
|
130
|
+
newUserId: inkeepUserId,
|
|
131
|
+
tenantId
|
|
132
|
+
}, "Slack user already linked, updating to new user");
|
|
133
|
+
const slackUserMapping = await createWorkAppSlackUserMapping(runDbClient_default)({
|
|
134
|
+
tenantId,
|
|
135
|
+
clientId: "work-apps-slack",
|
|
136
|
+
slackUserId,
|
|
137
|
+
slackTeamId: teamId,
|
|
138
|
+
slackEnterpriseId: enterpriseId,
|
|
139
|
+
slackUsername: username,
|
|
140
|
+
slackEmail: body.userEmail,
|
|
141
|
+
inkeepUserId
|
|
142
|
+
});
|
|
143
|
+
logger.info({
|
|
144
|
+
slackUserId,
|
|
145
|
+
slackTeamId: teamId,
|
|
146
|
+
tenantId,
|
|
147
|
+
inkeepUserId: body.userId,
|
|
148
|
+
linkId: slackUserMapping.id
|
|
149
|
+
}, "Successfully linked Slack user to Inkeep account via JWT token");
|
|
150
|
+
return c.json({
|
|
151
|
+
success: true,
|
|
152
|
+
linkId: slackUserMapping.id,
|
|
153
|
+
slackUsername: username || void 0,
|
|
154
|
+
slackTeamId: teamId,
|
|
155
|
+
tenantId
|
|
156
|
+
});
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
159
|
+
if (errorMessage.includes("duplicate key") || errorMessage.includes("unique constraint")) {
|
|
160
|
+
logger.warn({ userId: body.userId }, "Slack user already linked");
|
|
161
|
+
return c.json({ error: "This Slack account is already linked to an Inkeep account." }, 409);
|
|
162
|
+
}
|
|
163
|
+
logger.error({
|
|
164
|
+
error,
|
|
165
|
+
userId: body.userId
|
|
166
|
+
}, "Failed to verify link token");
|
|
167
|
+
return c.json({ error: "Failed to verify link. Please try again." }, 500);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
app.openapi(createRoute({
|
|
171
|
+
method: "post",
|
|
172
|
+
path: "/connect",
|
|
173
|
+
summary: "Create Nango Connect Session",
|
|
174
|
+
description: "Create a Nango session for Slack OAuth flow. Used by the dashboard.",
|
|
175
|
+
operationId: "slack-user-connect",
|
|
176
|
+
tags: [
|
|
177
|
+
"Work Apps",
|
|
178
|
+
"Slack",
|
|
179
|
+
"Users"
|
|
180
|
+
],
|
|
181
|
+
request: { body: { content: { "application/json": { schema: z.object({
|
|
182
|
+
userId: z.string().describe("Inkeep user ID"),
|
|
183
|
+
userEmail: z.string().optional().describe("User email"),
|
|
184
|
+
userName: z.string().optional().describe("User display name"),
|
|
185
|
+
tenantId: z.string().optional().describe("Tenant ID")
|
|
186
|
+
}) } } } },
|
|
187
|
+
responses: {
|
|
188
|
+
200: {
|
|
189
|
+
description: "Connect session created",
|
|
190
|
+
content: { "application/json": { schema: z.object({
|
|
191
|
+
sessionToken: z.string().optional(),
|
|
192
|
+
connectUrl: z.string().optional()
|
|
193
|
+
}) } }
|
|
194
|
+
},
|
|
195
|
+
400: { description: "Missing required userId" },
|
|
196
|
+
500: { description: "Failed to create session" }
|
|
197
|
+
}
|
|
198
|
+
}), async (c) => {
|
|
199
|
+
const { userId, userEmail, userName, tenantId } = c.req.valid("json");
|
|
200
|
+
if (!userId) return c.json({ error: "userId is required" }, 400);
|
|
201
|
+
if (!isAuthorizedForUser(c, userId)) return c.json({ error: "Can only create sessions for your own account" }, 403);
|
|
202
|
+
logger.debug({
|
|
203
|
+
userId,
|
|
204
|
+
userEmail,
|
|
205
|
+
userName
|
|
206
|
+
}, "Creating Nango connect session");
|
|
207
|
+
const session = await createConnectSession({
|
|
208
|
+
userId,
|
|
209
|
+
userEmail,
|
|
210
|
+
userName,
|
|
211
|
+
tenantId: tenantId || "default"
|
|
212
|
+
});
|
|
213
|
+
if (!session) return c.json({ error: "Failed to create session" }, 500);
|
|
214
|
+
return c.json(session);
|
|
215
|
+
});
|
|
216
|
+
app.openapi(createRoute({
|
|
217
|
+
method: "post",
|
|
218
|
+
path: "/disconnect",
|
|
219
|
+
summary: "Disconnect User",
|
|
220
|
+
description: "Unlink a Slack user from their Inkeep account.",
|
|
221
|
+
operationId: "slack-user-disconnect",
|
|
222
|
+
tags: [
|
|
223
|
+
"Work Apps",
|
|
224
|
+
"Slack",
|
|
225
|
+
"Users"
|
|
226
|
+
],
|
|
227
|
+
request: { body: { content: { "application/json": { schema: z.object({
|
|
228
|
+
userId: z.string().optional().describe("Inkeep user ID"),
|
|
229
|
+
slackUserId: z.string().optional().describe("Slack user ID"),
|
|
230
|
+
slackTeamId: z.string().optional().describe("Slack team ID"),
|
|
231
|
+
tenantId: z.string().optional().describe("Tenant ID")
|
|
232
|
+
}) } } } },
|
|
233
|
+
responses: {
|
|
234
|
+
200: {
|
|
235
|
+
description: "User disconnected",
|
|
236
|
+
content: { "application/json": { schema: z.object({ success: z.boolean() }) } }
|
|
237
|
+
},
|
|
238
|
+
400: { description: "Missing required identifiers" },
|
|
239
|
+
404: { description: "No connection found" },
|
|
240
|
+
500: { description: "Failed to disconnect" }
|
|
241
|
+
}
|
|
242
|
+
}), async (c) => {
|
|
243
|
+
const { userId, slackUserId, slackTeamId, tenantId } = c.req.valid("json");
|
|
244
|
+
if (!userId && !(slackUserId && slackTeamId)) return c.json({ error: "Either userId or (slackUserId + slackTeamId) is required" }, 400);
|
|
245
|
+
if (userId && !isAuthorizedForUser(c, userId)) return c.json({ error: "Can only disconnect your own account" }, 403);
|
|
246
|
+
try {
|
|
247
|
+
const effectiveTenantId = tenantId || "default";
|
|
248
|
+
if (slackUserId && slackTeamId) {
|
|
249
|
+
if (await deleteWorkAppSlackUserMapping(runDbClient_default)(effectiveTenantId, slackUserId, slackTeamId, "work-apps-slack")) {
|
|
250
|
+
logger.info({
|
|
251
|
+
slackUserId,
|
|
252
|
+
slackTeamId,
|
|
253
|
+
tenantId: effectiveTenantId
|
|
254
|
+
}, "User unlinked");
|
|
255
|
+
return c.json({ success: true });
|
|
256
|
+
}
|
|
257
|
+
return c.json({ error: "No link found for this user" }, 404);
|
|
258
|
+
}
|
|
259
|
+
if (userId) {
|
|
260
|
+
const userMappings = await findWorkAppSlackUserMappingByInkeepUserId(runDbClient_default)(userId);
|
|
261
|
+
if (userMappings.length === 0) return c.json({ error: "No link found for this user" }, 404);
|
|
262
|
+
let deletedCount = 0;
|
|
263
|
+
for (const mapping of userMappings) if (await deleteWorkAppSlackUserMapping(runDbClient_default)(mapping.tenantId, mapping.slackUserId, mapping.slackTeamId, "work-apps-slack")) deletedCount++;
|
|
264
|
+
logger.info({
|
|
265
|
+
userId,
|
|
266
|
+
deletedCount
|
|
267
|
+
}, "User disconnected from Slack");
|
|
268
|
+
return c.json({ success: true });
|
|
269
|
+
}
|
|
270
|
+
return c.json({ error: "No connection found for this user" }, 404);
|
|
271
|
+
} catch (error) {
|
|
272
|
+
logger.error({
|
|
273
|
+
error,
|
|
274
|
+
userId,
|
|
275
|
+
slackUserId,
|
|
276
|
+
slackTeamId
|
|
277
|
+
}, "Failed to disconnect from Slack");
|
|
278
|
+
return c.json({ error: "Failed to disconnect" }, 500);
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
app.openapi(createRoute({
|
|
282
|
+
method: "get",
|
|
283
|
+
path: "/status",
|
|
284
|
+
summary: "Get Connection Status",
|
|
285
|
+
description: "Check if an Inkeep user has a linked Slack account.",
|
|
286
|
+
operationId: "slack-user-status",
|
|
287
|
+
tags: [
|
|
288
|
+
"Work Apps",
|
|
289
|
+
"Slack",
|
|
290
|
+
"Users"
|
|
291
|
+
],
|
|
292
|
+
request: { query: z.object({ userId: z.string().describe("Inkeep user ID") }) },
|
|
293
|
+
responses: {
|
|
294
|
+
200: {
|
|
295
|
+
description: "Connection status",
|
|
296
|
+
content: { "application/json": { schema: z.object({
|
|
297
|
+
connected: z.boolean(),
|
|
298
|
+
connection: z.object({
|
|
299
|
+
connectionId: z.string(),
|
|
300
|
+
appUserId: z.string(),
|
|
301
|
+
appUserEmail: z.string(),
|
|
302
|
+
slackDisplayName: z.string(),
|
|
303
|
+
linkedAt: z.string(),
|
|
304
|
+
tenantId: z.string(),
|
|
305
|
+
slackUserId: z.string(),
|
|
306
|
+
slackTeamId: z.string()
|
|
307
|
+
}).nullable()
|
|
308
|
+
}) } }
|
|
309
|
+
},
|
|
310
|
+
400: { description: "Missing userId" },
|
|
311
|
+
500: { description: "Failed to get status" }
|
|
312
|
+
}
|
|
313
|
+
}), async (c) => {
|
|
314
|
+
const { userId: appUserId } = c.req.valid("query");
|
|
315
|
+
if (!isAuthorizedForUser(c, appUserId)) return c.json({ error: "Can only query your own connection status" }, 403);
|
|
316
|
+
try {
|
|
317
|
+
const userMappings = await findWorkAppSlackUserMappingByInkeepUserId(runDbClient_default)(appUserId);
|
|
318
|
+
if (userMappings.length === 0) {
|
|
319
|
+
logger.debug({
|
|
320
|
+
appUserId,
|
|
321
|
+
connected: false
|
|
322
|
+
}, "Retrieved connection status from DB");
|
|
323
|
+
return c.json({
|
|
324
|
+
connected: false,
|
|
325
|
+
connection: null
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
const mostRecent = userMappings.sort((a, b) => new Date(b.linkedAt).getTime() - new Date(a.linkedAt).getTime())[0];
|
|
329
|
+
const connection = {
|
|
330
|
+
connectionId: mostRecent.id,
|
|
331
|
+
appUserId: mostRecent.inkeepUserId,
|
|
332
|
+
appUserEmail: mostRecent.slackEmail || "",
|
|
333
|
+
slackDisplayName: mostRecent.slackUsername || "",
|
|
334
|
+
linkedAt: mostRecent.linkedAt,
|
|
335
|
+
tenantId: mostRecent.tenantId,
|
|
336
|
+
slackUserId: mostRecent.slackUserId,
|
|
337
|
+
slackTeamId: mostRecent.slackTeamId
|
|
338
|
+
};
|
|
339
|
+
logger.debug({
|
|
340
|
+
appUserId,
|
|
341
|
+
connected: true
|
|
342
|
+
}, "Retrieved connection status from DB");
|
|
343
|
+
return c.json({
|
|
344
|
+
connected: true,
|
|
345
|
+
connection
|
|
346
|
+
});
|
|
347
|
+
} catch (error) {
|
|
348
|
+
logger.error({
|
|
349
|
+
error,
|
|
350
|
+
appUserId
|
|
351
|
+
}, "Failed to get connection status");
|
|
352
|
+
return c.json({ error: "Failed to get connection status" }, 500);
|
|
353
|
+
}
|
|
354
|
+
});
|
|
355
|
+
var users_default = app;
|
|
356
|
+
|
|
357
|
+
//#endregion
|
|
358
|
+
export { users_default as default };
|