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.
- package/bun.lock +4 -4
- package/package.json +4 -3
- package/src/__tests__/asset-materialize-tool.test.ts +2 -2
- package/src/__tests__/checker.test.ts +104 -0
- package/src/__tests__/config-schema.test.ts +0 -6
- package/src/__tests__/forbidden-legacy-symbols.test.ts +69 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +538 -0
- package/src/__tests__/ingress-url-consistency.test.ts +214 -0
- package/src/__tests__/ipc-snapshot.test.ts +17 -5
- package/src/__tests__/oauth-callback-registry.test.ts +85 -0
- package/src/__tests__/oauth2-gateway-transport.test.ts +304 -0
- package/src/__tests__/provider-commit-message-generator.test.ts +51 -12
- package/src/__tests__/public-ingress-urls.test.ts +222 -0
- package/src/__tests__/runtime-events-sse-parity.test.ts +343 -0
- package/src/__tests__/runtime-events-sse.test.ts +162 -0
- package/src/__tests__/tool-executor.test.ts +88 -0
- package/src/__tests__/turn-commit.test.ts +64 -0
- package/src/__tests__/twilio-provider.test.ts +1 -1
- package/src/__tests__/twilio-routes.test.ts +4 -4
- package/src/__tests__/twitter-auth-handler.test.ts +87 -2
- package/src/calls/call-domain.ts +8 -6
- package/src/calls/twilio-config.ts +18 -3
- package/src/calls/twilio-routes.ts +10 -2
- package/src/config/bundled-skills/tasks/TOOLS.json +25 -0
- package/src/config/bundled-skills/tasks/tools/task-queue-run.ts +9 -0
- package/src/config/bundled-skills/transcribe/SKILL.md +25 -0
- package/src/config/bundled-skills/transcribe/TOOLS.json +32 -0
- package/src/config/bundled-skills/transcribe/tools/transcribe-media.ts +370 -0
- package/src/config/defaults.ts +4 -1
- package/src/config/schema.ts +30 -6
- package/src/config/system-prompt.ts +1 -1
- package/src/config/types.ts +1 -0
- package/src/config/vellum-skills/google-oauth-setup/SKILL.md +5 -4
- package/src/config/vellum-skills/slack-oauth-setup/SKILL.md +4 -2
- package/src/config/vellum-skills/telegram-setup/SKILL.md +3 -3
- package/src/daemon/computer-use-session.ts +2 -1
- package/src/daemon/handlers/config.ts +49 -17
- package/src/daemon/handlers/sessions.ts +2 -2
- package/src/daemon/handlers/shared.ts +1 -0
- package/src/daemon/handlers/subagents.ts +85 -2
- package/src/daemon/handlers/twitter-auth.ts +31 -2
- package/src/daemon/handlers/work-items.ts +1 -1
- package/src/daemon/ipc-contract-inventory.json +8 -4
- package/src/daemon/ipc-contract.ts +34 -15
- package/src/daemon/lifecycle.ts +9 -4
- package/src/daemon/server.ts +7 -0
- package/src/daemon/session-tool-setup.ts +8 -1
- package/src/inbound/public-ingress-urls.ts +112 -0
- package/src/memory/attachments-store.ts +0 -1
- package/src/memory/channel-delivery-store.ts +0 -1
- package/src/memory/conversation-key-store.ts +0 -1
- package/src/memory/db.ts +472 -148
- package/src/memory/llm-usage-store.ts +0 -1
- package/src/memory/runs-store.ts +51 -6
- package/src/memory/schema.ts +2 -6
- package/src/runtime/gateway-client.ts +7 -1
- package/src/runtime/http-server.ts +174 -7
- package/src/runtime/routes/channel-routes.ts +7 -2
- package/src/runtime/routes/events-routes.ts +79 -0
- package/src/runtime/routes/run-routes.ts +43 -0
- package/src/runtime/run-orchestrator.ts +64 -7
- package/src/security/oauth-callback-registry.ts +66 -0
- package/src/security/oauth2.ts +208 -58
- package/src/subagent/manager.ts +3 -1
- package/src/swarm/backend-claude-code.ts +1 -1
- package/src/tools/assets/search.ts +1 -36
- package/src/tools/claude-code/claude-code.ts +3 -3
- package/src/tools/tasks/work-item-list.ts +16 -2
- package/src/tools/tasks/work-item-run.ts +78 -0
- package/src/util/platform.ts +1 -1
- package/src/work-items/work-item-runner.ts +171 -0
- package/src/workspace/provider-commit-message-generator.ts +39 -23
- package/src/workspace/turn-commit.ts +6 -2
- package/src/__tests__/handlers-twilio-config.test.ts +0 -221
- package/src/calls/__tests__/twilio-webhook-urls.test.ts +0 -162
- 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
|
-
|
|
343
|
-
type: '
|
|
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
|
-
|
|
1133
|
-
type: '
|
|
1134
|
-
|
|
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
|
+
});
|