@vellumai/vellum-gateway 0.8.2 → 0.8.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.
@@ -78,14 +78,17 @@ export function createTwilioVoiceVerifyCallbackHandler(
78
78
  { callSid, fromNumber },
79
79
  "No pending verification session found on callback — forwarding to assistant",
80
80
  );
81
- return forwardToAssistant(config, params, req.url, caches);
81
+ return forwardToAssistant(
82
+ config,
83
+ params,
84
+ req.url,
85
+ validation.validatedCandidateUrl,
86
+ caches,
87
+ );
82
88
  }
83
89
 
84
90
  if (!digits) {
85
- log.info(
86
- { callSid, fromNumber },
87
- "No digits entered — re-prompting",
88
- );
91
+ log.info({ callSid, fromNumber }, "No digits entered — re-prompting");
89
92
  const actionUrl = buildActionUrl(url, attempt);
90
93
  return twimlResponse(
91
94
  gatherVerificationTwiml(actionUrl, attempt, session.codeDigits ?? 6),
@@ -113,7 +116,11 @@ export function createTwilioVoiceVerifyCallbackHandler(
113
116
  const nextAttempt = attempt + 1;
114
117
  const actionUrl = buildActionUrl(url, nextAttempt);
115
118
  return twimlResponse(
116
- gatherVerificationTwiml(actionUrl, nextAttempt, session.codeDigits ?? 6),
119
+ gatherVerificationTwiml(
120
+ actionUrl,
121
+ nextAttempt,
122
+ session.codeDigits ?? 6,
123
+ ),
117
124
  );
118
125
  }
119
126
 
@@ -147,7 +154,10 @@ export function createTwilioVoiceVerifyCallbackHandler(
147
154
  );
148
155
 
149
156
  const existingGuardian = existingPhoneGuardians[0];
150
- if (existingGuardian && existingGuardian.externalUserId !== fromNumber) {
157
+ if (
158
+ existingGuardian &&
159
+ existingGuardian.externalUserId !== fromNumber
160
+ ) {
151
161
  log.warn(
152
162
  {
153
163
  callSid,
@@ -209,7 +219,13 @@ export function createTwilioVoiceVerifyCallbackHandler(
209
219
  { callSid, fromNumber },
210
220
  "Voice verification complete — forwarding to assistant for call setup",
211
221
  );
212
- return forwardToAssistant(config, params, req.url, caches);
222
+ return forwardToAssistant(
223
+ config,
224
+ params,
225
+ req.url,
226
+ validation.validatedCandidateUrl,
227
+ caches,
228
+ );
213
229
  };
214
230
  }
215
231
 
@@ -248,13 +264,17 @@ async function revokeExistingPhoneGuardian(): Promise<void> {
248
264
  try {
249
265
  const gwDb = getGatewayDb();
250
266
  for (const id of ids) {
251
- gwDb.update(gwContactChannels)
267
+ gwDb
268
+ .update(gwContactChannels)
252
269
  .set({ status: "revoked", policy: "deny", updatedAt: now })
253
270
  .where(eq(gwContactChannels.id, id))
254
271
  .run();
255
272
  }
256
273
  } catch (gwErr) {
257
- log.warn({ err: gwErr }, "Gateway DB revoke dual-write failed (best-effort)");
274
+ log.warn(
275
+ { err: gwErr },
276
+ "Gateway DB revoke dual-write failed (best-effort)",
277
+ );
258
278
  }
259
279
  }
260
280
 
@@ -276,6 +296,7 @@ async function forwardToAssistant(
276
296
  config: GatewayConfig,
277
297
  params: Record<string, string>,
278
298
  originalUrl: string,
299
+ validatedPublicUrl?: string,
279
300
  caches?: TwilioValidationCaches,
280
301
  ): Promise<Response> {
281
302
  try {
@@ -288,7 +309,12 @@ async function forwardToAssistant(
288
309
  config,
289
310
  params,
290
311
  originalUrl,
291
- resolvePublicBaseWssUrl(config, caches?.configFile, platformAssistantId),
312
+ resolvePublicBaseWssUrl(
313
+ config,
314
+ caches?.configFile,
315
+ platformAssistantId,
316
+ validatedPublicUrl,
317
+ ),
292
318
  );
293
319
  return new Response(runtimeResponse.body, {
294
320
  status: runtimeResponse.status,
@@ -304,7 +330,10 @@ async function forwardToAssistant(
304
330
  },
305
331
  );
306
332
  }
307
- log.error({ err }, "Failed to forward voice webhook to runtime after verification");
333
+ log.error(
334
+ { err },
335
+ "Failed to forward voice webhook to runtime after verification",
336
+ );
308
337
  return Response.json({ error: "Internal server error" }, { status: 502 });
309
338
  }
310
339
  }
@@ -352,6 +352,61 @@ describe("resolvePublicBaseWssUrl", () => {
352
352
  );
353
353
  });
354
354
 
355
+ test("uses assistant ID from validated platform callback URL with Velay", () => {
356
+ const config = {
357
+ ...baseConfig,
358
+ velayBaseUrl: "https://velay-staging.vellum.ai",
359
+ };
360
+ const result = resolvePublicBaseWssUrl(
361
+ config,
362
+ undefined,
363
+ undefined,
364
+ "https://staging-platform.vellum.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e/webhooks/twilio/voice/?callSessionId=37b47ade-2eaf-469a-bede-6f2454875e6e",
365
+ );
366
+ expect(result).toBe(
367
+ "wss://velay-staging.vellum.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
368
+ );
369
+ });
370
+
371
+ test("prefers validated platform callback URL over stale configFile publicBaseUrl", () => {
372
+ const config = {
373
+ ...baseConfig,
374
+ velayBaseUrl: "https://velay-staging.vellum.ai",
375
+ };
376
+ const mockConfigFile = {
377
+ getString: (section: string, key: string) =>
378
+ section === "ingress" && key === "publicBaseUrl"
379
+ ? "https://stale-tunnel.example.test"
380
+ : undefined,
381
+ } as Parameters<typeof resolvePublicBaseWssUrl>[1];
382
+ const result = resolvePublicBaseWssUrl(
383
+ config,
384
+ mockConfigFile,
385
+ undefined,
386
+ "https://staging-platform.vellum.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e/webhooks/twilio/voice?callSessionId=sess-1",
387
+ );
388
+ expect(result).toBe(
389
+ "wss://velay-staging.vellum.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
390
+ );
391
+ });
392
+
393
+ test("does not use a platform callback URL as the websocket base", () => {
394
+ const config = {
395
+ ...baseConfig,
396
+ velayBaseUrl: "https://velay-staging.vellum.ai",
397
+ };
398
+ const mockConfigFile = {
399
+ getString: (section: string, key: string) =>
400
+ section === "ingress" && key === "publicBaseUrl"
401
+ ? "https://staging-platform.vellum.ai/v1/gateway/callbacks/019e2d0d-f355-744c-a12c-d7e7dcefcf1e"
402
+ : undefined,
403
+ } as Parameters<typeof resolvePublicBaseWssUrl>[1];
404
+ const result = resolvePublicBaseWssUrl(config, mockConfigFile, undefined);
405
+ expect(result).toBe(
406
+ "wss://velay-staging.vellum.ai/019e2d0d-f355-744c-a12c-d7e7dcefcf1e",
407
+ );
408
+ });
409
+
355
410
  test("strips trailing slash from velayBaseUrl before joining assistant ID", () => {
356
411
  const config = {
357
412
  ...baseConfig,
@@ -151,7 +151,10 @@ export function createTwilioVoiceWebhookHandler(
151
151
  // The display name is intentionally included: the caller registered
152
152
  // this number themselves, so disclosing their own name is expected.
153
153
  const unverifiedStatuses = new Set(["unverified", "pending"]);
154
- if (callerRecord && unverifiedStatuses.has(callerRecord.channel.status)) {
154
+ if (
155
+ callerRecord &&
156
+ unverifiedStatuses.has(callerRecord.channel.status)
157
+ ) {
155
158
  const isGuardian = callerRecord.contact.role === "guardian";
156
159
  log.info(
157
160
  {
@@ -197,7 +200,12 @@ export function createTwilioVoiceWebhookHandler(
197
200
  config,
198
201
  params,
199
202
  req.url,
200
- resolvePublicBaseWssUrl(config, caches?.configFile, platformAssistantId),
203
+ resolvePublicBaseWssUrl(
204
+ config,
205
+ caches?.configFile,
206
+ platformAssistantId,
207
+ validation.validatedCandidateUrl,
208
+ ),
201
209
  );
202
210
  return new Response(runtimeResponse.body, {
203
211
  status: runtimeResponse.status,
package/src/index.ts CHANGED
@@ -118,6 +118,10 @@ import { createWorkspaceCommitProxyHandler } from "./http/routes/workspace-commi
118
118
  import { createBrainGraphProxyHandler } from "./http/routes/brain-graph-proxy.js";
119
119
  import { createLogExportHandler } from "./http/routes/log-export.js";
120
120
  import { createLogTailHandler } from "./http/routes/log-tail.js";
121
+ import {
122
+ createAgentCardHandler,
123
+ A2A_AGENT_CARD_PATH,
124
+ } from "./http/routes/a2a-routes.js";
121
125
  import {
122
126
  createTrustRulesListHandler,
123
127
  createTrustRulesCreateHandler,
@@ -467,6 +471,8 @@ async function main() {
467
471
  const handleTrustRulesReset = createTrustRulesResetHandler();
468
472
  const handleTrustRulesSuggest = createTrustRulesSuggestHandler();
469
473
 
474
+ const handleAgentCard = createAgentCardHandler(configFileCache);
475
+
470
476
  const audioProxy = createAudioProxyHandler(config);
471
477
 
472
478
  const backupDeps = {
@@ -505,6 +511,13 @@ async function main() {
505
511
  // Auth middleware is applied declaratively per route — no manual
506
512
  // requireEdgeAuth/wrapWithAuthFailureTracking calls needed.
507
513
  const routes: RouteDefinition[] = [
514
+ // ── A2A agent card discovery (read-only, unauthenticated per spec) ──
515
+ {
516
+ path: A2A_AGENT_CARD_PATH,
517
+ method: "GET",
518
+ handler: (req) => handleAgentCard(req),
519
+ },
520
+
508
521
  // ── Webhooks (unauthenticated, validated by provider-specific mechanisms) ──
509
522
  {
510
523
  path: "/webhooks/telegram",
@@ -2104,6 +2117,7 @@ async function main() {
2104
2117
  // Fires on initial credential load and whenever vellum credentials change
2105
2118
  // (key rotation, late provisioning).
2106
2119
  if (changed.has("vellum")) {
2120
+ velayTunnelClient?.refreshCredentials("vellum credentials changed");
2107
2121
  registerEmailCallbackRoute({
2108
2122
  credentials: credentialCache,
2109
2123
  configFile: configFileCache,
@@ -1035,6 +1035,30 @@ describe("assistant subcommand classification", () => {
1035
1035
  expect(result.riskLevel).toBe("low");
1036
1036
  });
1037
1037
 
1038
+ test("assistant schedules enable → medium", async () => {
1039
+ const result = await classifier.classify({
1040
+ command: "assistant schedules enable schedule-1",
1041
+ toolName: "bash",
1042
+ });
1043
+ expect(result.riskLevel).toBe("medium");
1044
+ });
1045
+
1046
+ test("assistant schedules disable → medium", async () => {
1047
+ const result = await classifier.classify({
1048
+ command: "assistant schedules disable schedule-1",
1049
+ toolName: "bash",
1050
+ });
1051
+ expect(result.riskLevel).toBe("medium");
1052
+ });
1053
+
1054
+ test("assistant schedules cancel → medium", async () => {
1055
+ const result = await classifier.classify({
1056
+ command: "assistant schedules cancel schedule-1",
1057
+ toolName: "bash",
1058
+ });
1059
+ expect(result.riskLevel).toBe("medium");
1060
+ });
1061
+
1038
1062
  test("assistant schedules execute → medium", async () => {
1039
1063
  const result = await classifier.classify({
1040
1064
  command: "assistant schedules execute schedule-1",
@@ -192,6 +192,11 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
192
192
  "schedules",
193
193
  "schedules list",
194
194
  "schedules runs",
195
+ "schedules create",
196
+ "schedules enable",
197
+ "schedules disable",
198
+ "schedules cancel",
199
+ "schedules delete",
195
200
  "schedules execute",
196
201
  "sequence",
197
202
  "sequence list",
@@ -261,6 +266,7 @@ const ASSISTANT_SUPPORTED_COMMAND_PATHS = [
261
266
  "plugins",
262
267
  "plugins install",
263
268
  "plugins list",
269
+ "plugins search",
264
270
  "plugins uninstall",
265
271
  ] as const;
266
272
 
@@ -499,6 +505,33 @@ const riskOverrides: AssistantRiskOverride[] = [
499
505
  { path: "platform connect", risk: "low" },
500
506
  { path: "platform disconnect", risk: "medium" },
501
507
  { path: "platform callback-routes register", risk: "low" },
508
+ {
509
+ path: "schedules create",
510
+ risk: "medium",
511
+ reason:
512
+ "Creates a new recurring schedule that fires assistant-side messages",
513
+ },
514
+ {
515
+ path: "schedules enable",
516
+ risk: "medium",
517
+ reason: "Enables a schedule and mutates assistant schedule state",
518
+ },
519
+ {
520
+ path: "schedules disable",
521
+ risk: "medium",
522
+ reason: "Disables a schedule and mutates assistant schedule state",
523
+ },
524
+ {
525
+ path: "schedules cancel",
526
+ risk: "medium",
527
+ reason: "Cancels a pending schedule and mutates assistant schedule state",
528
+ },
529
+ {
530
+ path: "schedules delete",
531
+ risk: "medium",
532
+ reason:
533
+ "Permanently removes a schedule and its run history from assistant state",
534
+ },
502
535
  {
503
536
  path: "schedules execute",
504
537
  risk: "medium",
@@ -599,6 +599,11 @@ describe("command-registry", () => {
599
599
  expect(getAssistantPath("inference session list").baseRisk).toBe("low");
600
600
  expect(getAssistantPath("schedules list").baseRisk).toBe("low");
601
601
  expect(getAssistantPath("schedules runs").baseRisk).toBe("low");
602
+ expect(getAssistantPath("schedules create").baseRisk).toBe("medium");
603
+ expect(getAssistantPath("schedules enable").baseRisk).toBe("medium");
604
+ expect(getAssistantPath("schedules disable").baseRisk).toBe("medium");
605
+ expect(getAssistantPath("schedules cancel").baseRisk).toBe("medium");
606
+ expect(getAssistantPath("schedules delete").baseRisk).toBe("medium");
602
607
  expect(getAssistantPath("schedules execute").baseRisk).toBe("medium");
603
608
  });
604
609
  });
@@ -503,15 +503,58 @@ export type TwilioForwardResponse = {
503
503
  * to Twilio, keeping the signing key out of the daemon for this flow.
504
504
  */
505
505
  const TWILIO_RELAY_TOKEN_PLACEHOLDER = "__VELLUM_RELAY_TOKEN__";
506
+ const PLATFORM_CALLBACK_MARKER = "/gateway/callbacks/";
507
+
508
+ function toWebSocketBaseUrl(url: string): string {
509
+ return url.replace(/^http(s?)/, "ws$1");
510
+ }
511
+
512
+ function extractPlatformCallbackAssistantId(
513
+ value: unknown,
514
+ ): string | undefined {
515
+ const normalized = normalizePublicBaseUrl(value);
516
+ if (!normalized) return undefined;
517
+
518
+ let pathname: string;
519
+ try {
520
+ pathname = new URL(normalized).pathname;
521
+ } catch {
522
+ return undefined;
523
+ }
524
+
525
+ const markerIndex = pathname.indexOf(PLATFORM_CALLBACK_MARKER);
526
+ if (markerIndex === -1) return undefined;
527
+
528
+ const assistantIdStart = markerIndex + PLATFORM_CALLBACK_MARKER.length;
529
+ const assistantId = pathname.slice(assistantIdStart).split("/")[0]?.trim();
530
+ return assistantId || undefined;
531
+ }
532
+
533
+ function resolveVelayBaseWssUrl(
534
+ config: GatewayConfig,
535
+ platformAssistantId?: string,
536
+ ): string | undefined {
537
+ if (!config.velayBaseUrl || !platformAssistantId) return undefined;
538
+
539
+ const withPath =
540
+ config.velayBaseUrl.replace(/\/+$/, "") + "/" + platformAssistantId;
541
+ const normalizedVelayUrl = normalizePublicBaseUrl(withPath);
542
+ return normalizedVelayUrl
543
+ ? toWebSocketBaseUrl(normalizedVelayUrl)
544
+ : undefined;
545
+ }
506
546
 
507
547
  /**
508
548
  * Resolve the public base URL as a WebSocket URL (`wss://…`).
509
549
  *
510
550
  * Sources (in priority order):
511
- * 1. `ingress.publicBaseUrl` from the config file written by Velay
512
- * after tunnel registration, or set manually for self-hosted.
513
- * 2. `VELAY_BASE_URL` + platform assistant ID fallback for platform
514
- * sidecars before the config cache has observed the registered URL.
551
+ * 1. `VELAY_BASE_URL` + the assistant ID from the signed platform callback
552
+ * URL.
553
+ * 2. `ingress.publicBaseUrl` from the config file when it is a real public
554
+ * gateway base URL written by Velay after tunnel registration, or set
555
+ * manually for self-hosted.
556
+ * 3. `VELAY_BASE_URL` + the platform assistant ID from a configured callback
557
+ * URL or credential cache.
515
558
  *
516
559
  * Returns `undefined` when no source provides a value — the placeholder
517
560
  * will remain in TwiML and Twilio will fail to connect, which is the
@@ -521,20 +564,29 @@ export function resolvePublicBaseWssUrl(
521
564
  config: GatewayConfig,
522
565
  configFile?: ConfigFileCache,
523
566
  platformAssistantId?: string,
567
+ validatedPublicUrl?: string,
524
568
  ): string | undefined {
525
569
  const raw = configFile?.getString("ingress", "publicBaseUrl");
526
570
  const normalized = normalizePublicBaseUrl(raw);
527
- if (normalized) return normalized.replace(/^http(s?)/, "ws$1");
528
-
529
- if (config.velayBaseUrl && platformAssistantId) {
530
- const withPath =
531
- config.velayBaseUrl.replace(/\/+$/, "") + "/" + platformAssistantId;
532
- const normalizedVelayUrl = normalizePublicBaseUrl(withPath);
533
- if (normalizedVelayUrl) {
534
- return normalizedVelayUrl.replace(/^http(s?)/, "ws$1");
535
- }
571
+
572
+ const validatedCallbackAssistantId =
573
+ extractPlatformCallbackAssistantId(validatedPublicUrl);
574
+ const validatedCallbackWssUrl = resolveVelayBaseWssUrl(
575
+ config,
576
+ validatedCallbackAssistantId,
577
+ );
578
+ if (validatedCallbackWssUrl) return validatedCallbackWssUrl;
579
+
580
+ const configuredCallbackAssistantId = extractPlatformCallbackAssistantId(raw);
581
+
582
+ if (normalized && !configuredCallbackAssistantId) {
583
+ return toWebSocketBaseUrl(normalized);
536
584
  }
537
- return undefined;
585
+
586
+ return resolveVelayBaseWssUrl(
587
+ config,
588
+ configuredCallbackAssistantId ?? platformAssistantId,
589
+ );
538
590
  }
539
591
 
540
592
  /**
@@ -182,6 +182,8 @@ export type TwilioValidationSuccess = {
182
182
  rawBody: string;
183
183
  /** Parsed key-value pairs from the form body. */
184
184
  params: Record<string, string>;
185
+ /** Candidate URL that matched X-Twilio-Signature. */
186
+ validatedCandidateUrl?: string;
185
187
  };
186
188
 
187
189
  /** Options bag for optional cache injection into Twilio webhook validation. */
@@ -411,5 +413,9 @@ export async function validateTwilioWebhookRequest(
411
413
  log.info(successLogContext, "Twilio webhook signature validated");
412
414
  }
413
415
 
414
- return { rawBody, params };
416
+ return {
417
+ rawBody,
418
+ params,
419
+ validatedCandidateUrl: validatingCandidate.url,
420
+ };
415
421
  }
package/src/types.ts CHANGED
@@ -6,4 +6,5 @@ export type {
6
6
  WhatsAppInboundEvent,
7
7
  SlackInboundEvent,
8
8
  EmailInboundEvent,
9
+ A2aInboundEvent,
9
10
  } from "./channels/inbound-event.js";
@@ -44,6 +44,7 @@ const WS_CLOSED = WebSocket.CLOSED;
44
44
  function makeCredentials(values: Record<string, string | undefined>) {
45
45
  return {
46
46
  get: async (key: string) => values[key],
47
+ onInvalidate: () => () => {},
47
48
  } as unknown as CredentialCache;
48
49
  }
49
50
 
@@ -526,6 +527,105 @@ describe("VelayTunnelClient", () => {
526
527
  expect(reconnectDelays).toEqual([10, 20, 10]);
527
528
  });
528
529
 
530
+ test("refreshCredentials cancels stale credential backoff and reconnects immediately", async () => {
531
+ const sockets: FakeWebSocket[] = [];
532
+ const reconnectDelays: number[] = [];
533
+ const reconnectCallbacks: Array<() => void> = [];
534
+ const credentialValues: Record<string, string | undefined> = {};
535
+ const credentials = makeCredentials(credentialValues);
536
+ const client = new VelayTunnelClient({
537
+ velayBaseUrl: "http://velay.example.test",
538
+ gatewayLoopbackBaseUrl: "http://127.0.0.1:7830",
539
+ credentials,
540
+ configFile: makeConfigFileCache({ count: 0 }),
541
+ webSocketConstructor: makeFakeWebSocketConstructor(sockets),
542
+ reconnect: { baseDelayMs: 10, maxDelayMs: 80, jitterRatio: 0 },
543
+ heartbeat: { intervalMs: 0, readTimeoutMs: 0 },
544
+ timerApi: makeManualTimerApi(reconnectDelays, reconnectCallbacks),
545
+ });
546
+
547
+ client.start();
548
+ await flushPromises();
549
+
550
+ expect(sockets).toHaveLength(0);
551
+ expect(reconnectDelays).toEqual([10]);
552
+
553
+ credentialValues[credentialKey("vellum", "assistant_api_key")] =
554
+ "api-key-123";
555
+ credentialValues[credentialKey("vellum", "platform_assistant_id")] =
556
+ "asst-123";
557
+
558
+ client.refreshCredentials("vellum credentials changed");
559
+ await flushPromises();
560
+
561
+ expect(sockets).toHaveLength(1);
562
+ expect(sockets[0].options).toEqual({
563
+ protocols: [VELAY_TUNNEL_SUBPROTOCOL],
564
+ headers: {
565
+ Authorization: "Api-Key api-key-123",
566
+ "X-Vellum-Velay-Allowed-Paths": VELAY_ALLOWED_PATHS_HEADER_VALUE,
567
+ },
568
+ });
569
+ expect(reconnectDelays).toEqual([10]);
570
+ });
571
+
572
+ test("refreshCredentials retries immediately when credentials change during active connect", async () => {
573
+ const sockets: FakeWebSocket[] = [];
574
+ const reconnectDelays: number[] = [];
575
+ const apiKeyCredential = credentialKey("vellum", "assistant_api_key");
576
+ const assistantIdCredential = credentialKey(
577
+ "vellum",
578
+ "platform_assistant_id",
579
+ );
580
+ let resolveFirstApiKeyRead: (value: string | undefined) => void = () => {};
581
+ const firstApiKeyRead = new Promise<string | undefined>((resolve) => {
582
+ resolveFirstApiKeyRead = resolve;
583
+ });
584
+ let useFreshCredentials = false;
585
+ const credentials = {
586
+ get: async (key: string) => {
587
+ if (key === apiKeyCredential) {
588
+ return useFreshCredentials ? "api-key-123" : firstApiKeyRead;
589
+ }
590
+ if (key === assistantIdCredential) return "asst-123";
591
+ return undefined;
592
+ },
593
+ onInvalidate: () => () => {},
594
+ } as unknown as CredentialCache;
595
+ const client = new VelayTunnelClient({
596
+ velayBaseUrl: "http://velay.example.test",
597
+ gatewayLoopbackBaseUrl: "http://127.0.0.1:7830",
598
+ credentials,
599
+ configFile: makeConfigFileCache({ count: 0 }),
600
+ webSocketConstructor: makeFakeWebSocketConstructor(sockets),
601
+ reconnect: { baseDelayMs: 10, maxDelayMs: 80, jitterRatio: 0 },
602
+ heartbeat: { intervalMs: 0, readTimeoutMs: 0 },
603
+ timerApi: makeTimerApi(reconnectDelays),
604
+ });
605
+
606
+ client.start();
607
+ await flushPromises();
608
+
609
+ expect(sockets).toHaveLength(0);
610
+ expect(reconnectDelays).toEqual([]);
611
+
612
+ client.refreshCredentials("vellum credentials changed");
613
+ useFreshCredentials = true;
614
+ resolveFirstApiKeyRead(undefined);
615
+ await flushPromises();
616
+
617
+ expect(sockets).toHaveLength(1);
618
+ expect(sockets[0].options).toEqual({
619
+ protocols: [VELAY_TUNNEL_SUBPROTOCOL],
620
+ headers: {
621
+ Authorization: "Api-Key api-key-123",
622
+ "X-Vellum-Velay-Allowed-Paths": VELAY_ALLOWED_PATHS_HEADER_VALUE,
623
+ },
624
+ });
625
+ expect(reconnectDelays).toEqual([]);
626
+ await client.stop();
627
+ });
628
+
529
629
  test("writes only ingress.publicBaseUrl when publishing a Velay URL", async () => {
530
630
  const sockets: FakeWebSocket[] = [];
531
631
  writeConfig({