@vellumai/assistant 0.11.9-dev.202609080319.eb4f1b0 → 0.11.9-dev.202609080628.cb7bdcf

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.
@@ -401,6 +401,78 @@ export type ContactsIdentitySnapshotIpcResponse = z.infer<
401
401
  typeof ContactsIdentitySnapshotIpcResponseSchema
402
402
  >;
403
403
 
404
+ // ── webhook ingress routes ───────────────────────────────────────────────────
405
+ // The gateway owns the registry of subpaths this assistant answers from
406
+ // outside; the daemon claims, drops, and inspects them over IPC. Both sides
407
+ // read these schemas so a change to the stored row has to travel through the
408
+ // contract.
409
+
410
+ /**
411
+ * Feature flag gating the whole webhook ingress registry. The gateway reads it
412
+ * to decide whether to accept a claim and how to advertise its allowed paths,
413
+ * and the daemon reads it to decide whether to claim at all, so both sides have
414
+ * to name the same key.
415
+ */
416
+ export const WebhookIngressRouteSchema = z.object({
417
+ path: z.string(),
418
+ type: z.string(),
419
+ source: z.string().nullable(),
420
+ match: z.literal("exact"),
421
+ createdAt: z.number(),
422
+ lastRegisteredAt: z.number(),
423
+ });
424
+
425
+ export type WebhookIngressRoute = z.infer<typeof WebhookIngressRouteSchema>;
426
+
427
+ export const RegisterWebhookRouteIpcParamsSchema = z.object({
428
+ /** Exact subpath, leading slash included, under `/webhooks/`. */
429
+ path: z.string().min(1),
430
+ type: z.string().min(1),
431
+ source: z.string().nullish(),
432
+ });
433
+
434
+ export type RegisterWebhookRouteIpcParams = z.infer<
435
+ typeof RegisterWebhookRouteIpcParamsSchema
436
+ >;
437
+
438
+ /**
439
+ * A refusal is a normal result rather than an error: the daemon reads
440
+ * `disabled` as "this assistant is not serving its own webhooks" and falls
441
+ * back to the platform's callback routes.
442
+ */
443
+ export const RegisterWebhookRouteIpcResponseSchema = z.union([
444
+ z.object({ disabled: z.literal(true) }),
445
+ z.object({ disabled: z.literal(false), route: WebhookIngressRouteSchema }),
446
+ ]);
447
+
448
+ export type RegisterWebhookRouteIpcResponse = z.infer<
449
+ typeof RegisterWebhookRouteIpcResponseSchema
450
+ >;
451
+
452
+ export const UnregisterWebhookRouteIpcParamsSchema = z.object({
453
+ path: z.string().min(1),
454
+ });
455
+
456
+ export type UnregisterWebhookRouteIpcParams = z.infer<
457
+ typeof UnregisterWebhookRouteIpcParamsSchema
458
+ >;
459
+
460
+ export const UnregisterWebhookRouteIpcResponseSchema = z.object({
461
+ removed: z.boolean(),
462
+ });
463
+
464
+ export type UnregisterWebhookRouteIpcResponse = z.infer<
465
+ typeof UnregisterWebhookRouteIpcResponseSchema
466
+ >;
467
+
468
+ export const ListWebhookRoutesIpcResponseSchema = z.object({
469
+ routes: z.array(WebhookIngressRouteSchema),
470
+ });
471
+
472
+ export type ListWebhookRoutesIpcResponse = z.infer<
473
+ typeof ListWebhookRoutesIpcResponseSchema
474
+ >;
475
+
404
476
  // ── classify_risk ────────────────────────────────────────────────────────────
405
477
  // Risk classification is gateway-owned; the assistant sends one request per
406
478
  // tool invocation and reads the whole answer back. The gateway validates the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.11.9-dev.202609080319.eb4f1b0",
3
+ "version": "0.11.9-dev.202609080628.cb7bdcf",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -1,15 +1,35 @@
1
1
  import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
2
2
 
3
+ import type { RegisterWebhookRouteIpcParams } from "@vellumai/gateway-client";
4
+
3
5
  import { setIngressPublicBaseUrl } from "../config/env.js";
6
+ import type { IpcRegisterWebhookRouteResult } from "../ipc/gateway-client.js";
4
7
  import { credentialKey } from "../security/credential-key.js";
5
8
 
6
9
  let mockIsPlatform = true;
10
+ let mockVelayWebhooksEnabled = false;
7
11
  let mockPlatformBaseUrl = "";
8
12
  let mockPlatformAssistantId = "";
9
13
  let mockSecureKeys: Record<string, string> = {};
10
14
  let mockConfig: { ingress?: { publicBaseUrl?: string; enabled?: boolean } } =
11
15
  {};
12
16
 
17
+ const REGISTERED_ROUTE = {
18
+ path: "/webhooks/twilio/voice",
19
+ type: "twilio_voice",
20
+ source: null,
21
+ match: "exact" as const,
22
+ createdAt: 1,
23
+ lastRegisteredAt: 1,
24
+ };
25
+
26
+ let mockRegisterWebhookRouteResult: IpcRegisterWebhookRouteResult = {
27
+ ok: true,
28
+ disabled: false,
29
+ route: REGISTERED_ROUTE,
30
+ };
31
+ let ipcRegisterCalls: RegisterWebhookRouteIpcParams[] = [];
32
+
13
33
  // Bun shares mocked modules across test files in a combined run, so each mock
14
34
  // spreads the real module and overrides only what this file drives. Replacing
15
35
  // a module wholesale drops the exports peer tests import from it and breaks
@@ -20,6 +40,12 @@ mock.module("../config/env-registry.js", () => ({
20
40
  getIsPlatform: () => mockIsPlatform,
21
41
  }));
22
42
 
43
+ const actualVelayGate = await import("../inbound/velay-webhooks-gate.js");
44
+ mock.module("../inbound/velay-webhooks-gate.js", () => ({
45
+ ...actualVelayGate,
46
+ isVelayWebhooksEnabled: () => mockVelayWebhooksEnabled,
47
+ }));
48
+
23
49
  const actualEnv = await import("../config/env.js");
24
50
  mock.module("../config/env.js", () => ({
25
51
  ...actualEnv,
@@ -27,6 +53,15 @@ mock.module("../config/env.js", () => ({
27
53
  getPlatformAssistantId: () => mockPlatformAssistantId,
28
54
  }));
29
55
 
56
+ const actualGatewayClient = await import("../ipc/gateway-client.js");
57
+ mock.module("../ipc/gateway-client.js", () => ({
58
+ ...actualGatewayClient,
59
+ ipcRegisterWebhookRoute: async (input: RegisterWebhookRouteIpcParams) => {
60
+ ipcRegisterCalls.push(input);
61
+ return mockRegisterWebhookRouteResult;
62
+ },
63
+ }));
64
+
30
65
  const actualSecureKeys = await import("../security/secure-keys.js");
31
66
  mock.module("../security/secure-keys.js", () => ({
32
67
  ...actualSecureKeys,
@@ -207,9 +242,7 @@ describe("platform callback registration", () => {
207
242
 
208
243
  await expect(
209
244
  registerCallbackRoute("webhooks/telegram", "telegram"),
210
- ).resolves.toBe(
211
- "https://my-assistant.example.com/v1/gateway/callbacks/x/",
212
- );
245
+ ).resolves.toBe("https://my-assistant.example.com/v1/gateway/callbacks/x/");
213
246
  });
214
247
 
215
248
  test("self-hosted registerCallbackRoute sends detected module-level callback_base_url", async () => {
@@ -373,6 +406,7 @@ describe("resolveCallbackUrl resolution order", () => {
373
406
 
374
407
  beforeEach(() => {
375
408
  mockIsPlatform = false;
409
+ mockVelayWebhooksEnabled = false;
376
410
  mockPlatformBaseUrl = "";
377
411
  mockPlatformAssistantId = "";
378
412
  mockSecureKeys = {};
@@ -380,6 +414,12 @@ describe("resolveCallbackUrl resolution order", () => {
380
414
  setIngressPublicBaseUrl(undefined);
381
415
  delete process.env.ASSISTANT_API_KEY;
382
416
  registerCalls = 0;
417
+ ipcRegisterCalls = [];
418
+ mockRegisterWebhookRouteResult = {
419
+ ok: true,
420
+ disabled: false,
421
+ route: REGISTERED_ROUTE,
422
+ };
383
423
  globalThis.fetch = mock(async () => {
384
424
  registerCalls++;
385
425
  return new Response(
@@ -408,6 +448,124 @@ describe("resolveCallbackUrl resolution order", () => {
408
448
  resolveCallbackUrl(noIngress, "webhooks/twilio/voice", "twilio_voice"),
409
449
  ).resolves.toBe(PLATFORM_URL);
410
450
  expect(registerCalls).toBe(1);
451
+ expect(ipcRegisterCalls).toEqual([]);
452
+ });
453
+
454
+ test("velay-webhooks on: a pod claims the subpath and uses the published URL", async () => {
455
+ mockIsPlatform = true;
456
+ mockVelayWebhooksEnabled = true;
457
+ seedPlatformCredentials();
458
+
459
+ await expect(
460
+ resolveCallbackUrl(
461
+ () => "https://velay.example.com/assistant-1/webhooks/twilio/voice",
462
+ "webhooks/twilio/voice",
463
+ "twilio_voice",
464
+ { callSessionId: "conv-xyz" },
465
+ "+15555550142",
466
+ ),
467
+ ).resolves.toBe(
468
+ "https://velay.example.com/assistant-1/webhooks/twilio/voice",
469
+ );
470
+ expect(registerCalls).toBe(0);
471
+ expect(ipcRegisterCalls).toEqual([
472
+ {
473
+ path: "/webhooks/twilio/voice",
474
+ type: "twilio_voice",
475
+ source: "+15555550142",
476
+ },
477
+ ]);
478
+ });
479
+
480
+ test("velay-webhooks on: a refused claim falls back to the platform", async () => {
481
+ mockIsPlatform = true;
482
+ mockVelayWebhooksEnabled = true;
483
+ mockRegisterWebhookRouteResult = { ok: true, disabled: true };
484
+ seedPlatformCredentials();
485
+
486
+ await expect(
487
+ resolveCallbackUrl(
488
+ () => "https://velay.example.com/assistant-1/webhooks/twilio/voice",
489
+ "webhooks/twilio/voice",
490
+ "twilio_voice",
491
+ ),
492
+ ).resolves.toBe(PLATFORM_URL);
493
+ expect(registerCalls).toBe(1);
494
+ expect(ipcRegisterCalls).toHaveLength(1);
495
+ });
496
+
497
+ test("velay-webhooks on: an unreachable gateway falls back to the platform", async () => {
498
+ mockIsPlatform = true;
499
+ mockVelayWebhooksEnabled = true;
500
+ mockRegisterWebhookRouteResult = { ok: false, reason: "no_response" };
501
+ seedPlatformCredentials();
502
+
503
+ await expect(
504
+ resolveCallbackUrl(
505
+ () => "https://velay.example.com/assistant-1/webhooks/twilio/voice",
506
+ "webhooks/twilio/voice",
507
+ "twilio_voice",
508
+ ),
509
+ ).resolves.toBe(PLATFORM_URL);
510
+ expect(registerCalls).toBe(1);
511
+ expect(ipcRegisterCalls).toHaveLength(1);
512
+ });
513
+
514
+ test("velay-webhooks on: a pod with no published URL still registers with the platform", async () => {
515
+ mockIsPlatform = true;
516
+ mockVelayWebhooksEnabled = true;
517
+ seedPlatformCredentials();
518
+
519
+ await expect(
520
+ resolveCallbackUrl(noIngress, "webhooks/twilio/voice", "twilio_voice"),
521
+ ).resolves.toBe(PLATFORM_URL);
522
+ expect(registerCalls).toBe(1);
523
+ expect(ipcRegisterCalls).toEqual([]);
524
+ });
525
+
526
+ test("velay-webhooks on: a pod with ingress disabled falls back instead of throwing", async () => {
527
+ mockIsPlatform = true;
528
+ mockVelayWebhooksEnabled = true;
529
+ seedPlatformCredentials();
530
+
531
+ await expect(
532
+ resolveCallbackUrl(
533
+ ingressDisabled,
534
+ "webhooks/twilio/voice",
535
+ "twilio_voice",
536
+ ),
537
+ ).resolves.toBe(PLATFORM_URL);
538
+ expect(registerCalls).toBe(1);
539
+ expect(ipcRegisterCalls).toEqual([]);
540
+ });
541
+
542
+ test("velay-webhooks on: self-hosted opt-out still surfaces", async () => {
543
+ mockVelayWebhooksEnabled = true;
544
+ seedPlatformCredentials();
545
+
546
+ await expect(
547
+ resolveCallbackUrl(
548
+ ingressDisabled,
549
+ "webhooks/twilio/voice",
550
+ "twilio_voice",
551
+ ),
552
+ ).rejects.toThrow("Public ingress is disabled");
553
+ expect(registerCalls).toBe(0);
554
+ expect(ipcRegisterCalls).toEqual([]);
555
+ });
556
+
557
+ test("velay-webhooks on: a self-hosted ingress URL is not claimed on the gateway", async () => {
558
+ mockVelayWebhooksEnabled = true;
559
+ seedPlatformCredentials();
560
+
561
+ await expect(
562
+ resolveCallbackUrl(
563
+ () => "https://tunnel.example.com/webhooks/twilio/voice",
564
+ "webhooks/twilio/voice",
565
+ "twilio_voice",
566
+ ),
567
+ ).resolves.toBe("https://tunnel.example.com/webhooks/twilio/voice");
568
+ expect(ipcRegisterCalls).toEqual([]);
411
569
  });
412
570
 
413
571
  test("a configured ingress wins over platform connectivity", async () => {
@@ -421,6 +579,7 @@ describe("resolveCallbackUrl resolution order", () => {
421
579
  ),
422
580
  ).resolves.toBe("https://tunnel.example.com/webhooks/twilio/voice");
423
581
  expect(registerCalls).toBe(0);
582
+ expect(ipcRegisterCalls).toEqual([]);
424
583
  });
425
584
 
426
585
  test("a platform-connected assistant with no ingress registers with the platform", async () => {
@@ -8,13 +8,29 @@
8
8
  * registration type it derives, and what it refuses.
9
9
  */
10
10
 
11
- import { describe, expect, spyOn, test } from "bun:test";
11
+ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
12
12
 
13
- import * as registration from "../inbound/platform-callback-registration.js";
14
- import { resolveWebhookUrl } from "../plugin-api/webhook-url.js";
15
- import { runInPluginContext } from "../plugins/plugin-execution-context.js";
13
+ let mockIsPlatform = false;
14
+
15
+ // Bun shares mocked modules across test files in a combined run, so the mock
16
+ // spreads the real module and overrides only what this file drives.
17
+ const actualEnvRegistry = await import("../config/env-registry.js");
18
+ mock.module("../config/env-registry.js", () => ({
19
+ ...actualEnvRegistry,
20
+ getIsPlatform: () => mockIsPlatform,
21
+ }));
22
+
23
+ const loader = await import("../config/loader.js");
24
+ const registration =
25
+ await import("../inbound/platform-callback-registration.js");
26
+ const { resolveWebhookUrl } = await import("../plugin-api/webhook-url.js");
27
+ const { runInPluginContext } =
28
+ await import("../plugins/plugin-execution-context.js");
16
29
 
17
30
  describe("resolveWebhookUrl", () => {
31
+ afterEach(() => {
32
+ mockIsPlatform = false;
33
+ });
18
34
  test("composes the plugin's namespaced path and delegates the tier choice", async () => {
19
35
  const spy = spyOn(registration, "resolveCallbackUrl").mockResolvedValue(
20
36
  "https://callbacks.vellum.ai/abc/webhooks/plugins/imessage/events-photon",
@@ -143,6 +159,52 @@ describe("resolveWebhookUrl", () => {
143
159
  spy.mockRestore();
144
160
  });
145
161
 
162
+ test("hands out an ingress URL under exactly the path it claimed", async () => {
163
+ // The Velay tunnel forwards the request path verbatim and the gateway
164
+ // admits it only when it matches a claimed path byte for byte. A trailing
165
+ // slash on this URL would be rejected before the gateway ever saw it.
166
+ mockIsPlatform = true;
167
+ const configSpy = spyOn(loader, "getConfig").mockReturnValue({
168
+ ingress: { publicBaseUrl: "https://velay.vellum.ai/assistant-abc" },
169
+ } as ReturnType<typeof loader.getConfig>);
170
+ const spy = spyOn(registration, "resolveCallbackUrl").mockImplementation(
171
+ async (directUrl) => directUrl(),
172
+ );
173
+
174
+ const url = await resolveWebhookUrl({
175
+ plugin: "imessage",
176
+ path: "events-photon",
177
+ });
178
+
179
+ const claimedPath = `/${spy.mock.calls[0]![1]}`;
180
+ expect(claimedPath).toBe("/webhooks/plugins/imessage/events-photon");
181
+ expect(new URL(url).pathname).toBe(`/assistant-abc${claimedPath}`);
182
+ spy.mockRestore();
183
+ configSpy.mockRestore();
184
+ });
185
+
186
+ test("keeps the trailing slash on a self-hosted ingress URL", async () => {
187
+ // Off a pod the direct URL never rides the tunnel, so the spelling stays
188
+ // what it always was.
189
+ const configSpy = spyOn(loader, "getConfig").mockReturnValue({
190
+ ingress: { publicBaseUrl: "https://assistant.example.test" },
191
+ } as ReturnType<typeof loader.getConfig>);
192
+ const spy = spyOn(registration, "resolveCallbackUrl").mockImplementation(
193
+ async (directUrl) => directUrl(),
194
+ );
195
+
196
+ const url = await resolveWebhookUrl({
197
+ plugin: "imessage",
198
+ path: "events-photon",
199
+ });
200
+
201
+ expect(url).toBe(
202
+ "https://assistant.example.test/webhooks/plugins/imessage/events-photon/",
203
+ );
204
+ spy.mockRestore();
205
+ configSpy.mockRestore();
206
+ });
207
+
146
208
  test("leaves a URL carrying a query string alone", async () => {
147
209
  // Appending there would cut into the query rather than the path.
148
210
  const spy = spyOn(registration, "resolveCallbackUrl").mockResolvedValue(
@@ -95,6 +95,18 @@ mock.module("../../../ipc/gateway-client.js", () => ({
95
95
  ipcGetFeatureFlags: async () => ({}),
96
96
  ipcGetVelayStatus: async () => null,
97
97
  ipcClassifyRisk: async () => ({ risk: "low" }),
98
+ ipcRegisterWebhookRoute: async () => ({
99
+ ok: false as const,
100
+ reason: "no_response" as const,
101
+ }),
102
+ ipcUnregisterWebhookRoute: async () => ({
103
+ ok: false as const,
104
+ reason: "no_response" as const,
105
+ }),
106
+ ipcListWebhookRoutes: async () => ({
107
+ ok: false as const,
108
+ reason: "no_response" as const,
109
+ }),
98
110
  }));
99
111
 
100
112
  mock.module("../../../messaging/providers/slack/send.js", () => ({
@@ -12,6 +12,7 @@
12
12
  import { beforeEach, describe, expect, mock, test } from "bun:test";
13
13
 
14
14
  let isPlatform = false;
15
+ let velayWebhooksEnabled = false;
15
16
  let rawConfig: Record<string, unknown> = {};
16
17
  let platformContextEnabled = false;
17
18
 
@@ -30,6 +31,12 @@ mock.module("../loader.js", () => ({
30
31
  getConfig: () => rawConfig,
31
32
  }));
32
33
 
34
+ const actualVelayGate = await import("../../inbound/velay-webhooks-gate.js");
35
+ mock.module("../../inbound/velay-webhooks-gate.js", () => ({
36
+ ...actualVelayGate,
37
+ isVelayWebhooksEnabled: () => velayWebhooksEnabled,
38
+ }));
39
+
33
40
  const actualRegistration =
34
41
  await import("../../inbound/platform-callback-registration.js");
35
42
  mock.module("../../inbound/platform-callback-registration.js", () => ({
@@ -50,6 +57,7 @@ const { hasIngressConfigured, hasWebhookRoutingConfigured } =
50
57
  describe("hasWebhookRoutingConfigured resolution order", () => {
51
58
  beforeEach(() => {
52
59
  isPlatform = false;
60
+ velayWebhooksEnabled = false;
53
61
  rawConfig = {};
54
62
  platformContextEnabled = false;
55
63
  });
@@ -78,6 +86,40 @@ describe("hasWebhookRoutingConfigured resolution order", () => {
78
86
  });
79
87
  });
80
88
 
89
+ test("velay-webhooks on: a pod with a published ingress URL reports it", async () => {
90
+ isPlatform = true;
91
+ velayWebhooksEnabled = true;
92
+ rawConfig = { ingress: { publicBaseUrl: "https://tunnel.example.com" } };
93
+
94
+ expect(await hasWebhookRoutingConfigured(true)).toEqual({
95
+ configured: true,
96
+ usesManagedCallbacks: false,
97
+ });
98
+ });
99
+
100
+ test("velay-webhooks on: a pod with no published URL still reports managed", async () => {
101
+ isPlatform = true;
102
+ velayWebhooksEnabled = true;
103
+
104
+ expect(await hasWebhookRoutingConfigured(true)).toEqual({
105
+ configured: true,
106
+ usesManagedCallbacks: true,
107
+ });
108
+ });
109
+
110
+ test("velay-webhooks on: a pod with ingress disabled falls back to managed", async () => {
111
+ isPlatform = true;
112
+ velayWebhooksEnabled = true;
113
+ rawConfig = { ingress: { enabled: false } };
114
+
115
+ // Matches `resolveCallbackUrl` on pods: an owner toggling ingress off
116
+ // must not lose webhooks entirely.
117
+ expect(await hasWebhookRoutingConfigured(true)).toEqual({
118
+ configured: true,
119
+ usesManagedCallbacks: true,
120
+ });
121
+ });
122
+
81
123
  // ── Tier 2: a configured ingress wins ────────────────────────────────────
82
124
 
83
125
  test("ingress beats the platform-connected fallback", async () => {
@@ -446,6 +446,14 @@
446
446
  "label": "Model-First Profile Create",
447
447
  "description": "Reverses the two questions the web client's New Model modal asks when creating an inference profile. Off: the modal asks for a provider first and then offers that provider's models. On: it opens on one searchable list of every catalog model the assistant can reach, deduplicated across providers, and only then asks which provider serves the chosen model. That second question is skipped when a single provider serves it, answered with radio cards when several do, and turned into an inline connect form (API key, setup, or ChatGPT sign-in) when the chosen route has no connection yet. Editing an existing profile is unchanged either way, and both flows persist the same profile shape.",
448
448
  "defaultEnabled": false
449
+ },
450
+ {
451
+ "id": "velay-webhooks",
452
+ "scope": "assistant",
453
+ "key": "velay-webhooks",
454
+ "label": "Velay Webhooks",
455
+ "description": "Platform pods resolve webhook callback URLs from the Velay-published ingress URL instead of registering platform callback routes. Falls back to platform callback registration while no tunnel URL is published.",
456
+ "defaultEnabled": false
449
457
  }
450
458
  ]
451
459
  }
@@ -16,6 +16,7 @@ import {
16
16
 
17
17
  import { resolvePlatformCallbackRegistrationContext } from "../inbound/platform-callback-registration.js";
18
18
  import { isPublicIngressDisabled } from "../inbound/public-ingress-urls.js";
19
+ import { isVelayWebhooksEnabled } from "../inbound/velay-webhooks-gate.js";
19
20
  import { getIsPlatform } from "./env-registry.js";
20
21
  import { getConfig, loadRawConfig } from "./loader.js";
21
22
 
@@ -67,7 +68,11 @@ function isIngressExplicitlyDisabled(): boolean {
67
68
  * reports "no ingress" while `webhooks register` hands back a working callback
68
69
  * URL hides a broken registration instead of surfacing it.
69
70
  *
70
- * 1. **Platform pods** (`IS_PLATFORM`) always use managed callbacks.
71
+ * 1. **Platform pods** (`IS_PLATFORM`) with the `velay-webhooks` flag off
72
+ * always use managed callbacks. With the flag on, a configured ingress
73
+ * (the Velay-published URL) wins, and managed callbacks remain the
74
+ * fallback — including when ingress is explicitly disabled, matching
75
+ * `resolveCallbackUrl`'s pod behavior.
71
76
  * 2. **A configured public ingress wins** for everyone else.
72
77
  * 3. **Platform-connected assistants with no ingress** fall back to managed
73
78
  * callbacks. Connectivity is decided by credentials (platform base URL +
@@ -99,7 +104,8 @@ export async function hasWebhookRoutingConfigured(
99
104
  configured: boolean;
100
105
  usesManagedCallbacks: boolean;
101
106
  }> {
102
- if (allowManagedCallbacks && getIsPlatform()) {
107
+ const platformManaged = allowManagedCallbacks && getIsPlatform();
108
+ if (platformManaged && !isVelayWebhooksEnabled()) {
103
109
  return { configured: true, usesManagedCallbacks: true };
104
110
  }
105
111
 
@@ -107,6 +113,10 @@ export async function hasWebhookRoutingConfigured(
107
113
  return { configured: true, usesManagedCallbacks: false };
108
114
  }
109
115
 
116
+ if (platformManaged) {
117
+ return { configured: true, usesManagedCallbacks: true };
118
+ }
119
+
110
120
  if (!allowManagedCallbacks || isIngressExplicitlyDisabled()) {
111
121
  return { configured: false, usesManagedCallbacks: false };
112
122
  }
@@ -21,13 +21,16 @@
21
21
  import { getPlatformAssistantId, getPlatformBaseUrl } from "../config/env.js";
22
22
  import { getIsPlatform } from "../config/env-registry.js";
23
23
  import { getConfig } from "../config/loader.js";
24
+ import { ipcRegisterWebhookRoute } from "../ipc/gateway-client.js";
24
25
  import { credentialKey } from "../security/credential-key.js";
25
26
  import { getSecureKeyAsync } from "../security/secure-keys.js";
26
27
  import { getLogger } from "../util/logger.js";
28
+ import { resolveClaimedPodWebhookUrl } from "./pod-webhook-claim.js";
27
29
  import {
28
30
  PublicIngressDisabledError,
29
31
  tryGetPublicBaseUrl,
30
32
  } from "./public-ingress-urls.js";
33
+ import { isVelayWebhooksEnabled } from "./velay-webhooks-gate.js";
31
34
 
32
35
  const log = getLogger("platform-callback-registration");
33
36
 
@@ -177,6 +180,47 @@ function resolveSelfHostedCallbackBaseUrl(): string | undefined {
177
180
  }
178
181
  }
179
182
 
183
+ /**
184
+ * Claim a webhook subpath on the gateway so the Velay tunnel forwards it.
185
+ *
186
+ * The registry matches paths exactly, so query parameters a caller appends to
187
+ * the resolved URL play no part. Returns false when the gateway declines the
188
+ * claim or cannot be reached, leaving platform callback registration as the
189
+ * way to keep the webhook reachable.
190
+ *
191
+ * @param callbackPath - The path to claim, e.g. "webhooks/twilio/voice".
192
+ */
193
+ export async function registerLocalWebhookRoute(
194
+ callbackPath: string,
195
+ type: string,
196
+ sourceIdentifier?: string,
197
+ ): Promise<boolean> {
198
+ const path = callbackPath.startsWith("/") ? callbackPath : `/${callbackPath}`;
199
+ const result = await ipcRegisterWebhookRoute({
200
+ path,
201
+ type,
202
+ source: sourceIdentifier,
203
+ });
204
+
205
+ if (!result.ok) {
206
+ log.warn(
207
+ { path, type, reason: result.reason },
208
+ "Gateway webhook route registration failed, falling back to the platform",
209
+ );
210
+ return false;
211
+ }
212
+ if (result.disabled) {
213
+ log.info(
214
+ { path, type },
215
+ "Gateway is not serving its own webhooks, falling back to the platform",
216
+ );
217
+ return false;
218
+ }
219
+
220
+ log.debug({ path, type }, "Gateway webhook route registered");
221
+ return true;
222
+ }
223
+
180
224
  /**
181
225
  * Resolve a callback URL, registering with the platform when appropriate.
182
226
  *
@@ -184,8 +228,15 @@ function resolveSelfHostedCallbackBaseUrl(): string | undefined {
184
228
  * `runtime/routes/webhook-routes.ts` and `hasWebhookRoutingConfigured` in
185
229
  * `config/webhook-routing.ts`:
186
230
  *
187
- * 1. **Platform pods** (`IS_PLATFORM`) always register with the platform
188
- * gateway: they have no ingress of their own to advertise.
231
+ * 1. **Platform pods** (`IS_PLATFORM`) with the `velay-webhooks` flag off
232
+ * always register with the platform gateway. With the flag on, they try
233
+ * the direct supplier first — the gateway's Velay client publishes the
234
+ * tunnel URL into `ingress.publicBaseUrl` — and fall back to platform
235
+ * registration on any failure, including an explicit
236
+ * `ingress.enabled: false`: a pod owner toggling that flag must not
237
+ * lose webhooks entirely. The subpath is claimed on the gateway before
238
+ * the tunnel URL is handed out, and a refused claim falls back the same
239
+ * way.
189
240
  * 2. **A configured public ingress wins** for everyone else, so the direct
190
241
  * supplier is tried first and its value returned when it resolves.
191
242
  * 3. **Platform-connected assistants with no ingress** register with the
@@ -194,12 +245,12 @@ function resolveSelfHostedCallbackBaseUrl(): string | undefined {
194
245
  * ID + assistant API key), not by `IS_PLATFORM`, which is only ever true
195
246
  * on a platform pod.
196
247
  *
197
- * An explicit `ingress.enabled: false` is a decision not to accept inbound
198
- * webhooks at all, so `PublicIngressDisabledError` propagates instead of being
199
- * routed around. Ingress precedes the platform fallback because any logged-in
200
- * local assistant holds platform credentials for the LLM proxy: treating
201
- * credential presence as "managed" would silently reroute an explicitly
202
- * configured self-hosted callback through the platform.
248
+ * Off a pod, an explicit `ingress.enabled: false` is a decision not to accept
249
+ * inbound webhooks at all, so `PublicIngressDisabledError` propagates instead
250
+ * of being routed around. Ingress precedes the platform fallback because any
251
+ * logged-in local assistant holds platform credentials for the LLM proxy:
252
+ * treating credential presence as "managed" would silently reroute an
253
+ * explicitly configured self-hosted callback through the platform.
203
254
  *
204
255
  * The `directUrl` parameter is a **lazy supplier** (a function returning a
205
256
  * string) rather than an eagerly-evaluated string. This is critical because
@@ -222,10 +273,20 @@ export async function resolveCallbackUrl(
222
273
  queryParams?: Record<string, string>,
223
274
  sourceIdentifier?: string,
224
275
  ): Promise<string> {
225
- if (!getIsPlatform()) {
276
+ if (getIsPlatform()) {
277
+ if (isVelayWebhooksEnabled()) {
278
+ const claimed = await resolveClaimedPodWebhookUrl(directUrl, () =>
279
+ registerLocalWebhookRoute(callbackPath, type, sourceIdentifier),
280
+ );
281
+ if (claimed !== undefined) {
282
+ return claimed;
283
+ }
284
+ }
285
+ } else {
286
+ let ingressUrl: string | undefined;
226
287
  let ingressError: unknown;
227
288
  try {
228
- return directUrl();
289
+ ingressUrl = directUrl();
229
290
  } catch (err) {
230
291
  if (err instanceof PublicIngressDisabledError) {
231
292
  throw err;
@@ -233,8 +294,13 @@ export async function resolveCallbackUrl(
233
294
  ingressError = err;
234
295
  }
235
296
 
297
+ if (ingressUrl !== undefined) {
298
+ return ingressUrl;
299
+ }
300
+
236
301
  // No ingress configured. Fall back to the platform gateway when this
237
- // assistant is connected to the platform.
302
+ // assistant is connected to the platform. Platform pods always are, so
303
+ // they skip the context probe and register directly.
238
304
  const context = await resolvePlatformCallbackRegistrationContext();
239
305
  if (!context.enabled) {
240
306
  throw ingressError;
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The platform-pod tier of webhook URL resolution, shared by the two seams that
3
+ * resolve callback URLs: `resolveCallbackUrl` and the `webhooks_register` route
4
+ * handler.
5
+ *
6
+ * A pod's Velay tunnel only forwards subpaths the gateway has claimed, so the
7
+ * published ingress URL is handed out only once the claim succeeds. Anything
8
+ * else leaves the caller to register with the platform instead.
9
+ */
10
+
11
+ /**
12
+ * The published ingress URL for a webhook, or undefined when the pod should
13
+ * fall back to platform registration.
14
+ *
15
+ * `ingressUrl` is a lazy supplier because the builders behind it throw when no
16
+ * public base URL has been published yet, and on a pod that throw is not fatal:
17
+ * a pod owner with no tunnel yet, or one who turned ingress off, must not lose
18
+ * webhooks entirely.
19
+ *
20
+ * @param ingressUrl - Lazy supplier for the published ingress callback URL.
21
+ * @param claimRoute - Claims the subpath on the gateway; false when the gateway
22
+ * refuses the claim or cannot be reached.
23
+ */
24
+ export async function resolveClaimedPodWebhookUrl(
25
+ ingressUrl: () => string,
26
+ claimRoute: () => Promise<boolean>,
27
+ ): Promise<string | undefined> {
28
+ let url: string;
29
+ try {
30
+ url = ingressUrl();
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ return (await claimRoute()) ? url : undefined;
35
+ }
@@ -0,0 +1,20 @@
1
+ import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags.js";
2
+
3
+ const VELAY_WEBHOOKS_FLAG_KEY = "velay-webhooks" as const;
4
+
5
+ /**
6
+ * Whether platform pods resolve webhook callback URLs from the
7
+ * Velay-published ingress URL instead of registering platform callback
8
+ * routes.
9
+ *
10
+ * Gates the platform-pod tier in all three webhook resolution sites
11
+ * (`resolveCallbackUrl`, `hasWebhookRoutingConfigured`,
12
+ * `handleWebhooksRegister`), which must agree tier for tier. Off means the
13
+ * pre-Velay behavior: platform pods always register with the platform
14
+ * gateway. On means ingress-first with platform registration as the
15
+ * fallback, so a pod whose tunnel has not published a URL yet keeps
16
+ * working either way.
17
+ */
18
+ export function isVelayWebhooksEnabled(): boolean {
19
+ return isAssistantFeatureFlagEnabled(VELAY_WEBHOOKS_FLAG_KEY);
20
+ }
@@ -14,6 +14,11 @@ import {
14
14
  type ClassifyRiskIpcParams,
15
15
  type ClassifyRiskIpcResponse,
16
16
  ClassifyRiskIpcResponseSchema,
17
+ ListWebhookRoutesIpcResponseSchema,
18
+ type RegisterWebhookRouteIpcParams,
19
+ RegisterWebhookRouteIpcResponseSchema,
20
+ UnregisterWebhookRouteIpcResponseSchema,
21
+ type WebhookIngressRoute,
17
22
  } from "@vellumai/gateway-client";
18
23
  import {
19
24
  ipcCall as packageIpcCall,
@@ -145,6 +150,92 @@ export async function ipcGetVelayStatus(): Promise<VelayTunnelStatus | null> {
145
150
  };
146
151
  }
147
152
 
153
+ // ---------------------------------------------------------------------------
154
+ // Webhook ingress route registry
155
+ // ---------------------------------------------------------------------------
156
+
157
+ /**
158
+ * Why a webhook-route call failed.
159
+ *
160
+ * `no_response` means nothing came back. The one-shot IPC transport answers a
161
+ * missing gateway and a gateway-side refusal the same way, with no result, so
162
+ * both land here. `invalid_response` means the gateway answered but the answer
163
+ * does not match the shared contract, so the two sides have drifted.
164
+ */
165
+ export type WebhookRouteIpcFailureReason = "no_response" | "invalid_response";
166
+
167
+ export type IpcRegisterWebhookRouteResult =
168
+ | { ok: true; disabled: true }
169
+ | { ok: true; disabled: false; route: WebhookIngressRoute }
170
+ | { ok: false; reason: WebhookRouteIpcFailureReason };
171
+
172
+ export type IpcUnregisterWebhookRouteResult =
173
+ | { ok: true; removed: boolean }
174
+ | { ok: false; reason: WebhookRouteIpcFailureReason };
175
+
176
+ export type IpcListWebhookRoutesResult =
177
+ | { ok: true; routes: WebhookIngressRoute[] }
178
+ | { ok: false; reason: WebhookRouteIpcFailureReason };
179
+
180
+ function webhookRouteFailure(
181
+ method: string,
182
+ result: unknown,
183
+ detail: Record<string, unknown> = {},
184
+ ): { ok: false; reason: WebhookRouteIpcFailureReason } {
185
+ const reason: WebhookRouteIpcFailureReason =
186
+ result === undefined ? "no_response" : "invalid_response";
187
+ log.warn({ ...detail, result, reason }, `${method}: gateway call failed`);
188
+ return { ok: false, reason };
189
+ }
190
+
191
+ /**
192
+ * Claim a webhook subpath on the gateway.
193
+ *
194
+ * A `disabled` result is a normal answer, not a failure: the gateway is not
195
+ * serving its own webhooks and the caller should fall back to platform
196
+ * callback registration. A failure carries the reason it failed so a caller
197
+ * that also falls back can log the two apart.
198
+ */
199
+ export async function ipcRegisterWebhookRoute(
200
+ input: RegisterWebhookRouteIpcParams,
201
+ ): Promise<IpcRegisterWebhookRouteResult> {
202
+ const result = await ipcCall("register_webhook_route", { ...input });
203
+ const parsed = RegisterWebhookRouteIpcResponseSchema.safeParse(result);
204
+ if (!parsed.success) {
205
+ return webhookRouteFailure("ipcRegisterWebhookRoute", result, {
206
+ path: input.path,
207
+ });
208
+ }
209
+ return parsed.data.disabled
210
+ ? { ok: true, disabled: true }
211
+ : { ok: true, disabled: false, route: parsed.data.route };
212
+ }
213
+
214
+ /**
215
+ * Drop a webhook subpath from the gateway registry. A successful call reports
216
+ * whether a route was actually removed.
217
+ */
218
+ export async function ipcUnregisterWebhookRoute(
219
+ path: string,
220
+ ): Promise<IpcUnregisterWebhookRouteResult> {
221
+ const result = await ipcCall("unregister_webhook_route", { path });
222
+ const parsed = UnregisterWebhookRouteIpcResponseSchema.safeParse(result);
223
+ if (!parsed.success) {
224
+ return webhookRouteFailure("ipcUnregisterWebhookRoute", result, { path });
225
+ }
226
+ return { ok: true, removed: parsed.data.removed };
227
+ }
228
+
229
+ /** List every webhook subpath the gateway currently answers. */
230
+ export async function ipcListWebhookRoutes(): Promise<IpcListWebhookRoutesResult> {
231
+ const result = await ipcCall("list_webhook_routes");
232
+ const parsed = ListWebhookRoutesIpcResponseSchema.safeParse(result);
233
+ if (!parsed.success) {
234
+ return webhookRouteFailure("ipcListWebhookRoutes", result);
235
+ }
236
+ return { ok: true, routes: parsed.data.routes };
237
+ }
238
+
148
239
  // classify_risk is an idempotent, side-effect-free read, so a transient gateway
149
240
  // blip (socket dropped between calls, momentary unreachability) is safe to
150
241
  // retry — the persistent client re-establishes its socket on the next call. The
@@ -16,6 +16,7 @@
16
16
 
17
17
  import { createHash } from "node:crypto";
18
18
 
19
+ import { getIsPlatform } from "../config/env-registry.js";
19
20
  import { getConfig } from "../config/loader.js";
20
21
  import { resolveCallbackUrl } from "../inbound/platform-callback-registration.js";
21
22
  import { getPublicBaseUrl } from "../inbound/public-ingress-urls.js";
@@ -90,12 +91,11 @@ function registrationType(plugin: string, route: string): string {
90
91
  }
91
92
 
92
93
  /**
93
- * Keep a trailing slash on a resolved callback URL.
94
+ * Keep a trailing slash on a managed callback URL.
94
95
  *
95
96
  * Django in front of managed callbacks canonicalizes onto `/`. A vendor given
96
97
  * the slashless spelling is 301'd, and clients that follow a 301 on POST
97
- * typically retry as GET and drop the body. The gateway serves both spellings
98
- * of a plugin webhook, so the slashed URL is the one to hand out.
98
+ * typically retry as GET and drop the body.
99
99
  *
100
100
  * Query-bearing URLs are left alone: this resolver passes no query
101
101
  * parameters, and appending there would cut into the query rather than the
@@ -142,12 +142,26 @@ export async function resolveWebhookUrl(
142
142
 
143
143
  const callbackPath = `${PLUGIN_WEBHOOK_PREFIX}/${plugin}/${path}`;
144
144
 
145
+ let ingressUrl: string | undefined;
145
146
  const resolved = await resolveCallbackUrl(
146
- () => `${getPublicBaseUrl(getConfig())}/${callbackPath}`,
147
+ () => {
148
+ ingressUrl = `${getPublicBaseUrl(getConfig())}/${callbackPath}`;
149
+ return ingressUrl;
150
+ },
147
151
  callbackPath,
148
152
  registrationType(plugin, path),
149
153
  undefined,
150
154
  sourceIdentifier,
151
155
  );
156
+
157
+ // A pod's ingress URL reaches the gateway through the tunnel, which forwards
158
+ // the request path verbatim and admits it only when it matches a claimed
159
+ // path byte for byte. The claim above is made under the slashless spelling,
160
+ // so that is the spelling to hand out. On a pod the supplier URL is only
161
+ // returned once its path is claimed, so this condition holds exactly for
162
+ // tunnel URLs; every other branch keeps the trailing slash.
163
+ if (getIsPlatform() && resolved === ingressUrl) {
164
+ return resolved;
165
+ }
152
166
  return withTrailingSlash(resolved);
153
167
  }
@@ -8,10 +8,13 @@ import { beforeEach, describe, expect, mock, test } from "bun:test";
8
8
  import { InternalError, UnprocessableEntityError } from "../errors.js";
9
9
 
10
10
  let isPlatform = false;
11
+ let velayWebhooksEnabled = false;
11
12
  let config: Record<string, unknown> = {};
12
13
  let platformContextEnabled = false;
13
14
  let registerCallbackRouteError: Error | undefined;
14
15
 
16
+ let localWebhookRouteRegistered = true;
17
+
15
18
  const registerCallbackRouteMock = mock(
16
19
  async (callbackPath: string, _type: string, _source?: string) => {
17
20
  if (registerCallbackRouteError) {
@@ -21,6 +24,11 @@ const registerCallbackRouteMock = mock(
21
24
  },
22
25
  );
23
26
 
27
+ const registerLocalWebhookRouteMock = mock(
28
+ async (_callbackPath: string, _type: string, _source?: string) =>
29
+ localWebhookRouteRegistered,
30
+ );
31
+
24
32
  // Spread the real modules: these are broad barrels and replacing them wholesale
25
33
  // breaks unrelated importers pulled in by the module under test.
26
34
  const actualEnvRegistry = await import("../../../config/env-registry.js");
@@ -35,8 +43,15 @@ mock.module("../../../config/loader.js", () => ({
35
43
  getConfig: () => config,
36
44
  }));
37
45
 
46
+ const actualVelayGate = await import("../../../inbound/velay-webhooks-gate.js");
47
+ mock.module("../../../inbound/velay-webhooks-gate.js", () => ({
48
+ ...actualVelayGate,
49
+ isVelayWebhooksEnabled: () => velayWebhooksEnabled,
50
+ }));
51
+
38
52
  mock.module("../../../inbound/platform-callback-registration.js", () => ({
39
53
  registerCallbackRoute: registerCallbackRouteMock,
54
+ registerLocalWebhookRoute: registerLocalWebhookRouteMock,
40
55
  resolvePlatformCallbackRegistrationContext: async () => ({
41
56
  isPlatform,
42
57
  platformBaseUrl: "https://api.vellum.ai",
@@ -59,10 +74,13 @@ const register = (body: Record<string, unknown>) =>
59
74
  describe("webhooks_register callback URL resolution", () => {
60
75
  beforeEach(() => {
61
76
  isPlatform = false;
77
+ velayWebhooksEnabled = false;
62
78
  config = {};
63
79
  platformContextEnabled = false;
64
80
  registerCallbackRouteError = undefined;
81
+ localWebhookRouteRegistered = true;
65
82
  registerCallbackRouteMock.mockClear();
83
+ registerLocalWebhookRouteMock.mockClear();
66
84
  });
67
85
 
68
86
  test("platform pods register with the platform gateway", async () => {
@@ -75,6 +93,7 @@ describe("webhooks_register callback URL resolution", () => {
75
93
  path: "webhooks/telegram",
76
94
  mode: "platform",
77
95
  });
96
+ expect(registerLocalWebhookRouteMock).not.toHaveBeenCalled();
78
97
  });
79
98
 
80
99
  // The bug: a local assistant that IS connected to the platform used to fall
@@ -95,6 +114,68 @@ describe("webhooks_register callback URL resolution", () => {
95
114
  );
96
115
  });
97
116
 
117
+ test("velay-webhooks on: a pod claims the subpath and uses the published URL", async () => {
118
+ isPlatform = true;
119
+ velayWebhooksEnabled = true;
120
+ platformContextEnabled = true;
121
+ config = {
122
+ ingress: { publicBaseUrl: "https://velay.vellum.ai/assistant-123" },
123
+ };
124
+
125
+ expect(await register({ type: "telegram", source: "@my_bot" })).toEqual({
126
+ callbackUrl: "https://velay.vellum.ai/assistant-123/webhooks/telegram",
127
+ type: "telegram",
128
+ path: "webhooks/telegram",
129
+ mode: "self-hosted",
130
+ });
131
+ expect(registerLocalWebhookRouteMock).toHaveBeenCalledWith(
132
+ "webhooks/telegram",
133
+ "telegram",
134
+ "@my_bot",
135
+ );
136
+ expect(registerCallbackRouteMock).not.toHaveBeenCalled();
137
+ });
138
+
139
+ test("velay-webhooks on: a refused claim falls back to the platform", async () => {
140
+ isPlatform = true;
141
+ velayWebhooksEnabled = true;
142
+ platformContextEnabled = true;
143
+ localWebhookRouteRegistered = false;
144
+ config = {
145
+ ingress: { publicBaseUrl: "https://velay.vellum.ai/assistant-123" },
146
+ };
147
+
148
+ expect(await register({ type: "telegram" })).toMatchObject({
149
+ callbackUrl: "https://gateway.vellum.ai/assistant-123/webhooks/telegram",
150
+ mode: "platform",
151
+ });
152
+ expect(registerLocalWebhookRouteMock).toHaveBeenCalled();
153
+ });
154
+
155
+ test("velay-webhooks on: a pod with no published URL still registers with the platform", async () => {
156
+ isPlatform = true;
157
+ velayWebhooksEnabled = true;
158
+ platformContextEnabled = true;
159
+
160
+ expect(await register({ type: "telegram" })).toMatchObject({
161
+ callbackUrl: "https://gateway.vellum.ai/assistant-123/webhooks/telegram",
162
+ mode: "platform",
163
+ });
164
+ expect(registerLocalWebhookRouteMock).not.toHaveBeenCalled();
165
+ });
166
+
167
+ test("velay-webhooks on: a pod with ingress disabled falls back to the platform", async () => {
168
+ isPlatform = true;
169
+ velayWebhooksEnabled = true;
170
+ platformContextEnabled = true;
171
+ config = { ingress: { enabled: false } };
172
+
173
+ expect(await register({ type: "telegram" })).toMatchObject({
174
+ mode: "platform",
175
+ });
176
+ expect(registerLocalWebhookRouteMock).not.toHaveBeenCalled();
177
+ });
178
+
98
179
  test("disconnected local assistant uses the configured publicBaseUrl", async () => {
99
180
  config = { ingress: { publicBaseUrl: "https://abc.ngrok.io" } };
100
181
 
@@ -105,6 +186,7 @@ describe("webhooks_register callback URL resolution", () => {
105
186
  mode: "self-hosted",
106
187
  });
107
188
  expect(registerCallbackRouteMock).not.toHaveBeenCalled();
189
+ expect(registerLocalWebhookRouteMock).not.toHaveBeenCalled();
108
190
  });
109
191
 
110
192
  test("disconnected local assistant with no ingress is still unprocessable", async () => {
@@ -124,6 +206,7 @@ describe("webhooks_register callback URL resolution", () => {
124
206
  mode: "self-hosted",
125
207
  });
126
208
  expect(registerCallbackRouteMock).not.toHaveBeenCalled();
209
+ expect(registerLocalWebhookRouteMock).not.toHaveBeenCalled();
127
210
  });
128
211
 
129
212
  // The gateway publishes the Velay tunnel URL into ingress.publicBaseUrl, so
@@ -15,12 +15,15 @@ import { getIsPlatform } from "../../config/env-registry.js";
15
15
  import { getConfig } from "../../config/loader.js";
16
16
  import {
17
17
  registerCallbackRoute,
18
+ registerLocalWebhookRoute,
18
19
  resolvePlatformCallbackRegistrationContext,
19
20
  } from "../../inbound/platform-callback-registration.js";
21
+ import { resolveClaimedPodWebhookUrl } from "../../inbound/pod-webhook-claim.js";
20
22
  import {
21
23
  getPublicBaseUrl,
22
24
  isPublicIngressDisabled,
23
25
  } from "../../inbound/public-ingress-urls.js";
26
+ import { isVelayWebhooksEnabled } from "../../inbound/velay-webhooks-gate.js";
24
27
  import { ACTOR_PRINCIPALS } from "../auth/route-policy.js";
25
28
  import {
26
29
  BadRequestError,
@@ -109,8 +112,13 @@ async function registerWithPlatform(
109
112
  * Resolve a stable callback URL for a webhook type.
110
113
  *
111
114
  * Resolution order:
112
- * 1. **Platform pods** (`IS_PLATFORM`) always register with the platform
113
- * gateway: they have no ingress of their own to advertise.
115
+ * 1. **Platform pods** (`IS_PLATFORM`) with the `velay-webhooks` flag off
116
+ * always register with the platform gateway. With the flag on, the
117
+ * Velay-published `ingress.publicBaseUrl` wins and platform
118
+ * registration is the fallback for any failure to resolve it —
119
+ * including an explicit `ingress.enabled: false`, matching
120
+ * `resolveCallbackUrl`'s pod behavior. The subpath is claimed on the
121
+ * gateway first, and a refused claim falls back the same way.
114
122
  * 2. **A configured public ingress wins** for everyone else. That URL is
115
123
  * either the user's own tunnel (ngrok, a custom domain) or the Velay
116
124
  * tunnel URL the gateway publishes into `ingress.publicBaseUrl`, so a
@@ -141,6 +149,20 @@ async function handleWebhooksRegister(
141
149
  const sourceIdentifier = source as string | undefined;
142
150
 
143
151
  if (getIsPlatform()) {
152
+ if (isVelayWebhooksEnabled()) {
153
+ const callbackUrl = await resolveClaimedPodWebhookUrl(
154
+ () => `${getPublicBaseUrl(getConfig())}/${webhookPath}`,
155
+ () => registerLocalWebhookRoute(webhookPath, type, sourceIdentifier),
156
+ );
157
+ if (callbackUrl !== undefined) {
158
+ return {
159
+ callbackUrl,
160
+ type,
161
+ path: webhookPath,
162
+ mode: "self-hosted",
163
+ };
164
+ }
165
+ }
144
166
  return registerWithPlatform(webhookPath, type, sourceIdentifier);
145
167
  }
146
168