vellum 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bun.lock +2 -2
  2. package/package.json +2 -2
  3. package/src/__tests__/asset-materialize-tool.test.ts +2 -2
  4. package/src/__tests__/checker.test.ts +104 -0
  5. package/src/__tests__/gateway-only-enforcement.test.ts +458 -0
  6. package/src/__tests__/ipc-snapshot.test.ts +11 -0
  7. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  8. package/src/__tests__/oauth2-gateway-transport.test.ts +298 -0
  9. package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
  10. package/src/__tests__/public-ingress-urls.test.ts +206 -0
  11. package/src/__tests__/tool-executor.test.ts +88 -0
  12. package/src/__tests__/turn-commit.test.ts +64 -0
  13. package/src/calls/twilio-config.ts +17 -1
  14. package/src/calls/twilio-routes.ts +10 -2
  15. package/src/calls/twilio-webhook-urls.ts +18 -21
  16. package/src/config/defaults.ts +4 -0
  17. package/src/config/schema.ts +30 -2
  18. package/src/config/system-prompt.ts +1 -1
  19. package/src/config/types.ts +1 -0
  20. package/src/daemon/computer-use-session.ts +2 -1
  21. package/src/daemon/handlers/config.ts +51 -2
  22. package/src/daemon/handlers/sessions.ts +2 -2
  23. package/src/daemon/handlers/work-items.ts +1 -1
  24. package/src/daemon/ipc-contract-inventory.json +4 -0
  25. package/src/daemon/ipc-contract.ts +16 -1
  26. package/src/daemon/session-tool-setup.ts +7 -0
  27. package/src/inbound/public-ingress-urls.ts +106 -0
  28. package/src/memory/attachments-store.ts +0 -1
  29. package/src/memory/channel-delivery-store.ts +0 -1
  30. package/src/memory/conversation-key-store.ts +0 -1
  31. package/src/memory/db.ts +346 -149
  32. package/src/memory/runs-store.ts +0 -3
  33. package/src/memory/schema.ts +0 -4
  34. package/src/runtime/http-server.ts +84 -2
  35. package/src/security/oauth-callback-registry.ts +56 -0
  36. package/src/security/oauth2.ts +174 -58
  37. package/src/swarm/backend-claude-code.ts +1 -1
  38. package/src/tools/assets/search.ts +1 -36
  39. package/src/tools/claude-code/claude-code.ts +3 -3
  40. package/src/tools/tasks/work-item-list.ts +16 -2
  41. package/src/workspace/provider-commit-message-generator.ts +39 -23
  42. package/src/workspace/turn-commit.ts +6 -2
@@ -0,0 +1,85 @@
1
+ import { describe, test, expect, afterEach } from 'bun:test';
2
+ import {
3
+ registerPendingCallback,
4
+ consumeCallback,
5
+ consumeCallbackError,
6
+ clearAllCallbacks,
7
+ } from '../security/oauth-callback-registry.js';
8
+
9
+ afterEach(() => {
10
+ clearAllCallbacks();
11
+ });
12
+
13
+ describe('OAuth callback registry', () => {
14
+ test('registerPendingCallback + consumeCallback resolves with code', async () => {
15
+ const promise = new Promise<string>((resolve, reject) => {
16
+ registerPendingCallback('state-1', resolve, reject);
17
+ });
18
+
19
+ const consumed = consumeCallback('state-1', 'auth-code-123');
20
+ expect(consumed).toBe(true);
21
+
22
+ const code = await promise;
23
+ expect(code).toBe('auth-code-123');
24
+ });
25
+
26
+ test('consumeCallback with unknown state returns false', () => {
27
+ const consumed = consumeCallback('nonexistent', 'code');
28
+ expect(consumed).toBe(false);
29
+ });
30
+
31
+ test('consumeCallbackError rejects the pending callback', async () => {
32
+ const promise = new Promise<string>((resolve, reject) => {
33
+ registerPendingCallback('state-err', resolve, reject);
34
+ });
35
+
36
+ const consumed = consumeCallbackError('state-err', 'access_denied');
37
+ expect(consumed).toBe(true);
38
+
39
+ await expect(promise).rejects.toThrow('access_denied');
40
+ });
41
+
42
+ test('consumeCallbackError with unknown state returns false', () => {
43
+ const consumed = consumeCallbackError('nonexistent', 'some error');
44
+ expect(consumed).toBe(false);
45
+ });
46
+
47
+ test('duplicate consumeCallback returns false on second call', async () => {
48
+ const promise = new Promise<string>((resolve, reject) => {
49
+ registerPendingCallback('state-dup', resolve, reject);
50
+ });
51
+
52
+ const first = consumeCallback('state-dup', 'code-1');
53
+ expect(first).toBe(true);
54
+
55
+ const second = consumeCallback('state-dup', 'code-2');
56
+ expect(second).toBe(false);
57
+
58
+ const code = await promise;
59
+ expect(code).toBe('code-1');
60
+ });
61
+
62
+ test('TTL expiry rejects callback with timeout error', async () => {
63
+ const promise = new Promise<string>((resolve, reject) => {
64
+ registerPendingCallback('state-ttl', resolve, reject, 50);
65
+ });
66
+
67
+ // Wait for the TTL to expire
68
+ await new Promise((r) => setTimeout(r, 100));
69
+
70
+ await expect(promise).rejects.toThrow('OAuth callback timed out');
71
+
72
+ // After expiry, consume should return false
73
+ const consumed = consumeCallback('state-ttl', 'late-code');
74
+ expect(consumed).toBe(false);
75
+ });
76
+
77
+ test('clearAllCallbacks cleans up all pending entries', () => {
78
+ registerPendingCallback('s1', () => {}, () => {});
79
+ registerPendingCallback('s2', () => {}, () => {});
80
+ clearAllCallbacks();
81
+
82
+ expect(consumeCallback('s1', 'code')).toBe(false);
83
+ expect(consumeCallback('s2', 'code')).toBe(false);
84
+ });
85
+ });
@@ -0,0 +1,298 @@
1
+ import { describe, test, expect, mock, beforeEach } from 'bun:test';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks — must be set up before importing the module under test
5
+ // ---------------------------------------------------------------------------
6
+
7
+ let mockPublicBaseUrl = '';
8
+
9
+ mock.module('../config/loader.js', () => ({
10
+ loadConfig: () => ({
11
+ ingress: { publicBaseUrl: mockPublicBaseUrl },
12
+ }),
13
+ getConfig: () => ({
14
+ ingress: { publicBaseUrl: mockPublicBaseUrl },
15
+ }),
16
+ loadRawConfig: () => ({}),
17
+ saveConfig: () => {},
18
+ invalidateConfigCache: () => {},
19
+ }));
20
+
21
+ mock.module('../util/logger.js', () => ({
22
+ getLogger: () => ({
23
+ info: () => {},
24
+ warn: () => {},
25
+ error: () => {},
26
+ debug: () => {},
27
+ trace: () => {},
28
+ fatal: () => {},
29
+ child: () => ({
30
+ info: () => {},
31
+ warn: () => {},
32
+ error: () => {},
33
+ debug: () => {},
34
+ }),
35
+ }),
36
+ }));
37
+
38
+ // Track registerPendingCallback calls
39
+ let pendingCallbacks: Map<string, { resolve: (code: string) => void; reject: (error: Error) => void }> = new Map();
40
+
41
+ mock.module('../security/oauth-callback-registry.js', () => ({
42
+ registerPendingCallback: (state: string, resolve: (code: string) => void, reject: (error: Error) => void) => {
43
+ pendingCallbacks.set(state, { resolve, reject });
44
+ },
45
+ consumeCallback: () => true,
46
+ consumeCallbackError: () => true,
47
+ clearAllCallbacks: () => { pendingCallbacks.clear(); },
48
+ }));
49
+
50
+ let mockOAuthCallbackUrl = '';
51
+
52
+ mock.module('../inbound/public-ingress-urls.js', () => ({
53
+ getOAuthCallbackUrl: () => mockOAuthCallbackUrl,
54
+ getPublicBaseUrl: () => 'https://gw.example.com',
55
+ }));
56
+
57
+ // Mock fetch for token exchange
58
+ let mockTokenResponse: { ok: boolean; status: number; body: Record<string, unknown> } = {
59
+ ok: true,
60
+ status: 200,
61
+ body: {
62
+ access_token: 'test-access-token',
63
+ refresh_token: 'test-refresh-token',
64
+ expires_in: 3600,
65
+ scope: 'read write',
66
+ token_type: 'Bearer',
67
+ },
68
+ };
69
+
70
+ const originalFetch = globalThis.fetch;
71
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
72
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
73
+ if (url.includes('token')) {
74
+ if (!mockTokenResponse.ok) {
75
+ return new Response(JSON.stringify({ error: 'invalid_grant' }), {
76
+ status: mockTokenResponse.status,
77
+ headers: { 'Content-Type': 'application/json' },
78
+ });
79
+ }
80
+ return new Response(JSON.stringify(mockTokenResponse.body), {
81
+ status: mockTokenResponse.status,
82
+ headers: { 'Content-Type': 'application/json' },
83
+ });
84
+ }
85
+ return originalFetch(input, init);
86
+ }) as typeof fetch;
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // Import module under test AFTER mocks are in place
90
+ // ---------------------------------------------------------------------------
91
+
92
+ import { startOAuth2Flow, type OAuth2Config } from '../security/oauth2.js';
93
+
94
+ const BASE_OAUTH_CONFIG: OAuth2Config = {
95
+ authUrl: 'https://provider.example.com/authorize',
96
+ tokenUrl: 'https://provider.example.com/token',
97
+ scopes: ['read', 'write'],
98
+ clientId: 'test-client-id',
99
+ };
100
+
101
+ beforeEach(() => {
102
+ mockPublicBaseUrl = '';
103
+ mockOAuthCallbackUrl = 'https://gw.example.com/webhooks/oauth/callback';
104
+ pendingCallbacks.clear();
105
+ mockTokenResponse = {
106
+ ok: true,
107
+ status: 200,
108
+ body: {
109
+ access_token: 'test-access-token',
110
+ refresh_token: 'test-refresh-token',
111
+ expires_in: 3600,
112
+ scope: 'read write',
113
+ token_type: 'Bearer',
114
+ },
115
+ };
116
+ });
117
+
118
+ // ---------------------------------------------------------------------------
119
+ // Tests
120
+ // ---------------------------------------------------------------------------
121
+
122
+ describe('OAuth2 gateway transport', () => {
123
+ describe('auto-detection', () => {
124
+ test('selects gateway transport when ingress.publicBaseUrl is configured', async () => {
125
+ mockPublicBaseUrl = 'https://gw.example.com';
126
+
127
+ let capturedAuthUrl = '';
128
+ const flowPromise = startOAuth2Flow(BASE_OAUTH_CONFIG, {
129
+ openUrl: (url) => { capturedAuthUrl = url; },
130
+ });
131
+
132
+ // Give the flow a tick to register the callback and open the browser
133
+ await new Promise((r) => setTimeout(r, 10));
134
+
135
+ // The auth URL should contain the gateway redirect_uri, not a loopback one
136
+ expect(capturedAuthUrl).toContain('redirect_uri=');
137
+ expect(capturedAuthUrl).not.toContain('127.0.0.1');
138
+ expect(capturedAuthUrl).toContain(encodeURIComponent('https://gw.example.com'));
139
+
140
+ // Resolve the pending callback to complete the flow
141
+ const entries = Array.from(pendingCallbacks.entries());
142
+ expect(entries.length).toBe(1);
143
+ const [, { resolve }] = entries[0];
144
+ resolve('auth-code-from-gateway');
145
+
146
+ const result = await flowPromise;
147
+ expect(result.tokens.accessToken).toBe('test-access-token');
148
+ });
149
+
150
+ test('selects loopback transport when ingress.publicBaseUrl is empty', async () => {
151
+ mockPublicBaseUrl = '';
152
+
153
+ let capturedAuthUrl = '';
154
+ const flowPromise = startOAuth2Flow(BASE_OAUTH_CONFIG, {
155
+ openUrl: (url) => { capturedAuthUrl = url; },
156
+ });
157
+
158
+ // Give the flow a tick
159
+ await new Promise((r) => setTimeout(r, 10));
160
+
161
+ // The auth URL should contain a loopback redirect_uri
162
+ expect(capturedAuthUrl).toContain('redirect_uri=');
163
+ expect(capturedAuthUrl).toContain('127.0.0.1');
164
+
165
+ // Extract the redirect_uri to send the callback
166
+ const authUrlParsed = new URL(capturedAuthUrl);
167
+ const redirectUri = authUrlParsed.searchParams.get('redirect_uri')!;
168
+ const stateParam = authUrlParsed.searchParams.get('state')!;
169
+
170
+ // Simulate the OAuth provider callback to the loopback server
171
+ const callbackUrl = `${redirectUri}?code=loopback-code&state=${stateParam}`;
172
+ await fetch(callbackUrl);
173
+
174
+ const result = await flowPromise;
175
+ expect(result.tokens.accessToken).toBe('test-access-token');
176
+ });
177
+ });
178
+
179
+ describe('explicit transport', () => {
180
+ test('uses gateway transport when explicitly specified', async () => {
181
+ // Even with no publicBaseUrl, explicit gateway should work
182
+ mockPublicBaseUrl = 'https://gw.example.com';
183
+
184
+ let capturedAuthUrl = '';
185
+ const flowPromise = startOAuth2Flow(
186
+ BASE_OAUTH_CONFIG,
187
+ { openUrl: (url) => { capturedAuthUrl = url; } },
188
+ { callbackTransport: 'gateway' },
189
+ );
190
+
191
+ await new Promise((r) => setTimeout(r, 10));
192
+
193
+ expect(capturedAuthUrl).toContain(encodeURIComponent('https://gw.example.com'));
194
+
195
+ const entries = Array.from(pendingCallbacks.entries());
196
+ expect(entries.length).toBe(1);
197
+ entries[0][1].resolve('explicit-gateway-code');
198
+
199
+ const result = await flowPromise;
200
+ expect(result.tokens.accessToken).toBe('test-access-token');
201
+ });
202
+
203
+ test('uses loopback transport when explicitly specified', async () => {
204
+ // Even with publicBaseUrl configured, explicit loopback should work
205
+ mockPublicBaseUrl = 'https://gw.example.com';
206
+
207
+ let capturedAuthUrl = '';
208
+ const flowPromise = startOAuth2Flow(
209
+ BASE_OAUTH_CONFIG,
210
+ { openUrl: (url) => { capturedAuthUrl = url; } },
211
+ { callbackTransport: 'loopback' },
212
+ );
213
+
214
+ await new Promise((r) => setTimeout(r, 10));
215
+
216
+ expect(capturedAuthUrl).toContain('127.0.0.1');
217
+
218
+ const authUrlParsed = new URL(capturedAuthUrl);
219
+ const redirectUri = authUrlParsed.searchParams.get('redirect_uri')!;
220
+ const stateParam = authUrlParsed.searchParams.get('state')!;
221
+
222
+ await fetch(`${redirectUri}?code=loopback-code&state=${stateParam}`);
223
+
224
+ const result = await flowPromise;
225
+ expect(result.tokens.accessToken).toBe('test-access-token');
226
+ });
227
+ });
228
+
229
+ describe('gateway transport flow', () => {
230
+ test('success: register callback, consume with code, exchange for tokens', async () => {
231
+ mockPublicBaseUrl = 'https://gw.example.com';
232
+
233
+ const flowPromise = startOAuth2Flow(
234
+ BASE_OAUTH_CONFIG,
235
+ { openUrl: () => {} },
236
+ { callbackTransport: 'gateway' },
237
+ );
238
+
239
+ await new Promise((r) => setTimeout(r, 10));
240
+
241
+ // A callback should be registered
242
+ const entries = Array.from(pendingCallbacks.entries());
243
+ expect(entries.length).toBe(1);
244
+
245
+ // Simulate gateway delivering the authorization code
246
+ const [state, { resolve }] = entries[0];
247
+ expect(typeof state).toBe('string');
248
+ expect(state.length).toBeGreaterThan(0);
249
+
250
+ resolve('gateway-auth-code');
251
+
252
+ const result = await flowPromise;
253
+ expect(result.tokens.accessToken).toBe('test-access-token');
254
+ expect(result.tokens.refreshToken).toBe('test-refresh-token');
255
+ expect(result.tokens.expiresIn).toBe(3600);
256
+ expect(result.grantedScopes).toEqual(['read', 'write']);
257
+ });
258
+
259
+ test('error: register callback, consume with error, rejects', async () => {
260
+ mockPublicBaseUrl = 'https://gw.example.com';
261
+
262
+ const flowPromise = startOAuth2Flow(
263
+ BASE_OAUTH_CONFIG,
264
+ { openUrl: () => {} },
265
+ { callbackTransport: 'gateway' },
266
+ );
267
+
268
+ await new Promise((r) => setTimeout(r, 10));
269
+
270
+ const entries = Array.from(pendingCallbacks.entries());
271
+ expect(entries.length).toBe(1);
272
+
273
+ // Simulate the gateway delivering an error (e.g. user denied access)
274
+ const [, { reject }] = entries[0];
275
+ reject(new Error('OAuth2 authorization denied: access_denied'));
276
+
277
+ await expect(flowPromise).rejects.toThrow('OAuth2 authorization denied: access_denied');
278
+ });
279
+
280
+ test('token exchange failure propagates error', async () => {
281
+ mockPublicBaseUrl = 'https://gw.example.com';
282
+ mockTokenResponse = { ok: false, status: 400, body: { error: 'invalid_grant' } };
283
+
284
+ const flowPromise = startOAuth2Flow(
285
+ BASE_OAUTH_CONFIG,
286
+ { openUrl: () => {} },
287
+ { callbackTransport: 'gateway' },
288
+ );
289
+
290
+ await new Promise((r) => setTimeout(r, 10));
291
+
292
+ const entries = Array.from(pendingCallbacks.entries());
293
+ entries[0][1].resolve('code-that-fails-exchange');
294
+
295
+ await expect(flowPromise).rejects.toThrow('OAuth2 token exchange failed');
296
+ });
297
+ });
298
+ });
@@ -262,34 +262,51 @@ describe('ProviderCommitMessageGenerator', () => {
262
262
  expect(result.reason).toBe('invalid_output');
263
263
  });
264
264
 
265
- // 11. LLM output too long (subject > 72 chars)
266
- test('LLM output too long (subject > 72 chars)returns deterministic, reason "invalid_output"', async () => {
267
- const longSubject = 'a'.repeat(73);
265
+ // 11. LLM subject > 72 chars truncated to 72, still source "llm"
266
+ test('LLM subject > 72 chars → truncated to 72, source "llm"', async () => {
267
+ const longSubject = 'a'.repeat(100);
268
268
  mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(longSubject));
269
269
  const gen = getCommitMessageGenerator();
270
270
  const result = await gen.generateCommitMessage(baseContext, {
271
271
  changedFiles: baseContext.changedFiles,
272
272
  });
273
- expect(result.source).toBe('deterministic');
274
- expect(result.reason).toBe('invalid_output');
273
+ expect(result.source).toBe('llm');
274
+ expect(result.reason).toBeUndefined();
275
+ expect(result.message.split('\n')[0].length).toBeLessThanOrEqual(72);
276
+ expect(result.message).toBe('a'.repeat(72));
275
277
  });
276
278
 
277
- // 12. Keyless provider (Ollama) skips API key preflight
278
- test('Ollama without API keydoes not return missing_provider_api_key', async () => {
279
+ // 11b. LLM subject > 72 chars with body → subject truncated, body preserved
280
+ test('LLM subject > 72 chars with body subject truncated, body preserved', async () => {
281
+ const longSubject = 'b'.repeat(80);
282
+ const body = '\n\n- bullet one\n- bullet two';
283
+ mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(longSubject + body));
284
+ const gen = getCommitMessageGenerator();
285
+ const result = await gen.generateCommitMessage(baseContext, {
286
+ changedFiles: baseContext.changedFiles,
287
+ });
288
+ expect(result.source).toBe('llm');
289
+ expect(result.reason).toBeUndefined();
290
+ expect(result.message.split('\n')[0].length).toBeLessThanOrEqual(72);
291
+ expect(result.message).toBe('b'.repeat(72) + body);
292
+ });
293
+
294
+ // 12. Keyless provider (Ollama) without fast model → missing_fast_model (skips API key check)
295
+ test('Ollama without API key or fast model → returns deterministic, reason "missing_fast_model"', async () => {
279
296
  (currentConfig as Record<string, unknown>).provider = 'ollama';
280
297
  currentConfig.apiKeys = {} as Record<string, string>;
281
- // Ollama has no default fast model, so it will fall through to provider_error,
282
- // but crucially it should NOT be blocked by missing_provider_api_key
283
298
  const gen = getCommitMessageGenerator();
284
299
  const result = await gen.generateCommitMessage(baseContext, {
285
300
  changedFiles: baseContext.changedFiles,
286
301
  });
287
302
  expect(result.source).toBe('deterministic');
303
+ expect(result.reason).toBe('missing_fast_model');
288
304
  expect(result.reason).not.toBe('missing_provider_api_key');
305
+ expect(mockSendMessage).not.toHaveBeenCalled();
289
306
  });
290
307
 
291
- // 13. No default fast model for unknown provider
292
- test('No default fast model for unknown provider → returns deterministic fallback', async () => {
308
+ // 13. Unknown provider without fast model default missing_fast_model, no provider call
309
+ test('Unknown provider without fast model default → returns deterministic, reason "missing_fast_model"', async () => {
293
310
  (currentConfig as Record<string, unknown>).provider = 'exotic-provider';
294
311
  currentConfig.apiKeys = { 'exotic-provider': 'sk-exotic' } as Record<string, string>;
295
312
  const gen = getCommitMessageGenerator();
@@ -297,7 +314,29 @@ describe('ProviderCommitMessageGenerator', () => {
297
314
  changedFiles: baseContext.changedFiles,
298
315
  });
299
316
  expect(result.source).toBe('deterministic');
300
- expect(result.reason).toBe('provider_error');
317
+ expect(result.reason).toBe('missing_fast_model');
301
318
  expect(mockSendMessage).not.toHaveBeenCalled();
302
319
  });
320
+
321
+ // 14. Fast-model override enables LLM path for provider without built-in default
322
+ test('fast-model override enables LLM path for provider without built-in default', async () => {
323
+ (currentConfig as Record<string, unknown>).provider = 'ollama';
324
+ currentConfig.apiKeys = {} as Record<string, string>; // Ollama is keyless
325
+ currentConfig.workspaceGit.commitMessageLLM.providerFastModelOverrides = {
326
+ ollama: 'llama3.2:3b',
327
+ };
328
+ const commitMsg = 'fix: local model commit';
329
+ mockSendMessage.mockResolvedValueOnce(makeSuccessResponse(commitMsg));
330
+ const gen = getCommitMessageGenerator();
331
+ const result = await gen.generateCommitMessage(baseContext, {
332
+ changedFiles: baseContext.changedFiles,
333
+ });
334
+ expect(result.source).toBe('llm');
335
+ expect(result.message).toBe(commitMsg);
336
+
337
+ // Verify the override model was passed
338
+ const callArgs = mockSendMessage.mock.calls[0];
339
+ const options = callArgs[3] as { config: { model: string } };
340
+ expect(options.config.model).toBe('llama3.2:3b');
341
+ });
303
342
  });
@@ -0,0 +1,206 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // Mocks — silence logger output during tests
5
+ // ---------------------------------------------------------------------------
6
+
7
+ function makeLoggerStub(): Record<string, unknown> {
8
+ const stub: Record<string, unknown> = {};
9
+ for (const m of ['info', 'warn', 'error', 'debug', 'trace', 'fatal', 'silent', 'child']) {
10
+ stub[m] = m === 'child' ? () => makeLoggerStub() : () => {};
11
+ }
12
+ return stub;
13
+ }
14
+
15
+ mock.module('../util/logger.js', () => ({
16
+ getLogger: () => makeLoggerStub(),
17
+ }));
18
+
19
+ import {
20
+ getPublicBaseUrl,
21
+ getTwilioVoiceWebhookUrl,
22
+ getTwilioStatusCallbackUrl,
23
+ getTwilioConnectActionUrl,
24
+ getTwilioRelayUrl,
25
+ getOAuthCallbackUrl,
26
+ } from '../inbound/public-ingress-urls.js';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // getPublicBaseUrl — fallback chain
30
+ // ---------------------------------------------------------------------------
31
+
32
+ describe('getPublicBaseUrl', () => {
33
+ let savedEnv: string | undefined;
34
+
35
+ beforeEach(() => {
36
+ savedEnv = process.env.TWILIO_WEBHOOK_BASE_URL;
37
+ delete process.env.TWILIO_WEBHOOK_BASE_URL;
38
+ });
39
+
40
+ afterEach(() => {
41
+ if (savedEnv !== undefined) {
42
+ process.env.TWILIO_WEBHOOK_BASE_URL = savedEnv;
43
+ } else {
44
+ delete process.env.TWILIO_WEBHOOK_BASE_URL;
45
+ }
46
+ });
47
+
48
+ test('prefers ingress.publicBaseUrl when set', () => {
49
+ const result = getPublicBaseUrl({
50
+ ingress: { publicBaseUrl: 'https://ingress.example.com/' },
51
+ calls: { webhookBaseUrl: 'https://calls.example.com' },
52
+ });
53
+ expect(result).toBe('https://ingress.example.com');
54
+ });
55
+
56
+ test('falls back to calls.webhookBaseUrl when ingress.publicBaseUrl is empty', () => {
57
+ const result = getPublicBaseUrl({
58
+ ingress: { publicBaseUrl: '' },
59
+ calls: { webhookBaseUrl: 'https://calls.example.com/' },
60
+ });
61
+ expect(result).toBe('https://calls.example.com');
62
+ });
63
+
64
+ test('falls back to calls.webhookBaseUrl when ingress is undefined', () => {
65
+ const result = getPublicBaseUrl({
66
+ calls: { webhookBaseUrl: 'https://calls.example.com' },
67
+ });
68
+ expect(result).toBe('https://calls.example.com');
69
+ });
70
+
71
+ test('falls back to TWILIO_WEBHOOK_BASE_URL env var when both config fields are empty', () => {
72
+ process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com/';
73
+ const result = getPublicBaseUrl({
74
+ ingress: { publicBaseUrl: '' },
75
+ calls: { webhookBaseUrl: '' },
76
+ });
77
+ expect(result).toBe('https://env.example.com');
78
+ });
79
+
80
+ test('falls back to env var when config fields are undefined', () => {
81
+ process.env.TWILIO_WEBHOOK_BASE_URL = 'https://env.example.com';
82
+ const result = getPublicBaseUrl({});
83
+ expect(result).toBe('https://env.example.com');
84
+ });
85
+
86
+ test('throws when no source provides a value', () => {
87
+ expect(() => getPublicBaseUrl({
88
+ ingress: { publicBaseUrl: '' },
89
+ calls: { webhookBaseUrl: '' },
90
+ })).toThrow(/No public base URL configured/);
91
+ });
92
+
93
+ test('throws when all sources are undefined', () => {
94
+ expect(() => getPublicBaseUrl({})).toThrow(/No public base URL configured/);
95
+ });
96
+
97
+ test('normalizes trailing slashes from ingress.publicBaseUrl', () => {
98
+ const result = getPublicBaseUrl({
99
+ ingress: { publicBaseUrl: 'https://example.com///' },
100
+ });
101
+ expect(result).toBe('https://example.com');
102
+ });
103
+
104
+ test('trims whitespace from ingress.publicBaseUrl', () => {
105
+ const result = getPublicBaseUrl({
106
+ ingress: { publicBaseUrl: ' https://example.com ' },
107
+ });
108
+ expect(result).toBe('https://example.com');
109
+ });
110
+
111
+ test('skips whitespace-only ingress.publicBaseUrl and falls through', () => {
112
+ const result = getPublicBaseUrl({
113
+ ingress: { publicBaseUrl: ' ' },
114
+ calls: { webhookBaseUrl: 'https://calls.example.com' },
115
+ });
116
+ expect(result).toBe('https://calls.example.com');
117
+ });
118
+ });
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // getTwilioVoiceWebhookUrl
122
+ // ---------------------------------------------------------------------------
123
+
124
+ describe('getTwilioVoiceWebhookUrl', () => {
125
+ test('builds correct URL with callSessionId', () => {
126
+ const url = getTwilioVoiceWebhookUrl(
127
+ { ingress: { publicBaseUrl: 'https://example.com' } },
128
+ 'session-123',
129
+ );
130
+ expect(url).toBe('https://example.com/webhooks/twilio/voice?callSessionId=session-123');
131
+ });
132
+
133
+ test('normalizes base URL before composing', () => {
134
+ const url = getTwilioVoiceWebhookUrl(
135
+ { ingress: { publicBaseUrl: 'https://example.com/' } },
136
+ 'abc',
137
+ );
138
+ expect(url).toBe('https://example.com/webhooks/twilio/voice?callSessionId=abc');
139
+ });
140
+ });
141
+
142
+ // ---------------------------------------------------------------------------
143
+ // getTwilioStatusCallbackUrl
144
+ // ---------------------------------------------------------------------------
145
+
146
+ describe('getTwilioStatusCallbackUrl', () => {
147
+ test('builds correct URL', () => {
148
+ const url = getTwilioStatusCallbackUrl({
149
+ ingress: { publicBaseUrl: 'https://example.com' },
150
+ });
151
+ expect(url).toBe('https://example.com/webhooks/twilio/status');
152
+ });
153
+ });
154
+
155
+ // ---------------------------------------------------------------------------
156
+ // getTwilioConnectActionUrl
157
+ // ---------------------------------------------------------------------------
158
+
159
+ describe('getTwilioConnectActionUrl', () => {
160
+ test('builds correct URL', () => {
161
+ const url = getTwilioConnectActionUrl({
162
+ ingress: { publicBaseUrl: 'https://example.com' },
163
+ });
164
+ expect(url).toBe('https://example.com/webhooks/twilio/connect-action');
165
+ });
166
+ });
167
+
168
+ // ---------------------------------------------------------------------------
169
+ // getTwilioRelayUrl — scheme conversion
170
+ // ---------------------------------------------------------------------------
171
+
172
+ describe('getTwilioRelayUrl', () => {
173
+ test('converts https to wss', () => {
174
+ const url = getTwilioRelayUrl({
175
+ ingress: { publicBaseUrl: 'https://example.com' },
176
+ });
177
+ expect(url).toBe('wss://example.com/webhooks/twilio/relay');
178
+ });
179
+
180
+ test('converts http to ws', () => {
181
+ const url = getTwilioRelayUrl({
182
+ ingress: { publicBaseUrl: 'http://localhost:7821' },
183
+ });
184
+ expect(url).toBe('ws://localhost:7821/webhooks/twilio/relay');
185
+ });
186
+
187
+ test('normalizes trailing slash before conversion', () => {
188
+ const url = getTwilioRelayUrl({
189
+ ingress: { publicBaseUrl: 'https://example.com/' },
190
+ });
191
+ expect(url).toBe('wss://example.com/webhooks/twilio/relay');
192
+ });
193
+ });
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // getOAuthCallbackUrl
197
+ // ---------------------------------------------------------------------------
198
+
199
+ describe('getOAuthCallbackUrl', () => {
200
+ test('builds correct URL', () => {
201
+ const url = getOAuthCallbackUrl({
202
+ ingress: { publicBaseUrl: 'https://example.com' },
203
+ });
204
+ expect(url).toBe('https://example.com/webhooks/oauth/callback');
205
+ });
206
+ });