@zero-server/webrtc 0.9.7

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.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +37 -0
  3. package/index.d.ts +2 -0
  4. package/index.js +53 -0
  5. package/lib/auth/index.js +1 -0
  6. package/lib/debug.js +372 -0
  7. package/lib/errors.js +1 -0
  8. package/lib/middleware/index.js +1 -0
  9. package/lib/observe/index.js +1 -0
  10. package/lib/webrtc/bot.js +361 -0
  11. package/lib/webrtc/cli.js +182 -0
  12. package/lib/webrtc/cluster.js +350 -0
  13. package/lib/webrtc/e2ee.js +282 -0
  14. package/lib/webrtc/ice.js +370 -0
  15. package/lib/webrtc/index.js +132 -0
  16. package/lib/webrtc/joinToken.js +116 -0
  17. package/lib/webrtc/observe.js +229 -0
  18. package/lib/webrtc/peer.js +116 -0
  19. package/lib/webrtc/room.js +171 -0
  20. package/lib/webrtc/sdp.js +508 -0
  21. package/lib/webrtc/sfu/index.js +201 -0
  22. package/lib/webrtc/sfu/livekit.js +301 -0
  23. package/lib/webrtc/sfu/mediasoup.js +317 -0
  24. package/lib/webrtc/sfu/memory.js +204 -0
  25. package/lib/webrtc/signaling.js +546 -0
  26. package/lib/webrtc/stun.js +492 -0
  27. package/lib/webrtc/turn/codec.js +370 -0
  28. package/lib/webrtc/turn/credentials.js +141 -0
  29. package/lib/webrtc/turn/server.js +633 -0
  30. package/lib/ws/index.js +1 -0
  31. package/package.json +62 -0
  32. package/types/app.d.ts +223 -0
  33. package/types/auth.d.ts +520 -0
  34. package/types/body.d.ts +14 -0
  35. package/types/cli.d.ts +2 -0
  36. package/types/cluster.d.ts +75 -0
  37. package/types/env.d.ts +80 -0
  38. package/types/errors.d.ts +316 -0
  39. package/types/fetch.d.ts +43 -0
  40. package/types/grpc.d.ts +432 -0
  41. package/types/index.d.ts +396 -0
  42. package/types/lifecycle.d.ts +60 -0
  43. package/types/middleware.d.ts +320 -0
  44. package/types/observe.d.ts +304 -0
  45. package/types/orm.d.ts +1887 -0
  46. package/types/request.d.ts +109 -0
  47. package/types/response.d.ts +157 -0
  48. package/types/router.d.ts +78 -0
  49. package/types/sse.d.ts +78 -0
  50. package/types/webrtc.d.ts +501 -0
  51. package/types/websocket.d.ts +126 -0
@@ -0,0 +1,370 @@
1
+ /**
2
+ * @module webrtc/ice
3
+ * @description Zero-dependency ICE candidate parser, serializer, and address
4
+ * classifiers (private / loopback / link-local / mDNS), plus a
5
+ * `filterCandidates` helper used by `SignalingHub` to enforce
6
+ * privacy-preserving policies on relayed offers/answers.
7
+ *
8
+ * Candidate grammar follows RFC 8839 §5.1 / RFC 5245 §15.1:
9
+ *
10
+ * candidate-attribute = "candidate" ":" foundation SP component-id
11
+ * SP transport SP priority SP connection-address
12
+ * SP port SP cand-type [SP rel-addr] [SP rel-port]
13
+ * *(SP extension-att-name SP extension-att-value)
14
+ *
15
+ * @see https://datatracker.ietf.org/doc/html/rfc8839
16
+ * @see https://datatracker.ietf.org/doc/html/rfc5245
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ const { IceError } = require('../errors');
22
+
23
+ // -- Constants -----------------------------------------------------
24
+
25
+ /**
26
+ * Recognised ICE candidate types (RFC 5245).
27
+ * @type {ReadonlyArray<string>}
28
+ */
29
+ const CANDIDATE_TYPES = Object.freeze(['host', 'srflx', 'prflx', 'relay']);
30
+
31
+ /**
32
+ * Recognised TCP candidate types (RFC 6544 §4.5).
33
+ * @type {ReadonlyArray<string>}
34
+ */
35
+ const TCP_TYPES = Object.freeze(['active', 'passive', 'so']);
36
+
37
+ // -- Public types --------------------------------------------------
38
+
39
+ /**
40
+ * @typedef {object} IceCandidate
41
+ * @property {string} foundation
42
+ * @property {number} component
43
+ * @property {string} transport - 'udp' or 'tcp' (lowercased).
44
+ * @property {number} priority
45
+ * @property {string} address - IPv4, IPv6, or mDNS hostname.
46
+ * @property {number} port
47
+ * @property {string} type - One of CANDIDATE_TYPES.
48
+ * @property {string} [relatedAddress] - From `raddr`.
49
+ * @property {number} [relatedPort] - From `rport`.
50
+ * @property {string} [tcpType] - From `tcptype` (active/passive/so).
51
+ * @property {Object<string,string>} extensions - All other key/value pairs, insertion-ordered.
52
+ */
53
+
54
+ // =================================================================
55
+ // Parser
56
+ // =================================================================
57
+
58
+ /**
59
+ * Parse a single ICE candidate line.
60
+ *
61
+ * Accepts inputs with or without the `a=` SDP-attribute prefix. Returns
62
+ * a plain object; throws `IceError` on any structural problem.
63
+ *
64
+ * @param {string} line - Candidate line, e.g.
65
+ * `candidate:842163049 1 udp 1677729535 192.168.1.5 50000 typ host`.
66
+ * @returns {IceCandidate} Parsed candidate.
67
+ * @throws {IceError} On malformed input.
68
+ *
69
+ * @example
70
+ * const c = parseCandidate('candidate:1 1 udp 2122194687 1.2.3.4 50001 typ srflx raddr 192.168.1.5 rport 50000');
71
+ * if (c.type === 'relay') { console.log('relay candidate'); }
72
+ *
73
+ * @section ICE & TURN
74
+ */
75
+ function parseCandidate(line)
76
+ {
77
+ if (typeof line !== 'string')
78
+ throw new IceError('parseCandidate: input must be a string');
79
+
80
+ let s = line.trim();
81
+ if (s.startsWith('a=')) s = s.slice(2);
82
+ if (!s.startsWith('candidate:'))
83
+ throw new IceError('parseCandidate: missing "candidate:" prefix', { candidate: line });
84
+ s = s.slice('candidate:'.length);
85
+
86
+ const tok = s.split(/\s+/);
87
+ if (tok.length < 8)
88
+ throw new IceError('parseCandidate: too few tokens', { candidate: line });
89
+
90
+ const [foundation, componentStr, transportRaw, priorityStr,
91
+ address, portStr, typKw, type, ...rest] = tok;
92
+
93
+ if (typKw !== 'typ')
94
+ throw new IceError('parseCandidate: expected "typ" keyword', { candidate: line });
95
+ if (!CANDIDATE_TYPES.includes(type))
96
+ throw new IceError(`parseCandidate: unknown type "${type}"`, { candidate: line });
97
+
98
+ const component = Number(componentStr);
99
+ const priority = Number(priorityStr);
100
+ const port = Number(portStr);
101
+ if (!Number.isInteger(component) || component < 0)
102
+ throw new IceError('parseCandidate: invalid component', { candidate: line });
103
+ if (!Number.isFinite(priority))
104
+ throw new IceError('parseCandidate: invalid priority', { candidate: line });
105
+ if (!Number.isInteger(port) || port < 0 || port > 65535)
106
+ throw new IceError('parseCandidate: invalid port', { candidate: line });
107
+
108
+ /** @type {IceCandidate} */
109
+ const out = {
110
+ foundation,
111
+ component,
112
+ transport: transportRaw.toLowerCase(),
113
+ priority,
114
+ address,
115
+ port,
116
+ type,
117
+ extensions: {},
118
+ };
119
+
120
+ // Walk remaining key/value pairs. raddr / rport / tcptype are lifted
121
+ // to named fields; everything else lands in `extensions` in input order.
122
+ for (let i = 0; i < rest.length - 1; i += 2)
123
+ {
124
+ const k = rest[i];
125
+ const v = rest[i + 1];
126
+ if (k === 'raddr') out.relatedAddress = v;
127
+ else if (k === 'rport') out.relatedPort = Number(v);
128
+ else if (k === 'tcptype') out.tcpType = v;
129
+ else out.extensions[k] = v;
130
+ }
131
+
132
+ return out;
133
+ }
134
+
135
+ // =================================================================
136
+ // Serializer
137
+ // =================================================================
138
+
139
+ /**
140
+ * Serialize a parsed candidate back to its canonical line format.
141
+ * Round-trips outputs of `parseCandidate` exactly, including the
142
+ * insertion order of `extensions`.
143
+ *
144
+ * @param {IceCandidate} c - Parsed candidate object.
145
+ * @returns {string} `candidate:...` line (no `a=` prefix).
146
+ * @throws {IceError} If required fields are missing.
147
+ *
148
+ * @example
149
+ * const out = stringifyCandidate(parseCandidate(line));
150
+ *
151
+ * @section ICE & TURN
152
+ */
153
+ function stringifyCandidate(c)
154
+ {
155
+ if (!c || typeof c !== 'object')
156
+ throw new IceError('stringifyCandidate: input must be an object');
157
+ const required = ['foundation', 'component', 'transport', 'priority', 'address', 'port', 'type'];
158
+ for (const k of required)
159
+ {
160
+ if (c[k] === undefined || c[k] === null)
161
+ throw new IceError(`stringifyCandidate: missing "${k}"`);
162
+ }
163
+
164
+ let s = `candidate:${c.foundation} ${c.component} ${c.transport} ${c.priority} ${c.address} ${c.port} typ ${c.type}`;
165
+ if (c.relatedAddress !== undefined) s += ` raddr ${c.relatedAddress}`;
166
+ if (c.relatedPort !== undefined) s += ` rport ${c.relatedPort}`;
167
+ if (c.tcpType !== undefined) s += ` tcptype ${c.tcpType}`;
168
+ if (c.extensions)
169
+ {
170
+ for (const [k, v] of Object.entries(c.extensions))
171
+ s += ` ${k} ${v}`;
172
+ }
173
+ return s;
174
+ }
175
+
176
+ // =================================================================
177
+ // Address classifiers
178
+ // =================================================================
179
+
180
+ /**
181
+ * Test whether the address looks like an IPv4 string.
182
+ * @private
183
+ */
184
+ function _isIPv4(addr)
185
+ {
186
+ if (typeof addr !== 'string') return false;
187
+ const m = addr.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
188
+ if (!m) return false;
189
+ for (let i = 1; i <= 4; i++) if (Number(m[i]) > 255) return false;
190
+ return true;
191
+ }
192
+
193
+ /**
194
+ * Test whether the address looks like an IPv6 string (very permissive).
195
+ * @private
196
+ */
197
+ function _isIPv6(addr)
198
+ {
199
+ if (typeof addr !== 'string') return false;
200
+ return addr.includes(':') && /^[0-9a-fA-F:]+$/.test(addr);
201
+ }
202
+
203
+ /**
204
+ * True for RFC 1918, RFC 6598 (CGNAT), and IPv6 ULA (RFC 4193) addresses.
205
+ *
206
+ * @param {string} addr - Address to classify.
207
+ * @returns {boolean}
208
+ *
209
+ * @section ICE & TURN
210
+ */
211
+ function isPrivateIp(addr)
212
+ {
213
+ if (_isIPv4(addr))
214
+ {
215
+ const [a, b] = addr.split('.').map(Number);
216
+ if (a === 10) return true;
217
+ if (a === 172 && b >= 16 && b <= 31) return true;
218
+ if (a === 192 && b === 168) return true;
219
+ if (a === 100 && b >= 64 && b <= 127) return true; // RFC 6598 CGNAT
220
+ return false;
221
+ }
222
+ if (_isIPv6(addr))
223
+ {
224
+ // fc00::/7 ULA
225
+ const head = addr.toLowerCase().split(':')[0];
226
+ if (head.length === 0) return false;
227
+ const n = parseInt(head, 16);
228
+ return (n & 0xfe00) === 0xfc00;
229
+ }
230
+ return false;
231
+ }
232
+
233
+ /**
234
+ * True for IPv4 127.0.0.0/8 and IPv6 ::1.
235
+ *
236
+ * @param {string} addr - Address to classify.
237
+ * @returns {boolean}
238
+ *
239
+ * @section ICE & TURN
240
+ */
241
+ function isLoopbackIp(addr)
242
+ {
243
+ if (_isIPv4(addr)) return addr.startsWith('127.');
244
+ if (_isIPv6(addr)) return addr === '::1' || /^0*:0*:0*:0*:0*:0*:0*:0*1$/.test(addr);
245
+ return false;
246
+ }
247
+
248
+ /**
249
+ * True for IPv4 169.254/16 and IPv6 fe80::/10.
250
+ *
251
+ * @param {string} addr - Address to classify.
252
+ * @returns {boolean}
253
+ *
254
+ * @section ICE & TURN
255
+ */
256
+ function isLinkLocalIp(addr)
257
+ {
258
+ if (_isIPv4(addr)) return addr.startsWith('169.254.');
259
+ if (_isIPv6(addr))
260
+ {
261
+ const head = addr.toLowerCase().split(':')[0];
262
+ if (head.length === 0) return false;
263
+ const n = parseInt(head, 16);
264
+ return (n & 0xffc0) === 0xfe80;
265
+ }
266
+ return false;
267
+ }
268
+
269
+ /**
270
+ * True for mDNS `.local` hostnames used by browsers to avoid leaking
271
+ * local IPs (Chrome's mDNS ICE candidates - RFC 8624 / draft-ietf-mmusic-mdns-ice-candidates).
272
+ *
273
+ * @param {string} host - Hostname to test.
274
+ * @returns {boolean}
275
+ *
276
+ * @section ICE & TURN
277
+ */
278
+ function isMdnsHostname(host)
279
+ {
280
+ if (typeof host !== 'string') return false;
281
+ if (_isIPv4(host) || _isIPv6(host)) return false;
282
+ return host.toLowerCase().endsWith('.local');
283
+ }
284
+
285
+ // =================================================================
286
+ // Policy filter
287
+ // =================================================================
288
+
289
+ /**
290
+ * @typedef {object} CandidateFilterPolicy
291
+ * @property {boolean} [blockPrivate=false] - Drop private / loopback / link-local addresses.
292
+ * @property {boolean} [blockMdns=false] - Drop `.local` (mDNS) hostnames.
293
+ * @property {boolean} [blockTcp=false] - Drop TCP-transport candidates.
294
+ * @property {ReadonlyArray<string>} [allowedTypes] - Whitelist of `type` values (host/srflx/prflx/relay).
295
+ * @property {number} [maxCandidates] - Cap the number of returned candidates.
296
+ * @property {(c:IceCandidate)=>boolean} [predicate] - Custom drop function (return false to drop).
297
+ */
298
+
299
+ /**
300
+ * Filter an array of candidates (lines or parsed objects) against a policy.
301
+ *
302
+ * Returns the same shape it was given: if you pass strings you get strings
303
+ * back; if you pass parsed objects you get parsed objects back. Unparseable
304
+ * string lines are silently skipped so a single bad candidate never poisons
305
+ * the whole offer.
306
+ *
307
+ * @param {Array<string|IceCandidate>} candidates - Input list.
308
+ * @param {CandidateFilterPolicy} [policy={}] - Policy (all defaults are permissive).
309
+ * @returns {Array<string|IceCandidate>} Surviving candidates, same element shape as input.
310
+ *
311
+ * @example
312
+ * const safe = filterCandidates(offer.candidates, {
313
+ * blockPrivate: true,
314
+ * blockMdns: true,
315
+ * allowedTypes: ['srflx', 'relay'],
316
+ * });
317
+ *
318
+ * @section ICE & TURN
319
+ */
320
+ function filterCandidates(candidates, policy = {})
321
+ {
322
+ if (!Array.isArray(candidates)) return [];
323
+ const {
324
+ blockPrivate = false,
325
+ blockMdns = false,
326
+ blockTcp = false,
327
+ allowedTypes,
328
+ maxCandidates,
329
+ predicate,
330
+ } = policy;
331
+
332
+ const out = [];
333
+ for (const item of candidates)
334
+ {
335
+ const isString = typeof item === 'string';
336
+ let parsed;
337
+ try { parsed = isString ? parseCandidate(item) : item; }
338
+ catch { continue; }
339
+ if (!parsed) continue;
340
+
341
+ if (allowedTypes && !allowedTypes.includes(parsed.type)) continue;
342
+ if (blockTcp && parsed.transport === 'tcp') continue;
343
+ if (blockMdns && isMdnsHostname(parsed.address)) continue;
344
+ if (blockPrivate)
345
+ {
346
+ const a = parsed.address;
347
+ const r = parsed.relatedAddress;
348
+ const isLocal = (x) => x && (isPrivateIp(x) || isLoopbackIp(x) || isLinkLocalIp(x));
349
+ if (isLocal(a) || isLocal(r)) continue;
350
+ }
351
+ if (predicate && !predicate(parsed)) continue;
352
+
353
+ out.push(isString ? item : parsed);
354
+ if (maxCandidates && out.length >= maxCandidates) break;
355
+ }
356
+ return out;
357
+ }
358
+
359
+ module.exports = {
360
+ parseCandidate,
361
+ stringifyCandidate,
362
+ isPrivateIp,
363
+ isLoopbackIp,
364
+ isLinkLocalIp,
365
+ isMdnsHostname,
366
+ filterCandidates,
367
+ CANDIDATE_TYPES,
368
+ TCP_TYPES,
369
+ IceError,
370
+ };
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @module @zero-server/webrtc
3
+ * @description First-class WebRTC support for Zero Server.
4
+ *
5
+ * Signaling hub, room / peer orchestration, RFC 8489 STUN client,
6
+ * RFC 7635 TURN credential issuance, optional embedded TURN server,
7
+ * SFrame E2EE key relay, and a pluggable SFU adapter interface.
8
+ *
9
+ * Implementation is landing PR-by-PR per `.myshit/WEBRTC-ROADMAP.md`.
10
+ * Real exports already live in this barrel; the rest throw
11
+ * `WEBRTC_NOT_IMPLEMENTED` so accidental production use fails loud.
12
+ */
13
+
14
+ 'use strict';
15
+
16
+ const {
17
+ WebRTCError, SignalingError, IceError, TurnError, SdpError,
18
+ } = require('../errors');
19
+
20
+ const { parseSdp, stringifySdp } = require('./sdp');
21
+ const {
22
+ parseCandidate, stringifyCandidate, filterCandidates,
23
+ isPrivateIp, isLoopbackIp, isLinkLocalIp, isMdnsHostname,
24
+ CANDIDATE_TYPES, TCP_TYPES,
25
+ } = require('./ice');
26
+ const {
27
+ stunBinding, encodeBindingRequest, decodeMessage,
28
+ encodeXorMappedAddress, decodeXorMappedAddress,
29
+ STUN_MAGIC_COOKIE, STUN_METHOD, STUN_CLASS, STUN_ATTR,
30
+ } = require('./stun');
31
+ const { issueTurnCredentials } = require('./turn/credentials');
32
+ const { TurnServer } = require('./turn/server');
33
+ const { SignalingHub, Room, Peer, PEER_STATE } = require('./signaling');
34
+ const { signJoinToken, verifyJoinToken } = require('./joinToken');
35
+ const { bindObservability } = require('./observe');
36
+ const {
37
+ E2eeChannel, attachE2ee,
38
+ generateE2eeKeyPair, sealKey, openSealedKey,
39
+ } = require('./e2ee');
40
+ const {
41
+ useCluster, ClusterCoordinator, MemoryClusterAdapter,
42
+ } = require('./cluster');
43
+ const { runWebRTCCommand } = require('./cli');
44
+ const { SfuAdapter, loadSfuAdapter } = require('./sfu');
45
+ const { MemorySfuAdapter } = require('./sfu/memory');
46
+ const { MediasoupSfuAdapter } = require('./sfu/mediasoup');
47
+ const { LiveKitSfuAdapter } = require('./sfu/livekit');
48
+ const { spawnBotPeer } = require('./bot');
49
+
50
+ /**
51
+ * @private
52
+ * Sentinel for surfaces that have not yet been implemented. Each PR in the
53
+ * roadmap replaces one of these with a real function/class export.
54
+ */
55
+ const notImplemented = (name) =>
56
+ {
57
+ throw new WebRTCError(
58
+ `${name} is not implemented yet - see .myshit/WEBRTC-ROADMAP.md for the implementation plan.`,
59
+ { code: 'WEBRTC_NOT_IMPLEMENTED' },
60
+ );
61
+ };
62
+
63
+ module.exports = {
64
+ // Signaling - landing in a later PR
65
+ createWebRTC: () => notImplemented('createWebRTC'),
66
+ SignalingHub,
67
+ Room,
68
+ Peer,
69
+ PEER_STATE,
70
+
71
+ // SDP - PR 1
72
+ parseSdp,
73
+ stringifySdp,
74
+
75
+ // ICE - PR 1
76
+ parseCandidate,
77
+ stringifyCandidate,
78
+ filterCandidates,
79
+ isPrivateIp,
80
+ isLoopbackIp,
81
+ isLinkLocalIp,
82
+ isMdnsHostname,
83
+ CANDIDATE_TYPES,
84
+ TCP_TYPES,
85
+
86
+ // NAT traversal - later PRs
87
+ stunBinding,
88
+ encodeBindingRequest,
89
+ decodeMessage,
90
+ encodeXorMappedAddress,
91
+ decodeXorMappedAddress,
92
+ STUN_MAGIC_COOKIE,
93
+ STUN_METHOD,
94
+ STUN_CLASS,
95
+ STUN_ATTR,
96
+ issueTurnCredentials,
97
+ TurnServer,
98
+
99
+ // SFU + tokens - later PRs
100
+ SfuAdapter,
101
+ MemorySfuAdapter,
102
+ MediasoupSfuAdapter,
103
+ LiveKitSfuAdapter,
104
+ loadSfuAdapter,
105
+ signJoinToken,
106
+ verifyJoinToken,
107
+
108
+ // Server-side WebRTC peer (wrtc bot)
109
+ spawnBotPeer,
110
+
111
+ // Observability - PR 6
112
+ bindObservability,
113
+
114
+ // E2EE key relay - PR 7
115
+ E2eeChannel,
116
+ attachE2ee,
117
+ generateE2eeKeyPair,
118
+ sealKey,
119
+ openSealedKey,
120
+
121
+ // Cluster - PR 8
122
+ useCluster,
123
+ ClusterCoordinator,
124
+ MemoryClusterAdapter,
125
+
126
+ // CLI
127
+ runWebRTCCommand,
128
+
129
+ // Errors (re-exported from lib/errors.js so consumers can `instanceof` them
130
+ // through @zero-server/webrtc without also requiring @zero-server/errors).
131
+ WebRTCError, SignalingError, IceError, TurnError, SdpError,
132
+ };
@@ -0,0 +1,116 @@
1
+ /**
2
+ * @module webrtc/joinToken
3
+ * @description Signed, short-TTL join tokens that authenticate a peer's
4
+ * right to join a specific room. JWT-shaped (HS256 by default)
5
+ * and audience-scoped to `room:<name>` so a token leaked from
6
+ * one channel cannot be replayed against another.
7
+ *
8
+ * Reuses the canonical sign/verify primitives from `lib/auth/jwt.js`.
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { sign, verify } = require('../auth');
14
+ const { SignalingError, WebRTCError } = require('../errors');
15
+
16
+ /**
17
+ * Issue a join token for `user` to enter `room`.
18
+ *
19
+ * @param {object} opts
20
+ * @param {string|Buffer} opts.secret - HMAC secret (HS256) or PEM key (RS256).
21
+ * @param {string|object} opts.user - User identifier (string) or object containing `id`.
22
+ * @param {string} opts.room - Target room name.
23
+ * @param {number} [opts.ttl=300] - Seconds until expiry. Negative values are accepted
24
+ * (used by tests to mint already-expired tokens).
25
+ * @param {string} [opts.algorithm='HS256']
26
+ * @param {string|string[]} [opts.audience] - Override the default `room:<name>` audience.
27
+ * @param {object} [opts.claims] - Additional claims merged into the payload.
28
+ * @returns {string} Compact JWT.
29
+ *
30
+ * @example
31
+ * const token = signJoinToken({
32
+ * secret: process.env.JOIN_SECRET,
33
+ * user: req.user,
34
+ * room: 'boardroom',
35
+ * ttl: 300,
36
+ * });
37
+ * res.json({ wsUrl: '/rtc', token });
38
+ *
39
+ * @section Signaling
40
+ */
41
+ function signJoinToken(opts = {})
42
+ {
43
+ if (!opts || typeof opts !== 'object')
44
+ throw new SignalingError('signJoinToken: opts must be an object');
45
+ if (!opts.secret) throw new SignalingError('signJoinToken: secret is required');
46
+ if (opts.user === undefined || opts.user === null)
47
+ throw new SignalingError('signJoinToken: user is required');
48
+ if (typeof opts.room !== 'string' || opts.room.length === 0)
49
+ throw new SignalingError('signJoinToken: room is required');
50
+
51
+ const ttl = Number.isFinite(opts.ttl) ? opts.ttl : 300;
52
+ const sub = typeof opts.user === 'string' ? opts.user
53
+ : (opts.user && (opts.user.id || opts.user.userId || opts.user.sub));
54
+ if (!sub) throw new SignalingError('signJoinToken: user.id is required');
55
+
56
+ const payload = Object.assign({}, opts.claims || {}, {
57
+ room: opts.room,
58
+ user: typeof opts.user === 'object' ? opts.user : { id: sub },
59
+ });
60
+
61
+ return sign(payload, opts.secret, {
62
+ algorithm: opts.algorithm || 'HS256',
63
+ expiresIn: ttl,
64
+ subject: String(sub),
65
+ audience: opts.audience || ('room:' + opts.room),
66
+ });
67
+ }
68
+
69
+ /**
70
+ * Verify a join token and return its payload. Throws a `WebRTCError` with
71
+ * `code: 'INVALID_TOKEN'` on any failure - bad signature, expired, audience
72
+ * mismatch, malformed, etc.
73
+ *
74
+ * @param {string} token
75
+ * @param {object} opts
76
+ * @param {string|Buffer} opts.secret
77
+ * @param {string} [opts.room] - If supplied, audience must be `room:<room>`.
78
+ * @param {string|string[]} [opts.audience] - Explicit audience override.
79
+ * @param {string|string[]} [opts.algorithms=['HS256']]
80
+ * @param {number} [opts.clockTolerance=0]
81
+ * @returns {object} Verified payload.
82
+ *
83
+ * @section Signaling
84
+ */
85
+ function verifyJoinToken(token, opts = {})
86
+ {
87
+ if (!opts || typeof opts !== 'object')
88
+ throw new WebRTCError('verifyJoinToken: opts must be an object', { code: 'INVALID_TOKEN' });
89
+ if (!opts.secret)
90
+ throw new WebRTCError('verifyJoinToken: secret is required', { code: 'INVALID_TOKEN' });
91
+ if (typeof token !== 'string' || token.length === 0)
92
+ throw new WebRTCError('verifyJoinToken: token must be a non-empty string', { code: 'INVALID_TOKEN' });
93
+
94
+ const audience = opts.audience || (opts.room ? 'room:' + opts.room : undefined);
95
+ try
96
+ {
97
+ const { payload } = verify(token, opts.secret, {
98
+ algorithms: opts.algorithms || ['HS256'],
99
+ audience,
100
+ clockTolerance: opts.clockTolerance || 0,
101
+ });
102
+ if (opts.room && payload.room && payload.room !== opts.room)
103
+ throw new WebRTCError('verifyJoinToken: room claim mismatch', { code: 'INVALID_TOKEN' });
104
+ return payload;
105
+ }
106
+ catch (err)
107
+ {
108
+ if (err instanceof WebRTCError) throw err;
109
+ throw new WebRTCError(
110
+ 'verifyJoinToken: ' + (err && err.message ? err.message : 'invalid token'),
111
+ { code: 'INVALID_TOKEN', cause: err && err.code ? err.code : undefined },
112
+ );
113
+ }
114
+ }
115
+
116
+ module.exports = { signJoinToken, verifyJoinToken };