How Instatic’s 38‑Capability Access Control System Works

Instatic’s access control system centralizes 38 permissions in a single CoreCapability catalog and enforces them through server-side helpers like requireCapability and client-side wrappers like hasCapability across the entire CoreBunch/Instatic codebase.

Instatic (CoreBunch/Instatic) implements a granular permission model that governs every admin action through 38 discrete capabilities. The entire system flows from one source-of-truth file—src/core/capabilities.ts—which eliminates duplicate permission lists by exporting a single CoreCapability type consumed by both server handlers and the React admin UI.

The 38-Capability Catalog at the Heart of Instatic’s Access Control

The canonical list of all 38 permissions lives in src/core/capabilities.ts, where CORE_CAPABILITIES is defined as a const array and exported as the CoreCapability type (typeof CORE_CAPABILITIES[number]). This guarantees that no handler or UI component maintains a parallel copy of the permission list. The capabilities range from read-only flags like dashboard.read and site.read to granular controls for media, data workspace, plugins, and AI features. Comprehensive documentation mapping each capability to the four built-in roles appears in docs/reference/capabilities.md.

Adding a New Capability

Because the catalog is the single source of truth, introducing a new permission requires only three steps:

// 1. Append to src/core/capabilities.ts
export const CORE_CAPABILITIES = [
  // … existing caps
  'analytics.read',
] as const;

// 2. Add to Owner/Admin role lists in server/auth/capabilities.ts
SYSTEM_ROLES.owner.capabilities.push('analytics.read');
SYSTEM_ROLES.admin.capabilities.push('analytics.read');

// 3. Use in a handler
const user = await requireCapability(req, db, 'analytics.read');

Server-Side Capability Enforcement

All HTTP handlers under server/handlers/cms/** invoke authorization helpers from server/auth/authz.ts. The three primary functions are:

  • requireCapability(req, db, capability) — verifies the user holds one specific capability.
  • requireAnyCapability(req, db, capabilityArray) — verifies the user holds at least one capability from a set such as SITE_WRITE_CAPABILITIES.
  • requireAuthenticatedUser(req, db) — validates the session without checking a specific capability.

These helpers read the capabilities array from the user record in the database and return either the authenticated user object or a Response with a 403 status. Architecture tests in src/__tests__/architecture/cms-handlers-capability-gated.test.ts scan the codebase to enforce that every CMS handler calls one of these gate functions.

Example: Gating a CMS Handler

// src/server/handlers/cms/site.ts
import { requireCapability } from '../../auth/authz';

if (req.method === 'GET') {
  const user = await requireCapability(req, db, 'site.read');
  if (user instanceof Response) return user;   // 403 if missing
  // … fetch and return site data
}

Client-Side Permission Checks in the Admin UI

The React admin application imports the same CoreCapability type and uses the thin hasCapability(user, capability) helper exported from src/admin/access.ts. This helper performs a simple user?.capabilities.includes(capability) check. Higher-level convenience functions such as canEditStructure, canReadMedia, and canAccessWorkspace group related capabilities to simplify conditional rendering throughout the admin codebase.

Example: Conditionally Rendering a Control

import { useAdminSession } from '@admin/session';
import { hasCapability } from '@admin/access';

export function MediaUploadButton() {
  const { user } = useAdminSession();
  if (!hasCapability(user, 'media.write')) return null; // hide for unauthorized users
  return <Button onClick={openUploader}>Upload Media</Button>;
}

Example: Workspace Visibility

import { canAccessWorkspace } from '@admin/access';
import type { AdminWorkspace } from './workspace';

function WorkspaceLink({ workspace }: { workspace: AdminWorkspace }) {
  const { user } = useAdminSession();
  if (!canAccessWorkspace(user, workspace)) return null;
  return <Link to={workspacePath(workspace)}>{workspace}</Link>;
}

Built-In Roles and Automatic Synchronization

System roles are defined in server/auth/capabilities.ts. The four built-in roles receive default capability sets as follows:

  • Owner — granted all 38 capabilities.
  • Admin — granted all capabilities except roles.manage.
  • Client — receives a minimal subset including read-only dashboard, site view, content edit, media read, and custom-table read.
  • Member — receives no capabilities by default.

On every server start, syncSystemRoles(db) force-syncs the Owner and Admin roles against the current catalog. Consequently, any newly added capability automatically propagates to Owner and Admin without manual migration. Custom roles and the Client and Member roles preserve their existing capability sets until an administrator explicitly updates them through the Roles UI.

Step-Up Authentication for High-Impact Actions

Certain destructive actions—such as plugins.install, storage.migrate, and data.import with replace mode—require additional verification. The requireStepUp(req, db) helper, documented in docs/features/auth-and-access.md, forces the user to re-authenticate before the operation proceeds. This step-up flow runs after the initial capability check, adding a second layer of protection for high-risk handlers.

Core Capabilities vs. Plugin Permissions

Core capabilities govern human users interacting with the admin UI. Plugin permissions, declared in a plugin’s plugin.json, control code running inside the QuickJS sandbox and are enforced by a separate subsystem. However, plugin routes can still leverage core capabilities through the api.cms.routes.get(path, capability, handler) signature defined in src/core/plugin-sdk/types/serverApi.ts, keeping the two permission models interoperable but distinct.

How a Request Flows Through the Access Control System

A typical gated request flows through five stages:

  1. Authentication — the session cookie is validated.
  2. Capability extraction — the user record’s capabilities array is loaded from the assigned role.
  3. Gate — server handlers call requireCapability or requireAnyCapability; client components call hasCapability or workspace helpers.
  4. Step-up check — for high-impact actions, requireStepUp is invoked after the capability gate passes.
  5. Result — the request proceeds or returns a 403/401 response.

Architecture tests continuously validate that no CMS handler exists without a capability gate, preventing accidental bypass of the access control system.

Key Files

Purpose Path
Source of truth for capabilities src/core/capabilities.ts
Server-side auth helpers server/auth/authz.ts
Role definitions and sync logic server/auth/capabilities.ts
UI-side capability wrappers src/admin/access.ts
Capability documentation and role matrix docs/reference/capabilities.md
Architecture tests enforcing gating src/__tests__/architecture/cms-handlers-capability-gated.test.ts
Role-edit UI picker utilities src/admin/pages/users/utils/capabilities.ts

Summary

  • The CORE_CAPABILITIES array in src/core/capabilities.ts serves as the single source of truth for all 38 permissions.
  • Server handlers enforce gates through requireCapability, requireAnyCapability, and requireAuthenticatedUser in server/auth/authz.ts.
  • The React admin UI uses hasCapability and grouped helpers from src/admin/access.ts to conditionally render controls.
  • Built-in roles default to predefined capability sets, with Owner and Admin auto-synced on server start via syncSystemRoles(db).
  • Destructive operations require step-up re-authentication via requireStepUp(req, db).
  • Plugin permissions and core capabilities remain separate systems, though plugins can opt into core capability checks.

Frequently Asked Questions

What are the 38 capabilities in Instatic?

The 38 capabilities cover dashboard access, site configuration, content editing, media management, data workspace operations, plugin administration, AI features, and role management. The complete enumerated list is exported as CORE_CAPABILITIES from src/core/capabilities.ts and documented in docs/reference/capabilities.md, which also maps each capability to the built-in Owner, Admin, Client, and Member roles.

How does Instatic prevent handlers from skipping capability checks?

Instatic uses architecture tests located in src/__tests__/architecture/cms-handlers-capability-gated.test.ts to statically analyze every file under server/handlers/cms/** and verify that it calls requireCapability, requireAnyCapability, or requireAuthenticatedUser. This automated test suite blocks any pull request that introduces an ungated CMS handler.

What happens when a new capability is added to Instatic?

Developers append the new string to the CORE_CAPABILITIES array in src/core/capabilities.ts and update the Owner and Admin role definitions in server/auth/capabilities.ts. Because syncSystemRoles(db) runs on every server start, the two system roles automatically receive the new capability, while custom roles and Client/Member roles remain unchanged until an admin manually grants the permission.

What is the difference between a core capability and a plugin permission?

Core capabilities are strings from src/core/capabilities.ts that regulate human users in the admin UI. Plugin permissions are declared in a plugin’s plugin.json and restrict what sandboxed QuickJS code is allowed to execute. A plugin route may optionally require a core capability through api.cms.routes.get(path, capability, handler), but that requirement is independent of the plugin’s own permission manifest.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →