Last Updated: 2026-09-05 · Status: Active
The permission set
Defined in packages/core-access/src/permission-catalogue.ts. Owner, admin and viewer roles derive from it automatically; nothing hardcodes a role's engagement permissions.
| Permission | readOnly | Owner / Admin | Member | Viewer | Group |
|---|---|---|---|---|---|
engagement:stats.read |
yes | ✓ | ✓ | ✓ | Analytics |
engagement:sites.read |
yes | ✓ | ✓ | ✓ | Sites |
engagement:sites.write |
✓ | ✓ | Sites | ||
engagement:settings.admin |
✓ | ✗ | Settings | ||
engagement:comments.read |
yes | ✓ | ✓ | ✓ | Comments |
engagement:comments.moderate |
✓ | ✓ | Comments | ||
engagement:comments.reply |
✓ | ✓ | Comments | ||
engagement:comments.delete |
✓ | ✓ | Comments | ||
engagement:bans.manage |
✓ | ✓ | Comments | ||
engagement:members.read |
yes | ✓ | ✓ | ✓ | Audience |
engagement:members.manage |
✓ | ✓ | Audience |
Three things worth knowing
settings.admin, not settings.write. The .admin suffix is what excludes a permission from the Member role (rbac.manager.ts). Engagement settings decide whether comments are open at all, what the moderation policy is, and where notifications go — editor-level access is not the right bar for that. Precedent: media:files.admin.
The suffix must be spelled the same everywhere — catalogue, worker route guards, MCP catalog, dashboard. sanitizeKeyPermissions silently drops permission ids that aren't in the catalogue when minting an API key, so a settings.write typo wouldn't error; it would just make update_settings quietly unreachable for that key. packages/core-access/test/catalogue.test.ts catches this class of typo.
comments.read exposes commenter email addresses. Treat it like newsletter:subscribers.read. It is marked readOnly and reaches viewers, which is the same posture — but be aware that "read-only" here still means "can see the addresses of everyone who commented".
Members can moderate. engagement is in MEMBER_WRITE_SERVICES, so an editor can approve, reply, delete and ban. That is the intended division: moderation is editorial work; changing the policy is not.
How a request gets authorised
- Gateway validates the JWT or API key, derives the PBAC floor from the catalogue, and forwards
x-user-permissionsas a JSON array. app.on(METHOD, [paths], requirePermission("engagement:…"))insrc/index.tsgates every admin and MCP route.test/route-coverage.test.tsfails the build on any ungated mutation that isn't in its explicit allowlist.
The allowlist holds exactly the routes that authenticate some other way — the public door (opaque site_id), /internal/* (a shared token), and /cron/* (the gateway key). Listing them explicitly rather than excluding them by pattern means a route accidentally moved out of /e/* trips the test.
API keys
| Preset | Gets engagement? |
|---|---|
| Editor | ✓ writes, via EDITOR_WRITE_SERVICES |
| Publishable / public-read | ✗ — PUBLIC_READ_SERVICES is blog-only |
| Custom | whatever you tick |
A publishable key can therefore read blog posts but not engagement stats unless you build a Custom key. Publishable keys are refused on MCP entirely (mcp-core/src/auth.ts).
The public door has no permissions at all
/e/* routes are ungated by design and are listed in the route-coverage allowlist. Their security model is entirely different — see architecture.md. If you find yourself wanting to add a permission check to a public route, the route is probably in the wrong place.
The platform blocklist is not a permission
/internal/platform-blocklist acts across every tenant at once, so it is deliberately outside the permission system: no tenant role, however privileged, can reach it. It is guarded by PLATFORM_ADMIN_TOKEN, held only by apps/internal-api, and every write goes to the staff audit log with the blocked value attached.
Adding a permission
- Add it to the catalogue with the right
groupandreadOnly. - Guard the route in
src/index.ts. - Set
requiredPermissionon the MCP tool descriptor, if it has one. - Gate the dashboard control with
usePermission(...)/<PermissionGate>.
Steps 1 and 2 are what the tests enforce; 3 and 4 fail silently if you skip them (a dropped key permission, an enabled button that 403s).
Step 5, easy to miss: existing keys don't retroactively gain a new permission
Owner/admin dashboard sessions re-derive their permission set live from the catalogue on every mint (rbac.manager.ts's ALL_PERMISSION_IDS), so a brand-new permission group shows up for them immediately. A secret API key is different: its permissions column is a snapshot written once, at mint or edit time (apps/manager/src/lib/mint-api-key.ts, apps/manager/src/routes/api-keys.ts), and it is never recomputed just because the catalogue grew. A key minted before this file's permission set existed will never see engagement:* — or any permission added after it was created — until someone re-saves or regenerates it from the dashboard.
This is a real, easy-to-hit trap: adding a new service to the catalogue does not retroactively unlock it for existing keys, even ones assigned a role/preset that is supposed to include it. If an MCP client reports missing tools right after a catalogue change, check whether its key predates that change before assuming the tools themselves are broken.