@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.
Files changed (58) hide show
  1. package/Dockerfile +6 -6
  2. package/README.md +1 -2
  3. package/package.json +1 -1
  4. package/src/__tests__/call-controller.test.ts +1074 -751
  5. package/src/__tests__/call-routes-http.test.ts +329 -279
  6. package/src/__tests__/channel-approval-routes.test.ts +0 -11
  7. package/src/__tests__/channel-approvals.test.ts +227 -182
  8. package/src/__tests__/channel-guardian.test.ts +1 -0
  9. package/src/__tests__/conversation-attention-telegram.test.ts +157 -114
  10. package/src/__tests__/conversation-routes-guardian-reply.test.ts +164 -104
  11. package/src/__tests__/conversation-routes.test.ts +71 -41
  12. package/src/__tests__/daemon-server-session-init.test.ts +258 -191
  13. package/src/__tests__/deterministic-verification-control-plane.test.ts +183 -134
  14. package/src/__tests__/extract-email.test.ts +42 -0
  15. package/src/__tests__/gateway-only-enforcement.test.ts +467 -368
  16. package/src/__tests__/gateway-only-guard.test.ts +54 -55
  17. package/src/__tests__/gmail-integration.test.ts +48 -46
  18. package/src/__tests__/guardian-action-followup-executor.test.ts +215 -150
  19. package/src/__tests__/guardian-outbound-http.test.ts +334 -208
  20. package/src/__tests__/guardian-routing-invariants.test.ts +680 -613
  21. package/src/__tests__/guardian-routing-state.test.ts +257 -209
  22. package/src/__tests__/guardian-verification-voice-binding.test.ts +47 -40
  23. package/src/__tests__/handle-user-message-secret-resume.test.ts +44 -21
  24. package/src/__tests__/handlers-user-message-approval-consumption.test.ts +269 -195
  25. package/src/__tests__/inbound-invite-redemption.test.ts +194 -151
  26. package/src/__tests__/ingress-reconcile.test.ts +184 -142
  27. package/src/__tests__/non-member-access-request.test.ts +291 -247
  28. package/src/__tests__/notification-telegram-adapter.test.ts +60 -46
  29. package/src/__tests__/recording-intent-handler.test.ts +422 -291
  30. package/src/__tests__/runtime-attachment-metadata.test.ts +107 -69
  31. package/src/__tests__/runtime-events-sse.test.ts +67 -50
  32. package/src/__tests__/send-endpoint-busy.test.ts +314 -232
  33. package/src/__tests__/session-approval-overrides.test.ts +93 -91
  34. package/src/__tests__/sms-messaging-provider.test.ts +74 -47
  35. package/src/__tests__/trusted-contact-approval-notifier.test.ts +339 -274
  36. package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +484 -372
  37. package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +261 -239
  38. package/src/__tests__/trusted-contact-multichannel.test.ts +179 -140
  39. package/src/__tests__/twilio-config.test.ts +49 -41
  40. package/src/__tests__/twilio-routes-elevenlabs.test.ts +189 -162
  41. package/src/__tests__/twilio-routes.test.ts +389 -280
  42. package/src/config/bundled-skills/chatgpt-import/tools/chatgpt-import.ts +29 -4
  43. package/src/config/bundled-skills/messaging/SKILL.md +5 -4
  44. package/src/config/bundled-skills/messaging/tools/messaging-reply.ts +11 -7
  45. package/src/config/env.ts +39 -29
  46. package/src/daemon/handlers/skills.ts +18 -10
  47. package/src/daemon/ipc-contract/messages.ts +1 -0
  48. package/src/daemon/ipc-contract/surfaces.ts +7 -1
  49. package/src/daemon/session-agent-loop-handlers.ts +5 -0
  50. package/src/daemon/session-agent-loop.ts +1 -1
  51. package/src/daemon/session-process.ts +1 -1
  52. package/src/daemon/session-surfaces.ts +42 -2
  53. package/src/runtime/auth/token-service.ts +74 -47
  54. package/src/sequence/reply-matcher.ts +10 -6
  55. package/src/skills/frontmatter.ts +9 -6
  56. package/src/tools/ui-surface/definitions.ts +2 -1
  57. package/src/util/platform.ts +0 -12
  58. 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 'node:fs';
13
- import { tmpdir } from 'node:os';
14
- import { join } from 'node:path';
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 'bun:test';
16
+ import { afterAll, beforeAll, describe, expect, mock, test } from "bun:test";
17
17
 
18
- const testDir = realpathSync(mkdtempSync(join(tmpdir(), 'gw-only-enforcement-test-')));
18
+ const testDir = realpathSync(
19
+ mkdtempSync(join(tmpdir(), "gw-only-enforcement-test-")),
20
+ );
19
21
 
20
- mock.module('../util/platform.js', () => ({
22
+ mock.module("../util/platform.js", () => ({
21
23
  getRootDir: () => testDir,
22
24
  getDataDir: () => testDir,
23
- getWorkspaceConfigPath: () => join(testDir, 'config.json'),
24
- isMacOS: () => process.platform === 'darwin',
25
- isLinux: () => process.platform === 'linux',
26
- isWindows: () => process.platform === 'win32',
27
- getSocketPath: () => join(testDir, 'test.sock'),
28
- getPidPath: () => join(testDir, 'test.pid'),
29
- getDbPath: () => join(testDir, 'test.db'),
30
- getLogPath: () => join(testDir, 'test.log'),
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('../util/logger.js', () => ({
39
- getLogger: () => new Proxy({} as Record<string, unknown>, {
40
- get: (_target, prop: string) => {
41
- if (prop === 'child') return () => new Proxy({} as Record<string, unknown>, {
42
- get: () => () => {},
43
- });
44
- return (...args: unknown[]) => {
45
- if (typeof args[0] === 'string') {
46
- logMessages.push({ level: prop, msg: args[0] });
47
- } else if (typeof args[1] === 'string') {
48
- logMessages.push({ level: prop, msg: args[1], args: args[0] });
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('../config/loader.js', () => ({
60
+ mock.module("../config/loader.js", () => ({
56
61
  loadConfig: () => ({
57
- model: 'test',
58
- provider: 'test',
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: 'twilio',
66
- webhookBaseUrl: 'https://test.example.com',
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: 'https://test.example.com',
78
+ publicBaseUrl: "https://test.example.com",
74
79
  },
75
80
  sms: {
76
- phoneNumber: '+15550001111',
81
+ phoneNumber: "+15550001111",
77
82
  },
78
83
  }),
79
84
  getConfig: () => ({
80
- model: 'test',
81
- provider: 'test',
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: 'https://test.example.com',
92
+ publicBaseUrl: "https://test.example.com",
88
93
  },
89
94
  sms: {
90
- phoneNumber: '+15550001111',
95
+ phoneNumber: "+15550001111",
91
96
  },
92
97
  }),
93
98
  invalidateConfigCache: () => {},
94
99
  }));
95
100
 
96
101
  // Mock Twilio provider
97
- mock.module('../calls/twilio-provider.js', () => ({
102
+ mock.module("../calls/twilio-provider.js", () => ({
98
103
  TwilioConversationRelayProvider: class {
99
- static getAuthToken() { return 'mock-auth-token'; }
100
- static verifyWebhookSignature() { return true; }
101
- async initiateCall() { return { callSid: 'CA_mock_sid' }; }
102
- async endCall() { return; }
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
- 'credential:twilio:account_sid': 'AC_test',
108
- 'credential:twilio:auth_token': 'test_token',
109
- 'credential:twilio:phone_number': '+15550001111',
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('../security/secure-keys.js', () => ({
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('../security/oauth-callback-registry.js', () => ({
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('../calls/call-store.js', () => ({
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 { isPrivateAddress,RuntimeHttpServer } from '../runtime/http-server.js';
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
- const TEST_TOKEN = 'test-bearer-token-gw';
151
- const AUTH_HEADERS = { Authorization: `Bearer ${TEST_TOKEN}` };
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('gateway-only ingress enforcement', () => {
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: '127.0.0.1',
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('runtime has no Telegram webhook routes', () => {
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: 'POST',
189
- headers: { 'Content-Type': 'application/json' },
190
- body: JSON.stringify({ update_id: 1, message: { text: 'hello' } }),
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('GET /webhooks/telegram is rejected', async () => {
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('POST /webhooks/telegram/test is rejected', async () => {
204
- const res = await fetch(`http://127.0.0.1:${port}/webhooks/telegram/test`, {
205
- method: 'POST',
206
- headers: { 'Content-Type': 'application/json' },
207
- body: JSON.stringify({}),
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('POST /webhooks/telegram returns 404 when authenticated (no handler exists)', async () => {
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: 'POST',
215
- headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
216
- body: JSON.stringify({ update_id: 1, message: { text: 'hello' } }),
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('POST /webhooks/telegram/test returns 404 when authenticated (no handler exists)', async () => {
224
- const res = await fetch(`http://127.0.0.1:${port}/webhooks/telegram/test`, {
225
- method: 'POST',
226
- headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
227
- body: JSON.stringify({}),
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('direct webhook routes are blocked', () => {
238
-
239
- test('POST /webhooks/twilio/voice returns 410', async () => {
240
- const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/voice`, {
241
- method: 'POST',
242
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
243
- body: makeFormBody({ CallSid: 'CA123', AccountSid: 'AC_test' }),
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 { error: { code: string; message: string } };
247
- expect(body.error.code).toBe('GONE');
248
- expect(body.error.message).toContain('Direct webhook access disabled');
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('POST /webhooks/twilio/status returns 410', async () => {
252
- const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/status`, {
253
- method: 'POST',
254
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
255
- body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
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 { error: { code: string; message: string } };
259
- expect(body.error.code).toBe('GONE');
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('POST /webhooks/twilio/connect-action returns 410', async () => {
263
- const res = await fetch(`http://127.0.0.1:${port}/webhooks/twilio/connect-action`, {
264
- method: 'POST',
265
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
266
- body: makeFormBody({ CallSid: 'CA123' }),
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 { error: { code: string; message: string } };
270
- expect(body.error.code).toBe('GONE');
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('POST /v1/calls/twilio/voice-webhook returns 410', async () => {
274
- const res = await fetch(`http://127.0.0.1:${port}/v1/calls/twilio/voice-webhook`, {
275
- method: 'POST',
276
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
277
- body: makeFormBody({ CallSid: 'CA123' }),
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 { error: { code: string; message: string } };
281
- expect(body.error.code).toBe('GONE');
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('POST /v1/calls/twilio/status returns 410', async () => {
285
- const res = await fetch(`http://127.0.0.1:${port}/v1/calls/twilio/status`, {
286
- method: 'POST',
287
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
288
- body: makeFormBody({ CallSid: 'CA123', CallStatus: 'completed' }),
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 { error: { code: string; message: string } };
292
- expect(body.error.code).toBe('GONE');
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('SMS webhook routes are blocked at the runtime (gateway-only)', () => {
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: 'POST',
303
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
304
- body: makeFormBody({ Body: 'hello', From: '+15551234567', To: '+15559876543', MessageSid: 'SM123' }),
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 { error: { code: string; message: string } };
308
- expect(body.error.code).toBe('GONE');
309
- expect(body.error.message).toContain('Direct webhook access disabled');
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('POST /v1/calls/twilio/sms returns 410 (legacy path also blocked)', async () => {
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: 'POST',
315
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
316
- body: makeFormBody({ Body: 'hello', From: '+15551234567', MessageSid: 'SM456' }),
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 { error: { code: string; message: string } };
320
- expect(body.error.code).toBe('GONE');
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('POST /webhooks/twilio/sms with valid auth still returns 410 (auth does not bypass gateway-only)', async () => {
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: 'POST',
400
+ method: "POST",
326
401
  headers: {
327
402
  ...AUTH_HEADERS,
328
- 'Content-Type': 'application/x-www-form-urlencoded',
403
+ "Content-Type": "application/x-www-form-urlencoded",
329
404
  },
330
- body: makeFormBody({ Body: 'sneaky', From: '+15551234567', MessageSid: 'SM789' }),
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 { error: { code: string; message: string } };
335
- expect(body.error.code).toBe('GONE');
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('internal forwarding routes are not blocked', () => {
342
-
343
- test('POST /v1/internal/twilio/voice-webhook is NOT blocked', async () => {
344
- const res = await fetch(`http://127.0.0.1:${port}/v1/internal/twilio/voice-webhook`, {
345
- method: 'POST',
346
- headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
347
- body: JSON.stringify({
348
- params: { CallSid: 'CA123', AccountSid: 'AC_test' },
349
- originalUrl: `http://127.0.0.1:${port}/v1/internal/twilio/voice-webhook?callSessionId=sess-123`,
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('POST /v1/internal/twilio/status is NOT blocked', async () => {
358
- const res = await fetch(`http://127.0.0.1:${port}/v1/internal/twilio/status`, {
359
- method: 'POST',
360
- headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
361
- body: JSON.stringify({
362
- params: { CallSid: 'CA123', CallStatus: 'completed' },
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('POST /v1/internal/twilio/connect-action is NOT blocked', async () => {
369
- const res = await fetch(`http://127.0.0.1:${port}/v1/internal/twilio/connect-action`, {
370
- method: 'POST',
371
- headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
372
- body: JSON.stringify({
373
- params: { CallSid: 'CA123' },
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('POST /v1/internal/oauth/callback is NOT blocked', async () => {
380
- const res = await fetch(`http://127.0.0.1:${port}/v1/internal/oauth/callback`, {
381
- method: 'POST',
382
- headers: { ...AUTH_HEADERS, 'Content-Type': 'application/json' },
383
- body: JSON.stringify({
384
- state: 'test-state',
385
- code: 'test-code',
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('relay WebSocket upgrade', () => {
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(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
401
- headers: {
402
- 'Upgrade': 'websocket',
403
- 'Connection': 'Upgrade',
404
- 'Origin': 'https://external.example.com',
405
- 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
406
- 'Sec-WebSocket-Version': '13',
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 { error: { code: string; message: string } };
411
- expect(body.error.code).toBe('FORBIDDEN');
412
- expect(body.error.message).toContain('Direct relay access disabled');
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('allows request with no origin header (private network peer)', async () => {
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(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
419
- headers: {
420
- 'Upgrade': 'websocket',
421
- 'Connection': 'Upgrade',
422
- 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
423
- 'Sec-WebSocket-Version': '13',
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('allows localhost origin from loopback peer', async () => {
432
- const res = await fetch(`http://127.0.0.1:${port}/v1/calls/relay?callSessionId=sess-123`, {
433
- headers: {
434
- 'Upgrade': 'websocket',
435
- 'Connection': 'Upgrade',
436
- 'Origin': 'http://127.0.0.1:3000',
437
- 'Sec-WebSocket-Key': 'dGhlIHNhbXBsZSBub25jZQ==',
438
- 'Sec-WebSocket-Version': '13',
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('isPrivateAddress', () => {
562
+ describe("isPrivateAddress", () => {
449
563
  // Loopback
450
564
  test.each([
451
- '127.0.0.1',
452
- '127.0.0.2',
453
- '127.255.255.255',
454
- '::1',
455
- '::ffff:127.0.0.1',
456
- ])('accepts loopback address %s', (addr) => {
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
- '10.0.0.1',
463
- '10.255.255.255',
464
- '172.16.0.1',
465
- '172.31.255.255',
466
- '192.168.0.1',
467
- '192.168.1.100',
468
- ])('accepts RFC 1918 private address %s', (addr) => {
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
- '169.254.0.1',
475
- '169.254.255.255',
476
- ])('accepts link-local address %s', (addr) => {
477
- expect(isPrivateAddress(addr)).toBe(true);
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
- 'fc00::1',
483
- 'fd12:3456:789a::1',
484
- 'fdff::1',
485
- ])('accepts IPv6 unique local address %s', (addr) => {
486
- expect(isPrivateAddress(addr)).toBe(true);
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
- 'fe80::1',
492
- 'fe80::abcd:1234',
493
- ])('accepts IPv6 link-local address %s', (addr) => {
494
- expect(isPrivateAddress(addr)).toBe(true);
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
- '::ffff:10.0.0.1',
500
- '::ffff:172.16.0.1',
501
- '::ffff:192.168.1.1',
502
- '::ffff:169.254.0.1',
503
- ])('accepts IPv4-mapped IPv6 private address %s', (addr) => {
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
- '8.8.8.8',
510
- '1.1.1.1',
511
- '203.0.113.1',
512
- '172.32.0.1',
513
- '172.15.255.255',
514
- '11.0.0.1',
515
- '192.169.0.1',
516
- '::ffff:8.8.8.8',
517
- '2001:db8::1',
518
- ])('rejects public address %s', (addr) => {
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('channel sync endpoints require authentication', () => {
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: 'POST',
530
- headers: { 'Content-Type': 'application/json' },
641
+ method: "POST",
642
+ headers: { "Content-Type": "application/json" },
531
643
  body: JSON.stringify({
532
- sourceChannel: 'telegram',
533
- externalChatId: '12345',
534
- externalMessageId: 'msg-1',
535
- content: 'hello',
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('DELETE /v1/channels/conversation without auth returns 401', async () => {
542
- const res = await fetch(`http://127.0.0.1:${port}/v1/channels/conversation`, {
543
- method: 'DELETE',
544
- headers: { 'Content-Type': 'application/json' },
545
- body: JSON.stringify({
546
- sourceChannel: 'telegram',
547
- externalChatId: '12345',
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('POST /v1/channels/delivery-ack without auth returns 401', async () => {
554
- const res = await fetch(`http://127.0.0.1:${port}/v1/channels/delivery-ack`, {
555
- method: 'POST',
556
- headers: { 'Content-Type': 'application/json' },
557
- body: JSON.stringify({
558
- sourceChannel: 'telegram',
559
- externalChatId: '12345',
560
- externalMessageId: 'msg-1',
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
- // ── Gateway-origin proof on /channels/inbound ──────────────────────
569
-
570
- describe('gateway-origin proof on /channels/inbound', () => {
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
- test('POST /v1/channels/inbound with auth but missing X-Gateway-Origin returns 403', async () => {
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: 'POST',
693
+ method: "POST",
575
694
  headers: {
576
695
  ...AUTH_HEADERS,
577
- 'Content-Type': 'application/json',
696
+ "Content-Type": "application/json",
578
697
  },
579
698
  body: JSON.stringify({
580
- sourceChannel: 'telegram',
581
- externalChatId: '12345',
582
- externalMessageId: 'msg-gw-1',
583
- content: 'hello',
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 { error: string; code: string };
588
- expect(body.code).toBe('GATEWAY_ORIGIN_REQUIRED');
589
- expect(body.error).toContain('gateway-origin proof');
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('POST /v1/channels/inbound with auth and valid X-Gateway-Origin passes gateway check', async () => {
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: 'POST',
714
+ method: "POST",
615
715
  headers: {
616
- ...AUTH_HEADERS,
617
- 'Content-Type': 'application/json',
618
- 'X-Gateway-Origin': TEST_TOKEN,
716
+ ...GATEWAY_AUTH_HEADERS,
717
+ "Content-Type": "application/json",
619
718
  },
620
719
  body: JSON.stringify({
621
- sourceChannel: 'telegram',
622
- externalChatId: '12345',
623
- externalMessageId: 'msg-gw-3',
624
- content: 'hello',
720
+ sourceChannel: "telegram",
721
+ externalChatId: "12345",
722
+ externalMessageId: "msg-gw-3",
723
+ content: "hello",
625
724
  }),
626
725
  });
627
- // Should NOT be 403 — the gateway-origin check passes. It may return
628
- // 200 (accepted) since there's no processMessage configured, or another
629
- // non-403 status from downstream logic.
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('POST /v1/channels/inbound without auth still returns 401 (auth before gateway-origin)', async () => {
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: 'POST',
734
+ method: "POST",
636
735
  headers: {
637
- 'Content-Type': 'application/json',
638
- 'X-Gateway-Origin': TEST_TOKEN,
736
+ "Content-Type": "application/json",
639
737
  },
640
738
  body: JSON.stringify({
641
- sourceChannel: 'telegram',
642
- externalChatId: '12345',
643
- externalMessageId: 'msg-gw-4',
644
- content: 'hello',
739
+ sourceChannel: "telegram",
740
+ externalChatId: "12345",
741
+ externalMessageId: "msg-gw-4",
742
+ content: "hello",
645
743
  }),
646
744
  });
647
- // Auth middleware fires first, so even with a valid gateway-origin
648
- // header, the request is rejected if bearer auth is missing.
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('POST /v1/channels/inbound with SMS sourceChannel requires X-Gateway-Origin', async () => {
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: 'POST',
752
+ method: "POST",
655
753
  headers: {
656
754
  ...AUTH_HEADERS,
657
- 'Content-Type': 'application/json',
755
+ "Content-Type": "application/json",
658
756
  },
659
757
  body: JSON.stringify({
660
- sourceChannel: 'sms',
661
- externalChatId: '+15551234567',
662
- externalMessageId: 'SM-test-gw-1',
663
- content: 'hello via SMS',
758
+ sourceChannel: "sms",
759
+ externalChatId: "+15551234567",
760
+ externalMessageId: "SM-test-gw-1",
761
+ content: "hello via SMS",
664
762
  }),
665
763
  });
666
- // SMS messages must also go through the gateway — missing X-Gateway-Origin is rejected.
764
+ // SMS messages also require svc_gateway principal type.
667
765
  expect(res.status).toBe(403);
668
- const body = await res.json() as { error: string; code: string };
669
- expect(body.code).toBe('GATEWAY_ORIGIN_REQUIRED');
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('POST /v1/channels/inbound with SMS sourceChannel and valid X-Gateway-Origin passes', async () => {
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: 'POST',
774
+ method: "POST",
675
775
  headers: {
676
- ...AUTH_HEADERS,
677
- 'Content-Type': 'application/json',
678
- 'X-Gateway-Origin': TEST_TOKEN,
776
+ ...GATEWAY_AUTH_HEADERS,
777
+ "Content-Type": "application/json",
679
778
  },
680
779
  body: JSON.stringify({
681
- sourceChannel: 'sms',
682
- externalChatId: '+15551234567',
683
- externalMessageId: 'SM-test-gw-2',
684
- content: 'hello via SMS',
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 gateway-origin check passes.
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('startup guard — non-loopback host', () => {
695
- test('server starts successfully when hostname is not loopback', async () => {
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: '0.0.0.0',
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('server starts successfully when hostname is loopback', async () => {
805
+ test("server starts successfully when hostname is loopback", async () => {
707
806
  const loopbackServer = new RuntimeHttpServer({
708
807
  port: 0,
709
- hostname: '127.0.0.1',
808
+ hostname: "127.0.0.1",
710
809
  bearerToken: TEST_TOKEN,
711
810
  });
712
811
  await loopbackServer.start();