Capabilities
Channel pattern to allowed operations — the matching rules, the operations, and how to scope a browser token.
The shape
A capability map is channel pattern → operations:
{
"chat:*": ["subscribe", "publish", "presence"],
"metrics.>": ["subscribe", "history"],
"*": ["history"]
}Every operation is checked against this map, on the channel it names. Nothing is
implied: a pattern granting subscribe grants only subscribe, and a channel
matching no pattern is refused.
The operations
| operation | what it covers |
|---|---|
subscribe | receiving messages on the channel, over the socket |
publish | sending messages to it, over the socket or REST |
presence | enter, leave, update, and reading the member list |
history | reading past messages and past presence events |
amqp-subscribe | the account's AMQP queue endpoints |
history covering presence history too is deliberate: reading the past is one
permission whatever it is the past of.
Matching
Two wildcards, the same engine the message router uses:
| pattern | matches | does not match |
|---|---|---|
chat:* | chat:lobby, chat:room-7 | chat:room-7:typing |
metrics.> | metrics.cpu, metrics.cpu.node1 | metrics |
* | everything | — |
chat:lobby | exactly that | anything else |
* is one segment. > is the rest of the tree, one segment deep or more.
Scoping a browser
The default when you pass no capability is * — every channel, every
operation. That is right for a server key and wrong for anything a user can
read.
await rest.auth.createTokenRequest({
clientId: String(user.id),
ttl: 3_600_000,
capability: {
[`chat:team-${user.teamId}`]: ['subscribe', 'publish'],
[`presence:chat:team-${user.teamId}`]: ['presence'],
[String(user.id)]: ['subscribe', 'publish'],
'announcements:global': ['subscribe'],
},
});Read it as a sentence: this user may talk on their own team's channel, appear in
its presence, receive messages addressed to them personally, and read the global
announcements. Nothing else — including any other team's channel, which is the
whole point of interpolating the id rather than granting chat:*.
A token scoped like this is worth very little to whoever lifts it out of the browser it was minted for. A key in the same place is worth your account.
Subsets
A token's capabilities must be a subset of the issuing key's. Either the
patterns match exactly, or the key holds * or a > that covers them.
key { "chat:*": ["subscribe", "publish"] }
token { "chat:*": ["subscribe"] } ✓ fewer operations
token { "chat:lobby": ["subscribe"] } ✗ not an exact pattern match
token { "chat:*": ["subscribe", "history"] } ✗ history not on the keyThe second case is the one that surprises people: narrowing a pattern is not
subsetting, because the check is on the pattern, not on the set of channels it
would expand to. If you want per-room tokens, the key needs * or chat.> —
not chat:*.
The refusal happens at issue time and names the key, which is a far better error than a 403 at connect time naming a session.
When it fails
| status | means |
|---|---|
| 401 | missing, malformed, unknown or revoked credential |
| 403 | authenticated, but this capability does not cover this channel and operation |
Over the socket, a forbidden operation comes back as a Nack with code 403 and the connection stays open — one refused publish does not cost you the session.
401 is deliberately identical for "malformed" and "wrong". Whether the format parsed is not something a caller should be able to learn from the response.
Scoping is not optional
With auth on, a channel name is scoped to the calling key's account before it
reaches any store. Two accounts publishing to chat:lobby are on two different
channels and cannot see each other, and there is no way to ask for another
account's channel — the scope comes from the credential, never from the request.
Capabilities narrow what you may do inside your account. They are not what keeps accounts apart; that happens a layer below and is not configurable.