Skip to content

Real-time — Socket.IO, WebRTC Meetings & Terminal Relay

This document is the canonical reference for every real-time transport in the MAS / Mr. Mentor backend: the Socket.IO server that powers 1:1 mentorship video meetings (WebRTC signalling, recording, presence, chat, meeting timers) and the separate raw-WebSocket "terminal relay" that bridges a student's browser to a CLI running on their own laptop. It covers the connection handshake, the in-memory room model, every socket event group, the S3 multipart recording flow, presence/online tracking, and the token-paired terminal relay.

Status: documented from source on this branch.


Overview

There are two independent real-time servers, both bolted onto the same Node HTTP server created in src/index.ts:

  1. Socket.IO server (src/socket.ts, ~1170 lines) — mounted at path /socket.io. It drives the live mentorship meeting room: WebRTC peer signalling, media toggles, the meeting timer (start / pause / resume / auto-end), client-side recording uploaded to S3 via multipart, in-room chat, and a global online-presence registry. It also doubles as the notification fan-out bus — services and workers push events to a single user via emitToUser (src/socket/bus.ts).

  2. Terminal relay WebSocket server (src/services/TerminalRelayWsServer.ts) — a bare ws WebSocketServer in noServer mode, listening only for HTTP upgrade requests on /api/terminal/relay. It pairs exactly two peers (a student browser running xterm.js and the @myanalyticsschool/connect CLI on the student's own machine) sharing one short-lived token, then pipes bytes between them. This is the in-browser terminal / Monaco "Run" feature.

Who uses it

Persona Real-time touchpoint
Student (USER) Joins the meeting room, sends/receives WebRTC media, chats, runs code in the browser terminal
Mentor (EXPERT) Joins the meeting room, starts/stops recording, ends the meeting
Observer Joins silently with the reserved name __observer__ (no participant logging, hidden from rosters)
Admin / Superadmin Reviews recordings and meeting logs over REST
Backend services / workers Push targeted notifications via emitToUser

Where it sits: Socket.IO is part of the Mr. Mentor mentorship product; the terminal relay belongs to the MAS LMS in-browser coding experience. Both are cross-cutting transports rather than a single feature domain. For the booking/earnings side of meetings see mentorship-and-meetings.


Key concepts & entities

Glossary

  • Room (roomId) — the Socket.IO room a meeting runs in. All meeting events are scoped to it via io.to(roomId). Frontends derive roomId from the slot.
  • Slot (slotId) — the booked mentorship session (the Slots entity). Drives start/end times, the hard auto-end deadline, and earnings crediting.
  • Meeting state — the in-memory MeetingState object per active room (activeMeetings map). Not persisted as a whole; reconstructed on demand from the slot and Redis.
  • Participant vs Observer — a participant has a real userId and is counted toward starting/ending the meeting and crediting earnings. An observer joins with userName === '__observer__' and is excluded from rosters, logging, and timers.
  • Notification room (user:<userId>) — every authenticated socket joins this personal room on connect so backend code can target a user with emitToUser.
  • Recording session — an in-memory RecordingSession keyed by <roomId>-<timestamp>, holding the S3 multipart uploadId, accumulated parts, and a pendingBuffer for sub-5MB chunks.
  • Terminal token — a mas_term_* token (≈144 bits) stored in Redis with a 300s TTL that authorises one browser↔CLI bridge.

Main entities & state (file paths)

Thing Where Persisted?
MeetingState interface src/types/socketTypes/index.ts In-memory only (activeMeetings)
User (socket roster entry) src/types/socketTypes/index.ts In-memory (roomUsers)
RecordingSession src/types/socketTypes/index.ts In-memory (recordingSessions, src/state/recordingSessions.ts)
MeetingRecording (DB row) src/entities/MeetingRecording.ts PostgreSQL meeting_recordings
MeetingLogs (DB row) src/entities/MeetingLogs.ts PostgreSQL — every lifecycle event
Slots src/entities/Slots.ts PostgreSQL — status, actualStartTime, endDateTime
TerminalSessionRecord src/services/TerminalRelayService.ts Redis (tterm:tok:*, 300s TTL)

In-memory maps in src/socket.ts (all module-scoped, single-process):

roomUsers            // roomId -> User[]                  socket roster per room
activeMeetings       // roomId -> MeetingState            live meeting timer/participants
onlineUsers          // userId -> Set<socketId>           global presence
endedMeetings        // Set<slotId>                       formally-ended guard (blocks rejoin)
slotExpiryTimers     // roomId -> setTimeout handle       hard auto-end timer
disconnectGraceTimers// socketId -> handle                LEGACY, no longer armed
activeUserSockets    // userId -> socketId                takeover bookkeeping

Architecture

flowchart TD
  subgraph CLIENT["Clients"]
    FE["mr-mentor-frontend meeting room"]
    XT["mas-website xterm.js + Monaco"]
    CLI["mas-connect CLI on student laptop"]
  end

  subgraph HTTP["Node HTTP server (src/index.ts)"]
    SIO["Socket.IO server (path /socket.io)"]
    UPG["upgrade handler /api/terminal/relay"]
    REST["Express REST routes"]
  end

  subgraph SOCKET["src/socket.ts setupSocket"]
    ROOMS["In-memory maps: roomUsers, activeMeetings, onlineUsers, recordingSessions"]
    SIG["WebRTC signalling relay"]
    REC["Recording session manager"]
    TIMER["Meeting timer + auto-end"]
  end

  subgraph RELAY["TerminalRelayWsServer"]
    PAIR["pairings map (token -> peer)"]
    BRIDGE["bridgePeers byte pump"]
  end

  subgraph SERVICES["Services"]
    SCS["SlotCompletionService"]
    MRS["MeetingRecordingService"]
    MLS["MeetingLogsService"]
    S3U["S3UploaderService"]
    TRS["TerminalRelayService"]
    BUS["socket/bus emitToUser"]
  end

  subgraph EXT["External + stores"]
    PG[("PostgreSQL")]
    RD[("Redis")]
    S3[("AWS S3 recordings")]
  end

  FE -->|websocket| SIO
  XT -->|browser peer| UPG
  CLI -->|cli peer| UPG
  FE -->|POST upload-chunk| REST
  XT -->|POST terminal session| REST

  SIO --> SOCKET
  UPG --> RELAY
  REST --> MRS
  REST --> TRS

  SIG --> ROOMS
  REC --> MRS
  REC --> S3U
  TIMER --> SCS
  SOCKET --> MLS

  RELAY --> TRS
  TRS --> RD
  SCS --> PG
  MRS --> PG
  MLS --> PG
  S3U --> S3
  BUS --> SIO
  TIMER --> RD

Data model

Real-time code owns no entities of its own beyond what it persists for meetings. The durable footprint is MeetingRecording and MeetingLogs, both keyed by Slots.

erDiagram
  SLOTS ||--o{ MEETING_RECORDINGS : "has"
  SLOTS ||--o{ MEETING_LOGS : "logs"
  USER ||--o{ MEETING_RECORDINGS : "recordedBy"
  USER ||--o{ MEETING_LOGS : "actor"

  SLOTS {
    uuid id PK
    uuid studentId FK
    uuid mentorId FK
    timestamp startDateTime
    timestamp endDateTime
    timestamp actualStartTime
    int durationMinutes
    string status
  }

  MEETING_RECORDINGS {
    uuid id PK
    uuid slotId FK
    uuid recordedBy FK
    string sessionId
    string fileName
    string s3Key
    string s3Url
    string s3Bucket
    string status
    timestamp recordingStartTime
    timestamp recordingEndTime
    int durationSeconds
    bigint fileSizeBytes
    string mimeType
    boolean localFileDeleted
    string errorMessage
  }

  MEETING_LOGS {
    uuid id PK
    uuid slotId FK
    uuid userId FK
    string roomId
    string eventType
    string userRole
    timestamp eventTime
    json metadata
    string notes
  }

Notable enums / status fields

  • RecordingStatus (src/entities/MeetingRecording.ts): recording, processing, uploaded, failed, deleted. Default on create is recording.
  • MeetingStatus (src/entities/Slots.ts): the socket layer specifically guards against COMPLETED.
  • MeetingLogEventType (src/entities/MeetingLogs.ts): meeting_initialized, user_joined, user_left, meeting_started, meeting_ended, recording_started, recording_stopped.
  • ParticipantRole: mentor, student, observer, unknown — derived in determineUserRole by comparing the socket userId to meeting.mentorId / meeting.studentId.
  • TerminalMode (src/services/TerminalRelayService.ts): sandbox, local-shell, local-ide.

API surface

The transports themselves are not REST. Two REST endpoints feed them, plus the recording read/write endpoints. Socket.IO and the relay listen on the same host/port as the HTTP server (default 8000).

HTTP / WebSocket entry points

Method Path Auth/role Purpose
WS /socket.io handshake.auth.userId (no JWT verify — see gotchas) Socket.IO meeting + presence + notification transport
WS upgrade /api/terminal/relay Token in first frame (resolved against Redis) Browser↔CLI terminal byte relay
POST /api/terminal/session authMiddleware (student) Mint a terminal token + return the relay wss:// URL

src/routes/terminalSession.routes.ts mounts /terminal/session under /api. The relay path is matched directly in the HTTP upgrade handler (TerminalRelayWsServer.attachTerminalRelay).

Recording REST (mounted under /api, src/routes/recording.routes.ts)

Method Path Auth/role Purpose
POST /api/recordings/db/upload-chunk authMiddleware + multer 10MB Stream one recording chunk into the active S3 multipart upload
POST /api/recordings/db/upload authMiddleware + multer 500MB Legacy full-file upload
POST /api/recordings/db/upload-url authMiddleware Presigned client-side upload URL
POST /api/recordings/db/confirm-upload authMiddleware Confirm a client-side upload
GET /api/recordings/db/slot/:slotId authMiddleware Recordings for a slot
GET /api/recordings/db/session/:sessionId authMiddleware Recording by session id
GET /api/recordings/db/all authMiddleware + adminMiddleware All recordings
GET /api/recordings/db/status/:status authMiddleware + adminMiddleware Recordings by status
GET /api/recordings/db/stats/storage authMiddleware + adminMiddleware Storage stats
DELETE /api/recordings/db/:sessionId authMiddleware + adminMiddleware Soft-delete a recording
GET/DELETE /api/recordings, /api/recordings/:filename none (legacy local disk) Legacy local-file listing/download/delete

Socket.IO event catalogue

Inbound = client emits to server; Outbound = server emits to client(s).

Group Inbound events Outbound events
Presence get-online-users user-online, user-offline, online-users-list
Meeting lifecycle initialize-meeting, join-room, end-meeting meeting-initialized, meeting-started, meeting-ended, meeting-ending-soon, join-denied, join-error, meeting-error, meeting-end-error, room-users, room-users-updated, user-joined, user-left, participant-rejoined
WebRTC signalling offer, answer, ice-candidate offer, answer, ice-candidate
Media toggles audio-toggle, video-toggle audio-toggled, video-toggled
Recording start-recording, stop-recording, pause-recording, resume-recording, recording-notification-started, recording-notification-stopped recording-session-created, recording-bot-ready, recording-started, recording-stopped, recording-stopped-confirmed, recording-paused, recording-resumed, recording-error
Connection takeover takeover-connection takeover-confirmed, connection-takenover
Timer timer-sync-request, timer-sync-response, timer-started, timer-extended timer-paused, timer-resumed, timer-stopped, plus echoes of the inbound sync events
Chat chat-message chat-message

There is no explicit leave-room handler — leaving is handled entirely by the disconnect handler. meeting-ending-soon is emitted by the server during auto-end (it is not a client event).


User journeys

1. Connection + auth handshake and presence

On connect, the server reads socket.handshake.auth.userId. There is no JWT verification at the socket layer — the userId is trusted as supplied (see gotchas). If present, the socket joins its personal notification room and is registered in the global presence map.

sequenceDiagram
  participant FE as Frontend
  participant IO as Socket.IO server
  participant MAP as onlineUsers map
  participant OTH as Other clients

  FE->>IO: connect with auth userId
  IO->>IO: read handshake.auth.userId
  alt userId present and first socket for user
    IO->>MAP: create set then add socketId
    IO->>OTH: broadcast user-online for userId
  else additional tab for same user
    IO->>MAP: add socketId to existing set
  end
  IO->>IO: socket.join user room for userId
  Note over IO: personal room enables emitToUser fan-out
  FE->>IO: get-online-users
  IO-->>FE: online-users-list with all userIds

On disconnect the socket id is removed from the user's set; when the set becomes empty the user is dropped and user-offline is broadcast globally.

2. Meeting initialization and join (timer start)

initialize-meeting is idempotent per room and seeds MeetingState. join-room admits a participant, starts the meeting when the first real participant arrives, and syncs the timer for rejoiners. A "backfill" path covers the race where a participant joins the socket room before initialize-meeting runs.

sequenceDiagram
  participant FE as Frontend
  participant IO as Socket.IO server
  participant SCS as SlotCompletionService
  participant DB as PostgreSQL
  participant RD as Redis

  FE->>IO: initialize-meeting with roomId slotId mentorId studentId
  IO->>DB: find slot by id
  alt slot ended or completed or past hard deadline
    IO-->>FE: join-denied with reason flags
  else ok and room not yet active
    IO->>RD: get cached pausedMs for slot
    IO->>IO: create MeetingState and store in activeMeetings
    IO->>IO: schedule hard auto-end at slot end plus 5 min
    IO->>DB: log MEETING_INITIALIZED
    opt participants already in room
      IO->>SCS: startMeeting slotId
      IO->>IO: emit meeting-started to room
    end
    IO-->>FE: meeting-initialized
  end

  FE->>IO: join-room with roomId userName userId
  IO->>DB: re-check slot status and deadline
  alt blocked
    IO-->>FE: join-denied
  else allowed
    IO->>IO: socket.join roomId and add to roomUsers
    IO->>IO: store participant socketId to userId
    IO->>DB: log USER_JOINED
    opt first participant this session
      IO->>SCS: startMeeting slotId
      IO->>DB: set actualStartTime
      IO->>IO: emit meeting-started to room
    end
    opt timer was paused and someone rejoined
      IO->>SCS: savePausedMs for slot
      IO->>IO: emit timer-resumed to room
      IO-->>OTH: participant-rejoined
    end
    IO-->>FE: room-users with existing peers
    IO-->>FE: room-users-updated broadcast
  end

Note: the elapsed timer the client renders is now minus startTime minus totalPausedMs, computed server-side and sent in meeting-started / timer-resumed payloads so reconnecting clients stay in sync.

3. WebRTC peer-connection establishment (two participants)

The server is a pure signalling relay — it forwards offer / answer / ice-candidate verbatim to the single target socket (data.to) and stamps from: socket.id. Media never touches the server. Each new joiner learns existing peers via room-users, and existing peers learn the joiner via user-joined; the frontend decides who initiates the offer (typically the existing peer offers to the newcomer).

sequenceDiagram
  participant A as Peer A existing
  participant IO as Socket.IO relay
  participant B as Peer B joining

  Note over A,B: both already in the same roomId
  B->>IO: join-room
  IO-->>B: room-users lists A.socketId
  IO-->>A: user-joined with B

  A->>A: createOffer then setLocalDescription
  A->>IO: offer with to B.socketId
  IO-->>B: offer from A.socketId

  B->>B: setRemoteDescription then createAnswer
  B->>IO: answer with to A.socketId
  IO-->>A: answer from B.socketId
  A->>A: setRemoteDescription

  par ICE trickle both directions
    A->>IO: ice-candidate with to B.socketId
    IO-->>B: ice-candidate from A.socketId
  and
    B->>IO: ice-candidate with to A.socketId
    IO-->>A: ice-candidate from B.socketId
  end

  Note over A,B: peer connection established and media flows directly
  A->>IO: audio-toggle isEnabled false
  IO-->>B: audio-toggled with A.socketId

Media-toggle state is persisted on the roster User (initialAudioEnabled / initialVideoEnabled) so a late joiner receives the correct current mute state.

4. Recording — client capture, S3 multipart upload, DB metadata

Recording is client-side: the browser does MediaRecorder capture and POSTs chunks over REST while the socket layer manages the S3 multipart lifecycle and DB row. There is no server-side recording bot — recording-bot-ready is emitted immediately so the client can begin. Chunks under S3's 5MB minimum are buffered in session.pendingBuffer and flushed once they cross the threshold (or on the final chunk).

sequenceDiagram
  participant FE as Recorder client
  participant IO as Socket.IO server
  participant REST as Recording REST
  participant S3U as S3UploaderService
  participant MRS as MeetingRecordingService
  participant S3 as AWS S3

  FE->>IO: start-recording with roomId meetingId slotId recordedBy
  IO->>IO: build s3Key and create RecordingSession
  IO->>MRS: createRecording status recording
  IO->>S3U: startMultipartUpload for s3Key
  S3U->>S3: CreateMultipartUpload
  S3-->>S3U: uploadId
  IO->>IO: store uploadId and init parts and nextPartNumber
  IO-->>FE: recording-session-created with sessionId uploadId
  IO-->>FE: recording-bot-ready
  IO->>IO: emit recording-started to room

  loop every chunk via REST
    FE->>REST: POST upload-chunk with sessionId partNumber chunk
    REST->>REST: append to pendingBuffer
    alt buffer at least 5MB or final
      REST->>S3U: uploadPart with buffer
      S3U->>S3: UploadPart
      S3-->>S3U: eTag
      REST->>REST: push PartNumber and eTag to session.parts
      REST-->>FE: flushed true with partNumber
    else still accumulating
      REST-->>FE: flushed false with bufferSize
    end
  end

  FE->>IO: stop-recording with sessionId durationSeconds
  opt pending buffer remains
    IO->>S3U: uploadPart final part
  end
  IO->>S3U: completeMultipartUpload with sorted parts
  S3U->>S3: CompleteMultipartUpload
  S3-->>S3U: s3Url
  IO->>MRS: updateRecordingOnStop then updateRecordingAfterUpload status uploaded
  IO->>IO: log RECORDING_STOPPED
  IO-->>FE: recording-stopped-confirmed success true
  IO->>IO: emit recording-stopped to room
  IO->>IO: delete recording session from map

Failure paths: if no parts were uploaded, the multipart upload is aborted and the row is marked failed via updateRecordingOnUploadFailure. If the recorder socket disconnects mid-recording, the disconnect handler marks the session failed and deletes it. pause-recording / resume-recording only flip the in-memory isRecording flag — they do not pause the S3 upload.

5. Ending a meeting (manual + automatic)

A user clicks End → end-meeting. The server completes the slot (crediting mentor earnings if participation was valid), marks the slot in endedMeetings to block rejoin, stops the timer, notifies everyone, and cleans the room after a 1s grace so clients receive the event.

sequenceDiagram
  participant FE as Frontend
  participant IO as Socket.IO server
  participant SCS as SlotCompletionService
  participant DB as PostgreSQL

  FE->>IO: end-meeting with roomId
  alt meeting missing or not started
    IO-->>FE: meeting-end-error
  else ok
    IO->>DB: log MEETING_ENDED
    IO->>SCS: completeMeeting slotId now
    SCS->>DB: set status COMPLETED and credit earnings
    SCS-->>IO: result with earnings and participation
    IO->>IO: add slotId to endedMeetings and clear expiry timer
    IO->>IO: emit timer-stopped reason ended
    IO->>IO: emit meeting-ended with earnings info
    IO->>IO: after 1s delete roomUsers and cleanupRoom
  end

Automatic end runs via scheduleSlotExpiryAutoEnd: a setTimeout fires at slot.endDateTime + 5 minutes. It emits timer-stopped, meeting-ending-soon, completes the slot, emits meeting-ended, and cleans up. The same hard-deadline check also runs immediately when the last participant leaves an empty room (if already past deadline, end now; otherwise just pause and allow rejoin).

6. Connection takeover (multi-tab)

When a user opens the meeting in a second tab, the new tab emits takeover-connection. The server disconnects the user's older socket(s) for that room, tells the old tab it was taken over, and confirms to the new tab.

sequenceDiagram
  participant NEW as New tab
  participant IO as Socket.IO server
  participant OLD as Old tab

  NEW->>IO: takeover-connection with roomId userId
  IO->>IO: find other sockets for userId in room
  loop each old socket
    IO-->>OLD: connection-takenover newTab true
    IO->>OLD: disconnect true
  end
  IO->>IO: activeUserSockets set userId to new socketId
  IO-->>NEW: takeover-confirmed success true

Note a separate dedup path in join-room: if the same userId rejoins with a new socket, the old roster entry is removed and a user-left for the old socket id is broadcast so peers drop the stale video tile immediately.

7. In-browser terminal relay (browser ↔ student CLI)

The student requests a token over REST, pastes/launches the CLI with that token, and both peers dial the relay WebSocket. The relay parks the first peer and bridges the two once both present the same token.

sequenceDiagram
  participant BR as Browser xterm
  participant API as POST terminal session
  participant TRS as TerminalRelayService
  participant RD as Redis
  participant WS as TerminalRelay WS
  participant CLI as mas-connect CLI

  BR->>API: POST terminal session with sceneId mode
  API->>TRS: issueToken for studentId sceneId
  TRS->>RD: reuse bind key token or mint mas_term token TTL 300s
  TRS-->>API: token and expiresAt
  API-->>BR: token relayUrl expiresAt

  Note over BR,CLI: student starts CLI with the same token

  BR->>WS: upgrade then first frame auth token peer browser
  WS->>TRS: resolveToken
  TRS->>RD: get token record
  TRS-->>WS: record valid
  WS-->>BR: ready
  WS->>WS: park browser peer in pairings

  CLI->>WS: upgrade then first frame auth token peer cli
  WS->>TRS: resolveToken
  TRS-->>WS: record valid
  WS-->>CLI: ready
  WS->>WS: both present so bridge peers and clear pairing
  WS-->>BR: peer-connected and peer-resize dims
  WS-->>CLI: peer-connected and peer-resize dims

  loop interactive session
    CLI-->>BR: stdout bytes binary
    BR-->>CLI: keystrokes binary unless read only
    BR-->>CLI: resize control message
  end
  CLI->>WS: close
  WS-->>BR: peer-disconnect then close both

Control-frame whitelist forwarded between peers: resize, close, ide-ready, ide-error. Everything else that is text JSON is dropped; binary frames are stdio. See the [browser-terminal-and-editor skill] reference for the client side.


Background jobs & async

  • Slot-expiry auto-end timersslotExpiryTimers holds one setTimeout per active room, armed at initialize-meeting for slot.endDateTime + 5 min. These are in-process timers, not BullMQ jobs, so they are lost on restart (the deadline is re-checked on the next join/disconnect, which re-guards).
  • Relay heartbeatTerminalRelayWsServer runs a 30s setInterval ping/pong sweep, terminating dead peers so dangling halves do not accumulate. The interval is .unref()-ed.
  • Meeting notification emailsMeetingNotificationHandler (src/handlers/MeetingNotificationHandler.ts) enqueues meeting-approval / meeting-confirmation jobs onto the BullMQ email queue via QueueService.addEmailJob (3 attempts, exponential backoff). This is the async path that complements the live socket events.
  • Notification busemitToUser(userId, event, payload) in src/socket/bus.ts lets any service or worker push a real-time event to a connected user via their user:<userId> room. No-ops if the socket server has not booted, so workers can call it freely.
  • Recording uploads — chunk uploads are REST POSTs (not a queue); the multipart parts array lives in the in-memory session until stop-recording completes the upload.
  • Paused-time persistencetotalPausedMs is mirrored to Redis (meeting:pausedMs:<slotId>) and the DB via SlotCompletionService.savePausedMs so elapsed time survives a room re-init.

External integrations

System Used by Env vars Failure / fallback
AWS S3 Recording multipart upload AWS_S3_BUCKET_NAME, AWS_REGION, AWS creds On startMultipartUpload failure: emit recording-error, delete session. On complete failure: abort MPU, mark row failed
Redis Terminal tokens, paused-ms cache REDIS_HOST, REDIS_PORT Token issuance/resolution requires Redis; relay auth fails closed if record missing
PostgreSQL Meeting logs, recordings, slots DB_* Log writes are wrapped non-fatal (try/catch, logged and continued)
Socket.IO CORS Browser origin allow-list CORS_ALLOWED_ORIGINS (JSON array) Defaults to [] (empty) if unset — no origins allowed
Meeting join window Early-join control ALLOW_EARLY_MEETING_JOIN, MEETING_JOIN_BUFFER_MINUTES If true, all join-time checks pass; on DB error canJoinMeeting fails open (allows)

Feature flags / config

  • ALLOW_EARLY_MEETING_JOIN=true bypasses the start-time buffer check entirely.
  • MEETING_JOIN_BUFFER_MINUTES (default 5) is how early a user may join before startDateTime.
  • The hard auto-end grace is a constant: GRACE_PERIOD_AFTER_SLOT_END_MS = 5 * 60 * 1000.
  • Terminal token TTL is a constant 300s in TerminalRelayService.

Status lifecycles

MeetingRecording

stateDiagram-v2
  [*] --> recording: createRecording on start-recording
  recording --> uploaded: stop-recording then multipart complete
  recording --> failed: complete fails or no parts or disconnect
  recording --> processing: reserved transitional state
  processing --> uploaded
  uploaded --> deleted: admin DELETE
  failed --> [*]
  deleted --> [*]
  uploaded --> [*]

Meeting state (in-memory, per room)

stateDiagram-v2
  [*] --> initialized: initialize-meeting
  initialized --> started: first participant join-room
  started --> paused: last participant leaves before deadline
  paused --> started: someone rejoins
  started --> ended: end-meeting or hard deadline
  paused --> ended: deadline reached while empty
  ended --> [*]: cleanupRoom after delay

Terminal relay pairing

stateDiagram-v2
  [*] --> waiting: first peer auth ok and parked
  waiting --> bridged: second peer auth ok
  waiting --> dropped: first peer closes before partner
  bridged --> closed: either peer closes or errors
  dropped --> [*]
  closed --> [*]

Edge cases, limits & gotchas

  • No JWT verification on the socket handshake. src/socket.ts trusts socket.handshake.auth.userId as-is for presence and notification-room membership. A client could supply any userId. (inferred: this is acceptable today because meeting authorisation is enforced at booking time and meeting events are room-scoped, but it is a real trust gap to flag.)
  • CORS defaults to deny. If CORS_ALLOWED_ORIGINS is unset, the parsed origin list is [], so all browser origins are rejected. This env var must be set in every environment.
  • Single-process in-memory state. roomUsers, activeMeetings, onlineUsers, endedMeetings, and the recording-session map live in module scope. The app does not use a Socket.IO Redis adapter, so it will not scale horizontally without sticky sessions and/or an adapter. A blue-green/restart loses active meeting timers (re-derived from slot + Redis paused-ms on next join).
  • Terminal relay requires both peers on the same instance. pairings is in-memory; the code explicitly documents the multi-replica caveat (needs sticky sessions or a Redis pub/sub router).
  • Recording-bot-ready is a no-op signal. There is no server bot — the name is historical; recording is fully client-driven.
  • S3 5MB minimum part size. Chunks are accumulated in pendingBuffer until they reach 5MB; only the final part may be smaller. Parts are sorted by PartNumber before completeMultipartUpload.
  • fileSizeBytes is always 0. The stop handler does not track per-part sizes, so the DB row stores 0 for size (totalFileSize = 0).
  • Idempotent terminal tokens. issueToken reuses the live token for a (studentId, sceneId) pair via the tterm:bind: reverse index, so React strict-mode double mounts and refreshes do not orphan a token the CLI is already paired with. Tokens are not consumed on read because both peers present the same one.
  • Read-only terminal sessions. If the CLI handshakes with readOnly: true, the relay drops browser→CLI binary frames server-side (defense in depth; the CLI also drops them).
  • Disconnect grace is legacy. disconnectGraceTimers exists but disconnect handling is now immediate — peers see user-left at once. Code comments note the old 15s grace period was removed.
  • Observer escape hatch. userName === '__observer__' bypasses join checks, participant logging, timers, and rosters. Anyone using that exact name joins silently.
  • leave-room is not handled explicitly — all departures flow through disconnect. A client that navigates away without disconnecting the socket can briefly remain in the roster.
  • Multi-platform (x-platform) routing does not affect these transports directly; the socket layer is platform-agnostic and keys everything off slotId / roomId.