@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.
- package/node_modules/@vellumai/gateway-client/src/gateway-ipc-contracts.ts +72 -0
- package/package.json +1 -1
- package/src/__tests__/platform-callback-registration.test.ts +162 -3
- package/src/__tests__/plugin-api-webhook-url.test.ts +66 -4
- package/src/cli/commands/__tests__/conversations-slack.test.ts +12 -0
- package/src/config/__tests__/webhook-routing.test.ts +42 -0
- package/src/config/feature-flag-registry.json +8 -0
- package/src/config/webhook-routing.ts +12 -2
- package/src/inbound/platform-callback-registration.ts +77 -11
- package/src/inbound/pod-webhook-claim.ts +35 -0
- package/src/inbound/velay-webhooks-gate.ts +20 -0
- package/src/ipc/gateway-client.ts +91 -0
- package/src/plugin-api/webhook-url.ts +18 -4
- package/src/runtime/routes/__tests__/webhook-routes.test.ts +83 -0
- package/src/runtime/routes/webhook-routes.ts +24 -2
|
@@ -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,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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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`)
|
|
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
|
-
|
|
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`)
|
|
188
|
-
*
|
|
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
|
-
*
|
|
198
|
-
* webhooks at all, so `PublicIngressDisabledError` propagates instead
|
|
199
|
-
* routed around. Ingress precedes the platform fallback because any
|
|
200
|
-
* local assistant holds platform credentials for the LLM proxy:
|
|
201
|
-
* credential presence as "managed" would silently reroute an
|
|
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 (
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
() =>
|
|
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`)
|
|
113
|
-
*
|
|
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
|
|