QUESTPIE
ClientChannels

Authorization

Subscribe and publish are two rules on one channel. This page covers what each one defaults to, what the rule receives, why a route handler can be denied its own publish, and how to cut a subscriber off mid-session.

View markdown

Most channel bugs come from one assumption: that a channel you can subscribe to is a channel you can publish to. It is not. The two verbs are decided separately, and the quiet default denies the second one.

What the builder decides

.authorize() is what makes a channel private. .presence() needs it first, and the compiler enforces that order.

Builder stateVisibilitySubscribePublish
channel(…).events(…)publicanyonedenied for a user context
.authorize(fn)privatefnfn
.authorize({ subscribe })privatethe rulethe same rule
.authorize({ subscribe, publish })privatesubscribepublish
.presence(resolver)presenceunchangedunchanged

The object form always needs subscribe. publish is the optional half.

Visibility also lands on the wire name. A private channel is delivered as private-<pattern> and a presence channel as presence-<pattern>. A public one carries no prefix.

Writing a rule

A rule is a boolean or a function. The function receives the generated AppContext with typed params merged in. So session, collections, db and your services are all there.

src/questpie/server/channels/chat-room.ts
export default channel("chat-room-[roomId]")
	.events({ message: z.object({ text: z.string() }) })
	.authorize({
		subscribe: async ({ params, session, collections }) => {
			if (!session?.user) return false;
			const member = await collections.roomMembers.findOne({
				where: { room: params.roomId, user: session.user.id },
			});
			return member !== null;
		},
		publish: true,
	});

Two things deny the operation: returning false and running out of time. The deadline is 5 seconds by default.

Throwing is the third way to be refused, and it is reported as its own thing. A rule that throws fails closed exactly like a denial, so an error in your query cannot leak access. But QUESTPIE logs it at error level with the cause and raises channel_rule_failed instead of a verdict. Over SSE the subscription error names the channel whose rule failed; on the channel HTTP routes the status is 500, not 403, because the server never decided. Your rule's own message stays server-side. Do not use a throw to mean no: return false.

Why your own handler gets denied

Publish has one bypass. A system context skips the publish rule entirely. Nothing else does.

ContextCounts as systemPublish rule runs
Job handler, app.createContext()yesno
HTTP request, with or without a sessionnoyes
A hook fired during an HTTP requestnoyes
POST /channels/publish from the browsernoyes

HTTP contexts are created with accessMode: "user". So channels.publish() inside a route handler is judged by the same rule the browser is. On a public channel there is no rule to fall back on, and the answer is no. Add publish: true if the check belongs in your handler rather than the channel.

Revoking access mid-session

The subscribe rule runs when the subscription opens, and then not again. Removing someone from a room later does not close the stream they already hold. Call revokeAuthority() in the same transaction as the write that removed them.

await channels.revokeAuthority("chatRoom", {
	params: { roomId },
	subject: { kind: "user", id: removedUserId },
	idempotencyKey: `chat-room:${roomId}:${removedUserId}:v2`,
});

The subject kind is "user", "oauth" or "session", and its id must be non-empty and at most 256 characters. The call advances a durable generation for that exact channel and subject, then returns { generation, scope }.

scope tells you how wide the cut was. It is set by the transport, not by you.

TransportscopeWhat closed
SSE"exact-subscription"That one binding, with reason access_revoked
Pusher/Soketi"principal-connections"Every current connection for that principal

SSE closes the single logical binding. Other channels on the same multiplexed stream keep running. Pusher's termination API is user-wide, so QUESTPIE terminates the principal's connections and lets the reconnect reauthorize. Bindings that are still allowed come straight back. The revoked one does not.

Managed providers have an in-flight window

Pusher cannot promise that a frame already accepted by the socket will not land while termination is running. The durable fence blocks new ordered dispatch, and reconnect reauthorizes. This is not zero-frame revocation.

Security settings

These live under realtime.channelSecurity in your runtime config.

OptionDefaultWhat it does
trustedOriginsnoneExtra exact browser origins
authorizationTimeoutMs5000Rule deadline. A timeout denies
publishRatePerSecond10Token refill for each publish bucket
publishBurst20Tokens each bucket holds

Origins

The publish, auth and replay routes require an exact Origin header whenever the request carries a cookie. GET /channels/config does not. Your app URL is trusted already. Anything else must be listed. Each entry must be HTTPS, or plain HTTP on localhost, 127.0.0.1 or [::1], and must carry no path. A wildcard is rejected. There is no way to trust every origin.

Publish rate limits

Every client publish spends one token from two independent buckets, one keyed by session and one by principal. Both must have a token or the request is refused. An anonymous caller falls back to its forwarded IP. A refusal returns 429 with a Retry-After header.

Next

Presence is the API .authorize() unlocks. It answers who is in the room right now.

On this page