@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 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
- /* uuid_openai_s2s / uuid_voice_agent_s2s: the llm task interface.
134
- * args arrive '^^|<uuid>|<command>[|...]'; map to s2s.* commands. */
135
- const s2 = /^uuid_(openai|voice_agent)_s2s$/.exec(command);
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
- await this._request('s2s.start', {
145
- vendor, host: parts[2], path: parts[3], authType: parts[4], apiKey: parts[5]
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
  }
@@ -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.8",
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
+ });