@vellumai/vellum-gateway 0.12.1 → 0.12.2-staging.1
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/ARCHITECTURE.md +46 -48
- package/package.json +1 -1
- package/src/__tests__/credential-cache.test.ts +59 -0
- package/src/__tests__/credential-reader.test.ts +106 -0
- package/src/__tests__/credential-watcher-outage.test.ts +86 -0
- package/src/__tests__/desktop-stream-websocket.test.ts +212 -0
- package/src/__tests__/edge-auth.test.ts +20 -0
- package/src/__tests__/edge-guardian-auth.test.ts +20 -0
- package/src/__tests__/guardian-pin.test.ts +292 -0
- package/src/__tests__/live-voice-websocket.test.ts +75 -127
- package/src/__tests__/platform-identity.test.ts +404 -0
- package/src/__tests__/runtime-stream-test-utils.ts +187 -0
- package/src/__tests__/speech-relay-websocket.test.ts +29 -74
- package/src/__tests__/stt-stream-websocket.test.ts +107 -102
- package/src/__tests__/telegram-webhook-manager.test.ts +15 -0
- package/src/__tests__/watch-stream-websocket.test.ts +90 -317
- package/src/channels/ingress-verification.test.ts +238 -1
- package/src/channels/ingress-verification.ts +191 -42
- package/src/channels/plugin-ingress-approvals.test.ts +22 -0
- package/src/channels/plugin-ingress.test.ts +20 -0
- package/src/credential-cache.ts +40 -9
- package/src/credential-reader.ts +103 -41
- package/src/credential-watcher.ts +18 -2
- package/src/email/register-callback.test.ts +12 -1
- package/src/email/register-callback.ts +8 -11
- package/src/feature-flag-registry.json +24 -12
- package/src/http/middleware/auth.ts +29 -21
- package/src/http/router.ts +2 -2
- package/src/http/routes/channel-ingress.test.ts +26 -0
- package/src/http/routes/channel-ingress.ts +9 -3
- package/src/http/routes/desktop-setup-proxy.ts +16 -0
- package/src/http/routes/desktop-stream-websocket.ts +59 -0
- package/src/http/routes/guardian-pin.ts +91 -15
- package/src/http/routes/live-voice-websocket.ts +13 -10
- package/src/http/routes/platform-push-proxy.test.ts +19 -4
- package/src/http/routes/platform-push-proxy.ts +8 -5
- package/src/http/routes/plugin-webhook.test.ts +255 -0
- package/src/http/routes/plugin-webhook.ts +40 -13
- package/src/http/routes/runtime-audio-stream.ts +39 -10
- package/src/http/routes/stt-stream-websocket.ts +3 -3
- package/src/http/routes/twilio-voice-verify-callback.ts +2 -6
- package/src/http/routes/twilio-voice-webhook.ts +2 -6
- package/src/http/routes/watch-stream-websocket.ts +5 -68
- package/src/index.ts +74 -10
- package/src/ipc/credential-request-handlers.ts +5 -9
- package/src/platform-identity.ts +331 -0
- package/src/platform-user-id.ts +12 -0
- package/src/risk/bash-risk-classifier.test.ts +14 -0
- package/src/risk/command-registry/commands/assistant.ts +14 -0
- package/src/risk/command-registry.test.ts +18 -0
- package/src/runtime/client.ts +2 -2
- package/src/schema.ts +81 -0
- package/src/telegram/webhook-manager.ts +8 -11
- package/src/velay/allowed-paths.test.ts +11 -1
- package/src/velay/allowed-paths.ts +5 -19
- package/src/velay/bridge-utils.ts +1 -0
- package/src/velay/client.test.ts +102 -1
- package/src/velay/client.ts +12 -12
- 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
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
988
|
-
| `assistant/src/calls/
|
|
989
|
-
| `assistant/src/calls/
|
|
990
|
-
| `assistant/src/calls/call-
|
|
991
|
-
| `assistant/src/calls/
|
|
992
|
-
| `assistant/src/calls/
|
|
993
|
-
| `assistant/src/calls/
|
|
994
|
-
| `
|
|
995
|
-
| `gateway/src/http/routes/twilio-
|
|
996
|
-
| `gateway/src/http/routes/twilio-
|
|
997
|
-
| `gateway/src/
|
|
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
|
|
1144
|
-
| --------------------------------- |
|
|
1145
|
-
| `calls.enabled` | boolean
|
|
1146
|
-
| `calls.provider` | enum
|
|
1147
|
-
| `calls.maxDurationSeconds` | int
|
|
1148
|
-
| `calls.userConsultTimeoutSeconds` | int
|
|
1149
|
-
| `calls.disclosure.enabled` | boolean
|
|
1150
|
-
| `calls.disclosure.text` | string
|
|
1151
|
-
| `
|
|
1152
|
-
| `
|
|
1153
|
-
| `services.stt.
|
|
1154
|
-
| `services.
|
|
1155
|
-
| `services.tts.
|
|
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
|
|
package/package.json
CHANGED
|
@@ -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
|
+
});
|