Capabilities

July 30, 2026 · View on GitHub

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.


TL;DR

  • Defined as a const array in src/core/capabilities.ts (@core/capabilities); CoreCapability is derived via typeof 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_ROLES on 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_CAPABILITIES in src/core/capabilities.ts (one place — server imports it), add it to the relevant SYSTEM_ROLES entries, wire requireCapability(...) 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).

The 38 core capabilities

Read

CapabilityGrantsRoles
dashboard.readOpen the Dashboard workspaceOwner, Admin, Client
site.readOpen the Site workspace; view pages, VCs, classes. Also gates /runtime/preview (preview HTML rendering of the posted draft).Owner, Admin, Client

Site editing (three-way split)

CapabilityGrantsRoles
site.structure.editAdd / remove / move / rename nodes; manage pages, VCs, classesOwner, Admin
site.content.editModify content props (text, image src/alt, link href) on existing nodes — no structure or style editsOwner, Admin, Client
site.style.editModify CSS classes, style overrides, breakpoints, framework tokensOwner, 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).

Co-editing enforcement: relay writes are held to the SAME per-category rules as the HTTP save. Full site-writers (all three capabilities) skip validation; every update frame from a PARTIAL writer runs through server/collab/updateGuard.ts, which forks the authoritative doc, applies the update to the fork, projects both sides back to JSON, and reuses validateSiteWriteDiff / validatePageWriteDiff — component/layout changes and roster changes are structural wholesale. A rejected update is never applied; the server sends the offender a targeted reset so their diverged local doc reseeds from the authoritative state. Read-only connections may not write docs at all, but their AWARENESS frames relay — presence is not a doc write, so viewers are visible peers.

Page publishing

CapabilityGrantsRoles
pages.editEdit page metadata (title, slug, ...)Owner, Admin
pages.publishPublish / unpublish pagesOwner, Admin

Content (postType rows)

CapabilityGrantsRoles
content.createCreate new draft postType rowsOwner, Admin
content.edit.ownEdit rows where author_user_id = meOwner, Admin
content.edit.anyEdit any rowOwner, Admin
content.publish.ownPublish own rowsOwner, Admin
content.publish.anyPublish any rowOwner, Admin
content.manageFull content admin: edit / publish / status any row regardless of authorOwner, 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.

Data workspace (schema + raw rows + bundles)

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).

CapabilityGrantsRoles
data.custom.tables.readOpen the Data workspace; see + browse custom tables and their field schemasOwner, Admin, Client
data.custom.tables.manageCreate, 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.readSee + open the four system tables (posts/pages/components/layouts).Owner, Admin
data.system.tables.manageOn 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.movePATCH /data/rows/:id/table — move a row to a different table (changes its public URL because route base differs per table).Owner, Admin
data.exportGET /export and POST /import/preview (read-only bundle ops). Row visibility is filtered against canSeeAllDataRows.Owner, Admin
data.importPOST /import (write). replace strategy ALSO requires content.manage AND step-up. Bundles carrying a site shell ALSO require site.structure.edit.Owner, Admin

Media (granular split)

CapabilityGrantsRoles
media.readOpen the Media workspace; browse assets and folders; see thumbnails in pickers. Also gated by /dashboard/media.Owner, Admin, Client
media.writeUpload assets; edit metadata (alt text, caption, tags); manage folders; restore from trash.Owner, Admin
media.replaceOverwrite 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.deleteSoft-delete to trash; hard-purge (?purge=1) additionally requires step-up. Also gates DELETE /media/folders/:id (cascade).Owner, Admin

Runtime + storage (granular split)

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.

CapabilityGrantsRoles
runtime.dependenciesEdit site package.json dependencies; trigger POST /runtime/dependencies/resolve.Owner, Admin
storage.electElect a media storage adapter per asset role (originals / variants / avatars / fonts); elect/clear the variant delegate; verify adapter credentials.Owner, Admin
storage.migrateRun the migration SSE that moves bytes between adapters after an election change.Owner, Admin

Plugins (granular split)

Was a single plugins.manage. Split per the four very different blast radii: read / configure / install (RCE-class) / lifecycle.

CapabilityGrantsStep-upRoles
plugins.readList installed plugins; read masked settings; view event SSE stream; read schedule list. Also gates /dashboard/plugins.noOwner, Admin
plugins.configureEdit per-plugin settings via PUT /plugins/:id/settings; manage plugin records via /plugins/:id/resources/*.yes (settings only)Owner, Admin
plugins.installInstall / upgrade / uninstall plugins; pack install; inspect-package. RCE-class — runs third-party code on the host.yes (mutations)Owner, Admin
plugins.lifecycleEnable / disable / restart plugins; schedule run-now / pause / resume.yes (mutations)Owner, Admin

Users + roles

CapabilityGrantsRoles
users.manageCreate, edit, delete, suspend users; assign rolesOwner, Admin
roles.manageCreate, edit, delete custom roles; assign capabilities to rolesOwner 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.

Audit

CapabilityGrantsRoles
audit.readRead the dedicated /admin/api/cms/audit endpoint AND the Dashboard activity widget (previously leaked to every authenticated user — see A2 fix).Owner, Admin

AI runtime

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.

CapabilityGrantsRoles
ai.chatOpen AI conversations. The agent inherits the caller's capabilities: every tool declares requiredCapabilities (ANY-OF, mirroring its HTTP-route gate — e.g. list_usersusers.manage, document reads → the requireDataAccess set, list_mediamedia.read) and is only offered when the caller holds one. ai.chat is the floor, not a blanket read grant.Owner, Admin
ai.tools.writeEnable 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.manageCreate / update / delete AI provider credentials + per-scope defaultsOwner, Admin
ai.audit.readRead site-wide AI usage, cost, and error events across all usersOwner, Admin

Roles

Four built-in SYSTEM_ROLES:

RoleidCapabilitiesBoot behaviour
OwnerownerAll 36 (CORE_CAPABILITIES)Force-resynced on every boot. Owner-only roles.manage.
AdminadminAll 36 except roles.manageForce-resynced on every boot (changed from previous "seeded once"). Hand-edits restored at boot.
Clientclientdashboard.read, site.read, site.content.edit, media.read, data.custom.tables.readSeeded once; freely editable. Sees custom tables only — never the system tables.
Membermember(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.


Plugin permissions vs. core capabilities

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.ts and emitted into plugin.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.


Handler gates

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 set

Each 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 policy

See 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).


Any-of capability gates

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:

ConstantDefined inGate
SITE_WRITE_CAPABILITIESserver/handlers/cms/site.ts, src/admin/access.tsSave the draft site
DATA_ACCESS_CAPABILITIES, DATA_EDIT_CAPABILITIES, DATA_PUBLISH_CAPABILITIES, …server/handlers/cms/data/access.tsData/content row operations
CONTENT_ACCESS_CAPABILITIES, PLUGIN_READ_CAPABILITIES, DATA_WORKSPACE_READ_CAPABILITIESsrc/admin/access.tsAdmin 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.


Cookbook

Gate a handler

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
}

Gate the UI

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).

Add a new capability

  1. Append the string to CORE_CAPABILITIES in src/core/capabilities.ts — the single source of truth. The CoreCapability type updates automatically (typeof CORE_CAPABILITIES[number]), and the server picks it up via its import:
    export const CORE_CAPABILITIES = [
      // ...
      'analytics.read',
    ] as const
    
  2. If it belongs to the Owner / Admin / Client default sets, add it to the matching SYSTEM_ROLES entry in server/auth/capabilities.ts. Owner + Admin force-sync on next boot.
  3. Use it at the gate point:
    const user = await requireCapability(req, db, 'analytics.read')
    if (user instanceof Response) return user
    
  4. Add a CAPABILITY_META entry + a CAPABILITY_GROUPS section in src/admin/pages/users/utils/capabilities.ts so the role-edit dialog renders a checkbox for it. The picker-coverage test fails until you do.
  5. Existing custom roles will NOT have the new capability until users grant it through the Roles admin page.
  6. Update this doc (table + adjacent docs) so agents and humans can find the new capability.

Add a custom role

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.

Check capability without responding

import { userHasCapability, userHasAnyCapability } from '@auth/authz'

if (userHasCapability(user, 'audit.read')) { /* show menu */ }
if (userHasAnyCapability(user, SITE_WRITE_CAPABILITIES)) { /* allow save */ }

Forbidden patterns

PatternUse instead
user.role === 'admin' to gateuserHasCapability(user, 'media.read')
Hand-rolling a capability check (user.capabilities.includes(...))userHasCapability / requireCapability
Granting roles.manage to non-Owner rolesOwner-only by design. Don't expand.
Skipping the boot-time syncSystemRoles(db) call in testsTests should call it to set up a realistic state
Adding a "permission" string outside the known setAppend to CORE_CAPABILITIES in @core/capabilities first; the derived type catches typos
Per-route ad-hoc auth that doesn't go through requireCapabilityAlways 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 derived CoreCapability type
    • server/auth/capabilities.ts — imports/re-exports the list; owns SYSTEM_ROLES, FORCE_SYNC_ROLE_IDS, and the runtime guards
    • server/auth/authz.tsrequireCapability, requireAnyCapability, userHasCapability, requireStepUp
    • server/repositories/roles.ts — role persistence + syncSystemRoles
    • src/admin/access.tscanAccessWorkspace, firstAccessibleWorkspace, per-workspace helpers
    • src/admin/pages/users/utils/capabilities.tsCAPABILITY_META, CAPABILITY_GROUPS
    • server/handlers/cms/roles.ts/admin/api/cms/roles (gated by roles.manage)
    • Architecture tests: src/__tests__/architecture/capability-picker-coverage.test.ts, src/__tests__/architecture/cms-handlers-capability-gated.test.ts