The full catalog of CoreCapability strings, what each grants, which role gets them by default, and how to add a new capability.
For the broader auth flow (sessions, MFA, step-up), see docs/features/auth-and-access.md. This page is the capability matrix and the "how do I add one?" cookbook.
- Defined as a
constarray insrc/core/capabilities.ts(@core/capabilities);CoreCapabilityis derived viatypeof CORE_CAPABILITIES[number]. 38 capabilities. - Handlers gate on capability, not on role:
requireCapability(req, db, 'site.read'). - The Owner AND Admin roles get their capability lists force-resynced from
SYSTEM_ROLESon every server boot. Hand-edits to either built-in role through the admin UI are restored at next boot — they are code-level decisions, not runtime ones. - Adding a capability: append the literal to
CORE_CAPABILITIESinsrc/core/capabilities.ts(one place — server imports it), add it to the relevantSYSTEM_ROLESentries, wirerequireCapability(...)at the gate point, and add picker meta + groups for the role-edit dialog. The two architecture tests (capability-picker-coverage.test.ts,cms-handlers-capability-gated.test.ts) catch missing pieces. - Custom roles editable in the Roles admin page (Owner-only
roles.manage).
| Capability | Grants | Roles |
|---|---|---|
dashboard.read |
Open the Dashboard workspace | Owner, Admin, Client |
site.read |
Open the Site workspace; view pages, VCs, classes. Also gates /runtime/preview (preview HTML rendering of the posted draft). |
Owner, Admin, Client |
| Capability | Grants | Roles |
|---|---|---|
site.structure.edit |
Add / remove / move / rename nodes; manage pages, VCs, classes | Owner, Admin |
site.content.edit |
Modify content props (text, image src/alt, link href) on existing nodes — no structure or style edits | Owner, Admin, Client |
site.style.edit |
Modify CSS classes, style overrides, breakpoints, framework tokens | Owner, Admin |
SITE_WRITE_CAPABILITIES is the convenience set ['site.structure.edit', 'site.content.edit', 'site.style.edit'] — defined locally in server/handlers/cms/siteDocument.ts and src/admin/access.ts at each point of use, not in a shared capabilities module. The transactional site-document save (PUT /admin/api/cms/site-document) accepts any site writer, then diff-validates the batch by category: page deletions, page metadata, topology, module identity, non-content props, and dynamic bindings require site.structure.edit; content-category props (and site-wide SEO copy on the shell) require site.content.edit; inline styles/classes/breakpoint overrides and style rules require site.style.edit. Empty change sets are no-op saves any site writer may perform, but changed/deleted components and layouts remain structural work (site.structure.edit).
| Capability | Grants | Roles |
|---|---|---|
pages.edit |
Edit page metadata (title, slug, ...) | Owner, Admin |
pages.publish |
Publish / unpublish pages | Owner, Admin |
| Capability | Grants | Roles |
|---|---|---|
content.create |
Create new draft postType rows | Owner, Admin |
content.edit.own |
Edit rows where author_user_id = me |
Owner, Admin |
content.edit.any |
Edit any row | Owner, Admin |
content.publish.own |
Publish own rows | Owner, Admin |
content.publish.any |
Publish any row | Owner, Admin |
content.manage |
Full content admin: edit / publish / status any row regardless of author | Owner, Admin |
The own / any split is the standard CMS workflow: a contributor can edit/publish their own posts; an editor (content.edit.any, content.publish.any) can manage everyone's.
The Data workspace is split from the Content workspace: Content owns row-level editorial via content.*; Data owns schema design, cross-collection row moves, and bundle export/import. Table read/manage is further split system vs custom, so a persona (e.g. Client) can browse and manage custom tables without ever seeing the four internal system tables (posts, pages, components, layouts).
| Capability | Grants | Roles |
|---|---|---|
data.custom.tables.read |
Open the Data workspace; see + browse custom tables and their field schemas | Owner, Admin, Client |
data.custom.tables.manage |
Create, rename, delete custom tables; add/rename/delete fields; change primary field, route base. Step-up gated — changes public URL surface. | Owner, Admin |
data.system.tables.read |
See + open the four system tables (posts/pages/components/layouts). |
Owner, Admin |
data.system.tables.manage |
On a system table: add/edit/remove custom fields and set the primary field. The table's identity (name, slug, route base, labels, kind) and its built-in fields are frozen for everyone — assertSystemTableUpdateAllowed rejects those edits server-side. Built-in field values on the structural system tables (pages/components/layouts) are read-only in the grid; posts built-ins stay editable. |
Owner, Admin |
data.rows.move |
PATCH /data/rows/:id/table — move a row to a different table (changes its public URL because route base differs per table). |
Owner, Admin |
data.export |
GET /export and POST /import/preview (read-only bundle ops). Row visibility is filtered against canSeeAllDataRows. |
Owner, Admin |
data.import |
POST /import (write). replace strategy ALSO requires content.manage AND step-up. Bundles carrying a site shell ALSO require site.structure.edit. |
Owner, Admin |
| Capability | Grants | Roles |
|---|---|---|
media.read |
Open the Media workspace; browse assets and folders; see thumbnails in pickers. Also gated by /dashboard/media. |
Owner, Admin, Client |
media.write |
Upload assets; edit metadata (alt text, caption, tags); manage folders; restore from trash. | Owner, Admin |
media.replace |
Overwrite the bytes for an existing asset (variants regenerate). Split out from media.write because this silently swaps the bytes every page reference points at. |
Owner, Admin |
media.delete |
Soft-delete to trash; hard-purge (?purge=1) additionally requires step-up. Also gates DELETE /media/folders/:id (cascade). |
Owner, Admin |
Was a single runtime.manage. Split because adapter election (bytes go to a plugin-provided backend) is a separate trust decision from package.json dependency editing.
| Capability | Grants | Roles |
|---|---|---|
runtime.dependencies |
Edit site package.json dependencies; trigger POST /runtime/dependencies/resolve. |
Owner, Admin |
storage.elect |
Elect a media storage adapter per asset role (originals / variants / avatars / fonts); elect/clear the variant delegate; verify adapter credentials. | Owner, Admin |
storage.migrate |
Run the migration SSE that moves bytes between adapters after an election change. | Owner, Admin |
Was a single plugins.manage. Split per the four very different blast radii: read / configure / install (RCE-class) / lifecycle.
| Capability | Grants | Step-up | Roles |
|---|---|---|---|
plugins.read |
List installed plugins; read masked settings; view event SSE stream; read schedule list. Also gates /dashboard/plugins. |
no | Owner, Admin |
plugins.configure |
Edit per-plugin settings via PUT /plugins/:id/settings; manage plugin records via /plugins/:id/resources/*. |
yes (settings only) | Owner, Admin |
plugins.install |
Install / upgrade / uninstall plugins; pack install; inspect-package. RCE-class — runs third-party code on the host. | yes (mutations) | Owner, Admin |
plugins.lifecycle |
Enable / disable / restart plugins; schedule run-now / pause / resume. | yes (mutations) | Owner, Admin |
| Capability | Grants | Roles |
|---|---|---|
users.manage |
Create, edit, delete, suspend users; assign roles | Owner, Admin |
roles.manage |
Create, edit, delete custom roles; assign capabilities to roles | Owner only. Admin does not get this. |
roles.manage is owner-only by design — only the installation owner edits capability grants. Admins manage everything else but can't grant themselves new capabilities.
| Capability | Grants | Roles |
|---|---|---|
audit.read |
Read the dedicated /admin/api/cms/audit endpoint AND the Dashboard activity widget (previously leaked to every authenticated user — see A2 fix). |
Owner, Admin |
Was a single ai.use. Split so a Client persona can have chat assistance without the agent being able to mutate the editor store on their behalf.
| Capability | Grants | Roles |
|---|---|---|
ai.chat |
Open AI conversations. The agent inherits the caller's capabilities: every tool declares requiredCapabilities (ANY-OF, mirroring its HTTP-route gate — e.g. list_users → users.manage, document reads → the requireDataAccess set, list_media → media.read) and is only offered when the caller holds one. ai.chat is the floor, not a blanket read grant. |
Owner, Admin |
ai.tools.write |
Enable canvas write tools (setNodeProps, insertNode, deleteNode, etc.) in registered AI conversations. Without this, the model has no write tools at all. |
Owner, Admin |
ai.providers.manage |
Create / update / delete AI provider credentials + per-scope defaults | Owner, Admin |
ai.audit.read |
Read site-wide AI usage, cost, and error events across all users | Owner, Admin |
Four built-in SYSTEM_ROLES:
| Role | id | Capabilities | Boot behaviour |
|---|---|---|---|
| Owner | owner |
All 36 (CORE_CAPABILITIES) |
Force-resynced on every boot. Owner-only roles.manage. |
| Admin | admin |
All 36 except roles.manage |
Force-resynced on every boot (changed from previous "seeded once"). Hand-edits restored at boot. |
| Client | client |
dashboard.read, site.read, site.content.edit, media.read, data.custom.tables.read |
Seeded once; freely editable. Sees custom tables only — never the system tables. |
| Member | member |
(none) | Seeded once; freely editable. |
A new capability added to the codebase appears on Owner AND Admin on the next boot (force-sync). Client and Member don't auto-update — users grant the new capability via the Roles admin page if they want it. Existing custom roles also don't auto-update — same reason.
The trade-off for Admin force-sync: an operator who hand-removes a capability from Admin through the UI gets it back at next boot. That's intentional — capability grants for built-in roles are a code-level decision. Operators who need a "limited admin" persona should create a custom role.
Don't confuse them:
- Core capabilities (this doc) govern what a logged-in human user can do in the admin. Stored on
users.role. - Plugin permissions govern what a plugin's code can do via the SDK. Authored in
instatic-plugin.config.tsand emitted intoplugin.json, then approved at install. See docs/features/plugin-system.md.
A plugin route handler can additionally gate on a core capability:
// Standard: caller needs a core capability.
api.cms.routes.get('/admin-data', 'content.manage', handler)
// Any logged-in user — no specific capability needed, session cookie required.
api.cms.routes.authenticated.get('/me-private', handler)
// Anonymous-callable (webhooks). Plugin manifest must declare
// `cms.routes.public` permission. Install dialog flags this to the operator.
api.cms.routes.public.post('/webhook', handler)The three forms map to HostRouteAccess = { kind: 'capability'; capability } | { kind: 'authenticated' } | { kind: 'public' }. The host's route forwarder dispatches on kind; the previous capability: string | null shape was ambiguous about whether null meant "authenticated" or "fully public", which was an A3-class footgun.
The canonical pattern is in server/auth/authz.ts. Three helpers:
requireAuthenticatedUser(req, db) // any logged-in user
requireCapability(req, db, 'site.read') // one capability
requireAnyCapability(req, db, SITE_WRITE_CAPABILITIES) // any of a setEach returns AuthUser | Response. The handler checks instanceof Response and returns early on auth failure.
Step-up-gated actions:
requireStepUp(req, db) // per-user sensitive-action step-up policySee docs/features/auth-and-access.md for the full step-up flow.
The architecture test at src/__tests__/architecture/cms-handlers-capability-gated.test.ts walks every file under server/handlers/cms/**.ts and asserts each calls one of these helpers. The allowlist (with per-entry justifications) handles the few intentional exceptions (setup wizard, dispatcher, shared utilities).
The capability surface is a single source of truth: CORE_CAPABILITIES (and the derived CoreCapability type) lives only in src/core/capabilities.ts (@core/capabilities). server/auth/capabilities.ts imports and re-exports it, then adds the server-only concerns — the system-role definitions and the runtime guards. There is no parallel server list and no TypeBox union to keep in sync. Both files are pure registries of capabilities + roles — they do not hold capability groupings.
When an endpoint or UI affordance is reachable by any of several capabilities, that grouping is a small const defined locally, at the point of use, and named for what the gate protects (not for a capability family). It is then passed to requireAnyCapability (server) or hasAnyCapability (client). These lists routinely cross capability families.
Examples already in the tree:
| Constant | Defined in | Gate |
|---|---|---|
SITE_WRITE_CAPABILITIES |
server/handlers/cms/site.ts, src/admin/access.ts |
Save the draft site |
DATA_ACCESS_CAPABILITIES, DATA_EDIT_CAPABILITIES, DATA_PUBLISH_CAPABILITIES, … |
server/handlers/cms/data/access.ts |
Data/content row operations |
CONTENT_ACCESS_CAPABILITIES, PLUGIN_READ_CAPABILITIES, DATA_WORKSPACE_READ_CAPABILITIES |
src/admin/access.ts |
Admin workspace visibility |
There are deliberately no whole-family "super-set" constants (e.g. one MEDIA_CAPABILITIES listing every media.* cap). The system roles don't consume one — Owner uses the full CORE_CAPABILITIES, and Admin's grant list is written out leaf-by-leaf on purpose so every new capability forces a conscious per-PR decision about whether Admin gets it (see the SYSTEM_ROLES comment). A "future leaf auto-flows in" super-set is exactly the silent drift that design rejects. Group caps by what a gate needs, locally — never by family, globally.
import { requireCapability } from '../../auth/authz'
if (req.method === 'GET') {
const user = await requireCapability(req, db, 'audit.read')
if (user instanceof Response) return user
// … fetch + return audit events
}The admin reads currentUser.capabilities from /admin/api/cms/me:
import { useAdminSession } from '@admin/session'
function AuditMenuItem() {
const { user } = useAdminSession()
if (!user.capabilities.includes('audit.read')) return null
return <Link to="/admin/audit">Audit log</Link>
}For workspace-level gating, canAccessWorkspace(user, section) is the single source of truth (src/admin/access.ts).
- Append the string to
CORE_CAPABILITIESinsrc/core/capabilities.ts— the single source of truth. TheCoreCapabilitytype updates automatically (typeof CORE_CAPABILITIES[number]), and the server picks it up via its import:export const CORE_CAPABILITIES = [ // ... 'analytics.read', ] as const
- If it belongs to the Owner / Admin / Client default sets, add it to the matching
SYSTEM_ROLESentry inserver/auth/capabilities.ts. Owner + Admin force-sync on next boot. - Use it at the gate point:
const user = await requireCapability(req, db, 'analytics.read') if (user instanceof Response) return user
- Add a
CAPABILITY_METAentry + aCAPABILITY_GROUPSsection insrc/admin/pages/users/utils/capabilities.tsso the role-edit dialog renders a checkbox for it. The picker-coverage test fails until you do. - Existing custom roles will NOT have the new capability until users grant it through the Roles admin page.
- Update this doc (table + adjacent docs) so agents and humans can find the new capability.
Owner-only (roles.manage). Admin → Roles → New role.
{
id: 'editor-content-only',
name: 'Content editor',
description: 'Can manage all content rows but not site structure.',
capabilities: ['dashboard.read', 'site.read', 'content.create', 'content.edit.any', 'content.publish.any', 'media.read', 'media.write'],
}Saved to the roles table with capabilities_json: CoreCapability[]. Assigned to users via Admin → Users.
import { userHasCapability, userHasAnyCapability } from '@auth/authz'
if (userHasCapability(user, 'audit.read')) { /* show menu */ }
if (userHasAnyCapability(user, SITE_WRITE_CAPABILITIES)) { /* allow save */ }| Pattern | Use instead |
|---|---|
user.role === 'admin' to gate |
userHasCapability(user, 'media.read') |
Hand-rolling a capability check (user.capabilities.includes(...)) |
userHasCapability / requireCapability |
Granting roles.manage to non-Owner roles |
Owner-only by design. Don't expand. |
Skipping the boot-time syncSystemRoles(db) call in tests |
Tests should call it to set up a realistic state |
| Adding a "permission" string outside the known set | Append to CORE_CAPABILITIES in @core/capabilities first; the derived type catches typos |
Per-route ad-hoc auth that doesn't go through requireCapability |
Always use the helpers — gates aren't optional. The arch test catches missing gates. |
Plugin route registered with capability: null (legacy shape) |
Use api.cms.routes.authenticated.* (logged-in user) or api.cms.routes.public.* (anonymous, requires cms.routes.public permission). |
- docs/features/auth-and-access.md — sessions, MFA, step-up, the auth funnel
- docs/features/plugin-system.md — plugin permissions (separate from core capabilities), including
cms.routes.public - docs/server.md — handler patterns
- Source-of-truth files:
src/core/capabilities.ts(@core/capabilities) —CORE_CAPABILITIES(the single canonical list) + the derivedCoreCapabilitytypeserver/auth/capabilities.ts— imports/re-exports the list; ownsSYSTEM_ROLES,FORCE_SYNC_ROLE_IDS, and the runtime guardsserver/auth/authz.ts—requireCapability,requireAnyCapability,userHasCapability,requireStepUpserver/repositories/roles.ts— role persistence +syncSystemRolessrc/admin/access.ts—canAccessWorkspace,firstAccessibleWorkspace, per-workspace helperssrc/admin/pages/users/utils/capabilities.ts—CAPABILITY_META,CAPABILITY_GROUPSserver/handlers/cms/roles.ts—/admin/api/cms/roles(gated byroles.manage)- Architecture tests:
src/__tests__/architecture/capability-picker-coverage.test.ts,src/__tests__/architecture/cms-handlers-capability-gated.test.ts