# How Instatic’s 38‑Capability Access Control System Works

> Explore how Instatic's 38 capability access control system centralizes permissions in a single catalog and enforces them with server and client-side helpers for robust security.

- Repository: [CoreBunch/Instatic](https://github.com/CoreBunch/Instatic)
- Tags: internals
- Published: 2026-07-28

---

**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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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:

```ts
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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

```ts
// 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`](https://github.com/CoreBunch/Instatic/blob/main/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

```tsx
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

```ts
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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/capabilities.ts) |
| Server-side auth helpers | [`server/auth/authz.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/auth/authz.ts) |
| Role definitions and sync logic | [`server/auth/capabilities.ts`](https://github.com/CoreBunch/Instatic/blob/main/server/auth/capabilities.ts) |
| UI-side capability wrappers | [`src/admin/access.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/access.ts) |
| Capability documentation and role matrix | [`docs/reference/capabilities.md`](https://github.com/CoreBunch/Instatic/blob/main/docs/reference/capabilities.md) |
| Architecture tests enforcing gating | [`src/__tests__/architecture/cms-handlers-capability-gated.test.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/__tests__/architecture/cms-handlers-capability-gated.test.ts) |
| Role-edit UI picker utilities | [`src/admin/pages/users/utils/capabilities.ts`](https://github.com/CoreBunch/Instatic/blob/main/src/admin/pages/users/utils/capabilities.ts) |

## Summary

- The `CORE_CAPABILITIES` array in [`src/core/capabilities.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/server/auth/authz.ts).
- The React admin UI uses `hasCapability` and grouped helpers from [`src/admin/access.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/capabilities.ts) and documented in [`docs/reference/capabilities.md`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/capabilities.ts) and update the Owner and Admin role definitions in [`server/auth/capabilities.ts`](https://github.com/CoreBunch/Instatic/blob/main/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`](https://github.com/CoreBunch/Instatic/blob/main/src/core/capabilities.ts) that regulate human users in the admin UI. Plugin permissions are declared in a plugin’s [`plugin.json`](https://github.com/CoreBunch/Instatic/blob/main/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.