@molecule/api-realtime-rooms
v1.0.1
Published
Realtime rooms abstraction: named pub/sub rooms with membership, capacity, join codes, role-based auth, built atop @molecule/api-realtime.
Downloads
518
Maintainers
Readme
@molecule/api-realtime-rooms
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.
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-realtimeAPI
Interfaces
BroadcastOptions
Options 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
}CreateRoomOptions
Options 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
}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 {@link Room.joinCode}. */
isPublic: boolean
/** Creation timestamp. */
createdAt: 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
}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
}Types
RoomEventHandler
Subscription handler receiving every broadcast on a room.
type RoomEventHandler = (event: RoomEvent) => void | Promise<void>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'Unsubscribe
Function returned by {@link subscribe} that removes the subscription when invoked. Implementations should be idempotent.
type Unsubscribe = () => voidClasses
InvalidJoinCodeError
Supplied join code did not match the room's configured code.
RoomCapacityExceededError
Room is full — capacity has been reached.
RoomError
Base class for all realtime-rooms errors.
RoomNotFoundError
The requested room does not exist.
UnauthorizedRoomActionError
The 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.
Functions
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): stringroomId— 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 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.
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): UnsubscriberoomId— Room to subscribe to.handler— Invoked with each {@link RoomEvent} broadcast 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.
