@vellumai/vellum-gateway 0.7.1 → 0.7.3
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/AGENTS.md +4 -0
- package/ARCHITECTURE.md +58 -17
- package/Dockerfile +1 -0
- package/README.md +46 -5
- package/bun.lock +9 -2
- package/knip.json +2 -1
- package/package.json +2 -1
- package/src/__tests__/auto-approve-thresholds.test.ts +49 -22
- package/src/__tests__/config-file-watcher.test.ts +181 -0
- package/src/__tests__/contact-prompt-submit.test.ts +349 -0
- package/src/__tests__/credential-watcher.test.ts +20 -2
- package/src/__tests__/feature-flags-route.test.ts +3 -3
- package/src/__tests__/guardian-init-lockfile.test.ts +24 -0
- package/src/__tests__/ipc-route-policy.test.ts +24 -0
- package/src/__tests__/nonbash-trust-rule-overrides.test.ts +50 -0
- package/src/__tests__/pair-origin-allowlist.test.ts +155 -0
- package/src/__tests__/rate-limit-loopback.test.ts +1 -1
- package/src/__tests__/remote-feature-flag-sync.test.ts +63 -21
- package/src/__tests__/route-schema-guard.test.ts +42 -6
- package/src/__tests__/slack-display-name.test.ts +6 -2
- package/src/__tests__/slack-normalize.test.ts +36 -56
- package/src/__tests__/slack-socket-mode-catchup.test.ts +857 -0
- package/src/__tests__/slack-socket-mode-scopes.test.ts +52 -0
- package/src/__tests__/slack-socket-mode-thread-tracking.test.ts +28 -2
- package/src/__tests__/telegram-webhook-manager.test.ts +8 -25
- package/src/__tests__/twilio-webhooks.test.ts +216 -2
- package/src/__tests__/upsert-verified-contact-channel.test.ts +173 -0
- package/src/__tests__/upstream-transport.test.ts +0 -36
- package/src/auth/guardian-bootstrap.ts +49 -0
- package/src/auth/guardian-refresh.ts +4 -18
- package/src/auth/ipc-route-policy.ts +5 -0
- package/src/backup/backup-key.ts +138 -0
- package/src/backup/backup-routes.ts +159 -0
- package/src/backup/backup-worker.ts +374 -0
- package/src/backup/list-snapshots.ts +97 -0
- package/src/backup/local-writer.ts +87 -0
- package/src/backup/offsite-writer.ts +182 -0
- package/src/backup/paths.ts +123 -0
- package/src/backup/stream-crypt.ts +258 -0
- package/src/chrome-extension-origins.ts +28 -0
- package/src/config-file-cache.ts +3 -19
- package/src/config-file-utils.ts +124 -0
- package/src/config-file-watcher.ts +57 -25
- package/src/config.ts +4 -0
- package/src/db/contact-store.ts +56 -1
- package/src/db/data-migrations/index.ts +2 -0
- package/src/db/data-migrations/m0003-recover-backup-key.ts +71 -0
- package/src/db/schema.ts +30 -0
- package/src/db/slack-store.ts +144 -11
- package/src/email/register-callback.test.ts +4 -4
- package/src/email/register-callback.ts +12 -16
- package/src/feature-flag-registry.json +47 -135
- package/src/handlers/handle-inbound.ts +102 -0
- package/src/http/middleware/auth.ts +1 -1
- package/src/http/middleware/cors.ts +84 -0
- package/src/http/middleware/rate-limit.ts +6 -8
- package/src/http/routes/auto-approve-thresholds.ts +17 -1
- package/src/http/routes/channel-verification-session-proxy.ts +17 -35
- package/src/http/routes/contact-prompt.ts +260 -0
- package/src/http/routes/contacts-control-plane-proxy.ts +34 -5
- package/src/http/routes/ipc-runtime-proxy.ts +18 -0
- package/src/http/routes/log-tail.test.ts +336 -0
- package/src/http/routes/log-tail.ts +87 -0
- package/src/http/routes/pair.ts +322 -0
- package/src/http/routes/privacy-config.ts +65 -79
- package/src/http/routes/runtime-proxy.ts +3 -1
- package/src/http/routes/stt-stream-websocket.ts +2 -3
- package/src/http/routes/twilio-media-websocket.ts +5 -5
- package/src/http/routes/twilio-voice-verify-callback.ts +30 -2
- package/src/http/routes/twilio-voice-webhook.test.ts +81 -0
- package/src/http/routes/twilio-voice-webhook.ts +61 -0
- package/src/index.ts +331 -248
- package/src/ipc/contact-handlers.ts +88 -3
- package/src/ipc/threshold-handlers.ts +2 -0
- package/src/ipc/velay-handlers.ts +31 -0
- package/src/remote-feature-flag-sync.ts +10 -8
- package/src/risk/bash-risk-classifier.test.ts +35 -3
- package/src/risk/bash-risk-classifier.ts +44 -14
- package/src/risk/command-registry/commands/assistant.ts +6 -0
- package/src/risk/risk-classifier-parity.test.ts +1 -1
- package/src/risk/skill-risk-classifier.ts +12 -3
- package/src/runtime/client.ts +71 -3
- package/src/schema.ts +220 -67
- package/src/slack/normalize.test.ts +27 -3
- package/src/slack/normalize.ts +14 -69
- package/src/slack/slack-web.ts +213 -0
- package/src/slack/socket-mode.ts +521 -25
- package/src/telegram/webhook-manager.ts +9 -13
- package/src/twilio/validate-webhook.ts +53 -14
- package/src/twilio/webhook-sync-trigger.ts +58 -0
- package/src/twilio/webhook-sync.test.ts +286 -0
- package/src/twilio/webhook-sync.ts +84 -0
- package/src/util/is-loopback-address.ts +27 -0
- package/src/velay/bridge-utils.ts +228 -0
- package/src/velay/client.test.ts +939 -0
- package/src/velay/client.ts +566 -0
- package/src/velay/http-bridge.test.ts +217 -0
- package/src/velay/http-bridge.ts +83 -0
- package/src/velay/protocol.ts +178 -0
- package/src/velay/test-fake-websocket.ts +69 -0
- package/src/velay/websocket-bridge.test.ts +367 -0
- package/src/velay/websocket-bridge.ts +324 -0
- package/src/verification/contact-helpers.ts +143 -3
- package/src/version.ts +35 -0
- package/src/__tests__/browser-relay-websocket.test.ts +0 -697
- package/src/auth/capability-tokens.ts +0 -248
- package/src/http/routes/browser-extension-pair.ts +0 -455
- package/src/http/routes/browser-relay-websocket.ts +0 -381
- package/src/http/routes/config-file-utils.ts +0 -73
- package/src/ipc/capability-token-handlers.ts +0 -30
- package/src/pairing/approved-devices-store.ts +0 -110
- package/src/pairing/pairing-routes.ts +0 -379
- package/src/pairing/pairing-store.ts +0 -218
package/AGENTS.md
CHANGED
|
@@ -37,6 +37,10 @@ In Docker mode, the gateway is the sole owner of trust rule storage. Trust files
|
|
|
37
37
|
|
|
38
38
|
The assistant reads and writes trust rules via the gateway's HTTP trust API instead of accessing the filesystem directly. This ensures the security boundary is enforced at the container level — even if the assistant container is compromised, it cannot tamper with trust rules without going through the gateway's API.
|
|
39
39
|
|
|
40
|
+
### Backup Encryption Key
|
|
41
|
+
|
|
42
|
+
The backup encryption key (`backup.key`) lives in `GATEWAY_SECURITY_DIR` and is never exposed to the assistant daemon or workspace. The gateway owns all backup encryption/decryption — the assistant produces plaintext vbundles via `/v1/migrations/export`, and the gateway encrypts them for offsite storage. The assistant cannot read the key via `file_read` or any other tool. This is a security boundary: even if the assistant is prompt-injected, backup encryption remains intact.
|
|
43
|
+
|
|
40
44
|
### Credential Access in Docker Mode
|
|
41
45
|
|
|
42
46
|
In Docker mode, the gateway accesses stored credentials via the CES HTTP API (`CES_CREDENTIAL_URL`), authenticated with `CES_SERVICE_TOKEN`. The gateway does not have direct filesystem access to credential encryption keys (`keys.enc`, `store.key`), which reside on the CES security volume.
|
package/ARCHITECTURE.md
CHANGED
|
@@ -280,9 +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
|
|
284
|
-
|
|
|
285
|
-
| `ingress.publicBaseUrl` (workspace config)
|
|
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 |
|
|
286
287
|
|
|
287
288
|
### Tunnel-Agnostic Setup
|
|
288
289
|
|
|
@@ -291,7 +292,34 @@ To expose the gateway for external callbacks during local development:
|
|
|
291
292
|
1. **Start your tunnel** service (ngrok, Cloudflare Tunnel, or any similar tool), pointing it at the local gateway: `http://127.0.0.1:7830`
|
|
292
293
|
2. **Set the public URL** provided by the tunnel as `ingress.publicBaseUrl` in the Settings UI (Public Ingress section)
|
|
293
294
|
|
|
294
|
-
The assistant runtime reads this URL via the centralized `public-ingress-urls.ts` module and uses it to construct
|
|
295
|
+
The assistant runtime reads this URL via the centralized `public-ingress-urls.ts` module and uses it to construct webhook and callback URLs automatically. Ngrok and custom tunnels remain supported for every ingress surface, including Twilio fallback.
|
|
296
|
+
|
|
297
|
+
### Velay Twilio Ingress
|
|
298
|
+
|
|
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
|
+
|
|
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
|
+
|
|
303
|
+
Velay forwards both HTTP request frames and WebSocket frames into the local gateway loopback listener:
|
|
304
|
+
|
|
305
|
+
```text
|
|
306
|
+
Public Velay HTTPS/WSS URL
|
|
307
|
+
→ Velay tunnel session
|
|
308
|
+
→ Gateway Velay bridge
|
|
309
|
+
→ Gateway loopback listener (http://127.0.0.1:<GATEWAY_PORT>)
|
|
310
|
+
→ Existing gateway route handlers
|
|
311
|
+
```
|
|
312
|
+
|
|
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
|
+
|
|
315
|
+
Local platform smoke-test flow:
|
|
316
|
+
|
|
317
|
+
1. In `vellum-assistant-platform`, run `vel up velay`.
|
|
318
|
+
2. Ensure vembda passes the environment-appropriate `VELAY_BASE_URL` into assistant gateway containers.
|
|
319
|
+
3. Re-hatch or restart the assistant so the gateway receives the new environment.
|
|
320
|
+
4. Confirm gateway logs show `Velay tunnel connected` and `Velay tunnel registered`.
|
|
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 the gateway has registered with Velay.
|
|
295
323
|
|
|
296
324
|
### URL Builders
|
|
297
325
|
|
|
@@ -300,10 +328,10 @@ All public-facing URLs are constructed by `assistant/src/inbound/public-ingress-
|
|
|
300
328
|
| Function | URL Pattern |
|
|
301
329
|
| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
302
330
|
| `getPublicBaseUrl()` | Resolves the canonical base URL from `ingress.publicBaseUrl` in workspace config or module-level state (assistant-side; the gateway reads via `ConfigFileCache`) |
|
|
303
|
-
| `getTwilioVoiceWebhookUrl()` | `${base}/webhooks/twilio/voice?callSessionId
|
|
304
|
-
| `getTwilioStatusCallbackUrl()` | `${base}/webhooks/twilio/status`
|
|
305
|
-
| `getTwilioConnectActionUrl()` | `${base}/webhooks/twilio/connect-action`
|
|
306
|
-
| `getTwilioRelayUrl()` | `ws(s)://.../webhooks/twilio/relay`
|
|
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` |
|
|
307
335
|
| `getOAuthCallbackUrl()` | `${base}/webhooks/oauth/callback` |
|
|
308
336
|
| `getTelegramWebhookUrl()` | `${base}/webhooks/telegram` |
|
|
309
337
|
|
|
@@ -702,7 +730,7 @@ The Slack channel enables inbound and outbound messaging via Slack's Socket Mode
|
|
|
702
730
|
|
|
703
731
|
1. Every Socket Mode envelope is ACKed immediately by echoing `{ envelope_id }` back on the WebSocket — this is required by Slack regardless of whether the event is processed.
|
|
704
732
|
2. Only `events_api` envelopes with `app_mention` events are processed in MVP. Other envelope types (slash commands, interactive payloads) are ACKed but ignored.
|
|
705
|
-
3. Events are deduplicated by `
|
|
733
|
+
3. Events are deduplicated by a compound key in the SQLite-backed `slack_seen_events` table: every event records its Slack `event_id`, and message-shaped events additionally record `msg:${channel}:${ts}` so the live and reconnect-replay paths dedup symmetrically. Entries TTL out after 24h; a periodic cleanup sweep evicts expired rows.
|
|
706
734
|
4. The `normalizeSlackAppMention()` function strips leading bot-mention tokens (`<@U...>`) from the message text and produces a `GatewayInboundEvent` with `sourceChannel: "slack"`, using the Slack channel ID as `conversationExternalId` and the sender's user ID as `actorExternalId`.
|
|
707
735
|
5. Routing uses the standard `resolveAssistant()` chain (conversation_id -> actor_id -> default/reject). Events that cannot be routed are dropped.
|
|
708
736
|
6. The normalized event is forwarded to the runtime via `POST /v1/channels/inbound` with a `replyCallbackUrl` pointing to `/deliver/slack`.
|
|
@@ -729,13 +757,24 @@ Both tokens are stored in secure storage (`credential/slack_channel/app_token`,
|
|
|
729
757
|
|
|
730
758
|
The Socket Mode client auto-reconnects on any WebSocket close or error. The backoff schedule is: `min(1000 * 2^attempt, 30000)` + random jitter. After a successful connection, the attempt counter resets. Slack-initiated disconnects (envelope `type: "disconnect"`) trigger an immediate reconnect with no backoff.
|
|
731
759
|
|
|
760
|
+
**Reconnect catch-up:**
|
|
761
|
+
|
|
762
|
+
Slack [does not buffer Socket Mode events](https://api.slack.com/apis/socket-mode#events) for disconnected clients, so any @mention or DM that arrives during a reconnect window is lost on the wire. The client recovers these by maintaining a persistent high-watermark (`slack_last_seen_ts`) of the latest accepted event timestamp and, on every WebSocket `open`, fetching a bounded slice of [`conversations.history`](https://api.slack.com/methods/conversations.history) and [`conversations.replies`](https://api.slack.com/methods/conversations.replies) since that watermark. Recovered messages are wrapped in synthetic Socket Mode envelopes and dispatched through the same `processEventPayload` path as live events, so filter, dedup, normalize, and routing logic stay in one place.
|
|
763
|
+
|
|
764
|
+
Catch-up is scoped to channels the gateway can reasonably reach: routing entries with a Slack-shaped conversation ID (`C…` / `D…` / `G…`), tracked active threads (`slack_active_threads`), and previously-seen DM channels (`contact_channels` rows of type `slack`). It is bounded by max-lookback (1h), per-channel limit (50), and concurrency (4); on a 429 the cycle aborts immediately and resumes on the next reconnect. Brand-new mentions in unrouted, never-engaged channels remain unrecoverable here — the daemon's existing inbound-triggered Slack backfill (`triggerSlackThreadBackfillIfNeeded` and `tryBackfillSlackDmIfCold`) hydrates context once the next live event arrives.
|
|
765
|
+
|
|
766
|
+
**General principle — stateful-stream transports require catch-up-on-reconnect:**
|
|
767
|
+
|
|
768
|
+
Any persistent-stream transport that does not buffer events for disconnected clients (Slack Socket Mode, similar WebSocket gateways) must combine the WebSocket with a HTTP catch-up path on reconnect. A persisted high-watermark + bounded replay through the shared event pipeline is the standard pattern; without it, every transient disconnect silently drops events.
|
|
769
|
+
|
|
732
770
|
**Key modules:**
|
|
733
771
|
|
|
734
|
-
| Module | Purpose
|
|
735
|
-
| ------------------------------------------ |
|
|
736
|
-
| `gateway/src/slack/socket-mode.ts` | `SlackSocketModeClient` — WebSocket lifecycle, ACK, dedup, auto-reconnect
|
|
737
|
-
| `gateway/src/slack/
|
|
738
|
-
| `gateway/src/
|
|
772
|
+
| Module | Purpose |
|
|
773
|
+
| ------------------------------------------ | --------------------------------------------------------------------------------------------- |
|
|
774
|
+
| `gateway/src/slack/socket-mode.ts` | `SlackSocketModeClient` — WebSocket lifecycle, ACK, dedup, auto-reconnect, reconnect catch-up |
|
|
775
|
+
| `gateway/src/slack/slack-web.ts` | `conversations.history` / `conversations.replies` helpers for reconnect catch-up |
|
|
776
|
+
| `gateway/src/slack/normalize.ts` | `normalizeSlackAppMention()` — event normalization and bot-mention stripping |
|
|
777
|
+
| `gateway/src/http/routes/slack-deliver.ts` | `/deliver/slack` — outbound message delivery via `chat.postMessage` |
|
|
739
778
|
|
|
740
779
|
**Limitations (MVP):** Text-only — attachments are rejected. Only `app_mention` events are processed (direct messages to the bot are not handled). Rich approval UI (inline buttons) is not supported.
|
|
741
780
|
|
|
@@ -1038,10 +1077,12 @@ In gateway-fronted deployments, the TwiML WebSocket URL (returned by the voice w
|
|
|
1038
1077
|
|
|
1039
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).
|
|
1040
1079
|
|
|
1041
|
-
**Webhook base URL resolution:**
|
|
1080
|
+
**Webhook base URL resolution:** Public ingress URL construction is centralized in `public-ingress-urls.ts`:
|
|
1042
1081
|
|
|
1043
|
-
|
|
1044
|
-
|
|
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
|
+
- Module-level assistant state remains a fallback for legacy tunnel start/stop flows.
|
|
1045
1086
|
|
|
1046
1087
|
All webhook paths (`/webhooks/twilio/voice`, `/webhooks/twilio/status`, `/webhooks/telegram`, `/webhooks/oauth/callback`, etc.) are appended automatically.
|
|
1047
1088
|
|
package/Dockerfile
CHANGED
|
@@ -13,6 +13,7 @@ COPY packages/assistant-client ./packages/assistant-client
|
|
|
13
13
|
COPY packages/ces-client ./packages/ces-client
|
|
14
14
|
COPY packages/service-contracts ./packages/service-contracts
|
|
15
15
|
COPY packages/slack-text ./packages/slack-text
|
|
16
|
+
COPY packages/twilio-client ./packages/twilio-client
|
|
16
17
|
|
|
17
18
|
# Install deps for shared packages that have their own file: dependencies.
|
|
18
19
|
RUN cd /app/packages/ces-client && bun install --frozen-lockfile
|
package/README.md
CHANGED
|
@@ -26,11 +26,11 @@ bun run dev
|
|
|
26
26
|
|
|
27
27
|
## Configuration
|
|
28
28
|
|
|
29
|
-
| Variable | Required | Default | Description
|
|
30
|
-
| ------------------------- | -------- | ------- |
|
|
29
|
+
| Variable | Required | Default | Description |
|
|
30
|
+
| ------------------------- | -------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
31
31
|
| `TELEGRAM_BOT_TOKEN` | No | — | Bot token from @BotFather (Telegram disabled when unset). When not set as an env var, the gateway reads from the assistant's secure credential store: CES HTTP API first (when `CES_CREDENTIAL_URL` is configured), then the encrypted file store (`~/.vellum/protected/keys.enc`). |
|
|
32
|
-
| `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`.
|
|
33
|
-
| `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server
|
|
32
|
+
| `TELEGRAM_WEBHOOK_SECRET` | No | — | Secret for verifying webhook requests (Telegram disabled when unset). Same credential reader fallback behavior as `TELEGRAM_BOT_TOKEN`. |
|
|
33
|
+
| `GATEWAY_PORT` | No | `7830` | Port for the gateway HTTP server |
|
|
34
34
|
|
|
35
35
|
Most gateway behavior is now configured via hardcoded defaults or workspace config (`~/.vellum/workspace/config.json`) rather than environment variables. Channel operational settings (Telegram API base URL, timeouts, deliver auth bypass flags, runtime base URL, routing, proxy settings, attachment limits, shutdown drain) are managed via `workspace/config.json` through `ConfigFileCache`. See the channel-specific sections in `ARCHITECTURE.md` for details.
|
|
36
36
|
|
|
@@ -176,7 +176,7 @@ The gateway serves as the single public ingress point for all external callbacks
|
|
|
176
176
|
|
|
177
177
|
### Tunnel Setup
|
|
178
178
|
|
|
179
|
-
To receive external callbacks during local development, point a tunnel service at the local gateway (default `http://127.0.0.1:7830`) and configure the resulting public URL
|
|
179
|
+
To receive external callbacks during local development, point a tunnel service at the local gateway (default `http://127.0.0.1:7830`) and configure the resulting public URL. Ngrok, Cloudflare Tunnel, and other custom HTTPS/WSS tunnels remain supported.
|
|
180
180
|
|
|
181
181
|
#### Test Gateway Source Changes Locally (No Release Needed)
|
|
182
182
|
|
|
@@ -210,6 +210,47 @@ 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 Testing
|
|
214
|
+
|
|
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
|
+
|
|
217
|
+
Use Velay when testing Twilio voice webhooks or Twilio WebSocket upgrades through the platform-managed tunnel:
|
|
218
|
+
|
|
219
|
+
1. In `vellum-assistant-platform`, start the local Velay service:
|
|
220
|
+
|
|
221
|
+
```bash
|
|
222
|
+
vel up velay
|
|
223
|
+
```
|
|
224
|
+
|
|
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
|
+
|
|
227
|
+
```bash
|
|
228
|
+
VELAY_BASE_URL=http://host.docker.internal:8501
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Hosted environments should use their environment's deployed Velay URL instead.
|
|
232
|
+
|
|
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.publicBaseUrl`.
|
|
235
|
+
|
|
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
|
+
|
|
238
|
+
```bash
|
|
239
|
+
curl -i "$VELAY_PUBLIC_BASE_URL/<assistant-id>/healthz"
|
|
240
|
+
curl -i "$VELAY_PUBLIC_BASE_URL/<assistant-id>/schema"
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
When testing a JSON webhook route under active development, POST a small JSON body through the same Velay public URL and confirm the gateway logs or handler response show the request reached the loopback listener.
|
|
244
|
+
|
|
245
|
+
For a synthetic Twilio WebSocket smoke test, connect a local WebSocket client to the Velay public URL using one of the gateway Twilio WebSocket paths, such as:
|
|
246
|
+
|
|
247
|
+
```bash
|
|
248
|
+
bun -e 'const ws = new WebSocket(process.argv[1]); ws.onopen = () => { console.log("open"); ws.close(); }; ws.onerror = (event) => console.error(event);' \
|
|
249
|
+
"wss://<velay-host>/<assistant-id>/webhooks/twilio/relay?callSessionId=session-123&token=<edge-token>"
|
|
250
|
+
```
|
|
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. Use ngrok or another custom tunnel in `ingress.publicBaseUrl` only for local/self-hosted workflows that are not routed through Velay.
|
|
253
|
+
|
|
213
254
|
## Ingress Boundary Guarantees
|
|
214
255
|
|
|
215
256
|
The gateway is the **sole public ingress point** for all external webhooks. The assistant runtime never directly accepts public webhook traffic — all Twilio and Telegram webhook routes on the runtime return `410 GATEWAY_ONLY` when accessed directly.
|
package/bun.lock
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"@vellumai/ces-client": "file:../packages/ces-client",
|
|
10
10
|
"@vellumai/service-contracts": "file:../packages/service-contracts",
|
|
11
11
|
"@vellumai/slack-text": "file:../packages/slack-text",
|
|
12
|
+
"@vellumai/twilio-client": "file:../packages/twilio-client",
|
|
12
13
|
"drizzle-kit": "0.30.6",
|
|
13
14
|
"drizzle-orm": "0.45.2",
|
|
14
15
|
"file-type": "21.3.0",
|
|
@@ -212,6 +213,8 @@
|
|
|
212
213
|
|
|
213
214
|
"@vellumai/slack-text": ["@vellumai/slack-text@file:../packages/slack-text", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
|
|
214
215
|
|
|
216
|
+
"@vellumai/twilio-client": ["@vellumai/twilio-client@file:../packages/twilio-client", { "devDependencies": { "@types/bun": "1.3.10", "typescript": "5.9.3" } }],
|
|
217
|
+
|
|
215
218
|
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
|
216
219
|
|
|
217
220
|
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
|
@@ -352,7 +355,7 @@
|
|
|
352
355
|
|
|
353
356
|
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
|
|
354
357
|
|
|
355
|
-
"minimatch": ["minimatch@10.2.
|
|
358
|
+
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
|
356
359
|
|
|
357
360
|
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
|
358
361
|
|
|
@@ -494,7 +497,7 @@
|
|
|
494
497
|
|
|
495
498
|
"@vellumai/ces-client/@types/bun": ["@types/bun@1.2.4", "", { "dependencies": { "bun-types": "1.2.4" } }, "sha512-QtuV5OMR8/rdKJs213iwXDpfVvnskPXY/S0ZiFbsTjQZycuqPbMW8Gf/XhLfwE5njW8sxI2WjISURXPlHypMFA=="],
|
|
496
499
|
|
|
497
|
-
"@vellumai/ces-client/@vellumai/service-contracts": ["@vellumai/service-contracts@file:../packages/service-contracts", {
|
|
500
|
+
"@vellumai/ces-client/@vellumai/service-contracts": ["@vellumai/service-contracts@file:../packages/service-contracts", {}],
|
|
498
501
|
|
|
499
502
|
"@vellumai/ces-client/typescript": ["typescript@5.7.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw=="],
|
|
500
503
|
|
|
@@ -504,6 +507,8 @@
|
|
|
504
507
|
|
|
505
508
|
"@vellumai/slack-text/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
|
506
509
|
|
|
510
|
+
"@vellumai/twilio-client/@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
|
511
|
+
|
|
507
512
|
"fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
|
508
513
|
|
|
509
514
|
"gel/which": ["which@4.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg=="],
|
|
@@ -566,6 +571,8 @@
|
|
|
566
571
|
|
|
567
572
|
"@vellumai/slack-text/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
|
568
573
|
|
|
574
|
+
"@vellumai/twilio-client/@types/bun/bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
|
575
|
+
|
|
569
576
|
"gel/which/isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
|
|
570
577
|
|
|
571
578
|
"@typescript-eslint/typescript-estree/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
|
package/knip.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vellumai/vellum-gateway",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"exports": {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"@vellumai/ces-client": "file:../packages/ces-client",
|
|
29
29
|
"@vellumai/service-contracts": "file:../packages/service-contracts",
|
|
30
30
|
"@vellumai/slack-text": "file:../packages/slack-text",
|
|
31
|
+
"@vellumai/twilio-client": "file:../packages/twilio-client",
|
|
31
32
|
"drizzle-kit": "0.30.6",
|
|
32
33
|
"drizzle-orm": "0.45.2",
|
|
33
34
|
"file-type": "21.3.0",
|
|
@@ -52,6 +52,7 @@ describe("auto-approve thresholds", () => {
|
|
|
52
52
|
expect(data).toEqual({
|
|
53
53
|
interactive: "medium",
|
|
54
54
|
autonomous: "low",
|
|
55
|
+
headless: "none",
|
|
55
56
|
});
|
|
56
57
|
});
|
|
57
58
|
|
|
@@ -59,16 +60,15 @@ describe("auto-approve thresholds", () => {
|
|
|
59
60
|
const putHandler = createGlobalThresholdPutHandler();
|
|
60
61
|
const getHandler = createGlobalThresholdGetHandler();
|
|
61
62
|
|
|
62
|
-
// First PUT to set values
|
|
63
63
|
await putHandler(makeRequest({ interactive: "none" }));
|
|
64
64
|
|
|
65
|
-
// GET should reflect the update
|
|
66
65
|
const res = await getHandler(makeRequest(undefined, "GET"));
|
|
67
66
|
expect(res.status).toBe(200);
|
|
68
67
|
const data = await res.json();
|
|
69
68
|
expect(data).toEqual({
|
|
70
69
|
interactive: "none",
|
|
71
70
|
autonomous: "low",
|
|
71
|
+
headless: "none",
|
|
72
72
|
});
|
|
73
73
|
});
|
|
74
74
|
});
|
|
@@ -83,6 +83,7 @@ describe("auto-approve thresholds", () => {
|
|
|
83
83
|
expect(data).toEqual({
|
|
84
84
|
interactive: "none",
|
|
85
85
|
autonomous: "low",
|
|
86
|
+
headless: "none",
|
|
86
87
|
});
|
|
87
88
|
});
|
|
88
89
|
|
|
@@ -105,6 +106,7 @@ describe("auto-approve thresholds", () => {
|
|
|
105
106
|
expect(data).toEqual({
|
|
106
107
|
interactive: "high",
|
|
107
108
|
autonomous: "low",
|
|
109
|
+
headless: "none",
|
|
108
110
|
});
|
|
109
111
|
});
|
|
110
112
|
|
|
@@ -160,7 +162,6 @@ describe("auto-approve thresholds", () => {
|
|
|
160
162
|
test("upserts correctly — first write creates, second write updates", async () => {
|
|
161
163
|
const handler = createGlobalThresholdPutHandler();
|
|
162
164
|
|
|
163
|
-
// First write — creates the row
|
|
164
165
|
const res1 = await handler(
|
|
165
166
|
makeRequest({ interactive: "none", autonomous: "low" }),
|
|
166
167
|
);
|
|
@@ -169,17 +170,16 @@ describe("auto-approve thresholds", () => {
|
|
|
169
170
|
expect(data1).toEqual({
|
|
170
171
|
interactive: "none",
|
|
171
172
|
autonomous: "low",
|
|
173
|
+
headless: "none",
|
|
172
174
|
});
|
|
173
175
|
|
|
174
|
-
|
|
175
|
-
const res2 = await handler(
|
|
176
|
-
makeRequest({ autonomous: "medium" }),
|
|
177
|
-
);
|
|
176
|
+
const res2 = await handler(makeRequest({ autonomous: "medium" }));
|
|
178
177
|
expect(res2.status).toBe(200);
|
|
179
178
|
const data2 = await res2.json();
|
|
180
179
|
expect(data2).toEqual({
|
|
181
180
|
interactive: "none",
|
|
182
181
|
autonomous: "medium",
|
|
182
|
+
headless: "none",
|
|
183
183
|
});
|
|
184
184
|
});
|
|
185
185
|
|
|
@@ -187,42 +187,69 @@ describe("auto-approve thresholds", () => {
|
|
|
187
187
|
const handler = createGlobalThresholdPutHandler();
|
|
188
188
|
|
|
189
189
|
const res = await handler(
|
|
190
|
-
makeRequest({
|
|
191
|
-
interactive: "medium",
|
|
192
|
-
autonomous: "low",
|
|
193
|
-
}),
|
|
190
|
+
makeRequest({ interactive: "medium", autonomous: "low" }),
|
|
194
191
|
);
|
|
195
192
|
expect(res.status).toBe(200);
|
|
196
193
|
const data = await res.json();
|
|
197
194
|
expect(data).toEqual({
|
|
198
195
|
interactive: "medium",
|
|
199
196
|
autonomous: "low",
|
|
197
|
+
headless: "none",
|
|
200
198
|
});
|
|
201
199
|
});
|
|
202
200
|
|
|
203
201
|
test("empty object preserves existing values when row exists", async () => {
|
|
204
202
|
const putHandler = createGlobalThresholdPutHandler();
|
|
205
203
|
|
|
206
|
-
|
|
207
|
-
await putHandler(
|
|
208
|
-
makeRequest({
|
|
209
|
-
interactive: "medium",
|
|
210
|
-
autonomous: "low",
|
|
211
|
-
}),
|
|
212
|
-
);
|
|
204
|
+
await putHandler(makeRequest({ interactive: "medium", autonomous: "low" }));
|
|
213
205
|
|
|
214
|
-
// Then PUT empty object — existing values should be preserved
|
|
215
206
|
const res = await putHandler(makeRequest({}));
|
|
216
207
|
expect(res.status).toBe(200);
|
|
217
208
|
const data = await res.json();
|
|
218
209
|
expect(data).toEqual({
|
|
219
210
|
interactive: "medium",
|
|
220
211
|
autonomous: "low",
|
|
212
|
+
headless: "none",
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// ── headless field ────────────────────────────────────────────────────────
|
|
217
|
+
|
|
218
|
+
test("can set headless threshold", async () => {
|
|
219
|
+
const handler = createGlobalThresholdPutHandler();
|
|
220
|
+
|
|
221
|
+
const res = await handler(makeRequest({ headless: "low" }));
|
|
222
|
+
expect(res.status).toBe(200);
|
|
223
|
+
const data = await res.json();
|
|
224
|
+
expect(data).toEqual({
|
|
225
|
+
interactive: "medium",
|
|
226
|
+
autonomous: "low",
|
|
227
|
+
headless: "low",
|
|
221
228
|
});
|
|
222
229
|
});
|
|
223
230
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
231
|
+
test("returns 400 for invalid headless value", async () => {
|
|
232
|
+
const handler = createGlobalThresholdPutHandler();
|
|
233
|
+
|
|
234
|
+
const res = await handler(makeRequest({ headless: "extreme" }));
|
|
235
|
+
expect(res.status).toBe(400);
|
|
236
|
+
const data = await res.json();
|
|
237
|
+
expect(data.error).toContain("headless");
|
|
238
|
+
expect(data.error).toContain("none, low, medium, high");
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("headless is preserved when other fields are updated", async () => {
|
|
242
|
+
const handler = createGlobalThresholdPutHandler();
|
|
243
|
+
|
|
244
|
+
// Explicitly set headless to a non-default value
|
|
245
|
+
await handler(makeRequest({ headless: "low" }));
|
|
246
|
+
|
|
247
|
+
// Update only interactive — headless should remain "low"
|
|
248
|
+
const res = await handler(makeRequest({ interactive: "high" }));
|
|
249
|
+
expect(res.status).toBe(200);
|
|
250
|
+
const data = await res.json();
|
|
251
|
+
expect(data.headless).toBe("low");
|
|
252
|
+
expect(data.interactive).toBe("high");
|
|
253
|
+
});
|
|
227
254
|
});
|
|
228
255
|
});
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import { existsSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
ConfigFileWatcher,
|
|
7
|
+
type ConfigChangeEvent,
|
|
8
|
+
} from "../config-file-watcher.js";
|
|
9
|
+
import {
|
|
10
|
+
isOnlyVelayPublicBaseUrlChange,
|
|
11
|
+
shouldSyncTwilioPhoneWebhooksAfterConfigChange,
|
|
12
|
+
} from "../twilio/webhook-sync-trigger.js";
|
|
13
|
+
import { testWorkspaceDir } from "./test-preload.js";
|
|
14
|
+
|
|
15
|
+
const configPath = join(testWorkspaceDir, "config.json");
|
|
16
|
+
|
|
17
|
+
function writeConfig(data: Record<string, unknown>): void {
|
|
18
|
+
writeFileSync(configPath, JSON.stringify(data), "utf-8");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function pollOnce(watcher: ConfigFileWatcher): void {
|
|
22
|
+
(
|
|
23
|
+
watcher as unknown as {
|
|
24
|
+
pollOnce: () => void;
|
|
25
|
+
}
|
|
26
|
+
).pollOnce();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makeEvent(
|
|
30
|
+
changedKeys: string[],
|
|
31
|
+
changedFields: Record<string, string[]>,
|
|
32
|
+
): ConfigChangeEvent {
|
|
33
|
+
return {
|
|
34
|
+
data: {},
|
|
35
|
+
changedKeys: new Set(changedKeys),
|
|
36
|
+
changedFields: new Map(
|
|
37
|
+
Object.entries(changedFields).map(([section, fields]) => [
|
|
38
|
+
section,
|
|
39
|
+
new Set(fields),
|
|
40
|
+
]),
|
|
41
|
+
),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
afterEach(() => {
|
|
46
|
+
try {
|
|
47
|
+
if (existsSync(configPath)) unlinkSync(configPath);
|
|
48
|
+
} catch {
|
|
49
|
+
// best-effort cleanup
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("ConfigFileWatcher", () => {
|
|
54
|
+
test("reports shallow ingress fields changed by Velay-managed URL writes", () => {
|
|
55
|
+
writeConfig({
|
|
56
|
+
ingress: {
|
|
57
|
+
publicBaseUrl: "https://public.example.test",
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
const events: ConfigChangeEvent[] = [];
|
|
61
|
+
const watcher = new ConfigFileWatcher((event) => {
|
|
62
|
+
events.push(event);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
pollOnce(watcher);
|
|
66
|
+
writeConfig({
|
|
67
|
+
ingress: {
|
|
68
|
+
publicBaseUrl: "https://velay.example.test",
|
|
69
|
+
publicBaseUrlManagedBy: "velay",
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
pollOnce(watcher);
|
|
73
|
+
|
|
74
|
+
expect(events).toHaveLength(2);
|
|
75
|
+
expect(events[1].changedKeys).toEqual(new Set(["ingress"]));
|
|
76
|
+
expect(events[1].changedFields.get("ingress")).toEqual(
|
|
77
|
+
new Set(["publicBaseUrl", "publicBaseUrlManagedBy"]),
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("reports Twilio-only fields when Velay creates ingress from scratch", () => {
|
|
82
|
+
writeConfig({
|
|
83
|
+
gateway: {
|
|
84
|
+
runtimeProxyRequireAuth: false,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const events: ConfigChangeEvent[] = [];
|
|
88
|
+
const watcher = new ConfigFileWatcher((event) => {
|
|
89
|
+
events.push(event);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
pollOnce(watcher);
|
|
93
|
+
writeConfig({
|
|
94
|
+
gateway: {
|
|
95
|
+
runtimeProxyRequireAuth: false,
|
|
96
|
+
},
|
|
97
|
+
ingress: {
|
|
98
|
+
publicBaseUrl: "https://velay.example.test",
|
|
99
|
+
publicBaseUrlManagedBy: "velay",
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
pollOnce(watcher);
|
|
103
|
+
|
|
104
|
+
expect(events).toHaveLength(2);
|
|
105
|
+
expect(events[1].changedKeys).toEqual(new Set(["ingress"]));
|
|
106
|
+
expect(events[1].changedFields.get("ingress")).toEqual(
|
|
107
|
+
new Set(["publicBaseUrl", "publicBaseUrlManagedBy"]),
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("detects public base URL changes", () => {
|
|
112
|
+
writeConfig({
|
|
113
|
+
ingress: {
|
|
114
|
+
publicBaseUrl: "https://old-public.example.test",
|
|
115
|
+
publicBaseUrlManagedBy: "velay",
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
const events: ConfigChangeEvent[] = [];
|
|
119
|
+
const watcher = new ConfigFileWatcher((event) => {
|
|
120
|
+
events.push(event);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
pollOnce(watcher);
|
|
124
|
+
writeConfig({
|
|
125
|
+
ingress: {
|
|
126
|
+
publicBaseUrl: "https://new-public.example.test",
|
|
127
|
+
publicBaseUrlManagedBy: "velay",
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
pollOnce(watcher);
|
|
131
|
+
|
|
132
|
+
expect(events).toHaveLength(2);
|
|
133
|
+
expect(events[1].changedFields.get("ingress")).toEqual(
|
|
134
|
+
new Set(["publicBaseUrl"]),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe("Twilio webhook sync config-change triggers", () => {
|
|
140
|
+
test("syncs when generic public ingress changes without a Twilio override", () => {
|
|
141
|
+
const event = makeEvent(["ingress"], { ingress: ["publicBaseUrl"] });
|
|
142
|
+
|
|
143
|
+
expect(isOnlyVelayPublicBaseUrlChange(event)).toBe(false);
|
|
144
|
+
expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("syncs when Velay-managed public ingress changes", () => {
|
|
148
|
+
const event = makeEvent(["ingress"], {
|
|
149
|
+
ingress: ["publicBaseUrl", "publicBaseUrlManagedBy"],
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(isOnlyVelayPublicBaseUrlChange(event)).toBe(true);
|
|
153
|
+
expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("does not sync when only the Velay manager marker changes", () => {
|
|
157
|
+
const event = makeEvent(["ingress"], {
|
|
158
|
+
ingress: ["publicBaseUrlManagedBy"],
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(isOnlyVelayPublicBaseUrlChange(event)).toBe(true);
|
|
162
|
+
expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("syncs when Twilio phone configuration becomes available", () => {
|
|
166
|
+
const event = makeEvent(["twilio"], {
|
|
167
|
+
twilio: ["phoneNumber", "accountSid"],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(isOnlyVelayPublicBaseUrlChange(event)).toBe(false);
|
|
171
|
+
expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(true);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("does not sync when unrelated Twilio configuration changes", () => {
|
|
175
|
+
const event = makeEvent(["twilio"], {
|
|
176
|
+
twilio: ["assistantPhoneNumbers"],
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(shouldSyncTwilioPhoneWebhooksAfterConfigChange(event)).toBe(false);
|
|
180
|
+
});
|
|
181
|
+
});
|