@jambonz/mrf 0.1.9 → 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 +82 -5
- 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
|
|
@@ -132,8 +152,12 @@ class Endpoint extends EventEmitter {
|
|
|
132
152
|
}
|
|
133
153
|
/* uuid_<vendor>_s2s: the llm task interface. args arrive
|
|
134
154
|
* '^^|<uuid>|<command>[|...]'; map to s2s.* commands. ultravox passes
|
|
135
|
-
* only host/path (a pre-authenticated joinUrl — no authType/apiKey)
|
|
136
|
-
|
|
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);
|
|
137
161
|
if (s2) {
|
|
138
162
|
const vendor = s2[1];
|
|
139
163
|
const raw = Array.isArray(args) ? args.join('|') : String(args || '');
|
|
@@ -142,9 +166,29 @@ class Endpoint extends EventEmitter {
|
|
|
142
166
|
try {
|
|
143
167
|
switch (cmd) {
|
|
144
168
|
case 'session.create':
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
+
}
|
|
148
192
|
return { body: '+OK' };
|
|
149
193
|
case 'client.event': {
|
|
150
194
|
const event = JSON.parse(parts.slice(2).join('|'));
|
|
@@ -247,6 +291,15 @@ class Endpoint extends EventEmitter {
|
|
|
247
291
|
|
|
248
292
|
async set(param, value) {
|
|
249
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
|
+
}
|
|
250
303
|
for (const [k, v] of Object.entries(obj)) {
|
|
251
304
|
if (v === '' || v === null || v === undefined) delete this._channelVars[k];
|
|
252
305
|
else this._channelVars[k] = String(v);
|
|
@@ -273,6 +326,23 @@ class Endpoint extends EventEmitter {
|
|
|
273
326
|
return this.set(param, value);
|
|
274
327
|
}
|
|
275
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
|
+
|
|
276
346
|
async modify(sdp) {
|
|
277
347
|
const { localSdp } = await this._request('endpoint.modify', { remoteSdp: sdp });
|
|
278
348
|
this.remote = parseSdp(sdp);
|
|
@@ -376,6 +446,13 @@ class Endpoint extends EventEmitter {
|
|
|
376
446
|
sampleRate: opts.bidirectionalAudio.sampleRate || 0
|
|
377
447
|
};
|
|
378
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 || ''};
|
|
379
456
|
await this._request('fork.start', data);
|
|
380
457
|
return this;
|
|
381
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
|
+
});
|