@rapidmx/meet-plugin 0.14.1 → 0.16.0

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 (98) hide show
  1. package/README.md +35 -55
  2. package/apps/meet/[token].tsx +341 -341
  3. package/apps/meet/_CallControls.tsx +508 -463
  4. package/apps/meet/_CallView.tsx +1060 -891
  5. package/apps/meet/_ChatPanel.tsx +194 -0
  6. package/apps/meet/_ColorWheel.tsx +136 -0
  7. package/apps/meet/_DiagnosticsPanel.tsx +65 -7
  8. package/apps/meet/_MeetLobby.tsx +224 -224
  9. package/apps/meet/_ParticipantTile.tsx +243 -169
  10. package/apps/meet/_ParticipantsDrawer.tsx +10 -7
  11. package/apps/meet/_Whiteboard.tsx +577 -0
  12. package/apps/meet/_icons.tsx +135 -80
  13. package/apps/meet/_meetApi.ts +213 -212
  14. package/apps/shared/collab/CollabSession.ts +675 -0
  15. package/apps/shared/collab/WhiteboardModel.ts +216 -0
  16. package/apps/shared/collab/chatMarkdown.tsx +375 -0
  17. package/apps/shared/collab/color.ts +67 -0
  18. package/apps/shared/collab/protocol.ts +321 -0
  19. package/apps/shared/collab/whiteboardRender.ts +311 -0
  20. package/apps/shared/media/filters/VideoFilterProcessor.ts +408 -408
  21. package/apps/shared/media/filters/mlModels.ts +174 -174
  22. package/apps/shared/relay/AudioPlayer.ts +29 -0
  23. package/apps/shared/relay/AudioSender.ts +23 -0
  24. package/apps/shared/relay/RelayClient.ts +54 -2
  25. package/apps/shared/relay/RelayReceiver.ts +43 -2
  26. package/apps/shared/relay/RelaySender.ts +23 -1
  27. package/apps/shared/relay/RelayTransport.ts +21 -1
  28. package/apps/shared/relay/SilenceMeter.ts +59 -0
  29. package/apps/shared/relay/relayEnv.ts +1 -1
  30. package/apps/shared/signaling/SignalingClient.ts +335 -0
  31. package/apps/shared/webrtc/MeshConnectionManager.ts +971 -949
  32. package/apps/shared/webrtc/types.ts +335 -247
  33. package/dist/apps/meet/[token].js +1 -1
  34. package/dist/apps/meet/_CallControls.d.ts +21 -6
  35. package/dist/apps/meet/_CallControls.js +14 -8
  36. package/dist/apps/meet/_CallView.d.ts +36 -7
  37. package/dist/apps/meet/_CallView.js +132 -16
  38. package/dist/apps/meet/_ChatPanel.d.ts +24 -0
  39. package/dist/apps/meet/_ChatPanel.js +116 -0
  40. package/dist/apps/meet/_ColorWheel.d.ts +14 -0
  41. package/dist/apps/meet/_ColorWheel.js +82 -0
  42. package/dist/apps/meet/_DiagnosticsPanel.d.ts +5 -3
  43. package/dist/apps/meet/_DiagnosticsPanel.js +17 -1
  44. package/dist/apps/meet/_ParticipantTile.d.ts +11 -3
  45. package/dist/apps/meet/_ParticipantTile.js +36 -5
  46. package/dist/apps/meet/_ParticipantsDrawer.js +3 -3
  47. package/dist/apps/meet/_Whiteboard.d.ts +64 -0
  48. package/dist/apps/meet/_Whiteboard.js +312 -0
  49. package/dist/apps/meet/_icons.d.ts +16 -0
  50. package/dist/apps/meet/_icons.js +16 -0
  51. package/dist/apps/meet/_meetApi.d.ts +8 -7
  52. package/dist/apps/meet/_meetApi.js +2 -2
  53. package/dist/apps/shared/collab/CollabSession.d.ts +208 -0
  54. package/dist/apps/shared/collab/CollabSession.js +559 -0
  55. package/dist/apps/shared/collab/WhiteboardModel.d.ts +54 -0
  56. package/dist/apps/shared/collab/WhiteboardModel.js +197 -0
  57. package/dist/apps/shared/collab/chatMarkdown.d.ts +65 -0
  58. package/dist/apps/shared/collab/chatMarkdown.js +311 -0
  59. package/dist/apps/shared/collab/color.d.ts +18 -0
  60. package/dist/apps/shared/collab/color.js +59 -0
  61. package/dist/apps/shared/collab/protocol.d.ts +157 -0
  62. package/dist/apps/shared/collab/protocol.js +222 -0
  63. package/dist/apps/shared/collab/whiteboardRender.d.ts +31 -0
  64. package/dist/apps/shared/collab/whiteboardRender.js +268 -0
  65. package/dist/apps/shared/relay/AudioPlayer.d.ts +9 -0
  66. package/dist/apps/shared/relay/AudioPlayer.js +24 -4
  67. package/dist/apps/shared/relay/AudioSender.d.ts +8 -0
  68. package/dist/apps/shared/relay/AudioSender.js +20 -4
  69. package/dist/apps/shared/relay/RelayClient.d.ts +16 -0
  70. package/dist/apps/shared/relay/RelayClient.js +46 -2
  71. package/dist/apps/shared/relay/RelayReceiver.d.ts +5 -0
  72. package/dist/apps/shared/relay/RelayReceiver.js +38 -6
  73. package/dist/apps/shared/relay/RelaySender.d.ts +7 -0
  74. package/dist/apps/shared/relay/RelaySender.js +20 -5
  75. package/dist/apps/shared/relay/RelayTransport.d.ts +6 -1
  76. package/dist/apps/shared/relay/RelayTransport.js +16 -1
  77. package/dist/apps/shared/relay/SilenceMeter.d.ts +25 -0
  78. package/dist/apps/shared/relay/SilenceMeter.js +55 -0
  79. package/dist/apps/shared/relay/relayEnv.d.ts +1 -1
  80. package/dist/apps/shared/signaling/SignalingClient.d.ts +95 -0
  81. package/dist/apps/shared/signaling/SignalingClient.js +278 -0
  82. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +7 -7
  83. package/dist/apps/shared/webrtc/MeshConnectionManager.js +28 -9
  84. package/dist/apps/shared/webrtc/types.d.ts +104 -26
  85. package/dist/apps/shared/webrtc/types.js +3 -4
  86. package/dist/lib/routes/BaseVideoMeetingRoute.js +171 -69
  87. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  88. package/dist/lib/util/RelayHub.js +15 -2
  89. package/dist/lib/util/RelayHub.js.map +1 -1
  90. package/dist/lib/util/SignalingHub.js +339 -0
  91. package/dist/lib/util/SignalingHub.js.map +1 -0
  92. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +68 -46
  93. package/dist/types/util/RelayHub.d.ts +5 -2
  94. package/dist/types/util/SignalingHub.d.ts +112 -0
  95. package/package.json +208 -208
  96. package/apps/shared/push/GuestSignalingClient.ts +0 -359
  97. package/dist/apps/shared/push/GuestSignalingClient.d.ts +0 -98
  98. package/dist/apps/shared/push/GuestSignalingClient.js +0 -312
@@ -22,8 +22,9 @@ import { buildIceServers } from "../util/IceServerUtils.js";
22
22
  import { hashPassword, verifyPassword as verifyPasswordHash } from "../util/PasswordUtils.js";
23
23
  import { buildBaseUrl } from "../util/PublicUrlUtils.js";
24
24
  import { RedisRelayBus } from "../util/RedisRelayBus.js";
25
- import { parseRelayEnabled, RELAY_LARGE_PAYLOAD_BYTES, RELAY_MAX_PAYLOAD_BYTES, RELAY_WS_MAX_BACKPRESSURE_BYTES, RelayHub, } from "../util/RelayHub.js";
25
+ import { parseRelayEnabled, RELAY_LARGE_PAYLOAD_BYTES, RELAY_MAX_PAYLOAD_BYTES, RELAY_WS_MAX_BACKPRESSURE_BYTES, InProcessRelayBus, RelayHub, } from "../util/RelayHub.js";
26
26
  import { stripTrustedRoles } from "../util/RouteAccessUtils.js";
27
+ import { SIGNALING_LARGE_PAYLOAD_BYTES, SIGNALING_MAX_PAYLOAD_BYTES, SignalingHub } from "../util/SignalingHub.js";
27
28
  import { JOIN_TOKEN_PATTERN, PUBLIC_SLUG_PATTERN, mintJoinToken, mintPublicSlug } from "../util/TokenUtils.js";
28
29
  import { VideoMeetingStatus, VideoMeetingVisibility } from "../models/types.js";
29
30
  const { Config, Destroy, Init, Inject, Logger } = ObjectDecorators;
@@ -42,6 +43,12 @@ const RELAY_MESSAGE_LIMIT_BYTES = WS_ROUTE_OPTIONS_SUPPORTED ? RELAY_LARGE_PAYLO
42
43
  const RELAY_WS_OPTIONS = WS_ROUTE_OPTIONS_SUPPORTED
43
44
  ? { maxPayloadLength: RELAY_LARGE_PAYLOAD_BYTES, maxBackpressure: RELAY_WS_MAX_BACKPRESSURE_BYTES }
44
45
  : undefined;
46
+ /** The largest message a signaling socket may send: what the route asks the framework for and what `ready` tells
47
+ * the client. */
48
+ const SIGNALING_MESSAGE_LIMIT_BYTES = WS_ROUTE_OPTIONS_SUPPORTED ? SIGNALING_LARGE_PAYLOAD_BYTES : SIGNALING_MAX_PAYLOAD_BYTES;
49
+ /** The signaling route's WebSocket options: bigger messages, so a late joiner's whiteboard catch-up is a few messages.
50
+ * `undefined` (the framework's defaults) where it cannot be set. */
51
+ const SIGNALING_WS_OPTIONS = WS_ROUTE_OPTIONS_SUPPORTED ? { maxPayloadLength: SIGNALING_LARGE_PAYLOAD_BYTES } : undefined;
45
52
  /** Upper bounds on caller-supplied text, matching the general shape `booking-plugin`'s `BaseBookingRoute` bounds
46
53
  * its own free-text fields with. */
47
54
  const MAX_TITLE_LENGTH = 200;
@@ -108,11 +115,11 @@ const RELAY_MAX_QUEUED_MESSAGES = 8;
108
115
  *
109
116
  * ## Why `VideoMeeting` gets its own per-record `AccessControlList`
110
117
  *
111
- * Unlike `Booking`, `VideoMeeting.uid` doubles as a `/push` signaling channel (see `.claude/NOTES.md`'s "Signaling"
112
- * design note): SDP offers/answers and ICE candidates are ordinary `NotificationUtils.sendMessage()` payloads
113
- * published to it, exactly as `MailPushRoute`'s doc comment describes for a `Mailbox`/`Folder` channel. A `/push`
114
- * channel is authorized purely by `ACLUtils.hasPermission(user, channelUid, action)` against a per-record
115
- * `AccessControlList` document keyed by that exact uid - there being no such document at all (the case for
118
+ * Unlike `Booking`, `VideoMeeting.uid` doubles as the meeting's signaling channel: SDP offers/answers and ICE
119
+ * candidates travel over `signal()`'s WebSocket (see "The signaling WebSocket" below; until it replaced them they
120
+ * were published on the server's shared `/push` route), whose room is the meeting's uid. Like a `/push` channel
121
+ * before it, that socket is authorized purely by `ACLUtils.hasPermission(user, meetingUid, action)` against a
122
+ * per-record `AccessControlList` document keyed by that exact uid - there being no such document at all (the case for
116
123
  * `Booking`, whose `@Protect` sets `recordACL: false`) makes `hasPermission()` always answer `false`. So
117
124
  * `VideoMeetingMongo`/`VideoMeetingSQL` set `recordACL: true`, and `create()` below claims that per-record ACL
118
125
  * with `parentUid` set to the meeting's own `mailboxUid` (mirroring `BaseFolderRoute`'s identical `acl: { uid,
@@ -123,17 +130,17 @@ const RELAY_MAX_QUEUED_MESSAGES = 8;
123
130
  *
124
131
  * ## The channel-ACL-grant mechanism
125
132
  *
126
- * An anonymous guest holds no `AccessControlList` grant of their own and has no `JWTUser` to authenticate `/push`
127
- * with in the first place - `join()` therefore does two things together: it mints a short-lived, scope-limited
133
+ * An anonymous guest holds no `AccessControlList` grant of their own and has no `JWTUser` to authenticate the
134
+ * meeting's WebSockets with in the first place - `join()` therefore does two things together: it mints a short-lived, scope-limited
128
135
  * guest JWT (`mintGuestToken()`: a synthetic `guest:<random>` uid, no roles, `GUEST_JWT_TTL_SECONDS` expiry, real and
129
136
  * verifiable since it's signed with the same `auth` config every other token is), and it adds an explicit
130
137
  * `ACLRecord` for that exact uid onto the meeting's own `AccessControlList` (`ensureChannelGrant()`), granting
131
- * `READ` (so `BasePushRoute`'s SUBSCRIBE succeeds) and `CREATE` (so a publish does - see `MailPushRoute`'s doc
132
- * comment: "publishing to a channel needs CREATE on it as an ordinary user would"). The grant is scoped to exactly
138
+ * `READ` and `CREATE` - the pair `signal()` and `relay()` both require (`authorizeMeetingSocket()`), the same pair
139
+ * `/push` demanded to subscribe to and publish on a channel when signaling still went through it. The grant is scoped to exactly
133
140
  * that one meeting's uid, nothing else - the same "possession of a link is the credential" pattern
134
141
  * `BaseBookingRoute.requireBookingByToken()`/`resolveEffectiveUser()`'s `share:<token>` identity already establish
135
- * elsewhere in this codebase, extended one step further because signaling needs a channel *subscription*, not just
136
- * a stateless REST call.
142
+ * elsewhere in this codebase, extended one step further because signaling needs a long-lived socket, not just a
143
+ * stateless REST call.
137
144
  *
138
145
  * **Known limitation, documented rather than silently assumed away**: because each `join()` call for an anonymous
139
146
  * caller mints a *fresh* random guest uid (so simultaneous participants of one shared link are distinguishable from
@@ -153,16 +160,16 @@ const RELAY_MAX_QUEUED_MESSAGES = 8;
153
160
  * authenticated-optional endpoint in this codebase's family (e.g. `BaseScopedChildRoute.resolveEffectiveUser()`'s
154
161
  * "prefer the real authenticated user, fall back to the anonymous token" precedent). This matters because a
155
162
  * logged-in RapidMX user's browser already carries a real, `HttpOnly` `jwt` session cookie for this origin - a
156
- * cookie `apps/shared/push/GuestSignalingClient.ts` cannot overwrite with a guest token even if `join()` minted one
163
+ * cookie `apps/shared/signaling/SignalingClient.ts` cannot overwrite with a guest token even if `join()` minted one
157
164
  * (browsers refuse to let a script override an `HttpOnly` cookie of the same name), so a guest-only `join()` would
158
- * leave that browser's `/push` WebSocket authenticating as the real session while the meeting's ACL only names a
159
- * synthetic guest uid - the subscribe is simply, safely refused, and the real user could never actually join.
165
+ * leave that browser's signaling WebSocket authenticating as the real session while the meeting's ACL only names a
166
+ * synthetic guest uid - the socket is simply, safely refused, and the real user could never actually join.
160
167
  *
161
168
  * The fix: when `user` is present and is a *real* identity - not a guest uid from a previous `join()` call, told
162
169
  * apart by the `GUEST_UID_PREFIX` a guest uid always starts with and a real, mailbox-owning identity never can -
163
170
  * `join()` grants `user.uid` itself (not a synthetic one) `READ`/`CREATE` on the meeting's channel via
164
171
  * `ensureChannelGrant()`, mints no guest JWT at all, and returns `authenticated: true` with `selfUid: user.uid`. The
165
- * caller's own already-existing session cookie now already authenticates `/push` for them with zero new
172
+ * caller's own already-existing session cookie now already authenticates `signal()` for them with zero new
166
173
  * client-side auth handling - the frontend must not write a cookie of its own in this case (see
167
174
  * `VideoMeetingJoinResult`'s doc comment). When `user` is absent (the common, true-anonymous case - no existing
168
175
  * session at all), behavior is exactly Phase 1's: a fresh guest identity is minted and granted instead.
@@ -223,6 +230,19 @@ const RELAY_MAX_QUEUED_MESSAGES = 8;
223
230
  * it): a disabled relay closes every socket with 1008 "Relay disabled." and `join()` reports `relayEnabled: false`, so
224
231
  * clients don't try it.
225
232
  *
233
+ * ## The signaling WebSocket (`signal()`)
234
+ *
235
+ * Every participant's offers, answers, ICE candidates, presence, moderation signals, chat messages and whiteboard
236
+ * strokes travel over this plugin's own WebSocket, `signal()` at `/signal/:id`
237
+ * (`/api/mail/video-meetings/signal/<meetingUid>`), not the server's shared `/push` route: one socket in both
238
+ * directions, so a message is not a whole HTTP request, and a meeting's traffic never mixes with the webmail's own
239
+ * push notifications. `SignalingHub` carries the messages (see it for the protocol and limits) on the same bus as the
240
+ * relay, so it crosses replicas exactly when the relay does. It is authorized exactly as `relay()` is (the same
241
+ * `READ`/`CREATE` grant from `join()`, the same live-meeting check, the same "Not permitted." for every refusal) but
242
+ * is never switched off - without it there is no call. The hub stamps every message with the socket's own uid and
243
+ * peer id, so nobody can speak as someone else, and a point-to-point message (`to`) reaches only that peer. `kick()`
244
+ * closes the removed participant's sockets at once.
245
+ *
226
246
  * ## Other known limitations
227
247
  *
228
248
  * **`VideoMeetingInvitee.joinToken` never expires** and has no GC job - identical tradeoff to `Booking.manageToken`.
@@ -257,10 +277,6 @@ export class BaseVideoMeetingRoute {
257
277
  /** The deployment's `events` datastore (`datastores:events`) - the Redis the framework's push system publishes
258
278
  * across replicas on; `null` when the deployment has none. Decides how `initRelayBus()` carries relay frames. */
259
279
  this.eventsConfig = null;
260
- /** The rooms of this process's relay sockets. One per route instance. It starts out on an in-process bus, which
261
- * is what a route that was never initialized (a test's) keeps; `initRelayBus()` swaps in a hub on a Redis bus
262
- * when the deployment has one, before any socket can attach. Its socket caps are per server replica. */
263
- this.relayHub = new RelayHub({ maxPayloadBytes: RELAY_MESSAGE_LIMIT_BYTES, logger: { debug: (message) => this.logger.debug(message) } });
264
280
  /** Every meeting's pending admission requests (`VideoMeeting.waitingRoomEnabled`), keyed by meeting uid then by
265
281
  * the requester's own uid (real or freshly minted guest). In-memory and per server instance only - the same
266
282
  * accepted tradeoff `relayHub` above already has: a restart drops anyone currently waiting, who simply
@@ -280,30 +296,53 @@ export class BaseVideoMeetingRoute {
280
296
  return this.constructor.modelClass;
281
297
  }
282
298
  /**
283
- * Picks how relay frames travel between server replicas, once the route is created: over Redis pub/sub
284
- * (`RedisRelayBus`) when `datastores:events` is configured, else in-process only with a single warning that
285
- * participants on different replicas cannot relay to each other. A Redis bus that cannot be created (the `redis`
299
+ * Picks how relay frames and signaling messages travel between server replicas, once the route is created: over
300
+ * Redis pub/sub (`RedisRelayBus`) when `datastores:events` is configured, else in-process only with a single
301
+ * warning that participants on different replicas cannot reach each other. A Redis bus that cannot be created (the `redis`
286
302
  * package is missing, an unusable URL) falls back the same way, with the error logged. Runs before the route
287
303
  * serves anything, so no socket is attached to the hub it replaces.
288
304
  */
289
305
  async initRelayBus() {
306
+ if (!this._objectFactory) {
307
+ throw new Error("objectFactory is not set.");
308
+ }
309
+ if (this.relayHub) {
310
+ return;
311
+ }
312
+ // Named after the concrete route, so the two backends' routes never share an instance.
313
+ const name = this.constructor.name;
314
+ let bus;
290
315
  const url = this.eventsConfig?.url;
291
316
  if (!url) {
292
- this.logger.warn("The `events` datastore is not configured: the video meeting relay only carries media between participants " +
317
+ this.logger.warn("The `events` datastore is not configured: video meeting signaling and the media relay only reach participants " +
293
318
  "connected to this server instance, not between server replicas.");
294
- return;
295
319
  }
296
- const bus = new RedisRelayBus({ url, logger: this.logger });
297
- try {
298
- await bus.connect();
320
+ else {
321
+ const redisBus = await this._objectFactory.newInstance(RedisRelayBus, {
322
+ name: `${name}RedisRelayBus`,
323
+ args: [{ url, logger: this.logger }],
324
+ });
325
+ try {
326
+ await redisBus.connect();
327
+ this.relayBus = redisBus;
328
+ bus = redisBus;
329
+ }
330
+ catch (err) {
331
+ this.logger.error("Could not create the video meeting relay's Redis bus, so signaling and relay only work between participants connected to this " +
332
+ `server instance: ${err?.message ?? err}`);
333
+ }
299
334
  }
300
- catch (err) {
301
- this.logger.error("Could not create the video meeting relay's Redis bus, so relay only works between participants connected to this " +
302
- `server instance: ${err?.message ?? err}`);
303
- return;
335
+ if (!bus) {
336
+ bus = await this._objectFactory.newInstance(InProcessRelayBus, { name: `${name}InProcessRelayBus`, args: [] });
304
337
  }
305
- this.relayBus = bus;
306
- this.relayHub = new RelayHub({ bus, maxPayloadBytes: RELAY_MESSAGE_LIMIT_BYTES, logger: { debug: (message) => this.logger.debug(message) } });
338
+ this.relayHub = await this._objectFactory.newInstance(RelayHub, {
339
+ name: `${name}RelayHub`,
340
+ args: [{ bus, maxPayloadBytes: RELAY_MESSAGE_LIMIT_BYTES, logger: { debug: (message) => this.logger.debug(message) } }],
341
+ });
342
+ this.signalingHub = await this._objectFactory.newInstance(SignalingHub, {
343
+ name: `${name}SignalingHub`,
344
+ args: [{ bus, maxPayloadBytes: SIGNALING_MESSAGE_LIMIT_BYTES, logger: { debug: (message) => this.logger.debug(message) } }],
345
+ });
307
346
  }
308
347
  /** Disconnects the Redis relay bus, if there is one, when the object factory destroys the route. (The factory
309
348
  * runs one destroy method per object: a subclass declaring its own `@Destroy` must call this one.) */
@@ -311,17 +350,21 @@ export class BaseVideoMeetingRoute {
311
350
  this.relayBus?.close();
312
351
  this.relayBus = undefined;
313
352
  }
314
- async init() {
315
- if (!this.meetingRepo) {
353
+ /** Builds the model repositories once, before the route serves anything. */
354
+ async initialize() {
355
+ if (!this._objectFactory) {
356
+ throw new Error("objectFactory is not set.");
357
+ }
358
+ if (!this.meetingRepo && this.meetingClass) {
316
359
  this.meetingRepo = await this._objectFactory.newInstance(RepoUtils, { name: this.meetingClass.name, args: [this.meetingClass] });
317
360
  }
318
- if (!this.inviteeRepo) {
361
+ if (!this.inviteeRepo && this.inviteeClass) {
319
362
  this.inviteeRepo = await this._objectFactory.newInstance(RepoUtils, { name: this.inviteeClass.name, args: [this.inviteeClass] });
320
363
  }
321
- if (!this.mailboxRepo) {
364
+ if (!this.mailboxRepo && this.mailboxClass) {
322
365
  this.mailboxRepo = await this._objectFactory.newInstance(RepoUtils, { name: this.mailboxClass.name, args: [this.mailboxClass] });
323
366
  }
324
- if (!this.attendeeLinkRepo) {
367
+ if (!this.attendeeLinkRepo && this.attendeeLinkClass) {
325
368
  this.attendeeLinkRepo = await this._objectFactory.newInstance(RepoUtils, {
326
369
  name: this.attendeeLinkClass.name,
327
370
  args: [this.attendeeLinkClass],
@@ -511,7 +554,6 @@ export class BaseVideoMeetingRoute {
511
554
  return rest;
512
555
  }
513
556
  async create(rawBody, req, user) {
514
- await this.init();
515
557
  const { mailboxUid, visibility } = this.validateCreateBody(rawBody);
516
558
  const body = rawBody;
517
559
  const startTime = this.requireOptionalDate(body.startTime, "startTime");
@@ -558,7 +600,6 @@ export class BaseVideoMeetingRoute {
558
600
  return result;
559
601
  }
560
602
  async find(mailboxUid, limit, page, user) {
561
- await this.init();
562
603
  if (typeof mailboxUid !== "string" || !mailboxUid) {
563
604
  throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
564
605
  }
@@ -572,7 +613,6 @@ export class BaseVideoMeetingRoute {
572
613
  return meetings.map((meeting) => this.withJoinUrls(meeting));
573
614
  }
574
615
  async personalRoom(user) {
575
- await this.init();
576
616
  if (!user?.uid) {
577
617
  throw new ApiError(ApiErrors.AUTH_REQUIRED, 401, ApiErrorMessages.AUTH_REQUIRED);
578
618
  }
@@ -595,12 +635,10 @@ export class BaseVideoMeetingRoute {
595
635
  throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
596
636
  }
597
637
  async findById(id, user) {
598
- await this.init();
599
638
  const meeting = await this.requireOwnedMeeting(id, user, ACLAction.READ);
600
639
  return this.withJoinUrls(meeting);
601
640
  }
602
641
  async update(id, body, user) {
603
- await this.init();
604
642
  const meeting = await this.requireOwnedMeeting(id, user, ACLAction.UPDATE);
605
643
  const patch = { uid: meeting.uid, version: meeting.version };
606
644
  if (body?.title !== undefined) {
@@ -653,7 +691,6 @@ export class BaseVideoMeetingRoute {
653
691
  return this.withoutPasswordHash(updated);
654
692
  }
655
693
  async delete(id, user) {
656
- await this.init();
657
694
  const meeting = await this.requireOwnedMeeting(id, user, ACLAction.DELETE);
658
695
  const strippedUser = stripTrustedRoles(user, this.trustedRoles);
659
696
  const invitees = await this.findInvitees(meeting);
@@ -664,12 +701,11 @@ export class BaseVideoMeetingRoute {
664
701
  await this.meetingRepo.delete(meeting.uid, { user: strippedUser, ignoreACL: true });
665
702
  }
666
703
  async kick(id, uid, user) {
667
- await this.init();
668
704
  const meeting = await this.requireOwnedMeeting(id, user, ACLAction.UPDATE);
669
705
  await this.revokeChannelGrant(meeting.uid, uid);
706
+ this.signalingHub?.evict(meeting.uid, uid);
670
707
  }
671
708
  async listWaiting(id, user) {
672
- await this.init();
673
709
  const meeting = await this.requireOwnedMeeting(id, user, ACLAction.READ);
674
710
  const pending = this.pendingAdmissions.get(meeting.uid);
675
711
  if (!pending) {
@@ -681,7 +717,6 @@ export class BaseVideoMeetingRoute {
681
717
  .map(([uid, request]) => ({ uid, name: request.name, requestedAt: request.requestedAt.toISOString() }));
682
718
  }
683
719
  async admit(id, uid, user) {
684
- await this.init();
685
720
  const meeting = await this.requireOwnedMeeting(id, user, ACLAction.UPDATE);
686
721
  const request = this.pendingAdmissions.get(meeting.uid)?.get(uid);
687
722
  if (request) {
@@ -689,7 +724,6 @@ export class BaseVideoMeetingRoute {
689
724
  }
690
725
  }
691
726
  async deny(id, uid, user) {
692
- await this.init();
693
727
  const meeting = await this.requireOwnedMeeting(id, user, ACLAction.UPDATE);
694
728
  const request = this.pendingAdmissions.get(meeting.uid)?.get(uid);
695
729
  if (request) {
@@ -803,9 +837,9 @@ export class BaseVideoMeetingRoute {
803
837
  }
804
838
  /**
805
839
  * Removes `uid`'s `ACLRecord` from `meetingUid`'s own `AccessControlList`, if it has one - the mirror image of
806
- * `ensureChannelGrant()`, used to enforce a kick (see `kick()`): once removed, `uid` can no longer subscribe to
807
- * or publish on the meeting's `/push` channel, so even a client that ignores the cooperative `"kicked"` signal
808
- * (`SignalMessage`'s doc comment) cannot keep participating. A no-op when `uid` holds no grant (already kicked,
840
+ * `ensureChannelGrant()`, used to enforce a kick (see `kick()`, which also closes `uid`'s open signaling sockets):
841
+ * once removed, `uid` can no longer open the meeting's signaling (`signal()`) or relay (`relay()`) socket, so even
842
+ * a client that ignores the cooperative `"kicked"` signal (`SignalMessage`'s doc comment) cannot keep participating. A no-op when `uid` holds no grant (already kicked,
809
843
  * or never joined) or the meeting has no ACL, rather than an error - the caller wants "not able to rejoin",
810
844
  * which is already true either way. Same optimistic-lock retry as `ensureChannelGrant()`, for the same reason:
811
845
  * a concurrent joiner or the owner editing the ACL directly may save first.
@@ -954,7 +988,6 @@ export class BaseVideoMeetingRoute {
954
988
  pending.set(uid, { name, requestedAt: new Date(), status: "pending" });
955
989
  }
956
990
  async join(token, user) {
957
- await this.init();
958
991
  const { meeting, resolvedVia } = await this.requireMeetingByToken(token);
959
992
  await this.requireOrganizerAuth(meeting, resolvedVia, user);
960
993
  const publicMeeting = await this.buildPublicMeeting(meeting);
@@ -969,7 +1002,6 @@ export class BaseVideoMeetingRoute {
969
1002
  return this.completeJoin(meeting, publicMeeting, user);
970
1003
  }
971
1004
  async verifyPassword(token, body, user) {
972
- await this.init();
973
1005
  const { meeting, resolvedVia } = await this.requireMeetingByToken(token);
974
1006
  await this.requireOrganizerAuth(meeting, resolvedVia, user);
975
1007
  const bypassed = resolvedVia === "organizerSlug";
@@ -988,7 +1020,6 @@ export class BaseVideoMeetingRoute {
988
1020
  return this.completeJoin(meeting, publicMeeting, user);
989
1021
  }
990
1022
  async pollAdmission(token, user) {
991
- await this.init();
992
1023
  const { meeting } = await this.requireMeetingByToken(token);
993
1024
  const pending = this.pendingAdmissions.get(meeting.uid)?.get(user.uid);
994
1025
  if (!pending) {
@@ -1015,7 +1046,7 @@ export class BaseVideoMeetingRoute {
1015
1046
  /**
1016
1047
  * Decides whether `user` may open a relay socket to meeting `id`: the relay must be enabled, `user` must hold BOTH
1017
1048
  * `READ` and `CREATE` on the meeting's uid - exactly what `ensureChannelGrant()` gives a joined participant, and
1018
- * what `/push` demands to subscribe and publish - and the meeting must exist and be neither cancelled nor ended.
1049
+ * what `signal()` demands too (`authorizeMeetingSocket()`) - and the meeting must exist and be neither cancelled nor ended.
1019
1050
  * Returns the reason to close the socket with, or `undefined` when permitted.
1020
1051
  *
1021
1052
  * `hasPermission()` gets `user` with its trusted roles stripped (`stripTrustedRoles()`), exactly as
@@ -1028,11 +1059,16 @@ export class BaseVideoMeetingRoute {
1028
1059
  if (!this.isRelayEnabled()) {
1029
1060
  return "Relay disabled.";
1030
1061
  }
1062
+ return this.authorizeMeetingSocket(id, user);
1063
+ }
1064
+ /** Why a meeting WebSocket (`relay()`, `signal()`) of `user` for meeting `id` must be refused, or `undefined` to
1065
+ * accept it - see `authorizeRelay()`'s doc comment for the rules, which are the same for both apart from the
1066
+ * relay's own on/off setting. */
1067
+ async authorizeMeetingSocket(id, user) {
1031
1068
  // `hasPermission()` answers `true` for an empty ACL uid, so an empty id must never reach it.
1032
1069
  if (!user || !id) {
1033
1070
  return RELAY_NOT_PERMITTED;
1034
1071
  }
1035
- await this.init();
1036
1072
  const checked = stripTrustedRoles(user, this.trustedRoles);
1037
1073
  if (!(await this.aclUtils.hasPermission(checked, id, ACLAction.READ)) || !(await this.aclUtils.hasPermission(checked, id, ACLAction.CREATE))) {
1038
1074
  return RELAY_NOT_PERMITTED;
@@ -1044,14 +1080,14 @@ export class BaseVideoMeetingRoute {
1044
1080
  return undefined;
1045
1081
  }
1046
1082
  /**
1047
- * The meeting uid a relay socket's URL names. `@Param("id")` is filled in by the Bun router, but NOT by the uWS
1083
+ * The meeting uid a relay or signaling socket's URL names. `@Param("id")` is filled in by the Bun router, but NOT by the uWS
1048
1084
  * router this framework actually serves with: it never populates `req.params` for a WebSocket upgrade (a
1049
1085
  * `@WebSocket()` route has, so far, never had a path parameter - `BasePushRoute.connect()` has none). So when the
1050
1086
  * decorated argument is empty this reads the last segment of the request path instead, which the
1051
- * `/relay/:id` pattern guarantees is the id (the framework also registers the trailing-slash form; the path
1087
+ * `/relay/:id` and `/signal/:id` patterns guarantee is the id (the framework also registers the trailing-slash form; the path
1052
1088
  * never carries the query string). Returns `undefined` when there is none or it isn't valid percent-encoding.
1053
1089
  */
1054
- relayMeetingId(paramId, req) {
1090
+ socketMeetingId(paramId, req) {
1055
1091
  if (typeof paramId === "string" && paramId) {
1056
1092
  return paramId;
1057
1093
  }
@@ -1064,7 +1100,7 @@ export class BaseVideoMeetingRoute {
1064
1100
  }
1065
1101
  }
1066
1102
  async relay(paramId, req, sock, user) {
1067
- const id = this.relayMeetingId(paramId, req);
1103
+ const id = this.socketMeetingId(paramId, req);
1068
1104
  // Listeners first, before the first await: the client may send its `hello` the moment the socket opens,
1069
1105
  // while the meeting is still being looked up, and an event with no listener is lost. Whatever arrives until
1070
1106
  // the socket is admitted is held (a few messages at most) and replayed to it.
@@ -1105,6 +1141,45 @@ export class BaseVideoMeetingRoute {
1105
1141
  conn?.message(message.data, message.isBinary);
1106
1142
  }
1107
1143
  }
1144
+ async signal(paramId, req, sock, user) {
1145
+ const id = this.socketMeetingId(paramId, req);
1146
+ // Listeners first, before the first await - see `relay()`, which this mirrors.
1147
+ let conn;
1148
+ let closed = false;
1149
+ const queued = [];
1150
+ sock.on("message", (data, isBinary) => {
1151
+ if (conn) {
1152
+ conn.message(data, isBinary);
1153
+ }
1154
+ else if (queued.length < RELAY_MAX_QUEUED_MESSAGES) {
1155
+ queued.push({ data, isBinary });
1156
+ }
1157
+ });
1158
+ sock.on("close", () => {
1159
+ closed = true;
1160
+ conn?.close();
1161
+ });
1162
+ let refusal;
1163
+ try {
1164
+ refusal = await this.authorizeMeetingSocket(id, user);
1165
+ }
1166
+ catch (err) {
1167
+ this.logger.error(`Failed to authorize a signaling socket for meeting ${id}: ${err?.message ?? err}`);
1168
+ sock.close(1011, "Signaling unavailable.");
1169
+ return;
1170
+ }
1171
+ if (refusal) {
1172
+ sock.close(1008, refusal);
1173
+ return;
1174
+ }
1175
+ if (closed) {
1176
+ return;
1177
+ }
1178
+ conn = this.signalingHub.attach(id, user.uid, sock);
1179
+ for (const message of queued) {
1180
+ conn?.message(message.data, message.isBinary);
1181
+ }
1182
+ }
1108
1183
  }
1109
1184
  __decorate([
1110
1185
  Inject(ACLUtils),
@@ -1166,6 +1241,12 @@ __decorate([
1166
1241
  __metadata("design:paramtypes", []),
1167
1242
  __metadata("design:returntype", void 0)
1168
1243
  ], BaseVideoMeetingRoute.prototype, "destroyRelayBus", null);
1244
+ __decorate([
1245
+ Init,
1246
+ __metadata("design:type", Function),
1247
+ __metadata("design:paramtypes", []),
1248
+ __metadata("design:returntype", Promise)
1249
+ ], BaseVideoMeetingRoute.prototype, "initialize", null);
1169
1250
  __decorate([
1170
1251
  Transactional(),
1171
1252
  __metadata("design:type", Function),
@@ -1244,7 +1325,7 @@ __decorate([
1244
1325
  Summary("Deletes a video meeting."),
1245
1326
  Description("Deletes the meeting, every one of its invitees and the calendar invite links written for them. Deleting the meeting also removes its own " +
1246
1327
  "per-record AccessControlList (RepoUtils.delete()'s standard recordACL cleanup - see the VideoMeeting " +
1247
- "interface's doc comment), so its uid stops working as a push channel immediately. Requires DELETE on " +
1328
+ "interface's doc comment), so its uid stops authorizing a signaling or relay socket immediately. Requires DELETE on " +
1248
1329
  "the meeting's owning mailbox."),
1249
1330
  Delete("/:id"),
1250
1331
  __param(0, Param("id")),
@@ -1255,8 +1336,8 @@ __decorate([
1255
1336
  ], BaseVideoMeetingRoute.prototype, "delete", null);
1256
1337
  __decorate([
1257
1338
  Summary("Removes a participant from a video meeting."),
1258
- Description("Revokes 'uid's grant on the meeting's own push channel (revokeChannelGrant()), so they can no longer " +
1259
- "subscribe to or publish on it - and therefore can no longer signal or relay media, even if their " +
1339
+ Description("Revokes 'uid's grant on the meeting (revokeChannelGrant()) and closes every signaling socket they hold in it " +
1340
+ "(on every replica the bus reaches), so they can no longer signal, chat, draw or relay media, even if their " +
1260
1341
  "client ignores the cooperative 'kicked' signal the host's own UI also sends over the mesh (see " +
1261
1342
  "SignalMessage's doc comment for why that signal alone isn't enforcement). A no-op if 'uid' holds no " +
1262
1343
  "grant (already removed, or never joined). Requires UPDATE on the meeting's owning mailbox. " +
@@ -1317,11 +1398,11 @@ __decorate([
1317
1398
  Summary("Joins a video meeting."),
1318
1399
  Description("Resolves an invitee's join token or a public meeting's slug. If the caller already presents a valid " +
1319
1400
  "session for a real RapidMX identity (not a returning guest), that identity is granted READ/CREATE " +
1320
- "on the meeting's own push channel directly and the response carries 'authenticated: true' with no " +
1321
- "guest token at all - the caller's own existing session cookie/header already authenticates /push " +
1322
- "for them. Otherwise (the common anonymous case) mints a short-lived guest JWT (see " +
1323
- "GUEST_JWT_TTL_SECONDS) already granted READ/CREATE on the same channel, ready to use against /push " +
1324
- "to exchange WebRTC signaling messages. Requires no authentication beyond the token itself; a " +
1401
+ "on the meeting directly and the response carries 'authenticated: true' with no " +
1402
+ "guest token at all - the caller's own existing session cookie/header already authenticates the " +
1403
+ "meeting's signaling WebSocket (/signal/<meetingUid>) for them. Otherwise (the common anonymous case) " +
1404
+ "mints a short-lived guest JWT (see GUEST_JWT_TTL_SECONDS) already granted READ/CREATE on the same " +
1405
+ "meeting, ready to use against /signal/<meetingUid> to exchange WebRTC signaling messages. Requires no authentication beyond the token itself; a " +
1325
1406
  "stale/unknown token answers 404. The one exception is a private meeting's organizerSlug, which is " +
1326
1407
  "never an anonymous surface: it additionally requires an already-authenticated caller holding READ on " +
1327
1408
  "the meeting's owning mailbox, and answers the very same 404 for anyone else. If the meeting requires " +
@@ -1397,4 +1478,25 @@ __decorate([
1397
1478
  __metadata("design:paramtypes", [Object, Object, Object, Object]),
1398
1479
  __metadata("design:returntype", Promise)
1399
1480
  ], BaseVideoMeetingRoute.prototype, "relay", null);
1481
+ __decorate([
1482
+ Summary("Carries a video meeting's signaling over a WebSocket."),
1483
+ Description("The meeting's own signaling channel: a WebSocket at /signal/<meetingUid> carrying JSON messages - WebRTC " +
1484
+ "offers, answers and ICE candidates, presence, moderation, chat and whiteboard - between the meeting's " +
1485
+ "participants (see SignalingHub for the protocol: a 'hello' first, answered with 'ready', then 'signal' " +
1486
+ "messages both ways). The server stamps each message with the sender's own uid and peer id, and delivers a " +
1487
+ "message naming a recipient ('to') to that participant only. Requires a JWT (a real user's, or the guest " +
1488
+ "token join() minted) holding READ and CREATE on the meeting - the grant join() gives - and an existing, " +
1489
+ "unended, uncancelled meeting; anything else closes the socket with code 1008. Removing a participant " +
1490
+ "(kick) closes their sockets. With the events datastore (Redis) configured, messages reach participants " +
1491
+ "connected to other server replicas too."),
1492
+ Auth(["jwt"]),
1493
+ WebSocket("/signal/:id", SIGNALING_WS_OPTIONS),
1494
+ __param(0, Param("id")),
1495
+ __param(1, Request),
1496
+ __param(2, Socket),
1497
+ __param(3, AuthUser),
1498
+ __metadata("design:type", Function),
1499
+ __metadata("design:paramtypes", [Object, Object, Object, Object]),
1500
+ __metadata("design:returntype", Promise)
1501
+ ], BaseVideoMeetingRoute.prototype, "signal", null);
1400
1502
  //# sourceMappingURL=BaseVideoMeetingRoute.js.map