zoom-meet-clone
PublicOpen-source Zoom / Meet clone: WebRTC rooms, invite links, chat, screen share and recording. MIT.
Harakumo1 commitUpdated 3 downloads
This page describes how Meet stores its data, how its main flows run, who may do what, its limits, how it handles failure, how it is secured, and how it scales. Every number comes from the code; the file that sets it is named. ARCHITECTURE.md has the parts and the diagrams.
MongoDB is reached through the official driver (lib/mongodb.ts). There is no
ORM and no schema enforced by the database. The TypeScript types below are the
schema. The database name is MONGODB_DB, which defaults to converge. Ids
that the app generates are crypto.randomUUID() strings stored in an id
field, next to Mongo's own _id. The exceptions are users and sessions, which
use _id.
users: type UserDocument in lib/auth.ts| Field | Type | Notes |
|---|---|---|
_id | ObjectId | The user id. Other collections store it as a string. |
name | string | Trimmed, 2 to 80 characters. |
username | string | Lower-cased, leading @ removed, must match ^[a-z0-9._-]{3,24}$. |
passwordHash | string | bcrypt with cost 12 (PASSWORD_ROUNDS). |
avatarUrl | string, optional | An http(s) URL, or an inline data:image/... URL of at most 400,000 characters. |
createdAt | Date |
Index: { username: 1 }, unique. POST /api/auth/register creates it on
every call.
sessions: inline type in lib/auth.ts| Field | Type | Notes |
|---|---|---|
_id | ObjectId | Generated by Mongo. |
token | string | crypto.randomUUID(). This is also the value of the converge_session cookie. |
userId | ObjectId | Points to users._id. |
expiresAt | Date | Now plus 30 days. |
createdAt | Date |
Index: { expiresAt: 1 } with expireAfterSeconds: 0, so MongoDB deletes
expired sessions on its own. createSession creates the index on every
sign-in.
meetings: type Meeting in types/meeting.ts, built by buildMeeting in lib/meetings.ts| Field | Type | Notes |
|---|---|---|
id | string (UUID) | The room id in /room/[meetingId]. |
ownerId | string, optional | The creator's user id. Always set by buildMeeting. |
workspaceId | string, optional | The workspace it belongs to. Missing means the owner's default workspace. |
roomName | string, optional | converge-<uuid>. Written, but no code reads it. |
title | string | Up to 200 characters when scheduled. Direct calls are titled "First × First". |
dateLabel, timeLabel | string | en-US labels made when the meeting is created, in the server's time zone. The dashboard re-formats from dateTime. |
dateTime | string (ISO) | The start time. |
duration | string | Default "30 min". Stored as sent, with no validation. |
type | "Design review" | "Weekly sync" | "Client call" | "Planning" | Default "Planning". Not validated on POST. |
attendees | string[] | Initials of the people on the invite. |
attendeeCount | number | |
status | "upcoming" | "live" | "ended" | Scheduled and instant meetings start as upcoming, direct calls as live. Only /api/meetings/end changes it, and only to ended. "Live" on the dashboard comes from signaling presence, not from this field. |
createdAt | string (ISO), optional | |
endedAt | string (ISO), optional | |
endedReason | string, optional | "empty" when the signaling server ends it, otherwise the reason sent, or "manual". |
meeting_participants: type ParticipantDocument in types/db.ts| Field | Type | Notes |
|---|---|---|
meetingId | string | Points to meetings.id. It can also be an ad-hoc room id with no stored meeting. |
userId | string | Points to users._id. |
name, username | string | A snapshot taken at join time. |
joinedAt | string (ISO) | Overwritten every time this user gets a room token. |
Written only by POST /api/realtime/token, as an upsert on
(meetingId, userId), and only for signed-in users. This collection powers
contacts, "recent meetings", notifications, profile stats and the
meeting-time-limit clock.
call_invites: type CallInvite in types/call.ts| Field | Type | Notes |
|---|---|---|
id | string (UUID) | |
meetingId | string | The room the callee is rung into. |
fromUserId, fromName, fromUsername | string | The caller. |
toUserId, toName | string | The callee. |
status | "ringing" | "accepted" | "declined" | "cancelled" | |
createdAt | string (ISO) |
"Missed" is not a stored status. It is an invite still ringing after
RING_WINDOW_MS (45 seconds, types/call.ts).
recordings: type RecordingDocument in types/db.ts| Field | Type | Notes |
|---|---|---|
id | string (UUID) | |
ownerId | string | The user who recorded and uploaded it. |
provider | "storage" | Always the app's S3-compatible bucket. |
filename | string | Up to 200 characters. |
contentType | string | video/webm from the room page. |
size | number | Bytes, more than 0 and at most 10 GiB. |
key | string | The bucket object key. Typed optional, but POST requires it. |
publicUrl | string, optional | Must be https, and must sit under STORAGE_PUBLIC_URL when that is set. |
meetingId | string, optional | |
workspaceId | string, optional | Taken from the meeting's workspace, otherwise from the request. |
createdAt | string (ISO) |
workspaces: type Workspace in types/workspace.ts| Field | Type | Notes |
|---|---|---|
id | string (UUID) | |
name | string | Trimmed, 1 to 60 characters. |
ownerId | string | |
isDefault | boolean | true for the "Personal" workspace created on a user's first GET /api/workspaces. |
createdAt | string (ISO) |
| What | Where it lives |
|---|---|
| Who is in which room right now | Memory of the signaling server (rooms and clients maps in realtime/server.mjs) |
| Chat messages | Nowhere. They are relayed live and lost when the room empties. |
| SFU sessions and tracks | The hosted SFU. Ownership is proven by a stateless HMAC, not stored. |
| Recording and avatar files | The S3-compatible bucket |
| The end-to-end encryption key | The #k= URL fragment, in browsers only |
| Active workspace, camera and mic preferences | Browser localStorage keys converge_active_workspace and converge_av_preferences |
POST /api/auth/register validates the name, username and password, and
checks that the two passwords match. It then requires the database, ensures
the unique username index, rejects a taken username with 409, and inserts
the user with a bcrypt hash.createSession inserts a session and attachSessionCookie sets
converge_session.POST /api/auth/login normalises the username, compares the password with
bcrypt, and does the same as step 2. With remember: false the cookie has
no expiry.POST /api/auth/logout deletes the session document and the cookie./api/meetings. An instant meeting has dateTime
set to now and the title "Instant team meeting". A scheduled one uses the
picked time converted to ISO.buildMeeting and inserts it with
status upcoming./room/<id>. A scheduled
one appears in the list. "Copy link" copies <origin>/room/<id>.{ meetingId } to /api/realtime/token. No sign-in is
needed.ended or its time limit
has passed. Otherwise it upserts the participant row (signed-in users only)
and signs a 6-hour room token: base64url(JSON claims) + "." + HMAC-SHA256.
It returns iceServers, which are STUN plus TURN from lib/turn.ts,
along with sfuEnabled and timeLimit.join, gets
room-state, and creates one RTCPeerConnection per existing participant.
The newcomer sends the offers. Every connection always negotiates one audio
and one video slot, so a device with no camera can still receive.SfuSession.connect creates an
SFU session through /api/sfu and pushes local tracks. Video is pushed with
three simulcast layers: a at 1.2 Mbps, b at 400 kbps and half
resolution, c at 120 kbps and quarter resolution. Once both the socket
and the push are ready, the browser broadcasts sfu-state. Each receiver
pulls those tracks, first at layer a (b once it is already pulling two
or more peers). Every SDP change goes through one serial queue.a and spotlight thumbnails ask for c. In
grid view a tile asks for a when that peer is presenting or there are at
most two remote peers, and b otherwise.POST /api/calls { toUserId } creates a new live meeting
titled "First × First" and a ringing invite. The caller goes straight
into the room.GET /api/calls every 5 seconds. It shows
invites that are still ringing and less than 45 seconds old.PATCH /api/calls/<id> { action: "accept" } and opens the
room. Decline sets declined. The API also lets the caller send cancel,
but the UI never calls it.ringing. After 45 seconds it drops
out of GET /api/calls, and for 24 hours GET /api/notifications lists it
as a missed call.POST /api/calls { toUserId, meetingId }. This is allowed only for
the meeting owner or someone who has joined it. It is hidden in encrypted
rooms, because ringing would bring the contact in without the key.AudioContext. MediaRecorder writes WebM (VP8 and Opus where
supported) at 2.5 Mbps.POST /api/uploads/sign for a
presigned URL and PUTs the blob straight to the bucket.POST /api/recordings. The recording
appears under Recordings and in notifications for 24 hours.Room left empty: when the last socket leaves, the signaling server waits
AUTO_END_MINUTES (default 5). If the room is still empty, it calls
POST /api/meetings/end with the shared secret. The meeting becomes ended
with reason empty, and later token requests get 410.
Owner ends it: the API accepts POST /api/meetings/end from the owner.
No button in the UI calls it. Leaving a room does not end it.
Time limit: set MEETING_TIME_LIMIT_MINUTES to turn this on.
joinedAt among the meeting's
participant rows. Rooms with no stored meeting count from now.Two gaps follow from this design:
joinedAt is overwritten on every token request, so once every
signed-in participant has rejoined, the clock restarts.Six-hour token expiry: the room token is checked when a socket
connects, and every reconnect fetches a fresh token, so an open call is
not cut at 6 hours. In SFU mode, however, SfuSession keeps the token it
was created with, and /api/sfu checks expiry. Pulls and layer changes
more than 6 hours after joining are therefore rejected.
encrypted-restart. Every other participant's media
stops, and they are told to ask for the new link.#k=<24 random bytes, base64url> to its own URL and
reloads.#k= flag only marks the room as encrypted. The media is
already DTLS-SRTP between the two browsers.GET /api/workspaces creates
"Personal" (isDefault: true) the first time.?workspaceId= through workspaceFilter
(lib/workspace-scope.ts). The default workspace also claims rows with no
workspaceId.workspaceId is unset on that owner's
meetings and recordings, so they fall back to the default workspace.POST /api/uploads/sign with kind: "avatar" and PUTs the
image to the bucket.data: URL on the user document instead.There are no admin, host or workspace-member roles. Workspaces cannot be shared. Permissions come from who you are relative to a document:
| Who | Can | Cannot |
|---|---|---|
| Signed-out visitor | See the landing page, sign up, sign in, and join any room by link as "Guest User". In the room they can use chat, screen share and layouts. | Open the dashboard pages (middleware redirects), use any route that calls requireUser, save a recording (the recording button shows, but upload signing answers 401), or ring contacts. |
| Signed-in user | Create meetings, workspaces, calls and recordings. Read their own meetings, recordings, contacts and notifications. Edit their own profile. | See other users' meetings or recordings, except a meeting they have joined. |
| Meeting owner | End the meeting through the API. Ring people into it. | |
| Meeting participant (has joined) | Ring people into it. The meeting shows in their lists. | End it. |
| Callee | Accept or decline an invite addressed to them. | Cancel it. |
| Caller | Cancel their own invite (API only). | Accept or decline it. |
| Workspace owner | Rename or delete it (not the last one). | |
| SFU session creator | Push, pull, renegotiate, close, and change layers on their own SFU session. | Change anyone else's session. |
| Anyone in a room, guests included | Send chat. Trigger encrypted-restart, which disconnects everyone else in the room. | |
Signaling server (holds REALTIME_SECRET) | End any meeting. | |
The app (holds REALTIME_SECRET) | Read the live-room list from GET /rooms. |
There are no host controls in the room: no mute others, no remove participant, no waiting room or lobby. Room ids are the only access control on a room. Stored meetings use UUIDs, but any string works as a room id. An unknown id opens an ad-hoc "Instant Meeting Room".
| Limit | Value | Set in |
|---|---|---|
| Username | 3 to 24 characters from a-z 0-9 . _ - | lib/auth.ts |
| Password | 8 to 200 characters | lib/auth.ts |
| Display name | 2 to 80 characters | lib/auth.ts |
| Server session lifetime | 30 days (cookie: 30 days, or the browser session) | lib/auth.ts |
| Room token lifetime | 6 hours | app/api/realtime/token/route.ts |
| Meeting title | 200 characters | app/api/meetings/route.ts |
| Upcoming meetings returned | 50, soonest first, not ended | app/api/meetings/route.ts |
| Recent meetings returned | 3 by default, at most 50 | app/api/meetings/recent/route.ts |
| Meeting shows as ended | 12 hours after its start time, when not live | app/api/meetings/recent/route.ts |
| Ring window | 45 seconds | types/call.ts |
| Notifications | 24-hour look-back; at most 20 missed calls, 5 recordings and 12 items in total | app/api/notifications/route.ts |
| Recordings listed | 50, newest first | app/api/recordings/route.ts |
| Recording size | 10 GiB (metadata check) | app/api/recordings/route.ts |
| Recording filename | 200 characters | app/api/recordings/route.ts |
| Presigned upload URL | 15 minutes | lib/storage.ts |
| Inline avatar | 400,000 characters | app/api/profile/route.ts |
| Workspace name | 60 characters; at least one workspace must remain | lib/workspace-scope.ts, app/api/workspaces/[workspaceId]/route.ts |
| Signaling message | 64 KB | realtime/server.mjs |
| Chat message | 1,000 characters | realtime/server.mjs |
| SFU announcement | 8 tracks, 64-character track names and session id | realtime/server.mjs |
| Empty-room auto-end | AUTO_END_MINUTES, default 5 | realtime/server.mjs |
| Meeting time limit | Off unless MEETING_TIME_LIMIT_MINUTES is set | app/api/realtime/token/route.ts |
| Presence lookup timeout | 1.2 seconds | lib/realtime-presence.ts |
| TURN credential lifetime requested | 86,400 seconds | lib/turn.ts |
| SFU pulled-track wait | 10 seconds | lib/sfu-client.ts |
| Socket reconnects | 6 attempts with back-off of 2, 4, 8, 8, 8 and 8 seconds | app/room/[meetingId]/page.tsx |
| Dashboard polling | Calls 5 s, recent meetings 15 s, notifications 20 s | app/dashboard.tsx |
There is no limit on how many people may join a room. In practice, mesh mode is bounded by each browser's upload, because every participant sends a copy of their media to every other participant.
apiHandler (lib/api.ts) maps ApiError to its status.
It logs 4xx at debug and 5xx at warn. Anything unexpected is logged with a
stack and answered with a generic 500. Malformed JSON bodies are 400.
Malformed ObjectIds read as "not found" (tryObjectId).getDatabase() returns null and logs one warning.
Routes that must write answer 503 "MongoDB is not configured yet.". Routes
that call requireUser answer 401 first, because no session can be found.
The demo fallbacks in lib/demo-data.ts are therefore reached only by the
room-token route, which gives the seeded room ids (design-review,
weekly-sync, client-call) their titles.{ configured: false }. Avatars fall back to inline images. A recording
whose upload or metadata save fails shows an error toast, and the recording
is not kept or retried.uncaughtException and unhandledRejection are
logged instead of crashing every live room. Token checks compare byte
lengths before timingSafeEqual, so a crafted signature cannot throw.SameSite=Lax cookie that is Secure in production. Logout deletes the
server copy. There is no CSRF token; state-changing routes take JSON bodies
and rely on the SameSite=Lax cookie.REALTIME_SECRET, checked in constant
time on both sides, and expiring after 6 hours. In production the app throws
rather than use the built-in development secret. The signaling server only
logs a warning and keeps running on that development secret when
REALTIME_SECRET is missing. A misconfigured signaling server would then
accept forged tokens and serve GET /rooms to anyone who knows the
published development value.GET /rooms and POST /api/meetings/end both
require the shared secret. The signaling server compares it in constant
time; app/api/meetings/end/route.ts uses a plain ===.app/api/calls/[callId]/route.ts
has a null prototype, so constructor and __proto__ are not valid
actions.workspaceId sent with a new
meeting, call or recording is stored without checking that the caller owns
that workspace. Lists are still limited to the caller's own and joined
meetings, so a wrong id only mislabels the caller's own rows and exposes
nothing.publicUrl must be https and sit under
STORAGE_PUBLIC_URL when that is set, because it is later used as a
<video> source. Files are served from public bucket URLs. Their keys
contain a random UUID, but there is no signed read or per-user access check
on playback. Avatars accept any http(s) URL.lib/logger.ts redacts context keys that match
token, secret, password, authorization, cookie, credential or apikey, and
strips user:pass@ from URLs. The contacts query excludes passwordHash.guest- plus Math.random() characters. Guests
write nothing to the database.MongoClient.users.username and
sessions.expiresAt). Hot queries filter meetings by id and ownerId,
meeting_participants by userId and meetingId, call_invites by
toUserId, status and createdAt, and recordings and workspaces by
ownerId. These scan without indexes, so a deployment past a handful of
users should add them by hand.GET /rooms. Load grows linearly with open
dashboards.These are absent from the code today: