@inkeep/agents-work-apps 0.47.5 → 0.48.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.
Files changed (65) hide show
  1. package/dist/env.d.ts +24 -2
  2. package/dist/env.js +13 -2
  3. package/dist/github/index.d.ts +3 -3
  4. package/dist/github/mcp/auth.d.ts +2 -2
  5. package/dist/github/mcp/index.d.ts +2 -2
  6. package/dist/github/mcp/index.js +23 -34
  7. package/dist/github/mcp/schemas.d.ts +1 -1
  8. package/dist/github/routes/setup.d.ts +2 -2
  9. package/dist/github/routes/tokenExchange.d.ts +2 -2
  10. package/dist/github/routes/webhooks.d.ts +2 -2
  11. package/dist/slack/i18n/index.d.ts +2 -0
  12. package/dist/slack/i18n/index.js +3 -0
  13. package/dist/slack/i18n/strings.d.ts +73 -0
  14. package/dist/slack/i18n/strings.js +67 -0
  15. package/dist/slack/index.d.ts +18 -0
  16. package/dist/slack/index.js +28 -0
  17. package/dist/slack/middleware/permissions.d.ts +31 -0
  18. package/dist/slack/middleware/permissions.js +167 -0
  19. package/dist/slack/routes/events.d.ts +10 -0
  20. package/dist/slack/routes/events.js +551 -0
  21. package/dist/slack/routes/index.d.ts +10 -0
  22. package/dist/slack/routes/index.js +47 -0
  23. package/dist/slack/routes/oauth.d.ts +20 -0
  24. package/dist/slack/routes/oauth.js +344 -0
  25. package/dist/slack/routes/users.d.ts +10 -0
  26. package/dist/slack/routes/users.js +365 -0
  27. package/dist/slack/routes/workspaces.d.ts +10 -0
  28. package/dist/slack/routes/workspaces.js +909 -0
  29. package/dist/slack/services/agent-resolution.d.ts +41 -0
  30. package/dist/slack/services/agent-resolution.js +99 -0
  31. package/dist/slack/services/blocks/index.d.ts +73 -0
  32. package/dist/slack/services/blocks/index.js +103 -0
  33. package/dist/slack/services/client.d.ts +108 -0
  34. package/dist/slack/services/client.js +232 -0
  35. package/dist/slack/services/commands/index.d.ts +19 -0
  36. package/dist/slack/services/commands/index.js +553 -0
  37. package/dist/slack/services/events/app-mention.d.ts +40 -0
  38. package/dist/slack/services/events/app-mention.js +297 -0
  39. package/dist/slack/services/events/block-actions.d.ts +40 -0
  40. package/dist/slack/services/events/block-actions.js +265 -0
  41. package/dist/slack/services/events/index.d.ts +6 -0
  42. package/dist/slack/services/events/index.js +7 -0
  43. package/dist/slack/services/events/modal-submission.d.ts +30 -0
  44. package/dist/slack/services/events/modal-submission.js +400 -0
  45. package/dist/slack/services/events/streaming.d.ts +26 -0
  46. package/dist/slack/services/events/streaming.js +255 -0
  47. package/dist/slack/services/events/utils.d.ts +146 -0
  48. package/dist/slack/services/events/utils.js +370 -0
  49. package/dist/slack/services/index.d.ts +16 -0
  50. package/dist/slack/services/index.js +16 -0
  51. package/dist/slack/services/modals.d.ts +86 -0
  52. package/dist/slack/services/modals.js +355 -0
  53. package/dist/slack/services/nango.d.ts +85 -0
  54. package/dist/slack/services/nango.js +476 -0
  55. package/dist/slack/services/security.d.ts +35 -0
  56. package/dist/slack/services/security.js +65 -0
  57. package/dist/slack/services/types.d.ts +26 -0
  58. package/dist/slack/services/types.js +1 -0
  59. package/dist/slack/services/workspace-tokens.d.ts +25 -0
  60. package/dist/slack/services/workspace-tokens.js +27 -0
  61. package/dist/slack/tracer.d.ts +40 -0
  62. package/dist/slack/tracer.js +39 -0
  63. package/dist/slack/types.d.ts +10 -0
  64. package/dist/slack/types.js +1 -0
  65. package/package.json +11 -2
@@ -0,0 +1,476 @@
1
+ import { env } from "../../env.js";
2
+ import { getLogger } from "../../logger.js";
3
+ import runDbClient_default from "../../db/runDbClient.js";
4
+ import { findWorkAppSlackWorkspaceBySlackTeamId } from "@inkeep/agents-core";
5
+ import { Nango } from "@nangohq/node";
6
+
7
+ //#region src/slack/services/nango.ts
8
+ /**
9
+ * Nango Service for Slack OAuth Token Management
10
+ *
11
+ * ARCHITECTURE NOTE: PostgreSQL is the authoritative source of truth for:
12
+ * - User linking data (work_app_slack_user_mappings table)
13
+ * - Workspace metadata (work_app_slack_workspaces table)
14
+ * - Channel agent configs (work_app_slack_channel_agent_configs table)
15
+ *
16
+ * Nango is used ONLY for:
17
+ * - OAuth token storage and refresh (bot tokens for workspaces)
18
+ * - OAuth flow management (createConnectSession)
19
+ * - Workspace default agent config (stored in connection metadata)
20
+ *
21
+ * PERFORMANCE: Workspace lookups use PostgreSQL first (O(1)), with Nango
22
+ * fallback only when needed for bot token retrieval.
23
+ *
24
+ * For user data, use the PostgreSQL data access layer:
25
+ * @see packages/agents-core/src/data-access/runtime/workAppSlack.ts
26
+ */
27
+ const MAX_WORKSPACE_CACHE_SIZE = 1e3;
28
+ const workspaceConnectionCache = /* @__PURE__ */ new Map();
29
+ const CACHE_TTL_MS = 6e4;
30
+ const logger = getLogger("slack-nango");
31
+ /**
32
+ * Retry a function with exponential backoff for transient failures.
33
+ * Retries on AbortError (timeout) and 5xx HTTP errors.
34
+ */
35
+ async function retryWithBackoff(fn, maxAttempts = 3) {
36
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) try {
37
+ return await fn();
38
+ } catch (error) {
39
+ const isTimeout = error.name === "AbortError";
40
+ const isServerError = typeof error.status === "number" && error.status >= 500;
41
+ if (!(isTimeout || isServerError) || attempt === maxAttempts) throw error;
42
+ const delay = Math.min(500 * 2 ** (attempt - 1), 2e3) + Math.random() * 100;
43
+ logger.warn({
44
+ attempt,
45
+ maxAttempts,
46
+ isTimeout,
47
+ delay: Math.round(delay)
48
+ }, "Retrying Nango API call after transient failure");
49
+ await new Promise((resolve) => setTimeout(resolve, delay));
50
+ }
51
+ throw new Error("Unreachable");
52
+ }
53
+ /**
54
+ * Evict expired entries from workspace cache to bound memory.
55
+ */
56
+ function evictWorkspaceCache() {
57
+ if (workspaceConnectionCache.size <= MAX_WORKSPACE_CACHE_SIZE) return;
58
+ const now = Date.now();
59
+ for (const [key, entry] of workspaceConnectionCache) if (entry.expiresAt <= now) workspaceConnectionCache.delete(key);
60
+ if (workspaceConnectionCache.size > MAX_WORKSPACE_CACHE_SIZE) {
61
+ const excess = workspaceConnectionCache.size - MAX_WORKSPACE_CACHE_SIZE;
62
+ const keys = workspaceConnectionCache.keys();
63
+ for (let i = 0; i < excess; i++) {
64
+ const { value } = keys.next();
65
+ if (value) workspaceConnectionCache.delete(value);
66
+ }
67
+ }
68
+ }
69
+ function getSlackNango() {
70
+ const secretKey = env.NANGO_SLACK_SECRET_KEY || env.NANGO_SECRET_KEY;
71
+ if (!secretKey) throw new Error("NANGO_SLACK_SECRET_KEY or NANGO_SECRET_KEY is required for Slack integration");
72
+ return new Nango({
73
+ secretKey,
74
+ host: env.NANGO_SERVER_URL
75
+ });
76
+ }
77
+ function getSlackIntegrationId() {
78
+ return env.NANGO_SLACK_INTEGRATION_ID || "slack-agent";
79
+ }
80
+ async function createConnectSession(params) {
81
+ try {
82
+ const nango = getSlackNango();
83
+ const integrationId = getSlackIntegrationId();
84
+ const session = await nango.createConnectSession({
85
+ end_user: {
86
+ id: params.userId,
87
+ email: params.userEmail,
88
+ display_name: params.userName
89
+ },
90
+ organization: {
91
+ id: params.tenantId,
92
+ display_name: params.tenantId
93
+ },
94
+ allowed_integrations: [integrationId]
95
+ });
96
+ logger.info({
97
+ userId: params.userId,
98
+ userEmail: params.userEmail,
99
+ integrationId
100
+ }, "Created Nango connect session");
101
+ return { sessionToken: session.data.token };
102
+ } catch (error) {
103
+ logger.error({ error }, "Failed to create Nango connect session");
104
+ return null;
105
+ }
106
+ }
107
+ async function getConnectionAccessToken(connectionId) {
108
+ try {
109
+ const nango = getSlackNango();
110
+ const integrationId = getSlackIntegrationId();
111
+ return (await nango.getConnection(integrationId, connectionId)).credentials?.access_token || null;
112
+ } catch (error) {
113
+ logger.error({
114
+ error,
115
+ connectionId
116
+ }, "Failed to get connection access token");
117
+ return null;
118
+ }
119
+ }
120
+ /**
121
+ * Find a workspace connection by Slack team ID.
122
+ * Uses PostgreSQL first (O(1)) with in-memory caching, then falls back to Nango.
123
+ *
124
+ * Performance: This function is called on every @mention and command.
125
+ * The PostgreSQL-first approach with caching provides O(1) lookups.
126
+ */
127
+ async function findWorkspaceConnectionByTeamId(teamId) {
128
+ const cached = workspaceConnectionCache.get(teamId);
129
+ if (cached && cached.expiresAt > Date.now()) {
130
+ logger.debug({ teamId }, "Workspace connection cache hit");
131
+ return cached.connection;
132
+ }
133
+ try {
134
+ const dbWorkspace = await findWorkAppSlackWorkspaceBySlackTeamId(runDbClient_default)(teamId);
135
+ if (dbWorkspace?.nangoConnectionId) {
136
+ const botToken = await getConnectionAccessToken(dbWorkspace.nangoConnectionId);
137
+ if (botToken) {
138
+ const connection = {
139
+ connectionId: dbWorkspace.nangoConnectionId,
140
+ teamId,
141
+ teamName: dbWorkspace.slackTeamName || void 0,
142
+ botToken,
143
+ tenantId: dbWorkspace.tenantId,
144
+ defaultAgent: void 0
145
+ };
146
+ const defaultAgentConfig = await getWorkspaceDefaultAgentFromNangoByConnectionId(dbWorkspace.nangoConnectionId);
147
+ if (defaultAgentConfig) connection.defaultAgent = defaultAgentConfig;
148
+ evictWorkspaceCache();
149
+ workspaceConnectionCache.set(teamId, {
150
+ connection,
151
+ expiresAt: Date.now() + CACHE_TTL_MS
152
+ });
153
+ logger.debug({ teamId }, "Workspace connection found via PostgreSQL");
154
+ return connection;
155
+ }
156
+ }
157
+ logger.debug({ teamId }, "PostgreSQL lookup failed, falling back to Nango iteration");
158
+ return findWorkspaceConnectionByTeamIdFromNango(teamId);
159
+ } catch (error) {
160
+ logger.error({
161
+ error,
162
+ teamId
163
+ }, "Failed to find workspace connection by team ID");
164
+ return findWorkspaceConnectionByTeamIdFromNango(teamId);
165
+ }
166
+ }
167
+ async function getWorkspaceDefaultAgentFromNangoByConnectionId(connectionId) {
168
+ try {
169
+ const nango = getSlackNango();
170
+ const integrationId = getSlackIntegrationId();
171
+ const metadata = (await nango.getConnection(integrationId, connectionId)).metadata;
172
+ if (metadata?.default_agent) try {
173
+ return JSON.parse(metadata.default_agent);
174
+ } catch {
175
+ return null;
176
+ }
177
+ return null;
178
+ } catch (error) {
179
+ logger.warn({ error }, "Failed to get workspace default agent from Nango");
180
+ return null;
181
+ }
182
+ }
183
+ /**
184
+ * Legacy fallback: Find workspace by iterating all Nango connections.
185
+ * Only used when PostgreSQL lookup fails.
186
+ */
187
+ async function findWorkspaceConnectionByTeamIdFromNango(teamId) {
188
+ try {
189
+ const nango = getSlackNango();
190
+ const integrationId = getSlackIntegrationId();
191
+ const connections = await nango.listConnections();
192
+ for (const conn of connections.connections) if (conn.provider_config_key === integrationId) try {
193
+ const fullConn = await nango.getConnection(integrationId, conn.connection_id);
194
+ const connectionConfig = fullConn.connection_config;
195
+ const metadata = fullConn.metadata;
196
+ const credentials = fullConn;
197
+ if ((connectionConfig?.["team.id"] || metadata?.slack_team_id) === teamId && credentials.credentials?.access_token) {
198
+ let defaultAgent;
199
+ if (metadata?.default_agent) try {
200
+ defaultAgent = JSON.parse(metadata.default_agent);
201
+ } catch {}
202
+ const connection = {
203
+ connectionId: conn.connection_id,
204
+ teamId,
205
+ teamName: metadata?.slack_team_name,
206
+ botToken: credentials.credentials.access_token,
207
+ tenantId: metadata?.tenant_id || metadata?.inkeep_tenant_id || "",
208
+ defaultAgent
209
+ };
210
+ evictWorkspaceCache();
211
+ workspaceConnectionCache.set(teamId, {
212
+ connection,
213
+ expiresAt: Date.now() + CACHE_TTL_MS
214
+ });
215
+ return connection;
216
+ }
217
+ } catch (error) {
218
+ logger.warn({
219
+ error,
220
+ connectionId: conn.connection_id
221
+ }, "Failed to get Nango connection details");
222
+ }
223
+ return null;
224
+ } catch (error) {
225
+ logger.error({
226
+ error,
227
+ teamId
228
+ }, "Failed to find workspace connection from Nango");
229
+ return null;
230
+ }
231
+ }
232
+ function clearWorkspaceConnectionCache(teamId) {
233
+ if (teamId) workspaceConnectionCache.delete(teamId);
234
+ else workspaceConnectionCache.clear();
235
+ }
236
+ async function updateConnectionMetadata(connectionId, metadata) {
237
+ try {
238
+ const nango = getSlackNango();
239
+ const integrationId = getSlackIntegrationId();
240
+ await nango.updateMetadata(integrationId, connectionId, metadata);
241
+ return true;
242
+ } catch (error) {
243
+ logger.error({
244
+ error,
245
+ connectionId
246
+ }, "Failed to update connection metadata");
247
+ return false;
248
+ }
249
+ }
250
+ async function setWorkspaceDefaultAgent(teamId, defaultAgent) {
251
+ try {
252
+ const workspace = await findWorkspaceConnectionByTeamId(teamId);
253
+ if (!workspace) {
254
+ logger.warn({ teamId }, "No workspace connection found to set default agent");
255
+ return false;
256
+ }
257
+ const success = await updateConnectionMetadata(workspace.connectionId, { default_agent: defaultAgent ? JSON.stringify(defaultAgent) : "" });
258
+ if (success) clearWorkspaceConnectionCache(teamId);
259
+ return success;
260
+ } catch (error) {
261
+ logger.error({
262
+ error,
263
+ teamId
264
+ }, "Failed to set workspace default agent");
265
+ return false;
266
+ }
267
+ }
268
+ async function getWorkspaceDefaultAgentFromNango(teamId) {
269
+ try {
270
+ return (await findWorkspaceConnectionByTeamId(teamId))?.defaultAgent || null;
271
+ } catch (error) {
272
+ logger.error({
273
+ error,
274
+ teamId
275
+ }, "Failed to get workspace default agent");
276
+ return null;
277
+ }
278
+ }
279
+ /**
280
+ * Compute a stable, deterministic connection ID for a Slack workspace.
281
+ * Format: "T:<team_id>" or "E:<enterprise_id>:T:<team_id>" for Enterprise Grid
282
+ */
283
+ function computeWorkspaceConnectionId(params) {
284
+ const { teamId, enterpriseId } = params;
285
+ if (enterpriseId) return `E:${enterpriseId}:T:${teamId}`;
286
+ return `T:${teamId}`;
287
+ }
288
+ /**
289
+ * Store a workspace installation in Nango.
290
+ * Uses upsert semantics - will update if the connection already exists.
291
+ */
292
+ async function storeWorkspaceInstallation(data) {
293
+ const connectionId = computeWorkspaceConnectionId({
294
+ teamId: data.teamId,
295
+ enterpriseId: data.enterpriseId
296
+ });
297
+ try {
298
+ const integrationId = getSlackIntegrationId();
299
+ const secretKey = env.NANGO_SLACK_SECRET_KEY || env.NANGO_SECRET_KEY;
300
+ if (!secretKey) {
301
+ logger.error({}, "No Nango secret key available");
302
+ return {
303
+ connectionId,
304
+ success: false
305
+ };
306
+ }
307
+ const nangoApiUrl = env.NANGO_SERVER_URL || "https://api.nango.dev";
308
+ logger.info({
309
+ integrationId,
310
+ connectionId,
311
+ teamId: data.teamId,
312
+ teamName: data.teamName
313
+ }, "Importing connection to Nango");
314
+ const displayName = data.enterpriseName ? `${data.teamName || data.teamId} (${data.enterpriseName})` : data.teamName || data.teamId;
315
+ const workspaceUrl = data.workspaceUrl || (data.teamDomain ? `https://${data.teamDomain}.slack.com` : "");
316
+ const now = (/* @__PURE__ */ new Date()).toISOString();
317
+ const requestBody = {
318
+ provider_config_key: integrationId,
319
+ connection_id: connectionId,
320
+ credentials: {
321
+ type: "OAUTH2",
322
+ access_token: data.botToken
323
+ },
324
+ metadata: {
325
+ display_name: displayName,
326
+ connection_type: "workspace",
327
+ slack_team_id: data.teamId,
328
+ slack_team_name: data.teamName || "",
329
+ slack_team_domain: data.teamDomain || "",
330
+ slack_workspace_url: workspaceUrl,
331
+ slack_workspace_icon_url: data.workspaceIconUrl || "",
332
+ slack_enterprise_id: data.enterpriseId || "",
333
+ slack_enterprise_name: data.enterpriseName || "",
334
+ is_enterprise_install: String(data.isEnterpriseInstall || false),
335
+ slack_bot_user_id: data.botUserId || "",
336
+ slack_bot_scopes: data.botScopes || "",
337
+ slack_app_id: data.appId || "",
338
+ installed_by_slack_user_id: data.installerUserId || "",
339
+ installed_by_slack_user_name: data.installerUserName || "",
340
+ installed_at: now,
341
+ last_updated_at: now,
342
+ installation_source: data.installationSource || "dashboard",
343
+ inkeep_tenant_id: data.tenantId || "",
344
+ status: "active"
345
+ },
346
+ connection_config: { "team.id": data.teamId }
347
+ };
348
+ const response = await retryWithBackoff(async () => {
349
+ const controller = new AbortController();
350
+ const timeout = setTimeout(() => controller.abort(), 1e4);
351
+ try {
352
+ const res = await fetch(`${nangoApiUrl}/connections`, {
353
+ method: "POST",
354
+ headers: {
355
+ Authorization: `Bearer ${secretKey}`,
356
+ "Content-Type": "application/json"
357
+ },
358
+ body: JSON.stringify(requestBody),
359
+ signal: controller.signal
360
+ });
361
+ if (!res.ok && res.status >= 500) {
362
+ const errorBody = await res.text().catch(() => "Unknown error");
363
+ const err = /* @__PURE__ */ new Error(`Nango API error ${res.status}: ${errorBody}`);
364
+ err.status = res.status;
365
+ throw err;
366
+ }
367
+ return res;
368
+ } finally {
369
+ clearTimeout(timeout);
370
+ }
371
+ });
372
+ const responseText = await response.text();
373
+ if (!response.ok) {
374
+ logger.error({
375
+ status: response.status,
376
+ errorBody: responseText,
377
+ connectionId
378
+ }, "Failed to import connection to Nango");
379
+ return {
380
+ connectionId,
381
+ success: false
382
+ };
383
+ }
384
+ logger.info({
385
+ connectionId,
386
+ teamId: data.teamId,
387
+ teamName: data.teamName
388
+ }, "Stored workspace installation in Nango");
389
+ return {
390
+ connectionId,
391
+ success: true
392
+ };
393
+ } catch (error) {
394
+ logger.error({
395
+ error,
396
+ connectionId,
397
+ teamId: data.teamId
398
+ }, "Failed to store workspace installation");
399
+ return {
400
+ connectionId,
401
+ success: false
402
+ };
403
+ }
404
+ }
405
+ /**
406
+ * List all workspace installations from Nango.
407
+ */
408
+ async function listWorkspaceInstallations() {
409
+ try {
410
+ const nango = getSlackNango();
411
+ const integrationId = getSlackIntegrationId();
412
+ const connections = await nango.listConnections();
413
+ const workspaces = [];
414
+ for (const conn of connections.connections) if (conn.provider_config_key === integrationId) try {
415
+ const fullConn = await nango.getConnection(integrationId, conn.connection_id);
416
+ const metadata = fullConn.metadata;
417
+ const credentials = fullConn;
418
+ if (metadata?.connection_type === "workspace" && credentials.credentials?.access_token) {
419
+ let defaultAgent;
420
+ if (metadata?.default_agent) try {
421
+ defaultAgent = JSON.parse(metadata.default_agent);
422
+ } catch {}
423
+ workspaces.push({
424
+ connectionId: conn.connection_id,
425
+ teamId: metadata.slack_team_id || "",
426
+ teamName: metadata.slack_team_name,
427
+ botToken: credentials.credentials.access_token,
428
+ tenantId: metadata.tenant_id || metadata.inkeep_tenant_id || "",
429
+ defaultAgent
430
+ });
431
+ }
432
+ } catch (error) {
433
+ logger.warn({
434
+ error,
435
+ connectionId: conn.connection_id
436
+ }, "Failed to get Nango connection during list");
437
+ }
438
+ return workspaces;
439
+ } catch (error) {
440
+ logger.error({ error }, "Failed to list workspace installations");
441
+ return [];
442
+ }
443
+ }
444
+ /**
445
+ * Delete a workspace installation from Nango.
446
+ */
447
+ async function deleteWorkspaceInstallation(connectionId) {
448
+ try {
449
+ const nango = getSlackNango();
450
+ const integrationId = getSlackIntegrationId();
451
+ logger.info({
452
+ connectionId,
453
+ integrationId
454
+ }, "Attempting to delete workspace installation");
455
+ await nango.deleteConnection(integrationId, connectionId);
456
+ logger.info({ connectionId }, "Deleted workspace installation from Nango");
457
+ return true;
458
+ } catch (error) {
459
+ const errorObj = error;
460
+ const errorMessage = errorObj?.message || String(error);
461
+ const statusCode = errorObj?.status;
462
+ if (statusCode === 404 || errorMessage.includes("404") || errorMessage.includes("not found")) {
463
+ logger.warn({ connectionId }, "Connection not found in Nango, treating as already deleted");
464
+ return true;
465
+ }
466
+ logger.error({
467
+ error: errorMessage,
468
+ statusCode,
469
+ connectionId
470
+ }, "Failed to delete workspace installation");
471
+ return false;
472
+ }
473
+ }
474
+
475
+ //#endregion
476
+ export { clearWorkspaceConnectionCache, computeWorkspaceConnectionId, createConnectSession, deleteWorkspaceInstallation, findWorkspaceConnectionByTeamId, getConnectionAccessToken, getSlackIntegrationId, getSlackNango, getWorkspaceDefaultAgentFromNango, listWorkspaceInstallations, setWorkspaceDefaultAgent, storeWorkspaceInstallation, updateConnectionMetadata };
@@ -0,0 +1,35 @@
1
+ //#region src/slack/services/security.d.ts
2
+ /**
3
+ * Slack Security Utilities
4
+ *
5
+ * Provides security functions for verifying Slack requests and parsing payloads.
6
+ * All incoming Slack requests are verified using HMAC-SHA256 signatures.
7
+ */
8
+ /**
9
+ * Verify that a request originated from Slack using HMAC-SHA256 signature.
10
+ *
11
+ * @param signingSecret - The Slack signing secret from app settings
12
+ * @param requestBody - The raw request body string
13
+ * @param timestamp - The X-Slack-Request-Timestamp header value
14
+ * @param signature - The X-Slack-Signature header value
15
+ * @returns true if the signature is valid, false otherwise
16
+ */
17
+ declare function verifySlackRequest(signingSecret: string, requestBody: string, timestamp: string, signature: string): boolean;
18
+ /**
19
+ * Parse a URL-encoded Slack command body into key-value pairs.
20
+ *
21
+ * @param body - The URL-encoded request body from a slash command
22
+ * @returns Parsed parameters as a string record
23
+ */
24
+ declare function parseSlackCommandBody(body: string): Record<string, string>;
25
+ /**
26
+ * Parse a Slack event body based on content type.
27
+ * Handles both JSON and URL-encoded payloads (for interactive components).
28
+ *
29
+ * @param body - The raw request body
30
+ * @param contentType - The Content-Type header value
31
+ * @returns Parsed event payload
32
+ */
33
+ declare function parseSlackEventBody(body: string, contentType: string): Record<string, unknown>;
34
+ //#endregion
35
+ export { parseSlackCommandBody, parseSlackEventBody, verifySlackRequest };
@@ -0,0 +1,65 @@
1
+ import { getLogger } from "../../logger.js";
2
+ import crypto from "node:crypto";
3
+
4
+ //#region src/slack/services/security.ts
5
+ /**
6
+ * Slack Security Utilities
7
+ *
8
+ * Provides security functions for verifying Slack requests and parsing payloads.
9
+ * All incoming Slack requests are verified using HMAC-SHA256 signatures.
10
+ */
11
+ const logger = getLogger("slack-security");
12
+ /**
13
+ * Verify that a request originated from Slack using HMAC-SHA256 signature.
14
+ *
15
+ * @param signingSecret - The Slack signing secret from app settings
16
+ * @param requestBody - The raw request body string
17
+ * @param timestamp - The X-Slack-Request-Timestamp header value
18
+ * @param signature - The X-Slack-Signature header value
19
+ * @returns true if the signature is valid, false otherwise
20
+ */
21
+ function verifySlackRequest(signingSecret, requestBody, timestamp, signature) {
22
+ try {
23
+ const fiveMinutesAgo = Math.floor(Date.now() / 1e3) - 300;
24
+ if (Number.parseInt(timestamp, 10) < fiveMinutesAgo) {
25
+ logger.warn({}, "Slack request timestamp too old");
26
+ return false;
27
+ }
28
+ const sigBaseString = `v0:${timestamp}:${requestBody}`;
29
+ const mySignature = `v0=${crypto.createHmac("sha256", signingSecret).update(sigBaseString).digest("hex")}`;
30
+ return crypto.timingSafeEqual(Buffer.from(mySignature), Buffer.from(signature));
31
+ } catch (error) {
32
+ logger.error({ error }, "Error verifying Slack request");
33
+ return false;
34
+ }
35
+ }
36
+ /**
37
+ * Parse a URL-encoded Slack command body into key-value pairs.
38
+ *
39
+ * @param body - The URL-encoded request body from a slash command
40
+ * @returns Parsed parameters as a string record
41
+ */
42
+ function parseSlackCommandBody(body) {
43
+ const params = new URLSearchParams(body);
44
+ return Object.fromEntries(params.entries());
45
+ }
46
+ /**
47
+ * Parse a Slack event body based on content type.
48
+ * Handles both JSON and URL-encoded payloads (for interactive components).
49
+ *
50
+ * @param body - The raw request body
51
+ * @param contentType - The Content-Type header value
52
+ * @returns Parsed event payload
53
+ */
54
+ function parseSlackEventBody(body, contentType) {
55
+ if (contentType.includes("application/x-www-form-urlencoded")) {
56
+ const params = new URLSearchParams(body);
57
+ const payload = params.get("payload");
58
+ if (payload) return JSON.parse(payload);
59
+ return Object.fromEntries(params.entries());
60
+ }
61
+ return JSON.parse(body);
62
+ }
63
+
64
+ //#endregion
65
+ export { parseSlackCommandBody, parseSlackEventBody, verifySlackRequest };
@@ -0,0 +1,26 @@
1
+ import { MessageAttachment } from "@slack/types";
2
+
3
+ //#region src/slack/services/types.d.ts
4
+ interface SlackCommandPayload {
5
+ command: string;
6
+ text: string;
7
+ userId: string;
8
+ userName: string;
9
+ teamId: string;
10
+ teamDomain: string;
11
+ enterpriseId?: string;
12
+ channelId: string;
13
+ channelName: string;
14
+ responseUrl: string;
15
+ triggerId: string;
16
+ }
17
+ interface SlackCommandResponse {
18
+ response_type?: 'ephemeral' | 'in_channel';
19
+ text?: string;
20
+ blocks?: unknown[];
21
+ attachments?: MessageAttachment[];
22
+ replace_original?: boolean;
23
+ delete_original?: boolean;
24
+ }
25
+ //#endregion
26
+ export { SlackCommandPayload, SlackCommandResponse };
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,25 @@
1
+ //#region src/slack/services/workspace-tokens.d.ts
2
+ /**
3
+ * Workspace Token Service
4
+ *
5
+ * In-memory cache for Slack bot tokens during OAuth installation flow.
6
+ * Primary token storage is in Nango; this is a temporary fallback.
7
+ *
8
+ * Note: Tokens stored here do not persist across server restarts.
9
+ * Always prefer fetching tokens from Nango for production use.
10
+ */
11
+ /**
12
+ * Get the cached bot token for a Slack team.
13
+ * Falls back to null if not cached (caller should fetch from Nango).
14
+ */
15
+ declare function getBotTokenForTeam(teamId: string): string | null;
16
+ /**
17
+ * Cache a bot token for a Slack team (used during OAuth installation).
18
+ */
19
+ declare function setBotTokenForTeam(teamId: string, data: {
20
+ botToken: string;
21
+ teamName: string;
22
+ installedAt: string;
23
+ }): void;
24
+ //#endregion
25
+ export { getBotTokenForTeam, setBotTokenForTeam };
@@ -0,0 +1,27 @@
1
+ //#region src/slack/services/workspace-tokens.ts
2
+ /**
3
+ * Workspace Token Service
4
+ *
5
+ * In-memory cache for Slack bot tokens during OAuth installation flow.
6
+ * Primary token storage is in Nango; this is a temporary fallback.
7
+ *
8
+ * Note: Tokens stored here do not persist across server restarts.
9
+ * Always prefer fetching tokens from Nango for production use.
10
+ */
11
+ const workspaceBotTokens = /* @__PURE__ */ new Map();
12
+ /**
13
+ * Get the cached bot token for a Slack team.
14
+ * Falls back to null if not cached (caller should fetch from Nango).
15
+ */
16
+ function getBotTokenForTeam(teamId) {
17
+ return workspaceBotTokens.get(teamId)?.botToken || null;
18
+ }
19
+ /**
20
+ * Cache a bot token for a Slack team (used during OAuth installation).
21
+ */
22
+ function setBotTokenForTeam(teamId, data) {
23
+ workspaceBotTokens.set(teamId, data);
24
+ }
25
+
26
+ //#endregion
27
+ export { getBotTokenForTeam, setBotTokenForTeam };