@vellumai/vellum-gateway 0.7.0 → 0.7.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.
Files changed (162) hide show
  1. package/AGENTS.md +4 -0
  2. package/ARCHITECTURE.md +67 -25
  3. package/Dockerfile +2 -0
  4. package/README.md +50 -13
  5. package/bun.lock +16 -2
  6. package/knip.json +3 -1
  7. package/package.json +3 -1
  8. package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
  9. package/src/__tests__/channel-verification-session-proxy.test.ts +0 -1
  10. package/src/__tests__/config-file-watcher.test.ts +181 -0
  11. package/src/__tests__/config.test.ts +0 -1
  12. package/src/__tests__/contacts-control-plane-proxy.test.ts +0 -1
  13. package/src/__tests__/credential-watcher-managed-bootstrap.test.ts +10 -2
  14. package/src/__tests__/credential-watcher.test.ts +30 -2
  15. package/src/__tests__/db-connection-isolation.test.ts +157 -0
  16. package/src/__tests__/fake-assistant-ipc.ts +39 -0
  17. package/src/__tests__/feature-flags-route.test.ts +8 -8
  18. package/src/__tests__/guardian-init-lockfile.test.ts +30 -4
  19. package/src/__tests__/ipc-feature-flag-routes.test.ts +1 -1
  20. package/src/__tests__/live-voice-websocket.test.ts +0 -1
  21. package/src/__tests__/load-guards.test.ts +0 -1
  22. package/src/__tests__/migration-teleport-gcs-proxy.test.ts +0 -1
  23. package/src/__tests__/oauth-callback.test.ts +0 -1
  24. package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
  25. package/src/__tests__/rate-limit-loopback.test.ts +1 -1
  26. package/src/__tests__/remote-feature-flag-sync.test.ts +47 -7
  27. package/src/__tests__/resolve-assistant.test.ts +0 -1
  28. package/src/__tests__/route-schema-guard.test.ts +42 -6
  29. package/src/__tests__/runtime-client.test.ts +0 -1
  30. package/src/__tests__/runtime-health-proxy.test.ts +0 -1
  31. package/src/__tests__/runtime-proxy-auth.test.ts +0 -1
  32. package/src/__tests__/runtime-proxy.test.ts +0 -1
  33. package/src/__tests__/slack-control-plane-proxy.test.ts +0 -1
  34. package/src/__tests__/slack-display-name.test.ts +66 -1
  35. package/src/__tests__/slack-normalize.test.ts +158 -4
  36. package/src/__tests__/slack-reaction-normalize.test.ts +0 -1
  37. package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
  38. package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
  39. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +654 -0
  40. package/src/__tests__/stt-stream-websocket.test.ts +0 -1
  41. package/src/__tests__/telegram-control-plane-proxy.test.ts +0 -1
  42. package/src/__tests__/telegram-send-attachments.test.ts +0 -1
  43. package/src/__tests__/telegram-webhook-handler.test.ts +0 -1
  44. package/src/__tests__/text-verification-helpers.test.ts +136 -0
  45. package/src/__tests__/twilio-media-websocket.test.ts +0 -1
  46. package/src/__tests__/twilio-relay-websocket.test.ts +0 -1
  47. package/src/__tests__/twilio-webhooks.test.ts +220 -3
  48. package/src/__tests__/upstream-transport.test.ts +0 -36
  49. package/src/__tests__/whatsapp-download.test.ts +0 -1
  50. package/src/__tests__/whatsapp-webhook.test.ts +0 -1
  51. package/src/auth/guardian-refresh.ts +4 -18
  52. package/src/auth/ipc-route-policy.ts +217 -0
  53. package/src/backup/backup-key.ts +138 -0
  54. package/src/backup/backup-routes.ts +159 -0
  55. package/src/backup/backup-worker.ts +374 -0
  56. package/src/backup/list-snapshots.ts +97 -0
  57. package/src/backup/local-writer.ts +87 -0
  58. package/src/backup/offsite-writer.ts +182 -0
  59. package/src/backup/paths.ts +123 -0
  60. package/src/backup/stream-crypt.ts +258 -0
  61. package/src/chrome-extension-origins.ts +28 -0
  62. package/src/cli/enable-proxy.ts +0 -1
  63. package/src/config-file-cache.ts +3 -19
  64. package/src/config-file-utils.ts +124 -0
  65. package/src/config-file-watcher.ts +57 -25
  66. package/src/config.ts +4 -7
  67. package/src/db/connection.ts +65 -3
  68. package/src/db/contact-store.ts +30 -1
  69. package/src/db/data-migrations/index.ts +2 -0
  70. package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
  71. package/src/db/schema.ts +92 -0
  72. package/src/db/slack-store.ts +144 -11
  73. package/src/feature-flag-registry.json +40 -152
  74. package/src/handlers/handle-inbound.ts +123 -0
  75. package/src/http/middleware/auth.ts +44 -1
  76. package/src/http/middleware/cors.ts +84 -0
  77. package/src/http/middleware/rate-limit.ts +6 -8
  78. package/src/http/routes/auto-approve-thresholds.ts +17 -1
  79. package/src/http/routes/brain-graph-proxy.ts +1 -1
  80. package/src/http/routes/channel-readiness-proxy.ts +2 -2
  81. package/src/http/routes/channel-verification-session-proxy.ts +19 -37
  82. package/src/http/routes/contact-prompt.ts +149 -0
  83. package/src/http/routes/contacts-control-plane-proxy.ts +2 -2
  84. package/src/http/routes/email-webhook.test.ts +0 -1
  85. package/src/http/routes/ipc-runtime-proxy.test.ts +197 -1
  86. package/src/http/routes/ipc-runtime-proxy.ts +95 -0
  87. package/src/http/routes/log-export.test.ts +0 -1
  88. package/src/http/routes/log-tail.test.ts +336 -0
  89. package/src/http/routes/log-tail.ts +87 -0
  90. package/src/http/routes/migration-proxy.ts +1 -2
  91. package/src/http/routes/oauth-apps-proxy.ts +2 -2
  92. package/src/http/routes/oauth-providers-proxy.ts +2 -2
  93. package/src/http/routes/pair.ts +322 -0
  94. package/src/http/routes/privacy-config.ts +65 -79
  95. package/src/http/routes/runtime-health-proxy.ts +2 -2
  96. package/src/http/routes/runtime-proxy.ts +3 -1
  97. package/src/http/routes/slack-control-plane-proxy.ts +3 -20
  98. package/src/http/routes/stt-stream-websocket.ts +2 -3
  99. package/src/http/routes/telegram-control-plane-proxy.ts +2 -2
  100. package/src/http/routes/telegram-webhook.test.ts +0 -1
  101. package/src/http/routes/telegram-webhook.ts +6 -0
  102. package/src/http/routes/trust-rules.suggest.test.ts +25 -0
  103. package/src/http/routes/trust-rules.ts +7 -0
  104. package/src/http/routes/twilio-control-plane-proxy.ts +2 -2
  105. package/src/http/routes/twilio-media-websocket.ts +5 -5
  106. package/src/http/routes/twilio-voice-verify-callback.ts +310 -0
  107. package/src/http/routes/twilio-voice-webhook.test.ts +65 -1
  108. package/src/http/routes/twilio-voice-webhook.ts +45 -1
  109. package/src/http/routes/whatsapp-webhook.test.ts +0 -1
  110. package/src/index.ts +357 -278
  111. package/src/ipc/assistant-client.ts +8 -4
  112. package/src/ipc/contact-handlers.ts +88 -3
  113. package/src/ipc/threshold-handlers.ts +2 -0
  114. package/src/post-assistant-ready.ts +5 -3
  115. package/src/risk/bash-risk-classifier.test.ts +35 -27
  116. package/src/risk/bash-risk-classifier.ts +44 -14
  117. package/src/risk/command-registry/commands/assistant.ts +8 -19
  118. package/src/risk/command-registry.test.ts +0 -15
  119. package/src/risk/risk-classifier-parity.test.ts +1 -3
  120. package/src/runtime/client.ts +58 -3
  121. package/src/schema.ts +277 -104
  122. package/src/slack/normalize.test.ts +98 -0
  123. package/src/slack/normalize.ts +107 -32
  124. package/src/slack/slack-web.ts +213 -0
  125. package/src/slack/socket-mode.ts +701 -39
  126. package/src/telegram/send.test.ts +0 -1
  127. package/src/twilio/validate-webhook.ts +53 -14
  128. package/src/twilio/webhook-sync-trigger.ts +58 -0
  129. package/src/twilio/webhook-sync.test.ts +286 -0
  130. package/src/twilio/webhook-sync.ts +84 -0
  131. package/src/util/is-loopback-address.ts +27 -0
  132. package/src/velay/bridge-utils.ts +228 -0
  133. package/src/velay/client.test.ts +939 -0
  134. package/src/velay/client.ts +555 -0
  135. package/src/velay/http-bridge.test.ts +217 -0
  136. package/src/velay/http-bridge.ts +83 -0
  137. package/src/velay/protocol.ts +178 -0
  138. package/src/velay/test-fake-websocket.ts +69 -0
  139. package/src/velay/websocket-bridge.test.ts +367 -0
  140. package/src/velay/websocket-bridge.ts +324 -0
  141. package/src/verification/binding-helpers.ts +107 -0
  142. package/src/verification/code-parsing.ts +44 -0
  143. package/src/verification/contact-helpers.ts +342 -0
  144. package/src/verification/identity-match.ts +68 -0
  145. package/src/verification/identity.ts +61 -0
  146. package/src/verification/rate-limit-helpers.ts +205 -0
  147. package/src/verification/reply-delivery.ts +109 -0
  148. package/src/verification/session-helpers.ts +164 -0
  149. package/src/verification/text-verification.ts +372 -0
  150. package/src/version.ts +35 -0
  151. package/src/voice/verification.ts +456 -0
  152. package/src/webhook-pipeline.ts +4 -0
  153. package/src/__tests__/browser-relay-websocket.test.ts +0 -698
  154. package/src/__tests__/telegram-only-default.test.ts +0 -133
  155. package/src/auth/capability-tokens.ts +0 -248
  156. package/src/http/routes/browser-extension-pair.ts +0 -455
  157. package/src/http/routes/browser-relay-websocket.ts +0 -381
  158. package/src/http/routes/config-file-utils.ts +0 -73
  159. package/src/ipc/capability-token-handlers.ts +0 -30
  160. package/src/pairing/approved-devices-store.ts +0 -110
  161. package/src/pairing/pairing-routes.ts +0 -379
  162. package/src/pairing/pairing-store.ts +0 -218
package/src/index.ts CHANGED
@@ -1,6 +1,15 @@
1
1
  process.title = "vellum-gateway";
2
2
 
3
3
  import { randomBytes } from "node:crypto";
4
+
5
+ import {
6
+ TWILIO_CONNECT_ACTION_WEBHOOK_PATH,
7
+ TWILIO_MEDIA_STREAM_WEBHOOK_PATH,
8
+ TWILIO_RELAY_WEBHOOK_PATH,
9
+ TWILIO_STATUS_WEBHOOK_PATH,
10
+ TWILIO_VOICE_WEBHOOK_PATH,
11
+ } from "@vellumai/service-contracts/twilio-ingress";
12
+
4
13
  import { AuthRateLimiter } from "./auth-rate-limiter.js";
5
14
  import {
6
15
  loadOrCreateSigningKey,
@@ -20,16 +29,13 @@ import {
20
29
  type CredentialChangeEvent,
21
30
  } from "./credential-watcher.js";
22
31
  import { createRuntimeProxyHandler } from "./http/routes/runtime-proxy.js";
23
- import {
24
- createBrowserRelayWebsocketHandler,
25
- getBrowserRelayWebsocketHandlers,
26
- type BrowserRelaySocketData,
27
- } from "./http/routes/browser-relay-websocket.js";
32
+
28
33
  import { createTelegramWebhookHandler } from "./http/routes/telegram-webhook.js";
29
34
  import { createAudioProxyHandler } from "./http/routes/audio-proxy.js";
30
35
  import { createTwilioVoiceWebhookHandler } from "./http/routes/twilio-voice-webhook.js";
31
36
  import { createTwilioStatusWebhookHandler } from "./http/routes/twilio-status-webhook.js";
32
37
  import { createTwilioConnectActionWebhookHandler } from "./http/routes/twilio-connect-action-webhook.js";
38
+ import { createTwilioVoiceVerifyCallbackHandler } from "./http/routes/twilio-voice-verify-callback.js";
33
39
  import {
34
40
  createTwilioRelayWebsocketHandler,
35
41
  getRelayWebsocketHandlers,
@@ -57,8 +63,6 @@ import { createMailgunWebhookHandler } from "./http/routes/mailgun-webhook.js";
57
63
  import { createResendWebhookHandler } from "./http/routes/resend-webhook.js";
58
64
 
59
65
  import { createOAuthCallbackHandler } from "./http/routes/oauth-callback.js";
60
- import { createPairingHandler } from "./pairing/pairing-routes.js";
61
- import { PairingStore } from "./pairing/pairing-store.js";
62
66
  import {
63
67
  createFeatureFlagsGetHandler,
64
68
  createFeatureFlagsPatchHandler,
@@ -74,12 +78,13 @@ import {
74
78
  createConversationThresholdPutHandler,
75
79
  createConversationThresholdDeleteHandler,
76
80
  } from "./http/routes/auto-approve-thresholds.js";
77
- import { handleBrowserExtensionPair } from "./http/routes/browser-extension-pair.js";
78
81
  import { createChannelVerificationSessionProxyHandler } from "./http/routes/channel-verification-session-proxy.js";
79
82
  import { createTelegramControlPlaneProxyHandler } from "./http/routes/telegram-control-plane-proxy.js";
80
83
  import { createTwilioControlPlaneProxyHandler } from "./http/routes/twilio-control-plane-proxy.js";
81
84
  import { createVercelControlPlaneProxyHandler } from "./http/routes/vercel-control-plane-proxy.js";
82
85
  import { createContactsControlPlaneProxyHandler } from "./http/routes/contacts-control-plane-proxy.js";
86
+ import { handleContactPromptSubmit } from "./http/routes/contact-prompt.js";
87
+ import { handlePair } from "./http/routes/pair.js";
83
88
  import { createSlackControlPlaneProxyHandler } from "./http/routes/slack-control-plane-proxy.js";
84
89
  import { createOAuthAppsProxyHandler } from "./http/routes/oauth-apps-proxy.js";
85
90
  import { createOAuthProvidersProxyHandler } from "./http/routes/oauth-providers-proxy.js";
@@ -96,9 +101,15 @@ import {
96
101
  createMigrationJobStatusProxyHandler,
97
102
  } from "./http/routes/migration-proxy.js";
98
103
  import { createMigrationRollbackProxyHandler } from "./http/routes/migration-rollback-proxy.js";
104
+ import {
105
+ createListBackupsHandler,
106
+ createBackupSnapshotHandler,
107
+ } from "./backup/backup-routes.js";
108
+ import { startBackupWorker } from "./backup/backup-worker.js";
99
109
  import { createWorkspaceCommitProxyHandler } from "./http/routes/workspace-commit-proxy.js";
100
110
  import { createBrainGraphProxyHandler } from "./http/routes/brain-graph-proxy.js";
101
111
  import { createLogExportHandler } from "./http/routes/log-export.js";
112
+ import { createLogTailHandler } from "./http/routes/log-tail.js";
102
113
  import {
103
114
  createTrustRulesListHandler,
104
115
  createTrustRulesCreateHandler,
@@ -122,8 +133,13 @@ import {
122
133
  } from "./slack/socket-mode.js";
123
134
  import { downloadSlackFile } from "./slack/download.js";
124
135
  import { handleInbound } from "./handlers/handle-inbound.js";
136
+ import { upsertContactChannel } from "./verification/contact-helpers.js";
125
137
  import { checkAuthRateLimit } from "./http/middleware/rate-limit.js";
138
+ import { logAuthBypassState } from "./http/middleware/auth.js";
126
139
  import {
140
+ resolveExtensionOrigin,
141
+ handleExtensionPreflight,
142
+ withExtensionCorsHeaders,
127
143
  resolveWebviewOrigin,
128
144
  handlePreflight,
129
145
  withCorsHeaders,
@@ -139,14 +155,16 @@ import { fetchImpl } from "./fetch.js";
139
155
  import { isNewCommand, handleNewCommand } from "./webhook-pipeline.js";
140
156
  import { reconcileTelegramWebhook } from "./telegram/webhook-manager.js";
141
157
  import { registerEmailCallbackRoute } from "./email/register-callback.js";
158
+ import { syncConfiguredTwilioPhoneNumberWebhooks } from "./twilio/webhook-sync.js";
159
+ import {
160
+ isOnlyVelayPublicBaseUrlChange,
161
+ shouldSyncTwilioPhoneWebhooksAfterConfigChange,
162
+ } from "./twilio/webhook-sync-trigger.js";
142
163
  import { GatewayIpcServer } from "./ipc/server.js";
143
164
  import { contactRoutes } from "./ipc/contact-handlers.js";
144
- import {
145
- featureFlagRoutes,
146
- getMergedFeatureFlags,
147
- } from "./ipc/feature-flag-handlers.js";
165
+ import { featureFlagRoutes } from "./ipc/feature-flag-handlers.js";
148
166
  import { thresholdRoutes } from "./ipc/threshold-handlers.js";
149
- import { capabilityTokenRoutes } from "./ipc/capability-token-handlers.js";
167
+
150
168
  import { riskClassificationRoutes } from "./ipc/risk-classification-handlers.js";
151
169
  import { refreshRouteSchema } from "./ipc/route-schema-cache.js";
152
170
  import { AvatarChannelSyncer } from "./avatar-sync/avatar-channel-syncer.js";
@@ -154,6 +172,8 @@ import { AvatarSyncWatcher } from "./avatar-sync/avatar-sync-watcher.js";
154
172
  import { SlackAvatarSyncer } from "./avatar-sync/slack-avatar-syncer.js";
155
173
  import { initGatewayDb } from "./db/connection.js";
156
174
  import { runPostAssistantReady } from "./post-assistant-ready.js";
175
+ import { createVelayTunnelClient } from "./velay/client.js";
176
+ import { VERSION_HEADER_NAME, VERSION_HEADER_VALUE } from "./version.js";
157
177
 
158
178
  const log = getLogger("main");
159
179
 
@@ -197,16 +217,6 @@ function detectCredentialChanges(
197
217
  // Shared rate limiter for auth failures and unauthenticated endpoints
198
218
  const authRateLimiter = new AuthRateLimiter();
199
219
 
200
- function isBrowserRelaySocketData(
201
- data: unknown,
202
- ): data is BrowserRelaySocketData {
203
- return (
204
- !!data &&
205
- typeof data === "object" &&
206
- (data as { wsType?: unknown }).wsType === "browser-relay"
207
- );
208
- }
209
-
210
220
  function isMediaStreamSocketData(data: unknown): data is MediaStreamSocketData {
211
221
  return (
212
222
  !!data &&
@@ -262,12 +272,23 @@ async function main() {
262
272
  await initGatewayDb();
263
273
  initTrustRuleCache();
264
274
 
275
+ // Wait for the assistant runtime to be healthy before serving traffic.
276
+ // Data migrations (e.g. m0002 actor-token-tables-to-gateway) must
277
+ // complete before the HTTP server starts accepting auth requests —
278
+ // otherwise newly minted tokens can be overwritten by stale rows
279
+ // migrated from the assistant DB.
280
+ await runPostAssistantReady();
281
+
265
282
  // ── TTL caches ──
266
283
  // Instantiate caches for credential and config file reads.
267
284
  // Handlers read dynamic credentials and config.json values from these
268
285
  // caches at call time, with automatic TTL refresh.
269
286
  const credentialCache = new CredentialCache();
270
287
  const configFileCache = new ConfigFileCache();
288
+ const velayTunnelClient = createVelayTunnelClient(config, {
289
+ credentials: credentialCache,
290
+ configFile: configFileCache,
291
+ });
271
292
 
272
293
  // ── Avatar sync ──
273
294
  const avatarChannelSyncer = new AvatarChannelSyncer();
@@ -305,18 +326,18 @@ async function main() {
305
326
  );
306
327
  const handleTwilioConnectActionWebhook =
307
328
  createTwilioConnectActionWebhookHandler(config, twilioValidationCaches);
329
+ const handleTwilioVoiceVerifyCallback =
330
+ createTwilioVoiceVerifyCallbackHandler(config, twilioValidationCaches);
308
331
  const handleTwilioRelayWs = createTwilioRelayWebsocketHandler(config, {
309
332
  configFile: configFileCache,
310
333
  });
311
334
  const handleTwilioMediaWs = createTwilioMediaWebsocketHandler(config, {
312
335
  configFile: configFileCache,
313
336
  });
314
- const handleBrowserRelayWs = createBrowserRelayWebsocketHandler(config);
315
337
  const handleSttStreamWs = createSttStreamWebsocketHandler(config);
316
338
  const handleLiveVoiceWs = createLiveVoiceWebsocketHandler(config);
317
339
  const twilioRelayWebsocketHandlers = getRelayWebsocketHandlers();
318
340
  const twilioMediaStreamWebsocketHandlers = getMediaStreamWebsocketHandlers();
319
- const browserRelayWebsocketHandlers = getBrowserRelayWebsocketHandlers();
320
341
  const sttStreamWebsocketHandlers = getSttStreamWebsocketHandlers();
321
342
  const liveVoiceWebsocketHandlers = getLiveVoiceWebsocketHandlers();
322
343
  const { handler: handleWhatsAppWebhook, dedupCache: whatsappDedupCache } =
@@ -345,9 +366,6 @@ async function main() {
345
366
  credentialCache,
346
367
  );
347
368
  const handleOAuthCallback = createOAuthCallbackHandler(config);
348
- const pairingStore = new PairingStore();
349
- pairingStore.start();
350
- const pairingHandler = createPairingHandler({ pairingStore });
351
369
  const channelVerificationSessionProxy =
352
370
  createChannelVerificationSessionProxyHandler(config);
353
371
  const telegramControlPlaneProxy =
@@ -376,6 +394,7 @@ async function main() {
376
394
  const workspaceCommitProxy = createWorkspaceCommitProxyHandler(config);
377
395
  const brainGraphProxy = createBrainGraphProxyHandler(config);
378
396
  const handleLogExport = createLogExportHandler(config);
397
+ const handleLogTail = createLogTailHandler(config);
379
398
  const handleFeatureFlagsGet = createFeatureFlagsGetHandler();
380
399
  const handleFeatureFlagsPatch = createFeatureFlagsPatchHandler();
381
400
  const handlePrivacyConfigGet = createPrivacyConfigGetHandler();
@@ -397,9 +416,11 @@ async function main() {
397
416
 
398
417
  const audioProxy = createAudioProxyHandler(config);
399
418
 
400
- const handleRuntimeProxy = config.runtimeProxyEnabled
401
- ? createRuntimeProxyHandler(config)
402
- : null;
419
+ const backupDeps = { assistantRuntimeBaseUrl: config.assistantRuntimeBaseUrl };
420
+ const handleListBackups = createListBackupsHandler(backupDeps);
421
+ const handleCreateBackup = createBackupSnapshotHandler(backupDeps);
422
+
423
+ const handleRuntimeProxy = createRuntimeProxyHandler(config);
403
424
 
404
425
  // Helper to reject when an integration isn't configured
405
426
  const requireConfigured = (
@@ -436,17 +457,21 @@ async function main() {
436
457
  handler: (req) => handleTelegramWebhook(req),
437
458
  },
438
459
  {
439
- path: "/webhooks/twilio/voice",
460
+ path: TWILIO_VOICE_WEBHOOK_PATH,
440
461
  handler: (req) => handleTwilioVoiceWebhook(req),
441
462
  },
442
463
  {
443
- path: "/webhooks/twilio/status",
464
+ path: TWILIO_STATUS_WEBHOOK_PATH,
444
465
  handler: (req) => handleTwilioStatusWebhook(req),
445
466
  },
446
467
  {
447
- path: "/webhooks/twilio/connect-action",
468
+ path: TWILIO_CONNECT_ACTION_WEBHOOK_PATH,
448
469
  handler: (req) => handleTwilioConnectActionWebhook(req),
449
470
  },
471
+ {
472
+ path: "/webhooks/twilio/voice-verify",
473
+ handler: (req) => handleTwilioVoiceVerifyCallback(req),
474
+ },
450
475
  {
451
476
  path: "/webhooks/whatsapp",
452
477
  precondition: requireWhatsApp,
@@ -469,7 +494,8 @@ async function main() {
469
494
  {
470
495
  path: "/inbound/register",
471
496
  method: "POST",
472
- auth: "edge",
497
+ auth: "edge-scoped",
498
+ scope: "internal.write",
473
499
  handler: (req) => handleInboundRegister(req),
474
500
  },
475
501
 
@@ -487,28 +513,6 @@ async function main() {
487
513
  handler: (req) => handleOAuthCallback(req),
488
514
  },
489
515
 
490
- // ── Pairing (mixed auth) ──
491
- {
492
- path: "/pairing/register",
493
- method: "POST",
494
- auth: "edge",
495
- handler: (req) => pairingHandler.handlePairingRegister(req),
496
- },
497
- {
498
- path: "/pairing/request",
499
- method: "POST",
500
- auth: "track-failures",
501
- trackFailureStatuses: [401, 403],
502
- handler: (req) => pairingHandler.handlePairingRequest(req),
503
- },
504
- {
505
- path: "/pairing/status",
506
- method: "GET",
507
- auth: "track-failures",
508
- trackFailureStatuses: [401, 403],
509
- handler: (req) => pairingHandler.handlePairingStatus(req),
510
- },
511
-
512
516
  // ── Runtime health ──
513
517
  {
514
518
  path: "/v1/health",
@@ -599,6 +603,12 @@ async function main() {
599
603
  },
600
604
 
601
605
  // ── Contacts control plane ──
606
+ {
607
+ path: "/v1/contacts/prompt/submit",
608
+ method: "POST",
609
+ auth: "edge",
610
+ handler: (req) => handleContactPromptSubmit(req),
611
+ },
602
612
  {
603
613
  path: "/v1/contacts",
604
614
  method: "GET",
@@ -674,13 +684,12 @@ async function main() {
674
684
  contactsControlPlaneProxy.handleGetContact(req, params[0]),
675
685
  },
676
686
 
677
- // ── Browser extension pairing (localhost-only, auth: none) ──
687
+ // ── Generic loopback pairing (localhost-only, auth: none) ──
678
688
  {
679
- path: "/v1/browser-extension-pair",
689
+ path: "/v1/pair",
680
690
  method: "POST",
681
691
  auth: "none",
682
- handler: (req, _params, getClientIp) =>
683
- handleBrowserExtensionPair(req, getClientIp()),
692
+ handler: (req, _params, getClientIp) => handlePair(req, getClientIp()),
684
693
  },
685
694
 
686
695
  // ── Channel verification sessions ──
@@ -827,13 +836,6 @@ async function main() {
827
836
  auth: "edge",
828
837
  handler: (req) => slackControlPlaneProxy.handleShareToSlack(req),
829
838
  },
830
- {
831
- path: "/v1/integrations/slack/channel/oauth-install",
832
- method: "POST",
833
- auth: "edge-scoped",
834
- scope: "settings.write",
835
- handler: (req) => slackControlPlaneProxy.handleSlackOAuthInstall(req),
836
- },
837
839
 
838
840
  // ── OAuth providers ──
839
841
  {
@@ -948,10 +950,10 @@ async function main() {
948
950
  },
949
951
 
950
952
  // ── Teleport-GCS migration (unified daemon-async flow) ──
951
- // These are registered explicitly (not via the runtime-proxy catch-all)
952
- // so local/docker teleport works whether or not `runtimeProxyEnabled`
953
- // is set. The daemon returns 202 { job_id } on POST and cheap JSON on
954
- // GET, so the gateway just transparently forwards without wrapping.
953
+ // Registered as explicit routes (not via the runtime-proxy catch-all)
954
+ // for dedicated auth and timeout handling. The daemon returns 202
955
+ // { job_id } on POST and cheap JSON on GET, so the gateway just
956
+ // transparently forwards without wrapping.
955
957
  {
956
958
  path: "/v1/migrations/export-to-gcs",
957
959
  method: "POST",
@@ -992,6 +994,22 @@ async function main() {
992
994
  handler: (req) => migrationRollbackProxy(req),
993
995
  },
994
996
 
997
+ // ── Backups ──
998
+ {
999
+ path: "/v1/backups",
1000
+ method: "GET",
1001
+ auth: "edge-scoped",
1002
+ scope: "settings.read",
1003
+ handler: (req) => handleListBackups(req),
1004
+ },
1005
+ {
1006
+ path: "/v1/backups/create",
1007
+ method: "POST",
1008
+ auth: "edge-scoped",
1009
+ scope: "settings.write",
1010
+ handler: (req) => handleCreateBackup(req),
1011
+ },
1012
+
995
1013
  // ── Channel readiness ──
996
1014
  {
997
1015
  path: "/v1/channels/readiness",
@@ -1205,6 +1223,12 @@ async function main() {
1205
1223
  handler: (req, params, getClientIp) =>
1206
1224
  handleLogExport(req, params, getClientIp),
1207
1225
  },
1226
+ {
1227
+ path: "/v1/logs/tail",
1228
+ method: "GET",
1229
+ auth: "edge",
1230
+ handler: (req) => handleLogTail(req),
1231
+ },
1208
1232
 
1209
1233
  // ── Trust rules v3 ──
1210
1234
  {
@@ -1248,21 +1272,24 @@ async function main() {
1248
1272
  },
1249
1273
  ];
1250
1274
 
1251
- // The runtime proxy catch-all is only added when the proxy is enabled.
1252
- // It must be last so that all specific routes are checked first.
1253
- if (handleRuntimeProxy) {
1254
- routes.push({
1255
- path: /^\//, // match everything
1256
- auth: "track-failures",
1257
- handler: (req, _params, getClientIp) =>
1258
- handleRuntimeProxy(req, getClientIp()),
1259
- });
1260
- }
1275
+ // Runtime proxy catch-all must be last so specific routes are checked first.
1276
+ routes.push({
1277
+ path: /^\//, // match everything
1278
+ auth: "track-failures",
1279
+ handler: (req, _params, getClientIp) =>
1280
+ handleRuntimeProxy(req, getClientIp()),
1281
+ });
1261
1282
 
1262
1283
  const router = createRouter(routes, {
1263
1284
  authRateLimiter,
1264
1285
  });
1265
1286
 
1287
+ /** Stamp the assistant version header on a response. */
1288
+ function stampVersion<T extends Response>(res: T): T {
1289
+ res.headers.set(VERSION_HEADER_NAME, VERSION_HEADER_VALUE);
1290
+ return res;
1291
+ }
1292
+
1266
1293
  const server = Bun.serve({
1267
1294
  port: config.port,
1268
1295
  idleTimeout: 0,
@@ -1271,10 +1298,6 @@ async function main() {
1271
1298
  maxRequestBodySize: 512 * 1024 * 1024,
1272
1299
  websocket: {
1273
1300
  open(ws) {
1274
- if (isBrowserRelaySocketData(ws.data)) {
1275
- browserRelayWebsocketHandlers.open(ws as never);
1276
- return;
1277
- }
1278
1301
  if (isMediaStreamSocketData(ws.data)) {
1279
1302
  twilioMediaStreamWebsocketHandlers.open(ws as never);
1280
1303
  return;
@@ -1290,10 +1313,6 @@ async function main() {
1290
1313
  twilioRelayWebsocketHandlers.open(ws as never);
1291
1314
  },
1292
1315
  message(ws, message) {
1293
- if (isBrowserRelaySocketData(ws.data)) {
1294
- browserRelayWebsocketHandlers.message(ws as never, message);
1295
- return;
1296
- }
1297
1316
  if (isMediaStreamSocketData(ws.data)) {
1298
1317
  twilioMediaStreamWebsocketHandlers.message(ws as never, message);
1299
1318
  return;
@@ -1309,10 +1328,6 @@ async function main() {
1309
1328
  twilioRelayWebsocketHandlers.message(ws as never, message);
1310
1329
  },
1311
1330
  close(ws, code, reason) {
1312
- if (isBrowserRelaySocketData(ws.data)) {
1313
- browserRelayWebsocketHandlers.close(ws as never, code, reason);
1314
- return;
1315
- }
1316
1331
  if (isMediaStreamSocketData(ws.data)) {
1317
1332
  twilioMediaStreamWebsocketHandlers.close(ws as never, code, reason);
1318
1333
  return;
@@ -1330,210 +1345,228 @@ async function main() {
1330
1345
  },
1331
1346
  error(err) {
1332
1347
  if (err instanceof CircuitBreakerOpenError) {
1333
- return Response.json(
1334
- { error: "Service temporarily unavailable — runtime is unreachable" },
1335
- {
1336
- status: 503,
1337
- headers: { "Retry-After": String(err.retryAfterSecs) },
1338
- },
1348
+ return stampVersion(
1349
+ Response.json(
1350
+ {
1351
+ error: "Service temporarily unavailable — runtime is unreachable",
1352
+ },
1353
+ {
1354
+ status: 503,
1355
+ headers: { "Retry-After": String(err.retryAfterSecs) },
1356
+ },
1357
+ ),
1339
1358
  );
1340
1359
  }
1341
1360
  log.error({ err }, "Unhandled gateway error");
1342
- return Response.json({ error: "Internal server error" }, { status: 500 });
1361
+ return stampVersion(
1362
+ Response.json({ error: "Internal server error" }, { status: 500 }),
1363
+ );
1343
1364
  },
1344
1365
  async fetch(req, svr) {
1345
1366
  svr.timeout(req, 1800);
1346
- const url = new URL(req.url);
1347
-
1348
- // ── CORS: webview preflight & origin tracking ──
1349
- // The macOS WKWebView loads pages from https://{appId}.vellum.local/
1350
- // which is cross-origin to the gateway at http://127.0.0.1:{port}.
1351
- // Reflect the origin back on matched requests so window.vellum.fetch
1352
- // calls succeed.
1353
- const webviewOrigin = resolveWebviewOrigin(req);
1354
- if (webviewOrigin && req.method === "OPTIONS") {
1355
- return handlePreflight(webviewOrigin);
1356
- }
1367
+ const inner = await routeRequest(req, svr);
1368
+ if (inner) stampVersion(inner);
1369
+ return inner;
1370
+ },
1371
+ });
1357
1372
 
1358
- // ── Pre-router: health/readiness probes ──
1359
- // These bypass rate limiting and tracing for minimal overhead.
1360
- if (url.pathname === "/healthz") {
1361
- const includeMigrations =
1362
- url.searchParams.get("include") === "migrations";
1363
- if (!includeMigrations) {
1364
- return Response.json({ status: "ok" });
1365
- }
1366
- // Fetch the daemon's /v1/health to surface migration state
1367
- // (dbVersion, lastWorkspaceMigrationId) so the CLI can capture
1368
- // pre-upgrade migration state through the gateway.
1369
- try {
1370
- const upstream = await fetch(
1371
- `${config.assistantRuntimeBaseUrl}/v1/health`,
1372
- {
1373
- signal: AbortSignal.timeout(3000),
1374
- headers: { authorization: `Bearer ${mintServiceToken()}` },
1375
- },
1376
- );
1377
- if (upstream.ok) {
1378
- const body = (await upstream.json()) as {
1379
- migrations?: {
1380
- dbVersion?: number;
1381
- lastWorkspaceMigrationId?: string;
1382
- };
1373
+ /** Core request routing extracted so `fetch` can stamp headers on every response. */
1374
+ async function routeRequest(
1375
+ req: Request,
1376
+ svr: ReturnType<typeof Bun.serve>,
1377
+ ): Promise<Response | undefined> {
1378
+ const url = new URL(req.url);
1379
+
1380
+ // ── CORS: webview preflight & origin tracking ──
1381
+ // The macOS WKWebView loads pages from https://{appId}.vellum.local/
1382
+ // which is cross-origin to the gateway at http://127.0.0.1:{port}.
1383
+ // Reflect the origin back on matched requests so window.vellum.fetch
1384
+ // calls succeed.
1385
+ const extensionOrigin = resolveExtensionOrigin(req);
1386
+ if (extensionOrigin && req.method === "OPTIONS") {
1387
+ return handleExtensionPreflight(extensionOrigin);
1388
+ }
1389
+
1390
+ const webviewOrigin = resolveWebviewOrigin(req);
1391
+ if (webviewOrigin && req.method === "OPTIONS") {
1392
+ return handlePreflight(webviewOrigin);
1393
+ }
1394
+
1395
+ // ── Pre-router: health/readiness probes ──
1396
+ // These bypass rate limiting and tracing for minimal overhead.
1397
+ if (url.pathname === "/healthz") {
1398
+ const includeMigrations =
1399
+ url.searchParams.get("include") === "migrations";
1400
+ if (!includeMigrations) {
1401
+ return Response.json({ status: "ok" });
1402
+ }
1403
+ // Fetch the daemon's /v1/health to surface migration state
1404
+ // (dbVersion, lastWorkspaceMigrationId) so the CLI can capture
1405
+ // pre-upgrade migration state through the gateway.
1406
+ try {
1407
+ const upstream = await fetch(
1408
+ `${config.assistantRuntimeBaseUrl}/v1/health`,
1409
+ {
1410
+ signal: AbortSignal.timeout(3000),
1411
+ headers: { authorization: `Bearer ${mintServiceToken()}` },
1412
+ },
1413
+ );
1414
+ if (upstream.ok) {
1415
+ const body = (await upstream.json()) as {
1416
+ migrations?: {
1417
+ dbVersion?: number;
1418
+ lastWorkspaceMigrationId?: string;
1383
1419
  };
1384
- return Response.json({
1385
- status: "ok",
1386
- ...(body.migrations ? { migrations: body.migrations } : {}),
1387
- });
1388
- }
1389
- } catch {
1390
- // Daemon unreachable — graceful degradation, still return ok
1420
+ };
1421
+ return Response.json({
1422
+ status: "ok",
1423
+ ...(body.migrations ? { migrations: body.migrations } : {}),
1424
+ });
1391
1425
  }
1392
- return Response.json({ status: "ok" });
1426
+ } catch {
1427
+ // Daemon unreachable — graceful degradation, still return ok
1393
1428
  }
1429
+ return Response.json({ status: "ok" });
1430
+ }
1394
1431
 
1395
- if (url.pathname === "/schema") {
1396
- return Response.json(buildSchema());
1397
- }
1432
+ if (url.pathname === "/schema") {
1433
+ return Response.json(buildSchema());
1434
+ }
1398
1435
 
1399
- if (url.pathname === "/readyz") {
1400
- if (draining) {
1401
- return Response.json({ status: "draining" }, { status: 503 });
1402
- }
1403
- // Check that the upstream assistant is also reachable so callers
1404
- // know the full stack is ready, not just the gateway process.
1405
- try {
1406
- const upstream = await fetch(
1407
- `${config.assistantRuntimeBaseUrl}/readyz`,
1408
- { signal: AbortSignal.timeout(3000) },
1409
- );
1410
- if (!upstream.ok) {
1411
- return Response.json(
1412
- { status: "upstream_unhealthy", upstream: upstream.status },
1413
- { status: 503 },
1414
- );
1415
- }
1416
- } catch {
1436
+ if (url.pathname === "/readyz") {
1437
+ if (draining) {
1438
+ return Response.json({ status: "draining" }, { status: 503 });
1439
+ }
1440
+ // Check that the upstream assistant is also reachable so callers
1441
+ // know the full stack is ready, not just the gateway process.
1442
+ try {
1443
+ const upstream = await fetch(
1444
+ `${config.assistantRuntimeBaseUrl}/readyz`,
1445
+ { signal: AbortSignal.timeout(3000) },
1446
+ );
1447
+ if (!upstream.ok) {
1417
1448
  return Response.json(
1418
- { status: "upstream_unreachable" },
1449
+ { status: "upstream_unhealthy", upstream: upstream.status },
1419
1450
  { status: 503 },
1420
1451
  );
1421
1452
  }
1422
- return Response.json({ status: "ok" });
1453
+ } catch {
1454
+ return Response.json(
1455
+ { status: "upstream_unreachable" },
1456
+ { status: 503 },
1457
+ );
1423
1458
  }
1459
+ return Response.json({ status: "ok" });
1460
+ }
1424
1461
 
1425
- // Per-request IP resolver — scoped to this request so it remains
1426
- // correct across async yields under concurrent load.
1427
- const resolveClientIp: GetClientIp = () =>
1428
- getClientIp(req, svr, config.trustProxy);
1462
+ // Per-request IP resolver — scoped to this request so it remains
1463
+ // correct across async yields under concurrent load.
1464
+ const resolveClientIp: GetClientIp = () =>
1465
+ getClientIp(req, svr, config.trustProxy);
1429
1466
 
1430
- const rateLimitResponse = checkAuthRateLimit(
1431
- url,
1432
- authRateLimiter,
1433
- resolveClientIp(),
1434
- );
1435
- if (rateLimitResponse) {
1436
- if (webviewOrigin)
1437
- return withCorsHeaders(rateLimitResponse, webviewOrigin);
1438
- return rateLimitResponse;
1439
- }
1440
-
1441
- // ── Pre-router: WebSocket upgrades ──
1442
- // Bun's WS upgrade needs `server.upgrade()` which doesn't return
1443
- // a Response, so these can't go through the route table.
1444
- if (url.pathname === "/webhooks/twilio/relay") {
1445
- const upgradeResult = handleTwilioRelayWs(req, server);
1446
- if (upgradeResult !== undefined) return upgradeResult;
1447
- return undefined as unknown as Response;
1448
- }
1467
+ const rateLimitResponse = checkAuthRateLimit(
1468
+ url,
1469
+ authRateLimiter,
1470
+ resolveClientIp(),
1471
+ );
1472
+ if (rateLimitResponse) {
1473
+ if (extensionOrigin)
1474
+ return withExtensionCorsHeaders(rateLimitResponse, extensionOrigin);
1475
+ if (webviewOrigin)
1476
+ return withCorsHeaders(rateLimitResponse, webviewOrigin);
1477
+ return rateLimitResponse;
1478
+ }
1449
1479
 
1450
- if (
1451
- url.pathname === "/webhooks/twilio/media-stream" ||
1452
- url.pathname.startsWith("/webhooks/twilio/media-stream/")
1453
- ) {
1454
- const upgradeResult = handleTwilioMediaWs(req, server);
1455
- if (upgradeResult !== undefined) return upgradeResult;
1456
- return undefined as unknown as Response;
1457
- }
1480
+ // ── Pre-router: WebSocket upgrades ──
1481
+ // Bun's WS upgrade needs `server.upgrade()` which doesn't return
1482
+ // a Response, so these can't go through the route table.
1483
+ if (url.pathname === TWILIO_RELAY_WEBHOOK_PATH) {
1484
+ const upgradeResult = handleTwilioRelayWs(req, server);
1485
+ if (upgradeResult !== undefined) return upgradeResult;
1486
+ return undefined as unknown as Response;
1487
+ }
1458
1488
 
1459
- if (config.runtimeProxyEnabled && url.pathname === "/v1/browser-relay") {
1460
- const upgradeResult = handleBrowserRelayWs(req, server);
1461
- if (upgradeResult !== undefined) return upgradeResult;
1462
- return undefined as unknown as Response;
1463
- }
1489
+ if (
1490
+ url.pathname === TWILIO_MEDIA_STREAM_WEBHOOK_PATH ||
1491
+ url.pathname.startsWith(`${TWILIO_MEDIA_STREAM_WEBHOOK_PATH}/`)
1492
+ ) {
1493
+ const upgradeResult = handleTwilioMediaWs(req, server);
1494
+ if (upgradeResult !== undefined) return upgradeResult;
1495
+ return undefined as unknown as Response;
1496
+ }
1464
1497
 
1465
- if (url.pathname === "/v1/stt/stream") {
1466
- const upgradeResult = handleSttStreamWs(req, server);
1467
- if (upgradeResult !== undefined) return upgradeResult;
1468
- return undefined as unknown as Response;
1469
- }
1498
+ if (url.pathname === "/v1/stt/stream") {
1499
+ const upgradeResult = handleSttStreamWs(req, server);
1500
+ if (upgradeResult !== undefined) return upgradeResult;
1501
+ return undefined as unknown as Response;
1502
+ }
1470
1503
 
1471
- if (url.pathname === "/v1/live-voice") {
1472
- const upgradeResult = handleLiveVoiceWs(req, server);
1473
- if (upgradeResult !== undefined) return upgradeResult;
1474
- return undefined as unknown as Response;
1475
- }
1504
+ if (url.pathname === "/v1/live-voice") {
1505
+ const upgradeResult = handleLiveVoiceWs(req, server);
1506
+ if (upgradeResult !== undefined) return upgradeResult;
1507
+ return undefined as unknown as Response;
1508
+ }
1476
1509
 
1477
- // Attach a trace ID to every non-healthcheck request for
1478
- // end-to-end correlation across webhook -> runtime -> reply.
1479
- if (!req.headers.has("x-trace-id")) {
1480
- req.headers.set("x-trace-id", generateTraceId());
1481
- }
1510
+ // Attach a trace ID to every non-healthcheck request for
1511
+ // end-to-end correlation across webhook -> runtime -> reply.
1512
+ if (!req.headers.has("x-trace-id")) {
1513
+ req.headers.set("x-trace-id", generateTraceId());
1514
+ }
1482
1515
 
1483
- // ── Route table dispatch ──
1484
- try {
1485
- const response = router(req, url, resolveClientIp, svr);
1486
- if (response !== null) {
1487
- if (webviewOrigin) {
1488
- const resolved = await response;
1489
- return withCorsHeaders(resolved, webviewOrigin);
1490
- }
1491
- return response;
1516
+ // ── Route table dispatch ──
1517
+ try {
1518
+ const response = router(req, url, resolveClientIp, svr);
1519
+ if (response !== null) {
1520
+ if (extensionOrigin) {
1521
+ const resolved = await response;
1522
+ return withExtensionCorsHeaders(resolved, extensionOrigin);
1492
1523
  }
1493
- } catch (err) {
1494
- // Mirror the error() handler logic while retaining CORS context.
1495
- // Bun's error() callback doesn't receive the request, so thrown
1496
- // errors during webview requests would otherwise lose CORS headers.
1497
- if (!webviewOrigin) throw err;
1498
- if (err instanceof CircuitBreakerOpenError) {
1499
- return withCorsHeaders(
1500
- Response.json(
1501
- {
1502
- error:
1503
- "Service temporarily unavailable \u2014 runtime is unreachable",
1504
- },
1505
- {
1506
- status: 503,
1507
- headers: { "Retry-After": String(err.retryAfterSecs) },
1508
- },
1509
- ),
1510
- webviewOrigin,
1511
- );
1524
+ if (webviewOrigin) {
1525
+ const resolved = await response;
1526
+ return withCorsHeaders(resolved, webviewOrigin);
1512
1527
  }
1513
- log.error({ err }, "Unhandled gateway error");
1514
- return withCorsHeaders(
1515
- Response.json({ error: "Internal server error" }, { status: 500 }),
1516
- webviewOrigin,
1528
+ return response;
1529
+ }
1530
+ } catch (err) {
1531
+ // Mirror the error() handler logic while retaining CORS context.
1532
+ // Bun's error() callback doesn't receive the request, so thrown
1533
+ // errors during webview/extension requests would otherwise lose CORS headers.
1534
+ if (!webviewOrigin && !extensionOrigin) throw err;
1535
+ if (err instanceof CircuitBreakerOpenError) {
1536
+ const body = Response.json(
1537
+ {
1538
+ error:
1539
+ "Service temporarily unavailable \u2014 runtime is unreachable",
1540
+ },
1541
+ {
1542
+ status: 503,
1543
+ headers: { "Retry-After": String(err.retryAfterSecs) },
1544
+ },
1517
1545
  );
1546
+ if (extensionOrigin) return withExtensionCorsHeaders(body, extensionOrigin);
1547
+ return withCorsHeaders(body, webviewOrigin!);
1518
1548
  }
1519
-
1520
- const notFound = Response.json(
1521
- { error: "Not found", source: "gateway" },
1522
- { status: 404 },
1549
+ log.error({ err }, "Unhandled gateway error");
1550
+ const errBody = Response.json(
1551
+ { error: "Internal server error" },
1552
+ { status: 500 },
1523
1553
  );
1524
- if (webviewOrigin) return withCorsHeaders(notFound, webviewOrigin);
1525
- return notFound;
1526
- },
1527
- });
1554
+ if (extensionOrigin) return withExtensionCorsHeaders(errBody, extensionOrigin);
1555
+ return withCorsHeaders(errBody, webviewOrigin!);
1556
+ }
1528
1557
 
1529
- log.info({ port: server.port }, "Gateway HTTP server listening");
1558
+ const notFound = Response.json(
1559
+ { error: "Not found", source: "gateway" },
1560
+ { status: 404 },
1561
+ );
1562
+ if (extensionOrigin) return withExtensionCorsHeaders(notFound, extensionOrigin);
1563
+ if (webviewOrigin) return withCorsHeaders(notFound, webviewOrigin);
1564
+ return notFound;
1565
+ }
1530
1566
 
1531
- // Deferred startup tasks that depend on the assistant runtime being
1532
- // ready (e.g. guardian binding backfill, data migrations that touch
1533
- // the assistant DB). Runs in the background — does not block startup.
1534
- runPostAssistantReady().catch((err) => {
1535
- log.error({ err }, "Post-assistant-ready lifecycle failed");
1536
- });
1567
+ log.info({ port: server.port }, "Gateway HTTP server listening");
1568
+ logAuthBypassState();
1569
+ velayTunnelClient?.start();
1537
1570
 
1538
1571
  // Start periodic background cleanup for dedup caches
1539
1572
  telegramDedupCache.startCleanup();
@@ -1668,6 +1701,20 @@ async function main() {
1668
1701
  }
1669
1702
 
1670
1703
  const forward = async () => {
1704
+ // Seed contact channel for the Slack actor (dual-write, fire-and-forget).
1705
+ // Covers both DMs (externalChatId = DM channel) and workspace messages.
1706
+ if (normalized.event.actor.actorExternalId) {
1707
+ void upsertContactChannel({
1708
+ sourceChannel: "slack",
1709
+ externalUserId: normalized.event.actor.actorExternalId,
1710
+ ...(normalized.event.source.chatType === "im"
1711
+ ? { externalChatId: normalized.event.message.conversationExternalId }
1712
+ : {}),
1713
+ displayName: normalized.event.actor.displayName,
1714
+ username: normalized.event.actor.username,
1715
+ }).catch(() => {});
1716
+ }
1717
+
1671
1718
  try {
1672
1719
  // Download and upload attachments if present (skip for edits and
1673
1720
  // callback actions — edits only update text, callbacks have no media)
@@ -1889,12 +1936,7 @@ async function main() {
1889
1936
  );
1890
1937
  });
1891
1938
 
1892
- // Sync avatar to Slack bot profile on credential change (including
1893
- // initial startup). Gated behind channel-avatar-sync feature flag.
1894
- // Known limitation: if the flag is toggled off without a credential
1895
- // change, the syncer stays registered until the next credential
1896
- // change triggers this callback.
1897
- if (slackReady && getMergedFeatureFlags()["channel-avatar-sync"]) {
1939
+ if (slackReady) {
1898
1940
  avatarChannelSyncer.register(new SlackAvatarSyncer(credentialCache));
1899
1941
  avatarChannelSyncer.syncToChannel("slack").catch((err) => {
1900
1942
  log.warn({ err }, "Initial Slack avatar sync failed");
@@ -1904,6 +1946,15 @@ async function main() {
1904
1946
  }
1905
1947
  }
1906
1948
 
1949
+ if (changed.has("twilio")) {
1950
+ syncConfiguredTwilioPhoneNumberWebhooks({
1951
+ credentials: credentialCache,
1952
+ configFile: configFileCache,
1953
+ }).catch((err) => {
1954
+ log.warn({ err }, "Twilio webhook sync failed after credential change");
1955
+ });
1956
+ }
1957
+
1907
1958
  // Register email callback route with the platform so inbound email
1908
1959
  // webhooks are forwarded to this gateway (same pattern as Telegram).
1909
1960
  // Fires on initial credential load and whenever vellum credentials change
@@ -1935,7 +1986,13 @@ async function main() {
1935
1986
  configFileCache.invalidate();
1936
1987
 
1937
1988
  // Side effect: reconcile Telegram webhook when ingress URL changes
1938
- if (event.changedKeys.has("ingress") && isTelegramConfigured()) {
1989
+ const onlyVelayPublicBaseUrlChanged = isOnlyVelayPublicBaseUrlChange(event);
1990
+
1991
+ if (
1992
+ event.changedKeys.has("ingress") &&
1993
+ !onlyVelayPublicBaseUrlChanged &&
1994
+ isTelegramConfigured()
1995
+ ) {
1939
1996
  reconcileTelegramWebhook(telegramCaches).catch((err) => {
1940
1997
  log.error(
1941
1998
  { err },
@@ -1944,9 +2001,22 @@ async function main() {
1944
2001
  });
1945
2002
  }
1946
2003
 
2004
+ if (shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)) {
2005
+ syncConfiguredTwilioPhoneNumberWebhooks({
2006
+ credentials: credentialCache,
2007
+ configFile: configFileCache,
2008
+ }).catch((err) => {
2009
+ log.warn({ err }, "Twilio webhook sync failed after config change");
2010
+ });
2011
+ }
2012
+
1947
2013
  // Side effect: re-register email callback when ingress URL changes so
1948
2014
  // the platform callback route points at the new self-hosted URL.
1949
- if (event.changedKeys.has("ingress") && vellumReady) {
2015
+ if (
2016
+ event.changedKeys.has("ingress") &&
2017
+ !onlyVelayPublicBaseUrlChanged &&
2018
+ vellumReady
2019
+ ) {
1950
2020
  registerEmailCallbackRoute({
1951
2021
  credentials: credentialCache,
1952
2022
  configFile: configFileCache,
@@ -1967,12 +2037,16 @@ async function main() {
1967
2037
  ...contactRoutes,
1968
2038
  ...thresholdRoutes,
1969
2039
  ...riskClassificationRoutes,
1970
- ...capabilityTokenRoutes,
1971
2040
  ]);
1972
2041
  ipcServer.start();
1973
2042
 
1974
2043
  void refreshRouteSchema();
1975
2044
 
2045
+ // ── Backup worker ──
2046
+ const backupWorkerHandle = startBackupWorker({
2047
+ assistantRuntimeBaseUrl: config.assistantRuntimeBaseUrl,
2048
+ });
2049
+
1976
2050
  const featureFlagWatcher = new FeatureFlagWatcher();
1977
2051
  featureFlagWatcher.start();
1978
2052
 
@@ -2016,25 +2090,30 @@ async function main() {
2016
2090
  process.on("SIGTERM", () => {
2017
2091
  log.info("SIGTERM received, starting graceful shutdown");
2018
2092
  draining = true;
2093
+ const shutdownTasks: Promise<void>[] = [];
2019
2094
  sleepWakeDetector.stop();
2095
+ backupWorkerHandle.stop();
2020
2096
  credentialWatcher.stop();
2021
2097
  configFileWatcher.stop();
2022
2098
  avatarSyncWatcher.stop();
2023
2099
  featureFlagWatcher.stop();
2024
2100
  remoteFeatureFlagSync.stop();
2101
+ const velayStop = velayTunnelClient?.stop();
2102
+ if (velayStop) shutdownTasks.push(velayStop);
2025
2103
  ipcServer.stop();
2026
2104
  telegramDedupCache.stopCleanup();
2027
2105
  whatsappDedupCache.stopCleanup();
2028
2106
  emailDedupCache.stopCleanup();
2029
- pairingStore.stop();
2030
2107
  if (slackSocketClient) {
2031
2108
  slackSocketClient.stop();
2032
2109
  slackSocketClient = null;
2033
2110
  }
2034
2111
  setTimeout(() => {
2035
2112
  log.info("Drain window elapsed, stopping server");
2036
- server.stop(true);
2037
- process.exit(0);
2113
+ void Promise.allSettled(shutdownTasks).then(() => {
2114
+ server.stop(true);
2115
+ process.exit(0);
2116
+ });
2038
2117
  }, drainMs);
2039
2118
  });
2040
2119
  }