Skip to content

Permissions ​

Tiers do not grant permissions ​

A plugin's permissions come from declared_permissions in its manifest, and nothing else. The manifest tier (1, 2 or 3; any other value is stored as 1) is a label — Passive, Interactive or Activity — shown in the marketplace, server settings and the consent card. It changes no behaviour.

In channel and sidebar placements, a plugin that declares any permission shows a consent card before it loads, whatever its tier; a plugin that declares none shows no card. The card lists declared_permissions, and Join grants exactly that list; the node refuses to grant anything the plugin did not declare. View Without Joining grants nothing and loads the plugin anyway, so every permissioned call is refused.

Changed 2026-09-13

The card used to be shown only for tier 2 and 3, so a tier 1 plugin that declared permissions was never asked and could not use them. It now follows declared_permissions.

What a call may use is the intersection of what the user granted and what the installed plugin still declares. The consent card comes back when the plugin declares a permission the user has not granted — for example after an update adds one. Dropping a permission from the manifest takes it away from everyone at once.

Profile plugins have their own lane: only storage:local can be used there, and the card offers nothing else. See Profile Plugins.

Personal plugins do not use the card: the user approves declared_permissions when adding the plugin on My plugins. See Personal Plugins.

Every permission ​

The node accepts these strings and no others. The catalog also records a tier for each permission, but nothing reads it — it does not restrict which tier may declare a permission.

PermissionCatalog tierConsent card label (abridged)SDK
profile:read1Read your display name and avatargetUser()
identity1Access your stable user ID to link records across sessionsctx.user.id
storage:user1Store private data on your accountstorage.* with scope user
storage:server1Read and write shared server datastorage.* with scope server
storage:local1Store data on this device onlystorageLocal.*
storage:log1Read and add to this channel's shared plugin history, recording who did what— (log:append, log:read)
members:read1View the list of server members— (members:list)
overlay:context1Know which game the overlay is showing overoverlay.*
net:direct2Connect directly to approved websites from your computer (they see your IP address)net.fetch() — desktop only
friends:link2Connect with Dissent friends who use this plugin — by invite, or automatically when you play together (matched by your connected Steam accounts)friends.list(), links(), invite(), respond(), remove(), match(), setAuto() — personal installs
fetch:external2Fetch data from external websitesfetch()
bot:post2Post messages as the plugin botpostMessage()
realtime2Send and receive live events with other users in this channelon() to receive; realtime:publish to send
media:embed2Open embedded media in an overlaymedia.embed()
clips:gallery2Browse and manage this channel's clip galleriesgallery.*
voice2Adjust the playback volume of voice participants (only for you)voice.setGain()
game:telemetry2Read live match data from games running on your computergameTelemetry.*
game:saves2Read your Slay the Spire 2 runs and stats saved on this computergameSaves.*
wallet3⚠ Connect a crypto wallet and approve real-money signatureswallet.*

Actions marked "—" have no SDK method; send them as raw requests — see postMessage Protocol.

Permission strings ​

Each permission grants access to a specific API:

profile:read ​

Access to user profiles. Allows:

  • Dissent.getUser() — read the current user's profile
  • In profile context: see the profile owner's user data

No message access ​

There is no permission that lets a plugin read channel messages. channel:read was removed on 2026-08-15, and the node now refuses it as an unknown permission — both in a registry submission and in a consent grant.

fetch:external ​

Make outbound HTTP/HTTPS requests. Allows:

  • Dissent.fetch(url) — fetch from any external domain

All external requests are proxied through dissent-core for privacy — your users' IPs are never exposed.

bot:post ​

Post messages to the channel. Allows:

  • Dissent.postMessage(content) — send a message as a bot

storage:user ​

Key–value storage kept by the node for this user. Allows Dissent.storage.* with scope user (values ≤ 64 KB).

game:saves · personal plugins ​

Read the user's own save files for a supported game, filtered by the desktop app. Allows Dissent.gameSaves.*. See Game Saves.

overlay:context · personal plugins ​

Know which game and panel the overlay is showing, and when it opens. Allows Dissent.overlay.*. See Overlay Panels.

net:direct · desktop only ​

HTTPS requests from the user's own computer to hosts the plugin lists in allowed_fetch_domains and the user approved. Nothing passes through the node, so no admin is involved — and the site sees the user's IP address, which the consent says in those words.

The desktop app enforces, whatever the plugin does:

  • Exact approved hosts, on every redirect too. example.com does not admit api.example.com.
  • HTTPS only.
  • Never the user's own network — loopback, private ranges, link-local, Tailscale/CGNAT (100.64.0.0/10) and IPv6 private ranges are refused, checked on the address actually connected to.
  • No cookies or saved credentials; identity headers (Cookie, Origin, Referer, User-Agent, Sec-*, …) are dropped. Authorization passes.
  • Bounded: request body 1 MB, response 5 MB (then truncated: true), 15-second timeout, 60 requests a minute per install.

A later version that adds a host shows Review permissions; until the user approves it, that host is refused. A host the plugin stops listing stops working at once. On web, net.fetch rejects with net:direct needs the Dissent desktop app. My plugins shows what the plugin contacted this session.

Reach this same plugin on a friend's account. It is how a co-op plugin lets the host's copy share with the guest's, without a code to type.

  • friends.list() answers { friends: [{ handle, name }] } — only local Dissent friends who installed this plugin and granted friends:link. A handle is opaque and different for every plugin; it is never a user id.
  • friends.invite(handle, payload) sends an invite with a JSON object of at most 4 KB — a key or a pointer, never bulk data. One link per pair of people; inviting again returns the existing link.
  • friends.links() lists invites sent and received and accepted links: { links: [{ id, status: 'pending' | 'accepted', direction: 'sent' | 'received', peer: { handle, name }, payload?, created_at }] }. 🔴 A received invite has no payload until the user accepts it.
  • friends.respond(id, accept) — the recipient only. Declining deletes the invite.
  • friends.remove(id) — either side withdraws an invite or stops a link.
  • Unfriending, or either person uninstalling or revoking friends:link, ends the link at once.

Automatic linking — friends.match(game, run?) (desktop, and game:saves must be granted too). The Dissent app reads the players of the run in progress, or of a finished run by id, from the save on this computer, hashes their Steam ids with a fresh salt, and asks the node which friends' verified Steam accounts (connected through Steam sign-in) are among them. Those friends are linked without an invite, and the answer is { status, matches: [{ handle, name, player, link_id, status, secret }] } — which player each friend was, never a Steam id. The reasoning: a friend in the same game already sees that game on their own screen. Guards: friendship, the same plugin, both grants, and either person's setAuto(false). A link either person stopped is never matched again; only a new invite revives it. Send a run only to the friends matched in that run.

Every accepted link has a random secret both sides read; key your own channel from it rather than from a payload.

Nothing here carries the data itself: send that over your own channel (for example with net:direct), keyed by what the accepted payload holds. A plugin that also reads game data gets an elevated consent finding saying it can share that data with friends who accept. There is no push yet: poll links() (the node allows 240 calls a minute per user).

The personal lane ​

A plugin whose context is personal may declare only storage:user, storage:local, game:saves, overlay:context, net:direct and friends:link. The node refuses to install one that declares anything else, and the app refuses every call outside the lane. See Personal Plugins.

In an overlay panel a second limit applies, to any plugin however it was installed: only storage:user, storage:local, game:saves, overlay:context, net:direct, friends:link and realtime work there. A refused call answers <action> is not available in the overlay, and the same plugin may use everything it is granted on its in-app page. See Overlay Panels.

Slash commands ​

Not a permission, and not tied to a tier: Dissent.onCommand() needs no grant. The current app never delivers a command to a plugin, though — see onCommand.

Checking permissions at runtime ​

Your plugin receives ctx.permissions — an array of permission strings the user has actually granted. Always check before calling a method:

javascript
const ctx = await Dissent.init();

// Example: only greet the user if they granted profile:read
if (ctx.permissions.includes('profile:read')) {
  const user = await Dissent.getUser();
} else {
  console.log('User did not grant profile:read permission');
}

This is useful for plugins that gracefully degrade: a user who chose View Without Joining has granted nothing.

  • No declared permissions (channel, sidebar): no card. The plugin loads immediately.
  • Any declared permission (channel, sidebar): a card before first load, showing the plugin's name, tier, a summary of what its permissions add up to, and each declared permission. Two buttons:
    • Join — grants every declared permission.
    • View Without Joining — grants nothing; the plugin still loads.
  • Either choice is remembered. The card returns only if the plugin later declares a permission the user has not granted.

Best practices ​

  1. Declare only the permissions you use. Every one appears on the consent card. Fewer permissions = more trust from users.
  2. Explain why you need each permission. Use clear, honest descriptions in your manifest.json.
  3. Gracefully degrade. If a call is refused, show a fallback UI instead of crashing.
  4. Never ask for more permissions than you use. If you declare bot:post but never post messages, users will lose trust.