Creating a channel
Add a channel kind to the registry — pattern, modes, events — and teach the token endpoint who may join.
You have a new realtime surface: "staff in this election room", "everyone looking at this report." A channel kind is the unit you add. Until it is in the registry, publishTo cannot name it and the Hub cannot request a subscription token for it.
This is not "open a websocket." The Hub already has one connection. You are adding a named room on that connection, with a grant policy.
What you will touch
libs/shared/src/realtime/channel-registry.ts— the kind.apps/server/src/app/realtime/realtime-token.service.ts— who may be granted it.apps/server/src/app/realtime/dto/realtime.dto.ts—kindenum on the request DTO if it is requestable (Swagger / generated HubRealtimeChannel).- Tests in
libs/shared/src/realtime/tests/channel-registry.spec.tsand the token-service spec. - Only then: Hub
useRealtimeChannel(kind, params).
If you only need a new event on an existing kind, skip this page and go to Publishing an event.
Step 1: Declare the kind
export const REALTIME_CHANNELS = {
// ...existing kinds
'campus-staff': {
pattern: 'staff:campus.{campusId}',
modes: ['participant', 'observer'],
events: [], // presence-only until you add product events
},
} as const satisfies Record<string, ChannelDefinition>;Pattern. One {param} per id you need. Params are substituted by buildChannel. Centrifugo treats . # : , * as structure — SAFE_PARAM only allows [A-Za-z0-9_-]+ (UUIDs). A param with a dot would forge a different channel; the builder throws.
Pick a prefix that cannot parse as another kind. cbt-exam-proctor is cbt:exam.{examId}.proctor so it can never be mistaken for cbt-exam with a weird examId.
Modes.
participant— subscribe and appear in presence.observer— subscribe and receive presence/join/leave without appearing. School managers watching a campus use this.- Empty
modes— not requestable.personalis the example: the connection token's user-limited channel covers it. The Hub must notPOST subscription-tokenfor it.
Events. The RealtimeEventName values this kind may carry. Empty is valid (presence-only). publishTo checks this at compile time and at runtime.
ChannelKind and ChannelParamsFor<'your-kind'> derive from this object. buildChannel / parseChannel share the same pattern, so a grant and a refresh cannot drift.
Step 2: Authorize the grant
RealtimeTokenService.grant is a switch on kind. Add a case. The rules that already exist are the templates:
| Kind | Who gets in |
|---|---|
school-staff / admin-staff | Staff; channel id is their schoolId, never a client-supplied school |
campus-staff | Staff; campus must exist in their school. Participant: CampusScopeService.canAccessCampus. Observer: school-level scope only |
cbt-exam | Observer = staff who can proctor. Participant = the student in the exam (staff rejected) |
cbt-exam-proctor | Staff who can proctor; always participant on this sibling channel |
Name the channel on the server. Grant mode sends { kind, campusId? } (or examId). Refresh mode sends the literal { channel } plus mode. Parse the name with parseChannel, then re-run the same authorization. A stolen channel string must not mint a token.
Observer mode is not remembered. Every refresh must send mode: 'observer' again or you silently re-mint as a participant and the manager pops into the campus presence list. The Hub hooks already resend it; your new Hub call site must too.
If many observer channels are granted at once (school admin overview), follow observerGrant: one request, return campusId next to each grant so the client never parses names.
Step 3: Expose kind on the DTO
Requestable kinds must appear on SubscriptionTokenRequestDtoKind (the generated Hub const RealtimeChannel is a re-export of that enum). Add the swagger enum value, regenerate the Hub client. After regen, RealtimeChannel.YOUR_KIND exists — do not hand-write a mirror in realtime-channels.ts.
Step 4: Prove the pattern
expect(buildChannel('campus-staff', { campusId: 'c-1' })).toBe(
'staff:campus.c-1',
);
expect(() => buildChannel('campus-staff', { campusId: 'bad.id' })).toThrow();Add a token-service spec for the new case: happy grant, wrong school/campus, observer rejected for campus-level staff, refresh with a forged channel name.
Gotchas
Do not let the client invent the channel string on first grant. Kind + params in, server builds the name. Refresh is the only time the client echoes the string, and it is parsed and re-checked.
Presence is per kind. A user can be a participant on cbt-exam (the student roster) and an observer on the same exam's sibling — two channels, two policies. Don't overload one kind with contradictory presence rules.
Empty events is fine. You can ship presence before you ship publications. Adding events later is Publishing an event.