vellum 0.2.7 → 0.2.9

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 (76) hide show
  1. package/bun.lock +4 -4
  2. package/package.json +4 -3
  3. package/src/__tests__/asset-materialize-tool.test.ts +2 -2
  4. package/src/__tests__/checker.test.ts +104 -0
  5. package/src/__tests__/config-schema.test.ts +0 -6
  6. package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
  7. package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
  8. package/src/__tests__/ingress-url-consistency.test.ts +214 -0
  9. package/src/__tests__/ipc-snapshot.test.ts +17 -5
  10. package/src/__tests__/oauth-callback-registry.test.ts +85 -0
  11. package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
  12. package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
  13. package/src/__tests__/public-ingress-urls.test.ts +222 -0
  14. package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
  15. package/src/__tests__/runtime-events-sse.test.ts +162 -0
  16. package/src/__tests__/tool-executor.test.ts +88 -0
  17. package/src/__tests__/turn-commit.test.ts +64 -0
  18. package/src/__tests__/twilio-provider.test.ts +1 -1
  19. package/src/__tests__/twilio-routes.test.ts +4 -4
  20. package/src/__tests__/twitter-auth-handler.test.ts +87 -2
  21. package/src/calls/call-domain.ts +8 -6
  22. package/src/calls/twilio-config.ts +18 -3
  23. package/src/calls/twilio-routes.ts +10 -2
  24. package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
  25. package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
  26. package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
  27. package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
  28. package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
  29. package/src/config/defaults.ts +4 -1
  30. package/src/config/schema.ts +30 -6
  31. package/src/config/system-prompt.ts +1 -1
  32. package/src/config/types.ts +1 -0
  33. package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
  34. package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
  35. package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
  36. package/src/daemon/computer-use-session.ts +2 -1
  37. package/src/daemon/handlers/config.ts +49 -17
  38. package/src/daemon/handlers/sessions.ts +2 -2
  39. package/src/daemon/handlers/shared.ts +1 -0
  40. package/src/daemon/handlers/subagents.ts +85 -2
  41. package/src/daemon/handlers/twitter-auth.ts +31 -2
  42. package/src/daemon/handlers/work-items.ts +1 -1
  43. package/src/daemon/ipc-contract-inventory.json +8 -4
  44. package/src/daemon/ipc-contract.ts +34 -15
  45. package/src/daemon/lifecycle.ts +9 -4
  46. package/src/daemon/server.ts +7 -0
  47. package/src/daemon/session-tool-setup.ts +8 -1
  48. package/src/inbound/public-ingress-urls.ts +112 -0
  49. package/src/memory/attachments-store.ts +0 -1
  50. package/src/memory/channel-delivery-store.ts +0 -1
  51. package/src/memory/conversation-key-store.ts +0 -1
  52. package/src/memory/db.ts +472 -148
  53. package/src/memory/llm-usage-store.ts +0 -1
  54. package/src/memory/runs-store.ts +51 -6
  55. package/src/memory/schema.ts +2 -6
  56. package/src/runtime/gateway-client.ts +7 -1
  57. package/src/runtime/http-server.ts +174 -7
  58. package/src/runtime/routes/channel-routes.ts +7 -2
  59. package/src/runtime/routes/events-routes.ts +79 -0
  60. package/src/runtime/routes/run-routes.ts +43 -0
  61. package/src/runtime/run-orchestrator.ts +64 -7
  62. package/src/security/oauth-callback-registry.ts +66 -0
  63. package/src/security/oauth2.ts +208 -58
  64. package/src/subagent/manager.ts +3 -1
  65. package/src/swarm/backend-claude-code.ts +1 -1
  66. package/src/tools/assets/search.ts +1 -36
  67. package/src/tools/claude-code/claude-code.ts +3 -3
  68. package/src/tools/tasks/work-item-list.ts +16 -2
  69. package/src/tools/tasks/work-item-run.ts +78 -0
  70. package/src/util/platform.ts +1 -1
  71. package/src/work-items/work-item-runner.ts +171 -0
  72. package/src/workspace/provider-commit-message-generator.ts +39 -23
  73. package/src/workspace/turn-commit.ts +6 -2
  74. package/src/__tests__/handlers-twilio-config.test.ts +0 -221
  75. package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
  76. package/src/calls/twilio-webhook-urls.ts +0 -50
@@ -0,0 +1,214 @@
1
+ import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
2
+ import { createHmac } from 'node:crypto';
3
+
4
+ // ---------------------------------------------------------------------------
5
+ // Mocks — silence logger output during tests
6
+ // ---------------------------------------------------------------------------
7
+
8
+ function makeLoggerStub(): Record<string, unknown> {
9
+ const stub: Record<string, unknown> = {};
10
+ for (const m of ['info', 'warn', 'error', 'debug', 'trace', 'fatal', 'silent', 'child']) {
11
+ stub[m] = m === 'child' ? () => makeLoggerStub() : () => {};
12
+ }
13
+ return stub;
14
+ }
15
+
16
+ mock.module('../util/logger.js', () => ({
17
+ getLogger: () => makeLoggerStub(),
18
+ }));
19
+
20
+ import {
21
+ getPublicBaseUrl,
22
+ getTwilioVoiceWebhookUrl,
23
+ getTwilioStatusCallbackUrl,
24
+ type IngressConfig,
25
+ } from '../inbound/public-ingress-urls.js';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers — simulate Twilio signature validation the same way the gateway does
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * Reproduce the gateway's canonical URL reconstruction logic from
33
+ * gateway/src/twilio/validate-webhook.ts (lines 72-76).
34
+ */
35
+ function reconstructGatewayCanonicalUrl(
36
+ ingressPublicBaseUrl: string | undefined,
37
+ requestUrl: string,
38
+ ): string {
39
+ const parsedUrl = new URL(requestUrl);
40
+ if (ingressPublicBaseUrl) {
41
+ return ingressPublicBaseUrl.replace(/\/$/, '') + parsedUrl.pathname + parsedUrl.search;
42
+ }
43
+ return requestUrl;
44
+ }
45
+
46
+ /**
47
+ * Reproduce Twilio's HMAC-SHA1 signature algorithm (same as
48
+ * gateway/src/twilio/verify.ts).
49
+ */
50
+ function computeTwilioSignature(
51
+ url: string,
52
+ params: Record<string, string>,
53
+ authToken: string,
54
+ ): string {
55
+ const sortedKeys = Object.keys(params).sort();
56
+ let data = url;
57
+ for (const key of sortedKeys) {
58
+ data += key + params[key];
59
+ }
60
+ return createHmac('sha1', authToken).update(data).digest('base64');
61
+ }
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Tests
65
+ // ---------------------------------------------------------------------------
66
+
67
+ describe('Ingress URL consistency between assistant and gateway', () => {
68
+ let savedIngressEnv: string | undefined;
69
+
70
+ beforeEach(() => {
71
+ savedIngressEnv = process.env.INGRESS_PUBLIC_BASE_URL;
72
+ delete process.env.INGRESS_PUBLIC_BASE_URL;
73
+ });
74
+
75
+ afterEach(() => {
76
+ if (savedIngressEnv !== undefined) {
77
+ process.env.INGRESS_PUBLIC_BASE_URL = savedIngressEnv;
78
+ } else {
79
+ delete process.env.INGRESS_PUBLIC_BASE_URL;
80
+ }
81
+ });
82
+
83
+ test('assistant callback URL and gateway signature reconstruction use same base when config is set', () => {
84
+ const config: IngressConfig = {
85
+ ingress: { publicBaseUrl: 'https://my-tunnel.ngrok.io' },
86
+ };
87
+
88
+ // What the assistant would generate as the Twilio voice webhook callback
89
+ const assistantCallbackUrl = getTwilioVoiceWebhookUrl(config, 'session-abc');
90
+
91
+ // Simulate: when hatch.ts spawns the gateway, it reads config.ingress.publicBaseUrl
92
+ // and passes it as INGRESS_PUBLIC_BASE_URL. The gateway stores this as
93
+ // config.ingressPublicBaseUrl.
94
+ const gatewayIngressPublicBaseUrl = getPublicBaseUrl(config);
95
+
96
+ // When Twilio calls the gateway, the gateway reconstructs the canonical URL
97
+ // from the inbound request URL (which is localhost) + the configured base.
98
+ const inboundRequestUrl = 'http://127.0.0.1:7830/webhooks/twilio/voice?callSessionId=session-abc';
99
+ const gatewayCanonicalUrl = reconstructGatewayCanonicalUrl(
100
+ gatewayIngressPublicBaseUrl,
101
+ inboundRequestUrl,
102
+ );
103
+
104
+ // Both must resolve to the same URL for Twilio signatures to validate
105
+ expect(gatewayCanonicalUrl).toBe(assistantCallbackUrl);
106
+ });
107
+
108
+ test('Twilio signature computed against assistant URL validates at gateway', () => {
109
+ const publicBase = 'https://my-tunnel.ngrok.io';
110
+ const authToken = 'test-twilio-auth-token-12345';
111
+ const config: IngressConfig = {
112
+ ingress: { publicBaseUrl: publicBase },
113
+ };
114
+
115
+ // Assistant generates the callback URL and registers it with Twilio
116
+ const callbackUrl = getTwilioStatusCallbackUrl(config);
117
+ expect(callbackUrl).toBe('https://my-tunnel.ngrok.io/webhooks/twilio/status');
118
+
119
+ // Twilio signs the request using the callback URL
120
+ const params = { CallSid: 'CA123', CallStatus: 'completed' };
121
+ const twilioSignature = computeTwilioSignature(callbackUrl, params, authToken);
122
+
123
+ // Gateway receives the request on its local address
124
+ const localRequestUrl = 'http://127.0.0.1:7830/webhooks/twilio/status';
125
+
126
+ // Gateway reconstructs the canonical URL using its configured base
127
+ // (which was passed from the assistant's config via INGRESS_PUBLIC_BASE_URL)
128
+ const gatewayIngressPublicBaseUrl = getPublicBaseUrl(config);
129
+ const canonicalUrl = reconstructGatewayCanonicalUrl(
130
+ gatewayIngressPublicBaseUrl,
131
+ localRequestUrl,
132
+ );
133
+
134
+ // Verify the signature matches
135
+ const recomputedSignature = computeTwilioSignature(canonicalUrl, params, authToken);
136
+ expect(recomputedSignature).toBe(twilioSignature);
137
+ });
138
+
139
+ test('mismatch scenario: gateway without config creates signature validation failure', () => {
140
+ const authToken = 'test-twilio-auth-token-12345';
141
+
142
+ // Assistant uses config-based URL
143
+ const assistantConfig: IngressConfig = {
144
+ ingress: { publicBaseUrl: 'https://my-tunnel.ngrok.io' },
145
+ };
146
+ const callbackUrl = getTwilioStatusCallbackUrl(assistantConfig);
147
+
148
+ // Twilio signs against the callback URL the assistant registered
149
+ const params = { CallSid: 'CA123', CallStatus: 'completed' };
150
+ const twilioSignature = computeTwilioSignature(callbackUrl, params, authToken);
151
+
152
+ // Gateway does NOT have the ingress URL configured (simulating the bug)
153
+ const localRequestUrl = 'http://127.0.0.1:7830/webhooks/twilio/status';
154
+ const canonicalUrlWithout = reconstructGatewayCanonicalUrl(undefined, localRequestUrl);
155
+
156
+ // Signature should NOT match — this proves the mismatch bug
157
+ const recomputedWithout = computeTwilioSignature(canonicalUrlWithout, params, authToken);
158
+ expect(recomputedWithout).not.toBe(twilioSignature);
159
+
160
+ // Now simulate the fix: gateway has the same ingress URL
161
+ const canonicalUrlWith = reconstructGatewayCanonicalUrl(
162
+ 'https://my-tunnel.ngrok.io',
163
+ localRequestUrl,
164
+ );
165
+ const recomputedWith = computeTwilioSignature(canonicalUrlWith, params, authToken);
166
+ expect(recomputedWith).toBe(twilioSignature);
167
+ });
168
+
169
+ test('env var fallback produces consistent URLs across assistant and gateway', () => {
170
+ // When no config.ingress.publicBaseUrl is set, both assistant and gateway
171
+ // fall back to the INGRESS_PUBLIC_BASE_URL env var.
172
+ process.env.INGRESS_PUBLIC_BASE_URL = 'https://env-tunnel.example.com';
173
+
174
+ const config: IngressConfig = {};
175
+
176
+ // Assistant resolves the base URL from env
177
+ const assistantBase = getPublicBaseUrl(config);
178
+ expect(assistantBase).toBe('https://env-tunnel.example.com');
179
+
180
+ // Gateway would also read the same env var (process.env.INGRESS_PUBLIC_BASE_URL)
181
+ // and store it as config.ingressPublicBaseUrl.
182
+ const gatewayIngressPublicBaseUrl = process.env.INGRESS_PUBLIC_BASE_URL;
183
+
184
+ // Callback URL generated by assistant
185
+ const callbackUrl = getTwilioVoiceWebhookUrl(config, 'session-xyz');
186
+
187
+ // Gateway canonical URL reconstruction
188
+ const localUrl = 'http://127.0.0.1:7830/webhooks/twilio/voice?callSessionId=session-xyz';
189
+ const gatewayCanonical = reconstructGatewayCanonicalUrl(
190
+ gatewayIngressPublicBaseUrl,
191
+ localUrl,
192
+ );
193
+
194
+ expect(gatewayCanonical).toBe(callbackUrl);
195
+ });
196
+
197
+ test('trailing slashes are normalized consistently', () => {
198
+ const config: IngressConfig = {
199
+ ingress: { publicBaseUrl: 'https://my-tunnel.ngrok.io///' },
200
+ };
201
+
202
+ const assistantBase = getPublicBaseUrl(config);
203
+ expect(assistantBase).toBe('https://my-tunnel.ngrok.io');
204
+
205
+ const callbackUrl = getTwilioVoiceWebhookUrl(config, 'session-1');
206
+
207
+ // Gateway would receive the normalized value (hatch.ts trims trailing slashes)
208
+ const gatewayBase = 'https://my-tunnel.ngrok.io';
209
+ const localUrl = 'http://127.0.0.1:7830/webhooks/twilio/voice?callSessionId=session-1';
210
+ const gatewayCanonical = reconstructGatewayCanonicalUrl(gatewayBase, localUrl);
211
+
212
+ expect(gatewayCanonical).toBe(callbackUrl);
213
+ });
214
+ });
@@ -339,8 +339,8 @@ const clientMessages: Record<ClientMessageType, ClientMessage> = {
339
339
  type: 'slack_webhook_config',
340
340
  action: 'get',
341
341
  },
342
- twilio_webhook_config: {
343
- type: 'twilio_webhook_config',
342
+ ingress_config: {
343
+ type: 'ingress_config',
344
344
  action: 'get',
345
345
  },
346
346
  vercel_api_config: {
@@ -892,6 +892,11 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
892
892
  title: 'Urgent email from Alice',
893
893
  body: 'Meeting rescheduled to 3pm today.',
894
894
  },
895
+ agent_heartbeat_alert: {
896
+ type: 'agent_heartbeat_alert',
897
+ title: 'Agent heartbeat stalled',
898
+ body: 'No activity detected in the last 60 minutes.',
899
+ },
895
900
  watch_started: {
896
901
  type: 'watch_started',
897
902
  sessionId: 'sess-001',
@@ -1129,9 +1134,10 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1129
1134
  webhookUrl: 'https://hooks.slack.com/services/T00/B00/xxx',
1130
1135
  success: true,
1131
1136
  },
1132
- twilio_webhook_config_response: {
1133
- type: 'twilio_webhook_config_response',
1134
- webhookBaseUrl: 'https://example.com/twilio',
1137
+ ingress_config_response: {
1138
+ type: 'ingress_config_response',
1139
+ publicBaseUrl: 'https://example.com',
1140
+ localGatewayTarget: 'http://127.0.0.1:7830',
1135
1141
  success: true,
1136
1142
  },
1137
1143
  vercel_api_config_response: {
@@ -1408,6 +1414,12 @@ const serverMessages: Record<ServerMessageType, ServerMessage> = {
1408
1414
  open_tasks_window: {
1409
1415
  type: 'open_tasks_window',
1410
1416
  },
1417
+ task_run_thread_created: {
1418
+ type: 'task_run_thread_created',
1419
+ conversationId: 'conv-task-run-001',
1420
+ workItemId: 'wi-001',
1421
+ title: 'Process report',
1422
+ },
1411
1423
  subagent_spawned: {
1412
1424
  type: 'subagent_spawned',
1413
1425
  subagentId: 'sub-001',
@@ -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,304 @@
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: (config?: { ingress?: { publicBaseUrl?: string } }) => {
55
+ const url = config?.ingress?.publicBaseUrl ?? mockPublicBaseUrl;
56
+ if (!url) {
57
+ throw new Error('No public base URL configured.');
58
+ }
59
+ return url;
60
+ },
61
+ }));
62
+
63
+ // Mock fetch for token exchange
64
+ let mockTokenResponse: { ok: boolean; status: number; body: Record<string, unknown> } = {
65
+ ok: true,
66
+ status: 200,
67
+ body: {
68
+ access_token: 'test-access-token',
69
+ refresh_token: 'test-refresh-token',
70
+ expires_in: 3600,
71
+ scope: 'read write',
72
+ token_type: 'Bearer',
73
+ },
74
+ };
75
+
76
+ const originalFetch = globalThis.fetch;
77
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
78
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
79
+ if (url.includes('token')) {
80
+ if (!mockTokenResponse.ok) {
81
+ return new Response(JSON.stringify({ error: 'invalid_grant' }), {
82
+ status: mockTokenResponse.status,
83
+ headers: { 'Content-Type': 'application/json' },
84
+ });
85
+ }
86
+ return new Response(JSON.stringify(mockTokenResponse.body), {
87
+ status: mockTokenResponse.status,
88
+ headers: { 'Content-Type': 'application/json' },
89
+ });
90
+ }
91
+ return originalFetch(input, init);
92
+ }) as typeof fetch;
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Import module under test AFTER mocks are in place
96
+ // ---------------------------------------------------------------------------
97
+
98
+ import { startOAuth2Flow, type OAuth2Config } from '../security/oauth2.js';
99
+
100
+ const BASE_OAUTH_CONFIG: OAuth2Config = {
101
+ authUrl: 'https://provider.example.com/authorize',
102
+ tokenUrl: 'https://provider.example.com/token',
103
+ scopes: ['read', 'write'],
104
+ clientId: 'test-client-id',
105
+ };
106
+
107
+ beforeEach(() => {
108
+ mockPublicBaseUrl = '';
109
+ mockOAuthCallbackUrl = 'https://gw.example.com/webhooks/oauth/callback';
110
+ pendingCallbacks.clear();
111
+ mockTokenResponse = {
112
+ ok: true,
113
+ status: 200,
114
+ body: {
115
+ access_token: 'test-access-token',
116
+ refresh_token: 'test-refresh-token',
117
+ expires_in: 3600,
118
+ scope: 'read write',
119
+ token_type: 'Bearer',
120
+ },
121
+ };
122
+ });
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Tests
126
+ // ---------------------------------------------------------------------------
127
+
128
+ describe('OAuth2 gateway transport', () => {
129
+ describe('auto-detection', () => {
130
+ test('selects gateway transport when ingress.publicBaseUrl is configured', async () => {
131
+ mockPublicBaseUrl = 'https://gw.example.com';
132
+
133
+ let capturedAuthUrl = '';
134
+ const flowPromise = startOAuth2Flow(BASE_OAUTH_CONFIG, {
135
+ openUrl: (url) => { capturedAuthUrl = url; },
136
+ });
137
+
138
+ // Give the flow a tick to register the callback and open the browser
139
+ await new Promise((r) => setTimeout(r, 10));
140
+
141
+ // The auth URL should contain the gateway redirect_uri, not a loopback one
142
+ expect(capturedAuthUrl).toContain('redirect_uri=');
143
+ expect(capturedAuthUrl).not.toContain('127.0.0.1');
144
+ expect(capturedAuthUrl).toContain(encodeURIComponent('https://gw.example.com'));
145
+
146
+ // Resolve the pending callback to complete the flow
147
+ const entries = Array.from(pendingCallbacks.entries());
148
+ expect(entries.length).toBe(1);
149
+ const [, { resolve }] = entries[0];
150
+ resolve('auth-code-from-gateway');
151
+
152
+ const result = await flowPromise;
153
+ expect(result.tokens.accessToken).toBe('test-access-token');
154
+ });
155
+
156
+ test('selects loopback transport when ingress.publicBaseUrl is empty', async () => {
157
+ mockPublicBaseUrl = '';
158
+
159
+ let capturedAuthUrl = '';
160
+ const flowPromise = startOAuth2Flow(BASE_OAUTH_CONFIG, {
161
+ openUrl: (url) => { capturedAuthUrl = url; },
162
+ });
163
+
164
+ // Give the flow a tick
165
+ await new Promise((r) => setTimeout(r, 10));
166
+
167
+ // The auth URL should contain a loopback redirect_uri
168
+ expect(capturedAuthUrl).toContain('redirect_uri=');
169
+ expect(capturedAuthUrl).toContain('127.0.0.1');
170
+
171
+ // Extract the redirect_uri to send the callback
172
+ const authUrlParsed = new URL(capturedAuthUrl);
173
+ const redirectUri = authUrlParsed.searchParams.get('redirect_uri')!;
174
+ const stateParam = authUrlParsed.searchParams.get('state')!;
175
+
176
+ // Simulate the OAuth provider callback to the loopback server
177
+ const callbackUrl = `${redirectUri}?code=loopback-code&state=${stateParam}`;
178
+ await fetch(callbackUrl);
179
+
180
+ const result = await flowPromise;
181
+ expect(result.tokens.accessToken).toBe('test-access-token');
182
+ });
183
+ });
184
+
185
+ describe('explicit transport', () => {
186
+ test('uses gateway transport when explicitly specified', async () => {
187
+ // Even with no publicBaseUrl, explicit gateway should work
188
+ mockPublicBaseUrl = 'https://gw.example.com';
189
+
190
+ let capturedAuthUrl = '';
191
+ const flowPromise = startOAuth2Flow(
192
+ BASE_OAUTH_CONFIG,
193
+ { openUrl: (url) => { capturedAuthUrl = url; } },
194
+ { callbackTransport: 'gateway' },
195
+ );
196
+
197
+ await new Promise((r) => setTimeout(r, 10));
198
+
199
+ expect(capturedAuthUrl).toContain(encodeURIComponent('https://gw.example.com'));
200
+
201
+ const entries = Array.from(pendingCallbacks.entries());
202
+ expect(entries.length).toBe(1);
203
+ entries[0][1].resolve('explicit-gateway-code');
204
+
205
+ const result = await flowPromise;
206
+ expect(result.tokens.accessToken).toBe('test-access-token');
207
+ });
208
+
209
+ test('uses loopback transport when explicitly specified', async () => {
210
+ // Even with publicBaseUrl configured, explicit loopback should work
211
+ mockPublicBaseUrl = 'https://gw.example.com';
212
+
213
+ let capturedAuthUrl = '';
214
+ const flowPromise = startOAuth2Flow(
215
+ BASE_OAUTH_CONFIG,
216
+ { openUrl: (url) => { capturedAuthUrl = url; } },
217
+ { callbackTransport: 'loopback' },
218
+ );
219
+
220
+ await new Promise((r) => setTimeout(r, 10));
221
+
222
+ expect(capturedAuthUrl).toContain('127.0.0.1');
223
+
224
+ const authUrlParsed = new URL(capturedAuthUrl);
225
+ const redirectUri = authUrlParsed.searchParams.get('redirect_uri')!;
226
+ const stateParam = authUrlParsed.searchParams.get('state')!;
227
+
228
+ await fetch(`${redirectUri}?code=loopback-code&state=${stateParam}`);
229
+
230
+ const result = await flowPromise;
231
+ expect(result.tokens.accessToken).toBe('test-access-token');
232
+ });
233
+ });
234
+
235
+ describe('gateway transport flow', () => {
236
+ test('success: register callback, consume with code, exchange for tokens', async () => {
237
+ mockPublicBaseUrl = 'https://gw.example.com';
238
+
239
+ const flowPromise = startOAuth2Flow(
240
+ BASE_OAUTH_CONFIG,
241
+ { openUrl: () => {} },
242
+ { callbackTransport: 'gateway' },
243
+ );
244
+
245
+ await new Promise((r) => setTimeout(r, 10));
246
+
247
+ // A callback should be registered
248
+ const entries = Array.from(pendingCallbacks.entries());
249
+ expect(entries.length).toBe(1);
250
+
251
+ // Simulate gateway delivering the authorization code
252
+ const [state, { resolve }] = entries[0];
253
+ expect(typeof state).toBe('string');
254
+ expect(state.length).toBeGreaterThan(0);
255
+
256
+ resolve('gateway-auth-code');
257
+
258
+ const result = await flowPromise;
259
+ expect(result.tokens.accessToken).toBe('test-access-token');
260
+ expect(result.tokens.refreshToken).toBe('test-refresh-token');
261
+ expect(result.tokens.expiresIn).toBe(3600);
262
+ expect(result.grantedScopes).toEqual(['read', 'write']);
263
+ });
264
+
265
+ test('error: register callback, consume with error, rejects', async () => {
266
+ mockPublicBaseUrl = 'https://gw.example.com';
267
+
268
+ const flowPromise = startOAuth2Flow(
269
+ BASE_OAUTH_CONFIG,
270
+ { openUrl: () => {} },
271
+ { callbackTransport: 'gateway' },
272
+ );
273
+
274
+ await new Promise((r) => setTimeout(r, 10));
275
+
276
+ const entries = Array.from(pendingCallbacks.entries());
277
+ expect(entries.length).toBe(1);
278
+
279
+ // Simulate the gateway delivering an error (e.g. user denied access)
280
+ const [, { reject }] = entries[0];
281
+ reject(new Error('OAuth2 authorization denied: access_denied'));
282
+
283
+ await expect(flowPromise).rejects.toThrow('OAuth2 authorization denied: access_denied');
284
+ });
285
+
286
+ test('token exchange failure propagates error', async () => {
287
+ mockPublicBaseUrl = 'https://gw.example.com';
288
+ mockTokenResponse = { ok: false, status: 400, body: { error: 'invalid_grant' } };
289
+
290
+ const flowPromise = startOAuth2Flow(
291
+ BASE_OAUTH_CONFIG,
292
+ { openUrl: () => {} },
293
+ { callbackTransport: 'gateway' },
294
+ );
295
+
296
+ await new Promise((r) => setTimeout(r, 10));
297
+
298
+ const entries = Array.from(pendingCallbacks.entries());
299
+ entries[0][1].resolve('code-that-fails-exchange');
300
+
301
+ await expect(flowPromise).rejects.toThrow('OAuth2 token exchange failed');
302
+ });
303
+ });
304
+ });