# Access Control (/access-control)
Aphex has a **capability-based access control system**. A user (or API key) carries a set of flat capability strings (`document.read`, `member.invite`, etc.), and every protected operation is gated by one of them.
Roles are just named bundles of capabilities, stored per organization. That means you can edit the built-in roles, create your own, and ship schemas with per-role access lists — all without touching code.
## The short version
* Every organization gets four **built-in roles** seeded automatically: `owner`, `admin`, `editor`, `viewer`.
* Each role maps to a list of **capabilities** (e.g. `document.publish`, `asset.upload`).
* You can **edit built-in roles** and **create custom roles** per organization.
* Schemas can declare per-operation **access rules** that match against role names.
* Fields can declare **field-level access** to hide or lock them per role.
* **Instance roles** (`super_admin` and `admin` on the user profile) override everything — they always have every capability.
## Capabilities
Capabilities are the atomic unit. Every route, policy, and UI gate checks for a specific capability rather than "is this user an admin".
| Capability | Grants |
| -------------------- | -------------------------------------------------- |
| `document.read` | Read documents (draft and published). |
| `document.create` | Create new documents. |
| `document.update` | Update existing documents. |
| `document.delete` | Delete documents. |
| `document.publish` | Publish a draft. |
| `document.unpublish` | Revert a published document to draft. |
| `asset.read` | Read assets. |
| `asset.upload` | Upload new assets. |
| `asset.delete` | Delete assets. |
| `member.invite` | Invite new members to the organization. |
| `member.remove` | Remove existing members. |
| `member.changeRole` | Change a member's role in the organization. |
| `apiKey.manage` | Create and delete API keys. |
| `role.manage` | Create, edit, and delete custom roles. |
| `org.settings` | Edit organization settings (name, slug, metadata). |
**Organization deletion** is intentionally *not* a capability. It's locked to the `owner` role by
a hardcoded check in `DELETE /organizations/[id]`, so it can't be granted to a custom role.
Write capabilities automatically imply the matching read — creating a role with `document.create` but no `document.read` would leave members unable to see their own edits, so the server normalizes those away on intake.
## Built-in roles
Every new organization is seeded with these four roles. None can be deleted. `admin`, `editor`, and `viewer` are **defaults** — a starting point you're free to edit. `owner` is different: see below.
### `owner` is an invariant, not a default
`owner` doesn't mean "a role that happens to start with everything" — it means *every
capability*, continuously. Two consequences:
* **It's reconciled on every boot.** Capabilities added by a core upgrade, or declared by
a plugin you install, reach owners automatically. Without this, an organization created
last year would quietly lack permissions introduced since.
* **Its capabilities can't be edited.** `PATCH /api/roles/owner` rejects capability changes
with a 403 — the boot reconcile would revert them anyway, so accepting the write would be
a lie. To give someone narrower access, assign them a custom role instead.
The other three built-ins are **floors**: seeded once, then left alone. A new capability is
never granted to them retroactively, because that could re-widen access you deliberately
narrowed. Add it yourself in the Roles UI if you want it.
## Custom roles
Organizations can define additional roles through the `/api/roles` endpoints. A custom role is a name plus a list of capabilities.
```bash
# Create a "Publisher" role — can edit and publish but not delete
curl -X POST https://your-app.com/api/roles \
-H "Content-Type: application/json" \
-b session.cookie \
-d '{
"name": "Publisher",
"description": "Can edit and publish but not delete.",
"capabilities": [
"document.read",
"document.create",
"document.update",
"document.publish",
"document.unpublish",
"asset.read",
"asset.upload"
]
}'
```
Custom roles:
* Cannot reuse a built-in name (`owner`, `admin`, `editor`, `viewer`).
* Can be assigned when inviting a member or via the member-role endpoint.
* Are cached for 30 seconds by `RolesService` — role edits show up on the next request after that.
* Cannot be deleted while they're assigned to any member or pending invitation.
### Roles HTTP API
| Method | Endpoint | Capability | Purpose |
| -------- | ------------------- | ------------- | ------------------------------------------------------------------------------------ |
| `GET` | `/api/roles` | (session) | List roles for the active organization. |
| `POST` | `/api/roles` | `role.manage` | Create a custom role. |
| `PATCH` | `/api/roles/{name}` | `role.manage` | Edit a role's description or capabilities (`owner`'s capabilities are locked — 403). |
| `DELETE` | `/api/roles/{name}` | `role.manage` | Delete a custom role (built-ins are locked). |
## Checking capabilities in code
The auth hook pre-resolves capabilities for every request via `RolesService`, so checks are synchronous:
```ts title="src/routes/api/custom/+server.ts"
import { hasCapability } from '@aphexcms/cms-core/server';
export const POST = async ({ locals }) => {
const auth = locals.auth;
if (!auth) return new Response('Unauthorized', { status: 401 });
if (!hasCapability(auth, 'document.publish')) {
return new Response('Forbidden', { status: 403 });
}
// ...
};
```
Core capabilities autocomplete, and plugin-declared ones (`'forms.export'`) are accepted too — see [Plugins](/docs/plugins#capabilities--aphexcapabilities).
For common coarse-grained UI gating, use the helpers:
```ts
import { canWrite, canManageMembers, canManageApiKeys, isViewer } from '@aphexcms/cms-core/server';
canWrite(auth); // true if any document/asset write cap
canManageMembers(auth); // member.invite | member.remove | member.changeRole
canManageApiKeys(auth); // apiKey.manage
isViewer(auth); // inverse of canWrite
```
Advanced call sites can resolve the full set:
```ts
import { resolveCapabilities } from '@aphexcms/cms-core/server';
const caps = resolveCapabilities(auth); // ReadonlySet
```
## Schema-level access rules
A schema (`document` or shared `object`) can declare an `access` object to restrict what roles can perform each operation. When an operation is omitted, the default capability check applies.
```ts title="src/lib/schemaTypes/invoice.ts"
import type { SchemaType } from '@aphexcms/cms-core';
const invoice: SchemaType = {
type: 'document',
name: 'invoice',
title: 'Invoice',
access: {
read: ['admin', 'owner', 'Accountant'],
create: ['admin', 'owner'],
update: ['admin', 'owner'],
delete: ['owner'],
publish: ['admin', 'owner'],
unpublish: ['owner']
},
fields: [
/* ... */
]
};
```
Each key accepts:
* An **array of role names** — built-in or custom. Matched literally against the user's active organization role.
* Or a **policy function** `(ctx) => boolean` for rules that depend on the document itself:
```ts
access: {
update: ({ auth, doc }) => {
// Only the creator can update their own draft
if (auth.type !== 'session') return false;
return doc?.createdBy === auth.user.id;
};
}
```
**Instance roles always bypass schema access.** `super_admin` and `admin` user profiles behave as
`owner` for the purposes of access rule matching.
## Field-level access
`BaseField.access` narrows reads and writes at the individual field level. Field access is evaluated *after* schema access — if you can't read the document, you'll never reach the field check.
```ts
{
name: 'internalNotes',
type: 'text',
title: 'Internal Notes',
access: {
read: ['admin', 'owner'],
update: ['admin', 'owner']
}
}
```
* **`read`** — members without the role get the field stripped from API responses and hidden in the admin UI.
* **`update`** — members without the role have their writes silently dropped at the API boundary; the admin UI renders the field read-only.
When `read` is omitted, anyone who can read the document can read the field. When `update` is omitted, anyone who can update the document can update the field.
Instance roles (super\_admin/admin) bypass field rules too.
## API keys and capabilities
API keys support both the legacy coarse-grained `permissions: ('read' | 'write')[]` format and the fine-grained `capabilities: Capability[]` allowlist.
```json
{
"name": "Publish-only key",
"capabilities": ["document.read", "document.publish"]
}
```
When both are present, `capabilities` wins — the key can do exactly what's listed and nothing else. See [API Keys](/api-keys#capabilities) for the full reference.
## Instance roles
Every CMS user has a system-wide role on their profile in addition to their per-organization role. Instance roles are your "break glass" admin path — they bypass every check below them.
| Instance role | Behaviour |
| ------------- | ------------------------------------------------------------------------------------------------------- |
| `super_admin` | Assigned to the first user to sign up. Every capability in every organization. Can never be locked out. |
| `admin` | Every capability in every organization. Typically used for platform operators, not content managers. |
| `editor` | Default for new sign-ups. No instance-level powers — behaves according to their per-organization role. |
| `viewer` | No instance-level powers. |
When an instance `super_admin` or `admin` is treated as a member of an organization, `effectiveOrganizationRole()` returns `'owner'` — that's what schema access lists match against.
## How it all fits together
Every request is processed in this order:
### Auth hook
Resolves the session or API key, loads the active organization, and asks `RolesService` for the capability list associated with the role. The list is attached as `auth.capabilities`.
### Route-level check
Before running the handler, the route (or `PermissionChecker`) calls `hasCapability(auth, '…')` for the operation it's about to perform. On failure → `403`.
### Schema-level access
If the route is operating on a typed document, the schema's `access[operation]` rule is evaluated. Role-list rules match against `effectiveOrganizationRole(auth)`; policy functions receive `{ auth, doc }`. Failure → `403`.
### Field-level access
When reading: fields without `read` access are stripped from the response. When writing: writes to fields without `update` access are dropped before persistence.
## See also
# API Keys (/api-keys)
API keys let you access the [HTTP API](/http-api) and [GraphQL API](/graphql) programmatically — from external apps, CI/CD pipelines, static site generators, or any client that isn't the admin UI.
## Creating an API key
API keys are created from the admin UI under **Settings > API Keys**, or via the settings endpoint:
```
POST /api/settings/api-keys
```
A key can be scoped in one of two ways — pass **either** `permissions` (coarse) **or** `capabilities` (fine-grained). At least one is required.
```json
{
"name": "Production Frontend",
"permissions": ["read"],
"expiresInDays": 90
}
```
| Field | Type | Required | Description |
| --------------- | ----------------------- | ---------- | ------------------------------------------------------------------------------------- |
| `name` | `string` | Yes | A display name for the key. |
| `permissions` | `('read' \| 'write')[]` | Either[^1] | Coarse scope. `'write'` auto-includes `'read'`. |
| `capabilities` | `Capability[]` | Either[^1] | Fine-grained allowlist. Write capabilities auto-include the matching read. See below. |
| `expiresInDays` | `number` | No | Days until expiration. Omit for no expiry. |
[^1]: Provide at least one of `permissions` or `capabilities`. If both are set, `capabilities` wins at request time.
The response includes the full key **once** — store it securely, it won't be shown again:
```json
{
"success": true,
"data": {
"id": "key_abc123",
"name": "Production Frontend",
"key": "aphex_live_xxxxxxxxxxxxxxxxxxxxxxxx",
"permissions": ["read"],
"createdAt": "2025-06-15T10:00:00Z",
"expiresAt": "2025-09-13T10:00:00Z"
}
}
```
### Who can create keys
Creating and deleting API keys requires the **`apiKey.manage`** capability — granted by default to the `owner` and `admin` roles, and available to any custom role you add it to. See [Access Control](/access-control#capabilities).
## Using an API key
Pass the key in the `x-api-key` header:
```bash
curl -H "x-api-key: aphex_live_xxxxxxxx" \
https://your-app.com/api/documents?type=post&perspective=published
```
Works with both the HTTP API and GraphQL:
```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "x-api-key: aphex_live_xxxxxxxx" \
-d '{"query": "{ allPost(perspective: \"published\") { id title } }"}' \
https://your-app.com/api/graphql
```
### Permission levels (coarse)
| Permission | Can do |
| ---------- | --------------------------------------------------------------------------------------------------------------- |
| `read` | `GET` requests, `POST /api/documents/query`, GraphQL queries. |
| `write` | Everything `read` can do, plus `POST`, `PUT`, `PATCH`, `DELETE` on documents and assets, and GraphQL mutations. |
A read-only key attempting a mutation receives a `403 Forbidden` response.
When a key only has `permissions` (no `capabilities`), the scopes map to capabilities internally:
* `read` → `document.read`, `asset.read`.
* `write` → all document capabilities + `asset.upload` + `asset.delete` (plus the read caps).
## Capabilities
For fine-grained control, provide a `capabilities` array instead of (or alongside) `permissions`. The key can then do **exactly** what's listed and nothing else.
```json
{
"name": "Publish-only key",
"capabilities": ["document.read", "document.publish"],
"expiresInDays": 30
}
```
This is useful when you want to let an external service perform a single operation without handing it a full write key — e.g. a build webhook that only needs `document.publish` on a specific schema, or a moderation bot that can only run `document.unpublish`.
See [Access Control → Capabilities](/access-control#capabilities) for the full capability list. Write capabilities automatically pull in their matching read cap, so you never end up with a key that can mutate something it can't see.
When a key carries both `permissions` and `capabilities`, the `capabilities` allowlist is authoritative at request time.
## Organization scoping
**Every API key is bound to a single organization.** When you create a key, it's automatically scoped to your currently active organization. All requests made with that key operate within that organization's data.
```
Create key while active org = "Acme Corp"
→ Key is scoped to "Acme Corp"
→ All queries return only "Acme Corp" documents
→ All mutations create documents in "Acme Corp"
```
### How scoping is stored
The organization ID is stored in the key's metadata alongside permissions. When the key is validated on each request, the organization context is extracted and used for all downstream operations — Local API calls, database queries, and Row-Level Security policies.
## Organization hierarchy
Aphex supports a **parent–child organization** hierarchy (one level deep). This is useful for agencies, record labels, enterprise teams, or any multi-tenant setup where a parent needs visibility into child data.
```
Parent Org (Agency)
├── Child Org (Client A)
├── Child Org (Client B)
└── Child Org (Client C)
```
### How it works
* **Parent organizations can read child organization data.** This is enforced at the database level via Row-Level Security policies.
* **Child organizations can only see their own data.** They have no access to sibling or parent data.
* **Writes always target the key's own organization.** Even if a parent can read child data, new documents are created in the parent's organization.
### Reading child data via API
When querying from a parent organization, use `includeChildOrganizations` to include child data:
**HTTP API:**
```bash
curl -H "x-api-key: parent_org_key" \
"https://your-app.com/api/documents?type=post&perspective=published&includeChildOrganizations=true"
```
**Advanced query:**
```json
{
"type": "post",
"perspective": "published",
"includeChildOrganizations": true
}
```
You can also filter to specific child organizations:
```json
{
"type": "post",
"perspective": "published",
"filterOrganizationIds": ["child_org_a_id", "child_org_b_id"]
}
```
### Row-Level Security
Organization isolation is enforced at the PostgreSQL level. The RLS policy on the documents table allows a query to see rows where:
1. `organization_id` matches the current organization, **OR**
2. `organization_id` belongs to a child of the current organization (via `parent_organization_id`).
Writes are restricted — you can only insert/update rows in your own organization:
```sql
-- Read: own org + children
WHERE organization_id IN (
SELECT current_setting('app.organization_id')::uuid
UNION
SELECT id FROM cms_organizations
WHERE parent_organization_id = current_setting('app.organization_id')::uuid
)
-- Write: own org only
WHERE organization_id = current_setting('app.organization_id')::uuid
```
This applies to both documents and assets.
## Deleting an API key
```
DELETE /api/settings/api-keys/{id}
```
Requires the `apiKey.manage` capability. Deleted keys are immediately invalidated.
## Examples
### Static site build
A read-only key for your static site generator:
```ts title="build-script.ts"
const API_KEY = process.env.APHEX_API_KEY;
const API_URL = process.env.APHEX_URL;
const response = await fetch(
`${API_URL}/api/documents?type=post&perspective=published&pageSize=100`,
{
headers: { 'x-api-key': API_KEY }
}
);
const { data: posts } = await response.json();
```
### Content sync between orgs
A parent org key that aggregates content from all child organizations:
```ts title="sync.ts"
const response = await fetch(`${API_URL}/api/documents/query`, {
method: 'POST',
headers: {
'x-api-key': PARENT_ORG_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'article',
perspective: 'published',
includeChildOrganizations: true,
sort: '-publishedAt',
limit: 50
})
});
const { data: articles } = await response.json();
// articles contains published content from parent + all child orgs
```
### Write key for external integrations
A key with write permission for a webhook or integration:
```ts title="webhook-handler.ts"
await fetch(`${API_URL}/api/documents`, {
method: 'POST',
headers: {
'x-api-key': WRITE_KEY,
'Content-Type': 'application/json'
},
body: JSON.stringify({
type: 'event',
data: {
title: 'New Signup',
email: payload.email,
source: 'webhook'
}
})
});
```
# Authentication (/authentication)
Authentication is **not built into `@aphexcms/cms-core`**. The core package only defines an `AuthProvider` interface — a contract telling the CMS how to resolve sessions, validate API keys, and look up users. The implementation lives in your SvelteKit app.
That leaves you two paths, and they produce the same result:
* **`@aphexcms/auth`** — the Better Auth instance, session/API-key service, `AuthProvider`, and Drizzle tables in one call. Start here.
* **[Wiring it by hand](#wiring-it-by-hand)** — the same pieces as files you own. What the base template ships today, and what the package assembles for you.
Either way the CMS engine never imports Better Auth. It only consumes the resolved auth state your app injects via the hook, so upgrading auth doesn't mean upgrading `cms-core`.
## Quick start
**`@aphexcms/auth` hasn't been released yet.** It lands in an upcoming release; until then the
base template wires auth by hand and that layout is still fully supported — see [Wiring it by
hand](#wiring-it-by-hand). Everything else on this page (sign-up, roles, organizations, route
protection, auth shapes) applies to both paths.
Three lines of `.env` cover the basics:
```bash title=".env"
BETTER_AUTH_SECRET=long-random-string-change-in-production
BETTER_AUTH_URL=http://localhost:5173
AUTH_TRUSTED_ORIGINS=http://localhost:5173
```
`BETTER_AUTH_SECRET` signs session cookies **and** API key hashes. Rotating it invalidates every
session and key. Keep it long (32+ chars), random, and out of your repo.
Then assemble auth in one call:
```ts title="src/lib/server/auth/index.ts"
import { createAphexAuth } from '@aphexcms/auth';
import { db, drizzleDb, dbDialect } from '$lib/server/db';
import { emailAdapter, emailConfig } from '$lib/server/email';
import { env } from '$env/dynamic/private';
import { building } from '$app/environment';
export const { auth, service, provider } = createAphexAuth({
database: db,
drizzleDb,
dialect: dbDialect,
secret: env.BETTER_AUTH_SECRET,
baseURL: env.BETTER_AUTH_URL,
trustedOrigins: env.AUTH_TRUSTED_ORIGINS?.split(','),
building,
emailAdapter,
email: emailConfig,
options: {
requireEmailVerification: env.AUTH_REQUIRE_EMAIL_VERIFICATION === 'true',
// On unless explicitly disabled — note the inverted test. Writing
// `=== 'true'` here would quietly turn the default off for everyone who
// never sets the variable.
inviteOnly: env.AUTH_INVITE_ONLY !== 'false'
}
});
```
Hand `provider` to the CMS config:
```ts title="aphex.config.ts"
import { provider } from '$lib/server/auth';
createCMSConfig({
auth: { provider }
});
```
…and `auth` to the SvelteKit hook — see [Hook composition](#hook-composition).
### What you get back
| Key | What it is |
| ---------- | --------------------------------------------------------------------------------------------------------------- |
| `auth` | The underlying Better Auth instance. Exposed deliberately — anything Better Auth can do is reachable from here. |
| `service` | Server-side operations: sessions, API keys, user lookups, password reset. |
| `provider` | The `AuthProvider` Aphex consumes. Pass straight to `createCMSConfig({ auth: { provider } })`. |
**Nothing is read from the environment.** A package can't use SvelteKit's `$env/dynamic/private`,
and env lookups buried inside a dependency make misconfiguration hard to trace. Your app reads its
own env and passes values in.
Beyond the keys above, `AphexAuthConfig` also takes `cache` (a `CacheAdapter` backing session-cookie
caching and the verification-email throttle), [`socialProviders`](#oauth),
[`twoFactor`](#two-factor-authentication), `appName`, and [`betterAuth`](#the-escape-hatch). All are
optional.
### What you still own
The package deliberately does **not** ship your `/login`, `/reset-password/[token]`, or
`/verify-email` pages. Those are the parts worth customizing, and baking them in would mean every
site looked the same.
## Options
```ts
options: {
requireEmailVerification: false,
inviteOnly: true
}
```
| Option | Default | Behaviour |
| -------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `requireEmailVerification` | `false` | Send a verification email at sign-up and block sign-in until the address is confirmed. Off by default so a fresh install works without an SMTP server. |
| `inviteOnly` | `true` | Restrict account creation to addresses holding a pending, unexpired invitation — except while the instance is empty, so the first sign-up can claim it. |
The two defaults are designed as a pair: **the first person to sign up owns the instance, and the
door shuts behind them.** No configuration, no code to copy out of a log — the same install flow as
WordPress, Ghost, Strapi, Payload and Dokploy, but without leaving public registration open
afterwards.
That leaves exactly one window: an instance reachable **before** you claim it belongs to whoever
finds the URL first. Sign up right after deploying, or close the window with a [bootstrap
recipe](#bootstrapping-the-first-admin) — not by turning `inviteOnly` off.
`inviteOnly` gates the sign-up **endpoint**, not just the form, so a direct `POST` is rejected the
same way. It is not Better Auth's `disableSignUp`: an invitee has to create an account before they
can accept an invitation, so disabling sign-up outright makes invitations impossible to accept.
The empty-instance exception requires *proof* of emptiness. An adapter without
`hasAnyUserProfiles()` falls through to the gate rather than being waved past — the inverse fails
open, which is how a missing implementation once promoted every sign-up to super admin.
## Schema
The auth tables (`user`, `session`, `account`, `verification`, `apikey`, `two_factor`) ship per
dialect. Your app re-exports them from one local file:
```ts title="src/lib/server/db/auth-schema/pg.ts"
export * from '@aphexcms/auth/schema/pg';
```
```ts title="src/lib/server/db/auth-schema/sqlite.ts"
export * from '@aphexcms/auth/schema/sqlite';
```
Re-exporting rather than importing the package at every call site buys two things: drizzle-kit keeps
resolving a **local** path in your `schema.ts`, and you get one obvious file to edit when you want to
change a table.
The tables move with the Better Auth version that expects them, which is the reason they live in the
package at all — a hand-copied duplicate is exactly how the two drift apart.
Match the dialect to the Drizzle client you actually use — `pg` for Postgres and PGlite, `sqlite`
for libsql. Mixing them produces queries that compile but fail at runtime.
## Adding tables and columns
Three cases, handled differently.
### Your own tables
Nothing to do with auth. Define them in your own file and export it from your schema barrel
alongside the CMS and auth schemas:
```ts title="src/lib/server/db/schema.ts"
export * from './cms-schema';
export * from './auth-schema';
export * from './my-schema'; // [!code ++]
```
### An extra column on an auth table
You can't extend a Drizzle table object, so you redefine the table in your local `auth-schema` file
instead of re-exporting it. An explicit export wins over `export *`, so this overrides cleanly:
```ts title="src/lib/server/db/auth-schema/pg.ts"
import { pgTable, text, timestamp, boolean } from 'drizzle-orm/pg-core';
export * from '@aphexcms/auth/schema/pg';
// Same table name, one extra column — this export shadows the re-exported `user`.
export const user = pgTable('user', {
id: text('id').primaryKey(),
name: text('name').notNull(),
email: text('email').notNull().unique(),
emailVerified: boolean('email_verified').default(false).notNull(),
image: text('image'),
stripeCustomerId: text('stripe_customer_id'), // [!code ++]
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().notNull()
});
```
`pnpm db:generate` then picks it up as an ordinary migration:
```sql
ALTER TABLE "user" ADD COLUMN "stripe_customer_id" text;
```
The migration only creates the column. For Better Auth to **read and write** it, declare it as an
additional field through the [escape hatch](#the-escape-hatch):
```ts
betterAuth: (base) => ({
...base,
user: { additionalFields: { stripeCustomerId: { type: 'string', required: false } } }
})
```
### Tables from a Better Auth plugin
[Two-factor](#two-factor-authentication) is the exception — its tables ship with the package because
it's a first-class option. Any other plugin you add through [the escape
hatch](#the-escape-hatch) brings its own tables, so define them in your own schema file. Check the
plugin's schema against the Better Auth version you have installed: the required fields do change
between releases.
## Bootstrapping the first admin
Claiming a fresh instance is a deployment question, not a library one — so it's a
policy you pass in. Four recipes ship with the package, and anything they don't cover is a
plain function.
```ts title="src/lib/server/auth/index.ts"
import { createAphexAuth, allowlistEmail } from '@aphexcms/auth';
createAphexAuth({
// ...
bootstrap: allowlistEmail(env.APHEX_BOOTSTRAP_EMAIL)
});
```
| Recipe | Who becomes super admin | Mirrors |
| ------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------ |
| `openFirstUser()` **(default)** | Whoever signs up first, no further proof | WordPress, Ghost, Strapi, Payload, Dokploy |
| `claimCode()` | First user who also supplies a code printed to the server log at startup | Jupyter, GitLab |
| `allowlistEmail(emails)` | First user whose address is on the allowlist | Discourse |
| `never()` | Nobody — provision out of band | Directus, Keycloak |
`allowlistEmail` is only as strong as the address is trustworthy, so pair it with
[`AUTH_REQUIRE_EMAIL_VERIFICATION`](#turning-verification-on) — otherwise anyone who knows the
configured address can register as it. That's enforced at the **auth layer**, not by the policy:
Better Auth won't complete sign-in for an unconfirmed address, so an unverified user never reaches
profile creation. The policy logs a warning if it promotes an unverified address, but doesn't
block — that decision isn't bootstrap's to make.
### Hardening the first run
The default is "first user wins", the same install flow as WordPress, Ghost, Strapi, Payload and
Dokploy. It's fine when you install the moment you deploy, and it's what most people want.
It stops being fine when an instance sits reachable before anyone signs in — a container that's up
for ten minutes before you open it, or one redeployed onto an empty volume. Then the first stranger
to find the URL owns your CMS. If that window is real for your deployment, close it.
`allowlistEmail()` is the simpler answer when you know the owner's address at deploy time.
`claimCode()` keeps the same first-run flow and adds one step: promotion also requires a code
that only someone with access to the deployment's logs could have.
```
This instance has no administrator yet.
Sign up at /admin and enter this claim code to become the super admin:
Xk4mQ2pR9vLc7hT1wY6nB3sF8jD5gA0z
```
The sign-up form shows a **Claim code** field whenever the instance is unclaimed, so paste it there
and sign up — nothing else to do. The field is driven by `isInstanceUnclaimed(db)`, which is true
only while no profile exists and a code is still pending; it reveals that the instance is empty and
nothing more.
Under the hood the form sets a short-lived `aphex_bootstrap_code` cookie just before sign-up, and
clears it as soon as sign-up returns. It has to be a cookie rather than a header: the policy runs
while the auth client's own sign-up request is being handled, and the form can't set a header on it.
If you're driving sign-up yourself, the code is read from an `x-aphex-bootstrap-code` header or that
cookie, in that order. Pass `readCode` to accept it from somewhere else:
A `?claim=` query parameter used to be accepted and no longer is. A URL carrying the code
ends up in browser history, access logs, and any `Referer` header sent to a third party — several
places nobody thinks to clear a credential out of. Use the header or the cookie.
```ts
claimCode({ readCode: (request) => request?.headers.get('x-setup-token') ?? undefined });
```
Only the code's **hash** is stored, so a leaked database dump doesn't hand someone the instance.
It's single-use — cleared the moment it's accepted.
A recipe carries its own startup step. `claimCode()` sets `policy.prepare` to the function that
generates and logs the code; every other recipe leaves it undefined. Call
`bootstrapPolicy.prepare?.(db)` once at boot (studio does this in `hooks.server.ts`) and switching
recipes needs no other change — you can't strand a hook that logs a code nothing asks for.
The claim window closes on the **first sign-up**, not on the code being used. Someone who signs up
without entering the code becomes an ordinary `editor` and leaves the code permanently
unreachable, since the policy checks "is this the first user?" before anything else. Recovering
means clearing `bootstrapClaimCodeHash` from instance settings and promoting the row by hand.
Single-use is not the same as mutually exclusive. Two concurrent requests can both read the code
before either clears it, so a determined race can produce **two** super admins — both of whom
already had the code. Preventing it needs a database-level uniqueness constraint, not a longer
lock: holding a transaction across the claim and the profile write blocks the auth provider's own
concurrent inserts, which fails sign-up outright on SQLite (`SQLITE_BUSY`).
### Keeping the classic behavior
Nothing stops you — just say so explicitly:
```ts
import { openFirstUser } from '@aphexcms/auth';
createAphexAuth({ bootstrap: openFirstUser() });
```
This is already the default, so you only need to write it out to be explicit. Either way it
assumes you claim the instance promptly after deploying — [`inviteOnly`](#options) shuts the
sign-up endpoint behind you, but it can't help before anyone has signed up.
### Writing your own
The policy is one function. It returns the instance role to grant, or `null` for no promotion —
in which case the user gets the ordinary `editor` role.
```ts
bootstrap: async ({ user, isFirstUser, request, db }) => {
if (!isFirstUser) return null;
return user.email.endsWith('@example.com') ? 'super_admin' : null;
};
```
`isFirstUser` is true only when the system is **provably** empty. An adapter that can't answer
the question reports `false`, so "couldn't check" is never mistaken for "nobody's here yet".
## Sign-up flow
The first sign-up bootstraps the system; everyone after that is opt-in via invitation.
**First user signs up** → assigned the `super_admin` instance role automatically. A default
organization is created with them as `owner`. They're dropped into the admin UI immediately.
**Subsequent users sign up** → assigned the `editor` instance role automatically. They have **no
organization membership** yet, so they hit the `/invitations` screen on first login. From there
they accept any pending invitation an org owner / admin sent to their email.
**Editor invites teammates** — owners and admins use the admin UI's members page (or `POST
/api/organizations/{id}/invitations`) to invite by email. The invitee gets a Better Auth-signed
link, clicks through, signs up if needed, and is auto-added as a member.
## Email verification & password reset
Both flows require an [email adapter](/configuration#email). The base template uses `createMailpitAdapter()` in dev (so verification + reset mail lands at [http://localhost:8025](http://localhost:8025)) and `createResendAdapter()` in prod.
| Flow | Trigger | Token expiry |
| ------------------ | ----------------------------- | ------------ |
| Email verification | New sign-up | 1 day |
| Password reset | "Forgot password" on `/login` | 1 hour |
If `email` is `null` in your config, both flows are disabled — sign-ups are auto-verified and password reset returns a 503.
### Turning verification on
Email verification is **off by default**, so a fresh install works without an SMTP or Mailpit server. Opt in explicitly:
```bash
# apps/studio/.env (or your project's .env)
AUTH_REQUIRE_EMAIL_VERIFICATION=true
```
Only the exact string `true` enables it — any other value (or omitting the variable) leaves it off. With it off, the first sign-up can log in immediately and no verification email is sent.
**Turn this on in production.** Without it, anyone can sign up with an address they don't own, and
since the first user to sign up becomes super admin, that's an account-takeover risk. Pair it with
[`inviteOnly`](#options) if the site shouldn't accept public sign-ups at all.
## Roles and permissions
Aphex uses a **capability-based** access control system. Every protected operation is gated against a capability string (`document.publish`, `member.invite`, `asset.upload`, …) and roles are named bundles of those capabilities. See [Access Control](/access-control) for the full list of capabilities and the per-schema / per-field rules.
### Instance (system) roles
Every CMS user has a system-wide role on their profile, independent of any organization. This is the "break glass" path.
### Organization roles
Each user also has a role **per organization** they belong to. Four built-in roles are seeded for every new organization, and you can edit their capabilities or add your own.
| Role | Capabilities |
| -------- | -------------------------------------------------------------------------------------------------------------- |
| `owner` | Every capability, plus the hardcoded ability to delete the organization. |
| `admin` | Everything `editor` has, plus `member.*`, `apiKey.manage`, `role.manage`, and `org.settings`. |
| `editor` | All document and asset capabilities (read / create / update / delete / publish / unpublish + upload / delete). |
| `viewer` | Read-only — `document.read` and `asset.read` only. |
Built-in roles are editable and can't be deleted. Each organization can also define **custom roles** with any capability list — schemas can then grant access to specific role names.
### Checking capabilities in code
Capabilities are resolved once per request by the auth hook (via `RolesService`) and attached to `auth.capabilities`. Checks are synchronous:
```ts
import {
hasCapability,
canWrite,
canManageMembers,
canManageApiKeys,
isViewer
} from '@aphexcms/cms-core/server';
hasCapability(auth, 'document.publish'); // exact capability check
canWrite(auth); // any mutating doc/asset capability
canManageMembers(auth); // member.invite | member.remove | member.changeRole
canManageApiKeys(auth); // apiKey.manage
isViewer(auth); // inverse of canWrite
```
## Organizations
Aphex supports multi-tenancy with a **one-level parent / child hierarchy**.
```
Parent Organization (e.g. Record Label)
├── Child Organization A (e.g. Artist 1)
└── Child Organization B (e.g. Artist 2)
```
* A parent organization can **read** documents and assets from all its children.
* A child organization can only access its **own** data.
* **Writes are always scoped** to the user's active organization — a parent can't accidentally modify child data.
Users can belong to multiple organizations and switch between them via the admin UI's organization switcher. The active one is persisted in `cms_user_sessions`.
### Invitations
```http
POST /api/organizations/{id}/invitations
{
"email": "newperson@example.com",
"role": "editor"
}
```
The full flow:
An invitation row is created with a 7-day expiry and a one-time token.
If an email adapter is configured, an invite email is sent. (Without one, surface the link in the
admin UI yourself.)
The invitee signs up (or logs in) and accepts at
`/invite/{token}`
.
They're added as an organization member with the assigned role and dropped into the admin UI.
## Route protection
The CMS hook automatically protects routes based on the auth configuration:
| Route pattern | Auth required | Auth type |
| ----------------------- | ------------- | --------------------------- |
| `/admin/*` | Yes | Session only |
| `/api/*` | Yes | Session or API key |
| `/media/*`, `/assets/*` | Optional | Session, API key, or public |
For API routes, if an `x-api-key` header is present, it takes precedence over session cookies. Mutating requests (`POST`, `PUT`, `PATCH`, `DELETE`) require write permission — read-only API keys receive a `403`.
The one exception is `POST /api/documents/query`, which is treated as a read operation because the POST is only used to carry the complex filter payload.
## OAuth
OAuth needs **no schema change**: provider links live in the `account` table the auth schema already
includes — `providerId`, `accountId`, `accessToken`, `refreshToken`, one row per (user, provider)
pair. If you provisioned the database with the current schema, OAuth works without a migration.
**Add the provider.** With `createAphexAuth`, pass it straight through:
```ts title="src/lib/server/auth/index.ts"
createAphexAuth({
// ...
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET
}
}
});
```
Wiring by hand, it's the same key on your own `betterAuth({ ... })` call.
**Set the env vars** and make sure your auth URL is in `AUTH_TRUSTED_ORIGINS`:
```bash title=".env"
GOOGLE_CLIENT_ID=...
GOOGLE_CLIENT_SECRET=...
BETTER_AUTH_URL=https://cms.example.com
AUTH_TRUSTED_ORIGINS=https://cms.example.com
```
**Register the OAuth callback URL** in Google Cloud Console (or the provider's dashboard): `/api/auth/callback/google`. Locally that's `http://localhost:5173/api/auth/callback/google`.
**Add a sign-in button** that calls the Better Auth client:
```svelte title="src/routes/login/+page.svelte"
```
The first OAuth user becomes `super_admin` and gets a default organization, exactly like email sign-up. Subsequent users land on `/invitations` until an admin invites them.
**Why OAuth and other sign-in methods "just work":** Aphex uses lazy profile sync. The first time
`getSession` sees a user with no `cms_user_profiles` row, it creates one (the first user ever
becomes `super_admin`, everyone else becomes `editor`). This runs regardless of whether the user
signed in via email, OAuth, magic link, or anything else.
For other providers (GitHub, Apple, Microsoft, Discord, OIDC, …) see [Better Auth — Social Sign-on](https://www.better-auth.com/docs/concepts/oauth).
## Two-factor authentication
Off by default. Turning it on adds a TOTP second factor — the user scans a QR code with an
authenticator app (1Password, Authy, Google Authenticator) and enters a 6-digit code at sign-in —
plus one-time backup codes for recovery.
```ts title="src/lib/server/auth/index.ts"
createAphexAuth({
// ...
appName: 'Acme CMS', // shown as the issuer in the authenticator app
twoFactor: true
});
```
`true` takes the defaults; pass an object to configure it (`issuer`, `skipVerificationOnEnable`,
`totpOptions`, `backupCodeOptions`). The type is Better Auth's own `TwoFactorOptions`, so every
option the installed version supports is reachable.
**No migration needed to turn this on.** The `two_factor` table and `user.two_factor_enabled`
column ship in the schema whether or not the option is set — a config flag that silently changes
your table shape would make enabling 2FA a migration on a live install. The plugin, and the
`/two-factor/*` routes it mounts, are what's conditional.
### Client setup
Enrollment and verification are driven from the browser, so add the client plugin:
```ts title="src/lib/auth-client.ts"
import { createAuthClient } from 'better-auth/client';
import { twoFactorClient } from 'better-auth/client/plugins';
export const authClient = createAuthClient({
plugins: [
twoFactorClient({
onTwoFactorRedirect() {
window.location.href = '/two-factor';
}
})
]
});
```
Once a user has 2FA enabled, `signIn.email` **no longer returns a session** — it returns
`twoFactorRedirect: true` and the sign-in is incomplete until the code is verified. Build the
verification screen before enabling this, or you'll lock users at the login form.
### Enrolling a user
**Enable it**, re-authenticating with the current password. This returns the TOTP URI to render as a QR code, plus the backup codes — the only time they're shown in plaintext.
```ts
const { data } = await authClient.twoFactor.enable({ password });
// data.totpURI → render as a QR code
// data.backupCodes → show once, tell the user to store them
```
**Verify the first code.** `twoFactorEnabled` stays `false` until this succeeds, so a user who scans the QR code but never confirms isn't locked out of their own account.
```ts
await authClient.twoFactor.verifyTotp({ code, trustDevice: true });
```
`trustDevice` skips the prompt on that device for 30 days, refreshed on each sign-in.
**At sign-in**, handle the challenge:
```ts
await authClient.signIn.email(
{ email, password },
{
onSuccess(ctx) {
if (ctx.data.twoFactorRedirect) {
// send them to your /two-factor screen
}
}
}
);
```
Disabling is `authClient.twoFactor.disable({ password })`, which deletes the row and clears the flag.
2FA gates the **credential** sign-in endpoints (`/sign-in/email`, `/sign-in/username`,
`/sign-in/phone-number`). It does not gate OAuth, magic links, or passkeys — those are already
second factors of a sort, and Better Auth leaves the policy to you. If a user can reach an account
through both a password and Google, enabling 2FA only covers the password path.
### Other Better Auth features
These need no AphexCMS changes — configure them on the Better Auth instance, or through [the escape hatch](#the-escape-hatch):
| Feature | Better Auth reference |
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
| OAuth providers | [Social Sign-on](https://www.better-auth.com/docs/concepts/oauth) |
| Magic link / email OTP | [Magic Link](https://www.better-auth.com/docs/plugins/magic-link), [Email OTP](https://www.better-auth.com/docs/plugins/email-otp) |
| Two-factor auth | [Two Factor](https://www.better-auth.com/docs/plugins/2fa) |
| Passkeys / WebAuthn | [Passkey](https://www.better-auth.com/docs/plugins/passkey) |
| Session lifetime, cookie policy | [Session Management](https://www.better-auth.com/docs/concepts/session-management) |
| Rate limiting | [Rate Limit](https://www.better-auth.com/docs/concepts/rate-limit) |
| Cookie cache (perf) | [Cookie Cache](https://www.better-auth.com/docs/concepts/session-management#cookie-cache) — already enabled in the base template with a 60s TTL |
## Auth shapes
The `AuthProvider` returns one of three states. You'll typically read them off `event.locals.auth`.
### `SessionAuth`
Full browser session with active organization context — the admin UI's normal state.
```ts
interface SessionAuth {
type: 'session';
user: CMSUser;
session: { id: string; expiresAt: Date };
organizationId: string;
organizationRole: OrganizationRole; // 'owner' | 'admin' | 'editor' | 'viewer'
organizations?: Array<{
id: string;
name: string;
slug: string;
role: OrganizationRole;
isActive: boolean;
}>;
}
```
### `PartialSessionAuth`
Authenticated user who hasn't joined an organization yet (just signed up, has pending invitations).
```ts
interface PartialSessionAuth {
type: 'partial_session';
user: CMSUser;
session: { id: string; expiresAt: Date };
}
```
The CMS redirects users with this state to `/invitations` until they accept an invite or get added.
### `ApiKeyAuth`
Programmatic access via the `x-api-key` header.
```ts
interface ApiKeyAuth {
type: 'api_key';
keyId: string;
name: string;
permissions: ('read' | 'write')[];
organizationId: string;
}
```
API keys are always scoped to a single organization. See [API Keys](/api-keys) for capability-based keys (the modern equivalent of `permissions`).
## Under the hood
Everything above sits on one seam: your app builds an `AuthProvider` and hands it to
`createCMSConfig`. The CMS never learns which library produced it.
```
┌─────────────────────────────────────────────────────┐
│ Your SvelteKit App (app-level) │
│ │
│ src/lib/server/auth/ │
│ createAphexAuth() ─or─ your own wiring │
│ → { auth, service, provider } │
│ │
│ src/hooks.server.ts │
│ sequence(authHook, aphexHook) │
└──────────────────────────────────┬──────────────────┘
│ passes AuthProvider
▼
┌─────────────────────────────────────────────────────┐
│ @aphexcms/cms-core (package-level) │
│ │
│ Defines: AuthProvider interface │
│ Consumes: event.locals.auth (SessionAuth, etc.) │
│ Never imports: better-auth, lucia, authjs, etc. │
└─────────────────────────────────────────────────────┘
```
Which is why **different apps can use different auth backends** against the same CMS engine, and why
tests can inject a mock provider without spinning up a real auth server.
### Hook composition
Both Better Auth and the CMS run via SvelteKit's `sequence()`:
```ts title="src/hooks.server.ts"
import { sequence } from '@sveltejs/kit/hooks';
import { createCMSHook } from '@aphexcms/cms-core/server';
import { auth } from '$lib/server/auth/index.js';
import config from '../aphex.config.ts';
// Better Auth handles /api/auth/* routes
const authHook: Handle = async ({ event, resolve }) => {
return svelteKitHandler({ event, resolve, auth });
};
// CMS hook — route protection + DI on locals.aphexCMS
const aphexHook = createCMSHook(config);
export const handle = sequence(authHook, aphexHook);
```
The auth hook **must** come before the CMS hook so sessions are available when the CMS checks capabilities.
### You still own Better Auth
Better Auth is a **peer dependency** of `@aphexcms/auth`. You choose the version in your own
`package.json` and upgrade when you want — the package doesn't pin you to its release cycle.
#### The escape hatch
`betterAuth` receives the fully assembled options and returns what actually gets passed to
`betterAuth()`. This is what keeps the wrapper from becoming a ceiling: any option Better Auth
gains — new plugins, new providers, new session policies — works immediately, without waiting on a
release here.
```ts
import { twoFactor } from 'better-auth/plugins';
createAphexAuth({
// ...
betterAuth: (base) => ({
...base,
plugins: [...base.plugins, twoFactor()],
session: { ...base.session, expiresIn: 60 * 60 * 24 * 30 }
})
});
```
Reach for this only when a first-class option doesn't already cover it. Spreading `base` matters —
replacing a key outright drops the defaults the package set up for you (cookie cache, rate limits,
the API-key plugin, CMS profile sync).
### Wiring it by hand
The package is a convenience, not a requirement — and it's what the base template currently ships,
as files you own:
| File | Responsibility |
| ------------------------- | ---------------------------------------------------------------------------------- |
| `index.ts` | Exports the `AuthProvider` instance Aphex consumes. |
| `auth.config.ts` | App-owned options — `requireEmailVerification`, `inviteOnly`. |
| `service.ts` | `AuthService` — session, API key, lazy user-profile sync, first-user bootstrap. |
| `better-auth/instance.ts` | Better Auth instance with email & password, verification, API keys, organizations. |
Configure Better Auth directly on `instance.ts`. Aphex only consumes the resolved session shape
(`auth.user`, `auth.organizationRole`), not how the user authenticated — so if a feature is
configurable on Better Auth, you don't need to touch AphexCMS to use it. See the [Better Auth
documentation](https://www.better-auth.com/docs).
### Custom auth providers
You can replace Better Auth entirely — Lucia, Auth.js, something custom — by implementing the `AuthProvider` interface and passing your instance to `createCMSConfig({ auth: { provider } })`. Most projects will never need this.
```ts
interface AuthProvider {
// Session auth (browser, admin UI)
getSession(
request: Request,
db: DatabaseAdapter
): Promise;
requireSession(request: Request, db: DatabaseAdapter): Promise;
// API key auth (programmatic access)
validateApiKey(request: Request, db: DatabaseAdapter): Promise;
requireApiKey(
request: Request,
db: DatabaseAdapter,
permission?: 'read' | 'write'
): Promise;
// User management
getUserById(
userId: string
): Promise<{ id: string; name?: string; email: string; image?: string } | null>;
getUserByEmail(
email: string
): Promise<{ id: string; name?: string; email: string; image?: string } | null>;
changeUserName(userId: string, name: string): Promise;
changeUserImage?(userId: string, image: string | null): Promise;
// Password reset
requestPasswordReset(email: string, redirectTo?: string): Promise;
resetPassword(token: string, newPassword: string): Promise;
}
```
Your provider needs to:
1. **Resolve sessions** — return `SessionAuth` with org context for the admin UI, or `PartialSessionAuth` for users without an org.
2. **Validate API keys** — return `ApiKeyAuth` scoped to one organization.
3. **Look up users** — resolve user IDs to email / name (used in version history `createdByName`, etc.).
4. **Handle password reset** — token-based reset, or throw if unsupported.
Once you pass the provider to `auth.provider`, the rest of the system (route protection, Local API context, admin UI) works without modification.
## See also
# Configuration (/configuration)
The `aphex.config.ts` file is the central configuration for your Aphex project. It's created by `createCMSConfig()` and wires together your schemas, database, storage, authentication, and email.
```ts title="aphex.config.ts"
import { createCMSConfig } from '@aphexcms/cms-core/server';
import { schemaTypes } from '$lib/schemaTypes/index.js';
import { db } from '$lib/server/db/index.js';
import { authProvider } from '$lib/server/auth/index.js';
import { email } from '$lib/server/email/index.js';
export default createCMSConfig({
schemaTypes,
database: db,
email,
auth: {
provider: authProvider,
loginUrl: '/login'
},
graphql: {
defaultPerspective: 'published',
path: '/api/graphql'
},
customization: {
branding: {
title: 'My CMS'
}
}
});
```
## Options
| Option | Type | Required | Default | Description |
| ------------------- | --------------------------- | -------- | ---------------- | ------------------------------------------------------------------- |
| `schemaTypes` | `SchemaType[]` | Yes | — | Your content schemas (document and object types). |
| `database` | `DatabaseAdapter` | Yes | — | Database adapter instance. |
| `storage` | `StorageAdapter \| null` | No | Local filesystem | Storage adapter for file uploads. |
| `images` | `object \| null` | No | Enabled | Responsive image derivatives. `null` disables them. |
| `upload` | `object` | No | 10 MB, proxied | Upload size ceiling and direct-to-storage transport. |
| `signedDownloads` | `object` | No | — | Serve selected assets as a signed-URL redirect instead of proxying. |
| `email` | `EmailAdapter \| null` | No | `null` | Email adapter for sending emails. |
| `cache` | `CacheAdapter \| null` | No | `null` | Cache adapter for published-perspective reads. |
| `auth` | `object` | No | — | Authentication configuration. |
| `graphql` | `boolean \| GraphQLConfig` | No | `true` | GraphQL API configuration. |
| `versioning` | `object` | No | — | Document version history options. |
| `api` | `(app: Hono) => void` | No | — | Register custom HTTP routes / middleware on the built-in Hono app. |
| `aiProvider` | `AIProviderAdapter \| null` | No | `null` | Model backend for the in-admin agent. Omit to leave the agent off. |
| `agentModel` | `string` | No | — | Model id the agent calls. Required when `aiProvider` is set. |
| `agentSystemPrompt` | `string` | No | built-in | Replaces the default agent system prompt. |
| `customization` | `object` | No | — | Branding and theme options. |
| `logLevel` | `string` | No | auto | `'debug'` in dev, `'warn'` in prod. |
| `security` | `object` | No | — | Security options (asset signing). |
## schemaTypes
An array of `SchemaType` objects defining your content model. Each schema is either a `document` (top-level collection) or an `object` (reusable nested structure).
```ts
import { schemaTypes } from '$lib/schemaTypes/index.js';
createCMSConfig({
schemaTypes
// ...
});
```
Schemas are registered in the database on first startup and re-synced when they change during development. The `aphex()` Vite plugin (from `@aphexcms/cms-core/vite`, included in the template's `vite.config.ts`) watches `aphex.config.ts` and `src/lib/schemaTypes/**` and hot-swaps the engine config in place — the dev server keeps running, only the schema map updates. See [Schemas](/schemas) for the full schema reference.
## database
A `DatabaseAdapter` instance that handles all content storage. This is the only required adapter. The interface is part of the ports-and-adapters architecture so additional database backends can be added later, but the only adapter shipped today is PostgreSQL via Drizzle ORM (`@aphexcms/postgresql-adapter`).
```ts
import { db } from '$lib/server/db/index.js';
createCMSConfig({
database: db
// ...
});
```
The PostgreSQL adapter (`@aphexcms/postgresql-adapter`) is the built-in implementation. It uses Drizzle ORM and supports Row-Level Security for multi-tenant isolation.
## storage
A `StorageAdapter` instance for file uploads and asset management. If not provided, Aphex creates a **local filesystem adapter** automatically:
```ts
// Default (no config needed):
// Files stored at ./storage/assets
// Served via /assets/{id}/{filename}
// S3-compatible storage:
import { storage } from '$lib/server/storage/index.js';
createCMSConfig({
storage
// ...
});
```
Available adapters:
* **Local filesystem** (default) — stores files in `./storage/assets`.
* **`@aphexcms/storage-s3`** — S3-compatible storage (AWS S3, Cloudflare R2, MinIO).
## images
Responsive image derivatives, generated **on first request** rather than at upload.
```ts
export default createCMSConfig({
images: {
widths: [320, 640, 960, 1280, 1920],
quality: 80
}
});
```
**Enabled by default** — these are the defaults, so you only need this block to change them. Set
`images: null` to turn the pipeline off entirely; `/media` then always serves the original.
Variants are addressed as siblings of the original: `/media/{assetId}/w960-{configHash}.webp`.
That hash is derived from `widths` + `quality`, which is what earns the URLs a one-year immutable
cache — the bytes behind a given URL can never change.
### Changing the ladder
Safe, and needs no migration. Edit either value and the hash moves, so every variant URL changes
and the next request regenerates at the new settings. Nothing breaks in between, because a URL
carrying an old hash simply serves the original.
The one cost is that superseded files stay in the bucket unreferenced until the asset is deleted
(deletion sweeps the whole `{assetId}/` prefix, so it does catch them). Reordering `widths` is a
no-op: they're sorted and de-duplicated before hashing.
There is deliberately no per-collection or per-block size config. One ladder means adding a width
is a config edit rather than a migration, and two placements of one image share a single set of
files. The per-placement control is the `sizes` attribute — see
[Frontend](/frontend#image-rendering).
Widths at or above an asset's own width are skipped, so a 1600 px original's `srcset` stops at
1280 rather than upscaling. SVG and animated images are served as-is.
## upload
```ts
export default createCMSConfig({
upload: {
maxFileSize: 100 * 1024 * 1024, // 100 MB
allowedMimeTypes: ['image/*', 'application/pdf'],
direct: true
}
});
```
`maxFileSize` is the **only** place to set the limit. It backs the request check, the direct-upload
grant, the ceiling the admin UI reads back, and — since v1 — the storage adapter's own guard, which
`createCMSConfig` overrides so the two can't disagree.
The one thing it can't raise is a host's own request cap. Vercel Functions reject a body over
4.5 MB before your app is invoked, and unlike responses there's no streaming escape — which is what
`direct` is for.
`allowedMimeTypes` is the installation-wide upload policy. When omitted, Aphex allows this
conservative set of common CMS formats:
```ts
const defaultAllowedMimeTypes = [
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
'image/avif',
'image/heic',
'image/heif',
'application/pdf',
'text/plain',
'text/csv',
'text/markdown',
'application/json',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.presentation',
'application/zip',
'audio/mpeg',
'audio/mp4',
'audio/wav',
'audio/ogg',
'audio/aac',
'audio/flac',
'video/mp4',
'video/webm',
'video/quicktime',
'video/ogg',
'font/woff',
'font/woff2',
'font/ttf',
'font/otf'
];
```
SVG is excluded by default because it can contain active content. Providing `allowedMimeTypes`
replaces the built-in list. Configure exact MIME types (`application/pdf`) or wildcards (`image/*`);
filename extensions such as `.pdf` are rejected because they identify a name, not its content. A
schema field's [`accept`](/docs/schemas/file#accepted-file-types) rule is applied in addition to this
list, so fields may narrow the global policy but cannot loosen it. Aphex's built-in dangerous-content
checks always apply, including when an explicit list is configured.
To add formats while retaining the defaults, spread the exported list:
```ts
import { createCMSConfig, DEFAULT_ALLOWED_MIME_TYPES } from '@aphexcms/cms-core/server';
export default createCMSConfig({
upload: {
allowedMimeTypes: [...DEFAULT_ALLOWED_MIME_TYPES, 'application/x-your-custom-format']
}
});
```
To replace the defaults, provide only the formats the installation should accept:
```ts
upload: {
allowedMimeTypes: ['application/pdf', 'text/csv'];
}
```
### direct
`direct: true` additionally requires **CORS `PUT` on your bucket from your site's origin**, which
nothing in Aphex can detect. Without it every upload fails in the browser. It's off by default for
that reason, rather than inferred from "the adapter can sign". The grant is checked against the
declared MIME type, then confirmation reads a bounded prefix back from storage and magic-byte
checks the actual content before exposing the asset. Uploads land at a temporary key; confirmation
claims a non-servable row once, promotes and inspects the object, then exposes its final path. The
still-live signed URL and confirmation ticket therefore cannot overwrite an approved asset. A
storage adapter must support server-side copies; without ranged reads it must buffer smaller files
and rejects large direct uploads it cannot inspect safely.
It's also ignored — with a silent fallback to uploading through the app — when the storage adapter
can't sign uploads or `security.secretEncryptionKey` is unset. Uploading through the app works
everywhere, so the fallback is always safe.
## email
An `EmailAdapter` instance for sending transactional emails (password resets, invitations, email verification). If not provided, email features are disabled.
```ts
import { email } from '$lib/server/email/index.js';
createCMSConfig({
email
// ...
});
```
Available adapters:
* **`@aphexcms/nodemailer-adapter`** — SMTP via Nodemailer. Includes a `createMailpitAdapter()` shorthand for local development.
* **`@aphexcms/resend-adapter`** — [Resend](https://resend.com) API for production.
In development, `createMailpitAdapter()` sends all emails to [Mailpit](http://localhost:8025) on `localhost:1025`. The base template wires this up automatically — see [Getting Started](/getting-started#email-in-development-vs-production).
## cache
An optional `CacheAdapter` that caches reads with `perspective: 'published'`. Entries are invalidated automatically when a document is published, unpublished, or deleted. Draft reads bypass the cache so the admin UI always sees the latest data.
```ts
import { InMemoryCacheAdapter } from '@aphexcms/cms-core/server';
createCMSConfig({
cache: new InMemoryCacheAdapter({ maxSize: 5000 })
});
```
The base template exports a shared `cacheAdapter` from `src/lib/server/cache/index.ts` and passes the same instance to both the CMS config and Better Auth (for API-key lookup caching).
| Value | Behavior |
| ---------------------- | --------------------------------------------------------------- |
| `undefined` or `null` | No caching (default). |
| `InMemoryCacheAdapter` | LRU in-memory cache, shipped with `@aphexcms/cms-core/server`. |
| Custom `CacheAdapter` | Implement the `CacheAdapter` interface for Redis, Upstash, etc. |
## auth
Authentication configuration. If not provided, the CMS has no access control.
```ts
createCMSConfig({
auth: {
provider: authProvider,
loginUrl: '/login'
}
// ...
});
```
| Option | Type | Required | Default | Description |
| --------------- | -------------- | -------- | ---------- | --------------------------------------- |
| `auth.provider` | `AuthProvider` | Yes | — | Authentication provider instance. |
| `auth.loginUrl` | `string` | No | `'/login'` | Redirect URL for unauthenticated users. |
The `AuthProvider` interface handles:
* **Session auth** — browser sessions for the admin UI.
* **API key auth** — programmatic access via `x-api-key` header.
* **User management** — fetching users, changing names.
* **Password reset** — request and confirm password resets.
The built-in integration uses [Better Auth](https://www.better-auth.com/) with organization support. See the scaffolded `src/lib/server/auth/` directory for the full implementation.
## graphql
Controls the built-in GraphQL API. Can be `true` (defaults), `false` (disabled), or a config object.
```ts
// Enabled with defaults
createCMSConfig({ graphql: true });
// Disabled
createCMSConfig({ graphql: false });
// Custom options
createCMSConfig({
graphql: {
defaultPerspective: 'published',
path: '/api/graphql',
enableGraphiQL: true,
defaultQuery: '{ allPost { id title } }'
}
});
```
| Option | Type | Default | Description |
| -------------------- | ------------------------ | ---------------- | ---------------------------------------------------- |
| `defaultPerspective` | `'draft' \| 'published'` | `'published'` | Default perspective when not specified in a query. |
| `path` | `string` | `'/api/graphql'` | GraphQL endpoint path. |
| `enableGraphiQL` | `boolean` | `true` | Enable the interactive GraphiQL IDE at the endpoint. |
| `defaultQuery` | `string` | Built-in example | Default query shown in GraphiQL. |
See [GraphQL API](/graphql) for the full reference.
## versioning
Controls document version history. Each draft save and publish creates an entry in the `cms_document_versions` table. See [Version History](/version-history) for the full reference.
```ts
createCMSConfig({
versioning: {
maxVersions: 25 // default — set 0 to disable rolling cleanup
}
});
```
## api
The escape hatch for registering custom HTTP routes and middleware. Aphex's HTTP API runs on [Hono](https://hono.dev) — the function you pass receives the same `Hono` app the built-in routes mount onto, **before** they mount.
```ts title="aphex.config.ts"
createCMSConfig({
api: (app) => {
// Add a brand-new endpoint
app.post('/send-email', async (c) => {
const { aphexCMS, auth } = c.var;
// ...
return c.json({ success: true });
});
// Wrap a built-in route with side effects (registration order
// matters: register before built-ins to intercept them)
app.use('/organizations/invitations', async (c, next) => {
await next();
if (c.res.status === 201) sendInviteEmail(/* ... */);
});
}
});
```
Hono is registration-order-strict and first-match-wins. Registering before built-ins lets you wrap
them with `app.use()` or override them outright with `app.METHOD(path, handler)`.
`c.var` exposes the same context the built-in routes use:
| Var | Type | Description |
| ---------------- | -------------- | ----------------------------------- |
| `c.var.aphexCMS` | `CMSInstances` | Local API, services, adapters. |
| `c.var.auth` | `Auth \| null` | Resolved auth — session or API key. |
## customization
Branding and theme options for the admin UI.
```ts
createCMSConfig({
customization: {
branding: {
title: 'My CMS',
logo: '/images/logo.png',
favicon: '/favicon.ico'
},
theme: {
colors: { primary: '#3b82f6' },
fonts: { sans: 'Inter, sans-serif' }
}
}
});
```
### Branding
| Option | Type | Default | Description |
| --------- | -------- | ------------- | ------------------------------ |
| `title` | `string` | `'Aphex CMS'` | Display title in the admin UI. |
| `logo` | `string` | — | URL to a logo image. |
| `favicon` | `string` | — | URL to a favicon. |
### Theme
| Option | Type | Default | Description |
| -------- | ------------------------ | ------- | ------------------------------------------------------------ |
| `colors` | `Record` | — | Custom color values (e.g. `{ primary: '#3b82f6' }`). |
| `fonts` | `Record` | — | Custom font families (e.g. `{ sans: 'Inter, sans-serif' }`). |
## security
Security options for asset access control.
```ts
createCMSConfig({
security: {
assetSigningSecret: 'a-long-random-string-32-chars-minimum'
}
});
```
| Option | Type | Default | Description |
| -------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `assetSigningSecret` | `string` | — | Secret key for HMAC-signed asset URLs. Lets a private asset be served to a viewer with no admin session — one asset, for a bounded window. Should be 32+ characters. |
Mint links with `signAssetUrl` from `@aphexcms/cms-core/server`; see
[Private assets](/docs/storage#private-assets) for the full flow. Without this secret, signing is a
no-op and verification always fails, so private assets stay reachable only with a session.
## aiProvider
Turns on the in-admin agent — a chat panel that can read and edit content through the same [agent tools](/docs/plugins#give-an-ai-agent-a-tool) exposed over MCP. Off unless you configure it.
```ts title="aphex.config.ts"
import { createOpenAIAdapter } from '@aphexcms/ai-openai';
createCMSConfig({
aiProvider: env.OPENAI_API_KEY ? createOpenAIAdapter({ apiKey: env.OPENAI_API_KEY }) : null,
agentModel: 'gpt-4.1'
});
```
| Option | Type | Default | Description |
| ------------------- | --------------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `aiProvider` | `AIProviderAdapter \| null` | `null` | Model backend. When unset, `POST /api/agent/chat` returns `404` and the admin doesn't show the panel. |
| `agentModel` | `string` | — | Provider-specific model id. **Required** when `aiProvider` is set — the route `501`s without it. A request may override it per call. |
| `agentSystemPrompt` | `string` | built-in | Injected fresh ahead of every turn, so edits apply to open conversations immediately. Replaces the default, which only carries behavioral guardrails (draft-first, confirm before broad changes) — schema and tool knowledge is self-describing via the `describe_cms` tool, so there's no need to restate it. Useful for giving a client's deployment its own tone or house rules. |
`AIProviderAdapter` is a port, same as database and storage. `@aphexcms/ai-openai` implements it for OpenAI and any OpenAI-compatible endpoint (OpenRouter, a local router); you can write your own for anything else.
### Pointing at a different endpoint
`createOpenAIAdapter` takes an optional `baseURL`. Leave it out and requests go to `https://api.openai.com/v1` — the OpenAI SDK's own default, which also means the standard `OPENAI_BASE_URL` environment variable keeps working. Set it to talk to anything OpenAI-compatible:
```ts title="aphex.config.ts"
createOpenAIAdapter({
apiKey: env.OPENAI_API_KEY ?? 'local', // some local servers ignore the key
...(env.OPENAI_API_URL ? { baseURL: env.OPENAI_API_URL } : {})
});
```
Spreading `baseURL` conditionally rather than passing `undefined` isn't required — the SDK treats both the same — but it keeps the intent obvious. `createOpenRouterAdapter` is a thin wrapper that just presets this value.
Every agent write goes through the same validation, permission checks, and [compare-and-swap
guard](/docs/local-api#concurrency--expectedrevision) as a human edit — an agent can't write
shapes the admin UI couldn't, and can't silently clobber a document someone else is editing.
Mutating tool calls are recorded per turn and can be undone from the Activity view.
## How config is consumed
The `createCMSHook()` function in `hooks.server.ts` takes your config and creates singleton instances of all adapters:
```ts title="src/hooks.server.ts"
import { createCMSHook } from '@aphexcms/cms-core/server';
import config from '../aphex.config.ts';
const aphexHook = createCMSHook(config);
export const handle = sequence(authHook, aphexHook);
```
On the first request, the hook:
1. Creates the storage adapter (or uses the default local filesystem).
2. Initializes the `AssetService` with the storage adapter.
3. Creates the `CMSEngine` and registers schemas in the database.
4. Creates the `LocalAPI` (unified data layer).
5. Calls your `api(app)` hook (if provided), then mounts the built-in Hono routes.
6. Initializes GraphQL if enabled.
On every subsequent request, the hook injects these singletons into `event.locals.aphexCMS`, making them available in all route handlers and load functions.
### What's available on `event.locals.aphexCMS`
```ts
event.locals.aphexCMS.localAPI; // LocalAPI instance
event.locals.aphexCMS.databaseAdapter; // DatabaseAdapter
event.locals.aphexCMS.assetService; // AssetService
event.locals.aphexCMS.storageAdapter; // StorageAdapter
event.locals.aphexCMS.emailAdapter; // EmailAdapter (or null)
event.locals.aphexCMS.cmsEngine; // CMSEngine
event.locals.aphexCMS.rolesService; // RolesService
event.locals.aphexCMS.auth; // AuthProvider (or undefined)
event.locals.aphexCMS.config; // Your resolved CMSConfig (includes `cache`, `versioning`, etc.)
event.locals.aphexCMS.graphqlSettings; // GraphQL endpoint info (or null)
```
# Contributing (/contributing)
The canonical contributor guide is
[`CONTRIBUTING.md`](https://github.com/IcelandicIcecream/aphex/blob/main/CONTRIBUTING.md) in the
repo. This page mirrors the highlights so you can browse them alongside the rest of the docs.
Aphex is open source and contributions are welcome — bug fixes, new field types, database or storage adapters, UI improvements, and docs all help. Browse the [open issues](https://github.com/IcelandicIcecream/aphex/issues) for ideas.
## Prerequisites
* Node.js 20+
* pnpm 10+ (`npm install -g pnpm`)
* Docker + Docker Compose
* Git with SSH keys configured
## Repository layout
```text
aphex/
├── apps/
│ └── studio/ # Reference SvelteKit app — features land here first
├── packages/
│ ├── cms-core/ # Core engine + admin UI (adapter-agnostic via ports)
│ ├── postgresql-adapter/
│ ├── storage-s3/
│ ├── nodemailer-adapter/
│ ├── resend-adapter/
│ ├── ui/ # Shared shadcn-svelte components
│ ├── create-aphex/ # `pnpm create aphex` scaffolder
│ └── cli/ # `aphx` thin wrapper around create-aphex
├── templates/
│ └── base/ # Starter template (mirrored to aphex-base)
├── docs/
│ └── aphex-docs/ # This site (mirrored to aphex-docs)
└── .github/workflows/ # release.yml, sync-template.yml, sync-docs.yml
```
## Local setup
```bash
git clone git@github.com:IcelandicIcecream/aphex.git
cd aphex
pnpm install
cp apps/studio/.env.example apps/studio/.env
pnpm db:start # Postgres + Mailpit via Docker
pnpm db:push # apply schema (dev only)
pnpm dev # studio + cms-core via Turborepo
```
Admin UI lands at `http://localhost:5173/admin`. The first user to sign up at `/login` becomes super admin and gets a default org.
## Hot reload behavior
| Change | Behavior |
| --------------------------- | ------------------------------------------------------------------- |
| Schema files | Picked up on the next request. |
| Component changes | Instant via Vite HMR. |
| `cms-core` source | Live — consumed from source via the workspace protocol. |
| `postgresql-adapter` source | **Requires a rebuild + dev server restart** (consumed from `dist`). |
| `storage-s3` source | **Requires a rebuild + dev server restart** (consumed from `dist`). |
| Drizzle schema changes | `pnpm db:push` (dev) or generate + migrate cycle. |
## Common commands
```bash
pnpm dev # studio + cms-core
pnpm build # build everything (Turborepo)
pnpm check # type-check all packages
pnpm lint # Prettier + ESLint
pnpm format # write Prettier formatting
pnpm test:package # build + type-check cms-core
pnpm shadcn # add shadcn-svelte components to @aphexcms/ui
```
Tests live in `apps/studio/tests/`:
```bash
pnpm -F @aphexcms/studio test # all
pnpm -F @aphexcms/studio test:local # Local API only
pnpm -F @aphexcms/studio test:http # HTTP API only
pnpm -F @aphexcms/studio test:graphql # GraphQL only
```
## Commits & PRs
Conventional Commits — `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`, `test:`. One feature or fix per PR; aim for under 500 lines of diff.
If your PR touches a published package, **add a changeset**:
```bash
pnpm changeset
```
Pick the affected packages, the bump type (patch / minor / major), and write a one-liner. Commit the generated `.changeset/*.md` file with the rest of your PR. Skip the changeset for docs-only or studio-only changes.
## Releases & publishing
Releases run through [Changesets](https://github.com/changesets/changesets) and `.github/workflows/release.yml`. The flow on every push to `main`:
**Pending changesets exist** → workflow opens (or updates) a `chore: version packages` PR that
bumps versions in every affected `package.json` and regenerates each package's `CHANGELOG.md`.
**Version PR merges** → workflow runs `pnpm release` which is `turbo build --filter='./packages/*'
&& changeset publish --provenance`. Packages ship to npm with a signed [SLSA
provenance](https://slsa.dev) statement.
**Tags and GitHub Releases**
are created automatically by
`changesets/action`
.
Published packages: `@aphexcms/cms-core`, `@aphexcms/postgresql-adapter`, `@aphexcms/storage-s3`, `@aphexcms/nodemailer-adapter`, `@aphexcms/resend-adapter`, `@aphexcms/ui`, `create-aphex`, and `@aphexcms/cli` (the `aphx` bin). The `studio`, `base`, and `aphex-docs` packages are explicitly **ignored** in `.changeset/config.json` — they ship via mirror repos instead of npm.
Required repo secrets:
| Secret | Purpose |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `GITHUB_TOKEN` | Auto-provided. Used to open the version PR. |
| `NPM_TOKEN` | Required unless [npm trusted publishing](https://docs.npmjs.com/trusted-publishers) is configured for the `@aphexcms` scope. |
## Studio → template sync
`apps/studio` is the working reference. `templates/base/` is the starter shipped to end users via `pnpm create aphex` (or `npm create aphex@latest`). To flow studio changes into the template:
```bash
./scripts/sync-template.sh # dry run — preview changes
./scripts/sync-template.sh --apply # actually copy files
```
The script is **template-driven**: it walks every file tracked in `templates/base/` and copies the matching file from `apps/studio/` if it exists. Studio-only files (tests, seed routes) don't get copied because the template has no matching path. Three special cases:
* `src/lib/schemaTypes/**` — skipped. Template keeps its `post.ts` example, not studio's fixtures.
* `src/app.css` — skipped. Template uses `node_modules/@aphexcms/*/dist` paths for Tailwind `@source`.
* `package.json` — merged. Studio content wins; template's `name` and `version` are preserved.
If studio adds a brand-new file or directory, create a placeholder in `templates/base/` first so the next sync picks it up.
After applying, update `templates/base/CHANGELOG.md` under `## Unreleased` so downstream users know what changed — the template is meant to be customized, so changes don't auto-apply to anyone's existing project.
To refresh the scaffolder so `pnpm create aphex` ships the new template:
```bash
pnpm -F create-aphex build
```
This runs `scripts/copy-templates.js` which copies `templates/` into `packages/create-aphex/templates/` and rewrites `workspace:*` deps to concrete versions, then `tsc` builds the CLI bundle. The bundled `templates/` is what npm publishes — the root `templates/` is for the monorepo's working state and the standalone mirror.
```bash
node packages/create-aphex/dist/index.js my-test-app # smoke-test
```
## Mirror workflows
Two GitHub Actions push subdirectories of the monorepo to standalone public repos:
| Workflow | What | Destination | Secret |
| ------------------------------------- | ----------------------------------------------- | ------------------------------ | -------------------------- |
| `.github/workflows/sync-template.yml` | `templates/base/` (with `workspace:*` resolved) | `IcelandicIcecream/aphex-base` | `TEMPLATE_REPO_DEPLOY_KEY` |
| `.github/workflows/sync-docs.yml` | `docs/aphex-docs/` | `IcelandicIcecream/aphex-docs` | `DOCS_REPO_DEPLOY_KEY` |
Both run on pushes to `main` that touch the relevant directory and use [`s0/git-publish-subdir-action`](https://github.com/s0/git-publish-subdir-action) under the hood.
### Setting up a deploy key
```bash
ssh-keygen -t ed25519 -f my_repo_deploy -N ""
```
**Public key (`my_repo_deploy.pub`)** → destination repo → Settings → Deploy keys → Add deploy
key. **Tick "Allow write access".**
**Private key (full file content, including the `-----BEGIN`/`-----END` markers and trailing
newline)** → source repo → Settings → Secrets and variables → Actions → New repository secret.
Name it to match the workflow (`TEMPLATE_REPO_DEPLOY_KEY` or `DOCS_REPO_DEPLOY_KEY`).
Push a change touching the relevant directory and watch the Actions tab — first run prints the SSH
handshake.
## Adding new things
| What you're adding | Where it goes |
| ------------------------------- | -------------------------------------------------------------------------------------- |
| New field type | `packages/cms-core/src/lib/types/schemas.ts` + an editor in `components/admin/fields/` |
| New database adapter | New package implementing `DatabaseAdapter` and its sub-interfaces |
| New storage adapter | New package implementing `StorageAdapter` |
| New email adapter | New package implementing `EmailAdapter` |
| Custom HTTP route or middleware | `api(app)` hook in `aphex.config.ts` (no plugin system) |
See [`ARCHITECTURE.md`](https://github.com/IcelandicIcecream/aphex/blob/main/ARCHITECTURE.md) for the deep dive on adapter interfaces and the request lifecycle.
## See also
# Database (/database)
Aphex ships with two database adapters built on [Drizzle ORM](https://orm.drizzle.team/), both running the same `DatabaseAdapter` contract and the same conformance suite — neither is a reduced tier:
* **SQLite via libsql — the default.** Both templates run on it out of the box: a plain local `file:` database, **no Docker, no server, no migration step** (the schema is pushed on boot). The fastest way to get running, and the reason we default to it. Scales up to a Turso-hosted `libsql://` URL without code changes.
* **PostgreSQL — for production scale.** One env var away (`APHEX_DATABASE=postgres`), with Row-Level Security and connection pooling. See [Switching to Postgres](#switching-to-postgres).
Most projects never touch this page — `pnpm dev` just works. The interface details at the bottom are only relevant if you're writing a custom adapter for another backend.
## Quick setup
There's nothing to set up. The template defaults to SQLite, so:
```bash
pnpm dev
```
A local `file:` database is created (e.g. `.aphex/base.db`) and the schema is pushed on boot. To persist somewhere else or use [Turso](https://turso.tech), set `APHEX_SQLITE_URL`:
```bash title=".env"
# Local file (default if unset)
APHEX_SQLITE_URL=file:.aphex/base.db
# Turso (hosted libsql) — no code change
APHEX_SQLITE_URL=libsql://mydb-me.turso.io
DATABASE_AUTH_TOKEN=your-turso-token
```
SQLite has no Row-Level Security — organization isolation comes from the explicit `organizationId`
WHERE clauses every query applies (the same mechanism that isolates tenants on pooled Postgres).
On Postgres, the CMS hook additionally calls `initializeRLS()` on first request to set up RLS
policies on `cms_documents` and `cms_assets` — no manual step either way.
## Switching to Postgres
The Postgres adapter is wired into both templates — switching is one env var, no code changes:
### Point at a Postgres database
```bash title=".env"
APHEX_DATABASE=postgres
DATABASE_URL=postgres://root:my-secret-password@localhost:5432/local
```
You can also split the URL into discrete vars (`PG_HOST`, `PG_PORT`, `PG_USER`, `PG_PASSWORD`, `PG_DATABASE`) — `pgConnectionUrl(env)` reads either form. No local Postgres? The template ships a `docker-compose.yml` — `pnpm db:start` boots Postgres 18 with the env-driven credentials.
### Apply the migrations
```bash
pnpm db:migrate # applies the SQL migrations in ./drizzle/
```
Unlike SQLite (which pushes on boot), the Postgres path uses generated migration files. Boot-migrate is on by default for dev; in production, run this as a separate deploy step (`APHEX_DB_AUTO_MIGRATE=false`).
## What the template wires up
`src/lib/server/db/index.ts` is the only file you usually touch — it picks an adapter from `APHEX_DATABASE` and builds a singleton `DatabaseAdapter`. Each adapter encapsulates its own client, Drizzle instance, schema, and dialect, so switching databases is a single env change:
```ts title="src/lib/server/db/index.ts (simplified)"
import { env } from '$env/dynamic/private';
import { postgresAdapter } from './adapters/postgres';
import { sqliteAdapter } from './adapters/sqlite';
const driver = env.APHEX_DATABASE?.toLowerCase();
// Default: SQLite (zero-infra). Opt into Postgres with APHEX_DATABASE=postgres.
const database =
driver === 'postgres'
? await postgresAdapter({
connectionString: pgConnectionUrl(env),
multiTenancy: { enableRLS: true }
})
: await sqliteAdapter({ url: env.APHEX_SQLITE_URL || 'file:.aphex/base.db' });
export const { client, drizzleDb, db, dbDialect } = database;
```
A single-database app can collapse this to one line — e.g. `const db = createSQLiteProvider({ url }).createAdapter();`. Both adapters merge **CMS tables** (documents, assets, schemas, organizations) with **auth tables** (Better Auth users, sessions, accounts), split by dialect (`schema.ts` for Postgres, `@aphexcms/sqlite-adapter/schema` for SQLite):
### Connection pooling
The default pool is 10 connections. Tune it for your deployment:
```ts
export const client = postgres(pgConnectionUrl(env), {
max: 10, // serverless: keep small (1–3); long-lived: 10–50
idle_timeout: 20
});
```
## SQLite (libsql)
`@aphexcms/sqlite-adapter` runs the same `DatabaseAdapter` contract on SQLite through [libsql](https://github.com/tursodatabase/libsql): a plain local `file:` database (no Docker, no server) or a Turso-hosted `libsql://` URL. It is a first-class alternative to the PostgreSQL adapter, not a reduced tier — the two run the same conformance suite. Both templates use it by default.
```ts title="src/lib/server/db/index.ts"
import { createSQLiteProvider } from '@aphexcms/sqlite-adapter';
// Simplest: the adapter creates the client and tunes it for you
const db = createSQLiteProvider({ url: 'file:.aphex/blog.db' }).createAdapter();
// Turso-hosted
const db = createSQLiteProvider({
url: 'libsql://mydb-me.turso.io',
authToken: env.DATABASE_AUTH_TOKEN
}).createAdapter();
```
If you need the raw libsql client yourself (the templates share it with Better Auth and Drizzle), create it and pass it in — the adapter never modifies a client it didn't create:
```ts
import { createClient } from '@libsql/client';
import { createSQLiteProvider, applyRecommendedPragmas } from '@aphexcms/sqlite-adapter';
const client = createClient({ url });
await applyRecommendedPragmas(client, url); // see Pragmas below
const db = createSQLiteProvider({ client }).createAdapter();
```
SQLite has no Row-Level Security; organization isolation comes from the explicit `organizationId` WHERE clauses every query applies — the same mechanism that actually isolates tenants on pooled Postgres. `multiTenancy.enableHierarchy` works as on Postgres; there is no `enableRLS` option.
### Pragmas
When the adapter creates the client from `url`, it applies a recommended pragma set to local `file:` databases: `journal_mode=WAL` (reads proceed while a write is in flight), `synchronous=NORMAL` (the safe WAL pairing), and `busy_timeout=5000` (waits instead of throwing `SQLITE_BUSY` under concurrent requests). In-memory and Turso URLs are skipped — Turso manages its own journaling.
The `pragmas` option controls this:
```ts
// Default — the recommended set
createSQLiteProvider({ url });
// Opt out entirely and manage pragmas yourself
createSQLiteProvider({ url, pragmas: false });
// Recommended set plus extra tuning knobs (see SQLitePragmaOptions for the full list)
createSQLiteProvider({
url,
pragmas: {
cacheSize: -65536, // 64 MiB page cache (negative = KiB); SQLite default ~2 MiB
mmapSize: 268435456, // memory-mapped I/O for faster reads
tempStore: 'MEMORY', // temp tables/indices in RAM
busyTimeout: 10000 // overrides the default 5000
}
});
// Raw statements, run verbatim instead of the recommended set (regardless of url)
createSQLiteProvider({ url, pragmas: 'PRAGMA busy_timeout=10000;' });
```
On the `client` path, call the exported `applyRecommendedPragmas(client, url, options?)` instead — it takes the same options object and skips non-local-file URLs automatically.
Except `journal_mode=WAL` (persisted in the database file), pragmas are per-connection settings
applied to the client's main connection. libsql opens a fresh connection per interactive
transaction, which gets SQLite defaults — safer, slightly slower. Avoid `locking_mode=EXCLUSIVE`
(breaks the per-transaction connections) and `query_only=ON` (the CMS writes) from read-optimized
tuning guides.
### Migrations
`drizzle.config.ts` uses `dialect: 'sqlite'`, and the CMS tables are re-exported from `@aphexcms/sqlite-adapter/schema` instead of the Postgres adapter. The `aphex migrate` CLI detects SQLite automatically from `APHEX_DATABASE=sqlite` or a `file:`/`libsql:` `DATABASE_URL`. The blog template also auto-migrates on boot — a blog deploy is single-instance, so there's no concurrent-migration race.
### Behavioral parity
Both adapters run the same conformance suite (`packages/sqlite-adapter/tests/`), so filters, sorting, versioning, references, and org isolation behave identically — with one caveat: `contains` filters use SQLite's `LIKE`, which is case-insensitive for ASCII only (Postgres `ILIKE` handles full Unicode).
## Multi-tenancy
**Aphex is multi-tenant by nature.** Every document, asset, and CMS row is stored against an `organizationId`, and every query is scoped to it — so one Aphex instance can serve many isolated tenants (agencies with per-client spaces, SaaS products with per-account content) with no extra modeling.
**The starter templates are wired for a single tenant.** First signup creates one organization and
everything lives under it — the common case, and the simplest to reason about. The org-scoping is
still there underneath (it's how isolation works at all), so growing into true multi-tenancy is an
application-layer concern — creating additional organizations and routing users to them — not a
schema migration.
Two layers enforce isolation:
### Row-Level Security (RLS)
Before each query the adapter runs:
```sql
SET LOCAL app.organization_id = '';
```
The RLS policy then filters rows automatically:
| Operation | Behaviour |
| ------------------------------ | ---------------------------------------------------------------------- |
| `SELECT` | returns rows from the current org **and** any child organizations |
| `INSERT` / `UPDATE` / `DELETE` | only allowed against the current org (parents can't write to children) |
That asymmetry — read down the tree, write only your own — is what enables the parent / child publishing workflows.
### System operations
Background jobs, migrations, and seed scripts use `systemContext()`, which sets `app.override_access = true` and bypasses RLS:
```ts
import { systemContext } from '@aphexcms/cms-core/server';
const docs = await localAPI.collections.post.find(
systemContext('org-id'), // overrideAccess: true
{ perspective: 'published' }
);
```
### Single-tenant deployments
If you don't need multi-tenancy, disable both flags. RLS still adds about 0.5–1 ms per query — measurable at scale.
```ts
const provider = createPostgreSQLProvider({
client,
multiTenancy: {
enableRLS: false,
enableHierarchy: false
}
});
```
## Document lifecycle
Documents follow a draft / published model with hash-based change detection:
**Create**
— always starts as
`status: 'draft'`
with
`draftData`
only.
**Auto-save** — the admin UI saves draft changes every 2 seconds via `updateDocDraft()`.
**Publish** — copies `draftData` to `publishedData`, generates a `publishedHash`, sets
`publishedAt`. A new entry is written to `cms_document_versions`.
**Unpublish** — clears `publishedData` and `publishedHash`, reverts to `status: 'draft'`.
**Delete**
— permanently removes the document and its version history.
`publishedHash` is a 20-character base36 hash with sorted object keys, so the admin UI can reliably show "you have unpublished changes" without diffing JSON.
## Migrations workflow
Migrations only matter on the **Postgres** path — SQLite pushes the schema on boot, so there's no generate/migrate step. `drizzle.config.ts` switches dialect on `APHEX_DATABASE`:
```ts title="drizzle.config.ts"
import { defineConfig } from 'drizzle-kit';
const driver = process.env.APHEX_DATABASE?.toLowerCase();
export default defineConfig(
driver === 'sqlite' || !driver
? {
schema: './src/lib/server/db/schema.sqlite.ts',
dialect: 'sqlite',
dbCredentials: { url: process.env.APHEX_SQLITE_URL || 'file:.aphex/base.db' }
}
: {
schema: './src/lib/server/db/schema.ts',
dialect: 'postgresql',
dbCredentials: { url: databaseUrl }
}
);
```
| Command | When to use |
| ------------------ | ----------------------------------------------------------------- |
| `pnpm db:generate` | After editing your Drizzle schema. Produces a SQL migration file. |
| `pnpm db:push` | **Dev only.** Pushes schema directly without writing a migration. |
| `pnpm db:migrate` | **Production.** Applies pending migrations. |
| `pnpm db:studio` | Opens Drizzle Studio at `localhost:4983` for inspecting tables. |
For production, always go through `db:generate` → review the SQL → `db:migrate`. `db:push` will happily drop a column without warning.
## Advanced filtering
The adapter translates the `where` syntax in the Local API directly into JSONB operators:
```ts
const posts = await localAPI.collections.post.find(context, {
where: {
title: { contains: 'tutorial' },
status: { equals: 'published' }
},
sort: ['-publishedAt'],
limit: 10,
depth: 1 // resolve one level of references
});
```
| Filter | SQL it produces |
| ---------------- | -------------------- |
| `equals` | `=` |
| `contains` | `ILIKE '%value%'` |
| `in` | `= ANY(...)` |
| `greater_than` | `>` (numeric / date) |
| `'a.b': { ... }` | `data->'a'->>'b'` |
References are resolved recursively up to a configurable `depth` (0–5). At `depth: 0`, references come back as `{ _ref: 'doc-id' }`. At higher depths the referenced document is inlined. Circular references are detected and skipped.
## Table reference
You'll only need this when writing custom adapters or doing direct SQL.
### `cms_documents`
| Column | Type | Description |
| ---------------- | -------------- | ------------------------------------- |
| `id` | `uuid` | Primary key |
| `organizationId` | `uuid` | Foreign key to `cms_organizations` |
| `type` | `varchar(100)` | Schema name (`post`, `page`, …) |
| `status` | `enum` | `'draft'` or `'published'` |
| `draftData` | `jsonb` | Current working version |
| `publishedData` | `jsonb` | Live version (`null` until published) |
| `publishedHash` | `varchar(20)` | Content hash for change detection |
| `createdBy` | `text` | User ID |
| `updatedBy` | `text` | User ID |
| `publishedAt` | `timestamp` | When last published |
| `createdAt` | `timestamp` | Creation time |
| `updatedAt` | `timestamp` | Last modification |
### `cms_assets`
| Column | Type | Description |
| ------------------------------------------- | -------------- | ----------------------------------- |
| `id` | `uuid` | Primary key |
| `organizationId` | `uuid` | FK to organizations |
| `assetType` | `varchar(20)` | `'image'` or `'file'` |
| `filename` | `varchar(255)` | Generated filename |
| `originalFilename` | `varchar(255)` | Original upload name |
| `mimeType` | `varchar(100)` | MIME type |
| `size` | `integer` | Bytes |
| `url` | `text` | Public URL |
| `path` | `text` | Internal storage path |
| `storageAdapter` | `varchar(50)` | Adapter that stored the file |
| `width`, `height` | `integer` | Image dimensions (`null` for files) |
| `metadata` | `jsonb` | Image metadata (format, color, …) |
| `title`, `description`, `alt`, `creditLine` | `text` | Editor-supplied metadata |
### Other tables
* **`cms_document_versions`** — version history (one row per draft save / publish). See [Version History](/version-history).
* **`cms_schema_types`** — registered document and object type definitions (fields stored as JSONB).
* **`cms_organizations`** — organizations with `parentOrganizationId` for hierarchy.
* **`cms_organization_members`** — user-to-organization membership with role.
* **`cms_organization_roles`** — built-in and custom roles per org with capability arrays.
* **`cms_invitations`** — pending org invitations with token and expiry.
* **`cms_user_sessions`** — tracks each user's active organization.
* **`cms_user_profiles`** — CMS-specific user data (instance role, preferences).
* **`cms_instance_settings`** — single-row instance configuration.
## Custom adapters
Implement `DatabaseAdapter` (a composite of specialized interfaces) and pass the result to `createCMSConfig({ database })`:
```ts
interface DatabaseAdapter
extends
DocumentAdapter,
AssetAdapter,
UserProfileAdapter,
SchemaAdapter,
OrganizationAdapter,
InstanceAdapter {
connect?(): Promise;
disconnect?(): Promise;
isHealthy(): Promise;
// Multi-tenancy (optional)
initializeRLS?(): Promise;
hierarchyEnabled: boolean;
withOrgContext?(organizationId: string, fn: () => Promise): Promise;
getChildOrganizations(parentOrganizationId: string): Promise;
// First-user detection
hasAnyUserProfiles?(): Promise;
}
```
Each sub-interface is a focused contract:
| Interface | Responsibility |
| --------------------- | -------------------------------------------------------------------------------------- |
| `DocumentAdapter` | CRUD for documents, publishing, advanced queries with filtering / sorting / pagination |
| `AssetAdapter` | CRUD for assets, advanced filtering, reference counting |
| `UserProfileAdapter` | CMS user profiles (instance role, preferences) |
| `SchemaAdapter` | Schema type registration and retrieval |
| `OrganizationAdapter` | Organizations, members, invitations, user sessions |
| `InstanceAdapter` | Instance-level settings |
The `@aphexcms/postgresql-adapter` source is the reference implementation — clone it as a starting point.
## See also
# Deployment (/deployment)
Aphex is a SvelteKit app (adapter-node), so it deploys like any Node web service. The base template ships two ready-to-go deploy paths:
* **`Dockerfile`** — single-package multi-stage build. Drop into Render, Fly, Railway, k8s, plain `docker run`.
* **`Procfile`** (`web: node build`) — for buildpack platforms like canine.sh, Heroku, or any host that detects `package.json` and runs the Procfile.
Both paths produce the same `build/index.js` from `@sveltejs/adapter-node`. Pick whichever fits your host.
**Build no longer requires `.env`.** Earlier template versions crashed during SvelteKit's analyze
pass if `DATABASE_URL` / `AUTH_SECRET` / `RESEND_API_KEY` weren't set at build time. The current
template guards every server-module init with `building` from `$app/environment` and falls back to
placeholders, so `pnpm build` succeeds with zero env. Real values are required at runtime.
Other platforms (Vercel, Cloudflare Pages) may work but aren't tested in production by the maintainers — adapter-node is portable but Sharp + Postgres driver want Node. If you ship on one, please open a PR with notes.
## Production checklist
Before your first deploy:
**Generate a strong `BETTER_AUTH_SECRET`.** 32+ random bytes. Rotating it logs everyone out and invalidates every API key, so generate once and keep it stable.
```bash
openssl rand -base64 48
```
**Provision Postgres.** A managed instance (Neon, Supabase, Render Postgres, RDS) or your own
container. Capture `DATABASE_URL`. Default pool is 10 — fine for a VPS, drop to 1–3 for
serverless.
**Decide on storage.** Local filesystem works for single-node Docker deploys (mount a volume). For
anything else, provision R2 / S3 — capture all four `R2_*` vars.
**Pick an email provider.** Resend in prod (`RESEND_API_KEY`). Optional in dev. Required for
password reset, email verification, and invitations.
**Run migrations.** `pnpm db:generate` locally → review the SQL → commit → apply with `pnpm
migrate` (runs `aphex migrate`, runtime-safe). Single-instance / self-host: the template's Docker
image applies them on start automatically. Multi-instance: run `pnpm migrate` as a pre-deploy
release step instead of at boot, so concurrent instances don't race.
**Sign up the first user immediately.** The first sign-up gets `super_admin`. Do this as soon as
the app is live, before anyone else can hit `/login`.
## Environment variables
```bash title=".env.production"
# --- Database ----------------------------------------------
DATABASE_URL=postgres://user:pass@host:5432/dbname?sslmode=require
# Or split: PG_HOST, PG_PORT, PG_USER, PG_PASSWORD, PG_DATABASE
# --- Auth --------------------------------------------------
BETTER_AUTH_SECRET=<48+ bytes of randomness — never commit>
BETTER_AUTH_URL=https://cms.your-app.com # public origin of the SvelteKit app
AUTH_TRUSTED_ORIGINS=https://cms.your-app.com,https://your-app.com
# AUTH_SECRET / AUTH_URL also work for backwards compat (and the
# bundled Dockerfile uses those names).
# --- Email (Resend in prod, Mailpit in dev) ----------------
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
RESEND_FROM=no-reply@your-app.com # must be a verified sender
# --- Storage (any S3-compatible — optional, falls back to local) ---
R2_BUCKET=my-bucket
R2_ENDPOINT=https://.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_PUBLIC_URL=https://cdn.your-app.com # what end-users see in
```
`BETTER_AUTH_URL` and `AUTH_TRUSTED_ORIGINS` are **not** the same thing. The first is where the
auth cookies are scoped — must match the public origin exactly (protocol + host + port). The
second is the CSRF allowlist — comma-separated origins your frontend(s) call from. Get either
wrong and login silently fails with no obvious error.
### Optional but recommended
```bash
# Asset signing — short-lived signed URLs for cross-org sharing
ASSET_SIGNING_SECRET=<32+ random chars>
# Public org id — pinned in your frontend integration (see /frontend)
PUBLIC_ORG_ID=
```
## Docker
The base template ships a single-package multi-stage `Dockerfile`. It installs with pnpm via corepack, builds with `ADAPTER=node` so the output is `build/index.js`, and prunes devDependencies before copying into the runtime stage.
```dockerfile title="Dockerfile (excerpt)"
FROM node:20-alpine AS builder
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN ADAPTER=node pnpm build
RUN pnpm prune --prod
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=3000
COPY --from=builder /app/build ./build
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/drizzle ./drizzle
COPY --from=builder /app/drizzle.config.ts ./drizzle.config.ts
EXPOSE 3000
CMD ["node", "build"]
```
### Build and run
```bash
# Build the image (no env needed at build time)
docker build -t my-aphex .
# Run with prod env vars
docker run --rm -d -p 3000:3000 \
-e DATABASE_URL=postgres://user:pass@db-host:5432/aphex \
-e AUTH_SECRET=$(openssl rand -base64 48) \
-e AUTH_URL=https://cms.your-app.com \
-e RESEND_API_KEY=re_xxxx \
-e AUTH_TRUSTED_ORIGINS=https://cms.your-app.com,https://your-app.com \
--name aphex-studio my-aphex
# The template image auto-applies migrations on start, so this is usually unnecessary.
# To force a manual run (runtime-safe — drizzle-kit isn't in the pruned prod image):
docker exec aphex-studio node node_modules/.bin/aphex migrate
```
### Compose with Postgres + Cloudflare Tunnel
If you want everything in one box, write a `docker-compose.yml` next to the `Dockerfile`. (The template no longer ships `prod.docker-compose.yml` — different users want different stacks.)
```yaml title="docker-compose.yml"
services:
postgres:
image: postgres:16-alpine
env_file: [.env.production]
environment:
POSTGRES_USER: ${PG_USER}
POSTGRES_PASSWORD: ${PG_PASSWORD}
POSTGRES_DB: ${PG_DATABASE}
volumes: [postgres_data:/var/lib/postgresql/data]
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U $${PG_USER} -d $${PG_DATABASE}']
interval: 10s
retries: 5
studio:
build:
context: .
dockerfile: Dockerfile
env_file: [.env.production]
depends_on:
postgres:
condition: service_healthy
ports: ['3000:3000']
volumes:
- ./storage:/app/storage # local-storage persistence; remove if using R2/S3
cloudflared:
image: cloudflare/cloudflared:latest
command: tunnel run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
profiles: [cloudflare]
volumes:
postgres_data:
```
```bash
docker compose up -d --build
docker compose exec studio node node_modules/.bin/aphex migrate # usually auto-applied on start
docker compose logs -f studio
```
### Local storage in Docker
The `./storage:/app/storage` mount keeps uploaded assets durable across container rebuilds. If you switch to R2 / S3, you can remove the volume — `cms_assets` rows still reference the old `storageAdapter`, so historical local files keep working through the mount.
### Updating
```bash
git pull
docker compose up -d --build studio
docker compose exec studio node node_modules/.bin/aphex migrate # usually auto-applied on start
```
Only the `studio` service rebuilds; Postgres data persists in the named volume.
## Buildpack / PaaS (Procfile)
For platforms like **canine.sh**, **Heroku**, **Fly.io with `flyctl launch`**, or anything that detects `package.json` and runs your `Procfile`, the template ships:
```
web: node build
```
You typically need two pieces of platform configuration:
1. **Build command** — set `ADAPTER=node` so SvelteKit emits `build/index.js`. On most platforms this means a `heroku-postbuild` script in `package.json` or a build env var. The simplest fix is to make `node` the default — change `svelte.config.js`:
```js title="svelte.config.js"
import adapterNode from '@sveltejs/adapter-node';
export default {
kit: { adapter: adapterNode() }
};
```
Then any `pnpm build` (Docker, Procfile, CI, local) produces a runnable Node bundle without needing `ADAPTER=node`.
2. **Runtime env vars** — set `DATABASE_URL`, `AUTH_SECRET`, `AUTH_URL`, `RESEND_API_KEY`, `AUTH_TRUSTED_ORIGINS` in the platform's secret manager. These are required at runtime; the build doesn't need them.
### Buildpack gotchas
A few things that bite people on buildpack-based platforms (canine.sh, Heroku, Fly's CNB launcher, anything Kubernetes-with-buildpacks):
* **Don't set a custom Start command.** Buildpacks rely on a launcher that sets `PATH` correctly so `node`/`npm`/`pnpm` resolve. Leaving the start command blank lets the launcher read your `Procfile` (`web: node build`). Setting it explicitly bypasses the launcher and you'll see `exec: "node": executable file not found in $PATH`.
* **Set the container port to `3000`.** SvelteKit's adapter-node listens on `PORT` (defaulting to `3000` if unset). Some platforms inject `PORT` automatically; others don't — if yours doesn't, hard-code `3000` in the platform's container/port config and your `ENV PORT=3000` in the runtime.
* **Lockfile presence matters.** Buildpacks pick a package manager based on which lockfile is committed (`pnpm-lock.yaml` → pnpm, `package-lock.json` → npm). If you're publishing the template via subtree-push or similar, make sure the standalone repo gets a generated lockfile — buildpacks won't fall back gracefully when there's no lockfile in a Node project.
* **TLS for self-managed Kubernetes.** If you're on a PaaS that runs on top of cert-manager + Let's Encrypt, HTTP-01 challenges fail when the ingress controller redirects `/.well-known/acme-challenge/...` to HTTPS. Switch the `ClusterIssuer` to DNS-01 (e.g. via Cloudflare API token) to bypass — it validates against your DNS provider directly without needing public HTTP reachability on port 80/443.
## Render.com (battle-tested)
Render runs the same Docker image, with managed Postgres alongside.
**Create a managed Postgres** in the Render dashboard. Pick a region close to where you'll host
the web service. Capture the **Internal Database URL** for use inside the app.
**Create a Web Service** from your repo:
* **Environment:** Docker
* **Dockerfile path:** `Dockerfile` (the scaffolded template ships one at the project root)
* **Docker Build Context Directory:** project root (`.`)
Render builds with Docker and runs the resulting container. No native Sharp gotchas because you control the base image.
**Add environment variables** (matching the matrix above). Use the internal Postgres URL for
`DATABASE_URL`. Set `BETTER_AUTH_URL` to the public Render URL (e.g.
`https://aphex-studio.onrender.com`) until you point a custom domain.
**Add a Disk** (Render's persistent volume) and mount it at `/app/storage` if you're using local
storage. **Skip this if you're using R2 / S3** — assets live in the bucket.
**Migration Pre-Deploy command:** set `pnpm migrate` (runs `aphex migrate` — runtime-safe, unlike
the drizzle-kit `db:migrate`) so Render applies migrations before flipping traffic to the new
release.
**Custom domain** — once added in Render's settings, update `BETTER_AUTH_URL` and
`AUTH_TRUSTED_ORIGINS` to match. Without this, login redirects fail.
### Free vs Starter plan caveats
Render's free plan spins down after 15 minutes of inactivity. The first request after spin-down hits a cold start that can take 30–60 seconds — fine for occasional editing, painful for production. Use the Starter plan for anything customer-facing.
## Migrations on deploy
Never run `db:push` against production — it can drop columns silently. Always commit a generated migration file:
**Locally** edit your Drizzle schema, then `pnpm db:generate`. Review the SQL in
`drizzle/0NNN_*.sql`. Commit both the schema change and the migration.
**On deploy** apply the migrations before serving traffic with `pnpm migrate` (which runs `aphex
migrate` — a runtime-safe applier that works in the pruned production image; the drizzle-kit
`db:migrate` does **not**, since `drizzle-kit` is a devDependency). The template's Docker image
already runs this on container start; for other platforms wire it into Render's pre-deploy
command, a CI job, etc.
**On first request** after the deploy, the CMS hook calls `initializeRLS()` to ensure RLS policies
exist on `cms_documents` and `cms_assets`. This is idempotent.
## Signed asset URLs
If you serve assets to multiple downstream apps that don't share the CMS's session, generate short-lived signed URLs instead of exposing API keys:
```ts title="aphex.config.ts"
createCMSConfig({
security: {
assetSigningSecret: env.ASSET_SIGNING_SECRET // 32+ chars
}
});
```
The `/media/{id}/{filename}?sig=...&exp=...` URL is HMAC-validated on every request. After expiry, it's a 403.
## Health checks
The CMS exposes adapter health via `databaseAdapter.isHealthy()` and `storageAdapter.isHealthy()`. Wire them into a `/healthz` route:
```ts title="src/routes/healthz/+server.ts"
import { json } from '@sveltejs/kit';
export const GET = async ({ locals }) => {
const { databaseAdapter, storageAdapter } = locals.aphexCMS;
const [db, storage] = await Promise.all([
databaseAdapter.isHealthy(),
storageAdapter.isHealthy()
]);
const ok = db && storage;
return json({ ok, db, storage }, { status: ok ? 200 : 503 });
};
```
Point your platform's health probe at `/healthz`. Render watches HTTP 200 by default.
## Backups
Three things to back up:
| Asset | How |
| -------------------------------------------- | ------------------------------------------------------------------------------ |
| Postgres | Render's managed Postgres has automated backups. Self-hosted: `pg_dump` to S3. |
| Storage bucket | R2 / S3 versioning + lifecycle rules. Local: rsync the mounted volume. |
| Secrets (`BETTER_AUTH_SECRET`, signing keys) | Password manager. Losing them invalidates every session and signed URL. |
Documents are content + a hash; restoring Postgres restores everything including version history.
## CDN / cache headers
The CMS's asset CDN sets `Cache-Control: public, max-age=31536000, immutable` because URLs are content-addressed (the `id` is unique per upload). For your public site's HTML, set sensible cache headers yourself — the CMS doesn't touch SvelteKit page responses.
For the published-data cache layer, see [Configuration → cache](/configuration#cache). Pair with a CDN like Cloudflare in front of your origin and you've got two-tier caching for free.
## Other platforms
These should work — the studio is a standard adapter-node SvelteKit app — but aren't tested in production by the maintainers:
* **Vercel / Netlify** — adapter-vercel exists; cold starts will hit Sharp's native binaries. Test asset uploads carefully.
* **Cloudflare Pages / Workers** — partial fit. The admin uses Sharp + Better Auth crypto + Postgres driver, all of which want Node. The pragmatic split: studio on a Node platform, public site on Pages, both reading the same DB.
* **Fly.io / Railway** — Docker-friendly platforms; the bundled `Dockerfile` drops in directly.
* **canine.sh / Heroku** — Procfile + buildpacks (see above).
If you ship on one of these, file an issue (or a docs PR) so we can fold real-world notes into this page.
## See also
# Events & Jobs (/events-and-jobs)
Aphex has a durable spine for **reacting to things that happen** — a document being published, and (over time) anything else worth acting on. It's built from an append-only event log, a transactional outbox, and a job queue with retries. No Redis, no external broker: everything is DB-backed and organization-scoped, so it runs the same on Postgres, PGlite, and SQLite.
The guiding rule mirrors [schema hooks](/docs/schemas/hooks):
> **Hooks transform, events react.** A hook mutates input synchronously in the write path. A consumer reacts to a committed fact, out of band, with its own retries. Never put side effects (email, webhooks, cache busts) in a hook — that's what consumers are for.
## The four parts
You rarely touch these tables directly — you emit an event and write a consumer. But knowing the
pieces explains the guarantees.
* **Domain event log (`cms_domain_events`)** — an **immutable, append-only** record of business facts, like `document.published`. Written inside the same transaction as the change that caused it, so a fact never exists without its change, nor is lost if the change committed. This is the audit ledger.
* **Outbox (`cms_event_outbox`)** — a **mutable worklist** row written in that same transaction. The relay claims rows by *status* (`processed_at IS NULL`), never by log position — so an event whose transaction commits late (with an early timestamp) is still picked up, which a cursor over the append-only log would silently skip.
* **Job queue (`cms_jobs`)** — commands to run now or later, with **leases** (a crashed worker's claim expires and is reclaimed), **exponential backoff + jitter**, **dead-lettering** after `maxAttempts`, and idempotent enqueue. Delivery is **at-least-once** — handlers must be idempotent.
* **The relay** — turns facts into work: it drains the outbox and, for each subscribed consumer, enqueues one delivery job, then marks the row processed.
## How one publish flows through
```text
publish() ──[one transaction]──> published data
+ domain event (immutable fact)
+ outbox row (worklist)
[worker tick — runJobsBatch]
1. relay: outbox rows → for each subscribed consumer, enqueue a
delivery job (idempotent) → mark row processed
2. run: claim due jobs (incl. those deliveries) → invoke handler
→ complete, or retry-with-backoff, or dead-letter
```
A **delivery is just a job**, so a consumer inherits the queue's retries and dead-lettering for free. And because the relay enqueues with an idempotency key of `evt::`, delivery is **exactly-once per (event, consumer)** even if two workers race or one crashes mid-batch — the duplicate enqueue is absorbed by the queue's unique key. That's why the relay needs no lock of its own.
## Reacting to events
Register an [`aphex/event/consumer`](/docs/plugins#event-consumers--aphexeventconsumer) part. It subscribes to one or more event types and runs when they fire:
```ts title="A publish notifier"
import { definePlugin } from '@aphexcms/cms-core';
const PLUGIN_ID = '@acme/aphex-plugin-notify';
export const notifyPlugin = definePlugin({
name: PLUGIN_ID,
parts: [
{
implements: 'aphex/settings',
pluginId: PLUGIN_ID,
title: 'Publish Notifications',
fields: [{ name: 'webhookUrl', type: 'secret', title: 'Webhook URL' }]
},
{
implements: 'aphex/event/consumer',
id: 'notify.on-publish',
events: ['document.published'],
async handler({ event, databaseAdapter, logger, settings }) {
const { webhookUrl } = await settings.get(PLUGIN_ID); // decrypted, scoped to the event's org
if (typeof webhookUrl !== 'string') return;
// Events carry ids only — fetch the doc for a title.
const doc = await databaseAdapter.findByDocIdAdvanced(
event.organizationId,
String(event.payload.documentId)
);
const res = await fetch(webhookUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ content: `📝 ${doc?.publishedData?.title} was published` })
});
if (!res.ok) throw new Error(`webhook failed: ${res.status}`); // throw → retry
}
}
]
});
```
Two things worth internalizing:
* **Events are lean.** The payload carries identifiers and intentional metadata only — never secrets or full document copies. If a consumer needs the title or body, it **fetches** the document. This keeps the log small and avoids stale copies.
* **Consumers read their own config.** `settings.get(pluginId)` returns the plugin's decrypted [settings](/docs/plugins#settings--secrets--aphexsettings) for the event's organization — so a webhook URL stored as a `secret` is available inside the reaction, without threading anything through.
Handlers run **at-least-once** and can re-run on retry. Make them idempotent: use `event.id` to
dedupe if the side effect isn't naturally safe to repeat.
### Built-in events
| Event | Fires when | Payload |
| -------------------- | ---------------------------------------------- | --------------------------------------------- |
| `document.published` | a document is published (any path — see below) | `documentId`, `documentType`, `publishedHash` |
`document.published` is emitted on **every** publish path — the admin UI, Local API, REST, GraphQL, MCP, and scheduled jobs — because they all funnel through the same publish code.
## Custom events
Events aren't limited to the built-ins. A plugin (or your app) can **define, emit, and react to its own** — which is what makes a plugin fully self-contained: it produces a fact from its own code and handles it, with nothing wired into `aphex.config.ts`. Three steps:
### Define the event
`defineEvent(type, schema)` pairs a namespaced type name with a Zod payload schema — one source of truth for the shape.
```ts
import { defineEvent } from '@aphexcms/cms-core';
import { z } from 'zod';
export const orderReceived = defineEvent('acme.order.received', z.object({ orderId: z.string() }));
```
### Emit it
From any server code — commonly a [server route](/docs/plugins#add-an-api-endpoint) receiving a webhook. Call `appendEvent` inside `withTransaction` so the event commits atomically with any DB write it accompanies, and `parse` the payload so a bad shape fails at the write site.
```ts
handler: async (c) => {
const { databaseAdapter } = c.var.aphexCMS;
await databaseAdapter.withTransaction((tx) =>
tx.appendEvent({
organizationId: /* … */,
type: orderReceived.type,
payload: orderReceived.parse({ orderId: '123' })
})
);
return c.json({ ok: true });
}
```
### React to it
An `aphex/event/consumer` subscribed to your type — matched exactly, built-in or custom, it makes no difference to the relay.
```ts
{
implements: 'aphex/event/consumer',
id: 'orders.on-received',
events: [orderReceived.type],
async handler({ event, databaseAdapter, logger }) {
/* react durably, with retries */
}
}
```
Bundle all three into one `definePlugin` and you have a plugin that emits and handles its own facts — the exact shape of a Shopify or payments integration (webhook → `provider.thing.happened` → sync consumer).
**Consumers can emit, too** — the handler context has `databaseAdapter`, so a consumer can emit a
follow-on event, chaining reactions. Powerful, but there's no cycle guard: `A → B → A` runs
forever. Keep chains acyclic, or gate them on state.
## Running the worker
Nothing runs until something drives the worker endpoint. The job runner lives behind a protected endpoint:
```
POST /api/internal/workers/run
Authorization: Bearer
```
Enable it by setting `jobs.workerSecret` in `aphex.config.ts`. When it's unset the endpoint returns **404** — it's never an unauthenticated surface by default. One tick **relays the outbox, then runs due jobs**, both bounded by config, and returns counts.
```ts title="aphex.config.ts"
import { env } from '$env/dynamic/private';
createCMSConfig({
jobs: {
workerSecret: env.APHEX_WORKER_SECRET,
handlers: {
/* app-level job handlers, keyed by type */
}
}
});
```
Three ways to drive that one endpoint — same execution path, different clock:
* **Platform cron** (hosted) — a scheduler POSTs the endpoint on an interval.
* **Self-hosted loop** — a tiny process that POSTs on a cadence. The template ships one at `scripts/worker.ts`:
```bash
APHEX_WORKER_SECRET=… pnpm -F @aphexcms/studio worker
```
* **Embedded** *(planned)* — an in-process loop for single-container deploys.
The runner never loops itself — the **caller** sets cadence, and each call is bounded. That's what
makes "run it from cron" and "run it from a loop" the same code.
## Scheduled publishing
Scheduled publish/unpublish is built on the queue — no separate machinery:
```ts
await localAPI.collections.blog_post.schedulePublish(ctx, id, new Date('2025-12-01T09:00:00Z'));
await localAPI.collections.blog_post.scheduleUnpublish(ctx, id, runAt);
```
Permission is checked **now** (you must be able to publish to schedule one), and the actual publish runs at `runAt` — re-validating, guarding references, and emitting `document.published`. A document has **at most one** pending schedule; rescheduling cancels the prior, so it can't double-publish. In the editor, scheduling is available from the publish controls and a pending schedule shows as a banner.
The worker must be running for scheduled jobs to fire. Set `jobs.workerSecret` and run one of the
worker modes above — otherwise jobs queue up and simply wait.
## See also
* [Plugins](/docs/plugins) — the `aphex/event/consumer` and `aphex/job/handler` parts in full.
* [Schema hooks](/docs/schemas/hooks) — the *transform* half of the rule (events are the *react* half).
* [Settings & secrets](/docs/plugins#settings--secrets--aphexsettings) — how a consumer reads its own encrypted config.
# Frontend Integration (/frontend)
The whole point of having a CMS is putting content on a page. Aphex runs **inside** your SvelteKit app, so the public site and the admin live in the same project — no API client, no token juggling, no CORS. Server load functions call the Local API directly.
This guide walks you from "I have a `post` schema" to "the published content is rendered at `/blog/[slug]`."
## Mental model
| Where you are | What auth carries | What you can read |
| ---------------------------- | ------------------------------------- | ------------------------------------------------ |
| Public visitor (no session) | No `auth` on `event.locals` | Only published documents. Drafts are invisible. |
| Logged-in editor | `SessionAuth` with `organizationRole` | Drafts + published, gated by capabilities. |
| Server-side cron / migration | `systemContext('org-id')` | Everything — bypasses RLS and capability checks. |
Every fetch goes through `event.locals.aphexCMS.localAPI.collections.` with a context. `authToContext()` converts `event.locals.auth` (which may be `null` for public visitors) into the right shape.
## Set up the public org context
Public visitors don't have a session, but the Local API still needs an `organizationId` on every read. Two patterns cover most projects.
### Pattern 1 — single tenant: just grab the first org
If your app only ever has one organization (the typical case for a marketing site or single-product app), pick whatever org exists and run with it. No env var, no setup.
```ts title="src/lib/server/cms.ts"
import { authToContext } from '@aphexcms/cms-core/server';
import type { LocalAPIContext } from '@aphexcms/cms-core/server';
/**
* Returns the right Local API context for the current request:
* - logged-in editor → their session (drafts visible if perspective='draft')
* - public visitor → system context scoped to the first org (published only)
*/
export async function publicContext(locals: App.Locals): Promise {
if (locals.auth) return authToContext(locals.auth);
const orgs = await locals.aphexCMS.databaseAdapter.findAllOrganizations();
const organizationId = orgs[0]?.id;
if (!organizationId) throw new Error('No organizations exist yet');
return { organizationId, overrideAccess: true };
}
```
Cache the result inside the load function if you call it more than once per request — `findAllOrganizations()` is cheap but it's still a round-trip.
### Pattern 2 — multi-tenant: pin a specific org
If your CMS hosts multiple tenants and the public site needs to read from a specific one, stash the id in env and use `systemContext()`:
```bash title=".env"
PUBLIC_ORG_ID=525ca4a8-8204-5469-925c-a1a88204bf50
```
```ts title="src/lib/server/cms.ts"
import { authToContext, systemContext } from '@aphexcms/cms-core/server';
import { env } from '$env/dynamic/private';
export function publicContext(locals: App.Locals): LocalAPIContext {
if (locals.auth) return authToContext(locals.auth);
return systemContext(env.PUBLIC_ORG_ID);
}
```
Both patterns set `overrideAccess: true`, which bypasses RLS and capability checks. That's safe on
public routes **as long as you always pass `perspective: 'published'`** — drafts stay hidden in
practice because `publishedData` is `null` on unpublished docs. If you want stricter isolation,
pin the perspective inside the helper.
## List page — `/blog`
```ts title="src/routes/blog/+page.server.ts"
import { publicContext } from '$lib/server/cms';
export const load = async ({ locals }) => {
const api = locals.aphexCMS.localAPI;
const ctx = await publicContext(locals);
const result = await api.collections.post.find(ctx, {
perspective: 'published',
sort: '-publishedAt',
limit: 12
});
return {
posts: result.docs,
hasMore: result.hasNextPage
};
};
```
```svelte title="src/routes/blog/+page.svelte"
```
`_meta` is always present on every document and has `publishedAt`, `updatedAt`, `createdAt`, `status`, and `type`. Your custom fields sit alongside it.
## Detail page — `/blog/[slug]`
```ts title="src/routes/blog/[slug]/+page.server.ts"
import { error } from '@sveltejs/kit';
import { publicContext } from '$lib/server/cms';
export const load = async ({ locals, params }) => {
const api = locals.aphexCMS.localAPI;
const ctx = await publicContext(locals);
const result = await api.collections.post.find(ctx, {
where: { 'slug.current': { equals: params.slug } },
perspective: 'published',
limit: 1,
depth: 1 // resolve `author` and any other references one level deep
});
const post = result.docs[0];
if (!post) error(404, 'Post not found');
return { post };
};
```
The slug field in Aphex stores `{ current: 'my-post' }` — that's why the filter path is `'slug.current'`. Dot-notation works for any nested JSON path.
`depth: 1` instructs the adapter to inline referenced documents. Without it, `post.author` would come back as `{ _ref: 'author-id' }`. With `depth: 1`, it's the full author document. `depth: 2` resolves nested references inside the author too. Cap is 5 — circular references are detected and skipped.
## Page builder — rendering polymorphic block arrays
The most common CMS UI shape: a `page` document with a `pageBuilder` array that holds different block types — hero, text, image, CTA, etc. Editors compose pages by stacking blocks; the frontend renders each one with a discriminated `_type`.
### Schema
```ts title="src/lib/schemaTypes/page.ts"
import type { SchemaType } from '@aphexcms/cms-core';
const page: SchemaType = {
type: 'document',
name: 'page',
title: 'Page',
fields: [
{ name: 'title', type: 'string', title: 'Title' },
{ name: 'slug', type: 'slug', title: 'Slug', source: 'title' },
{
name: 'pageBuilder',
type: 'array',
title: 'Page Builder',
of: [
{ type: 'hero' },
{ type: 'textBlock' },
{ type: 'imageBlock' },
{ type: 'finalCtaBlock' }
]
}
]
};
export default page;
```
`hero`, `textBlock`, etc. are object schemas registered in `schemaTypes/index.ts`.
### Server load
```ts title="src/routes/+page.server.ts"
import { publicContext } from '$lib/server/cms';
export const load = async ({ locals }) => {
const api = locals.aphexCMS.localAPI;
const ctx = await publicContext(locals);
const result = await api.collections.page.find(ctx, {
where: { 'slug.current': { equals: 'home' } },
perspective: 'published',
limit: 1,
depth: 2 // resolve images/refs inside blocks
});
const page = result.docs[0];
if (!page) return { page: null };
return { page };
};
```
### Render with `_type` discrimination
```svelte title="src/lib/PageRenderer.svelte"
{#each blocks as block, i (i)}
{#if block._type === 'hero'}
{:else if block._type === 'textBlock'}
{:else if block._type === 'imageBlock'}
{:else if block._type === 'finalCtaBlock'}
{:else}
Unknown block type: {block._type}
{/if}
{/each}
```
The `_type` field is added automatically when a block is selected in the array editor — you don't need to declare it on the schema.
### Per-block server-side processing
Sometimes you want to enrich a block server-side before sending it to the client — syntax-highlight code, fetch external data, generate signed URLs. Walk the `pageBuilder` array in the load function:
```ts
import { highlight } from '$lib/server/highlight';
if (page?.pageBuilder) {
await Promise.all(
page.pageBuilder.map(async (block: any) => {
if (block._type !== 'codeShowcaseBlock') return;
const tabs = block.codeFrame?.tabs;
if (!Array.isArray(tabs)) return;
await Promise.all(
tabs.map(async (tab: any) => {
if (typeof tab.code === 'string') {
tab.highlightedHtml = await highlight(tab.code, tab.language);
}
})
);
})
);
}
```
Mutating in place is fine — the load function's return value is serialized once on its way to the client.
## Singletons in the root layout
Singletons are perfect for site-wide things — navigation, footer, settings. Load them once in the root layout and they're available everywhere.
```ts title="src/routes/+layout.server.ts"
import { publicContext } from '$lib/server/cms';
export const load = async ({ locals }) => {
const api = locals.aphexCMS.localAPI;
const ctx = await publicContext(locals);
const [nav, footer] = await Promise.all([
api.collections.siteNavigation.get(ctx, { perspective: 'published' }),
api.collections.siteFooter?.get(ctx, { perspective: 'published' })
]);
return { nav, footer };
};
```
```svelte title="src/routes/+layout.svelte"
{#each data.nav.links as link}
{link.label}
{/each}
{@render children()}
```
Because singletons lazy-create on first read, you never have to handle a "doesn't exist yet" case. The first deploy will create an empty draft; editors fill it in; published reads start returning real data.
## Static rendering with `prerender`
If your content doesn't change per request, prerender the page. SvelteKit's `prerender` option works because the load function is server-side:
```ts title="src/routes/blog/[slug]/+page.server.ts"
export const prerender = true;
export async function entries() {
// Optional — tells SvelteKit which slugs to crawl at build time.
// Without this, SvelteKit follows links from prerendered pages.
const api = (await import('$lib/server/getLocalAPI')).getLocalAPI();
const ctx = (await import('$lib/server/cms')).publicSystemContext();
const result = await api.collections.post.find(ctx, {
perspective: 'published',
select: ['slug']
});
return result.docs.map((p) => ({ slug: p.slug.current }));
}
```
The `select` option limits the projection to `slug` — useful when you're iterating thousands of documents and only need IDs.
## Live preview pattern
To preview drafts before publishing, swap the perspective based on a query param and a session check:
```ts title="src/routes/blog/[slug]/+page.server.ts"
import { error } from '@sveltejs/kit';
import { authToContext, hasCapability } from '@aphexcms/cms-core/server';
import { publicContext } from '$lib/server/cms';
export const load = async ({ locals, params, url }) => {
const api = locals.aphexCMS.localAPI;
const wantsPreview = url.searchParams.get('preview') === '1';
// Only authenticated editors can preview drafts
const isPreview = wantsPreview && locals.auth && hasCapability(locals.auth, 'document.read');
const ctx = isPreview ? authToContext(locals.auth) : await publicContext(locals);
const perspective = isPreview ? 'draft' : 'published';
const result = await api.collections.post.find(ctx, {
where: { 'slug.current': { equals: params.slug } },
perspective,
limit: 1,
depth: 1
});
const post = result.docs[0];
if (!post) error(404, 'Post not found');
return { post, isPreview };
};
```
Add a "Preview" link in the admin's `preview` config so editors land on `/blog/?preview=1` directly from the document editor.
## Image rendering
Use ``. It takes the field value as-is and emits a responsive ``:
```svelte
```
That renders `src`, `srcset`, `sizes`, intrinsic `width`/`height`, `loading="lazy"` and
`decoding="async"`. `priority` swaps in `loading="eager"` + `fetchpriority="high"` — correct for an
LCP image, wasteful for anything else. It renders nothing when the value has no resolved url,
rather than an `` with an empty `src`.
It ships from `@aphexcms/cms-core/image`, a narrow entrypoint, so importing it can't drag admin or
editor chunks onto a public page.
**`sizes` is the one thing you have to get right.** Without it a browser assumes the image
occupies the full viewport width and picks the largest candidate on a phone — which defeats the
entire point. Describe the *rendered* width: `sizes="400px"` for a fixed avatar,
`sizes="(max-width: 820px) 100vw, 516px"` for a card in a two-column grid.
### Why not just ``
`asset.url` is the **original** — full size, original format. It's the right answer for a download
link or an OG tag, and the wrong one for a rendered image: a 4 MB photo in a 400 px card is the
failure mode that looks perfect and costs a hundred times what it should.
Everything `` needs is injected server-side onto the field value, so you can read it
directly if you're building your own component:
```ts
post.coverImage.asset.url; // the original
post.coverImage.asset.srcset; // "/media/{id}/w320-{hash}.webp 320w, …"
post.coverImage.asset.width; // intrinsic dimensions, for aspect ratio
post.coverImage.asset.height;
```
`srcset` is absent when there are no derivatives to offer — the pipeline is disabled, or the asset
is an SVG or animated (both are served as-is, since resizing them makes them worse). Fall back to
`asset.url` in that case, which is what `` does.
### One fixed URL instead
When you need a plain string at a genuinely fixed size — an OG image, an email, a canvas source —
`urlFor` snaps to the nearest generated variant that covers the width you ask for:
```ts
import { imageUrlBuilder } from '@aphexcms/cms-core';
const urlFor = imageUrlBuilder();
urlFor(post.coverImage).width(640).url(); // → /media/{id}/w640-{hash}.webp
urlFor(post.coverImage).width(333).url(); // → the 640 rung; the ladder is a closed set
urlFor(post.coverImage).url(); // → the original
```
Prefer `` for anything on a page: one fixed URL can't respond to viewport or device pixel
ratio.
### Sizing per placement, not per field
There's no per-collection or per-block size config, and that's deliberate. One global ladder means
adding a width is a config edit rather than a migration, and two placements of the same image share
one set of files instead of generating duplicates. The per-placement control is `sizes`, which is a
render-time hint and costs nothing.
If a width you actually need isn't on the ladder, add it to `images.widths` — no regeneration
script, no backfill. See [Configuration](/configuration#images).
### Resolution
When `depth >= 1`, the asset record is inlined. With `depth: 0` you get only
`{ asset: { _ref: 'asset-id' } }` — handy when you don't need the file.
Every asset URL is `/media/{assetId}/{filename}` regardless of backend, and goes through Aphex's
handler (access control, then a proxied read, 1y immutable cache on variants). See
[Storage](/storage) for the full breakdown.
## Recipe: resolve asset refs in a load function
`depth` resolves **reference fields** (a `reference` pointing at another document). It does **not**
turn an `image`/`file` field — or an image block buried in Portable Text — into a usable URL,
because those store an asset ref (`{ _type: 'image', asset: { _type: 'reference', _ref } }`) that
points at the assets table, not a document.
`assetService.injectAssetUrls` fills them in. Hand it whatever you're about to return:
```ts title="src/routes/blog/[slug]/+page.server.ts"
import { error } from '@sveltejs/kit';
import { publicContext } from '$lib/server/cms';
export const load = async ({ locals, params }) => {
const api = locals.aphexCMS.localAPI;
const ctx = await publicContext(locals);
const result = await api.collections.post.find(ctx, {
where: { 'slug.current': { equals: params.slug } },
perspective: 'published',
limit: 1
});
const post = result.docs[0];
if (!post) error(404, 'Post not found');
// Walks the whole object, finds every asset ref at any depth — cover image,
// image blocks inside Portable Text, images nested in array items — and fills
// each one in place. Variadic, so pass as many documents as you have.
await locals.aphexCMS.assetService.injectAssetUrls(ctx.organizationId, post);
return { post };
};
```
Then render the field directly, no lookup map to thread through the component tree:
```svelte title="src/routes/blog/[slug]/+page.svelte"
```
Three things it does that a hand-rolled resolver usually doesn't:
* **One query per batch, not one per ref.** This runs on every public page render, so an N+1 here
is N round trips to a database that, on a serverless deploy, is both remote and reached through a
small connection pool — the lookups queue rather than running concurrently.
* **Injects `srcset`, `width` and `height`, not just `url`.** A resolver that returns a
`{ ref: url }` map throws away everything the image pipeline produces, and does it silently: the
page renders perfectly and ships full-size originals.
* **Fails soft.** An unresolvable ref leaves its image unrendered rather than throwing and taking
the whole page load with it.
`ctx.organizationId` is present on the context returned by the `publicContext` helper above. If
you build the context differently, pass whichever org id the read was scoped to — the asset must
belong to the same organization.
## Cache published reads
Plug an `InMemoryCacheAdapter` (or any `CacheAdapter`) into the config — every `perspective: 'published'` query gets cached and is automatically invalidated on publish / unpublish. You don't write any cache code yourself.
```ts title="aphex.config.ts"
import { InMemoryCacheAdapter } from '@aphexcms/cms-core/server';
export default createCMSConfig({
cache: new InMemoryCacheAdapter({ maxSize: 5000 })
// ...
});
```
Drafts always bypass the cache, so the admin UI never sees stale data. See [Configuration → cache](/configuration#cache).
## Surviving a failed chunk load
A route's lazily-loaded JS chunk can fail to fetch for reasons that have nothing to do with your code — a dropped connection between a CDN edge and your origin, or a cached HTML document pointing at hashed filenames a newer deploy has already replaced. The visitor sees a page that rendered fine but doesn't respond to clicks.
`@aphexcms/cms-core/chunk-recovery` handles it. Zero dependencies, deliberately kept out of the `/client` barrel so it doesn't drag the admin component tree onto every visitor's first paint.
```ts title="src/hooks.client.ts"
import {
installChunkLoadRecovery,
handleChunkLoadClientError
} from '@aphexcms/cms-core/chunk-recovery';
import type { HandleClientError } from '@sveltejs/kit';
installChunkLoadRecovery();
export const handleError: HandleClientError = ({ error, event }) => {
handleChunkLoadClientError(error, 'url' in event ? event.url : undefined);
console.error(error);
};
```
Three failure modes, three functions — you need more than one because they fail in structurally different places:
| Function | Covers |
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `installChunkLoadRecovery()` | **Initial hydration.** The entry module itself fails; the browser surfaces it globally, so `window` listeners catch it. Call once from `hooks.client.ts`. |
| `handleChunkLoadClientError(error, url?)` | **A failed client-side navigation.** SvelteKit's router catches the rejected import internally and routes it to `handleError`, so it never becomes a `window` event — the listeners above are structurally blind to it. Call from your exported `handleError`. |
| `installNavigationTimeoutRecovery(ms = 4000)` | **A navigation that hangs rather than fails.** Both of the above only fire once something has already *decided* the load failed, which can ride on a CDN gateway timeout. Forces a hard navigation to the destination if a client-side nav hasn't finished in time. |
Pass `event.url` to `handleChunkLoadClientError` when you have it. SvelteKit doesn't update the address bar until a navigation resolves, so at the moment of failure the browser is still showing the page the visitor was leaving — reloading the current URL would re-fetch the wrong page.
`installNavigationTimeoutRecovery` is the odd one out: it uses `beforeNavigate`/`afterNavigate`, so it must run during component initialization. Call it at the top level of your root `+layout.svelte`, not from `hooks.client.ts`.
```svelte title="src/routes/+layout.svelte"
```
All three share one session-scoped guard, so a page reloads or force-navigates **at most once**. A
second failure means something is actually wrong — the origin is down, not hiccuping — and looping
would serve the visitor worse than a broken-but-stable page.
## External consumers
If your frontend isn't in the same SvelteKit project — Astro, Next, mobile, a static SSG — use the [HTTP API](/http-api) or [GraphQL API](/graphql) with an [API key](/api-keys). Both are read-only by default; the Local API is only available inside your SvelteKit app.
| Consumer | Recommended |
| --------------------------- | ------------------------------------------------------------------ |
| Same SvelteKit app | Local API — type-safe, no network hop, capability checks built in. |
| Other Node / browser app | HTTP API + read-only API key. |
| Static SSG / build pipeline | HTTP API or GraphQL with a key, run at build time. |
| GraphQL clients | `/api/aphex-graphql` (or whatever path you configured). |
## See also
# Getting Started (/getting-started)
This guide walks you from zero to a running Aphex CMS with a content schema you can edit in the admin UI. Everything below uses the **base template**, which is the recommended way to start a new project — it ships with a full auth, storage, email, and cache setup already wired together.
## Prerequisites
* **Node.js** 18 or later
* **pnpm** (`npm install -g pnpm`)
* **Docker** (optional) — only if you want [Mailpit](https://mailpit.axllent.org/) for local email, or Postgres instead of the default SQLite. The default setup needs neither.
## Quick start
### Scaffold
```bash
pnpm create aphex
# or: npm create aphex@latest
# or: yarn create aphex
```
Pick a lowercase-hyphen project name (e.g. `my-cms`). The [`create-aphex`](https://www.npmjs.com/package/create-aphex) scaffolder copies the **base template** into a new directory with all workspace deps already pinned to published versions.
### Install dependencies
```bash
cd my-cms
pnpm install
```
### Start the dev server
```bash
pnpm dev # http://localhost:5173
```
That's it — no database to set up. The template runs on a local **SQLite** file
(`.aphex/base.db`) and pushes the schema on boot. Prefer Postgres? See
[Switching to Postgres](/database#switching-to-postgres).
### Create the first user
Open [http://localhost:5173/admin](http://localhost:5173/admin). The first account that signs up becomes the **super admin** and gets a freshly-seeded default organization (with them as `owner`). Anyone else who signs up gets the `editor` instance role automatically, but no organization membership — they'll land on the invitations page until someone invites them.
The root `/` route is a public landing page that lists your published `page` documents — read
through the **Local API** in `src/routes/+page.server.ts`. It's the starting point for your own
frontend; a "Go to Studio" button links to `/admin`.
## What's running locally
The default setup needs no containers — SQLite is a local file and the schema pushes on boot. Two things are optional:
* **Email** — dev email goes to [Mailpit](https://mailpit.axllent.org/). Start it with `pnpm mail` (`docker compose up -d mailpit`); the UI is at [localhost:8025](http://localhost:8025) and catches everything. Email is off by default (verification is opt-in), so you can skip this until you need it.
* **Postgres** — only if you switch off SQLite. `pnpm db:start` boots the Postgres 18 container from `docker-compose.yml`. See [Switching to Postgres](/database#switching-to-postgres).
| Service | Port | Purpose |
| -------- | ------ | ---------------------------------------------------------------- |
| SQLite | — | Default. Local file at `.aphex/base.db`, schema pushed on boot. |
| Mailpit | `8025` | Optional. Web UI — catches all dev email (`pnpm mail`). |
| Mailpit | `1025` | Optional. SMTP endpoint the dev email adapter points at. |
| Postgres | `5432` | Optional. Content database if you set `APHEX_DATABASE=postgres`. |
## Environment variables
`pnpm create aphex` copies `.env.example` to `.env`. For local dev the defaults just work — you only need to change things for production.
```bash title=".env"
# --- Database (SQLite by default — nothing required) -------
# APHEX_SQLITE_URL=file:.aphex/base.db # default; or a libsql:// Turso URL
# To use Postgres instead:
# APHEX_DATABASE=postgres
# DATABASE_URL="postgres://root:my-secret-password@localhost:5432/local"
# --- Auth --------------------------------------------------
BETTER_AUTH_SECRET=your-secret-key-here-change-in-production
BETTER_AUTH_URL=http://localhost:5173
AUTH_TRUSTED_ORIGINS=http://localhost:5173
# --- Email -------------------------------------------------
# APHEX_EMAIL_FROM="Acme " # sender identity
# RESEND_API_KEY=re_your_api_key_here # production (dev uses Mailpit)
# --- S3 / R2 storage (optional — falls back to local) ------
R2_ENDPOINT=
R2_ACCESS_KEY_ID=
R2_SECRET_ACCESS_KEY=
R2_BUCKET=
R2_PUBLIC_URL=
```
Rotate `BETTER_AUTH_SECRET` before deploying — it signs session cookies and API key hashes.
## Project structure
Key paths:
* **`aphex.config.ts`** — the central config. Wires every adapter together.
* **`src/hooks.server.ts`** — `auth → CMS → seed` hook sequence on every request.
* **`src/lib/schemaTypes/`** — your content schemas. Register each new schema in `index.ts`.
* **`src/lib/server/*`** — adapter singletons (auth, cache, db, email, storage). These are imported by `aphex.config.ts`.
* **`src/routes/(protected)/admin/`** — the CMS-guarded admin UI.
* **`src/routes/api/`** — REST endpoints re-exported from `@aphexcms/cms-core/server`.
* **`static/uploads/`** — local fallback for image/file uploads when no `R2_*` env vars are set.
### `aphex.config.ts`
The template wires in every adapter the base setup needs:
```ts title="aphex.config.ts"
import { createCMSConfig } from '@aphexcms/cms-core/server';
import { schemaTypes } from './src/lib/schemaTypes/index.js';
import { authProvider } from './src/lib/server/auth';
import { db } from './src/lib/server/db';
import { email } from './src/lib/server/email';
import { storageAdapter } from './src/lib/server/storage';
import { cacheAdapter } from './src/lib/server/cache';
export default createCMSConfig({
schemaTypes,
database: db,
storage: storageAdapter,
email,
cache: cacheAdapter,
auth: {
provider: authProvider,
loginUrl: '/login'
},
graphql: {
defaultPerspective: 'draft',
path: '/api/aphex-graphql'
},
customization: {
branding: { title: 'Aphex' }
}
});
```
See [Configuration](/configuration) for the full option reference.
### `src/hooks.server.ts`
Three hooks run in order on every request:
```ts title="src/hooks.server.ts (simplified)"
export const handle = sequence(authHook, aphexHook, seedHook);
```
1. **`authHook`** — Better Auth handles `/api/auth/*` and attaches the session to `event.locals`.
2. **`aphexHook`** — Builds the CMS engine once, injects it into `event.locals.aphexCMS`, and protects `/admin/*` and `/api/*`. It watches for schema changes in dev via a `__aphexSchemasDirty` flag and rebuilds the engine when schemas change, so HMR picks up new fields without a restart.
3. **`seedHook`** — Seeds demo content on first run against an untouched site (set `APHEX_SEED=false` or delete it to opt out). There's no routing hook — `/` is a public page you own (see `src/routes/+page.server.ts`).
## Writing your first schema
The template ships with a minimal `Page` document at `src/lib/schemaTypes/page.ts` — the one the homepage lists via the Local API:
```ts title="src/lib/schemaTypes/page.ts"
import type { SchemaType } from '@aphexcms/cms-core';
const page: SchemaType = {
type: 'document',
name: 'page',
title: 'Page',
fields: [
{ name: 'title', type: 'string', title: 'Title', validation: (Rule) => Rule.required() },
{
name: 'slug',
type: 'slug',
title: 'Slug',
source: 'title',
validation: (Rule) => Rule.required()
},
{ name: 'body', type: 'text', title: 'Body', rows: 8 }
]
};
export default page;
```
Add a new type by creating another file next to it and registering it in `src/lib/schemaTypes/index.ts`:
```ts title="src/lib/schemaTypes/post.ts"
import type { SchemaType } from '@aphexcms/cms-core';
import { FileText } from '@lucide/svelte';
const post: SchemaType = {
type: 'document',
name: 'post',
title: 'Post',
icon: FileText,
fields: [
{ name: 'title', type: 'string', title: 'Title', validation: (Rule) => Rule.required() },
{ name: 'slug', type: 'slug', title: 'Slug', source: 'title' },
{ name: 'excerpt', type: 'text', title: 'Excerpt' },
{ name: 'content', type: 'text', title: 'Content', rows: 10 },
{ name: 'coverImage', type: 'image', title: 'Cover Image' }
]
};
export default post;
```
```ts title="src/lib/schemaTypes/index.ts"
import page from './page.js';
import post from './post.js';
export const schemaTypes = [page, post];
```
Save. The dev server's Vite plugin flags the schemas as dirty, the next request rebuilds the engine, and **Post** appears in the admin sidebar. No restart needed.
See [Schema Types](/schemas) for every field type, validation rules, references, arrays, and objects.
## TypeScript types for your schemas
You don't manage this — it's automatic. Editing a schema rewrites `src/lib/generated-types.ts` on save (the `aphex()` Vite plugin), augmenting `localAPI.collections` so queries stay fully typed. You commit that file; builds, CI, and prod use it as-is.
The template still exposes a `pnpm generate:types` script for the rare catch-up case (a schema changed while the dev server was off). See [Type Generation](/type-generation) for details.
## Email in development vs production
`src/lib/server/email/index.ts` decides the adapter at runtime:
```ts title="src/lib/server/email/index.ts"
export const email = dev
? createMailpitAdapter()
: createResendAdapter({ apiKey: env.RESEND_API_KEY ?? '' });
```
* **Dev** — all password-reset, verification, and invitation emails go to [Mailpit](http://localhost:8025). No setup required.
* **Prod** — set `RESEND_API_KEY` and update the `from` address in `emailConfig` inside the same file.
Swap in any other SMTP provider by replacing `createMailpitAdapter()` with `createNodemailerAdapter({ host, port, auth })` — see the [`@aphexcms/nodemailer-adapter`](https://www.npmjs.com/package/@aphexcms/nodemailer-adapter) package.
## Storage in development vs production
`src/lib/server/storage/index.ts` picks an adapter based on environment variables:
```ts title="src/lib/server/storage/index.ts"
if (env.R2_BUCKET && env.R2_ENDPOINT && env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY) {
storageAdapter = s3Storage({
/* R2 / S3 config */
}).adapter;
} else {
storageAdapter = createStorageAdapter('local', {
basePath: './static/uploads',
baseUrl: '/uploads'
});
}
```
* **Dev (default)** — uploads land in `static/uploads/` and are served by SvelteKit's static handler.
* **Prod** — fill in the `R2_*` env vars (works with Cloudflare R2, AWS S3, MinIO, or any S3-compatible backend).
See [Storage](/storage) for adapter options and provider examples.
## Caching (optional but shipped)
The template creates a shared `InMemoryCacheAdapter` and hands it to both the CMS config (for `published` read caching) and Better Auth (for API key lookups):
```ts title="src/lib/server/cache/index.ts"
import { InMemoryCacheAdapter } from '@aphexcms/cms-core/server';
export const cacheAdapter: InMemoryCacheAdapter | null = new InMemoryCacheAdapter({
maxSize: 5000
});
```
Set the export to `null` to disable caching, or swap in a Redis-backed `CacheAdapter` when you outgrow in-memory storage.
## Common commands
```bash
# Dev
pnpm dev # vite dev --host
pnpm build # Production build
pnpm preview # Preview built app
# Database — SQLite (default) needs none of these; they're for the Postgres path
pnpm db:start # docker compose up -d (Postgres)
pnpm db:delete # docker compose down -v (wipes volume)
pnpm db:push # Push schema (dev)
pnpm db:generate # Generate migration SQL
pnpm db:migrate # Run migrations (prod)
pnpm db:studio # Drizzle Studio — localhost:4983
pnpm mail # docker compose up -d mailpit (local email)
# Schemas
pnpm generate:types # Regenerate typed collections
# Quality
pnpm check # svelte-check type check
pnpm test # vitest run
```
## Next steps
# God Mode (/god-mode)
God Mode is the **instance-level admin** surface — a separate section at `/god-mode` reserved for the `super_admin` role. Where the regular admin UI scopes you to one organization at a time, God Mode shows everything across the whole instance and exposes the few operations that don't belong to any single org.
## Who can access it
Only the `super_admin` instance role. The first user to sign up on a fresh deploy is auto-promoted to `super_admin` — that's the bootstrap path. After that, an existing super admin can promote another user via the database directly (there's no UI for it yet).
The layout guard at `apps/studio/src/routes/god-mode/+layout.server.ts` rejects anyone else with an "Access Denied" page; unauthenticated requests redirect to `/login`. Treat the role as production-sensitive — it bypasses every per-org capability check.
The "God Mode" link only appears in the user dropdown if your account has `role ===
'super_admin'`. Non-super-admins won't even see the entry point. Don't rely on UI obscurity,
though — the route is server-side gated, but the role itself grants instance-wide reach.
## Routes
| Route | What it shows |
| ------------------------- | ----------------------------------------------------------------------- |
| `/god-mode` | General page with your admin email + instance info. |
| `/god-mode/organizations` | Every organization in the instance + the `allowUserOrgCreation` toggle. |
Open the user-menu dropdown in the sidebar and click "God Mode" to enter. Use the breadcrumb / sidebar to navigate back out.
## What you can do here
### See every organization
The organizations page lists all orgs in the instance, enriched with member count and owner email. This is the only place you can see orgs you're not a member of — the regular admin UI scopes by `event.locals.auth.organizationId`.
### Create an organization without an invite
Regular org creation requires an invite flow (an existing member invites a new one, who accepts and joins). God Mode lets a super admin **create an org directly** — fill in name + slug, submit, the org exists. Useful for:
* Bootstrapping tenants on behalf of a customer who hasn't signed up yet.
* Setting up demo / staging / test orgs.
* Recovering from a botched signup where the auto-org creation failed.
The org has no members until you invite them through the normal flow.
### Delete an organization
Trash icon on each row. Confirmation dialog warns that **all members will be removed and pending invitations cancelled**. The owner of the org (if any) is detached. Documents and assets owned by the org are subject to whatever cascade behavior your database adapter implements — for the bundled Postgres adapter, RLS-protected rows are physically deleted by `ON DELETE CASCADE`.
This is irreversible. There's no soft-delete and no trash bin.
### Toggle `allowUserOrgCreation`
A switch on `/god-mode/organizations` controls whether non-super-admins can self-create organizations. Two modes:
* **On (default)** — any logged-in user can create their own org from the regular admin UI.
* **Off** — only super admins can create orgs (i.e. only via God Mode). Useful for closed-tenancy installations where you decide who gets a workspace.
The setting is global — there's no per-user override.
### Switch org context
Clicking an org in the list switches your active org context to that one (same mechanism the regular org-switcher uses, just with the full set of orgs visible). After switching, your subsequent admin UI sessions operate as a member of that org.
## Instance settings
The `instance_settings` table holds a single row per instance and is keyed by the `InstanceSettings` interface in `packages/cms-core/src/lib/types/instance.ts`:
```ts title="InstanceSettings"
interface InstanceSettings {
allowUserOrgCreation?: boolean;
[key: string]: any;
}
```
The shape is intentionally open — adapters can extend it without changing the core. To read or write programmatically, use the `InstanceAdapter` methods on your database adapter:
```ts
const settings = await databaseAdapter.getInstanceSettings();
await databaseAdapter.updateInstanceSettings({ allowUserOrgCreation: false });
```
### HTTP endpoints
| Method | Route | Auth | Use |
| ------- | ------------------------ | --------------------- | ------------------------------- |
| `GET` | `/api/instance-settings` | Any logged-in session | Read current instance settings. |
| `PATCH` | `/api/instance-settings` | `super_admin` only | Update one or more fields. |
The `GET` is intentionally not super-admin-gated — the regular admin UI reads `allowUserOrgCreation` to decide whether to show the "Create org" button to non-super-admins.
## How God Mode differs from the admin UI
| Capability | Admin UI (`/admin`) | God Mode (`/god-mode`) |
| ------------------------------------- | ----------------------------------------- | ----------------------------------------- |
| Visible orgs | Just the ones you're a member of. | All orgs in the instance. |
| Create organization | Yes (if `allowUserOrgCreation: true`). | Yes, always — bypasses the invite flow. |
| Delete organization | Owner only, within the org. | Any org, from one place. |
| Per-org RBAC checks | Enforced. | Bypassed via `super_admin` instance role. |
| Capability checks on documents/assets | Enforced. | Bypassed. |
| Instance settings | Read-only (`GET /api/instance-settings`). | Read + write. |
| Sidebar discoverability | Always shown. | User dropdown, only for super admins. |
## Promoting another user to super admin
There's no UI for this yet — it's deliberate, since the role is dangerous and we don't want a misclick to grant it. Update the instance role directly in the database:
```sql
UPDATE user_profiles
SET role = 'super_admin'
WHERE user_id = (SELECT id FROM "user" WHERE email = 'admin@your-app.com');
```
Reload the admin tab — the "God Mode" link appears in the user dropdown.
To demote, set the role back to `editor` (or another non-super-admin role). Sessions don't need to be invalidated — the role is read on each request.
## See also
# GraphQL API (/graphql)
Aphex auto-generates a full GraphQL API from your content schemas. Every document type gets queries, mutations, filter inputs, and data inputs — no manual schema writing required.
## Enabling GraphQL
GraphQL is built into `@aphexcms/cms-core` and enabled by default. Configure it in `aphex.config.ts`:
```ts title="aphex.config.ts"
export default createCMSConfig({
// ...
graphql: {
defaultPerspective: 'published',
path: '/api/graphql'
}
});
```
| Option | Type | Default | Description |
| -------------------- | ------------------------ | ---------------- | -------------------------------------------------- |
| `defaultPerspective` | `'draft' \| 'published'` | `'published'` | Default perspective when not specified in a query. |
| `path` | `string` | `'/api/graphql'` | The endpoint path. |
| `enableGraphiQL` | `boolean` | `true` | Enable the interactive GraphiQL IDE. |
| `defaultQuery` | `string` | - | Default query shown in GraphiQL. |
Set `graphql: false` to disable entirely.
The base template mounts GraphQL at **`/api/aphex-graphql`** (with `defaultPerspective: 'draft'`)
to leave `/api/graphql` free for your own GraphQL endpoint. All examples below use the default
`/api/graphql` — adjust the path to match whatever you set in `aphex.config.ts`.
## GraphiQL
When enabled, visit the GraphQL endpoint in your browser to open the interactive explorer:
```
http://localhost:5173/api/graphql
```
You must be logged in (session auth) to use GraphiQL.
## Authentication
All GraphQL operations require authentication — either a session cookie or an `x-api-key` header.
```bash
curl -X POST \
-H "Content-Type: application/json" \
-H "x-api-key: your_key_here" \
-d '{"query": "{ allPost(perspective: \"published\") { id title } }"}' \
https://your-app.com/api/graphql
```
API keys with only `read` permission can run queries but not mutations. Attempting a mutation with a read-only key returns `403`.
## Generated schema
For each document type in your schema, Aphex generates:
### Queries
```graphql
# Get a single document by ID
post(id: ID!, perspective: String, depth: Int): Post
# Get all documents with filtering
allPost(
where: PostWhereInput
perspective: String
limit: Int
offset: Int
sort: String
depth: Int
): [Post!]!
```
### Mutations
```graphql
createPost(data: PostDataInput!, publish: Boolean): Post!
updatePost(id: ID!, data: JSON!, publish: Boolean): Post!
deletePost(id: ID!): DeleteResult!
publishPost(id: ID!): Post!
unpublishPost(id: ID!): Post!
```
### Document types
Every document type includes metadata fields alongside your custom fields:
```graphql
type Post {
id: ID!
type: String!
status: String!
createdAt: String
updatedAt: String
publishedAt: String
# Your schema fields:
title: String!
slug: String
body: String
author: Author # Reference fields resolve automatically
tags: [String]
}
```
## Type mapping
| Schema type | GraphQL type |
| ------------------------ | ------------------------------------- |
| `string`, `text`, `slug` | `String` |
| `number` | `Float` |
| `boolean` | `Boolean` |
| `image` | `Image` |
| `reference` | Referenced document type |
| `object` (inline fields) | Generated type (e.g. `PostSeoObject`) |
| `array` (single type) | `[Type]` |
| `array` (multiple types) | Union type (e.g. `PostContentItem`) |
The `Image` type has the following shape:
```graphql
type Image {
_type: String!
asset: ImageAsset
url: String # Convenience URL: /media/{assetRef}/image
}
type ImageAsset {
_ref: String!
_type: String!
}
```
## Queries
### Single document
```graphql
{
post(id: "doc_123", perspective: "published", depth: 1) {
id
title
slug
author {
id
name
}
}
}
```
### Collection with filtering
```graphql
{
allPost(
where: { title: { contains: "tutorial" }, status: { equals: "published" } }
perspective: "published"
limit: 10
sort: "-publishedAt"
) {
id
title
publishedAt
}
}
```
### Filter operators
Filter inputs are generated per field type:
**StringFilter:**
`equals`, `not_equals`, `in`, `not_in`, `contains`, `starts_with`, `ends_with`, `like`, `exists`
**NumberFilter:**
`equals`, `not_equals`, `in`, `not_in`, `greater_than`, `greater_than_equal`, `less_than`, `less_than_equal`, `exists`
**BooleanFilter:**
`equals`, `not_equals`, `exists`
**IDFilter:**
`equals`, `not_equals`, `in`, `not_in`, `exists`
### Logical operators
```graphql
{
allPost(where: { OR: [{ title: { contains: "guide" } }, { title: { contains: "tutorial" } }] }) {
id
title
}
}
```
Use `AND` and `OR` to combine conditions.
## Mutations
### Create
```graphql
mutation {
createPost(data: { title: "New Post", slug: "new-post", body: "Hello world." }, publish: true) {
id
title
status
}
}
```
### Update
```graphql
mutation {
updatePost(id: "doc_123", data: { title: "Updated Title" }, publish: false) {
id
title
status
}
}
```
The `data` argument uses the `JSON` scalar, so you can pass any subset of fields.
### Delete
```graphql
mutation {
deletePost(id: "doc_123") {
success
}
}
```
### Publish / Unpublish
```graphql
mutation {
publishPost(id: "doc_123") {
id
status
publishedAt
}
}
```
```graphql
mutation {
unpublishPost(id: "doc_123") {
id
status
}
}
```
## Singletons
[Singleton schemas](/singletons) generate a different shape:
* The query has **no `id` argument** and there is **no `allXxx`** version — the resolver always returns the canonical row.
* Mutations are limited to `update`, `publish`, and `unpublish`. `create` and `delete` are intentionally absent.
```graphql
type Query {
# Get the siteNavigation singleton (lazy-creates an empty draft on first access)
siteNavigation(perspective: String, depth: Int): SiteNavigation!
}
type Mutation {
updateSiteNavigation(data: JSON!, publish: Boolean): SiteNavigation!
publishSiteNavigation: SiteNavigation!
unpublishSiteNavigation: SiteNavigation!
}
```
```graphql
{
siteNavigation(perspective: "published") {
id
brand
links {
label
url
}
}
}
```
## References
Reference fields are resolved automatically. Use the `depth` argument to control how many levels deep references are fetched:
```graphql
{
post(id: "doc_123", depth: 2) {
title
author {
name
avatar {
url
}
}
}
}
```
Without `depth` (or `depth: 0`), reference fields return `null`. Set `depth: 1` or higher to populate them.
## Perspectives
The `perspective` argument controls which version of a document is returned:
* `"draft"` — the working copy with unpublished changes.
* `"published"` — the last published version. Documents never published won't appear.
The default is set by `graphql.defaultPerspective` in your config.
## Union types
When an `array` field allows multiple schema types, Aphex generates a union:
```graphql
union PostContentItem = Block | Hero | Callout
type Post {
content: [PostContentItem]
}
```
Query with inline fragments:
```graphql
{
post(id: "doc_123") {
content {
... on Block {
text
}
... on Hero {
heading
image {
url
}
}
}
}
}
```
Union types resolve using the `_type` field on each array item.
## Input types
### Data inputs (create)
Generated per document type with typed fields:
```graphql
input PostDataInput {
title: String!
slug: String
body: String
image: JSON # Complex types use JSON scalar
tags: [JSON] # Arrays use [JSON]
seo: JSON # Objects use JSON
author: String # References accept the document ID
}
```
### Update data
Update mutations accept `JSON` for maximum flexibility — you can pass any subset of fields without type restrictions.
# HTTP API (/http-api)
The HTTP API gives you full CRUD access to documents, assets, and schemas. All document and asset endpoints require authentication via session or [API key](/api-keys).
## Base URL
All endpoints are relative to your app's origin:
```
https://your-app.com/api/...
```
## Reserved URLs
Three different layers handle requests under `/api/*`, and they're picked in this order:
```
1. SvelteKit handle hook → Better Auth (/api/auth/*)
2. SvelteKit filesystem router → specific +server.ts files
3. Catch-all (/api/[...slug]) → forwards into Hono → CMS routes
```
SvelteKit's filesystem router prefers more-specific paths over rest parameters. Hono only sees what falls through the catch-all — it does **not** take precedence over a sibling `+server.ts`. That means if you create `apps/studio/src/routes/api/documents/+server.ts`, your file silently shadows the CMS's `/api/documents` handler and the CMS code never runs.
To wrap or replace a CMS route safely, use the [`api(app)` config hook](/configuration#api) — it registers your handler inside Hono itself, where you can call `await next()` to chain to the built-in handler.
The full list of URLs the CMS owns:
### Documents
| Method | Path | Notes |
| -------- | ---------------------------------------------- | --------------------------------- |
| `GET` | `/api/documents` | List with filters and pagination. |
| `POST` | `/api/documents` | Create a draft. |
| `POST` | `/api/documents/query` | Advanced query — read-only. |
| `GET` | `/api/documents/:id` | Read a single document. |
| `PUT` | `/api/documents/:id` | Update draft data. |
| `DELETE` | `/api/documents/:id` | Delete (draft or both). |
| `POST` | `/api/documents/:id/publish` | Publish draft to published. |
| `DELETE` | `/api/documents/:id/publish` | Unpublish. |
| `GET` | `/api/documents/:id/versions` | List versions. |
| `GET` | `/api/documents/:id/versions/:version` | Read a specific version. |
| `POST` | `/api/documents/:id/versions/:version/restore` | Restore a version into the draft. |
### Assets
| Method | Path | Notes |
| -------- | ------------------------------- | ------------------------------------------ |
| `GET` | `/api/assets` | List with filters. |
| `POST` | `/api/assets` | Upload (`multipart/form-data`). |
| `DELETE` | `/api/assets/bulk` | Bulk delete by id list. |
| `POST` | `/api/assets/references/counts` | Reference counts for a list of asset ids. |
| `GET` | `/api/assets/:id` | Read asset metadata. |
| `PATCH` | `/api/assets/:id` | Update metadata (title, alt, credit, etc). |
| `DELETE` | `/api/assets/:id` | Delete an asset. |
| `GET` | `/api/assets/:id/references` | List documents referencing this asset. |
### Organizations
| Method | Path | Notes |
| -------- | -------------------------------- | ------------------------------------ |
| `GET` | `/api/organizations` | List orgs the caller belongs to. |
| `POST` | `/api/organizations` | Create an organization. |
| `POST` | `/api/organizations/switch` | Switch active org context. |
| `GET` | `/api/organizations/members` | List members of the active org. |
| `PATCH` | `/api/organizations/members` | Update a member's role. |
| `DELETE` | `/api/organizations/members` | Remove a member. |
| `POST` | `/api/organizations/invitations` | Send an invitation. |
| `DELETE` | `/api/organizations/invitations` | Cancel an invitation. |
| `GET` | `/api/organizations/:id` | Read a specific org. |
| `PATCH` | `/api/organizations/:id` | Update org name / slug / metadata. |
| `DELETE` | `/api/organizations/:id` | Delete an org (super admin / owner). |
### Roles
| Method | Path | Notes |
| -------- | ------------------ | --------------------------------------- |
| `GET` | `/api/roles` | List built-in + custom org roles. |
| `POST` | `/api/roles` | Create a custom role. |
| `PATCH` | `/api/roles/:name` | Update an existing role's capabilities. |
| `DELETE` | `/api/roles/:name` | Delete a custom role. |
### Schemas
| Method | Path | Notes |
| ------ | -------------------- | -------------------------------------------------- |
| `GET` | `/api/schemas` | All registered schemas (used by the studio shell). |
| `GET` | `/api/schemas/:type` | A single schema. |
### User account
| Method | Path | Notes |
| ------- | ---------------------------------- | ------------------------------------------------- |
| `PATCH` | `/api/user` | Update profile (name, email). |
| `GET` | `/api/user/cms-preference` | Read editor preferences (sidebar state, etc). |
| `PATCH` | `/api/user/cms-preference` | Update editor preferences. |
| `POST` | `/api/user/request-password-reset` | Trigger a password-reset email. |
| `POST` | `/api/user/reset-password` | Complete a password reset with the emailed token. |
### Outside Hono
| Path | Owned by |
| ------------------------ | --------------------------------------------------------------------------------------------------------------- |
| `/api/auth/*` | Better Auth — handled by `svelteKitHandler` in `hooks.server.ts`, intercepts before the filesystem router runs. |
| `/api/graphql` | The GraphQL endpoint. Configurable via `graphql.path` in `aphex.config.ts`. |
| `/api/instance-settings` | Studio `+server.ts` (super-admin gated). See [God Mode](/god-mode#instance-settings). |
| `/media/:id/:filename` | Studio `+server.ts` — asset CDN handler. Lives at `/media/*`, **not** `/api/media/*`. |
### Picking a safe path for custom endpoints
When you mount your own `+server.ts` under `/api/*`, avoid these prefixes: `/api/auth`, `/api/documents`, `/api/assets`, `/api/organizations`, `/api/roles`, `/api/schemas`, `/api/user`, `/api/graphql`, `/api/instance-settings`. The convention some teams adopt to be future-proof:
* Namespace custom routes under a stable prefix, e.g. `/api/app/*` or `/api/v1/*`.
* For one-off webhooks: `/api/webhooks/` (Stripe, Slack, GitHub).
* For internal tooling: `/api/admin/*` or `/api/internal/*`.
If you ever need to claim a URL the CMS already owns — to wrap, replace, or extend a built-in handler — use the [`api(app)` config hook](/configuration#api). That's the only safe path to do so without forking `cms-core`.
## Authentication
Include an API key in the `x-api-key` header:
```bash
curl -H "x-api-key: your_key_here" \
https://your-app.com/api/documents?type=post
```
If no `x-api-key` header is present, the API falls back to session authentication (cookies). When both are available, the API key takes precedence.
Each API key is scoped to the organization that was active when the key was created. All requests made with that key only see and modify data within that organization. See [API Keys](/api-keys) for details on organization scoping and parent–child hierarchy access.
### Write protection
Mutating requests (`POST`, `PUT`, `PATCH`, `DELETE`) require write permission. API keys with only `read` permission receive a `403` response on mutations.
The one exception is `POST /api/documents/query`, which is treated as a read operation (it uses POST only because the filter payload can be complex).
## Response format
Successful responses share a consistent envelope:
```json
{
"success": true,
"data": { ... },
"pagination": { ... }
}
```
Errors come in two shapes depending on which layer rejected the request:
```json
// Validation / business errors (most 400s, 404s)
{
"success": false,
"error": "Bad Request",
"message": "Detailed error message",
"issues": [ ... ] // present when zod validation failed
}
```
```json
// Auth / permission middleware (401, 403)
{
"error": "Unauthorized"
}
```
The middleware path is intentionally minimal — it short-circuits before the route handler, so it doesn't carry `success` or `message`. Treat both shapes as errors when the HTTP status is ≥ 400.
## Documents
### List documents
```
GET /api/documents?type={type}
```
| Parameter | Type | Default | Description |
| --------------------------- | --------- | ---------- | ----------------------------------------------------------------- |
| `type` | `string` | *required* | Document type (e.g. `post`, `page`). |
| `status` | `string` | - | Filter by `draft` or `published`. |
| `perspective` | `string` | `'draft'` | Which version to return: `'draft'` or `'published'`. |
| `page` | `number` | `1` | Page number. |
| `pageSize` | `number` | `20` | Results per page. Alias: `limit`. |
| `sort` | `string` | - | Sort field. Prefix with `-` for descending (e.g. `-publishedAt`). |
| `depth` | `number` | `0` | Reference resolution depth (0–5). |
| `includeChildOrganizations` | `boolean` | `false` | Include documents from child organizations. |
| `filterOrganizationIds` | `string` | - | Comma-separated list of organization IDs to filter by. |
```bash
curl -H "x-api-key: your_key" \
"https://your-app.com/api/documents?type=post&perspective=published&pageSize=10&sort=-publishedAt"
```
**Response:**
```json
{
"success": true,
"data": [ ... ],
"pagination": {
"total": 42,
"page": 1,
"pageSize": 10,
"totalPages": 5,
"hasNextPage": true,
"hasPrevPage": false
}
}
```
### Get document by ID
```
GET /api/documents/{id}
```
| Parameter | Type | Default | Description |
| ------------- | -------- | --------- | --------------------------------- |
| `perspective` | `string` | `'draft'` | `'draft'` or `'published'`. |
| `depth` | `number` | `0` | Reference resolution depth (0–5). |
```bash
curl -H "x-api-key: your_key" \
"https://your-app.com/api/documents/doc_123?perspective=published&depth=1"
```
### Create document
```
POST /api/documents
```
```json
{
"type": "post",
"data": {
"title": "My New Post",
"slug": "my-new-post",
"body": "Hello world."
},
"publish": false
}
```
Returns `201` with the created document and validation results:
```json
{
"success": true,
"data": { ... },
"validation": { "isValid": true, "errors": [] }
}
```
Set `"publish": true` to publish immediately. This validates before publishing and returns `400` if validation fails.
### Update document
```
PUT /api/documents/{id}
```
```json
{
"data": {
"title": "Updated Title"
},
"publish": false,
"expectedRevision": 7
}
```
`expectedRevision` is optional — see [Concurrency](#concurrency) below.
### Delete document
```
DELETE /api/documents/{id}
```
### Publish document
```
POST /api/documents/{id}/publish
```
Validates the draft and copies it to published data. Returns `400` if validation fails.
### Unpublish document
```
DELETE /api/documents/{id}/publish
```
Reverts the document to draft-only state.
## Concurrency
Every document carries a monotonic `revision`, returned as `_meta.revision` and incremented on every draft write. Echo it back as `expectedRevision` on your next write and the request only lands if nobody changed the document in between:
```json
{
"data": { "title": "Updated Title" },
"expectedRevision": 7
}
```
If the stored revision has moved on, the request is rejected with `409` rather than overwriting:
```json
{
"success": false,
"error": "Conflict",
"message": "Document was modified by another write (expected revision 7, current 9)",
"currentRevision": 9
}
```
Re-read the document and decide what to do — merge, discard, or ask the user. Accepted by update, publish, unpublish, and version restore.
`expectedRevision` is optional on every endpoint. Omit it and the write is unconditional
(last-write-wins), exactly as before — existing integrations keep working unchanged.
## Versions
Every draft save and publish writes an entry to the document's version history. See [Version History](/version-history) for the full guide.
### List versions
```
GET /api/documents/{id}/versions?limit=25&offset=0
```
Returns versions newest-first with the author resolved to a friendly `createdByName`.
### Get a specific version
```
GET /api/documents/{id}/versions/{versionNumber}
```
### Restore a version
```
POST /api/documents/{id}/versions/{versionNumber}/restore
```
Replaces the document's draft with the snapshot data and writes a new draft-event version recording the restore. Published data is untouched until the editor publishes again.
Accepts an optional `expectedRevision` in the body — see [Concurrency](#concurrency).
## Singletons
[Singleton schemas](/singletons) are reachable through the same endpoints, with two differences:
* The list endpoint (`GET /api/documents?type=siteNavigation`) **always** returns a one-element array — singletons ignore filters, pagination, and `status` parameters because there is at most one row.
* `DELETE /api/documents/{id}` returns `400` when called against the canonical singleton row.
Singletons are lazy-created on first read, so consumers never have to handle a `404`.
## Advanced querying
For complex filters that don't fit in query parameters, use the query endpoint:
```
POST /api/documents/query
```
This is a **read** operation — API keys with `read` permission can use it.
```json
{
"type": "post",
"where": {
"status": { "equals": "published" },
"title": { "contains": "tutorial" }
},
"limit": 20,
"page": 1,
"sort": ["-publishedAt", "title"],
"depth": 1,
"perspective": "published",
"includeChildOrganizations": true
}
```
The `where` clause uses the same [filter operators](/local-api#filtering) as the Local API.
## Assets
### List assets
```
GET /api/assets
```
| Parameter | Type | Default | Description |
| ----------- | -------- | ------- | --------------------------------------- |
| `assetType` | `string` | - | `'image'` or `'file'`. |
| `mimeType` | `string` | - | Filter by MIME type (e.g. `image/png`). |
| `search` | `string` | - | Search by title or description. |
| `limit` | `number` | `20` | Results per page. |
| `offset` | `number` | `0` | Number of results to skip. |
**Response:**
```json
{
"success": true,
"data": [ ... ],
"pagination": {
"total": 85,
"page": 1,
"pageSize": 20,
"totalPages": 5,
"hasNextPage": true,
"hasPrevPage": false
}
}
```
### Upload asset
```
POST /api/assets
Content-Type: multipart/form-data
```
| Field | Type | Description |
| ---------------- | -------- | --------------------------------------------------------------------- |
| `file` | `File` | The file to upload. *Required.* |
| `title` | `string` | Display title. |
| `description` | `string` | Description. |
| `alt` | `string` | Alt text (for images). |
| `creditLine` | `string` | Credit/attribution. |
| `organizationId` | `string` | Target organization. Defaults to the authenticated user's active org. |
```bash
curl -H "x-api-key: your_key" \
-F "file=@photo.jpg" \
-F "title=Hero Image" \
-F "alt=A sunset over the ocean" \
https://your-app.com/api/assets
```
### Get asset
```
GET /api/assets/{id}
```
### Update asset metadata
```
PATCH /api/assets/{id}
```
```json
{
"title": "Updated Title",
"alt": "New alt text"
}
```
### Delete asset
```
DELETE /api/assets/{id}
```
Returns `409` if the asset is still referenced by documents.
### Bulk delete assets
```
DELETE /api/assets/bulk
```
```json
{
"ids": ["asset_id_1", "asset_id_2", "asset_id_3"]
}
```
**Response:**
```json
{
"success": true,
"data": {
"deleted": 2,
"failed": 0
}
}
```
Returns `409` if any assets are still referenced, with the list of blocked IDs:
```json
{
"success": false,
"error": "Cannot delete 1 asset because it is still referenced by documents",
"referencedIds": ["asset_id_2"]
}
```
### Find asset references
```
GET /api/assets/{id}/references
```
Returns which documents reference a given asset:
```json
{
"success": true,
"data": {
"references": [ ... ],
"total": 3
}
}
```
### Batch reference counts
```
POST /api/assets/references/counts
```
Get reference counts for multiple assets in one request. Useful for checking which assets are safe to delete.
```json
{
"ids": ["asset_id_1", "asset_id_2", "asset_id_3"]
}
```
**Response:**
```json
{
"success": true,
"data": {
"asset_id_1": 2,
"asset_id_2": 0,
"asset_id_3": 1
}
}
```
## Schemas
### List all schemas
```
GET /api/schemas
```
### Get schema by type
```
GET /api/schemas/{type}
```
## Roles
Roles let an organization map names (`owner`, `admin`, custom roles like `Publisher`) to sets of [capabilities](/access-control#capabilities). All role endpoints require a **session** — API keys cannot manage roles.
### List roles
```
GET /api/roles
```
Returns every role defined for the active organization, including the four built-ins.
```json
{
"success": true,
"data": [
{
"id": "role_abc",
"organizationId": "org_123",
"name": "owner",
"description": "Full access including organization deletion.",
"capabilities": ["document.read", "document.create", "…"],
"isBuiltIn": true,
"createdAt": "2025-01-10T00:00:00Z",
"updatedAt": "2025-01-10T00:00:00Z"
}
]
}
```
### Create a custom role
```
POST /api/roles
```
Requires the `role.manage` capability. Built-in names (`owner`, `admin`, `editor`, `viewer`) are reserved.
```json
{
"name": "Publisher",
"description": "Can edit and publish but not delete.",
"capabilities": [
"document.read",
"document.create",
"document.update",
"document.publish",
"document.unpublish",
"asset.read",
"asset.upload"
]
}
```
Write capabilities auto-include their matching read cap, so you don't need to list `document.read` alongside `document.create` manually (it's inserted on intake).
### Update a role
```
PATCH /api/roles/{name}
```
Requires `role.manage`. Both built-in and custom roles can be edited — at least one of `description` or `capabilities` must be provided.
```json
{
"capabilities": ["document.read", "asset.read", "member.invite"]
}
```
### Delete a custom role
```
DELETE /api/roles/{name}
```
Requires `role.manage`. Returns `403` for built-in names and `409` if the role is still assigned to any member or pending invitation.
## Status codes
| Code | Meaning |
| ----- | ------------------------------------------------------------------------------------------------------- |
| `200` | Success (GET, PUT, PATCH). |
| `201` | Created (POST). |
| `400` | Bad request — missing parameters, invalid data, or validation failure. |
| `401` | Unauthorized — no valid session or API key. |
| `403` | Forbidden — insufficient permissions (e.g. viewer trying to write, or read-only API key on a mutation). |
| `404` | Not found. |
| `409` | Conflict — a stale `expectedRevision` on a document write, or an asset that still has references. |
| `500` | Server error. |
# Introduction (/)
Aphex is a headless CMS built with SvelteKit and Svelte 5. It takes inspiration from Sanity's schema-driven approach and ships with a PostgreSQL adapter via Drizzle ORM. The architecture follows the ports-and-adapters pattern so additional database backends can be added in the future, but PostgreSQL is the only adapter shipped today.
The fastest way to try it is with the [`create-aphex`](https://www.npmjs.com/package/create-aphex) scaffolder:
```bash
pnpm create aphex
# or: npm create aphex@latest
```
Head to [Getting Started](/getting-started) for the full walkthrough.
## Key Features
* **Schema-driven** — define your content model with TypeScript. Get a full admin UI, REST API, GraphQL API, and database schema automatically.
* **PostgreSQL-backed** — ships with a Drizzle ORM adapter for PostgreSQL. The ports-and-adapters layer leaves room for additional backends, though PostgreSQL is the only adapter shipped today.
* **SvelteKit-native** — runs as part of your SvelteKit app. No separate server to manage.
* **Draft / published workflow** — auto-save every couple of seconds, hash-based change detection, one-click publish.
* **Version history** — every draft save and publish is captured. Editors can preview and restore any prior version.
* **Singletons** — mark a schema as `singleton: true` to model global content (site nav, footer, settings) as a single auto-resolving row.
* **Multi-tenancy** — organizations with PostgreSQL row-level security and a parent / child hierarchy.
* **Capability-based access control** — edit built-in roles, define custom roles per organization, and gate schemas / fields by role or policy function.
* **Authentication** — Better Auth integration with email + password, API keys, and email-verified sign-up.
* **S3-compatible storage** — AWS S3, Cloudflare R2, MinIO, or local filesystem.
* **Hono-powered HTTP layer** — register custom routes and middleware via the `api(app)` config hook.
* **Email** — Resend or any SMTP provider via the Nodemailer adapter; Mailpit by default in dev.
## Architecture
Aphex follows a **hexagonal (ports & adapters)** architecture. The core engine (`@aphexcms/cms-core`) defines interfaces for database, storage, authentication, email, and cache. Separate packages provide implementations:
| Package | Purpose |
| ------------------------------ | ---------------------------------------------------------------- |
| `@aphexcms/cms-core` | Core engine, admin UI, Hono HTTP routes, built-in GraphQL, types |
| `@aphexcms/postgresql-adapter` | PostgreSQL + Drizzle ORM adapter |
| `@aphexcms/sqlite-adapter` | SQLite via libsql (local `file:` databases and Turso) |
| `@aphexcms/storage-s3` | S3-compatible storage (R2, AWS S3, MinIO) |
| `@aphexcms/resend-adapter` | Email via Resend |
| `@aphexcms/nodemailer-adapter` | Email via Nodemailer / SMTP (includes a Mailpit shorthand) |
| `@aphexcms/ui` | Shared shadcn-svelte component library |
## Next Steps
# Local API (/local-api)
The Local API is the core data layer of Aphex. Both the REST API and GraphQL API are thin wrappers around it. You can use it directly in SvelteKit route handlers, server load functions, and scripts for full control over your content.
**You don't run a type-generation step.** Editing a schema regenerates `generated-types.ts` automatically (the `aphex()` Vite plugin does it on save), the file is committed to git, and builds/CI/prod use it as-is — so `localAPI.collections.` just stays type-safe. The `generate:types` command exists only for edge cases (regenerating after a pull without starting dev, or a CI drift-check). (Content fields are stored as JSON, so a schema change needs no `db:push` either — only adding custom DB tables does.) See [Type-safe collections](#type-safe-collections) below.
## Accessing the Local API
The Local API is available on `event.locals.aphexCMS.localAPI` in any SvelteKit server context:
```ts title="src/routes/api/my-endpoint/+server.ts"
import { json } from '@sveltejs/kit';
import { authToContext } from '@aphexcms/cms-core/server';
export const GET = async ({ locals }) => {
const api = locals.aphexCMS.localAPI;
const context = authToContext(locals.auth);
const result = await api.collections.post.find(context, {
where: { status: { equals: 'published' } },
limit: 10
});
return json({ data: result.docs });
};
```
## Context
Every Local API operation requires a `LocalAPIContext`. This tells the API who is making the request and which organization the data belongs to.
### From an authenticated request
Use `authToContext()` to convert `locals.auth` into a context. This works with both session auth and API key auth:
```ts
import { authToContext } from '@aphexcms/cms-core/server';
const context = authToContext(locals.auth);
```
### System context (bypass permissions)
For seed scripts, cron jobs, or migrations where there is no user session, use `systemContext()`. This sets `overrideAccess: true`, bypassing all permission checks and row-level security:
```ts
import { systemContext } from '@aphexcms/cms-core/server';
const context = systemContext('your-organization-id');
```
### Context shape
```ts
interface LocalAPIContext {
organizationId: string; // Required for multi-tenancy
user?: CMSUser; // For permission checks and audit trails
overrideAccess?: boolean; // Bypass RLS and permissions (default: false)
auth?: Auth; // Full auth object for custom logic
}
```
## Collections
Each document type in your schema becomes a collection on `localAPI.collections`. Collections provide CRUD methods:
```ts
const api = locals.aphexCMS.localAPI;
api.collections.post; // CollectionAPI for the 'post' document type
api.collections.page; // CollectionAPI for the 'page' document type
api.collections.product; // etc.
```
You can also check what collections exist:
```ts
api.getCollectionNames(); // ['post', 'page', 'product']
api.hasCollection('post'); // true
api.getCollectionSchema('post'); // SchemaType for 'post'
```
### Type-safe collections
The Local API's typesafety is powered by generated TypeScript interfaces that mirror your current schema, and keeping them in sync is automatic:
1. **Edit a schema** in `src/lib/schemaTypes/` (add a field, rename a document type, change a reference, etc.)
2. **`generated-types.ts` rewrites itself** — the `aphex()` Vite plugin regenerates on save. You commit the result; CI/builds/prod use the committed file. (If the dev server wasn't running, run `pnpm generate:types` once to catch up.)
3. **Restart the TS server** in your editor if autocomplete doesn't pick up the new types immediately (in VS Code: `Cmd+Shift+P` → "TypeScript: Restart TS Server").
See [Type Generation](/type-generation) for the underlying mechanism (module augmentation, file outputs, CI considerations).
## Methods
### find
Find multiple documents with filtering, sorting, and pagination.
```ts
const result = await api.collections.post.find(context, {
where: { status: { equals: 'published' } },
sort: '-publishedAt',
limit: 20,
offset: 0,
perspective: 'published'
});
result.docs; // Post[]
result.totalDocs; // Total matching documents
result.totalPages; // Total pages
result.hasNextPage; // boolean
result.hasPrevPage; // boolean
```
### findByID
Find a single document by ID.
```ts
const post = await api.collections.post.findByID(context, 'doc_123', {
perspective: 'published'
});
// Returns the document or null
```
### count
Count documents matching a filter.
```ts
const total = await api.collections.post.count(context, {
where: { status: { equals: 'published' } }
});
```
### create
Create a new document. Returns the document and validation results.
```ts
const result = await api.collections.post.create(context, {
title: 'My New Post',
slug: 'my-new-post',
body: 'Hello world.'
});
result.document; // The created document (draft)
result.validation; // { isValid: boolean, errors: [...] }
```
Pass `{ publish: true }` to publish immediately. This will throw if validation fails:
```ts
const result = await api.collections.post.create(
context,
{ title: 'Published Post', slug: 'published-post' },
{ publish: true }
);
```
### update
Update an existing document. Merges the provided data with existing fields.
```ts
const result = await api.collections.post.update(context, 'doc_123', { title: 'Updated Title' });
// Returns DocumentResult or null if not found
```
Publish after updating:
```ts
const result = await api.collections.post.update(
context,
'doc_123',
{ title: 'Updated Title' },
{ publish: true }
);
```
### delete
Delete a document by ID.
```ts
const deleted = await api.collections.post.delete(context, 'doc_123');
// Returns boolean
```
### publish
Publish a document. Validates the draft data first and throws if validation fails.
```ts
const published = await api.collections.post.publish(context, 'doc_123');
// Returns the published document or null
```
### unpublish
Revert a document to draft-only state.
```ts
const draft = await api.collections.post.unpublish(context, 'doc_123');
// Returns the draft document or null
```
## Concurrency — `expectedRevision`
Every document carries a monotonic `revision`, returned as `_meta.revision` and incremented on every draft write. Pass the revision you last read as `expectedRevision` and the write only lands if nobody changed the document in between:
```ts
const post = await api.collections.post.findById(context, 'doc_123');
await api.collections.post.update(
context,
'doc_123',
{ title: 'Updated Title' },
{ expectedRevision: post._meta.revision }
);
```
If the stored revision has moved on, the write throws `RevisionConflictError` instead of overwriting:
```ts
import { RevisionConflictError } from '@aphexcms/cms-core/server';
try {
await api.collections.post.update(context, id, data, { expectedRevision: rev });
} catch (err) {
if (err instanceof RevisionConflictError) {
// err.documentId, err.expectedRevision, err.currentRevision
// Re-read the document and decide: merge, discard, or ask the user.
}
throw err;
}
```
Accepted by `update`, `publish`, `unpublish`, and `VersionService.restoreVersion`.
`expectedRevision` is optional everywhere. Omit it and you get unconditional last-write-wins,
exactly as before — so existing code keeps working unchanged. Pass it whenever a write is based on
data you read earlier: a second browser tab, a background job, or an AI agent editing a document
someone has open.
## Singletons
Schemas marked `singleton: true` expose a different surface — there is at most one row, so most of the collection methods don't apply. The codegen narrows the autocompletion to a `SingletonCollection` so invalid calls fail at compile time.
```ts
// Lazy-creates the canonical row on first access
const nav = await api.collections.siteNavigation.get(context, {
perspective: 'published'
});
// Update by name — no id needed
await api.collections.siteNavigation.update(context, nav.id, {
brand: 'Aphex'
});
// Compute the deterministic id (rarely needed)
const id = api.collections.siteNavigation.getSingletonId(context);
```
`find()` still works on a singleton (it returns a one-element page) so generic helpers can keep treating every schema the same way. `create()` and `delete()` throw `SingletonOperationError`. See [Singletons](/singletons) for the full guide.
## Version history
Each `update` (and every `publish`) writes a snapshot to `cms_document_versions`. Use `localAPI.versionService` directly for scripts and migrations:
```ts
const adapter = locals.aphexCMS.databaseAdapter;
const ctx = authToContext(locals.auth);
const { versions, total } = await api.versionService.listVersions(
adapter,
ctx.organizationId,
'doc_xyz',
{ limit: 25, offset: 0 }
);
const restored = await api.versionService.restoreVersion(
adapter,
ctx.organizationId,
'doc_xyz',
8,
ctx.user?.id
);
```
Restoring replaces the **draft** with the snapshot's data and writes a new draft-event version recording the action. See [Version History](/version-history) for the HTTP endpoints and admin UI.
## Filtering
The `where` option accepts a structured filter object that the active adapter translates into its native query language.
### Comparison operators
```ts
where: { title: { equals: 'Hello' } }
where: { title: { not_equals: 'Hello' } }
where: { status: { in: ['draft', 'published'] } }
where: { status: { not_in: ['archived'] } }
where: { image: { exists: true } }
```
### Numeric and date comparisons
```ts
where: {
price: {
greater_than: 10;
}
}
where: {
price: {
greater_than_equal: 10;
}
}
where: {
price: {
less_than: 100;
}
}
where: {
price: {
less_than_equal: 100;
}
}
```
### String operations
```ts
where: {
title: {
contains: 'blog';
}
}
where: {
title: {
starts_with: 'How';
}
}
where: {
title: {
ends_with: '?';
}
}
where: {
title: {
like: '%blog%';
}
}
```
### Logical operators
Multiple conditions at the top level are combined with AND:
```ts
where: {
status: { equals: 'published' },
title: { contains: 'blog' }
}
```
Use `or` for OR logic:
```ts
where: {
or: [{ title: { contains: 'tutorial' } }, { title: { contains: 'guide' } }];
}
```
Use `and` for explicit AND grouping:
```ts
where: {
and: [{ title: { contains: 'blog' } }, { body: { exists: true } }];
}
```
### Nested field filters
Use dot notation for nested fields:
```ts
where: { 'seo.metaTitle': { contains: 'blog' } }
where: { 'author.name': { equals: 'John' } }
```
## Find Options
The full set of options for `find`:
| Option | Type | Default | Description |
| ------------- | ------------------------ | --------- | ------------------------------------------------------------------- |
| `where` | `Where` | - | Filter conditions. |
| `limit` | `number` | `50` | Max results per page. |
| `offset` | `number` | `0` | Number of results to skip. |
| `sort` | `string \| string[]` | - | Sort order. Prefix with `-` for descending (e.g. `'-publishedAt'`). |
| `depth` | `number` | `0` | Reference resolution depth. |
| `select` | `string[]` | - | Only return specified fields. |
| `perspective` | `'draft' \| 'published'` | `'draft'` | Which version of the document to return. |
## Perspectives
Documents in Aphex have two versions: **draft** and **published**.
* `'draft'` (default) - Returns the working copy with unpublished changes.
* `'published'` - Returns the last published version. Documents that have never been published won't appear.
```ts
// Get published content for the public site
const published = await api.collections.post.find(context, {
perspective: 'published'
});
// Get draft content for the admin UI
const drafts = await api.collections.post.find(context, {
perspective: 'draft'
});
```
## Document Shape
Documents returned by the Local API include your content fields plus a `_meta` object:
```ts
{
id: 'doc_123',
title: 'My Post', // Your fields
slug: 'my-post',
body: '...',
_meta: {
type: 'post',
status: 'draft',
organizationId: 'org_123',
createdAt: '2025-01-15T10:30:00Z',
updatedAt: '2025-01-15T15:45:00Z',
createdBy: 'user_123',
updatedBy: 'user_123',
publishedAt: null,
publishedHash: null
}
}
```
## Permissions
The Local API enforces the same **capability-based** access control as the HTTP and GraphQL APIs. Each operation is gated by a specific [capability](/access-control#capabilities):
| Operation | Required capability |
| ----------------------------- | -------------------- |
| `find` / `findByID` / `count` | `document.read` |
| `create` | `document.create` |
| `update` | `document.update` |
| `delete` | `document.delete` |
| `publish` | `document.publish` |
| `unpublish` | `document.unpublish` |
Built-in role mapping for reference:
| Role | read | create/update/delete | publish / unpublish |
| -------- | ---- | -------------------- | ------------------- |
| `viewer` | yes | no | no |
| `editor` | yes | yes | yes |
| `admin` | yes | yes | yes |
| `owner` | yes | yes | yes |
Custom roles grant whatever capabilities you assign them. Schema-level `access` rules and field-level `access` rules apply on top of this check — see [Access Control](/access-control).
Set `overrideAccess: true` in the context to bypass all checks (for system operations only).
## Examples
### Public API endpoint
```ts title="src/routes/api/posts/+server.ts"
import { json } from '@sveltejs/kit';
import { authToContext } from '@aphexcms/cms-core/server';
export const GET = async ({ locals, url }) => {
const api = locals.aphexCMS.localAPI;
const context = authToContext(locals.auth);
const page = parseInt(url.searchParams.get('page') || '1');
const limit = 10;
const result = await api.collections.post.find(context, {
where: { status: { equals: 'published' } },
perspective: 'published',
sort: '-publishedAt',
limit,
offset: (page - 1) * limit
});
return json({
posts: result.docs,
totalPages: result.totalPages,
hasNextPage: result.hasNextPage
});
};
```
### Server load function
```ts title="src/routes/blog/[slug]/+page.server.ts"
import { error } from '@sveltejs/kit';
import { authToContext } from '@aphexcms/cms-core/server';
export const load = async ({ locals, params }) => {
const api = locals.aphexCMS.localAPI;
const context = authToContext(locals.auth);
const result = await api.collections.post.find(context, {
where: { slug: { equals: params.slug } },
perspective: 'published',
limit: 1
});
const post = result.docs[0];
if (!post) throw error(404, 'Post not found');
return { post };
};
```
### Seed script
```ts title="scripts/seed.ts"
import { getLocalAPI, systemContext } from '@aphexcms/cms-core/server';
const api = getLocalAPI();
const context = systemContext('your-org-id');
await api.collections.post.create(
context,
{
title: 'Welcome to Aphex',
slug: 'welcome',
body: 'Your first post.'
},
{ publish: true }
);
```
# MCP Server (/mcp)
Aphex ships a built-in [MCP](https://modelcontextprotocol.io) server, so AI clients — Claude Code, Cursor, Claude.ai connectors, or anything else that speaks MCP — can work with your content directly: inspect the schema, query and create documents, validate data before writing, publish, and browse assets.
## Mounting the endpoint
The implementation lives in `@aphexcms/cms-core`; your app exposes it with a one-line re-export. Scaffolded apps already include this at `src/routes/mcp/+server.ts`:
```ts
export { POST, GET, DELETE } from '@aphexcms/cms-core/routes/mcp';
```
That serves an MCP endpoint at `/mcp` on your app's origin. Bump the package to update the server — there's nothing else to maintain.
## Transport
The endpoint speaks **streamable HTTP** — the current MCP transport (spec 2025-03-26), which replaced the deprecated two-endpoint SSE transport. Streamable HTTP is a single endpoint using plain request/response POSTs (with optional SSE streaming inside a response), so it's stateless-friendly and works behind proxies and load balancers without sticky sessions. When configuring clients, choose `http`, not `sse`.
## Authentication
Requests authenticate with an **org-scoped API key** passed as an `x-api-key` header. Create keys in **Admin → Settings → API Keys** — see [API Keys](/api-keys) for scopes and capabilities. A read-only key limits the client to querying; `write` unlocks create/update/publish.
Prefer one key per client or environment, so you can revoke narrowly.
## Connecting clients
**Claude Code:**
```bash
claude mcp add --transport http aphex https://your-app.com/mcp \
--header "x-api-key: "
```
**Cursor / other JSON-config clients:**
```json
{
"mcpServers": {
"aphex": {
"url": "https://your-app.com/mcp",
"headers": { "x-api-key": "" }
}
}
}
```
Once connected, the tools appear under the server name you chose (e.g. `mcp__aphex__describe_cms`).
## Tools
| Tool | What it does |
| ------------------- | -------------------------------------------------------------------------------------- |
| `describe_cms` | Orientation: all content types, relationships, field-type vocabulary, key permissions. |
| `list_collections` | List document collections with names and titles. |
| `get_schema` | Field schema for one collection — the shape to use when writing documents. |
| `query_documents` | Query with `where` filters, sorting, pagination, and draft/published perspective. |
| `get_document` | Read a single document by id. |
| `create_document` | Create a document (optionally publish). |
| `update_document` | Update a document's fields (optionally publish). |
| `publish_document` | Publish an existing draft. |
| `get_singleton` | Read a singleton (e.g. site settings) without needing an id. |
| `update_singleton` | Update a singleton's fields. |
| `validate_document` | Dry-run validation against the collection schema — same validator as create/update. |
| `validate_schema` | Validate a proposed schema definition before writing a schema file. |
| `list_assets` | List media assets, filterable by type and filename. |
All schema information is derived live from the running config — never stale.
## Suggested workflow for agents
1. Call `describe_cms` first — it returns the content model and what the API key may do.
2. `get_schema` for the collection you're about to write.
3. `validate_document` to dry-run the payload and get field-level errors.
4. `create_document` / `update_document`, publishing when ready.
Validation runs through the same pipeline as the HTTP and Local APIs, so an agent can't write shapes the admin UI couldn't.
# Plugins (/plugins)
A **plugin** is how you add features to your CMS without touching core: content types, custom field inputs, editor buttons, admin screens, API endpoints, background jobs, and reactions to things like publishing. They're plain TypeScript objects — no config DSL, no runtime magic.
There are two ways to get one:
This guide assumes you scaffolded your project with `pnpm create aphex` (or `npm create aphex`).
You have a normal standalone project — **no monorepo, no pnpm workspace needed**. Everything below
is `pnpm add` and editing files in your own `src/`.
## The one file you edit
Your project has a single plugin registry at **`src/lib/plugins.ts`**. It starts empty:
```ts title="src/lib/plugins.ts"
import type { CMSPlugin } from '@aphexcms/cms-core';
export const plugins: CMSPlugin[] = [];
```
Every plugin — installed or homegrown — goes in this array. Your project already wires it into both places that need it (the server engine via `aphex.config.ts`, and the admin UI), so **you never touch anything else**. Add to the array, restart, done.
***
## Use a ready-made plugin
### Install it
```bash
pnpm add @aphexcms/plugin-color-picker
```
```bash
npm install @aphexcms/plugin-color-picker
```
```bash
yarn add @aphexcms/plugin-color-picker
```
### Register it
```ts title="src/lib/plugins.ts"
import type { CMSPlugin } from '@aphexcms/cms-core';
import { colorPickerPlugin } from '@aphexcms/plugin-color-picker';
export const plugins: CMSPlugin[] = [colorPickerPlugin()];
```
### Restart the dev server
Plugins are read at boot. Restart, and the feature is live — in this case a `color` field type you can use in any schema.
That's the whole flow for any published plugin. Some plugins take options (`somePlugin({ collections: ['post'] })`); check the plugin's README.
***
## Write your own plugin
A plugin doesn't have to be a package. The fastest way to learn the SDK is to write one **inside your project** — a normal file you import. Here's a complete, working plugin that runs code every time a document is published:
### Create the file
```ts title="src/lib/plugins/on-publish.ts"
import { definePlugin } from '@aphexcms/cms-core';
export const onPublishPlugin = definePlugin({
name: 'on-publish',
parts: [
{
implements: 'aphex/event/consumer',
id: 'my.on-publish',
events: ['document.published'],
async handler({ event, logger }) {
logger.info('🚀 A document was published!', event.payload);
}
}
]
});
```
### Add it to the registry
```ts title="src/lib/plugins.ts"
import type { CMSPlugin } from '@aphexcms/cms-core';
import { onPublishPlugin } from './plugins/on-publish';
export const plugins: CMSPlugin[] = [onPublishPlugin];
```
### Restart, publish something
Publish any document in the admin. Your handler runs. (Reactions run through the job worker — see [Events & Jobs](/docs/events-and-jobs) for how to keep it running; in local dev it fires within seconds.)
That's a real plugin. It didn't need a package, a build step, or `npm publish` — it lives in your app like any other module. When you understand this shape, the [recipes](#recipes) below are all variations on it.
Anywhere under `src/lib`. A `src/lib/plugins/` folder next to the `plugins.ts` file is a tidy
convention. For a one-liner you can even `definePlugin(...)` inline inside `plugins.ts`.
***
## How it works
A plugin is an object with a `name` and a list of **parts**. Each part `implements` a named extension point — a slot in the CMS it plugs into. `definePlugin` gives you autocomplete: set `implements` and your editor narrows to that part's exact shape.
```ts
definePlugin({
name: 'my-plugin',
parts: [
{
implements: 'aphex/schema',
schemas: [
/* … */
]
}, // add content types
{ implements: 'aphex/event/consumer' /* … */ }, // react to events
{ implements: 'aphex/field/component' /* … */ } // custom field input
// …one plugin can mix as many parts as it needs
]
});
```
### Server parts vs UI parts
The only concept worth internalizing: parts come in two kinds.
| Kind | Examples | Runs |
| ---------------- | -------------------------------------------------------------------------- | ---------------------------- |
| **Server parts** | schemas, API routes, settings, event consumers, job handlers, capabilities | on the server (DB, requests) |
| **UI parts** | field inputs, editor buttons, admin screens | in the browser (admin UI) |
Both live in the same `plugins` array — your project registers that array on both sides for you. There's just **one rule** that keeps it safe:
**A plugin never imports server-only modules.** No `$lib/server/*`, no `$env/dynamic/private`, no
database driver, no `node:` built-ins — anywhere in a plugin file. Server parts get everything
they need **handed to them** at request time (via `c.var.aphexCMS`), so they never need to import
it.
Why: the `plugins` array is imported by the browser too. If a plugin reached for a database driver or a secret, that code would try to ship to the browser — so SvelteKit **fails the build loudly**, not silently. Follow the rule and your plugin is browser-safe no matter how server-heavy its logic is, because the heavy stuff is handed in, never imported:
```ts
// ✅ handed in — safe
async handler({ event, databaseAdapter, logger, settings }) { /* … */ }
// ❌ imported — build fails (and that's the point)
import { db } from '$lib/server/db';
```
***
## Recipes
Each recipe is a self-contained plugin part. Mix as many as you like into one `definePlugin`.
### Run code when a document is published
Use an **event consumer**. It reacts durably — with automatic retries — and is decoupled from the publish, so a slow or failing reaction never blocks the editor. This is how you build webhooks, cache invalidation, notifications, and search-index sync.
```ts
{
implements: 'aphex/event/consumer',
id: 'notify.on-publish', // unique id for this consumer
events: ['document.published'],
maxAttempts: 5, // optional retry budget
async handler({ event, databaseAdapter, logger, settings }) {
// Events carry ids only, so fetch the doc if you need its content:
const doc = await databaseAdapter.findByDocIdAdvanced(
event.organizationId,
String(event.payload.documentId)
);
await fetch('https://example.com/webhook', {
method: 'POST',
body: JSON.stringify({ title: doc?.publishedData?.title })
});
// Throw to retry with backoff; return to mark done.
}
}
```
Handlers can run more than once (at-least-once delivery), so make them **idempotent**. Full model,
including how to define and emit your **own** events, is in [Events &
Jobs](/docs/events-and-jobs).
### Give your plugin a settings screen
Declare the **shape** of your config and the CMS renders a form under **Settings → Plugins**, stores values **per organization**, and encrypts anything marked `secret`. No table, no route, no form to build.
```ts
{
implements: 'aphex/settings',
pluginId: 'my-notify', // storage key — keep it stable
title: 'Notifications',
description: 'Where to send publish alerts.',
fields: [
{ name: 'channel', type: 'string', title: 'Channel name' },
{ name: 'webhookUrl', type: 'secret', title: 'Webhook URL' } // encrypted at rest
]
}
```
Read them **inside a server part** — settings arrive decrypted, scoped to the current org:
```ts
// In an event consumer or job handler:
async handler({ event, settings }) {
const config = await settings.get('my-notify'); // { channel, webhookUrl }
if (typeof config.webhookUrl === 'string') { /* … */ }
}
// In an API route:
handler: async (c) => {
const config = await c.var.aphexCMS.pluginSettingsService.get(orgId, 'my-notify');
}
```
Secrets need an encryption key. Generate one and add it to your `.env`, or saving a secret fails
loudly (it never stores plaintext): `APHEX_SECRET_ENCRYPTION_KEY=$(openssl rand -base64 32)`.
Settings fields are a small set on purpose — `string`, `text`, `number`, `boolean`, and `secret`.
Anything richer belongs in a content type, not settings.
### Add your own field input
Swap the editing UI for a field, selected by an `input` key on the schema. The stored value and validation still come from `type`; `input` only changes the widget.
```ts
// The plugin part:
{ implements: 'aphex/field/component', input: 'color', component: ColorPickerField }
```
```ts
// Use it in any schema — a string, edited with your picker:
{ name: 'brand', type: 'string', input: 'color' }
```
Your component receives a stable `FieldComponentProps` contract (`field`, `value`, `onUpdate`, `readonly`) — the same one built-in inputs use, so it's a drop-in.
A widget re-skins an existing `type`. To offer a first-class `type: 'x'` of your own, combine two pieces:
1. **Augment the field-type registry** so the literal is type-safe wherever your plugin is imported:
```ts
declare module '@aphexcms/cms-core' {
interface FieldTypeMap {
color: ColorField; // ColorField extends BaseField with `type: 'color'`
}
}
```
2. **Add an `aphex/schema/transform` part** that rewrites `{ type: 'color' }` into a built-in field (usually `object`) before the engine, admin, and type generator see it. Use `desugarFieldType` — it owns the tree walk and preserves everything the author declared (`validation`, `access`, `group`):
```ts
import { desugarFieldType } from '@aphexcms/cms-core';
export function expandColorTypes(schemas) {
return desugarFieldType(schemas, {
type: 'color',
sugarKeys: ['alpha'], // sugar-only props that must not survive onto the expansion
build: (f) => color({ name: f.name, title: f.title, alpha: f.alpha === true })
});
}
```
The stored value is always a built-in type, so documents stay portable even if the plugin is removed. `@aphexcms/plugin-color-picker` is a complete worked example.
### Add a button to the document editor
An editor toolbar action. Your component gets a stable `DocumentActionProps` (the current data, `updateData`, `save`, `publish`) — nothing from editor internals.
```ts
{
implements: 'aphex/document/action',
id: 'seo.generate',
title: 'Generate SEO',
component: GenerateSeoAction,
appliesTo: ['blog_post'] // omit for all types
}
```
```svelte title="GenerateSeoAction.svelte"
```
### Add your own screen to the admin
A top-level admin section. Renders as a tab next to Content/Media by default; set `placement: 'sidebar'` to put it in the left nav instead.
```ts
{
implements: 'aphex/admin/tool',
id: 'reports',
title: 'Reports',
icon: BarChart, // any Lucide icon
component: ReportsTool,
placement: 'sidebar'
}
```
Your component receives `AdminToolProps` — the org, the user's capabilities, `can(...)` checks, and navigation helpers. For document data, use the session-authenticated REST client.
### Add an API endpoint
Mounts under `/api`. The handler gets the same context as built-in routes (`c.var.aphexCMS`, `c.var.auth`).
```ts
{
implements: 'aphex/server/route',
id: 'reports.export',
method: 'GET',
path: '/reports/export', // → GET /api/reports/export
requiredCapabilities: ['reports.export'],
handler: async (c) => c.json(await buildReport(c.var.aphexCMS))
}
```
`requiredCapabilities` is **required** — a plugin route is a public-internet endpoint, and there's no safe default. Pick one:
| Value | Who can call it | Use for |
| -------------------- | --------------------------------------------- | ----------------------------------------------- |
| `['reports.export']` | Authenticated **and** holding that capability | The usual case. |
| `[]` | Any signed-in member | When org membership is the authorization. |
| `'public'` | Anyone on the internet | Webhook receivers (verify the caller yourself). |
The route is auto-gated: a request missing what it needs is rejected **before your handler runs** (401/403), so the check can't be forgotten.
`'public'` means *public* — it's the only value that skips the gate, and it's a word you have to
type on purpose. If a capability check is just inconvenient, use `[]` (a signed-in member) instead
of opening it to the internet.
### Add new content types
Ship schemas from a plugin — they merge into your project's content types. Keep the schema in a server-safe module (no component imports).
```ts
import { bookingSchema, customerSchema } from './schemas';
{ implements: 'aphex/schema', schemas: [bookingSchema, customerSchema] }
```
For a plugin's schema types to get generated TypeScript types, they must be visible to `aphex
generate:types`. See [Type generation](#type-generation) below — it's one line.
### Do background work
Register a **job handler** to run work your plugin (or app) enqueues via `databaseAdapter.scheduleJob(...)` — a sync, a report, a cleanup. Where an event consumer is triggered *by an event*, a job handler runs whatever was *explicitly queued*.
```ts
{
implements: 'aphex/job/handler',
handlers: {
'reports.nightly': async ({ job, databaseAdapter, logger }) => {
/* … do the work; throw to retry … */
}
}
}
```
Namespace job types (`reports.nightly`) to avoid collisions. See [Events & Jobs](/docs/events-and-jobs).
### Give an AI agent a tool
Register an **agent tool** and it becomes callable over [MCP](/docs/mcp) with no app-level wiring — same self-registration as event consumers and job handlers. A part is a `{ definition, execute }` pair:
```ts
import { z } from 'zod';
{
implements: 'aphex/agent/tool',
definition: {
name: 'reports_export',
description: 'Export a report as CSV and return a download URL.',
mutates: false,
requiredCapabilities: ['reports.export'],
execution: 'server',
inputSchema: z.object({ reportId: z.string(), month: z.string() })
},
execute: async (input, { aphexCMS, context }) => {
const url = await buildCsv(aphexCMS, context, input.reportId, input.month);
return { success: true, data: { url } };
}
}
```
The split matters: `definition` is serializable and client-safe (name, description, zod schema), so the admin can list a tool without loading server code. `execute` receives services as call-time arguments rather than static imports, keeping the module light enough to sit next to client-shared code.
`requiredCapabilities` is filtered against the calling API key's resolved capabilities — a tool the caller can't use isn't advertised to the model at all.
`requiredCapabilities` is enforced at **both** advertisement and execution. Never rely on the
advertisement filter alone: a tool hidden from an unauthorized caller must also reject a direct
invocation. `mutates` is a display hint for approval flows and audit, not an authorization
mechanism.
A core tool name always wins a collision with a plugin tool, so namespace yours by package.
### Add a permission
Declare a **capability** so it appears in the org **Roles** editor and can gate your routes, actions, and tools.
```ts
import { defineCapability } from '@aphexcms/cms-core';
{
implements: 'aphex/capabilities',
capabilities: [
defineCapability('reports.export', {
title: 'Export reports',
description: 'Download reports as CSV.',
group: 'Reports'
})
]
}
```
**When do you need one?** Only for a genuinely new, sensitive action — exporting PII, refunding a
booking, triggering a deploy. A field widget or a plugin that just reads content through the
standard API needs **none**; it inherits the built-in permissions. Owners get new capabilities
automatically on the next restart; grant them to other roles in the Roles UI.
***
## Share your plugin as a package
Once a homegrown plugin proves itself, you can lift it into its own npm package to reuse across projects or publish. Nothing about the plugin changes — only how it's distributed. It's a normal Svelte-compatible package with split exports so a consumer's server never bundles your components:
```json title="package.json"
{
"name": "@acme/aphex-plugin-reports",
"type": "module",
"exports": {
".": { "svelte": "./dist/index.js" },
"./schema": { "types": "./dist/schema.d.ts", "default": "./dist/schema.js" }
},
"peerDependencies": { "@aphexcms/cms-core": "^9", "svelte": "^5" }
}
```
Consumers then `pnpm add @acme/aphex-plugin-reports` and register it exactly like the [ready-made flow](#use-a-ready-made-plugin) above. Until then, a local file works just as well — don't reach for a package before you need to share.
## Type generation
`aphex generate:types` reads your `schemaTypes` export. If a plugin contributes content types (`aphex/schema`), merge them in so they get generated types too:
```ts title="src/lib/schemaTypes/index.ts"
import { createPartResolver } from '@aphexcms/cms-core';
import { plugins } from '../plugins';
export const schemaTypes = [...appSchemas, ...createPartResolver(plugins).schemaTypes()];
```
Type generation stubs out component imports, so a plugin's UI parts never break it.
# Singletons (/singletons)
A **singleton** is a document type with exactly one row per organization. Use it for content that is global by nature: site navigation, footer config, organization-wide settings, the homepage, the contact page.
Singletons skip the document list in the admin UI, can't be deleted, and are lazy-created the first time they're read — so consumers never have to handle "doesn't exist yet" cases.
## Declaring a singleton
Set `singleton: true` on any `document` schema. Object types ignore the flag.
```ts title="src/lib/schemaTypes/siteNavigation.ts"
import type { SchemaType } from '@aphexcms/cms-core';
import { Menu } from '@lucide/svelte';
const siteNavigation: SchemaType = {
type: 'document',
name: 'siteNavigation',
title: 'Site Navigation',
description: 'Primary navigation links shown in the global header',
icon: Menu,
singleton: true,
fields: [
{ name: 'brand', type: 'string', title: 'Brand Label' },
{
name: 'links',
type: 'array',
title: 'Links',
of: [
{
type: 'object',
name: 'navLink',
title: 'Nav Link',
fields: [
{ name: 'label', type: 'string', title: 'Label' },
{ name: 'url', type: 'string', title: 'URL' },
{ name: 'openInNewTab', type: 'boolean', title: 'Open in New Tab' }
]
}
]
}
]
};
export default siteNavigation;
```
Register it in `src/lib/schemaTypes/index.ts` like any other schema and run `pnpm generate:types` so the type-narrowed `Collections` interface picks it up.
## How they differ from regular documents
` for singleton entries — a TS narrow that hides `find`, `findByID`, `create`, and `delete` from autocompletion.'
}
}}
/>
## Local API
Singletons expose a dedicated `get()` method. Pagination, filters, and IDs are not part of the surface — there's only one row.
```ts title="src/routes/+layout.server.ts"
import { authToContext } from '@aphexcms/cms-core/server';
export const load = async ({ locals }) => {
const api = locals.aphexCMS.localAPI;
const context = authToContext(locals.auth);
const nav = await api.collections.siteNavigation.get(context, {
perspective: 'published'
});
return { nav };
};
```
Calling `find()` on a singleton still works — it returns a single-element page so you can share code paths with regular collections — but `create()` and `delete()` throw `SingletonOperationError`.
```ts
// Equivalent — singletons ignore filters and pagination
const result = await api.collections.siteNavigation.find(context);
result.docs[0]; // the canonical row
```
If you ever need the deterministic ID (for migrations, audits, or external links), use:
```ts
const id = api.collections.siteNavigation.getSingletonId(context);
// → '6f4d2c3b-7a51-4e62-9b1d-...' — stable per org
```
## HTTP API
Singletons are still reachable through the standard document endpoints. The only difference is that mutations match a fixed ID and `DELETE` is rejected.
```bash
# Read the singleton (lazy-creates on first call)
curl -H "x-api-key: $KEY" "/api/documents?type=siteNavigation"
# Update — Aphex resolves the deterministic id for you
curl -X PATCH "/api/documents/$(SINGLETON_ID)" \
-H "Content-Type: application/json" \
-H "x-api-key: $KEY" \
-d '{"data": {"brand": "Aphex"}}'
```
## GraphQL
The generated GraphQL schema differs for singletons. There's no `id` argument and no `allXxx` query — the resolver always returns the canonical row.
```graphql
type Query {
# Get the siteNavigation singleton (lazy-creates an empty draft on first access)
siteNavigation(perspective: String, depth: Int): SiteNavigation!
}
type Mutation {
# Update the siteNavigation singleton
updateSiteNavigation(data: JSON!, publish: Boolean): SiteNavigation!
# Publish the siteNavigation singleton
publishSiteNavigation: SiteNavigation!
# Unpublish the siteNavigation singleton
unpublishSiteNavigation: SiteNavigation!
}
```
`createXxx`, `deleteXxx`, and the `allXxx` query are intentionally absent — they don't make sense on a one-row schema.
## Common patterns
### Site-wide navigation / footer
Mark schemas like `siteNavigation`, `siteFooter`, and `siteSettings` as singletons and load them in `+layout.server.ts`. Editors get one obvious place to update them; the public site reads them with `perspective: 'published'`.
### Singleton "page" documents
The home page or contact page often have unique fields and only one instance ever. Modeling them as singletons removes the create flow from editors and lets you query them by name instead of by slug.
### Per-org settings
Anything you'd otherwise stash in a `settings` table — feature flags, organization branding, default copy — can be a singleton. You get the same admin UI, validation, version history, and access control as regular content.
## See also
# Storage (/storage)
Aphex uses a pluggable storage system for file uploads. By default it writes to the local filesystem so you can develop without setting anything up. For production you'll usually swap to S3, R2, or another S3-compatible backend.
## Quick setup
The base template's `src/lib/server/storage/index.ts` picks an adapter at boot from environment variables:
```ts title="src/lib/server/storage/index.ts"
import { s3Storage } from '@aphexcms/storage-s3';
import { createStorageAdapter } from '@aphexcms/cms-core/server';
import { env } from '$env/dynamic/private';
let storageAdapter;
if (env.R2_BUCKET && env.R2_ENDPOINT && env.R2_ACCESS_KEY_ID && env.R2_SECRET_ACCESS_KEY) {
storageAdapter = s3Storage({
bucket: env.R2_BUCKET,
endpoint: env.R2_ENDPOINT,
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
publicUrl: env.R2_PUBLIC_URL || ''
}).adapter;
} else {
storageAdapter = createStorageAdapter('local', {
basePath: './static/uploads',
baseUrl: '/uploads'
});
}
export { storageAdapter };
```
Leave the `R2_*` vars empty in `.env`. Files land in `./static/uploads/` and are served by SvelteKit's static handler at `/uploads/...`. Restart not required after upload.
```bash title=".env"
R2_BUCKET=my-bucket
R2_ENDPOINT=https://.r2.cloudflarestorage.com
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_PUBLIC_URL=https://cdn.your-app.com
```
Filenames get a timestamp + random suffix. The CMS skips its built-in local adapter when an S3 helper returns `disableLocalStorage: true`.
## Default local adapter
When you omit the `storage` option from `createCMSConfig` entirely, Aphex spins up its **own** local adapter — different from the template's:
| Property | Default local (no template) | Template's `static/uploads` adapter |
| ------------------ | ------------------------------------------ | ----------------------------------- |
| Storage path | `./storage/assets` (private) | `./static/uploads` |
| Serving path | `/media/{id}/{filename}` (CMS handler) | `/uploads/...` (SvelteKit static) |
| Access control | yes — passes through `assetService` | none — anyone with the URL |
| Cache-Control | `max-age=31536000` (1y) | SvelteKit defaults |
| Max file size | 10 MB | configurable |
| Allowed MIME types | jpg / png / webp / gif / avif / pdf / text | configurable |
If you want the access-controlled `/media/...` serving without the template's pass-through, just delete the conditional in `src/lib/server/storage/index.ts` and don't set `storage` on the config at all.
## Switching to S3
```bash
pnpm add @aphexcms/storage-s3
```
`s3Storage()` returns `{ adapter, disableLocalStorage: true }` so you can plug it straight into the config:
```ts title="aphex.config.ts"
import { s3Storage } from '@aphexcms/storage-s3';
import { env } from '$env/dynamic/private';
const storage = s3Storage({
bucket: env.R2_BUCKET,
endpoint: env.R2_ENDPOINT,
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
publicUrl: env.R2_PUBLIC_URL
});
export default createCMSConfig({
storage
// ...
});
```
### Options
### Provider snippets
```ts
s3Storage({
bucket: env.R2_BUCKET,
endpoint: env.R2_ENDPOINT, // https://.r2.cloudflarestorage.com
accessKeyId: env.R2_ACCESS_KEY_ID,
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
publicUrl: env.R2_PUBLIC_URL // your cdn / public bucket URL
});
```
```ts
s3Storage({
bucket: 'my-bucket',
endpoint: 'https://s3.us-east-1.amazonaws.com',
accessKeyId: env.AWS_ACCESS_KEY_ID,
secretAccessKey: env.AWS_SECRET_ACCESS_KEY,
region: 'us-east-1'
});
```
```ts
s3Storage({
bucket: 'my-bucket',
endpoint: 'http://localhost:9000',
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin'
});
```
## Upload flow
When a file is uploaded via the admin UI or `POST /api/assets`:
**Validation** — MIME type and size are checked against the adapter's limits before anything
touches disk.
**Image metadata extraction** — for images, [Sharp](https://sharp.pixelplumbing.com/) extracts
width, height, format, color space, dominant color, and ICC profile presence.
**Storage** — the file is stored via the adapter. S3 helpers generate unique filenames with a
timestamp and random suffix.
**Database record** — an asset row lands in `cms_assets` with the metadata, storage path, and
adapter name. If the database write fails, the storage write is rolled back.
**URL generation** — every asset gets an access-controlled `/media/{id}/{filename}` URL,
regardless of backend. Variants are siblings of it: `/media/{id}/w960-{configHash}.webp`.
## Image metadata
Sharp extracts the following on image upload:
| Field | Description |
| --------------- | ---------------------------------------- |
| `width` | Width in pixels |
| `height` | Height in pixels |
| `format` | Image format (`jpeg`, `png`, `webp`, …) |
| `space` | Color space (`srgb`, `rgb`, …) |
| `channels` | Number of color channels |
| `density` | DPI if available |
| `hasProfile` | Whether an ICC color profile is embedded |
| `hasAlpha` | Whether the image has transparency |
| `dominantColor` | Dominant RGB color |
Stored in the asset's `metadata` JSONB column.
## Asset access control
Assets served through the CDN route `/media/[id]/[filename]` go through the same auth + capability stack as documents:
* **Public assets** — served without authentication.
* **Private assets** — fields marked `private: true` in your schema require an authenticated session, **or a signed URL**.
* **Organization isolation** — assets respect the same multi-tenant rules as documents. Parent orgs can read child org assets.
Every asset is **proxied** through this route by default, including S3 and R2 ones — the bytes are
read with the adapter's `getObject` and streamed back, so the checks above actually decide whether
the caller gets the file.
Older versions redirected S3/R2 assets straight to the bucket's public URL, which meant the access
checks ran and were then bypassed by the redirect — and broke outright on a private bucket. If you
relied on that redirect for bandwidth reasons, see `signedDownloads` below.
Proxying costs a round-trip through your app for every byte, which is the wrong trade for large
files — a 200 MB video download shouldn't occupy a server process. Opt those out with a predicate:
```ts
export default createCMSConfig({
signedDownloads: {
// Access checks still run first: a signed URL is only ever minted for a
// request that was already allowed to read the file.
shouldUseSignedURL: (asset) => asset.size > 25 * 1024 * 1024,
expiresIn: 900 // seconds, default 15 minutes
}
});
```
Requires `getSignedUrl` on the adapter. Without it the route proxies anyway rather than failing —
serving the file correctly beats refusing to serve it.
## Private assets
Mark a field `private: true` and any asset uploaded into it requires authorization to read:
```ts
{ name: 'contract', type: 'file', private: true }
{ name: 'proofSheet', type: 'image', private: true }
```
Both `image` and `file` fields support it. A request for a private asset without authorization gets
`401`; with a session belonging to another organization, `403`. Private responses are always sent
`Cache-Control: private, no-store`, so they never land in a shared cache.
Privacy is resolved from the field an asset was **uploaded into**, recorded on the asset at upload
time. An asset uploaded into a public field and later reused in a private one stays public. If you
need an asset to be private, upload it through the private field — the media library passes the
field along when you open it from one.
### Signed URLs
A session is the wrong mechanism for a public-facing page: an ``, a `