@vellumai/vellum-gateway 0.11.3 → 0.11.4-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 (36) hide show
  1. package/ARCHITECTURE.md +7 -7
  2. package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  3. package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  4. package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
  5. package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
  6. package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
  7. package/openapi.json +3 -1
  8. package/package.json +1 -1
  9. package/src/__tests__/log-redact.test.ts +281 -0
  10. package/src/__tests__/telegram-webhook-manager.test.ts +66 -0
  11. package/src/channels/inbound-event.ts +11 -2
  12. package/src/channels/ingress-inbound-vendors.test.ts +213 -0
  13. package/src/channels/ingress-inbound.test.ts +220 -0
  14. package/src/channels/ingress-inbound.ts +289 -0
  15. package/src/channels/plugin-inbound.test.ts +224 -0
  16. package/src/channels/plugin-inbound.ts +204 -0
  17. package/src/channels/plugin-ingress-approvals.test.ts +97 -165
  18. package/src/channels/plugin-ingress-approvals.ts +57 -22
  19. package/src/channels/plugin-ingress.test.ts +49 -0
  20. package/src/channels/plugin-ingress.ts +37 -0
  21. package/src/channels/types.ts +2 -0
  22. package/src/db/inbound-dedup-store.test.ts +187 -0
  23. package/src/db/inbound-dedup-store.ts +186 -0
  24. package/src/db/schema.ts +64 -0
  25. package/src/db/seed-admission-policy.ts +15 -2
  26. package/src/handlers/handle-inbound.ts +82 -16
  27. package/src/http/read-limited-body.ts +17 -5
  28. package/src/http/routes/channel-ingress-routes.ts +7 -0
  29. package/src/http/routes/channel-ingress.test.ts +1 -0
  30. package/src/http/routes/channel-ingress.ts +6 -0
  31. package/src/http/routes/plugin-webhook.test.ts +548 -10
  32. package/src/http/routes/plugin-webhook.ts +368 -50
  33. package/src/index.ts +53 -0
  34. package/src/log-redact.ts +10 -23
  35. package/src/telegram/webhook-manager.ts +18 -5
  36. package/src/verification/identity.ts +44 -6
package/ARCHITECTURE.md CHANGED
@@ -60,16 +60,16 @@ Clients open WebSocket connections through the gateway to the daemon's real-time
60
60
 
61
61
  **Query parameters:**
62
62
 
63
- | Parameter | Required | Description |
64
- | ------------ | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
- | `mimeType` | Yes | MIME type of the audio being streamed (e.g. `audio/webm;codecs=opus`) |
66
- | `provider` | No | Optional STT provider identifier (`deepgram`, `google-gemini`, `openai-whisper`, `xai`). Forwarded as compatibility metadata — the runtime resolves the transcriber from config, not from this parameter. |
67
- | `sampleRate` | No | Sample rate in Hz (e.g. `16000`). Passed through to the daemon. |
68
- | `token` | No | Edge JWT (alternative to `Authorization: Bearer` header for WS upgrades) |
63
+ | Parameter | Required | Description |
64
+ | ------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
+ | `mimeType` | Yes | MIME type of the audio being streamed (e.g. `audio/webm;codecs=opus`) |
66
+ | `provider` | No | Optional STT provider identifier (any ID in the daemon's STT provider catalog, e.g. `deepgram`, `deepgram-flux`, `google-gemini`). Forwarded as compatibility metadata, and the gateway does not validate it: the runtime resolves the transcriber from config, not from this parameter. |
67
+ | `sampleRate` | No | Sample rate in Hz (e.g. `16000`). Passed through to the daemon. |
68
+ | `token` | No | Edge JWT (alternative to `Authorization: Bearer` header for WS upgrades) |
69
69
 
70
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.
71
71
 
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 transcript events (JSON text frames: `ready`, `partial`, `final`, `error`, `closed`) flow downstream. When either side closes, the other side is closed with the same code/reason.
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
 
74
74
  **Key source files:**
75
75
 
@@ -6,6 +6,16 @@
6
6
  * assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
7
7
  * internal ids (`vellum` for native app conversations, `platform` for the
8
8
  * internal control plane). This is the single source of truth for that set:
9
+ *
10
+ * One id, `plugin`, does not name a surface: it names *every* surface a plugin
11
+ * brings. A plugin channel's real identity is the plugin, which is workspace
12
+ * state and cannot be a compile-time union member, so the plugin name travels
13
+ * in `sourceMetadata.plugin` and is prefixed onto every external id the gateway
14
+ * forwards (`imessage:+15551234567`). Two plugins therefore share a channel
15
+ * row — one admission floor, one set of channel-wide defaults — while their
16
+ * conversations, contacts, and trust records stay disjoint. See
17
+ * `gateway/src/channels/plugin-inbound.ts` for what that concedes.
18
+ *
9
19
  * the assistant adopts it wholesale as its `ChannelId`, and the gateway
10
20
  * asserts its own (narrower) inbound list is a subset of it so the two sides
11
21
  * cannot silently drift.
@@ -30,6 +40,7 @@ export const CHANNEL_IDS = [
30
40
  "platform",
31
41
  "a2a",
32
42
  "discord",
43
+ "plugin",
33
44
  ] as const;
34
45
 
35
46
  export type ChannelId = (typeof CHANNEL_IDS)[number];
@@ -6,6 +6,16 @@
6
6
  * assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
7
7
  * internal ids (`vellum` for native app conversations, `platform` for the
8
8
  * internal control plane). This is the single source of truth for that set:
9
+ *
10
+ * One id, `plugin`, does not name a surface: it names *every* surface a plugin
11
+ * brings. A plugin channel's real identity is the plugin, which is workspace
12
+ * state and cannot be a compile-time union member, so the plugin name travels
13
+ * in `sourceMetadata.plugin` and is prefixed onto every external id the gateway
14
+ * forwards (`imessage:+15551234567`). Two plugins therefore share a channel
15
+ * row — one admission floor, one set of channel-wide defaults — while their
16
+ * conversations, contacts, and trust records stay disjoint. See
17
+ * `gateway/src/channels/plugin-inbound.ts` for what that concedes.
18
+ *
9
19
  * the assistant adopts it wholesale as its `ChannelId`, and the gateway
10
20
  * asserts its own (narrower) inbound list is a subset of it so the two sides
11
21
  * cannot silently drift.
@@ -30,6 +40,7 @@ export const CHANNEL_IDS = [
30
40
  "platform",
31
41
  "a2a",
32
42
  "discord",
43
+ "plugin",
33
44
  ] as const;
34
45
 
35
46
  export type ChannelId = (typeof CHANNEL_IDS)[number];
@@ -10,6 +10,8 @@
10
10
 
11
11
  import { z } from "zod";
12
12
 
13
+ import type { TrustClass } from "./trust-verdict-contract.js";
14
+
13
15
  /**
14
16
  * Per-channel inbound admission policy — ordered from most-restrictive
15
17
  * (`no_one`, hard kill switch) to most-permissive (`strangers`, admits any
@@ -101,3 +103,35 @@ export function isAdmissionPolicy(value: unknown): value is AdmissionPolicy {
101
103
  (ADMISSION_POLICY_VALUES as readonly string[]).includes(value)
102
104
  );
103
105
  }
106
+
107
+ /**
108
+ * Trust-class ordinal compared against {@link ADMISSION_FLOOR} to make the
109
+ * admission decision. Higher rank = more trusted. Blocked and revoked members
110
+ * never reach this comparison, short-circuiting to deny on member status, so
111
+ * they carry no rank.
112
+ */
113
+ export const TRUST_CLASS_RANK: Record<TrustClass, number> = {
114
+ guardian: 4,
115
+ trusted_contact: 3,
116
+ unverified_contact: 2,
117
+ unknown: 1,
118
+ };
119
+
120
+ /**
121
+ * Whether a sender of this trust class clears a channel's admission floor.
122
+ *
123
+ * The two halves of the check live together because they are meaningless
124
+ * apart: this compares a table keyed by {@link TrustClass} against one keyed
125
+ * by {@link AdmissionPolicy}, and a floor added to one without a rank in the
126
+ * other silently admits or denies everyone.
127
+ *
128
+ * Both enforcement points read this. The runtime's admission stage answers for
129
+ * every channel it receives; the gateway answers for a channel it delivers
130
+ * somewhere other than the runtime, where there is no later stage to ask.
131
+ */
132
+ export function meetsAdmissionFloor(
133
+ policy: AdmissionPolicy,
134
+ trustClass: TrustClass,
135
+ ): boolean {
136
+ return TRUST_CLASS_RANK[trustClass] >= ADMISSION_FLOOR[policy];
137
+ }
@@ -71,6 +71,8 @@ export {
71
71
  isAdmissionPolicy,
72
72
  isAdmissionPolicyExemptChannel,
73
73
  isAdmissionPolicyHiddenChannel,
74
+ meetsAdmissionFloor,
75
+ TRUST_CLASS_RANK,
74
76
  } from "./admission-policy-contract.js";
75
77
 
76
78
  export type { AdmissionPolicy } from "./admission-policy-contract.js";
@@ -6,6 +6,16 @@
6
6
  * assistant through (Slack, Telegram, WhatsApp, phone, …) plus a couple of
7
7
  * internal ids (`vellum` for native app conversations, `platform` for the
8
8
  * internal control plane). This is the single source of truth for that set:
9
+ *
10
+ * One id, `plugin`, does not name a surface: it names *every* surface a plugin
11
+ * brings. A plugin channel's real identity is the plugin, which is workspace
12
+ * state and cannot be a compile-time union member, so the plugin name travels
13
+ * in `sourceMetadata.plugin` and is prefixed onto every external id the gateway
14
+ * forwards (`imessage:+15551234567`). Two plugins therefore share a channel
15
+ * row — one admission floor, one set of channel-wide defaults — while their
16
+ * conversations, contacts, and trust records stay disjoint. See
17
+ * `gateway/src/channels/plugin-inbound.ts` for what that concedes.
18
+ *
9
19
  * the assistant adopts it wholesale as its `ChannelId`, and the gateway
10
20
  * asserts its own (narrower) inbound list is a subset of it so the two sides
11
21
  * cannot silently drift.
@@ -30,6 +40,7 @@ export const CHANNEL_IDS = [
30
40
  "platform",
31
41
  "a2a",
32
42
  "discord",
43
+ "plugin",
33
44
  ] as const;
34
45
 
35
46
  export type ChannelId = (typeof CHANNEL_IDS)[number];
package/openapi.json CHANGED
@@ -388,6 +388,7 @@
388
388
  "description": { "type": "string" },
389
389
  "credential": { "type": "string" },
390
390
  "served": { "type": "boolean" },
391
+ "deliversInbound": { "type": "boolean" },
391
392
  "verification": {
392
393
  "type": "object",
393
394
  "properties": {
@@ -406,7 +407,8 @@
406
407
  "handshake",
407
408
  "description",
408
409
  "credential",
409
- "served"
410
+ "served",
411
+ "deliversInbound"
410
412
  ],
411
413
  "additionalProperties": false
412
414
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/vellum-gateway",
3
- "version": "0.11.3",
3
+ "version": "0.11.4-staging.2",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -0,0 +1,281 @@
1
+ import { describe, expect, test } from "bun:test";
2
+
3
+ import { logSerializers } from "../log-redact.js";
4
+
5
+ const {
6
+ err: errSerializer,
7
+ req: reqSerializer,
8
+ res: resSerializer,
9
+ } = logSerializers;
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Bearer token
13
+ // ---------------------------------------------------------------------------
14
+
15
+ describe("bearer token redaction", () => {
16
+ test("redacts a bearer token in a string value", () => {
17
+ const out = reqSerializer({
18
+ headers: { host: "api.example.com", accept: "Bearer eyJhbGci.abc.def" },
19
+ });
20
+ expect(JSON.stringify(out)).not.toContain("eyJhbGci");
21
+ expect(JSON.stringify(out)).toContain("Bearer [REDACTED]");
22
+ });
23
+ });
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // API-key patterns - sourced from service-contracts (these were missing from
27
+ // the old hardcoded gateway list and are the main motivation for this change)
28
+ // ---------------------------------------------------------------------------
29
+
30
+ describe("API key patterns from service-contracts", () => {
31
+ // Patterns that were MISSING from the old hardcoded gateway list
32
+ test("redacts a Linear API key (lin_api_...)", () => {
33
+ const key = "lin_api_" + "a".repeat(32);
34
+ const out = resSerializer({ body: key });
35
+ expect(JSON.stringify(out)).not.toContain(key);
36
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
37
+ });
38
+
39
+ test("redacts a Notion integration token (ntn_...)", () => {
40
+ const key = "ntn_" + "b".repeat(40);
41
+ const out = reqSerializer({ body: key });
42
+ expect(JSON.stringify(out)).not.toContain(key);
43
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
44
+ });
45
+
46
+ test("redacts an OpenRouter API key (sk-or-v1-...)", () => {
47
+ const key = "sk-or-v1-" + "c".repeat(40);
48
+ const out = reqSerializer({ url: key });
49
+ expect(JSON.stringify(out)).not.toContain(key);
50
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
51
+ });
52
+
53
+ test("redacts a PyPI token (pypi-...)", () => {
54
+ const key = "pypi-" + "d".repeat(50);
55
+ const out = reqSerializer({ body: key });
56
+ expect(JSON.stringify(out)).not.toContain(key);
57
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
58
+ });
59
+
60
+ test("redacts a Fireworks API key (fw_...)", () => {
61
+ const key = "fw_" + "e".repeat(32);
62
+ const out = reqSerializer({ body: key });
63
+ expect(JSON.stringify(out)).not.toContain(key);
64
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
65
+ });
66
+
67
+ test("redacts a Perplexity API key (pplx-...)", () => {
68
+ const key = "pplx-" + "f".repeat(40);
69
+ const out = reqSerializer({ body: key });
70
+ expect(JSON.stringify(out)).not.toContain(key);
71
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
72
+ });
73
+
74
+ test("redacts a Tavily API key (tvly-...)", () => {
75
+ const key = "tvly-" + "g".repeat(20);
76
+ const out = reqSerializer({ body: key });
77
+ expect(JSON.stringify(out)).not.toContain(key);
78
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
79
+ });
80
+
81
+ test("redacts a Firecrawl API key (fc-...)", () => {
82
+ const key = "fc-" + "h".repeat(20);
83
+ const out = reqSerializer({ body: key });
84
+ expect(JSON.stringify(out)).not.toContain(key);
85
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
86
+ });
87
+
88
+ test("redacts a Slack App token (xapp-...)", () => {
89
+ const key = "xapp-1-ABC12345-9876543210-abcdefghij1234567890";
90
+ const out = reqSerializer({ body: key });
91
+ expect(JSON.stringify(out)).not.toContain(key);
92
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
93
+ });
94
+
95
+ test("redacts a Mailgun API key (key-...)", () => {
96
+ const key = "key-" + "a".repeat(32);
97
+ const out = reqSerializer({ body: key });
98
+ expect(JSON.stringify(out)).not.toContain(key);
99
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
100
+ });
101
+
102
+ test("redacts a Twilio API key (SK...)", () => {
103
+ const key = "SK" + "a1b2c3d4e5f6".repeat(3).slice(0, 32);
104
+ const out = reqSerializer({ body: key });
105
+ expect(JSON.stringify(out)).not.toContain(key);
106
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
107
+ });
108
+
109
+ // Patterns that were already in the old list (regression guard)
110
+ test("redacts an Anthropic API key (sk-ant-...)", () => {
111
+ const key = "sk-ant-" + "A".repeat(80);
112
+ const out = reqSerializer({ authorization: `Bearer ${key}` });
113
+ expect(JSON.stringify(out)).not.toContain(key);
114
+ });
115
+
116
+ test("redacts a GitHub token (ghp_...)", () => {
117
+ const key = "ghp_" + "Z".repeat(36);
118
+ const out = reqSerializer({ body: `token=${key}` });
119
+ expect(JSON.stringify(out)).not.toContain(key);
120
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
121
+ });
122
+
123
+ test("redacts an OpenAI project key (sk-proj-...)", () => {
124
+ const key = "sk-proj-" + "X".repeat(40);
125
+ const out = reqSerializer({ body: key });
126
+ expect(JSON.stringify(out)).not.toContain(key);
127
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
128
+ });
129
+ });
130
+
131
+ // ---------------------------------------------------------------------------
132
+ // Sensitive headers - always fully redacted regardless of value
133
+ // ---------------------------------------------------------------------------
134
+
135
+ describe("sensitive header redaction", () => {
136
+ test("redacts authorization header", () => {
137
+ const out = reqSerializer({
138
+ headers: { authorization: "Bearer secret-token" },
139
+ });
140
+ expect((out as Record<string, unknown>).headers).toEqual({
141
+ authorization: "[REDACTED]",
142
+ });
143
+ });
144
+
145
+ test("redacts x-api-key header", () => {
146
+ const out = reqSerializer({ headers: { "x-api-key": "my-key-value" } });
147
+ expect((out as Record<string, unknown>).headers).toEqual({
148
+ "x-api-key": "[REDACTED]",
149
+ });
150
+ });
151
+
152
+ test("redacts x-vellum-velay-bridge-auth header", () => {
153
+ const out = reqSerializer({
154
+ headers: { "x-vellum-velay-bridge-auth": "bridge-secret-123" },
155
+ });
156
+ expect((out as Record<string, unknown>).headers).toEqual({
157
+ "x-vellum-velay-bridge-auth": "[REDACTED]",
158
+ });
159
+ });
160
+
161
+ test("redacts cookie and set-cookie headers", () => {
162
+ const out = reqSerializer({
163
+ headers: {
164
+ cookie: "session=abc123",
165
+ "set-cookie": "session=xyz; HttpOnly",
166
+ },
167
+ });
168
+ const headers = (out as Record<string, unknown>).headers as Record<
169
+ string,
170
+ unknown
171
+ >;
172
+ expect(headers.cookie).toBe("[REDACTED]");
173
+ expect(headers["set-cookie"]).toBe("[REDACTED]");
174
+ });
175
+
176
+ test("header name matching is case-insensitive", () => {
177
+ const out = reqSerializer({ headers: { Authorization: "token xyz" } });
178
+ const headers = (out as Record<string, unknown>).headers as Record<
179
+ string,
180
+ unknown
181
+ >;
182
+ expect(headers.Authorization).toBe("[REDACTED]");
183
+ });
184
+
185
+ test("non-sensitive headers pass through unchanged", () => {
186
+ const out = reqSerializer({
187
+ headers: { "content-type": "application/json" },
188
+ });
189
+ expect((out as Record<string, unknown>).headers).toEqual({
190
+ "content-type": "application/json",
191
+ });
192
+ });
193
+ });
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Deep object / array traversal
197
+ // ---------------------------------------------------------------------------
198
+
199
+ describe("deep value redaction", () => {
200
+ test("redacts secrets nested inside an object", () => {
201
+ const key = "lin_api_" + "x".repeat(32);
202
+ const out = reqSerializer({ outer: { inner: { value: key } } });
203
+ expect(JSON.stringify(out)).not.toContain(key);
204
+ expect(JSON.stringify(out)).toContain("[REDACTED]");
205
+ });
206
+
207
+ test("redacts secrets inside an array", () => {
208
+ const key = "ghp_" + "Y".repeat(36);
209
+ const out = reqSerializer({ items: [key, "safe-value"] });
210
+ const items = (out as Record<string, unknown>).items as unknown[];
211
+ expect(items[0]).toBe("[REDACTED]");
212
+ expect(items[1]).toBe("safe-value");
213
+ });
214
+
215
+ test("stops recursing at depth 8 to prevent stack overflow", () => {
216
+ // Build an object 10 levels deep - beyond the depth cap it returns as-is
217
+ let deep: unknown = "leaf-value";
218
+ for (let i = 0; i < 10; i++) {
219
+ deep = { child: deep };
220
+ }
221
+ // Should not throw; the leaf may survive redaction past depth 8
222
+ expect(() => reqSerializer(deep)).not.toThrow();
223
+ });
224
+
225
+ test("non-object, non-string, non-array values pass through unchanged", () => {
226
+ const out = reqSerializer({ count: 42, flag: true, nothing: null });
227
+ expect(out).toEqual({ count: 42, flag: true, nothing: null });
228
+ });
229
+ });
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Error serializer
233
+ // ---------------------------------------------------------------------------
234
+
235
+ describe("err serializer", () => {
236
+ test("extracts name, message, stack from an Error", () => {
237
+ const err = new Error("something broke");
238
+ const out = errSerializer(err) as Record<string, unknown>;
239
+ expect(out.name).toBe("Error");
240
+ expect(out.message).toBe("something broke");
241
+ expect(typeof out.stack).toBe("string");
242
+ });
243
+
244
+ test("redacts a secret embedded in the error message", () => {
245
+ const key = "sk-ant-" + "B".repeat(80);
246
+ const err = new Error(`connection failed: auth=${key}`);
247
+ const out = errSerializer(err) as Record<string, unknown>;
248
+ expect(out.message as string).not.toContain(key);
249
+ expect(out.message as string).toContain("[REDACTED]");
250
+ });
251
+
252
+ test("walks the cause chain and redacts secrets in nested causes", () => {
253
+ const key = "ghp_" + "C".repeat(36);
254
+ const cause = new Error(`token=${key}`);
255
+ const err = new Error("outer error", { cause });
256
+ const out = errSerializer(err) as Record<string, unknown>;
257
+ const causeOut = out.cause as Record<string, unknown>;
258
+ expect(causeOut.message as string).not.toContain(key);
259
+ expect(causeOut.message as string).toContain("[REDACTED]");
260
+ });
261
+
262
+ test("preserves structured code property on errors", () => {
263
+ const err = Object.assign(new Error("auth failed"), {
264
+ code: "AUTH_EXPIRED",
265
+ });
266
+ const out = errSerializer(err) as Record<string, unknown>;
267
+ expect(out.code).toBe("AUTH_EXPIRED");
268
+ });
269
+
270
+ test("preserves extra enumerable properties", () => {
271
+ const err = Object.assign(new Error("oops"), { statusCode: 429 });
272
+ const out = errSerializer(err) as Record<string, unknown>;
273
+ expect(out.statusCode).toBe(429);
274
+ });
275
+
276
+ test("non-Error values pass through the err serializer unchanged", () => {
277
+ expect(errSerializer("a string")).toBe("a string");
278
+ expect(errSerializer(null)).toBeNull();
279
+ expect(errSerializer(42)).toBe(42);
280
+ });
281
+ });
@@ -481,6 +481,72 @@ describe("reconcileTelegramWebhook", () => {
481
481
  ]);
482
482
  });
483
483
 
484
+ test("prefers the managed callback route over the Velay ingress URL on platform pods", async () => {
485
+ const calls: string[] = [];
486
+ let registeredUrl: string | undefined;
487
+ process.env.IS_PLATFORM = "true";
488
+ // A platform pod always has an `ingress.publicBaseUrl`: the Velay tunnel
489
+ // client publishes one at boot. It must not win over the managed callback
490
+ // route — the Velay address dies with the tunnel, and the daemon's
491
+ // `hasWebhookRoutingConfigured` reports this pod as managed-callback mode.
492
+ const caches = makeCaches({
493
+ ingressUrl:
494
+ "https://velay.vellum.ai/11111111-2222-4333-8444-555555555555",
495
+ platformBaseUrl: "https://platform.example.com",
496
+ assistantApiKey: "ast-managed-key",
497
+ platformAssistantId: "11111111-2222-4333-8444-555555555555",
498
+ });
499
+
500
+ fetchMock = mock(
501
+ async (input: string | URL | Request, init?: RequestInit) => {
502
+ const url =
503
+ typeof input === "string"
504
+ ? input
505
+ : input instanceof URL
506
+ ? input.toString()
507
+ : input.url;
508
+ if (url.includes("/callback-routes/register/")) {
509
+ calls.push("registerCallbackRoute");
510
+ return new Response(
511
+ JSON.stringify({
512
+ callback_url:
513
+ "https://platform.example.com/v1/gateway/callbacks/11111111-2222-4333-8444-555555555555/webhooks/telegram/",
514
+ }),
515
+ { status: 201, headers: { "content-type": "application/json" } },
516
+ );
517
+ }
518
+ if (url.includes("/getWebhookInfo")) {
519
+ calls.push("getWebhookInfo");
520
+ return makeTelegramResponse({
521
+ url: "",
522
+ has_custom_certificate: false,
523
+ pending_update_count: 0,
524
+ });
525
+ }
526
+ if (url.includes("/setWebhook")) {
527
+ calls.push("setWebhook");
528
+ const body = init?.body
529
+ ? (JSON.parse(init.body as string) as { url?: string })
530
+ : undefined;
531
+ registeredUrl = body?.url;
532
+ return makeTelegramResponse(true);
533
+ }
534
+ return new Response("Not found", { status: 404 });
535
+ },
536
+ );
537
+
538
+ await reconcileTelegramWebhook(caches);
539
+
540
+ expect(calls).toEqual([
541
+ "registerCallbackRoute",
542
+ "getWebhookInfo",
543
+ "setWebhook",
544
+ ]);
545
+ expect(registeredUrl).toBe(
546
+ "https://platform.example.com/v1/gateway/callbacks/11111111-2222-4333-8444-555555555555/webhooks/telegram/",
547
+ );
548
+ });
549
+
484
550
  test("does not call Telegram when ingress is disabled but credentials are absent", async () => {
485
551
  fetchMock = mock(async () => new Response("", { status: 200 }));
486
552
 
@@ -10,7 +10,7 @@ import type { ChannelId } from "./types.js";
10
10
 
11
11
  export type InboundChannelId = Extract<
12
12
  ChannelId,
13
- "telegram" | "whatsapp" | "slack" | "email" | "a2a" | "discord"
13
+ "telegram" | "whatsapp" | "slack" | "email" | "a2a" | "discord" | "plugin"
14
14
  >;
15
15
 
16
16
  interface InboundEventBase<C extends InboundChannelId> {
@@ -93,6 +93,14 @@ export type A2aInboundEvent = InboundEventBase<"a2a">;
93
93
  * `conversationExternalId` a private address.
94
94
  */
95
95
  export type DiscordInboundEvent = InboundEventBase<"discord">;
96
+ /**
97
+ * Constructed by `plugin-inbound.ts` from a plugin's reply to a verified
98
+ * webhook delivery. One channel covers every plugin, so `conversationExternalId`
99
+ * and `actorExternalId` are both prefixed with the plugin's directory name and
100
+ * `source.chatType` carries whatever the plugin reported. Which plugin it was
101
+ * reaches the runtime on `sourceMetadata`, not here.
102
+ */
103
+ export type PluginInboundEvent = InboundEventBase<"plugin">;
96
104
 
97
105
  export type GatewayInboundEvent =
98
106
  | TelegramInboundEvent
@@ -100,4 +108,5 @@ export type GatewayInboundEvent =
100
108
  | SlackInboundEvent
101
109
  | EmailInboundEvent
102
110
  | A2aInboundEvent
103
- | DiscordInboundEvent;
111
+ | DiscordInboundEvent
112
+ | PluginInboundEvent;