Chatpack
Core Concepts

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 canWrite returning true, only the original sender can edit or delete a message - a non-sender gets 403 NOT_MESSAGE_SENDER. This rule is not hook-overridable.
  • canManage gates group administration only. It runs on addParticipants, removeParticipant, setParticipantRole and updateConversation - which is also where a group's visibility and joinPolicy change, so making a group a public channel is an admin action, not an invite action. Reading and sending are unaffected: a plain member is a full participant for every other purpose.
  • Leaving is exempt from canManage. Removing yourself always works, even when your canManage returns false, so a member is never trapped in a group.
  • Forwarding runs two checks in two conversations. canRead on the source and canWrite on the target, so a 403 from a forward can mean either - FORBIDDEN_READ for the message you tried to copy, FORBIDDEN_WRITE for 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.
  • canManage deliberately has the narrowest default. canRead and canWrite default to "any participant"; canManage defaults to "any participant whose role is admin". If you override it, you are replacing the role check entirely - re-add it yourself unless you mean to drop it.
  • canInvite gates invite creation only. Listing invites, revoking them, and resolving join requests all stay on canManage. 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 loosening canManage, 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: loosening canInvite to "any member" hands out links, but publishing the group to every user on the platform stays with canManage.
  • Nothing gates browsing or joining a public channel. GET /channels and POST /conversations/:id/join are 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, and canRead still runs unchanged on the conversation and its messages. To restrict who may join, leave visibility private and use invite links instead.
  • A ban is checked before any hook runs. When the moderation option is configured, an active ban short-circuits the request right after authentication - 403 USER_BANNED on every route including /stream, with no canRead/canWrite call at all. Enforcement is on by default whenever canModerate is set (banUser is the only way to mint a ban); set moderation: { 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),
},

On this page