@vellumai/vellum-gateway 0.5.12 → 0.5.14

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/ARCHITECTURE.md CHANGED
@@ -53,14 +53,14 @@ The gateway exposes a REST API for reading and mutating assistant feature flags.
53
53
 
54
54
  **Token separation (authentication boundary):**
55
55
 
56
- The assistant feature flags API uses a dedicated feature-flag token stored at `~/.vellum/feature-flag-token`, separate from JWT auth tokens. This separation ensures that clients with feature-flag access cannot access runtime endpoints, and vice versa.
56
+ The assistant feature flags API uses scope-based JWT auth. The gateway issues JWTs with `feature_flags.read` and `feature_flags.write` scopes to control access.
57
57
 
58
- | Operation | Accepted tokens |
59
- | ------------------------------ | ------------------------------------------------------------------- |
60
- | `GET /v1/feature-flags` | JWT bearer token OR feature-flag token |
61
- | `PATCH /v1/feature-flags/:key` | Feature-flag token ONLY (JWT bearer tokens are explicitly rejected) |
58
+ | Operation | Required scope |
59
+ | ------------------------------ | --------------------- |
60
+ | `GET /v1/feature-flags` | `feature_flags.read` |
61
+ | `PATCH /v1/feature-flags/:key` | `feature_flags.write` |
62
62
 
63
- The feature-flag token is auto-generated on first gateway startup if the file does not exist. The gateway watches the token file for changes and hot-reloads without restart.
63
+ The assistant daemon does not read or distribute a feature-flag token. All feature-flag auth flows go through the gateway's scoped JWT mechanism.
64
64
 
65
65
  **Protected feature flag store:** The canonical storage for assistant feature flag overrides is `~/.vellum/protected/feature-flags.json` (local) or `GATEWAY_SECURITY_DIR/feature-flags.json` (Docker). The store is managed by `gateway/src/feature-flag-store.ts` and uses a versioned JSON format with `Record<string, boolean>` values keyed by canonical flag keys (simple kebab-case, e.g., `browser`). The gateway's PATCH handler writes exclusively to this store. The daemon's resolver reads it with highest priority, falling back to the defaults registry. Undeclared keys are ignored by the resolver.
66
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.5.12",
3
+ "version": "0.5.14",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -55,6 +55,7 @@ describe("/schema route", () => {
55
55
  expect(body.paths["/readyz"]).toBeDefined();
56
56
  expect(body.paths["/schema"]).toBeDefined();
57
57
  expect(body.paths["/v1/health"]).toBeDefined();
58
+ expect(body.paths["/v1/healthz"]).toBeDefined();
58
59
  expect(body.paths["/webhooks/telegram"]).toBeDefined();
59
60
  expect(body.paths["/webhooks/twilio/voice"]).toBeDefined();
60
61
  expect(body.paths["/webhooks/twilio/status"]).toBeDefined();
@@ -47,7 +47,7 @@
47
47
  "key": "app-builder-multifile",
48
48
  "label": "App Builder Multi-file",
49
49
  "description": "Enable multi-file TSX app creation with esbuild compilation instead of single-HTML apps",
50
- "defaultEnabled": false
50
+ "defaultEnabled": true
51
51
  },
52
52
  {
53
53
  "id": "mobile-pairing",
@@ -289,6 +289,14 @@
289
289
  "description": "Auto-expand completed tool call step groups instead of showing them collapsed",
290
290
  "defaultEnabled": false
291
291
  },
292
+ {
293
+ "id": "show-thinking-blocks",
294
+ "scope": "macos",
295
+ "key": "show-thinking-blocks",
296
+ "label": "Show Thinking Blocks",
297
+ "description": "Display the assistant's thinking/reasoning inline in chat messages as collapsible blocks",
298
+ "defaultEnabled": false
299
+ },
292
300
  {
293
301
  "id": "inline-skill-commands",
294
302
  "scope": "assistant",
@@ -344,6 +352,14 @@
344
352
  "label": "Voice Mode",
345
353
  "description": "Show the live voice conversation button in the composer",
346
354
  "defaultEnabled": false
355
+ },
356
+ {
357
+ "id": "fast-mode",
358
+ "scope": "assistant",
359
+ "key": "fast-mode",
360
+ "label": "Fast Mode",
361
+ "description": "Enable Anthropic fast mode for Opus 4.6, delivering up to 2.5x higher output tokens per second at premium pricing",
362
+ "defaultEnabled": false
347
363
  }
348
364
  ]
349
365
  }
@@ -4,8 +4,11 @@ import { validateEdgeToken } from "../../auth/token-exchange.js";
4
4
  import { resolveScopeProfile } from "../../auth/scopes.js";
5
5
  import type { Scope } from "../../auth/types.js";
6
6
  import type { AuthRateLimiter } from "../../auth-rate-limiter.js";
7
+ import { getLogger } from "../../logger.js";
7
8
  import { isLoopbackPeer } from "../routes/browser-relay-websocket.js";
8
9
 
10
+ const log = getLogger("auth");
11
+
9
12
  type GetClientIp = () => string;
10
13
 
11
14
  /**
@@ -34,11 +37,19 @@ export function createAuthMiddleware(
34
37
  const token = extractBearerToken(req);
35
38
  if (!token) {
36
39
  authRateLimiter.recordFailure(getClientIp());
40
+ log.warn(
41
+ { path: new URL(req.url).pathname },
42
+ "Edge auth rejected: missing or malformed Authorization header",
43
+ );
37
44
  return Response.json({ error: "Unauthorized" }, { status: 401 });
38
45
  }
39
46
  const result = validateEdgeToken(token);
40
47
  if (!result.ok) {
41
48
  authRateLimiter.recordFailure(getClientIp());
49
+ log.warn(
50
+ { path: new URL(req.url).pathname, reason: result.reason },
51
+ "Edge auth rejected: token validation failed",
52
+ );
42
53
  return Response.json({ error: "Unauthorized" }, { status: 401 });
43
54
  }
44
55
  return null;
@@ -60,11 +71,19 @@ export function createAuthMiddleware(
60
71
  const token = extractBearerToken(req);
61
72
  if (!token) {
62
73
  authRateLimiter.recordFailure(getClientIp());
74
+ log.warn(
75
+ { path: new URL(req.url).pathname, scope },
76
+ "Scoped edge auth rejected: missing or malformed Authorization header",
77
+ );
63
78
  return Response.json({ error: "Unauthorized" }, { status: 401 });
64
79
  }
65
80
  const result = validateEdgeToken(token);
66
81
  if (!result.ok) {
67
82
  authRateLimiter.recordFailure(getClientIp());
83
+ log.warn(
84
+ { path: new URL(req.url).pathname, scope, reason: result.reason },
85
+ "Scoped edge auth rejected: token validation failed",
86
+ );
68
87
  return Response.json({ error: "Unauthorized" }, { status: 401 });
69
88
  }
70
89
  const scopes = resolveScopeProfile(result.claims.scope_profile);
@@ -1,4 +1,7 @@
1
1
  import { verifyToken } from "../../auth/token-service.js";
2
+ import { getLogger } from "../../logger.js";
3
+
4
+ const log = getLogger("deliver-auth");
2
5
 
3
6
  /**
4
7
  * Creates a fail-closed auth check for delivery routes.
@@ -21,12 +24,20 @@ export function checkDeliverAuth(
21
24
 
22
25
  const authHeader = req.headers.get("authorization");
23
26
  if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
27
+ log.warn(
28
+ { path: new URL(req.url).pathname },
29
+ "Deliver auth rejected: missing or malformed Authorization header",
30
+ );
24
31
  return Response.json({ error: "Unauthorized" }, { status: 401 });
25
32
  }
26
33
 
27
34
  const token = authHeader.slice(7);
28
35
  const result = verifyToken(token, "vellum-daemon");
29
36
  if (!result.ok) {
37
+ log.warn(
38
+ { path: new URL(req.url).pathname, reason: result.reason },
39
+ "Deliver auth rejected: token validation failed",
40
+ );
30
41
  return Response.json({ error: "Unauthorized" }, { status: 401 });
31
42
  }
32
43
 
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Gateway proxy endpoints for OAuth provider discovery routes.
3
+ *
4
+ * These routes remain available even when the broad runtime proxy is
5
+ * disabled, so skills and clients can use gateway URLs exclusively.
6
+ */
7
+
8
+ import { mintServiceToken } from "../../auth/token-exchange.js";
9
+ import type { GatewayConfig } from "../../config.js";
10
+ import { fetchImpl } from "../../fetch.js";
11
+ import { getLogger } from "../../logger.js";
12
+ import { stripHopByHop } from "../../util/strip-hop-by-hop.js";
13
+
14
+ const log = getLogger("oauth-providers-proxy");
15
+
16
+ export function createOAuthProvidersProxyHandler(config: GatewayConfig) {
17
+ async function proxyToRuntime(
18
+ req: Request,
19
+ upstreamPath: string,
20
+ upstreamSearch: string,
21
+ ): Promise<Response> {
22
+ const start = performance.now();
23
+ const upstream = `${config.assistantRuntimeBaseUrl}${upstreamPath}${upstreamSearch}`;
24
+
25
+ const reqHeaders = stripHopByHop(new Headers(req.headers));
26
+ reqHeaders.delete("host");
27
+ reqHeaders.delete("authorization");
28
+
29
+ reqHeaders.set("authorization", `Bearer ${mintServiceToken()}`);
30
+
31
+ const hasBody = req.method !== "GET" && req.method !== "HEAD";
32
+ const bodyBuffer = hasBody ? await req.arrayBuffer() : null;
33
+ if (bodyBuffer !== null) {
34
+ reqHeaders.set("content-length", String(bodyBuffer.byteLength));
35
+ }
36
+
37
+ const controller = new AbortController();
38
+ const timeoutId = setTimeout(() => {
39
+ controller.abort(
40
+ new DOMException(
41
+ "The operation was aborted due to timeout",
42
+ "TimeoutError",
43
+ ),
44
+ );
45
+ }, config.runtimeTimeoutMs);
46
+
47
+ let response: Response;
48
+ try {
49
+ response = await fetchImpl(upstream, {
50
+ method: req.method,
51
+ headers: reqHeaders,
52
+ body: bodyBuffer,
53
+ signal: controller.signal,
54
+ });
55
+ clearTimeout(timeoutId);
56
+ } catch (err) {
57
+ clearTimeout(timeoutId);
58
+ const duration = Math.round(performance.now() - start);
59
+ if (err instanceof DOMException && err.name === "TimeoutError") {
60
+ log.error(
61
+ { path: upstreamPath, duration },
62
+ "OAuth providers proxy upstream timed out",
63
+ );
64
+ return Response.json({ error: "Gateway Timeout" }, { status: 504 });
65
+ }
66
+ log.error(
67
+ { err, path: upstreamPath, duration },
68
+ "OAuth providers proxy upstream connection failed",
69
+ );
70
+ return Response.json({ error: "Bad Gateway" }, { status: 502 });
71
+ }
72
+
73
+ const resHeaders = stripHopByHop(new Headers(response.headers));
74
+ const duration = Math.round(performance.now() - start);
75
+
76
+ if (response.status >= 400) {
77
+ const body = await response.text();
78
+ log.warn(
79
+ { path: upstreamPath, status: response.status, duration },
80
+ "OAuth providers proxy upstream error",
81
+ );
82
+ return new Response(body, {
83
+ status: response.status,
84
+ headers: resHeaders,
85
+ });
86
+ }
87
+
88
+ log.info(
89
+ { path: upstreamPath, status: response.status, duration },
90
+ "OAuth providers proxy completed",
91
+ );
92
+ return new Response(response.body, {
93
+ status: response.status,
94
+ headers: resHeaders,
95
+ });
96
+ }
97
+
98
+ return {
99
+ async handleListProviders(req: Request): Promise<Response> {
100
+ return proxyToRuntime(
101
+ req,
102
+ "/v1/oauth/providers",
103
+ new URL(req.url).search,
104
+ );
105
+ },
106
+
107
+ async handleGetProvider(
108
+ req: Request,
109
+ providerKey: string,
110
+ ): Promise<Response> {
111
+ return proxyToRuntime(req, `/v1/oauth/providers/${providerKey}`, "");
112
+ },
113
+ };
114
+ }
@@ -44,12 +44,19 @@ export function createRuntimeProxyHandler(config: GatewayConfig) {
44
44
  if (config.runtimeProxyRequireAuth && req.method !== "OPTIONS") {
45
45
  const authHeader = req.headers.get("authorization");
46
46
  if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
47
+ log.warn(
48
+ { method: req.method, path: url.pathname },
49
+ "Runtime proxy auth rejected: missing or malformed Authorization header",
50
+ );
47
51
  return Response.json({ error: "Unauthorized" }, { status: 401 });
48
52
  }
49
53
  const edgeJwt = authHeader.slice(7);
50
54
  const result = validateEdgeToken(edgeJwt);
51
55
  if (!result.ok) {
52
- log.debug({ reason: result.reason }, "Edge token validation failed");
56
+ log.warn(
57
+ { method: req.method, path: url.pathname, reason: result.reason },
58
+ "Runtime proxy auth rejected: edge token validation failed",
59
+ );
53
60
  return Response.json({ error: "Unauthorized" }, { status: 401 });
54
61
  }
55
62
  exchangeToken = mintExchangeToken(
package/src/index.ts CHANGED
@@ -56,6 +56,7 @@ import { createVercelControlPlaneProxyHandler } from "./http/routes/vercel-contr
56
56
  import { createContactsControlPlaneProxyHandler } from "./http/routes/contacts-control-plane-proxy.js";
57
57
  import { createSlackControlPlaneProxyHandler } from "./http/routes/slack-control-plane-proxy.js";
58
58
  import { createOAuthAppsProxyHandler } from "./http/routes/oauth-apps-proxy.js";
59
+ import { createOAuthProvidersProxyHandler } from "./http/routes/oauth-providers-proxy.js";
59
60
  import { createChannelReadinessProxyHandler } from "./http/routes/channel-readiness-proxy.js";
60
61
  import { createRuntimeHealthProxyHandler } from "./http/routes/runtime-health-proxy.js";
61
62
  import { createUpgradeBroadcastProxyHandler } from "./http/routes/upgrade-broadcast-proxy.js";
@@ -76,12 +77,17 @@ import {
76
77
  createTrustRulesStarterBundleHandler,
77
78
  } from "./http/routes/trust-rules.js";
78
79
  import { getLogger, initLogger } from "./logger.js";
79
- import { CircuitBreakerOpenError } from "./runtime/client.js";
80
+ import {
81
+ AttachmentValidationError,
82
+ CircuitBreakerOpenError,
83
+ uploadAttachment,
84
+ } from "./runtime/client.js";
80
85
  import { buildSchema } from "./schema.js";
81
86
  import {
82
87
  createSlackSocketModeClient,
83
88
  type SlackSocketModeClient,
84
89
  } from "./slack/socket-mode.js";
90
+ import { downloadSlackFile } from "./slack/download.js";
85
91
  import { fetchThreadContext } from "./slack/thread-context.js";
86
92
  import { handleInbound } from "./handlers/handle-inbound.js";
87
93
  import { checkAuthRateLimit } from "./http/middleware/rate-limit.js";
@@ -276,6 +282,7 @@ async function main() {
276
282
  const twilioControlPlaneProxy = createTwilioControlPlaneProxyHandler(config);
277
283
  const slackControlPlaneProxy = createSlackControlPlaneProxyHandler(config);
278
284
  const oauthAppsProxy = createOAuthAppsProxyHandler(config);
285
+ const oauthProvidersProxy = createOAuthProvidersProxyHandler(config);
279
286
  const channelReadinessProxy = createChannelReadinessProxyHandler(config);
280
287
  const runtimeHealthProxy = createRuntimeHealthProxyHandler(config);
281
288
  const upgradeBroadcastProxy = createUpgradeBroadcastProxyHandler(config);
@@ -417,6 +424,12 @@ async function main() {
417
424
  auth: "edge",
418
425
  handler: (req) => runtimeHealthProxy.handleRuntimeHealth(req),
419
426
  },
427
+ {
428
+ path: "/v1/healthz",
429
+ method: "GET",
430
+ auth: "edge",
431
+ handler: (req) => runtimeHealthProxy.handleRuntimeHealth(req),
432
+ },
420
433
 
421
434
  // ── Brain graph ──
422
435
  {
@@ -629,12 +642,20 @@ async function main() {
629
642
  const authHeader = req.headers.get("authorization");
630
643
  if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
631
644
  authRateLimiter.recordFailure(getClientIp());
645
+ log.warn(
646
+ { path: new URL(req.url).pathname },
647
+ "Guardian refresh auth rejected: missing or malformed Authorization header",
648
+ );
632
649
  return Response.json({ error: "Unauthorized" }, { status: 401 });
633
650
  }
634
651
  const token = authHeader.slice(7);
635
652
  const result = validateEdgeToken(token, { allowExpired: true });
636
653
  if (!result.ok) {
637
654
  authRateLimiter.recordFailure(getClientIp());
655
+ log.warn(
656
+ { path: new URL(req.url).pathname, reason: result.reason },
657
+ "Guardian refresh auth rejected: token validation failed",
658
+ );
638
659
  return Response.json({ error: "Unauthorized" }, { status: 401 });
639
660
  }
640
661
  return channelVerificationSessionProxy.handleGuardianRefresh(req);
@@ -700,6 +721,21 @@ async function main() {
700
721
  handler: (req) => slackControlPlaneProxy.handleShareToSlack(req),
701
722
  },
702
723
 
724
+ // ── OAuth providers ──
725
+ {
726
+ path: "/v1/oauth/providers",
727
+ method: "GET",
728
+ auth: "edge",
729
+ handler: (req) => oauthProvidersProxy.handleListProviders(req),
730
+ },
731
+ {
732
+ path: /^\/v1\/oauth\/providers\/([^/]+)\/?$/,
733
+ method: "GET",
734
+ auth: "edge",
735
+ handler: (req, params) =>
736
+ oauthProvidersProxy.handleGetProvider(req, params[0]),
737
+ },
738
+
703
739
  // ── OAuth apps ──
704
740
  {
705
741
  path: "/v1/oauth/apps",
@@ -1186,28 +1222,147 @@ async function main() {
1186
1222
  !isEdit &&
1187
1223
  !isCallback;
1188
1224
 
1189
- const forward = (threadContextHint?: string) => {
1190
- const hints: string[] = [];
1191
- if (threadContextHint) hints.push(threadContextHint);
1192
-
1193
- handleInbound(config, normalized.event, {
1194
- replyCallbackUrl,
1195
- routingOverride: normalized.routing,
1196
- ...(hints.length > 0 ? { transportMetadata: { hints } } : {}),
1197
- }).catch((err) => {
1225
+ const forward = async (threadContextHint?: string) => {
1226
+ try {
1227
+ const hints: string[] = [];
1228
+ if (threadContextHint) hints.push(threadContextHint);
1229
+
1230
+ // Download and upload attachments if present (skip for edits and
1231
+ // callback actions — edits only update text, callbacks have no media)
1232
+ let attachmentIds: string[] | undefined;
1233
+ const eventAttachments = normalized.event.message.attachments;
1234
+ if (
1235
+ eventAttachments &&
1236
+ eventAttachments.length > 0 &&
1237
+ normalized.slackFiles &&
1238
+ !isEdit &&
1239
+ !isCallback
1240
+ ) {
1241
+ attachmentIds = [];
1242
+ const maxBytes =
1243
+ config.maxAttachmentBytes.slack ??
1244
+ config.maxAttachmentBytes.default;
1245
+
1246
+ // Filter oversized attachments
1247
+ const eligible = eventAttachments.filter((att) => {
1248
+ if (
1249
+ att.fileSize !== undefined &&
1250
+ att.fileSize > maxBytes
1251
+ ) {
1252
+ log.warn(
1253
+ {
1254
+ fileId: att.fileId,
1255
+ fileSize: att.fileSize,
1256
+ limit: maxBytes,
1257
+ },
1258
+ "Skipping oversized Slack attachment",
1259
+ );
1260
+ return false;
1261
+ }
1262
+ return true;
1263
+ });
1264
+
1265
+ // Process with bounded concurrency. Socket Mode has no retry
1266
+ // mechanism, so all errors (validation and transient) are logged
1267
+ // and skipped — the message is still delivered without the
1268
+ // failed attachment.
1269
+ for (
1270
+ let i = 0;
1271
+ i < eligible.length;
1272
+ i += config.maxAttachmentConcurrency
1273
+ ) {
1274
+ const batch = eligible.slice(
1275
+ i,
1276
+ i + config.maxAttachmentConcurrency,
1277
+ );
1278
+ const results = await Promise.allSettled(
1279
+ batch.map(async (att) => {
1280
+ const slackFile = normalized.slackFiles?.get(att.fileId);
1281
+ if (!slackFile) {
1282
+ throw new Error(
1283
+ `No SlackFile found for attachment ${att.fileId}`,
1284
+ );
1285
+ }
1286
+ const downloaded = await downloadSlackFile(
1287
+ slackFile,
1288
+ botToken,
1289
+ );
1290
+ return uploadAttachment(config, downloaded, {
1291
+ skipCircuitBreaker: true,
1292
+ });
1293
+ }),
1294
+ );
1295
+ for (const result of results) {
1296
+ if (result.status === "fulfilled") {
1297
+ attachmentIds.push(result.value.id);
1298
+ } else if (
1299
+ result.reason instanceof AttachmentValidationError
1300
+ ) {
1301
+ log.warn(
1302
+ { err: result.reason },
1303
+ "Skipping Slack attachment with validation error",
1304
+ );
1305
+ } else {
1306
+ log.warn(
1307
+ { err: result.reason },
1308
+ "Skipping Slack attachment due to download/upload failure",
1309
+ );
1310
+ }
1311
+ }
1312
+ }
1313
+ }
1314
+
1315
+ handleInbound(config, normalized.event, {
1316
+ replyCallbackUrl,
1317
+ routingOverride: normalized.routing,
1318
+ ...(attachmentIds && attachmentIds.length > 0
1319
+ ? { attachmentIds }
1320
+ : {}),
1321
+ ...(hints.length > 0 ? { transportMetadata: { hints } } : {}),
1322
+ }).catch((err) => {
1323
+ log.error(
1324
+ { err, channel, threadTs },
1325
+ "Failed to forward Slack event to runtime",
1326
+ );
1327
+ });
1328
+ } catch (err) {
1198
1329
  log.error(
1199
1330
  { err, channel, threadTs },
1200
- "Failed to forward Slack event to runtime",
1331
+ "Failed to process Slack event delivering message without attachments",
1201
1332
  );
1202
- });
1333
+ handleInbound(config, normalized.event, {
1334
+ replyCallbackUrl,
1335
+ routingOverride: normalized.routing,
1336
+ ...(threadContextHint
1337
+ ? { transportMetadata: { hints: [threadContextHint] } }
1338
+ : {}),
1339
+ }).catch((fwdErr) => {
1340
+ log.error(
1341
+ { err: fwdErr, channel, threadTs },
1342
+ "Failed to forward Slack event to runtime (fallback)",
1343
+ );
1344
+ });
1345
+ }
1203
1346
  };
1204
1347
 
1205
1348
  if (isThreadReply && botToken) {
1206
1349
  fetchThreadContext(channel, threadTs, messageTs, botToken)
1207
- .then((context) => forward(context ?? undefined))
1208
- .catch(() => forward());
1350
+ .then((context) => context ?? undefined)
1351
+ .catch(() => undefined)
1352
+ .then((context) => forward(context))
1353
+ .catch((err) => {
1354
+ log.error(
1355
+ { err, channel, threadTs },
1356
+ "Unhandled error in Slack forward (thread reply)",
1357
+ );
1358
+ });
1209
1359
  } else {
1210
- forward();
1360
+ forward().catch((err) => {
1361
+ log.error(
1362
+ { err, channel, threadTs },
1363
+ "Unhandled error in Slack forward",
1364
+ );
1365
+ });
1211
1366
  }
1212
1367
 
1213
1368
  // When an approval button is clicked, store the approval message ts
@@ -547,8 +547,11 @@ export async function forwardTwilioConnectActionWebhook(
547
547
  export async function uploadAttachment(
548
548
  config: GatewayConfig,
549
549
  input: UploadAttachmentInput,
550
+ opts?: { skipCircuitBreaker?: boolean },
550
551
  ): Promise<UploadAttachmentResponse> {
551
- cbBeforeRequest();
552
+ const skipCb = opts?.skipCircuitBreaker === true;
553
+
554
+ if (!skipCb) cbBeforeRequest();
552
555
 
553
556
  const url = `${config.assistantRuntimeBaseUrl}/v1/attachments`;
554
557
 
@@ -563,7 +566,7 @@ export async function uploadAttachment(
563
566
  signal: AbortSignal.timeout(config.runtimeTimeoutMs),
564
567
  });
565
568
  } catch (err) {
566
- cbOnFailure();
569
+ if (!skipCb) cbOnFailure();
567
570
  throw err;
568
571
  }
569
572
 
@@ -573,16 +576,16 @@ export async function uploadAttachment(
573
576
  // extension, missing fields). Distinguish from transient 5xx/network errors
574
577
  // so callers can decide whether to skip or propagate.
575
578
  if (response.status >= 400 && response.status < 500) {
576
- cbOnSuccess();
579
+ if (!skipCb) cbOnSuccess();
577
580
  throw new AttachmentValidationError(
578
581
  `Attachment rejected (${response.status}): ${body}`,
579
582
  );
580
583
  }
581
- cbOnFailure();
584
+ if (!skipCb) cbOnFailure();
582
585
  throw new Error(`Attachment upload failed (${response.status}): ${body}`);
583
586
  }
584
587
 
585
- cbOnSuccess();
588
+ if (!skipCb) cbOnSuccess();
586
589
  return (await response.json()) as UploadAttachmentResponse;
587
590
  }
588
591
 
package/src/schema.ts CHANGED
@@ -153,6 +153,52 @@ export function buildSchema(): Record<string, unknown> {
153
153
  },
154
154
  },
155
155
  },
156
+ "/v1/healthz": {
157
+ get: {
158
+ summary: "Runtime health (via gateway, alias)",
159
+ description:
160
+ "Alias for `/v1/health`. Authenticated gateway endpoint that proxies runtime health checks to `/v1/health` on the assistant runtime.",
161
+ operationId: "runtimeHealthz",
162
+ security: [{ BearerAuth: [] }],
163
+ responses: {
164
+ "200": {
165
+ description: "Runtime health returned",
166
+ },
167
+ "401": {
168
+ description: "Unauthorized — missing or invalid bearer token",
169
+ content: {
170
+ "application/json": {
171
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
172
+ },
173
+ },
174
+ },
175
+ "503": {
176
+ description: "Bearer token not configured",
177
+ content: {
178
+ "application/json": {
179
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
180
+ },
181
+ },
182
+ },
183
+ "502": {
184
+ description: "Failed to reach assistant runtime",
185
+ content: {
186
+ "application/json": {
187
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
188
+ },
189
+ },
190
+ },
191
+ "504": {
192
+ description: "Assistant runtime request timed out",
193
+ content: {
194
+ "application/json": {
195
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
196
+ },
197
+ },
198
+ },
199
+ },
200
+ },
201
+ },
156
202
  "/v1/brain-graph": {
157
203
  get: {
158
204
  summary: "Brain graph data",
@@ -1725,6 +1771,74 @@ export function buildSchema(): Record<string, unknown> {
1725
1771
  },
1726
1772
  },
1727
1773
  },
1774
+ "/v1/oauth/providers": {
1775
+ get: {
1776
+ summary: "List OAuth providers",
1777
+ description:
1778
+ "Authenticated gateway endpoint that lists available OAuth providers by proxying to the assistant runtime.",
1779
+ operationId: "oauthProvidersList",
1780
+ parameters: [
1781
+ {
1782
+ name: "supports_managed_mode",
1783
+ in: "query",
1784
+ required: false,
1785
+ schema: { type: "boolean" },
1786
+ description:
1787
+ "When true, only return providers that support managed mode.",
1788
+ },
1789
+ ],
1790
+ security: [{ BearerAuth: [] }],
1791
+ responses: {
1792
+ "200": {
1793
+ description: "OAuth providers returned",
1794
+ content: {
1795
+ "application/json": {
1796
+ schema: { type: "object" },
1797
+ },
1798
+ },
1799
+ },
1800
+ "401": {
1801
+ description: "Unauthorized — missing or invalid bearer token",
1802
+ },
1803
+ "502": { description: "Failed to reach assistant runtime" },
1804
+ "504": { description: "Assistant runtime request timed out" },
1805
+ },
1806
+ },
1807
+ },
1808
+ "/v1/oauth/providers/{providerKey}": {
1809
+ get: {
1810
+ summary: "Get OAuth provider",
1811
+ description:
1812
+ "Authenticated gateway endpoint that retrieves a single OAuth provider by key by proxying to the assistant runtime.",
1813
+ operationId: "oauthProvidersGet",
1814
+ parameters: [
1815
+ {
1816
+ name: "providerKey",
1817
+ in: "path",
1818
+ required: true,
1819
+ schema: { type: "string" },
1820
+ description: "The provider key, for example `google`.",
1821
+ },
1822
+ ],
1823
+ security: [{ BearerAuth: [] }],
1824
+ responses: {
1825
+ "200": {
1826
+ description: "OAuth provider returned",
1827
+ content: {
1828
+ "application/json": {
1829
+ schema: { type: "object" },
1830
+ },
1831
+ },
1832
+ },
1833
+ "401": {
1834
+ description: "Unauthorized — missing or invalid bearer token",
1835
+ },
1836
+ "404": { description: "OAuth provider not found" },
1837
+ "502": { description: "Failed to reach assistant runtime" },
1838
+ "504": { description: "Assistant runtime request timed out" },
1839
+ },
1840
+ },
1841
+ },
1728
1842
  "/v1/oauth/apps": {
1729
1843
  get: {
1730
1844
  summary: "List OAuth apps",
@@ -0,0 +1,183 @@
1
+ import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import type { SlackFile } from "./normalize.js";
3
+
4
+ type FetchFn = (
5
+ input: string | URL | Request,
6
+ init?: RequestInit,
7
+ ) => Promise<Response>;
8
+ let fetchMock: ReturnType<typeof mock<FetchFn>> = mock(
9
+ async () => new Response(),
10
+ );
11
+
12
+ mock.module("../fetch.js", () => ({
13
+ fetchImpl: (...args: Parameters<FetchFn>) => fetchMock(...args),
14
+ }));
15
+
16
+ const { downloadSlackFile } = await import("./download.js");
17
+
18
+ function makeSlackFile(overrides?: Partial<SlackFile>): SlackFile {
19
+ return {
20
+ id: "F12345",
21
+ name: "test-image.png",
22
+ mimetype: "image/png",
23
+ url_private_download: "https://files.slack.com/download/F12345",
24
+ url_private: "https://files.slack.com/files-pri/F12345",
25
+ ...overrides,
26
+ };
27
+ }
28
+
29
+ /** Create a minimal valid PNG buffer that file-type can detect. */
30
+ function makePngBuffer(): ArrayBuffer {
31
+ // PNG signature (8 bytes) + minimal IHDR chunk (25 bytes)
32
+ const png = new Uint8Array([
33
+ // PNG signature
34
+ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
35
+ // IHDR chunk: length (13)
36
+ 0x00, 0x00, 0x00, 0x0d,
37
+ // IHDR type
38
+ 0x49, 0x48, 0x44, 0x52,
39
+ // Width: 1
40
+ 0x00, 0x00, 0x00, 0x01,
41
+ // Height: 1
42
+ 0x00, 0x00, 0x00, 0x01,
43
+ // Bit depth, color type, compression, filter, interlace
44
+ 0x08, 0x02, 0x00, 0x00, 0x00,
45
+ // CRC (placeholder)
46
+ 0x90, 0x77, 0x53, 0xde,
47
+ ]);
48
+ return png.buffer;
49
+ }
50
+
51
+ /** Create a plain text buffer (undetectable by file-type). */
52
+ function makeTextBuffer(): ArrayBuffer {
53
+ return new TextEncoder().encode("hello world").buffer;
54
+ }
55
+
56
+ describe("downloadSlackFile", () => {
57
+ afterEach(() => {
58
+ fetchMock = mock(async () => new Response());
59
+ });
60
+
61
+ test("downloads file using url_private_download", async () => {
62
+ const fileBuffer = makePngBuffer();
63
+ fetchMock = mock(async () => new Response(fileBuffer));
64
+
65
+ const file = makeSlackFile();
66
+ const result = await downloadSlackFile(file, "xoxb-test-token");
67
+
68
+ expect(result.filename).toBe("test-image.png");
69
+ expect(result.mimeType).toBe("image/png");
70
+ expect(result.data).toBe(
71
+ Buffer.from(fileBuffer).toString("base64"),
72
+ );
73
+
74
+ // Verify the correct URL and auth header were used
75
+ expect(fetchMock).toHaveBeenCalledTimes(1);
76
+ const [url, init] = fetchMock.mock.calls[0];
77
+ expect(url).toBe("https://files.slack.com/download/F12345");
78
+ expect((init as RequestInit).headers).toEqual({
79
+ Authorization: "Bearer xoxb-test-token",
80
+ });
81
+ });
82
+
83
+ test("falls back to url_private when url_private_download is absent", async () => {
84
+ const fileBuffer = makePngBuffer();
85
+ fetchMock = mock(async () => new Response(fileBuffer));
86
+
87
+ const file = makeSlackFile({
88
+ url_private_download: undefined,
89
+ });
90
+ const result = await downloadSlackFile(file, "xoxb-test-token");
91
+
92
+ expect(result.filename).toBe("test-image.png");
93
+
94
+ const [url] = fetchMock.mock.calls[0];
95
+ expect(url).toBe("https://files.slack.com/files-pri/F12345");
96
+ });
97
+
98
+ test("throws when neither URL is present", async () => {
99
+ const file = makeSlackFile({
100
+ url_private_download: undefined,
101
+ url_private: undefined,
102
+ });
103
+
104
+ await expect(
105
+ downloadSlackFile(file, "xoxb-test-token"),
106
+ ).rejects.toThrow("Slack file F12345 has no download URL");
107
+ });
108
+
109
+ test("throws on HTTP error response", async () => {
110
+ fetchMock = mock(
111
+ async () => new Response("Forbidden", { status: 403, statusText: "Forbidden" }),
112
+ );
113
+
114
+ const file = makeSlackFile();
115
+
116
+ await expect(
117
+ downloadSlackFile(file, "xoxb-test-token"),
118
+ ).rejects.toThrow(
119
+ "Failed to download Slack file F12345: 403 Forbidden",
120
+ );
121
+ });
122
+
123
+ test("file.mimetype wins over detected and Content-Type", async () => {
124
+ // Return a PNG buffer but set file.mimetype to a custom type
125
+ const fileBuffer = makePngBuffer();
126
+ fetchMock = mock(
127
+ async () =>
128
+ new Response(fileBuffer, {
129
+ headers: { "Content-Type": "application/octet-stream" },
130
+ }),
131
+ );
132
+
133
+ const file = makeSlackFile({ mimetype: "image/webp" });
134
+ const result = await downloadSlackFile(file, "xoxb-test-token");
135
+
136
+ expect(result.mimeType).toBe("image/webp");
137
+ });
138
+
139
+ test("falls back to detected MIME when file.mimetype is absent", async () => {
140
+ const fileBuffer = makePngBuffer();
141
+ fetchMock = mock(async () => new Response(fileBuffer));
142
+
143
+ const file = makeSlackFile({ mimetype: undefined });
144
+ const result = await downloadSlackFile(file, "xoxb-test-token");
145
+
146
+ expect(result.mimeType).toBe("image/png");
147
+ });
148
+
149
+ test("falls back to Content-Type when mimetype is absent and type is undetectable", async () => {
150
+ const fileBuffer = makeTextBuffer();
151
+ fetchMock = mock(
152
+ async () =>
153
+ new Response(fileBuffer, {
154
+ headers: { "Content-Type": "text/plain; charset=utf-8" },
155
+ }),
156
+ );
157
+
158
+ const file = makeSlackFile({ mimetype: undefined });
159
+ const result = await downloadSlackFile(file, "xoxb-test-token");
160
+
161
+ expect(result.mimeType).toBe("text/plain");
162
+ });
163
+
164
+ test("falls back to application/octet-stream when nothing else is available", async () => {
165
+ const fileBuffer = makeTextBuffer();
166
+ fetchMock = mock(async () => new Response(fileBuffer));
167
+
168
+ const file = makeSlackFile({ mimetype: undefined });
169
+ const result = await downloadSlackFile(file, "xoxb-test-token");
170
+
171
+ expect(result.mimeType).toBe("application/octet-stream");
172
+ });
173
+
174
+ test("falls back to slack_file_{id} when file.name is absent", async () => {
175
+ const fileBuffer = makeTextBuffer();
176
+ fetchMock = mock(async () => new Response(fileBuffer));
177
+
178
+ const file = makeSlackFile({ name: undefined, mimetype: "text/plain" });
179
+ const result = await downloadSlackFile(file, "xoxb-test-token");
180
+
181
+ expect(result.filename).toBe("slack_file_F12345");
182
+ });
183
+ });
@@ -0,0 +1,50 @@
1
+ import { fileTypeFromBuffer } from "file-type";
2
+ import { fetchImpl } from "../fetch.js";
3
+ import type { SlackFile } from "./normalize.js";
4
+
5
+ export interface DownloadedFile {
6
+ filename: string;
7
+ mimeType: string;
8
+ data: string; // base64-encoded
9
+ }
10
+
11
+ const DOWNLOAD_TIMEOUT_MS = 30_000;
12
+
13
+ /**
14
+ * Download a Slack file using the bot token for authentication.
15
+ * Prefers url_private_download; falls back to url_private.
16
+ */
17
+ export async function downloadSlackFile(
18
+ file: SlackFile,
19
+ botToken: string,
20
+ ): Promise<DownloadedFile> {
21
+ const url = file.url_private_download || file.url_private;
22
+ if (!url) {
23
+ throw new Error(`Slack file ${file.id} has no download URL`);
24
+ }
25
+
26
+ const response = await fetchImpl(url, {
27
+ headers: { Authorization: `Bearer ${botToken}` },
28
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
29
+ });
30
+
31
+ if (!response.ok) {
32
+ throw new Error(
33
+ `Failed to download Slack file ${file.id}: ${response.status} ${response.statusText}`,
34
+ );
35
+ }
36
+
37
+ const buffer = await response.arrayBuffer();
38
+ const detected = await fileTypeFromBuffer(new Uint8Array(buffer));
39
+
40
+ const mimeType =
41
+ file.mimetype ||
42
+ detected?.mime ||
43
+ response.headers.get("Content-Type")?.split(";")[0].trim() ||
44
+ "application/octet-stream";
45
+
46
+ const filename = file.name || `slack_file_${file.id}`;
47
+ const data = Buffer.from(buffer).toString("base64");
48
+
49
+ return { filename, mimeType, data };
50
+ }
@@ -2,8 +2,15 @@ import { describe, it, expect } from "bun:test";
2
2
  import {
3
3
  normalizeSlackBlockActions,
4
4
  normalizeSlackReactionAdded,
5
+ normalizeSlackDirectMessage,
6
+ normalizeSlackChannelMessage,
7
+ normalizeSlackAppMention,
5
8
  type SlackBlockActionsPayload,
6
9
  type SlackReactionAddedEvent,
10
+ type SlackDirectMessageEvent,
11
+ type SlackChannelMessageEvent,
12
+ type SlackAppMentionEvent,
13
+ type SlackFile,
7
14
  } from "./normalize.js";
8
15
  import type { GatewayConfig } from "../config.js";
9
16
 
@@ -252,3 +259,216 @@ describe("normalizeSlackReactionAdded", () => {
252
259
  );
253
260
  });
254
261
  });
262
+
263
+ // --- Attachment extraction tests ---
264
+
265
+ function makeSlackFile(overrides?: Partial<SlackFile>): SlackFile {
266
+ return {
267
+ id: "F001",
268
+ name: "photo.png",
269
+ mimetype: "image/png",
270
+ size: 12345,
271
+ url_private_download: "https://files.slack.com/download/photo.png",
272
+ url_private: "https://files.slack.com/files/photo.png",
273
+ ...overrides,
274
+ };
275
+ }
276
+
277
+ function makeDmEvent(
278
+ overrides?: Partial<SlackDirectMessageEvent>,
279
+ ): SlackDirectMessageEvent {
280
+ return {
281
+ type: "message",
282
+ user: "U123",
283
+ text: "hello",
284
+ ts: "1234567890.123456",
285
+ channel: "D789",
286
+ channel_type: "im",
287
+ ...overrides,
288
+ };
289
+ }
290
+
291
+ function makeChannelEvent(
292
+ overrides?: Partial<SlackChannelMessageEvent>,
293
+ ): SlackChannelMessageEvent {
294
+ return {
295
+ type: "message",
296
+ user: "U123",
297
+ text: "hello",
298
+ ts: "1234567890.123456",
299
+ channel: "C456",
300
+ channel_type: "channel",
301
+ ...overrides,
302
+ };
303
+ }
304
+
305
+ function makeAppMentionEvent(
306
+ overrides?: Partial<SlackAppMentionEvent>,
307
+ ): SlackAppMentionEvent {
308
+ return {
309
+ type: "app_mention",
310
+ user: "U123",
311
+ text: "<@UBOT> hello",
312
+ ts: "1234567890.123456",
313
+ channel: "C456",
314
+ ...overrides,
315
+ };
316
+ }
317
+
318
+ describe("attachment extraction in normalize functions", () => {
319
+ describe("normalizeSlackDirectMessage", () => {
320
+ it("populates attachments with type 'image' for image files", () => {
321
+ const config = makeConfig();
322
+ const event = makeDmEvent({
323
+ files: [
324
+ makeSlackFile({ id: "F001", mimetype: "image/png", name: "photo.png" }),
325
+ makeSlackFile({ id: "F002", mimetype: "image/jpeg", name: "pic.jpg" }),
326
+ ],
327
+ });
328
+ const result = normalizeSlackDirectMessage(event, "evt-1", config);
329
+
330
+ expect(result).not.toBeNull();
331
+ expect(result!.event.message.attachments).toHaveLength(2);
332
+ expect(result!.event.message.attachments![0]).toEqual({
333
+ type: "image",
334
+ fileId: "F001",
335
+ fileName: "photo.png",
336
+ mimeType: "image/png",
337
+ fileSize: 12345,
338
+ });
339
+ expect(result!.event.message.attachments![1]).toEqual({
340
+ type: "image",
341
+ fileId: "F002",
342
+ fileName: "pic.jpg",
343
+ mimeType: "image/jpeg",
344
+ fileSize: 12345,
345
+ });
346
+
347
+ // slackFiles map should be populated
348
+ expect(result!.slackFiles).toBeDefined();
349
+ expect(result!.slackFiles!.size).toBe(2);
350
+ expect(result!.slackFiles!.get("F001")!.id).toBe("F001");
351
+ expect(result!.slackFiles!.get("F002")!.id).toBe("F002");
352
+ });
353
+
354
+ it("populates attachments with type 'document' for non-image files", () => {
355
+ const config = makeConfig();
356
+ const event = makeDmEvent({
357
+ files: [
358
+ makeSlackFile({
359
+ id: "F003",
360
+ mimetype: "application/pdf",
361
+ name: "doc.pdf",
362
+ size: 99999,
363
+ }),
364
+ ],
365
+ });
366
+ const result = normalizeSlackDirectMessage(event, "evt-2", config);
367
+
368
+ expect(result).not.toBeNull();
369
+ expect(result!.event.message.attachments).toHaveLength(1);
370
+ expect(result!.event.message.attachments![0]).toEqual({
371
+ type: "document",
372
+ fileId: "F003",
373
+ fileName: "doc.pdf",
374
+ mimeType: "application/pdf",
375
+ fileSize: 99999,
376
+ });
377
+ });
378
+
379
+ it("filters out files missing download URLs", () => {
380
+ const config = makeConfig();
381
+ const event = makeDmEvent({
382
+ files: [
383
+ makeSlackFile({ id: "F004" }),
384
+ makeSlackFile({
385
+ id: "F005",
386
+ url_private_download: undefined,
387
+ url_private: undefined,
388
+ }),
389
+ ],
390
+ });
391
+ const result = normalizeSlackDirectMessage(event, "evt-3", config);
392
+
393
+ expect(result).not.toBeNull();
394
+ // Only F004 has download URLs
395
+ expect(result!.event.message.attachments).toHaveLength(1);
396
+ expect(result!.event.message.attachments![0].fileId).toBe("F004");
397
+ expect(result!.slackFiles!.size).toBe(1);
398
+ expect(result!.slackFiles!.has("F005")).toBe(false);
399
+ });
400
+
401
+ it("omits attachments field when files is empty", () => {
402
+ const config = makeConfig();
403
+ const event = makeDmEvent({ files: [] });
404
+ const result = normalizeSlackDirectMessage(event, "evt-4", config);
405
+
406
+ expect(result).not.toBeNull();
407
+ expect(result!.event.message.attachments).toBeUndefined();
408
+ expect(result!.slackFiles).toBeUndefined();
409
+ });
410
+
411
+ it("omits attachments field when files is undefined", () => {
412
+ const config = makeConfig();
413
+ const event = makeDmEvent();
414
+ const result = normalizeSlackDirectMessage(event, "evt-5", config);
415
+
416
+ expect(result).not.toBeNull();
417
+ expect(result!.event.message.attachments).toBeUndefined();
418
+ expect(result!.slackFiles).toBeUndefined();
419
+ });
420
+ });
421
+
422
+ describe("normalizeSlackChannelMessage", () => {
423
+ it("populates attachments for channel messages with files", () => {
424
+ const config = makeConfig();
425
+ const event = makeChannelEvent({
426
+ files: [
427
+ makeSlackFile({ id: "F010", mimetype: "image/gif", name: "anim.gif" }),
428
+ ],
429
+ });
430
+ const result = normalizeSlackChannelMessage(event, "evt-ch-1", config);
431
+
432
+ expect(result).not.toBeNull();
433
+ expect(result!.event.message.attachments).toHaveLength(1);
434
+ expect(result!.event.message.attachments![0]).toEqual({
435
+ type: "image",
436
+ fileId: "F010",
437
+ fileName: "anim.gif",
438
+ mimeType: "image/gif",
439
+ fileSize: 12345,
440
+ });
441
+ expect(result!.slackFiles).toBeDefined();
442
+ expect(result!.slackFiles!.get("F010")!.name).toBe("anim.gif");
443
+ });
444
+ });
445
+
446
+ describe("normalizeSlackAppMention", () => {
447
+ it("populates attachments for app mention events with files", () => {
448
+ const config = makeConfig();
449
+ const event = makeAppMentionEvent({
450
+ files: [
451
+ makeSlackFile({
452
+ id: "F020",
453
+ mimetype: "text/plain",
454
+ name: "notes.txt",
455
+ size: 500,
456
+ }),
457
+ ],
458
+ });
459
+ const result = normalizeSlackAppMention(event, "evt-am-1", config);
460
+
461
+ expect(result).not.toBeNull();
462
+ expect(result!.event.message.attachments).toHaveLength(1);
463
+ expect(result!.event.message.attachments![0]).toEqual({
464
+ type: "document",
465
+ fileId: "F020",
466
+ fileName: "notes.txt",
467
+ mimeType: "text/plain",
468
+ fileSize: 500,
469
+ });
470
+ expect(result!.slackFiles).toBeDefined();
471
+ expect(result!.slackFiles!.get("F020")!.id).toBe("F020");
472
+ });
473
+ });
474
+ });
@@ -166,6 +166,16 @@ export function getUserInfoCacheSize(): number {
166
166
  return userInfoCache.size;
167
167
  }
168
168
 
169
+ /** Slack file object (subset relevant to attachment handling). */
170
+ export interface SlackFile {
171
+ id: string;
172
+ name?: string;
173
+ mimetype?: string;
174
+ size?: number;
175
+ url_private_download?: string;
176
+ url_private?: string;
177
+ }
178
+
169
179
  /**
170
180
  * Slack `app_mention` event shape (subset relevant to normalization).
171
181
  */
@@ -178,6 +188,7 @@ export interface SlackAppMentionEvent {
178
188
  thread_ts?: string;
179
189
  client_msg_id?: string;
180
190
  event_ts?: string;
191
+ files?: SlackFile[];
181
192
  }
182
193
 
183
194
  /**
@@ -194,6 +205,7 @@ export interface SlackDirectMessageEvent {
194
205
  thread_ts?: string;
195
206
  client_msg_id?: string;
196
207
  event_ts?: string;
208
+ files?: SlackFile[];
197
209
  }
198
210
 
199
211
  /**
@@ -211,6 +223,7 @@ export interface SlackChannelMessageEvent {
211
223
  thread_ts?: string;
212
224
  client_msg_id?: string;
213
225
  event_ts?: string;
226
+ files?: SlackFile[];
214
227
  }
215
228
 
216
229
  /**
@@ -251,6 +264,29 @@ export function stripBotMention(text: string): string {
251
264
  return stripped || text.trim();
252
265
  }
253
266
 
267
+ function extractSlackAttachments(
268
+ files: SlackFile[] | undefined,
269
+ ): Array<{
270
+ type: "image" | "document";
271
+ fileId: string;
272
+ fileName?: string;
273
+ mimeType?: string;
274
+ fileSize?: number;
275
+ }> {
276
+ if (!files || files.length === 0) return [];
277
+ return files
278
+ .filter((f) => f.url_private_download || f.url_private)
279
+ .map((f) => ({
280
+ type: f.mimetype?.startsWith("image/")
281
+ ? ("image" as const)
282
+ : ("document" as const),
283
+ fileId: f.id,
284
+ fileName: f.name,
285
+ mimeType: f.mimetype,
286
+ fileSize: f.size,
287
+ }));
288
+ }
289
+
254
290
  export type NormalizedSlackEvent = {
255
291
  event: GatewayInboundEvent;
256
292
  routing: RouteResult;
@@ -258,6 +294,8 @@ export type NormalizedSlackEvent = {
258
294
  threadTs: string;
259
295
  /** Slack channel ID. */
260
296
  channel: string;
297
+ /** Original Slack file objects keyed by file ID, for download in the I/O layer. */
298
+ slackFiles?: Map<string, SlackFile>;
261
299
  };
262
300
 
263
301
  /**
@@ -300,6 +338,15 @@ export function normalizeSlackDirectMessage(
300
338
  const externalMessageId =
301
339
  event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
302
340
 
341
+ const attachments = extractSlackAttachments(event.files);
342
+ const slackFiles = event.files?.length
343
+ ? new Map(
344
+ event.files
345
+ .filter((f) => f.url_private_download || f.url_private)
346
+ .map((f) => [f.id, f]),
347
+ )
348
+ : undefined;
349
+
303
350
  // Use cache-only lookup to avoid blocking normalization on network calls.
304
351
  // A background fetch warms the cache for subsequent messages from this user.
305
352
  const userInfo =
@@ -316,6 +363,7 @@ export function normalizeSlackDirectMessage(
316
363
  content: event.text,
317
364
  conversationExternalId: event.channel,
318
365
  externalMessageId,
366
+ ...(attachments.length > 0 ? { attachments } : {}),
319
367
  },
320
368
  actor: {
321
369
  actorExternalId: event.user,
@@ -333,6 +381,7 @@ export function normalizeSlackDirectMessage(
333
381
  routing,
334
382
  threadTs: event.thread_ts ?? event.ts,
335
383
  channel: event.channel,
384
+ ...(slackFiles ? { slackFiles } : {}),
336
385
  };
337
386
  }
338
387
 
@@ -361,6 +410,15 @@ export function normalizeSlackChannelMessage(
361
410
  const externalMessageId =
362
411
  event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
363
412
 
413
+ const attachments = extractSlackAttachments(event.files);
414
+ const slackFiles = event.files?.length
415
+ ? new Map(
416
+ event.files
417
+ .filter((f) => f.url_private_download || f.url_private)
418
+ .map((f) => [f.id, f]),
419
+ )
420
+ : undefined;
421
+
364
422
  const userInfo =
365
423
  botToken && event.user
366
424
  ? resolveSlackUserSync(event.user, botToken)
@@ -375,6 +433,7 @@ export function normalizeSlackChannelMessage(
375
433
  content,
376
434
  conversationExternalId: event.channel,
377
435
  externalMessageId,
436
+ ...(attachments.length > 0 ? { attachments } : {}),
378
437
  },
379
438
  actor: {
380
439
  actorExternalId: event.user,
@@ -393,6 +452,7 @@ export function normalizeSlackChannelMessage(
393
452
  routing,
394
453
  threadTs: event.thread_ts ?? event.ts,
395
454
  channel: event.channel,
455
+ ...(slackFiles ? { slackFiles } : {}),
396
456
  };
397
457
  }
398
458
 
@@ -418,6 +478,15 @@ export function normalizeSlackAppMention(
418
478
  const externalMessageId =
419
479
  event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
420
480
 
481
+ const attachments = extractSlackAttachments(event.files);
482
+ const slackFiles = event.files?.length
483
+ ? new Map(
484
+ event.files
485
+ .filter((f) => f.url_private_download || f.url_private)
486
+ .map((f) => [f.id, f]),
487
+ )
488
+ : undefined;
489
+
421
490
  const userInfo =
422
491
  botToken && event.user
423
492
  ? resolveSlackUserSync(event.user, botToken)
@@ -432,6 +501,7 @@ export function normalizeSlackAppMention(
432
501
  content,
433
502
  conversationExternalId: event.channel,
434
503
  externalMessageId,
504
+ ...(attachments.length > 0 ? { attachments } : {}),
435
505
  },
436
506
  actor: {
437
507
  actorExternalId: event.user,
@@ -449,6 +519,7 @@ export function normalizeSlackAppMention(
449
519
  routing,
450
520
  threadTs: event.thread_ts ?? event.ts,
451
521
  channel: event.channel,
522
+ ...(slackFiles ? { slackFiles } : {}),
452
523
  };
453
524
  }
454
525