@vellumai/vellum-gateway 0.12.1 → 0.12.2-staging.2

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 (61) hide show
  1. package/ARCHITECTURE.md +46 -48
  2. package/node_modules/@vellumai/slack-text/src/index.ts +13 -8
  3. package/node_modules/@vellumai/slack-text/src/label-resolution-entities.test.ts +95 -0
  4. package/package.json +1 -1
  5. package/src/__tests__/credential-cache.test.ts +59 -0
  6. package/src/__tests__/credential-reader.test.ts +106 -0
  7. package/src/__tests__/credential-watcher-outage.test.ts +86 -0
  8. package/src/__tests__/desktop-stream-websocket.test.ts +212 -0
  9. package/src/__tests__/edge-auth.test.ts +20 -0
  10. package/src/__tests__/edge-guardian-auth.test.ts +20 -0
  11. package/src/__tests__/guardian-pin.test.ts +292 -0
  12. package/src/__tests__/live-voice-websocket.test.ts +75 -127
  13. package/src/__tests__/platform-identity.test.ts +404 -0
  14. package/src/__tests__/runtime-stream-test-utils.ts +187 -0
  15. package/src/__tests__/speech-relay-websocket.test.ts +29 -74
  16. package/src/__tests__/stt-stream-websocket.test.ts +107 -102
  17. package/src/__tests__/telegram-webhook-manager.test.ts +15 -0
  18. package/src/__tests__/watch-stream-websocket.test.ts +90 -317
  19. package/src/channels/ingress-verification.test.ts +238 -1
  20. package/src/channels/ingress-verification.ts +191 -42
  21. package/src/channels/plugin-ingress-approvals.test.ts +22 -0
  22. package/src/channels/plugin-ingress.test.ts +20 -0
  23. package/src/credential-cache.ts +40 -9
  24. package/src/credential-reader.ts +103 -41
  25. package/src/credential-watcher.ts +18 -2
  26. package/src/email/register-callback.test.ts +12 -1
  27. package/src/email/register-callback.ts +8 -11
  28. package/src/feature-flag-registry.json +33 -13
  29. package/src/http/middleware/auth.ts +29 -21
  30. package/src/http/router.ts +2 -2
  31. package/src/http/routes/channel-ingress.test.ts +26 -0
  32. package/src/http/routes/channel-ingress.ts +9 -3
  33. package/src/http/routes/desktop-setup-proxy.ts +16 -0
  34. package/src/http/routes/desktop-stream-websocket.ts +59 -0
  35. package/src/http/routes/guardian-pin.ts +91 -15
  36. package/src/http/routes/live-voice-websocket.ts +13 -10
  37. package/src/http/routes/platform-push-proxy.test.ts +19 -4
  38. package/src/http/routes/platform-push-proxy.ts +8 -5
  39. package/src/http/routes/plugin-webhook.test.ts +255 -0
  40. package/src/http/routes/plugin-webhook.ts +40 -13
  41. package/src/http/routes/runtime-audio-stream.ts +39 -10
  42. package/src/http/routes/stt-stream-websocket.ts +3 -3
  43. package/src/http/routes/twilio-voice-verify-callback.ts +2 -6
  44. package/src/http/routes/twilio-voice-webhook.ts +2 -6
  45. package/src/http/routes/watch-stream-websocket.ts +5 -68
  46. package/src/index.ts +74 -10
  47. package/src/ipc/credential-request-handlers.ts +5 -9
  48. package/src/platform-identity.ts +331 -0
  49. package/src/platform-user-id.ts +12 -0
  50. package/src/risk/bash-risk-classifier.test.ts +14 -0
  51. package/src/risk/command-registry/commands/assistant.ts +14 -0
  52. package/src/risk/command-registry.test.ts +18 -0
  53. package/src/runtime/client.ts +2 -2
  54. package/src/schema.ts +81 -0
  55. package/src/telegram/webhook-manager.ts +8 -11
  56. package/src/velay/allowed-paths.test.ts +11 -1
  57. package/src/velay/allowed-paths.ts +5 -19
  58. package/src/velay/bridge-utils.ts +1 -0
  59. package/src/velay/client.test.ts +102 -1
  60. package/src/velay/client.ts +12 -12
  61. package/src/velay/websocket-bridge.test.ts +60 -0
package/ARCHITECTURE.md CHANGED
@@ -52,7 +52,7 @@ The request carries base64-encoded WAV audio and a MIME type. The daemon resolve
52
52
 
53
53
  ### STT Streaming WebSocket Proxy
54
54
 
55
- Clients open WebSocket connections through the gateway to the daemon's real-time STT streaming endpoint for conversation chat message capture. The gateway authenticates the downstream client using an edge JWT (actor principal required), then opens an upstream WebSocket connection to the daemon's `/v1/stt/stream` endpoint with a short-lived gateway service token. This keeps the daemon's WebSocket endpoint unreachable from the public internet while allowing authenticated clients to stream audio for real-time transcription.
55
+ Clients open WebSocket connections through the gateway to the daemon's real-time STT streaming endpoint for conversation chat message capture. The gateway authenticates the downstream client using an edge JWT (actor principal required), or a velay-attested caller when the client arrived through the gateway's velay tunnel, then opens an upstream WebSocket connection to the daemon's `/v1/stt/stream` endpoint with a short-lived gateway service token. This keeps the daemon's WebSocket endpoint unreachable from the public internet while allowing authenticated clients to stream audio for real-time transcription.
56
56
 
57
57
  **Config-authoritative model:** The runtime always resolves the streaming transcriber from the assistant config, regardless of any `provider` query parameter. Dictation reads its own role (`services.stt.roles.dictation`, falling back to `services.stt.provider`). The `provider` parameter is optional compatibility metadata: when supplied and it disagrees with the provider that role resolves to, the runtime logs a mismatch warning for operator visibility.
58
58
 
@@ -67,7 +67,7 @@ Clients open WebSocket connections through the gateway to the daemon's real-time
67
67
  | `sampleRate` | No | Sample rate in Hz (e.g. `16000`). Passed through to the daemon. |
68
68
  | `token` | No | Edge JWT (alternative to `Authorization: Bearer` header for WS upgrades) |
69
69
 
70
- **Auth model:** STT streaming is an authenticated, assistant-scoped path. The client must present a valid edge JWT with an actor principal. Service tokens are rejected. When `runtimeProxyRequireAuth` is globally disabled (dev bypass), the upgrade proceeds without token validation.
70
+ **Auth model:** STT streaming is an authenticated, assistant-scoped path. The client presents a valid edge JWT with an actor principal; service tokens are rejected. A client that reached the gateway through its velay tunnel (a managed pod, or a locally hosted assistant dialled from the mobile app) carries no edge JWT: velay validated the browser's token and injected `X-Velay-*` headers, and the gateway admits that attestation when it has a velay tunnel configured (`acceptsVelayAttestation` in `guardian-pin.ts`) and the request carries the process-local bridge proof. Live voice and the watch stream take the same velay path and additionally pin the caller to the bound guardian; dictation accepts any valid actor. When `runtimeProxyRequireAuth` is globally disabled (dev bypass), the upgrade proceeds without token validation.
71
71
 
72
72
  **Proxy behavior:** The gateway buffers up to 100 downstream messages while the upstream connection to the daemon is being established. If the buffer overflows, the downstream connection is closed with code 1008 (policy violation). Once the upstream connection opens, buffered messages are flushed in order. All subsequent messages are forwarded bidirectionally: client audio frames flow upstream, daemon session events (JSON text frames: `ready`, the transcript and turn-boundary events of the daemon's `SttStreamServerEvent` union, and `error` / `closed`) flow downstream. The gateway forwards these opaquely and needs no change when the daemon adds an event type. When either side closes, the other side is closed with the same code/reason.
73
73
 
@@ -830,7 +830,7 @@ sequenceDiagram
830
830
  loop Conversation turns
831
831
  TwilioAPI->>WS: media frames (mu-law audio)
832
832
  WS->>WS: daemon STT (streaming or batch) → final transcript
833
- WS->>Ctrl: handleCallerUtterance(transcript, speakerContext)
833
+ WS->>Ctrl: handleCallerUtterance(transcript)
834
834
  Ctrl->>Bridge: startVoiceTurn()
835
835
  Bridge->>RunOrch: startRun(conversationId, content, {sourceChannel: 'phone', eventSink})
836
836
  RunOrch->>Session: route to session pipeline
@@ -945,7 +945,7 @@ sequenceDiagram
945
945
  loop Conversation turns
946
946
  Caller->>WS: media frames (mu-law audio)
947
947
  WS->>WS: daemon STT (streaming or batch) → final transcript
948
- WS->>Ctrl: handleCallerUtterance(transcript, speakerContext)
948
+ WS->>Ctrl: handleCallerUtterance(transcript)
949
949
  Ctrl->>Bridge: startVoiceTurn()
950
950
  Bridge->>RunOrch: startRun(conversationId, content, {sourceChannel: 'phone', eventSink})
951
951
  RunOrch->>Session: route to session pipeline
@@ -966,36 +966,35 @@ sequenceDiagram
966
966
 
967
967
  ### Key Components
968
968
 
969
- | File | Role |
970
- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
971
- | `assistant/src/calls/call-store.ts` | CRUD operations for call sessions, call events, and pending questions in SQLite via Drizzle ORM |
972
- | `assistant/src/calls/call-domain.ts` | Shared domain functions (`startCall`, `getCallStatus`, `cancelCall`, `answerCall`, `relayInstruction`) used by both tools and HTTP routes |
973
- | `assistant/src/calls/guardian-dispatch.ts` | Cross-channel dispatch engine: fans out ASK_GUARDIAN questions to mac/telegram, creates server-side guardian conversations, manages deliveries |
974
- | `gateway/src/db/guardian-request-store.ts` | Gateway-side store for guardian requests and deliveries; first-writer-wins resolution via atomic status CAS (`guardian_requests_decide`) |
975
- | `assistant/src/calls/guardian-action-sweep.ts` | Expiry notices for expired guardian requests, sent to all delivery destinations |
976
- | `assistant/src/calls/call-domain.ts:createInboundVoiceSession()` | Creates or reuses a voice session for an inbound call keyed by CallSid (idempotent replay protection) |
977
- | `assistant/src/runtime/channel-verification-service.ts` | Channel verification session lifecycle: create session with six-digit code, find pending sessions, validate and consume on match |
978
- | `assistant/src/calls/call-state-machine.ts` | Deterministic state transition validator with allowed-transition table and terminal-state enforcement |
979
- | `assistant/src/calls/call-recovery.ts` | Startup reconciliation of non-terminal calls: fetches provider status and transitions stale sessions |
980
- | `assistant/src/calls/twilio-provider.ts` | Twilio Voice REST API integration (initiateCall, endCall, getCallStatus) using direct fetch — no Twilio SDK dependency |
981
- | `assistant/src/calls/twilio-routes.ts` | HTTP webhook handlers: voice webhook (returns `<Connect><Stream>` TwiML, enforces the credential preflight with `<Say>` + `<Hangup/>` when not ready), status callback |
982
- | `assistant/src/calls/media-stream-server.ts` | WebSocket handler for Twilio Media Streams; manages one MediaStreamCallSession per call, runs `routeSetup`, and drives interactive setup outcomes through `CallSetupFlow` |
983
- | `assistant/src/calls/call-setup-flow.ts` | Transport-agnostic call setup flow: verification, invite-redemption, name-capture, and unverified-caller sub-flows over DTMF/spoken input |
984
- | `assistant/src/calls/guardian-wait-controller.ts` | Guardian access-request wait orchestration: hold messaging, heartbeats, status polling, consultation timeout, callback handoff |
985
- | `assistant/src/calls/media-stream-stt-session.ts` | Daemon-side STT for media-stream audio: streaming transcriber (utterance-boundary finals) with batch + VAD turn-detection fallback |
986
- | `assistant/src/calls/telephony-credential-preflight.ts` | Combined STT + TTS credential-readiness resolver gating inbound TwiML and outbound call placement |
987
- | `assistant/src/calls/speaker-identification.ts` | Reusable speaker recognition primitive for voice prompts: extracts provider speaker metadata (top-level and nested fields), resolves stable per-call speaker identities, and emits speaker context for personalization |
988
- | `assistant/src/calls/call-controller.ts` | Session-backed voice controller: routes voice turns through the daemon session pipeline via voice-session-bridge, detects ASK_GUARDIAN and END_CALL control markers |
989
- | `assistant/src/calls/voice-session-bridge.ts` | Bridge between the voice call controller and the daemon session/run pipeline: wraps RunOrchestrator.startRun() with voice-specific defaults, translating agent-loop events into callbacks for real-time TTS streaming |
990
- | `assistant/src/calls/call-state.ts` | Notifier pattern (Maps with register/unregister/fire helpers) for cross-component communication: question notifiers, completion notifiers, and controller registry |
991
- | `assistant/src/calls/call-constants.ts` | Config-backed constants: max call duration, user consultation timeout, silence timeout, denied emergency numbers |
992
- | `assistant/src/calls/voice-provider.ts` | Abstract VoiceProvider interface for provider-agnostic call initiation |
993
- | `assistant/src/calls/twilio-config.ts` | Twilio credential and configuration resolution from secure key store and environment |
994
- | `assistant/src/calls/types.ts` | TypeScript type definitions: CallSession, CallEvent, CallPendingQuestion, CallStatus, CallEventType |
995
- | `gateway/src/http/routes/twilio-voice-webhook.ts` | Gateway route: validates Twilio signature, forwards voice webhook to runtime |
996
- | `gateway/src/http/routes/twilio-status-webhook.ts` | Gateway route: validates Twilio signature, forwards status callback to runtime |
997
- | `gateway/src/http/routes/twilio-media-websocket.ts` | Gateway route: WebSocket proxy for Media Streams frames between Twilio and runtime (all calls) |
998
- | `gateway/src/twilio/validate-webhook.ts` | Twilio webhook validation: HMAC-SHA1 signature verification, payload size limits, fail-closed when auth token missing |
969
+ | File | Role |
970
+ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
971
+ | `assistant/src/calls/call-store.ts` | CRUD operations for call sessions, call events, and pending questions in SQLite via Drizzle ORM |
972
+ | `assistant/src/calls/call-domain.ts` | Shared domain functions (`startCall`, `getCallStatus`, `cancelCall`, `answerCall`, `relayInstruction`) used by both tools and HTTP routes |
973
+ | `assistant/src/calls/guardian-dispatch.ts` | Cross-channel dispatch engine: fans out ASK_GUARDIAN questions to mac/telegram, creates server-side guardian conversations, manages deliveries |
974
+ | `gateway/src/db/guardian-request-store.ts` | Gateway-side store for guardian requests and deliveries; first-writer-wins resolution via atomic status CAS (`guardian_requests_decide`) |
975
+ | `assistant/src/calls/guardian-action-sweep.ts` | Expiry notices for expired guardian requests, sent to all delivery destinations |
976
+ | `assistant/src/calls/call-domain.ts:createInboundVoiceSession()` | Creates or reuses a voice session for an inbound call keyed by CallSid (idempotent replay protection) |
977
+ | `assistant/src/runtime/channel-verification-service.ts` | Channel verification session lifecycle: create session with six-digit code, find pending sessions, validate and consume on match |
978
+ | `assistant/src/calls/call-state-machine.ts` | Deterministic state transition validator with allowed-transition table and terminal-state enforcement |
979
+ | `assistant/src/calls/call-recovery.ts` | Startup reconciliation of non-terminal calls: fetches provider status and transitions stale sessions |
980
+ | `assistant/src/calls/twilio-provider.ts` | Twilio Voice REST API integration (initiateCall, endCall, getCallStatus) using direct fetch, no Twilio SDK dependency |
981
+ | `assistant/src/calls/twilio-routes.ts` | HTTP webhook handlers: voice webhook (returns `<Connect><Stream>` TwiML, enforces the credential preflight with `<Say>` + `<Hangup/>` when not ready), status callback |
982
+ | `assistant/src/calls/media-stream-server.ts` | WebSocket handler for Twilio Media Streams; manages one MediaStreamCallSession per call, runs `routeSetup`, and drives interactive setup outcomes through `CallSetupFlow` |
983
+ | `assistant/src/calls/call-setup-flow.ts` | Transport-agnostic call setup flow: verification, invite-redemption, name-capture, and unverified-caller sub-flows over DTMF/spoken input |
984
+ | `assistant/src/calls/guardian-wait-controller.ts` | Guardian access-request wait orchestration: hold messaging, heartbeats, status polling, consultation timeout, callback handoff |
985
+ | `assistant/src/calls/media-stream-stt-session.ts` | Daemon-side STT for media-stream audio: streaming transcriber (utterance-boundary finals) with batch + VAD turn-detection fallback |
986
+ | `assistant/src/calls/telephony-credential-preflight.ts` | Combined STT + TTS credential-readiness resolver gating inbound TwiML and outbound call placement |
987
+ | `assistant/src/calls/call-controller.ts` | Session-backed voice controller: routes voice turns through the daemon session pipeline via voice-session-bridge, detects ASK_GUARDIAN and END_CALL control markers |
988
+ | `assistant/src/calls/voice-session-bridge.ts` | Bridge between the voice call controller and the daemon session/run pipeline: wraps RunOrchestrator.startRun() with voice-specific defaults, translating agent-loop events into callbacks for real-time TTS streaming |
989
+ | `assistant/src/calls/call-state.ts` | Notifier pattern (Maps with register/unregister/fire helpers) for cross-component communication: question notifiers, completion notifiers, and controller registry |
990
+ | `assistant/src/calls/call-constants.ts` | Config-backed constants: max call duration, user consultation timeout, silence timeout, denied emergency numbers |
991
+ | `assistant/src/calls/voice-provider.ts` | Abstract VoiceProvider interface for provider-agnostic call initiation |
992
+ | `assistant/src/calls/twilio-config.ts` | Twilio credential and configuration resolution from secure key store and environment |
993
+ | `assistant/src/calls/types.ts` | TypeScript type definitions: CallSession, CallEvent, CallPendingQuestion, CallStatus, CallEventType |
994
+ | `gateway/src/http/routes/twilio-voice-webhook.ts` | Gateway route: validates Twilio signature, forwards voice webhook to runtime |
995
+ | `gateway/src/http/routes/twilio-status-webhook.ts` | Gateway route: validates Twilio signature, forwards status callback to runtime |
996
+ | `gateway/src/http/routes/twilio-media-websocket.ts` | Gateway route: WebSocket proxy for Media Streams frames between Twilio and runtime (all calls) |
997
+ | `gateway/src/twilio/validate-webhook.ts` | Twilio webhook validation: HMAC-SHA1 signature verification, payload size limits, fail-closed when auth token missing |
999
998
 
1000
999
  ### Call State Machine
1001
1000
 
@@ -1140,20 +1139,19 @@ Malformed or unprocessable provider callback payloads are logged as dead-letter
1140
1139
 
1141
1140
  Call behavior is controlled via the `calls` config block in the assistant configuration (`config/schema.ts`). All values have sensible defaults and are validated via Zod:
1142
1141
 
1143
- | Field | Type | Default | Description |
1144
- | --------------------------------- | -------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1145
- | `calls.enabled` | boolean | `true` | Master toggle for the calls feature. When `false`, call routes return 403 and tools return errors. |
1146
- | `calls.provider` | enum | `'twilio'` | Voice provider to use (currently only Twilio is supported). |
1147
- | `calls.maxDurationSeconds` | int | `3600` | Maximum allowed duration per call. |
1148
- | `calls.userConsultTimeoutSeconds` | int | `120` | How long to wait for a user answer before timing out a pending question. |
1149
- | `calls.disclosure.enabled` | boolean | `true` | Whether the AI should disclose it is an AI at the start of the call. |
1150
- | `calls.disclosure.text` | string | _(default disclosure prompt)_ | The disclosure instruction included in the system prompt. |
1151
- | `calls.safety.denyCategories` | string[] | `[]` | Categories of calls to deny (e.g., emergency numbers are always denied regardless of this setting). |
1152
- | `llm.callSites.callAgent.model` | string | _(unset; falls back to the resolved call-site default)_ | Optional override for the LLM model used in voice call conversations. |
1153
- | `services.stt.provider` | enum | `'deepgram'` | Global STT provider. Every boundary falls back to it, and a consumer with a `services.stt.roles.<role>` override (`liveVoice`, `telephony`, `dictation`, `watch`, `batch`) uses that instead. The daemon transcribes media-stream call audio with whichever provider the `telephony` role resolves to (streaming when supported, batch otherwise). |
1154
- | `services.stt.language` | string | `multi` | Spoken language for transcription: a BCP-47 code pins one language, `multi` (the schema default) enables code-switching on providers that support it. Per-language TTS voices are configured via `services.tts.providers.<id>.languageVoices`. |
1155
- | `services.tts.provider` | enum | `'elevenlabs'` | Active TTS provider for speech synthesis (catalog-driven; see [TTS Provider Abstraction](../assistant/ARCHITECTURE.md#tts-provider-abstraction-servicestts)). |
1156
- | `services.tts.providers.<id>.*` | object | _(per-provider defaults)_ | Provider-specific settings block. One block per catalog entry (e.g. `elevenlabs`, `fish-audio`). |
1142
+ | Field | Type | Default | Description |
1143
+ | --------------------------------- | ------- | ------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1144
+ | `calls.enabled` | boolean | `true` | Master toggle for the calls feature. When `false`, call routes return 403 and tools return errors. |
1145
+ | `calls.provider` | enum | `'twilio'` | Voice provider to use (currently only Twilio is supported). |
1146
+ | `calls.maxDurationSeconds` | int | `3600` | Maximum allowed duration per call. |
1147
+ | `calls.userConsultTimeoutSeconds` | int | `120` | How long to wait for a user answer before timing out a pending question. |
1148
+ | `calls.disclosure.enabled` | boolean | `true` | Whether the AI should disclose it is an AI at the start of the call. |
1149
+ | `calls.disclosure.text` | string | _(default disclosure prompt)_ | The disclosure instruction included in the system prompt. |
1150
+ | `llm.callSites.callAgent.model` | string | _(unset; falls back to the resolved call-site default)_ | Optional override for the LLM model used in voice call conversations. |
1151
+ | `services.stt.provider` | enum | `'deepgram'` | Global STT provider. Every boundary falls back to it, and a consumer with a `services.stt.roles.<role>` override (`liveVoice`, `telephony`, `dictation`, `watch`, `batch`) uses that instead. The daemon transcribes media-stream call audio with whichever provider the `telephony` role resolves to (streaming when supported, batch otherwise). |
1152
+ | `services.stt.language` | string | `multi` | Spoken language for transcription: a BCP-47 code pins one language, `multi` (the schema default) enables code-switching on providers that support it. Per-language TTS voices are configured via `services.tts.providers.<id>.languageVoices`. |
1153
+ | `services.tts.provider` | enum | `'elevenlabs'` | Active TTS provider for speech synthesis (catalog-driven; see [TTS Provider Abstraction](../assistant/ARCHITECTURE.md#tts-provider-abstraction-servicestts)). |
1154
+ | `services.tts.providers.<id>.*` | object | _(per-provider defaults)_ | Provider-specific settings block. One block per catalog entry (e.g. `elevenlabs`, `fish-audio`). |
1157
1155
 
1158
1156
  ### Caller Identity Resolution
1159
1157
 
@@ -113,7 +113,7 @@ export async function buildSlackUserLabelMap(
113
113
  // that label directly; only queue a lookup when the label is missing,
114
114
  // empty, or ID-shaped. Mirrors the channel builder below.
115
115
  const [, embeddedLabel] = splitSlackLabel(match[0].slice(1, -1));
116
- const sanitizedEmbeddedLabel = sanitizeOptionalLabel(embeddedLabel);
116
+ const sanitizedEmbeddedLabel = sanitizeEmbeddedSlackLabel(embeddedLabel);
117
117
  if (sanitizedEmbeddedLabel && sanitizedEmbeddedLabel !== id) continue;
118
118
  if (seen.has(id)) continue;
119
119
  seen.add(id);
@@ -151,7 +151,7 @@ export async function buildSlackChannelLabelMap(
151
151
  for (const match of text.matchAll(SLACK_CHANNEL_REFERENCE_RE)) {
152
152
  const id = match[1];
153
153
  const [, embeddedLabel] = splitSlackLabel(match[0].slice(1, -1));
154
- const sanitizedEmbeddedLabel = sanitizeOptionalLabel(embeddedLabel);
154
+ const sanitizedEmbeddedLabel = sanitizeEmbeddedSlackLabel(embeddedLabel);
155
155
  if (sanitizedEmbeddedLabel && sanitizedEmbeddedLabel !== id) continue;
156
156
  if (!seen.has(id)) {
157
157
  seen.add(id);
@@ -192,9 +192,7 @@ function renderUserMention(
192
192
  // prefer it over a lookup, mirroring renderChannelReference. The embedded
193
193
  // label is Slack-sourced (part of the token), so entities decode before
194
194
  // sanitization; the caller-resolved label below is not.
195
- const embeddedLabel = sanitizeOptionalLabel(
196
- label === undefined ? undefined : decodeSlackHtmlEntities(label),
197
- );
195
+ const embeddedLabel = sanitizeEmbeddedSlackLabel(label);
198
196
  if (embeddedLabel && embeddedLabel !== id) {
199
197
  return `@${embeddedLabel}`;
200
198
  }
@@ -219,9 +217,7 @@ function renderChannelReference(
219
217
  );
220
218
  // The embedded label is Slack-sourced (part of the token), so entities
221
219
  // decode before sanitization; the caller-resolved label below is not.
222
- const embeddedLabel = sanitizeOptionalLabel(
223
- label === undefined ? undefined : decodeSlackHtmlEntities(label),
224
- );
220
+ const embeddedLabel = sanitizeEmbeddedSlackLabel(label);
225
221
  if (embeddedLabel && embeddedLabel !== channelId) {
226
222
  return `#${embeddedLabel}`;
227
223
  }
@@ -320,6 +316,15 @@ export function sanitizeSlackLabel(
320
316
  return sanitized || undefined;
321
317
  }
322
318
 
319
+ // Slack-sourced labels are decoded before validation in both lookup and render.
320
+ function sanitizeEmbeddedSlackLabel(
321
+ label: string | undefined,
322
+ ): string | undefined {
323
+ return sanitizeOptionalLabel(
324
+ label === undefined ? undefined : decodeSlackHtmlEntities(label),
325
+ );
326
+ }
327
+
323
328
  function sanitizeOptionalLabel(label: string | undefined): string | undefined {
324
329
  return sanitizeSlackLabel(label);
325
330
  }
@@ -0,0 +1,95 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, test } from "bun:test";
3
+ import {
4
+ buildSlackChannelLabelMap,
5
+ buildSlackUserLabelMap,
6
+ renderSlackTextForModel,
7
+ } from "./index.js";
8
+
9
+ describe("Slack embedded-label lookup parity", () => {
10
+ const cases = [
11
+ { id: "U123", prefix: "@", build: buildSlackUserLabelMap, mapKey: "userLabels" },
12
+ {
13
+ id: "C123",
14
+ prefix: "#",
15
+ build: buildSlackChannelLabelMap,
16
+ mapKey: "channelLabels",
17
+ },
18
+ ] as const;
19
+
20
+ for (const { id, prefix, build, mapKey } of cases) {
21
+ for (const label of ["&lt;&gt;", `&lt;${id}&gt;`, "&lt; @# &gt;"]) {
22
+ test(`${prefix} resolves label ${label} after Slack entity decoding`, async () => {
23
+ const text = `<${prefix}${id}|${label}>`;
24
+ const requested: string[] = [];
25
+ const labels = await build([text], async (value) => {
26
+ requested.push(value);
27
+ return "Example Name";
28
+ });
29
+ assert.deepEqual(requested, [id]);
30
+ assert.equal(
31
+ renderSlackTextForModel(text, { [mapKey]: labels }),
32
+ `${prefix}Example Name`,
33
+ );
34
+ });
35
+ }
36
+
37
+ for (const label of ["&lt;Example&gt;", "R&amp;D", "&amp;lt;&amp;gt;"]) {
38
+ test(`${prefix} keeps usable embedded label ${label} without lookup`, async () => {
39
+ const requested: string[] = [];
40
+ const text = `<${prefix}${id}|${label}>`;
41
+ const labels = await build([text], async (value) => {
42
+ requested.push(value);
43
+ return "Wrong Label";
44
+ });
45
+ assert.deepEqual(requested, []);
46
+ assert.deepEqual(labels, {});
47
+ const expected =
48
+ label === "&lt;Example&gt;"
49
+ ? "Example"
50
+ : label === "R&amp;D"
51
+ ? "R&D"
52
+ : "&lt;&gt;";
53
+ assert.equal(
54
+ renderSlackTextForModel(text, { [mapKey]: labels }),
55
+ `${prefix}${expected}`,
56
+ );
57
+ });
58
+ }
59
+
60
+ test(`${prefix} deduplicates required lookups across encoded and bare mentions`, async () => {
61
+ const requested: string[] = [];
62
+ const labels = await build(
63
+ [`<${prefix}${id}|&lt;&gt;>`, `<${prefix}${id}>`],
64
+ async (value) => {
65
+ requested.push(value);
66
+ return "Example";
67
+ },
68
+ );
69
+ assert.deepEqual(requested, [id]);
70
+ assert.deepEqual(labels, { [id]: "Example" });
71
+ });
72
+
73
+ test(`${prefix} does not decode caller-resolved entity text`, async () => {
74
+ const text = `<${prefix}${id}|&lt;&gt;>`;
75
+ const labels = await build([text], async () => "&lt;Example&gt;");
76
+ assert.equal(
77
+ renderSlackTextForModel(text, { [mapKey]: labels }),
78
+ `${prefix}&lt;Example&gt;`,
79
+ );
80
+ });
81
+
82
+ test(`${prefix} preserves unknown fallback after a failed required lookup`, async () => {
83
+ let attempts = 0;
84
+ const text = `<${prefix}${id}|&lt;&gt;>`;
85
+ const labels = await build([text], async () => {
86
+ attempts++;
87
+ throw new Error("unavailable");
88
+ });
89
+ assert.equal(attempts, 1);
90
+ assert.deepEqual(labels, {});
91
+ const expected = prefix === "@" ? "@unknown-user" : "#unknown-channel";
92
+ assert.equal(renderSlackTextForModel(text, { [mapKey]: labels }), expected);
93
+ });
94
+ }
95
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.12.1",
3
+ "version": "0.12.2-staging.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -6,9 +6,22 @@ import { credentialKey } from "../credential-key.js";
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
8
  let readCredentialImpl: (account: string) => Promise<string | undefined>;
9
+ let readCredentialResultImpl:
10
+ | ((account: string) => Promise<{
11
+ value: string | undefined;
12
+ unreachable: boolean;
13
+ }>)
14
+ | undefined;
9
15
 
10
16
  mock.module("../credential-reader.js", () => ({
11
17
  readCredential: (account: string) => readCredentialImpl(account),
18
+ readCredentialResult: async (account: string) => {
19
+ if (readCredentialResultImpl) {
20
+ return readCredentialResultImpl(account);
21
+ }
22
+ const value = await readCredentialImpl(account);
23
+ return { value, unreachable: false };
24
+ },
12
25
  }));
13
26
 
14
27
  import { CredentialCache } from "../credential-cache.js";
@@ -25,6 +38,7 @@ beforeEach(() => {
25
38
  Date.now = globalThis.Date.now;
26
39
  callCount = 0;
27
40
  callLog = [];
41
+ readCredentialResultImpl = undefined;
28
42
  readCredentialImpl = async (account: string) => {
29
43
  callCount++;
30
44
  callLog.push(account);
@@ -91,6 +105,51 @@ describe("CredentialCache", () => {
91
105
  expect(callCount).toBe(1);
92
106
  });
93
107
 
108
+ test("keeps last known value when the vault is unreachable", async () => {
109
+ const cache = new CredentialCache({ ttlMs: 100 });
110
+ const realNow = Date.now();
111
+ expect(await cache.get("key-a")).toBe("value-for-key-a");
112
+ expect(callCount).toBe(1);
113
+
114
+ readCredentialResultImpl = async () => {
115
+ callCount++;
116
+ return { value: undefined, unreachable: true };
117
+ };
118
+ Date.now = () => realNow + 200;
119
+
120
+ expect(await cache.get("key-a")).toBe("value-for-key-a");
121
+ expect(callCount).toBe(2);
122
+ expect(await cache.get("key-a")).toBe("value-for-key-a");
123
+ expect(callCount).toBe(2);
124
+ });
125
+
126
+ test("a genuine miss still replaces last known value", async () => {
127
+ const cache = new CredentialCache({ ttlMs: 100 });
128
+ const realNow = Date.now();
129
+ expect(await cache.get("key-a")).toBe("value-for-key-a");
130
+
131
+ readCredentialResultImpl = async () => {
132
+ callCount++;
133
+ return { value: undefined, unreachable: false };
134
+ };
135
+ Date.now = () => realNow + 200;
136
+
137
+ expect(await cache.get("key-a")).toBeUndefined();
138
+ expect(await cache.get("key-a")).toBeUndefined();
139
+ expect(callCount).toBe(2);
140
+ });
141
+
142
+ test("unreachable with no prior value returns undefined", async () => {
143
+ readCredentialResultImpl = async () => {
144
+ callCount++;
145
+ return { value: undefined, unreachable: true };
146
+ };
147
+ const cache = new CredentialCache({ ttlMs: 5_000 });
148
+ expect(await cache.get("key-a")).toBeUndefined();
149
+ expect(await cache.get("key-a")).toBeUndefined();
150
+ expect(callCount).toBe(1);
151
+ });
152
+
94
153
  // -------------------------------------------------------------------------
95
154
  // force: true
96
155
  // -------------------------------------------------------------------------
@@ -26,7 +26,9 @@ mock.module("../logger.js", () => ({
26
26
  import {
27
27
  ALL_CREDENTIAL_SPECS,
28
28
  readCredential,
29
+ readCredentialResult,
29
30
  readServiceCredentials,
31
+ readServiceCredentialsResult,
30
32
  type ServiceCredentialSpec,
31
33
  } from "../credential-reader.js";
32
34
 
@@ -356,6 +358,110 @@ describe("readServiceCredentials", () => {
356
358
  });
357
359
  });
358
360
 
361
+ function withCesEnv(url: string, token: string, run: () => Promise<void>) {
362
+ const previousUrl = process.env.CES_CREDENTIAL_URL;
363
+ const previousToken = process.env.CES_SERVICE_TOKEN;
364
+ process.env.CES_CREDENTIAL_URL = url;
365
+ process.env.CES_SERVICE_TOKEN = token;
366
+ return run().finally(() => {
367
+ if (previousUrl === undefined) {
368
+ delete process.env.CES_CREDENTIAL_URL;
369
+ } else {
370
+ process.env.CES_CREDENTIAL_URL = previousUrl;
371
+ }
372
+ if (previousToken === undefined) {
373
+ delete process.env.CES_SERVICE_TOKEN;
374
+ } else {
375
+ process.env.CES_SERVICE_TOKEN = previousToken;
376
+ }
377
+ });
378
+ }
379
+
380
+ describe("readCredentialResult", () => {
381
+ const account = credentialKey("vellum", "platform_user_id");
382
+
383
+ test("CES 5xx does not fall through to keys.enc", async () => {
384
+ writeEncryptedStore({ [account]: "stale-keys-enc-value" });
385
+ const server = Bun.serve({
386
+ port: 0,
387
+ fetch() {
388
+ return new Response("internal error", { status: 500 });
389
+ },
390
+ });
391
+ try {
392
+ await withCesEnv(
393
+ `http://127.0.0.1:${server.port}`,
394
+ "test-ces-service-token",
395
+ async () => {
396
+ const result = await readCredentialResult(account);
397
+ expect(result).toEqual({ value: undefined, unreachable: true });
398
+ expect(await readCredential(account)).toBeUndefined();
399
+ },
400
+ );
401
+ } finally {
402
+ server.stop(true);
403
+ }
404
+ });
405
+
406
+ test("CES 404 falls through to keys.enc", async () => {
407
+ writeEncryptedStore({ [account]: "keys-enc-value" });
408
+ const server = Bun.serve({
409
+ port: 0,
410
+ fetch() {
411
+ return Response.json({ error: "not found" }, { status: 404 });
412
+ },
413
+ });
414
+ try {
415
+ await withCesEnv(
416
+ `http://127.0.0.1:${server.port}`,
417
+ "test-ces-service-token",
418
+ async () => {
419
+ const result = await readCredentialResult(account);
420
+ expect(result).toEqual({
421
+ value: "keys-enc-value",
422
+ unreachable: false,
423
+ });
424
+ },
425
+ );
426
+ } finally {
427
+ server.stop(true);
428
+ }
429
+ });
430
+ });
431
+
432
+ describe("readServiceCredentialsResult", () => {
433
+ const telegramSpec: ServiceCredentialSpec = {
434
+ service: "telegram",
435
+ requiredFields: ["bot_token", "webhook_secret"],
436
+ };
437
+
438
+ test("CES 5xx is unreachable, not missing", async () => {
439
+ writeEncryptedStore({
440
+ [credentialKey("telegram", "bot_token")]: "my-bot-token",
441
+ [credentialKey("telegram", "webhook_secret")]: "my-webhook-secret",
442
+ });
443
+ const server = Bun.serve({
444
+ port: 0,
445
+ fetch() {
446
+ return new Response("internal error", { status: 500 });
447
+ },
448
+ });
449
+ try {
450
+ await withCesEnv(
451
+ `http://127.0.0.1:${server.port}`,
452
+ "test-ces-service-token",
453
+ async () => {
454
+ const result = await readServiceCredentialsResult(telegramSpec);
455
+ expect(result).toEqual({ status: "unreachable" });
456
+ expect(await readServiceCredentials(telegramSpec)).toBeNull();
457
+ },
458
+ );
459
+ } finally {
460
+ server.stop(true);
461
+ }
462
+ });
463
+ });
464
+
359
465
  // ---------------------------------------------------------------------------
360
466
  // Tests: secret values must not leak into log output
361
467
  // ---------------------------------------------------------------------------
@@ -0,0 +1,86 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ import "./test-preload.js";
4
+
5
+ type ServiceCredentialsRead =
6
+ | { status: "ok"; credentials: Record<string, string> }
7
+ | { status: "missing" }
8
+ | { status: "unreachable" };
9
+
10
+ const specs = [
11
+ { service: "telegram", requiredFields: ["bot_token", "webhook_secret"] },
12
+ { service: "vellum", requiredFields: ["platform_assistant_id"] },
13
+ ] as const;
14
+
15
+ const reads = new Map<string, ServiceCredentialsRead>();
16
+
17
+ const actualCredentialReader = await import("../credential-reader.js");
18
+ mock.module("../credential-reader.js", () => ({
19
+ ...actualCredentialReader,
20
+ ALL_CREDENTIAL_SPECS: specs,
21
+ getCesHttpConfig: () => undefined,
22
+ readServiceCredentialsResult: async (spec: { service: string }) =>
23
+ reads.get(spec.service) ?? { status: "missing" as const },
24
+ }));
25
+
26
+ const { CredentialWatcher } = await import("../credential-watcher.js");
27
+
28
+ const TELEGRAM_CREDS = {
29
+ bot_token: "bot-token",
30
+ webhook_secret: "webhook-secret",
31
+ };
32
+
33
+ beforeEach(() => {
34
+ reads.clear();
35
+ });
36
+
37
+ describe("CredentialWatcher keeps last-known credentials on vault outage", () => {
38
+ test("does not emit a clear when a previously loaded service becomes unreachable", async () => {
39
+ const events: Array<{
40
+ services: string[];
41
+ telegram: Record<string, string> | null | undefined;
42
+ }> = [];
43
+ const watcher = new CredentialWatcher((event) => {
44
+ events.push({
45
+ services: [...event.changedServices],
46
+ telegram: event.credentials.get("telegram"),
47
+ });
48
+ });
49
+
50
+ reads.set("telegram", { status: "ok", credentials: TELEGRAM_CREDS });
51
+ await watcher._pollOnceForTest();
52
+ expect(events).toEqual([
53
+ { services: ["telegram"], telegram: TELEGRAM_CREDS },
54
+ ]);
55
+
56
+ reads.set("telegram", { status: "unreachable" });
57
+ await watcher._pollOnceForTest();
58
+ expect(events).toHaveLength(1);
59
+
60
+ reads.set("telegram", { status: "missing" });
61
+ await watcher._pollOnceForTest();
62
+ expect(events).toEqual([
63
+ { services: ["telegram"], telegram: TELEGRAM_CREDS },
64
+ { services: ["telegram"], telegram: null },
65
+ ]);
66
+ });
67
+
68
+ test("still emits a genuine credential update", async () => {
69
+ const events: Array<Record<string, string> | null | undefined> = [];
70
+ const watcher = new CredentialWatcher((event) => {
71
+ events.push(event.credentials.get("telegram"));
72
+ });
73
+
74
+ reads.set("telegram", { status: "ok", credentials: TELEGRAM_CREDS });
75
+ await watcher._pollOnceForTest();
76
+
77
+ const rotated = {
78
+ bot_token: "rotated-bot-token",
79
+ webhook_secret: "rotated-webhook-secret",
80
+ };
81
+ reads.set("telegram", { status: "ok", credentials: rotated });
82
+ await watcher._pollOnceForTest();
83
+
84
+ expect(events).toEqual([TELEGRAM_CREDS, rotated]);
85
+ });
86
+ });