Skip to main content

Realtime & streaming

CNS has two separate, currently-disconnected realtime transports. This is not a stylistic choice you can ignore — picking the wrong one against a given deployment will silently do nothing or fail outright, so this page exists to make the split explicit.

1. GET /v1/stream — CNS's own SSE bridge

A Server-Sent-Events endpoint on cns-server itself. It authorizes the request (see below), then subscribes an ephemeral consumer group to the internal event bus and forwards matching events as data: <event JSON>\n\n frames — one raw event envelope per frame, filtered to the caller's own tenant_id and (by default) their own subject_id as actor-or-target.

GET /v1/stream?channel=<subject_id-or-group-scope> # omit `channel` for "my own feed"

This works today, entirely independent of whichever realtime adapter is configured — it's transported over the same in-process bus every event already flows through on ingestion, not through the realtime adapter's own publish path.

:::caution But it still requires a configured realtime adapter to authorize GET /v1/stream runs two subscribe-authorization checks: CNS's own core/authz.SubscribeAuthorizer (works regardless of realtime config), and realtime.Realtime.AuthorizeSubscribe — the realtime adapter's own callback. The noop realtime adapter (what you get with no realtime settings configured) fails this second check closed by design (internal/adapters/realtime/noop's AuthorizeSubscribe always returns Deny), and cmd/cns-server always wires something for this seam (noop when unconfigured, never left nil). Net effect, verified by reading both files directly: GET /v1/stream returns 403 on any deployment that hasn't configured a real realtime adapter — including the dev fixture. This is a genuine Phase-1 gap, not documented elsewhere as of this writing. :::

2. CNSClient.subscribe() — direct WebSocket to the realtime adapter

The TypeScript client SDK's subscribe() does not call GET /v1/stream at all. It opens a WebSocket directly to a realtime adapter URL you configure separately (e.g. Centrifugo's own wss://.../connection/websocket endpoint) and speaks a connect/subscribe command protocol modeled on Centrifugo's client protocol, with CNS's own since_seq folded into the subscribe request for replay.

const client = new CNSClient({
baseUrl: "https://cns.example.com",
getToken: () => getUserToken(),
realtime: { url: "wss://realtime.example.com/connection/websocket" },
});

const unsubscribe = client.subscribe("sub_alice", (event) => {
console.log(event.event_type, event.data);
}, { sinceSeq: lastKnownSeq });

:::caution Wire protocol is an explicit placeholder The design spec lists "Centrifugo channel naming + subscribe-token authz binding" as an unresolved open item, and the client's own source comment says as much: packages/client/src/realtime.ts implements "a minimal, reasonable JSON command/reply shape," not a confirmed contract with a real realtime backend. Revise before depending on it in production; CNSClient's public subscribe()/ connectionState surface is designed not to need to change once the real contract lands. :::

Channel naming (server side)

The Centrifugo adapter resolves channel names as:

cns:<tenant_id>:subject:<subject_id>
cns:<tenant_id>:group:<group_id>

A subject channel is authorized structurally (identity equality — you may always subscribe to your own subject channel); a group channel delegates to the same subscribe authorizer the rest of the platform uses, and fails closed (ErrNoGroupAuthorizer) if none is configured. Known gap: group-channel messages are not yet membership-filtered on the bus-transported GET /v1/stream path — an authorized group-scope stream currently receives every tenant event once authorized, not just events targeting that group's members.

Presence

The realtime adapter interface also exposes Presence(channel) — "who is live on this channel right now." It's used for operational visibility and as a plausibility signal (e.g., a passive client-asserted "viewed" event claiming to have watched a 10-minute video in 2 seconds is suspicious — see Trust & render-tokens) — never as proof of anything on its own.