@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 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
- const s2 = /^uuid_(openai|voice_agent|ultravox)_s2s$/.exec(command);
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
- await this._request('s2s.start', {
146
- vendor, host: parts[2], path: parts[3], authType: parts[4], apiKey: parts[5]
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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jambonz/mrf",
3
- "version": "0.1.9",
3
+ "version": "0.1.14",
4
4
  "main": "index.js",
5
5
  "scripts": {
6
6
  "test": "node --test",
@@ -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
+ });