@rapidmx/meet-plugin 0.9.3 → 0.10.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 (52) hide show
  1. package/apps/meet/[token].tsx +204 -13
  2. package/apps/meet/_CallControls.tsx +64 -3
  3. package/apps/meet/_CallView.tsx +244 -7
  4. package/apps/meet/_DiagnosticsPanel.tsx +113 -0
  5. package/apps/meet/_ParticipantTile.tsx +10 -2
  6. package/apps/meet/_ParticipantsDrawer.tsx +341 -0
  7. package/apps/meet/_icons.tsx +12 -0
  8. package/apps/meet/_meetApi.ts +141 -1
  9. package/apps/shared/media/filters/ScreenTransform.ts +148 -0
  10. package/apps/shared/media/useLocalMedia.ts +7 -3
  11. package/apps/shared/webrtc/MeshConnectionManager.ts +89 -4
  12. package/apps/shared/webrtc/realPeerConnection.ts +47 -7
  13. package/apps/shared/webrtc/types.ts +55 -7
  14. package/dist/apps/meet/[token].d.ts +4 -1
  15. package/dist/apps/meet/[token].js +121 -9
  16. package/dist/apps/meet/_CallControls.d.ts +9 -1
  17. package/dist/apps/meet/_CallControls.js +6 -5
  18. package/dist/apps/meet/_CallView.d.ts +28 -2
  19. package/dist/apps/meet/_CallView.js +155 -6
  20. package/dist/apps/meet/_DiagnosticsPanel.d.ts +22 -0
  21. package/dist/apps/meet/_DiagnosticsPanel.js +32 -0
  22. package/dist/apps/meet/_ParticipantTile.d.ts +8 -0
  23. package/dist/apps/meet/_ParticipantTile.js +9 -2
  24. package/dist/apps/meet/_ParticipantsDrawer.d.ts +61 -0
  25. package/dist/apps/meet/_ParticipantsDrawer.js +86 -0
  26. package/dist/apps/meet/_icons.d.ts +3 -0
  27. package/dist/apps/meet/_icons.js +3 -0
  28. package/dist/apps/meet/_meetApi.d.ts +93 -1
  29. package/dist/apps/meet/_meetApi.js +82 -0
  30. package/dist/apps/shared/media/filters/ScreenTransform.d.ts +53 -0
  31. package/dist/apps/shared/media/filters/ScreenTransform.js +100 -0
  32. package/dist/apps/shared/media/useLocalMedia.d.ts +5 -1
  33. package/dist/apps/shared/media/useLocalMedia.js +3 -3
  34. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +30 -1
  35. package/dist/apps/shared/webrtc/MeshConnectionManager.js +78 -3
  36. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +19 -6
  37. package/dist/apps/shared/webrtc/realPeerConnection.js +42 -5
  38. package/dist/apps/shared/webrtc/types.d.ts +54 -6
  39. package/dist/lib/models/mongo/VideoMeetingMongo.js +21 -0
  40. package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -1
  41. package/dist/lib/models/sql/VideoMeetingSQL.js +21 -0
  42. package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -1
  43. package/dist/lib/routes/BaseVideoMeetingRoute.js +380 -51
  44. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
  45. package/dist/lib/util/PasswordUtils.js +52 -0
  46. package/dist/lib/util/PasswordUtils.js.map +1 -0
  47. package/dist/types/models/mongo/VideoMeetingMongo.d.ts +3 -0
  48. package/dist/types/models/sql/VideoMeetingSQL.d.ts +3 -0
  49. package/dist/types/models/types.d.ts +23 -0
  50. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +147 -5
  51. package/dist/types/util/PasswordUtils.d.ts +17 -0
  52. package/package.json +1 -1
@@ -19,6 +19,7 @@ import { ApiError, JWTUtils, ObjectDecorators } from "@rapidrest/core";
19
19
  import { ACLAction, ACLUtils, ApiErrorMessages, ApiErrors, DatabaseDecorators, DocDecorators, ModelUtils, RepoUtils, RouteDecorators, } from "@rapidrest/service-core";
20
20
  import { parseEffectsAssetsUrl } from "../util/EffectsUtils.js";
21
21
  import { buildIceServers } from "../util/IceServerUtils.js";
22
+ import { hashPassword, verifyPassword as verifyPasswordHash } from "../util/PasswordUtils.js";
22
23
  import { buildBaseUrl } from "../util/PublicUrlUtils.js";
23
24
  import { RedisRelayBus } from "../util/RedisRelayBus.js";
24
25
  import { parseRelayEnabled, RELAY_LARGE_PAYLOAD_BYTES, RELAY_MAX_PAYLOAD_BYTES, RELAY_WS_MAX_BACKPRESSURE_BYTES, RelayHub, } from "../util/RelayHub.js";
@@ -46,6 +47,10 @@ const RELAY_WS_OPTIONS = WS_ROUTE_OPTIONS_SUPPORTED
46
47
  const MAX_TITLE_LENGTH = 200;
47
48
  const MAX_DISPLAY_NAME_LENGTH = 200;
48
49
  const MAX_EMAIL_LENGTH = 254;
50
+ /** A generous ceiling, not a real policy - `hashPassword()`'s cost is already what actually limits how useful a
51
+ * very long guess is to an attacker. The lower bound (1) just rejects an accidentally-empty string; this feature
52
+ * has no stated complexity requirement. */
53
+ const MAX_PASSWORD_LENGTH = 200;
49
54
  /** The most invitees a single private meeting may be created with - a defensive cap, not a real product limit
50
55
  * (this plugin's "full-mesh, up to 4-6 participants" design already makes a larger list impractical - see
51
56
  * `.claude/NOTES.md`), so a single request can't mint an unbounded number of invitee rows/tokens. */
@@ -256,6 +261,14 @@ export class BaseVideoMeetingRoute {
256
261
  * is what a route that was never initialized (a test's) keeps; `initRelayBus()` swaps in a hub on a Redis bus
257
262
  * when the deployment has one, before any socket can attach. Its socket caps are per server replica. */
258
263
  this.relayHub = new RelayHub({ maxPayloadBytes: RELAY_MESSAGE_LIMIT_BYTES, logger: { debug: (message) => this.logger.debug(message) } });
264
+ /** Every meeting's pending admission requests (`VideoMeeting.waitingRoomEnabled`), keyed by meeting uid then by
265
+ * the requester's own uid (real or freshly minted guest). In-memory and per server instance only - the same
266
+ * accepted tradeoff `relayHub` above already has: a restart drops anyone currently waiting, who simply
267
+ * requests again (see `VideoMeeting.waitingRoomEnabled`'s own doc comment). A request is removed once it
268
+ * resolves to a final answer and that answer has been polled (`pollAdmission()`) - an abandoned request (the
269
+ * requester never polls again) is never otherwise cleaned up; there is no GC job, matching this codebase's
270
+ * existing precedent for other never-expiring state (`Booking.manageToken`, `VideoMeetingInvitee.joinToken`). */
271
+ this.pendingAdmissions = new Map();
259
272
  }
260
273
  /**
261
274
  * Exposes the `@Model(...)`-supplied entity class as an instance property so `@Transactional()` on
@@ -367,6 +380,15 @@ export class BaseVideoMeetingRoute {
367
380
  }
368
381
  }
369
382
  }
383
+ if (body.forceMuteOnJoin !== undefined && typeof body.forceMuteOnJoin !== "boolean") {
384
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "'forceMuteOnJoin' must be a boolean.");
385
+ }
386
+ if (body.password !== undefined && (typeof body.password !== "string" || body.password.length > MAX_PASSWORD_LENGTH)) {
387
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, `'password' must be a string of at most ${MAX_PASSWORD_LENGTH} characters.`);
388
+ }
389
+ if (body.waitingRoomEnabled !== undefined && typeof body.waitingRoomEnabled !== "boolean") {
390
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "'waitingRoomEnabled' must be a boolean.");
391
+ }
370
392
  return { mailboxUid: body.mailboxUid, visibility: body.visibility };
371
393
  }
372
394
  /** Parses a caller-supplied ISO timestamp, rejecting anything unparseable with a `400` - matches
@@ -394,6 +416,7 @@ export class BaseVideoMeetingRoute {
394
416
  // Same mint function - and so the same shape/entropy - as `publicSlug`: the two live in separate columns, so
395
417
  // the only collision namespace either shares is its own, exactly as in Phase 1.
396
418
  const organizerSlug = visibility === VideoMeetingVisibility.PRIVATE ? mintPublicSlug() : undefined;
419
+ const passwordHash = body.password ? await hashPassword(body.password) : undefined;
397
420
  // Constructed before `create()` is called (rather than inline) because its own, already-generated `uid`
398
421
  // (every `BaseEntity` mints one on construction) is what `acl.uid` below claims the meeting's own
399
422
  // per-record ACL under - mirroring `BaseFolderRoute.create()`'s identical `acl: { uid: instance.uid, ... }`
@@ -408,6 +431,9 @@ export class BaseVideoMeetingRoute {
408
431
  endTime,
409
432
  publicSlug,
410
433
  organizerSlug,
434
+ forceMuteOnJoin: body.forceMuteOnJoin,
435
+ passwordHash,
436
+ waitingRoomEnabled: body.waitingRoomEnabled,
411
437
  });
412
438
  const meeting = await this.meetingRepo.create(instance, {
413
439
  user: strippedUser,
@@ -476,6 +502,14 @@ export class BaseVideoMeetingRoute {
476
502
  const base = buildBaseUrl(this.publicUrl);
477
503
  return base ? `${base}/${tokenOrSlug}` : undefined;
478
504
  }
505
+ /** Never let the stored password hash itself reach a response, not even the meeting's own owner's - a hash
506
+ * leak invites offline brute-forcing for no benefit to the owner, who already knows the plaintext they set (or
507
+ * can simply set a new one if they forgot it). Applied everywhere a raw persisted entity would otherwise flow
508
+ * straight into a response: `create()`, `withJoinUrls()` (so `find()`/`findById()` inherit it too), `update()`. */
509
+ withoutPasswordHash(meeting) {
510
+ const { passwordHash: _passwordHash, ...rest } = meeting;
511
+ return rest;
512
+ }
479
513
  async create(rawBody, req, user) {
480
514
  await this.init();
481
515
  const { mailboxUid, visibility } = this.validateCreateBody(rawBody);
@@ -484,7 +518,7 @@ export class BaseVideoMeetingRoute {
484
518
  const endTime = this.requireOptionalDate(body.endTime, "endTime");
485
519
  await this.requireMailboxAccess(mailboxUid, user, ACLAction.CREATE);
486
520
  const { meeting, invitees } = await this.persistMeeting(mailboxUid, body, visibility, startTime, endTime, user);
487
- const result = { meeting };
521
+ const result = { meeting: this.withoutPasswordHash(meeting) };
488
522
  if (visibility === VideoMeetingVisibility.PRIVATE) {
489
523
  result.invitees = invitees.map((invitee) => ({
490
524
  uid: invitee.uid,
@@ -510,10 +544,11 @@ export class BaseVideoMeetingRoute {
510
544
  * that IS a user's "personal room" - see `.claude/NOTES.md`'s Phase 4 entry - was created in an earlier visit)
511
545
  * would otherwise have no way to recover a public meeting's shareable link, or a private meeting's organizer
512
546
  * link, at all. Spreads rather than mutates: the loaded instance is the repo's own entity, and both fields are
513
- * response-only.
547
+ * response-only. Also strips `passwordHash` (`withoutPasswordHash()`) - `find()`/`findById()` are this field's
548
+ * only other response paths besides `create()`/`update()`, which strip it themselves.
514
549
  */
515
550
  withJoinUrls(meeting) {
516
- const result = { ...meeting };
551
+ const result = this.withoutPasswordHash({ ...meeting });
517
552
  if (meeting.organizerSlug) {
518
553
  result.organizerJoinUrl = this.joinUrl(meeting.organizerSlug);
519
554
  }
@@ -580,15 +615,42 @@ export class BaseVideoMeetingRoute {
580
615
  }
581
616
  patch.status = VideoMeetingStatus.CANCELLED;
582
617
  }
583
- if (patch.title === undefined && patch.status === undefined) {
584
- throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "Nothing to update: supply 'title' and/or 'status'.");
618
+ if (body?.forceMuteOnJoin !== undefined) {
619
+ if (typeof body.forceMuteOnJoin !== "boolean") {
620
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "'forceMuteOnJoin' must be a boolean.");
621
+ }
622
+ patch.forceMuteOnJoin = body.forceMuteOnJoin;
623
+ }
624
+ if (body?.password !== undefined) {
625
+ if (body.password === null) {
626
+ patch.passwordHash = null;
627
+ }
628
+ else if (typeof body.password !== "string" || !body.password || body.password.length > MAX_PASSWORD_LENGTH) {
629
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, `'password' must be a non-empty string of at most ${MAX_PASSWORD_LENGTH} characters, or null to remove it.`);
630
+ }
631
+ else {
632
+ patch.passwordHash = await hashPassword(body.password);
633
+ }
634
+ }
635
+ if (body?.waitingRoomEnabled !== undefined) {
636
+ if (typeof body.waitingRoomEnabled !== "boolean") {
637
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "'waitingRoomEnabled' must be a boolean.");
638
+ }
639
+ patch.waitingRoomEnabled = body.waitingRoomEnabled;
640
+ }
641
+ if (patch.title === undefined &&
642
+ patch.status === undefined &&
643
+ patch.forceMuteOnJoin === undefined &&
644
+ patch.passwordHash === undefined &&
645
+ patch.waitingRoomEnabled === undefined) {
646
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "Nothing to update: supply 'title', 'forceMuteOnJoin', 'password', 'waitingRoomEnabled' and/or 'status'.");
585
647
  }
586
648
  const strippedUser = stripTrustedRoles(user, this.trustedRoles);
587
649
  const updated = await this.meetingRepo.update(patch, meeting, { user: strippedUser, ignoreACL: true });
588
650
  if (patch.status === VideoMeetingStatus.CANCELLED) {
589
651
  await this.deleteAttendeeLinks(meeting, await this.findInvitees(meeting), strippedUser);
590
652
  }
591
- return updated;
653
+ return this.withoutPasswordHash(updated);
592
654
  }
593
655
  async delete(id, user) {
594
656
  await this.init();
@@ -601,6 +663,39 @@ export class BaseVideoMeetingRoute {
601
663
  }
602
664
  await this.meetingRepo.delete(meeting.uid, { user: strippedUser, ignoreACL: true });
603
665
  }
666
+ async kick(id, uid, user) {
667
+ await this.init();
668
+ const meeting = await this.requireOwnedMeeting(id, user, ACLAction.UPDATE);
669
+ await this.revokeChannelGrant(meeting.uid, uid);
670
+ }
671
+ async listWaiting(id, user) {
672
+ await this.init();
673
+ const meeting = await this.requireOwnedMeeting(id, user, ACLAction.READ);
674
+ const pending = this.pendingAdmissions.get(meeting.uid);
675
+ if (!pending) {
676
+ return [];
677
+ }
678
+ return [...pending.entries()]
679
+ .filter(([, request]) => request.status === "pending")
680
+ .sort(([, a], [, b]) => a.requestedAt.getTime() - b.requestedAt.getTime())
681
+ .map(([uid, request]) => ({ uid, name: request.name, requestedAt: request.requestedAt.toISOString() }));
682
+ }
683
+ async admit(id, uid, user) {
684
+ await this.init();
685
+ const meeting = await this.requireOwnedMeeting(id, user, ACLAction.UPDATE);
686
+ const request = this.pendingAdmissions.get(meeting.uid)?.get(uid);
687
+ if (request) {
688
+ request.status = "admitted";
689
+ }
690
+ }
691
+ async deny(id, uid, user) {
692
+ await this.init();
693
+ const meeting = await this.requireOwnedMeeting(id, user, ACLAction.UPDATE);
694
+ const request = this.pendingAdmissions.get(meeting.uid)?.get(uid);
695
+ if (request) {
696
+ request.status = "denied";
697
+ }
698
+ }
604
699
  /**
605
700
  * Resolves `token` to the meeting it names, and to *how* it named it: an invitee's `joinToken` (43 base64url
606
701
  * characters - `JOIN_TOKEN_PATTERN`), or one of the two 11-character slugs (`PUBLIC_SLUG_PATTERN`) - a public
@@ -706,6 +801,33 @@ export class BaseVideoMeetingRoute {
706
801
  }
707
802
  }
708
803
  }
804
+ /**
805
+ * 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,
809
+ * or never joined) or the meeting has no ACL, rather than an error - the caller wants "not able to rejoin",
810
+ * which is already true either way. Same optimistic-lock retry as `ensureChannelGrant()`, for the same reason:
811
+ * a concurrent joiner or the owner editing the ACL directly may save first.
812
+ */
813
+ async revokeChannelGrant(meetingUid, uid) {
814
+ for (let attempt = 0; attempt < GUEST_GRANT_MAX_ATTEMPTS; attempt++) {
815
+ const acl = await this.aclUtils.findACL(meetingUid, [], { skipCache: true, skipParents: true });
816
+ if (!acl || !acl.records.some((record) => record.userOrRoleId === uid)) {
817
+ return;
818
+ }
819
+ acl.records = acl.records.filter((record) => record.userOrRoleId !== uid);
820
+ try {
821
+ await this.aclUtils.saveACL(acl);
822
+ return;
823
+ }
824
+ catch (err) {
825
+ if (attempt === GUEST_GRANT_MAX_ATTEMPTS - 1 || !/must be of the same version/.test(err?.message ?? "")) {
826
+ throw err;
827
+ }
828
+ }
829
+ }
830
+ }
709
831
  /**
710
832
  * Mints a short-lived, scope-limited guest identity - see this class's doc comment on the channel-ACL-grant
711
833
  * mechanism. Only called for the true-anonymous case (`join()`'s `user` is absent, or presents a prior guest
@@ -725,65 +847,166 @@ export class BaseVideoMeetingRoute {
725
847
  const token = JWTUtils.createTokenSync(guestAuthConfig, { uid: guestUid, roles: [], scopes: [] }, { sessionUid: crypto.randomUUID(), exp: Math.floor(expiresAt.getTime() / 1000) });
726
848
  return { guestUid, token, expiresAt };
727
849
  }
728
- /** The host mailbox's `displayName`, or `undefined` when the mailbox has none (or no longer exists - an
729
- * orphaned meeting should still be joinable, just without a host name to show). */
730
- async hostDisplayName(mailboxUid) {
850
+ /** The host mailbox's `displayName` and owning account uid, or both `undefined` when the mailbox has none (or no
851
+ * longer exists - an orphaned meeting should still be joinable, just without a host to show or grant controls
852
+ * to). One lookup for both fields, since `join()` needs them together. */
853
+ async hostInfo(mailboxUid) {
731
854
  const mailbox = await this.mailboxRepo.findOne(mailboxUid, { ignoreACL: true });
732
- return mailbox?.displayName || undefined;
855
+ return { hostDisplayName: mailbox?.displayName || undefined, hostUid: mailbox?.ownerUserUid || undefined };
733
856
  }
734
- async join(token, user) {
735
- await this.init();
736
- const { meeting, resolvedVia } = await this.requireMeetingByToken(token);
737
- // An `organizerSlug` is not a credential of its own (unlike an invitee `joinToken` or a `publicSlug`): it
738
- // only exists so the owner of a private meeting has something that resolves to it at all, since they are
739
- // deliberately never one of its invitees. So it is gated here on BOTH conditions, before anything about
740
- // the meeting is computed or returned: a real, already-authenticated identity (the same non-guest check
741
- // the authenticated branch below uses - a returning guest presenting a prior join()'s guest JWT is not
742
- // one), AND that identity actually holding READ on this meeting's own mailbox, with its trusted roles
743
- // stripped first exactly as `requireMailboxAccess()` does. A failure answers the bare 404
744
- // `requireMeetingByToken()` already throws for a token that matched nothing whatsoever - deliberately NOT
745
- // `requireMailboxAccess()`'s 403, which would tell an anonymous prober that this slug named a real meeting.
746
- // Nothing here changes how an invitee token or a publicSlug resolves.
747
- if (resolvedVia === "organizerSlug") {
748
- const isRealCaller = !!user && !user.uid.startsWith(GUEST_UID_PREFIX);
749
- if (!isRealCaller || !(await this.aclUtils.hasPermission(stripTrustedRoles(user, this.trustedRoles), meeting.mailboxUid, ACLAction.READ))) {
750
- throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
751
- }
752
- }
753
- const relayEnabled = this.isRelayEnabled();
754
- const effectsAssetsUrl = parseEffectsAssetsUrl(this.effectsAssetsUrlSetting);
755
- const publicMeeting = {
857
+ /** The `PublicVideoMeeting` projection shared by `join()`'s two possible responses (granted or
858
+ * password-required) and `verifyPassword()`'s - computed once a token/slug has resolved, before either method
859
+ * decides what else (if anything) to grant. */
860
+ async buildPublicMeeting(meeting) {
861
+ const { hostDisplayName, hostUid } = await this.hostInfo(meeting.mailboxUid);
862
+ return {
756
863
  uid: meeting.uid,
757
864
  title: meeting.title,
758
865
  visibility: meeting.visibility,
759
866
  status: meeting.status,
760
- hostDisplayName: await this.hostDisplayName(meeting.mailboxUid),
867
+ hostDisplayName,
868
+ hostUid,
869
+ forceMuteOnJoin: meeting.forceMuteOnJoin || undefined,
870
+ hasPassword: meeting.passwordHash ? true : undefined,
871
+ waitingRoomEnabled: meeting.waitingRoomEnabled || undefined,
761
872
  };
873
+ }
874
+ /**
875
+ * Enforces the organizer-slug-only authority check - see `join()`'s doc comment on why holding an
876
+ * `organizerSlug` is not, by itself, a credential. A no-op for the other two resolutions. Shared between
877
+ * `join()` and `verifyPassword()`, since either could in principle be called first for an organizer's own link
878
+ * (a password a host also set on their own meeting should never block them - this check already proves
879
+ * stronger authority than any password could add, which is exactly why both methods skip the password
880
+ * check entirely once this passes).
881
+ */
882
+ async requireOrganizerAuth(meeting, resolvedVia, user) {
883
+ if (resolvedVia !== "organizerSlug") {
884
+ return;
885
+ }
886
+ const isRealCaller = !!user && !user.uid.startsWith(GUEST_UID_PREFIX);
887
+ if (!isRealCaller || !(await this.aclUtils.hasPermission(stripTrustedRoles(user, this.trustedRoles), meeting.mailboxUid, ACLAction.READ))) {
888
+ throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
889
+ }
890
+ }
891
+ /**
892
+ * The actual grant for a *definite*, already-known identity: ICE servers and `READ`/`CREATE` on the meeting's
893
+ * channel. Shared by `completeJoin()` (which still has to decide whether that identity is the caller's own
894
+ * real uid or a freshly minted guest) and `pollAdmission()`'s admitted branch (whose identity was already
895
+ * fixed back when `requestAdmission()` filed it - polling must grant exactly that uid, never mint a different
896
+ * one, which is why this doesn't call `mintGuestToken()` itself). Never includes `token`/`expiresAt` - a fresh
897
+ * guest mint (when there is one) is the caller's job, since only `completeJoin()`'s anonymous branch needs it.
898
+ */
899
+ async grantJoin(meeting, publicMeeting, selfUid, authenticated) {
900
+ await this.ensureChannelGrant(meeting.uid, selfUid);
901
+ const relayEnabled = this.isRelayEnabled();
902
+ const effectsAssetsUrl = parseEffectsAssetsUrl(this.effectsAssetsUrlSetting);
762
903
  const iceServers = buildIceServers({
763
904
  url: this.turnUrl,
764
905
  username: this.turnUsername,
765
906
  credential: this.turnCredential,
766
907
  sharedSecret: this.turnSharedSecret,
767
908
  });
909
+ return { meeting: publicMeeting, iceServers, authenticated, selfUid, relayEnabled, ...(effectsAssetsUrl && { effectsAssetsUrl }) };
910
+ }
911
+ /**
912
+ * Grants the caller's own real uid, or a freshly minted guest identity - shared by `join()` (when neither a
913
+ * password nor a waiting room blocks it) and `verifyPassword()` (once both are satisfied, or were never
914
+ * required). Everything `join()`'s own doc comment says about the `authenticated`/`selfUid`/`token` shape
915
+ * applies here unchanged.
916
+ */
917
+ async completeJoin(meeting, publicMeeting, user) {
768
918
  // A real, already-authenticated RapidMX identity - never a guest uid from an earlier join() call
769
919
  // presenting its own guest JWT back (a guest uid is never a valid mailbox-owning identity anyway, so this
770
920
  // prefix check is a safe, cheap discriminator - see this class's doc comment and GUEST_UID_PREFIX).
771
921
  if (user && !user.uid.startsWith(GUEST_UID_PREFIX)) {
772
- await this.ensureChannelGrant(meeting.uid, user.uid);
773
- return { meeting: publicMeeting, iceServers, authenticated: true, selfUid: user.uid, relayEnabled, ...(effectsAssetsUrl && { effectsAssetsUrl }) };
922
+ return this.grantJoin(meeting, publicMeeting, user.uid, true);
774
923
  }
775
- const { guestUid, token: guestToken, expiresAt } = this.mintGuestToken();
776
- await this.ensureChannelGrant(meeting.uid, guestUid);
777
- return {
778
- meeting: publicMeeting,
779
- iceServers,
780
- authenticated: false,
781
- selfUid: guestUid,
782
- token: guestToken,
783
- expiresAt: expiresAt.toISOString(),
784
- relayEnabled,
785
- ...(effectsAssetsUrl && { effectsAssetsUrl }),
786
- };
924
+ const { guestUid, token, expiresAt } = this.mintGuestToken();
925
+ const granted = await this.grantJoin(meeting, publicMeeting, guestUid, false);
926
+ return { ...granted, token, expiresAt: expiresAt.toISOString() };
927
+ }
928
+ /**
929
+ * Files `name` as a pending admission for `meeting` under the caller's own identity - their real uid when
930
+ * already authenticated (never a guest uid - same `GUEST_UID_PREFIX` discriminator `completeJoin()` uses), or
931
+ * a freshly minted guest identity otherwise, exactly as `completeJoin()` would choose between the two. Grants
932
+ * nothing: `ensureChannelGrant()` runs only once `pollAdmission()` sees the host admitted this uid, which is
933
+ * the whole point of a waiting room. The caller gets back enough to identify themselves on every subsequent
934
+ * poll - their `selfUid`, and a guest's own `token` to poll with (an already-authenticated caller's existing
935
+ * session cookie already does that job, so none is minted for them).
936
+ */
937
+ requestAdmission(meeting, publicMeeting, user, name) {
938
+ const isRealCaller = !!user && !user.uid.startsWith(GUEST_UID_PREFIX);
939
+ if (isRealCaller) {
940
+ this.registerPendingAdmission(meeting.uid, user.uid, name);
941
+ return { meeting: publicMeeting, requiresAdmission: true, authenticated: true, selfUid: user.uid };
942
+ }
943
+ const { guestUid, token, expiresAt } = this.mintGuestToken();
944
+ this.registerPendingAdmission(meeting.uid, guestUid, name);
945
+ return { meeting: publicMeeting, requiresAdmission: true, authenticated: false, selfUid: guestUid, token, expiresAt: expiresAt.toISOString() };
946
+ }
947
+ /** Records (or overwrites, for a re-request under the same identity) one pending admission. */
948
+ registerPendingAdmission(meetingUid, uid, name) {
949
+ let pending = this.pendingAdmissions.get(meetingUid);
950
+ if (!pending) {
951
+ pending = new Map();
952
+ this.pendingAdmissions.set(meetingUid, pending);
953
+ }
954
+ pending.set(uid, { name, requestedAt: new Date(), status: "pending" });
955
+ }
956
+ async join(token, user) {
957
+ await this.init();
958
+ const { meeting, resolvedVia } = await this.requireMeetingByToken(token);
959
+ await this.requireOrganizerAuth(meeting, resolvedVia, user);
960
+ const publicMeeting = await this.buildPublicMeeting(meeting);
961
+ if (resolvedVia !== "organizerSlug") {
962
+ if (meeting.waitingRoomEnabled) {
963
+ return { meeting: publicMeeting, requiresAdmission: true };
964
+ }
965
+ if (meeting.passwordHash) {
966
+ return { meeting: publicMeeting, requiresPassword: true };
967
+ }
968
+ }
969
+ return this.completeJoin(meeting, publicMeeting, user);
970
+ }
971
+ async verifyPassword(token, body, user) {
972
+ await this.init();
973
+ const { meeting, resolvedVia } = await this.requireMeetingByToken(token);
974
+ await this.requireOrganizerAuth(meeting, resolvedVia, user);
975
+ const bypassed = resolvedVia === "organizerSlug";
976
+ if (!bypassed && meeting.passwordHash) {
977
+ if (typeof body?.password !== "string" || !(await verifyPasswordHash(body.password, meeting.passwordHash))) {
978
+ throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, "Incorrect password.");
979
+ }
980
+ }
981
+ const publicMeeting = await this.buildPublicMeeting(meeting);
982
+ if (!bypassed && meeting.waitingRoomEnabled) {
983
+ if (typeof body?.name !== "string" || !body.name.trim()) {
984
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "'name' is required to request admission.");
985
+ }
986
+ return this.requestAdmission(meeting, publicMeeting, user, body.name.trim());
987
+ }
988
+ return this.completeJoin(meeting, publicMeeting, user);
989
+ }
990
+ async pollAdmission(token, user) {
991
+ await this.init();
992
+ const { meeting } = await this.requireMeetingByToken(token);
993
+ const pending = this.pendingAdmissions.get(meeting.uid)?.get(user.uid);
994
+ if (!pending) {
995
+ throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
996
+ }
997
+ if (pending.status === "denied") {
998
+ this.pendingAdmissions.get(meeting.uid)?.delete(user.uid);
999
+ throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, "The host denied your request to join.");
1000
+ }
1001
+ const publicMeeting = await this.buildPublicMeeting(meeting);
1002
+ if (pending.status === "pending") {
1003
+ return { meeting: publicMeeting, requiresAdmission: true, authenticated: !user.uid.startsWith(GUEST_UID_PREFIX), selfUid: user.uid };
1004
+ }
1005
+ this.pendingAdmissions.get(meeting.uid)?.delete(user.uid);
1006
+ // Grants exactly the uid that was admitted - never completeJoin(), which would mint a brand new guest
1007
+ // identity instead of recognizing this poller as the one requestAdmission() already filed (see
1008
+ // grantJoin()'s own doc comment).
1009
+ return this.grantJoin(meeting, publicMeeting, user.uid, !user.uid.startsWith(GUEST_UID_PREFIX));
787
1010
  }
788
1011
  /** Whether the WebSocket media relay is enabled - `mail:videoconf:relay:enabled`, see `parseRelayEnabled()`. */
789
1012
  isRelayEnabled() {
@@ -1004,9 +1227,12 @@ __decorate([
1004
1227
  __metadata("design:returntype", Promise)
1005
1228
  ], BaseVideoMeetingRoute.prototype, "findById", null);
1006
1229
  __decorate([
1007
- Summary("Updates a video meeting's title, or cancels it."),
1008
- Description("Deliberately minimal for Phase 1: only 'title' and cancellation ('status': 'cancelled') may be changed. " +
1009
- "Requires UPDATE on the meeting's owning mailbox."),
1230
+ Summary("Updates a video meeting's title, force-mute-on-join/password/waiting-room settings, or cancels it."),
1231
+ Description("Deliberately minimal for Phase 1: only 'title', 'forceMuteOnJoin', 'password', 'waitingRoomEnabled' and " +
1232
+ "cancellation ('status': 'cancelled') may be changed. 'password' hashes before storage; 'null' " +
1233
+ "removes password protection entirely, a non-empty string sets/replaces it. Requires UPDATE on the " +
1234
+ "meeting's owning mailbox. The response never carries the stored password hash, same as every other " +
1235
+ "response that includes a meeting."),
1010
1236
  Put("/:id"),
1011
1237
  __param(0, Param("id")),
1012
1238
  __param(2, AuthUser),
@@ -1027,6 +1253,66 @@ __decorate([
1027
1253
  __metadata("design:paramtypes", [String, Object]),
1028
1254
  __metadata("design:returntype", Promise)
1029
1255
  ], BaseVideoMeetingRoute.prototype, "delete", null);
1256
+ __decorate([
1257
+ 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 " +
1260
+ "client ignores the cooperative 'kicked' signal the host's own UI also sends over the mesh (see " +
1261
+ "SignalMessage's doc comment for why that signal alone isn't enforcement). A no-op if 'uid' holds no " +
1262
+ "grant (already removed, or never joined). Requires UPDATE on the meeting's owning mailbox. " +
1263
+ "Known limitation: this revokes the one grant 'uid' currently holds, not the token/slug they used to " +
1264
+ "get it - a removed guest who reloads the same join link is minted a brand new guest uid and " +
1265
+ "re-granted by join() exactly as any other first-time joiner would be, and a removed real caller who " +
1266
+ "still holds mailbox READ is re-granted their own uid back the same way. An actual ban (rejecting a " +
1267
+ "specific identity's future joins, not just today's connection) is a larger feature - new state " +
1268
+ "tracking who was removed and join()/ensureChannelGrant() consulting it - out of scope here."),
1269
+ Post("/:id/kick/:uid"),
1270
+ __param(0, Param("id")),
1271
+ __param(1, Param("uid")),
1272
+ __param(2, AuthUser),
1273
+ __metadata("design:type", Function),
1274
+ __metadata("design:paramtypes", [String, String, Object]),
1275
+ __metadata("design:returntype", Promise)
1276
+ ], BaseVideoMeetingRoute.prototype, "kick", null);
1277
+ __decorate([
1278
+ Summary("Lists a video meeting's pending admission requests."),
1279
+ Description("Returns everyone currently waiting to be admitted (VideoMeeting.waitingRoomEnabled), oldest first - " +
1280
+ "never anyone already admitted or denied, since pendingAdmissions drops a request once it reaches a " +
1281
+ "final answer and the requester has polled it. Requires READ on the meeting's owning mailbox."),
1282
+ Get("/:id/waiting"),
1283
+ __param(0, Param("id")),
1284
+ __param(1, AuthUser),
1285
+ __metadata("design:type", Function),
1286
+ __metadata("design:paramtypes", [String, Object]),
1287
+ __metadata("design:returntype", Promise)
1288
+ ], BaseVideoMeetingRoute.prototype, "listWaiting", null);
1289
+ __decorate([
1290
+ Summary("Admits a pending admission request."),
1291
+ Description("Marks 'uid's pending request admitted - their next GET /join/:token/status poll completes the join and " +
1292
+ "grants their channel access, exactly as join() itself would for a meeting with no waiting room. A " +
1293
+ "no-op (not an error) if 'uid' has no pending request (already resolved, or never requested at all) - " +
1294
+ "there is nothing left to admit either way. Requires UPDATE on the meeting's owning mailbox."),
1295
+ Post("/:id/admit/:uid"),
1296
+ __param(0, Param("id")),
1297
+ __param(1, Param("uid")),
1298
+ __param(2, AuthUser),
1299
+ __metadata("design:type", Function),
1300
+ __metadata("design:paramtypes", [String, String, Object]),
1301
+ __metadata("design:returntype", Promise)
1302
+ ], BaseVideoMeetingRoute.prototype, "admit", null);
1303
+ __decorate([
1304
+ Summary("Denies a pending admission request."),
1305
+ Description("Marks 'uid's pending request denied - their next GET /join/:token/status poll answers 403 and removes " +
1306
+ "the record, so a second poll (or a fresh admission request under the same identity) starts clean. " +
1307
+ "A no-op if 'uid' has no pending request. Requires UPDATE on the meeting's owning mailbox."),
1308
+ Post("/:id/deny/:uid"),
1309
+ __param(0, Param("id")),
1310
+ __param(1, Param("uid")),
1311
+ __param(2, AuthUser),
1312
+ __metadata("design:type", Function),
1313
+ __metadata("design:paramtypes", [String, String, Object]),
1314
+ __metadata("design:returntype", Promise)
1315
+ ], BaseVideoMeetingRoute.prototype, "deny", null);
1030
1316
  __decorate([
1031
1317
  Summary("Joins a video meeting."),
1032
1318
  Description("Resolves an invitee's join token or a public meeting's slug. If the caller already presents a valid " +
@@ -1038,7 +1324,12 @@ __decorate([
1038
1324
  "to exchange WebRTC signaling messages. Requires no authentication beyond the token itself; a " +
1039
1325
  "stale/unknown token answers 404. The one exception is a private meeting's organizerSlug, which is " +
1040
1326
  "never an anonymous surface: it additionally requires an already-authenticated caller holding READ on " +
1041
- "the meeting's owning mailbox, and answers the very same 404 for anyone else."),
1327
+ "the meeting's owning mailbox, and answers the very same 404 for anyone else. If the meeting requires " +
1328
+ "admission (a waiting room - see VideoMeeting.waitingRoomEnabled) and wasn't resolved via " +
1329
+ "organizerSlug, returns { meeting, requiresAdmission: true } instead - even when a password is also " +
1330
+ "required, since both are requested together on POST /join/:token/verify's one combined screen. " +
1331
+ "Otherwise, if just a password is required, returns { meeting, requiresPassword: true }. Either way " +
1332
+ "nothing is granted until POST /join/:token/verify succeeds."),
1042
1333
  RateLimit(),
1043
1334
  Get("/join/:token"),
1044
1335
  __param(0, Param("token")),
@@ -1047,6 +1338,44 @@ __decorate([
1047
1338
  __metadata("design:paramtypes", [String, Object]),
1048
1339
  __metadata("design:returntype", Promise)
1049
1340
  ], BaseVideoMeetingRoute.prototype, "join", null);
1341
+ __decorate([
1342
+ Summary("Verifies a video meeting's join password and/or files an admission request."),
1343
+ Description("Checks 'password' against the meeting's own stored hash when one is required - 403 for a wrong or " +
1344
+ "missing password, never revealing how close a guess was. Once satisfied (or when no password is " +
1345
+ "required at all): if the meeting also has a waiting room (VideoMeeting.waitingRoomEnabled), requires " +
1346
+ "'name' and files a pending admission instead of granting anything, returning " +
1347
+ "{ meeting, requiresAdmission: true, selfUid, token? } - poll GET /join/:token/status, authenticated " +
1348
+ "as that identity, to learn whether the host has responded. Otherwise completes the join exactly as " +
1349
+ "GET /join/:token would for a meeting with neither requirement - the same grant, the same response " +
1350
+ "shape. Calling this via the organizer's own slug bypasses both checks unconditionally, matching GET " +
1351
+ "/join/:token's own posture. Rate-limited, like GET /join/:token, since this is the one endpoint a " +
1352
+ "brute-force password guesser would actually hit repeatedly."),
1353
+ RateLimit(),
1354
+ Post("/join/:token/verify"),
1355
+ __param(0, Param("token")),
1356
+ __param(2, AuthUser),
1357
+ __metadata("design:type", Function),
1358
+ __metadata("design:paramtypes", [String, Object, Object]),
1359
+ __metadata("design:returntype", Promise)
1360
+ ], BaseVideoMeetingRoute.prototype, "verifyPassword", null);
1361
+ __decorate([
1362
+ Summary("Polls the status of a pending admission request."),
1363
+ Description("Authenticated as the identity verifyPassword() (or a previous poll) returned - the cookie session for " +
1364
+ "an already-authenticated real caller, or the 'Authorization: jwt <token>' header for a guest's own " +
1365
+ "minted token. Returns a granted VideoMeetingJoinResult once the host has admitted the request (the " +
1366
+ "same shape and grant GET /join/:token itself would give), re-returns " +
1367
+ "{ requiresAdmission: true } while the host hasn't responded yet, and 403 once the host has denied " +
1368
+ "it (the pending record is then gone - polling again answers 404, the same as never having requested " +
1369
+ "admission at all). 404 if this identity never requested admission to this meeting (or already " +
1370
+ "polled past a final answer)."),
1371
+ Auth(["jwt"]),
1372
+ Get("/join/:token/status"),
1373
+ __param(0, Param("token")),
1374
+ __param(1, AuthUser),
1375
+ __metadata("design:type", Function),
1376
+ __metadata("design:paramtypes", [String, Object]),
1377
+ __metadata("design:returntype", Promise)
1378
+ ], BaseVideoMeetingRoute.prototype, "pollAdmission", null);
1050
1379
  __decorate([
1051
1380
  Summary("Relays media over a WebSocket."),
1052
1381
  Description("The last-resort media path for a participant whose network blocks both a direct WebRTC connection and TURN: " +