← All @molecule/* packages · App templates
@molecule/api-realtime-roomsUtility · realtime-rooms · API (Node) · v1.0.1 · Apache-2.0
Realtime rooms abstraction: named pub/sub rooms with membership, capacity, join codes, role-based auth, built atop @molecule/api-realtime.
npm install @molecule/api-realtime-rooms@molecule/api-realtime-rooms is a utility package for the API (Node) side (realtime-rooms).
import {
createRoom,
joinRoom,
broadcast,
subscribe,
assertCanAct,
} from '@molecule/api-realtime-rooms'
// Host creates a private room with a join code.
const room = await createRoom({
kind: 'quiz-session',
ownerId: hostUserId,
capacity: 30,
joinCode: 'ABC123',
})
// Guest joins.
await joinRoom(room.id, guestUserId, 'ABC123')
// Broadcast a question — handler must authorise first.
await assertCanAct(room.id, hostUserId, 'host')
await broadcast(room.id, { kind: 'question-asked', payload: { qid: 1 } })Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Realtime-rooms utility for molecule.dev.
Authenticated, capacity-bounded, role-aware named pub/sub rooms built
on top of @molecule/api-realtime's transport bond and persisted via
@molecule/api-database's abstract DataStore.
Solves the IDOR pattern flagged in flagship realtime apps (quiz-platform live sessions, virtual-classroom rooms) where any authenticated user could subscribe to / broadcast on any channel — {@link assertCanAct} is the central guard.
import {
createRoom,
joinRoom,
broadcast,
subscribe,
assertCanAct,
} from '@molecule/api-realtime-rooms'
// Host creates a private room with a join code.
const room = await createRoom({
kind: 'quiz-session',
ownerId: hostUserId,
capacity: 30,
joinCode: 'ABC123',
})
// Guest joins.
await joinRoom(room.id, guestUserId, 'ABC123')
// Broadcast a question — handler must authorise first.
await assertCanAct(room.id, hostUserId, 'host')
await broadcast(room.id, { kind: 'question-asked', payload: { qid: 1 } })
utility
npm install @molecule/api-realtime-rooms @molecule/api-database @molecule/api-realtime
BroadcastOptionsOptions for {@link broadcast}.
interface BroadcastOptions {
/** Event name / discriminator. */
kind: string
/** Event payload. */
payload: unknown
/** Optional override for the broadcast timestamp. Defaults to `new Date()`. */
sentAt?: Date
}
CreateRoomOptionsOptions for {@link createRoom}.
interface CreateRoomOptions {
/** App-level discriminator. */
kind: string
/** User id that will be recorded as the host. */
ownerId: string
/** Maximum concurrent members (host counts). Optional. */
capacity?: number
/** Shared secret required to join. When supplied, `isPublic` is forced `false`. */
joinCode?: string
/** Whether non-invited users may join freely. Defaults to `true`. */
isPublic?: boolean
/**
* Optional pre-generated room id. When omitted, a new id is minted by
* the underlying DataStore.
*/
id?: string
}
RoomA persisted realtime room.
kind is an opaque app-level discriminator (e.g. 'quiz-session',
'virtual-classroom') so a single rooms table can serve multiple
features without collision.
interface Room {
/** Unique room identifier. */
id: string
/** App-level discriminator, e.g. `'quiz-session'`. */
kind: string
/** User id of the room host (creator). */
ownerId: string
/** Maximum number of concurrent members. `undefined` means uncapped. */
capacity?: number
/** Optional shared secret required to join private rooms. */
joinCode?: string
/** When `false`, joiners must supply a matching {@link Room.joinCode}. */
isPublic: boolean
/** Creation timestamp. */
createdAt: Date
}
RoomEventAn event broadcast to all subscribers of a room.
kind is the event name (e.g. 'question-asked', 'answer-revealed')
and payload is the arbitrary serialisable body.
interface RoomEvent {
/** The room this event was broadcast on. */
roomId: string
/** Event name / discriminator. */
kind: string
/** Event payload. Must be JSON-serialisable. */
payload: unknown
/** Server-side broadcast timestamp. */
sentAt: Date
}
RoomMemberA persisted member of a room.
interface RoomMember {
/** The room this membership belongs to. */
roomId: string
/** The user holding the membership. */
userId: string
/** The user's role within the room. */
role: RoomRole
/** When the user joined. */
joinedAt: Date
}
RoomEventHandlerSubscription handler receiving every broadcast on a room.
type RoomEventHandler = (event: RoomEvent) => void | Promise<void>
RoomRoleRole a member holds within a room.
host — created the room, full control (close, kick, broadcast).guest — joined the room, may broadcast iff the host allows it.type RoomRole = 'host' | 'guest'
UnsubscribeFunction returned by {@link subscribe} that removes the subscription when invoked. Implementations should be idempotent.
type Unsubscribe = () => void
InvalidJoinCodeErrorSupplied join code did not match the room's configured code.
RoomCapacityExceededErrorRoom is full — capacity has been reached.
RoomErrorBase class for all realtime-rooms errors.
RoomNotFoundErrorThe requested room does not exist.
UnauthorizedRoomActionErrorThe acting user is not authorised to perform the requested action.
Thrown by {@link assertCanAct} when the user is not a member of the room or lacks the required role. This is the central guard that fixes the IDOR pattern in flagship realtime apps.
assertCanAct(roomId, userId, requiredRole)Throws {@link UnauthorizedRoomActionError} unless userId is a member
of roomId (and optionally holds at least requiredRole).
Role hierarchy: host > guest. Requesting requiredRole = 'guest'
is satisfied by any membership; requiredRole = 'host' requires the
member's role to be host.
Always call this before any broadcast / membership-mutation / privileged read in your handler. This is the IDOR fix.
function assertCanAct(roomId: string, userId: string, requiredRole?: RoomRole): Promise<RoomMember>
roomId — Room being acted on.userId — Acting user id.requiredRole — Minimum role required. Optional.Returns: The member row (useful when the caller needs the role).
broadcast(roomId, event)Broadcasts an event to all subscribers of a room via the bonded
realtime provider.
Caller responsibility: authorise first via {@link assertCanAct}. This function is intentionally unauthenticated to keep it composable — middleware/handlers decide whether the actor is allowed to publish.
function broadcast(roomId: string, event: BroadcastOptions): Promise<RoomEvent>
roomId — Room to broadcast on.event — Event kind + payload.Returns: The {@link RoomEvent} that was broadcast (useful for echo / local persistence).
channelFor(roomId)Realtime channel identifier for a given room. Apps subscribing to the underlying transport directly should use this same convention so the abstraction layers up cleanly.
function channelFor(roomId: string): string
roomId — Room identifier.Returns: Stable channel name.
closeRoom(roomId)Closes a room — deletes membership rows then the room itself.
Subscribers should observe a final event of their choosing (callers
commonly broadcast 'room-closed' via {@link broadcast} immediately
before invoking this).
function closeRoom(roomId: string): Promise<void>
roomId — The room to close.createRoom(options)Creates a new room and registers the owner as the host member.
Capacity, join-code, and public/private invariants are enforced here
(a join-code forces isPublic=false).
function createRoom(options: CreateRoomOptions): Promise<Room>
options — Creation parameters.Returns: The persisted room.
joinRoom(roomId, userId, joinCode)Adds a user to a room as a guest.
Validates capacity and join-code. Re-joining is idempotent — a user already in the room receives their existing membership without incrementing the count.
function joinRoom(roomId: string, userId: string, joinCode?: string): Promise<RoomMember>
roomId — Room to join.userId — User joining.joinCode — Required when the room has a configured joinCode.Returns: The user's membership row.
leaveRoom(roomId, userId)Removes a user from a room. Idempotent — silently succeeds if the user is not a member.
function leaveRoom(roomId: string, userId: string): Promise<void>
roomId — Room to leave.userId — User leaving.listMembers(roomId)Lists all current members of a room.
Note: callers that need to enforce visibility (only members can list
other members) should await assertCanAct(roomId, viewerUserId) first.
function listMembers(roomId: string): Promise<RoomMember[]>
roomId — Room to list.Returns: Members ordered by joined_at ascending.
subscribe(roomId, handler)Subscribes handler to every event broadcast on roomId.
This package installs exactly one transport-level onMessage listener
for the whole process (at module load) and multiplexes it: subscribe()
registers handler against the room's channel and the returned
{@link Unsubscribe} removes that handler. Subscribing installs no
additional transport listener, and unsubscribing fully detaches the handler
from the registry — so listeners never accumulate and it is safe to
subscribe / unsubscribe per request. The unsubscribe is idempotent.
function subscribe(roomId: string, handler: RoomEventHandler): Unsubscribe
roomId — Room to subscribe to.handler — Invoked with each {@link RoomEvent} broadcast on the room.Returns: Idempotent function that removes this subscription.
Peer dependencies:
@molecule/api-database ^1.0.1@molecule/api-realtime ^1.0.1@molecule/api-database@molecule/api-realtimeTables: the .sql file under src/__setup__ creates realtime_rooms +
realtime_room_members. An mlcl-scaffolded API replays .sql files under
__setup__ automatically on migrate; anywhere else run it once — nothing
at runtime creates them. The shipped DDL is PostgreSQL-flavoured
(gen_random_uuid(), TIMESTAMPTZ); adapt the column types for
SQLite/MySQL — the service itself is dialect-agnostic (abstract DataStore).
Wiring prereqs: a database bond must be wired (any @molecule/api-database-*
provider) AND a realtime transport must be set via @molecule/api-realtime's
setProvider() (e.g. the @molecule/api-realtime-socketio provider) before
broadcast/subscribe deliver anything. subscribe() registrations made
before the transport is set are buffered by api-realtime and flushed when
it is; broadcast() before then throws "Realtime provider not configured".
subscribe() does NOT install a transport-level listener per call. The
package installs exactly ONE shared onMessage listener for the whole
process (at import) and multiplexes it to per-room handlers; subscribe()
registers a handler and the returned unsubscribe fully removes it. Listeners
never accumulate, so subscribing / unsubscribing per request is safe.