@rapidmx/meet-plugin 0.9.2 → 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.
- package/apps/meet/[token].tsx +204 -13
- package/apps/meet/_CallControls.tsx +64 -3
- package/apps/meet/_CallView.tsx +244 -7
- package/apps/meet/_DiagnosticsPanel.tsx +113 -0
- package/apps/meet/_ParticipantTile.tsx +10 -2
- package/apps/meet/_ParticipantsDrawer.tsx +341 -0
- package/apps/meet/_icons.tsx +12 -0
- package/apps/meet/_meetApi.ts +141 -1
- package/apps/shared/media/deviceMedia.ts +22 -4
- package/apps/shared/media/filters/ScreenTransform.ts +148 -0
- package/apps/shared/media/useLocalMedia.ts +38 -10
- package/apps/shared/relay/AudioPlayer.ts +30 -4
- package/apps/shared/relay/relayEnv.ts +8 -1
- package/apps/shared/webrtc/MeshConnectionManager.ts +89 -4
- package/apps/shared/webrtc/realPeerConnection.ts +47 -7
- package/apps/shared/webrtc/types.ts +55 -7
- package/dist/apps/meet/[token].d.ts +4 -1
- package/dist/apps/meet/[token].js +121 -9
- package/dist/apps/meet/_CallControls.d.ts +9 -1
- package/dist/apps/meet/_CallControls.js +6 -5
- package/dist/apps/meet/_CallView.d.ts +28 -2
- package/dist/apps/meet/_CallView.js +155 -6
- package/dist/apps/meet/_DiagnosticsPanel.d.ts +22 -0
- package/dist/apps/meet/_DiagnosticsPanel.js +32 -0
- package/dist/apps/meet/_ParticipantTile.d.ts +8 -0
- package/dist/apps/meet/_ParticipantTile.js +9 -2
- package/dist/apps/meet/_ParticipantsDrawer.d.ts +61 -0
- package/dist/apps/meet/_ParticipantsDrawer.js +86 -0
- package/dist/apps/meet/_icons.d.ts +3 -0
- package/dist/apps/meet/_icons.js +3 -0
- package/dist/apps/meet/_meetApi.d.ts +93 -1
- package/dist/apps/meet/_meetApi.js +82 -0
- package/dist/apps/shared/media/deviceMedia.d.ts +11 -2
- package/dist/apps/shared/media/deviceMedia.js +20 -4
- package/dist/apps/shared/media/filters/ScreenTransform.d.ts +53 -0
- package/dist/apps/shared/media/filters/ScreenTransform.js +100 -0
- package/dist/apps/shared/media/useLocalMedia.d.ts +5 -1
- package/dist/apps/shared/media/useLocalMedia.js +32 -10
- package/dist/apps/shared/relay/AudioPlayer.d.ts +14 -2
- package/dist/apps/shared/relay/AudioPlayer.js +31 -4
- package/dist/apps/shared/relay/relayEnv.d.ts +5 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +30 -1
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +78 -3
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +19 -6
- package/dist/apps/shared/webrtc/realPeerConnection.js +42 -5
- package/dist/apps/shared/webrtc/types.d.ts +54 -6
- package/dist/lib/models/mongo/VideoMeetingMongo.js +21 -0
- package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -1
- package/dist/lib/models/sql/VideoMeetingSQL.js +21 -0
- package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -1
- package/dist/lib/routes/BaseVideoMeetingRoute.js +380 -51
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -1
- package/dist/lib/util/PasswordUtils.js +52 -0
- package/dist/lib/util/PasswordUtils.js.map +1 -0
- package/dist/types/models/mongo/VideoMeetingMongo.d.ts +3 -0
- package/dist/types/models/sql/VideoMeetingSQL.d.ts +3 -0
- package/dist/types/models/types.d.ts +23 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +147 -5
- package/dist/types/util/PasswordUtils.d.ts +17 -0
- 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 (
|
|
584
|
-
|
|
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
|
|
729
|
-
* orphaned meeting should still be joinable, just without a host
|
|
730
|
-
|
|
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
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
776
|
-
await this.
|
|
777
|
-
return {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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'
|
|
1009
|
-
"
|
|
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: " +
|