Naalya Handbook
Realtime

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

  1. libs/shared/src/realtime/channel-registry.ts — the kind.
  2. apps/server/src/app/realtime/realtime-token.service.ts — who may be granted it.
  3. apps/server/src/app/realtime/dto/realtime.dto.tskind enum on the request DTO if it is requestable (Swagger / generated Hub RealtimeChannel).
  4. Tests in libs/shared/src/realtime/tests/channel-registry.spec.ts and the token-service spec.
  5. 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

libs/shared/src/realtime/channel-registry.ts
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. personal is the example: the connection token's user-limited channel covers it. The Hub must not POST subscription-token for 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:

KindWho gets in
school-staff / admin-staffStaff; channel id is their schoolId, never a client-supplied school
campus-staffStaff; campus must exist in their school. Participant: CampusScopeService.canAccessCampus. Observer: school-level scope only
cbt-examObserver = staff who can proctor. Participant = the student in the exam (staff rejected)
cbt-exam-proctorStaff 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

libs/shared/src/realtime/tests/channel-registry.spec.ts
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.

Where to go next

On this page