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