Skip to content

Roles

Three roles, fixed. Nine permissions, fixed. A plugin can require a permission; it cannot define a new one or a new role.

Permission Developer Editor Viewer
content.edit yes yes no
content.arrange yes no no
navigation.edit yes yes no
navigation.arrange yes no no
media.upload yes yes no
analytics.view yes yes yes
plugins.manage yes no no
activity.view yes no no
people.manage yes no no

In words: a developer does everything. An editor changes words and pictures but not structure, so they can edit a menu item’s label and link but not reorder the menu, and they can edit a section but not add, remove or reorder one. A viewer reads.

Taking a page off the site counts as structure, so the Published toggle needs content.arrange. An editor is not shown it at all rather than shown one whose value is discarded, and the state is still visible to them as a pill beside the page title. The home page and the 404 page never show it to anyone, for the same reason they cannot be moved or trashed.

import { allowed, requires, refuse } from '@ouncepage/core/guard';
const who = Astro.locals.editor;
const denied = requires(who, 'plugins.manage');
if (denied) return denied; // 403 Response, or null
const mayEdit = allowed(who, 'content.edit'); // boolean, for rendering

allowed decides what to render. requires decides whether to run at all. refuse(permission) builds the 403 directly, when you need it without the check.

The engine wraps the form in a disabled <fieldset>, which disables every input inside it. That is the whole mechanism, and it has one gap: a <label>, a <button type="button"> and an <a> are not disabled by it.

if (input.matches(':disabled')) {
wrapper.querySelector('[data-actions]')?.setAttribute('hidden', '');
}

Hide, do not just disable, anything that offers an action the role cannot take. An upload button greyed out is worse than an upload button absent.

/admin/people, guarded by people.manage. It is also where the two invariants live:

  • Nobody can grant a role above their own.
  • The last developer cannot be demoted or removed.

Outside the admin, an email in the OUNCE_OWNERS var is always a developer. That is the bootstrap path, and it is the only one that does not touch the editors table.

A request arrives carrying a role by one of two routes, and they are guarded differently.

Cloudflare Access covers everything under /admin. The middleware verifies the Access JWT against your team’s published keys, checking issuer and audience, and looks the email up in the editors table. No token, no role, no page: the whole area answers 403 before a route runs.

A bearer token covers /_ounce/mcp, and that path is deliberately not under /admin, because an MCP client cannot pass Access. Its only credential is the token. Minting one is people.manage, so only a developer can, but once minted the token is a standalone key to the site at whatever role it was given.

Access MCP token
Guards /admin/** /_ounce/mcp
Credential Access JWT, verified per request 256-bit bearer token, stored as a SHA-256 hash
Role from the editors table, re-read per request the row the token was created with
Expires with the Access session never, until revoked
View as available not available

The consequences worth knowing before you mint one:

  • A token does not expire. Revoke it at /admin/people when the client that held it is gone. The screen shows when each was last used, which is how you spot one nobody is using.
  • Changing someone’s role does not change their token. The role is copied onto the token row at creation. Revoke and re-issue instead.
  • A token is a role, not a person. The activity log records the email the token acts as, with ai as the source for writes the assistant made.

Every tool the endpoint exposes still checks a permission, twice: once so tools/list hides what the role cannot run, and again in tools/call so naming a hidden tool directly is refused rather than executed.

A developer can preview the admin as an editor or a viewer. Astro.locals.editor then reports:

{ role: 'viewer', actualRole: 'developer', viewingAs: 'viewer' }

Check role, never actualRole. The preview is only useful if it is complete.

The preview only ever goes down. A forged cookie asking for a role at or above the real one is ignored, and every server-side check still runs against the effective role, so a previewing developer’s writes are refused exactly like the role they are previewing.

Can: require any of the nine permissions on a nav entry or inside a screen.

Cannot: define a permission, define a role, change a grant, or read the editors table. If your plugin needs an authorisation concept the nine do not cover, that is a change to the engine, not to your plugin.