@vellumai/vellum-gateway 0.7.2 → 0.8.0

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 (49) hide show
  1. package/ARCHITECTURE.md +20 -21
  2. package/Dockerfile +2 -1
  3. package/README.md +6 -6
  4. package/bun.lock +8 -1
  5. package/knip.json +1 -0
  6. package/package.json +2 -1
  7. package/src/__tests__/config-file-watcher.test.ts +1 -1
  8. package/src/__tests__/contact-prompt-submit.test.ts +349 -0
  9. package/src/__tests__/ipc-route-policy-coverage.test.ts +297 -0
  10. package/src/__tests__/ipc-route-policy.test.ts +43 -0
  11. package/src/__tests__/ipc-server-watchdog.test.ts +189 -0
  12. package/src/__tests__/nonbash-trust-rule-overrides.test.ts +50 -0
  13. package/src/__tests__/remote-feature-flag-sync.test.ts +16 -14
  14. package/src/__tests__/slack-display-name.test.ts +6 -2
  15. package/src/__tests__/slack-normalize.test.ts +36 -56
  16. package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +4 -2
  17. package/src/__tests__/telegram-webhook-manager.test.ts +8 -25
  18. package/src/__tests__/twilio-webhooks.test.ts +2 -6
  19. package/src/__tests__/upsert-verified-contact-channel.test.ts +173 -0
  20. package/src/auth/guardian-bootstrap.ts +49 -0
  21. package/src/auth/ipc-route-policy.ts +24 -0
  22. package/src/db/contact-store.ts +27 -1
  23. package/src/email/register-callback.test.ts +4 -4
  24. package/src/email/register-callback.ts +12 -16
  25. package/src/feature-flag-registry.json +13 -5
  26. package/src/handlers/handle-inbound.ts +1 -0
  27. package/src/http/routes/contact-prompt.ts +134 -23
  28. package/src/http/routes/contacts-control-plane-proxy.ts +34 -5
  29. package/src/http/routes/ipc-runtime-proxy.ts +18 -0
  30. package/src/http/routes/twilio-voice-webhook.test.ts +22 -1
  31. package/src/http/routes/twilio-voice-webhook.ts +53 -0
  32. package/src/index.ts +11 -2
  33. package/src/ipc/server.ts +113 -46
  34. package/src/ipc/velay-handlers.ts +31 -0
  35. package/src/remote-feature-flag-sync.ts +10 -8
  36. package/src/risk/bash-risk-classifier.test.ts +82 -0
  37. package/src/risk/bash-risk-classifier.ts +19 -15
  38. package/src/risk/command-registry/commands/assistant.ts +1 -0
  39. package/src/risk/shell-parser.test.ts +159 -0
  40. package/src/risk/shell-parser.ts +150 -19
  41. package/src/risk/skill-risk-classifier.ts +12 -3
  42. package/src/runtime/client.ts +14 -12
  43. package/src/slack/normalize.test.ts +3 -3
  44. package/src/slack/normalize.ts +6 -69
  45. package/src/slack/socket-mode.ts +1 -5
  46. package/src/telegram/webhook-manager.ts +9 -13
  47. package/src/velay/client.ts +27 -16
  48. package/src/verification/contact-helpers.ts +6 -3
  49. package/src/verification/voice-approval-sync.ts +107 -0
package/ARCHITECTURE.md CHANGED
@@ -280,10 +280,10 @@ Channel bindings follow a three-phase lifecycle:
280
280
 
281
281
  The public URL where the gateway is reachable is configured via:
282
282
 
283
- | Source | Description |
284
- | ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
285
- | `ingress.publicBaseUrl` (workspace config) | Canonical public ingress URL for Telegram webhooks, OAuth callbacks, email callbacks, generic JSON webhooks, and custom/ngrok tunnel based Twilio fallback |
286
- | `ingress.twilioPublicBaseUrl` (workspace config) | Twilio-specific public ingress URL written by Velay registration; used only by Twilio URL builders when present |
283
+ | Source | Description |
284
+ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
285
+ | `ingress.publicBaseUrl` (workspace config) | Canonical public ingress URL for Telegram webhooks, OAuth callbacks, email callbacks, generic JSON webhooks, Twilio webhooks, and Twilio WebSocket URLs |
286
+ | `ingress.publicBaseUrlManagedBy` (workspace config) | Ownership marker used when Velay published `ingress.publicBaseUrl`; lets the gateway clear stale Velay-managed URLs without disturbing manual URLs |
287
287
 
288
288
  ### Tunnel-Agnostic Setup
289
289
 
@@ -296,9 +296,9 @@ The assistant runtime reads this URL via the centralized `public-ingress-urls.ts
296
296
 
297
297
  ### Velay Twilio Ingress
298
298
 
299
- Velay is a platform-managed tunnel for assistant-hosted HTTP and WebSocket traffic. It is intentionally additive to the tunnel-agnostic setup above: Velay registration does not overwrite `ingress.publicBaseUrl`, and operators can continue using ngrok or custom tunnels for non-Twilio webhooks.
299
+ Velay is a platform-managed tunnel for assistant-hosted HTTP and WebSocket traffic. When it is active, Velay publishes the registered public assistant URL to `ingress.publicBaseUrl` and marks it with `ingress.publicBaseUrlManagedBy: "velay"`.
300
300
 
301
- When `VELAY_BASE_URL` is present in the gateway environment, the gateway starts `VelayTunnelClient`. The client registers with Velay over `GET /v1/register` using the assistant API key, then receives a `registered` frame containing a public assistant URL such as `https://velay.vellum.ai/<assistant-id>`. The gateway writes that URL to `ingress.twilioPublicBaseUrl` only. When the tunnel disconnects, it clears that same value if it still matches the URL the tunnel published, leaving `ingress.publicBaseUrl` intact.
301
+ When `VELAY_BASE_URL` is present in the gateway environment, the gateway starts `VelayTunnelClient`. The client registers with Velay over `GET /v1/register` using the assistant API key, then receives a `registered` frame containing a public assistant URL such as `https://velay.vellum.ai/<assistant-id>`. The gateway writes that URL to `ingress.publicBaseUrl`. When the tunnel disconnects, it clears that value only if the Velay ownership marker is still present and the URL still matches what the tunnel published, leaving manual URLs intact.
302
302
 
303
303
  Velay forwards both HTTP request frames and WebSocket frames into the local gateway loopback listener:
304
304
 
@@ -310,16 +310,16 @@ Public Velay HTTPS/WSS URL
310
310
  → Existing gateway route handlers
311
311
  ```
312
312
 
313
- The HTTP bridge can carry normal JSON requests and health checks, so it is useful for local bridge smoke tests. The advertised URL split is Twilio-scoped in this phase, though: only Twilio URL generation prefers `ingress.twilioPublicBaseUrl`; Telegram webhook registration, OAuth redirects, email callbacks, and generic public URL builders continue to use `ingress.publicBaseUrl`.
313
+ The HTTP bridge can carry normal JSON requests and health checks, so it is useful for local bridge smoke tests. Velay-managed `ingress.publicBaseUrl` changes are tagged with `publicBaseUrlManagedBy` so gateway side effects can skip unrelated webhook reconciliation while still refreshing Twilio phone-number webhooks.
314
314
 
315
315
  Local platform smoke-test flow:
316
316
 
317
317
  1. In `vellum-assistant-platform`, run `vel up velay`.
318
- 2. Ensure vembda passes `VELAY_BASE_URL=http://host.docker.internal:8501` into assistant gateway containers.
318
+ 2. Ensure vembda passes the environment-appropriate `VELAY_BASE_URL` into assistant gateway containers.
319
319
  3. Re-hatch or restart the assistant so the gateway receives the new environment.
320
320
  4. Confirm gateway logs show `Velay tunnel connected` and `Velay tunnel registered`.
321
321
  5. Verify HTTP forwarding by requesting `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/healthz` and `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/schema`. When validating a JSON webhook route under active development, POST a small JSON body through the same Velay public URL and confirm it reaches the loopback gateway.
322
- 6. Verify Twilio WebSocket forwarding with a synthetic local WebSocket client against `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/webhooks/twilio/relay?callSessionId=...&token=...`, then with a real Twilio call after `VELAY_PUBLIC_BASE_URL` is backed by a public HTTPS/WSS tunnel.
322
+ 6. Verify Twilio WebSocket forwarding with a synthetic local WebSocket client against `${VELAY_PUBLIC_BASE_URL}/<assistant-id>/webhooks/twilio/relay?callSessionId=...&token=...`, then with a real Twilio call after the gateway has registered with Velay.
323
323
 
324
324
  ### URL Builders
325
325
 
@@ -328,10 +328,10 @@ All public-facing URLs are constructed by `assistant/src/inbound/public-ingress-
328
328
  | Function | URL Pattern |
329
329
  | ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
330
330
  | `getPublicBaseUrl()` | Resolves the canonical base URL from `ingress.publicBaseUrl` in workspace config or module-level state (assistant-side; the gateway reads via `ConfigFileCache`) |
331
- | `getTwilioVoiceWebhookUrl()` | `${twilioBase}/webhooks/twilio/voice?callSessionId=...`, where `twilioBase` is `ingress.twilioPublicBaseUrl` when present, otherwise `ingress.publicBaseUrl` |
332
- | `getTwilioStatusCallbackUrl()` | `${twilioBase}/webhooks/twilio/status`, with the same Twilio-specific base resolution |
333
- | `getTwilioConnectActionUrl()` | `${twilioBase}/webhooks/twilio/connect-action`, with the same Twilio-specific base resolution |
334
- | `getTwilioRelayUrl()` | `ws(s)://.../webhooks/twilio/relay`, with the same Twilio-specific base resolution |
331
+ | `getTwilioVoiceWebhookUrl()` | `${base}/webhooks/twilio/voice?callSessionId=...`, using `ingress.publicBaseUrl` |
332
+ | `getTwilioStatusCallbackUrl()` | `${base}/webhooks/twilio/status`, using `ingress.publicBaseUrl` |
333
+ | `getTwilioConnectActionUrl()` | `${base}/webhooks/twilio/connect-action`, using `ingress.publicBaseUrl` |
334
+ | `getTwilioRelayUrl()` | `ws(s)://.../webhooks/twilio/relay`, using `ingress.publicBaseUrl` |
335
335
  | `getOAuthCallbackUrl()` | `${base}/webhooks/oauth/callback` |
336
336
  | `getTelegramWebhookUrl()` | `${base}/webhooks/telegram` |
337
337
 
@@ -1077,21 +1077,20 @@ In gateway-fronted deployments, the TwiML WebSocket URL (returned by the voice w
1077
1077
 
1078
1078
  Signature validation is **fail-closed**: if the Twilio auth token is not configured, all webhook requests are rejected with `403`. Missing or invalid `X-Twilio-Signature` headers are also rejected with `403`. Payload size is capped by `maxWebhookPayloadBytes` (checked via both `Content-Length` header and actual body size).
1079
1079
 
1080
- **Webhook base URL resolution:** Public ingress URL construction is centralized in `public-ingress-urls.ts`, with a Twilio-specific override for Velay:
1080
+ **Webhook base URL resolution:** Public ingress URL construction is centralized in `public-ingress-urls.ts`:
1081
1081
 
1082
- - Twilio voice/status/connect-action/relay/media-stream URLs use `ingress.twilioPublicBaseUrl` when present. This is the value published by Velay registration.
1083
- - If `ingress.twilioPublicBaseUrl` is absent, Twilio falls back to `ingress.publicBaseUrl`.
1084
- - Telegram webhooks, OAuth callbacks, email callbacks, and normal JSON webhook URLs use `ingress.publicBaseUrl`; Velay does not replace those advertised URLs in this phase.
1082
+ - Twilio voice/status/connect-action/relay/media-stream URLs use `ingress.publicBaseUrl`.
1083
+ - Velay registration publishes its public assistant URL to `ingress.publicBaseUrl` with `ingress.publicBaseUrlManagedBy: "velay"`.
1084
+ - Telegram webhooks, OAuth callbacks, email callbacks, and normal JSON webhook URLs also use `ingress.publicBaseUrl`; Velay-managed URL changes are tagged so unrelated reconciliation can be skipped when appropriate.
1085
1085
  - Module-level assistant state remains a fallback for legacy tunnel start/stop flows.
1086
1086
 
1087
1087
  All webhook paths (`/webhooks/twilio/voice`, `/webhooks/twilio/status`, `/webhooks/telegram`, `/webhooks/oauth/callback`, etc.) are appended automatically.
1088
1088
 
1089
1089
  For **inbound Twilio signature validation** at the gateway, URL reconstruction now supports multiple candidates in order:
1090
1090
 
1091
- 1. `ConfigFileCache.getString("ingress", "twilioPublicBaseUrl")` (if configured)
1092
- 2. `ConfigFileCache.getString("ingress", "publicBaseUrl")` (if configured)
1093
- 3. Forwarded public URL headers from the tunnel/proxy (`X-Forwarded-Proto` + `X-Forwarded-Host`/`X-Original-Host` fallbacks)
1094
- 4. Raw request URL (always included as the final fallback)
1091
+ 1. `ConfigFileCache.getString("ingress", "publicBaseUrl")` (if configured)
1092
+ 2. Forwarded public URL headers from the tunnel/proxy (`X-Forwarded-Proto` + `X-Forwarded-Host`/`X-Original-Host` fallbacks)
1093
+ 3. Raw request URL (always included as the final fallback)
1095
1094
 
1096
1095
  This makes ingress URL updates smoother in local tunnel workflows because Twilio webhooks can continue validating immediately. For Telegram, the config file watcher detects ingress URL changes and triggers webhook reconciliation directly, so neither channel requires a gateway restart.
1097
1096
 
package/Dockerfile CHANGED
@@ -11,6 +11,7 @@ COPY --from=bun /usr/local/bin/bun /usr/local/bin/bun
11
11
  # Copy shared packages needed by gateway's repo-local dependencies
12
12
  COPY packages/assistant-client ./packages/assistant-client
13
13
  COPY packages/ces-client ./packages/ces-client
14
+ COPY packages/ipc-server-utils ./packages/ipc-server-utils
14
15
  COPY packages/service-contracts ./packages/service-contracts
15
16
  COPY packages/slack-text ./packages/slack-text
16
17
  COPY packages/twilio-client ./packages/twilio-client
@@ -56,4 +57,4 @@ EXPOSE 7830
56
57
 
57
58
  ENV GATEWAY_PORT=7830
58
59
 
59
- CMD ["bun", "run", "src/index.ts"]
60
+ CMD ["bun", "--smol", "run", "src/index.ts"]
package/README.md CHANGED
@@ -210,9 +210,9 @@ In local tunnel setups, updating `ingress.publicBaseUrl` in Settings is typicall
210
210
 
211
211
  The assistant runtime uses this URL to construct all webhook and OAuth callback URLs automatically.
212
212
 
213
- ### Velay for Twilio Local Testing
213
+ ### Velay for Twilio Testing
214
214
 
215
- Velay is an additional managed ingress transport for Twilio calls. It does not replace ngrok or `ingress.publicBaseUrl`. In this phase, Velay registration writes only `ingress.twilioPublicBaseUrl`; Twilio URL builders prefer that value when it is present, while Telegram webhooks, OAuth callbacks, email callbacks, and normal JSON webhook URLs continue to use `ingress.publicBaseUrl`.
215
+ Velay is a managed ingress transport for assistant-hosted HTTP and WebSocket traffic. When Velay registration succeeds, the gateway writes the registered public assistant URL to `ingress.publicBaseUrl` and marks it with `ingress.publicBaseUrlManagedBy: "velay"`. Twilio URL builders use that public base URL for voice, status, relay, and media-stream endpoints.
216
216
 
217
217
  Use Velay when testing Twilio voice webhooks or Twilio WebSocket upgrades through the platform-managed tunnel:
218
218
 
@@ -222,16 +222,16 @@ Use Velay when testing Twilio voice webhooks or Twilio WebSocket upgrades throug
222
222
  vel up velay
223
223
  ```
224
224
 
225
- 2. Ensure vembda injects the Velay endpoint into assistant gateway containers:
225
+ 2. Ensure vembda injects the Velay endpoint into assistant gateway containers. For local Docker-hosted assistants, the gateway container must dial the Velay service running on the host:
226
226
 
227
227
  ```bash
228
228
  VELAY_BASE_URL=http://host.docker.internal:8501
229
229
  ```
230
230
 
231
- The `host.docker.internal` host is important for Docker-hosted assistants because the gateway container must dial the Velay service running on the host.
231
+ Hosted environments should use their environment's deployed Velay URL instead.
232
232
 
233
233
  3. Re-hatch or restart the assistant so the gateway process receives `VELAY_BASE_URL`.
234
- 4. Confirm the gateway logs include `Velay tunnel connected` followed by `Velay tunnel registered`. Registration publishes the returned Velay URL to `ingress.twilioPublicBaseUrl` without changing `ingress.publicBaseUrl`.
234
+ 4. Confirm the gateway logs include `Velay tunnel connected` followed by `Velay tunnel registered`. Registration publishes the returned Velay URL to `ingress.publicBaseUrl`.
235
235
 
236
236
  For an HTTP bridge smoke test, send a request to the registered Velay public URL and confirm it reaches the loopback gateway, for example:
237
237
 
@@ -249,7 +249,7 @@ bun -e 'const ws = new WebSocket(process.argv[1]); ws.onopen = () => { console.l
249
249
  "wss://<velay-host>/<assistant-id>/webhooks/twilio/relay?callSessionId=session-123&token=<edge-token>"
250
250
  ```
251
251
 
252
- For a real Twilio call, expose local Velay with a public HTTPS/WSS tunnel and configure the platform Velay service with that origin as `VELAY_PUBLIC_BASE_URL`. After the assistant re-registers, Twilio should fetch `/webhooks/twilio/voice` and open `/webhooks/twilio/relay` or `/webhooks/twilio/media-stream/...` through the Velay URL. Keep using ngrok or another custom tunnel in `ingress.publicBaseUrl` when you need Telegram, OAuth, email, or non-Twilio webhook ingress.
252
+ For a real Twilio call, expose local Velay with a public HTTPS/WSS tunnel and configure the platform Velay service with that origin as `VELAY_PUBLIC_BASE_URL`. After the assistant re-registers, Twilio should fetch `/webhooks/twilio/voice` and open `/webhooks/twilio/relay` or `/webhooks/twilio/media-stream/...` through the Velay URL. Use ngrok or another custom tunnel in `ingress.publicBaseUrl` only for local/self-hosted workflows that are not routed through Velay.
253
253
 
254
254
  ## Ingress Boundary Guarantees
255
255
 
package/bun.lock CHANGED
@@ -7,6 +7,7 @@
7
7
  "dependencies": {
8
8
  "@vellumai/assistant-client": "file:../packages/assistant-client",
9
9
  "@vellumai/ces-client": "file:../packages/ces-client",
10
+ "@vellumai/ipc-server-utils": "file:../packages/ipc-server-utils",
10
11
  "@vellumai/service-contracts": "file:../packages/service-contracts",
11
12
  "@vellumai/slack-text": "file:../packages/slack-text",
12
13
  "@vellumai/twilio-client": "file:../packages/twilio-client",
@@ -209,6 +210,8 @@
209
210
 
210
211
  "@vellumai/ces-client": ["@vellumai/ces-client@file:../packages/ces-client", { "dependencies": { "@vellumai/service-contracts": "file:../service-contracts" }, "devDependencies": { "@types/bun": "1.2.4", "typescript": "5.7.3" } }],
211
212
 
213
+ "@vellumai/ipc-server-utils": ["@vellumai/ipc-server-utils@file:../packages/ipc-server-utils", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
214
+
212
215
  "@vellumai/service-contracts": ["@vellumai/service-contracts@file:../packages/service-contracts", { "dependencies": { "zod": "4.3.6" }, "devDependencies": { "@types/bun": "1.2.4", "typescript": "5.7.3" } }],
213
216
 
214
217
  "@vellumai/slack-text": ["@vellumai/slack-text@file:../packages/slack-text", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
@@ -497,10 +500,12 @@
497
500
 
498
501
  "@vellumai/ces-client/@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="],
499
502
 
500
- "@vellumai/ces-client/@vellumai/service-contracts": ["@vellumai/service-contracts@file:../packages/service-contracts", {}],
503
+ "@vellumai/ces-client/@vellumai/service-contracts": ["@vellumai/service-contracts@file:../packages/service-contracts", { "dependencies": { "zod": "4.3.6" }, "devDependencies": { "@types/bun": "1.2.4", "typescript": "5.7.3" } }],
501
504
 
502
505
  "@vellumai/ces-client/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
503
506
 
507
+ "@vellumai/ipc-server-utils/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
508
+
504
509
  "@vellumai/service-contracts/@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="],
505
510
 
506
511
  "@vellumai/service-contracts/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
@@ -567,6 +572,8 @@
567
572
 
568
573
  "@vellumai/ces-client/@types/bun/bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="],
569
574
 
575
+ "@vellumai/ipc-server-utils/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
576
+
570
577
  "@vellumai/service-contracts/@types/bun/bun-types": ["bun-types@1.2.4", "", { "dependencies": { "@types/node": "*", "@types/ws": "~8.5.10" } }, "sha512-nDPymR207ZZEoWD4AavvEaa/KZe/qlrbMSchqpQwovPZCKc7pwMoENjEtHgMKaAjJhy+x6vfqSBA1QU3bJgs0Q=="],
571
578
 
572
579
  "@vellumai/slack-text/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
package/knip.json CHANGED
@@ -4,6 +4,7 @@
4
4
  "ignoreDependencies": [
5
5
  "@vellumai/assistant-client",
6
6
  "@vellumai/ces-client",
7
+ "@vellumai/ipc-server-utils",
7
8
  "@vellumai/service-contracts",
8
9
  "@vellumai/slack-text",
9
10
  "@vellumai/twilio-client"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -26,6 +26,7 @@
26
26
  "dependencies": {
27
27
  "@vellumai/assistant-client": "file:../packages/assistant-client",
28
28
  "@vellumai/ces-client": "file:../packages/ces-client",
29
+ "@vellumai/ipc-server-utils": "file:../packages/ipc-server-utils",
29
30
  "@vellumai/service-contracts": "file:../packages/service-contracts",
30
31
  "@vellumai/slack-text": "file:../packages/slack-text",
31
32
  "@vellumai/twilio-client": "file:../packages/twilio-client",
@@ -144,7 +144,7 @@ describe("Twilio webhook sync config-change triggers", () => {
144
144
  expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(true);
145
145
  });
146
146
 
147
- test("syncs when the Twilio-specific public ingress changes", () => {
147
+ test("syncs when Velay-managed public ingress changes", () => {
148
148
  const event = makeEvent(["ingress"], {
149
149
  ingress: ["publicBaseUrl", "publicBaseUrlManagedBy"],
150
150
  });
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Tests for POST /v1/contacts/prompt/submit.
3
+ *
4
+ * Covers the key contact-first resolution logic:
5
+ * - Guardian prompts always bind to the existing guardian contact.
6
+ * - Guardian prompts conflict (409) when the channel belongs to another contact.
7
+ * - Non-guardian prompts create or reuse contacts via channel lookup.
8
+ * - All writes are dual-written to the gateway DB.
9
+ */
10
+ import {
11
+ afterAll,
12
+ afterEach,
13
+ beforeAll,
14
+ beforeEach,
15
+ describe,
16
+ expect,
17
+ mock,
18
+ test,
19
+ } from "bun:test";
20
+ import { Database } from "bun:sqlite";
21
+
22
+ import { initSigningKey } from "../auth/token-service.js";
23
+
24
+ initSigningKey(Buffer.from("test-signing-key-at-least-32-bytes-long-xx"));
25
+
26
+ // ---------------------------------------------------------------------------
27
+ // Mock assistant DB proxy with a real in-memory SQLite.
28
+ // ---------------------------------------------------------------------------
29
+
30
+ let testAssistantDb: Database | null = null;
31
+
32
+ mock.module("../db/assistant-db-proxy.js", () => ({
33
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
34
+ async assistantDbQuery(sql: string, bind?: any[]) {
35
+ if (!testAssistantDb) throw new Error("test assistant DB not initialized");
36
+ const stmt = testAssistantDb.prepare(sql);
37
+ return bind ? stmt.all(...bind) : stmt.all();
38
+ },
39
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
40
+ async assistantDbRun(sql: string, bind?: any[]) {
41
+ if (!testAssistantDb) throw new Error("test assistant DB not initialized");
42
+ const stmt = testAssistantDb.prepare(sql);
43
+ const result = bind ? stmt.run(...bind) : stmt.run();
44
+ return { changes: result.changes, lastInsertRowid: Number(result.lastInsertRowid) };
45
+ },
46
+ }));
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Mock IPC so resolve_contact_prompt doesn't try to dial a real socket.
50
+ // ---------------------------------------------------------------------------
51
+
52
+ const ipcMock = mock(async () => ({ resolved: true }));
53
+
54
+ mock.module("../ipc/assistant-client.js", () => ({
55
+ ipcCallAssistant: ipcMock,
56
+ }));
57
+
58
+ // ---------------------------------------------------------------------------
59
+ // Imports that depend on the mocks above.
60
+ // ---------------------------------------------------------------------------
61
+
62
+ const { handleContactPromptSubmit } = await import(
63
+ "../http/routes/contact-prompt.js"
64
+ );
65
+ const { initGatewayDb, getGatewayDb, resetGatewayDb } = await import(
66
+ "../db/connection.js"
67
+ );
68
+ const { contactChannels: gwContactChannels, contacts: gwContacts } =
69
+ await import("../db/schema.js");
70
+ const { eq } = await import("drizzle-orm");
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Schema helpers
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function initAssistantDb(): Database {
77
+ const db = new Database(":memory:");
78
+ db.exec("PRAGMA foreign_keys=ON");
79
+ db.exec(`
80
+ CREATE TABLE contacts (
81
+ id TEXT PRIMARY KEY,
82
+ display_name TEXT NOT NULL,
83
+ notes TEXT,
84
+ created_at INTEGER NOT NULL,
85
+ updated_at INTEGER NOT NULL,
86
+ role TEXT NOT NULL DEFAULT 'contact',
87
+ principal_id TEXT,
88
+ user_file TEXT,
89
+ contact_type TEXT NOT NULL DEFAULT 'human'
90
+ )
91
+ `);
92
+ db.exec(`
93
+ CREATE TABLE contact_channels (
94
+ id TEXT PRIMARY KEY,
95
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
96
+ type TEXT NOT NULL,
97
+ address TEXT NOT NULL,
98
+ is_primary INTEGER NOT NULL DEFAULT 0,
99
+ external_user_id TEXT,
100
+ external_chat_id TEXT,
101
+ status TEXT NOT NULL DEFAULT 'unverified',
102
+ policy TEXT NOT NULL DEFAULT 'allow',
103
+ verified_at INTEGER,
104
+ verified_via TEXT,
105
+ invite_id TEXT,
106
+ revoked_reason TEXT,
107
+ blocked_reason TEXT,
108
+ last_seen_at INTEGER,
109
+ interaction_count INTEGER NOT NULL DEFAULT 0,
110
+ last_interaction INTEGER,
111
+ updated_at INTEGER,
112
+ created_at INTEGER NOT NULL
113
+ )
114
+ `);
115
+ db.exec(
116
+ `CREATE UNIQUE INDEX idx_contact_channels_type_address ON contact_channels(type, address)`,
117
+ );
118
+ return db;
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // Request factory
123
+ // ---------------------------------------------------------------------------
124
+
125
+ function makeRequest(body: Record<string, unknown>): Request {
126
+ return new Request("http://localhost:7830/v1/contacts/prompt/submit", {
127
+ method: "POST",
128
+ headers: { "content-type": "application/json" },
129
+ body: JSON.stringify(body),
130
+ });
131
+ }
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Suite setup
135
+ // ---------------------------------------------------------------------------
136
+
137
+ beforeAll(async () => {
138
+ await initGatewayDb();
139
+ });
140
+
141
+ afterAll(() => {
142
+ resetGatewayDb();
143
+ });
144
+
145
+ beforeEach(() => {
146
+ testAssistantDb = initAssistantDb();
147
+ ipcMock.mockClear();
148
+
149
+ const gwDb = getGatewayDb();
150
+ gwDb.delete(gwContactChannels).run();
151
+ gwDb.delete(gwContacts).run();
152
+ });
153
+
154
+ afterEach(() => {
155
+ testAssistantDb?.close();
156
+ testAssistantDb = null;
157
+ });
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // Tests
161
+ // ---------------------------------------------------------------------------
162
+
163
+ describe("handleContactPromptSubmit", () => {
164
+ test("guardian prompt — creates channel bound to existing guardian contact", async () => {
165
+ const now = Date.now();
166
+ // Seed an existing guardian contact in the assistant DB.
167
+ testAssistantDb!.run(
168
+ `INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
169
+ VALUES ('guardian-1', 'Vargas', 'guardian', 'human', ?, ?)`,
170
+ [now, now],
171
+ );
172
+
173
+ const res = await handleContactPromptSubmit(
174
+ makeRequest({ requestId: "req-1", address: "+15551234567", channelType: "phone", role: "guardian" }),
175
+ );
176
+
177
+ expect(res.status).toBe(200);
178
+ const body = await res.json() as Record<string, unknown>;
179
+ expect(body.accepted).toBe(true);
180
+
181
+ // Channel should be created in assistant DB pointing to guardian.
182
+ const channels = testAssistantDb!
183
+ .prepare(`SELECT contact_id FROM contact_channels WHERE type = 'phone' AND address = ?`)
184
+ .all("+15551234567") as { contact_id: string }[];
185
+ expect(channels).toHaveLength(1);
186
+ expect(channels[0].contact_id).toBe("guardian-1");
187
+
188
+ // IPC should have been called with the guardian contactId.
189
+ expect(ipcMock).toHaveBeenCalledTimes(1);
190
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
191
+ const ipcCall = (ipcMock.mock.calls as any[][])[0][1] as { body: Record<string, unknown> };
192
+ expect(ipcCall.body.contactId).toBe("guardian-1");
193
+ });
194
+
195
+ test("guardian prompt — reuses channel already bound to guardian", async () => {
196
+ const now = Date.now();
197
+ testAssistantDb!.run(
198
+ `INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
199
+ VALUES ('guardian-1', 'Vargas', 'guardian', 'human', ?, ?)`,
200
+ [now, now],
201
+ );
202
+ testAssistantDb!.run(
203
+ `INSERT INTO contact_channels (id, contact_id, type, address, is_primary, status, policy, interaction_count, created_at, updated_at)
204
+ VALUES ('chan-1', 'guardian-1', 'phone', '+15551234567', 1, 'active', 'allow', 5, ?, ?)`,
205
+ [now, now],
206
+ );
207
+
208
+ const res = await handleContactPromptSubmit(
209
+ makeRequest({ requestId: "req-2", address: "+15551234567", channelType: "phone", role: "guardian" }),
210
+ );
211
+
212
+ expect(res.status).toBe(200);
213
+ const body = await res.json() as Record<string, unknown>;
214
+ expect(body.accepted).toBe(true);
215
+
216
+ // No new channel should have been inserted.
217
+ const channels = testAssistantDb!
218
+ .prepare(`SELECT id FROM contact_channels WHERE type = 'phone'`)
219
+ .all() as { id: string }[];
220
+ expect(channels).toHaveLength(1);
221
+ expect(channels[0].id).toBe("chan-1");
222
+ });
223
+
224
+ test("guardian prompt — 409 when channel already belongs to another contact", async () => {
225
+ const now = Date.now();
226
+ // Guardian contact.
227
+ testAssistantDb!.run(
228
+ `INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
229
+ VALUES ('guardian-1', 'Vargas', 'guardian', 'human', ?, ?)`,
230
+ [now, now],
231
+ );
232
+ // A different (orphaned or stale) contact that owns the channel.
233
+ testAssistantDb!.run(
234
+ `INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
235
+ VALUES ('other-1', 'Orphan', 'contact', 'human', ?, ?)`,
236
+ [now, now],
237
+ );
238
+ testAssistantDb!.run(
239
+ `INSERT INTO contact_channels (id, contact_id, type, address, is_primary, status, policy, interaction_count, created_at, updated_at)
240
+ VALUES ('chan-other', 'other-1', 'phone', '+15551234567', 1, 'unverified', 'allow', 0, ?, ?)`,
241
+ [now, now],
242
+ );
243
+
244
+ const res = await handleContactPromptSubmit(
245
+ makeRequest({ requestId: "req-3", address: "+15551234567", channelType: "phone", role: "guardian" }),
246
+ );
247
+
248
+ expect(res.status).toBe(409);
249
+ const body = await res.json() as Record<string, unknown>;
250
+ expect(body.accepted).toBe(false);
251
+
252
+ // The stale channel must not have been deleted.
253
+ const channels = testAssistantDb!
254
+ .prepare(`SELECT id FROM contact_channels WHERE type = 'phone'`)
255
+ .all() as { id: string }[];
256
+ expect(channels).toHaveLength(1);
257
+ expect(channels[0].id).toBe("chan-other");
258
+
259
+ // IPC should have been called with an error so the CLI doesn't hang.
260
+ expect(ipcMock).toHaveBeenCalledTimes(1);
261
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
262
+ const ipcCall = (ipcMock.mock.calls as any[][])[0][1] as { body: Record<string, unknown> };
263
+ expect(typeof ipcCall.body.error).toBe("string");
264
+ });
265
+
266
+ test("non-guardian prompt — creates new contact and channel", async () => {
267
+ const res = await handleContactPromptSubmit(
268
+ makeRequest({
269
+ requestId: "req-4",
270
+ address: "alice@example.com",
271
+ channelType: "email",
272
+ role: "trusted-contact",
273
+ displayName: "Alice",
274
+ }),
275
+ );
276
+
277
+ expect(res.status).toBe(200);
278
+
279
+ const contacts = testAssistantDb!
280
+ .prepare(`SELECT id, role FROM contacts WHERE display_name = 'Alice'`)
281
+ .all() as { id: string; role: string }[];
282
+ expect(contacts).toHaveLength(1);
283
+ expect(contacts[0].role).toBe("contact");
284
+
285
+ const channels = testAssistantDb!
286
+ .prepare(`SELECT contact_id FROM contact_channels WHERE type = 'email' AND address = ?`)
287
+ .all("alice@example.com") as { contact_id: string }[];
288
+ expect(channels).toHaveLength(1);
289
+ expect(channels[0].contact_id).toBe(contacts[0].id);
290
+ });
291
+
292
+ test("non-guardian prompt — reuses existing contact when channel already known", async () => {
293
+ const now = Date.now();
294
+ testAssistantDb!.run(
295
+ `INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
296
+ VALUES ('contact-1', 'Alice', 'contact', 'human', ?, ?)`,
297
+ [now, now],
298
+ );
299
+ testAssistantDb!.run(
300
+ `INSERT INTO contact_channels (id, contact_id, type, address, is_primary, status, policy, interaction_count, created_at, updated_at)
301
+ VALUES ('chan-alice', 'contact-1', 'email', 'alice@example.com', 1, 'active', 'allow', 3, ?, ?)`,
302
+ [now, now],
303
+ );
304
+
305
+ const res = await handleContactPromptSubmit(
306
+ makeRequest({ requestId: "req-5", address: "alice@example.com", channelType: "email" }),
307
+ );
308
+
309
+ expect(res.status).toBe(200);
310
+
311
+ // Should not have created a second contact.
312
+ const contacts = testAssistantDb!
313
+ .prepare(`SELECT id FROM contacts`)
314
+ .all() as { id: string }[];
315
+ expect(contacts).toHaveLength(1);
316
+ expect(contacts[0].id).toBe("contact-1");
317
+
318
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
319
+ const ipcCall = (ipcMock.mock.calls as any[][])[0][1] as { body: Record<string, unknown> };
320
+ expect(ipcCall.body.contactId).toBe("contact-1");
321
+ });
322
+
323
+ test("gateway DB receives dual-write for new contact and channel", async () => {
324
+ const now = Date.now();
325
+ testAssistantDb!.run(
326
+ `INSERT INTO contacts (id, display_name, role, contact_type, created_at, updated_at)
327
+ VALUES ('guardian-1', 'Vargas', 'guardian', 'human', ?, ?)`,
328
+ [now, now],
329
+ );
330
+
331
+ // Also seed guardian in gateway DB so FK is satisfied.
332
+ getGatewayDb()
333
+ .insert(gwContacts)
334
+ .values({ id: "guardian-1", displayName: "Vargas", role: "guardian", createdAt: now, updatedAt: now })
335
+ .run();
336
+
337
+ await handleContactPromptSubmit(
338
+ makeRequest({ requestId: "req-6", address: "+15559876543", channelType: "phone", role: "guardian" }),
339
+ );
340
+
341
+ const gwChannels = getGatewayDb()
342
+ .select()
343
+ .from(gwContactChannels)
344
+ .where(eq(gwContactChannels.address, "+15559876543"))
345
+ .all();
346
+ expect(gwChannels).toHaveLength(1);
347
+ expect(gwChannels[0].contactId).toBe("guardian-1");
348
+ });
349
+ });