Permissions
The default participants-only model, the canRead / canWrite / canManage / canInvite hooks, and the canModerate hook.
By default, only the participants of a conversation can read or write it -
enforced on every chat.api.* call and every HTTP route. Managing a group
(membership, roles, the name) additionally requires the admin role. You
can loosen or tighten all of that with four hooks, and authorize
platform-wide moderators with a fifth.
The conversation hooks
import { chatpack } from "@chatpack/core";
const chat = chatpack({
storage,
auth,
permissions: {
// May `user` read `conversation`? Default: participants only.
canRead: ({ user, conversation }) =>
conversation.participantIds.includes(user.id) || isSupportAgent(user.id),
// May `user` write to `conversation`? Default: participants only.
canWrite: ({ user, conversation }) =>
conversation.participantIds.includes(user.id) && isVerified(user.id),
// May `user` manage this group - add/remove members, change roles, rename,
// publish it as a public channel? Default: participants whose role is "admin".
canManage: ({ user, conversation }) =>
conversation.participants.some((p) => p.userId === user.id && p.role === "admin"),
// May `user` mint an invite link for this group?
// Default: the same admin check as canManage.
canInvite: ({ user, conversation }) => conversation.participantIds.includes(user.id),
},
});All four hooks receive a PermissionContext:
Prop
Type
Hooks may be sync or async and must return a boolean. Returning false maps
to a 403 - FORBIDDEN_READ, FORBIDDEN_WRITE or NOT_CONVERSATION_ADMIN -
both over HTTP and as a thrown ChatpackError from chat.api.*.
The moderation hook
canModerate is the fifth hook, and it sits outside permissions because it is
not about one conversation - it authorizes platform-wide moderator actions on the
report queue and bans:
const chat = chatpack({
storage,
auth,
moderation: {
canModerate: async ({ user, action }) => {
if (!(await hasRole(user.id, "staff"))) return false;
// Optional: split reading the queue from wielding the ban hammer.
return action.startsWith("reports.") || (await hasRole(user.id, "admin"));
},
},
});Its context is a ModerationPermissionContext, not a PermissionContext:
Prop
Type
Omit canModerate and every moderator route answers 403 NOT_MODERATOR; blocks,
mutes, and filing a report keep working, because those are self-service.
What the hooks do and don't cover
- Sender-only edit/delete is separate. Even with
canWritereturningtrue, only the original sender can edit or delete a message - a non-sender gets403 NOT_MESSAGE_SENDER. This rule is not hook-overridable. canManagegates group administration only. It runs onaddParticipants,removeParticipant,setParticipantRoleandupdateConversation- which is also where a group'svisibilityandjoinPolicychange, so making a group a public channel is an admin action, not an invite action. Reading and sending are unaffected: a plainmemberis a full participant for every other purpose.- Leaving is exempt from
canManage. Removing yourself always works, even when yourcanManagereturnsfalse, so a member is never trapped in a group. - Forwarding runs two checks in two conversations.
canReadon the source andcanWriteon the target, so a403from a forward can mean either -FORBIDDEN_READfor the message you tried to copy,FORBIDDEN_WRITEfor the place you tried to put it. Nothing is relaxed because the content already exists: a conversation you can't write to stays closed to forwards, and the block and ban checks an ordinary send makes apply here too. canManagedeliberately has the narrowest default.canReadandcanWritedefault to "any participant";canManagedefaults to "any participant whose role isadmin". If you override it, you are replacing the role check entirely - re-add it yourself unless you mean to drop it.canInvitegates invite creation only. Listing invites, revoking them, and resolving join requests all stay oncanManage. It exists as its own hook so "any member may invite, but only admins may remove people" - the most common variation of this feature - doesn't require looseningcanManage, which would also hand every member the power to remove others and rewrite roles. It defaults to the same admin check, so adding invites changes no existing deployment's behavior. Note the asymmetry with the point above: looseningcanInviteto "any member" hands out links, but publishing the group to every user on the platform stays withcanManage.- Nothing gates browsing or joining a public channel.
GET /channelsandPOST /conversations/:id/joinare open to any signed-in user by design - that is what "public" means - so there is no hook to loosen. What protects a channel is that discovery is not read access: the directory returns a name and a participant count, andcanReadstill runs unchanged on the conversation and its messages. To restrict who may join, leavevisibilityprivate and use invite links instead. - A ban is checked before any hook runs. When the
moderationoption is configured, an active ban short-circuits the request right after authentication -403 USER_BANNEDon every route including/stream, with nocanRead/canWritecall at all. Enforcement is on by default whenevercanModerateis set (banUseris the only way to mint a ban); setmoderation: { enforceBans: true }if ban rows are written outside Chatpack. Blocks are narrower and separate: they only stop direct writes, and only between the two users involved. - Read permission gates live delivery too. SSE events are only delivered for conversations the connected user may read - participation is re-checked server-side per event.
- Adapters never enforce permissions. Core validates before calling
storage. If you write a custom adapter, don't re-check permissions there -
and on hosted databases, make sure browser/anon clients can't read the
chatpack_*tables directly (see Custom adapters).
Common patterns
Support/admin read access - let a support role read any conversation:
permissions: {
canRead: async ({ user, conversation }) =>
conversation.participantIds.includes(user.id) ||
(await hasRole(user.id, "support")),
},Read-only archive - block writes to conversations you've flagged:
permissions: {
canWrite: ({ user, conversation }) =>
conversation.participantIds.includes(user.id) &&
conversation.metadata.archived !== true,
},Blocklists - Chatpack has these built in now
(moderation routes), so reach for
canWrite only when your blocklist already lives somewhere else. Note the
type guard: "the other participant" only means something in a DM, so the rule
skips groups rather than picking an arbitrary member:
permissions: {
canWrite: async ({ user, conversation }) => {
if (!conversation.participantIds.includes(user.id)) return false;
if (conversation.type !== "direct") return true;
const other = conversation.participantIds.find((id) => id !== user.id)!;
return !(await isBlocked({ by: other, target: user.id }));
},
},Org staff can manage any group - keep the admin rule and add an escape hatch on top:
permissions: {
canManage: async ({ user, conversation }) =>
conversation.participants.some((p) => p.userId === user.id && p.role === "admin") ||
(await hasRole(user.id, "staff")),
},Discord-style invites - any member can share a link, only admins can kick.
Override canInvite alone and leave canManage at its default:
permissions: {
canInvite: ({ user, conversation }) =>
conversation.participantIds.includes(user.id),
},