@molecule/api-realtime-rooms
Utility · realtime-rooms · API (Node) · v1.0.3 · 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-roomsHow it works
@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 } })Reference
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 —
assertCanAct is the central guard.
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.
Quick Start
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 } })
Type
utility
Installation
npm install @molecule/api-realtime-rooms @molecule/api-database @molecule/api-realtime
API
Interfaces
Room
A 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 `Room.joinCode`. */
isPublic: boolean
/** Creation timestamp. */
createdAt: Date
}
RoomMember
A 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
}
RoomEvent
An 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
}
CreateRoomOptions
Options for 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
}
BroadcastOptions
Options for broadcast.
interface BroadcastOptions {
/** Event name / discriminator. */
kind: string
/** Event payload. */
payload: unknown
/** Optional override for the broadcast timestamp. Defaults to `new Date()`. */
sentAt?: Date
}
Types
RoomRole
Role 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'
RoomEventHandler
Subscription handler receiving every broadcast on a room.
type RoomEventHandler = (event: RoomEvent) => void | Promise<void>
event— TheRoomEventthat was broadcast.
Unsubscribe
Function returned by subscribe that removes the subscription
when invoked. Implementations should be idempotent.
type Unsubscribe = () => void
Classes
RoomError
Base class for all realtime-rooms errors.
class RoomError extends Error {
/**
* Creates a new RoomError with the given message.
* @param message - English fallback message.
*/
constructor(message: string) {
super(message)
this.name = 'RoomError'
}
}
RoomNotFoundError
The requested room does not exist.
class RoomNotFoundError extends RoomError {
/**
* Creates a RoomNotFoundError for the given room id.
* @param roomId - The room id that was not found.
*/
constructor(public readonly roomId: string) {
super(`Room not found: ${roomId}`)
this.name = 'RoomNotFoundError'
}
}
RoomCapacityExceededError
Room is full — capacity has been reached.
class RoomCapacityExceededError extends RoomError {
/**
* Creates a RoomCapacityExceededError for the given room and capacity.
* @param roomId - The room that is full.
* @param capacity - The configured capacity.
*/
constructor(
public readonly roomId: string,
public readonly capacity: number,
) {
super(`Room ${roomId} is at capacity (${capacity})`)
this.name = 'RoomCapacityExceededError'
}
}
InvalidJoinCodeError
Supplied join code did not match the room's configured code.
class InvalidJoinCodeError extends RoomError {
/**
* Creates an InvalidJoinCodeError for the given room.
* @param roomId - The room that rejected the join.
*/
constructor(public readonly roomId: string) {
super(`Invalid join code for room ${roomId}`)
this.name = 'InvalidJoinCodeError'
}
}
UnauthorizedRoomActionError
The acting user is not authorised to perform the requested action.
Thrown by 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.
class UnauthorizedRoomActionError extends RoomError {
/**
* Creates an UnauthorizedRoomActionError for the given room, user, and optional role.
* @param roomId - The room the action was attempted on.
* @param userId - The user id that attempted the action.
* @param requiredRole - Optional minimum role that was required.
*/
constructor(
public readonly roomId: string,
public readonly userId: string,
public readonly requiredRole?: 'host' | 'guest',
) {
super(
requiredRole
? `User ${userId} is not authorised as ${requiredRole} on room ${roomId}`
: `User ${userId} is not a member of room ${roomId}`,
)
this.name = 'UnauthorizedRoomActionError'
}
}
Functions
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.
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.
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 broadcast immediately
before invoking this).
function closeRoom(roomId: string): Promise<void>
roomId— The room to close.
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 configuredjoinCode.
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.
assertCanAct(roomId, userId, requiredRole)
Throws 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 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 RoomEvent that was broadcast (useful for echo / local persistence).
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
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 eachRoomEventbroadcast on the room.
Returns: Idempotent function that removes this subscription.
Injection Notes
Requirements
Peer dependencies:
@molecule/api-database^1.0.1@molecule/api-realtime^1.0.1
Runtime Dependencies
@molecule/api-database@molecule/api-realtime
Tables: 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.