@rapidmx/meet-plugin 0.1.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 (108) hide show
  1. package/LICENSE +382 -0
  2. package/README.md +38 -0
  3. package/apps/meet/[token].tsx +144 -0
  4. package/apps/meet/_CallControls.tsx +69 -0
  5. package/apps/meet/_CallView.tsx +338 -0
  6. package/apps/meet/_MeetChrome.tsx +30 -0
  7. package/apps/meet/_MeetLobby.tsx +223 -0
  8. package/apps/meet/_ParticipantTile.tsx +66 -0
  9. package/apps/meet/_layout.tsx +34 -0
  10. package/apps/meet/_meetApi.ts +66 -0
  11. package/apps/meet/index.tsx +22 -0
  12. package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +140 -0
  13. package/apps/settings-video-conferencing/_layout.tsx +47 -0
  14. package/apps/settings-video-conferencing/index.tsx +192 -0
  15. package/apps/shared/media/activeSpeaker.ts +45 -0
  16. package/apps/shared/media/deviceMedia.ts +141 -0
  17. package/apps/shared/media/levelMeter.ts +91 -0
  18. package/apps/shared/push/GuestSignalingClient.ts +338 -0
  19. package/apps/shared/webrtc/MeshConnectionManager.ts +350 -0
  20. package/apps/shared/webrtc/realPeerConnection.ts +37 -0
  21. package/apps/shared/webrtc/types.ts +90 -0
  22. package/dist/apps/meet/[token].d.ts +33 -0
  23. package/dist/apps/meet/[token].js +101 -0
  24. package/dist/apps/meet/_CallControls.d.ts +18 -0
  25. package/dist/apps/meet/_CallControls.js +6 -0
  26. package/dist/apps/meet/_CallView.d.ts +41 -0
  27. package/dist/apps/meet/_CallView.js +225 -0
  28. package/dist/apps/meet/_MeetChrome.d.ts +13 -0
  29. package/dist/apps/meet/_MeetChrome.js +10 -0
  30. package/dist/apps/meet/_MeetLobby.d.ts +29 -0
  31. package/dist/apps/meet/_MeetLobby.js +120 -0
  32. package/dist/apps/meet/_ParticipantTile.d.ts +18 -0
  33. package/dist/apps/meet/_ParticipantTile.js +25 -0
  34. package/dist/apps/meet/_layout.d.ts +9 -0
  35. package/dist/apps/meet/_layout.js +8 -0
  36. package/dist/apps/meet/_meetApi.d.ts +47 -0
  37. package/dist/apps/meet/_meetApi.js +23 -0
  38. package/dist/apps/meet/index.d.ts +5 -0
  39. package/dist/apps/meet/index.js +8 -0
  40. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +24 -0
  41. package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +77 -0
  42. package/dist/apps/settings-video-conferencing/_layout.d.ts +16 -0
  43. package/dist/apps/settings-video-conferencing/_layout.js +8 -0
  44. package/dist/apps/settings-video-conferencing/index.d.ts +16 -0
  45. package/dist/apps/settings-video-conferencing/index.js +92 -0
  46. package/dist/apps/shared/media/activeSpeaker.d.ts +20 -0
  47. package/dist/apps/shared/media/activeSpeaker.js +39 -0
  48. package/dist/apps/shared/media/deviceMedia.d.ts +65 -0
  49. package/dist/apps/shared/media/deviceMedia.js +110 -0
  50. package/dist/apps/shared/media/levelMeter.d.ts +40 -0
  51. package/dist/apps/shared/media/levelMeter.js +61 -0
  52. package/dist/apps/shared/push/GuestSignalingClient.d.ts +89 -0
  53. package/dist/apps/shared/push/GuestSignalingClient.js +291 -0
  54. package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +97 -0
  55. package/dist/apps/shared/webrtc/MeshConnectionManager.js +262 -0
  56. package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
  57. package/dist/apps/shared/webrtc/realPeerConnection.js +26 -0
  58. package/dist/apps/shared/webrtc/types.d.ts +94 -0
  59. package/dist/apps/shared/webrtc/types.js +9 -0
  60. package/dist/lib/index.js +25 -0
  61. package/dist/lib/index.js.map +1 -0
  62. package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js +87 -0
  63. package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js.map +1 -0
  64. package/dist/lib/models/mongo/VideoMeetingMongo.js +126 -0
  65. package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -0
  66. package/dist/lib/models/sql/VideoMeetingInviteeSQL.js +87 -0
  67. package/dist/lib/models/sql/VideoMeetingInviteeSQL.js.map +1 -0
  68. package/dist/lib/models/sql/VideoMeetingSQL.js +127 -0
  69. package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -0
  70. package/dist/lib/models/types.js +19 -0
  71. package/dist/lib/models/types.js.map +1 -0
  72. package/dist/lib/mongo.js +13 -0
  73. package/dist/lib/mongo.js.map +1 -0
  74. package/dist/lib/routes/BaseVideoMeetingRoute.js +743 -0
  75. package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -0
  76. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +31 -0
  77. package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -0
  78. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +31 -0
  79. package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -0
  80. package/dist/lib/sql.js +13 -0
  81. package/dist/lib/sql.js.map +1 -0
  82. package/dist/lib/util/BookingIntegrationUtils.js +115 -0
  83. package/dist/lib/util/BookingIntegrationUtils.js.map +1 -0
  84. package/dist/lib/util/IceServerUtils.js +68 -0
  85. package/dist/lib/util/IceServerUtils.js.map +1 -0
  86. package/dist/lib/util/PublicUrlUtils.js +37 -0
  87. package/dist/lib/util/PublicUrlUtils.js.map +1 -0
  88. package/dist/lib/util/RouteAccessUtils.js +32 -0
  89. package/dist/lib/util/RouteAccessUtils.js.map +1 -0
  90. package/dist/lib/util/TokenUtils.js +33 -0
  91. package/dist/lib/util/TokenUtils.js.map +1 -0
  92. package/dist/types/index.d.ts +20 -0
  93. package/dist/types/models/mongo/VideoMeetingInviteeMongo.d.ts +16 -0
  94. package/dist/types/models/mongo/VideoMeetingMongo.d.ts +23 -0
  95. package/dist/types/models/sql/VideoMeetingInviteeSQL.d.ts +16 -0
  96. package/dist/types/models/sql/VideoMeetingSQL.d.ts +23 -0
  97. package/dist/types/models/types.d.ts +131 -0
  98. package/dist/types/mongo.d.ts +8 -0
  99. package/dist/types/routes/BaseVideoMeetingRoute.d.ts +368 -0
  100. package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +10 -0
  101. package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +10 -0
  102. package/dist/types/sql.d.ts +8 -0
  103. package/dist/types/util/BookingIntegrationUtils.d.ts +51 -0
  104. package/dist/types/util/IceServerUtils.d.ts +57 -0
  105. package/dist/types/util/PublicUrlUtils.d.ts +11 -0
  106. package/dist/types/util/RouteAccessUtils.d.ts +8 -0
  107. package/dist/types/util/TokenUtils.d.ts +23 -0
  108. package/package.json +181 -0
@@ -0,0 +1,743 @@
1
+ var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
2
+ var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
3
+ if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
4
+ else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
5
+ return c > 3 && r && Object.defineProperty(target, key, r), r;
6
+ };
7
+ var __metadata = (this && this.__metadata) || function (k, v) {
8
+ if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
9
+ };
10
+ var __param = (this && this.__param) || function (paramIndex, decorator) {
11
+ return function (target, key) { decorator(target, key, paramIndex); }
12
+ };
13
+ ///////////////////////////////////////////////////////////////////////////////
14
+ // Copyright (C) 2026 Jean-Philippe Steinmetz. All rights reserved.
15
+ // SPDX-License-Identifier: MPL-2.0
16
+ ///////////////////////////////////////////////////////////////////////////////
17
+ import * as crypto from "crypto";
18
+ import { ApiError, JWTUtils, ObjectDecorators } from "@rapidrest/core";
19
+ import { ACLAction, ACLUtils, ApiErrorMessages, ApiErrors, DatabaseDecorators, DocDecorators, ModelUtils, RepoUtils, RouteDecorators, } from "@rapidrest/service-core";
20
+ import { buildIceServers } from "../util/IceServerUtils.js";
21
+ import { buildBaseUrl } from "../util/PublicUrlUtils.js";
22
+ import { stripTrustedRoles } from "../util/RouteAccessUtils.js";
23
+ import { JOIN_TOKEN_PATTERN, PUBLIC_SLUG_PATTERN, mintJoinToken, mintPublicSlug } from "../util/TokenUtils.js";
24
+ import { VideoMeetingStatus, VideoMeetingVisibility } from "../models/types.js";
25
+ const { Config, Inject, Logger } = ObjectDecorators;
26
+ const { Description, Summary } = DocDecorators;
27
+ const { Transactional } = DatabaseDecorators;
28
+ const { Delete, Get, Param, Post, Put, Query, RateLimit, Request, User: AuthUser } = RouteDecorators;
29
+ /** Upper bounds on caller-supplied text, matching the general shape `booking-plugin`'s `BaseBookingRoute` bounds
30
+ * its own free-text fields with. */
31
+ const MAX_TITLE_LENGTH = 200;
32
+ const MAX_DISPLAY_NAME_LENGTH = 200;
33
+ const MAX_EMAIL_LENGTH = 254;
34
+ /** The most invitees a single private meeting may be created with - a defensive cap, not a real product limit
35
+ * (this plugin's "full-mesh, up to 4-6 participants" design already makes a larger list impractical - see
36
+ * `.claude/NOTES.md`), so a single request can't mint an unbounded number of invitee rows/tokens. */
37
+ const MAX_INVITEES = 50;
38
+ /** A very small sanity check on an invitee's supplied address - deliberately not a full RFC 5322 parser, matching
39
+ * `BaseBookingRoute`'s identical `EMAIL_PATTERN`. */
40
+ const EMAIL_PATTERN = /^[^\s@]+@[^\s@.]+\.[^\s@]+$/;
41
+ /** How long a guest JWT minted by `join()` remains valid for. Four hours comfortably covers a realistic call's
42
+ * length (including some run-over) without a guest's session dying mid-call; there is no refresh mechanism in
43
+ * Phase 1, so a guest who is still on the call past this needs to re-open the join link. */
44
+ export const GUEST_JWT_TTL_SECONDS = 4 * 60 * 60;
45
+ /** The fixed prefix every guest uid `mintGuestToken()` mints starts with - also how `join()` tells a genuine,
46
+ * already-authenticated RapidMX identity apart from a returning guest presenting a JWT from an earlier `join()`
47
+ * call (see `join()`'s doc comment): a guest uid is never a real mailbox-owning identity, so this prefix is a safe,
48
+ * cheap discriminator with no separate "is this a guest" flag needed anywhere. */
49
+ export const GUEST_UID_PREFIX = "guest:";
50
+ /** How many times `ensureChannelGrant()` retries an optimistic-lock conflict on the meeting's own ACL before
51
+ * giving up - see that method's doc comment. */
52
+ const GUEST_GRANT_MAX_ATTEMPTS = 5;
53
+ /**
54
+ * The owner's management of their own `VideoMeeting`s (JWT-authenticated, mailbox-ACL-checked exactly like any
55
+ * other mailbox-scoped entity - see `booking-plugin`'s `BaseBookingTypeRoute` for the same "must own or hold a
56
+ * grant on the mailbox" pattern this class hand-implements) and the anonymous `join()` endpoint a private
57
+ * invitee's `joinToken` or a public meeting's `publicSlug` resolves through.
58
+ *
59
+ * Like `BaseBookingRoute`, this is a standalone class, NOT a `CRUDRoute`/`BaseScopedChildRoute` subclass: `create()`'s
60
+ * response is not itself a bare `VideoMeeting` (it also carries invitee join links / the public join link, which the
61
+ * generic CRUD return type can't express), and `join()` is anonymous and token-resolved, the same shape of
62
+ * requirement that keeps `BaseBookingRoute` standalone. Every repo call passes `ignoreACL: true` because this class
63
+ * performs its own authorization: the owner-side methods check `ACLUtils.hasPermission()` against the meeting's
64
+ * `mailboxUid` with the caller's trusted roles stripped first (`util/RouteAccessUtils.ts` - see that module's doc
65
+ * comment for why: without it, `ACLUtils.hasPermission()`'s own "trusted users always have permission" rule would
66
+ * let an administrator read or manage a mailbox they hold no grant on, which is never true for mail here), and
67
+ * `join()` resolves an anonymous caller purely by the token/slug's own database match. Like `Booking`/`BookingType`,
68
+ * the concrete `VideoMeetingMongo`/`VideoMeetingSQL`/`VideoMeetingInviteeMongo`/`VideoMeetingInviteeSQL` classes carry
69
+ * an ordinary deny-all class ACL; `"anonymous"` is never granted an action anywhere.
70
+ *
71
+ * Like `BaseBookingRoute`/`BaseMailIngestRoute`/`BasePushRoute`, this class carries no `@Route`/`@ApiRoute` of its
72
+ * own - the consuming Mongo/SQL concrete subclass applies one (`mail/video-meetings`).
73
+ *
74
+ * ## Path shape
75
+ *
76
+ * `join()` lives at the literal `/join/:token` (not `/:token` at the root), the same reasoning `BaseBookingRoute`'s
77
+ * doc comment gives for its own `/manage/<token>` vs. `/:slug/slots` split: a bare `/:token` at the root would
78
+ * collide with (and make router registration order decide between) this class's own `/:id` owner routes.
79
+ *
80
+ * ## Why `VideoMeeting` gets its own per-record `AccessControlList`
81
+ *
82
+ * Unlike `Booking`, `VideoMeeting.uid` doubles as a `/push` signaling channel (see `.claude/NOTES.md`'s "Signaling"
83
+ * design note): SDP offers/answers and ICE candidates are ordinary `NotificationUtils.sendMessage()` payloads
84
+ * published to it, exactly as `MailPushRoute`'s doc comment describes for a `Mailbox`/`Folder` channel. A `/push`
85
+ * channel is authorized purely by `ACLUtils.hasPermission(user, channelUid, action)` against a per-record
86
+ * `AccessControlList` document keyed by that exact uid - there being no such document at all (the case for
87
+ * `Booking`, whose `@Protect` sets `recordACL: false`) makes `hasPermission()` always answer `false`. So
88
+ * `VideoMeetingMongo`/`VideoMeetingSQL` set `recordACL: true`, and `create()` below claims that per-record ACL
89
+ * with `parentUid` set to the meeting's own `mailboxUid` (mirroring `BaseFolderRoute`'s identical `acl: { uid,
90
+ * parentUid: mailboxUid, records: [] }` pattern) - which is also what lets a mailbox owner/delegate reach the
91
+ * meeting (and its channel) through the ordinary ACL parent-chain, with no extra mechanism needed on top of what
92
+ * `RepoUtils.create()`'s automatic per-record ACL claim already does (it grants the creator full rights on the
93
+ * fresh ACL - see `RepoUtils.claimRecordACL()`).
94
+ *
95
+ * ## The channel-ACL-grant mechanism
96
+ *
97
+ * An anonymous guest holds no `AccessControlList` grant of their own and has no `JWTUser` to authenticate `/push`
98
+ * with in the first place - `join()` therefore does two things together: it mints a short-lived, scope-limited
99
+ * guest JWT (`mintGuestToken()`: a synthetic `guest:<random>` uid, no roles, `GUEST_JWT_TTL_SECONDS` expiry, real and
100
+ * verifiable since it's signed with the same `auth` config every other token is), and it adds an explicit
101
+ * `ACLRecord` for that exact uid onto the meeting's own `AccessControlList` (`ensureChannelGrant()`), granting
102
+ * `READ` (so `BasePushRoute`'s SUBSCRIBE succeeds) and `CREATE` (so a publish does - see `MailPushRoute`'s doc
103
+ * comment: "publishing to a channel needs CREATE on it as an ordinary user would"). The grant is scoped to exactly
104
+ * that one meeting's uid, nothing else - the same "possession of a link is the credential" pattern
105
+ * `BaseBookingRoute.requireBookingByToken()`/`resolveEffectiveUser()`'s `share:<token>` identity already establish
106
+ * elsewhere in this codebase, extended one step further because signaling needs a channel *subscription*, not just
107
+ * a stateless REST call.
108
+ *
109
+ * **Known limitation, documented rather than silently assumed away**: because each `join()` call for an anonymous
110
+ * caller mints a *fresh* random guest uid (so simultaneous participants of one shared link are distinguishable from
111
+ * each other in the signaling channel), each such join adds one more `ACLRecord` to the meeting's own ACL document,
112
+ * and nothing in Phase 1 ever removes one. A meeting joined many times over its lifetime accumulates unused records;
113
+ * there is no GC job, matching this codebase's own precedent for `Booking.manageToken` (documented as never
114
+ * expiring, no GC job either). Since the guest JWTs themselves expire, an accumulated record is inert (unusable)
115
+ * well before it becomes a real concern - a cleanup pass is a reasonable thing for a later phase to add, not a
116
+ * Phase 1 requirement. An already-authenticated real caller (see below) is granted their own stable uid instead, so
117
+ * repeated joins by the same real identity never add more than the one record `ensureChannelGrant()`'s own
118
+ * idempotency check already collapses them to.
119
+ *
120
+ * ## Real, already-authenticated callers (the browser-session-collision fix)
121
+ *
122
+ * `join()` also accepts an optional `@AuthUser`: whatever `req.user` the framework's own `JWTStrategy` already
123
+ * populated from the caller's existing `Authorization` header or `jwt` cookie, exactly like every other
124
+ * authenticated-optional endpoint in this codebase's family (e.g. `BaseScopedChildRoute.resolveEffectiveUser()`'s
125
+ * "prefer the real authenticated user, fall back to the anonymous token" precedent). This matters because a
126
+ * logged-in RapidMX user's browser already carries a real, `HttpOnly` `jwt` session cookie for this origin - a
127
+ * cookie `apps/shared/push/GuestSignalingClient.ts` cannot overwrite with a guest token even if `join()` minted one
128
+ * (browsers refuse to let a script override an `HttpOnly` cookie of the same name), so a guest-only `join()` would
129
+ * leave that browser's `/push` WebSocket authenticating as the real session while the meeting's ACL only names a
130
+ * synthetic guest uid - the subscribe is simply, safely refused, and the real user could never actually join.
131
+ *
132
+ * The fix: when `user` is present and is a *real* identity - not a guest uid from a previous `join()` call, told
133
+ * apart by the `GUEST_UID_PREFIX` a guest uid always starts with and a real, mailbox-owning identity never can -
134
+ * `join()` grants `user.uid` itself (not a synthetic one) `READ`/`CREATE` on the meeting's channel via
135
+ * `ensureChannelGrant()`, mints no guest JWT at all, and returns `authenticated: true` with `selfUid: user.uid`. The
136
+ * caller's own already-existing session cookie now already authenticates `/push` for them with zero new
137
+ * client-side auth handling - the frontend must not write a cookie of its own in this case (see
138
+ * `VideoMeetingJoinResult`'s doc comment). When `user` is absent (the common, true-anonymous case - no existing
139
+ * session at all), behavior is exactly Phase 1's: a fresh guest identity is minted and granted instead.
140
+ *
141
+ * ## The organizer's own slug (`VideoMeeting.organizerSlug`)
142
+ *
143
+ * The fix above lets a real, already-authenticated caller join *if they already hold something that resolves to the
144
+ * meeting*. The organizer of their own private meeting does not: the calendar integration that mints a meeting per
145
+ * event builds `invitees` from the event's attendees **excluding the organizer** (who manages the meeting through
146
+ * ownership, not as a guest), and `publicSlug` is minted only for a public meeting - so the one person who owns the
147
+ * meeting had no token `requireMeetingByToken()` could resolve for them at all. `persistMeeting()` therefore also
148
+ * mints an `organizerSlug` for every private meeting, `create()` returns it as `organizerJoinUrl` alongside the
149
+ * per-invitee links, and `findById()` returns the same link for a meeting loaded later.
150
+ *
151
+ * **This does not widen the `"private"` invariant by one caller.** The other two resolutions are credentials in
152
+ * themselves - possession of an invitee `joinToken` or a `publicSlug` is exactly what authorizes the join, by
153
+ * design. An `organizerSlug` is not: `requireMeetingByToken()` reports *which* field resolved the match, and for
154
+ * `"organizerSlug"` `join()` requires, before computing or returning anything about the meeting, both that the
155
+ * caller is a real already-authenticated identity (the same non-guest `GUEST_UID_PREFIX` check as above, so a
156
+ * returning guest presenting a prior `join()`'s own guest JWT never qualifies) and that this identity holds `READ`
157
+ * on the meeting's own `mailboxUid` - the very same `ACLUtils.hasPermission()` call, trusted roles stripped, that
158
+ * `requireMailboxAccess()` makes for every owner-side route, so a trusted administrator with no explicit grant is
159
+ * refused here exactly as they are there. Every caller who fails either condition - a true anonymous stranger, a
160
+ * returning guest, or a real but unrelated logged-in user - gets the identical bare `404` an entirely unknown token
161
+ * gets, never a `403`: this class never leaks whether a token almost-matched something, and an
162
+ * organizer-slug-shaped probe must be indistinguishable from a slug naming nothing at all. A caller who satisfies
163
+ * both proceeds through exactly the authenticated branch described above, with no new response field.
164
+ *
165
+ * ## Recovering a join link on a later read (`find()`/`findById()`, Phase 4)
166
+ *
167
+ * `create()`'s response computes `organizerJoinUrl`/`publicJoinUrl` inline, once, from the slug it just minted -
168
+ * fine for the moment of creation, but Phase 4's settings page (`apps/settings-video-conferencing`) needs a
169
+ * meeting's own persistent link on every later page load too, not only the one response `create()` ever sent (a
170
+ * user's "personal room", by this codebase's Phase 4 convention, is simply their oldest non-cancelled `PUBLIC`
171
+ * meeting - see `.claude/NOTES.md`'s Phase 4 entry for why no new field/route was needed to name it as such). Since
172
+ * a plain `RepoUtils.find()`/`findOne()` returns only the persisted slug columns, `find()` and `findById()` both
173
+ * now run every loaded meeting through `withJoinUrls()` - the exact same computation `create()` already did,
174
+ * applied uniformly on read instead of only once on write.
175
+ *
176
+ * ## Other known limitations
177
+ *
178
+ * **`VideoMeetingInvitee.joinToken` never expires** and has no GC job - identical tradeoff to `Booking.manageToken`.
179
+ *
180
+ * **`VideoMeeting.publicSlug` is only uniqueness-checked within its own mailbox** by the database, while `join()`'s
181
+ * lookup is global (the public join URL carries no mailbox segment) - see the `VideoMeeting.publicSlug` doc comment
182
+ * for the full reasoning; a cross-mailbox collision is not actually prevented, only made astronomically unlikely by
183
+ * the slug's own entropy. `VideoMeeting.organizerSlug` has the same shape and entropy but is indexed unique
184
+ * *globally*, which is exactly the scope its own lookup uses - see its doc comment for why a per-mailbox compound
185
+ * index cannot work for a field only half the rows carry. Less rides on it either way: resolving through it grants
186
+ * nothing by itself, so an unlucky collision there would cost a caller a `404`, never access.
187
+ *
188
+ * @author Jean-Philippe Steinmetz
189
+ */
190
+ export class BaseVideoMeetingRoute {
191
+ constructor() {
192
+ this.trustedRoles = ["admin"];
193
+ /** The externally reachable base URL of the public join pages, used to build the invite links `create()`
194
+ * returns. Same single-value-config pattern as `mail:booking:public_url`/`mail:autodiscover:public_url`; when
195
+ * unset (or unsafe - see `buildBaseUrl()`) a join link is simply omitted rather than returning a broken one. */
196
+ this.publicUrl = "";
197
+ this.turnUrl = "";
198
+ this.turnUsername = "";
199
+ this.turnCredential = "";
200
+ this.turnSharedSecret = "";
201
+ }
202
+ /**
203
+ * Exposes the `@Model(...)`-supplied entity class as an instance property so `@Transactional()` on
204
+ * `persistMeeting()` can resolve which datasource to open a transaction against - identical to
205
+ * `BaseBookingRoute`'s own `modelClass` getter, for the same reason (this class deliberately doesn't extend
206
+ * `ModelRoute`, which defines the same getter for its own subclasses).
207
+ */
208
+ get modelClass() {
209
+ return this.constructor.modelClass;
210
+ }
211
+ async init() {
212
+ if (!this.meetingRepo) {
213
+ this.meetingRepo = await this._objectFactory.newInstance(RepoUtils, { name: this.meetingClass.name, args: [this.meetingClass] });
214
+ }
215
+ if (!this.inviteeRepo) {
216
+ this.inviteeRepo = await this._objectFactory.newInstance(RepoUtils, { name: this.inviteeClass.name, args: [this.inviteeClass] });
217
+ }
218
+ if (!this.mailboxRepo) {
219
+ this.mailboxRepo = await this._objectFactory.newInstance(RepoUtils, { name: this.mailboxClass.name, args: [this.mailboxClass] });
220
+ }
221
+ }
222
+ /** Rejects a `403` unless `user` (with its trusted roles stripped - see `util/RouteAccessUtils.ts`) holds
223
+ * `action` on `mailboxUid`, by ownership or an explicit ACL grant. */
224
+ async requireMailboxAccess(mailboxUid, user, action) {
225
+ if (!mailboxUid || !(await this.aclUtils.hasPermission(stripTrustedRoles(user, this.trustedRoles), mailboxUid, action))) {
226
+ throw new ApiError(ApiErrors.AUTH_PERMISSION_FAILURE, 403, ApiErrorMessages.AUTH_PERMISSION_FAILURE);
227
+ }
228
+ }
229
+ /** Loads the meeting `id` names, `404` if it doesn't exist, then enforces `requireMailboxAccess()` against its
230
+ * `mailboxUid`. Used by every owner-side method that acts on a single existing meeting. */
231
+ async requireOwnedMeeting(id, user, action) {
232
+ const meeting = await this.meetingRepo.findOne(id, { ignoreACL: true });
233
+ if (!meeting) {
234
+ throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
235
+ }
236
+ await this.requireMailboxAccess(meeting.mailboxUid, user, action);
237
+ return meeting;
238
+ }
239
+ /** Pure shape/format validation of `create()`'s body - independent of any permission check, matching
240
+ * `BaseBookingRoute.validateBook()`'s split between format checks (here) and business-rule checks (in the
241
+ * handler itself, which needs a DB round trip this does not). */
242
+ validateCreateBody(body) {
243
+ if (typeof body?.mailboxUid !== "string" || !body.mailboxUid) {
244
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "'mailboxUid' is required.");
245
+ }
246
+ if (typeof body.title !== "string" || !body.title.trim()) {
247
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "'title' is required.");
248
+ }
249
+ if (body.title.trim().length > MAX_TITLE_LENGTH) {
250
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, `'title' must be at most ${MAX_TITLE_LENGTH} characters.`);
251
+ }
252
+ if (body.visibility !== VideoMeetingVisibility.PRIVATE && body.visibility !== VideoMeetingVisibility.PUBLIC) {
253
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "'visibility' must be 'private' or 'public'.");
254
+ }
255
+ if (body.visibility === VideoMeetingVisibility.PRIVATE) {
256
+ const invitees = body.invitees ?? [];
257
+ if (!Array.isArray(invitees) || invitees.length === 0) {
258
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "A private meeting requires at least one invitee.");
259
+ }
260
+ if (invitees.length > MAX_INVITEES) {
261
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, `A meeting may have at most ${MAX_INVITEES} invitees.`);
262
+ }
263
+ for (const invitee of invitees) {
264
+ if (typeof invitee?.email !== "string" ||
265
+ invitee.email.trim().length > MAX_EMAIL_LENGTH ||
266
+ !EMAIL_PATTERN.test(invitee.email.trim())) {
267
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "Each invitee requires a valid 'email'.");
268
+ }
269
+ if (invitee.displayName != null && (typeof invitee.displayName !== "string" || invitee.displayName.length > MAX_DISPLAY_NAME_LENGTH)) {
270
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, `'displayName' must be a string of at most ${MAX_DISPLAY_NAME_LENGTH} characters.`);
271
+ }
272
+ }
273
+ }
274
+ return { mailboxUid: body.mailboxUid, visibility: body.visibility };
275
+ }
276
+ /** Parses a caller-supplied ISO timestamp, rejecting anything unparseable with a `400` - matches
277
+ * `BaseBookingRoute.requireDate()`. `startTime`/`endTime` are optional, so this is only called when present. */
278
+ requireOptionalDate(value, fieldName) {
279
+ if (value === undefined) {
280
+ return undefined;
281
+ }
282
+ const parsed = new Date(value);
283
+ if (isNaN(parsed.valueOf())) {
284
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, `'${fieldName}' must be a valid ISO 8601 date/time.`);
285
+ }
286
+ return parsed;
287
+ }
288
+ /**
289
+ * Writes the `VideoMeeting`/`VideoMeetingInvitee` rows for a new meeting. `@Transactional()` (resolving its
290
+ * datasource from the `@Model(...)` on the concrete subclass, via the `modelClass` getter above) makes every
291
+ * write atomic, matching `BaseBookingRoute.persistBooking()`'s identical reasoning.
292
+ */
293
+ async persistMeeting(mailboxUid, body, visibility, startTime, endTime, user) {
294
+ const strippedUser = stripTrustedRoles(user, this.trustedRoles);
295
+ const publicSlug = visibility === VideoMeetingVisibility.PUBLIC ? mintPublicSlug() : undefined;
296
+ // The organizer is deliberately never one of their own private meeting's `invitees` (see
297
+ // `VideoMeeting.organizerSlug`), so a private meeting also mints the one slug that resolves to it for them.
298
+ // Same mint function - and so the same shape/entropy - as `publicSlug`: the two live in separate columns, so
299
+ // the only collision namespace either shares is its own, exactly as in Phase 1.
300
+ const organizerSlug = visibility === VideoMeetingVisibility.PRIVATE ? mintPublicSlug() : undefined;
301
+ // Constructed before `create()` is called (rather than inline) because its own, already-generated `uid`
302
+ // (every `BaseEntity` mints one on construction) is what `acl.uid` below claims the meeting's own
303
+ // per-record ACL under - mirroring `BaseFolderRoute.create()`'s identical `acl: { uid: instance.uid, ... }`
304
+ // pattern exactly.
305
+ const instance = new this.meetingClass({
306
+ mailboxUid,
307
+ title: body.title.trim(),
308
+ visibility,
309
+ calendarEventUid: body.calendarEventUid?.trim() || undefined,
310
+ status: VideoMeetingStatus.SCHEDULED,
311
+ startTime,
312
+ endTime,
313
+ publicSlug,
314
+ organizerSlug,
315
+ });
316
+ const meeting = await this.meetingRepo.create(instance, {
317
+ user: strippedUser,
318
+ ignoreACL: true,
319
+ acl: { uid: instance.uid, parentUid: mailboxUid, records: [] },
320
+ });
321
+ const invitees = [];
322
+ if (visibility === VideoMeetingVisibility.PRIVATE) {
323
+ // `validateCreateBody()` already guarantees a non-empty array here for a private meeting.
324
+ for (const raw of body.invitees) {
325
+ invitees.push(await this.inviteeRepo.create(new this.inviteeClass({
326
+ meetingUid: meeting.uid,
327
+ mailboxUid,
328
+ email: raw.email.trim().toLowerCase(),
329
+ displayName: raw.displayName?.trim() || undefined,
330
+ joinToken: mintJoinToken(),
331
+ }), { user: strippedUser, ignoreACL: true }));
332
+ }
333
+ }
334
+ return { meeting, invitees };
335
+ }
336
+ /** The join URL for a token/slug, or `undefined` when no public URL is configured - see this class's doc
337
+ * comment on `mail:videoconf:public_url`. */
338
+ joinUrl(tokenOrSlug) {
339
+ const base = buildBaseUrl(this.publicUrl);
340
+ return base ? `${base}/${tokenOrSlug}` : undefined;
341
+ }
342
+ async create(rawBody, req, user) {
343
+ await this.init();
344
+ const { mailboxUid, visibility } = this.validateCreateBody(rawBody);
345
+ const body = rawBody;
346
+ const startTime = this.requireOptionalDate(body.startTime, "startTime");
347
+ const endTime = this.requireOptionalDate(body.endTime, "endTime");
348
+ await this.requireMailboxAccess(mailboxUid, user, ACLAction.CREATE);
349
+ const { meeting, invitees } = await this.persistMeeting(mailboxUid, body, visibility, startTime, endTime, user);
350
+ const result = { meeting };
351
+ if (visibility === VideoMeetingVisibility.PRIVATE) {
352
+ result.invitees = invitees.map((invitee) => ({
353
+ uid: invitee.uid,
354
+ email: invitee.email,
355
+ displayName: invitee.displayName,
356
+ joinUrl: this.joinUrl(invitee.joinToken),
357
+ }));
358
+ }
359
+ else {
360
+ // `persistMeeting()` always mints `publicSlug` for a public meeting.
361
+ result.publicJoinUrl = this.joinUrl(meeting.publicSlug);
362
+ }
363
+ if (meeting.organizerSlug) {
364
+ result.organizerJoinUrl = this.joinUrl(meeting.organizerSlug);
365
+ }
366
+ return result;
367
+ }
368
+ /**
369
+ * Adds `organizerJoinUrl`/`publicJoinUrl` (see `joinUrl()`) to a persisted meeting for a caller re-reading it
370
+ * later. `create()`'s own response computes the same links inline from values it just minted, but `find()`/
371
+ * `findById()` load the plain persisted entity, which carries only the slugs themselves - so a caller who
372
+ * didn't keep `create()`'s one-time response (e.g. this plugin's own settings page, reloaded after the meeting
373
+ * that IS a user's "personal room" - see `.claude/NOTES.md`'s Phase 4 entry - was created in an earlier visit)
374
+ * would otherwise have no way to recover a public meeting's shareable link, or a private meeting's organizer
375
+ * link, at all. Spreads rather than mutates: the loaded instance is the repo's own entity, and both fields are
376
+ * response-only.
377
+ */
378
+ withJoinUrls(meeting) {
379
+ const result = { ...meeting };
380
+ if (meeting.organizerSlug) {
381
+ result.organizerJoinUrl = this.joinUrl(meeting.organizerSlug);
382
+ }
383
+ if (meeting.publicSlug) {
384
+ result.publicJoinUrl = this.joinUrl(meeting.publicSlug);
385
+ }
386
+ return result;
387
+ }
388
+ async find(mailboxUid, limit, page, user) {
389
+ await this.init();
390
+ if (typeof mailboxUid !== "string" || !mailboxUid) {
391
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, ApiErrorMessages.INVALID_REQUEST);
392
+ }
393
+ await this.requireMailboxAccess(mailboxUid, user, ACLAction.LIST);
394
+ const parsedLimit = limit ? Number(limit) : undefined;
395
+ const parsedPage = page ? Number(page) : undefined;
396
+ // `limit`/`page` are passed both in the criteria (which the SQL query builder reads them from) and in
397
+ // `options` (which the Mongo backend, and the ACL-filtered result trimming, read them from) - matching
398
+ // every other paged `RepoUtils.find()` call in this codebase (e.g. `BaseBookingRoute.findAllEvents()`).
399
+ const meetings = await this.meetingRepo.find({ mailboxUid: ModelUtils.literal(mailboxUid), limit: parsedLimit, page: parsedPage }, { ignoreACL: true, limit: parsedLimit, page: parsedPage });
400
+ return meetings.map((meeting) => this.withJoinUrls(meeting));
401
+ }
402
+ async findById(id, user) {
403
+ await this.init();
404
+ const meeting = await this.requireOwnedMeeting(id, user, ACLAction.READ);
405
+ return this.withJoinUrls(meeting);
406
+ }
407
+ async update(id, body, user) {
408
+ await this.init();
409
+ const meeting = await this.requireOwnedMeeting(id, user, ACLAction.UPDATE);
410
+ const patch = { uid: meeting.uid, version: meeting.version };
411
+ if (body?.title !== undefined) {
412
+ if (typeof body.title !== "string" || !body.title.trim() || body.title.trim().length > MAX_TITLE_LENGTH) {
413
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, `'title' must be a non-empty string of at most ${MAX_TITLE_LENGTH} characters.`);
414
+ }
415
+ patch.title = body.title.trim();
416
+ }
417
+ if (body?.status !== undefined) {
418
+ if (body.status !== VideoMeetingStatus.CANCELLED) {
419
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "'status' may only be set to 'cancelled'.");
420
+ }
421
+ patch.status = VideoMeetingStatus.CANCELLED;
422
+ }
423
+ if (patch.title === undefined && patch.status === undefined) {
424
+ throw new ApiError(ApiErrors.INVALID_REQUEST, 400, "Nothing to update: supply 'title' and/or 'status'.");
425
+ }
426
+ return await this.meetingRepo.update(patch, meeting, { user: stripTrustedRoles(user, this.trustedRoles), ignoreACL: true });
427
+ }
428
+ async delete(id, user) {
429
+ await this.init();
430
+ const meeting = await this.requireOwnedMeeting(id, user, ACLAction.DELETE);
431
+ const strippedUser = stripTrustedRoles(user, this.trustedRoles);
432
+ const invitees = await this.inviteeRepo.find({ meetingUid: ModelUtils.literal(meeting.uid) }, { ignoreACL: true });
433
+ for (const invitee of invitees) {
434
+ await this.inviteeRepo.delete(invitee.uid, { user: strippedUser, ignoreACL: true });
435
+ }
436
+ await this.meetingRepo.delete(meeting.uid, { user: strippedUser, ignoreACL: true });
437
+ }
438
+ /**
439
+ * Resolves `token` to the meeting it names, and to *how* it named it: an invitee's `joinToken` (43 base64url
440
+ * characters - `JOIN_TOKEN_PATTERN`), or one of the two 11-character slugs (`PUBLIC_SLUG_PATTERN`) - a public
441
+ * meeting's `publicSlug` or a private meeting's `organizerSlug`. The token and slug lengths never overlap (see
442
+ * `util/TokenUtils.ts`), so an invitee token is never tried as a slug or vice versa - unlike trying both in
443
+ * sequence, this can't accidentally treat one as the other just because the lookup that should have matched
444
+ * happened to miss. A stale/unknown/malformed/wrongly-shaped token, a cancelled meeting, or a meeting whose
445
+ * visibility no longer matches how the token was resolved (defense in depth - a meeting's `visibility` cannot
446
+ * actually change after creation) all answer identically: a plain `404`, matching
447
+ * `BaseBookingRoute.requireBookingByToken()`'s exact posture of never leaking whether a token almost-matched
448
+ * something.
449
+ *
450
+ * The two slug columns are disjoint by construction (`persistMeeting()` mints `publicSlug` only for a public
451
+ * meeting and `organizerSlug` only for a private one), so a slug-shaped token is looked up against `publicSlug`
452
+ * first - exactly Phase 1's lookup, with exactly Phase 1's outcome whenever it matches a row at all - and only
453
+ * a token that matches no `publicSlug` row is then looked up against `organizerSlug`.
454
+ *
455
+ * The resolution is returned alongside the meeting because `join()` authorizes the three cases differently: an
456
+ * invitee token and a `publicSlug` are each a self-contained credential ("possession of the link"), while an
457
+ * `organizerSlug` is not - see `join()`'s doc comment and `VideoMeeting.organizerSlug`.
458
+ */
459
+ async requireMeetingByToken(token) {
460
+ /* v8 ignore if -- unreachable via real usage: `@Param("token")` always supplies a string (a URL path
461
+ segment can't be anything else); this guards only a directly-invoked, non-HTTP call. */
462
+ if (typeof token !== "string") {
463
+ throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
464
+ }
465
+ if (JOIN_TOKEN_PATTERN.test(token)) {
466
+ const matches = await this.inviteeRepo.find({ joinToken: ModelUtils.literal(token) }, {
467
+ ignoreACL: true,
468
+ limit: 1,
469
+ skipCache: true,
470
+ });
471
+ const invitee = matches[0];
472
+ if (!invitee) {
473
+ throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
474
+ }
475
+ const meeting = await this.meetingRepo.findOne(invitee.meetingUid, { ignoreACL: true });
476
+ if (!meeting || meeting.visibility !== VideoMeetingVisibility.PRIVATE || meeting.status === VideoMeetingStatus.CANCELLED) {
477
+ throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
478
+ }
479
+ return { meeting, resolvedVia: "invitee" };
480
+ }
481
+ if (PUBLIC_SLUG_PATTERN.test(token)) {
482
+ const publicMatches = await this.meetingRepo.find({ publicSlug: ModelUtils.literal(token) }, {
483
+ ignoreACL: true,
484
+ limit: 1,
485
+ skipCache: true,
486
+ });
487
+ const publicMeeting = publicMatches[0];
488
+ if (publicMeeting) {
489
+ if (publicMeeting.visibility !== VideoMeetingVisibility.PUBLIC || publicMeeting.status === VideoMeetingStatus.CANCELLED) {
490
+ throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
491
+ }
492
+ return { meeting: publicMeeting, resolvedVia: "publicSlug" };
493
+ }
494
+ const organizerMatches = await this.meetingRepo.find({ organizerSlug: ModelUtils.literal(token) }, {
495
+ ignoreACL: true,
496
+ limit: 1,
497
+ skipCache: true,
498
+ });
499
+ const organizerMeeting = organizerMatches[0];
500
+ if (!organizerMeeting || organizerMeeting.visibility !== VideoMeetingVisibility.PRIVATE || organizerMeeting.status === VideoMeetingStatus.CANCELLED) {
501
+ throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
502
+ }
503
+ return { meeting: organizerMeeting, resolvedVia: "organizerSlug" };
504
+ }
505
+ throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
506
+ }
507
+ /**
508
+ * Adds an `ACLRecord` granting `uid` `READ`/`CREATE` on `meetingUid`'s own `AccessControlList`, unless one
509
+ * already exists (idempotent - a retried/duplicate call for the same uid, guest or real, is a no-op). Retries a
510
+ * handful of times on an optimistic-lock conflict (`saveACL()`'s version check): a public meeting can be joined
511
+ * by several callers at once, each racing to add their own record to the very same ACL document, and a lost
512
+ * race must be retried against the freshly re-read version rather than surfaced to the caller as an error.
513
+ * Named generically (not `ensureGuestChannelGrant()`, its Phase 1 name) since `join()` now calls this for a
514
+ * real, already-authenticated caller's own uid too - see this class's doc comment on "Real, already-
515
+ * authenticated callers".
516
+ */
517
+ async ensureChannelGrant(meetingUid, uid) {
518
+ for (let attempt = 0; attempt < GUEST_GRANT_MAX_ATTEMPTS; attempt++) {
519
+ const acl = await this.aclUtils.findACL(meetingUid, [], { skipCache: true, skipParents: true });
520
+ /* v8 ignore if -- unreachable via real usage: the meeting's own per-record ACL is claimed at creation
521
+ time (see `persistMeeting()`) and never removed except alongside the meeting itself (see `delete()`)
522
+ - `requireMeetingByToken()` already proved the meeting still exists, so one is always found here. */
523
+ if (!acl) {
524
+ throw new ApiError(ApiErrors.INTERNAL_ERROR, 500, ApiErrorMessages.INTERNAL_ERROR);
525
+ }
526
+ if (acl.records.some((record) => record.userOrRoleId === uid)) {
527
+ return;
528
+ }
529
+ acl.records.push({ userOrRoleId: uid, actions: [ACLAction.READ, ACLAction.CREATE] });
530
+ try {
531
+ await this.aclUtils.saveACL(acl);
532
+ return;
533
+ }
534
+ catch (err) {
535
+ if (attempt === GUEST_GRANT_MAX_ATTEMPTS - 1 || !/must be of the same version/.test(err?.message ?? "")) {
536
+ throw err;
537
+ }
538
+ // A concurrent joiner (or the owner, editing the meeting's own ACL directly) saved first - loop
539
+ // around and retry against the freshly re-read version.
540
+ }
541
+ }
542
+ }
543
+ /**
544
+ * Mints a short-lived, scope-limited guest identity - see this class's doc comment on the channel-ACL-grant
545
+ * mechanism. Only called for the true-anonymous case (`join()`'s `user` is absent, or presents a prior guest
546
+ * uid rather than a real one). The deployment's real `auth` config normally carries its own `options.expiresIn`
547
+ * (every other token's session length), which `jsonwebtoken` refuses to combine with an explicit `exp` claim in
548
+ * the payload ("Bad 'options.expiresIn' option the payload already has an 'exp' property") - so this signs with
549
+ * a shallow copy of `authConfig` that omits `options.expiresIn`, letting the payload's own `exp` (this guest
550
+ * token's own, shorter `GUEST_JWT_TTL_SECONDS` lifetime) govern instead. Everything else about `authConfig` -
551
+ * the secret, the algorithm, `audience`/`issuer` - is unchanged, so this guest token verifies through the exact
552
+ * same `JWTStrategy` every other token does.
553
+ */
554
+ mintGuestToken() {
555
+ const guestUid = `${GUEST_UID_PREFIX}${crypto.randomBytes(16).toString("base64url")}`;
556
+ const expiresAt = new Date(Date.now() + GUEST_JWT_TTL_SECONDS * 1000);
557
+ const { expiresIn: _expiresIn, ...guestOptions } = this.authConfig?.options ?? {};
558
+ const guestAuthConfig = { ...this.authConfig, options: guestOptions };
559
+ const token = JWTUtils.createTokenSync(guestAuthConfig, { uid: guestUid, roles: [], scopes: [] }, { sessionUid: crypto.randomUUID(), exp: Math.floor(expiresAt.getTime() / 1000) });
560
+ return { guestUid, token, expiresAt };
561
+ }
562
+ /** The host mailbox's `displayName`, or `undefined` when the mailbox has none (or no longer exists - an
563
+ * orphaned meeting should still be joinable, just without a host name to show). */
564
+ async hostDisplayName(mailboxUid) {
565
+ const mailbox = await this.mailboxRepo.findOne(mailboxUid, { ignoreACL: true });
566
+ return mailbox?.displayName || undefined;
567
+ }
568
+ async join(token, user) {
569
+ await this.init();
570
+ const { meeting, resolvedVia } = await this.requireMeetingByToken(token);
571
+ // An `organizerSlug` is not a credential of its own (unlike an invitee `joinToken` or a `publicSlug`): it
572
+ // only exists so the owner of a private meeting has something that resolves to it at all, since they are
573
+ // deliberately never one of its invitees. So it is gated here on BOTH conditions, before anything about
574
+ // the meeting is computed or returned: a real, already-authenticated identity (the same non-guest check
575
+ // the authenticated branch below uses - a returning guest presenting a prior join()'s guest JWT is not
576
+ // one), AND that identity actually holding READ on this meeting's own mailbox, with its trusted roles
577
+ // stripped first exactly as `requireMailboxAccess()` does. A failure answers the bare 404
578
+ // `requireMeetingByToken()` already throws for a token that matched nothing whatsoever - deliberately NOT
579
+ // `requireMailboxAccess()`'s 403, which would tell an anonymous prober that this slug named a real meeting.
580
+ // Nothing here changes how an invitee token or a publicSlug resolves.
581
+ if (resolvedVia === "organizerSlug") {
582
+ const isRealCaller = !!user && !user.uid.startsWith(GUEST_UID_PREFIX);
583
+ if (!isRealCaller || !(await this.aclUtils.hasPermission(stripTrustedRoles(user, this.trustedRoles), meeting.mailboxUid, ACLAction.READ))) {
584
+ throw new ApiError(ApiErrors.NOT_FOUND, 404, ApiErrorMessages.NOT_FOUND);
585
+ }
586
+ }
587
+ const publicMeeting = {
588
+ uid: meeting.uid,
589
+ title: meeting.title,
590
+ visibility: meeting.visibility,
591
+ status: meeting.status,
592
+ hostDisplayName: await this.hostDisplayName(meeting.mailboxUid),
593
+ };
594
+ const iceServers = buildIceServers({
595
+ url: this.turnUrl,
596
+ username: this.turnUsername,
597
+ credential: this.turnCredential,
598
+ sharedSecret: this.turnSharedSecret,
599
+ });
600
+ // A real, already-authenticated RapidMX identity - never a guest uid from an earlier join() call
601
+ // presenting its own guest JWT back (a guest uid is never a valid mailbox-owning identity anyway, so this
602
+ // prefix check is a safe, cheap discriminator - see this class's doc comment and GUEST_UID_PREFIX).
603
+ if (user && !user.uid.startsWith(GUEST_UID_PREFIX)) {
604
+ await this.ensureChannelGrant(meeting.uid, user.uid);
605
+ return { meeting: publicMeeting, iceServers, authenticated: true, selfUid: user.uid };
606
+ }
607
+ const { guestUid, token: guestToken, expiresAt } = this.mintGuestToken();
608
+ await this.ensureChannelGrant(meeting.uid, guestUid);
609
+ return {
610
+ meeting: publicMeeting,
611
+ iceServers,
612
+ authenticated: false,
613
+ selfUid: guestUid,
614
+ token: guestToken,
615
+ expiresAt: expiresAt.toISOString(),
616
+ };
617
+ }
618
+ }
619
+ __decorate([
620
+ Inject(ACLUtils),
621
+ __metadata("design:type", ACLUtils)
622
+ ], BaseVideoMeetingRoute.prototype, "aclUtils", void 0);
623
+ __decorate([
624
+ Config("trusted_roles", ["admin"]),
625
+ __metadata("design:type", Array)
626
+ ], BaseVideoMeetingRoute.prototype, "trustedRoles", void 0);
627
+ __decorate([
628
+ Config("auth"),
629
+ __metadata("design:type", Object)
630
+ ], BaseVideoMeetingRoute.prototype, "authConfig", void 0);
631
+ __decorate([
632
+ Config("mail:videoconf:public_url", ""),
633
+ __metadata("design:type", String)
634
+ ], BaseVideoMeetingRoute.prototype, "publicUrl", void 0);
635
+ __decorate([
636
+ Config("mail:videoconf:turn:url", ""),
637
+ __metadata("design:type", String)
638
+ ], BaseVideoMeetingRoute.prototype, "turnUrl", void 0);
639
+ __decorate([
640
+ Config("mail:videoconf:turn:username", ""),
641
+ __metadata("design:type", String)
642
+ ], BaseVideoMeetingRoute.prototype, "turnUsername", void 0);
643
+ __decorate([
644
+ Config("mail:videoconf:turn:credential", ""),
645
+ __metadata("design:type", String)
646
+ ], BaseVideoMeetingRoute.prototype, "turnCredential", void 0);
647
+ __decorate([
648
+ Config("mail:videoconf:turn:shared_secret", ""),
649
+ __metadata("design:type", String)
650
+ ], BaseVideoMeetingRoute.prototype, "turnSharedSecret", void 0);
651
+ __decorate([
652
+ Logger,
653
+ __metadata("design:type", Object)
654
+ ], BaseVideoMeetingRoute.prototype, "logger", void 0);
655
+ __decorate([
656
+ Transactional(),
657
+ __metadata("design:type", Function),
658
+ __metadata("design:paramtypes", [String, Object, String, Object, Object, Object]),
659
+ __metadata("design:returntype", Promise)
660
+ ], BaseVideoMeetingRoute.prototype, "persistMeeting", null);
661
+ __decorate([
662
+ Summary("Creates a video meeting."),
663
+ Description("Creates a video meeting owned by the given mailbox. A 'private' meeting requires at least one invitee, " +
664
+ "each minted their own join link; a 'public' meeting mints a single shareable join link. Requires " +
665
+ "CREATE on the owning mailbox."),
666
+ Post(),
667
+ __param(1, Request),
668
+ __param(2, AuthUser),
669
+ __metadata("design:type", Function),
670
+ __metadata("design:paramtypes", [Object, Object, Object]),
671
+ __metadata("design:returntype", Promise)
672
+ ], BaseVideoMeetingRoute.prototype, "create", null);
673
+ __decorate([
674
+ Summary("Lists the owner's video meetings."),
675
+ Description("Returns the meetings owned by the given mailbox, each carrying 'organizerJoinUrl'/'publicJoinUrl' " +
676
+ "exactly as findById() would for that meeting (see withJoinUrls()). Requires LIST on that mailbox."),
677
+ Get(),
678
+ __param(0, Query("mailboxUid")),
679
+ __param(1, Query("limit")),
680
+ __param(2, Query("page")),
681
+ __param(3, AuthUser),
682
+ __metadata("design:type", Function),
683
+ __metadata("design:paramtypes", [Object, Object, Object, Object]),
684
+ __metadata("design:returntype", Promise)
685
+ ], BaseVideoMeetingRoute.prototype, "find", null);
686
+ __decorate([
687
+ Summary("Retrieves one of the owner's video meetings."),
688
+ Description("Returns the meeting, plus 'organizerJoinUrl' when it has an organizerSlug (every private meeting this " +
689
+ "route created) and/or 'publicJoinUrl' when it has a publicSlug - so a caller loading an existing " +
690
+ "meeting later still gets a working link, not only the one create() returned once. Requires READ on " +
691
+ "the meeting's owning mailbox."),
692
+ Get("/:id"),
693
+ __param(0, Param("id")),
694
+ __param(1, AuthUser),
695
+ __metadata("design:type", Function),
696
+ __metadata("design:paramtypes", [String, Object]),
697
+ __metadata("design:returntype", Promise)
698
+ ], BaseVideoMeetingRoute.prototype, "findById", null);
699
+ __decorate([
700
+ Summary("Updates a video meeting's title, or cancels it."),
701
+ Description("Deliberately minimal for Phase 1: only 'title' and cancellation ('status': 'cancelled') may be changed. " +
702
+ "Requires UPDATE on the meeting's owning mailbox."),
703
+ Put("/:id"),
704
+ __param(0, Param("id")),
705
+ __param(2, AuthUser),
706
+ __metadata("design:type", Function),
707
+ __metadata("design:paramtypes", [String, Object, Object]),
708
+ __metadata("design:returntype", Promise)
709
+ ], BaseVideoMeetingRoute.prototype, "update", null);
710
+ __decorate([
711
+ Summary("Deletes a video meeting."),
712
+ Description("Deletes the meeting and every one of its invitees. Deleting the meeting also removes its own " +
713
+ "per-record AccessControlList (RepoUtils.delete()'s standard recordACL cleanup - see the VideoMeeting " +
714
+ "interface's doc comment), so its uid stops working as a push channel immediately. Requires DELETE on " +
715
+ "the meeting's owning mailbox."),
716
+ Delete("/:id"),
717
+ __param(0, Param("id")),
718
+ __param(1, AuthUser),
719
+ __metadata("design:type", Function),
720
+ __metadata("design:paramtypes", [String, Object]),
721
+ __metadata("design:returntype", Promise)
722
+ ], BaseVideoMeetingRoute.prototype, "delete", null);
723
+ __decorate([
724
+ Summary("Joins a video meeting."),
725
+ Description("Resolves an invitee's join token or a public meeting's slug. If the caller already presents a valid " +
726
+ "session for a real RapidMX identity (not a returning guest), that identity is granted READ/CREATE " +
727
+ "on the meeting's own push channel directly and the response carries 'authenticated: true' with no " +
728
+ "guest token at all - the caller's own existing session cookie/header already authenticates /push " +
729
+ "for them. Otherwise (the common anonymous case) mints a short-lived guest JWT (see " +
730
+ "GUEST_JWT_TTL_SECONDS) already granted READ/CREATE on the same channel, ready to use against /push " +
731
+ "to exchange WebRTC signaling messages. Requires no authentication beyond the token itself; a " +
732
+ "stale/unknown token answers 404. The one exception is a private meeting's organizerSlug, which is " +
733
+ "never an anonymous surface: it additionally requires an already-authenticated caller holding READ on " +
734
+ "the meeting's owning mailbox, and answers the very same 404 for anyone else."),
735
+ RateLimit(),
736
+ Get("/join/:token"),
737
+ __param(0, Param("token")),
738
+ __param(1, AuthUser),
739
+ __metadata("design:type", Function),
740
+ __metadata("design:paramtypes", [String, Object]),
741
+ __metadata("design:returntype", Promise)
742
+ ], BaseVideoMeetingRoute.prototype, "join", null);
743
+ //# sourceMappingURL=BaseVideoMeetingRoute.js.map