@vellumai/assistant 0.9.0-dev.202606181324.0804a69 → 0.9.0-dev.202606181513.fd39213
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/node_modules/@vellumai/gateway-client/src/admission-policy-contract.ts +4 -9
- package/package.json +1 -1
- package/src/__tests__/cancel-clears-processing.test.ts +22 -19
- package/src/__tests__/conversation-agent-loop.test.ts +48 -41
- package/src/__tests__/media-stream-server-integration.test.ts +317 -13
- package/src/__tests__/relay-server.test.ts +285 -0
- package/src/__tests__/twilio-routes.test.ts +81 -1
- package/src/calls/__tests__/channel-admission-reader.test.ts +132 -0
- package/src/calls/__tests__/relay-setup-router.test.ts +350 -0
- package/src/calls/channel-admission-reader.ts +104 -0
- package/src/calls/media-stream-server.ts +84 -2
- package/src/calls/relay-server.ts +66 -0
- package/src/calls/relay-setup-router.ts +77 -0
- package/src/calls/twilio-routes.ts +17 -8
- package/src/daemon/conversation-agent-loop.ts +122 -56
- package/src/daemon/conversation.ts +7 -0
- package/src/daemon/handlers/conversations.ts +6 -8
- package/src/runtime/routes/inbound-stages/admission-policy.test.ts +4 -2
- package/src/telemetry/usage-telemetry-reporter.test.ts +59 -13
- package/src/telemetry/usage-telemetry-reporter.ts +28 -16
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the inbound admission-floor enforcement in `routeSetup`.
|
|
3
|
+
*
|
|
4
|
+
* `routeSetup` is pure routing logic but reads several module-level singletons
|
|
5
|
+
* (config, trust resolver, pending verification session, invite store). These
|
|
6
|
+
* are mocked so the table below can drive trust class, member status, pending
|
|
7
|
+
* challenge, and active invites independently of any DB.
|
|
8
|
+
*
|
|
9
|
+
* The floor verdict is exercised through the REAL `enforceAdmissionPolicy`
|
|
10
|
+
* floor tables (`TRUST_CLASS_RANK` × `ADMISSION_FLOOR`), with the exempt-channel
|
|
11
|
+
* short-circuit bypassed in the mock so the tests assert the true floor
|
|
12
|
+
* semantics independent of any channel's exempt status (`phone` is now
|
|
13
|
+
* enforced).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
ADMISSION_FLOOR,
|
|
20
|
+
type AdmissionPolicy,
|
|
21
|
+
} from "@vellumai/gateway-client";
|
|
22
|
+
|
|
23
|
+
import type {
|
|
24
|
+
ChannelPolicy,
|
|
25
|
+
ChannelStatus,
|
|
26
|
+
} from "../../contacts/types.js";
|
|
27
|
+
import type {
|
|
28
|
+
ActorTrustContext,
|
|
29
|
+
TrustClass,
|
|
30
|
+
} from "../../runtime/actor-trust-resolver.js";
|
|
31
|
+
import { TRUST_CLASS_RANK } from "../../runtime/actor-trust-resolver.js";
|
|
32
|
+
|
|
33
|
+
mock.module("../../util/logger.js", () => ({
|
|
34
|
+
getLogger: () =>
|
|
35
|
+
new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
mock.module("../../config/loader.js", () => ({
|
|
39
|
+
getConfig: () => ({ calls: { verification: { enabled: false } } }),
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
// Controllable resolved trust context.
|
|
43
|
+
let nextTrust: ActorTrustContext;
|
|
44
|
+
mock.module("../../runtime/actor-trust-resolver.js", () => ({
|
|
45
|
+
resolveActorTrust: () => nextTrust,
|
|
46
|
+
// Re-export the real rank table; the floor mock below consumes it.
|
|
47
|
+
TRUST_CLASS_RANK,
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
// Controllable pending verification challenge.
|
|
51
|
+
let pendingChallenge: unknown = null;
|
|
52
|
+
mock.module("../../runtime/channel-verification-service.js", () => ({
|
|
53
|
+
getPendingSession: () => pendingChallenge,
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
// Controllable active voice invites.
|
|
57
|
+
let activeInvites: Array<{
|
|
58
|
+
friendName: string | null;
|
|
59
|
+
guardianName: string | null;
|
|
60
|
+
expiresAt?: number;
|
|
61
|
+
}> = [];
|
|
62
|
+
mock.module("../../memory/invite-store.js", () => ({
|
|
63
|
+
findActiveVoiceInvites: () => activeInvites,
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
// Real floor semantics, exemption bypassed (see file header).
|
|
67
|
+
mock.module(
|
|
68
|
+
"../../runtime/routes/inbound-stages/admission-policy.js",
|
|
69
|
+
() => ({
|
|
70
|
+
enforceAdmissionPolicy: (input: {
|
|
71
|
+
trustClass: TrustClass;
|
|
72
|
+
memberStatus: ChannelStatus | undefined;
|
|
73
|
+
policy: AdmissionPolicy;
|
|
74
|
+
}) => {
|
|
75
|
+
if (input.memberStatus === "blocked" || input.memberStatus === "revoked") {
|
|
76
|
+
return {
|
|
77
|
+
admitted: false,
|
|
78
|
+
reason:
|
|
79
|
+
input.memberStatus === "blocked"
|
|
80
|
+
? "member_blocked"
|
|
81
|
+
: "member_revoked",
|
|
82
|
+
shouldChallenge: false,
|
|
83
|
+
effectivePolicy: input.policy,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const rank = TRUST_CLASS_RANK[input.trustClass];
|
|
87
|
+
const floor = ADMISSION_FLOOR[input.policy];
|
|
88
|
+
if (rank >= floor) return { admitted: true };
|
|
89
|
+
return {
|
|
90
|
+
admitted: false,
|
|
91
|
+
reason: `admission_policy_${input.policy}`,
|
|
92
|
+
shouldChallenge: false,
|
|
93
|
+
effectivePolicy: input.policy,
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const { routeSetup } = await import("../relay-setup-router.js");
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// Fixtures
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function makeTrust(
|
|
106
|
+
trustClass: TrustClass,
|
|
107
|
+
channel?: { status: ChannelStatus; policy?: ChannelPolicy; role?: string },
|
|
108
|
+
): ActorTrustContext {
|
|
109
|
+
const memberRecord = channel
|
|
110
|
+
? {
|
|
111
|
+
contact: {
|
|
112
|
+
displayName: "Test Caller",
|
|
113
|
+
role: channel.role ?? "trusted_contact",
|
|
114
|
+
} as never,
|
|
115
|
+
channel: {
|
|
116
|
+
id: "ch_1",
|
|
117
|
+
status: channel.status,
|
|
118
|
+
policy: channel.policy ?? "allow",
|
|
119
|
+
} as never,
|
|
120
|
+
}
|
|
121
|
+
: null;
|
|
122
|
+
return {
|
|
123
|
+
canonicalSenderId: "+15551234567",
|
|
124
|
+
guardianBindingMatch: null,
|
|
125
|
+
memberRecord,
|
|
126
|
+
trustClass,
|
|
127
|
+
actorMetadata: {
|
|
128
|
+
identifier: "+15551234567",
|
|
129
|
+
displayName: undefined,
|
|
130
|
+
senderDisplayName: undefined,
|
|
131
|
+
memberDisplayName: undefined,
|
|
132
|
+
username: undefined,
|
|
133
|
+
channel: "phone",
|
|
134
|
+
trustStatus: trustClass,
|
|
135
|
+
},
|
|
136
|
+
} as ActorTrustContext;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function route(admissionPolicy?: AdmissionPolicy | null) {
|
|
140
|
+
return routeSetup({
|
|
141
|
+
callSessionId: "cs_1",
|
|
142
|
+
session: null, // inbound
|
|
143
|
+
from: "+15551234567",
|
|
144
|
+
to: "+15559999999",
|
|
145
|
+
admissionPolicy,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
beforeEach(() => {
|
|
150
|
+
pendingChallenge = null;
|
|
151
|
+
activeInvites = [];
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
// Floor table: trustClass × policy → admit/deny
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
describe("routeSetup — admission floor table", () => {
|
|
159
|
+
// For "known" classes (trusted_contact, guardian) the resolver yields a
|
|
160
|
+
// member channel; unknown/unverified flow through the unknown ACL branch.
|
|
161
|
+
function setTrust(trustClass: TrustClass) {
|
|
162
|
+
if (trustClass === "guardian") {
|
|
163
|
+
nextTrust = makeTrust("guardian", {
|
|
164
|
+
status: "active",
|
|
165
|
+
role: "guardian",
|
|
166
|
+
});
|
|
167
|
+
} else if (trustClass === "trusted_contact") {
|
|
168
|
+
nextTrust = makeTrust("trusted_contact", { status: "active" });
|
|
169
|
+
} else if (trustClass === "unverified_contact") {
|
|
170
|
+
nextTrust = makeTrust("unverified_contact", { status: "unverified" });
|
|
171
|
+
} else {
|
|
172
|
+
nextTrust = makeTrust("unknown");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const cases: Array<{
|
|
177
|
+
policy: AdmissionPolicy;
|
|
178
|
+
admits: TrustClass[];
|
|
179
|
+
denies: TrustClass[];
|
|
180
|
+
}> = [
|
|
181
|
+
{
|
|
182
|
+
policy: "strangers",
|
|
183
|
+
admits: ["unknown", "unverified_contact", "trusted_contact", "guardian"],
|
|
184
|
+
denies: [],
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
policy: "any_contact",
|
|
188
|
+
admits: ["unverified_contact", "trusted_contact", "guardian"],
|
|
189
|
+
denies: ["unknown"],
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
policy: "trusted_contacts",
|
|
193
|
+
admits: ["trusted_contact", "guardian"],
|
|
194
|
+
denies: ["unknown", "unverified_contact"],
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
policy: "guardian_only",
|
|
198
|
+
admits: ["guardian"],
|
|
199
|
+
denies: ["unknown", "unverified_contact", "trusted_contact"],
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
policy: "no_one",
|
|
203
|
+
admits: [],
|
|
204
|
+
denies: ["unknown", "unverified_contact", "trusted_contact", "guardian"],
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
for (const { policy, admits, denies } of cases) {
|
|
209
|
+
for (const trustClass of denies) {
|
|
210
|
+
test(`${policy} denies ${trustClass}`, () => {
|
|
211
|
+
setTrust(trustClass);
|
|
212
|
+
const { outcome } = route(policy);
|
|
213
|
+
expect(outcome.action).toBe("deny");
|
|
214
|
+
if (outcome.action === "deny") {
|
|
215
|
+
expect(outcome.logReason).toBe(
|
|
216
|
+
`Inbound voice admission floor: ${policy}`,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
for (const trustClass of admits) {
|
|
222
|
+
test(`${policy} admits ${trustClass}`, () => {
|
|
223
|
+
setTrust(trustClass);
|
|
224
|
+
const { outcome } = route(policy);
|
|
225
|
+
expect(outcome.action).not.toBe("deny");
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
// Blocked / revoked always deny
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
|
|
235
|
+
describe("routeSetup — blocked / revoked", () => {
|
|
236
|
+
test("blocked caller is denied (pre-floor ACL check)", () => {
|
|
237
|
+
nextTrust = makeTrust("unknown", { status: "blocked" });
|
|
238
|
+
const { outcome } = route("strangers");
|
|
239
|
+
expect(outcome.action).toBe("deny");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("revoked member is denied under permissive floor", () => {
|
|
243
|
+
// Revoked → resolver classifies as unknown; the floor mock denies on
|
|
244
|
+
// memberStatus regardless of the permissive policy.
|
|
245
|
+
nextTrust = makeTrust("unknown", { status: "revoked" });
|
|
246
|
+
const { outcome } = route("strangers");
|
|
247
|
+
expect(outcome.action).toBe("deny");
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// admissionPolicy null/undefined → current behavior unchanged
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
describe("routeSetup — no policy preserves current behavior", () => {
|
|
256
|
+
test("unknown caller → name_capture", () => {
|
|
257
|
+
nextTrust = makeTrust("unknown");
|
|
258
|
+
expect(route(null).outcome.action).toBe("name_capture");
|
|
259
|
+
expect(route(undefined).outcome.action).toBe("name_capture");
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("unverified known caller → unverified_caller", () => {
|
|
263
|
+
nextTrust = makeTrust("unverified_contact", { status: "unverified" });
|
|
264
|
+
expect(route(null).outcome.action).toBe("unverified_caller");
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("trusted contact → normal_call", () => {
|
|
268
|
+
nextTrust = makeTrust("trusted_contact", { status: "active" });
|
|
269
|
+
expect(route(null).outcome.action).toBe("normal_call");
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("guardian → normal_call", () => {
|
|
273
|
+
nextTrust = makeTrust("guardian", { status: "active", role: "guardian" });
|
|
274
|
+
expect(route(null).outcome.action).toBe("normal_call");
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("member policy deny still denies", () => {
|
|
278
|
+
nextTrust = makeTrust("trusted_contact", {
|
|
279
|
+
status: "active",
|
|
280
|
+
policy: "deny",
|
|
281
|
+
});
|
|
282
|
+
expect(route(null).outcome.action).toBe("deny");
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// Active permissive floor admits straight to a normal call
|
|
288
|
+
// ---------------------------------------------------------------------------
|
|
289
|
+
// When a policy is ACTIVELY set and admits the caller, the floor is the access
|
|
290
|
+
// decision: unknown/unverified callers bypass name_capture / unverified_caller
|
|
291
|
+
// and connect directly. With a null policy the legacy identity flows persist.
|
|
292
|
+
|
|
293
|
+
describe("routeSetup — permissive floor admits to normal_call", () => {
|
|
294
|
+
test("any_contact admits an unverified_contact to normal_call (not unverified_caller)", () => {
|
|
295
|
+
nextTrust = makeTrust("unverified_contact", { status: "unverified" });
|
|
296
|
+
const { outcome } = route("any_contact");
|
|
297
|
+
expect(outcome.action).toBe("normal_call");
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
test("strangers admits an unknown caller to normal_call (not name_capture)", () => {
|
|
301
|
+
nextTrust = makeTrust("unknown");
|
|
302
|
+
const { outcome } = route("strangers");
|
|
303
|
+
expect(outcome.action).toBe("normal_call");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("strangers admits an unverified_contact to normal_call", () => {
|
|
307
|
+
nextTrust = makeTrust("unverified_contact", { status: "unverified" });
|
|
308
|
+
const { outcome } = route("strangers");
|
|
309
|
+
expect(outcome.action).toBe("normal_call");
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("null policy preserves legacy name_capture for unknown caller", () => {
|
|
313
|
+
nextTrust = makeTrust("unknown");
|
|
314
|
+
expect(route(null).outcome.action).toBe("name_capture");
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
test("null policy preserves legacy unverified_caller for unverified caller", () => {
|
|
318
|
+
nextTrust = makeTrust("unverified_contact", { status: "unverified" });
|
|
319
|
+
expect(route(null).outcome.action).toBe("unverified_caller");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("trusted_contacts (default) still denies unknown and unverified", () => {
|
|
323
|
+
nextTrust = makeTrust("unknown");
|
|
324
|
+
expect(route("trusted_contacts").outcome.action).toBe("deny");
|
|
325
|
+
nextTrust = makeTrust("unverified_contact", { status: "unverified" });
|
|
326
|
+
expect(route("trusted_contacts").outcome.action).toBe("deny");
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// ---------------------------------------------------------------------------
|
|
331
|
+
// Invites & pending challenges bypass the floor
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
describe("routeSetup — floor bypasses", () => {
|
|
335
|
+
test("active voice invite bypasses the floor (no_one policy)", () => {
|
|
336
|
+
nextTrust = makeTrust("unknown");
|
|
337
|
+
activeInvites = [{ friendName: "Friend", guardianName: "Guardian" }];
|
|
338
|
+
const { outcome } = route("no_one");
|
|
339
|
+
expect(outcome.action).toBe("invite_redemption");
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("pending verification challenge bypasses the floor (no_one policy)", () => {
|
|
343
|
+
// A pending challenge for a known member routes to verification regardless
|
|
344
|
+
// of the floor.
|
|
345
|
+
nextTrust = makeTrust("trusted_contact", { status: "active" });
|
|
346
|
+
pendingChallenge = { id: "vs_1" };
|
|
347
|
+
const { outcome } = route("no_one");
|
|
348
|
+
expect(outcome.action).toBe("verification");
|
|
349
|
+
});
|
|
350
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gateway-backed channel admission policy reader.
|
|
3
|
+
*
|
|
4
|
+
* Reads a channel's resolved admission floor from the gateway via the
|
|
5
|
+
* `get_channel_admission_policy` IPC route. Admission must never break call
|
|
6
|
+
* setup, so this reader FAILS OPEN: any transport failure, malformed shape,
|
|
7
|
+
* or thrown error resolves to `null` (= admit, no enforcement). A policy is
|
|
8
|
+
* only returned when the gateway responds with `{ policy: <valid policy> }`.
|
|
9
|
+
*
|
|
10
|
+
* Caches per channelType with a short TTL, mirroring the conversation
|
|
11
|
+
* threshold cache in `../permissions/gateway-threshold-reader.ts`. Only the
|
|
12
|
+
* result of a SUCCESSFUL gateway round-trip is cached (a valid policy, or an
|
|
13
|
+
* explicit `{ policy: null }` "no enforcement" answer); a transport failure /
|
|
14
|
+
* throw / malformed shape fails open to `null` WITHOUT caching, so a recovered
|
|
15
|
+
* gateway is re-consulted on the next call setup rather than skipping the
|
|
16
|
+
* admission floor for the rest of the TTL.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
type AdmissionPolicy,
|
|
21
|
+
isAdmissionPolicy,
|
|
22
|
+
} from "@vellumai/gateway-client";
|
|
23
|
+
|
|
24
|
+
import type { ChannelId } from "../channels/types.js";
|
|
25
|
+
import { ipcCall } from "../ipc/gateway-client.js";
|
|
26
|
+
|
|
27
|
+
const CACHE_TTL_MS = 5_000;
|
|
28
|
+
|
|
29
|
+
// Short IPC timeout so admission fails open PROMPTLY: a gateway that accepts
|
|
30
|
+
// the socket but stalls must never delay a live call handshake. 1s is generous
|
|
31
|
+
// under normal conditions yet far below ipcCall's 5s default.
|
|
32
|
+
const ADMISSION_IPC_TIMEOUT_MS = 1_000;
|
|
33
|
+
|
|
34
|
+
const cache = new Map<
|
|
35
|
+
ChannelId,
|
|
36
|
+
{ policy: AdmissionPolicy | null; timestamp: number }
|
|
37
|
+
>();
|
|
38
|
+
|
|
39
|
+
/** Test-only: clear the policy cache. */
|
|
40
|
+
export function _clearCacheForTesting(): void {
|
|
41
|
+
cache.clear();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Outcome of a single gateway fetch. Only a SUCCESSFUL round-trip (a valid
|
|
46
|
+
* policy, or an explicit `{ policy: null }` "no enforcement" answer) is
|
|
47
|
+
* cacheable. A transport failure, thrown error, or malformed shape is NOT
|
|
48
|
+
* cached so a recovered gateway is re-consulted on the next call setup.
|
|
49
|
+
*/
|
|
50
|
+
type FetchResult =
|
|
51
|
+
| { ok: true; policy: AdmissionPolicy | null }
|
|
52
|
+
| { ok: false };
|
|
53
|
+
|
|
54
|
+
async function fetchAdmissionPolicy(
|
|
55
|
+
channelType: ChannelId,
|
|
56
|
+
): Promise<FetchResult> {
|
|
57
|
+
try {
|
|
58
|
+
// ipcCall() returns undefined on transport failure (socket not found,
|
|
59
|
+
// timeout, parse error). That, a throw, or a malformed shape is a FAILURE:
|
|
60
|
+
// fail open without caching so the next setup re-attempts the IPC.
|
|
61
|
+
const result = (await ipcCall(
|
|
62
|
+
"get_channel_admission_policy",
|
|
63
|
+
{ channelType },
|
|
64
|
+
ADMISSION_IPC_TIMEOUT_MS,
|
|
65
|
+
)) as { policy?: unknown } | null | undefined;
|
|
66
|
+
|
|
67
|
+
if (result === undefined) return { ok: false };
|
|
68
|
+
if (result && isAdmissionPolicy(result.policy)) {
|
|
69
|
+
return { ok: true, policy: result.policy };
|
|
70
|
+
}
|
|
71
|
+
// Explicit "no enforcement" — the gateway successfully answered. Cacheable.
|
|
72
|
+
if (result && result.policy === null) return { ok: true, policy: null };
|
|
73
|
+
// Anything else (missing/invalid policy field) is a malformed shape.
|
|
74
|
+
return { ok: false };
|
|
75
|
+
} catch {
|
|
76
|
+
return { ok: false };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Resolve a channel's admission policy from the gateway, or `null` on any
|
|
82
|
+
* failure / absence. `null` means admit with no enforcement.
|
|
83
|
+
*
|
|
84
|
+
* Always fails open to `null` on failure, but only caches the result of a
|
|
85
|
+
* SUCCESSFUL gateway round-trip — a transient hiccup must not skip the
|
|
86
|
+
* admission floor for the rest of the TTL.
|
|
87
|
+
*/
|
|
88
|
+
export async function getChannelAdmissionPolicy(
|
|
89
|
+
channelType: ChannelId,
|
|
90
|
+
): Promise<AdmissionPolicy | null> {
|
|
91
|
+
const cached = cache.get(channelType);
|
|
92
|
+
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
93
|
+
return cached.policy;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const result = await fetchAdmissionPolicy(channelType);
|
|
97
|
+
if (!result.ok) {
|
|
98
|
+
// Fail open WITHOUT caching so a recovered gateway is re-consulted next time.
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
cache.set(channelType, { policy: result.policy, timestamp: Date.now() });
|
|
103
|
+
return result.policy;
|
|
104
|
+
}
|
|
@@ -50,6 +50,7 @@ import {
|
|
|
50
50
|
recordCallEvent,
|
|
51
51
|
updateCallSession,
|
|
52
52
|
} from "./call-store.js";
|
|
53
|
+
import { getChannelAdmissionPolicy } from "./channel-admission-reader.js";
|
|
53
54
|
import { finalizeCall } from "./finalize-call.js";
|
|
54
55
|
import { MediaStreamOutput } from "./media-stream-output.js";
|
|
55
56
|
import { parseMediaStreamFrame } from "./media-stream-parser.js";
|
|
@@ -90,6 +91,18 @@ export class MediaStreamCallSession {
|
|
|
90
91
|
private streamSid: string | null = null;
|
|
91
92
|
private callSid: string | null = null;
|
|
92
93
|
private disposed = false;
|
|
94
|
+
// True from the synchronous start of handleStart until setup routing
|
|
95
|
+
// completes. Resolving the admission floor yields the event loop, so
|
|
96
|
+
// transcripts/barge-in arriving in this window are ignored — a denied or
|
|
97
|
+
// not-yet-authorized caller's speech is never persisted before the floor
|
|
98
|
+
// and trust ACL are applied. The controller-existence checks below also
|
|
99
|
+
// gate persistence, but this guard makes the intent explicit and covers
|
|
100
|
+
// the brief async window before the controller is created.
|
|
101
|
+
private setupRouting = false;
|
|
102
|
+
// Resolves when the async setup routing kicked off by the `start` frame
|
|
103
|
+
// settles. Exposed via whenSetupSettled() for deterministic test awaiting,
|
|
104
|
+
// since handleStart now yields on the admission-policy read.
|
|
105
|
+
private setupSettled: Promise<void> = Promise.resolve();
|
|
93
106
|
|
|
94
107
|
// ── Operational diagnostics counters ──────────────────────────────
|
|
95
108
|
/** Number of barge-in attempts that were accepted (assistant was speaking). */
|
|
@@ -141,6 +154,14 @@ export class MediaStreamCallSession {
|
|
|
141
154
|
return this.controller;
|
|
142
155
|
}
|
|
143
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Resolves once the async setup routing started by the `start` frame has
|
|
159
|
+
* settled. Test-only convenience — production code does not await this.
|
|
160
|
+
*/
|
|
161
|
+
whenSetupSettled(): Promise<void> {
|
|
162
|
+
return this.setupSettled;
|
|
163
|
+
}
|
|
164
|
+
|
|
144
165
|
/**
|
|
145
166
|
* Feed a raw WebSocket message into the session.
|
|
146
167
|
*
|
|
@@ -154,7 +175,16 @@ export class MediaStreamCallSession {
|
|
|
154
175
|
// Intercept `start` to bootstrap the session before forwarding.
|
|
155
176
|
const parseResult = parseMediaStreamFrame(raw);
|
|
156
177
|
if (parseResult.ok && parseResult.event.event === "start") {
|
|
157
|
-
|
|
178
|
+
// handleStart resolves the admission floor asynchronously; the
|
|
179
|
+
// setupRouting guard set synchronously at its top ensures inbound
|
|
180
|
+
// frames forwarded below are ignored until routing completes.
|
|
181
|
+
this.setupSettled = this.handleStart(parseResult.event).catch((err) => {
|
|
182
|
+
log.error(
|
|
183
|
+
{ err, callSessionId: this.callSessionId },
|
|
184
|
+
"Media-stream setup routing failed",
|
|
185
|
+
);
|
|
186
|
+
this.setupRouting = false;
|
|
187
|
+
});
|
|
158
188
|
}
|
|
159
189
|
|
|
160
190
|
// Always forward to the STT session (it handles all event types).
|
|
@@ -296,7 +326,12 @@ export class MediaStreamCallSession {
|
|
|
296
326
|
|
|
297
327
|
// ── Internal: media-stream event handlers ─────────────────────────
|
|
298
328
|
|
|
299
|
-
private handleStart(event: MediaStreamStartEvent): void {
|
|
329
|
+
private async handleStart(event: MediaStreamStartEvent): Promise<void> {
|
|
330
|
+
// Enter the setup-pending window synchronously, before any await. The
|
|
331
|
+
// admission-policy read below yields the event loop, so frames forwarded
|
|
332
|
+
// to the STT session can produce transcripts/barge-in before routing
|
|
333
|
+
// completes — those are dropped while setupRouting is true.
|
|
334
|
+
this.setupRouting = true;
|
|
300
335
|
this.streamSid = event.streamSid;
|
|
301
336
|
this.callSid = event.start.callSid;
|
|
302
337
|
|
|
@@ -337,12 +372,32 @@ export class MediaStreamCallSession {
|
|
|
337
372
|
const from = session?.fromNumber ?? "";
|
|
338
373
|
const to = session?.toNumber ?? "";
|
|
339
374
|
|
|
375
|
+
// Resolve the phone channel's inbound admission floor so the trust floor
|
|
376
|
+
// (e.g. guardian_only) is enforced on this transport too — not just the
|
|
377
|
+
// gateway webhook's no_one kill switch. The reader fails open to `null`
|
|
378
|
+
// by contract, so a transport hiccup admits the caller.
|
|
379
|
+
const admissionPolicy = await getChannelAdmissionPolicy("phone");
|
|
380
|
+
|
|
381
|
+
// The admission-policy read above yields the event loop; if Twilio closed
|
|
382
|
+
// the WebSocket meanwhile, the close handler will have called destroy().
|
|
383
|
+
// Abort setup so we don't create a controller or speak on a disposed
|
|
384
|
+
// session.
|
|
385
|
+
if (this.disposed) {
|
|
386
|
+
log.info(
|
|
387
|
+
{ callSessionId: this.callSessionId },
|
|
388
|
+
"Media-stream session disposed during admission read — aborting setup",
|
|
389
|
+
);
|
|
390
|
+
this.setupRouting = false;
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
|
|
340
394
|
const { outcome, resolved } = routeSetup({
|
|
341
395
|
callSessionId: this.callSessionId,
|
|
342
396
|
session: session ?? null,
|
|
343
397
|
from,
|
|
344
398
|
to,
|
|
345
399
|
customParameters: event.start.customParameters,
|
|
400
|
+
admissionPolicy,
|
|
346
401
|
});
|
|
347
402
|
|
|
348
403
|
log.info(
|
|
@@ -377,6 +432,10 @@ export class MediaStreamCallSession {
|
|
|
377
432
|
);
|
|
378
433
|
registerCallController(this.callSessionId, this.controller);
|
|
379
434
|
|
|
435
|
+
// Routing/ACL cleared — leave the setup-pending window so subsequent
|
|
436
|
+
// transcripts and barge-in are handled normally.
|
|
437
|
+
this.setupRouting = false;
|
|
438
|
+
|
|
380
439
|
// Fire the initial greeting.
|
|
381
440
|
this.controller.startInitialGreeting().catch((err) => {
|
|
382
441
|
log.error(
|
|
@@ -525,6 +584,18 @@ export class MediaStreamCallSession {
|
|
|
525
584
|
|
|
526
585
|
private handleTranscriptFinal(text: string, _durationMs: number): void {
|
|
527
586
|
if (!text.trim()) return;
|
|
587
|
+
|
|
588
|
+
// Drop transcripts arriving while setup routing is still pending so a
|
|
589
|
+
// not-yet-authorized / floor-denied caller's speech is never persisted
|
|
590
|
+
// before the admission floor and trust ACL are applied.
|
|
591
|
+
if (this.setupRouting) {
|
|
592
|
+
log.debug(
|
|
593
|
+
{ callSessionId: this.callSessionId },
|
|
594
|
+
"Transcript received during setup routing — dropping",
|
|
595
|
+
);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
|
|
528
599
|
this.transcriptFinalsProduced++;
|
|
529
600
|
|
|
530
601
|
if (!this.controller) {
|
|
@@ -560,6 +631,17 @@ export class MediaStreamCallSession {
|
|
|
560
631
|
}
|
|
561
632
|
|
|
562
633
|
private handleDtmf(digit: string): void {
|
|
634
|
+
// Drop DTMF arriving while setup routing is still pending so a
|
|
635
|
+
// not-yet-authorized / floor-denied caller's digit is never persisted
|
|
636
|
+
// before the admission floor and trust ACL are applied.
|
|
637
|
+
if (this.setupRouting) {
|
|
638
|
+
log.debug(
|
|
639
|
+
{ callSessionId: this.callSessionId, digit },
|
|
640
|
+
"DTMF received during setup routing — dropping",
|
|
641
|
+
);
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
|
|
563
645
|
log.info(
|
|
564
646
|
{ callSessionId: this.callSessionId, digit },
|
|
565
647
|
"DTMF digit received on media-stream",
|