@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/Dockerfile +3 -1
- package/package.json +1 -1
- package/src/__tests__/config.test.ts +2 -1
- package/src/__tests__/feature-flags-route.test.ts +38 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +130 -0
- package/src/channels/inbound-event.ts +4 -2
- package/src/channels/transport-hints.ts +18 -0
- package/src/config.ts +4 -1
- package/src/download-validation.test.ts +96 -0
- package/src/download-validation.ts +92 -0
- package/src/email/normalize.test.ts +129 -0
- package/src/email/normalize.ts +94 -0
- package/src/email/verify.test.ts +96 -0
- package/src/email/verify.ts +41 -0
- package/src/feature-flag-registry.json +17 -1
- package/src/feature-flag-remote-store.ts +19 -0
- package/src/feature-flag-watcher.ts +38 -12
- package/src/http/routes/email-webhook.test.ts +393 -0
- package/src/http/routes/email-webhook.ts +243 -0
- package/src/http/routes/log-export.test.ts +530 -0
- package/src/http/routes/log-export.ts +494 -0
- package/src/http/routes/telegram-webhook.ts +21 -1
- package/src/http/routes/whatsapp-webhook.ts +28 -1
- package/src/index.ts +37 -1
- package/src/logger.ts +21 -6
- package/src/remote-feature-flag-sync.ts +91 -21
- package/src/schema.ts +149 -0
- package/src/slack/download.test.ts +81 -10
- package/src/slack/download.ts +23 -1
- package/src/slack/socket-mode.ts +10 -0
- package/src/telegram/download.ts +3 -0
- package/src/types.ts +1 -0
- package/src/whatsapp/download.ts +3 -0
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 (
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
this.
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
89
|
+
clearTimeout(this.pollTimer);
|
|
71
90
|
this.pollTimer = null;
|
|
72
91
|
}
|
|
73
92
|
}
|
|
74
93
|
|
|
75
|
-
private
|
|
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.
|
|
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
|
|
160
|
-
|
|
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
|
-
|
|
106
|
-
)
|
|
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 () =>
|
|
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
|
});
|
package/src/slack/download.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|