@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.
- package/LICENSE +382 -0
- package/README.md +38 -0
- package/apps/meet/[token].tsx +144 -0
- package/apps/meet/_CallControls.tsx +69 -0
- package/apps/meet/_CallView.tsx +338 -0
- package/apps/meet/_MeetChrome.tsx +30 -0
- package/apps/meet/_MeetLobby.tsx +223 -0
- package/apps/meet/_ParticipantTile.tsx +66 -0
- package/apps/meet/_layout.tsx +34 -0
- package/apps/meet/_meetApi.ts +66 -0
- package/apps/meet/index.tsx +22 -0
- package/apps/settings-video-conferencing/_PersonalRoomCard.tsx +140 -0
- package/apps/settings-video-conferencing/_layout.tsx +47 -0
- package/apps/settings-video-conferencing/index.tsx +192 -0
- package/apps/shared/media/activeSpeaker.ts +45 -0
- package/apps/shared/media/deviceMedia.ts +141 -0
- package/apps/shared/media/levelMeter.ts +91 -0
- package/apps/shared/push/GuestSignalingClient.ts +338 -0
- package/apps/shared/webrtc/MeshConnectionManager.ts +350 -0
- package/apps/shared/webrtc/realPeerConnection.ts +37 -0
- package/apps/shared/webrtc/types.ts +90 -0
- package/dist/apps/meet/[token].d.ts +33 -0
- package/dist/apps/meet/[token].js +101 -0
- package/dist/apps/meet/_CallControls.d.ts +18 -0
- package/dist/apps/meet/_CallControls.js +6 -0
- package/dist/apps/meet/_CallView.d.ts +41 -0
- package/dist/apps/meet/_CallView.js +225 -0
- package/dist/apps/meet/_MeetChrome.d.ts +13 -0
- package/dist/apps/meet/_MeetChrome.js +10 -0
- package/dist/apps/meet/_MeetLobby.d.ts +29 -0
- package/dist/apps/meet/_MeetLobby.js +120 -0
- package/dist/apps/meet/_ParticipantTile.d.ts +18 -0
- package/dist/apps/meet/_ParticipantTile.js +25 -0
- package/dist/apps/meet/_layout.d.ts +9 -0
- package/dist/apps/meet/_layout.js +8 -0
- package/dist/apps/meet/_meetApi.d.ts +47 -0
- package/dist/apps/meet/_meetApi.js +23 -0
- package/dist/apps/meet/index.d.ts +5 -0
- package/dist/apps/meet/index.js +8 -0
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.d.ts +24 -0
- package/dist/apps/settings-video-conferencing/_PersonalRoomCard.js +77 -0
- package/dist/apps/settings-video-conferencing/_layout.d.ts +16 -0
- package/dist/apps/settings-video-conferencing/_layout.js +8 -0
- package/dist/apps/settings-video-conferencing/index.d.ts +16 -0
- package/dist/apps/settings-video-conferencing/index.js +92 -0
- package/dist/apps/shared/media/activeSpeaker.d.ts +20 -0
- package/dist/apps/shared/media/activeSpeaker.js +39 -0
- package/dist/apps/shared/media/deviceMedia.d.ts +65 -0
- package/dist/apps/shared/media/deviceMedia.js +110 -0
- package/dist/apps/shared/media/levelMeter.d.ts +40 -0
- package/dist/apps/shared/media/levelMeter.js +61 -0
- package/dist/apps/shared/push/GuestSignalingClient.d.ts +89 -0
- package/dist/apps/shared/push/GuestSignalingClient.js +291 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.d.ts +97 -0
- package/dist/apps/shared/webrtc/MeshConnectionManager.js +262 -0
- package/dist/apps/shared/webrtc/realPeerConnection.d.ts +7 -0
- package/dist/apps/shared/webrtc/realPeerConnection.js +26 -0
- package/dist/apps/shared/webrtc/types.d.ts +94 -0
- package/dist/apps/shared/webrtc/types.js +9 -0
- package/dist/lib/index.js +25 -0
- package/dist/lib/index.js.map +1 -0
- package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js +87 -0
- package/dist/lib/models/mongo/VideoMeetingInviteeMongo.js.map +1 -0
- package/dist/lib/models/mongo/VideoMeetingMongo.js +126 -0
- package/dist/lib/models/mongo/VideoMeetingMongo.js.map +1 -0
- package/dist/lib/models/sql/VideoMeetingInviteeSQL.js +87 -0
- package/dist/lib/models/sql/VideoMeetingInviteeSQL.js.map +1 -0
- package/dist/lib/models/sql/VideoMeetingSQL.js +127 -0
- package/dist/lib/models/sql/VideoMeetingSQL.js.map +1 -0
- package/dist/lib/models/types.js +19 -0
- package/dist/lib/models/types.js.map +1 -0
- package/dist/lib/mongo.js +13 -0
- package/dist/lib/mongo.js.map +1 -0
- package/dist/lib/routes/BaseVideoMeetingRoute.js +743 -0
- package/dist/lib/routes/BaseVideoMeetingRoute.js.map +1 -0
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js +31 -0
- package/dist/lib/routes/mongo/VideoMeetingRouteMongo.js.map +1 -0
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js +31 -0
- package/dist/lib/routes/sql/VideoMeetingRouteSQL.js.map +1 -0
- package/dist/lib/sql.js +13 -0
- package/dist/lib/sql.js.map +1 -0
- package/dist/lib/util/BookingIntegrationUtils.js +115 -0
- package/dist/lib/util/BookingIntegrationUtils.js.map +1 -0
- package/dist/lib/util/IceServerUtils.js +68 -0
- package/dist/lib/util/IceServerUtils.js.map +1 -0
- package/dist/lib/util/PublicUrlUtils.js +37 -0
- package/dist/lib/util/PublicUrlUtils.js.map +1 -0
- package/dist/lib/util/RouteAccessUtils.js +32 -0
- package/dist/lib/util/RouteAccessUtils.js.map +1 -0
- package/dist/lib/util/TokenUtils.js +33 -0
- package/dist/lib/util/TokenUtils.js.map +1 -0
- package/dist/types/index.d.ts +20 -0
- package/dist/types/models/mongo/VideoMeetingInviteeMongo.d.ts +16 -0
- package/dist/types/models/mongo/VideoMeetingMongo.d.ts +23 -0
- package/dist/types/models/sql/VideoMeetingInviteeSQL.d.ts +16 -0
- package/dist/types/models/sql/VideoMeetingSQL.d.ts +23 -0
- package/dist/types/models/types.d.ts +131 -0
- package/dist/types/mongo.d.ts +8 -0
- package/dist/types/routes/BaseVideoMeetingRoute.d.ts +368 -0
- package/dist/types/routes/mongo/VideoMeetingRouteMongo.d.ts +10 -0
- package/dist/types/routes/sql/VideoMeetingRouteSQL.d.ts +10 -0
- package/dist/types/sql.d.ts +8 -0
- package/dist/types/util/BookingIntegrationUtils.d.ts +51 -0
- package/dist/types/util/IceServerUtils.d.ts +57 -0
- package/dist/types/util/PublicUrlUtils.d.ts +11 -0
- package/dist/types/util/RouteAccessUtils.d.ts +8 -0
- package/dist/types/util/TokenUtils.d.ts +23 -0
- package/package.json +181 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import type { BaseEntity } from "@rapidrest/service-core";
|
|
2
|
+
/** Whether a `VideoMeeting` is joinable only by its explicitly invited `VideoMeetingInvitee`s (each with their
|
|
3
|
+
* own unguessable `joinToken`), or by anyone holding the meeting's single `publicSlug` link. */
|
|
4
|
+
export declare enum VideoMeetingVisibility {
|
|
5
|
+
PRIVATE = "private",
|
|
6
|
+
PUBLIC = "public"
|
|
7
|
+
}
|
|
8
|
+
/** The lifecycle of a `VideoMeeting`. Deliberately minimal for Phase 1 - `ACTIVE` and `ENDED` are not yet set by
|
|
9
|
+
* any code in this package (no signaling event currently transitions a meeting automatically); they exist so the
|
|
10
|
+
* shape doesn't need to change once a later phase starts using them. `CANCELLED` is the one transition
|
|
11
|
+
* `BaseVideoMeetingRoute.update()` actually performs today. */
|
|
12
|
+
export declare enum VideoMeetingStatus {
|
|
13
|
+
SCHEDULED = "scheduled",
|
|
14
|
+
ACTIVE = "active",
|
|
15
|
+
ENDED = "ended",
|
|
16
|
+
CANCELLED = "cancelled"
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* A WebRTC video meeting owned by a `Mailbox` - joinable directly via this plugin's own API (for now; a later
|
|
20
|
+
* phase wires an "Add video conferencing" hook into calendar event compose, see `calendarEventUid` below) or by
|
|
21
|
+
* anyone holding a valid link (a private invitee's `joinToken`, or a public meeting's `publicSlug`).
|
|
22
|
+
*
|
|
23
|
+
* Anonymous access to this entity is NOT granted through the `AccessControlList`'s class-level default (see
|
|
24
|
+
* `@Protect`'s deny-all `records` on the concrete `VideoMeetingMongo`/`VideoMeetingSQL`) - `BaseVideoMeetingRoute`
|
|
25
|
+
* does its own authorization: the owner's routes check `ACLUtils.hasPermission()` against the meeting's
|
|
26
|
+
* `mailboxUid` (with the caller's trusted roles stripped first - see `util/RouteAccessUtils.ts`), and the
|
|
27
|
+
* anonymous `join()` route resolves a caller by `joinToken`/`publicSlug` directly, the same posture
|
|
28
|
+
* `booking-plugin`'s `BaseBookingRoute` documents for `Booking`/`BookingType`.
|
|
29
|
+
*
|
|
30
|
+
* Unlike `Booking`, this entity DOES get its own per-record `AccessControlList` (`@Protect`'s `recordACL: true`) -
|
|
31
|
+
* its own `uid` doubles as its `/push` signaling channel (see `BaseVideoMeetingRoute`'s class doc comment), and a
|
|
32
|
+
* `/push` channel is authorized purely by whether a per-channel `AccessControlList` document grants the caller an
|
|
33
|
+
* action on it (`ACLUtils.hasPermission(user, channelUid, action)`); there would be no such document to check
|
|
34
|
+
* against at all without `recordACL: true`. Creating a meeting's own ACL with `parentUid` set to its `mailboxUid`
|
|
35
|
+
* (see `BaseVideoMeetingRoute.create()`) lets a mailbox owner/delegate reach it exactly as they reach any other
|
|
36
|
+
* mailbox-scoped record, through the normal ACL parent-chain inheritance - no separate mechanism is needed for
|
|
37
|
+
* the owner's own push access, only for a guest's (see `join()`'s doc comment).
|
|
38
|
+
*
|
|
39
|
+
* @author Jean-Philippe Steinmetz
|
|
40
|
+
*/
|
|
41
|
+
export interface VideoMeeting extends BaseEntity {
|
|
42
|
+
/** The unique identifier of the `Mailbox` that owns this meeting. Managing it (create/list/delete/cancel) is
|
|
43
|
+
* permission-checked against this mailbox's `AccessControlList`, the same as every other mailbox-scoped child
|
|
44
|
+
* entity - see the architecture note on `Message.mailboxUid` in `@rapidmx/restapi`'s `models/types.ts`. */
|
|
45
|
+
mailboxUid: string;
|
|
46
|
+
/** The unique identifier of the `CalendarEvent` this meeting was minted for, if any. Optional in Phase 1: a
|
|
47
|
+
* meeting can exist with no calendar event at all (created directly through this plugin's own API, e.g. for
|
|
48
|
+
* testing); a later phase wires an "Add video conferencing" control into calendar event compose that sets
|
|
49
|
+
* this when it mints a meeting for a real invite. */
|
|
50
|
+
calendarEventUid?: string;
|
|
51
|
+
title: string;
|
|
52
|
+
visibility: VideoMeetingVisibility;
|
|
53
|
+
/**
|
|
54
|
+
* The public join link's unique identifier, set only when `visibility` is `PUBLIC`. Random rather than
|
|
55
|
+
* name-derived (unlike `booking-plugin`'s human-chosen `BookingType.slug`) - see `util/TokenUtils.ts`'s
|
|
56
|
+
* `mintPublicSlug()` doc comment for the exact shape/entropy and why.
|
|
57
|
+
*
|
|
58
|
+
* The concrete `VideoMeetingMongo`/`VideoMeetingSQL` classes index this unique **globally**, matching exactly
|
|
59
|
+
* what `BaseVideoMeetingRoute.join()`'s public URL actually looks up (`/join/:token`, not
|
|
60
|
+
* `/join/:mailboxUid/:token` - the lookup carries no mailbox segment at all). An initial design considered
|
|
61
|
+
* scoping the database constraint to the owning mailbox instead (mirroring `BookingType.slug`'s own scope,
|
|
62
|
+
* for consistency with `booking-plugin`'s precedent - see this package's `.claude/NOTES.md`'s Phase 1 entry),
|
|
63
|
+
* but a per-mailbox *compound* sparse index doesn't actually work: it still indexes a document carrying at
|
|
64
|
+
* least one of its keys, and every row has `mailboxUid`, so two *private* meetings in one mailbox (both
|
|
65
|
+
* missing `publicSlug`) would collide on `(mailboxUid, null)` and the second could never be created - a real
|
|
66
|
+
* bug, not just a weaker-than-intended constraint, found and fixed before release. A single-field sparse
|
|
67
|
+
* index skips a document missing the field entirely, which both fixes that bug and happens to match the
|
|
68
|
+
* lookup's real (global) scope exactly - see `organizerSlug` below, which hit the identical pitfall.
|
|
69
|
+
*/
|
|
70
|
+
publicSlug?: string;
|
|
71
|
+
/**
|
|
72
|
+
* The organizer's own join link identifier, minted only for a `PRIVATE` meeting, at creation. It exists solely
|
|
73
|
+
* so the meeting's organizer has *some* token `BaseVideoMeetingRoute.requireMeetingByToken()` can resolve to
|
|
74
|
+
* their own meeting: a private meeting's only other resolvable credentials are its invitees' `joinToken`s, and
|
|
75
|
+
* the calendar integration that mints a meeting per event deliberately builds `invitees` from the event's
|
|
76
|
+
* attendees *excluding* the organizer (the organizer manages the meeting through ownership, not as a guest), so
|
|
77
|
+
* without this the organizer of their own private meeting would have no link that resolves at all.
|
|
78
|
+
*
|
|
79
|
+
* **Unlike `publicSlug`, holding this value is not by itself a credential.** Resolving a token through this
|
|
80
|
+
* field never grants anonymous or guest access: `BaseVideoMeetingRoute.join()` additionally requires a real,
|
|
81
|
+
* already-authenticated (non-guest) caller who holds `READ` on the meeting's own `mailboxUid`, and answers the
|
|
82
|
+
* same bare `404` as an entirely unknown token for anyone else - see that method's doc comment. The value is
|
|
83
|
+
* unguessable all the same (the same `mintPublicSlug()` shape/entropy as `publicSlug`, stored in its own
|
|
84
|
+
* column), but that unguessability is defense in depth here, not the authorization itself.
|
|
85
|
+
*
|
|
86
|
+
* A meeting's `visibility` never changes after creation, and neither does this field. It is never set for a
|
|
87
|
+
* `PUBLIC` meeting. It is, however, genuinely optional even for a private one: a private meeting minted through
|
|
88
|
+
* `createSingleInviteeVideoMeeting()` (`util/BookingIntegrationUtils.ts` - `booking-plugin`'s in-process
|
|
89
|
+
* integration, whose meetings are managed from the booking flow and have no organizer to hand a link to) has
|
|
90
|
+
* none, so code must check for its presence rather than infer it from `visibility` alone.
|
|
91
|
+
*
|
|
92
|
+
* The concrete `VideoMeetingMongo`/`VideoMeetingSQL` classes index this unique globally, single-field sparse -
|
|
93
|
+
* the same shape `publicSlug`'s own index was corrected to (see above); this field never had the per-mailbox
|
|
94
|
+
* version to begin with, having been added after that pitfall was already found.
|
|
95
|
+
*/
|
|
96
|
+
organizerSlug?: string;
|
|
97
|
+
status: VideoMeetingStatus;
|
|
98
|
+
/** Informational only in Phase 1 - not used for any availability or conflict checking (a video meeting has no
|
|
99
|
+
* concept of "busy" the way a `Booking` does). */
|
|
100
|
+
startTime?: Date;
|
|
101
|
+
/** Informational only in Phase 1 - see `startTime`. */
|
|
102
|
+
endTime?: Date;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* One invited participant of a `PRIVATE` `VideoMeeting`, minted server-side by `BaseVideoMeetingRoute.create()` -
|
|
106
|
+
* one row per `{ email, displayName? }` the owner supplied at creation time. `joinToken` is this invitee's only
|
|
107
|
+
* credential (mirroring `Booking.manageToken` exactly - see `util/TokenUtils.ts`'s `mintJoinToken()`): possession
|
|
108
|
+
* of the link is what lets an anonymous invitee reach `BaseVideoMeetingRoute.join()`, never an
|
|
109
|
+
* `AccessControlList` grant of its own (this entity's class ACL is deny-all, like `Booking`'s - see
|
|
110
|
+
* `@Protect` on the concrete `VideoMeetingInviteeMongo`/`VideoMeetingInviteeSQL`).
|
|
111
|
+
*
|
|
112
|
+
* @author Jean-Philippe Steinmetz
|
|
113
|
+
*/
|
|
114
|
+
export interface VideoMeetingInvitee extends BaseEntity {
|
|
115
|
+
/** The unique identifier of the `VideoMeeting` this invitee belongs to. */
|
|
116
|
+
meetingUid: string;
|
|
117
|
+
/**
|
|
118
|
+
* The unique identifier of the host `Mailbox`, denormalized from the meeting - mirroring `Booking.mailboxUid`'s
|
|
119
|
+
* own doc comment exactly: this lets `ErasureExecutionJob` find and purge every invitee of an erased mailbox's
|
|
120
|
+
* meetings by `mailboxUid` alone (the `@MailboxScopedData()` contract), without a join back through
|
|
121
|
+
* `VideoMeeting`, and lets a host's invitees be listed without one either.
|
|
122
|
+
*/
|
|
123
|
+
mailboxUid: string;
|
|
124
|
+
/** The invitee's email address, normalized to lowercase. */
|
|
125
|
+
email: string;
|
|
126
|
+
displayName?: string;
|
|
127
|
+
/** The unguessable token embedded in this invitee's join link, minted server-side (32 random bytes) and
|
|
128
|
+
* immutable thereafter. Like `Booking.manageToken`, this has no expiry and no GC job of its own - see
|
|
129
|
+
* `BaseVideoMeetingRoute`'s class doc comment for the same documented tradeoff. */
|
|
130
|
+
joinToken: string;
|
|
131
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This plugin's `./mongo` entry point: exactly the classes a server host loads for a Mongo deployment - the video
|
|
3
|
+
* meeting models and the route mounted at `/api/mail/video-meetings`. Anything else exported here would be
|
|
4
|
+
* registered by the host too, so the abstract route and utilities stay in the package root.
|
|
5
|
+
*/
|
|
6
|
+
export { VideoMeetingMongo } from "./models/mongo/VideoMeetingMongo.js";
|
|
7
|
+
export { VideoMeetingInviteeMongo } from "./models/mongo/VideoMeetingInviteeMongo.js";
|
|
8
|
+
export { VideoMeetingRouteMongo } from "./routes/mongo/VideoMeetingRouteMongo.js";
|
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
import { type JWTUser } from "@rapidrest/core";
|
|
2
|
+
import { HttpRequest } from "@rapidrest/service-core";
|
|
3
|
+
import { Mailbox } from "@rapidmx/restapi";
|
|
4
|
+
import { IceServerConfig } from "../util/IceServerUtils.js";
|
|
5
|
+
import { VideoMeeting, VideoMeetingInvitee, VideoMeetingStatus, VideoMeetingVisibility } from "../models/types.js";
|
|
6
|
+
/** How long a guest JWT minted by `join()` remains valid for. Four hours comfortably covers a realistic call's
|
|
7
|
+
* length (including some run-over) without a guest's session dying mid-call; there is no refresh mechanism in
|
|
8
|
+
* Phase 1, so a guest who is still on the call past this needs to re-open the join link. */
|
|
9
|
+
export declare const GUEST_JWT_TTL_SECONDS: number;
|
|
10
|
+
/** The fixed prefix every guest uid `mintGuestToken()` mints starts with - also how `join()` tells a genuine,
|
|
11
|
+
* already-authenticated RapidMX identity apart from a returning guest presenting a JWT from an earlier `join()`
|
|
12
|
+
* call (see `join()`'s doc comment): a guest uid is never a real mailbox-owning identity, so this prefix is a safe,
|
|
13
|
+
* cheap discriminator with no separate "is this a guest" flag needed anywhere. */
|
|
14
|
+
export declare const GUEST_UID_PREFIX = "guest:";
|
|
15
|
+
/** The request body accepted by `create()`. */
|
|
16
|
+
export interface CreateVideoMeetingBody {
|
|
17
|
+
mailboxUid?: string;
|
|
18
|
+
title?: string;
|
|
19
|
+
visibility?: string;
|
|
20
|
+
/** The `CalendarEvent` this meeting is being minted for, if any - see `VideoMeeting.calendarEventUid`. */
|
|
21
|
+
calendarEventUid?: string;
|
|
22
|
+
startTime?: string;
|
|
23
|
+
endTime?: string;
|
|
24
|
+
/** Required (and non-empty), only when `visibility` is `"private"`. */
|
|
25
|
+
invitees?: {
|
|
26
|
+
email?: string;
|
|
27
|
+
displayName?: string;
|
|
28
|
+
}[];
|
|
29
|
+
}
|
|
30
|
+
/** One invitee of a newly created private meeting, as returned by `create()` - everything a caller needs to build
|
|
31
|
+
* that invitee's own share of the calendar invite (Phase 3's job). */
|
|
32
|
+
export interface VideoMeetingInviteeJoinInfo {
|
|
33
|
+
uid: string;
|
|
34
|
+
email: string;
|
|
35
|
+
displayName?: string;
|
|
36
|
+
/** `undefined` when `mail:videoconf:public_url` isn't configured - see `BaseVideoMeetingRoute`'s class doc
|
|
37
|
+
* comment. */
|
|
38
|
+
joinUrl?: string;
|
|
39
|
+
}
|
|
40
|
+
/** `create()`'s response: the persisted meeting, plus whatever link(s) a caller needs to invite people to it. */
|
|
41
|
+
export interface VideoMeetingCreateResult<T extends VideoMeeting = VideoMeeting> {
|
|
42
|
+
meeting: T;
|
|
43
|
+
/** Present (possibly empty only if `invitees` validation somehow let that through - it never does, see
|
|
44
|
+
* `validateCreateBody()`) exactly when `meeting.visibility` is `"private"`. */
|
|
45
|
+
invitees?: VideoMeetingInviteeJoinInfo[];
|
|
46
|
+
/** Present exactly when `meeting.visibility` is `"public"`; `undefined` within that case only when
|
|
47
|
+
* `mail:videoconf:public_url` isn't configured. */
|
|
48
|
+
publicJoinUrl?: string;
|
|
49
|
+
/** The organizer's own join link, present exactly when `meeting.organizerSlug` is set - i.e. for every private
|
|
50
|
+
* meeting this route creates, *alongside* `invitees` rather than instead of it (`undefined` within that case
|
|
51
|
+
* only when `mail:videoconf:public_url` isn't configured). The organizer of a private meeting is deliberately
|
|
52
|
+
* never one of its own `invitees`, so this is the only link that resolves to the meeting for them - see
|
|
53
|
+
* `VideoMeeting.organizerSlug` and `join()`'s doc comment for why holding it is not by itself a credential. */
|
|
54
|
+
organizerJoinUrl?: string;
|
|
55
|
+
}
|
|
56
|
+
/** Which field of which row `requireMeetingByToken()` matched the caller's token against - `join()` authorizes each
|
|
57
|
+
* differently, so the resolution is returned explicitly rather than re-derived by comparing strings afterwards. */
|
|
58
|
+
export type VideoMeetingTokenResolution = "invitee" | "publicSlug" | "organizerSlug";
|
|
59
|
+
/** The public projection of a `VideoMeeting`, as returned to an anonymous joiner - deliberately narrow: a guest
|
|
60
|
+
* never sees `mailboxUid`, `calendarEventUid` or any other internal identifier. */
|
|
61
|
+
export interface PublicVideoMeeting {
|
|
62
|
+
uid: string;
|
|
63
|
+
title: string;
|
|
64
|
+
visibility: VideoMeetingVisibility;
|
|
65
|
+
status: VideoMeetingStatus;
|
|
66
|
+
/** The host mailbox's `displayName`, when it has one. */
|
|
67
|
+
hostDisplayName?: string;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* `join()`'s response.
|
|
71
|
+
*
|
|
72
|
+
* **`authenticated`/`selfUid`/`token` - reading this shape correctly.** A caller who already presented a valid
|
|
73
|
+
* session for a real, non-guest RapidMX identity (see `join()`'s doc comment for exactly how that's told apart
|
|
74
|
+
* from a returning guest) gets `authenticated: true` and no `token`/`expiresAt` at all - there is no guest JWT to
|
|
75
|
+
* hand over, because the caller's own already-existing session cookie/header already authenticates `/push` for
|
|
76
|
+
* them, with zero new client-side auth handling. The frontend must not write any cookie in that case (doing so
|
|
77
|
+
* would be pointless at best, and at worst overwrite the caller's real cookie with... the value it already has).
|
|
78
|
+
* The common anonymous case (`authenticated: false`) is unchanged from Phase 1: `token`/`expiresAt` are present
|
|
79
|
+
* and the frontend applies `token` as the `jwt` cookie before connecting - see
|
|
80
|
+
* `apps/shared/push/GuestSignalingClient.ts`.
|
|
81
|
+
*/
|
|
82
|
+
export interface VideoMeetingJoinResult {
|
|
83
|
+
meeting: PublicVideoMeeting;
|
|
84
|
+
iceServers: IceServerConfig[];
|
|
85
|
+
/** `true` when the caller already presented a valid session for a real RapidMX identity when calling `join()`;
|
|
86
|
+
* `false` for the common anonymous case, where a fresh guest identity was just minted instead. */
|
|
87
|
+
authenticated: boolean;
|
|
88
|
+
/** The identity now holding `READ`/`CREATE` on the meeting's own push channel, and the uid the frontend's mesh
|
|
89
|
+
* connection manager (`apps/shared/webrtc/MeshConnectionManager.ts`) must identify itself as: the caller's own
|
|
90
|
+
* real uid when `authenticated` is `true`, otherwise the freshly minted synthetic `guest:<random>` uid `token`
|
|
91
|
+
* authenticates as. Always present - this replaces Phase 1's guest-only `guestUid` field now that the same
|
|
92
|
+
* grant-and-identify step also runs for a real, already-authenticated caller. */
|
|
93
|
+
selfUid: string;
|
|
94
|
+
/** A short-lived guest JWT, immediately usable against `/push` to subscribe to and publish on `meeting.uid` -
|
|
95
|
+
* and nothing else. Present only when `authenticated` is `false`; omitted entirely for an already-authenticated
|
|
96
|
+
* real caller, who has no guest token minted for them at all (see `join()`). See this class's doc comment. */
|
|
97
|
+
token?: string;
|
|
98
|
+
/** ISO 8601 instant `token` expires at. Present only when `authenticated` is `false`, exactly when `token`
|
|
99
|
+
* itself is. */
|
|
100
|
+
expiresAt?: string;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The owner's management of their own `VideoMeeting`s (JWT-authenticated, mailbox-ACL-checked exactly like any
|
|
104
|
+
* other mailbox-scoped entity - see `booking-plugin`'s `BaseBookingTypeRoute` for the same "must own or hold a
|
|
105
|
+
* grant on the mailbox" pattern this class hand-implements) and the anonymous `join()` endpoint a private
|
|
106
|
+
* invitee's `joinToken` or a public meeting's `publicSlug` resolves through.
|
|
107
|
+
*
|
|
108
|
+
* Like `BaseBookingRoute`, this is a standalone class, NOT a `CRUDRoute`/`BaseScopedChildRoute` subclass: `create()`'s
|
|
109
|
+
* response is not itself a bare `VideoMeeting` (it also carries invitee join links / the public join link, which the
|
|
110
|
+
* generic CRUD return type can't express), and `join()` is anonymous and token-resolved, the same shape of
|
|
111
|
+
* requirement that keeps `BaseBookingRoute` standalone. Every repo call passes `ignoreACL: true` because this class
|
|
112
|
+
* performs its own authorization: the owner-side methods check `ACLUtils.hasPermission()` against the meeting's
|
|
113
|
+
* `mailboxUid` with the caller's trusted roles stripped first (`util/RouteAccessUtils.ts` - see that module's doc
|
|
114
|
+
* comment for why: without it, `ACLUtils.hasPermission()`'s own "trusted users always have permission" rule would
|
|
115
|
+
* let an administrator read or manage a mailbox they hold no grant on, which is never true for mail here), and
|
|
116
|
+
* `join()` resolves an anonymous caller purely by the token/slug's own database match. Like `Booking`/`BookingType`,
|
|
117
|
+
* the concrete `VideoMeetingMongo`/`VideoMeetingSQL`/`VideoMeetingInviteeMongo`/`VideoMeetingInviteeSQL` classes carry
|
|
118
|
+
* an ordinary deny-all class ACL; `"anonymous"` is never granted an action anywhere.
|
|
119
|
+
*
|
|
120
|
+
* Like `BaseBookingRoute`/`BaseMailIngestRoute`/`BasePushRoute`, this class carries no `@Route`/`@ApiRoute` of its
|
|
121
|
+
* own - the consuming Mongo/SQL concrete subclass applies one (`mail/video-meetings`).
|
|
122
|
+
*
|
|
123
|
+
* ## Path shape
|
|
124
|
+
*
|
|
125
|
+
* `join()` lives at the literal `/join/:token` (not `/:token` at the root), the same reasoning `BaseBookingRoute`'s
|
|
126
|
+
* doc comment gives for its own `/manage/<token>` vs. `/:slug/slots` split: a bare `/:token` at the root would
|
|
127
|
+
* collide with (and make router registration order decide between) this class's own `/:id` owner routes.
|
|
128
|
+
*
|
|
129
|
+
* ## Why `VideoMeeting` gets its own per-record `AccessControlList`
|
|
130
|
+
*
|
|
131
|
+
* Unlike `Booking`, `VideoMeeting.uid` doubles as a `/push` signaling channel (see `.claude/NOTES.md`'s "Signaling"
|
|
132
|
+
* design note): SDP offers/answers and ICE candidates are ordinary `NotificationUtils.sendMessage()` payloads
|
|
133
|
+
* published to it, exactly as `MailPushRoute`'s doc comment describes for a `Mailbox`/`Folder` channel. A `/push`
|
|
134
|
+
* channel is authorized purely by `ACLUtils.hasPermission(user, channelUid, action)` against a per-record
|
|
135
|
+
* `AccessControlList` document keyed by that exact uid - there being no such document at all (the case for
|
|
136
|
+
* `Booking`, whose `@Protect` sets `recordACL: false`) makes `hasPermission()` always answer `false`. So
|
|
137
|
+
* `VideoMeetingMongo`/`VideoMeetingSQL` set `recordACL: true`, and `create()` below claims that per-record ACL
|
|
138
|
+
* with `parentUid` set to the meeting's own `mailboxUid` (mirroring `BaseFolderRoute`'s identical `acl: { uid,
|
|
139
|
+
* parentUid: mailboxUid, records: [] }` pattern) - which is also what lets a mailbox owner/delegate reach the
|
|
140
|
+
* meeting (and its channel) through the ordinary ACL parent-chain, with no extra mechanism needed on top of what
|
|
141
|
+
* `RepoUtils.create()`'s automatic per-record ACL claim already does (it grants the creator full rights on the
|
|
142
|
+
* fresh ACL - see `RepoUtils.claimRecordACL()`).
|
|
143
|
+
*
|
|
144
|
+
* ## The channel-ACL-grant mechanism
|
|
145
|
+
*
|
|
146
|
+
* An anonymous guest holds no `AccessControlList` grant of their own and has no `JWTUser` to authenticate `/push`
|
|
147
|
+
* with in the first place - `join()` therefore does two things together: it mints a short-lived, scope-limited
|
|
148
|
+
* guest JWT (`mintGuestToken()`: a synthetic `guest:<random>` uid, no roles, `GUEST_JWT_TTL_SECONDS` expiry, real and
|
|
149
|
+
* verifiable since it's signed with the same `auth` config every other token is), and it adds an explicit
|
|
150
|
+
* `ACLRecord` for that exact uid onto the meeting's own `AccessControlList` (`ensureChannelGrant()`), granting
|
|
151
|
+
* `READ` (so `BasePushRoute`'s SUBSCRIBE succeeds) and `CREATE` (so a publish does - see `MailPushRoute`'s doc
|
|
152
|
+
* comment: "publishing to a channel needs CREATE on it as an ordinary user would"). The grant is scoped to exactly
|
|
153
|
+
* that one meeting's uid, nothing else - the same "possession of a link is the credential" pattern
|
|
154
|
+
* `BaseBookingRoute.requireBookingByToken()`/`resolveEffectiveUser()`'s `share:<token>` identity already establish
|
|
155
|
+
* elsewhere in this codebase, extended one step further because signaling needs a channel *subscription*, not just
|
|
156
|
+
* a stateless REST call.
|
|
157
|
+
*
|
|
158
|
+
* **Known limitation, documented rather than silently assumed away**: because each `join()` call for an anonymous
|
|
159
|
+
* caller mints a *fresh* random guest uid (so simultaneous participants of one shared link are distinguishable from
|
|
160
|
+
* each other in the signaling channel), each such join adds one more `ACLRecord` to the meeting's own ACL document,
|
|
161
|
+
* and nothing in Phase 1 ever removes one. A meeting joined many times over its lifetime accumulates unused records;
|
|
162
|
+
* there is no GC job, matching this codebase's own precedent for `Booking.manageToken` (documented as never
|
|
163
|
+
* expiring, no GC job either). Since the guest JWTs themselves expire, an accumulated record is inert (unusable)
|
|
164
|
+
* well before it becomes a real concern - a cleanup pass is a reasonable thing for a later phase to add, not a
|
|
165
|
+
* Phase 1 requirement. An already-authenticated real caller (see below) is granted their own stable uid instead, so
|
|
166
|
+
* repeated joins by the same real identity never add more than the one record `ensureChannelGrant()`'s own
|
|
167
|
+
* idempotency check already collapses them to.
|
|
168
|
+
*
|
|
169
|
+
* ## Real, already-authenticated callers (the browser-session-collision fix)
|
|
170
|
+
*
|
|
171
|
+
* `join()` also accepts an optional `@AuthUser`: whatever `req.user` the framework's own `JWTStrategy` already
|
|
172
|
+
* populated from the caller's existing `Authorization` header or `jwt` cookie, exactly like every other
|
|
173
|
+
* authenticated-optional endpoint in this codebase's family (e.g. `BaseScopedChildRoute.resolveEffectiveUser()`'s
|
|
174
|
+
* "prefer the real authenticated user, fall back to the anonymous token" precedent). This matters because a
|
|
175
|
+
* logged-in RapidMX user's browser already carries a real, `HttpOnly` `jwt` session cookie for this origin - a
|
|
176
|
+
* cookie `apps/shared/push/GuestSignalingClient.ts` cannot overwrite with a guest token even if `join()` minted one
|
|
177
|
+
* (browsers refuse to let a script override an `HttpOnly` cookie of the same name), so a guest-only `join()` would
|
|
178
|
+
* leave that browser's `/push` WebSocket authenticating as the real session while the meeting's ACL only names a
|
|
179
|
+
* synthetic guest uid - the subscribe is simply, safely refused, and the real user could never actually join.
|
|
180
|
+
*
|
|
181
|
+
* The fix: when `user` is present and is a *real* identity - not a guest uid from a previous `join()` call, told
|
|
182
|
+
* apart by the `GUEST_UID_PREFIX` a guest uid always starts with and a real, mailbox-owning identity never can -
|
|
183
|
+
* `join()` grants `user.uid` itself (not a synthetic one) `READ`/`CREATE` on the meeting's channel via
|
|
184
|
+
* `ensureChannelGrant()`, mints no guest JWT at all, and returns `authenticated: true` with `selfUid: user.uid`. The
|
|
185
|
+
* caller's own already-existing session cookie now already authenticates `/push` for them with zero new
|
|
186
|
+
* client-side auth handling - the frontend must not write a cookie of its own in this case (see
|
|
187
|
+
* `VideoMeetingJoinResult`'s doc comment). When `user` is absent (the common, true-anonymous case - no existing
|
|
188
|
+
* session at all), behavior is exactly Phase 1's: a fresh guest identity is minted and granted instead.
|
|
189
|
+
*
|
|
190
|
+
* ## The organizer's own slug (`VideoMeeting.organizerSlug`)
|
|
191
|
+
*
|
|
192
|
+
* The fix above lets a real, already-authenticated caller join *if they already hold something that resolves to the
|
|
193
|
+
* meeting*. The organizer of their own private meeting does not: the calendar integration that mints a meeting per
|
|
194
|
+
* event builds `invitees` from the event's attendees **excluding the organizer** (who manages the meeting through
|
|
195
|
+
* ownership, not as a guest), and `publicSlug` is minted only for a public meeting - so the one person who owns the
|
|
196
|
+
* meeting had no token `requireMeetingByToken()` could resolve for them at all. `persistMeeting()` therefore also
|
|
197
|
+
* mints an `organizerSlug` for every private meeting, `create()` returns it as `organizerJoinUrl` alongside the
|
|
198
|
+
* per-invitee links, and `findById()` returns the same link for a meeting loaded later.
|
|
199
|
+
*
|
|
200
|
+
* **This does not widen the `"private"` invariant by one caller.** The other two resolutions are credentials in
|
|
201
|
+
* themselves - possession of an invitee `joinToken` or a `publicSlug` is exactly what authorizes the join, by
|
|
202
|
+
* design. An `organizerSlug` is not: `requireMeetingByToken()` reports *which* field resolved the match, and for
|
|
203
|
+
* `"organizerSlug"` `join()` requires, before computing or returning anything about the meeting, both that the
|
|
204
|
+
* caller is a real already-authenticated identity (the same non-guest `GUEST_UID_PREFIX` check as above, so a
|
|
205
|
+
* returning guest presenting a prior `join()`'s own guest JWT never qualifies) and that this identity holds `READ`
|
|
206
|
+
* on the meeting's own `mailboxUid` - the very same `ACLUtils.hasPermission()` call, trusted roles stripped, that
|
|
207
|
+
* `requireMailboxAccess()` makes for every owner-side route, so a trusted administrator with no explicit grant is
|
|
208
|
+
* refused here exactly as they are there. Every caller who fails either condition - a true anonymous stranger, a
|
|
209
|
+
* returning guest, or a real but unrelated logged-in user - gets the identical bare `404` an entirely unknown token
|
|
210
|
+
* gets, never a `403`: this class never leaks whether a token almost-matched something, and an
|
|
211
|
+
* organizer-slug-shaped probe must be indistinguishable from a slug naming nothing at all. A caller who satisfies
|
|
212
|
+
* both proceeds through exactly the authenticated branch described above, with no new response field.
|
|
213
|
+
*
|
|
214
|
+
* ## Recovering a join link on a later read (`find()`/`findById()`, Phase 4)
|
|
215
|
+
*
|
|
216
|
+
* `create()`'s response computes `organizerJoinUrl`/`publicJoinUrl` inline, once, from the slug it just minted -
|
|
217
|
+
* fine for the moment of creation, but Phase 4's settings page (`apps/settings-video-conferencing`) needs a
|
|
218
|
+
* meeting's own persistent link on every later page load too, not only the one response `create()` ever sent (a
|
|
219
|
+
* user's "personal room", by this codebase's Phase 4 convention, is simply their oldest non-cancelled `PUBLIC`
|
|
220
|
+
* meeting - see `.claude/NOTES.md`'s Phase 4 entry for why no new field/route was needed to name it as such). Since
|
|
221
|
+
* a plain `RepoUtils.find()`/`findOne()` returns only the persisted slug columns, `find()` and `findById()` both
|
|
222
|
+
* now run every loaded meeting through `withJoinUrls()` - the exact same computation `create()` already did,
|
|
223
|
+
* applied uniformly on read instead of only once on write.
|
|
224
|
+
*
|
|
225
|
+
* ## Other known limitations
|
|
226
|
+
*
|
|
227
|
+
* **`VideoMeetingInvitee.joinToken` never expires** and has no GC job - identical tradeoff to `Booking.manageToken`.
|
|
228
|
+
*
|
|
229
|
+
* **`VideoMeeting.publicSlug` is only uniqueness-checked within its own mailbox** by the database, while `join()`'s
|
|
230
|
+
* lookup is global (the public join URL carries no mailbox segment) - see the `VideoMeeting.publicSlug` doc comment
|
|
231
|
+
* for the full reasoning; a cross-mailbox collision is not actually prevented, only made astronomically unlikely by
|
|
232
|
+
* the slug's own entropy. `VideoMeeting.organizerSlug` has the same shape and entropy but is indexed unique
|
|
233
|
+
* *globally*, which is exactly the scope its own lookup uses - see its doc comment for why a per-mailbox compound
|
|
234
|
+
* index cannot work for a field only half the rows carry. Less rides on it either way: resolving through it grants
|
|
235
|
+
* nothing by itself, so an unlucky collision there would cost a caller a `404`, never access.
|
|
236
|
+
*
|
|
237
|
+
* @author Jean-Philippe Steinmetz
|
|
238
|
+
*/
|
|
239
|
+
export declare abstract class BaseVideoMeetingRoute<VM extends VideoMeeting, VMI extends VideoMeetingInvitee, M extends Mailbox> {
|
|
240
|
+
protected abstract meetingClass: any;
|
|
241
|
+
protected abstract inviteeClass: any;
|
|
242
|
+
protected abstract mailboxClass: any;
|
|
243
|
+
private _objectFactory?;
|
|
244
|
+
private meetingRepo?;
|
|
245
|
+
private inviteeRepo?;
|
|
246
|
+
private mailboxRepo?;
|
|
247
|
+
private aclUtils?;
|
|
248
|
+
private trustedRoles;
|
|
249
|
+
/** The `auth` config every other JWT in this deployment is signed/verified with - used by `mintGuestToken()` to
|
|
250
|
+
* mint a guest's own, short-lived token with the exact same signature the framework's `JWTStrategy` verifies. */
|
|
251
|
+
private authConfig;
|
|
252
|
+
/** The externally reachable base URL of the public join pages, used to build the invite links `create()`
|
|
253
|
+
* returns. Same single-value-config pattern as `mail:booking:public_url`/`mail:autodiscover:public_url`; when
|
|
254
|
+
* unset (or unsafe - see `buildBaseUrl()`) a join link is simply omitted rather than returning a broken one. */
|
|
255
|
+
private publicUrl;
|
|
256
|
+
private turnUrl;
|
|
257
|
+
private turnUsername;
|
|
258
|
+
private turnCredential;
|
|
259
|
+
private turnSharedSecret;
|
|
260
|
+
private logger;
|
|
261
|
+
/**
|
|
262
|
+
* Exposes the `@Model(...)`-supplied entity class as an instance property so `@Transactional()` on
|
|
263
|
+
* `persistMeeting()` can resolve which datasource to open a transaction against - identical to
|
|
264
|
+
* `BaseBookingRoute`'s own `modelClass` getter, for the same reason (this class deliberately doesn't extend
|
|
265
|
+
* `ModelRoute`, which defines the same getter for its own subclasses).
|
|
266
|
+
*/
|
|
267
|
+
get modelClass(): any;
|
|
268
|
+
private init;
|
|
269
|
+
/** Rejects a `403` unless `user` (with its trusted roles stripped - see `util/RouteAccessUtils.ts`) holds
|
|
270
|
+
* `action` on `mailboxUid`, by ownership or an explicit ACL grant. */
|
|
271
|
+
private requireMailboxAccess;
|
|
272
|
+
/** Loads the meeting `id` names, `404` if it doesn't exist, then enforces `requireMailboxAccess()` against its
|
|
273
|
+
* `mailboxUid`. Used by every owner-side method that acts on a single existing meeting. */
|
|
274
|
+
private requireOwnedMeeting;
|
|
275
|
+
/** Pure shape/format validation of `create()`'s body - independent of any permission check, matching
|
|
276
|
+
* `BaseBookingRoute.validateBook()`'s split between format checks (here) and business-rule checks (in the
|
|
277
|
+
* handler itself, which needs a DB round trip this does not). */
|
|
278
|
+
private validateCreateBody;
|
|
279
|
+
/** Parses a caller-supplied ISO timestamp, rejecting anything unparseable with a `400` - matches
|
|
280
|
+
* `BaseBookingRoute.requireDate()`. `startTime`/`endTime` are optional, so this is only called when present. */
|
|
281
|
+
private requireOptionalDate;
|
|
282
|
+
/**
|
|
283
|
+
* Writes the `VideoMeeting`/`VideoMeetingInvitee` rows for a new meeting. `@Transactional()` (resolving its
|
|
284
|
+
* datasource from the `@Model(...)` on the concrete subclass, via the `modelClass` getter above) makes every
|
|
285
|
+
* write atomic, matching `BaseBookingRoute.persistBooking()`'s identical reasoning.
|
|
286
|
+
*/
|
|
287
|
+
protected persistMeeting(mailboxUid: string, body: CreateVideoMeetingBody, visibility: VideoMeetingVisibility, startTime: Date | undefined, endTime: Date | undefined, user: JWTUser | undefined): Promise<{
|
|
288
|
+
meeting: VM;
|
|
289
|
+
invitees: VMI[];
|
|
290
|
+
}>;
|
|
291
|
+
/** The join URL for a token/slug, or `undefined` when no public URL is configured - see this class's doc
|
|
292
|
+
* comment on `mail:videoconf:public_url`. */
|
|
293
|
+
private joinUrl;
|
|
294
|
+
create(rawBody: CreateVideoMeetingBody | undefined, req: HttpRequest, user?: JWTUser): Promise<VideoMeetingCreateResult<VM>>;
|
|
295
|
+
/**
|
|
296
|
+
* Adds `organizerJoinUrl`/`publicJoinUrl` (see `joinUrl()`) to a persisted meeting for a caller re-reading it
|
|
297
|
+
* later. `create()`'s own response computes the same links inline from values it just minted, but `find()`/
|
|
298
|
+
* `findById()` load the plain persisted entity, which carries only the slugs themselves - so a caller who
|
|
299
|
+
* didn't keep `create()`'s one-time response (e.g. this plugin's own settings page, reloaded after the meeting
|
|
300
|
+
* that IS a user's "personal room" - see `.claude/NOTES.md`'s Phase 4 entry - was created in an earlier visit)
|
|
301
|
+
* would otherwise have no way to recover a public meeting's shareable link, or a private meeting's organizer
|
|
302
|
+
* link, at all. Spreads rather than mutates: the loaded instance is the repo's own entity, and both fields are
|
|
303
|
+
* response-only.
|
|
304
|
+
*/
|
|
305
|
+
private withJoinUrls;
|
|
306
|
+
find(mailboxUid: string | undefined, limit: string | undefined, page: string | undefined, user?: JWTUser): Promise<(VM & {
|
|
307
|
+
organizerJoinUrl?: string;
|
|
308
|
+
publicJoinUrl?: string;
|
|
309
|
+
})[]>;
|
|
310
|
+
findById(id: string, user?: JWTUser): Promise<VM & {
|
|
311
|
+
organizerJoinUrl?: string;
|
|
312
|
+
publicJoinUrl?: string;
|
|
313
|
+
}>;
|
|
314
|
+
update(id: string, body: {
|
|
315
|
+
title?: string;
|
|
316
|
+
status?: string;
|
|
317
|
+
} | undefined, user?: JWTUser): Promise<VM>;
|
|
318
|
+
delete(id: string, user?: JWTUser): Promise<void>;
|
|
319
|
+
/**
|
|
320
|
+
* Resolves `token` to the meeting it names, and to *how* it named it: an invitee's `joinToken` (43 base64url
|
|
321
|
+
* characters - `JOIN_TOKEN_PATTERN`), or one of the two 11-character slugs (`PUBLIC_SLUG_PATTERN`) - a public
|
|
322
|
+
* meeting's `publicSlug` or a private meeting's `organizerSlug`. The token and slug lengths never overlap (see
|
|
323
|
+
* `util/TokenUtils.ts`), so an invitee token is never tried as a slug or vice versa - unlike trying both in
|
|
324
|
+
* sequence, this can't accidentally treat one as the other just because the lookup that should have matched
|
|
325
|
+
* happened to miss. A stale/unknown/malformed/wrongly-shaped token, a cancelled meeting, or a meeting whose
|
|
326
|
+
* visibility no longer matches how the token was resolved (defense in depth - a meeting's `visibility` cannot
|
|
327
|
+
* actually change after creation) all answer identically: a plain `404`, matching
|
|
328
|
+
* `BaseBookingRoute.requireBookingByToken()`'s exact posture of never leaking whether a token almost-matched
|
|
329
|
+
* something.
|
|
330
|
+
*
|
|
331
|
+
* The two slug columns are disjoint by construction (`persistMeeting()` mints `publicSlug` only for a public
|
|
332
|
+
* meeting and `organizerSlug` only for a private one), so a slug-shaped token is looked up against `publicSlug`
|
|
333
|
+
* first - exactly Phase 1's lookup, with exactly Phase 1's outcome whenever it matches a row at all - and only
|
|
334
|
+
* a token that matches no `publicSlug` row is then looked up against `organizerSlug`.
|
|
335
|
+
*
|
|
336
|
+
* The resolution is returned alongside the meeting because `join()` authorizes the three cases differently: an
|
|
337
|
+
* invitee token and a `publicSlug` are each a self-contained credential ("possession of the link"), while an
|
|
338
|
+
* `organizerSlug` is not - see `join()`'s doc comment and `VideoMeeting.organizerSlug`.
|
|
339
|
+
*/
|
|
340
|
+
private requireMeetingByToken;
|
|
341
|
+
/**
|
|
342
|
+
* Adds an `ACLRecord` granting `uid` `READ`/`CREATE` on `meetingUid`'s own `AccessControlList`, unless one
|
|
343
|
+
* already exists (idempotent - a retried/duplicate call for the same uid, guest or real, is a no-op). Retries a
|
|
344
|
+
* handful of times on an optimistic-lock conflict (`saveACL()`'s version check): a public meeting can be joined
|
|
345
|
+
* by several callers at once, each racing to add their own record to the very same ACL document, and a lost
|
|
346
|
+
* race must be retried against the freshly re-read version rather than surfaced to the caller as an error.
|
|
347
|
+
* Named generically (not `ensureGuestChannelGrant()`, its Phase 1 name) since `join()` now calls this for a
|
|
348
|
+
* real, already-authenticated caller's own uid too - see this class's doc comment on "Real, already-
|
|
349
|
+
* authenticated callers".
|
|
350
|
+
*/
|
|
351
|
+
private ensureChannelGrant;
|
|
352
|
+
/**
|
|
353
|
+
* Mints a short-lived, scope-limited guest identity - see this class's doc comment on the channel-ACL-grant
|
|
354
|
+
* mechanism. Only called for the true-anonymous case (`join()`'s `user` is absent, or presents a prior guest
|
|
355
|
+
* uid rather than a real one). The deployment's real `auth` config normally carries its own `options.expiresIn`
|
|
356
|
+
* (every other token's session length), which `jsonwebtoken` refuses to combine with an explicit `exp` claim in
|
|
357
|
+
* the payload ("Bad 'options.expiresIn' option the payload already has an 'exp' property") - so this signs with
|
|
358
|
+
* a shallow copy of `authConfig` that omits `options.expiresIn`, letting the payload's own `exp` (this guest
|
|
359
|
+
* token's own, shorter `GUEST_JWT_TTL_SECONDS` lifetime) govern instead. Everything else about `authConfig` -
|
|
360
|
+
* the secret, the algorithm, `audience`/`issuer` - is unchanged, so this guest token verifies through the exact
|
|
361
|
+
* same `JWTStrategy` every other token does.
|
|
362
|
+
*/
|
|
363
|
+
private mintGuestToken;
|
|
364
|
+
/** The host mailbox's `displayName`, or `undefined` when the mailbox has none (or no longer exists - an
|
|
365
|
+
* orphaned meeting should still be joinable, just without a host name to show). */
|
|
366
|
+
private hostDisplayName;
|
|
367
|
+
join(token: string, user?: JWTUser): Promise<VideoMeetingJoinResult>;
|
|
368
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { MailboxMongo } from "@rapidmx/restapi/mongo";
|
|
2
|
+
import { VideoMeetingMongo } from "../../models/mongo/VideoMeetingMongo.js";
|
|
3
|
+
import { VideoMeetingInviteeMongo } from "../../models/mongo/VideoMeetingInviteeMongo.js";
|
|
4
|
+
import { BaseVideoMeetingRoute } from "../BaseVideoMeetingRoute.js";
|
|
5
|
+
/** The owner's video meeting management endpoints and the anonymous join endpoint (`/api/mail/video-meetings`). */
|
|
6
|
+
export declare class VideoMeetingRouteMongo extends BaseVideoMeetingRoute<VideoMeetingMongo, VideoMeetingInviteeMongo, MailboxMongo> {
|
|
7
|
+
protected meetingClass: any;
|
|
8
|
+
protected inviteeClass: any;
|
|
9
|
+
protected mailboxClass: any;
|
|
10
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { MailboxSQL } from "@rapidmx/restapi/sql";
|
|
2
|
+
import { VideoMeetingSQL } from "../../models/sql/VideoMeetingSQL.js";
|
|
3
|
+
import { VideoMeetingInviteeSQL } from "../../models/sql/VideoMeetingInviteeSQL.js";
|
|
4
|
+
import { BaseVideoMeetingRoute } from "../BaseVideoMeetingRoute.js";
|
|
5
|
+
/** The owner's video meeting management endpoints and the anonymous join endpoint (`/api/mail/video-meetings`). */
|
|
6
|
+
export declare class VideoMeetingRouteSQL extends BaseVideoMeetingRoute<VideoMeetingSQL, VideoMeetingInviteeSQL, MailboxSQL> {
|
|
7
|
+
protected meetingClass: any;
|
|
8
|
+
protected inviteeClass: any;
|
|
9
|
+
protected mailboxClass: any;
|
|
10
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This plugin's `./sql` entry point: exactly the classes a server host loads for a SQL deployment - the video
|
|
3
|
+
* meeting models and the route mounted at `/api/mail/video-meetings`. Anything else exported here would be
|
|
4
|
+
* registered by the host too, so the abstract route and utilities stay in the package root.
|
|
5
|
+
*/
|
|
6
|
+
export { VideoMeetingSQL } from "./models/sql/VideoMeetingSQL.js";
|
|
7
|
+
export { VideoMeetingInviteeSQL } from "./models/sql/VideoMeetingInviteeSQL.js";
|
|
8
|
+
export { VideoMeetingRouteSQL } from "./routes/sql/VideoMeetingRouteSQL.js";
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { ObjectFactory } from "@rapidrest/service-core";
|
|
2
|
+
import { VideoMeeting, VideoMeetingInvitee } from "../models/types.js";
|
|
3
|
+
/** The sole invitee `createSingleInviteeVideoMeeting()` mints a new private meeting for. */
|
|
4
|
+
export interface SingleMeetingInvitee {
|
|
5
|
+
email: string;
|
|
6
|
+
displayName?: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* **The integration surface other plugins call.** This is the one thing `@rapidmx/videoconf-plugin` exposes for
|
|
10
|
+
* another, independently-installed plugin to use in-process: mints a private `VideoMeeting` for exactly one
|
|
11
|
+
* invitee and returns just that invitee's join URL. `booking-plugin` is today's only caller - a video-location
|
|
12
|
+
* booking with no host-preset URL mints one of these automatically (the booker as the sole invitee, matching this
|
|
13
|
+
* plugin's own "one link per invitee" private-meeting design) - see that package's `BaseBookingRoute`/`.claude/
|
|
14
|
+
* NOTES.md` for the calling side, and this plugin's own `.claude/NOTES.md` for the design this integration was
|
|
15
|
+
* built against.
|
|
16
|
+
*
|
|
17
|
+
* This is deliberately the ONLY integration point this package exposes: everything else about `VideoMeeting`/
|
|
18
|
+
* `VideoMeetingInvitee` (routes, signaling, the join flow, cancellation, ...) is reached only through this
|
|
19
|
+
* package's own HTTP API. A caller never gets a `VideoMeeting`/`VideoMeetingInvitee` instance back, only the one
|
|
20
|
+
* value it actually needs, so this package stays free to change anything else about their shape without breaking
|
|
21
|
+
* a caller compiled against an older version.
|
|
22
|
+
*
|
|
23
|
+
* Mirrors `BaseVideoMeetingRoute.create()`'s own private-meeting-plus-single-invitee path exactly, including its
|
|
24
|
+
* `acl: { uid: instance.uid, parentUid: mailboxUid, records: [] }` claim (see that class's doc comment on why
|
|
25
|
+
* `VideoMeeting` needs its own per-record `AccessControlList`) - but skips everything about validating an HTTP
|
|
26
|
+
* request body or checking a caller's own permission on the mailbox: this is an in-process call from code that has
|
|
27
|
+
* already established both by its own means (e.g. `booking-plugin`'s already-authorized, already-validated
|
|
28
|
+
* booking flow).
|
|
29
|
+
*
|
|
30
|
+
* A caller-supplied `title`/`invitee.displayName` longer than this package's own route-level limits is silently
|
|
31
|
+
* truncated rather than rejected - there is no request here to answer with a `400`, and a slightly-shortened title
|
|
32
|
+
* is a far better outcome than throwing back into whatever the caller's own flow was doing. (`booking-plugin`'s own
|
|
33
|
+
* call site additionally wraps this whole function in a `try`/`catch` so even a genuine failure here - a database
|
|
34
|
+
* error, a stale/misconfigured deployment - never blocks the caller's own operation; see its doc comment.)
|
|
35
|
+
*
|
|
36
|
+
* @param objectFactory The caller's own `ObjectFactory` - used exactly as `BaseVideoMeetingRoute` uses its own, to
|
|
37
|
+
* build the repos this needs and to resolve `mail:videoconf:public_url`.
|
|
38
|
+
* @param meetingClass The concrete `VideoMeeting` model class for the caller's own backend (`VideoMeetingMongo`/
|
|
39
|
+
* `VideoMeetingSQL`, from this package's `./mongo`/`./sql` entry points) - this package has no notion of "Mongo" vs.
|
|
40
|
+
* "SQL" itself (see `BaseVideoMeetingRoute`'s identical `meetingClass` constructor parameter), so the caller
|
|
41
|
+
* supplies it, the same way `booking-plugin`'s own `BookingRouteMongo`/`BookingRouteSQL` supply their own concrete
|
|
42
|
+
* classes to `BaseBookingRoute`.
|
|
43
|
+
* @param inviteeClass The concrete `VideoMeetingInvitee` model class for the same backend.
|
|
44
|
+
* @param mailboxUid The mailbox the meeting is minted under - the caller's own already-authorized mailbox.
|
|
45
|
+
* @param title A short, human-readable meeting title (e.g. the caller's own meeting/event name).
|
|
46
|
+
* @param invitee The sole invitee of the new private meeting.
|
|
47
|
+
* @returns The invitee's join URL, or `undefined` when `mail:videoconf:public_url` isn't configured on this
|
|
48
|
+
* deployment - mirroring `VideoMeetingInviteeJoinInfo.joinUrl`'s identical semantics exactly. Never throws for
|
|
49
|
+
* that reason alone; a genuine failure (a bad `objectFactory`, a database error, ...) still propagates.
|
|
50
|
+
*/
|
|
51
|
+
export declare function createSingleInviteeVideoMeeting<VM extends VideoMeeting, VMI extends VideoMeetingInvitee>(objectFactory: ObjectFactory, meetingClass: new (...args: any[]) => VM, inviteeClass: new (...args: any[]) => VMI, mailboxUid: string, title: string, invitee: SingleMeetingInvitee): Promise<string | undefined>;
|