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
| Piece | What it does |
|---|---|
| Centrifugo v6 | The broker. Redis-backed so history survives a restart. Client publish is disabled everywhere. |
RealtimeService | Mints tokens and runs publishTo. Product code never speaks Centrifugo's HTTP API itself. |
| Channel registry | REALTIME_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 Hub | RealtimeProvider 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.
| Token | Lifetime | Endpoint | Proves |
|---|---|---|---|
| Connection | ~1 hour | GET /api/v1/realtime/token | Who is on the websocket. Subject is the user id. |
| Subscription | ~5 minutes | POST /api/v1/realtime/subscription-token | That they may join this channel. Refresh must resend mode. |
Existing channels
| Kind | Pattern | Modes | Events |
|---|---|---|---|
personal | personal:user#{userId} | (not requestable — user-limited on the connection) | notification |
school-staff | staff:school.{schoolId} | participant | invalidate |
campus-staff | staff:campus.{campusId} | participant, observer | (presence — no product events yet) |
admin-staff | staff:admin.{schoolId} | participant | none yet |
cbt-exam | cbt:exam.{examId} | participant (students), observer (proctors) | exam-status |
cbt-exam-proctor | cbt:exam.{examId}.proctor | participant (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.