@vellumai/vellum-gateway 0.6.0 → 0.6.2

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/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  } from "./http/routes/twilio-relay-websocket.js";
42
42
  import { createWhatsAppWebhookHandler } from "./http/routes/whatsapp-webhook.js";
43
43
  import { createWhatsAppDeliverHandler } from "./http/routes/whatsapp-deliver.js";
44
+ import { createEmailWebhookHandler } from "./http/routes/email-webhook.js";
44
45
  import { createSlackDeliverHandler } from "./http/routes/slack-deliver.js";
45
46
  import { createOAuthCallbackHandler } from "./http/routes/oauth-callback.js";
46
47
  import { createPairingProxyHandler } from "./http/routes/pairing-proxy.js";
@@ -67,6 +68,7 @@ import {
67
68
  import { createMigrationRollbackProxyHandler } from "./http/routes/migration-rollback-proxy.js";
68
69
  import { createWorkspaceCommitProxyHandler } from "./http/routes/workspace-commit-proxy.js";
69
70
  import { createBrainGraphProxyHandler } from "./http/routes/brain-graph-proxy.js";
71
+ import { createLogExportHandler } from "./http/routes/log-export.js";
70
72
  import {
71
73
  createTrustRulesListHandler,
72
74
  createTrustRulesAddHandler,
@@ -238,6 +240,11 @@ async function main() {
238
240
  credentials: credentialCache,
239
241
  configFile: configFileCache,
240
242
  });
243
+ const { handler: handleEmailWebhook, dedupCache: emailDedupCache } =
244
+ createEmailWebhookHandler(config, {
245
+ credentials: credentialCache,
246
+ configFile: configFileCache,
247
+ });
241
248
  // Map: "channel:threadTs" -> { messageTs, expiresAt } for replacing approval
242
249
  // messages with the bot's follow-up content after an approval button click.
243
250
  const pendingApprovalReplacements = new Map<
@@ -282,6 +289,7 @@ async function main() {
282
289
  const migrationRollbackProxy = createMigrationRollbackProxyHandler(config);
283
290
  const workspaceCommitProxy = createWorkspaceCommitProxyHandler(config);
284
291
  const brainGraphProxy = createBrainGraphProxyHandler(config);
292
+ const handleLogExport = createLogExportHandler(config);
285
293
  const handleFeatureFlagsGet = createFeatureFlagsGetHandler();
286
294
  const handleFeatureFlagsPatch = createFeatureFlagsPatchHandler();
287
295
  const handlePrivacyConfigPatch = createPrivacyConfigPatchHandler();
@@ -351,6 +359,10 @@ async function main() {
351
359
  precondition: requireWhatsApp,
352
360
  handler: (req) => handleWhatsAppWebhook(req),
353
361
  },
362
+ {
363
+ path: "/webhooks/email/inbound",
364
+ handler: (req) => handleEmailWebhook(req),
365
+ },
354
366
 
355
367
  // ── Audio serving (unauthenticated — Twilio fetches these URLs directly) ──
356
368
  {
@@ -923,6 +935,15 @@ async function main() {
923
935
  handler: (req) => handlePrivacyConfigPatch(req),
924
936
  },
925
937
 
938
+ // ── Log export ──
939
+ {
940
+ path: "/v1/logs/export",
941
+ method: "POST",
942
+ auth: "edge",
943
+ handler: (req, params, getClientIp) =>
944
+ handleLogExport(req, params, getClientIp),
945
+ },
946
+
926
947
  // ── Trust rules ──
927
948
  {
928
949
  path: "/v1/trust-rules/clear",
@@ -1161,6 +1182,7 @@ async function main() {
1161
1182
  // Start periodic background cleanup for dedup caches
1162
1183
  telegramDedupCache.startCleanup();
1163
1184
  whatsappDedupCache.startCleanup();
1185
+ emailDedupCache.startCleanup();
1164
1186
 
1165
1187
  const telegramCaches = {
1166
1188
  credentials: credentialCache,
@@ -1240,6 +1262,7 @@ async function main() {
1240
1262
  !isCallback
1241
1263
  ) {
1242
1264
  attachmentIds = [];
1265
+ const failedAttachmentNames: string[] = [];
1243
1266
  const maxBytes =
1244
1267
  config.maxAttachmentBytes.slack ??
1245
1268
  config.maxAttachmentBytes.default;
@@ -1290,17 +1313,22 @@ async function main() {
1290
1313
  });
1291
1314
  }),
1292
1315
  );
1293
- for (const result of results) {
1316
+ for (let j = 0; j < results.length; j++) {
1317
+ const result = results[j];
1294
1318
  if (result.status === "fulfilled") {
1295
1319
  attachmentIds.push(result.value.id);
1296
1320
  } else if (
1297
1321
  result.reason instanceof AttachmentValidationError
1298
1322
  ) {
1323
+ const att = batch[j];
1324
+ failedAttachmentNames.push(att.fileName || att.fileId);
1299
1325
  log.warn(
1300
1326
  { err: result.reason },
1301
1327
  "Skipping Slack attachment with validation error",
1302
1328
  );
1303
1329
  } else {
1330
+ const att = batch[j];
1331
+ failedAttachmentNames.push(att.fileName || att.fileId);
1304
1332
  log.warn(
1305
1333
  { err: result.reason },
1306
1334
  "Skipping Slack attachment due to download/upload failure",
@@ -1308,6 +1336,13 @@ async function main() {
1308
1336
  }
1309
1337
  }
1310
1338
  }
1339
+
1340
+ if (failedAttachmentNames.length > 0) {
1341
+ const nameList = failedAttachmentNames
1342
+ .map((n) => `"${n}"`)
1343
+ .join(", ");
1344
+ normalized.event.message.content += `\n\n[The user attached file(s) that could not be retrieved: ${nameList}. Ask them to re-send if the content is important.]`;
1345
+ }
1311
1346
  }
1312
1347
 
1313
1348
  handleInbound(config, normalized.event, {
@@ -1508,6 +1543,7 @@ async function main() {
1508
1543
  remoteFeatureFlagSync.stop();
1509
1544
  telegramDedupCache.stopCleanup();
1510
1545
  whatsappDedupCache.stopCleanup();
1546
+ emailDedupCache.stopCleanup();
1511
1547
  if (slackSocketClient) {
1512
1548
  slackSocketClient.stop();
1513
1549
  slackSocketClient = null;
package/src/logger.ts CHANGED
@@ -17,7 +17,7 @@ export type LogFileConfig = {
17
17
 
18
18
  const LOG_FILE_PREFIX = "gateway-";
19
19
  const LOG_FILE_SUFFIX = ".log";
20
- const LOG_FILE_PATTERN = /^gateway-(\d{4}-\d{2}-\d{2})\.log$/;
20
+ export const LOG_FILE_PATTERN = /^gateway-(\d{4}-\d{2}-\d{2})\.log$/;
21
21
 
22
22
  function formatDate(date: Date): string {
23
23
  const y = date.getUTCFullYear();
@@ -119,10 +119,25 @@ export function initLogger(config: LogFileConfig): void {
119
119
  }
120
120
  }
121
121
 
122
+ /**
123
+ * Returns a lazy proxy logger that always delegates to the **current**
124
+ * rootLogger. This is critical because module-level `const log = getLogger(...)`
125
+ * calls execute before `initLogger()` runs. Without the proxy, those early
126
+ * child loggers would permanently hold the fallback stdout-only stream and
127
+ * never write to log files.
128
+ */
122
129
  export function getLogger(name: string): pino.Logger {
123
- ensureCurrentDate();
124
- if (!rootLogger) {
125
- rootLogger = buildLogger(null);
126
- }
127
- return rootLogger.child({ module: name });
130
+ const handler: ProxyHandler<pino.Logger> = {
131
+ get(_target, prop, receiver) {
132
+ ensureCurrentDate();
133
+ if (!rootLogger) {
134
+ rootLogger = buildLogger(null);
135
+ }
136
+ const child = rootLogger.child({ module: name });
137
+ const value = Reflect.get(child, prop, receiver);
138
+ return typeof value === "function" ? value.bind(child) : value;
139
+ },
140
+ };
141
+ // The proxy target is a throwaway logger — all access is intercepted.
142
+ return new Proxy({} as pino.Logger, handler);
128
143
  }
@@ -1,20 +1,28 @@
1
1
  import type { CredentialCache } from "./credential-cache.js";
2
2
  import { credentialKey } from "./credential-key.js";
3
3
  import { fetchImpl } from "./fetch.js";
4
+ import { loadFeatureFlagDefaults } from "./feature-flag-defaults.js";
4
5
  import { writeRemoteFeatureFlags } from "./feature-flag-remote-store.js";
5
6
  import { getLogger } from "./logger.js";
6
7
 
7
8
  const log = getLogger("remote-feature-flag-sync");
8
9
 
9
10
  /**
10
- * Default polling interval: 5 minutes.
11
+ * Steady-state polling interval: 5 minutes.
11
12
  *
12
13
  * Configurable via `REMOTE_FF_POLL_INTERVAL_MS` env var for testing or
13
14
  * deployment tuning.
14
15
  */
15
16
  const DEFAULT_POLL_INTERVAL_MS = 5 * 60 * 1000;
16
17
 
17
- function getPollIntervalMs(): number {
18
+ /**
19
+ * Initial polling interval when the first fetch fails (e.g. CES sidecar
20
+ * not ready yet). Doubles on each consecutive failure until it reaches
21
+ * the steady-state interval.
22
+ */
23
+ const INITIAL_POLL_INTERVAL_MS = 10_000;
24
+
25
+ function getMaxPollIntervalMs(): number {
18
26
  const envVal = process.env.REMOTE_FF_POLL_INTERVAL_MS;
19
27
  if (envVal) {
20
28
  const parsed = parseInt(envVal, 10);
@@ -26,63 +34,114 @@ function getPollIntervalMs(): number {
26
34
  export type RemoteFeatureFlagSyncConfig = {
27
35
  /** Credential cache for resolving platform URL, API key, and assistant ID dynamically. */
28
36
  credentials: CredentialCache;
37
+ /** Override the initial poll interval (ms) — useful for testing. Defaults to 10 000. */
38
+ initialPollIntervalMs?: number;
29
39
  };
30
40
 
31
41
  /**
32
42
  * Manages the lifecycle of syncing remote feature flags from the platform.
33
43
  *
34
44
  * On start, fetches the current flag state and persists it to disk via the
35
- * remote feature flag store, then polls on a configurable interval. Errors
36
- * are caught and logged the system falls through to registry defaults if
37
- * this fails.
45
+ * remote feature flag store, then polls with adaptive back-off: starts at
46
+ * {@link INITIAL_POLL_INTERVAL_MS} and doubles on each failure until it
47
+ * reaches the steady-state interval. On the first success the interval
48
+ * snaps to steady-state immediately.
38
49
  */
39
50
  export class RemoteFeatureFlagSync {
40
51
  private started = false;
41
- private pollTimer: ReturnType<typeof setInterval> | null = null;
52
+ private pollTimer: ReturnType<typeof setTimeout> | null = null;
53
+ private currentIntervalMs: number;
54
+ private readonly maxIntervalMs: number;
42
55
  private readonly credentials: CredentialCache;
43
56
 
44
57
  constructor(config: RemoteFeatureFlagSyncConfig) {
45
58
  this.credentials = config.credentials;
59
+ this.currentIntervalMs =
60
+ config.initialPollIntervalMs ?? INITIAL_POLL_INTERVAL_MS;
61
+ this.maxIntervalMs = getMaxPollIntervalMs();
46
62
  }
47
63
 
48
64
  async start(): Promise<void> {
49
65
  this.started = true;
50
66
 
67
+ let ok = false;
51
68
  try {
52
- await this.fetchAndCache();
69
+ ok = await this.fetchAndCache();
53
70
  } catch (err) {
54
71
  log.warn({ err }, "Failed to sync remote feature flags on startup");
55
72
  }
56
73
 
57
- const intervalMs = getPollIntervalMs();
58
- this.pollTimer = setInterval(() => {
59
- this.fetchAndCache().catch((err) => {
60
- log.warn({ err }, "Failed to sync remote feature flags during poll");
61
- });
62
- }, intervalMs);
74
+ if (ok) {
75
+ // First fetch succeeded — jump straight to steady-state polling.
76
+ this.currentIntervalMs = this.maxIntervalMs;
77
+ }
63
78
 
64
- log.info({ intervalMs }, "Remote feature flag polling started");
79
+ this.scheduleNextPoll();
80
+ log.info(
81
+ { intervalMs: this.currentIntervalMs },
82
+ "Remote feature flag polling started",
83
+ );
65
84
  }
66
85
 
67
86
  stop(): void {
68
87
  this.started = false;
69
88
  if (this.pollTimer) {
70
- clearInterval(this.pollTimer);
89
+ clearTimeout(this.pollTimer);
71
90
  this.pollTimer = null;
72
91
  }
73
92
  }
74
93
 
75
- private async fetchAndCache(): Promise<void> {
94
+ private scheduleNextPoll(): void {
95
+ this.pollTimer = setTimeout(() => {
96
+ this.poll();
97
+ }, this.currentIntervalMs);
98
+ }
99
+
100
+ private poll(): void {
101
+ if (!this.started) return;
102
+ this.fetchAndCache()
103
+ .then((ok) => {
104
+ if (ok) {
105
+ // Success — snap to steady-state interval.
106
+ this.currentIntervalMs = this.maxIntervalMs;
107
+ } else {
108
+ // Failure — double the interval, capped at max.
109
+ this.currentIntervalMs = Math.min(
110
+ this.currentIntervalMs * 2,
111
+ this.maxIntervalMs,
112
+ );
113
+ }
114
+ })
115
+ .catch((err) => {
116
+ log.warn({ err }, "Failed to sync remote feature flags during poll");
117
+ this.currentIntervalMs = Math.min(
118
+ this.currentIntervalMs * 2,
119
+ this.maxIntervalMs,
120
+ );
121
+ })
122
+ .finally(() => {
123
+ if (this.started) {
124
+ this.scheduleNextPoll();
125
+ }
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Fetch remote flags and write them to the store.
131
+ * Returns true if flags were successfully written, false otherwise.
132
+ */
133
+ private async fetchAndCache(): Promise<boolean> {
76
134
  const values = await this.fetchRemoteFeatureFlags();
77
135
  if (values === null) {
78
- log.debug("Skipping cache write — fetch returned no usable data");
79
- return;
136
+ log.warn("Skipping cache write — fetch returned no usable data");
137
+ return false;
80
138
  }
81
139
  writeRemoteFeatureFlags(values);
82
140
  log.info(
83
141
  { count: Object.keys(values).length },
84
142
  "Synced remote feature flags",
85
143
  );
144
+ return true;
86
145
  }
87
146
 
88
147
  private async fetchRemoteFeatureFlags(): Promise<Record<
@@ -153,12 +212,23 @@ export class RemoteFeatureFlagSync {
153
212
  return null;
154
213
  }
155
214
 
156
- // Filter to boolean values only (defensive)
215
+ // Filter to boolean values only (defensive), and prevent the platform
216
+ // from disabling flags that are already GA (defaultEnabled: true in the
217
+ // registry). The platform uses a blanket-deny posture, sending false for
218
+ // every flag it knows about. Without this filter, shipped features get
219
+ // silently turned off for all users.
220
+ const registry = loadFeatureFlagDefaults();
157
221
  const result: Record<string, boolean> = {};
158
222
  for (const [key, value] of Object.entries(body.flags)) {
159
- if (typeof value === "boolean") {
160
- result[key] = value;
223
+ if (typeof value !== "boolean") continue;
224
+ if (!value && registry[key]?.defaultEnabled) {
225
+ log.debug(
226
+ { key },
227
+ "Ignoring remote false for GA flag (defaultEnabled: true)",
228
+ );
229
+ continue;
161
230
  }
231
+ result[key] = value;
162
232
  }
163
233
 
164
234
  return result;
package/src/schema.ts CHANGED
@@ -613,6 +613,95 @@ export function buildSchema(): Record<string, unknown> {
613
613
  },
614
614
  },
615
615
  },
616
+ "/webhooks/email/inbound": {
617
+ post: {
618
+ summary: "Email inbound webhook",
619
+ description:
620
+ "Receives inbound email webhook events from the Vellum platform, verifies the HMAC signature, normalizes the message, and forwards it to the assistant runtime.",
621
+ operationId: "emailInboundWebhook",
622
+ security: [{ VellumSignature: [] }],
623
+ requestBody: {
624
+ required: true,
625
+ content: {
626
+ "application/json": {
627
+ schema: {
628
+ type: "object",
629
+ description: "Vellum email webhook payload.",
630
+ },
631
+ },
632
+ },
633
+ },
634
+ responses: {
635
+ "200": {
636
+ description: "Webhook accepted",
637
+ content: {
638
+ "application/json": {
639
+ schema: {
640
+ type: "object",
641
+ properties: { ok: { type: "boolean" } },
642
+ },
643
+ },
644
+ },
645
+ },
646
+ "400": {
647
+ description: "Invalid JSON or unreadable body",
648
+ content: {
649
+ "application/json": {
650
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
651
+ },
652
+ },
653
+ },
654
+ "403": {
655
+ description: "Signature verification failed",
656
+ content: {
657
+ "application/json": {
658
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
659
+ },
660
+ },
661
+ },
662
+ "405": {
663
+ description: "Method not allowed",
664
+ content: {
665
+ "application/json": {
666
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
667
+ },
668
+ },
669
+ },
670
+ "413": {
671
+ description: "Payload too large",
672
+ content: {
673
+ "application/json": {
674
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
675
+ },
676
+ },
677
+ },
678
+ "409": {
679
+ description: "Webhook secret not configured",
680
+ content: {
681
+ "application/json": {
682
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
683
+ },
684
+ },
685
+ },
686
+ "500": {
687
+ description: "Internal error",
688
+ content: {
689
+ "application/json": {
690
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
691
+ },
692
+ },
693
+ },
694
+ "503": {
695
+ description: "Service temporarily unavailable",
696
+ content: {
697
+ "application/json": {
698
+ schema: { $ref: "#/components/schemas/ErrorResponse" },
699
+ },
700
+ },
701
+ },
702
+ },
703
+ },
704
+ },
616
705
  "/v1/audio/{audioId}": {
617
706
  get: {
618
707
  summary: "Retrieve synthesized audio",
@@ -2354,6 +2443,59 @@ export function buildSchema(): Record<string, unknown> {
2354
2443
  },
2355
2444
  },
2356
2445
  },
2446
+ "/v1/logs/export": {
2447
+ post: {
2448
+ summary: "Export logs from all services",
2449
+ description:
2450
+ "Orchestrates parallel log collection from the gateway, daemon, and CES, " +
2451
+ "returning a tar.gz archive containing logs from all three services.",
2452
+ operationId: "logsExport",
2453
+ security: [{ BearerAuth: [] }],
2454
+ requestBody: {
2455
+ required: false,
2456
+ content: {
2457
+ "application/json": {
2458
+ schema: {
2459
+ type: "object",
2460
+ properties: {
2461
+ startTime: {
2462
+ type: "number",
2463
+ description: "Start of time range (epoch ms)",
2464
+ },
2465
+ endTime: {
2466
+ type: "number",
2467
+ description: "End of time range (epoch ms)",
2468
+ },
2469
+ auditLimit: {
2470
+ type: "number",
2471
+ description: "Maximum number of audit records to include",
2472
+ },
2473
+ conversationId: {
2474
+ type: "string",
2475
+ description: "Scope export to a specific conversation",
2476
+ },
2477
+ },
2478
+ },
2479
+ },
2480
+ },
2481
+ },
2482
+ responses: {
2483
+ "200": {
2484
+ description: "tar.gz archive of collected logs",
2485
+ content: {
2486
+ "application/gzip": {
2487
+ schema: { type: "string", format: "binary" },
2488
+ },
2489
+ },
2490
+ },
2491
+ "400": { description: "Invalid JSON body" },
2492
+ "401": {
2493
+ description: "Unauthorized — missing or invalid edge JWT",
2494
+ },
2495
+ "500": { description: "Failed to create export archive" },
2496
+ },
2497
+ },
2498
+ },
2357
2499
  "/v1/trust-rules": {
2358
2500
  get: {
2359
2501
  summary: "List trust rules",
@@ -3409,6 +3551,13 @@ export function buildSchema(): Record<string, unknown> {
3409
3551
  description:
3410
3552
  "HMAC-SHA1 signature computed by Twilio over the request URL and form parameters.",
3411
3553
  },
3554
+ VellumSignature: {
3555
+ type: "apiKey",
3556
+ in: "header",
3557
+ name: "Vellum-Signature",
3558
+ description:
3559
+ "HMAC-SHA256 signature computed by the Vellum platform over the raw request body using the webhook secret. Format: sha256=<hex-digest>.",
3560
+ },
3412
3561
  WhatsAppHubSignature: {
3413
3562
  type: "apiKey",
3414
3563
  in: "header",
@@ -1,4 +1,5 @@
1
1
  import { afterEach, describe, expect, mock, test } from "bun:test";
2
+ import { ContentMismatchError } from "../download-validation.js";
2
3
  import type { SlackFile } from "./normalize.js";
3
4
 
4
5
  type FetchFn = (
@@ -67,9 +68,7 @@ describe("downloadSlackFile", () => {
67
68
 
68
69
  expect(result.filename).toBe("test-image.png");
69
70
  expect(result.mimeType).toBe("image/png");
70
- expect(result.data).toBe(
71
- Buffer.from(fileBuffer).toString("base64"),
72
- );
71
+ expect(result.data).toBe(Buffer.from(fileBuffer).toString("base64"));
73
72
 
74
73
  // Verify the correct URL and auth header were used
75
74
  expect(fetchMock).toHaveBeenCalledTimes(1);
@@ -101,21 +100,68 @@ describe("downloadSlackFile", () => {
101
100
  url_private: undefined,
102
101
  });
103
102
 
104
- await expect(
105
- downloadSlackFile(file, "xoxb-test-token"),
106
- ).rejects.toThrow("Slack file F12345 has no download URL");
103
+ await expect(downloadSlackFile(file, "xoxb-test-token")).rejects.toThrow(
104
+ "Slack file F12345 has no download URL",
105
+ );
106
+ });
107
+
108
+ test("uses redirect: manual to prevent auth header stripping", async () => {
109
+ const fileBuffer = makePngBuffer();
110
+ fetchMock = mock(async () => new Response(fileBuffer));
111
+
112
+ const file = makeSlackFile();
113
+ await downloadSlackFile(file, "xoxb-test-token");
114
+
115
+ const [, init] = fetchMock.mock.calls[0];
116
+ expect((init as RequestInit).redirect).toBe("manual");
117
+ });
118
+
119
+ test("follows redirect to CDN without auth header", async () => {
120
+ const fileBuffer = makePngBuffer();
121
+ let callCount = 0;
122
+ fetchMock = mock(async () => {
123
+ callCount++;
124
+ if (callCount === 1) {
125
+ return new Response(null, {
126
+ status: 302,
127
+ headers: { Location: "https://files-edge.slack.com/signed/F12345" },
128
+ });
129
+ }
130
+ return new Response(fileBuffer);
131
+ });
132
+
133
+ const file = makeSlackFile();
134
+ const result = await downloadSlackFile(file, "xoxb-test-token");
135
+
136
+ expect(fetchMock).toHaveBeenCalledTimes(2);
137
+ // Second call should be to the redirect URL without auth
138
+ const [redirectUrl, redirectInit] = fetchMock.mock.calls[1];
139
+ expect(redirectUrl).toBe("https://files-edge.slack.com/signed/F12345");
140
+ expect((redirectInit as RequestInit).headers).toBeUndefined();
141
+ expect(result.mimeType).toBe("image/png");
142
+ });
143
+
144
+ test("throws when redirect has no Location header", async () => {
145
+ fetchMock = mock(
146
+ async () => new Response(null, { status: 302 }),
147
+ );
148
+
149
+ const file = makeSlackFile();
150
+
151
+ await expect(downloadSlackFile(file, "xoxb-test-token")).rejects.toThrow(
152
+ "returned 302 redirect with no Location header",
153
+ );
107
154
  });
108
155
 
109
156
  test("throws on HTTP error response", async () => {
110
157
  fetchMock = mock(
111
- async () => new Response("Forbidden", { status: 403, statusText: "Forbidden" }),
158
+ async () =>
159
+ new Response("Forbidden", { status: 403, statusText: "Forbidden" }),
112
160
  );
113
161
 
114
162
  const file = makeSlackFile();
115
163
 
116
- await expect(
117
- downloadSlackFile(file, "xoxb-test-token"),
118
- ).rejects.toThrow(
164
+ await expect(downloadSlackFile(file, "xoxb-test-token")).rejects.toThrow(
119
165
  "Failed to download Slack file F12345: 403 Forbidden",
120
166
  );
121
167
  });
@@ -180,4 +226,29 @@ describe("downloadSlackFile", () => {
180
226
 
181
227
  expect(result.filename).toBe("slack_file_F12345");
182
228
  });
229
+
230
+ test("throws ContentMismatchError when Slack returns HTML error page", async () => {
231
+ const htmlBuffer = new TextEncoder().encode(
232
+ "<!DOCTYPE html><html><body><h1>Error</h1></body></html>",
233
+ ).buffer;
234
+ fetchMock = mock(async () => new Response(htmlBuffer));
235
+
236
+ const file = makeSlackFile({ mimetype: "image/png" });
237
+
238
+ await expect(
239
+ downloadSlackFile(file, "xoxb-test-token"),
240
+ ).rejects.toBeInstanceOf(ContentMismatchError);
241
+ });
242
+
243
+ test("succeeds with valid image data when validation is active", async () => {
244
+ const fileBuffer = makePngBuffer();
245
+ fetchMock = mock(async () => new Response(fileBuffer));
246
+
247
+ const file = makeSlackFile({ mimetype: "image/png" });
248
+ const result = await downloadSlackFile(file, "xoxb-test-token");
249
+
250
+ expect(result.filename).toBe("test-image.png");
251
+ expect(result.mimeType).toBe("image/png");
252
+ expect(result.data).toBe(Buffer.from(fileBuffer).toString("base64"));
253
+ });
183
254
  });
@@ -1,4 +1,5 @@
1
1
  import { fileTypeFromBuffer } from "file-type";
2
+ import { validateDownloadedContent } from "../download-validation.js";
2
3
  import { fetchImpl } from "../fetch.js";
3
4
  import type { SlackFile } from "./normalize.js";
4
5
 
@@ -23,11 +24,30 @@ export async function downloadSlackFile(
23
24
  throw new Error(`Slack file ${file.id} has no download URL`);
24
25
  }
25
26
 
26
- const response = await fetchImpl(url, {
27
+ // Use manual redirect handling — Slack may redirect to a CDN subdomain
28
+ // (e.g. files-edge.slack.com) and the Fetch spec strips the Authorization
29
+ // header on cross-origin redirects, causing the CDN request to fail.
30
+ let response = await fetchImpl(url, {
27
31
  headers: { Authorization: `Bearer ${botToken}` },
32
+ redirect: "manual",
28
33
  signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
29
34
  });
30
35
 
36
+ if (response.status >= 300 && response.status < 400) {
37
+ const location = response.headers.get("Location");
38
+ if (!location) {
39
+ throw new Error(
40
+ `Slack file ${file.id} returned ${response.status} redirect with no Location header`,
41
+ );
42
+ }
43
+ // CDN redirect URLs are signed — no Authorization needed.
44
+ // Resolve against the original URL to handle relative Location headers.
45
+ const resolvedLocation = new URL(location, url).href;
46
+ response = await fetchImpl(resolvedLocation, {
47
+ signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
48
+ });
49
+ }
50
+
31
51
  if (!response.ok) {
32
52
  throw new Error(
33
53
  `Failed to download Slack file ${file.id}: ${response.status} ${response.statusText}`,
@@ -43,6 +63,8 @@ export async function downloadSlackFile(
43
63
  response.headers.get("Content-Type")?.split(";")[0].trim() ||
44
64
  "application/octet-stream";
45
65
 
66
+ await validateDownloadedContent(new Uint8Array(buffer), mimeType, file.id);
67
+
46
68
  const filename = file.name || `slack_file_${file.id}`;
47
69
  const data = Buffer.from(buffer).toString("base64");
48
70