Kulcare Docs
Developer

Ably realtime channel naming

Cross-cutting contract for Ably channel names across all domains. Defines how channels are scoped, what they carry, and how to add a new one.

Naming convention

<scope>:<resource>:<id>
Scope prefixCapability requirementUsed by
private:Token-scoped (subscribe; never publish from clients)All current channels
(future) public:No token requiredReserved; nothing uses it yet

Channels in use

Every publish goes through one seam: Shared::RealtimePublisher is the only place in the app that talks to ::AblyClient (#2465). Callers reach it as publish_to_user(user_id:, event:, payload:, source:) for a user channel, or publish(channel:, ...) for the handful that are not user-scoped.

ChannelLifetimeWho is granted subscribePublishes fromPurpose
private:user:<user_id>Long-lived per userthe owning userNotifications::PublishRealtimeJob, Clinical::TeleconsultationWaitingEventJob, Clinical::VideoSessions::NotificationService, Phr::RealtimePublisher, Uhi::RealtimePublisherNotification, waiting-room, video-session, Scan & Share and UHI invalidation signals
admin-patientsDomain-wideplatform admins onlyClinical::ProcessCsvUploadJobPatient CSV upload progress for the Kulcare admin app

There are only two. Every signal is addressed to a person or to the platform-admin audience, and the capability follows from that without a per-resource authorization check.

private-encounter-<encounter_id> used to be a third, carrying the video-session lifecycle. It was removed in #2797: it had had no possible subscriber since #2350 dropped the encounter-* grant from Notifications::AblyAuthController#capability_for, so Ably refused the capability and every publish was a billed no-op. Those events now go to the participants' own user channels. The lesson is the one in step 3 below — a channel nobody is granted is a publish into nothing, and nothing in the system complains about it.

Event names on the user channel are dotted and namespaced by domain — notification.created, teleconsultation.patient_waiting, video_session.call_started, abdm.share_profile.completed. The channel is shared, so a bare event name is a collision risk; the video-session events were bare until #2797 and were safe only because the channel was unreachable.

Channel name resolution

Backend channel names are produced by a single helper per domain. Frontend names mirror the backend exactly — drift is silent (a frontend subscribed to the wrong channel just never receives events) so resolution should always go through:

  • Backend: Shared::RealtimePublisher.user_channel_for(user_id), which Notifications::Realtime.user_channel_for delegates to. publish_to_user applies it for you, so a call site normally passes a user_id and never a channel string at all.
  • Frontend: useNotificationsRealtime(userId) (or the underlying useRealTimeEvent(channelName, ...) from @kulcare/api-client)

Do NOT inline channel name strings at call sites.

Token capability

Channels are protected by Ably tokenRequests issued from POST /api/ably/auth (Notifications::AblyAuthController). The endpoint issues capability scoped to only the requesting user's channel:

{
  "private:user:<current_user.id>": ["subscribe"]
}

Specifically:

  • Always subscribe-only. No client should publish to Ably; all publishes originate from server jobs.
  • No history capability. If a token is exfiltrated, the holder can subscribe for the remaining TTL but cannot enumerate past events.
  • TTL is configurable via Rails.application.config.x.ably.token_ttl_ms (default 30 min via ABLY_TOKEN_TTL_MS env).

Adding a new channel

  1. Decide the scope. What's the resource id pattern? Is it per-user, per-resource (encounter, order, etc.), or domain-wide?
  2. Add the channel name to Shared::RealtimePublisher, next to user_channel_for. Do not add a per-domain channels.get(...) call — that is the drift #2465 removed, and the seam owns the enabled? check and the transient-error set along with the name.
  3. Decide who can subscribe, and check that somebody does. Update Notifications::AblyAuthController#capability_for to issue subscribe permission for the new pattern based on current_user, scoped — not broadened across all channels. A channel with no grant is a publish into nothing, which is what happened to private-encounter-*.
  4. Publish from a Sidekiq job, not from a controller. Ably::Rest#channel#publish is HTTP-blocking; controllers shouldn't pay for it.
  5. Add a frontend hook in @kulcare/shared-services that wraps useRealTimeEvent with the channel name. Don't inline channel strings in components.
  6. Add a smoke spec that asserts the auth endpoint returns capability scoped to the new channel — not all channels — for an authenticated user.

Failure modes

  • Ably API key missing. Shared::RealtimePublisher.enabled? returns false (Notifications::Realtime.enabled? delegates to it). Server-side publishes silently no-op; client-side reconnects fail at the auth endpoint (returns 503). The inbox / domain data still works via the polling fallback (React Query staleTime).
  • Token expired mid-session. Ably client auto-renews via authUrl. If renewal fails, the client falls back to its disconnect-retry loop and the frontend hook's staleTime takes over.
  • Stale event during reconnect. Frontend re-receives notification.created events after the connection comes back. Hooks treat these as invalidation signals (idempotent refetches), not data — so duplicates are harmless.
  • Backend auth controller: backend/domains/notifications/controllers/ably_auth_controller.rb
  • Backend publisher seam: backend/domains/shared/services/realtime_publisher.rb
  • Backend publisher example: backend/domains/notifications/jobs/publish_realtime_job.rb
  • Frontend Ably provider: frontend/packages/api-client/src/services/realtime/ablyProvider.ts
  • Frontend hooks: frontend/packages/api-client/src/hooks/useRealTime.ts

On this page