Roles
Three roles, fixed. Nine permissions, fixed. A plugin can require a permission; it cannot define a new one or a new role.
Grants
Section titled “Grants”| 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.
Checking permissions
Section titled “Checking permissions”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 renderingallowed decides what to render. requires decides whether to run at all.
refuse(permission) builds the 403 directly, when you need it without the check.
Read-only rendering
Section titled “Read-only rendering”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.
Assigning roles
Section titled “Assigning roles”/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.
Two ways in
Section titled “Two ways in”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/peoplewhen 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
aias 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.
View as
Section titled “View as”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.
Limits
Section titled “Limits”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.