@vellumai/assistant 0.4.16 → 0.4.17
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/Dockerfile +6 -6
- package/README.md +1 -2
- package/package.json +1 -1
- package/src/__tests__/call-controller.test.ts +1074 -751
- package/src/__tests__/call-routes-http.test.ts +329 -279
- package/src/__tests__/channel-approval-routes.test.ts +0 -11
- package/src/__tests__/channel-approvals.test.ts +227 -182
- package/src/__tests__/channel-guardian.test.ts +1 -0
- package/src/__tests__/conversation-attention-telegram.test.ts +157 -114
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +164 -104
- package/src/__tests__/conversation-routes.test.ts +71 -41
- package/src/__tests__/daemon-server-session-init.test.ts +258 -191
- package/src/__tests__/deterministic-verification-control-plane.test.ts +183 -134
- package/src/__tests__/extract-email.test.ts +42 -0
- package/src/__tests__/gateway-only-enforcement.test.ts +467 -368
- package/src/__tests__/gateway-only-guard.test.ts +54 -55
- package/src/__tests__/gmail-integration.test.ts +48 -46
- package/src/__tests__/guardian-action-followup-executor.test.ts +215 -150
- package/src/__tests__/guardian-outbound-http.test.ts +334 -208
- package/src/__tests__/guardian-routing-invariants.test.ts +680 -613
- package/src/__tests__/guardian-routing-state.test.ts +257 -209
- package/src/__tests__/guardian-verification-voice-binding.test.ts +47 -40
- package/src/__tests__/handle-user-message-secret-resume.test.ts +44 -21
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +269 -195
- package/src/__tests__/inbound-invite-redemption.test.ts +194 -151
- package/src/__tests__/ingress-reconcile.test.ts +184 -142
- package/src/__tests__/non-member-access-request.test.ts +291 -247
- package/src/__tests__/notification-telegram-adapter.test.ts +60 -46
- package/src/__tests__/recording-intent-handler.test.ts +422 -291
- package/src/__tests__/runtime-attachment-metadata.test.ts +107 -69
- package/src/__tests__/runtime-events-sse.test.ts +67 -50
- package/src/__tests__/send-endpoint-busy.test.ts +314 -232
- package/src/__tests__/session-approval-overrides.test.ts +93 -91
- package/src/__tests__/sms-messaging-provider.test.ts +74 -47
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +339 -274
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +484 -372
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +261 -239
- package/src/__tests__/trusted-contact-multichannel.test.ts +179 -140
- package/src/__tests__/twilio-config.test.ts +49 -41
- package/src/__tests__/twilio-routes-elevenlabs.test.ts +189 -162
- package/src/__tests__/twilio-routes.test.ts +389 -280
- package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +29 -4
- package/src/config/bundled-skills/messaging/SKILL.md +5 -4
- package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +11 -7
- package/src/config/env.ts +39 -29
- package/src/daemon/handlers/skills.ts +18 -10
- package/src/daemon/ipc-contract/messages.ts +1 -0
- package/src/daemon/ipc-contract/surfaces.ts +7 -1
- package/src/daemon/session-agent-loop-handlers.ts +5 -0
- package/src/daemon/session-agent-loop.ts +1 -1
- package/src/daemon/session-process.ts +1 -1
- package/src/daemon/session-surfaces.ts +42 -2
- package/src/runtime/auth/token-service.ts +74 -47
- package/src/sequence/reply-matcher.ts +10 -6
- package/src/skills/frontmatter.ts +9 -6
- package/src/tools/ui-surface/definitions.ts +2 -1
- package/src/util/platform.ts +0 -12
- package/docs/architecture/http-token-refresh.md +0 -274
|
@@ -9,25 +9,27 @@
|
|
|
9
9
|
* - Relay WebSocket upgrade allowed from private network peers/origins
|
|
10
10
|
* - Startup warning when RUNTIME_HTTP_HOST is not loopback
|
|
11
11
|
*/
|
|
12
|
-
import { mkdtempSync, realpathSync } from
|
|
13
|
-
import { tmpdir } from
|
|
14
|
-
import { join } from
|
|
12
|
+
import { mkdtempSync, realpathSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
15
|
|
|
16
|
-
import { afterAll, beforeAll, describe, expect, mock,test } from
|
|
16
|
+
import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
|
|
17
17
|
|
|
18
|
-
const testDir = realpathSync(
|
|
18
|
+
const testDir = realpathSync(
|
|
19
|
+
mkdtempSync(join(tmpdir(), "gw-only-enforcement-test-")),
|
|
20
|
+
);
|
|
19
21
|
|
|
20
|
-
mock.module(
|
|
22
|
+
mock.module("../util/platform.js", () => ({
|
|
21
23
|
getRootDir: () => testDir,
|
|
22
24
|
getDataDir: () => testDir,
|
|
23
|
-
getWorkspaceConfigPath: () => join(testDir,
|
|
24
|
-
isMacOS: () => process.platform ===
|
|
25
|
-
isLinux: () => process.platform ===
|
|
26
|
-
isWindows: () => process.platform ===
|
|
27
|
-
getSocketPath: () => join(testDir,
|
|
28
|
-
getPidPath: () => join(testDir,
|
|
29
|
-
getDbPath: () => join(testDir,
|
|
30
|
-
getLogPath: () => join(testDir,
|
|
25
|
+
getWorkspaceConfigPath: () => join(testDir, "config.json"),
|
|
26
|
+
isMacOS: () => process.platform === "darwin",
|
|
27
|
+
isLinux: () => process.platform === "linux",
|
|
28
|
+
isWindows: () => process.platform === "win32",
|
|
29
|
+
getSocketPath: () => join(testDir, "test.sock"),
|
|
30
|
+
getPidPath: () => join(testDir, "test.pid"),
|
|
31
|
+
getDbPath: () => join(testDir, "test.db"),
|
|
32
|
+
getLogPath: () => join(testDir, "test.log"),
|
|
31
33
|
ensureDataDir: () => {},
|
|
32
34
|
migrateToDataLayout: () => {},
|
|
33
35
|
migrateToWorkspaceLayout: () => {},
|
|
@@ -35,81 +37,92 @@ mock.module('../util/platform.js', () => ({
|
|
|
35
37
|
|
|
36
38
|
const logMessages: { level: string; msg: string; args?: unknown }[] = [];
|
|
37
39
|
|
|
38
|
-
mock.module(
|
|
39
|
-
getLogger: () =>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
40
|
+
mock.module("../util/logger.js", () => ({
|
|
41
|
+
getLogger: () =>
|
|
42
|
+
new Proxy({} as Record<string, unknown>, {
|
|
43
|
+
get: (_target, prop: string) => {
|
|
44
|
+
if (prop === "child")
|
|
45
|
+
return () =>
|
|
46
|
+
new Proxy({} as Record<string, unknown>, {
|
|
47
|
+
get: () => () => {},
|
|
48
|
+
});
|
|
49
|
+
return (...args: unknown[]) => {
|
|
50
|
+
if (typeof args[0] === "string") {
|
|
51
|
+
logMessages.push({ level: prop, msg: args[0] });
|
|
52
|
+
} else if (typeof args[1] === "string") {
|
|
53
|
+
logMessages.push({ level: prop, msg: args[1], args: args[0] });
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
53
58
|
}));
|
|
54
59
|
|
|
55
|
-
mock.module(
|
|
60
|
+
mock.module("../config/loader.js", () => ({
|
|
56
61
|
loadConfig: () => ({
|
|
57
|
-
model:
|
|
58
|
-
provider:
|
|
62
|
+
model: "test",
|
|
63
|
+
provider: "test",
|
|
59
64
|
apiKeys: {},
|
|
60
65
|
memory: { enabled: false },
|
|
61
66
|
rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
|
|
62
67
|
secretDetection: { enabled: false },
|
|
63
68
|
calls: {
|
|
64
69
|
enabled: true,
|
|
65
|
-
provider:
|
|
66
|
-
webhookBaseUrl:
|
|
70
|
+
provider: "twilio",
|
|
71
|
+
webhookBaseUrl: "https://test.example.com",
|
|
67
72
|
maxDurationSeconds: 3600,
|
|
68
73
|
userConsultTimeoutSeconds: 120,
|
|
69
|
-
disclosure: { enabled: false, text:
|
|
74
|
+
disclosure: { enabled: false, text: "" },
|
|
70
75
|
safety: { denyCategories: [] },
|
|
71
76
|
},
|
|
72
77
|
ingress: {
|
|
73
|
-
publicBaseUrl:
|
|
78
|
+
publicBaseUrl: "https://test.example.com",
|
|
74
79
|
},
|
|
75
80
|
sms: {
|
|
76
|
-
phoneNumber:
|
|
81
|
+
phoneNumber: "+15550001111",
|
|
77
82
|
},
|
|
78
83
|
}),
|
|
79
84
|
getConfig: () => ({
|
|
80
|
-
model:
|
|
81
|
-
provider:
|
|
85
|
+
model: "test",
|
|
86
|
+
provider: "test",
|
|
82
87
|
apiKeys: {},
|
|
83
88
|
memory: { enabled: false },
|
|
84
89
|
rateLimit: { maxRequestsPerMinute: 0, maxTokensPerSession: 0 },
|
|
85
90
|
secretDetection: { enabled: false },
|
|
86
91
|
ingress: {
|
|
87
|
-
publicBaseUrl:
|
|
92
|
+
publicBaseUrl: "https://test.example.com",
|
|
88
93
|
},
|
|
89
94
|
sms: {
|
|
90
|
-
phoneNumber:
|
|
95
|
+
phoneNumber: "+15550001111",
|
|
91
96
|
},
|
|
92
97
|
}),
|
|
93
98
|
invalidateConfigCache: () => {},
|
|
94
99
|
}));
|
|
95
100
|
|
|
96
101
|
// Mock Twilio provider
|
|
97
|
-
mock.module(
|
|
102
|
+
mock.module("../calls/twilio-provider.js", () => ({
|
|
98
103
|
TwilioConversationRelayProvider: class {
|
|
99
|
-
static getAuthToken() {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
104
|
+
static getAuthToken() {
|
|
105
|
+
return "mock-auth-token";
|
|
106
|
+
}
|
|
107
|
+
static verifyWebhookSignature() {
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
async initiateCall() {
|
|
111
|
+
return { callSid: "CA_mock_sid" };
|
|
112
|
+
}
|
|
113
|
+
async endCall() {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
103
116
|
},
|
|
104
117
|
}));
|
|
105
118
|
|
|
106
119
|
const secureKeyStore: Record<string, string | undefined> = {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
120
|
+
"credential:twilio:account_sid": "AC_test",
|
|
121
|
+
"credential:twilio:auth_token": "test_token",
|
|
122
|
+
"credential:twilio:phone_number": "+15550001111",
|
|
110
123
|
};
|
|
111
124
|
|
|
112
|
-
mock.module(
|
|
125
|
+
mock.module("../security/secure-keys.js", () => ({
|
|
113
126
|
getSecureKey: (key: string) => secureKeyStore[key] ?? null,
|
|
114
127
|
setSecureKey: (key: string, value: string) => {
|
|
115
128
|
secureKeyStore[key] = value;
|
|
@@ -127,13 +140,13 @@ mock.module('../security/secure-keys.js', () => ({
|
|
|
127
140
|
// the real implementations, causing cross-test contamination.
|
|
128
141
|
|
|
129
142
|
// Mock the oauth callback registry
|
|
130
|
-
mock.module(
|
|
143
|
+
mock.module("../security/oauth-callback-registry.js", () => ({
|
|
131
144
|
consumeCallback: () => true,
|
|
132
145
|
consumeCallbackError: () => true,
|
|
133
146
|
}));
|
|
134
147
|
|
|
135
148
|
// Mock call-store so WebSocket close handlers don't hit the real DB
|
|
136
|
-
mock.module(
|
|
149
|
+
mock.module("../calls/call-store.js", () => ({
|
|
137
150
|
getCallSession: () => null,
|
|
138
151
|
getCallSessionByCallSid: () => null,
|
|
139
152
|
updateCallSession: () => {},
|
|
@@ -141,14 +154,35 @@ mock.module('../calls/call-store.js', () => ({
|
|
|
141
154
|
expirePendingQuestions: () => {},
|
|
142
155
|
}));
|
|
143
156
|
|
|
144
|
-
import {
|
|
157
|
+
import { mintToken } from "../runtime/auth/token-service.js";
|
|
158
|
+
import { isPrivateAddress, RuntimeHttpServer } from "../runtime/http-server.js";
|
|
145
159
|
|
|
146
160
|
// ---------------------------------------------------------------------------
|
|
147
161
|
// Helpers
|
|
148
162
|
// ---------------------------------------------------------------------------
|
|
149
163
|
|
|
150
|
-
|
|
151
|
-
const
|
|
164
|
+
/** Legacy shared secret — used only for pairing routes and non-JWT purposes. */
|
|
165
|
+
const TEST_TOKEN = "test-bearer-token-gw";
|
|
166
|
+
|
|
167
|
+
/** Actor JWT for standard authenticated requests. */
|
|
168
|
+
const TEST_JWT = mintToken({
|
|
169
|
+
aud: "vellum-daemon",
|
|
170
|
+
sub: "actor:self:test",
|
|
171
|
+
scope_profile: "actor_client_v1",
|
|
172
|
+
policy_epoch: 1,
|
|
173
|
+
ttlSeconds: 3600,
|
|
174
|
+
});
|
|
175
|
+
const AUTH_HEADERS = { Authorization: `Bearer ${TEST_JWT}` };
|
|
176
|
+
|
|
177
|
+
/** Gateway JWT for routes that require svc_gateway principal type. */
|
|
178
|
+
const GATEWAY_JWT = mintToken({
|
|
179
|
+
aud: "vellum-daemon",
|
|
180
|
+
sub: "svc:gateway:self",
|
|
181
|
+
scope_profile: "gateway_ingress_v1",
|
|
182
|
+
policy_epoch: 1,
|
|
183
|
+
ttlSeconds: 3600,
|
|
184
|
+
});
|
|
185
|
+
const GATEWAY_AUTH_HEADERS = { Authorization: `Bearer ${GATEWAY_JWT}` };
|
|
152
186
|
|
|
153
187
|
function makeFormBody(params: Record<string, string>): string {
|
|
154
188
|
return new URLSearchParams(params).toString();
|
|
@@ -158,7 +192,7 @@ function makeFormBody(params: Record<string, string>): string {
|
|
|
158
192
|
// Tests
|
|
159
193
|
// ---------------------------------------------------------------------------
|
|
160
194
|
|
|
161
|
-
describe(
|
|
195
|
+
describe("gateway-only ingress enforcement", () => {
|
|
162
196
|
let server: RuntimeHttpServer;
|
|
163
197
|
let port: number;
|
|
164
198
|
|
|
@@ -168,7 +202,7 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
168
202
|
beforeAll(async () => {
|
|
169
203
|
server = new RuntimeHttpServer({
|
|
170
204
|
port: 0,
|
|
171
|
-
hostname:
|
|
205
|
+
hostname: "127.0.0.1",
|
|
172
206
|
bearerToken: TEST_TOKEN,
|
|
173
207
|
});
|
|
174
208
|
await server.start();
|
|
@@ -181,13 +215,12 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
181
215
|
|
|
182
216
|
// ── Runtime does not expose Telegram webhook ingress ─────────────
|
|
183
217
|
|
|
184
|
-
describe(
|
|
185
|
-
|
|
186
|
-
test('POST /webhooks/telegram is rejected (not handled by runtime)', async () => {
|
|
218
|
+
describe("runtime has no Telegram webhook routes", () => {
|
|
219
|
+
test("POST /webhooks/telegram is rejected (not handled by runtime)", async () => {
|
|
187
220
|
const res = await fetch(`http://127.0.0.1:${port}/webhooks/telegram`, {
|
|
188
|
-
method:
|
|
189
|
-
headers: {
|
|
190
|
-
body: JSON.stringify({ update_id: 1, message: { text:
|
|
221
|
+
method: "POST",
|
|
222
|
+
headers: { "Content-Type": "application/json" },
|
|
223
|
+
body: JSON.stringify({ update_id: 1, message: { text: "hello" } }),
|
|
191
224
|
});
|
|
192
225
|
// The runtime has no route for /webhooks/telegram. Without auth, the
|
|
193
226
|
// request is rejected with 401 (auth middleware fires before 404).
|
|
@@ -195,37 +228,43 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
195
228
|
expect(res.status).toBe(401);
|
|
196
229
|
});
|
|
197
230
|
|
|
198
|
-
test(
|
|
231
|
+
test("GET /webhooks/telegram is rejected", async () => {
|
|
199
232
|
const res = await fetch(`http://127.0.0.1:${port}/webhooks/telegram`);
|
|
200
233
|
expect(res.status).toBe(401);
|
|
201
234
|
});
|
|
202
235
|
|
|
203
|
-
test(
|
|
204
|
-
const res = await fetch(
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
236
|
+
test("POST /webhooks/telegram/test is rejected", async () => {
|
|
237
|
+
const res = await fetch(
|
|
238
|
+
`http://127.0.0.1:${port}/webhooks/telegram/test`,
|
|
239
|
+
{
|
|
240
|
+
method: "POST",
|
|
241
|
+
headers: { "Content-Type": "application/json" },
|
|
242
|
+
body: JSON.stringify({}),
|
|
243
|
+
},
|
|
244
|
+
);
|
|
209
245
|
expect(res.status).toBe(401);
|
|
210
246
|
});
|
|
211
247
|
|
|
212
|
-
test(
|
|
248
|
+
test("POST /webhooks/telegram returns 404 when authenticated (no handler exists)", async () => {
|
|
213
249
|
const res = await fetch(`http://127.0.0.1:${port}/webhooks/telegram`, {
|
|
214
|
-
method:
|
|
215
|
-
headers: { ...AUTH_HEADERS,
|
|
216
|
-
body: JSON.stringify({ update_id: 1, message: { text:
|
|
250
|
+
method: "POST",
|
|
251
|
+
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
|
252
|
+
body: JSON.stringify({ update_id: 1, message: { text: "hello" } }),
|
|
217
253
|
});
|
|
218
254
|
// With valid auth, the request passes the auth middleware and reaches
|
|
219
255
|
// route matching — confirming no Telegram webhook handler exists.
|
|
220
256
|
expect(res.status).toBe(404);
|
|
221
257
|
});
|
|
222
258
|
|
|
223
|
-
test(
|
|
224
|
-
const res = await fetch(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
259
|
+
test("POST /webhooks/telegram/test returns 404 when authenticated (no handler exists)", async () => {
|
|
260
|
+
const res = await fetch(
|
|
261
|
+
`http://127.0.0.1:${port}/webhooks/telegram/test`,
|
|
262
|
+
{
|
|
263
|
+
method: "POST",
|
|
264
|
+
headers: { ...AUTH_HEADERS, "Content-Type": "application/json" },
|
|
265
|
+
body: JSON.stringify({}),
|
|
266
|
+
},
|
|
267
|
+
);
|
|
229
268
|
// With valid auth, the request passes the auth middleware and reaches
|
|
230
269
|
// route matching — confirming no Telegram subpath handler exists.
|
|
231
270
|
expect(res.status).toBe(404);
|
|
@@ -234,157 +273,222 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
234
273
|
|
|
235
274
|
// ── Direct Twilio webhook routes blocked in gateway_only mode ──────
|
|
236
275
|
|
|
237
|
-
describe(
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
276
|
+
describe("direct webhook routes are blocked", () => {
|
|
277
|
+
test("POST /webhooks/twilio/voice returns 410", async () => {
|
|
278
|
+
const res = await fetch(
|
|
279
|
+
`http://127.0.0.1:${port}/webhooks/twilio/voice`,
|
|
280
|
+
{
|
|
281
|
+
method: "POST",
|
|
282
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
283
|
+
body: makeFormBody({ CallSid: "CA123", AccountSid: "AC_test" }),
|
|
284
|
+
},
|
|
285
|
+
);
|
|
245
286
|
expect(res.status).toBe(410);
|
|
246
|
-
const body = await res.json() as {
|
|
247
|
-
|
|
248
|
-
|
|
287
|
+
const body = (await res.json()) as {
|
|
288
|
+
error: { code: string; message: string };
|
|
289
|
+
};
|
|
290
|
+
expect(body.error.code).toBe("GONE");
|
|
291
|
+
expect(body.error.message).toContain("Direct webhook access disabled");
|
|
249
292
|
});
|
|
250
293
|
|
|
251
|
-
test(
|
|
252
|
-
const res = await fetch(
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
294
|
+
test("POST /webhooks/twilio/status returns 410", async () => {
|
|
295
|
+
const res = await fetch(
|
|
296
|
+
`http://127.0.0.1:${port}/webhooks/twilio/status`,
|
|
297
|
+
{
|
|
298
|
+
method: "POST",
|
|
299
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
300
|
+
body: makeFormBody({ CallSid: "CA123", CallStatus: "completed" }),
|
|
301
|
+
},
|
|
302
|
+
);
|
|
257
303
|
expect(res.status).toBe(410);
|
|
258
|
-
const body = await res.json() as {
|
|
259
|
-
|
|
304
|
+
const body = (await res.json()) as {
|
|
305
|
+
error: { code: string; message: string };
|
|
306
|
+
};
|
|
307
|
+
expect(body.error.code).toBe("GONE");
|
|
260
308
|
});
|
|
261
309
|
|
|
262
|
-
test(
|
|
263
|
-
const res = await fetch(
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
310
|
+
test("POST /webhooks/twilio/connect-action returns 410", async () => {
|
|
311
|
+
const res = await fetch(
|
|
312
|
+
`http://127.0.0.1:${port}/webhooks/twilio/connect-action`,
|
|
313
|
+
{
|
|
314
|
+
method: "POST",
|
|
315
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
316
|
+
body: makeFormBody({ CallSid: "CA123" }),
|
|
317
|
+
},
|
|
318
|
+
);
|
|
268
319
|
expect(res.status).toBe(410);
|
|
269
|
-
const body = await res.json() as {
|
|
270
|
-
|
|
320
|
+
const body = (await res.json()) as {
|
|
321
|
+
error: { code: string; message: string };
|
|
322
|
+
};
|
|
323
|
+
expect(body.error.code).toBe("GONE");
|
|
271
324
|
});
|
|
272
325
|
|
|
273
|
-
test(
|
|
274
|
-
const res = await fetch(
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
326
|
+
test("POST /v1/calls/twilio/voice-webhook returns 410", async () => {
|
|
327
|
+
const res = await fetch(
|
|
328
|
+
`http://127.0.0.1:${port}/v1/calls/twilio/voice-webhook`,
|
|
329
|
+
{
|
|
330
|
+
method: "POST",
|
|
331
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
332
|
+
body: makeFormBody({ CallSid: "CA123" }),
|
|
333
|
+
},
|
|
334
|
+
);
|
|
279
335
|
expect(res.status).toBe(410);
|
|
280
|
-
const body = await res.json() as {
|
|
281
|
-
|
|
336
|
+
const body = (await res.json()) as {
|
|
337
|
+
error: { code: string; message: string };
|
|
338
|
+
};
|
|
339
|
+
expect(body.error.code).toBe("GONE");
|
|
282
340
|
});
|
|
283
341
|
|
|
284
|
-
test(
|
|
285
|
-
const res = await fetch(
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
342
|
+
test("POST /v1/calls/twilio/status returns 410", async () => {
|
|
343
|
+
const res = await fetch(
|
|
344
|
+
`http://127.0.0.1:${port}/v1/calls/twilio/status`,
|
|
345
|
+
{
|
|
346
|
+
method: "POST",
|
|
347
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
348
|
+
body: makeFormBody({ CallSid: "CA123", CallStatus: "completed" }),
|
|
349
|
+
},
|
|
350
|
+
);
|
|
290
351
|
expect(res.status).toBe(410);
|
|
291
|
-
const body = await res.json() as {
|
|
292
|
-
|
|
352
|
+
const body = (await res.json()) as {
|
|
353
|
+
error: { code: string; message: string };
|
|
354
|
+
};
|
|
355
|
+
expect(body.error.code).toBe("GONE");
|
|
293
356
|
});
|
|
294
357
|
});
|
|
295
358
|
|
|
296
359
|
// ── SMS-specific direct webhook routes blocked ──────────────────────
|
|
297
360
|
|
|
298
|
-
describe(
|
|
299
|
-
|
|
300
|
-
test('POST /webhooks/twilio/sms returns 410 (cannot bypass gateway)', async () => {
|
|
361
|
+
describe("SMS webhook routes are blocked at the runtime (gateway-only)", () => {
|
|
362
|
+
test("POST /webhooks/twilio/sms returns 410 (cannot bypass gateway)", async () => {
|
|
301
363
|
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/sms`, {
|
|
302
|
-
method:
|
|
303
|
-
headers: {
|
|
304
|
-
body: makeFormBody({
|
|
364
|
+
method: "POST",
|
|
365
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
366
|
+
body: makeFormBody({
|
|
367
|
+
Body: "hello",
|
|
368
|
+
From: "+15551234567",
|
|
369
|
+
To: "+15559876543",
|
|
370
|
+
MessageSid: "SM123",
|
|
371
|
+
}),
|
|
305
372
|
});
|
|
306
373
|
expect(res.status).toBe(410);
|
|
307
|
-
const body = await res.json() as {
|
|
308
|
-
|
|
309
|
-
|
|
374
|
+
const body = (await res.json()) as {
|
|
375
|
+
error: { code: string; message: string };
|
|
376
|
+
};
|
|
377
|
+
expect(body.error.code).toBe("GONE");
|
|
378
|
+
expect(body.error.message).toContain("Direct webhook access disabled");
|
|
310
379
|
});
|
|
311
380
|
|
|
312
|
-
test(
|
|
381
|
+
test("POST /v1/calls/twilio/sms returns 410 (legacy path also blocked)", async () => {
|
|
313
382
|
const res = await fetch(`http://127.0.0.1:${port}/v1/calls/twilio/sms`, {
|
|
314
|
-
method:
|
|
315
|
-
headers: {
|
|
316
|
-
body: makeFormBody({
|
|
383
|
+
method: "POST",
|
|
384
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
385
|
+
body: makeFormBody({
|
|
386
|
+
Body: "hello",
|
|
387
|
+
From: "+15551234567",
|
|
388
|
+
MessageSid: "SM456",
|
|
389
|
+
}),
|
|
317
390
|
});
|
|
318
391
|
expect(res.status).toBe(410);
|
|
319
|
-
const body = await res.json() as {
|
|
320
|
-
|
|
392
|
+
const body = (await res.json()) as {
|
|
393
|
+
error: { code: string; message: string };
|
|
394
|
+
};
|
|
395
|
+
expect(body.error.code).toBe("GONE");
|
|
321
396
|
});
|
|
322
397
|
|
|
323
|
-
test(
|
|
398
|
+
test("POST /webhooks/twilio/sms with valid auth still returns 410 (auth does not bypass gateway-only)", async () => {
|
|
324
399
|
const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/sms`, {
|
|
325
|
-
method:
|
|
400
|
+
method: "POST",
|
|
326
401
|
headers: {
|
|
327
402
|
...AUTH_HEADERS,
|
|
328
|
-
|
|
403
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
329
404
|
},
|
|
330
|
-
body: makeFormBody({
|
|
405
|
+
body: makeFormBody({
|
|
406
|
+
Body: "sneaky",
|
|
407
|
+
From: "+15551234567",
|
|
408
|
+
MessageSid: "SM789",
|
|
409
|
+
}),
|
|
331
410
|
});
|
|
332
411
|
// The gateway-only guard runs before auth for Twilio webhook paths
|
|
333
412
|
expect(res.status).toBe(410);
|
|
334
|
-
const body = await res.json() as {
|
|
335
|
-
|
|
413
|
+
const body = (await res.json()) as {
|
|
414
|
+
error: { code: string; message: string };
|
|
415
|
+
};
|
|
416
|
+
expect(body.error.code).toBe("GONE");
|
|
336
417
|
});
|
|
337
418
|
});
|
|
338
419
|
|
|
339
420
|
// ── Internal forwarding routes still work ─────
|
|
340
421
|
|
|
341
|
-
describe(
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
422
|
+
describe("internal forwarding routes are not blocked", () => {
|
|
423
|
+
test("POST /v1/internal/twilio/voice-webhook is NOT blocked", async () => {
|
|
424
|
+
const res = await fetch(
|
|
425
|
+
`http://127.0.0.1:${port}/v1/internal/twilio/voice-webhook`,
|
|
426
|
+
{
|
|
427
|
+
method: "POST",
|
|
428
|
+
headers: {
|
|
429
|
+
...GATEWAY_AUTH_HEADERS,
|
|
430
|
+
"Content-Type": "application/json",
|
|
431
|
+
},
|
|
432
|
+
body: JSON.stringify({
|
|
433
|
+
params: { CallSid: "CA123", AccountSid: "AC_test" },
|
|
434
|
+
originalUrl: `http://127.0.0.1:${port}/v1/internal/twilio/voice-webhook?callSessionId=sess-123`,
|
|
435
|
+
}),
|
|
436
|
+
},
|
|
437
|
+
);
|
|
352
438
|
// Should NOT be 410 — it may 404 or 400 because the call session
|
|
353
439
|
// doesn't exist, but the gateway-only guard should NOT block it.
|
|
354
440
|
expect(res.status).not.toBe(410);
|
|
355
441
|
});
|
|
356
442
|
|
|
357
|
-
test(
|
|
358
|
-
const res = await fetch(
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
443
|
+
test("POST /v1/internal/twilio/status is NOT blocked", async () => {
|
|
444
|
+
const res = await fetch(
|
|
445
|
+
`http://127.0.0.1:${port}/v1/internal/twilio/status`,
|
|
446
|
+
{
|
|
447
|
+
method: "POST",
|
|
448
|
+
headers: {
|
|
449
|
+
...GATEWAY_AUTH_HEADERS,
|
|
450
|
+
"Content-Type": "application/json",
|
|
451
|
+
},
|
|
452
|
+
body: JSON.stringify({
|
|
453
|
+
params: { CallSid: "CA123", CallStatus: "completed" },
|
|
454
|
+
}),
|
|
455
|
+
},
|
|
456
|
+
);
|
|
365
457
|
expect(res.status).not.toBe(410);
|
|
366
458
|
});
|
|
367
459
|
|
|
368
|
-
test(
|
|
369
|
-
const res = await fetch(
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
460
|
+
test("POST /v1/internal/twilio/connect-action is NOT blocked", async () => {
|
|
461
|
+
const res = await fetch(
|
|
462
|
+
`http://127.0.0.1:${port}/v1/internal/twilio/connect-action`,
|
|
463
|
+
{
|
|
464
|
+
method: "POST",
|
|
465
|
+
headers: {
|
|
466
|
+
...GATEWAY_AUTH_HEADERS,
|
|
467
|
+
"Content-Type": "application/json",
|
|
468
|
+
},
|
|
469
|
+
body: JSON.stringify({
|
|
470
|
+
params: { CallSid: "CA123" },
|
|
471
|
+
}),
|
|
472
|
+
},
|
|
473
|
+
);
|
|
376
474
|
expect(res.status).not.toBe(410);
|
|
377
475
|
});
|
|
378
476
|
|
|
379
|
-
test(
|
|
380
|
-
const res = await fetch(
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
477
|
+
test("POST /v1/internal/oauth/callback is NOT blocked", async () => {
|
|
478
|
+
const res = await fetch(
|
|
479
|
+
`http://127.0.0.1:${port}/v1/internal/oauth/callback`,
|
|
480
|
+
{
|
|
481
|
+
method: "POST",
|
|
482
|
+
headers: {
|
|
483
|
+
...GATEWAY_AUTH_HEADERS,
|
|
484
|
+
"Content-Type": "application/json",
|
|
485
|
+
},
|
|
486
|
+
body: JSON.stringify({
|
|
487
|
+
state: "test-state",
|
|
488
|
+
code: "test-code",
|
|
489
|
+
}),
|
|
490
|
+
},
|
|
491
|
+
);
|
|
388
492
|
// Should succeed or return a non-410 status
|
|
389
493
|
expect(res.status).not.toBe(410);
|
|
390
494
|
});
|
|
@@ -392,52 +496,62 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
392
496
|
|
|
393
497
|
// ── Relay WebSocket upgrade ───────────────────
|
|
394
498
|
|
|
395
|
-
describe(
|
|
396
|
-
|
|
397
|
-
test('blocks non-private-network origin', async () => {
|
|
499
|
+
describe("relay WebSocket upgrade", () => {
|
|
500
|
+
test("blocks non-private-network origin", async () => {
|
|
398
501
|
// The peer address (127.0.0.1) passes the private network check,
|
|
399
502
|
// but the external Origin header triggers the secondary defense-in-depth block.
|
|
400
|
-
const res = await fetch(
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
503
|
+
const res = await fetch(
|
|
504
|
+
`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`,
|
|
505
|
+
{
|
|
506
|
+
headers: {
|
|
507
|
+
Upgrade: "websocket",
|
|
508
|
+
Connection: "Upgrade",
|
|
509
|
+
Origin: "https://external.example.com",
|
|
510
|
+
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
|
|
511
|
+
"Sec-WebSocket-Version": "13",
|
|
512
|
+
},
|
|
407
513
|
},
|
|
408
|
-
|
|
514
|
+
);
|
|
409
515
|
expect(res.status).toBe(403);
|
|
410
|
-
const body = await res.json() as {
|
|
411
|
-
|
|
412
|
-
|
|
516
|
+
const body = (await res.json()) as {
|
|
517
|
+
error: { code: string; message: string };
|
|
518
|
+
};
|
|
519
|
+
expect(body.error.code).toBe("FORBIDDEN");
|
|
520
|
+
expect(body.error.message).toContain("Direct relay access disabled");
|
|
413
521
|
});
|
|
414
522
|
|
|
415
|
-
test(
|
|
523
|
+
test("allows request with no origin header (private network peer)", async () => {
|
|
416
524
|
// Without an origin header, isPrivateNetworkOrigin returns true.
|
|
417
525
|
// The peer address (127.0.0.1) passes the private network peer check.
|
|
418
|
-
const res = await fetch(
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
526
|
+
const res = await fetch(
|
|
527
|
+
`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`,
|
|
528
|
+
{
|
|
529
|
+
headers: {
|
|
530
|
+
Upgrade: "websocket",
|
|
531
|
+
Connection: "Upgrade",
|
|
532
|
+
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
|
|
533
|
+
"Sec-WebSocket-Version": "13",
|
|
534
|
+
},
|
|
424
535
|
},
|
|
425
|
-
|
|
536
|
+
);
|
|
426
537
|
// Should NOT be 403 — WebSocket upgrade may or may not succeed
|
|
427
538
|
// depending on test environment, but the gateway guard should pass.
|
|
428
539
|
expect(res.status).not.toBe(403);
|
|
429
540
|
});
|
|
430
541
|
|
|
431
|
-
test(
|
|
432
|
-
const res = await fetch(
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
542
|
+
test("allows localhost origin from loopback peer", async () => {
|
|
543
|
+
const res = await fetch(
|
|
544
|
+
`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`,
|
|
545
|
+
{
|
|
546
|
+
headers: {
|
|
547
|
+
Upgrade: "websocket",
|
|
548
|
+
Connection: "Upgrade",
|
|
549
|
+
Origin: "http://127.0.0.1:3000",
|
|
550
|
+
"Sec-WebSocket-Key": "dGhlIHNhbXBsZSBub25jZQ==",
|
|
551
|
+
"Sec-WebSocket-Version": "13",
|
|
552
|
+
},
|
|
439
553
|
},
|
|
440
|
-
|
|
554
|
+
);
|
|
441
555
|
// Should NOT be 403
|
|
442
556
|
expect(res.status).not.toBe(403);
|
|
443
557
|
});
|
|
@@ -445,257 +559,242 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
445
559
|
|
|
446
560
|
// ── isPrivateAddress unit tests ─────────────────────────────────────
|
|
447
561
|
|
|
448
|
-
describe(
|
|
562
|
+
describe("isPrivateAddress", () => {
|
|
449
563
|
// Loopback
|
|
450
564
|
test.each([
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
])(
|
|
565
|
+
"127.0.0.1",
|
|
566
|
+
"127.0.0.2",
|
|
567
|
+
"127.255.255.255",
|
|
568
|
+
"::1",
|
|
569
|
+
"::ffff:127.0.0.1",
|
|
570
|
+
])("accepts loopback address %s", (addr) => {
|
|
457
571
|
expect(isPrivateAddress(addr)).toBe(true);
|
|
458
572
|
});
|
|
459
573
|
|
|
460
574
|
// RFC 1918 private ranges
|
|
461
575
|
test.each([
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
])(
|
|
576
|
+
"10.0.0.1",
|
|
577
|
+
"10.255.255.255",
|
|
578
|
+
"172.16.0.1",
|
|
579
|
+
"172.31.255.255",
|
|
580
|
+
"192.168.0.1",
|
|
581
|
+
"192.168.1.100",
|
|
582
|
+
])("accepts RFC 1918 private address %s", (addr) => {
|
|
469
583
|
expect(isPrivateAddress(addr)).toBe(true);
|
|
470
584
|
});
|
|
471
585
|
|
|
472
586
|
// Link-local
|
|
473
|
-
test.each([
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
587
|
+
test.each(["169.254.0.1", "169.254.255.255"])(
|
|
588
|
+
"accepts link-local address %s",
|
|
589
|
+
(addr) => {
|
|
590
|
+
expect(isPrivateAddress(addr)).toBe(true);
|
|
591
|
+
},
|
|
592
|
+
);
|
|
479
593
|
|
|
480
594
|
// IPv6 unique local (fc00::/7)
|
|
481
|
-
test.each([
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
});
|
|
595
|
+
test.each(["fc00::1", "fd12:3456:789a::1", "fdff::1"])(
|
|
596
|
+
"accepts IPv6 unique local address %s",
|
|
597
|
+
(addr) => {
|
|
598
|
+
expect(isPrivateAddress(addr)).toBe(true);
|
|
599
|
+
},
|
|
600
|
+
);
|
|
488
601
|
|
|
489
602
|
// IPv6 link-local (fe80::/10)
|
|
490
|
-
test.each([
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
603
|
+
test.each(["fe80::1", "fe80::abcd:1234"])(
|
|
604
|
+
"accepts IPv6 link-local address %s",
|
|
605
|
+
(addr) => {
|
|
606
|
+
expect(isPrivateAddress(addr)).toBe(true);
|
|
607
|
+
},
|
|
608
|
+
);
|
|
496
609
|
|
|
497
610
|
// IPv4-mapped IPv6 private addresses
|
|
498
611
|
test.each([
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
])(
|
|
612
|
+
"::ffff:10.0.0.1",
|
|
613
|
+
"::ffff:172.16.0.1",
|
|
614
|
+
"::ffff:192.168.1.1",
|
|
615
|
+
"::ffff:169.254.0.1",
|
|
616
|
+
])("accepts IPv4-mapped IPv6 private address %s", (addr) => {
|
|
504
617
|
expect(isPrivateAddress(addr)).toBe(true);
|
|
505
618
|
});
|
|
506
619
|
|
|
507
620
|
// Public addresses — should be rejected
|
|
508
621
|
test.each([
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
])(
|
|
622
|
+
"8.8.8.8",
|
|
623
|
+
"1.1.1.1",
|
|
624
|
+
"203.0.113.1",
|
|
625
|
+
"172.32.0.1",
|
|
626
|
+
"172.15.255.255",
|
|
627
|
+
"11.0.0.1",
|
|
628
|
+
"192.169.0.1",
|
|
629
|
+
"::ffff:8.8.8.8",
|
|
630
|
+
"2001:db8::1",
|
|
631
|
+
])("rejects public address %s", (addr) => {
|
|
519
632
|
expect(isPrivateAddress(addr)).toBe(false);
|
|
520
633
|
});
|
|
521
634
|
});
|
|
522
635
|
|
|
523
636
|
// ── Channel sync endpoints require auth ─────────────────────────────
|
|
524
637
|
|
|
525
|
-
describe(
|
|
526
|
-
|
|
527
|
-
test('POST /v1/channels/inbound without auth returns 401', async () => {
|
|
638
|
+
describe("channel sync endpoints require authentication", () => {
|
|
639
|
+
test("POST /v1/channels/inbound without auth returns 401", async () => {
|
|
528
640
|
const res = await fetch(`http://127.0.0.1:${port}/v1/channels/inbound`, {
|
|
529
|
-
method:
|
|
530
|
-
headers: {
|
|
641
|
+
method: "POST",
|
|
642
|
+
headers: { "Content-Type": "application/json" },
|
|
531
643
|
body: JSON.stringify({
|
|
532
|
-
sourceChannel:
|
|
533
|
-
externalChatId:
|
|
534
|
-
externalMessageId:
|
|
535
|
-
content:
|
|
644
|
+
sourceChannel: "telegram",
|
|
645
|
+
externalChatId: "12345",
|
|
646
|
+
externalMessageId: "msg-1",
|
|
647
|
+
content: "hello",
|
|
536
648
|
}),
|
|
537
649
|
});
|
|
538
650
|
expect(res.status).toBe(401);
|
|
539
651
|
});
|
|
540
652
|
|
|
541
|
-
test(
|
|
542
|
-
const res = await fetch(
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
653
|
+
test("DELETE /v1/channels/conversation without auth returns 401", async () => {
|
|
654
|
+
const res = await fetch(
|
|
655
|
+
`http://127.0.0.1:${port}/v1/channels/conversation`,
|
|
656
|
+
{
|
|
657
|
+
method: "DELETE",
|
|
658
|
+
headers: { "Content-Type": "application/json" },
|
|
659
|
+
body: JSON.stringify({
|
|
660
|
+
sourceChannel: "telegram",
|
|
661
|
+
externalChatId: "12345",
|
|
662
|
+
}),
|
|
663
|
+
},
|
|
664
|
+
);
|
|
550
665
|
expect(res.status).toBe(401);
|
|
551
666
|
});
|
|
552
667
|
|
|
553
|
-
test(
|
|
554
|
-
const res = await fetch(
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
668
|
+
test("POST /v1/channels/delivery-ack without auth returns 401", async () => {
|
|
669
|
+
const res = await fetch(
|
|
670
|
+
`http://127.0.0.1:${port}/v1/channels/delivery-ack`,
|
|
671
|
+
{
|
|
672
|
+
method: "POST",
|
|
673
|
+
headers: { "Content-Type": "application/json" },
|
|
674
|
+
body: JSON.stringify({
|
|
675
|
+
sourceChannel: "telegram",
|
|
676
|
+
externalChatId: "12345",
|
|
677
|
+
externalMessageId: "msg-1",
|
|
678
|
+
}),
|
|
679
|
+
},
|
|
680
|
+
);
|
|
563
681
|
expect(res.status).toBe(401);
|
|
564
682
|
});
|
|
565
|
-
|
|
566
683
|
});
|
|
567
684
|
|
|
568
|
-
// ──
|
|
569
|
-
|
|
570
|
-
|
|
685
|
+
// ── Route policy enforcement on /channels/inbound ──────────────────
|
|
686
|
+
//
|
|
687
|
+
// Gateway origin is now enforced via JWT principal type (svc_gateway)
|
|
688
|
+
// rather than the legacy X-Gateway-Origin header.
|
|
571
689
|
|
|
572
|
-
|
|
690
|
+
describe("route policy enforcement on /channels/inbound", () => {
|
|
691
|
+
test("POST /v1/channels/inbound with actor JWT returns 403 (requires svc_gateway)", async () => {
|
|
573
692
|
const res = await fetch(`http://127.0.0.1:${port}/v1/channels/inbound`, {
|
|
574
|
-
method:
|
|
693
|
+
method: "POST",
|
|
575
694
|
headers: {
|
|
576
695
|
...AUTH_HEADERS,
|
|
577
|
-
|
|
696
|
+
"Content-Type": "application/json",
|
|
578
697
|
},
|
|
579
698
|
body: JSON.stringify({
|
|
580
|
-
sourceChannel:
|
|
581
|
-
externalChatId:
|
|
582
|
-
externalMessageId:
|
|
583
|
-
content:
|
|
699
|
+
sourceChannel: "telegram",
|
|
700
|
+
externalChatId: "12345",
|
|
701
|
+
externalMessageId: "msg-gw-1",
|
|
702
|
+
content: "hello",
|
|
584
703
|
}),
|
|
585
704
|
});
|
|
586
705
|
expect(res.status).toBe(403);
|
|
587
|
-
const body = await res.json() as {
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
test('POST /v1/channels/inbound with auth and invalid X-Gateway-Origin returns 403', async () => {
|
|
593
|
-
const res = await fetch(`http://127.0.0.1:${port}/v1/channels/inbound`, {
|
|
594
|
-
method: 'POST',
|
|
595
|
-
headers: {
|
|
596
|
-
...AUTH_HEADERS,
|
|
597
|
-
'Content-Type': 'application/json',
|
|
598
|
-
'X-Gateway-Origin': 'wrong-secret-value',
|
|
599
|
-
},
|
|
600
|
-
body: JSON.stringify({
|
|
601
|
-
sourceChannel: 'telegram',
|
|
602
|
-
externalChatId: '12345',
|
|
603
|
-
externalMessageId: 'msg-gw-2',
|
|
604
|
-
content: 'hello',
|
|
605
|
-
}),
|
|
606
|
-
});
|
|
607
|
-
expect(res.status).toBe(403);
|
|
608
|
-
const body = await res.json() as { error: string; code: string };
|
|
609
|
-
expect(body.code).toBe('GATEWAY_ORIGIN_REQUIRED');
|
|
706
|
+
const body = (await res.json()) as {
|
|
707
|
+
error: { code: string; message: string };
|
|
708
|
+
};
|
|
709
|
+
expect(body.error.code).toBe("FORBIDDEN");
|
|
610
710
|
});
|
|
611
711
|
|
|
612
|
-
test(
|
|
712
|
+
test("POST /v1/channels/inbound with gateway JWT passes policy check", async () => {
|
|
613
713
|
const res = await fetch(`http://127.0.0.1:${port}/v1/channels/inbound`, {
|
|
614
|
-
method:
|
|
714
|
+
method: "POST",
|
|
615
715
|
headers: {
|
|
616
|
-
...
|
|
617
|
-
|
|
618
|
-
'X-Gateway-Origin': TEST_TOKEN,
|
|
716
|
+
...GATEWAY_AUTH_HEADERS,
|
|
717
|
+
"Content-Type": "application/json",
|
|
619
718
|
},
|
|
620
719
|
body: JSON.stringify({
|
|
621
|
-
sourceChannel:
|
|
622
|
-
externalChatId:
|
|
623
|
-
externalMessageId:
|
|
624
|
-
content:
|
|
720
|
+
sourceChannel: "telegram",
|
|
721
|
+
externalChatId: "12345",
|
|
722
|
+
externalMessageId: "msg-gw-3",
|
|
723
|
+
content: "hello",
|
|
625
724
|
}),
|
|
626
725
|
});
|
|
627
|
-
// Should NOT be 403 — the
|
|
628
|
-
//
|
|
629
|
-
//
|
|
726
|
+
// Should NOT be 403 — the svc_gateway principal type passes the
|
|
727
|
+
// route policy. It may return 200 or another non-403 status from
|
|
728
|
+
// downstream logic.
|
|
630
729
|
expect(res.status).not.toBe(403);
|
|
631
730
|
});
|
|
632
731
|
|
|
633
|
-
test(
|
|
732
|
+
test("POST /v1/channels/inbound without auth returns 401 (auth before policy)", async () => {
|
|
634
733
|
const res = await fetch(`http://127.0.0.1:${port}/v1/channels/inbound`, {
|
|
635
|
-
method:
|
|
734
|
+
method: "POST",
|
|
636
735
|
headers: {
|
|
637
|
-
|
|
638
|
-
'X-Gateway-Origin': TEST_TOKEN,
|
|
736
|
+
"Content-Type": "application/json",
|
|
639
737
|
},
|
|
640
738
|
body: JSON.stringify({
|
|
641
|
-
sourceChannel:
|
|
642
|
-
externalChatId:
|
|
643
|
-
externalMessageId:
|
|
644
|
-
content:
|
|
739
|
+
sourceChannel: "telegram",
|
|
740
|
+
externalChatId: "12345",
|
|
741
|
+
externalMessageId: "msg-gw-4",
|
|
742
|
+
content: "hello",
|
|
645
743
|
}),
|
|
646
744
|
});
|
|
647
|
-
// Auth middleware fires first, so
|
|
648
|
-
//
|
|
745
|
+
// Auth middleware fires first, so without a JWT the request is
|
|
746
|
+
// rejected before the route policy is checked.
|
|
649
747
|
expect(res.status).toBe(401);
|
|
650
748
|
});
|
|
651
749
|
|
|
652
|
-
test(
|
|
750
|
+
test("POST /v1/channels/inbound with SMS and actor JWT returns 403", async () => {
|
|
653
751
|
const res = await fetch(`http://127.0.0.1:${port}/v1/channels/inbound`, {
|
|
654
|
-
method:
|
|
752
|
+
method: "POST",
|
|
655
753
|
headers: {
|
|
656
754
|
...AUTH_HEADERS,
|
|
657
|
-
|
|
755
|
+
"Content-Type": "application/json",
|
|
658
756
|
},
|
|
659
757
|
body: JSON.stringify({
|
|
660
|
-
sourceChannel:
|
|
661
|
-
externalChatId:
|
|
662
|
-
externalMessageId:
|
|
663
|
-
content:
|
|
758
|
+
sourceChannel: "sms",
|
|
759
|
+
externalChatId: "+15551234567",
|
|
760
|
+
externalMessageId: "SM-test-gw-1",
|
|
761
|
+
content: "hello via SMS",
|
|
664
762
|
}),
|
|
665
763
|
});
|
|
666
|
-
// SMS messages
|
|
764
|
+
// SMS messages also require svc_gateway principal type.
|
|
667
765
|
expect(res.status).toBe(403);
|
|
668
|
-
const body = await res.json() as {
|
|
669
|
-
|
|
766
|
+
const body = (await res.json()) as {
|
|
767
|
+
error: { code: string; message: string };
|
|
768
|
+
};
|
|
769
|
+
expect(body.error.code).toBe("FORBIDDEN");
|
|
670
770
|
});
|
|
671
771
|
|
|
672
|
-
test(
|
|
772
|
+
test("POST /v1/channels/inbound with SMS and gateway JWT passes", async () => {
|
|
673
773
|
const res = await fetch(`http://127.0.0.1:${port}/v1/channels/inbound`, {
|
|
674
|
-
method:
|
|
774
|
+
method: "POST",
|
|
675
775
|
headers: {
|
|
676
|
-
...
|
|
677
|
-
|
|
678
|
-
'X-Gateway-Origin': TEST_TOKEN,
|
|
776
|
+
...GATEWAY_AUTH_HEADERS,
|
|
777
|
+
"Content-Type": "application/json",
|
|
679
778
|
},
|
|
680
779
|
body: JSON.stringify({
|
|
681
|
-
sourceChannel:
|
|
682
|
-
externalChatId:
|
|
683
|
-
externalMessageId:
|
|
684
|
-
content:
|
|
780
|
+
sourceChannel: "sms",
|
|
781
|
+
externalChatId: "+15551234567",
|
|
782
|
+
externalMessageId: "SM-test-gw-2",
|
|
783
|
+
content: "hello via SMS",
|
|
685
784
|
}),
|
|
686
785
|
});
|
|
687
|
-
// Should NOT be 403 — the
|
|
786
|
+
// Should NOT be 403 — the svc_gateway principal type passes.
|
|
688
787
|
expect(res.status).not.toBe(403);
|
|
689
788
|
});
|
|
690
789
|
});
|
|
691
790
|
|
|
692
791
|
// ── Startup warning for non-loopback host ──────────────────────────
|
|
693
792
|
|
|
694
|
-
describe(
|
|
695
|
-
test(
|
|
793
|
+
describe("startup guard — non-loopback host", () => {
|
|
794
|
+
test("server starts successfully when hostname is not loopback", async () => {
|
|
696
795
|
const warnServer = new RuntimeHttpServer({
|
|
697
796
|
port: 0,
|
|
698
|
-
hostname:
|
|
797
|
+
hostname: "0.0.0.0",
|
|
699
798
|
bearerToken: TEST_TOKEN,
|
|
700
799
|
});
|
|
701
800
|
await warnServer.start();
|
|
@@ -703,10 +802,10 @@ describe('gateway-only ingress enforcement', () => {
|
|
|
703
802
|
await warnServer.stop();
|
|
704
803
|
});
|
|
705
804
|
|
|
706
|
-
test(
|
|
805
|
+
test("server starts successfully when hostname is loopback", async () => {
|
|
707
806
|
const loopbackServer = new RuntimeHttpServer({
|
|
708
807
|
port: 0,
|
|
709
|
-
hostname:
|
|
808
|
+
hostname: "127.0.0.1",
|
|
710
809
|
bearerToken: TEST_TOKEN,
|
|
711
810
|
});
|
|
712
811
|
await loopbackServer.start();
|