@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.
- package/ARCHITECTURE.md +7 -7
- package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +34 -0
- package/node_modules/@vellumai/gateway-client/src/index.ts +2 -0
- package/node_modules/@vellumai/service-contracts/src/channels.ts +11 -0
- package/openapi.json +3 -1
- package/package.json +1 -1
- package/src/__tests__/log-redact.test.ts +281 -0
- package/src/__tests__/telegram-webhook-manager.test.ts +66 -0
- package/src/channels/inbound-event.ts +11 -2
- package/src/channels/ingress-inbound-vendors.test.ts +213 -0
- package/src/channels/ingress-inbound.test.ts +220 -0
- package/src/channels/ingress-inbound.ts +289 -0
- package/src/channels/plugin-inbound.test.ts +224 -0
- package/src/channels/plugin-inbound.ts +204 -0
- package/src/channels/plugin-ingress-approvals.test.ts +97 -165
- package/src/channels/plugin-ingress-approvals.ts +57 -22
- package/src/channels/plugin-ingress.test.ts +49 -0
- package/src/channels/plugin-ingress.ts +37 -0
- package/src/channels/types.ts +2 -0
- package/src/db/inbound-dedup-store.test.ts +187 -0
- package/src/db/inbound-dedup-store.ts +186 -0
- package/src/db/schema.ts +64 -0
- package/src/db/seed-admission-policy.ts +15 -2
- package/src/handlers/handle-inbound.ts +82 -16
- package/src/http/read-limited-body.ts +17 -5
- package/src/http/routes/channel-ingress-routes.ts +7 -0
- package/src/http/routes/channel-ingress.test.ts +1 -0
- package/src/http/routes/channel-ingress.ts +6 -0
- package/src/http/routes/plugin-webhook.test.ts +548 -10
- package/src/http/routes/plugin-webhook.ts +368 -50
- package/src/index.ts +53 -0
- package/src/log-redact.ts +10 -23
- package/src/telegram/webhook-manager.ts +18 -5
- 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`, `
|
|
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
|
|
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
|
|
package/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/channels.ts
CHANGED
|
@@ -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
|
@@ -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;
|