@vellumai/vellum-gateway 0.7.2 → 0.7.3

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 (38) hide show
  1. package/ARCHITECTURE.md +20 -21
  2. package/README.md +6 -6
  3. package/package.json +1 -1
  4. package/src/__tests__/config-file-watcher.test.ts +1 -1
  5. package/src/__tests__/contact-prompt-submit.test.ts +349 -0
  6. package/src/__tests__/ipc-route-policy.test.ts +24 -0
  7. package/src/__tests__/nonbash-trust-rule-overrides.test.ts +50 -0
  8. package/src/__tests__/remote-feature-flag-sync.test.ts +16 -14
  9. package/src/__tests__/slack-display-name.test.ts +6 -2
  10. package/src/__tests__/slack-normalize.test.ts +36 -56
  11. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +4 -2
  12. package/src/__tests__/telegram-webhook-manager.test.ts +8 -25
  13. package/src/__tests__/twilio-webhooks.test.ts +2 -6
  14. package/src/__tests__/upsert-verified-contact-channel.test.ts +173 -0
  15. package/src/auth/guardian-bootstrap.ts +49 -0
  16. package/src/auth/ipc-route-policy.ts +5 -0
  17. package/src/db/contact-store.ts +27 -1
  18. package/src/email/register-callback.test.ts +4 -4
  19. package/src/email/register-callback.ts +12 -16
  20. package/src/feature-flag-registry.json +27 -3
  21. package/src/handlers/handle-inbound.ts +12 -0
  22. package/src/http/routes/contact-prompt.ts +134 -23
  23. package/src/http/routes/contacts-control-plane-proxy.ts +34 -5
  24. package/src/http/routes/ipc-runtime-proxy.ts +18 -0
  25. package/src/http/routes/twilio-voice-webhook.test.ts +22 -1
  26. package/src/http/routes/twilio-voice-webhook.ts +53 -0
  27. package/src/index.ts +4 -2
  28. package/src/ipc/velay-handlers.ts +31 -0
  29. package/src/remote-feature-flag-sync.ts +10 -8
  30. package/src/risk/command-registry/commands/assistant.ts +1 -0
  31. package/src/risk/skill-risk-classifier.ts +12 -3
  32. package/src/runtime/client.ts +25 -12
  33. package/src/slack/normalize.test.ts +3 -3
  34. package/src/slack/normalize.ts +6 -69
  35. package/src/slack/socket-mode.ts +1 -5
  36. package/src/telegram/webhook-manager.ts +9 -13
  37. package/src/velay/client.ts +27 -16
  38. package/src/verification/contact-helpers.ts +6 -3
@@ -20,6 +20,7 @@ import {
20
20
  findPendingPhoneSession,
21
21
  gatherVerificationTwiml,
22
22
  } from "../../voice/verification.js";
23
+ import { ContactStore } from "../../db/contact-store.js";
23
24
 
24
25
  const log = getLogger("twilio-voice-webhook");
25
26
 
@@ -29,6 +30,16 @@ const REJECT_TWIML =
29
30
 
30
31
  const TWIML_HEADERS = { "Content-Type": "text/xml" };
31
32
 
33
+ /** Escapes XML special characters so contact display names are safe to embed in TwiML. */
34
+ function escapeXml(str: string): string {
35
+ return str
36
+ .replace(/&/g, "&")
37
+ .replace(/</g, "&lt;")
38
+ .replace(/>/g, "&gt;")
39
+ .replace(/"/g, "&quot;")
40
+ .replace(/'/g, "&apos;");
41
+ }
42
+
32
43
  export function createTwilioVoiceWebhookHandler(
33
44
  config: GatewayConfig,
34
45
  caches?: TwilioValidationCaches & { configFile?: ConfigFileCache },
@@ -123,6 +134,48 @@ export function createTwilioVoiceWebhookHandler(
123
134
  "Failed to check pending verification session — falling through to assistant",
124
135
  );
125
136
  }
137
+
138
+ // ── Known-but-unverified caller guidance ─────────────────────────────
139
+ // If the caller's number is registered under a contact's phone channel
140
+ // but has not yet passed DTMF verification, intercept with a helpful
141
+ // message rather than letting the runtime treat them as an unknown caller.
142
+ if (params.From) {
143
+ try {
144
+ const callerRecord = new ContactStore().getContactByPhoneNumber(
145
+ params.From,
146
+ );
147
+ // Only intercept genuinely unverified channels — not blocked ones.
148
+ // A blocked caller should fall through to the runtime's deny path
149
+ // rather than hearing a helpful verification script (which would
150
+ // both leak the contact name and weaken block semantics).
151
+ // The display name is intentionally included: the caller registered
152
+ // this number themselves, so disclosing their own name is expected.
153
+ const unverifiedStatuses = new Set(["unverified", "pending"]);
154
+ if (callerRecord && unverifiedStatuses.has(callerRecord.channel.status)) {
155
+ log.info(
156
+ {
157
+ callSid: params.CallSid,
158
+ contactId: callerRecord.contact.id,
159
+ channelStatus: callerRecord.channel.status,
160
+ },
161
+ "Known-but-unverified caller — returning verification guidance TwiML",
162
+ );
163
+ const name = escapeXml(callerRecord.contact.displayName);
164
+ const twiml =
165
+ `<?xml version="1.0" encoding="UTF-8"?><Response>` +
166
+ `<Say>This number is registered as ${name}'s phone but has not been verified yet. ` +
167
+ `To verify, open your assistant's contacts page, click Verify next to the phone channel, ` +
168
+ `and follow the prompts. Then call back once the verification session is active.</Say>` +
169
+ `</Response>`;
170
+ return new Response(twiml, { status: 200, headers: TWIML_HEADERS });
171
+ }
172
+ } catch (err) {
173
+ log.warn(
174
+ { err, callSid: params.CallSid },
175
+ "Failed to check unverified caller — falling through to assistant",
176
+ );
177
+ }
178
+ }
126
179
  }
127
180
 
128
181
  try {
package/src/index.ts CHANGED
@@ -166,6 +166,7 @@ import { featureFlagRoutes } from "./ipc/feature-flag-handlers.js";
166
166
  import { thresholdRoutes } from "./ipc/threshold-handlers.js";
167
167
 
168
168
  import { riskClassificationRoutes } from "./ipc/risk-classification-handlers.js";
169
+ import { createVelayRoutes } from "./ipc/velay-handlers.js";
169
170
  import { refreshRouteSchema } from "./ipc/route-schema-cache.js";
170
171
  import { AvatarChannelSyncer } from "./avatar-sync/avatar-channel-syncer.js";
171
172
  import { AvatarSyncWatcher } from "./avatar-sync/avatar-sync-watcher.js";
@@ -673,8 +674,8 @@ async function main() {
673
674
  path: /^\/v1\/contacts\/(?!invites$)([^/]+)$/,
674
675
  method: "DELETE",
675
676
  auth: "edge",
676
- handler: (req, params) =>
677
- contactsControlPlaneProxy.handleDeleteContact(req, params[0]),
677
+ handler: (_req, params) =>
678
+ contactsControlPlaneProxy.handleDeleteContact(params[0]),
678
679
  },
679
680
  {
680
681
  path: /^\/v1\/contacts\/([^/]+)$/,
@@ -2037,6 +2038,7 @@ async function main() {
2037
2038
  ...contactRoutes,
2038
2039
  ...thresholdRoutes,
2039
2040
  ...riskClassificationRoutes,
2041
+ ...createVelayRoutes(velayTunnelClient),
2040
2042
  ]);
2041
2043
  ipcServer.start();
2042
2044
 
@@ -0,0 +1,31 @@
1
+ /**
2
+ * IPC route definitions for Velay tunnel status.
3
+ *
4
+ * Exports a factory that takes the optional VelayTunnelClient and returns
5
+ * a single `get_velay_status` IPC route. Returns disconnected/null when no
6
+ * client is configured (e.g. VELAY_BASE_URL not set).
7
+ */
8
+
9
+ import type { VelayTunnelClient } from "../velay/client.js";
10
+ import type { IpcRoute } from "./server.js";
11
+
12
+ export interface VelayStatus {
13
+ connected: boolean;
14
+ publicUrl: string | null;
15
+ }
16
+
17
+ export function createVelayRoutes(
18
+ velayTunnelClient: VelayTunnelClient | undefined,
19
+ ): IpcRoute[] {
20
+ return [
21
+ {
22
+ method: "get_velay_status",
23
+ handler: (): VelayStatus => {
24
+ if (!velayTunnelClient) {
25
+ return { connected: false, publicUrl: null };
26
+ }
27
+ return velayTunnelClient.getStatus();
28
+ },
29
+ },
30
+ ];
31
+ }
@@ -311,23 +311,25 @@ export class RemoteFeatureFlagSync {
311
311
  return { status: "error" };
312
312
  }
313
313
 
314
- // Fall back to env vars when credential cache values are missing.
314
+ // Fall back to env vars when managed pod credentials are not yet cached.
315
315
  const platformUrl = (
316
316
  platformUrlRaw?.trim() ||
317
317
  process.env.VELLUM_PLATFORM_URL?.trim() ||
318
318
  ""
319
319
  ).replace(/\/+$/, "");
320
320
 
321
- // Feature flag sync hits the public platform API (/v1/feature-flags/assistant-flag-values/),
322
- // which requires Api-Key auth. PLATFORM_INTERNAL_API_KEY is only valid
323
- // for internal gateway endpoints and would produce 401s here.
324
- const assistantApiKey = assistantApiKeyRaw?.trim() || undefined;
321
+ // Feature flag sync hits the public platform API and requires assistant
322
+ // API key auth.
323
+ const assistantCredential =
324
+ assistantApiKeyRaw?.trim() ||
325
+ process.env.ASSISTANT_API_KEY?.trim() ||
326
+ undefined;
325
327
 
326
- if (!platformUrl || !assistantApiKey) {
328
+ if (!platformUrl || !assistantCredential) {
327
329
  log.debug(
328
330
  {
329
331
  hasPlatformUrl: !!platformUrl,
330
- hasApiKey: !!assistantApiKey,
332
+ hasApiKey: !!assistantCredential,
331
333
  },
332
334
  "Remote feature flag sync skipped: missing credentials",
333
335
  );
@@ -340,7 +342,7 @@ export class RemoteFeatureFlagSync {
340
342
  const response = await fetchImpl(url, {
341
343
  method: "GET",
342
344
  headers: {
343
- Authorization: `Api-Key ${assistantApiKey}`,
345
+ Authorization: `Api-Key ${assistantCredential}`,
344
346
  Accept: "application/json",
345
347
  },
346
348
  signal: AbortSignal.timeout(10_000),
@@ -204,6 +204,7 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
204
204
  "skills install",
205
205
  "skills uninstall",
206
206
  "skills add",
207
+ "status",
207
208
  "stt",
208
209
  "stt transcribe",
209
210
  "task",
@@ -176,10 +176,18 @@ export class SkillLoadRiskClassifier implements RiskClassifier<SkillClassifierIn
176
176
  let assessment: RiskAssessment;
177
177
 
178
178
  switch (toolName) {
179
- case "skill_load":
179
+ case "skill_load": {
180
+ // Skills with inline command expansions execute shell commands at load
181
+ // time via child_process.spawn, bypassing the normal bash-tool
182
+ // permission pipeline. Elevate to medium so the default auto-approve
183
+ // threshold (low) requires an explicit prompt instead of silently
184
+ // running embedded commands.
185
+ const hasExpansions = resolvedMetadata?.hasInlineExpansions === true;
180
186
  assessment = {
181
- riskLevel: "low",
182
- reason: "Skill load (default)",
187
+ riskLevel: hasExpansions ? "medium" : "low",
188
+ reason: hasExpansions
189
+ ? "Skill load with inline command expansions (executes shell commands at load time)"
190
+ : "Skill load (default)",
183
191
  scopeOptions: [],
184
192
  matchType: "registry",
185
193
  allowlistOptions: buildSkillLoadAllowlistOptions(
@@ -188,6 +196,7 @@ export class SkillLoadRiskClassifier implements RiskClassifier<SkillClassifierIn
188
196
  ),
189
197
  };
190
198
  break;
199
+ }
191
200
  case "scaffold_managed_skill":
192
201
  assessment = {
193
202
  riskLevel: "high",
@@ -219,6 +219,17 @@ export type RuntimeInboundResponse = {
219
219
  };
220
220
  /** When true, the runtime denied the inbound message (e.g. ACL rejection). */
221
221
  denied?: boolean;
222
+ /**
223
+ * When a guardian approved an inbound voice access request, the contact that
224
+ * should be activated. The gateway writes the dual-write on behalf of the
225
+ * runtime so the assistant never triggers contact writes via IPC.
226
+ */
227
+ activatedContact?: {
228
+ sourceChannel: string;
229
+ externalUserId: string;
230
+ externalChatId?: string;
231
+ displayName?: string;
232
+ };
222
233
  /**
223
234
  * A user-facing rejection message that the runtime could not deliver via
224
235
  * the callback URL (e.g. due to auth failure). When present, the gateway
@@ -503,11 +514,10 @@ const TWILIO_RELAY_TOKEN_PLACEHOLDER = "__VELLUM_RELAY_TOKEN__";
503
514
  * Resolve the public base URL as a WebSocket URL (`wss://…`).
504
515
  *
505
516
  * Sources (in priority order):
506
- * 1. `VELAY_BASE_URL` present on platform-hosted gateway sidecars.
507
- * When Velay is the tunnel provider, Twilio WebSocket connections
508
- * are routed through the Velay service URL.
509
- * 2. `ingress.publicBaseUrl` from the config file written by Velay
510
- * on tunnel registration, or set manually for self-hosted.
517
+ * 1. `ingress.publicBaseUrl` from the config file written by Velay
518
+ * after tunnel registration, or set manually for self-hosted.
519
+ * 2. `VELAY_BASE_URL` + platform assistant ID — fallback for platform
520
+ * sidecars before the config cache has observed the registered URL.
511
521
  *
512
522
  * Returns `undefined` when no source provides a value — the placeholder
513
523
  * will remain in TwiML and Twilio will fail to connect, which is the
@@ -518,16 +528,19 @@ export function resolvePublicBaseWssUrl(
518
528
  configFile?: ConfigFileCache,
519
529
  platformAssistantId?: string,
520
530
  ): string | undefined {
531
+ const raw = configFile?.getString("ingress", "publicBaseUrl");
532
+ const normalized = normalizePublicBaseUrl(raw);
533
+ if (normalized) return normalized.replace(/^http(s?)/, "ws$1");
534
+
521
535
  if (config.velayBaseUrl && platformAssistantId) {
522
536
  const withPath =
523
537
  config.velayBaseUrl.replace(/\/+$/, "") + "/" + platformAssistantId;
524
- const normalized = normalizePublicBaseUrl(withPath);
525
- if (normalized) return normalized.replace(/^http(s?)/, "ws$1");
538
+ const normalizedVelayUrl = normalizePublicBaseUrl(withPath);
539
+ if (normalizedVelayUrl) {
540
+ return normalizedVelayUrl.replace(/^http(s?)/, "ws$1");
541
+ }
526
542
  }
527
- const raw = configFile?.getString("ingress", "publicBaseUrl");
528
- const normalized = normalizePublicBaseUrl(raw);
529
- if (!normalized) return undefined;
530
- return normalized.replace(/^http(s?)/, "ws$1");
543
+ return undefined;
531
544
  }
532
545
 
533
546
  /**
@@ -597,7 +610,7 @@ export async function forwardTwilioVoiceWebhook(
597
610
  } else {
598
611
  log.error(
599
612
  "TwiML contains public URL placeholder but no public base URL is configured. " +
600
- "Twilio will fail to connect. Set VELAY_BASE_URL or ingress.publicBaseUrl.",
613
+ "Twilio will fail to connect. Wait for Velay tunnel registration or set ingress.publicBaseUrl.",
601
614
  );
602
615
  }
603
616
  }
@@ -802,7 +802,7 @@ describe("attachment extraction in normalize functions", () => {
802
802
  expect(result!.slackFiles).toBeDefined();
803
803
  });
804
804
 
805
- it("normalizes channel file_share mentions after stripping only the bot mention", () => {
805
+ it("normalizes channel file_share mentions and renders the bot mention", () => {
806
806
  const config = makeConfig();
807
807
  const event = makeChannelEvent({
808
808
  subtype: "file_share",
@@ -821,11 +821,11 @@ describe("attachment extraction in normalize functions", () => {
821
821
  config,
822
822
  "UBOT",
823
823
  undefined,
824
- { userLabels: { ULEO: "leo" } },
824
+ { userLabels: { UBOT: "vex", ULEO: "leo" } },
825
825
  );
826
826
 
827
827
  expect(result).not.toBeNull();
828
- expect(result!.event.message.content).toBe("@leo shared this");
828
+ expect(result!.event.message.content).toBe("@vex @leo shared this");
829
829
  expect(result!.event.message.content).not.toContain("ULEO");
830
830
  expect(result!.event.message.attachments).toHaveLength(1);
831
831
  expect(result!.event.message.attachments![0]).toEqual({
@@ -1,8 +1,4 @@
1
- import {
2
- renderSlackTextForModel,
3
- stripLeadingSlackMentionFallback,
4
- stripLeadingSlackUserMention,
5
- } from "@vellumai/slack-text";
1
+ import { renderSlackTextForModel } from "@vellumai/slack-text";
6
2
  import type { GatewayConfig } from "../config.js";
7
3
  import { fetchImpl } from "../fetch.js";
8
4
  import { resolveAssistant, isRejection } from "../routing/resolve-assistant.js";
@@ -281,56 +277,18 @@ export interface SlackMessageDeletedEvent {
281
277
  }
282
278
 
283
279
  export type SlackTextRenderContext = {
284
- botUserId?: string;
285
280
  userLabels?: Record<string, string>;
286
281
  };
287
282
 
288
- /**
289
- * Strip leading bot-mention tokens from Slack message text.
290
- *
291
- * When the bot user ID is known, only that exact mention is stripped. Without a
292
- * bot user ID, strip just one leading mention as an app_mention compatibility
293
- * fallback.
294
- */
295
- export function stripBotMention(text: string, botUserId?: string): string {
296
- const stripped = botUserId
297
- ? stripLeadingSlackUserMention(text, botUserId)
298
- : stripLeadingSlackMentionFallback(text);
299
- return stripped.trim() || text.trim();
300
- }
301
-
302
283
  function renderSlackInboundText(
303
284
  text: string,
304
285
  context: SlackTextRenderContext = {},
305
- options: {
306
- stripLeadingBotMention?: boolean;
307
- fallbackStripFirstMention?: boolean;
308
- } = {},
309
286
  ): string {
310
- let stripped = text;
311
- if (options.stripLeadingBotMention) {
312
- stripped = context.botUserId
313
- ? stripBotMention(text, context.botUserId)
314
- : options.fallbackStripFirstMention
315
- ? stripBotMention(text)
316
- : text.trim();
317
- }
318
-
319
- return renderSlackTextForModel(stripped, {
287
+ return renderSlackTextForModel(text, {
320
288
  userLabels: context.userLabels,
321
289
  });
322
290
  }
323
291
 
324
- function withBotUserId(
325
- botUserId: string | undefined,
326
- context: SlackTextRenderContext | undefined,
327
- ): SlackTextRenderContext {
328
- return {
329
- ...context,
330
- botUserId: context?.botUserId ?? botUserId,
331
- };
332
- }
333
-
334
292
  function extractSlackAttachments(files: SlackFile[] | undefined): Array<{
335
293
  type: "image" | "document";
336
294
  fileId: string;
@@ -426,10 +384,7 @@ export function normalizeSlackDirectMessage(
426
384
  botToken && event.user
427
385
  ? resolveSlackUserSync(event.user, botToken)
428
386
  : undefined;
429
- const content = renderSlackInboundText(
430
- event.text,
431
- withBotUserId(botUserId, renderContext),
432
- );
387
+ const content = renderSlackInboundText(event.text, renderContext);
433
388
 
434
389
  return {
435
390
  event: {
@@ -487,11 +442,7 @@ export function normalizeSlackChannelMessage(
487
442
  const routing = resolveAssistant(config, event.channel, event.user);
488
443
  if (isRejection(routing)) return null;
489
444
 
490
- const content = renderSlackInboundText(
491
- event.text,
492
- withBotUserId(botUserId, renderContext),
493
- { stripLeadingBotMention: true, fallbackStripFirstMention: true },
494
- );
445
+ const content = renderSlackInboundText(event.text, renderContext);
495
446
  const externalMessageId =
496
447
  event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
497
448
 
@@ -556,14 +507,7 @@ export function normalizeSlackAppMention(
556
507
  return null;
557
508
  }
558
509
 
559
- const content = renderSlackInboundText(
560
- event.text,
561
- withBotUserId(botUserId, renderContext),
562
- {
563
- stripLeadingBotMention: true,
564
- fallbackStripFirstMention: true,
565
- },
566
- );
510
+ const content = renderSlackInboundText(event.text, renderContext);
567
511
  const externalMessageId =
568
512
  event.client_msg_id ?? event.ts ?? `${event.channel}:${event.ts}`;
569
513
 
@@ -876,14 +820,7 @@ export function normalizeSlackMessageEdit(
876
820
  }
877
821
  if (isRejection(routing)) return null;
878
822
 
879
- const content = renderSlackInboundText(
880
- edited.text,
881
- withBotUserId(botUserId, renderContext),
882
- {
883
- stripLeadingBotMention: true,
884
- fallbackStripFirstMention: !botUserId,
885
- },
886
- );
823
+ const content = renderSlackInboundText(edited.text, renderContext);
887
824
 
888
825
  // Each edit event gets a unique externalMessageId so the dedup pipeline
889
826
  // does not discard subsequent edits of the same Slack message.
@@ -762,7 +762,6 @@ export class SlackSocketModeClient {
762
762
  if (!userInfo) return undefined;
763
763
  return userInfo.displayName || userInfo.username;
764
764
  },
765
- { ignoredUserIds: [this.config.botUserId] },
766
765
  );
767
766
  }
768
767
 
@@ -876,10 +875,7 @@ export class SlackSocketModeClient {
876
875
  ): Promise<void> {
877
876
  const text = this.extractTextBearingContent(event);
878
877
  const userLabels = text ? await this.resolveMentionLabelsForText(text) : {};
879
- const renderContext = {
880
- botUserId: this.config.botUserId,
881
- userLabels,
882
- };
878
+ const renderContext = { userLabels };
883
879
 
884
880
  let normalized: NormalizedSlackEvent | null;
885
881
  if (isReactionAdded) {
@@ -31,7 +31,6 @@ interface PlatformCallbackRouteResponse {
31
31
  async function registerManagedTelegramCallbackRoute(
32
32
  caches?: WebhookManagerCaches,
33
33
  ): Promise<string | undefined> {
34
- // Read from credential cache when available
35
34
  const [platformBaseUrlRaw, assistantApiKeyRaw, assistantIdRaw] =
36
35
  caches?.credentials
37
36
  ? await Promise.all([
@@ -43,29 +42,26 @@ async function registerManagedTelegramCallbackRoute(
43
42
  ])
44
43
  : [undefined, undefined, undefined];
45
44
 
46
- // Fall back to env vars when credential cache values are missing, matching
47
- // the daemon's resolvePlatformCallbackRegistrationContext() behaviour.
45
+ // Fall back to env vars when managed pod credentials are not yet cached,
46
+ // matching the daemon's resolvePlatformCallbackRegistrationContext().
48
47
  const platformBaseUrl = (
49
48
  platformBaseUrlRaw?.trim() ||
50
49
  process.env.VELLUM_PLATFORM_URL?.trim() ||
51
50
  ""
52
51
  ).replace(/\/+$/, "");
53
52
 
54
- const platformInternalApiKey =
55
- process.env.PLATFORM_INTERNAL_API_KEY?.trim() || undefined;
56
- const assistantApiKey = !platformInternalApiKey
57
- ? assistantApiKeyRaw?.trim() || undefined
58
- : undefined;
59
- const authToken = platformInternalApiKey || assistantApiKey;
60
- const authScheme = platformInternalApiKey ? "Bearer" : "Api-Key";
53
+ const assistantCredential =
54
+ assistantApiKeyRaw?.trim() ||
55
+ process.env.ASSISTANT_API_KEY?.trim() ||
56
+ undefined;
61
57
 
62
58
  const assistantId = assistantIdRaw?.trim() || undefined;
63
59
 
64
- if (!platformBaseUrl || !authToken || !assistantId) {
60
+ if (!platformBaseUrl || !assistantCredential || !assistantId) {
65
61
  log.debug(
66
62
  {
67
63
  hasPlatformBaseUrl: !!platformBaseUrl,
68
- hasApiKey: !!authToken,
64
+ hasApiKey: !!assistantCredential,
69
65
  hasAssistantId: !!assistantId,
70
66
  },
71
67
  "Managed Telegram callback route registration unavailable",
@@ -95,7 +91,7 @@ async function registerManagedTelegramCallbackRoute(
95
91
  {
96
92
  method: "POST",
97
93
  headers: {
98
- Authorization: `${authScheme} ${authToken}`,
94
+ Authorization: `Api-Key ${assistantCredential}`,
99
95
  "Content-Type": "application/json",
100
96
  },
101
97
  body: JSON.stringify({
@@ -76,7 +76,7 @@ export class VelayTunnelClient {
76
76
  private connecting = false;
77
77
  private reconnectAttempt = 0;
78
78
  private reconnectTimer: unknown = null;
79
- private publishedTwilioPublicBaseUrl: string | undefined;
79
+ private publishedPublicBaseUrl: string | undefined;
80
80
  private unsubscribeConfigInvalidation: (() => void) | undefined;
81
81
 
82
82
  constructor(private readonly options: VelayTunnelClientOptions) {
@@ -97,6 +97,17 @@ export class VelayTunnelClient {
97
97
  this.timerApi = options.timerApi ?? defaultTimerApi;
98
98
  }
99
99
 
100
+ getStatus(): { connected: boolean; publicUrl: string | null } {
101
+ const connected =
102
+ this.ws !== null &&
103
+ this.ws.readyState === WebSocket.OPEN &&
104
+ this.publishedPublicBaseUrl !== undefined;
105
+ return {
106
+ connected,
107
+ publicUrl: this.publishedPublicBaseUrl ?? null,
108
+ };
109
+ }
110
+
100
111
  start(): void {
101
112
  if (this.running) return;
102
113
  this.running = true;
@@ -124,7 +135,7 @@ export class VelayTunnelClient {
124
135
  const ws = this.ws;
125
136
  this.ws = null;
126
137
  this.webSocketBridge.closeAll();
127
- await this.clearPublishedTwilioPublicBaseUrl();
138
+ await this.clearPublishedPublicBaseUrl();
128
139
  if (ws) {
129
140
  closeWebSocket(ws, 1000, "gateway shutdown");
130
141
  }
@@ -141,7 +152,7 @@ export class VelayTunnelClient {
141
152
 
142
153
  if (this.isPublicIngressDisabled()) {
143
154
  this.connecting = false;
144
- await this.clearPublishedTwilioPublicBaseUrl();
155
+ await this.clearPublishedPublicBaseUrl();
145
156
  log.info("Velay tunnel waiting because public ingress is disabled");
146
157
  this.scheduleReconnect();
147
158
  return;
@@ -286,7 +297,7 @@ export class VelayTunnelClient {
286
297
  if (!publicUrl) {
287
298
  log.error(
288
299
  { publicUrl: frame.public_url },
289
- "Velay registered invalid Twilio public URL",
300
+ "Velay registered invalid public URL",
290
301
  );
291
302
  this.disconnectActiveWebSocket(
292
303
  originWs,
@@ -299,14 +310,14 @@ export class VelayTunnelClient {
299
310
  if (this.isPublicIngressDisabled()) {
300
311
  log.info(
301
312
  { publicUrl },
302
- "Skipping Velay Twilio public URL publish because public ingress is disabled",
313
+ "Skipping Velay public URL publish because public ingress is disabled",
303
314
  );
304
315
  this.disconnectActiveWebSocket(originWs, 1000, "public ingress disabled");
305
316
  return;
306
317
  }
307
318
 
308
319
  await writeManagedPublicBaseUrl(publicUrl, this.options.configFile);
309
- this.publishedTwilioPublicBaseUrl = publicUrl;
320
+ this.publishedPublicBaseUrl = publicUrl;
310
321
  this.reconnectAttempt = 0;
311
322
  log.info({ publicUrl }, "Velay tunnel registered");
312
323
  }
@@ -332,7 +343,7 @@ export class VelayTunnelClient {
332
343
  { code: event.code, reason: event.reason },
333
344
  "Velay tunnel disconnected",
334
345
  );
335
- this.clearPublishedTwilioPublicBaseUrlThenReconnect();
346
+ this.clearPublishedPublicBaseUrlThenReconnect();
336
347
  }
337
348
 
338
349
  private disconnectActiveWebSocket(
@@ -345,24 +356,24 @@ export class VelayTunnelClient {
345
356
  this.connecting = false;
346
357
  this.webSocketBridge.closeAll();
347
358
  closeWebSocket(ws, code, reason);
348
- this.clearPublishedTwilioPublicBaseUrlThenReconnect();
359
+ this.clearPublishedPublicBaseUrlThenReconnect();
349
360
  }
350
361
 
351
- private async clearPublishedTwilioPublicBaseUrl(): Promise<void> {
352
- const publicUrl = this.publishedTwilioPublicBaseUrl;
362
+ private async clearPublishedPublicBaseUrl(): Promise<void> {
363
+ const publicUrl = this.publishedPublicBaseUrl;
353
364
  if (!publicUrl) return;
354
- this.publishedTwilioPublicBaseUrl = undefined;
365
+ this.publishedPublicBaseUrl = undefined;
355
366
  try {
356
367
  await clearManagedPublicBaseUrl(this.options.configFile, publicUrl);
357
368
  } catch (err) {
358
- log.error({ err }, "Failed to clear Velay Twilio public URL");
369
+ log.error({ err }, "Failed to clear Velay public URL");
359
370
  }
360
371
  }
361
372
 
362
- private clearPublishedTwilioPublicBaseUrlThenReconnect(): void {
363
- void this.clearPublishedTwilioPublicBaseUrl()
373
+ private clearPublishedPublicBaseUrlThenReconnect(): void {
374
+ void this.clearPublishedPublicBaseUrl()
364
375
  .catch((err) => {
365
- log.error({ err }, "Failed to clear Velay Twilio public URL");
376
+ log.error({ err }, "Failed to clear Velay public URL");
366
377
  })
367
378
  .finally(() => {
368
379
  this.scheduleReconnect();
@@ -430,7 +441,7 @@ export function createVelayTunnelClient(
430
441
  );
431
442
  }
432
443
  void clearManagedPublicBaseUrl(deps.configFile).catch((err) => {
433
- log.error({ err }, "Failed to clear disabled Velay Twilio public URL");
444
+ log.error({ err }, "Failed to clear disabled Velay public URL");
434
445
  });
435
446
  return undefined;
436
447
  }
@@ -111,9 +111,12 @@ export async function upsertVerifiedContactChannel(params: {
111
111
  if (existing.length > 0) {
112
112
  const row = existing[0];
113
113
 
114
- // Don't overwrite blocked channels
115
- if (row.channelStatus === "blocked") {
116
- log.warn({ sourceChannel, address }, "Skipping upsert: channel is blocked");
114
+ // Don't overwrite blocked or revoked channels.
115
+ if (row.channelStatus === "blocked" || row.channelStatus === "revoked") {
116
+ log.warn(
117
+ { sourceChannel, address, status: row.channelStatus },
118
+ "Skipping upsert: channel is blocked or revoked",
119
+ );
117
120
  return;
118
121
  }
119
122