Naalya Handbook

How realtime works

Centrifugo, connection vs subscription tokens, and the channel registry — the map before you add a channel or an event.

Some screens should not wait for a refresh. A department is created in another tab, a student submits an exam answer, a colleague comes online on a campus — the Hub should move. Realtime is that layer: the API publishes a small event to Centrifugo (an open-source websocket broker), and the Education Hub's websocket client applies it.

This is not Rover's chat stream (that is SSE). It is not a second REST API. Clients never publish — the server is the only writer, and the Hub is a subscriber holding a short-lived JWT.

The moving parts

PieceWhat it does
Centrifugo v6The broker. Redis-backed so history survives a restart. Client publish is disabled everywhere.
RealtimeServiceMints tokens and runs publishTo. Product code never speaks Centrifugo's HTTP API itself.
Channel registryREALTIME_CHANNELS in libs/shared/src/realtime/channel-registry.ts — each channel's kind, name pattern, allowed modes, and which events it may carry. publishTo throws if you fire the wrong event at a kind.
Education HubRealtimeProvider holds one Centrifuge client. useRealtimeChannel acquires a named channel for as long as the component is mounted.

The two tokens

Auth is deliberately two short-lived JWTs, not one. The Hub already holds a bearer JWT and swaps it for Centrifugo tokens at these endpoints — there is no connect proxy.

TokenLifetimeEndpointProves
Connection~1 hourGET /api/v1/realtime/tokenWho is on the websocket. Subject is the user id.
Subscription~5 minutesPOST /api/v1/realtime/subscription-tokenThat they may join this channel. Refresh must resend mode.

Existing channels

KindPatternModesEvents
personalpersonal:user#{userId}(not requestable — user-limited on the connection)notification
school-staffstaff:school.{schoolId}participantinvalidate
campus-staffstaff:campus.{campusId}participant, observer(presence — no product events yet)
admin-staffstaff:admin.{schoolId}participantnone yet
cbt-examcbt:exam.{examId}participant (students), observer (proctors)exam-status
cbt-exam-proctorcbt:exam.{examId}.proctorparticipant (staff)attempt-flag, attempt-progress

personal is joined automatically from the connection token (allow_user_limited_channels); the Hub subscribes to personal:user#<userId> inside RealtimeProvider. It carries notification only — a new notification for that user, or an all-null payload meaning their read state changed elsewhere. Do not put cache invalidation on personal — a user subscribed to several channels would fan out. Invalidation lives on school-staff only.

Channel names are opaque to the Hub. Grants return { channel, token }, plus campusId on observer grants so the client can key a map without parsing the name.

Events

Every publication is { type, data }, where type is a key of RealtimeEventMap. Clients ignore unknown types, so you can add an event without breaking old tabs.

Payload types do not go through Swagger. They ride the Chowbea type bus from realtime.chowbea.ts into the Hub's _generated/bus. Add an event without exporting it on the bus and the Hub is typing a guess.

Doing the work

On this page