@jambonz/mrf 0.1.8 → 0.1.14
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/lib/endpoint.js +84 -6
- package/lib/mediaserver.js +5 -0
- package/package.json +1 -1
- package/test/licensing-conduit.test.js +72 -0
package/lib/endpoint.js
CHANGED
|
@@ -75,6 +75,26 @@ class Endpoint extends EventEmitter {
|
|
|
75
75
|
/** fsmrf api() passthrough: translate the FS api commands in use. */
|
|
76
76
|
async api(command, args) {
|
|
77
77
|
const arr = Array.isArray(args) ? args : (args ? String(args).split(' ') : []);
|
|
78
|
+
/* uuid_jambonz_licensing: outbound session-token (token-1) minting. FS's
|
|
79
|
+
* mod_jambonz_token exposed 'generate-session-token <uuid> <callId>'; route
|
|
80
|
+
* it to the mediajam licensing.generate-token control command and stash
|
|
81
|
+
* token-1 so a later ep.set('jambonz_session_token_2', …) can be validated
|
|
82
|
+
* against it. Returns FS's '+OK <token>' shape so the feature-server's
|
|
83
|
+
* existing parser is unchanged; on an unlicensed binary the control command
|
|
84
|
+
* errors and we return '-ERR' (the +OK check then simply skips the header). */
|
|
85
|
+
if (command === 'uuid_jambonz_licensing') {
|
|
86
|
+
if (arr[0] === 'generate-session-token') {
|
|
87
|
+
const callId = arr[2];
|
|
88
|
+
try {
|
|
89
|
+
const res = await this._request('licensing.generate-token', { callId });
|
|
90
|
+
this._sessionToken1 = res?.token || '';
|
|
91
|
+
return { body: this._sessionToken1 ? `+OK ${this._sessionToken1}` : '-ERR not licensed' };
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return { body: `-ERR ${err.message}` };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return { body: '-ERR unsupported licensing command' };
|
|
97
|
+
}
|
|
78
98
|
/* uuid_<vendor>_noise_isolation / uuid_<vendor>_turn_taking: the
|
|
79
99
|
* noise-isolation and turn-taking task interfaces (space-delimited:
|
|
80
100
|
* '<uuid> start <direction> [level] [model]' / '<uuid> start
|
|
@@ -130,9 +150,14 @@ class Endpoint extends EventEmitter {
|
|
|
130
150
|
return { body: `-ERR ${err.message}` };
|
|
131
151
|
}
|
|
132
152
|
}
|
|
133
|
-
/*
|
|
134
|
-
*
|
|
135
|
-
|
|
153
|
+
/* uuid_<vendor>_s2s: the llm task interface. args arrive
|
|
154
|
+
* '^^|<uuid>|<command>[|...]'; map to s2s.* commands. ultravox passes
|
|
155
|
+
* only host/path (a pre-authenticated joinUrl — no authType/apiKey);
|
|
156
|
+
* elevenlabs prefixes in/out sample rates before host/path (the agent
|
|
157
|
+
* speaks fixed-rate pcm independent of the endpoint codec); assemblyai
|
|
158
|
+
* passes host/path/apiKey with no authType slot; google passes apiKey
|
|
159
|
+
* first then optional host/path (key rides a ?key= query param). */
|
|
160
|
+
const s2 = /^uuid_(openai|voice_agent|ultravox|elevenlabs|assemblyai|google)_s2s$/.exec(command);
|
|
136
161
|
if (s2) {
|
|
137
162
|
const vendor = s2[1];
|
|
138
163
|
const raw = Array.isArray(args) ? args.join('|') : String(args || '');
|
|
@@ -141,9 +166,29 @@ class Endpoint extends EventEmitter {
|
|
|
141
166
|
try {
|
|
142
167
|
switch (cmd) {
|
|
143
168
|
case 'session.create':
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
169
|
+
if (vendor === 'elevenlabs') {
|
|
170
|
+
/* args: inRate, outRate, host, path[, 'no_initial_config'] */
|
|
171
|
+
await this._request('s2s.start', {
|
|
172
|
+
vendor,
|
|
173
|
+
inRate: parseInt(parts[2], 10) || 0,
|
|
174
|
+
outRate: parseInt(parts[3], 10) || 0,
|
|
175
|
+
host: parts[4], path: parts[5]
|
|
176
|
+
});
|
|
177
|
+
} else if (vendor === 'assemblyai') {
|
|
178
|
+
/* args: host, path, apiKey (no authType slot) */
|
|
179
|
+
await this._request('s2s.start', {
|
|
180
|
+
vendor, host: parts[2], path: parts[3], apiKey: parts[4]
|
|
181
|
+
});
|
|
182
|
+
} else if (vendor === 'google') {
|
|
183
|
+
/* args: apiKey[, host[, path]] — key first, host/path optional */
|
|
184
|
+
await this._request('s2s.start', {
|
|
185
|
+
vendor, apiKey: parts[2], host: parts[3], path: parts[4]
|
|
186
|
+
});
|
|
187
|
+
} else {
|
|
188
|
+
await this._request('s2s.start', {
|
|
189
|
+
vendor, host: parts[2], path: parts[3], authType: parts[4], apiKey: parts[5]
|
|
190
|
+
});
|
|
191
|
+
}
|
|
147
192
|
return { body: '+OK' };
|
|
148
193
|
case 'client.event': {
|
|
149
194
|
const event = JSON.parse(parts.slice(2).join('|'));
|
|
@@ -246,6 +291,15 @@ class Endpoint extends EventEmitter {
|
|
|
246
291
|
|
|
247
292
|
async set(param, value) {
|
|
248
293
|
const obj = typeof param === 'object' ? param : { [param]: value };
|
|
294
|
+
/* licensing: the SBC returns its token-2 as this channel var. Validate it
|
|
295
|
+
* against the token-1 we minted (FS parity: mod_jambonz_token). A bad
|
|
296
|
+
* token-2 means the call egressed through an unlicensed drachtio — tear the
|
|
297
|
+
* endpoint down (the media-tier backstop; the SBC is the primary gate). */
|
|
298
|
+
if ('jambonz_session_token_2' in obj) {
|
|
299
|
+
const token2 = obj.jambonz_session_token_2;
|
|
300
|
+
delete obj.jambonz_session_token_2;
|
|
301
|
+
await this._validateSessionToken2(token2);
|
|
302
|
+
}
|
|
249
303
|
for (const [k, v] of Object.entries(obj)) {
|
|
250
304
|
if (v === '' || v === null || v === undefined) delete this._channelVars[k];
|
|
251
305
|
else this._channelVars[k] = String(v);
|
|
@@ -272,6 +326,23 @@ class Endpoint extends EventEmitter {
|
|
|
272
326
|
return this.set(param, value);
|
|
273
327
|
}
|
|
274
328
|
|
|
329
|
+
/* Validate the SBC's outbound token-2 against the stashed token-1. No-op when
|
|
330
|
+
* no token-1 was minted (unlicensed binary: licensing.generate-token returned
|
|
331
|
+
* nothing). On failure, signal an unexpected teardown ('destroy') so the
|
|
332
|
+
* feature-server drops the call — its media-timeout path already handles it. */
|
|
333
|
+
async _validateSessionToken2(token2) {
|
|
334
|
+
if (!this._sessionToken1 || !token2) return;
|
|
335
|
+
try {
|
|
336
|
+
await this._request('licensing.validate-token-2', { token1: this._sessionToken1, token2 });
|
|
337
|
+
} catch (err) {
|
|
338
|
+
if (this.connected) {
|
|
339
|
+
this.connected = false;
|
|
340
|
+
this.emit('destroy', { reason: 'license-violation', message: err.message });
|
|
341
|
+
this._request('endpoint.destroy', {}).catch(() => {});
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
275
346
|
async modify(sdp) {
|
|
276
347
|
const { localSdp } = await this._request('endpoint.modify', { remoteSdp: sdp });
|
|
277
348
|
this.remote = parseSdp(sdp);
|
|
@@ -375,6 +446,13 @@ class Endpoint extends EventEmitter {
|
|
|
375
446
|
sampleRate: opts.bidirectionalAudio.sampleRate || 0
|
|
376
447
|
};
|
|
377
448
|
}
|
|
449
|
+
/* HTTP Basic auth for the websocket upgrade. The feature-server sets it
|
|
450
|
+
* as MOD_AUDIO_BASIC_AUTH_* channel vars (ep.set) before forkAudioStart,
|
|
451
|
+
* exactly as mod_audio_fork consumed them; forward as wsAuth so mediajam
|
|
452
|
+
* adds the Authorization header. The recording server requires this. */
|
|
453
|
+
const username = opts.wsAuth?.username ?? this._channelVars.MOD_AUDIO_BASIC_AUTH_USERNAME;
|
|
454
|
+
const password = opts.wsAuth?.password ?? this._channelVars.MOD_AUDIO_BASIC_AUTH_PASSWORD;
|
|
455
|
+
if (username) data.wsAuth = {username, password: password || ''};
|
|
378
456
|
await this._request('fork.start', data);
|
|
379
457
|
return this;
|
|
380
458
|
}
|
package/lib/mediaserver.js
CHANGED
|
@@ -137,6 +137,11 @@ class MediaServer extends EventEmitter {
|
|
|
137
137
|
if (opts.remoteSdp) data.remoteSdp = opts.remoteSdp;
|
|
138
138
|
if (opts.codecs) data.codecs = Array.isArray(opts.codecs) ? opts.codecs : [opts.codecs];
|
|
139
139
|
if (opts.tags) data.tags = opts.tags;
|
|
140
|
+
/* licensing conduit: {callId, token} on an inbound call (mediajam validates
|
|
141
|
+
* the session token before allocating media), or {reason:'anchor-media'} to
|
|
142
|
+
* skip the token check while still enforcing the session cap (re-anchors and
|
|
143
|
+
* jambonz-originated outbound legs, which carry no inbound token). */
|
|
144
|
+
if (opts.license) data.license = opts.license;
|
|
140
145
|
const options = {};
|
|
141
146
|
if (opts.media_timeout) options.mediaTimeoutMs = parseInt(opts.media_timeout, 10);
|
|
142
147
|
if (opts.media_hold_timeout) options.holdTimeoutMs = parseInt(opts.media_hold_timeout, 10);
|
package/package.json
CHANGED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Unit tests for the licensing conduit translation in the Endpoint adapter:
|
|
2
|
+
// the FS uuid_jambonz_licensing api() and the jambonz_session_token_2 set()
|
|
3
|
+
// are routed to mediajam's licensing.* control commands. The control transport
|
|
4
|
+
// is mocked so these run without a server binary.
|
|
5
|
+
|
|
6
|
+
const { test } = require('node:test');
|
|
7
|
+
const assert = require('node:assert');
|
|
8
|
+
const Endpoint = require('../lib/endpoint');
|
|
9
|
+
|
|
10
|
+
// Build an Endpoint whose control requests are captured in `calls`. `handler`
|
|
11
|
+
// returns the response (or throws) per command.
|
|
12
|
+
function makeEp(handler) {
|
|
13
|
+
const calls = [];
|
|
14
|
+
const ms = {
|
|
15
|
+
_connection: {
|
|
16
|
+
request: async (cmd, uuid, data) => {
|
|
17
|
+
calls.push({ cmd, data });
|
|
18
|
+
return handler ? handler(cmd, data) : {};
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
conn: {}
|
|
22
|
+
};
|
|
23
|
+
return { ep: new Endpoint(ms, 'ep-1', null, null), calls };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
test('generate-session-token mints token-1 (FS +OK shape) and stashes it', async () => {
|
|
27
|
+
const { ep, calls } = makeEp((cmd) => (cmd === 'licensing.generate-token' ? { token: 'TOK1' } : {}));
|
|
28
|
+
const res = await ep.api('uuid_jambonz_licensing', 'generate-session-token ep-1 call-123');
|
|
29
|
+
assert.deepStrictEqual(calls[0], { cmd: 'licensing.generate-token', data: { callId: 'call-123' } });
|
|
30
|
+
assert.strictEqual(res.body, '+OK TOK1');
|
|
31
|
+
assert.strictEqual(ep._sessionToken1, 'TOK1');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('unlicensed binary: generate returns -ERR (no token), feature-server then skips the header', async () => {
|
|
35
|
+
const { ep } = makeEp(() => { throw new Error('not licensed'); });
|
|
36
|
+
const res = await ep.api('uuid_jambonz_licensing', 'generate-session-token ep-1 call-9');
|
|
37
|
+
assert.match(res.body, /^-ERR/);
|
|
38
|
+
assert.ok(!ep._sessionToken1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('token-2 is validated against the stashed token-1', async () => {
|
|
42
|
+
const { ep, calls } = makeEp((cmd) => (cmd === 'licensing.generate-token' ? { token: 'TOK1' } : { valid: true }));
|
|
43
|
+
await ep.api('uuid_jambonz_licensing', 'generate-session-token ep-1 call-123');
|
|
44
|
+
await ep.set('jambonz_session_token_2', 'TOK2');
|
|
45
|
+
const v = calls.find((c) => c.cmd === 'licensing.validate-token-2');
|
|
46
|
+
assert.deepStrictEqual(v.data, { token1: 'TOK1', token2: 'TOK2' });
|
|
47
|
+
assert.strictEqual(ep.connected, true);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('invalid token-2 tears the endpoint down (destroy)', async () => {
|
|
51
|
+
const { ep } = makeEp((cmd) => {
|
|
52
|
+
if (cmd === 'licensing.generate-token') return { token: 'TOK1' };
|
|
53
|
+
if (cmd === 'licensing.validate-token-2') throw new Error('session token 2 invalid');
|
|
54
|
+
return {};
|
|
55
|
+
});
|
|
56
|
+
await ep.api('uuid_jambonz_licensing', 'generate-session-token ep-1 call-123');
|
|
57
|
+
let destroyed = null;
|
|
58
|
+
ep.on('destroy', (evt) => { destroyed = evt; });
|
|
59
|
+
await ep.set('jambonz_session_token_2', 'BAD');
|
|
60
|
+
assert.strictEqual(ep.connected, false);
|
|
61
|
+
assert.strictEqual(destroyed.reason, 'license-violation');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('token-2 with no token-1 (unlicensed) is a no-op — no validate call, no teardown', async () => {
|
|
65
|
+
const { ep, calls } = makeEp(() => { throw new Error('should not be called'); });
|
|
66
|
+
let destroyed = false;
|
|
67
|
+
ep.on('destroy', () => { destroyed = true; });
|
|
68
|
+
await ep.set('jambonz_session_token_2', 'X');
|
|
69
|
+
assert.ok(!calls.find((c) => c.cmd === 'licensing.validate-token-2'));
|
|
70
|
+
assert.strictEqual(destroyed, false);
|
|
71
|
+
assert.strictEqual(ep.connected, true);
|
|
72
|
+
});
|