Channels
Realtime channels are declared, not discovered. Your backend publishes one table saying which channel names exist and who may do what on them, and every join is decided against it. A channel nobody declared is refused.
That default is the whole point. Without a declaration, any client holding a valid token could join any channel name it could type — including one named after another user. With it, user:42 is joinable by user 42 and nobody else, and the check costs no round trip to your code at all.
Where the declaration lives
One file, at the root of your project:
your-project/
├── channels.ts ← the declaration
├── db/public.ts
└── modules/
palbase push picks it up the same way it picks up db/public.ts — you do not import it from anywhere, and there is nothing to wire.
Warning: It has to be
channels.tsat the project root. Underconfig/it would never run, and a declaration that never runs is not a harmless absence here: undeclared channels are refused, so every channel in your app would go dark while the file sat in plain sight.So when every join starts answering
unauthorized, the first thing to check is thatchannels.tsis at the project root and thatpalbase pushreported it — not the client.
Quick example
// channels.ts
import { Database, defineChannels, ownerOnly, publicChannel } from "@palbase/backend";
/** An ordinary function in this file — `authorize` runs in the request scope a
* controller gets, so `Database` and RLS work exactly as they do in a service. */
async function isMember(userId: string, roomId: string): Promise<boolean> {
const rows = await Database.public.rooms.findMany({ where: { id: roomId }, limit: 1 });
return rows.length > 0 && userId !== "";
}
export default defineChannels({
// Anybody signed in may listen. The server broadcasts; clients cannot.
"announcements": publicChannel({ publish: false, state: { read: true } }),
// `user:42` is joinable by user 42 alone — decided from the token, no hop.
"user:{uid}": ownerOnly(),
// Your code decides, per join.
"room:{roomId}": {
async authorize({ user, params }) {
const member = await isMember(user.id, params.roomId);
if (!member) return null; // refused
return { subscribe: true, publish: true, state: { read: true, write: true } };
},
},
});
Three kinds, and the order they are written in matters: the first pattern that matches wins.
Patterns
A pattern is a channel name with {param} placeholders: user:{uid}, room:{roomId}, org:{orgId}:presence. A join names a concrete channel — room:42 — and the first declared pattern it matches decides it.
Because order is significant, write specific patterns above general ones. room:lobby declared after room:{roomId} never gets its own rule.
ownerOnly() — the channel belongs to one user
// channels-owner.ts
import { defineChannels, ownerOnly } from "@palbase/backend";
export default defineChannels({
"user:{uid}": ownerOnly(),
});
The pattern must carry exactly one {param}; anything else throws when the declaration is evaluated, naming the pattern. Its value is compared to the verified token subject, and the join is granted only when they match. Your code is never asked — this decision costs nothing.
Use it for anything keyed by user id: a personal notification feed, a device channel, a per-user cursor.
publicChannel() — anybody signed in may listen
// channels-public.ts
import { defineChannels, publicChannel } from "@palbase/backend";
export default defineChannels({
"announcements": publicChannel(),
"lobby": publicChannel({ publish: true, state: { read: true, write: true } }),
});
Every holder of a valid token may subscribe. Everything else is an explicit opt-in:
| Option | Default | Grants |
|---|---|---|
publish | false | Clients may send on the channel and have it fanned out. |
state.read | off | Clients receive the channel's shared state. |
state.write | off | Clients may write shared state entries. |
publicChannel() with no options is subscribe-only — the right shape for a channel your server broadcasts on, where a client that could publish could forge the message.
authorize — your code decides
For anything that depends on your data, supply a function:
// channels-authorize.ts
import { defineChannels } from "@palbase/backend";
type RoomRole = "host" | "speaker" | "observer";
declare function roleIn(roomId: string, userId: string): Promise<RoomRole | null>;
export default defineChannels({
"room:{roomId}": {
async authorize({ user, params }) {
const role = await roleIn(params.roomId, user.id);
if (!role) return null; // refuse
return {
subscribe: true,
publish: role !== "observer",
state: { read: true, write: role === "host" },
};
},
},
});
It receives the verified user and the pattern's captured params, and runs with the same request scope a controller gets — Database and the rest are available, and RLS applies as usual.
Return a grant to allow, or null to refuse.
| Grant field | Meaning |
|---|---|
subscribe | Required. May this connection receive events on the channel? |
publish | May it send events for fan-out? |
state.read | Does it receive the shared state snapshot and its updates? |
state.write | May it write shared state entries? |
A grant is remembered for the life of that connection's token, so a busy channel does not call your function on every message.
handler — take over publishes
Without it, a client publish is relayed to the other subscribers verbatim. That is right for a chat message and wrong for anything the client should not be trusted to assert — a bid, a vote, a game move. Those are requests, and the result is whatever your code decides:
// channels-handler.ts
import { defineChannels, Realtime } from "@palbase/backend";
declare function highestBid(lotId: string): Promise<number>;
declare function recordBid(lotId: string, userId: string, amount: number): Promise<void>;
export default defineChannels({
"auction:{lotId}": {
async authorize() {
return { subscribe: true, publish: true, state: { read: true } };
},
async handler({ user, params, event, payload }) {
if (event !== "bid") return;
const amount = Number(payload.amount);
const high = await highestBid(params.lotId);
if (!Number.isFinite(amount) || amount <= high) return; // silently ignored
await recordBid(params.lotId, user.id, amount);
// What the room sees is what YOU published, not what the client sent.
await Realtime.state.set(`auction:${params.lotId}`, "high", {
amount, by: user.id,
});
},
},
});
With a handler, the frame is not fanned out. It is delivered to your function, and the publisher gets your outcome as the reply. Whatever the other subscribers should see, you publish yourself — with Realtime.broadcast for an event, or Realtime.state.set for a value that should also reach whoever joins later.
handler receives { user, params, channel, event, payload }.
Rules worth knowing
- Undeclared is refused. There is no permissive mode and no wildcard that opens everything. If a join is being refused, the first question is whether its name matches a declared pattern.
- Declare channels once. A second, different
defineChannelscall in the same bundle is a hard error naming the patterns already declared — two channels files would mean one silently overwriting the other, and the symptom would be refused joins for channels you can see in your own source. An identical re-declaration is fine. - The internal namespaces are not yours. Names Palbase uses for its own features — flags and messaging — are decided by their own rules, and your patterns do not apply to them.
Related
- Realtime — broadcasting and writing shared state from your backend
- Realtime on the web — joining, state and presence from the browser
- Realtime on iOS — the same surface in Swift
- Controllers & Routing — the request scope
authorizeruns in