Role-based permissions
The administrative and MCP surfaces gate every operation with a role-based permission on top of
the collections entitlement. Permissions are injected by the gateway from the signed-in session
(via the x-user-permissions header) and enforced by the collections worker:
| Permission | Grants |
|---|---|
collections:read |
View collections and entries |
collections:write |
Create, update, delete, reorder, bulk-delete collections and entries |
collections:entries.publish |
Publish / unpublish entries |
Publish/unpublish is kept as a separate permission from write on purpose: an editor role can
author and edit content without being able to make it live. The same guard applies whether the
mutation arrives over REST (dashboard) or MCP (agents) — both go through the identical middleware and
service layer, so an agent is bound by the same rules as a human.
- Missing permission →
403 Forbidden - Missing tenant context →
400 Bad Request
Permissions map
| Operation | Permission | REST route | MCP tool |
|---|---|---|---|
| List / get collections | collections:read |
GET /admin/collections… |
list_collections, get_collection |
| List / get entries | collections:read |
GET /admin/collections/:id/entries (list), GET /admin/entries/:id (get) |
list_entries, get_entry |
| Create / update / delete collection | collections:write |
POST/PUT/DELETE /admin/collections… |
create_collection, update_collection, delete_collection |
| Create / update / delete entry | collections:write |
POST/PUT/DELETE /admin/entries… |
create_entry, update_entry, delete_entry |
| Reorder / bulk delete | collections:write |
PATCH /admin/entries/reorder, DELETE /admin/entries/bulk |
reorder_entries, bulk_delete_entries |
| Upload image/file | collections:write |
POST /admin/uploads |
— |
| Publish / unpublish | collections:entries.publish |
POST /admin/entries/:id/publish | /unpublish |
publish_entry, unpublish_entry |
Site-sync
When published content changes, collections notifies site-sync so your static site can be rebuilt. This is automatic and fire-and-forget: a site-sync failure never fails the publish that triggered it.
How it avoids wasted rebuilds:
- One interception point. A middleware notifies site-sync after any successful write to
/admin/*or/mcp/*— so a newly added route can't be forgotten. Batch operations emit exactly one notification per request, no matter how many rows they touch. - Cost-free over-notification. It fires even on publicly-invisible changes (e.g. saving a draft). That's safe because site-sync independently recomputes the tenant's content version before spending a build.
- Content-version check.
GET /internal/content-version?tenantId=…(internal, service-binding only) returns a digest of everything the tenant currently serves publicly: the count and max-updated_atof published entries and active collections. Unpublishing or deleting removes a row without moving the max timestamp, so counting matters as much as the timestamp — otherwise "content came down but the site still shows it" would be missed.
Net effect: editing a draft, or publishing then immediately unpublishing, produces no rebuild; nothing is wasted. Genuinely new public content triggers a rebuild.
The internal mesh
Three classes of traffic reach the collections worker, and each is distinguished deliberately:
- Public (
/c/*) — gateway-key only; the gateway injectsx-gateway-key. No JWT/tenant; the collection id is the credential. - Admin + MCP (
/admin/*,/mcp/*) — gateway-key plus tenant + role permissions. - Internal (
/internal/*) — worker-to-worker, authenticated with a service token that is service-specific: the collections worker calls two different internal services and uses a credential for each that would not work on the other.
NOTE
In local development or any environment where site-sync isn't bound, notifications are simply skipped — an unbound service is a valid state, not an error.