Instatic’s Capability-Based Access Control System: How It Works and How to Use It

Instatic implements a capability-based access control (CBAC) model that defines fine-grained permission strings in a centralized registry, maps them to system roles, and enforces authorization through type-safe runtime guards across server APIs, admin UIs, and plugin SDK endpoints.

The CoreBunch/Instatic repository uses a capability-based access control system to govern every actionable operation within the platform. Instead of hard-coding role checks throughout the codebase, the system maintains a single source-of-truth array of capability strings that can be assigned to roles and verified at runtime.

The Capability Registry: CORE_CAPABILITIES

The foundation of Instatic’s permission model lives in [src/core/capabilities.ts]. This module exports the CORE_CAPABILITIES array, which enumerates every discrete permission available in the system.

  • Shared type safety: The same file exports the CoreCapability TypeScript type, ensuring both client and server code use identical permission definitions.
  • Automatic propagation: Adding a new string to CORE_CAPABILITIES immediately makes it available throughout the codebase without requiring additional configuration changes.
  • Domain organization: Capabilities are logically grouped by functional area (site editing, media management, runtime configuration, plugins, data workspace, and AI features) as documented in [docs/reference/capabilities.md].

System Roles and Permission Mapping

Role definitions reside in [server/auth/capabilities.ts], which exports the SYSTEM_ROLES constant. On every server boot, the system synchronizes these roles using FORCE_SYNC_ROLE_IDS to ensure consistency.

The platform defines four built-in roles with distinct capability sets:

  • Owner: Receives the complete capability set via [...CORE_CAPABILITIES], granting unrestricted access.
  • Admin: Uses a curated subset called adminCapabilities, hand-picked to prevent silent privilege escalation.
  • Client: A lightweight editor role possessing only essential capabilities for content management.
  • Member: A public-facing account with no default capabilities, effectively read-only until explicitly granted permissions.

Runtime Enforcement and Type-Safe Guards

Authorization checks rely on helper functions exported from [server/auth/capabilities.ts]:

  • isCoreCapability(cap): Validates whether a string exists in the registry.
  • normalizeCapabilities(caps): Deduplicates and validates capability arrays.
  • roleHasCapability(userCaps, requiredCap): Performs the actual permission check.

These guards appear in server route definitions, UI command registrations, and plugin SDK route registration (api.cms.routes.get(path, capability, handler)). A typical enforcement pattern looks like:

if (!roleHasCapability(user.capabilities, 'site.structure.edit')) {
  throw new ApiError(403, 'Insufficient capability')
}

Implementation Examples

Checking Permissions on Server Routes

In [server/auth/capabilities.ts], capabilities act as gatekeepers on API endpoints. The following example demonstrates how to protect a page move operation:

import { api } from '@core/http'
import { roleHasCapability } from '@core/capabilities'
import { getUser } from '@/server/auth/session'

api.cms.routes.post(
  '/pages/:id/move',
  'site.structure.edit',               // <-- capability gate
  async (req, res) => {
    const user = await getUser(req)
    if (!roleHasCapability(user.capabilities, 'site.structure.edit')) {
      return res.status(403).json({ error: 'Missing capability' })
    }
    // …perform move operation
  },
)

Rendering the Capability Picker in Admin UI

The [src/admin/pages/users/utils/capabilities.ts] module provides metadata for the admin interface. You can render a complete permission editor using the CORE_CAPABILITIES registry:

import { CORE_CAPABILITIES } from '@core/capabilities'
import { useUserStore } from '@/admin/store'

// Render a list of checkboxes for a role editor
export const CapabilityPicker = () => {
  const { role } = useUserStore()
  return (
    <ul>
      {CORE_CAPABILITIES.map((cap) => (
        <li key={cap}>
          <label>
            <input
              type="checkbox"
              checked={role.capabilities.includes(cap)}
              onChange={() => toggleCapability(cap)}
            />
            {cap}
          </label>
        </li>
      ))}
    </ul>
  )
}

Dynamically Assigning Capabilities

To modify role permissions at runtime, use normalizeCapabilities to safely update database records:

import { normalizeCapabilities } from '@core/capabilities'

// Assume `role` is a mutable DB row
async function addCapability(roleId: string, newCap: string) {
  const role = await db.getRole(roleId)
  const caps = normalizeCapabilities([...role.capabilities, newCap])
  await db.updateRole(roleId, { capabilities: caps })
}

Plugin SDK Integration

Plugins declare required capabilities in [src/core/plugin-sdk/capabilities.ts], mapping them to human-readable metadata. The SDK enforces these declarations when registering routes or exposing functionality to the core platform.

Summary

  • Centralized registry: The CORE_CAPABILITIES array in [src/core/capabilities.ts] serves as the single source of truth for all permissions.
  • Immutable role definitions: [server/auth/capabilities.ts] defines owner, admin, client, and member roles with carefully curated capability sets.
  • Type-safe enforcement: Runtime guards like roleHasCapability and isCoreCapability prevent unauthorized access while maintaining compile-time safety.
  • Cross-platform coverage: The same capability strings govern server APIs, admin UI elements, and plugin SDK endpoints.
  • Extensible model: Adding new permissions requires only updating the central registry and assigning the capability to appropriate roles.

Frequently Asked Questions

How do I add a new permission to Instatic?

Navigate to [src/core/capabilities.ts] and append your new permission string to the CORE_CAPABILITIES array. This automatically updates the CoreCapability TypeScript type. Then assign the new capability to relevant roles in [server/auth/capabilities.ts] and document it in [docs/reference/capabilities.md].

What is the difference between the Owner and Admin roles?

The Owner role receives all capabilities via the spread operator [...CORE_CAPABILITIES], granting complete system access. The Admin role uses a manually curated array called adminCapabilities that excludes potentially dangerous permissions, preventing accidental privilege escalation in day-to-day administrative tasks.

How does capability checking work for plugins?

Plugins declare required capabilities in [src/core/plugin-sdk/capabilities.ts] when registering routes through api.cms.routes.get() or api.cms.routes.post(). The SDK automatically rejects requests from users lacking the specified capability before invoking the handler function.

Can I create custom roles beyond the built-in system roles?

Yes. While [server/auth/capabilities.ts] defines the immutable SYSTEM_ROLES, you can create custom roles at runtime using normalizeCapabilities() to validate capability arrays. Store these in your database and check permissions using roleHasCapability() in your application code.

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 →