Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Crap CMS

Crap CMS is a headless content management system built in Rust. It combines a compiled core with Lua hooks (neovim-style) and an overridable HTMX admin UI.

Motivation

I built several Rust/WebAssembly frontend projects and couldn’t find a CMS that fit the stack. So I built one.

The idea: a simple CMS written in Rust, extensible via a lightweight scripting language, with no complicated build steps or infrastructure requirements.

Inspiration came from what I consider the best solutions out there:

  • Lua scripting API — modeled after Neovim and Awesome WM, where Lua gives users deep control without touching core code
  • Configuration & hook system — inspired by Payload CMS, an excellent and highly recommended CMS for anyone needing a production-ready solution
  • CLI tooling — influenced by Laravel’s comprehensive Artisan CLI
  • SQLite + WAL + FTS — sufficient for most of my use cases, and it bundles cleanly into a single binary with zero external dependencies. The database layer is abstracted behind a trait, so alternative relational backends could be added in the future if the need arises
  • Pure JavaScript with JSDoc types — no TypeScript, no bundler, no build step. Type safety through JSDoc annotations, checkable with tsc --checkJs without compiling anything
  • HTMX + Web Components — easy to theme (similar to WordPress child themes), no frontend build step. Web Components are a native browser standard — no framework updates, no outdated dependencies, no breaking changes
  • gRPC API — binary protocol with streaming support, ideal for service-to-service communication. And because I wanted it. A separate REST proxy is available for those who prefer plain JSON over HTTP

The project is functional but not yet production-ready — it still needs to prove itself.

Warning: While in alpha (0.x), breaking changes may appear without prior notice.

Design Philosophy

  • Lua is the single source of truth for schemas. Collections and fields are defined in Lua files, not in the database. The database stores content, not structure.
  • Single binary. The admin UI (Axum) and gRPC API (Tonic) run in one process on two ports.
  • Config directory pattern. All customization lives in one directory, auto-detected from CWD (or set explicitly with --config/-C).
  • No JS build step. The admin UI uses Handlebars templates + HTMX with plain CSS.
  • Hooks are the extension system. Lua hooks at three levels (field, collection, global) provide full lifecycle control with transaction-safe CRUD access.

Feature Set

  • Collections with 20 field types (text, number, textarea, richtext, select, radio, checkbox, date, email, json, code, relationship, array, group, upload, blocks, row, collapsible, tabs, join)
  • Globals — single-document collections for site-wide settings
  • Hooks — field-level, collection-level, and globally registered lifecycle hooks
  • Access Control — collection-level and field-level, with filter constraint support
  • Authentication — JWT sessions, password login, custom auth strategies, email verification, password reset
  • Uploads — file uploads with automatic image resizing and format conversion (WebP, AVIF)
  • Relationships — has-one and has-many with configurable population depth
  • Localization — per-field opt-in localization with locale-suffixed columns
  • Versions & Drafts — document version history with draft/publish workflow
  • Live Updates — real-time mutation events via SSE and gRPC streaming
  • Admin UI — template overlay system, theme switching, Web Components
  • gRPC API — full CRUD with filtering, pagination, and server reflection
  • CLI Tooling — interactive scaffolding wizard, blueprints, data export/import, backups

Tech Stack

ComponentTechnology
LanguageRust (edition 2024)
Web frameworkAxum
gRPCTonic + Prost
DatabaseSQLite via rusqlite, r2d2 pool, WAL mode
TemplatesHandlebars + HTMX
HooksLua 5.4 via mlua
IDsnanoid

Installation

Static Binary

Pre-built static binaries are attached to each GitHub Release. No runtime dependencies required.

curl -L -o crap-cms \
  https://github.com/dkluhzeb/crap-cms/releases/latest/download/crap-cms-linux-x86_64
chmod +x crap-cms
sudo mv crap-cms /usr/local/bin/

Available binaries:

FilePlatform
crap-cms-linux-x86_64Linux x86_64 (musl, fully static)
crap-cms-linux-aarch64Linux ARM64 (musl, fully static)
crap-cms-windows-x86_64.exeWindows x86_64

Docker

docker run -p 3000:3000 -p 50051:50051 \
  ghcr.io/dkluhzeb/crap-cms:latest serve -C /example

Images are Alpine-based (~30 MB) and published to ghcr.io/dkluhzeb/crap-cms. See the README for production usage and available tags.

Building from Source

Requires a Rust toolchain (edition 2024) via rustup and a C compiler:

git clone https://github.com/dkluhzeb/crap-cms.git
cd crap-cms
cargo build --release

The binary is at target/release/crap-cms. SQLite and Lua are bundled — no system libraries needed.

Optional Tools

  • grpcurl — for testing the gRPC API from the command line. See grpcurl installation.
  • lua-language-server (LuaLS) — for IDE autocompletion in Lua config files. The project provides type definitions in types/crap.lua.

Quick Start

1. Scaffold a new project

The fastest way to get started is the interactive init wizard:

crap-cms init ./my-project

The wizard walks you through:

  1. Admin port (default: 3000)
  2. gRPC port (default: 50051)
  3. Localization — enable and choose locales (e.g., en, de, fr)
  4. Auth collection — creates a users collection with email/password login
  5. First admin user — prompts for email and password right away
  6. Upload collection — creates a media collection for file/image uploads
  7. Additional collections — keep adding as many as you need

A JWT auth secret is auto-generated and written to crap.toml so tokens survive restarts.

When it finishes you’ll have a ready-to-run config directory:

my-project/
├── crap.toml
├── init.lua
├── .luarc.json
├── .gitignore
├── collections/
│   ├── users.lua
│   └── media.lua
├── globals/
├── hooks/
├── migrations/
├── templates/
├── static/
├── types/
│   └── crap.lua
├── data/
└── uploads/

2. Start the server

cd my-project
crap-cms serve

This starts:

3. Log in to the admin UI

Visit http://localhost:3000/admin/login and sign in with the credentials you created during init.

If you skipped user creation during init, bootstrap one now:

# Interactive (prompts for password)
crap-cms user create -e admin@example.com

# Non-interactive
crap-cms user create \
    -e admin@example.com \
    -p secret123 \
    -f role=admin \
    -f name="Admin User"

4. Create content via gRPC

Use grpcurl to interact with the API. The server supports reflection, so no proto import is needed:

# List all posts
grpcurl -plaintext localhost:50051 crap.ContentAPI/Find \
    -d '{"collection": "posts"}'

# Create a post
grpcurl -plaintext localhost:50051 crap.ContentAPI/Create \
    -d '{
      "collection": "posts",
      "data": {
        "title": "Hello World",
        "slug": "hello-world",
        "status": "draft",
        "content": "My first post."
      }
    }'

Alternative: Run with the example config

The repository includes an example/ config directory with sample collections, useful if you’re building from source:

git clone https://github.com/dkluhzeb/crap-cms.git
cd crap-cms
cargo build --release
./target/release/crap-cms serve -C ./example

Then bootstrap an admin user:

crap-cms user create -C ./example -e admin@example.com

Config Directory

All customization lives in a single config directory. When you run CLI commands from inside this directory (or a subdirectory), the config is auto-detected by walking up and looking for crap.toml. You can also set it explicitly with --config/-C or the CRAP_CONFIG_DIR environment variable.

Directory Structure

my-project/
├── crap.toml              # Server/database/auth configuration
├── init.lua               # Runs at startup (register global hooks, etc.)
├── .luarc.json            # LuaLS config for IDE support
├── .gitignore             # Ignores data/, uploads/, types/ by default
├── collections/           # One .lua file per collection
│   ├── posts.lua
│   ├── users.lua
│   └── media.lua
├── globals/               # One .lua file per global
│   └── site_settings.lua
├── hooks/                 # Lua modules referenced by hook strings
│   ├── posts.lua
│   └── access.lua
├── migrations/            # Custom SQL migrations (see `migrate` command)
├── templates/             # Handlebars overrides for admin UI
│   └── fields/
│       └── custom.hbs
├── translations/          # Admin UI translation overrides (JSON per locale)
│   └── de.json
├── static/                # Static file overrides (CSS, JS, fonts)
├── data/                  # Runtime data (auto-created)
│   ├── crap.db            # SQLite database
│   ├── crap.pid           # Process ID file (when running with --detach)
│   └── logs/              # Rotating log files (when [logging] file = true)
├── uploads/               # Uploaded files (auto-created per collection)
│   └── media/
└── types/                 # Auto-generated type definitions (see `typegen`)
    ├── crap.lua           # API surface types (crap.* functions)
    └── generated.lua      # Per-collection types (data, doc, hook, filters)

File Loading Order

  1. crap.toml is loaded first (or defaults are used if absent)
  2. collections/*.lua files are loaded alphabetically
  3. globals/*.lua files are loaded alphabetically
  4. init.lua is executed last

Lua Package Path

The config directory is prepended to Lua’s package.path:

<config_dir>/?.lua;<config_dir>/?/init.lua;...

This means require("hooks.posts") resolves to <config_dir>/hooks/posts.lua.

LuaLS Support

Create a .luarc.json in your config directory for IDE autocompletion:

{
    "runtime": { "version": "Lua 5.4" },
    "workspace": { "library": ["./types"] }
}

Generate type definitions with:

crap-cms typegen

This writes two files: types/crap.lua (API surface types for the crap.* functions) and types/generated.lua (per-collection types derived from your field definitions). Use -l all to generate types for all supported languages.

crap.toml

The crap.toml file configures the server, database, authentication, and other global settings. All sections and fields are optional — sensible defaults are used when omitted.

If no crap.toml file exists in the config directory, all defaults are used. An empty file is also valid — all defaults apply.

Top-Level Fields

FieldTypeDefaultDescription
crap_versionstringExpected CMS version. If set, a warning is logged on startup when the running binary doesn’t match. Supports exact ("0.1.0") or prefix ("0.1") matching.

Environment Variable Substitution

String values in crap.toml can reference environment variables using ${VAR} syntax:

[auth]
secret = "${JWT_SECRET}"

[database]
path = "${DB_PATH:-data/crap.db}"

[email]
smtp_pass = "${SMTP_PASSWORD}"
  • ${VAR} — replaced with the value of VAR. Startup fails if VAR is not set.
  • ${VAR:-default} — replaced with VAR if set and non-empty, otherwise uses default.

Substitution only applies to string values — ${VAR} patterns in comments are safely ignored.

This allows keeping secrets out of config files and varying configuration across environments.

Duration Values

Most time-related fields accept an integer (seconds), a human-readable string with a suffix, or a bare number string:

# These are all equivalent:
token_expiry = 7200
token_expiry = "7200"
token_expiry = "2h"

# Supported suffixes: s (seconds), m (minutes), h (hours), d (days)
poll_interval = "5s"
login_lockout_seconds = "5m"
auto_purge = "7d"

Fields that support this: token_expiry, login_lockout_seconds, reset_token_expiry, forgot_password_window_seconds, max_age, poll_interval, cron_interval, heartbeat_interval, auto_purge, grpc_rate_limit_window, connection_timeout, smtp_timeout, busy_timeout, request_timeout, grpc_timeout.

File Size Values

File size fields accept both an integer (bytes) and a human-readable string:

# These are equivalent:
max_file_size = 52428800
max_file_size = "50MB"

# Supported suffixes (case-insensitive, 1024-based):
# B (bytes), KB (kilobytes), MB (megabytes), GB (gigabytes)
max_file_size = "500B"
max_file_size = "100KB"
max_file_size = "1GB"

Fields that support this: max_file_size (global and per-collection), max_memory, http_max_response_bytes, grpc_max_message_size.

Configuration Validation

crap.toml is validated at startup. Fatal validation errors prevent the server from starting with a descriptive error message. Non-fatal issues log warnings.

Fatal errors:

  • database.pool_max_size = 0
  • database.connection_timeout = 0
  • hooks.vm_pool_size = 0
  • server.admin_port or server.grpc_port is 0
  • server.admin_port == server.grpc_port (ports must be distinct)
  • auth.password_policy.min_length > auth.password_policy.max_length

Warnings (server starts but logs a warning):

  • jobs.max_concurrent = 0 — no jobs will execute
  • auth.secret is set but shorter than 32 characters
  • depth.max_depth = 0 — all population requests capped to 0

Full Reference

# Optional: warn if the running binary doesn't match this version
# crap_version = "0.1.0"

[server]
admin_port = 3000       # Admin UI port
grpc_port = 50051       # gRPC API port
host = "0.0.0.0"        # Bind address
# public_url = "https://cms.example.com"  # Public-facing base URL for generated links
# h2c = false           # Enable HTTP/2 cleartext (for reverse proxies)
# trust_proxy = false   # Trust X-Forwarded-For (enable behind reverse proxy)
# compression = "off"   # "off" (default), "gzip", "br", "all"
# grpc_reflection = false        # Enable gRPC server reflection (default: false)
# grpc_rate_limit_requests = 0   # Per-IP request limit (0 = disabled, recommended: 100)
# grpc_rate_limit_window = 60    # Sliding window in seconds (or "1m")
# grpc_max_message_size = "16MB" # Max gRPC message size (default 16MB)
# request_timeout = "30s"        # Admin HTTP request timeout (none by default)
# grpc_timeout = "30s"           # gRPC request timeout (none by default)

[database]
path = "data/crap.db"   # Relative to config dir, or absolute
pool_max_size = 32       # Max connections in the pool
busy_timeout = "30s"     # SQLite busy timeout (integer ms or "30s", "1m")
connection_timeout = 5   # Pool checkout timeout (seconds or "5s")

[admin]
dev_mode = false         # Reload templates per-request (enable in development)
require_auth = true      # Block admin when no auth collection exists (default: true)
# access = "access.admin_panel"  # Lua function: which users can access the admin UI

# [admin.csp]                    # Content-Security-Policy (enabled by default)
# enabled = true
# script_src = ["'self'", "'unsafe-inline'", "https://unpkg.com"]
# style_src = ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"]
# font_src = ["'self'", "https://fonts.gstatic.com"]
# img_src = ["'self'", "data:"]
# connect_src = ["'self'"]
# frame_ancestors = ["'none'"]
# form_action = ["'self'"]
# base_uri = ["'self'"]

[auth]
secret = ""              # JWT signing key. Empty = auto-generated and persisted to data/.jwt_secret
token_expiry = "2h"      # Default token expiry (accepts integer seconds or "2h", "30m", etc.)
max_login_attempts = 5   # Failed attempts per email before temporary lockout
max_ip_login_attempts = 20  # Failed attempts per IP before lockout (higher for shared IPs)
login_lockout_seconds = "5m"  # Lockout duration after max attempts
reset_token_expiry = "1h"    # Password reset token expiry
max_forgot_password_attempts = 3   # Forgot-password requests per email before rate limiting
forgot_password_window_seconds = "15m"  # Rate limit window for forgot-password

[auth.password_policy]
min_length = 8              # Minimum password length
max_length = 128            # Maximum password length (DoS protection)
# require_uppercase = false # Require at least one uppercase letter
# require_lowercase = false # Require at least one lowercase letter
# require_digit = false     # Require at least one digit
# require_special = false   # Require at least one special character

[depth]
default_depth = 1        # Default population depth for FindByID (Find always defaults to 0)
max_depth = 10           # Hard cap on population depth (prevents abuse)
# populate_cache = false           # Cross-request populate cache (opt-in)
# populate_cache_max_age_secs = 0  # Periodic cache clear for external DB mutations

[pagination]
default_limit = 20      # Default limit for Find queries (when none is specified)
max_limit = 1000         # Hard cap on limit — requests above this are clamped
# mode = "page"          # "page" (offset) or "cursor" (keyset)

[upload]
max_file_size = "50MB"   # Global max file size (accepts bytes or "50MB", "1GB", etc.)

[email]
smtp_host = ""           # SMTP server hostname. Empty = email disabled (no-op)
smtp_port = 587          # SMTP port (587 for STARTTLS, 465 for TLS, 25/1025 for plain)
smtp_user = ""           # SMTP username
smtp_pass = ""           # SMTP password
smtp_tls = "starttls"    # "starttls" (default), "tls" (implicit TLS), "none" (plain/test)
from_address = "noreply@example.com"  # Sender email address
from_name = "Crap CMS"  # Sender display name
# smtp_timeout = 30     # SMTP connection/send timeout in seconds (or "30s")

[hooks]
on_init = []             # Lua function refs to run at startup (with CRUD access)
# max_depth = 3          # Max hook recursion depth (0 = no hooks from Lua CRUD)
vm_pool_size = 8         # Number of Lua VMs for concurrent hook execution
                         # Default: max(available_parallelism, 4), capped at 32
max_instructions = 10000000  # Max Lua instructions per hook (0 = unlimited)
max_memory = "50MB"          # Max Lua memory per VM (0 = unlimited)
allow_private_networks = false  # Block HTTP requests to private/loopback IPs
http_max_response_bytes = "10MB"  # Max HTTP response body size

[live]
enabled = true           # Enable SSE + gRPC Subscribe for live mutation events
channel_capacity = 1024  # Broadcast channel buffer size
# max_sse_connections = 1000        # Max concurrent SSE connections (0 = unlimited)
# max_subscribe_connections = 1000  # Max concurrent gRPC Subscribe streams (0 = unlimited)

[locale]
default_locale = "en"    # Default locale code
locales = ["en", "de"]   # Supported locales (empty = disabled)
fallback = true          # Fall back to default locale if field is NULL

[jobs]
max_concurrent = 10          # Max concurrent job executions across all queues
poll_interval = "1s"         # How often to poll for pending jobs
cron_interval = "1m"         # How often to check cron schedules
heartbeat_interval = "10s"   # How often running jobs update their heartbeat
auto_purge = "7d"            # Auto-purge completed/failed runs older than this
image_queue_batch_size = 10  # Pending image conversions to process per poll

[access]
default_deny = false     # When true, deny all operations without explicit access functions

[cors]
allowed_origins = []     # Origins allowed for CORS. Empty = CORS disabled (default)
                         # Use ["*"] to allow any origin
allowed_methods = ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]
allowed_headers = ["Content-Type", "Authorization"]
exposed_headers = []     # Response headers exposed to the browser
max_age = "1h"   # How long browsers cache preflight results
allow_credentials = false # Allow cookies/Authorization. Cannot use with ["*"] origins

[mcp]
# enabled = false         # Enable MCP server
# http = false            # Mount POST /mcp on admin server
# config_tools = false    # Enable config read/write tools
# api_key = ""            # API key for HTTP transport
# include_collections = [] # Only expose these collections
# exclude_collections = [] # Hide these collections

Section Details

[server]

FieldTypeDefaultDescription
admin_portinteger3000Port for the Axum admin UI
grpc_portinteger50051Port for the Tonic gRPC API
hoststring"0.0.0.0"Bind address for both servers
h2cbooleanfalseEnable HTTP/2 cleartext (h2c). Allows reverse proxies (Caddy, nginx) to speak HTTP/2 to the backend without TLS. Browsers that don’t support h2c fall back to HTTP/1.1 on the same port.
trust_proxybooleanfalseTrust the X-Forwarded-For header for client IP extraction on the admin HTTP server. Enable when running behind a reverse proxy (nginx, Caddy, etc.) so per-IP rate limiting uses the real client IP. When false (default), the TCP socket address is used and XFF is ignored — preventing IP spoofing when exposed directly to the internet. Does not affect the gRPC server, which always uses the TCP peer address from Tonic’s remote_addr().
compressionstring"off"Response compression. "off" = disabled (default), "gzip" = gzip only, "br" = brotli only, "all" = gzip + brotli. Most deployments use a reverse proxy (nginx/caddy) for compression, so this is opt-in.
grpc_reflectionbooleanfalseEnable gRPC server reflection. Allows clients (e.g., grpcurl, Postman) to discover services and methods without a .proto file. Disabled by default to hide the API surface from unauthenticated probing.
public_urlstringPublic-facing base URL (e.g., "https://cms.example.com"). Used for password reset emails and other generated links. If not set, defaults to http://{host}:{admin_port}.
grpc_rate_limit_requestsinteger0Maximum number of gRPC requests per IP within the sliding window. 0 = disabled (default). Recommended to enable in production (e.g., 100). When enabled, requests exceeding the limit receive ResourceExhausted status.
grpc_rate_limit_windowinteger/string60 ("1m")Sliding window duration for rate limiting. Accepts seconds (integer) or human-readable ("1m", "30s").
grpc_max_message_sizeinteger/string16777216 ("16MB")Maximum gRPC message size in bytes (applies to both send and receive). Tonic’s built-in default is 4MB, which can be exceeded by large Find responses with deep population. Accepts bytes or file size string ("16MB", "32MB").
request_timeoutinteger/string— (none)Admin HTTP request timeout. When set, requests exceeding this duration return 408 Request Timeout. SSE streams are exempt (handled by shutdown). Accepts seconds or human-readable ("30s", "5m").
grpc_timeoutinteger/string— (none)gRPC request timeout. When set, RPCs exceeding this duration return DEADLINE_EXCEEDED. Applies to all RPCs including Subscribe streams. Accepts seconds or human-readable ("30s", "5m").

[database]

FieldTypeDefaultDescription
pathstring"data/crap.db"SQLite database path. Relative paths are resolved from the config directory. Absolute paths are used as-is.
pool_max_sizeinteger32Maximum number of connections in the SQLite connection pool.
busy_timeoutduration30000 ("30s")SQLite busy timeout in milliseconds. Controls how long a connection waits for locks before returning SQLITE_BUSY. Accepts integer ms or human-readable string ("30s", "1m").
connection_timeoutduration5Pool checkout timeout in seconds. How long pool.get() waits for a free connection before returning an error.

[admin]

FieldTypeDefaultDescription
dev_modebooleanfalseWhen true, templates are reloaded from disk on every request. The scaffold sets this to true for new projects. Set to false in production for cached templates.
require_authbooleantrueWhen true and no auth collection exists, the admin panel shows a “Setup Required” page (HTTP 503) instead of being open. Set to false for fully open dev mode without authentication.
accessstringLua function ref (e.g., "access.admin_panel") that gates admin panel access. Called after successful authentication with { user } context. Return true to allow, false/nil to deny (HTTP 403).
default_timezonestring""Default IANA timezone for date fields with timezone = true that don’t specify their own default_timezone. Pre-selects the timezone in the admin dropdown. Example: "America/New_York".
csptable(see below)Content-Security-Policy header configuration. See [admin.csp].

[admin.csp]

Content-Security-Policy header configuration for the admin UI. Each field is a list of CSP sources for the corresponding directive. Theme developers can extend these lists to allow external resources (CDNs, custom fonts, analytics, etc.).

FieldTypeDefaultDescription
enabledbooleantrueEnable the CSP header. Set to false to disable entirely.
default_srcstring[]["'self'"]Fallback for any directive not explicitly set.
script_srcstring[]["'self'", "'unsafe-inline'", "https://unpkg.com"]Allowed script sources. Includes 'unsafe-inline' for theme bootstrap and CSRF injection scripts.
style_srcstring[]["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"]Allowed stylesheet sources. Includes 'unsafe-inline' for Web Component Shadow DOM styles.
font_srcstring[]["'self'", "https://fonts.gstatic.com"]Allowed font sources. Includes Google Fonts for Material Symbols icons.
img_srcstring[]["'self'", "data:"]Allowed image sources. Includes data: for inline SVGs.
connect_srcstring[]["'self'"]Allowed targets for fetch, XHR, and WebSocket connections.
frame_ancestorsstring[]["'none'"]Who can embed this page in a frame. 'none' prevents clickjacking.
form_actionstring[]["'self'"]Allowed form submission targets.
base_uristring[]["'self'"]Allowed URLs for <base> tags.

Example: allowing a custom CDN and analytics:

[admin.csp]
script_src = ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://cdn.example.com", "https://analytics.example.com"]
style_src = ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.example.com"]
font_src = ["'self'", "https://fonts.gstatic.com", "https://cdn.example.com"]
img_src = ["'self'", "data:", "https://cdn.example.com"]
connect_src = ["'self'", "https://analytics.example.com"]

[auth]

FieldTypeDefaultDescription
secretstring"" (empty)JWT signing secret. If empty, a random secret is auto-generated and persisted to data/.jwt_secret so tokens survive restarts. Set explicitly if you prefer to manage the secret yourself.
token_expiryinteger/string7200 ("2h")Default JWT token lifetime. Accepts seconds (integer) or human-readable ("2h", "30m"). Can be overridden per auth collection.
max_login_attemptsinteger5Maximum failed login attempts per email before temporary lockout.
max_ip_login_attemptsinteger20Maximum failed login attempts per IP before temporary lockout. Higher than per-email to tolerate shared IPs (offices, NAT). Also used as the per-IP threshold for forgot-password requests.
login_lockout_secondsinteger/string300 ("5m")Duration of lockout after max_login_attempts or max_ip_login_attempts is reached. Accepts seconds or human-readable.
reset_token_expiryinteger/string3600 ("1h")Password reset token expiry. The “Forgot password” email link expires after this duration. Accepts seconds or human-readable.
max_forgot_password_attemptsinteger3Maximum forgot-password requests per email address before rate limiting. Further requests silently return success without sending email.
forgot_password_window_secondsinteger/string900 ("15m")Rate limit window for forgot-password requests. Also used as the per-IP window for forgot-password rate limiting. Accepts seconds or human-readable.

[auth.password_policy]

Password strength requirements applied to all password-setting paths (create, update, reset, CLI).

FieldTypeDefaultDescription
min_lengthinteger8Minimum password length in Unicode characters (codepoints). Multi-byte characters (accented letters, CJK, emoji) each count as 1. Must be ≤ max_length or the server refuses to start.
max_lengthinteger128Maximum password length in bytes. Prevents DoS via Argon2 on huge inputs. Uses byte count (not characters) to bound hashing cost.
require_uppercasebooleanfalseRequire at least one uppercase letter (A-Z).
require_lowercasebooleanfalseRequire at least one lowercase letter (a-z).
require_digitbooleanfalseRequire at least one digit (0-9).
require_specialbooleanfalseRequire at least one special (non-alphanumeric) character.

[depth]

FieldTypeDefaultDescription
default_depthinteger1Default population depth for FindByID. Find always defaults to 0.
max_depthinteger10Maximum allowed depth for any request. Hard cap to prevent excessive queries.
populate_cachebooleanfalseEnable cross-request populate cache. Caches populated documents in memory, cleared on any write through the API. Improves read performance for repeated deep population. Opt-in because external DB modifications can cause stale reads.
populate_cache_max_age_secsinteger0Periodic full cache clear interval in seconds. 0 = disabled (only write-through invalidation). Set > 0 to limit staleness when the database may be modified outside the API. Only used when populate_cache = true.

[pagination]

FieldTypeDefaultDescription
default_limitinteger20Default page size applied to Find queries when no limit is specified.
max_limitinteger1000Hard cap on limit. Requests above this value are clamped to max_limit.
modestring"page"Pagination mode: "page" (offset-based with page/totalPages) or "cursor" (keyset-based with startCursor/endCursor). In cursor mode, pass after_cursor (forward) or before_cursor (backward) instead of page.

[upload]

FieldTypeDefaultDescription
max_file_sizeinteger/string52428800 ("50MB")Global maximum file size. Accepts bytes (integer) or human-readable ("50MB", "1GB"). Per-collection max_file_size overrides this. Also sets the HTTP body limit (with 1MB overhead for multipart encoding).

[email]

FieldTypeDefaultDescription
smtp_hoststring"" (empty)SMTP server hostname. Empty = email disabled — all send attempts log a warning and return Ok.
smtp_portinteger587SMTP port. 587 is the standard STARTTLS port.
smtp_userstring""SMTP authentication username.
smtp_passstring""SMTP authentication password.
smtp_tlsstring"starttls"TLS mode: "starttls" (default, port 587), "tls" (implicit TLS, port 465), "none" (plain, for testing).
from_addressstring"noreply@example.com"Sender email address for outgoing mail.
from_namestring"Crap CMS"Sender display name.
smtp_timeoutinteger/string30SMTP connection and send timeout in seconds. Accepts integer or duration string ("30s", "1m").

When configured, email enables password reset (“Forgot password?” link on login), email verification (optional per-collection), and the crap.email.send() Lua API.

[hooks]

FieldTypeDefaultDescription
on_initstring[][]Lua function refs to execute at startup. These run synchronously with CRUD access — failure aborts startup.
max_depthinteger3Maximum hook recursion depth. When Lua CRUD in hooks triggers more hooks, this caps the chain. 0 = never run hooks from Lua CRUD.
vm_pool_sizeintegermax(cpus, 4) capped at 32Number of Lua VMs in the pool for concurrent hook execution. Default is the number of available CPU cores with a floor of 4 and ceiling of 32.
max_instructionsinteger10000000Maximum Lua instructions per hook invocation. 0 = unlimited.
max_memoryinteger/string52428800 (50 MB)Maximum Lua memory per VM in bytes. Accepts integer or filesize string ("50MB", "100MB"). 0 = unlimited.
allow_private_networksbooleanfalseAllow crap.http.request to reach private/loopback/link-local IPs.
http_max_response_bytesinteger/string10485760 (10 MB)Maximum HTTP response body size. Accepts integer or filesize string ("10MB", "1GB").

[live]

FieldTypeDefaultDescription
enabledbooleantrueEnable live event streaming (SSE + gRPC Subscribe).
channel_capacityinteger1024Internal broadcast channel buffer size. Increase if subscribers lag.
max_sse_connectionsinteger1000Maximum concurrent SSE connections. When reached, new connections receive 503 Service Unavailable. 0 = unlimited.
max_subscribe_connectionsinteger1000Maximum concurrent gRPC Subscribe streams. When reached, new subscriptions receive UNAVAILABLE status. 0 = unlimited.

See Live Updates for full documentation.

[locale]

FieldTypeDefaultDescription
default_localestring"en"Default locale code. Content without an explicit locale uses this.
localesstring[][] (empty)Supported locale codes. Empty = localization disabled. When empty, all fields behave as before (single value, no locale columns).
fallbackbooleantrueWhen reading a non-default locale, fall back to the default locale value if the requested locale field is NULL. Uses COALESCE in SQL.

[jobs]

FieldTypeDefaultDescription
max_concurrentinteger10Maximum concurrent job executions across all queues.
poll_intervalinteger/string1 ("1s")How often to poll for pending jobs. Accepts seconds or human-readable.
cron_intervalinteger/string60 ("1m")How often to evaluate cron schedules. Accepts seconds or human-readable.
heartbeat_intervalinteger/string10 ("10s")How often running jobs update their heartbeat. Used to detect stale jobs. Accepts seconds or human-readable.
auto_purgeinteger/string"7d"Auto-purge completed/failed runs older than this duration. Accepts seconds or human-readable ("7d", "24h", "30m", "3600"). Set to "" (empty string) to disable auto-purge. Absent = 7 days default.
image_queue_batch_sizeinteger10Number of pending image format conversions to claim per scheduler poll cycle. Increase for higher throughput on capable hardware.

[access]

FieldTypeDefaultDescription
default_denybooleanfalseWhen true, collections and globals without an explicit access function deny all operations. When false (default), missing access functions allow all operations.

Enable this to enforce a “secure by default” posture — every collection must explicitly declare its access rules. Without it, collections without access functions are open to any authenticated (or anonymous) user.

[cors]

FieldTypeDefaultDescription
allowed_originsstring[][] (empty)Origins allowed to make cross-origin requests. Empty = CORS disabled (no layer added, default). Use ["*"] to allow any origin.
allowed_methodsstring[]["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"]HTTP methods allowed in CORS preflight.
allowed_headersstring[]["Content-Type", "Authorization"]Request headers allowed in CORS requests.
exposed_headersstring[][] (empty)Response headers the browser is allowed to access.
max_ageinteger/string3600 ("1h")How long browsers may cache preflight results. Accepts seconds or human-readable.
allow_credentialsbooleanfalseAllow credentials (cookies, Authorization header). Cannot be used with allowed_origins = ["*"] — if both are set, credentials are ignored with a warning.

When CORS is enabled, the layer is added to both the admin UI (Axum) and gRPC API (Tonic) servers. CORS runs before CSRF middleware, so preflight OPTIONS requests get CORS headers without triggering CSRF validation.

[mcp]

FieldTypeDefaultDescription
enabledbooleanfalseEnable the MCP (Model Context Protocol) server. Required for both stdio and HTTP transports.
httpbooleanfalseMount POST /mcp on the admin server for HTTP-based MCP access.
config_toolsbooleanfalseEnable config generation tools (read_config_file, write_config_file, list_config_files). Opt-in because they allow filesystem writes.
api_keystring"" (empty)API key for HTTP transport. Required when http = true — the server will refuse to start without one. Requests must include Authorization: Bearer <key>.
include_collectionsstring[][] (empty)Only expose these collections via MCP. Empty = all collections. Enforced at both tool listing and execution time.
exclude_collectionsstring[][] (empty)Hide these collections from MCP. Takes precedence over include_collections. Enforced at both tool listing and execution time.

See MCP Overview for usage details.

[logging]

FieldTypeDefaultDescription
filebooleanfalseEnable file-based logging. When false (default), logs go to stdout only. Auto-enabled when running with --detach (where stdout is unavailable).
pathstring"data/logs"Log directory path. Relative paths are resolved from the config directory. Use an absolute path to log elsewhere.
rotationstring"daily"Log rotation strategy: "daily" (one file per day), "hourly" (one file per hour), or "never" (single file, no rotation).
max_filesinteger30Maximum rotated log files to keep. Old files are pruned on startup.

File logging writes to rotating files in the configured directory. Each project has its own log directory, so multiple instances on the same machine are naturally isolated.

Use crap-cms logs to view log output, crap-cms logs -f to follow in real time, and crap-cms logs clear to remove old rotated files. See the CLI Reference for details.

When locales are configured, any field with localized = true in its Lua definition gets one column per locale (title__en, title__de) instead of a single title column. The API accepts a locale parameter on Find, FindByID, Create, Update, GetGlobal, and UpdateGlobal to control which locale to read/write. The admin UI shows a locale selector in the edit sidebar.

Special locale values:

  • "all" — returns all locales as nested objects: { title: { en: "Hello", de: "Hallo" } }
  • Any locale code (e.g., "en", "de") — returns flat field names with that locale’s values
  • Omitted — uses the default locale

Example

[server]
admin_port = 8080
grpc_port = 9090
host = "127.0.0.1"

[database]
path = "/var/lib/crap/production.db"

[admin]
dev_mode = false
# require_auth = true
# access = "access.admin_panel"

[auth]
secret = "a-very-long-random-string-for-jwt-signing"
token_expiry = "24h"
max_login_attempts = 10
login_lockout_seconds = "10m"

[depth]
default_depth = 1
max_depth = 5

[upload]
max_file_size = "100MB"

[email]
smtp_host = "smtp.example.com"
smtp_port = 587
smtp_user = "noreply@example.com"
smtp_pass = "your-smtp-password"
from_address = "noreply@example.com"
from_name = "My App"

[logging]
file = true
# path = "data/logs"
# rotation = "daily"
# max_files = 30

[hooks]
on_init = ["hooks.seed.run"]
vm_pool_size = 8
max_instructions = 10000000
max_memory = "50MB"
allow_private_networks = false
http_max_response_bytes = "10MB"

Localization

Crap CMS supports per-field localization, allowing content to be managed in multiple languages. Any field type can be marked localized, and the API returns data differently based on a locale parameter.

Configuration

Enable localization by adding a [locale] section to crap.toml:

[locale]
default_locale = "en"
locales = ["en", "de", "fr"]
fallback = true
FieldDefaultDescription
default_locale"en"Default locale code. Content without an explicit locale uses this.
locales[]Supported locale codes. Empty = localization disabled.
fallbacktrueFall back to default locale value when the requested locale field is NULL.

When locales is empty (the default), localization is completely disabled and all behavior is unchanged.

Per-Field Opt-In

Mark individual fields as localized in your Lua definitions:

crap.collections.define("pages", {
    fields = {
        crap.fields.text({
            name = "title",
            required = true,
            localized = true,  -- this field has per-locale values
        }),
        crap.fields.text({
            name = "slug",
            required = true,
            -- not localized — single value shared across all locales
        }),
    },
})

Only fields with localized = true are affected. Non-localized fields behave exactly as before.

Storage

Localized fields use suffixed columns in SQLite:

  • A field title with locales ["en", "de"] becomes columns title__en and title__de
  • Non-localized fields keep their single column
  • required is only enforced on the default locale column (title__en)
  • unique checks the locale-specific column being written to (e.g., writing locale "de" checks title__de)
  • Junction tables (arrays, blocks, has-many) get a _locale column

Unique + Localized

When a field has both unique = true and localized = true, uniqueness is enforced per locale. Two documents can have the same value in different locales, but not in the same locale:

crap.fields.text({
    name = "slug",
    unique = true,
    localized = true,
})
ScenarioResult
Doc A has slug__en = "hello", Doc B creates with slug__en = "hello"Rejected — duplicate in same locale
Doc A has slug__en = "hello", Doc B creates with slug__de = "hello"Allowed — different locales
Writing with no locale parameterChecks the default locale column

This also applies to fields inside a localized Group — uniqueness is checked against the fully suffixed column (e.g., seo__slug__en).

API Behavior

All read and write RPCs accept an optional locale parameter:

Reading

Locale ParameterBehavior
OmittedReturns default locale values with flat field names
"en" or "de"Returns that locale’s values with flat field names
"all"Returns all locales as nested objects

Flat response (single locale):

{ "title": "Hello World" }

Nested response (locale = "all"):

{ "title": { "en": "Hello World", "de": "Hallo Welt" } }

When fallback = true and a field is NULL for the requested locale, the default locale value is returned instead.

Writing

Writes target a single locale. The locale parameter determines which locale column to write to:

# Write German title
grpcurl -plaintext -d '{
  "collection": "pages",
  "id": "abc123",
  "locale": "de",
  "data": { "title": "Hallo Welt" }
}' localhost:50051 crap.ContentAPI/Update

Non-localized fields are always written to their single column regardless of the locale parameter.

Admin UI

When locales are configured, the admin edit page shows a locale selector in the sidebar. Clicking a locale tab reloads the form with that locale’s data. The save action writes to the selected locale.

When editing in a non-default locale, non-localized fields are shown as readonly with a “Shared Field” badge. This prevents accidentally overwriting values that are shared across all locales.

Lua API

Locale in CRUD Operations

All Lua CRUD functions accept an optional locale parameter:

-- Find with locale
local result = crap.collections.find("pages", { locale = "de" })

-- Find by ID with locale
local doc = crap.collections.find_by_id("pages", id, { locale = "de" })

-- Create in a specific locale
crap.collections.create("pages", data, { locale = "de" })

-- Update in a specific locale
crap.collections.update("pages", id, data, { locale = "de" })

-- Globals
local settings = crap.globals.get("site_settings", { locale = "de" })
crap.globals.update("site_settings", data, { locale = "de" })

Locale Configuration Access

-- Check if localization is enabled
if crap.locale.is_enabled() then
    local default = crap.locale.get_default()  -- "en"
    local all = crap.locale.get_all()           -- {"en", "de", "fr"}
end

Hook Context

The locale is available in hook context:

function M.before_change(ctx)
    if ctx.locale then
        print("Writing to locale: " .. ctx.locale)
    end
    return ctx
end

Admin Label Localization

Field labels, descriptions, placeholders, select option labels, block labels, and collection/global display names can all be localized. Instead of a plain string, provide a table keyed by locale:

crap.collections.define("pages", {
    labels = {
        singular = { en = "Page", de = "Seite" },
        plural = { en = "Pages", de = "Seiten" },
    },
    fields = {
        crap.fields.text({
            name = "title",
            required = true,
            localized = true,
            admin = {
                label = { en = "Title", de = "Titel" },
                placeholder = { en = "Enter page title", de = "Seitentitel eingeben" },
                description = { en = "The main heading", de = "Die Hauptüberschrift" },
            },
        }),
        crap.fields.select({
            name = "status",
            options = {
                { label = { en = "Draft", de = "Entwurf" }, value = "draft" },
                { label = { en = "Published", de = "Veröffentlicht" }, value = "published" },
            },
        }),
    },
})

Plain strings still work — they’re used as-is regardless of locale:

admin = { label = "Title", placeholder = "Enter title" }

The admin UI resolves labels based on default_locale from crap.toml.

Admin UI Translations

All built-in admin UI text (buttons, labels, headings, error messages) can be translated. The system uses a {{t "key"}} Handlebars helper that looks up translation strings.

Built-in English

English translations are compiled into the binary. No configuration needed for English.

Custom Translations

Place a JSON file at <config_dir>/translations/<locale>.json to override or add strings:

{
  "save": "Speichern",
  "delete": "Löschen",
  "create": "Neu erstellen",
  "cancel": "Abbrechen",
  "search_placeholder": "Suchen...",
  "collections": "Sammlungen",
  "globals": "Globale",
  "dashboard": "Übersicht"
}

The file must match your default_locale in crap.toml. Keys not present in the override file fall back to English.

Interpolation

Translation strings support {{variable}} placeholders:

{
  "page_of": "Seite {{page}} von {{total}}",
  "no_items_yet": "Keine {{name}} vorhanden"
}

Templates pass values as hash parameters: {{t "page_of" page=pagination.page total=pagination.total_pages}}.

Available Keys

See translations/en.json in the source tree for all available translation keys.

Backward Compatibility

  • No [locale] config or empty locales = feature completely disabled
  • No localized = true on fields = no locale columns created
  • All existing behavior is preserved when localization is not configured
  • Plain string labels/descriptions/placeholders work exactly as before

Command-Line Reference

crap-cms <COMMAND> [OPTIONS]

Use crap-cms --help to list all commands, or crap-cms <command> --help for details on a specific command.

Global Flags

FlagDescription
-C, --config <PATH>Path to the config directory (overrides auto-detection)
-V, --versionPrint version and exit
-h, --helpPrint help

Config Directory Resolution

Most commands need a config directory (the folder containing crap.toml). The CLI resolves it in this order:

  1. --config / -C flag — explicit path, highest priority
  2. CRAP_CONFIG_DIR environment variable — useful for CI/Docker
  3. Auto-detection — walks up from the current working directory looking for crap.toml

If you cd into your project directory (or any subdirectory), commands just work without any flags:

cd my-project
crap-cms serve
crap-cms status
crap-cms user list

From elsewhere, use -C:

crap-cms -C ./my-project serve
crap-cms -C ./my-project status

Or set the environment variable:

export CRAP_CONFIG_DIR=./my-project
crap-cms serve

Commands

serve — Start the server

crap-cms serve [-d] [--stop] [--restart] [--status] [--json] [--only <admin|api>] [--no-scheduler]
FlagDescription
-d, --detachRun in the background (prints PID and exits)
--stopStop a running detached instance (SIGTERM, then SIGKILL after 10s)
--restartRestart a running detached instance (stop + start)
--statusShow whether a detached instance is running (PID, uptime)
--jsonOutput logs as structured JSON (for log aggregation)
--only <admin|api>Start only the specified server. Omit to start both.
--no-schedulerDisable the background job scheduler

--detach, --stop, --restart, and --status are mutually exclusive.

crap-cms serve                    # foreground
crap-cms serve -d                 # detached (background)
crap-cms serve --status           # is it running?
crap-cms serve --stop             # stop detached instance
crap-cms serve --restart          # stop + start detached
crap-cms serve --json
crap-cms serve --only admin       # admin UI only
crap-cms serve --only api         # gRPC API only
crap-cms serve --no-scheduler     # both servers, no scheduler
crap-cms serve --only admin --no-scheduler
crap-cms serve -d --only api      # detached, API only

status — Show project status

crap-cms status

Prints collections (with row counts), globals, DB size, and migration status.

user — User management

user create

crap-cms user create [-c <COLLECTION>] [-e <EMAIL>] [-p <PASSWORD>] [-f <KEY=VALUE>]...
FlagShortDefaultDescription
--collection-cusersAuth collection slug
--email-eUser email (prompted if omitted)
--password-pUser password (prompted if omitted)
--field-fExtra fields as key=value (repeatable)
# Interactive (prompts for password)
crap-cms user create -e admin@example.com

# Non-interactive
crap-cms user create \
    -e admin@example.com \
    -p secret123 \
    -f role=admin \
    -f name="Admin User"

user list

crap-cms user list [-c <COLLECTION>]

Lists all users with ID, email, locked status, and verified status (if email verification is enabled).

crap-cms user list
crap-cms user list -c admins

user info

crap-cms user info [-c <COLLECTION>] [-e <EMAIL>] [--id <ID>]

Shows detailed info for a single user: ID, email, locked/verified status, password status, timestamps, and all field values.

crap-cms user info -e admin@example.com
crap-cms user info --id abc123

user delete

crap-cms user delete [-c <COLLECTION>] [-e <EMAIL>] [--id <ID>] [-y]
FlagShortDescription
--collection-cAuth collection slug (default: users)
--email-eUser email
--idUser ID
--confirm-ySkip confirmation prompt

user lock / user unlock

crap-cms user lock [-c <COLLECTION>] [-e <EMAIL>] [--id <ID>]
crap-cms user unlock [-c <COLLECTION>] [-e <EMAIL>] [--id <ID>]

user verify / user unverify

crap-cms user verify [-c <COLLECTION>] [-e <EMAIL>] [--id <ID>]
crap-cms user unverify [-c <COLLECTION>] [-e <EMAIL>] [--id <ID>]

Manually mark a user’s email as verified or unverified. Only works on collections with verify_email = true. Useful when email is not configured.

user change-password

Change a user’s password. Prompts for the new password if -p is omitted.

crap-cms user change-password [-c <COLLECTION>] [-e <EMAIL>] [--id <ID>] [-p <PASSWORD>]

init — Scaffold a new config directory

crap-cms init [DIR] [--no-input]

Runs an interactive wizard that scaffolds a complete config directory. Defaults to ./crap-cms if no directory is given.

The wizard prompts for:

PromptDefaultDescription
Admin port3000Port for the admin UI
gRPC port50051Port for the gRPC API
Enable localization?NoIf yes, prompts for default locale and additional locales
Default localeenDefault locale code (only if localization enabled)
Additional localesComma-separated (e.g., de,fr)
Create auth collection?YesCreates a users collection with email/password login
Create first admin user?YesPrompts for email and password immediately
Create upload collection?YesCreates a media collection for file/image uploads
Create another collection?NoRepeat to add more collections interactively

A 64-character auth secret is auto-generated and written to crap.toml.

crap-cms init ./my-project

After scaffolding:

cd my-project
crap-cms serve

make — Generate scaffolding files

make collection

crap-cms make collection [SLUG] [-F <FIELDS>] [-T] [--auth] [--upload] [--versions] [--no-input] [-f]
FlagShortDescription
--fields-FInline field shorthand (see below)
--no-timestamps-TSet timestamps = false
--authEnable auth (email/password login)
--uploadEnable uploads (file upload collection)
--versionsEnable versioning (draft/publish workflow)
--no-inputNon-interactive mode — skip all prompts, use flags and defaults only
--force-fOverwrite existing file

Without --no-input, missing arguments (slug, fields) are collected via interactive prompts. The field survey asks for name, type, required, and localized (if localization is enabled).

Field shorthand syntax:

name:type[:modifier][:modifier]...

Modifiers are order-independent:

ModifierDescription
requiredField is required
localizedField has per-locale values (see Localization)
# Basic
crap-cms make collection posts

# With fields
crap-cms make collection articles \
    -F "title:text:required,body:richtext"

# With localized fields
crap-cms make collection pages \
    -F "title:text:required:localized,body:textarea:localized,slug:text:required"

# Auth collection
crap-cms make collection users --auth

# Upload collection
crap-cms make collection media --upload

# Non-interactive with versions
crap-cms make collection posts \
    -F "title:text:required,body:richtext" --versions --no-input

make global

crap-cms make global [SLUG] [-F <FIELDS>] [-f]
FlagShortDescription
--fields-FInline field shorthand (same syntax as make collection)
--force-fOverwrite existing file
crap-cms make global site_settings
crap-cms make global nav -F "links:array(label:text:required,url:text)"

make hook

crap-cms make hook [NAME] [-t <TYPE>] [-c <COLLECTION>] [-l <POSITION>] [-F <FIELD>] [--force]
FlagShortDescription
--type-tHook type: collection, field, access, or condition
--collection-cTarget collection or global slug
--position-lLifecycle position (e.g., before_change, after_read)
--field-FTarget field name (field hooks only; watched field for condition hooks)
--forceOverwrite existing file

Missing flags are resolved via interactive prompts. The wizard lists collections and globals from the registry (globals are tagged). For non-interactive mode, the slug is auto-detected as a global if it exists in the globals registry.

Valid positions by type:

TypePositions
collectionbefore_validate, before_change, after_change, before_read, after_read, before_delete, after_delete, before_broadcast
fieldbefore_validate, before_change, after_change, after_read
accessread, create, update, delete
conditiontable, boolean

Generated hooks use per-collection typed annotations for IDE support:

  • Collection hooks: crap.hook.Posts, crap.hook.global_site_settings
  • Field hooks: crap.field_hook.Posts, crap.field_hook.global_site_settings
  • Condition hooks: crap.data.Posts, crap.global_data.SiteSettings
  • Delete hooks: generic crap.HookContext (data only contains the document ID)
  • Access hooks: generic crap.AccessContext
# Interactive (prompts for everything)
crap-cms make hook

# Fully specified
crap-cms make hook auto_slug \
    -t collection -c posts -l before_change

# Field hook
crap-cms make hook normalize_email \
    -t field -c users -l before_validate -F email

# Access hook
crap-cms make hook owner_only \
    -t access -c posts -l read

# Condition hook (client-side table)
crap-cms make hook show_external_url \
    -t condition -c posts -l table -F post_type

make job

crap-cms make job [SLUG] [-s <SCHEDULE>] [-q <QUEUE>] [-r <RETRIES>] [-t <TIMEOUT>] [-f]
FlagShortDefaultDescription
--schedule-sCron expression (e.g., "0 3 * * *")
--queue-qdefaultQueue name
--retries-r0Max retry attempts
--timeout-t60Timeout in seconds
--force-fOverwrite existing file
# Interactive (prompts for slug)
crap-cms make job

# With schedule
crap-cms make job cleanup_expired -s "0 3 * * *" -r 3 -t 300

# Simple job (triggered from hooks)
crap-cms make job send_welcome_email

blueprint — Manage saved blueprints

blueprint save

crap-cms blueprint save <NAME> [-f]

Saves the current config directory as a reusable blueprint (excluding data/, uploads/, types/). A .crap-blueprint.toml manifest is written with the CMS version and timestamp.

blueprint use

crap-cms blueprint use <NAME> [DIR]

Creates a new project from a saved blueprint. If the blueprint was saved with a different CMS version, a warning is printed (but the operation proceeds).

blueprint list

crap-cms blueprint list

Lists saved blueprints with collection/global counts and the CMS version they were saved with.

blueprint remove

crap-cms blueprint remove <NAME>

db — Database tools

db console

crap-cms db console

Opens an interactive sqlite3 session on the project database.

db cleanup

crap-cms db cleanup [--confirm]
FlagDescription
--confirmActually drop orphan columns (default: dry-run report only)

Detects columns in collection tables that don’t correspond to any field in the current Lua definitions. System columns (_-prefixed like _password_hash, _locked) are always kept. Plugin columns are safe because plugins run during schema loading — their fields are part of the live definitions.

# Dry run — show orphans without removing them
crap-cms db cleanup

# Actually drop orphan columns
crap-cms db cleanup --confirm

export — Export collection data

crap-cms export [-c <COLLECTION>] [-o <FILE>]
FlagShortDescription
--collection-cExport only this collection (default: all)
--output-oOutput file (default: stdout)

Export includes crap_version and exported_at metadata in the JSON envelope. On import, a version mismatch produces a warning (but does not abort).

crap-cms export
crap-cms export -c posts -o posts.json

import — Import collection data

crap-cms import <FILE> [-c <COLLECTION>]
FlagShortDescription
--collection-cImport only this collection (default: all in file)
crap-cms import backup.json
crap-cms import backup.json -c posts

typegen — Generate typed definitions

crap-cms typegen [-l <LANG>] [-o <DIR>]
FlagShortDefaultDescription
--lang-lluaOutput language: lua, ts, go, py, rs, all
--output-o<config>/types/Output directory for generated files
crap-cms typegen
crap-cms typegen -l all
crap-cms typegen -l ts -o ./client/src/types

proto — Export proto file

crap-cms proto [-o <PATH>]

Writes content.proto to stdout or the given path. No config directory needed.

crap-cms proto
crap-cms proto -o ./proto/

migrate — Run database migrations

crap-cms migrate <create|up|down|list|fresh>
SubcommandDescription
create <NAME>Generate a new migration file (e.g., backfill_slugs)
upSync schema + run pending migrations
down [-s|--steps N]Roll back last N migrations (default: 1)
listShow all migration files with status
fresh [-y|--confirm]Drop all tables and recreate (destructive, requires confirmation)
crap-cms migrate create backfill_slugs
crap-cms migrate up
crap-cms migrate list
crap-cms migrate down -s 2
crap-cms migrate fresh -y

backup — Backup database

crap-cms backup [-o <DIR>] [-i]
FlagShortDescription
--output-oOutput directory (default: <config>/backups/)
--include-uploads-iAlso compress the uploads directory
crap-cms backup
crap-cms backup -o /tmp/backups -i

restore — Restore from backup

crap-cms restore <BACKUP> [-i] [-y]
FlagShortDescription
--include-uploads-iAlso restore uploads from uploads.tar.gz if present
--confirm-yRequired — confirms the destructive operation

Replaces the current database with a backup snapshot. Cleans up stale WAL/SHM files.

crap-cms restore ./backups/backup-2026-03-07T10-00-00 -y
crap-cms restore /tmp/backups/backup-2026-03-07T10-00-00 -i -y

templates — List and extract default admin templates

Extract the compiled-in admin templates and static files into your config directory for customization.

templates list

crap-cms templates list [-t <TYPE>] [-v]
FlagShortDescription
--type-tFilter: templates or static (default: both)
--verbose-vShow full file tree with individual sizes (default: compact summary)
crap-cms templates list
crap-cms templates list -t templates
crap-cms templates list -v

templates extract

crap-cms templates extract [PATHS...] [-a] [-t <TYPE>] [-f]
FlagShortDescription
--all-aExtract all files
--type-tFilter: templates or static (only with --all)
--force-fOverwrite existing files
# Extract specific files
crap-cms templates extract layout/base.hbs styles.css

# Extract all templates
crap-cms templates extract --all --type templates

# Extract everything, overwriting existing
crap-cms templates extract --all --force

jobs — Manage background jobs

jobs list

crap-cms jobs list

Lists all defined jobs with their configuration (handler, schedule, queue, retries, timeout, concurrency).

jobs trigger

crap-cms jobs trigger <SLUG> [-d <DATA>]
FlagShortDefaultDescription
--data-d"{}"JSON data to pass to the job

Manually queue a job for execution. Works even while the server is running (SQLite WAL allows concurrent access). Prints the queued job run ID.

jobs status

crap-cms jobs status [--id <ID>] [-s <SLUG>] [-l <LIMIT>]
FlagShortDefaultDescription
--idShow details for a specific run
--slug-sFilter by job slug
--limit-l20Max results to show

Show recent job runs. If --id is given, shows details for that specific run. Otherwise lists recent runs across all jobs.

jobs cancel

crap-cms jobs cancel [--slug <SLUG>]
FlagDefaultDescription
--slug, -s(all)Only cancel pending jobs with this slug. Without it, cancels all pending jobs.

Deletes pending jobs from the queue. Useful for clearing stuck or unwanted jobs that keep retrying.

jobs purge

crap-cms jobs purge [--older-than <DURATION>]
FlagDefaultDescription
--older-than7dDelete completed/failed/stale runs older than this. Supports Nd, Nh, Nm formats.

jobs healthcheck

crap-cms jobs healthcheck

Checks job system health and prints a summary: defined jobs, stale jobs (running but heartbeat expired), failed jobs in the last 24 hours, pending jobs waiting longer than 5 minutes, and scheduled jobs that have never completed a run.

Exit status: healthy (no issues), warning (failed or long-pending jobs), unhealthy (stale jobs detected).

crap-cms jobs list
crap-cms jobs trigger cleanup_expired
crap-cms jobs status
crap-cms jobs status --id abc123
crap-cms jobs cancel
crap-cms jobs cancel -s process_inquiry
crap-cms jobs purge --older-than 30d
crap-cms jobs healthcheck

images — Manage image processing queue

Inspect and manage the background image format conversion queue. See Image Processing for how to enable queued conversion.

images list

crap-cms images list [-s <STATUS>] [-l <LIMIT>]
FlagShortDefaultDescription
--status-sFilter by status: pending, processing, completed, failed
--limit-l20Max entries to show

images stats

crap-cms images stats

Shows counts by status (pending, processing, completed, failed) and total.

images retry

crap-cms images retry [--id <ID>] [--all] [-y]
FlagShortDescription
--idRetry a specific failed entry by ID
--allRetry all failed entries
--confirm-yRequired with --all

images purge

crap-cms images purge [--older-than <DURATION>]
FlagDefaultDescription
--older-than7dDelete completed/failed entries older than this. Supports Nd, Nh, Nm, Ns formats.
crap-cms images list
crap-cms images list -s failed
crap-cms images stats
crap-cms images retry --id abc123
crap-cms images retry --all -y
crap-cms images purge --older-than 30d

mcp — Start the MCP server (stdio)

Start an MCP (Model Context Protocol) server over stdio for AI assistant integration.

crap-cms mcp

Reads JSON-RPC 2.0 from stdin, writes responses to stdout. Use with Claude Desktop, Cursor, VS Code, or any MCP-compatible client. See MCP Overview for configuration and usage.

logs — View and manage log files

crap-cms logs [-f] [-n <lines>]
crap-cms logs clear

View log output from file-based logging. Requires [logging] file = true in crap.toml (auto-enabled when running with --detach).

FlagDescription
-f, --followFollow log output in real time (like tail -f)
-n, --lines <N>Number of lines to show (default: 100)

Subcommands:

SubcommandDescription
clearRemove old rotated log files, keeping only the current one
crap-cms logs                # show last 100 lines
crap-cms logs -f             # follow in real time
crap-cms logs -n 50          # show last 50 lines
crap-cms logs clear          # remove old rotated files

Log files are stored in data/logs/ (or the path configured in [logging] path). Old files are automatically pruned on startup based on max_files. See Configuration Reference for all logging options.

Environment Variables

VariableDescription
CRAP_CONFIG_DIRPath to the config directory (same as --config flag; flag takes priority)
RUST_LOGControls log verbosity. Default: crap_cms=debug,info for serve, crap_cms=error for all other commands. Example: RUST_LOG=crap_cms=trace
CRAP_LOG_FORMATSet to json for structured JSON log output (same as --json flag)

Collections

Collections are the core data model in Crap CMS. Each collection maps to a SQLite table and is defined in a Lua file.

Basics

  • One Lua file per collection in the collections/ directory
  • Files are loaded alphabetically at startup
  • Each file calls crap.collections.define(slug, config)
  • The slug becomes the table name and URL segment
  • Fields, hooks, access control, auth, and uploads are all configured in the definition

Example

-- collections/posts.lua
crap.collections.define("posts", {
    labels = {
        singular = "Post",
        plural = "Posts",
    },
    timestamps = true,
    admin = {
        use_as_title = "title",
        default_sort = "-created_at",
        list_searchable_fields = { "title", "slug" },
    },
    fields = {
        crap.fields.text({ name = "title", required = true }),
        crap.fields.text({ name = "slug", required = true, unique = true }),
        crap.fields.select({
            name = "status",
            default_value = "draft",
            options = {
                { label = "Draft", value = "draft" },
                { label = "Published", value = "published" },
            },
        }),
        crap.fields.richtext({ name = "content" }),
    },
    hooks = {
        before_change = { "hooks.posts.auto_slug" },
    },
    access = {
        read   = "hooks.access.public_read",
        create = "hooks.access.authenticated",
        delete = "hooks.access.admin_only",
    },
})

System Fields

Every collection automatically has these columns (not in your field definitions):

FieldTypeDescription
idTEXT PRIMARY KEYAuto-generated nanoid
created_atTEXTISO 8601 timestamp (if timestamps = true)
updated_atTEXTISO 8601 timestamp (if timestamps = true)

Auth collections also get a hidden _password_hash TEXT column.

Versioned collections with drafts = true also get:

FieldTypeDescription
_statusTEXT"published" or "draft" (auto-managed)

Versioned collections also get a companion _versions_{slug} table that stores JSON snapshots of every save. See Versions & Drafts.

Schema Sync

On startup, Crap CMS compares each Lua definition against the existing SQLite table:

  • Missing table — creates it with all defined columns
  • Missing columns — adds them via ALTER TABLE
  • Removed columns — logged as a warning (SQLite doesn’t easily drop columns)
  • Type changes — not automatically migrated (manual intervention needed)

Collection Definition Schema

Full reference for every property accepted by crap.collections.define(slug, config).

Top-Level Properties

PropertyTypeDefaultDescription
labelstable{}Display names for the admin UI
labels.singularstringslugSingular name (e.g., “Post”)
labels.pluralstringslugPlural name (e.g., “Posts”)
timestampsbooleantrueAuto-manage created_at and updated_at
fieldsFieldDefinition[]{}Field definitions (see Fields)
admintable{}Admin UI options
hookstable{}Lifecycle hook references
authboolean or tablenilAuthentication config (see Auth Collections)
uploadboolean or tablenilUpload config (see Uploads)
accesstable{}Access control function refs
versionsboolean or tablenilVersioning and drafts config (see Versions & Drafts)
soft_deletebooleanfalseEnable soft deletes (see Soft Deletes)
soft_delete_retentionstringnilAuto-purge retention period (e.g., "30d"). Requires soft_delete = true.
liveboolean or stringnilLive update broadcasting (see Live Updates)
mcptable{}MCP tool config. { description = "..." } for MCP tool descriptions.
indexesIndexDefinition[]{}Compound indexes (see Indexes below)

admin

PropertyTypeDefaultDescription
use_as_titlestringnilField name to display as the row label in admin lists
default_sortstringnilDefault sort field. Prefix with - for descending (e.g., "-created_at")
hiddenbooleanfalseHide this collection from the admin sidebar
list_searchable_fieldsstring[]{}Fields to search when using the admin list search bar

hooks

All hook values are arrays of string references in module.function format.

PropertyTypeDescription
before_validatestring[]Runs before field validation. Has CRUD access.
before_changestring[]Runs after validation, before write. Has CRUD access.
after_changestring[]Runs after create/update (inside transaction). Has CRUD access. Errors roll back.
before_readstring[]Runs before returning read results. No CRUD access.
after_readstring[]Runs after read, before response. No CRUD access.
before_deletestring[]Runs before delete. Has CRUD access.
after_deletestring[]Runs after delete (inside transaction). Has CRUD access. Errors roll back.
before_broadcaststring[]Runs after commit, before broadcast. No CRUD access. See Live Updates.

See Hooks for full details.

auth

Set to true for defaults, or provide a config table:

-- Simple
auth = true

-- With options
auth = {
    token_expiry = 3600,
    disable_local = false,
    strategies = {
        { name = "api-key", authenticate = "hooks.auth.api_key_check" },
    },
}

See Auth Collections for the full schema.

upload

Set to true for defaults, or provide a config table:

-- Simple
upload = true

-- With options
upload = {
    mime_types = { "image/*" },
    max_file_size = 10485760,
    image_sizes = {
        { name = "thumbnail", width = 300, height = 300, fit = "cover" },
    },
    format_options = {
        webp = { quality = 80 },
    },
}

See Uploads for the full schema.

access

PropertyTypeDescription
readstringLua function ref for read access.
createstringLua function ref for create access.
updatestringLua function ref for update access.
deletestringLua function ref for delete access.

If a property is omitted, that operation is allowed for everyone.

See Access Control for full details.

versions

Set to true for defaults (drafts enabled, unlimited versions), or provide a config table:

-- Simple: versions with drafts
versions = true

-- With options
versions = {
    drafts = true,
    max_versions = 20,
}

-- Versions without drafts (pure audit trail)
versions = {
    drafts = false,
    max_versions = 50,
}
PropertyTypeDefaultDescription
draftsbooleantrueEnable draft/publish workflow with _status field
max_versionsinteger0Max versions per document. 0 = unlimited.

See Versions & Drafts for the full workflow.

Indexes

Field-Level Indexes

Set index = true on a field to create a B-tree index on its column. This speeds up queries that filter or sort on that field. Unique fields are already indexed by SQLite, so index = true is skipped when unique = true.

crap.fields.text({ name = "status", index = true }),
crap.fields.date({ name = "published_at", index = true }),

For localized fields, one index is created per locale column (e.g., idx_posts_title__en, idx_posts_title__de).

Compound Indexes

Use the top-level indexes array for multi-column indexes:

indexes = {
    { fields = { "status", "created_at" } },
    { fields = { "category", "slug" }, unique = true },
}
PropertyTypeDefaultDescription
fieldsstring[]requiredColumn names to include in the index.
uniquebooleanfalseCreate a UNIQUE index.

Indexes are synced idempotently on startup: missing indexes are created with CREATE INDEX IF NOT EXISTS, and stale indexes (from removed fields or changed definitions) are dropped. Only indexes with the idx_{collection}_ naming prefix are managed — external indexes are left untouched.

Complete Example

crap.collections.define("posts", {
    labels = {
        singular = "Post",
        plural = "Posts",
    },
    timestamps = true,
    admin = {
        use_as_title = "title",
        default_sort = "-created_at",
        hidden = false,
        list_searchable_fields = { "title", "slug", "content" },
    },
    fields = {
        crap.fields.text({
            name = "title",
            required = true,
            hooks = {
                before_validate = { "hooks.posts.trim_title" },
            },
        }),
        crap.fields.text({
            name = "slug",
            required = true,
            unique = true,
        }),
        crap.fields.select({
            name = "status",
            required = true,
            default_value = "draft",
            options = {
                { label = "Draft", value = "draft" },
                { label = "Published", value = "published" },
                { label = "Archived", value = "archived" },
            },
        }),
        crap.fields.richtext({ name = "content" }),
        crap.fields.relationship({
            name = "tags",
            relationship = { collection = "tags", has_many = true },
        }),
    },
    hooks = {
        before_change = { "hooks.posts.auto_slug" },
    },
    access = {
        read   = "hooks.access.public_read",
        create = "hooks.access.authenticated",
        update = "hooks.access.authenticated",
        delete = "hooks.access.admin_only",
    },
})

Versions & Drafts

Crap CMS supports document versioning with an optional draft/publish workflow.

Enabling Versions

Add versions to your collection definition:

-- Simple: enables versions with drafts
crap.collections.define("articles", {
    versions = true,
    fields = { ... },
})

-- With options
crap.collections.define("articles", {
    versions = {
        drafts = true,
        max_versions = 20,
    },
    fields = { ... },
})

Config Properties

PropertyTypeDefaultDescription
draftsbooleantrueEnable draft/publish workflow with _status field
max_versionsinteger0Max versions per document. 0 = unlimited. Oldest versions are pruned first.

Setting versions = true is equivalent to { drafts = true, max_versions = 0 }.

Setting versions = false or omitting it disables versioning entirely.

How It Works

When versioning is enabled, every create and update operation saves a JSON snapshot of the document to a _versions_{slug} table. This provides a full audit trail with the ability to restore any previous version.

Database Changes

Versioned collections get an additional table:

_versions_articles (
    id TEXT PRIMARY KEY,
    _parent TEXT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
    _version INTEGER NOT NULL,
    _status TEXT NOT NULL,        -- "published" or "draft"
    _latest INTEGER NOT NULL DEFAULT 0,  -- 1 for the most recent version
    snapshot TEXT NOT NULL,               -- full JSON snapshot
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
)

When drafts = true, the main table also gets a _status column (TEXT NOT NULL DEFAULT 'published').

Draft/Publish Workflow

When drafts = true, documents have a _status field that is either "published" or "draft".

Creating Documents

ActionResult
Create (publish)Document inserted with _status = 'published' + version snapshot
Create (draft)Document inserted with _status = 'draft' + version snapshot

Updating Documents

ActionResult
Update (publish)Main table updated, _status = 'published' + new version snapshot
Update (draft)Version-only save — main table is NOT modified, only a new draft version snapshot is created
Unpublish_status set to 'draft' + new version snapshot

The version-only draft save is key: it lets authors iterate on changes without affecting the published version. The main table always reflects the last published state.

Reading Documents

API CallDefault Behavior
FindReturns only _status = 'published' documents
Find with draft = trueReturns all documents (published + draft)
FindByIDReturns the main table document (published version)
FindByID with draft = trueReturns the latest version snapshot (may be a newer draft)

Validation

Required field validation is skipped for draft saves. This lets authors save incomplete work. Validation is enforced when publishing (draft = false).

gRPC API

Draft Parameter

The draft parameter is available on these RPCs:

// Create a draft
CreateRequest { collection, data, draft: true }

// Draft update (version-only, main table unchanged)
UpdateRequest { collection, id, data, draft: true }

// Find all documents including drafts
FindRequest { collection, draft: true }

// Get the latest version (may be a newer draft)
FindByIDRequest { collection, id, draft: true }

ListVersions

List version history for a document:

grpcurl -plaintext -d '{
    "collection": "articles",
    "id": "abc123",
    "limit": "10"
}' localhost:50051 crap.ContentAPI/ListVersions

Response:

{
    "versions": [
        { "id": "v1", "version": 3, "status": "draft", "latest": true, "created_at": "..." },
        { "id": "v2", "version": 2, "status": "published", "latest": false, "created_at": "..." },
        { "id": "v3", "version": 1, "status": "published", "latest": false, "created_at": "..." }
    ]
}

RestoreVersion

Restore a previous version, writing its snapshot data back to the main table:

grpcurl -plaintext -d '{
    "collection": "articles",
    "document_id": "abc123",
    "version_id": "v3"
}' localhost:50051 crap.ContentAPI/RestoreVersion

This overwrites the main table with the snapshot data, sets _status to "published", and creates a new version entry for the restore.

Lua API

The draft option is available on create and update:

-- Create as draft
local doc = crap.collections.create("articles", {
    title = "Work in progress",
}, { draft = true })

-- Draft update (version-only save)
crap.collections.update("articles", doc.id, {
    title = "Still editing...",
}, { draft = true })

-- Publish
crap.collections.update("articles", doc.id, {
    title = "Final Title",
})  -- draft defaults to false

Admin UI

Buttons

When drafts are enabled, the edit form shows context-aware buttons:

Document StatePrimary ButtonSecondary ButtonExtra
Create (new)PublishSave as Draft
Editing (draft)PublishSave Draft
Editing (published)UpdateSave DraftUnpublish

Status Badge

A status badge (published or draft) appears in the document meta panel and in the collection list view.

Version History

The edit sidebar shows a “Version History” panel listing recent versions with:

  • Version number
  • Status badge (published/draft)
  • Timestamp
  • Restore button (for non-latest versions)

Clicking Restore writes the snapshot data back to the main table and redirects to the edit form.

Access Control

Draft operations use the existing update access rule. There is no separate access rule for drafts. If you need finer-grained control (e.g., only admins can publish, but editors can save drafts), inspect the incoming data._status field in your access hooks:

function hooks.access.publish_control(ctx)
    if ctx.data and ctx.data._status == "draft" then
        -- Any authenticated user can save drafts
        return ctx.user ~= nil
    end
    -- Only admins can publish
    return ctx.user and ctx.user.role == "admin"
end

Versions Without Drafts

You can enable version history without the draft/publish workflow:

versions = {
    drafts = false,
    max_versions = 50,
}

This creates version snapshots on every save but does not add a _status column, does not filter by publish state, and does not show draft/publish buttons in the admin UI. Useful for pure audit trails.

Example

crap.collections.define("articles", {
    labels = { singular = "Article", plural = "Articles" },
    timestamps = true,
    versions = {
        drafts = true,
        max_versions = 20,
    },
    admin = {
        use_as_title = "title",
        default_sort = "-created_at",
    },
    fields = {
        crap.fields.text({ name = "title", required = true }),
        crap.fields.text({ name = "slug", required = true, unique = true }),
        crap.fields.textarea({ name = "summary" }),
        crap.fields.richtext({ name = "body" }),
    },
    access = {
        read   = "hooks.access.public_read",
        create = "hooks.access.authenticated",
        update = "hooks.access.authenticated",
        delete = "hooks.access.admin_only",
    },
})

Soft Deletes

Collections can opt into soft deletes so that deleted documents are moved to trash instead of being permanently removed. Trashed documents can be restored or permanently purged after a configurable retention period.

Enabling

crap.collections.define("posts", {
  soft_delete = true,
  soft_delete_retention = "30d",  -- optional: auto-purge after 30 days
  -- ...
})

When soft_delete = true:

  • Deleting a document sets a _deleted_at timestamp instead of removing the row
  • Soft-deleted documents are excluded from all reads, counts, and search
  • Upload files are preserved until the document is permanently purged
  • Version history is preserved

Permissions

Soft deletes introduce a split between trashing (reversible) and permanent deletion (destructive):

ActionPermissionFallbackDescription
Move to trashaccess.trashaccess.updateSoft-delete a document
Restore from trashaccess.trashaccess.updateUn-delete a trashed document
Delete permanentlyaccess.delete(blocks if not set)Permanently remove a document
Empty trashaccess.delete(blocks if not set)Permanently remove all trashed documents
Auto-purge(none)System-level scheduler, always runs

Example configuration:

access = {
  read = "access.anyone",
  create = "access.editor_or_above",
  update = "access.editor_or_above",
  trash = "access.editor_or_above",       -- editors can trash and restore
  delete = "access.admin_or_director",    -- only admins can permanently delete
}

If access.trash is not set, it falls back to access.update — any user who can edit a document can also trash it. If access.delete is not set, permanent deletion is only possible via the auto-purge scheduler.

Admin UI

Trash view

The collection list shows a Trash button when soft_delete is enabled. Clicking it shows the trash view (?trash=1) with:

  • Restore button — moves the document back to the active list
  • Delete permanently button — permanently removes the document (only shown when access.delete is configured)
  • Empty trash button — permanently removes all trashed documents (only shown when access.delete is configured)

Delete dialog

The delete button on list rows and the edit sidebar opens a modal dialog:

  • Soft-delete collections: Shows “Move to trash” (primary) and “Delete permanently” (danger) buttons
  • Hard-delete collections: Shows only “Delete permanently” button
  • “Delete permanently” is hidden when the user lacks access.delete permission

Retention & Auto-Purge

Set soft_delete_retention to automatically purge expired documents:

soft_delete_retention = "30d"   -- purge after 30 days
soft_delete_retention = "7d"    -- purge after 7 days
soft_delete_retention = "90d"   -- purge after 90 days

The scheduler runs the purge job periodically. Documents with _deleted_at older than the retention period are permanently deleted, including upload file cleanup.

If soft_delete_retention is not set, trashed documents persist indefinitely until manually purged via the admin UI or CLI.

Supported formats: "30d" (days), "24h" (hours), or raw seconds.

API

gRPC

// Soft-delete (default for soft_delete collections)
rpc Delete (DeleteRequest) returns (DeleteResponse);

// Force permanent deletion
DeleteRequest { collection: "posts", id: "abc", force_hard_delete: true }

// Restore from trash
rpc Restore (RestoreRequest) returns (RestoreResponse);

The DeleteResponse includes a soft_deleted boolean indicating whether the deletion was soft or hard.

Lua

-- Soft delete (moves to trash)
crap.collections.delete("posts", id)

-- Force permanent delete
crap.collections.delete("posts", id, { forceHardDelete = true })

-- Restore from trash
crap.collections.restore("posts", id)

MCP

MCP delete tools automatically use soft delete when the collection has it enabled.

CLI

# List all trashed documents
crap trash list

# List trashed documents in a specific collection
crap trash list --collection posts

# Restore a document from trash
crap trash restore posts abc123

# Purge all expired documents (respects soft_delete_retention)
crap trash purge

# Purge documents older than 7 days
crap trash purge --older-than 7d

# Dry run — show what would be purged without deleting
crap trash purge --dry-run

# Empty all trash in a collection (requires --confirm)
crap trash empty posts --confirm

Database Schema

When soft_delete = true, a _deleted_at TEXT column is added to the collection table. The value is NULL for active documents and an ISO 8601 timestamp for soft-deleted documents.

All read queries automatically append AND _deleted_at IS NULL to exclude trashed documents. The include_deleted flag on FindQuery overrides this for the trash view.

Notes

  • Soft-deleted documents retain all join table data (arrays, blocks, relationships) — nothing is cascaded
  • FTS index entries are removed on soft-delete and re-synced on restore
  • Upload files are kept on disk until the document is permanently purged
  • Version history is preserved through soft-delete and restore
  • Back-reference warnings still appear on the delete confirmation for upload/media collections

Fields

Fields define the schema of a collection or global. Each field maps to a SQLite column (except arrays and has-many relationships, which use join tables).

Defining Fields

There are two ways to define fields: factory functions (recommended) and plain tables.

crap.fields.* functions set the type automatically and return a plain table. Your editor shows only the properties relevant to each field type — no blocks on a text field, no options on a checkbox.

fields = {
    crap.fields.text({ name = "title", required = true }),
    crap.fields.select({ name = "status", options = {
        { label = "Draft", value = "draft" },
        { label = "Published", value = "published" },
    }}),
    crap.fields.relationship({ name = "author", relationship = { collection = "users" } }),
}

Plain Tables

You can also define fields as plain tables with an explicit type key. This is fully supported and equivalent — factories just set type for you.

fields = {
    { name = "title", type = "text", required = true },
    { name = "status", type = "select", options = { ... } },
}

Both syntaxes can be freely mixed in the same fields array.

Why factories? The types/crap.lua file ships per-type LuaLS classes (e.g., crap.SelectField, crap.ArrayField). When you use crap.fields.select({...}), your editor autocompletes only the properties that apply to select fields. With plain tables, the single crap.FieldDefinition class shows every possible property.

Common Properties

Every field type accepts these properties:

PropertyTypeDefaultDescription
namestringrequiredColumn name. Must be a valid SQL identifier (alphanumeric + underscore).
requiredbooleanfalseValidation: must have a non-empty value on create/update.
uniquebooleanfalseUnique constraint. Checked in the current transaction. For localized fields, enforced per locale.
indexbooleanfalseCreate a B-tree index on this column. Skipped when unique = true (already indexed by SQLite).
localizedbooleanfalseEnable per-locale values. Requires localization to be configured.
validatestringnilLua function ref for custom validation (see below).
default_valueanynilDefault value applied on create if no value provided.
admintable{}Admin UI display options.
hookstable{}Per-field lifecycle hooks.
accesstable{}Per-field access control.

Supported Types

TypeSQLite ColumnDescription
textTEXTSingle-line string (has_many for tag input)
numberREALInteger or float (has_many for tag input)
textareaTEXTMulti-line text
richtextTEXTRich text (HTML string)
selectTEXTSingle value from predefined options
radioTEXTSingle value from predefined options (radio button UI)
checkboxINTEGERBoolean (0 or 1)
dateTEXTDate/datetime/time/month with picker_appearance control
emailTEXTEmail address
jsonTEXTArbitrary JSON blob
codeTEXTCode string with syntax-highlighted editor
relationshipTEXT (has-one) or join table (has-many)Reference to one or more collections; supports polymorphic (collection = { "posts", "pages" })
arrayjoin tableRepeatable group of sub-fields
groupprefixed columnsVisual grouping of sub-fields (no extra table)
uploadTEXT (has-one) or join table (has-many)File reference to upload collection; supports has_many for multi-file
blocksjoin tableFlexible content blocks with different schemas
join(none)Virtual reverse relationship (read-only, computed at read time)

admin Properties

PropertyTypeDefaultDescription
labelstring | tablenilUI label (defaults to title-cased field name). Supports localized strings.
placeholderstring | tablenilInput placeholder text. Supports localized strings.
descriptionstring | tablenilHelp text displayed below the input. Supports localized strings.
hiddenbooleanfalseHide from admin UI forms
readonlybooleanfalseDisplay but don’t allow editing
widthstringnilField width: "full" (default), "half", or "third"
positionstring"main"Form layout position: "main" or "sidebar"
conditionstringnilLua function ref for conditional visibility (see Conditions)
stepstringnilStep attribute for number inputs (e.g., "1", "0.01", "any")
rowsintegernilVisible rows for textarea fields
collapsedbooleantrueDefault collapsed state for groups, collapsibles, array/block rows

Layout Wrappers

Row, Collapsible, and Tabs are layout wrappers — they exist only for admin UI grouping. They are transparent at the data layer: sub-fields are promoted as top-level columns with no prefix (unlike Group, which creates prefixed columns).

Nesting

Layout wrappers can be nested inside each other and inside Array/Blocks sub-fields at arbitrary depth:

crap.fields.array({
    name = "team_members",
    fields = {
        crap.fields.tabs({
            name = "member_tabs",
            tabs = {
                {
                    label = "Personal",
                    fields = {
                        crap.fields.row({
                            name = "name_row",
                            fields = {
                                crap.fields.text({ name = "first_name", required = true }),
                                crap.fields.text({ name = "last_name", required = true }),
                            },
                        }),
                        crap.fields.email({ name = "email" }),
                    },
                },
                {
                    label = "Professional",
                    fields = {
                        crap.fields.text({ name = "job_title" }),
                    },
                },
            },
        }),
    },
})

In this example, first_name, last_name, email, and job_title all become flat columns in the {collection}_team_members join table — the Tabs and Row wrappers are invisible at the data and API layer.

All combinations work: Row inside Tabs, Tabs inside Collapsible, Collapsible inside Row, etc.

Depth limit

The admin UI rendering caps layout nesting at 5 levels deep. Beyond this, fields are silently omitted from the form. This limit is a safety guard against infinite recursion — realistic schemas never hit it (5 levels means something like Array → Tabs → Collapsible → Row → Tabs → field).

The data layer (DDL, read, write, versions) has no depth limit.

Custom Validation

The validate property references a Lua function in module.function format. The function receives (value, context) and returns:

  • nil or true — valid
  • false — invalid with a generic message
  • string — invalid with a custom error message
-- hooks/validators.lua
local M = {}

function M.min_length_3(value, ctx)
    if type(value) == "string" and #value < 3 then
        return ctx.field_name .. " must be at least 3 characters"
    end
end

return M
-- In field definition:
crap.fields.text({ name = "title", validate = "hooks.validators.min_length_3" })

The context table contains:

FieldTypeDescription
collectionstringCollection slug
field_namestringName of the field being validated
datatableFull document data
usertable/nilAuthenticated user document (nil if unauthenticated)
ui_localestring/nilAdmin UI locale code (e.g., "en", "de")

Text

Single-line string field. The most common field type.

SQLite Storage

TEXT column.

Definition

crap.fields.text({
    name = "title",
    required = true,
    unique = true,
    default_value = "Untitled",
    admin = {
        placeholder = "Enter title",
        description = "The display title",
    },
})

Multi-Value (has_many)

Store multiple text values as a JSON array in a TEXT column. Renders as a tag-style input in the admin UI.

crap.fields.text({
    name = "tags",
    has_many = true,
    min_length = 2,   -- each tag must be at least 2 chars
    max_rows = 10,    -- at most 10 tags
})
  • Values are stored as ["tag1","tag2","tag3"] in the TEXT column
  • min_length / max_length validate each individual value
  • min_rows / max_rows validate the count of values
  • Duplicate values are prevented in the admin UI
  • Type generation maps to string[] / Vec<String> / list[str] etc.

Admin Rendering

Renders as an <input type="text"> element. When has_many = true, renders as a tag input where users type and press Enter to add chips.

Notes

  • Empty strings are stored as NULL in SQLite
  • Unknown field types default to text

Number

Numeric field for integers or floating-point values.

SQLite Storage

REAL column. Empty values are stored as NULL.

Definition

crap.fields.number({
    name = "price",
    required = true,
    default_value = 0,
    admin = {
        placeholder = "0.00",
    },
})

Multi-Value (has_many)

Store multiple numbers as a JSON array in a TEXT column. Renders as a tag-style input in the admin UI.

crap.fields.number({
    name = "scores",
    has_many = true,
    min = 0,
    max = 100,
    max_rows = 5,
})
  • Values are stored as ["10","20","30"] in the TEXT column
  • min / max validate each individual value
  • min_rows / max_rows validate the count of values
  • Type generation maps to number[] / Vec<f64> / list[float] etc.

Step

Set admin.step to control the number input step attribute:

crap.fields.number({
    name = "price",
    admin = { step = "0.01" },
})

Valid values: "1" (integers only), "0.01" (cents), "any" (no step constraint). Defaults to browser default ("1").

Admin Rendering

Renders as an <input type="number"> element. When has_many = true, renders as a tag input where users type and press Enter to add number chips.

Value Coercion

String values from form submissions are parsed as f64. If parsing fails, NULL is stored.

Textarea

Multi-line text field for longer content.

SQLite Storage

TEXT column.

Definition

crap.fields.textarea({
    name = "description",
    admin = {
        placeholder = "Enter a description...",
        rows = 12,
        resizable = false,
    },
})

Admin Options

OptionTypeDefaultDescription
rowsinteger8Number of visible rows
resizablebooleantrueAllow vertical resize via drag handle

Admin Rendering

Renders as a <textarea> element. By default, the textarea is vertically resizable. Set admin.resizable = false to disable the resize handle.

Rich Text

Rich text field with a ProseMirror-based WYSIWYG editor. Stored as HTML (default) or ProseMirror JSON.

SQLite Storage

TEXT column containing HTML content (default) or ProseMirror JSON document.

Definition

crap.fields.richtext({
    name = "content",
    admin = {
        placeholder = "Write your content...",
    },
})

Storage Format

By default, richtext fields store raw HTML. Set admin.format = "json" to store the ProseMirror document structure as JSON instead:

crap.fields.richtext({
    name = "content",
    admin = {
        format = "json",
    },
})

HTML vs JSON

HTML (default)JSON
StorageRaw HTML stringProseMirror doc.toJSON()
Round-trip fidelityLoses some structural infoLossless
Programmatic manipulationParse HTMLWalk JSON tree
FTS searchIndexed as-isPlain text extracted automatically
API responseHTML stringJSON string

Important notes

  • Changing format does NOT migrate existing data. If you switch from "html" to "json" (or vice versa), existing documents retain their original format. The editor will attempt to parse the stored content according to the current format setting.
  • The API returns the stored format as-is (HTML string or JSON string).
  • Full-text search automatically extracts plain text from JSON-format richtext fields.

Toolbar Configuration

By default, all toolbar features are enabled. Use admin.features to limit which features are available:

crap.fields.richtext({
    name = "content",
    admin = {
        features = { "bold", "italic", "heading", "link", "bulletList" },
    },
})

Available Features

FeatureDescription
boldBold text (Ctrl+B)
italicItalic text (Ctrl+I)
codeInline code (Ctrl+`)
linkHyperlinks
headingH1, H2, H3 headings
blockquoteBlock quotes
orderedListNumbered lists
bulletListBullet lists
codeBlockCode blocks (```)
horizontalRuleHorizontal rule

When features is omitted or empty, all features are enabled (backward compatible). Undo/redo buttons are always available regardless of feature configuration.

Custom Nodes

Custom ProseMirror nodes let you embed structured components (CTAs, embeds, alerts, mentions, etc.) inside richtext content. Register nodes in init.lua, then enable them on specific fields via admin.nodes.

Registration

Node attributes use the same crap.fields.* factory functions as collection fields. Only scalar types are allowed: text, number, textarea, select, radio, checkbox, date, email, json, code.

-- init.lua
crap.richtext.register_node("cta", {
    label = "Call to Action",
    inline = false, -- block-level node
    attrs = {
        crap.fields.text({ name = "text", required = true, admin = { label = "Button Text" } }),
        crap.fields.text({ name = "url", required = true, admin = { label = "URL", placeholder = "https://..." } }),
        crap.fields.select({ name = "style", admin = { label = "Style" }, options = {
            { label = "Primary", value = "primary" },
            { label = "Secondary", value = "secondary" },
        }}),
    },
    searchable_attrs = { "text" },
    render = function(attrs)
        return string.format(
            '<a href="%s" class="btn btn--%s">%s</a>',
            attrs.url, attrs.style or "primary", attrs.text
        )
    end,
})

Field configuration

crap.fields.richtext({
    name = "content",
    admin = {
        format = "json",
        nodes = { "cta" },
        features = { "bold", "italic", "heading", "link", "bulletList" },
    },
})

Node spec options

OptionTypeDescription
labelstringDisplay label (defaults to node name)
inlinebooleanInline vs block-level (default: false)
attrstable[]Attribute definitions (see below)
searchable_attrsstring[]Attr names included in FTS search index
renderfunctionServer-side render function: (attrs) -> html

Allowed attribute types

Node attrs support all scalar field types. Complex types (array, group, blocks, relationship, upload, richtext, row, collapsible, tabs, join) are rejected at registration time.

TypeAdmin Input
textText input
numberNumber input
textareaMulti-line textarea
selectDropdown with options
radioRadio button group
checkboxCheckbox
dateDate picker
emailEmail input
jsonMonospace textarea
codeMonospace textarea

Supported attribute features

Node attrs support most field features that make sense in the richtext context.

Admin display hints

These control how attributes appear in the node edit modal:

FeatureEffect
admin.hiddenAttribute is not rendered in the modal (value preserved)
admin.readonlyInput is read-only / disabled
admin.widthCSS width on the field container (e.g. "50%")
admin.stepstep attribute on number inputs (e.g. "0.01")
admin.rowsNumber of rows for textarea/code/json fields
admin.languageLanguage label suffix for code fields (e.g. "JSON")
admin.placeholderPlaceholder text on inputs
admin.descriptionHelp text below the input
min / maxMin/max on number inputs
min_length / max_lengthMinlength/maxlength on text/textarea inputs
min_date / max_dateMin/max on date inputs
picker_appearanceDate input type: "dayOnly" (default), "dayAndTime", "timeOnly", "monthOnly"

Server-side validation

Node attribute values inside richtext content are validated server-side on create/update. The following checks run automatically:

CheckDescription
requiredAttribute must have a non-empty value
validateCustom Lua validation function
min_length / max_lengthText length bounds
min / maxNumeric bounds
min_date / max_dateDate bounds
email formatValid email for email type attrs
option validityValue must be in options for select/radio

Validation errors reference the node location: "content[cta#0].url" (first CTA node’s url attribute in the content field).

before_validate hooks

Node attrs support hooks.before_validate for normalizing values before validation:

crap.richtext.register_node("cta", {
    label = "CTA",
    attrs = {
        crap.fields.text({
            name = "url",
            required = true,
            hooks = {
                before_validate = { "hooks.trim_whitespace" },
            },
        }),
    },
})

The hook receives (value, context) and returns the transformed value. Runs before validation checks.

Unsupported features

These features have no effect on node attributes and will produce a warning at registration time:

FeatureReason
hooks.before_changeNo per-attr write lifecycle
hooks.after_changeNo per-attr write lifecycle
hooks.after_readNo per-attr read lifecycle
access (read/create/update)No per-attr access control
uniqueNo DB column
indexNo DB column
localizedRichtext field itself is localized or not
mcp.descriptionNot exposed as MCP fields
has_manyDoesn’t apply to scalar node attrs
admin.conditionNot yet supported (deferred)

Server-side rendering

Use crap.richtext.render(content) in hooks to replace custom nodes with rendered HTML. The function auto-detects format (JSON or HTML). Custom nodes with a render function produce the function’s output; nodes without one pass through as <crap-node> custom elements.

-- In an after_read hook
function hooks.render_content(context)
    local doc = context.doc
    if doc.content then
        doc.content = crap.richtext.render(doc.content)
    end
    return context
end

Custom node attributes listed in searchable_attrs are automatically extracted for full-text search when the field uses JSON format.

Resize Behavior

By default, the richtext editor is vertically resizable (no max-height constraint). Set admin.resizable = false to lock it to a fixed height range (200–600px):

crap.fields.richtext({
    name = "content",
    admin = {
        resizable = false,
    },
})

Admin Rendering

Renders as a ProseMirror-based rich text editor with a configurable toolbar. When custom nodes are configured, an insert button group appears in the toolbar for each node type. Nodes display as styled cards (block) or pills (inline) in the editor; double-click to edit attributes.

Notes

  • No server-side sanitization is applied — sanitize in hooks if needed
  • The toolbar configuration only affects the admin UI; it does not validate or strip content server-side
  • Custom node names must be alphanumeric with underscores only

Select

Single-value selection from predefined options.

SQLite Storage

TEXT column storing the selected value.

Definition

crap.fields.select({
    name = "status",
    required = true,
    default_value = "draft",
    options = {
        { label = "Draft", value = "draft" },
        { label = "Published", value = "published" },
        { label = "Archived", value = "archived" },
    },
})

Options Format

Each option is a table with:

PropertyTypeDescription
labelstringDisplay text in the admin UI
valuestringStored value in the database

Multi-Value (has_many)

Allow selecting multiple options. Values are stored as a JSON array in a TEXT column.

crap.fields.select({
    name = "categories",
    has_many = true,
    options = {
        { label = "News", value = "news" },
        { label = "Tech", value = "tech" },
        { label = "Sports", value = "sports" },
    },
})

Admin Rendering

Renders as a <select> dropdown. When has_many = true, renders as a multi-select.

Radio

Single-value selection from predefined options, rendered as radio buttons.

SQLite Storage

TEXT column storing the selected value.

Definition

crap.fields.radio({
    name = "priority",
    required = true,
    options = {
        { label = "Low", value = "low" },
        { label = "Medium", value = "medium" },
        { label = "High", value = "high" },
    },
})

Options Format

Each option is a table with:

PropertyTypeDescription
labelstringDisplay text in the admin UI
valuestringStored value in the database

Admin Rendering

Renders as a group of radio buttons (one selectable at a time). Functionally identical to Select but with a different UI presentation — use radio when there are few options and you want them all visible at once.

Checkbox

Boolean field stored as an integer (0 or 1).

SQLite Storage

INTEGER column with DEFAULT 0.

Definition

crap.fields.checkbox({
    name = "published",
    default_value = false,
})

Admin Rendering

Renders as an <input type="checkbox"> element.

Value Coercion

The following string values are treated as true: "on", "true", "1", "yes". Everything else (including absence) is false.

Special Behavior

  • Absent checkboxes are always treated as false (not as a missing/required field)
  • The required property is effectively ignored for checkboxes — an unchecked checkbox is always valid
  • Default value is 0 at the database level

Date

Date, datetime, time, or month field with configurable picker appearance and automatic normalization.

SQLite Storage

TEXT column. Values are normalized on write (see Storage Format below).

Definition

-- Date only (default) — stored as UTC noon to prevent timezone drift
crap.fields.date({ name = "birthday" })
crap.fields.date({ name = "birthday", picker_appearance = "dayOnly" })

-- Date and time — stored as full ISO 8601 UTC
crap.fields.date({ name = "published_at", picker_appearance = "dayAndTime" })

-- Time only — stored as HH:MM
crap.fields.date({ name = "reminder", picker_appearance = "timeOnly" })

-- Month only — stored as YYYY-MM
crap.fields.date({ name = "birth_month", picker_appearance = "monthOnly" })

Picker Appearance

The picker_appearance option controls the HTML input type in the admin UI and how values are stored:

ValueHTML InputStorage FormatExample
"dayOnly" (default)<input type="date">YYYY-MM-DDT12:00:00.000Z2026-01-15T12:00:00.000Z
"dayAndTime"<input type="datetime-local">YYYY-MM-DDTHH:MM:SS.000Z2026-01-15T09:30:00.000Z
"timeOnly"<input type="time">HH:MM14:30
"monthOnly"<input type="month">YYYY-MM2026-01

Date Normalization

All date values are normalized in coerce_value before writing to the database, regardless of how they arrive (admin form or gRPC API):

  • Date only (2026-01-15) → 2026-01-15T12:00:00.000Z (UTC noon prevents timezone drift)
  • Full ISO 8601 (2026-01-15T09:00:00Z, 2026-01-15T09:00:00+05:00) → converted to UTC, formatted as YYYY-MM-DDTHH:MM:SS.000Z
  • datetime-local (2026-01-15T09:00) → treated as UTC, formatted as YYYY-MM-DDTHH:MM:SS.000Z
  • Time only (14:30) → stored as-is
  • Month only (2026-01) → stored as-is

This normalization ensures consistent storage and correct behavior when filtering and sorting.

Admin Rendering

Renders as the appropriate HTML5 input type based on picker_appearance. For dayOnly and dayAndTime, the stored ISO string is automatically converted to the format the HTML input expects (YYYY-MM-DD and YYYY-MM-DDTHH:MM respectively).

Date Constraints

Use min_date and max_date to restrict the allowed range. Values are validated server-side and set as HTML min/max attributes on the input.

crap.fields.date({
    name = "event_date",
    min_date = "2026-01-01",
    max_date = "2026-12-31",
})

Both values use ISO 8601 format. Dates outside the range produce a validation error.

Validation

Non-empty date values are validated against recognized date/datetime/time/month formats. Invalid formats produce a validation error. If min_date or max_date are set, the value is also checked against those bounds.

Timezone Support

Date fields can opt into timezone awareness with timezone = true. This stores the user’s selected IANA timezone in a companion column and converts between local time and UTC automatically.

Enabling

crap.fields.date({
    name = "start_date",
    picker_appearance = "dayAndTime",
    timezone = true,
    default_timezone = "America/New_York",  -- optional pre-selected timezone
})

Only dayAndTime supports timezones — timezone only makes sense when there’s a time component. Using timezone = true with dayOnly, timeOnly, or monthOnly emits a warning and is ignored.

How It Works

  1. Admin UI: A timezone dropdown appears next to the date input. The user selects a timezone and enters a local time.
  2. On save: The local time is converted to UTC using the selected timezone (via chrono-tz). Both the UTC date and the IANA timezone string are stored.
  3. On reload: The UTC value is converted back to local time for display. The user always sees the time they entered — re-saving without changes produces the same UTC value (no drift).

Storage

Two columns are created:

ColumnTypeExample
start_dateTEXT2026-05-02T12:00:00.000Z (UTC)
start_date_tzTEXTAmerica/Sao_Paulo

The naming follows the pattern {field_name}_tz. Inside Groups, it becomes {group}__{field}_tz.

API Responses

Both fields appear in gRPC and MCP responses:

{
  "start_date": "2026-05-02T12:00:00.000Z",
  "start_date_tz": "America/Sao_Paulo"
}

The date is always UTC. Frontends convert to local display:

const local = new Date(doc.start_date)
    .toLocaleString("en-US", { timeZone: doc.start_date_tz });

Global Default Timezone

Set a default timezone for all date fields in crap.toml:

[admin]
default_timezone = "America/New_York"

This pre-selects the timezone in the admin dropdown for any date field with timezone = true that doesn’t specify its own default_timezone. The field-level setting takes precedence.

Compatibility

  • Localized fields: Each locale gets its own _tz column (e.g., start_date_tz__en)
  • Groups / Rows / Tabs / Collapsible / Arrays: Companion columns follow the parent field’s naming rules
  • Versioning: Timezone data is included in version snapshots and restored correctly
  • Migration: Adding timezone = true to an existing field creates the _tz column via ALTER TABLE ADD COLUMN with NULL default. No data migration needed.
  • Lua plugins: The timezone and default_timezone properties survive roundtrips through crap.collections.config.list() and crap.collections.define()

Notes

  • Pure dates are stored with UTC noon (T12:00:00.000Z) so timezone offsets up to ±12h never flip the calendar date
  • When timezone = true with dayOnly, noon is calculated in the selected timezone then converted to UTC
  • Comparison operators (greater_than, less_than) work correctly on the normalized ISO string representation
  • The picker_appearance option controls whether the picker shows date-only or date+time

Email

Email address field.

SQLite Storage

TEXT column.

Definition

crap.fields.email({
    name = "contact_email",
    required = true,
    unique = true,
    admin = {
        placeholder = "user@example.com",
    },
})

Admin Rendering

Renders as an <input type="email"> element with browser-native validation.

Auto-Injection

When a collection has auth = true and no email field is defined, one is automatically injected with required = true and unique = true. See Auth Collections.

JSON

Arbitrary JSON data stored as a text blob.

SQLite Storage

TEXT column containing a JSON string.

Definition

crap.fields.json({
    name = "metadata",
    admin = {
        description = "Arbitrary JSON metadata",
    },
})

Admin Rendering

Renders as a <textarea> with monospace font for JSON editing.

Notes

  • Values are stored as raw JSON strings
  • No schema validation is performed on the JSON content
  • Use hooks or custom validate functions to enforce structure if needed

Relationship

Reference to documents in another collection. Supports has-one (single reference) and has-many (multiple references via junction table).

Has-One

Stores a single document ID as a TEXT column on the parent table.

crap.fields.relationship({
    name = "author",
    relationship = {
        collection = "users",
        has_many = false,  -- default
    },
})

At depth=0, the field value is a string ID. At depth=1+, it’s replaced with the full document object.

Has-Many

Uses a junction table ({collection}_{field}) with parent_id, related_id, and _order columns.

crap.fields.relationship({
    name = "tags",
    relationship = {
        collection = "tags",
        has_many = true,
    },
})

At depth=0, the field value is an array of string IDs. At depth=1+, each ID is replaced with the full document object.

Polymorphic Relationships

A relationship field can reference documents from multiple collections by setting collection to a Lua array of slugs instead of a single string.

crap.fields.relationship({
    name = "related_content",
    relationship = {
        collection = { "posts", "pages" },
        has_many = false,
    },
})

Has-one storage — the column stores a composite string in "collection/id" format (e.g., "posts/abc123"). At depth=0 the raw composite string is returned. At depth=1+ it is replaced with the full document object.

Has-many storage — uses a junction table (same as a regular has-many) with an additional related_collection TEXT column that records which collection each referenced document belongs to.

crap.fields.relationship({
    name = "featured_items",
    relationship = {
        collection = { "posts", "pages", "events" },
        has_many = true,
    },
})

Admin UI — the relationship picker fetches and displays search results grouped by collection, so editors can find and select documents from any of the target collections in one widget.

Relationship Config

PropertyTypeDefaultDescription
collectionstring | string[]requiredTarget collection slug, or an array of slugs for polymorphic relationships
has_manybooleanfalseUse a junction table for many-to-many
max_depthintegernilPer-field cap on population depth

Legacy Flat Syntax (Deprecated)

A flat syntax is still supported but deprecated — a warning is logged at startup when it’s used:

-- Deprecated: triggers a warning
crap.fields.relationship({
    name = "author",
    relation_to = "users",
    has_many = false,
})

-- Preferred: use the relationship table
crap.fields.relationship({
    name = "author",
    relationship = { collection = "users" },
})

The flat syntax does not support max_depth or polymorphic collections. Migrate to the relationship = { ... } table form.

Junction Table Schema

For a has-many field tags on collection posts, the junction table is:

CREATE TABLE posts_tags (
    parent_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    related_id TEXT NOT NULL,
    _order INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (parent_id, related_id)
);

Admin Rendering

Has-one renders as a searchable input. Has-many renders as a multi-select with chips for selected items.

Drawer Picker

Add admin.picker = "drawer" to enable a browse button next to the search input. Clicking it opens a slide-in drawer panel with a searchable list for browsing documents.

crap.fields.relationship({
    name = "author",
    relationship = { collection = "users" },
    admin = { picker = "drawer" },
})
  • Without picker: inline search autocomplete only (default behavior)
  • With picker = "drawer": inline search + browse button that opens a drawer with a scrollable list

Population Depth

See Population Depth for details on controlling how deeply relationships are resolved.

Array

Repeatable group of sub-fields. Each array item is a row in a join table.

Storage

Array fields use a dedicated join table: {collection}_{field}.

The join table has columns:

ColumnTypeDescription
idTEXT PRIMARY KEYNanoid for each row
parent_idTEXT NOT NULLForeign key to the parent document
_orderINTEGER NOT NULLSort order (0-indexed)
sub-fieldsvariesOne column per sub-field

Definition

crap.fields.array({
    name = "slides",
    fields = {
        crap.fields.text({ name = "title", required = true }),
        crap.fields.text({ name = "image_url" }),
        crap.fields.textarea({ name = "caption" }),
    },
    admin = {
        description = "Image slides for the gallery",
    },
})

Sub-Fields

Sub-fields support the same properties as regular fields (name, type, required, default_value, admin, etc.). Has-one relationships are supported (stored as a TEXT column in the join table). Nested arrays (array inside array) are not supported.

Layout Wrappers in Sub-Fields

Array sub-fields can be organized with Row, Collapsible, and Tabs layout wrappers. These are transparent — their children become flat columns in the join table, exactly as if they were listed directly in fields.

crap.fields.array({
    name = "items",
    fields = {
        crap.fields.tabs({
            name = "item_tabs",
            tabs = {
                {
                    label = "Content",
                    fields = {
                        crap.fields.text({ name = "title", required = true }),
                        crap.fields.textarea({ name = "description" }),
                    },
                },
                {
                    label = "Appearance",
                    fields = {
                        crap.fields.select({ name = "color", options = { ... } }),
                    },
                },
            },
        }),
    },
})

The join table gets columns title, description, and color — the Tabs wrapper is invisible at the data layer. Nesting is supported at arbitrary depth (e.g., Row inside Tabs inside Array). See Layout Wrappers for details.

API Representation

In API responses, array fields appear as a JSON array of objects:

{
  "slides": [
    { "id": "abc123", "title": "Slide 1", "image_url": "/img/1.jpg", "caption": "First" },
    { "id": "def456", "title": "Slide 2", "image_url": "/img/2.jpg", "caption": "Second" }
  ]
}

Writing Array Data

Via gRPC, pass an array of objects:

{
  "slides": [
    { "title": "Slide 1", "image_url": "/img/1.jpg" },
    { "title": "Slide 2", "image_url": "/img/2.jpg" }
  ]
}

On write, all existing rows for the parent are deleted and replaced with the new data. This is a full replacement, not a merge.

Row Labels

By default, array rows in the admin UI are labeled with the field label and row index (e.g., “Slides 0”, “Slides 1”). You can customize this with label_field and row_label.

label_field

Set admin.label_field to the name of a sub-field. Its value is used as the row title, and updates live as you type.

crap.fields.array({
    name = "slides",
    admin = {
        label_field = "title",
    },
    fields = {
        crap.fields.text({ name = "title", required = true }),
        crap.fields.text({ name = "image_url" }),
        crap.fields.textarea({ name = "caption" }),
    },
})

With this configuration, each row shows the title value instead of “Slides 0”.

row_label (Lua function)

For computed labels, set admin.row_label to a Lua function reference. The function receives the row data as a table and returns a display string (or nil to fall back to label_field or the default).

-- collections/products.lua
crap.fields.array({
    name = "variants",
    admin = {
        row_label = "labels.variant_row",
        label_field = "name", -- fallback if row_label returns nil
    },
    fields = {
        crap.fields.text({ name = "name", required = true }),
        crap.fields.text({ name = "sku" }),
        crap.fields.number({ name = "price" }),
    },
})
-- hooks/labels.lua
local M = {}

function M.variant_row(row)
    local name = row.name or "Untitled"
    if row.sku and row.sku ~= "" then
        return name .. " (" .. row.sku .. ")"
    end
    return name
end

return M

Priority

  1. row_label Lua function (if set and returns a non-empty string)
  2. label_field sub-field value (if set and the field has a value)
  3. Default: field label + row index (e.g., “Slides 0”)

Note: row_label is only evaluated server-side. Rows added via JavaScript in the browser fall back to label_field (live-updated) or the default until the form is saved and reloaded.

Row Limits (min_rows / max_rows)

Enforce minimum and maximum row counts. These are validation constraints (like required), not just UI hints.

crap.fields.array({
    name = "slides",
    min_rows = 1,
    max_rows = 10,
    fields = { ... },
})
  • min_rows: Minimum number of items. Validated on create/update (skipped for draft saves).
  • max_rows: Maximum number of items. Validated on create/update. The admin UI disables the “Add” button when the limit is reached.

Validation runs in validate_fields(), shared by admin handlers, gRPC, and Lua crap.collections.create()/update().

Default Collapsed State (collapsed)

Existing rows render collapsed by default on page load (admin.collapsed = true). Set admin.collapsed = false to start rows expanded. New rows added via the “Add” button are always expanded.

crap.fields.array({
    name = "slides",
    admin = {
        collapsed = false, -- start rows expanded (default is true)
    },
    fields = { ... },
})

Custom Labels (labels)

Customize the “Add Row” button text and field header with singular/plural labels.

crap.fields.array({
    name = "slides",
    admin = {
        labels = { singular = "Slide", plural = "Slides" },
    },
    fields = { ... },
})

With this config, the add button reads “Add Slide” instead of “Add Row”.

Admin Rendering

Renders as a repeatable fieldset with:

  • Drag handle for drag-and-drop reordering
  • Row count badge showing the number of items
  • Toggle collapse/expand all button
  • Each row has expand/collapse toggle, move up/down, duplicate, and remove buttons
  • “No items yet” empty state when no rows exist
  • “Add Row” button (or custom label) to append new rows
  • Add button disabled when max_rows is reached

Group

Visual grouping of sub-fields. Sub-fields become prefixed columns on the parent table (no join table).

Storage

Group fields do not create their own column. Instead, each sub-field becomes a column with a double-underscore prefix: {group}__{sub}.

For example, a group named seo with sub-fields title and description creates columns:

  • seo__title TEXT
  • seo__description TEXT

Definition

crap.fields.group({
    name = "seo",
    fields = {
        crap.fields.text({ name = "title", required = true }),
        crap.fields.textarea({ name = "description" }),
        crap.fields.checkbox({ name = "no_index" }),
    },
    admin = {
        description = "Search engine optimization settings",
    },
})

Sub-Fields

Sub-fields support the same properties as regular fields (name, type, required, default_value, admin, etc.), including nested groups, arrays, blocks, and relationships.

  • Nested groups use stacked prefixes: outer__inner__field.
  • Arrays/Blocks inside groups create prefixed join tables: {collection}_{group}__{field}.
  • Relationships inside groups create prefixed junction tables (for has-many) or prefixed columns (for has-one).

API Representation

In API responses (after hydration), group fields appear as a nested object:

{
  "seo": {
    "title": "My Page Title",
    "description": "A page about...",
    "no_index": 0
  }
}

Writing Group Data

Via gRPC, pass the flat prefixed keys:

{
  "seo__title": "My Page Title",
  "seo__description": "A page about..."
}

The double-underscore separator is used in all write operations (forms, gRPC). On read, the prefixed columns are reconstructed into a nested object.

Filtering on Group Sub-Fields

Use dot notation to filter on group sub-fields. The dot syntax is converted to the double-underscore column name internally.

crap.collections.find("pages", {
    where = {
        ["seo.title"] = { contains = "SEO" },
        ["seo.no_index"] = "0",
    },
})

The equivalent double-underscore syntax also works: seo__title.

See Query & Filters for filtering on other nested field types (arrays, blocks, relationships).

Admin Rendering

Renders as a <fieldset> with a legend. Each sub-field is rendered using its own field type template (text, checkbox, select, etc.).

Row

Layout-only grouping of sub-fields. Unlike Group, sub-fields are promoted as top-level columns with no prefix.

Storage

Row fields do not create their own column. Each sub-field becomes a top-level column using its plain name — no prefix is added.

For example, a row with sub-fields firstname and lastname creates columns:

  • firstname TEXT
  • lastname TEXT

This is different from Group, which prefixes sub-field columns (seo__title).

Definition

crap.fields.row({
    name = "name_row",
    fields = {
        crap.fields.text({ name = "firstname", required = true }),
        crap.fields.text({ name = "lastname", required = true }),
    },
})

API Representation

In API responses, row sub-fields appear as flat top-level fields (not nested):

{
  "firstname": "Jane",
  "lastname": "Doe"
}

Writing Row Data

Use the plain sub-field names directly — no prefix needed:

{
  "firstname": "Jane",
  "lastname": "Doe"
}

Nesting

Row can be nested inside other layout wrappers (Tabs, Collapsible) and inside Array/Blocks sub-fields at arbitrary depth. All nesting combinations work — see the Layout Wrappers section for details and examples.

Depth limit: The admin UI caps layout nesting at 5 levels. The data layer has no limit.

Admin Rendering

Sub-fields are rendered in a horizontal row layout. The row itself has no fieldset, legend, or collapsible wrapper — it is purely a layout mechanism for placing related fields side by side.

Collapsible

Layout-only collapsible container for sub-fields. Like Row, sub-fields are promoted as top-level columns with no prefix. Unlike Group, which creates prefixed columns (group__subfield), Collapsible is purely a UI container.

Storage

Collapsible fields do not create their own column. Each sub-field becomes a top-level column using its plain name — no prefix is added. This is identical to Row storage.

For example, a collapsible with sub-fields meta_title and meta_description creates columns:

  • meta_title TEXT
  • meta_description TEXT

Definition

crap.fields.collapsible({
    name = "seo_section",
    admin = {
        label = "SEO Settings",
        -- collapsed defaults to true; set false to start expanded
        -- collapsed = false,
    },
    fields = {
        crap.fields.text({ name = "meta_title" }),
        crap.fields.textarea({ name = "meta_description" }),
    },
})

API Representation

In API responses, collapsible sub-fields appear as flat top-level fields (not nested):

{
  "meta_title": "My Page Title",
  "meta_description": "A description for search engines"
}

Writing Data

Use the plain sub-field names directly — no prefix needed:

{
  "meta_title": "My Page Title",
  "meta_description": "A description for search engines"
}

Nesting

Collapsible can be nested inside other layout wrappers (Tabs, Row) and inside Array/Blocks sub-fields at arbitrary depth. All nesting combinations work — see the Layout Wrappers section for details and examples.

Depth limit: The admin UI caps layout nesting at 5 levels. The data layer has no limit.

Admin Rendering

Sub-fields are rendered inside a collapsible section with a toggle header. The section starts collapsed by default (admin.collapsed = true). Set admin.collapsed = false to start expanded. Clicking the header toggles visibility. This is useful for grouping related fields that don’t need to be visible at all times (e.g., SEO settings, advanced options).

Comparison with Group and Row

FeatureGroupRowCollapsible
Column prefixgroup__subfieldnonenone
API nestingnested objectflatflat
Admin layoutcollapsible fieldsethorizontal rowcollapsible section
Use caseNamespaced fieldsSide-by-side fieldsToggleable sections

Tabs

Layout-only tabbed container for sub-fields. Like Row and Collapsible, sub-fields across all tabs are promoted as top-level columns with no prefix.

Storage

Tabs fields do not create their own column. Each sub-field across all tabs becomes a top-level column using its plain name — no prefix is added. This is identical to Row storage.

For example, tabs with sub-fields title, body, meta_title, and meta_description creates columns:

  • title TEXT
  • body TEXT
  • meta_title TEXT
  • meta_description TEXT

Definition

crap.fields.tabs({
    name = "content_tabs",
    tabs = {
        {
            label = "Content",
            fields = {
                crap.fields.text({ name = "title", required = true }),
                crap.fields.richtext({ name = "body" }),
            },
        },
        {
            label = "SEO",
            description = "Search engine optimization settings",
            fields = {
                crap.fields.text({ name = "meta_title" }),
                crap.fields.textarea({ name = "meta_description" }),
            },
        },
    },
})

Each tab has:

  • label (required) — the tab button text
  • description (optional) — help text shown inside the tab panel
  • fields — array of field definitions (same syntax as any other field list)

API Representation

In API responses, all tab sub-fields appear as flat top-level fields (not nested by tab):

{
  "title": "My Post",
  "body": "<p>Content here</p>",
  "meta_title": "My Post | Blog",
  "meta_description": "Read about my post"
}

Writing Data

Use the plain sub-field names directly — tabs are invisible at the data layer:

{
  "title": "My Post",
  "body": "<p>Content here</p>",
  "meta_title": "My Post | Blog",
  "meta_description": "Read about my post"
}

Nesting

Tabs can be nested inside other layout wrappers (Row, Collapsible) and inside Array/Blocks sub-fields at arbitrary depth. Tab fields can themselves contain Row, Collapsible, or nested Tabs. All nesting combinations work — see the Layout Wrappers section for details and examples.

Depth limit: The admin UI caps layout nesting at 5 levels. The data layer has no limit.

Admin Rendering

Sub-fields are organized into tabs with a tab bar at the top. The first tab is active by default. Clicking a tab button switches the visible panel. Each tab can have its own description text.

Comparison with Other Layout Types

FeatureGroupRowCollapsibleTabs
Column prefixgroup__subfieldnonenonenone
API nestingnested objectflatflatflat
Admin layoutcollapsible fieldsethorizontal rowcollapsible sectiontabbed panels
Use caseNamespaced fieldsSide-by-side fieldsToggleable sectionsOrganized sections

Code

Code editor field stored as a plain text string. Renders a CodeMirror 6 editor in the admin UI with syntax highlighting and language-aware features.

SQLite Storage

TEXT column containing the raw code string.

Definition

crap.fields.code({
    name = "metadata",
    admin = {
        language = "json", -- "json", "javascript", "html", "css", "python", or "plain"
    },
})

Admin Options

PropertyTypeDefaultDescription
admin.languagestring"json"Language mode for syntax highlighting.

Supported Languages

ValueLanguage
jsonJSON
javascript or jsJavaScript
htmlHTML
cssCSS
python or pyPython
plainNo syntax highlighting

Admin Rendering

Renders as a CodeMirror 6 editor with line numbers, bracket matching, code folding, auto-completion, and search. Falls back to a plain <textarea> if the CodeMirror bundle is not loaded.

Notes

  • Content is stored as a raw string (not parsed or validated for syntax)
  • Supports min_length and max_length validation (applied to the raw string)
  • The CodeMirror bundle (static/codemirror.js) is loaded via <script defer> in the admin layout
  • To rebuild the bundle: bash scripts/bundle-codemirror.sh

Join

A virtual, read-only field that displays documents from another collection that reference the current document. No data is stored — results are computed at read time by querying the target collection.

Lua Definition

crap.fields.join({ name = "posts", collection = "posts", on = "author" })

This reads as: “Show me all documents in the posts collection where posts.author equals this document’s ID.”

Properties

PropertyTypeRequiredDescription
namestringyesField name (display only, no column created)
type"join"yesMust be "join"
collectionstringyesTarget collection slug to query
onstringyesField name on the target collection that holds the reference

Behavior

  • No database column — join fields are virtual. No migration, no storage.
  • Read-only — displayed in the admin UI but not editable. No form input is rendered.
  • No validation — since no data is submitted, validation is skipped entirely.
  • Admin UI — shows a list of linked documents with clickable links to edit each one. Displays “No related items” when empty.
  • API responses — at depth >= 1, join fields return an array of document objects from the target collection. At depth = 0, join fields are omitted (no stored value).

Example

Given an authors collection and a posts collection where each post has a relationship field called author:

-- collections/authors.lua
return {
    slug = "authors",
    fields = {
        crap.fields.text({ name = "name", required = true }),
        crap.fields.join({ name = "posts", collection = "posts", on = "author" }),
    },
}

When editing an author, the “posts” join field displays all posts where posts.author equals the current author’s ID.

Upload

File reference field. Stores a relationship to an upload collection. Supports both single-file (has-one) and multi-file (has-many) modes.

Storage

  • Has-one (default): stores the referenced media document’s ID in a TEXT column on the parent table.
  • Has-many (has_many = true): stores references in a junction table {collection}_{field}, same as has-many relationships.

Definition

Single file (has-one):

crap.fields.upload({
    name = "featured_image",
    relationship = {
        collection = "media",
        max_depth = 1,
    },
})

Multi-file (has-many):

crap.fields.upload({
    name = "gallery",
    relationship = {
        collection = "media",
        has_many = true,
    },
})

Note: The flat relation_to syntax is deprecated for upload fields too. Use relationship = { collection = "..." } instead.

The target collection should be an upload collection (defined with upload = true).

API Representation

Has-one

At depth=0, returns the media document ID as a string:

{
  "featured_image": "abc123"
}

At depth=1+, the ID is populated with the full media document (same as relationship population):

{
  "featured_image": {
    "id": "abc123",
    "collection": "media",
    "filename": "hero.jpg",
    "mime_type": "image/jpeg",
    "url": "/uploads/media/hero.jpg",
    "sizes": { ... }
  }
}

Has-many

At depth=0, returns an array of media document IDs:

{
  "gallery": ["abc123", "def456"]
}

At depth=1+, each ID is populated with the full media document:

{
  "gallery": [
    { "id": "abc123", "collection": "media", "filename": "hero.jpg", ... },
    { "id": "def456", "collection": "media", "filename": "banner.png", ... }
  ]
}

Admin Rendering

  • Has-one: renders as a searchable input with filename as the display label and image preview above.
  • Has-many: renders as a searchable multi-select widget (same as has-many relationships) with chips for selected files.

Drawer Picker

Upload fields default to picker = "drawer", which shows a browse button next to the search input. Clicking it opens a slide-in drawer panel with a thumbnail grid for visually browsing upload documents.

-- drawer is the default for upload fields — no need to set it explicitly
crap.fields.upload({
    name = "featured_image",
    relation_to = "media",
})
  • Default (picker = "drawer"): inline search + browse button that opens a drawer with thumbnail grid
  • picker = "none": inline search autocomplete only (no browse button)

Blocks

Flexible content field with multiple block types. Each block type has its own schema. Stored in a join table with JSON data.

Storage

Blocks fields use a dedicated join table: {collection}_{field}.

The join table has columns:

ColumnTypeDescription
idTEXT PRIMARY KEYNanoid for each row
parent_idTEXT NOT NULLForeign key to the parent document
_orderINTEGER NOT NULLSort order (0-indexed)
_block_typeTEXT NOT NULLBlock type identifier
dataTEXT NOT NULLJSON object containing the block’s field values

Unlike arrays (which have typed columns per sub-field), blocks use a single JSON data column because each block type can have a different schema.

Definition

crap.fields.blocks({
    name = "content",
    blocks = {
        {
            type = "hero",
            label = "Hero Section",
            fields = {
                crap.fields.text({ name = "heading", required = true }),
                crap.fields.text({ name = "subheading" }),
                crap.fields.text({ name = "image_url" }),
            },
        },
        {
            type = "richtext",
            label = "Rich Text",
            fields = {
                crap.fields.richtext({ name = "body" }),
            },
        },
        {
            type = "cta",
            label = "Call to Action",
            fields = {
                crap.fields.text({ name = "text", required = true }),
                crap.fields.text({ name = "url", required = true }),
                crap.fields.select({ name = "style", options = {
                    { label = "Primary", value = "primary" },
                    { label = "Secondary", value = "secondary" },
                }}),
            },
        },
    },
})

Layout Wrappers in Block Fields

Block sub-fields can be organized with Row, Collapsible, and Tabs layout wrappers. Since blocks store data as JSON, wrappers are transparent at the data layer — their children appear as flat keys in the JSON object.

blocks = {
    {
        type = "feature_card",
        label = "Feature Card",
        fields = {
            crap.fields.tabs({
                name = "card_tabs",
                tabs = {
                    {
                        label = "Content",
                        fields = {
                            crap.fields.text({ name = "heading", required = true }),
                            crap.fields.textarea({ name = "body" }),
                        },
                    },
                    {
                        label = "Style",
                        fields = {
                            crap.fields.select({ name = "variant", options = { ... } }),
                        },
                    },
                },
            }),
        },
    },
}

The JSON data contains heading, body, and variant as flat keys — the Tabs wrapper is invisible. Nesting is supported at arbitrary depth. See Layout Wrappers for details.

Block Definitions

Each block definition has:

PropertyTypeDescription
typestringRequired. Block type identifier.
labelstringDisplay label (defaults to type name).
label_fieldstringSub-field name to use as row label for this block type.
groupstringGroup name for organizing blocks in the picker dropdown.
image_urlstringImage URL for icon/thumbnail in the block picker.
fieldsFieldDefinition[]Fields within this block type.

API Representation

In API responses, blocks fields appear as a JSON array of objects, each with _block_type and the block’s field values:

{
  "content": [
    {
      "id": "abc123",
      "_block_type": "hero",
      "heading": "Welcome",
      "subheading": "To our site"
    },
    {
      "id": "def456",
      "_block_type": "richtext",
      "body": "<p>Some content...</p>"
    }
  ]
}

Writing Blocks Data

Via gRPC, pass an array of objects with _block_type:

{
  "content": [
    { "_block_type": "hero", "heading": "Welcome", "subheading": "To our site" },
    { "_block_type": "richtext", "body": "<p>Content here</p>" }
  ]
}

On write, all existing block rows for the parent are deleted and replaced. This is a full replacement, not a merge.

Row Labels

By default, block rows display the block type label and row index (e.g., “Hero Section 0”). You can customize this per block type with label_field, or across all block types with row_label.

Per-Block label_field

Set label_field on each block definition to a sub-field name. The value of that field is used as the row title, and updates live as you type.

crap.fields.blocks({
    name = "content",
    blocks = {
        {
            type = "hero",
            label = "Hero Section",
            label_field = "heading",
            fields = {
                crap.fields.text({ name = "heading", required = true }),
                crap.fields.text({ name = "subheading" }),
            },
        },
        {
            type = "image",
            label = "Image",
            label_field = "caption",
            fields = {
                crap.fields.upload({ name = "image", relationship = { collection = "media" } }),
                crap.fields.text({ name = "caption" }),
            },
        },
    },
})

Each block type can have a different label_field — hero blocks show the heading, image blocks show the caption.

row_label (Lua function)

For computed labels across all block types, set admin.row_label on the blocks field. The function receives the full row data (including _block_type) and returns a display string.

-- collections/posts.lua
crap.fields.blocks({
    name = "content",
    admin = {
        row_label = "labels.content_block_row",
    },
    blocks = { ... },
})
-- hooks/labels.lua
local M = {}

function M.content_block_row(row)
    if row._block_type == "hero" then
        return "Hero: " .. (row.heading or "Untitled")
    elseif row._block_type == "code" then
        local lang = row.language or ""
        if lang ~= "" then return "Code (" .. lang .. ")" end
        return "Code"
    end
    return nil -- fall back to per-block label_field or default
end

return M

Priority

  1. row_label Lua function (if set and returns a non-empty string)
  2. Per-block label_field on the BlockDefinition
  3. Field-level admin.label_field (shared across all block types)
  4. Default: block type label + row index (e.g., “Hero Section 0”)

Note: row_label is only evaluated server-side. Rows added via JavaScript in the browser fall back to label_field (live-updated) or the default until the form is saved and reloaded.

Row Limits (min_rows / max_rows)

Enforce minimum and maximum block counts. These are validation constraints, not just UI hints.

crap.fields.blocks({
    name = "content",
    min_rows = 1,
    max_rows = 20,
    blocks = { ... },
})
  • min_rows: Minimum number of blocks. Validated on create/update (skipped for draft saves).
  • max_rows: Maximum number of blocks. Validated on create/update. The admin UI disables the “Add Block” button when the limit is reached.

Default Collapsed State (collapsed)

Existing block rows render collapsed by default on page load (admin.collapsed = true). Set admin.collapsed = false to start rows expanded. New blocks added via the UI are always expanded.

crap.fields.blocks({
    name = "content",
    admin = {
        collapsed = false, -- start rows expanded (default is true)
    },
    blocks = { ... },
})

Custom Labels (labels)

Customize the “Add Block” button text with singular/plural labels.

crap.fields.blocks({
    name = "content",
    admin = {
        labels = { singular = "Section", plural = "Sections" },
    },
    blocks = { ... },
})

With this config, the add button reads “Add Section” instead of “Add Block”.

Block Groups

Organize blocks into groups in the picker dropdown using <optgroup> elements. Ungrouped blocks appear at the top.

crap.fields.blocks({
    name = "content",
    blocks = {
        {
            type = "hero",
            label = "Hero Section",
            group = "Layout",
            fields = { ... },
        },
        {
            type = "columns",
            label = "Columns",
            group = "Layout",
            fields = { ... },
        },
        {
            type = "richtext",
            label = "Rich Text",
            group = "Content",
            fields = { ... },
        },
        {
            type = "divider",
            label = "Divider",
            -- No group: appears at the top of the dropdown
            fields = {},
        },
    },
})

Card Picker

By default, blocks use a dropdown select to choose the block type. Set admin.picker = "card" to use a visual card grid instead. This is useful when you have several block types and want a more visual picker.

crap.fields.blocks({
    name = "content",
    admin = {
        picker = "card",
    },
    blocks = {
        {
            type = "hero",
            label = "Hero Section",
            fields = { ... },
        },
        {
            type = "richtext",
            label = "Rich Text",
            fields = { ... },
        },
    },
})

Each card shows the block type label and a generic icon. To display custom icons or thumbnails, set image_url on individual block definitions:

blocks = {
    {
        type = "hero",
        label = "Hero Section",
        image_url = "/static/blocks/hero.svg",
        fields = { ... },
    },
    {
        type = "richtext",
        label = "Rich Text",
        image_url = "/static/blocks/text.svg",
        fields = { ... },
    },
}

Blocks without an image_url show a generic widget icon. Both group and image_url can be combined with the card picker.

Admin Rendering

Renders as a repeatable fieldset with:

  • Drag handle for drag-and-drop reordering
  • Row count badge showing the number of blocks
  • Toggle collapse/expand all button
  • Block type selector dropdown with “Add Block” button (or custom label)
  • Each row shows the block type label (or custom label), move up/down, duplicate, and remove buttons
  • “No items yet” empty state when no blocks exist
  • Block-specific fields rendered within each row
  • Add button disabled when max_rows is reached

Globals

Globals are single-document collections for site-wide settings. Each global stores exactly one row.

Definition

Define globals in globals/*.lua using crap.globals.define():

-- globals/site_settings.lua
crap.globals.define("site_settings", {
    labels = {
        singular = "Site Settings",
    },
    fields = {
        crap.fields.text({ name = "site_name", required = true, default_value = "My Site" }),
        crap.fields.text({ name = "tagline" }),
    },
})

Config Properties

PropertyTypeDefaultDescription
labelstable{}Display names
labels.singularstringslugSingular name (e.g., “Site Settings”)
labels.pluralstringslugPlural name
fieldsFieldDefinition[]{}Field definitions
hookstable{}Same lifecycle hooks as collections
accesstable{}Same access control as collections
versionsboolean or tablenilVersioning config (same as collections)
liveboolean or stringnilLive update broadcasting (same as collections)
mcptable{}MCP tool config. { description = "..." }

Database Table

Each global gets a table named _global_{slug} with a single row where id = 'default'. The row is auto-created on startup.

Globals always have created_at and updated_at timestamp columns.

Differences from Collections

FeatureCollectionsGlobals
DocumentsMultipleExactly one
Table name{slug}_global_{slug}
CRUD operationsfind, find_by_id, create, update, deleteget, update
TimestampsOptional (timestamps = true)Always enabled
Auth / UploadSupportedNot supported
VersionsSupportedSupported
Live updatesSupportedSupported
MCPSupportedSupported

Lua API

-- Get current value
local settings = crap.globals.get("site_settings")
print(settings.site_name)

-- Update
crap.globals.update("site_settings", {
    site_name = "New Name",
    tagline = "A fresh start",
})

gRPC API

# Get
grpcurl -plaintext -d '{"slug": "site_settings"}' \
    localhost:50051 crap.ContentAPI/GetGlobal

# Update
grpcurl -plaintext -d '{
    "slug": "site_settings",
    "data": {"site_name": "Updated Site"}
}' localhost:50051 crap.ContentAPI/UpdateGlobal

Relationships

Relationship fields reference documents in other collections. Crap CMS supports two relationship types:

Has-One

A single reference stored as a TEXT column containing the related document’s ID.

crap.fields.relationship({
    name = "author",
    relationship = {
        collection = "users",
    },
})

At depth=0 (default for Find):

{ "author": "user_abc123" }

At depth=1:

{
    "author": {
        "id": "user_abc123",
        "collection": "users",
        "name": "Admin User",
        "email": "admin@example.com",
        "created_at": "2024-01-01 00:00:00"
    }
}

Has-Many

Multiple references stored in a junction table ({collection}_{field}).

crap.fields.relationship({
    name = "tags",
    relationship = {
        collection = "tags",
        has_many = true,
    },
})

At depth=0:

{ "tags": ["tag_1", "tag_2", "tag_3"] }

At depth=1:

{
    "tags": [
        { "id": "tag_1", "collection": "tags", "name": "Rust" },
        { "id": "tag_2", "collection": "tags", "name": "CMS" },
        { "id": "tag_3", "collection": "tags", "name": "Lua" }
    ]
}

Writing Relationships

Has-One

Set the field to the related document’s ID:

{ "author": "user_abc123" }

Has-Many

Pass an array of IDs:

{ "tags": ["tag_1", "tag_2", "tag_3"] }

Order is preserved via the _order column in the junction table.

On write, all existing junction table rows for the parent are deleted and replaced. This is a full replacement.

Partial Updates

For has-many fields, if the field is absent from the update data, the junction table is not modified. This supports partial updates — only explicitly included fields are changed.

Delete Protection

Every collection table has a _ref_count column that tracks how many documents reference it. When _ref_count > 0, the document cannot be deleted — this prevents orphaned references across collections.

How It Works

When document A has a relationship field pointing to document B, B’s _ref_count is incremented. When A is updated to point elsewhere or hard-deleted, B’s _ref_count is decremented. This makes delete protection an O(1) check — no scanning required.

Reference counting covers all relationship types:

TypeStorageTracked
Has-one relationshipColumn on parent tableYes
Has-many relationshipJunction tableYes
Polymorphic (has-one/many)collection/id formatYes
Localized relationshipsPer-locale columnsYes
Upload fieldsSame as relationshipYes
Array sub-field refsColumn in array tableYes
Block sub-field refsJSON in blocks tableYes
Global outgoing refsGlobal table columnsYes

Scope

Delete protection applies to all collections, not just uploads. Any document referenced by another document is protected.

Soft Delete Interaction

Soft-deleting a document does not adjust ref counts. The outgoing references remain counted because:

  • Soft-deleted documents can be restored, so their references should remain tracked
  • Trashed documents still “own” their references in the database

Only hard deletion (permanent) decrements ref counts on the targets.

Soft-deleted documents that are referenced by other documents can still be trashed — the ref count check only blocks deletion of the target document.

Admin UI

The delete confirmation page shows a warning when a document has _ref_count > 0:

This document is referenced by other content. Referenced by 3 document(s). [Show details]

Clicking Show details lazy-loads the full list of referencing documents, fields, and counts via the back-references API endpoint.

API Behavior

Admin & gRPC

Attempting to delete a document with _ref_count > 0 returns an error:

Cannot delete '<id>' from '<collection>': referenced by N document(s)

Lua API

-- Single delete: fails with error if referenced
local ok, err = pcall(crap.collections.delete, "media", "m1")

-- Bulk delete: skips referenced documents and reports the count
local result = crap.collections.delete_many("media", {
    where = { status = { equals = "unused" } }
})
-- result.deleted = documents actually deleted
-- result.skipped = documents skipped due to outstanding references

Force Hard Delete

The forceHardDelete option bypasses the ref count check. This is used internally for Empty Trash operations and can be used in Lua hooks:

crap.collections.delete("media", "m1", {
    forceHardDelete = true  -- skips ref count check
})

Back-References API

To see which documents reference a target, use the back-references endpoint:

GET /admin/collections/{slug}/{id}/back-references

Returns a JSON array:

[
    {
        "owner_slug": "posts",
        "owner_label": "Posts",
        "field_name": "image",
        "field_label": "Image",
        "document_ids": ["p1", "p2"],
        "count": 2,
        "is_global": false
    }
]

This endpoint performs the full back-reference scan, so it’s heavier than the ref count check. It’s designed for on-demand use (e.g., the “Show details” button).

Migration

When upgrading to a version with reference counting, the _ref_count column is automatically added to all collection and global tables. A one-time backfill migration computes the initial counts from existing relationship data. This runs automatically on first startup and is gated by a _crap_meta flag so it only runs once.

Population Depth

The depth parameter controls how deeply relationship fields are populated with full document objects.

Depth Values

DepthBehavior
0IDs only. Has-one = string ID. Has-many = array of string IDs.
1Populate immediate relationships. Replace IDs with full document objects.
2+Recursively populate relationships within populated documents.

Defaults

OperationDefault Depth
Find (gRPC)0 (avoids N+1 on list queries)
FindByID (gRPC)depth.default_depth from crap.toml (default: 1)
crap.collections.find() (Lua)0
crap.collections.find_by_id() (Lua)0

Configuration

Global Config

[depth]
default_depth = 1   # Default for FindByID (default: 1)
max_depth = 10       # Hard cap for all requests (default: 10)

Per-Field Max Depth

Cap the depth for a specific relationship field, regardless of the request-level depth:

crap.fields.relationship({
    name = "author",
    relationship = {
        collection = "users",
        max_depth = 1,  -- never populate deeper than 1, even if depth=5
    },
})

Usage

gRPC

# Find with depth=1
grpcurl -plaintext -d '{
    "collection": "posts",
    "depth": 1
}' localhost:50051 crap.ContentAPI/Find

# FindByID with depth=2
grpcurl -plaintext -d '{
    "collection": "posts",
    "id": "abc123",
    "depth": 2
}' localhost:50051 crap.ContentAPI/FindByID

Lua API

-- Find with depth
local result = crap.collections.find("posts", { depth = 1 })

-- FindByID with depth
local post = crap.collections.find_by_id("posts", id, { depth = 2 })

Circular Reference Protection

The population algorithm tracks visited (collection, id) pairs. If a document has already been visited in the current recursion path, it’s kept as a plain ID string instead of being populated again.

This prevents infinite loops when collections reference each other (e.g., posts → users → posts).

Performance

Population adds queries beyond the main find/find_by_id. How many depends on the depth and number of relationship fields.

How It Works

  • Batch fetching: Find with depth >= 1 collects all referenced IDs across all returned documents per relationship field and fetches them in a single IN (...) query. This means one extra query per relationship field, regardless of how many documents reference it.
  • Recursive batching: At depth >= 2, the same batch strategy applies recursively — populated documents’ relationships are batch-fetched at each depth level.
  • Per-document fetching: FindByID populates a single document. Join fields (reverse lookups) also use per-document queries since they require a WHERE clause per parent.

Query Cost

ScenarioExtra Queries
depth=00
depth=1, Find returning N docs, M relationship fieldsM queries (one batch per field)
depth=1, FindByID, M relationship fieldsM queries
depth=2, Find, M fields at level 1, K fields at level 2M + (M × K) queries

Join fields add one query per document per join field at each depth level.

Populate Cache

For high-traffic deployments, an opt-in cross-request cache avoids redundant population queries. When enabled, populated documents are cached in memory and reused across requests. The cache is automatically cleared on any write operation (create, update, delete).

[depth]
populate_cache = true              # Enable cross-request populate cache (default: false)
populate_cache_max_age_secs = 60   # Optional: periodic full cache clear (default: 0 = off)

When to enable: High read traffic with repeated deep population of the same related documents (e.g., many posts referencing the same set of authors/categories).

Trade-off: If the database is modified outside the API (e.g., direct SQL, external tools), cached data can become stale. Set populate_cache_max_age_secs to limit staleness, or leave the cache disabled (default).

Recommendations

  • Use depth=0 for list endpoints. Find defaults to depth=0 for this reason. Fetch related data when displaying a single document instead.
  • Use select to limit populated fields. Non-selected relationship fields are skipped entirely during population.
  • Set per-field max_depth on relationship fields that don’t need deep population.
  • If you need related data in a list, use depth=1 with select to populate only the specific relationship fields you need.

Query & Filters

Unified reference for querying documents across both the Lua API and gRPC API.

Filter Operators

OperatorLuagRPC (where)SQL
Equalsstatus = "published" or { equals = "val" }{"equals": "val"}field = ?
Not equals{ not_equals = "val" }{"not_equals": "val"}field != ?
Like{ like = "pattern%" }{"like": "pattern%"}field LIKE ?
Contains{ contains = "text" }{"contains": "text"}field LIKE '%text%' ESCAPE '\' (wildcards % and _ in the search text are escaped)
Greater than{ greater_than = "10" }{"greater_than": "10"}field > ?
Less than{ less_than = "10" }{"less_than": "10"}field < ?
Greater/equal{ greater_than_or_equal = "10" }{"greater_than_or_equal": "10"}field >= ?
Less/equal{ less_than_or_equal = "10" }{"less_than_or_equal": "10"}field <= ?
In{ ["in"] = { "a", "b" } }{"in": ["a", "b"]}field IN (?, ?)
Not in{ not_in = { "a", "b" } }{"not_in": ["a", "b"]}field NOT IN (?, ?)
Exists{ exists = true }{"exists": true}field IS NOT NULL
Not exists{ not_exists = true }{"not_exists": true}field IS NULL

Note: For exists/not_exists, the value is ignored — only the key matters. Use not_exists for IS NULL (not { exists = false }).

gRPC shorthand limitation: In Lua, bare values like { count = 42 } or { active = true } are coerced to string equals. The gRPC where JSON only accepts string or operator object values — numeric/boolean shorthand is not supported.

Sorting

Prefix a field name with - for descending order. When order_by is omitted, results are sorted by created_at DESC (newest first) for collections with timestamps, or id ASC otherwise. When sorting by a non-id field, an id tiebreaker is always appended for stable ordering.

Lua:

crap.collections.find("posts", { order_by = "-created_at" })

gRPC:

grpcurl -plaintext -d '{
    "collection": "posts",
    "order_by": "-created_at"
}' localhost:50051 crap.ContentAPI/Find

Pagination

Use limit and page for pagination. The response includes a nested pagination object with total count and page info.

Lua:

local result = crap.collections.find("posts", {
    limit = 10,
    page = 3,
})
-- result.pagination.totalDocs   = 150 (total matching documents)
-- result.pagination.limit       = 10
-- result.pagination.totalPages  = 15
-- result.pagination.page        = 3   (1-based)
-- result.pagination.pageStart   = 21  (1-based index of first doc on this page)
-- result.pagination.hasNextPage = true
-- result.pagination.hasPrevPage = true
-- result.pagination.prevPage    = 2
-- result.pagination.nextPage    = 4
-- #result.documents             = 10  (this page)

gRPC:

grpcurl -plaintext -d '{
    "collection": "posts",
    "limit": "10",
    "page": "3"
}' localhost:50051 crap.ContentAPI/Find

Cursor-Based Pagination

Cursor-based pagination is opt-in via [pagination] mode = "cursor" in crap.toml. When enabled, the pagination object includes opaque startCursor and endCursor tokens instead of page/totalPages. These represent the cursors of the first and last documents in the result set. Pass after_cursor (forward) or before_cursor (backward) on the next request to navigate from any cursor position.

after_cursor/before_cursor and page are mutually exclusive. after_cursor and before_cursor are also mutually exclusive with each other.

Lua:

-- First page
local result = crap.collections.find("posts", {
    order_by = "-created_at",
    limit = 10,
})
-- result.pagination.hasNextPage  = true
-- result.pagination.hasPrevPage  = false
-- result.pagination.startCursor  = "eyJpZCI6ImFiYzEyMyJ9"  (cursor of first doc)
-- result.pagination.endCursor    = "eyJpZCI6Inh5ejc4OSJ9"  (cursor of last doc)

-- Next page (forward)
local page2 = crap.collections.find("posts", {
    order_by = "-created_at",
    limit = 10,
    after_cursor = result.pagination.endCursor,
})

-- Previous page (backward)
local page1_again = crap.collections.find("posts", {
    order_by = "-created_at",
    limit = 10,
    before_cursor = page2.pagination.startCursor,
})

gRPC:

# First page
grpcurl -plaintext -d '{
    "collection": "posts",
    "order_by": "-created_at",
    "limit": "10"
}' localhost:50051 crap.ContentAPI/Find
# Response pagination includes start_cursor / end_cursor when cursor mode is active

# Next page (forward)
grpcurl -plaintext -d '{
    "collection": "posts",
    "order_by": "-created_at",
    "limit": "10",
    "after_cursor": "eyJpZCI6Inh5ejc4OSJ9"
}' localhost:50051 crap.ContentAPI/Find

# Previous page (backward)
grpcurl -plaintext -d '{
    "collection": "posts",
    "order_by": "-created_at",
    "limit": "10",
    "before_cursor": "eyJpZCI6ImFiYzEyMyJ9"
}' localhost:50051 crap.ContentAPI/Find

Cursors encode the position of a document in the sorted result set. They are opaque — do not parse or construct them manually. startCursor and endCursor are always present when the result set is non-empty.

Combining Filters

Multiple filters are combined with AND:

Lua:

crap.collections.find("posts", {
    where = {
        status = "published",
        created_at = { greater_than = "2024-01-01" },
        title = { contains = "update" },
    },
    order_by = "-created_at",
    limit = 10,
})

gRPC:

grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"status\":\"published\",\"created_at\":{\"greater_than\":\"2024-01-01\"},\"title\":{\"contains\":\"update\"}}",
    "order_by": "-created_at",
    "limit": "10"
}' localhost:50051 crap.ContentAPI/Find

OR Filters

Use the or key to combine groups of conditions with OR logic. Each element in the or array is an object whose fields are AND-ed together. Multiple or groups are joined with OR.

Lua:

-- title contains "hello" OR category = "news"
crap.collections.find("posts", {
    where = {
        ["or"] = {
            { title = { contains = "hello" } },
            { category = "news" },
        },
    },
})

-- status = "published" AND (title contains "hello" OR title contains "world")
crap.collections.find("posts", {
    where = {
        status = "published",
        ["or"] = {
            { title = { contains = "hello" } },
            { title = { contains = "world" } },
        },
    },
})

-- Multi-condition groups: (status = "published" AND title contains "hello") OR (status = "draft")
crap.collections.find("posts", {
    where = {
        ["or"] = {
            { status = "published", title = { contains = "hello" } },
            { status = "draft" },
        },
    },
})

gRPC:

# title contains "hello" OR category = "news"
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"or\":[{\"title\":{\"contains\":\"hello\"}},{\"category\":\"news\"}]}"
}' localhost:50051 crap.ContentAPI/Find

# status = "published" AND (title contains "hello" OR title contains "world")
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"status\":\"published\",\"or\":[{\"title\":{\"contains\":\"hello\"}},{\"title\":{\"contains\":\"world\"}}]}"
}' localhost:50051 crap.ContentAPI/Find

Top-level filters and or groups are combined with AND. Each object inside the or array can have multiple fields which are AND-ed together within that group.

Field Selection (select)

Use select to specify which fields to return. Reduces data transfer and skips relationship hydration/population for non-selected fields. The id, created_at, and updated_at fields are always included.

Lua:

-- Return only title and status
crap.collections.find("posts", {
    select = { "title", "status" },
})

-- Works with find_by_id too
crap.collections.find_by_id("posts", id, {
    select = { "title", "status" },
})

gRPC:

# Return only title and status fields
grpcurl -plaintext -d '{
    "collection": "posts",
    "select": ["title", "status"]
}' localhost:50051 crap.ContentAPI/Find

# FindByID with select
grpcurl -plaintext -d '{
    "collection": "posts",
    "id": "abc123",
    "select": ["title", "status"]
}' localhost:50051 crap.ContentAPI/FindByID

Behavior:

  • select is optional. When omitted or empty, all fields are returned (backward compatible).
  • System fields (id, created_at, updated_at) are always included.
  • Selecting a group field name (e.g., "seo") includes all its sub-fields.
  • Relationship fields not in select are skipped during population (saves N+1 queries).

Field Validation

All filter field names and order_by fields are validated against the collection’s field definitions. Invalid field names return an error. This prevents SQL injection via field names.

Draft Parameter (Versioned Collections)

Collections with versions = { drafts = true } automatically filter by _status = 'published' on Find queries. Use the draft parameter to change this behavior.

Lua:

-- Default: only published documents
local published = crap.collections.find("articles", {})

-- Include drafts
local all = crap.collections.find("articles", { draft = true })

-- FindByID: get the latest version (may be a draft)
local latest = crap.collections.find_by_id("articles", id, { draft = true })

gRPC:

# Default: only published
grpcurl -plaintext -d '{"collection": "articles"}' \
    localhost:50051 crap.ContentAPI/Find

# Include drafts
grpcurl -plaintext -d '{"collection": "articles", "draft": true}' \
    localhost:50051 crap.ContentAPI/Find

# FindByID: get latest version snapshot
grpcurl -plaintext -d '{"collection": "articles", "id": "abc123", "draft": true}' \
    localhost:50051 crap.ContentAPI/FindByID

You can also filter directly on _status:

crap.collections.find("articles", {
    where = { _status = "draft" },
})

See Versions & Drafts for the full workflow.

Nested Field Filters (Dot Notation)

You can filter on sub-fields of group, array, blocks, and has-many relationship fields using dot notation.

Group Fields

Group sub-fields can be filtered using dot notation. Internally, seo.meta_title is converted to seo__meta_title (the flat column name). The double-underscore syntax also continues to work.

Lua:

crap.collections.find("pages", {
    where = {
        ["seo.meta_title"] = { contains = "SEO" },
    },
})

gRPC:

grpcurl -plaintext -d '{
    "collection": "pages",
    "where": "{\"seo.meta_title\":{\"contains\":\"SEO\"}}"
}' localhost:50051 crap.ContentAPI/Find

Array Sub-Fields

Filter by sub-field values in array rows. Uses an EXISTS subquery against the array join table. Returns parent documents that have at least one array row matching the condition.

Lua:

-- Find products where any variant has color "red"
crap.collections.find("products", {
    where = {
        ["variants.color"] = "red",
    },
})

-- Group-in-array: filter by a group sub-field within array rows
-- (uses json_extract on the JSON column in the join table)
crap.collections.find("products", {
    where = {
        ["variants.dimensions.width"] = "10",
    },
})

Block Sub-Fields

Filter by field values inside block rows. Uses json_extract on the block data column. Returns parent documents that have at least one block row matching.

Lua:

-- Find posts where any content block has body containing "hello"
crap.collections.find("posts", {
    where = {
        ["content.body"] = { contains = "hello" },
    },
})

-- Filter by block type
crap.collections.find("posts", {
    where = {
        ["content._block_type"] = "image",
    },
})

-- Group-in-block: filter by a group sub-field within block data
crap.collections.find("posts", {
    where = {
        ["content.meta.author"] = "Alice",
    },
})

Has-Many Relationships

Filter by related document IDs. Uses an EXISTS subquery against the relationship join table.

Lua:

-- Find posts that have tag "tag-123"
crap.collections.find("posts", {
    where = {
        ["tags.id"] = "tag-123",
    },
})

Combining Nested and Regular Filters

Nested field filters can be freely combined with regular column filters and OR groups:

Lua:

crap.collections.find("products", {
    where = {
        status = "published",
        ["variants.color"] = "red",
        ["or"] = {
            { ["content._block_type"] = "image" },
            { ["tags.id"] = "tag-featured" },
        },
    },
})

All filter operators (equals, contains, like, in, greater_than, etc.) work with nested field filters.

Use the search parameter for fast full-text search powered by SQLite FTS5. This searches across all text-like fields (text, textarea, richtext, email, code) or the fields specified in list_searchable_fields in the collection’s admin config.

Lua:

local result = crap.collections.find("posts", {
    search = "hello world",
    limit = 10,
})

gRPC:

grpcurl -plaintext -d '{
    "collection": "posts",
    "search": "hello world",
    "limit": "10"
}' localhost:50051 crap.ContentAPI/Find

Behavior:

  • Each whitespace-separated word is treated as a literal search term (implicit AND).
  • Results are ranked by relevance (FTS5 rank).
  • search can be combined with where filters, pagination, sorting, and all other query parameters.
  • Collections without text fields silently ignore the search parameter.
  • The search parameter also works with Count to get the total number of matching documents.

Indexed fields are determined by:

  1. admin.list_searchable_fields if configured on the collection.
  2. Otherwise, all parent-level fields with types: text, textarea, richtext, email, code.

The FTS index is automatically created and rebuilt on server startup for every collection with text fields.

Valid Filter Fields

You can filter on any column in the collection table:

  • User-defined fields (that have parent columns)
  • id
  • created_at (if timestamps enabled)
  • updated_at (if timestamps enabled)
  • _status (if versions.drafts enabled)

Additionally, you can filter on sub-fields using dot notation:

  • Group sub-fields: group_name.sub_field (syntactic sugar for group_name__sub_field)
  • Array sub-fields: array_name.sub_field or array_name.group.sub_field (group-in-array)
  • Block sub-fields: blocks_name.field, blocks_name._block_type, or blocks_name.group.sub_field
  • Has-many relationships: relationship_name.id

Hooks

Hooks let you intercept and modify data at every stage of a document’s lifecycle. They’re the primary extension mechanism in Crap CMS.

Three Levels of Hooks

  1. Field-level hooks — per-field value transformers. Defined on individual FieldDefinition entries.
  2. Collection-level hooks — per-collection lifecycle hooks. Defined on CollectionDefinition or GlobalDefinition.
  3. Globally registered hooks — fire for all collections. Registered via crap.hooks.register() in init.lua.

All hooks at all levels run in this order for each lifecycle event:

field-level → collection-level → globally registered

Hook References

Collection-level and field-level hooks are string references in module.function format:

hooks = {
    before_change = { "hooks.posts.auto_slug" },
}

This resolves to require("hooks.posts").auto_slug via Lua’s module system. The config directory is on the package path, so hooks/posts.lua should return a module table:

-- hooks/posts.lua
local M = {}

function M.auto_slug(ctx)
    if ctx.data.slug == nil or ctx.data.slug == "" then
        ctx.data.slug = crap.util.slugify(ctx.data.title or "")
    end
    return ctx
end

return M

No Closures

Hook references are always strings, never Lua functions. This keeps collection definitions serializable (important for the future visual builder).

The one exception is crap.hooks.register(), which takes a function directly — but it’s called in init.lua, not in collection definitions.

CRUD Access in Hooks

Before-event hooks (before_validate, before_change, before_delete) have full CRUD access via the crap.collections.* and crap.globals.* APIs. They share the parent operation’s database transaction.

After-write hooks (after_change, after_delete) also have CRUD access and run inside the same transaction. Errors roll back the entire operation.

After-read hooks (after_read) do NOT have CRUD access.

See Transaction Access for details.

Concurrency

Hooks execute in a pool of Lua VMs, allowing concurrent hook execution across requests. The pool size is configurable:

[hooks]
vm_pool_size = 8  # default: max(available_parallelism, 4), capped at 32

Each VM is fully initialized at startup with the same configuration (package paths, API registration, CRUD functions, init.lua execution). When a request needs to execute a hook, it acquires a VM from the pool and returns it when done. This prevents hook execution from serializing under concurrent load.

Resource Limits

Lua VMs have configurable instruction, memory, and recursion limits to prevent runaway hooks:

[hooks]
max_depth = 3                      # max hook recursion depth (hook → CRUD → hook; 0 = no hooks from Lua CRUD)
max_instructions = 10000000        # per hook invocation (0 = unlimited)
max_memory = 52428800              # per VM in bytes, 50 MB (0 = unlimited)
allow_private_networks = false     # block HTTP to internal IPs
http_max_response_bytes = 10485760 # 10 MB (increase for large file downloads)
  • Instruction limit — a hook that exceeds the instruction count is terminated with an error. The default (10M) is generous for complex hooks.
  • Memory limit — caps total Lua memory per VM. Exceeding it raises a memory error.
  • Private network blockingcrap.http.request resolves hostnames and rejects private/loopback/link-local IPs unless allow_private_networks = true.
  • crap.crypto.random_bytes — capped at 1 MB per call.

State & Module Caching

Lua’s require function caches modules in package.loaded. This means module-level variables persist across requests on the same VM:

-- hooks/posts.lua
local M = {}
local counter = 0  -- persists across requests!

function M.before_change(ctx)
    counter = counter + 1  -- increments forever on this VM
    return ctx
end

return M

To avoid cross-request state leaks, keep hook functions stateless — use the ctx table for input/output, and crap.collections.* for persistent storage. If you need request-scoped state, store it in ctx.context (the request-scoped shared table — see Hook Context), not module-level locals.

Module-level constants and utility functions are fine — only mutable state is the concern.

Important: VM pool behavior. Since HookRunner uses a pool of Lua VMs, global state in Lua modules persists across requests within the same VM but is not shared across VMs. Each VM in the pool has its own independent copy of module-level variables. This means:

  • Module-level variables can act as in-memory caches, but different requests may hit different VMs and see different cached values.
  • Cached state is not consistent across the pool — one VM’s counter may be at 5 while another is at 12.
  • All cached state is lost on server restart (VMs are re-initialized from scratch).

If you need shared, consistent state, use crap.collections.* or crap.globals.* to persist to the database.

Lifecycle Events

Nine lifecycle events fire during CRUD operations and admin page rendering.

Event Reference

EventFires OnMutable DataCRUD AccessNotes
before_validatecreate, update, update_manyYesYesNormalize inputs before validation
before_changecreate, update, update_manyYesYesTransform data after validation passes
after_changecreate, update, update_manyYesYesRuns inside the transaction. Audit logs, counters, side-effects. Errors roll back the entire operation.
before_readfind, find_by_idNoNo*Can abort the read by returning an error
after_readfind, find_by_idYesNoTransform data before it reaches the client
before_deletedelete, delete_manyNoYesCan abort the delete. CRUD access for cascading deletes.
after_deletedelete, delete_manyNoYesRuns inside the transaction. Cleanup, cascading deletes. Errors roll back the entire operation.
before_broadcastcreate, update, deleteYes (data)NoCan suppress or transform live update events. See Live Updates.
before_renderadmin page renderYes (context)NoRuns before rendering admin pages. Receives the full template context and can modify it. Global-only (no collection-level refs). Useful for injecting global template data.

* before_read hooks have no CRUD access when called from the gRPC API or admin UI. However, when triggered from a Lua CRUD call inside a hook (e.g., crap.collections.find() inside before_change), before_read hooks inherit the parent’s transaction context and DO have CRUD access.

Document ID in Hook Context

In after_change and after_delete hooks, context.data.id contains the document ID. This is useful for queuing jobs or looking up the document after it’s been written. In before_delete hooks, context.data.id is also available.

Write Lifecycle (create/update)

1. field before_validate hooks (CRUD access)
2. collection before_validate hooks (CRUD access)
3. global registered before_validate hooks (CRUD access)
4. field validation (required, unique, custom validate)
5. field before_change hooks (CRUD access)
6. collection before_change hooks (CRUD access)
7. global registered before_change hooks (CRUD access)
8. database write (INSERT or UPDATE)
9. join table write (has-many relationships, arrays)
10. field after_change hooks (CRUD access, same transaction)
11. collection after_change hooks (CRUD access, same transaction)
12. global registered after_change hooks (CRUD access, same transaction)
13. transaction commit
14. live setting check (background)
15. before_broadcast hooks (background, no CRUD)
16. EventBus publish (if not suppressed)

Bulk Operations (update_many/delete_many)

update_many and delete_many run the same per-document lifecycle as their single-document counterparts. Each matched document goes through the full hook pipeline individually, all within a single transaction (all-or-nothing).

update_many runs steps 1–12 above for each document. Key differences from single-document update:

  • Only provided fields are written (partial update). Absent fields — including checkboxes — are left unchanged.
  • Password updates are rejected. Use single-document Update instead.
  • Hook-modified data is captured and written (hooks can transform the data).
  • Set hooks = false to skip all hooks and validation for performance.

delete_many runs the delete lifecycle (steps 1–5 below) for each document.

Read Lifecycle (find/find_by_id)

1. collection before_read hooks
2. global registered before_read hooks
3. database query
4. field after_read hooks
5. collection after_read hooks
6. global registered after_read hooks

Delete Lifecycle

1. collection before_delete hooks (CRUD access)
2. global registered before_delete hooks (CRUD access)
3. database delete
4. collection after_delete hooks (CRUD access, same transaction)
5. global registered after_delete hooks (CRUD access, same transaction)
6. transaction commit
7. live setting check (background)
8. before_broadcast hooks (background, no CRUD)
9. EventBus publish (if not suppressed)

Execution Order

At each lifecycle stage, hooks run in this order:

1. Field-level hooks (per-field, in field definition order)
2. Collection-level hooks (string refs from collection definition, in array order)
3. Globally registered hooks (from crap.hooks.register(), in registration order)

Example: before_change on create

Given this setup:

-- collections/posts.lua
crap.collections.define("posts", {
    fields = {
        crap.fields.text({
            name = "title",
            hooks = { before_change = { "hooks.fields.uppercase" } },
        }),
        crap.fields.text({
            name = "slug",
            hooks = { before_change = { "hooks.fields.normalize_slug" } },
        }),
    },
    hooks = {
        before_change = { "hooks.posts.set_defaults", "hooks.posts.validate_business_rules" },
    },
})

-- init.lua
crap.hooks.register("before_change", function(ctx)
    crap.log.info("audit: " .. ctx.operation .. " on " .. ctx.collection)
    return ctx
end)

Execution order for a create operation:

  1. hooks.fields.uppercase (field: title)
  2. hooks.fields.normalize_slug (field: slug)
  3. hooks.posts.set_defaults (collection)
  4. hooks.posts.validate_business_rules (collection)
  5. Registered audit function (global)

Multiple Hooks per Event

Both field-level and collection-level hooks accept arrays. Hooks in the same array run sequentially, each receiving the output of the previous one.

hooks = {
    before_change = { "hooks.posts.first", "hooks.posts.second" },
}

first runs, its returned context is passed to second.

Field Hooks

Field-level hooks operate on individual field values rather than the full document context.

Signature

function hook(value, context)
    -- transform value
    return new_value
end
ParameterTypeDescription
valueanyCurrent field value
contexttableSee context fields below

Return value: The new field value. This replaces the existing value in the data.

Context Table

FieldTypeDescription
field_namestringName of the field being processed
collectionstringCollection slug
operationstring"create", "update", "find", "find_by_id"
datatableFull document data (read-only snapshot)
usertable/nilAuthenticated user document (nil if unauthenticated)
ui_localestring/nilAdmin UI locale code (e.g., "en", "de")

Typed Contexts

The type generator (crap-cms typegen) emits per-collection field hook contexts with typed data fields:

  • Collections: crap.field_hook.{PascalCase} — e.g., crap.field_hook.Posts has data: crap.data.Posts
  • Globals: crap.field_hook.global_{slug} — e.g., crap.field_hook.global_site_settings has data: crap.global_data.SiteSettings

Use the typed context when a hook is specific to one collection:

---@param value number|nil
---@param context crap.field_hook.Inquiries
---@return number|nil
return function(value, context)
    -- context.data is typed as crap.data.Inquiries
    -- IDE autocompletes context.data.name, context.data.email, etc.
    return value
end

For shared hooks that work across multiple collections, use the generic crap.FieldHookContext (where data is table<string, any>).

Events

EventCRUD AccessUse Case
before_validateYesNormalize values before validation (trim, lowercase, etc.)
before_changeYesTransform values after validation (compute derived fields)
after_changeYesSide effects after write with CRUD access (logging, cascades)
after_readNoTransform values before response (formatting, computed fields)

Definition

crap.fields.text({
    name = "title",
    hooks = {
        before_validate = { "hooks.fields.trim" },
        before_change = { "hooks.fields.sanitize_html" },
        after_read = { "hooks.fields.add_word_count" },
    },
})

Example

-- hooks/fields.lua
local M = {}

function M.trim(value, ctx)
    if type(value) == "string" then
        return value:match("^%s*(.-)%s*$")
    end
    return value
end

function M.slugify(value, ctx)
    -- Auto-generate slug from title if empty
    if (value == nil or value == "") and ctx.data.title then
        return crap.util.slugify(ctx.data.title)
    end
    return value
end

return M

Registered Hooks

Registered hooks fire for all collections at a given lifecycle event. Register them in init.lua using crap.hooks.register().

Registration

-- init.lua
crap.hooks.register("before_change", function(ctx)
    crap.log.info("[audit] " .. ctx.operation .. " on " .. ctx.collection)
    return ctx
end)

Unlike collection-level hooks (which are string references), registered hooks are Lua functions passed directly.

API

crap.hooks.register(event, fn)

Register a hook function for a lifecycle event.

ParameterTypeDescription
eventstringOne of the lifecycle events
fnfunctionHook function receiving a context table

crap.hooks.remove(event, fn)

Remove a previously registered hook. Uses rawequal for identity-based matching — you must pass the exact same function reference.

local my_hook = function(ctx)
    -- ...
    return ctx
end

crap.hooks.register("before_change", my_hook)
-- Later:
crap.hooks.remove("before_change", my_hook)

CRUD Access

Registered hooks follow the same rules as collection-level hooks:

  • Before-event hooks (before_validate, before_change, before_delete) have CRUD access via the shared transaction
  • Write after-event hooks (after_change, after_delete) have CRUD access — they run inside the same transaction via run_hooks_with_conn
  • Read after-event hooks (after_read) do NOT have CRUD access — they run without a transaction

Execution Order

Registered hooks run after field-level and collection-level hooks at each lifecycle stage.

Example: Audit Log

-- init.lua
crap.hooks.register("before_change", function(ctx)
    crap.log.info(string.format(
        "[audit] %s %s: %s",
        ctx.operation,
        ctx.collection,
        ctx.data.id or "(new)"
    ))
    return ctx
end)

Example: Auto-Set Created By

-- init.lua (requires auth + access context in hooks)
crap.hooks.register("before_change", function(ctx)
    if ctx.operation == "create" then
        -- Set created_by to current user ID if field exists
        -- (requires the collection to have a created_by field)
    end
    return ctx
end)

Hook Context

Collection-level hooks receive a context table and must return a (potentially modified) context table.

Context Shape

{
    collection = "posts",       -- Collection slug
    operation = "create",       -- "create", "update", "delete", "find", "find_by_id", or "init"
    data = {                    -- Document data (mutable in before-write hooks)
        title = "Hello World",
        slug = "hello-world",
        status = "draft",
        id = "abc123",          -- Present on update/delete, absent on create
        created_at = "...",     -- Present on read/update
        updated_at = "...",
    },
    locale = "en",              -- Locale for this operation (nil if not locale-specific)
    draft = true,               -- Whether this is a draft save (versioned collections only)
    hook_depth = 0,             -- Current recursion depth (0 = top-level, 1+ = from Lua CRUD in hooks)
    context = {                 -- Request-scoped shared table (persists across all hooks in the same request)
        -- Hooks can read and write arbitrary keys here to share data
    },
    user = {                    -- Authenticated user document (nil if unauthenticated)
        id = "user_123",
        email = "admin@example.com",
        role = "admin",
        -- ... all fields from the auth collection
    },
    ui_locale = "en",           -- Admin UI locale (nil if not set)
}

Typed Contexts

The type generator (crap-cms typegen) emits per-collection context types with typed data fields for IDE autocomplete:

  • Collections: crap.hook.{PascalCase} — e.g., crap.hook.Posts has data: crap.data.Posts and collection: "posts" (literal)
  • Globals: crap.hook.global_{slug} — e.g., crap.hook.global_site_settings has data: crap.global_data.SiteSettings

Use the typed context for hooks that target a specific collection:

---@param context crap.hook.Posts
---@return crap.hook.Posts
return function(context)
    -- context.data.title, context.data.slug, etc. autocomplete
    return context
end

Delete hooks receive only { id = "..." } in data (not full document fields), so they use the generic crap.HookContext:

---@param context crap.HookContext
---@return crap.HookContext
return function(context)
    local id = context.data.id
    return context
end

For shared hooks that fire across multiple collections (e.g., via crap.hooks.register()), use the generic crap.HookContext.

Data Mutation

In before-write hooks (before_validate, before_change), you can modify ctx.data and return the modified context. The changes flow through to the database write.

function M.auto_slug(ctx)
    if not ctx.data.slug or ctx.data.slug == "" then
        ctx.data.slug = crap.util.slugify(ctx.data.title or "")
    end
    return ctx
end

In after-read hooks, you can also modify ctx.data to transform the response before it reaches the client.

Return Value

Hooks must return the context table (or a new table with data). If a hook returns:

  • A table with a data key — the data is replaced
  • A table without a data key — the original data is kept
  • A non-table value — the original context is kept

System Fields in Data

FieldPresent WhenDescription
idupdate, delete, readDocument ID
created_atread, updateISO 8601 timestamp
updated_atread, updateISO 8601 timestamp
userwrite hooks, after_read (nil if unauthenticated)Authenticated user document
ui_localewrite hooks, after_read (nil if not set)Admin UI locale code

On create, id is not yet assigned (it’s generated by the database write).

Draft Field

For versioned collections with drafts = true, the context includes a draft field:

ValueMeaning
trueThis is a draft save (required field validation is skipped)
falseThis is a publish save (full validation applied)
nilCollection does not have versioning enabled

You can use this in hooks to customize behavior based on publish state:

function M.before_change(ctx)
    if ctx.draft then
        -- Draft save: skip expensive operations
        return ctx
    end
    -- Publishing: run full processing
    ctx.data.published_at = os.date("!%Y-%m-%d %H:%M:%S")
    return ctx
end

User

The user field contains the full authenticated user document from the auth collection, or nil if the request is unauthenticated (or no auth collection exists). This is the same user document used by access control functions.

function M.before_change(ctx)
    if ctx.user then
        ctx.data.last_edited_by = ctx.user.email
    end
    return ctx
end

UI Locale

The ui_locale field contains the admin UI locale code (e.g., "en", "de"), or nil if not set. This is useful for returning user-facing messages (e.g., validation errors) in the correct language.

function M.validate_title(value, ctx)
    if not value or value == "" then
        if ctx.ui_locale == "de" then
            return "Titel ist erforderlich"
        end
        return "Title is required"
    end
    return true
end

Hook Depth

The hook_depth field tracks how deep in the hook→CRUD→hook chain the current execution is:

ValueMeaning
0Top-level call from gRPC API or admin UI
1Called from Lua CRUD inside a hook
2+Deeper recursion (hook called CRUD which triggered another hook)

When hook_depth reaches hooks.max_depth (default: 3, configurable in crap.toml), hooks are automatically skipped but the DB operation still executes. This prevents infinite recursion when hooks create/update documents in the same collection.

function M.audit_hook(ctx)
    -- Only audit at the top level, not from recursive hook calls
    if ctx.hook_depth >= 1 then
        return ctx
    end
    crap.collections.create("audit_log", {
        action = ctx.operation,
        collection = ctx.collection,
    })
    return ctx
end

Context (Request-Scoped Shared Table)

The context field is a request-scoped table that persists across all hooks in the same request. It allows hooks to share data with each other without relying on module-level state.

Each hook in the chain receives the same context table, and any modifications made by one hook are visible to all subsequent hooks in the same request. The table starts empty at the beginning of each request.

This is useful for:

  • Passing computed values from before_validate to before_change without recomputing
  • Sharing request metadata between hooks
  • Accumulating data across multiple hooks for use in after_change
-- In before_validate hook: compute and share a value
function M.before_validate(ctx)
    ctx.context.original_title = ctx.data.title
    return ctx
end

-- In after_change hook: use the shared value
function M.after_change(ctx)
    if ctx.context.original_title ~= ctx.data.title then
        crap.log.info("Title changed from: " .. (ctx.context.original_title or "nil"))
    end
    return ctx
end

Note: The context table is not the same as module-level variables. Module-level variables persist across requests on the same VM (see Hooks Overview), while context is scoped to a single request and automatically cleaned up.

Transaction Access

Hooks can call back into the Crap CMS CRUD API. Whether a hook has CRUD access depends on the lifecycle event.

Which Hooks Get CRUD Access?

EventCRUD AccessReason
before_validateYesRuns inside the write transaction
before_changeYesRuns inside the write transaction
after_changeYesRuns inside the write transaction, after the DB operation
before_readNoRead operations don’t open a write transaction
after_readNoFire-and-forget, no transaction
before_deleteYesRuns inside the delete transaction
after_deleteYesRuns inside the delete transaction, after the DB delete

This applies to all three hook levels (field, collection, registered).

Available Functions

Inside hooks with CRUD access:

-- Collections
crap.collections.find("posts", { where = { status = "published" } })
crap.collections.find_by_id("posts", "abc123")
crap.collections.create("audit_log", { action = "update", target = ctx.data.id })
crap.collections.update("posts", id, { view_count = views + 1 })
crap.collections.delete("drafts", old_id)

-- Globals
crap.globals.get("site_settings")
crap.globals.update("counters", { total_posts = count + 1 })

Transaction Sharing

CRUD calls inside hooks share the same database transaction as the parent operation. This means:

  • If the hook creates a document and the parent operation later fails, the created document is rolled back
  • If the hook fails, the entire parent operation rolls back
  • All changes are atomic — either everything commits or nothing does

This applies to all write hooks: before_validate, before_change, after_change, before_delete, and after_delete.

Error Handling

If any hook (before or after) returns an error or throws a Lua error, the entire transaction is rolled back and the operation fails with an error message. This includes after-hooks — an after_change error will roll back the main DB operation too.

Calling CRUD Outside Hooks

Calling crap.collections.find() etc. outside a hook context (no active transaction) results in an error:

crap.collections CRUD functions are only available inside hooks
with transaction context (before_change, before_delete, etc.)

on_init Hooks

The [hooks] on_init list in crap.toml runs at startup with CRUD access. All on_init hooks share a single database transaction — if any hook fails, all changes are rolled back. This makes seeding and startup migrations atomic:

[hooks]
on_init = ["hooks.seed.run"]
-- hooks/seed.lua
local M = {}

function M.run(ctx)
    local result = crap.collections.find("posts")
    if result.pagination.totalDocs == 0 then
        crap.collections.create("posts", {
            title = "Welcome",
            slug = "welcome",
            status = "published",
            content = "Welcome to your new site!",
        })
        crap.log.info("Seeded initial post")
    end
    return ctx
end

return M

If an on_init hook fails, the server aborts startup.

Access Control Functions

Access control functions (access.read, access.create, access.update, access.delete on collections/globals and access.read, access.create, access.update on fields) run with CRUD access inside their own transaction. Each access check gets a dedicated transaction that commits on success or rolls back on error.

Auth Strategies

Custom auth strategy authenticate functions run with CRUD access inside a transaction. All strategies for a given request share a single transaction — if a strategy authenticates successfully, the transaction commits. If all strategies fail, the transaction rolls back.

Jobs

Background job system for scheduled and queued tasks.

Overview

Crap CMS includes a built-in job scheduler for running background tasks. Jobs are defined in Lua, can be triggered manually or on a cron schedule, and execute with full CRUD access to all collections.

Use cases:

  • Scheduled cleanup (e.g., delete expired posts nightly)
  • Async processing triggered from hooks (e.g., send welcome email after user creation)
  • Periodic data sync or aggregation

Defining Jobs

Jobs are defined via crap.jobs.define() in init.lua or files under jobs/:

-- jobs/cleanup_expired.lua
crap.jobs.define("cleanup_expired", {
    handler = "jobs.cleanup_expired.run",
    schedule = "0 3 * * *",        -- daily at 3am
    queue = "maintenance",
    retries = 3,
    timeout = 300,
    concurrency = 1,
    skip_if_running = true,
    labels = { singular = "Cleanup Expired Posts" },
    access = "hooks.check_admin",  -- optional access control
})

local M = {}
function M.run(ctx)
    -- ctx.data = input data from queue() or {} for cron
    -- ctx.job = { slug, attempt, max_attempts }
    -- Full CRUD access available
    local expired = crap.collections.find("posts", {
        where = { expires_at = { less_than = os.date("!%Y-%m-%dT%H:%M:%SZ") } }
    })
    for _, doc in ipairs(expired.documents) do
        crap.collections.delete("posts", doc.id)
    end
    return { deleted = #expired.documents }
end
return M

Configuration Options

FieldTypeDefaultDescription
handlerstring(required)Lua function ref (e.g., "jobs.cleanup.run")
schedulestringnilCron expression for automatic scheduling
queuestring"default"Queue name for grouping
retriesinteger0Max retry attempts on failure
timeoutinteger60Seconds before job is marked failed
concurrencyinteger1Max concurrent runs of this job
skip_if_runningbooleantrueSkip cron trigger if previous run still active
labelstablenilDisplay labels ({ singular = "..." })
accessstringnilLua function ref for trigger access control

Queuing from Hooks

Jobs can be queued programmatically from hooks:

-- In a hook
crap.jobs.queue("send_welcome_email", { user_id = ctx.data.id, email = ctx.data.email })

queue() inserts a pending job and returns immediately. The scheduler picks it up on its next poll cycle.

Handler Context

The handler function receives a context table:

function M.run(ctx)
    ctx.data          -- table: input data from queue() or {} for cron
    ctx.job.slug      -- string: job definition slug
    ctx.job.attempt   -- integer: current attempt (1-based)
    ctx.job.max_attempts -- integer: total attempts allowed
end

The handler has full CRUD access (crap.collections.find(), .create(), etc.) running inside its own database transaction. If the handler returns a table, it’s stored as the job result (JSON). If it errors, the job is marked failed (and retried if attempts remain).

Back Pressure

  • Global concurrency: [jobs] max_concurrent in crap.toml (default: 10)
  • Per-job concurrency: concurrency field on the definition
  • Timeout: Jobs running longer than timeout are marked failed
  • Skip-if-running: Cron-triggered jobs skip if a previous run is still active

Error Handling

Job execution is fully isolated. If a job handler panics, the panic is caught and logged — it does not crash the server or affect other jobs. The job is marked as failed and retried if attempts remain.

Crash Recovery

On startup, the scheduler marks any previously-running jobs as stale (the server was restarted while they were executing). Jobs with remaining retry attempts are re-queued.

Running jobs update a heartbeat timestamp periodically so stale detection works even during normal operation.

Configuration (crap.toml)

[jobs]
max_concurrent = 10       # global concurrency limit
poll_interval = 1         # seconds between pending job polls
cron_interval = 60        # seconds between cron schedule checks
heartbeat_interval = 10   # seconds between heartbeat updates
auto_purge = "7d"         # auto-delete completed jobs older than this

CLI Commands

crap-cms -C <config_dir> jobs list                   # list defined jobs
crap-cms -C <config_dir> jobs trigger <slug>         # manually queue a job
crap-cms -C <config_dir> jobs status [--id <id>]     # show recent job runs
crap-cms -C <config_dir> jobs purge [--older-than 7d] # clean up old runs

gRPC API

Four RPCs for job management:

  • ListJobs — list all defined jobs
  • TriggerJob(slug, data_json?) — queue a job, returns the run ID
  • GetJobRun(id) — get details of a specific run
  • ListJobRuns(slug?, status?, limit?, offset?) — list job runs with filters

All require authentication. TriggerJob also checks the job’s access function if defined.

Plugins

Crap CMS doesn’t have a formal plugin system. It doesn’t need one — Lua’s open module system provides everything required. A plugin is just a Lua module that modifies collections, globals, registers hooks, or any combination.

How It Works

  1. Collection and global definition files (collections/*.lua, globals/*.lua) are auto-loaded first.
  2. init.lua runs after all definitions are registered.
  3. Plugins are require()-d from init.lua and can read, modify, or extend any registered collection or global.

This works because crap.collections.define() and crap.globals.define() overwrite — calling either twice for the same slug replaces the first definition with the second.

Writing a Plugin

A plugin is a Lua module that returns a table with an install() function:

-- plugins/audit_log.lua
local M = {}

function M.install()
    -- Register a global hook that runs for all collections
    crap.hooks.register("before_change", function(ctx)
        if ctx.operation == "create" then
            ctx.data.created_by = ctx.user and ctx.user.email or "system"
        end
    end)
end

return M

Modifying Collections

Use crap.collections.config.get() to retrieve a single collection, or crap.collections.config.list() to iterate all collections:

-- plugins/alt_text.lua
-- Adds an alt_text field to every upload collection.
local M = {}

function M.install()
    for slug, def in pairs(crap.collections.config.list()) do
        if def.upload then
            def.fields[#def.fields + 1] = crap.fields.text({
                name = "alt_text",
                admin = { description = "Describe this image for accessibility" },
            })
            crap.collections.define(slug, def)
        end
    end
end

return M

Patching a Single Collection

-- plugins/post_reading_time.lua
local M = {}

function M.install()
    local def = crap.collections.config.get("posts")
    if not def then return end

    def.fields[#def.fields + 1] = crap.fields.number({
        name = "reading_time",
        admin = { readonly = true, description = "Estimated reading time (minutes)" },
    })

    -- Add a hook to calculate it
    def.hooks.before_change = def.hooks.before_change or {}
    def.hooks.before_change[#def.hooks.before_change + 1] = "plugins.post_reading_time.calculate"

    crap.collections.define("posts", def)
end

function M.calculate(ctx)
    local body = ctx.data.body or ""
    local words = select(2, body:gsub("%S+", ""))
    ctx.data.reading_time = math.ceil(words / 200)
    return ctx
end

return M

Modifying Globals

The same pattern works for globals:

-- plugins/global_meta.lua
-- Adds a "last_updated_by" field to every global.
local M = {}

function M.install()
    for slug, def in pairs(crap.globals.config.list()) do
        def.fields[#def.fields + 1] = crap.fields.text({
            name = "last_updated_by",
            admin = { readonly = true },
        })
        crap.globals.define(slug, def)
    end
end

return M

Patching a Single Global

local def = crap.globals.config.get("site_settings")
if def then
    def.fields[#def.fields + 1] = crap.fields.richtext({ name = "footer_html" })
    crap.globals.define("site_settings", def)
end

Installing a Plugin

-- init.lua
require("plugins.alt_text").install()
require("plugins.post_reading_time").install()

A plugin is a file in your config directory (typically plugins/). Install it by copying or cloning into that directory and adding a require line.

Collection-Level Override

When a plugin adds fields to all collections but you want a custom version for one collection, just define the field directly in that collection’s Lua file. The plugin should check for existing fields before adding:

-- Plugin checks before adding
for _, field in ipairs(def.fields) do
    if field.name == "seo" then
        has_seo = true
        break
    end
end

if not has_seo then
    def.fields[#def.fields + 1] = seo_fields
    crap.collections.define(slug, def)
end

This way, posts.lua can define its own custom SEO group (e.g., with an extra og_image field) and the plugin will skip it.

API Reference

FunctionDescription
crap.collections.config.get(slug)Get a collection’s full config as a Lua table. Returns nil if not found.
crap.collections.config.list()Get all collections as a { slug = config } table. Iterate with pairs().
crap.collections.define(slug, config)Define or redefine a collection.
crap.globals.config.get(slug)Get a global’s full config as a Lua table. Returns nil if not found.
crap.globals.config.list()Get all globals as a { slug = config } table. Iterate with pairs().
crap.globals.define(slug, config)Define or redefine a global.
crap.hooks.register(event, fn)Register a global hook for all collections.

Plugin Execution Order

Since init.lua runs sequentially, plugins install in the order you require them. If plugin B depends on fields added by plugin A, require A first:

require("plugins.seo").install()         -- adds seo fields
require("plugins.seo_defaults").install() -- sets default values on seo fields

Authentication

Crap CMS provides built-in authentication via auth-enabled collections. Any collection can serve as an auth collection by setting auth = true.

Key Concepts

  • Auth collection — a collection with auth = true. Users are regular documents in this collection.
  • Two auth surfaces — Admin UI uses an HttpOnly cookie (crap_session). gRPC API uses Bearer tokens.
  • JWT — all tokens are JWT signed with the configured secret (or an auto-generated one persisted to data/.jwt_secret).
  • Argon2id — passwords are hashed with Argon2id before storage.
  • Rate limiting — login endpoints enforce per-email rate limiting (configurable max attempts and lockout window).
  • Timing-safe — login always performs a password hash comparison, even when the user doesn’t exist, to prevent timing-based email enumeration.
  • CSRF protection — admin UI forms and HTMX requests are protected with double-submit cookie tokens.
  • Secure cookies — the crap_session cookie includes the Secure flag in production (when dev_mode = false).
  • _password_hash — a hidden column added to auth collection tables. Never exposed in API responses, hooks, or admin forms.
  • _locked — when set to a truthy value, the user is denied access on every request (JWT validation, Me, admin session). Takes effect immediately, even for valid unexpired tokens. See Auth Collections.
  • Custom strategies — pluggable auth via Lua functions (API keys, LDAP, SSO).
  • Password reset — token-based forgot/reset password flow via admin UI and gRPC. Requires email configuration.
  • Email verification — optional per-collection. When enabled, users must verify their email before logging in.

Activation

Auth middleware activates when at least one auth collection exists or when admin.require_auth is true (the default). This means:

  • require_auth = true (default): If no auth collections are defined, the admin shows a “Setup Required” page. Create an auth collection and bootstrap a user to proceed.
  • require_auth = false: If no auth collections are defined, the admin is fully open (dev/prototyping mode).
  • Auth collection exists: Standard authentication applies. Optionally, admin.access can further restrict which authenticated users can access the admin panel.

Quick Setup

  1. Define an auth collection:
-- collections/users.lua
crap.collections.define("users", {
    auth = true,
    fields = {
        crap.fields.text({ name = "name", required = true }),
        crap.fields.select({ name = "role", options = {
            { label = "Admin", value = "admin" },
            { label = "Editor", value = "editor" },
        }}),
    },
})
  1. Bootstrap the first user:
crap-cms -C ./my-project user create -e admin@example.com
  1. (Optional) Set a JWT secret explicitly, or let it auto-generate and persist to data/.jwt_secret:
[auth]
secret = "your-random-secret-here"  # omit to auto-generate (persisted across restarts)
  1. (Optional) Configure email for password reset and verification:
[email]
smtp_host = "smtp.example.com"
smtp_port = 587
smtp_user = "noreply@example.com"
smtp_pass = "your-smtp-password"
from_address = "noreply@example.com"
from_name = "My App"
  1. (Optional) Enable email verification:
crap.collections.define("users", {
    auth = {
        verify_email = true,
    },
    fields = { ... },
})

Password Reset

When email is configured, a “Forgot password?” link appears on the admin login page. The flow:

  1. User clicks “Forgot password?” and enters their email
  2. Server generates a single-use reset token (nanoid, stored in DB with 1-hour expiry). Forgot-password rate limiting applies — after max_forgot_password_attempts requests within forgot_password_window_seconds, further requests are silently accepted without sending email.
  3. Reset email is sent with a link to /admin/reset-password?token=xxx
  4. User sets a new password via the form
  5. Token is consumed (single-use) and user is redirected to login

Available via gRPC as ForgotPassword and ResetPassword RPCs.

Security: The forgot password endpoint always returns success regardless of whether the email exists, to prevent user enumeration.

Email Verification

When verify_email: true is set on an auth collection:

  1. A verification email is automatically sent when a user is created (admin UI or gRPC)
  2. The email contains a link to /admin/verify-email?token=xxx
  3. Verification tokens expire after 24 hours
  4. Until verified, the user cannot log in (returns “Please verify your email” error)
  5. Clicking the verification link marks the user as verified (if token hasn’t expired)

Available via gRPC as VerifyEmail RPC.

The verification endpoint is rate-limited by IP (using the forgot-password rate limiter). Only actual token validation failures (invalid or expired tokens) count toward the rate limit — transient system errors do not penalize the user’s IP.

Note: Email verification requires SMTP to be configured. If SMTP is not configured, verification emails won’t be sent (logged as warnings) and unverified users will be unable to log in.

Auth Collections

Any collection can be an auth collection. Set auth = true for defaults, or provide a configuration table.

Simple Auth

crap.collections.define("users", {
    auth = true,
    -- ...
})

Auth Config Table

crap.collections.define("users", {
    auth = {
        token_expiry = 3600,       -- 1 hour (default: 7200 = 2 hours)
        disable_local = false,      -- set true to disable password login
        strategies = {
            {
                name = "api-key",
                authenticate = "hooks.auth.api_key_check",
            },
        },
    },
    -- ...
})

Config Properties

PropertyTypeDefaultDescription
token_expiryinteger7200JWT token lifetime in seconds. Overrides the global [auth] token_expiry.
disable_localbooleanfalseWhen true, the password login form is hidden. Only custom strategies can authenticate.
verify_emailbooleanfalseWhen true, new users must verify their email before logging in. Requires email configuration.
forgot_passwordbooleantrueWhen true, enables the “Forgot password?” flow. Requires email configuration.
strategiesAuthStrategy[]{}Custom auth strategies. See Custom Strategies.

Email Auto-Injection

When auth = true and no email field exists in the field definitions, one is automatically injected:

crap.fields.email({
    name = "email",
    required = true,
    unique = true,
    admin = { placeholder = "user@example.com" },
})

If you define your own email field, it’s used as-is.

Password Storage

Auth collections get a hidden _password_hash TEXT column during schema migration. This column:

  • Is not a regular field — it doesn’t appear in def.fields
  • Is never returned in API responses
  • Is never included in hook contexts
  • Is never shown in admin forms
  • Is only accessed by dedicated auth functions (update_password, get_password_hash)

Password Policy

All password-setting paths (create, update, reset, CLI) enforce the password policy configured in [auth.password_policy] in crap.toml. Defaults: min 8 Unicode characters, max 128 bytes. min_length counts Unicode codepoints (so multi-byte characters count as 1). max_length counts bytes (to bound Argon2 hashing cost). See crap.toml reference for all options.

Password in Create/Update

When creating or updating a user, the password field (if present in the data) is:

  1. Extracted from the data before hooks run
  2. Hashed with Argon2id after the document is written
  3. Stored in the _password_hash column

In the admin UI:

  • Create form — password is required
  • Edit form — password is optional (“leave blank to keep current”)

Account Locking

Auth collections support a _locked system field. When a user’s _locked field is set to a truthy value (e.g., 1), that user is immediately denied access:

  • JWT validation — every authenticated request checks _locked after resolving the user from the token. A locked user’s token is effectively revoked, even if it hasn’t expired.
  • Me RPC — returns an unauthenticated error for locked users.
  • Admin UI — the session is rejected and the user is redirected to the login page.

Locking takes effect immediately — no token refresh or logout is needed. Use the CLI to lock/unlock users:

crap-cms -C ./my-project user lock -e admin@example.com
crap-cms -C ./my-project user unlock -e admin@example.com

JWT Claims

Tokens contain:

ClaimDescription
subUser document ID
collectionAuth collection slug (e.g., “users”)
emailUser email
expExpiration timestamp (Unix)
iatIssued-at timestamp (Unix)

Login Flow

Admin UI Flow

  1. User visits any /admin/* route
  2. Gate 1: require_auth check — if no auth collections exist and require_auth is true (default), returns a “Setup Required” page (HTTP 503). Set require_auth = false in [admin] for open dev mode.
  3. Auth middleware checks for crap_session HttpOnly cookie (includes Secure flag when dev_mode = false)
  4. If no valid cookie, tries custom auth strategies, then redirects to /admin/login
  5. Gate 2: admin.access check — if an access Lua function is configured in [admin], it runs after successful authentication. If the function returns false/nil, the user sees an “Access Denied” page (HTTP 403) with a logout button.
  6. User submits email + password (protected by CSRF double-submit cookie)
  7. Server checks rate limiting — too many failed attempts for this email triggers a temporary lockout
  8. Server verifies credentials against the auth collection (constant-time, even for non-existent users)
  9. On success: clears rate limit counter, sets crap_session cookie with JWT, redirects to /admin
  10. On failure: records failed attempt, re-renders login page with error

Public admin routes (no auth required):

  • /admin/login
  • /admin/logout
  • /admin/forgot-password
  • /admin/reset-password
  • /admin/verify-email
  • /static/*

Custom strategy flow: If custom strategies are configured, the middleware checks them before redirecting to login:

  1. JWT cookie check (fast path)
  2. Custom strategies in definition order
  3. Redirect to login (if all fail)

Security

Rate Limiting

Login and forgot-password endpoints enforce dual rate limiting — per-email and per-IP:

  • Per-email: After max_login_attempts (default: 5) failed attempts, further login attempts for that email are blocked for login_lockout_seconds (default: 300s).
  • Per-IP: After max_ip_login_attempts (default: 20) failed attempts from the same IP, all login attempts from that IP are blocked. The higher threshold tolerates shared IPs (offices, NAT).

Forgot-password requests are similarly limited per-email (max_forgot_password_attempts) and per-IP (max_ip_login_attempts with forgot_password_window_seconds).

[auth]
max_login_attempts = 5          # per-email threshold
max_ip_login_attempts = 20      # per-IP threshold (login + forgot-password)
login_lockout_seconds = "5m"    # lockout window for login
max_forgot_password_attempts = 3
forgot_password_window_seconds = "15m"

Rate limiting applies to the admin UI login, admin forgot-password, and the gRPC Login and ForgotPassword RPCs. Behind a reverse proxy, the admin UI reads the client IP from X-Forwarded-For.

CSRF Protection

All admin UI form submissions and HTMX requests are protected by a double-submit cookie pattern:

  • A crap_csrf cookie (SameSite=Strict, not HttpOnly) is set when absent (persists with a 24-hour Max-Age)
  • POST, PUT, PATCH, and DELETE requests must include a matching token via either:
    • X-CSRF-Token header (used by HTMX requests)
    • _csrf form field (used by plain form submissions)
  • Mismatched or missing tokens return 403 Forbidden

This is handled automatically by JavaScript included in the admin templates.

Timing Safety

Login always performs a full Argon2id hash comparison, even when the requested email doesn’t exist. This prevents timing-based user enumeration attacks.

gRPC Flow

Login

grpcurl -plaintext -d '{
    "collection": "users",
    "email": "admin@example.com",
    "password": "secret123"
}' localhost:50051 crap.ContentAPI/Login

Response:

{
    "token": "eyJhbGciOi...",
    "user": {
        "id": "abc123",
        "collection": "users",
        "fields": { "name": "Admin", "email": "admin@example.com", "role": "admin" }
    }
}

Authenticated Requests

Pass the token via authorization metadata:

grpcurl -plaintext \
    -H "authorization: Bearer eyJhbGciOi..." \
    -d '{"collection": "posts"}' \
    localhost:50051 crap.ContentAPI/Find

Get Current User

grpcurl -plaintext -d '{
    "token": "eyJhbGciOi..."
}' localhost:50051 crap.ContentAPI/Me

Multiple Auth Collections

You can have multiple auth collections (e.g., users and admins). The Login RPC takes a collection parameter to specify which one to authenticate against.

The admin UI login always tries all auth collections.

Password Reset Flow

When email is configured ([email] section in crap.toml):

Admin UI

  1. User clicks “Forgot password?” on the login page
  2. Enters their email address and selects the auth collection
  3. Server generates a nanoid reset token with 1-hour expiry
  4. Reset email is sent with a link to /admin/reset-password?token=xxx
  5. User clicks the link, enters a new password
  6. Server validates the token, updates the password, and redirects to login

gRPC

# Step 1: Request password reset
grpcurl -plaintext -d '{
    "collection": "users",
    "email": "admin@example.com"
}' localhost:50051 crap.ContentAPI/ForgotPassword

# Step 2: Reset password with token from email
grpcurl -plaintext -d '{
    "collection": "users",
    "token": "the-token-from-email",
    "new_password": "newsecret123"
}' localhost:50051 crap.ContentAPI/ResetPassword

Note: ForgotPassword always returns success to prevent user enumeration.

Email Verification Flow

When verify_email: true is set on an auth collection:

Admin UI

  1. User is created (via admin form or gRPC)
  2. Verification email is sent automatically with a link to /admin/verify-email?token=xxx
  3. Verification tokens expire after 24 hours
  4. User clicks the verification link (expired tokens show an error)
  5. Login attempts before verification return “Please verify your email”

gRPC

grpcurl -plaintext -d '{
    "collection": "users",
    "token": "the-token-from-email"
}' localhost:50051 crap.ContentAPI/VerifyEmail

Custom Strategies

Custom auth strategies let you authenticate users via mechanisms other than password login — API keys, LDAP, SSO headers, etc.

Configuration

crap.collections.define("users", {
    auth = {
        strategies = {
            {
                name = "api-key",
                authenticate = "hooks.auth.api_key_check",
            },
            {
                name = "sso",
                authenticate = "hooks.auth.sso_check",
            },
        },
        -- disable_local = true,  -- optionally disable password login
    },
    -- ...
})

Strategy Properties

PropertyTypeDescription
namestringStrategy name for logging and identification
authenticatestringLua function ref in module.function format

Authenticate Function

The function receives a context table and returns a user document (table) or nil/false.

-- hooks/auth.lua
local M = {}

function M.api_key_check(ctx)
    -- ctx.headers   = table of request headers (lowercase keys)
    -- ctx.collection = auth collection slug ("users")

    local key = ctx.headers["x-api-key"]
    if key == nil then return nil end

    -- Look up user by API key
    local result = crap.collections.find(ctx.collection, {
        where = { api_key = key },
        limit = 1,
    })

    if result.total > 0 then
        return result.documents[1]  -- return user document
    end

    return nil  -- strategy didn't match
end

return M

Context Table

FieldTypeDescription
headerstableHTTP request headers (lowercase keys, string values)
collectionstringAuth collection slug

CRUD Access

Strategy functions have full CRUD access (via the same TxContext pattern as hooks). They can query the database to look up users.

Execution Order

In admin UI middleware:

  1. JWT cookie check (fast path — always runs first)
  2. Custom strategies in definition order
  3. Redirect to /admin/login (if all fail)

Disabling Password Login

Set disable_local = true to hide the password login form:

auth = {
    disable_local = true,
    strategies = {
        { name = "sso", authenticate = "hooks.auth.sso_check" },
    },
}

When disable_local is true:

  • The login form shows a message instead of email/password inputs
  • Only custom strategies can authenticate users
  • The Login gRPC RPC is effectively disabled for this collection

CLI User Creation

The user create command bootstraps users without the admin UI or gRPC API. Useful for creating the first admin user.

Interactive Mode

Prompts for password with hidden input and confirmation:

crap-cms user create -e admin@example.com

Output:

Password: ********
Confirm password: ********
Created user abc123 in 'users'

If required fields have no default value, you’ll be prompted for those too.

Non-Interactive Mode

For CI/scripting. The -p flag skips the prompt:

crap-cms user create \
    -e admin@example.com \
    -p secret123 \
    -f role=admin \
    -f name="Admin User"

Warning: The password may be visible in shell history. Use interactive mode for production bootstrapping.

Flags

FlagShortDescription
--collection-cAuth collection to create the user in (default: users)
--email-eUser email (prompted if omitted)
--password-pUser password (prompted if omitted)
--field-fExtra field values as key=value (repeatable)

Behavior

  • Runs after Lua definitions are loaded and database schema is synced
  • No hooks are fired (this is a bootstrap/admin tool)
  • Creates the user in a single transaction
  • Hashes the password with Argon2id
  • Exits after creating the user (does not start the server)

Field Handling

  • Required fields with default_value — uses the default, prompts with [default] if interactive
  • Required fields without defaults — prompts for input, fails if empty
  • Optional fields — skipped unless provided via -f
  • Checkbox fields — skipped (absent = false)
  • Email field — always required (handled separately from -f)

Examples

# Minimal (will prompt for everything else)
crap-cms user create

# Different collection
crap-cms user create -c admins \
    -e boss@example.com

# Full non-interactive
crap-cms user create \
    -e editor@example.com \
    -p pass123 \
    -f name="Jane Editor" \
    -f role=editor

Other User Commands

# Show detailed info for a user
crap-cms user info -e admin@example.com

# List all users
crap-cms user list

# Lock/unlock a user
crap-cms user lock -e user@example.com
crap-cms user unlock -e user@example.com

# Verify/unverify a user (requires verify_email: true on collection)
crap-cms user verify -e user@example.com
crap-cms user unverify -e user@example.com

# Change password
crap-cms user change-password -e user@example.com

# Delete a user (with confirmation skip)
crap-cms user delete -e user@example.com -y

Access Control

Crap CMS provides opt-in access control at both collection and field levels. Access functions are Lua function refs that return one of three values:

  • true — allowed
  • false or nil — denied
  • A filter table (read only) — allowed with query constraints

Opt-In

If no access control is configured, everything is allowed. This is fully backward compatible with existing setups.

To enforce a “secure by default” posture, set default_deny = true in [access] in crap.toml. With this setting, collections and globals without explicit access functions deny all operations instead of allowing them. Every collection must then explicitly declare its access rules.

Three Levels

  1. Admin panel-leveladmin.access in crap.toml. A Lua function that gates access to the entire admin UI, checked after login. See Admin UI.
  2. Collection-level — controls who can read, create, update, or delete documents in a collection. See Collection-Level.
  3. Field-level — controls which fields are visible or writable per-user. See Field-Level.

Access Function Context

All access functions receive a context table:

function M.check(ctx)
    -- ctx.user  = full user document (or nil if anonymous)
    -- ctx.id    = document ID (for update/delete/find_by_id)
    -- ctx.data  = incoming data (for create/update)
    return true  -- or false, or a filter table
end
FieldTypePresent WhenDescription
usertable or nilAlwaysFull user document from the auth collection. nil if no auth or anonymous.
idstring or nilupdate, delete, find_by_idDocument ID
datatable or nilcreate, updateIncoming data

CRUD Access in Access Functions

Access functions run with transaction context — they can call crap.collections.find() etc. to make decisions based on data in other collections.

Note: Lua CRUD functions enforce access control by default (overrideAccess = false). If your access function calls CRUD internally, pass overrideAccess = true to avoid recursive access checks:

function M.check(ctx)
    local count = crap.collections.count("items", { overrideAccess = true })
    return count < 100  -- allow if under limit
end

Collection-Level Access Control

Collection-level access controls who can perform CRUD operations on a collection.

Configuration

crap.collections.define("posts", {
    access = {
        read   = "hooks.access.public_read",
        create = "hooks.access.authenticated",
        update = "hooks.access.authenticated",
        trash  = "hooks.access.authenticated",
        delete = "hooks.access.admin_only",
    },
    -- ...
})

Each property is a Lua function ref (string) or nil (no restriction).

PropertyControlsFallback
readFind and FindByID operations
createCreate operation
updateUpdate operation
trashSoft-delete (move to trash) and restore. Only relevant when soft_delete = true.update
deletePermanent deletion, empty trash. For collections without soft_delete, this is the only delete permission.

Note: When soft_delete = true, trash and delete are separate permissions. trash controls the reversible action (low privilege), delete controls the destructive action (high privilege). If trash is not set, it falls back to update. If delete is not set, permanent deletion is restricted to the auto-purge scheduler. See Soft Deletes.

Writing Access Functions

Access functions live in Lua modules under the config directory:

-- hooks/access.lua
local M = {}

-- Allow anyone (including anonymous)
function M.public_read(ctx)
    return true
end

-- Require any authenticated user
function M.authenticated(ctx)
    return ctx.user ~= nil
end

-- Require admin role
function M.admin_only(ctx)
    return ctx.user ~= nil and ctx.user.role == "admin"
end

-- Allow users to only read their own documents
function M.own_only(ctx)
    if ctx.user == nil then return false end
    if ctx.user.role == "admin" then return true end
    return { created_by = ctx.user.id }  -- filter constraint
end

return M

Return Values

Return ValueEffect
trueOperation is allowed
false or nilOperation is denied (403/permission error)
tableRead operation is allowed with additional WHERE filters (see Filter Constraints)

Filter table returns are only meaningful for read access. For create, update, and delete, a table return is treated as Allowed.

Enforcement Points

  • Admin UI — middleware checks access before rendering pages
  • gRPC API — service checks access before executing operations
  • Access is checked once, before the operation begins

Field-Level Access Control

Field-level access controls which fields are visible or writable per-user.

Configuration

crap.fields.select({
    name = "status",
    access = {
        read = "hooks.access.everyone",
        create = "hooks.access.admin_only",
        update = "hooks.access.admin_only",
    },
    -- ...
})
PropertyControls
readWhether the field appears in API responses
createWhether the field can be set on create
updateWhether the field can be changed on update

Omitted properties default to allowed (no restriction).

How It Works

Write Access (create/update)

Before a write operation, denied fields are stripped from the input data. The operation proceeds with the remaining fields. This means:

  • On create: denied fields get their default value (or NULL)
  • On update: denied fields keep their current value

Read Access

After a query, denied fields are stripped from the response. The field still exists in the database, but the user doesn’t see it.

Example

-- hooks/access.lua
local M = {}

-- Only admins can see the internal_notes field
function M.admin_read(ctx)
    return ctx.user ~= nil and ctx.user.role == "admin"
end

-- Only admins can change the status field
function M.admin_write(ctx)
    return ctx.user ~= nil and ctx.user.role == "admin"
end

return M
-- In collection definition
crap.fields.textarea({
    name = "internal_notes",
    access = {
        read = "hooks.access.admin_read",
    },
}),
crap.fields.select({
    name = "status",
    access = {
        update = "hooks.access.admin_write",
    },
    -- ...
}),

Error Behavior

If a field access function throws an error, the field is treated as denied (fail-closed) and a warning is logged.

Filter Constraints

Read access functions can return a filter table instead of a boolean. The filters are merged as AND clauses into the query, restricting which documents the user can see.

Basic Usage

function M.own_posts(ctx)
    if ctx.user == nil then return false end
    if ctx.user.role == "admin" then return true end
    -- Regular users can only see their own posts
    return { author = ctx.user.id }
end

When this function returns { author = ctx.user.id }, the query gets an additional WHERE author = ? clause. The user only sees documents where author matches their ID.

Filter Format

The returned table uses the same format as crap.collections.find() filters:

-- Simple equality
return { status = "published" }

-- Operator-based filter
return { status = { not_equals = "archived" } }

-- Multiple constraints (AND)
return {
    status = "published",
    department = ctx.user.department,
}

How Constraints Are Merged

Constraints from access functions are merged with any existing query filters using AND:

Final WHERE = (user's filters) AND (access constraints)

This means constraints can only narrow results, never expand them.

Example: Multi-Tenant Access

function M.tenant_read(ctx)
    if ctx.user == nil then return false end
    -- Users can only see documents in their tenant
    return { tenant_id = ctx.user.tenant_id }
end

Example: Published-Only for Anonymous

function M.public_or_own(ctx)
    if ctx.user == nil then
        return { status = "published" }
    end
    if ctx.user.role == "admin" then
        return true  -- admins see everything
    end
    -- Authors see their own + published
    -- (Note: complex OR logic isn't supported in filter returns)
    return { author = ctx.user.id }
end

Uploads

Upload collections handle file storage with automatic metadata tracking. Enable uploads by setting upload = true or providing a config table.

Configuration

crap.collections.define("media", {
    labels = { singular = "Media", plural = "Media" },
    upload = {
        mime_types = { "image/*" },
        max_file_size = "10MB",    -- accepts bytes or "10MB", "1GB", etc.
        image_sizes = {
            { name = "thumbnail", width = 300, height = 300, fit = "cover" },
            { name = "card", width = 640, height = 480, fit = "cover" },
        },
        admin_thumbnail = "thumbnail",
        format_options = {
            webp = { quality = 80 },
            avif = { quality = 60 },
        },
    },
    fields = {
        crap.fields.text({ name = "alt", admin = { description = "Alt text" } }),
    },
})

Upload Config Properties

PropertyTypeDefaultDescription
mime_typesstring[]{} (any)MIME type allowlist. Supports glob patterns ("image/*"). Empty = allow all.
max_file_sizeinteger/stringglobal defaultMax file size. Accepts bytes (integer) or human-readable ("10MB", "1GB"). Overrides [upload] max_file_size in crap.toml.
image_sizesImageSize[]{}Resize definitions for image uploads. See Image Processing.
admin_thumbnailstringnilName of an image_sizes entry to use as thumbnail in admin lists.
format_optionstable{}Auto-generate format variants. See Image Processing.

Auto-Injected Fields

When uploads are enabled, these fields are automatically injected before your custom fields:

FieldTypeHiddenDescription
filenametextNo (readonly)Sanitized filename with unique prefix
mime_typetextYesMIME type of the uploaded file
filesizenumberYesFile size in bytes
widthnumberYesImage width (images only)
heightnumberYesImage height (images only)
urltextYesURL path to the original file
focal_xnumberYesFocal point X coordinate (0.0–1.0, default center)
focal_ynumberYesFocal point Y coordinate (0.0–1.0, default center)

For each image size, additional fields are injected:

Field PatternTypeDescription
{size}_urltextURL to the resized image
{size}_widthnumberActual width after resize
{size}_heightnumberActual height after resize
{size}_webp_urltextURL to WebP variant (if enabled)
{size}_avif_urltextURL to AVIF variant (if enabled)

File Storage

Files are stored at <config_dir>/uploads/<collection_slug>/:

uploads/
└── media/
    ├── a1b2c3_my-photo.jpg          # original
    ├── a1b2c3_my-photo_thumbnail.jpg # resized
    ├── a1b2c3_my-photo_thumbnail.webp
    ├── a1b2c3_my-photo_thumbnail.avif
    ├── a1b2c3_my-photo_card.jpg
    ├── a1b2c3_my-photo_card.webp
    └── a1b2c3_my-photo_card.avif

Filenames are sanitized (lowercase, characters that are not alphanumeric, hyphens, or underscores are replaced with hyphens) and prefixed with a random 10-character nanoid.

URL Structure

Files are served at /uploads/<collection>/<filename>:

/uploads/media/a1b2c3_my-photo.jpg
/uploads/media/a1b2c3_my-photo_thumbnail.webp

API Response

The sizes field in API responses is a structured object assembled from the per-size columns:

{
    "url": "/uploads/media/a1b2c3_my-photo.jpg",
    "filename": "a1b2c3_my-photo.jpg",
    "sizes": {
        "thumbnail": {
            "url": "/uploads/media/a1b2c3_my-photo_thumbnail.jpg",
            "width": 300,
            "height": 300,
            "formats": {
                "webp": { "url": "/uploads/media/a1b2c3_my-photo_thumbnail.webp" },
                "avif": { "url": "/uploads/media/a1b2c3_my-photo_thumbnail.avif" }
            }
        }
    }
}

MIME Type Patterns

PatternMatches
"image/*"All image types (png, jpeg, gif, webp, etc.)
"application/pdf"Only PDF files
"*/*" or "*"Any file type

Empty mime_types array also accepts any file.

Error Cleanup

If an error occurs during upload processing (e.g., image resize fails partway through), all files written so far are automatically cleaned up. This prevents orphaned files from accumulating on disk.

Content Negotiation

When serving image files, the upload handler performs automatic content negotiation based on the browser’s Accept header. If a modern format variant exists on disk, it is served instead of the original:

  1. AVIF — served if the client sends Accept: image/avif and a .avif variant exists
  2. WebP — served if the client sends Accept: image/webp and a .webp variant exists
  3. Original — served if no matching variant exists

AVIF is preferred over WebP when both are accepted. The response includes a Vary: Accept header so caches store format-specific versions correctly.

This works for all image URLs (/uploads/...) including originals and resized variants. Non-image files (PDFs, etc.) are always served as-is.

Focal Point

Upload collections include focal_x and focal_y fields that store the subject/focus coordinates of an image as floats in the 0.0–1.0 range. Center is (0.5, 0.5).

Setting in Admin UI: On the upload collection edit page, click anywhere on the image preview to set the focal point. A crosshair marker shows the current position. The values are saved with the form.

Frontend usage: Use the coordinates with CSS object-position to keep the subject in frame when cropping at different aspect ratios:

.responsive-image {
  object-fit: cover;
  object-position: calc(var(--focal-x) * 100%) calc(var(--focal-y) * 100%);
}

Or inline from template data:

<img src="/uploads/media/photo.jpg"
     style="object-fit: cover; object-position: 50% 30%;" />

The values are available in API responses as focal_x and focal_y number fields.

File Deletion

When a document in an upload collection is deleted, all associated files (original + resized + format variants) are deleted from disk.

Image Processing

When an upload collection has image_sizes configured, uploaded images are automatically resized and optionally converted to modern formats.

Image Sizes

Each size definition creates a resized variant of the uploaded image:

image_sizes = {
    { name = "thumbnail", width = 300, height = 300, fit = "cover" },
    { name = "card", width = 640, height = 480, fit = "contain" },
    { name = "hero", width = 1920, height = 1080, fit = "inside" },
}

Size Properties

PropertyTypeDefaultDescription
namestringrequiredSize identifier. Used in URLs and field names.
widthintegerrequiredTarget width in pixels
heightintegerrequiredTarget height in pixels
fitstring"cover"Resize fit mode

Fit Modes

ModeBehavior
coverResize to fill the target dimensions, then center-crop. No empty space. Aspect ratio preserved.
containResize to fit within the target dimensions. May be smaller than target. Aspect ratio preserved.
insideSame as contain — resize to fit within bounds, preserving aspect ratio.
fillStretch to exact target dimensions. Aspect ratio may change.

Format Options

Generate modern format variants for each image size:

format_options = {
    webp = { quality = 80 },  -- WebP at 80% quality
    avif = { quality = 60 },  -- AVIF at 60% quality
}
FormatQuality RangeNotes
webp1-100Lossy WebP via libwebp
avif1-100AVIF via the image crate’s AVIF encoder (speed=8)

Format variants are generated for each image size, not for the original. This keeps original files untouched.

Background Queue

By default, format conversion happens synchronously during upload. For large images or slow formats like AVIF, you can defer conversion to a background queue:

format_options = {
    webp = { quality = 80 },
    avif = { quality = 60, queue = true },  -- processed in background
}

When queue = true:

  1. The upload completes immediately without generating that format variant
  2. A queue entry is inserted into the _crap_image_queue table
  3. The scheduler picks up pending entries and processes them in the background
  4. Once complete, the document’s URL column is updated with the new file path

This is useful for AVIF which is significantly slower to encode than WebP. The queue option is per-format — you can queue AVIF while keeping WebP synchronous.

Use the images CLI command to inspect and manage the queue:

crap-cms -C ./my-project images stats       # counts by status
crap-cms -C ./my-project images list        # list recent entries
crap-cms -C ./my-project images list -s failed  # show only failed
crap-cms -C ./my-project images retry --all -y  # retry all failed
crap-cms -C ./my-project images purge --older-than 7d  # clean up old entries

Processing Pipeline

For each uploaded image:

  1. Original — saved as-is to uploads/<collection>/<id>_<filename>
  2. Image dimensions — read from the decoded image
  3. Per-size variants — resized according to fit mode, saved in the original format
  4. Format variants — each sized image is also saved as WebP and/or AVIF (if configured)

Non-image files (PDFs, etc.) skip steps 2-4.

Admin Thumbnail

Set admin_thumbnail to the name of an image size to display it in admin list views:

upload = {
    image_sizes = {
        { name = "thumbnail", width = 300, height = 300, fit = "cover" },
    },
    admin_thumbnail = "thumbnail",
}

Example: Full Media Collection

crap.collections.define("media", {
    labels = { singular = "Media", plural = "Media" },
    upload = {
        mime_types = { "image/*" },
        max_file_size = 10485760,
        image_sizes = {
            { name = "thumbnail", width = 300, height = 300, fit = "cover" },
            { name = "card", width = 640, height = 480, fit = "cover" },
            { name = "hero", width = 1920, height = 1080, fit = "inside" },
        },
        admin_thumbnail = "thumbnail",
        format_options = {
            webp = { quality = 80 },
            avif = { quality = 60 },
        },
    },
    fields = {
        crap.fields.text({ name = "alt", admin = { description = "Alt text for accessibility" } }),
        crap.fields.textarea({ name = "caption" }),
    },
})

Uploading from Client Apps

File uploads in Crap CMS use dedicated HTTP endpoints that accept multipart form data and return JSON. These are separate from the admin UI routes and designed for programmatic use.

Upload API Endpoints

MethodRouteDescription
POST/api/upload/{slug}Upload file + create document
PATCH/api/upload/{slug}/{id}Replace file on existing document
DELETE/api/upload/{slug}/{id}Delete document + files

All endpoints require authentication via Authorization: Bearer <jwt> header and return JSON responses.

Upload Flow

Uploading is a two-step process:

  1. Upload the file — POST a multipart form to create a document in the upload collection
  2. Reference it — use the upload document’s ID as a relationship field value in other collections

Creating an Upload

POST /api/upload/{slug}
Content-Type: multipart/form-data
Authorization: Bearer <jwt>

Form Fields

FieldTypeDescription
_filefileThe file to upload (required)
Any other fieldtextCustom fields defined on the collection (e.g., alt, caption)

Response

201 Created
Content-Type: application/json
{
    "document": {
        "id": "abc123",
        "filename": "a1b2c3_photo.jpg",
        "mime_type": "image/jpeg",
        "filesize": 245760,
        "url": "/uploads/media/a1b2c3_photo.jpg",
        "width": 1920,
        "height": 1080,
        "alt": "A beautiful sunset",
        "created_at": "2025-01-15T10:30:00Z",
        "updated_at": "2025-01-15T10:30:00Z"
    }
}

Example: cURL

curl -X POST http://localhost:3000/api/upload/media \
  -H "Authorization: Bearer $TOKEN" \
  -F "_file=@/path/to/photo.jpg" \
  -F "alt=A beautiful sunset"

Example: JavaScript (fetch)

const form = new FormData();
form.append('_file', fileInput.files[0]);
form.append('alt', 'A beautiful sunset');

const response = await fetch('/api/upload/media', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${token}`,
  },
  body: form,
});
const { document } = await response.json();
console.log(document.url); // /uploads/media/a1b2c3_photo.jpg

Example: Python (requests)

import requests

files = {'_file': open('photo.jpg', 'rb')}
data = {'alt': 'A beautiful sunset'}

response = requests.post(
    'http://localhost:3000/api/upload/media',
    files=files,
    data=data,
    headers={'Authorization': f'Bearer {token}'},
)
doc = response.json()['document']

Replacing a File

Replace the file on an existing upload document. Old files are cleaned up on success.

PATCH /api/upload/{slug}/{id}
Content-Type: multipart/form-data
Authorization: Bearer <jwt>

The form fields are the same as create. The _file field is optional — if omitted, only the metadata fields are updated.

curl -X PATCH http://localhost:3000/api/upload/media/abc123 \
  -H "Authorization: Bearer $TOKEN" \
  -F "_file=@/path/to/new-photo.jpg" \
  -F "alt=Updated caption"

Response

{
    "document": {
        "id": "abc123",
        "filename": "x9y8z7_new-photo.jpg",
        "url": "/uploads/media/x9y8z7_new-photo.jpg",
        "alt": "Updated caption",
        "updated_at": "2025-01-15T11:00:00Z"
    }
}

Deleting an Upload

Delete an upload document and all associated files (original + resized + format variants).

DELETE /api/upload/{slug}/{id}
Authorization: Bearer <jwt>
curl -X DELETE http://localhost:3000/api/upload/media/abc123 \
  -H "Authorization: Bearer $TOKEN"

Response

{
    "success": true
}

Error Responses

All error responses follow the same format:

{
    "error": "description of what went wrong"
}
StatusCause
400Bad request (no file, invalid MIME type, file too large, validation error)
403Access denied (missing or invalid token, access control denied)
404Collection or document not found
500Server error

Server Processing

When the server receives an upload:

  1. Validates the MIME type against the collection’s mime_types allowlist
  2. Checks file size against max_file_size
  3. Sanitizes the filename (lowercase, hyphens, unique prefix)
  4. Saves the original file to uploads/{collection}/{nanoid}_{filename} (a random 10-character nanoid prefix, not the document ID)
  5. Resizes images according to image_sizes (if configured)
  6. Generates WebP/AVIF variants (if format_options configured)
  7. Runs before-hooks within a transaction
  8. Creates a document with all metadata fields populated
  9. Fires after-hooks and publishes live events

Downloading Files

Files are served via HTTP GET:

GET /uploads/{collection}/{filename}
# Public file (no access.read configured)
curl http://localhost:3000/uploads/media/a1b2c3_photo_thumbnail.webp

# Protected file (requires auth)
curl http://localhost:3000/uploads/media/a1b2c3_photo.jpg \
  -H "Authorization: Bearer ${TOKEN}"

Caching

AccessCache-Control
Public (no access.read)public, max-age=31536000, immutable
Protected (access.read configured)private, no-store

Using Uploads in Other Collections

Reference upload documents via relationship fields:

-- collections/posts.lua
crap.collections.define("posts", {
    fields = {
        crap.fields.text({ name = "title", required = true }),
        crap.fields.relationship({
            name = "cover_image",
            relationship = { collection = "media", has_many = false },
        }),
    },
})

Then when creating a post, pass the upload document’s ID:

# gRPC
grpcurl -plaintext -d '{
    "collection": "posts",
    "data": {
        "title": "My Post",
        "cover_image": "the_upload_id"
    }
}' localhost:50051 crap.ContentAPI/Create

With depth = 1, the upload document is fully populated in the response, giving you access to all URLs and sizes.

Authentication

Upload API endpoints use Bearer token authentication:

Authorization: Bearer <jwt>

Obtain a token via the Login gRPC RPC or the admin login flow. Access control on the upload collection (access.create, access.update, access.delete) is enforced the same as for gRPC operations.

Lua API

The crap global table is the entry point for all CMS operations in Lua. It’s available in init.lua, collection definitions, and hook functions.

Namespace

NamespaceDescription
crap.collectionsCollection definition and CRUD operations
crap.globalsGlobal definition and get/update operations
crap.fieldsField factory functions (crap.fields.text(), etc.)
crap.hooksGlobal hook registration
crap.jobsJob definition
crap.logStructured logging
crap.utilUtility functions
crap.authPassword hashing and verification (Argon2id)
crap.envRead-only environment variable access
crap.httpOutbound HTTP requests (blocking)
crap.configRead-only access to crap.toml values
crap.localeLocale configuration queries
crap.emailSend email via configured SMTP
crap.cryptoCryptographic utilities (HMAC, random bytes, hashing)
crap.schemaRuntime schema introspection
crap.richtextCustom rich text node registration

CRUD Availability

CRUD functions (crap.collections.find, .create, .update, .delete, crap.globals.get, .update) are only available inside hooks with transaction context:

  • before_validate hooks — Yes
  • before_change hooks — Yes
  • before_delete hooks — Yes
  • after_change hooks — Yes (runs inside the same transaction via run_hooks_with_conn)
  • after_delete hooks — Yes (runs inside the same transaction via run_hooks_with_conn)
  • after_read hooks — No (no transaction)
  • before_read hooks — No (no transaction)
  • Collection definition files — No

Calling CRUD functions outside of transaction context results in an error:

crap.collections CRUD functions are only available inside hooks
with transaction context (before_change, before_delete, etc.)

Lua VM Architecture

Crap CMS uses two stages of Lua execution:

  1. Startup VM — a single VM that loads collection/global definitions and runs init.lua. Used only during initialization, then discarded.
  2. HookRunner pool — a pool of Lua VMs for runtime hook execution (size configured via hooks.vm_pool_size). Each VM gets its own copy of the crap.* API with CRUD functions registered.

All VMs have the config directory on their package path, so require("hooks.posts") works in both stages.

crap.collections

Collection definition and runtime CRUD operations.

crap.collections.define(slug, config)

Define a new collection. Call this in collection definition files (collections/*.lua).

crap.collections.define("posts", {
    labels = { singular = "Post", plural = "Posts" },
    fields = {
        crap.fields.text({ name = "title", required = true }),
    },
})

See Collection Definition Schema for all config options.

crap.collections.config.get(slug)

Get a collection’s current definition as a Lua table. The returned table is round-trip compatible with define() — you can modify it and pass it back.

Returns nil if the collection doesn’t exist.

local def = crap.collections.config.get("posts")
if def then
    -- Add a field
    def.fields[#def.fields + 1] = crap.fields.text({ name = "extra" })
    crap.collections.define("posts", def)
end

crap.collections.config.list()

Get all registered collections as a slug-keyed table. Iterate with pairs().

for slug, def in pairs(crap.collections.config.list()) do
    if def.upload then
        -- Add alt_text to every upload collection
        def.fields[#def.fields + 1] = crap.fields.text({ name = "alt_text" })
        crap.collections.define(slug, def)
    end
end

See Plugins for patterns using these functions.

crap.collections.find(collection, query?)

Find documents matching a query. Returns a result table with documents and pagination.

Only available inside hooks with transaction context.

local result = crap.collections.find("posts", {
    where = {
        status = "published",
        title = { contains = "hello" },
    },
    order_by = "-created_at",
    limit = 10,
    page = 1,
    depth = 1,
})

-- result.documents               = array of document tables
-- result.pagination.totalDocs    = total count (before limit/page)
-- result.pagination.limit        = applied limit
-- result.pagination.totalPages   = total pages (offset mode only)
-- result.pagination.page         = current page (offset mode only, 1-based)
-- result.pagination.pageStart    = 1-based index of first doc on this page
-- result.pagination.hasNextPage  = boolean
-- result.pagination.hasPrevPage  = boolean
-- result.pagination.prevPage     = previous page number (nil if first page)
-- result.pagination.nextPage     = next page number (nil if last page)
-- result.pagination.startCursor  = opaque cursor of first doc (cursor mode only)
-- result.pagination.endCursor    = opaque cursor of last doc (cursor mode only)

for _, doc in ipairs(result.documents) do
    print(doc.id, doc.title)
end

Query Parameters

FieldTypeDefaultDescription
wheretable{}Field filters. See Filter Operators. Supports ["or"] key for OR groups.
order_bystringnilSort field. Prefix with - for descending.
limitintegernilMax results to return.
pageinteger1Page number (1-based). Converted to offset internally.
offsetintegernilNumber of results to skip (backward compat alias for page).
after_cursorstringnilForward cursor from a previous result.pagination.endCursor. Fetches the page after the cursor position. Mutually exclusive with page/offset/before_cursor. Only effective when [pagination] mode = "cursor" in crap.toml.
before_cursorstringnilBackward cursor from a previous result.pagination.startCursor. Fetches the page before the cursor position. Mutually exclusive with page/offset/after_cursor. Only effective when [pagination] mode = "cursor" in crap.toml.
depthinteger0Population depth for relationship fields.
selectstring[]nilFields to return. nil = all fields. Always includes id. When specified, created_at and updated_at are only included if explicitly listed.
draftbooleanfalseInclude draft documents. Only affects versioned collections with drafts = true.
localestringnilLocale code for localized fields (e.g., "en", "de").
overrideAccessbooleanfalseBypass access control checks. Set to true to skip collection-level and field-level access for the current user.
searchstringnilFTS5 full-text search query. Filters results to documents matching this search term.

crap.collections.find_by_id(collection, id, opts?)

Find a single document by ID. Returns the document table or nil.

Only available inside hooks with transaction context.

local doc = crap.collections.find_by_id("posts", "abc123")
if doc then
    print(doc.title)
end

-- With population depth
local doc = crap.collections.find_by_id("posts", "abc123", { depth = 2 })

-- With field selection (only return title and status)
local doc = crap.collections.find_by_id("posts", "abc123", { select = { "title", "status" } })

Options

FieldTypeDefaultDescription
depthinteger0Population depth for relationship fields.
selectstring[]nilFields to return. nil = all fields. Always includes id.
draftbooleanfalseReturn the latest draft version snapshot instead of the published main-table data. Only affects versioned collections with drafts = true.
localestringnilLocale code for localized fields (e.g., "en", "de").
overrideAccessbooleanfalseBypass access control checks. Set to true to skip collection-level and field-level access for the current user.

crap.collections.create(collection, data, opts?)

Create a new document. Returns the created document.

Only available inside hooks with transaction context.

local doc = crap.collections.create("posts", {
    title = "New Post",
    slug = "new-post",
})
print(doc.id)  -- auto-generated nanoid

-- Create as draft (versioned collections only)
local draft = crap.collections.create("articles", {
    title = "Work in progress",
}, { draft = true })

Options

FieldTypeDefaultDescription
localestringnilLocale code for localized fields.
draftbooleanfalseCreate as draft. Skips required field validation. Only affects versioned collections with drafts = true.
overrideAccessbooleanfalseBypass access control checks. Set to true to skip collection-level and field-level access for the current user.
hooksbooleantrueRun lifecycle hooks. Set to false to skip all hooks (before_validate, before_change, after_change) and validation. The DB operation still executes.

crap.collections.update(collection, id, data, opts?)

Update an existing document. Returns the updated document.

Only available inside hooks with transaction context.

local doc = crap.collections.update("posts", "abc123", {
    title = "Updated Title",
})

-- Draft update: saves a version snapshot only, main table unchanged
crap.collections.update("articles", "abc123", {
    title = "Still editing...",
}, { draft = true })

Options

FieldTypeDefaultDescription
localestringnilLocale code for localized fields.
draftbooleanfalseVersion-only save. Creates a draft version snapshot without modifying the main table. Only affects versioned collections with drafts = true.
unpublishbooleanfalseSet document status to draft and create a draft version snapshot. Ignores the data fields when unpublishing. Only affects versioned collections.
overrideAccessbooleanfalseBypass access control checks. Set to true to skip collection-level and field-level access for the current user.
hooksbooleantrueRun lifecycle hooks. Set to false to skip all hooks (before_validate, before_change, after_change) and validation. The DB operation still executes.

Auth Collections

For collections with auth = true, the password field is automatically handled:

  • On create, if the data contains a password key, it is extracted before hooks run, hashed with Argon2id, and stored in the hidden _password_hash column. Hooks never see the raw password.
  • On update, same pattern — if password is present and non-empty, the password is updated. Leave it out or set it to "" to keep the current password.

This matches the behavior of the gRPC API and admin UI.

crap.collections.delete(collection, id, opts?)

Delete a document. Returns true on success. For collections with soft_delete = true, moves the document to trash by default. For upload collections, associated files are cleaned up on permanent deletion (not on soft delete).

Only available inside hooks with transaction context.

-- Soft-delete (moves to trash if collection has soft_delete)
crap.collections.delete("posts", "abc123")

-- Force permanent delete even on soft-delete collections
crap.collections.delete("posts", "abc123", { forceHardDelete = true })

-- Bypass access control for internal operations
crap.collections.delete("posts", "abc123", { overrideAccess = true })

Options

FieldTypeDefaultDescription
overrideAccessbooleanfalseBypass access control checks. Set to true to skip access.trash (soft delete) or access.delete (permanent delete) checks.
hooksbooleantrueRun lifecycle hooks. Set to false to skip before_delete and after_delete hooks.
forceHardDeletebooleanfalsePermanently delete even when the collection has soft_delete = true. Requires access.delete permission when overrideAccess = false.

crap.collections.restore(collection, id)

Restore a soft-deleted document from trash. Returns true on success. Only works on collections with soft_delete = true. Re-syncs the FTS index after restore.

Only available inside hooks with transaction context.

crap.collections.restore("posts", "abc123")

Lifecycle Hooks in Lua CRUD

Lua CRUD operations run the same lifecycle hooks as the gRPC API and admin UI:

  • create: before_validate → validate → before_change → DB insert → after_change
  • update: before_validate → validate → before_change → DB update → after_change
  • update_many: per-document: before_validate → validate → before_change → DB update → after_change
  • delete: before_delete → DB delete → upload file cleanup → after_delete
  • delete_many: per-document: before_delete → DB delete → upload file cleanup → after_delete
  • find / find_by_id: before_read → DB query → after_read

All hooks have full CRUD access within the same transaction.

Hook Depth & Recursion Protection

When hooks call CRUD functions that trigger more hooks, the system tracks recursion depth via ctx.hook_depth. This prevents infinite loops:

  • Depth starts at 0 for gRPC/admin operations, 1 for Lua CRUD within hooks
  • When depth reaches hooks.max_depth (default: 3, configurable in crap.toml), hooks are automatically skipped but the DB operation still executes
  • Use ctx.hook_depth in hooks for manual recursion decisions
# crap.toml
[hooks]
max_depth = 3   # 0 = never run hooks from Lua CRUD
function M.my_hook(ctx)
    if ctx.hook_depth >= 2 then
        return ctx  -- bail early to avoid deep recursion
    end
    crap.collections.create("audit", { action = ctx.operation })
    return ctx
end

Skipping Hooks

Pass hooks = false to any write CRUD call to skip all lifecycle hooks:

-- Create without triggering any hooks
crap.collections.create("logs", { message = "raw insert" }, { hooks = false })

Access Control in Hooks

By default, all Lua CRUD functions enforce access control (overrideAccess = false). This follows the principle of least privilege — if your hook needs to bypass access checks, it must explicitly opt in with overrideAccess = true.

Breaking change (0.1.0-alpha.3): The default was changed from true to false. If you have hooks that call CRUD functions without specifying overrideAccess, they now enforce access control. Add overrideAccess = true to restore the old behavior.

When overrideAccess is false (the default), the function enforces the same access rules as the external API:

  • Collection-level access — the relevant access function (read, create, update, delete) is called with the authenticated user from the original request.
  • Field-level access — for find/find_by_id, fields the user can’t read are stripped from results. For create/update, fields the user can’t write are silently removed from the input data.
  • Constrained read access — if a read access function returns a filter table instead of true, those filters are merged into the query (same as the gRPC/admin behavior).
-- Default: access control is enforced (only shows posts the user can see)
local result = crap.collections.find("posts", {
    where = { status = "published" },
})

-- Bypass access control for internal/admin operations
local all = crap.collections.find("posts", {
    overrideAccess = true,
})

crap.collections.count(collection, query?)

Count documents matching a query. Returns an integer count.

Only available inside hooks with transaction context.

local n = crap.collections.count("posts")
local published = crap.collections.count("posts", {
    where = { status = "published" },
})

Query Parameters

FieldTypeDefaultDescription
wheretable{}Field filters. Same syntax as find.
localestringnilLocale code for localized fields.
overrideAccessbooleanfalseBypass access control checks.
draftbooleanfalseInclude draft documents.
searchstringnilFTS5 full-text search query (same as find).

crap.collections.update_many(collection, query, data, opts?)

Update multiple documents matching a query. Returns { modified = N }.

All-or-nothing semantics: finds all matching documents, checks update access for each (if overrideAccess = false), and only proceeds if all pass. If any document fails access, an error is returned and nothing is modified.

Runs the full per-document lifecycle by default: before_validate → field validation → before_change → DB update → after_change — the same pipeline as single-document update. Set hooks = false in opts to skip hooks and validation for performance on large batch operations.

Only provided fields are written (partial update). Absent fields are left unchanged — including checkbox fields, which are not reset to 0 as they would be in a full single-document update.

Only available inside hooks with transaction context.

local result = crap.collections.update_many("posts", {
    where = { status = "draft" },
}, {
    status = "published",
})
print(result.modified)  -- number of updated documents

-- Skip hooks and validation for performance
local result = crap.collections.update_many("posts", {
    where = { status = "draft" },
}, {
    status = "published",
}, { hooks = false })

Query Parameters (2nd argument)

FieldTypeDefaultDescription
wheretable{}Field filters to match documents.

Options (4th argument)

FieldTypeDefaultDescription
localestringnilLocale code for localized fields.
overrideAccessbooleanfalseBypass access control checks.
draftbooleanfalseInclude draft documents.
hooksbooleantrueRun per-document lifecycle hooks. Set to false to skip all hooks (before_validate, before_change, after_change) and field validation.

Data (3rd argument)

The data table contains fields to update on all matched documents (partial update).

crap.collections.delete_many(collection, query, opts?)

Delete multiple documents matching a query. Returns { deleted = N, skipped = N }. For upload collections, associated files are automatically cleaned up from disk for each deleted document. Documents that are still referenced by other documents are skipped (hard delete only) and reported in skipped.

All-or-nothing semantics: finds all matching documents, checks delete access for each (if overrideAccess = false), and only proceeds if all pass.

Fires per-document lifecycle hooks (before_delete, after_delete) by default. Set hooks = false in opts to skip for performance on large batch operations.

Only available inside hooks with transaction context.

local result = crap.collections.delete_many("posts", {
    where = { status = "archived" },
})
print(result.deleted)  -- number of deleted documents
print(result.skipped)  -- number skipped due to outstanding references

-- Bypass access control for internal operations
local result = crap.collections.delete_many("posts", {
    where = { status = "archived" },
}, { overrideAccess = true })

-- Skip hooks for performance
local result = crap.collections.delete_many("posts", {
    where = { status = "archived" },
}, { hooks = false })

Query Parameters (2nd argument)

FieldTypeDefaultDescription
wheretable{}Field filters to match documents.

Options (3rd argument)

FieldTypeDefaultDescription
overrideAccessbooleanfalseBypass access control checks.
hooksbooleantrueRun per-document lifecycle hooks. Set to false to skip before_delete and after_delete hooks.
localestringnilLocale code for localized fields.
draftbooleanfalseInclude draft documents.

crap.globals

Global (singleton document) definition and runtime operations.

crap.globals.define(slug, config)

Define a new global. Call this in global definition files (globals/*.lua).

crap.globals.define("site_settings", {
    labels = { singular = "Site Settings" },
    fields = {
        crap.fields.text({ name = "site_name", required = true, default_value = "My Site" }),
        crap.fields.text({ name = "tagline" }),
    },
})

See Globals for the full config reference.

crap.globals.config.get(slug)

Get a global’s current definition as a Lua table. The returned table is round-trip compatible with define() — you can modify it and pass it back.

Returns nil if the global doesn’t exist.

local def = crap.globals.config.get("site_settings")
if def then
    def.fields[#def.fields + 1] = crap.fields.textarea({ name = "footer_text" })
    crap.globals.define("site_settings", def)
end

crap.globals.config.list()

Get all registered globals as a slug-keyed table. Iterate with pairs().

for slug, def in pairs(crap.globals.config.list()) do
    -- Add a "last_updated_by" field to every global
    def.fields[#def.fields + 1] = crap.fields.text({ name = "last_updated_by" })
    crap.globals.define(slug, def)
end

See Plugins for patterns using these functions.

crap.globals.get(slug, opts?)

Get a global’s current value. Returns a document table.

Only available inside hooks with transaction context.

Parameters

ParameterTypeDescription
slugstringGlobal slug
optstable (optional)Options table

Options

KeyTypeDescription
localestringLocale code (e.g., "en", "de"). Fetches locale-specific field values. If omitted, returns the default locale data.
local settings = crap.globals.get("site_settings")
print(settings.site_name)  -- "My Site"
print(settings.id)         -- always "default"

-- Fetch German locale data
local settings_de = crap.globals.get("site_settings", { locale = "de" })

crap.globals.update(slug, data, opts?)

Update a global’s value. Returns the updated document.

Only available inside hooks with transaction context.

Parameters

ParameterTypeDescription
slugstringGlobal slug
datatableFields to update
optstable (optional)Options table

Options

KeyTypeDescription
localestringLocale code (e.g., "en", "de"). Updates locale-specific field values. If omitted, updates the default locale data.
local settings = crap.globals.update("site_settings", {
    site_name = "New Site Name",
    tagline = "A new beginning",
})

-- Update German locale data
crap.globals.update("site_settings", {
    site_name = "Neuer Seitenname",
}, { locale = "de" })

crap.hooks

Global hook registration API. Register hooks in init.lua to fire for all collections.

crap.hooks.register(event, fn)

Register a hook function for a lifecycle event.

crap.hooks.register("before_change", function(ctx)
    crap.log.info("[audit] " .. ctx.operation .. " on " .. ctx.collection)
    return ctx
end)

Parameters

ParameterTypeDescription
eventstringLifecycle event name
fnfunctionHook function receiving a context table

Events

EventDescription
before_validateBefore field validation on create/update
before_changeAfter validation, before write on create/update
after_changeAfter create/update (runs in transaction, has CRUD access)
before_readBefore returning read results
after_readAfter read, before response (no CRUD access)
before_deleteBefore delete
after_deleteAfter delete (runs in transaction, has CRUD access)
before_broadcastBefore live event broadcast (can suppress or transform)
before_renderBefore rendering admin pages (receives full template context, can modify it; global-only, no CRUD access)

crap.hooks.remove(event, fn)

Remove a previously registered hook. Uses rawequal for identity matching — you must pass the exact same function reference.

local my_hook = function(ctx) return ctx end

crap.hooks.register("before_change", my_hook)
crap.hooks.remove("before_change", my_hook)

Parameters

ParameterTypeDescription
eventstringLifecycle event name
fnfunctionThe exact function reference to remove

crap.hooks.list(event)

Return the list of registered hook functions for an event. Useful for debugging or introspection.

local hooks = crap.hooks.list("before_change")
print(#hooks)  -- number of registered before_change hooks

Parameters

ParameterTypeDescription
eventstringLifecycle event name

Returns

A Lua table (array) of the registered hook functions. Empty table if none are registered.

crap.log

Structured logging that maps to Rust’s tracing framework. Log messages appear with a [lua:<vm>] prefix, where <vm> is the VM label (e.g., init, vm-1, vm-2).

Functions

crap.log.info(msg)

Log an info-level message.

crap.log.info("Processing complete")

Output: INFO [lua:vm-1] Processing complete

crap.log.warn(msg)

Log a warning-level message.

crap.log.warn("Deprecated field used")

Output: WARN [lua:vm-1] Deprecated field used

crap.log.error(msg)

Log an error-level message.

crap.log.error("Failed to process webhook")

Output: ERROR [lua:vm-1] Failed to process webhook

Parameters

ParameterTypeDescription
msgstringLog message

Usage in Hooks

function M.before_change(ctx)
    crap.log.info(string.format(
        "[%s] %s on %s",
        os.date("%H:%M:%S"),
        ctx.operation,
        ctx.collection
    ))
    return ctx
end

crap.json

JSON encode/decode functions. These are the same functions available as crap.util.json_encode / crap.util.json_decode, exposed under a dedicated namespace for convenience.

crap.json.encode(value)

Encode a Lua value (table, string, number, boolean, nil) as a JSON string.

local json = crap.json.encode({ name = "test", count = 42 })
-- '{"count":42,"name":"test"}'
ParameterTypeDescription
valueanyLua value to encode
ReturnsstringJSON string

crap.json.decode(str)

Decode a JSON string into a Lua value.

local data = crap.json.decode('{"name":"test","count":42}')
print(data.name)   -- "test"
print(data.count)  -- 42
ParameterTypeDescription
strstringJSON string
ReturnsanyDecoded Lua value

Common Patterns

Webhook Payload

crap.http.request({
    method = "POST",
    url = webhook_url,
    headers = { ["Content-Type"] = "application/json" },
    body = crap.json.encode({
        event = "new_inquiry",
        name = inquiry.name,
        email = inquiry.email,
    }),
})

Parse API Response

local resp = crap.http.request({ url = "https://api.example.com/data" })
if resp.status == 200 then
    local data = crap.json.decode(resp.body)
    crap.log.info("Got " .. #data .. " items")
end

crap.util

Utility functions available everywhere the crap global is accessible.

crap.util.slugify(str)

Generate a URL-safe slug from a string. Lowercases, replaces non-alphanumeric characters with hyphens, and collapses consecutive hyphens.

crap.util.slugify("Hello World")      -- "hello-world"
crap.util.slugify("Hello, World!")     -- "hello-world"
crap.util.slugify("  multiple   spaces  ") -- "multiple-spaces"
ParameterTypeDescription
strstringInput string
ReturnsstringURL-safe slug

crap.util.nanoid()

Generate a unique nanoid string (21 characters by default).

local id = crap.util.nanoid()
-- e.g., "V1StGXR8_Z5jdHi6B-myT"

| Returns | string | Random nanoid |

crap.util.json_encode(value)

Encode a Lua value (table, string, number, boolean, nil) as a JSON string.

Tip: crap.json.encode() and crap.json.decode() are aliases — see crap.json.

local json = crap.util.json_encode({ name = "test", count = 42 })
-- '{"count":42,"name":"test"}'
ParameterTypeDescription
valueanyLua value to encode
ReturnsstringJSON string

crap.util.json_decode(str)

Decode a JSON string into a Lua value.

local data = crap.util.json_decode('{"name":"test","count":42}')
print(data.name)   -- "test"
print(data.count)  -- 42
ParameterTypeDescription
strstringJSON string
ReturnsanyDecoded Lua value

Common Hook Patterns

Auto-Slug Generation

function M.auto_slug(ctx)
    if not ctx.data.slug or ctx.data.slug == "" then
        ctx.data.slug = crap.util.slugify(ctx.data.title or "")
    end
    return ctx
end

Generate Unique Identifiers

function M.set_ref_code(ctx)
    if ctx.operation == "create" then
        ctx.data.ref_code = "REF-" .. crap.util.nanoid()
    end
    return ctx
end

Serialize Complex Data

function M.store_metadata(ctx)
    if type(ctx.data.metadata) == "table" then
        ctx.data.metadata = crap.util.json_encode(ctx.data.metadata)
    end
    return ctx
end

Table Helpers

crap.util.deep_merge(a, b)

Deep merge two tables. b overwrites a. Returns a new table.

local merged = crap.util.deep_merge(
    { name = "old", nested = { x = 1 } },
    { name = "new", nested = { y = 2 } }
)
-- { name = "new", nested = { x = 1, y = 2 } }

crap.util.pick(tbl, keys)

Return a table with only the listed keys.

local picked = crap.util.pick({ a = 1, b = 2, c = 3 }, { "a", "c" })
-- { a = 1, c = 3 }

crap.util.omit(tbl, keys)

Return a table without the listed keys.

local result = crap.util.omit({ a = 1, b = 2, c = 3 }, { "b" })
-- { a = 1, c = 3 }

crap.util.keys(tbl) / crap.util.values(tbl)

Extract keys or values as arrays.

local k = crap.util.keys({ a = 1, b = 2 })   -- { "a", "b" }
local v = crap.util.values({ a = 1, b = 2 })  -- { 1, 2 }

crap.util.map(tbl, fn) / crap.util.filter(tbl, fn) / crap.util.find(tbl, fn)

Functional array operations.

local doubled = crap.util.map({ 1, 2, 3 }, function(v) return v * 2 end)
-- { 2, 4, 6 }

local evens = crap.util.filter({ 1, 2, 3, 4 }, function(v) return v % 2 == 0 end)
-- { 2, 4 }

local found = crap.util.find({ 1, 2, 3 }, function(v) return v > 1 end)
-- 2

crap.util.includes(tbl, value)

Check if an array contains a value.

crap.util.includes({ "a", "b", "c" }, "b")  -- true

crap.util.is_empty(tbl)

Check if a table has no entries.

crap.util.is_empty({})       -- true
crap.util.is_empty({ a = 1 }) -- false

crap.util.clone(tbl)

Shallow copy a table.

local original = { a = 1 }
local copy = crap.util.clone(original)
copy.a = 2
print(original.a)  -- 1 (unchanged)

String Helpers

crap.util.trim(str)

Strip leading and trailing whitespace.

crap.util.trim("  hello  ")  -- "hello"

crap.util.split(str, sep)

Split a string by separator. Returns an array.

crap.util.split("a,b,c", ",")  -- { "a", "b", "c" }

crap.util.starts_with(str, prefix) / crap.util.ends_with(str, suffix)

crap.util.starts_with("hello world", "hello")  -- true
crap.util.ends_with("hello world", "world")    -- true

crap.util.truncate(str, max_len, suffix?)

Truncate a string with optional suffix (default: "...").

crap.util.truncate("Hello, World!", 8)         -- "Hello..."
crap.util.truncate("Hello, World!", 8, " >>")  -- "Hello >>"

Date Helpers

crap.util.date_now()

Get current UTC time as ISO 8601 string.

local now = crap.util.date_now()  -- "2024-01-15T10:30:00+00:00"

crap.util.date_timestamp()

Get current Unix timestamp in seconds.

local ts = crap.util.date_timestamp()  -- 1705312200

crap.util.date_parse(str)

Parse a date string to Unix timestamp. Tries RFC 3339, then %Y-%m-%d %H:%M:%S, then %Y-%m-%d.

local ts = crap.util.date_parse("2024-01-15T10:30:00Z")
local ts2 = crap.util.date_parse("2024-01-15")

crap.util.date_format(timestamp, format)

Format a Unix timestamp using chrono format syntax.

local str = crap.util.date_format(1705312200, "%Y-%m-%d")  -- "2024-01-15"

crap.util.date_add(timestamp, seconds) / crap.util.date_diff(a, b)

Arithmetic on timestamps.

local tomorrow = crap.util.date_add(crap.util.date_timestamp(), 86400)
local diff = crap.util.date_diff(tomorrow, crap.util.date_timestamp())  -- 86400

crap.auth

Password hashing and verification helpers using Argon2id.

Functions

crap.auth.hash_password(password)

Hash a plaintext password using Argon2id.

Parameters:

  • password (string) — Plaintext password.

Returns: string — The hashed password string.

local hash = crap.auth.hash_password("secret123")
-- hash is an Argon2id hash string like "$argon2id$v=19$..."

crap.auth.verify_password(password, hash)

Verify a plaintext password against a stored hash.

Parameters:

  • password (string) — Plaintext password to check.
  • hash (string) — Stored Argon2id hash.

Returns: boolean — true if the password matches.

local valid = crap.auth.verify_password("secret123", stored_hash)
if valid then
    crap.log.info("Password matches")
end

Notes

  • Available in both init.lua and hooks.
  • Uses the same Argon2id implementation as the built-in auth system.
  • Useful for custom auth strategies or migrating users from external systems.

crap.env

Read-only access to environment variables.

Functions

crap.env.get(key)

Get the value of an environment variable.

Parameters:

  • key (string) — Environment variable name.

Returns: string or nil — The value, or nil if the variable is not set.

local db_url = crap.env.get("CRAP_DATABASE_URL")
if db_url then
    crap.log.info("DB URL: " .. db_url)
end

-- Common pattern: env with fallback
local port = crap.env.get("CRAP_PORT") or "3000"

Allowed Prefixes

For security, crap.env.get() only allows access to environment variables with specific prefixes:

PrefixPurpose
CRAP_Application-specific variables (e.g., CRAP_API_KEY, CRAP_WEBHOOK_URL)
LUA_Lua-specific variables (e.g., LUA_PATH, LUA_CPATH)

All other environment variables (e.g., PATH, HOME, DATABASE_URL, AWS_SECRET_ACCESS_KEY) return nil regardless of whether they are set. This prevents hooks from accidentally or maliciously reading sensitive system or infrastructure variables.

-- These work (if set):
crap.env.get("CRAP_API_TOKEN")   -- returns the value
crap.env.get("LUA_PATH")         -- returns the value

-- These always return nil:
crap.env.get("PATH")             -- nil
crap.env.get("HOME")             -- nil
crap.env.get("DATABASE_URL")     -- nil

Notes

  • Available in both init.lua and hooks.
  • Returns nil for unset variables and for variables with disallowed prefixes (never errors).
  • Useful for reading secrets, feature flags, or deployment-specific values without hardcoding them in Lua files.
  • To pass configuration to hooks, set environment variables with the CRAP_ prefix (e.g., CRAP_SMTP_HOST, CRAP_WEBHOOK_SECRET).

crap.http

Outbound HTTP client for making requests from Lua hooks and init.lua.

Functions

crap.http.request(opts)

Make a blocking HTTP request.

Parameters:

  • opts (table):
    • url (string, required) — Request URL.
    • method (string, optional) — HTTP method. Default: "GET". Supported: GET, POST, PUT, PATCH, DELETE, HEAD.
    • headers (table, optional) — Request headers as key-value pairs.
    • body (string, optional) — Request body.
    • timeout (integer, optional) — Timeout in seconds. Default: 30.

Returns: table — Response with fields:

  • status (integer) — HTTP status code.
  • headers (table) — Response headers as key-value pairs.
  • body (string) — Response body as a string.

Errors: Throws a Lua error on transport failures (DNS, connection refused, timeout).

-- Simple GET
local resp = crap.http.request({ url = "https://api.example.com/data" })
if resp.status == 200 then
    local data = crap.util.json_decode(resp.body)
    crap.log.info("Got " .. #data .. " items")
end

-- POST with JSON body
local resp = crap.http.request({
    url = "https://api.example.com/webhook",
    method = "POST",
    headers = {
        ["Content-Type"] = "application/json",
        ["Authorization"] = "Bearer " .. crap.env.get("CRAP_API_TOKEN"),
    },
    body = crap.util.json_encode({ event = "document.created", id = ctx.data.id }),
    timeout = 10,
})

Notes

  • Uses reqwest (blocking HTTP client). Since Lua hooks run inside spawn_blocking, blocking I/O is correct and won’t stall the async runtime.
  • Non-2xx responses are not errors — they return normally with the status code. Only transport-level failures (DNS, timeout, connection refused) throw Lua errors.
  • Available in both init.lua and hooks.

Security

Private network blocking

When hooks.allow_private_networks is false (the default), crap.http.request resolves the URL hostname and rejects requests targeting loopback, private (RFC 1918), link-local, and unspecified IP addresses. This prevents SSRF attacks against internal services. Set allow_private_networks = true in crap.toml only if your hooks need to reach internal services.

DNS rebinding protection

DNS is resolved once during validation, checked against the SSRF policy, and the validated IP is pinned via reqwest::ClientBuilder::resolve(). The HTTP client connects to the exact validated address — no second DNS lookup occurs. Redirects are individually resolved, validated, and pinned before following.

crap.config

Read-only access to crap.toml configuration values using dot notation.

Functions

crap.config.get(key)

Get a configuration value by dot-separated key path.

Parameters:

  • key (string) — Dot-separated config key (e.g., "server.admin_port").

Returns: any — The value at that key path, or nil if the path doesn’t exist.

local port = crap.config.get("server.admin_port")   -- 3000
local host = crap.config.get("server.host")          -- "0.0.0.0"
local dev = crap.config.get("admin.dev_mode")        -- false
local depth = crap.config.get("depth.max_depth")     -- 10
local expiry = crap.config.get("auth.token_expiry")  -- 7200

Available Keys

The config structure mirrors crap.toml:

KeyTypeDefault
server.admin_portinteger3000
server.grpc_portinteger50051
server.hoststring“0.0.0.0”
database.pathstring“data/crap.db”
admin.dev_modebooleanfalse
auth.secretstring“”
auth.token_expiryinteger7200
depth.default_depthinteger1
depth.max_depthinteger10
upload.max_file_sizeinteger52428800
hooks.on_initstring[][]
hooks.max_depthinteger3
hooks.vm_pool_sizeinteger(auto)
hooks.max_instructionsinteger10000000
hooks.max_memoryinteger52428800
hooks.allow_private_networksbooleanfalse
hooks.http_max_response_bytesinteger10485760
pagination.default_limitinteger20
pagination.max_limitinteger1000
pagination.modestring“page”
locale.default_localestring“en”
locale.localesstring[][]
locale.fallbackbooleantrue
email.smtp_hoststring“”
live.enabledbooleantrue
access.default_denybooleanfalse

All sections from crap.toml are available — this table is not exhaustive. The entire CrapConfig struct is serialized to Lua.

Notes

  • Values are a read-only snapshot taken at VM creation time. Changes to crap.toml after startup won’t be reflected until the process restarts.
  • Available in both init.lua and hooks.
  • Returns nil for non-existent keys (never errors).

crap.email

Send emails via SMTP. Requires the [email] section in crap.toml to be configured.

Configuration

[email]
smtp_host = "smtp.example.com"
smtp_port = 587
smtp_user = "noreply@example.com"
smtp_pass = "your-smtp-password"
smtp_tls = "starttls"    # "starttls" (default), "tls" (implicit), "none" (plain/test)
from_address = "noreply@example.com"
from_name = "My App"

If smtp_host is empty (default), all crap.email.send() calls log a warning and return true (no-op). The system remains fully functional without SMTP.

crap.email.send(opts)

Send an email.

Parameters:

FieldTypeRequiredDescription
tostringyesRecipient email address
subjectstringyesEmail subject line
htmlstringyesHTML email body
textstringnoPlain text fallback body

Returns: true on success.

Example:

crap.email.send({
    to = "user@example.com",
    subject = "Welcome!",
    html = "<h1>Welcome</h1><p>Thanks for signing up.</p>",
    text = "Welcome! Thanks for signing up.",
})

Use in Hooks

crap.email.send() is blocking (uses SMTP transport), which is correct because Lua hooks run inside spawn_blocking. Safe to call from any hook.

-- hooks/notifications.lua
local M = {}

function M.notify_on_create(ctx)
    local admin_email = crap.env.get("CRAP_ADMIN_EMAIL")
    if admin_email then
        crap.email.send({
            to = admin_email,
            subject = "New " .. ctx.collection .. " created",
            html = "<p>A new document was created in <b>" .. ctx.collection .. "</b>.</p>",
        })
    end
    return ctx
end

return M
-- collections/posts.lua
crap.collections.define("posts", {
    hooks = {
        after_change = { "hooks.notifications.notify_on_create" },
    },
    fields = { ... },
})

crap.locale

Read-only access to the locale configuration. Available in init.lua and all hook functions.

Functions

crap.locale.get_default()

Returns the default locale code from crap.toml.

local default = crap.locale.get_default()  -- "en"

crap.locale.get_all()

Returns an array of all configured locale codes. Returns an empty table if localization is disabled.

local locales = crap.locale.get_all()  -- {"en", "de", "fr"}

crap.locale.is_enabled()

Returns true if localization is enabled (at least one locale configured in crap.toml).

if crap.locale.is_enabled() then
    -- localization is active
end

Example

-- In a hook: generate localized slugs
function M.before_change(ctx)
    if crap.locale.is_enabled() and ctx.locale then
        ctx.data.slug = crap.util.slugify(ctx.data.title) .. "-" .. ctx.locale
    else
        ctx.data.slug = crap.util.slugify(ctx.data.title)
    end
    return ctx
end

crap.crypto

Cryptographic helpers. AES-256-GCM encryption key is derived from the auth.secret in crap.toml.

crap.crypto.sha256(data)

SHA-256 hash of a string, returned as a 64-character hex string.

local hash = crap.crypto.sha256("hello world")
-- "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"

crap.crypto.hmac_sha256(data, key)

HMAC-SHA256 of data with a key, returned as hex.

local mac = crap.crypto.hmac_sha256("message", "secret-key")

crap.crypto.base64_encode(str) / crap.crypto.base64_decode(str)

Base64 encoding and decoding.

local encoded = crap.crypto.base64_encode("hello")  -- "aGVsbG8="
local decoded = crap.crypto.base64_decode(encoded)   -- "hello"

crap.crypto.encrypt(plaintext) / crap.crypto.decrypt(ciphertext)

AES-256-GCM encryption using the auth secret from crap.toml. The encrypted output is base64-encoded with a random nonce prepended.

local encrypted = crap.crypto.encrypt("sensitive data")
local original = crap.crypto.decrypt(encrypted)  -- "sensitive data"

Note: The encryption key is derived from auth.secret in crap.toml — the same secret used for JWT signing. Rotating the JWT secret will invalidate all previously encrypted data. If you rotate secrets, you must re-encrypt any data that was encrypted with the old secret.

crap.crypto.random_bytes(n)

Generate n random bytes, returned as a hex string of length 2*n.

local token = crap.crypto.random_bytes(16)  -- 32-character hex string

crap.schema

Schema introspection API. Provides read-only access to collection and global definitions loaded from Lua files. Available everywhere the crap global is accessible.

crap.schema.get_collection(slug)

Get a collection’s full schema definition. Returns a table or nil if not found.

local schema = crap.schema.get_collection("posts")
if schema then
    print(schema.slug)           -- "posts"
    print(schema.timestamps)     -- true
    print(schema.has_auth)       -- false
    print(#schema.fields)        -- number of fields
    for _, field in ipairs(schema.fields) do
        print(field.name, field.type, field.required)
    end
end

Return Value

FieldTypeDescription
slugstringCollection slug.
labelstable{ singular?, plural? } display names.
timestampsbooleanWhether created_at/updated_at are enabled.
has_authbooleanWhether authentication is enabled.
has_uploadbooleanWhether file uploads are enabled.
has_versionsbooleanWhether versioning is enabled.
has_draftsbooleanWhether draft/publish workflow is enabled (versioned + drafts).
fieldstable[]Array of field definitions (see below).

Field Schema

Each field table contains:

FieldTypeDescription
namestringField name.
typestringField type (text, number, relationship, etc.).
requiredbooleanWhether the field is required.
localizedbooleanWhether the field has per-locale values.
uniquebooleanWhether the field has a unique constraint.
relationshiptable?{ collection, has_many } for relationship fields.
optionstable[]?{ label, value } for select fields.
fieldstable[]?Sub-fields for array/group types (recursive).
blockstable[]?Block definitions for blocks type.

crap.schema.get_global(slug)

Get a global’s schema definition. Same return shape as get_collection.

local schema = crap.schema.get_global("site_settings")

crap.schema.list_collections()

List all registered collections with their slugs and labels.

local collections = crap.schema.list_collections()
for _, c in ipairs(collections) do
    print(c.slug, c.labels.singular)
end

crap.schema.list_globals()

List all registered globals with their slugs and labels.

local globals = crap.schema.list_globals()
for _, g in ipairs(globals) do
    print(g.slug, g.labels.singular)
end

crap.richtext

Register custom ProseMirror node types for the rich text editor and render rich text content to HTML.

Functions

crap.richtext.register_node(name, spec)

Register a custom rich text node type.

Parameters:

  • name (string) — Node name (alphanumeric + underscores only).
  • spec (table) — Node specification.

Spec fields:

FieldTypeDefaultDescription
labelstringnameDisplay label in the editor toolbar
inlinebooleanfalseWhether this is an inline node (vs block)
attrsFieldDefinition[]{}Node attributes via crap.fields.* (scalar types only)
searchable_attrsstring[]{}Attribute names included in full-text search
renderfunctionnilCustom HTML render function (attrs) -> string

Node attributes use crap.fields.* factory functions (same as collection fields). Only scalar types are allowed: text, number, textarea, select, radio, checkbox, date, email, json, code.

Supported attribute features:

  • Admin display hints: admin.hidden, admin.readonly, admin.width, admin.step, admin.rows, admin.language, admin.placeholder, admin.description
  • Validation bounds: required, validate, min/max, min_length/max_length, min_date/max_date, picker_appearance
  • Lifecycle hooks: hooks.before_validate (normalize values before validation)

Features that have no effect on node attrs (unique, index, localized, has_many, access, hooks.before_change/after_change/after_read, mcp, admin.condition) produce a warning at registration time but do not error.

crap.richtext.register_node("callout", {
    label = "Callout",
    attrs = {
        crap.fields.select({ name = "type", options = {
            { label = "Info", value = "info" },
            { label = "Warning", value = "warning" },
        }}),
        crap.fields.text({ name = "body", admin = { rows = 4 } }),
    },
    searchable_attrs = { "body" },
    render = function(attrs)
        return string.format(
            '<div class="callout callout-%s">%s</div>',
            attrs.type or "info",
            attrs.body or ""
        )
    end,
})

crap.richtext.render(content)

Render a rich text JSON string to HTML, including any registered custom nodes.

Parameters:

  • content (string) — ProseMirror JSON content string.

Returns: string — Rendered HTML.

local html = crap.richtext.render(doc.body)

Notes

  • Register nodes in init.lua so they’re available to all VMs.
  • Custom nodes appear in the rich text editor toolbar for fields that include them.
  • The render function is called during crap.richtext.render() to convert custom nodes to HTML.

crap.jobs

Background job definition and queuing.

crap.jobs.define(slug, config)

Define a background job. Call in init.lua or jobs/*.lua files.

Parameters:

  • slug (string) — Unique job identifier
  • config (table) — Job configuration:
    • handler (string, required) — Lua function ref (e.g., "jobs.cleanup.run")
    • schedule (string, optional) — Cron expression (e.g., "0 3 * * *")
    • queue (string, default: "default") — Queue name
    • retries (integer, default: 0) — Max retry attempts
    • timeout (integer, default: 60) — Seconds before timeout
    • concurrency (integer, default: 1) — Max concurrent runs
    • skip_if_running (boolean, default: true) — Skip cron if still running
    • labels (table, optional) — { singular = "Display Name" }
    • access (string, optional) — Lua function ref for trigger access control

Example:

crap.jobs.define("send_digest", {
    handler = "jobs.digest.run",
    schedule = "0 8 * * 1",  -- Mondays at 8am
    retries = 2,
    timeout = 120,
})

crap.jobs.queue(slug, data?)

Queue a job for background execution. Only available inside hooks with transaction context.

Parameters:

  • slug (string) — Job slug (must be previously defined)
  • data (table, optional) — Input data passed to the handler (default: {})

Returns: string — The queued job run ID.

Example:

-- In an after_change hook
local job_id = crap.jobs.queue("send_welcome_email", {
    user_id = ctx.data.id,
    email = ctx.data.email,
})
crap.log.info("Queued welcome email job: " .. job_id)

Handler Function

The handler function receives a context table and has full CRUD access:

local M = {}
function M.run(ctx)
    -- ctx.data: input data from queue() or {} for cron
    -- ctx.job.slug: job definition slug
    -- ctx.job.attempt: current attempt (1-based)
    -- ctx.job.max_attempts: total attempts allowed

    -- Full CRUD access:
    local result = crap.collections.find("posts", {
        where = { status = "expired" }
    })

    -- Return value is stored as the job result (optional)
    return { processed = result.pagination.totalDocs }
end
return M

Filter Operators

Filters are used in crap.collections.find() queries and in access control constraint returns. They map to SQL WHERE clauses.

Shorthand: Simple Equality

String values are treated as equals:

{ where = { status = "published" } }
-- SQL: WHERE status = 'published'

Operator Syntax

Use a table to specify an operator:

{ where = { title = { contains = "hello" } } }
-- SQL: WHERE title LIKE '%hello%'

Operator Reference

OperatorLua SyntaxSQLDescription
equals{ equals = "value" }field = ?Exact match
not_equals{ not_equals = "value" }field != ?Not equal
like{ like = "pattern%" }field LIKE ?SQL LIKE pattern
contains{ contains = "text" }field LIKE '%text%' ESCAPE '\'Substring match (wildcards % and _ in the search text are escaped)
greater_than{ greater_than = "10" }field > ?Greater than
less_than{ less_than = "10" }field < ?Less than
greater_than_or_equal{ greater_than_or_equal = "10" }field >= ?Greater than or equal
less_than_or_equal{ less_than_or_equal = "10" }field <= ?Less than or equal
in{ ["in"] = { "a", "b" } }field IN (?, ?)Value in list
not_in{ not_in = { "a", "b" } }field NOT IN (?, ?)Value not in list
exists{ exists = true }field IS NOT NULLField is not null (value is ignored — only the key matters)
not_exists{ not_exists = true }field IS NULLField is null (value is ignored — use not_exists for IS NULL, not { exists = false })

Note: in is a Lua keyword, so use ["in"] bracket syntax.

Examples

-- Published posts containing "hello"
crap.collections.find("posts", {
    where = {
        status = "published",
        title = { contains = "hello" },
    },
})

-- Posts created after a date
crap.collections.find("posts", {
    where = {
        created_at = { greater_than = "2024-01-01" },
    },
})

-- Posts with specific statuses
crap.collections.find("posts", {
    where = {
        status = { ["in"] = { "draft", "published" } },
    },
})

-- Posts without a category
crap.collections.find("posts", {
    where = {
        category = { not_exists = true },
    },
})

-- Posts with wildcard title match
crap.collections.find("posts", {
    where = {
        title = { like = "Hello%" },
    },
})

Multiple Filters

Multiple filters are combined with AND:

crap.collections.find("posts", {
    where = {
        status = "published",
        created_at = { greater_than = "2024-01-01" },
        title = { contains = "update" },
    },
})
-- SQL: WHERE status = ? AND created_at > ? AND title LIKE ?

OR Groups

Use the ["or"] key to combine groups of conditions with OR logic. Each element is a table of AND-ed conditions:

-- title contains "hello" OR category = "news"
crap.collections.find("posts", {
    where = {
        ["or"] = {
            { title = { contains = "hello" } },
            { category = "news" },
        },
    },
})
-- SQL: WHERE (title LIKE '%hello%' OR category = ?)

OR can combine with top-level AND filters:

-- status = "published" AND (title contains "hello" OR title contains "world")
crap.collections.find("posts", {
    where = {
        status = "published",
        ["or"] = {
            { title = { contains = "hello" } },
            { title = { contains = "world" } },
        },
    },
})
-- SQL: WHERE status = ? AND (title LIKE '%hello%' OR title LIKE '%world%')

Each OR element can have multiple fields (AND-ed within the group):

-- (status = "published" AND title contains "hello") OR (status = "draft")
crap.collections.find("posts", {
    where = {
        ["or"] = {
            { status = "published", title = { contains = "hello" } },
            { status = "draft" },
        },
    },
})
-- SQL: WHERE ((status = ? AND title LIKE '%hello%') OR status = ?)

Note: or is not a Lua keyword, but ["or"] bracket syntax is recommended for clarity.

Nested Field Filters (Dot Notation)

Filter on sub-fields of group, array, blocks, and has-many relationship fields using dot notation:

-- Group sub-field: seo.meta_title → seo__meta_title column
crap.collections.find("pages", {
    where = { ["seo.meta_title"] = { contains = "SEO" } },
})

-- Array sub-field: find products with any variant color "red"
crap.collections.find("products", {
    where = { ["variants.color"] = "red" },
})

-- Block sub-field: find posts with any content block containing "hello"
crap.collections.find("posts", {
    where = { ["content.body"] = { contains = "hello" } },
})

-- Block type filter
crap.collections.find("posts", {
    where = { ["content._block_type"] = "image" },
})

-- Has-many relationship: find posts with a specific tag
crap.collections.find("posts", {
    where = { ["tags.id"] = "tag-123" },
})

Array and block filters use EXISTS subqueries — they match parent documents where at least one row matches. All filter operators work with dot notation paths.

See Query & Filters for the full reference.

Value Types

Filter values are always converted to strings for SQL parameter binding. Numbers and booleans are stringified:

{ where = { count = 42 } }       -- equals "42"
{ where = { active = true } }    -- equals "true"

MCP (Model Context Protocol)

Crap CMS includes a built-in MCP server that lets AI assistants (Claude Desktop, Cursor, VS Code extensions, custom agents) interact with your CMS content and schema.

The MCP server auto-generates tool definitions from your Lua-defined collections and globals. Any CMS instance automatically gets a full MCP API matching its schema.

Configuration

Add an [mcp] section to crap.toml:

[mcp]
enabled = true              # Enable MCP server (default: false)
http = false                # Enable HTTP transport on /mcp (default: false)
config_tools = false        # Enable config generation tools (default: false)
api_key = ""                # API key for HTTP auth (required when http = true)
include_collections = []    # Whitelist (empty = all)
exclude_collections = []    # Blacklist (takes precedence over include)

Transports

stdio (default)

Run the MCP server as a subprocess that reads JSON-RPC from stdin and writes to stdout:

crap-cms mcp

Or from outside the config directory:

crap-cms mcp -C /path/to/config

For Claude Desktop, add to your claude_desktop_config.json:

{
  "mcpServers": {
    "my-cms": {
      "command": "crap-cms",
      "args": ["mcp", "-C", "/path/to/config"]
    }
  }
}

HTTP

When mcp.http = true, the admin server exposes a POST /mcp endpoint. Send JSON-RPC 2.0 requests as the request body.

An api_key is required when HTTP transport is enabled. Requests must include an Authorization: Bearer <key> header. The server will refuse to start if mcp.http = true and api_key is empty.

Auto-Generated Tools

Content CRUD (per collection)

For each collection (e.g., posts), five tools are generated:

ToolDescription
find_postsQuery documents with filters, ordering, pagination
find_by_id_postsGet a single document by ID
create_postsCreate a new document
update_postsUpdate an existing document
delete_postsDelete a document

Input schemas are generated from your field definitions. Required fields, select options, and relationship types are all reflected in the JSON Schema.

Global CRUD (per global)

For each global (e.g., settings):

ToolDescription
global_read_settingsRead the global document
global_update_settingsUpdate the global document

Schema Introspection

Always available:

ToolDescription
list_collectionsList all collections with their labels and capabilities
describe_collectionGet full field schema for a collection or global
list_field_typesList all field types with descriptions and capabilities
cli_referenceGet CLI command reference (all or specific command)

Config Generation Tools (opt-in)

When config_tools = true:

ToolDescription
read_config_fileRead a file from the config directory
write_config_fileWrite a Lua file to the config directory
list_config_filesList files in the config directory

These are opt-in because they allow writing to the filesystem.

MCP Descriptions

Add optional mcp tables to your Lua definitions to provide context for AI assistants:

Collection level

return {
  slug = "posts",
  mcp = {
    description = "Blog posts with title, content, and author relationship",
  },
  fields = { ... }
}

Field level

crap.fields.select({
  name = "status",
  mcp = {
    description = "Publication status - controls visibility on the frontend",
  },
  options = { ... },
})

If no mcp.description is set, the tool falls back to admin.description (for fields) or a generated description based on the collection label.

Collection Filtering

Use include_collections and exclude_collections to control which collections are exposed via MCP:

[mcp]
enabled = true
exclude_collections = ["users"]  # Hide sensitive collections

exclude_collections takes precedence when a collection appears in both lists.

Security & Access Model

MCP operates with full access — collection-level and field-level access control functions are not applied. This is by design: MCP is a machine-to-machine API surface (equivalent to Lua’s overrideAccess = true), gated by transport-level authentication:

  • stdio: Access is controlled by who can run the process.
  • HTTP: Access is controlled by the api_key setting. An API key is required when http = true — the server will refuse to start without one. As a defense-in-depth measure, the HTTP endpoint also rejects all requests if the API key is somehow empty at runtime.

To restrict which collections are accessible, use include_collections / exclude_collections. These filters are enforced both in tool listing (tools/list) and at execution time, so knowing a collection slug is not enough to bypass the filter.

All MCP write operations (create, update, delete) are logged at info level for audit purposes. Hooks still fire on all MCP writes (same lifecycle as admin/gRPC).

Resources

The MCP server also exposes read-only resources:

URIDescription
crap://schema/collectionsFull schema of all collections as JSON
crap://schema/globalsFull schema of all globals as JSON
crap://configCurrent configuration (secrets sanitized: auth.secret, email.smtp_pass, mcp.api_key)

Query Parameters

The find_* tools accept these parameters:

ParameterTypeDescription
whereobjectFilter conditions (same syntax as gRPC/Lua API)
order_bystringSort field (prefix with - for descending, e.g., "-created_at")
limitintegerMax results per page
pageintegerPage number, 1-indexed (page mode only)
after_cursorstringForward cursor (cursor mode only, mutually exclusive with page and before_cursor)
before_cursorstringBackward cursor (cursor mode only, mutually exclusive with page and after_cursor)
depthintegerRelationship population depth
searchstringFull-text search query

Response Format

find_* tools return a JSON object with docs and pagination:

{
  "docs": [
    { "id": "abc123", "title": "Hello World", "created_at": "2026-01-15T09:00:00Z" }
  ],
  "pagination": {
    "totalDocs": 25,
    "limit": 10,
    "hasNextPage": true,
    "hasPrevPage": false,
    "totalPages": 3,
    "page": 1,
    "pageStart": 1,
    "nextPage": 2
  }
}

In cursor mode, page/totalPages/pageStart/nextPage/prevPage are replaced by startCursor/endCursor.

Where clause example

{
  "name": "find_posts",
  "arguments": {
    "where": {
      "status": { "equals": "published" },
      "created_at": { "greater_than": "2024-01-01" }
    },
    "order_by": "-created_at",
    "limit": 10
  }
}

Supported operators: equals, not_equals, greater_than, greater_than_equal, less_than, less_than_equal, like, contains, in (array), not_in (array), exists, not_exists.

Note: MCP uses shortened operator names (greater_than_equal, less_than_equal) compared to the gRPC/Lua API which uses greater_than_or_equal and less_than_or_equal.

gRPC API

Crap CMS exposes a gRPC API via Tonic for programmatic access to all content operations.

Service

service ContentAPI {
  rpc Find (FindRequest) returns (FindResponse);
  rpc FindByID (FindByIDRequest) returns (FindByIDResponse);
  rpc Create (CreateRequest) returns (CreateResponse);
  rpc Update (UpdateRequest) returns (UpdateResponse);
  rpc Delete (DeleteRequest) returns (DeleteResponse);
  rpc Count (CountRequest) returns (CountResponse);
  rpc UpdateMany (UpdateManyRequest) returns (UpdateManyResponse);
  rpc DeleteMany (DeleteManyRequest) returns (DeleteManyResponse);
  rpc GetGlobal (GetGlobalRequest) returns (GetGlobalResponse);
  rpc UpdateGlobal (UpdateGlobalRequest) returns (UpdateGlobalResponse);
  rpc Login (LoginRequest) returns (LoginResponse);
  rpc Me (MeRequest) returns (MeResponse);
  rpc ForgotPassword (ForgotPasswordRequest) returns (ForgotPasswordResponse);
  rpc ResetPassword (ResetPasswordRequest) returns (ResetPasswordResponse);
  rpc VerifyEmail (VerifyEmailRequest) returns (VerifyEmailResponse);
  rpc ListCollections (ListCollectionsRequest) returns (ListCollectionsResponse);
  rpc DescribeCollection (DescribeCollectionRequest) returns (DescribeCollectionResponse);
  rpc Subscribe (SubscribeRequest) returns (stream MutationEvent);
  rpc ListVersions (ListVersionsRequest) returns (ListVersionsResponse);
  rpc RestoreVersion (RestoreVersionRequest) returns (RestoreVersionResponse);
  rpc ListJobs (ListJobsRequest) returns (ListJobsResponse);
  rpc TriggerJob (TriggerJobRequest) returns (TriggerJobResponse);
  rpc GetJobRun (GetJobRunRequest) returns (GetJobRunResponse);
  rpc ListJobRuns (ListJobRunsRequest) returns (ListJobRunsResponse);
}

Port

Default: 50051 (configurable via [server] grpc_port in crap.toml).

Message Size Limits

The maximum gRPC message size (both request and response) defaults to 16MB — configurable via grpc_max_message_size in [server]. This is higher than Tonic’s built-in 4MB default to accommodate large Find responses with deep relationship population.

[server]
grpc_max_message_size = "32MB"  # increase for very large responses

Timeouts

An optional request timeout can be set via grpc_timeout in [server]. When set, RPCs exceeding the timeout return DEADLINE_EXCEEDED.

[server]
grpc_timeout = "30s"

Bulk Operation Limits

UpdateMany and DeleteMany process at most 10,000 documents per call. This prevents unbounded memory usage when a broad filter matches a very large dataset. For larger operations, use paginated calls with a where clause to process documents in batches.

Server Reflection

When enabled (grpc_reflection = true in [server], disabled by default), the server supports gRPC reflection, so tools like grpcurl work without importing the proto file:

# List services
grpcurl -plaintext localhost:50051 list

# Describe a service
grpcurl -plaintext localhost:50051 describe crap.ContentAPI

# Describe a message type
grpcurl -plaintext localhost:50051 describe crap.FindRequest

Document Format

All documents use the same message format:

message Document {
  string id = 1;
  string collection = 2;
  google.protobuf.Struct fields = 3;
  optional string created_at = 4;
  optional string updated_at = 5;
}

The fields property is a Struct (JSON object) containing all user-defined field values.

Testing with grpcurl

The repository includes tests/api.sh with grpcurl commands for every RPC:

source tests/api.sh
find_posts
create_post
find_post_by_id abc123

All commands use -plaintext (no TLS) and server reflection.

RPCs

All RPCs with request/response shapes and grpcurl examples.

Find

Find documents in a collection with filtering, sorting, and pagination.

message FindRequest {
  string collection = 1;
  optional string where = 2;            // JSON where clause
  optional string order_by = 3;         // "-field" for descending
  optional int64 limit = 4;
  optional int64 page = 5;             // page number (1-based, default: 1)
  optional int32 depth = 6;             // population depth (default: 0)
  optional string locale = 7;           // locale code for localized fields
  repeated string select = 8;           // fields to return (empty = all)
  optional bool draft = 9;              // true = include drafts (versioned collections)
  optional string after_cursor = 10;    // opaque forward cursor for cursor-based pagination
  optional string before_cursor = 11;   // opaque backward cursor for cursor-based pagination
  optional string search = 12;          // FTS5 full-text search query
}

message PaginationInfo {
  int64 total_docs = 1;                 // total matching documents (before limit/page)
  int64 limit = 2;                      // applied limit
  optional int64 total_pages = 3;      // total pages (page mode only)
  optional int64 page = 4;             // current page (page mode only, 1-based)
  optional int64 page_start = 5;      // 1-based index of first doc on this page (page mode only)
  bool has_prev_page = 6;              // whether a previous page exists
  bool has_next_page = 7;              // whether a next page exists
  optional int64 prev_page = 8;        // previous page number (nil if first page)
  optional int64 next_page = 9;        // next page number (nil if last page)
  optional string start_cursor = 10;   // opaque cursor of first doc in results (cursor mode only)
  optional string end_cursor = 11;     // opaque cursor of last doc in results (cursor mode only)
}

message FindResponse {
  repeated Document documents = 1;
  PaginationInfo pagination = 2;
}

Pagination metadata is nested in a PaginationInfo message. In page mode (default), page, total_pages, page_start, prev_page, and next_page are computed. In cursor mode, start_cursor and end_cursor are provided instead — these are the cursors of the first and last documents in the result set. has_prev_page and has_next_page work in both modes.

after_cursor/before_cursor and page are mutually exclusive. after_cursor and before_cursor are also mutually exclusive with each other. Cursors are only present in the response when [pagination] mode = "cursor" is set in crap.toml.

grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"status\": \"published\"}",
    "order_by": "-created_at",
    "limit": "10",
    "depth": 1
}' localhost:50051 crap.ContentAPI/Find

FindByID

Get a single document by ID.

message FindByIDRequest {
  string collection = 1;
  string id = 2;
  optional int32 depth = 3;  // default: depth.default_depth from crap.toml
  optional string locale = 4;  // locale code for localized fields
  repeated string select = 5;  // fields to return (empty = all)
  optional bool draft = 6;   // true = return latest version (may be draft)
}

message FindByIDResponse {
  optional Document document = 1;
}
grpcurl -plaintext -d '{
    "collection": "posts",
    "id": "abc123",
    "depth": 2
}' localhost:50051 crap.ContentAPI/FindByID

Create

Create a new document.

message CreateRequest {
  string collection = 1;
  google.protobuf.Struct data = 2;
  optional string locale = 3;           // locale code for localized fields
  optional bool draft = 4;              // true = create as draft (versioned collections)
}

message CreateResponse {
  Document document = 1;
}
grpcurl -plaintext -d '{
    "collection": "posts",
    "data": {
        "title": "Hello World",
        "slug": "hello-world",
        "status": "draft"
    }
}' localhost:50051 crap.ContentAPI/Create

For auth collections, include password in the data to set the user’s password.

Update

Update an existing document.

message UpdateRequest {
  string collection = 1;
  string id = 2;
  google.protobuf.Struct data = 3;
  optional string locale = 4;           // locale code for localized fields
  optional bool draft = 5;              // true = version-only save (main table unchanged)
  optional bool unpublish = 6;          // true = set status to draft
}

message UpdateResponse {
  Document document = 1;
}
grpcurl -plaintext -d '{
    "collection": "posts",
    "id": "abc123",
    "data": { "title": "Updated Title", "status": "published" }
}' localhost:50051 crap.ContentAPI/Update

Delete

Delete a document by ID. For collections with soft_delete = true, moves to trash by default. Set force_hard_delete = true to permanently delete.

message DeleteRequest {
  string collection = 1;
  string id = 2;
  bool force_hard_delete = 3;  // permanently delete even with soft_delete
}

message DeleteResponse {
  bool success = 1;
  bool soft_deleted = 2;       // true if moved to trash (not permanently deleted)
}
# Soft delete (moves to trash)
grpcurl -plaintext -d '{
    "collection": "posts",
    "id": "abc123"
}' localhost:50051 crap.ContentAPI/Delete

# Force permanent delete
grpcurl -plaintext -d '{
    "collection": "posts",
    "id": "abc123",
    "force_hard_delete": true
}' localhost:50051 crap.ContentAPI/Delete

Restore

Restore a soft-deleted document from trash. Only works on collections with soft_delete = true.

message RestoreRequest {
  string collection = 1;
  string id = 2;
}

message RestoreResponse {
  Document document = 1;
}
grpcurl -plaintext -d '{
    "collection": "posts",
    "id": "abc123"
}' localhost:50051 crap.ContentAPI/Restore

Count

Count documents matching an optional filter. Respects collection-level read access.

message CountRequest {
  string collection = 1;
  optional string where = 2;            // JSON where clause
  optional string locale = 3;           // locale code for localized field filtering
  optional bool draft = 4;              // true = include drafts
  optional string search = 5;           // FTS5 full-text search query
}

message CountResponse {
  int64 count = 1;
}
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"status\": \"published\"}"
}' localhost:50051 crap.ContentAPI/Count

UpdateMany

Bulk-update all documents matching a filter. All updates run in a single transaction (all-or-nothing). Runs the full per-document lifecycle by default: before_validate → field validation → before_change → DB update → after_change — the same pipeline as single-document Update.

Only provided fields are written (partial update). Absent fields are left unchanged — including checkbox fields, which are not reset to 0 as they would be in a full single-document update.

Password updates are rejected in bulk operations. Use single-document Update instead.

message UpdateManyRequest {
  string collection = 1;
  optional string where = 2;            // JSON where clause (omit = all docs)
  google.protobuf.Struct data = 3;      // field values to apply
  optional string locale = 4;           // locale code for localized fields
  optional bool draft = 5;              // true = save as drafts
  optional bool hooks = 6;              // default: true. Set false to skip hooks & validation.
}

message UpdateManyResponse {
  int64 modified = 1;
}
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"status\": \"draft\"}",
    "data": { "status": "published" }
}' localhost:50051 crap.ContentAPI/UpdateMany

Limit: A single UpdateMany call processes at most 10,000 documents. Use paginated calls (with a where clause) for larger datasets.

DeleteMany

Bulk-delete all documents matching a filter. All deletions run in a single transaction (all-or-nothing). Fires per-document hooks by default. Respects the collection’s soft_delete setting — documents are moved to trash unless force_hard_delete is set.

message DeleteManyRequest {
  string collection = 1;
  optional string where = 2;            // JSON where clause (omit = all docs)
  optional bool hooks = 3;              // default: true. Set false to skip hooks.
  bool force_hard_delete = 4;           // permanently delete even if soft_delete is enabled
}

message DeleteManyResponse {
  int64 deleted = 1;                    // permanently deleted count
  int64 soft_deleted = 2;              // soft-deleted (trashed) count
  int64 skipped = 3;                   // skipped because still referenced by other documents
}
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"status\": \"archived\"}"
}' localhost:50051 crap.ContentAPI/DeleteMany

Limit: A single DeleteMany call processes at most 10,000 documents. Use paginated calls (with a where clause) for larger datasets.

GetGlobal

Get a global’s current value.

message GetGlobalRequest {
  string slug = 1;
  optional string locale = 2;           // locale code for localized fields
}

message GetGlobalResponse {
  Document document = 1;
}
grpcurl -plaintext -d '{"slug": "site_settings"}' \
    localhost:50051 crap.ContentAPI/GetGlobal

UpdateGlobal

Update a global’s value.

message UpdateGlobalRequest {
  string slug = 1;
  google.protobuf.Struct data = 2;
  optional string locale = 3;           // locale code for localized fields
}

message UpdateGlobalResponse {
  Document document = 1;
}
grpcurl -plaintext -d '{
    "slug": "site_settings",
    "data": { "site_name": "Updated Name" }
}' localhost:50051 crap.ContentAPI/UpdateGlobal

Login

Authenticate with email and password. Returns a JWT token and user document.

message LoginRequest {
  string collection = 1;
  string email = 2;
  string password = 3;
}

message LoginResponse {
  string token = 1;
  Document user = 2;
}
grpcurl -plaintext -d '{
    "collection": "users",
    "email": "admin@example.com",
    "password": "secret123"
}' localhost:50051 crap.ContentAPI/Login

Me

Get the current authenticated user from a token. The token is read from the authorization metadata header first; if absent, falls back to the token field in the request body.

message MeRequest {
  string token = 1;
}

message MeResponse {
  Document user = 1;
}
grpcurl -plaintext -d '{
    "token": "eyJhbGciOi..."
}' localhost:50051 crap.ContentAPI/Me

ForgotPassword

Initiate a password reset flow. Generates a reset token and sends a reset email. Always returns success to prevent user enumeration.

message ForgotPasswordRequest {
  string collection = 1;
  string email = 2;
}

message ForgotPasswordResponse {
  bool success = 1;  // always true
}
grpcurl -plaintext -d '{
    "collection": "users",
    "email": "admin@example.com"
}' localhost:50051 crap.ContentAPI/ForgotPassword

Requires email configuration ([email] in crap.toml). Without email configured, the reset token is generated and stored but never delivered — the forgot-password flow is non-functional without SMTP.

ResetPassword

Reset a user’s password using a token from the reset email.

message ResetPasswordRequest {
  string collection = 1;
  string token = 2;
  string new_password = 3;
}

message ResetPasswordResponse {
  bool success = 1;
}
grpcurl -plaintext -d '{
    "collection": "users",
    "token": "the-reset-token",
    "new_password": "newsecret123"
}' localhost:50051 crap.ContentAPI/ResetPassword

Tokens are single-use and expire after reset_token_expiry seconds (default: 3600 = 1 hour, configurable in [auth]).

VerifyEmail

Verify a user’s email address using a token sent during account creation.

message VerifyEmailRequest {
  string collection = 1;
  string token = 2;
}

message VerifyEmailResponse {
  bool success = 1;
}
grpcurl -plaintext -d '{
    "collection": "users",
    "token": "the-verification-token"
}' localhost:50051 crap.ContentAPI/VerifyEmail

Only relevant for auth collections with verify_email: true.

ListCollections

List all collections and globals (lightweight overview).

message ListCollectionsRequest {}

message ListCollectionsResponse {
  repeated CollectionInfo collections = 1;
  repeated GlobalInfo globals = 2;
}
grpcurl -plaintext -d '{}' localhost:50051 crap.ContentAPI/ListCollections

DescribeCollection

Get full field schema for a collection or global.

message DescribeCollectionRequest {
  string slug = 1;
  bool is_global = 2;
}

message DescribeCollectionResponse {
  string slug = 1;
  optional string singular_label = 2;
  optional string plural_label = 3;
  bool timestamps = 4;
  bool auth = 5;
  repeated FieldInfo fields = 6;
  bool upload = 7;
  bool drafts = 8;  // true if collection has versions with drafts enabled
}
# Describe a collection
grpcurl -plaintext -d '{"slug": "posts"}' \
    localhost:50051 crap.ContentAPI/DescribeCollection

# Describe a global
grpcurl -plaintext -d '{"slug": "site_settings", "is_global": true}' \
    localhost:50051 crap.ContentAPI/DescribeCollection

ListVersions

List version history for a document. Only available for versioned collections.

message ListVersionsRequest {
  string collection = 1;
  string id = 2;
  optional int64 limit = 3;
}

message ListVersionsResponse {
  repeated VersionInfo versions = 1;
}

message VersionInfo {
  string id = 1;
  int64 version = 2;
  string status = 3;      // "published" or "draft"
  bool latest = 4;
  string created_at = 5;
}
grpcurl -plaintext -d '{
    "collection": "articles",
    "id": "abc123",
    "limit": "10"
}' localhost:50051 crap.ContentAPI/ListVersions

Returns versions in newest-first order. Returns an error for non-versioned collections.

RestoreVersion

Restore a previous version, writing its snapshot data back to the main table.

message RestoreVersionRequest {
  string collection = 1;
  string document_id = 2;
  string version_id = 3;
}

message RestoreVersionResponse {
  Document document = 1;
}
grpcurl -plaintext -d '{
    "collection": "articles",
    "document_id": "abc123",
    "version_id": "v_xyz"
}' localhost:50051 crap.ContentAPI/RestoreVersion

This overwrites the main table with the version’s snapshot, sets _status to "published", and creates a new version entry for the restore. Returns an error for non-versioned collections.

Subscribe

Subscribe to real-time mutation events (server streaming). See Live Updates for full documentation.

message SubscribeRequest {
  repeated string collections = 1;  // empty = all accessible
  repeated string globals = 2;      // empty = all accessible
  repeated string operations = 3;   // "create","update","delete" — empty = all
  string token = 4;                 // auth token
}

message MutationEvent {
  uint64 sequence = 1;
  string timestamp = 2;
  string target = 3;
  string operation = 4;
  string collection = 5;
  string document_id = 6;
  google.protobuf.Struct data = 7;
}
# Subscribe to all events
grpcurl -plaintext -d '{}' \
    localhost:50051 crap.ContentAPI/Subscribe

# Subscribe to specific collections with auth
grpcurl -plaintext -d '{
    "collections": ["posts"],
    "operations": ["create", "update"],
    "token": "your-jwt-token"
}' localhost:50051 crap.ContentAPI/Subscribe

ListJobs

List all defined jobs and their configuration. Requires authentication.

message ListJobsRequest {}

message ListJobsResponse {
  repeated JobDefinitionInfo jobs = 1;
}

message JobDefinitionInfo {
  string slug = 1;
  string handler = 2;
  optional string schedule = 3;
  string queue = 4;
  uint32 retries = 5;
  uint64 timeout = 6;
  uint32 concurrency = 7;
  bool skip_if_running = 8;
  optional string label = 9;
}
grpcurl -plaintext -H "authorization: Bearer $TOKEN" -d '{}' \
    localhost:50051 crap.ContentAPI/ListJobs

TriggerJob

Queue a job for execution. Requires authentication. Checks the job’s access function if defined.

message TriggerJobRequest {
  string slug = 1;
  optional string data_json = 2;  // JSON input data
}

message TriggerJobResponse {
  string job_id = 1;  // the queued job run ID
}
grpcurl -plaintext -H "authorization: Bearer $TOKEN" -d '{
    "slug": "cleanup_expired",
    "data_json": "{\"force\": true}"
}' localhost:50051 crap.ContentAPI/TriggerJob

GetJobRun

Get details of a specific job run. Requires authentication.

message GetJobRunRequest {
  string id = 1;
}

message GetJobRunResponse {
  string id = 1;
  string slug = 2;
  string status = 3;
  string data_json = 4;
  optional string result_json = 5;
  optional string error = 6;
  uint32 attempt = 7;
  uint32 max_attempts = 8;
  optional string scheduled_by = 9;
  optional string created_at = 10;
  optional string started_at = 11;
  optional string completed_at = 12;
}
grpcurl -plaintext -H "authorization: Bearer $TOKEN" -d '{
    "id": "job_run_id_here"
}' localhost:50051 crap.ContentAPI/GetJobRun

ListJobRuns

List job runs with optional filters. Requires authentication.

message ListJobRunsRequest {
  optional string slug = 1;
  optional string status = 2;
  optional int64 limit = 3;
  optional int64 offset = 4;
}

message ListJobRunsResponse {
  repeated GetJobRunResponse runs = 1;
}
# List all recent job runs
grpcurl -plaintext -H "authorization: Bearer $TOKEN" -d '{}' \
    localhost:50051 crap.ContentAPI/ListJobRuns

# Filter by slug and status
grpcurl -plaintext -H "authorization: Bearer $TOKEN" -d '{
    "slug": "cleanup_expired",
    "status": "completed",
    "limit": "20"
}' localhost:50051 crap.ContentAPI/ListJobRuns

Where Clause

The Find RPC supports an advanced where parameter for operator-based filtering. This is a JSON string containing field filters with operators.

Format

The where parameter is a JSON-encoded string:

{
    "field_name": { "operator": "value" },
    "field_name2": { "operator": "value2" }
}

Multiple fields are combined with AND.

Operators

OperatorJSON SyntaxSQL
equals{"field": {"equals": "value"}}field = ?
not_equals{"field": {"not_equals": "value"}}field != ?
like{"field": {"like": "pattern%"}}field LIKE ?
contains{"field": {"contains": "text"}}field LIKE '%text%' ESCAPE '\' (wildcards escaped)
greater_than{"field": {"greater_than": "10"}}field > ?
less_than{"field": {"less_than": "10"}}field < ?
greater_than_or_equal{"field": {"greater_than_or_equal": "10"}}field >= ?
less_than_or_equal{"field": {"less_than_or_equal": "10"}}field <= ?
in{"field": {"in": ["a", "b"]}}field IN (?, ?)
not_in{"field": {"not_in": ["a", "b"]}}field NOT IN (?, ?)
exists{"field": {"exists": true}}field IS NOT NULL
not_exists{"field": {"not_exists": true}}field IS NULL

Note: For exists/not_exists, the value is ignored — only the key matters. Field values must be strings or operator objects — numeric/boolean shorthand (e.g., {"count": 42}) is not supported in the gRPC JSON where clause (use {"count": {"equals": "42"}} instead).

Examples

# Published posts with "hello" in the title
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"status\":{\"equals\":\"published\"},\"title\":{\"contains\":\"hello\"}}"
}' localhost:50051 crap.ContentAPI/Find

# Posts with status in a list
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"status\":{\"in\":[\"draft\",\"published\"]}}"
}' localhost:50051 crap.ContentAPI/Find

# Posts created after a date
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"created_at\":{\"greater_than\":\"2024-01-01\"}}"
}' localhost:50051 crap.ContentAPI/Find

# Posts with null status
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"status\":{\"not_exists\":true}}"
}' localhost:50051 crap.ContentAPI/Find

Nested Field Filters (Dot Notation)

Filter on sub-fields of group, array, blocks, and has-many relationship fields using dot notation:

# Group sub-field: seo.meta_title → seo__meta_title column
grpcurl -plaintext -d '{
    "collection": "pages",
    "where": "{\"seo.meta_title\":{\"contains\":\"SEO\"}}"
}' localhost:50051 crap.ContentAPI/Find

# Array sub-field: products with any variant color "red"
grpcurl -plaintext -d '{
    "collection": "products",
    "where": "{\"variants.color\":{\"equals\":\"red\"}}"
}' localhost:50051 crap.ContentAPI/Find

# Block sub-field: posts with content containing "hello"
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"content.body\":{\"contains\":\"hello\"}}"
}' localhost:50051 crap.ContentAPI/Find

# Block type filter
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"content._block_type\":{\"equals\":\"image\"}}"
}' localhost:50051 crap.ContentAPI/Find

# Has-many relationship: posts with a specific tag
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"tags.id\":{\"equals\":\"tag-123\"}}"
}' localhost:50051 crap.ContentAPI/Find

Array and block filters use EXISTS subqueries — they match parent documents where at least one row matches. All filter operators work with dot notation paths.

See Query & Filters for the full reference.

OR Filters

Use the or key to combine groups of conditions with OR logic. Each element in the or array is an object whose fields are AND-ed together. Top-level filters outside or are AND-ed with the OR result.

# title contains "hello" OR category = "news"
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"or\":[{\"title\":{\"contains\":\"hello\"}},{\"category\":{\"equals\":\"news\"}}]}"
}' localhost:50051 crap.ContentAPI/Find

# status = "published" AND (title contains "hello" OR title contains "world")
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"status\":{\"equals\":\"published\"},\"or\":[{\"title\":{\"contains\":\"hello\"}},{\"title\":{\"contains\":\"world\"}}]}"
}' localhost:50051 crap.ContentAPI/Find

# Multi-condition groups: (status = "published" AND title contains "hello") OR (status = "draft")
grpcurl -plaintext -d '{
    "collection": "posts",
    "where": "{\"or\":[{\"status\":{\"equals\":\"published\"},\"title\":{\"contains\":\"hello\"}},{\"status\":{\"equals\":\"draft\"}}]}"
}' localhost:50051 crap.ContentAPI/Find

gRPC Authentication

Login

Authenticate with email and password to get a JWT token. Login is rate-limited — after too many failed attempts for an email, further attempts are temporarily blocked (configurable via max_login_attempts and login_lockout_seconds in crap.toml).

grpcurl -plaintext -d '{
    "collection": "users",
    "email": "admin@example.com",
    "password": "secret123"
}' localhost:50051 crap.ContentAPI/Login

The response contains a token and the user document.

Bearer Token

Pass the token via the authorization metadata header:

grpcurl -plaintext \
    -H "authorization: Bearer eyJhbGciOi..." \
    -d '{"collection": "posts"}' \
    localhost:50051 crap.ContentAPI/Find

The token is extracted from the authorization metadata and validated. The authenticated user is available to access control functions.

Get Current User

Use the Me RPC to validate a token and get the user:

grpcurl -plaintext -d '{
    "token": "eyJhbGciOi..."
}' localhost:50051 crap.ContentAPI/Me

Token Expiry

Tokens expire after token_expiry seconds (default: 7200 = 2 hours). Configurable globally in crap.toml or per auth collection.

Security

  • Rate limiting — per-email tracking. After max_login_attempts (default: 5) failures, the email is locked out for login_lockout_seconds (default: 300). Per-IP rate limiting (max_ip_login_attempts in [auth]) provides additional protection against credential stuffing across multiple accounts.
  • Timing safety — login always performs a full Argon2id hash comparison, even for non-existent users, preventing timing-based email enumeration.
  • JWT persistence — when no secret is set in crap.toml, an auto-generated secret is persisted to data/.jwt_secret so tokens survive server restarts.
  • Account locking — when a user’s _locked field is truthy, all authenticated requests (including Me) are rejected with unauthenticated status. This takes effect immediately, even for valid unexpired tokens.

Creating Users via gRPC

Include password in the data field of a Create request:

grpcurl -plaintext -d '{
    "collection": "users",
    "data": {
        "email": "new@example.com",
        "password": "secret123",
        "name": "New User",
        "role": "editor"
    }
}' localhost:50051 crap.ContentAPI/Create

The password field is extracted, hashed with Argon2id, and stored separately. It never appears in the response.

Updating Passwords

Include password in the data field of an Update request:

grpcurl -plaintext -d '{
    "collection": "users",
    "id": "abc123",
    "data": { "password": "new-password" }
}' localhost:50051 crap.ContentAPI/Update

If password is omitted, the existing password is kept.

Type Safety

The gRPC API uses google.protobuf.Struct for document fields — a generic JSON object with no schema at the proto level. This is a deliberate design choice: Lua files define schemas, the proto stays stable, and the binary never needs recompiling when you add a field.

But Struct means your gRPC client sees fields as an untyped map. This page explains how to get type safety back.

The Two-Layer Architecture

┌──────────────────────────────────────────────────┐
│  Lua definitions (source of truth)               │
│  collections/posts.lua → fields, types, options  │
└────────────┬─────────────────────┬───────────────┘
             │                     │
    ┌────────▼────────┐   ┌───────▼────────────┐
    │  DescribeCollection │   │  crap-cms typegen     │
    │  (runtime, gRPC)    │   │  (build-time, Lua)   │
    └────────┬────────┘   └───────┬────────────┘
             │                     │
    ┌────────▼────────┐   ┌───────▼────────────┐
    │  Client codegen │   │  types/crap.lua     │
    │  TS/Go/Python   │   │  types/generated.lua│
    │  typed wrappers │   │  (IDE types for     │
    │                 │   │   hooks & init.lua) │
    └─────────────────┘   └────────────────────┘

Layer 1: Runtime schema discovery — the DescribeCollection RPC returns the full field schema. gRPC clients call it at startup or build time to generate typed wrappers.

Layer 2: Lua typegen — the crap-cms typegen command writes types/crap.lua (API surface types) and types/generated.lua (per-collection types) with LuaLS annotations. This gives you autocompletion and type checking inside hooks and init.lua.

DescribeCollection

The DescribeCollection RPC returns the full schema for any collection or global:

grpcurl -plaintext -d '{"slug": "posts"}' \
    localhost:50051 crap.ContentAPI/DescribeCollection

Response:

{
  "slug": "posts",
  "singularLabel": "Post",
  "pluralLabel": "Posts",
  "timestamps": true,
  "fields": [
    {
      "name": "title",
      "type": "text",
      "required": true,
      "unique": true
    },
    {
      "name": "slug",
      "type": "text",
      "required": true,
      "unique": true
    },
    {
      "name": "status",
      "type": "select",
      "required": true,
      "options": [
        { "label": "Draft", "value": "draft" },
        { "label": "Published", "value": "published" },
        { "label": "Archived", "value": "archived" }
      ]
    },
    {
      "name": "content",
      "type": "richtext"
    },
    {
      "name": "author",
      "type": "relationship",
      "relationshipCollection": "users",
      "relationshipMaxDepth": 1
    },
    {
      "name": "tags",
      "type": "relationship",
      "relationshipCollection": "tags",
      "relationshipHasMany": true
    }
  ]
}

FieldInfo Schema

Each field in the response has:

FieldTypeDescription
namestringColumn name
typestringField type: text, number, select, relationship, etc.
requiredboolWhether the field is required
uniqueboolWhether the field has a uniqueness constraint
optionsSelectOptionInfo[]Options for select fields (label + value)
relationship_collectionstring?Target collection slug for relationship fields
relationship_has_manybool?Whether it’s a many-to-many relationship
relationship_max_depthint?Per-field population depth cap
fieldsFieldInfo[]Sub-fields for array and group types (recursive)

Building Typed Clients

The idea: call DescribeCollection once (at build time or app startup), then generate typed wrappers for your language.

TypeScript Example

Call DescribeCollection for each collection and generate interfaces:

// Generated from DescribeCollection("posts")
interface Post {
  id: string;
  title: string;
  slug: string;
  status: "draft" | "published" | "archived";
  content?: string;
  author?: string;        // relationship ID (depth=0)
  tags?: string[];         // has_many relationship IDs
  created_at?: string;
  updated_at?: string;
}

interface CreatePostInput {
  title: string;           // required
  slug: string;            // required
  status: string;          // required
  content?: string;
  author?: string;
  tags?: string[];
}

The mapping from FieldInfo.type to TypeScript types:

function fieldTypeToTS(field: FieldInfo): string {
  switch (field.type) {
    case "text":
    case "textarea":
    case "richtext":
    case "email":
    case "date":
    case "slug":
      return "string";
    case "number":
      return "number";
    case "checkbox":
      return "boolean";
    case "json":
      return "unknown";
    case "select":
      return field.options.map(o => `"${o.value}"`).join(" | ");
    case "relationship":
      return field.relationshipHasMany ? "string[]" : "string";
    case "array":
      // Recurse into sub-fields
      return `Array<{ ${field.fields.map(f =>
        `${f.name}${f.required ? '' : '?'}: ${fieldTypeToTS(f)}`
      ).join('; ')} }>`;
    default:
      return "unknown";
  }
}

A typed wrapper around the gRPC client:

// Wrap the untyped gRPC client with generated types
class PostsClient {
  constructor(private client: ContentAPIClient) {}

  async find(query?: FindQuery): Promise<{ documents: Post[]; total: number }> {
    const resp = await this.client.find({ collection: "posts", ...query });
    return {
      documents: resp.documents.map(d => ({ id: d.id, ...d.fields } as Post)),
      total: resp.total,
    };
  }

  async create(data: CreatePostInput): Promise<Post> {
    const resp = await this.client.create({ collection: "posts", data });
    return { id: resp.document.id, ...resp.document.fields } as Post;
  }
}

Go Example

Same pattern — DescribeCollection at build time, generate structs:

// Generated from DescribeCollection("posts")
type Post struct {
    ID        string  `json:"id"`
    Title     string  `json:"title"`
    Slug      string  `json:"slug"`
    Status    string  `json:"status"`
    Content   *string `json:"content,omitempty"`
    Author    *string `json:"author,omitempty"`
    CreatedAt *string `json:"created_at,omitempty"`
    UpdatedAt *string `json:"updated_at,omitempty"`
}

// Convert a generic Document to a typed Post
func DocumentToPost(doc *crap.Document) Post {
    p := Post{ID: doc.Id}
    if f := doc.Fields.Fields; f != nil {
        if v, ok := f["title"]; ok {
            p.Title = v.GetStringValue()
        }
        // ...
    }
    return p
}

Python Example

# Generated from DescribeCollection("posts")
from dataclasses import dataclass
from typing import Optional, List

@dataclass
class Post:
    id: str
    title: str
    slug: str
    status: str  # "draft" | "published" | "archived"
    content: Optional[str] = None
    author: Optional[str] = None
    tags: Optional[List[str]] = None
    created_at: Optional[str] = None
    updated_at: Optional[str] = None

def document_to_post(doc) -> Post:
    fields = dict(doc.fields)
    return Post(
        id=doc.id,
        title=fields.get("title", {}).string_value,
        slug=fields.get("slug", {}).string_value,
        status=fields.get("status", {}).string_value,
        content=fields.get("content", {}).string_value or None,
        # ...
    )

Lua Typegen (for Hooks)

The gRPC type safety story above is for external clients. For Lua hooks and init.lua, the built-in typegen provides IDE-level type safety.

Generate Types

Types are auto-generated on every server startup. You can also generate them explicitly:

crap-cms typegen

This writes <config_dir>/types/generated.lua with LuaLS annotations derived from your Lua collection definitions. Use -l all to generate types for all supported languages (Lua, TypeScript, Go, Python, Rust).

What Gets Generated

For each collection, typegen emits:

TypePurpose
crap.data.PostsInput fields (for Create/Update data)
crap.doc.PostsFull document (fields + id + timestamps)
crap.hook.PostsTyped hook context (collection, operation, data)
crap.find_result.PostsFind result (documents[] + total)
crap.filters.PostsFilter keys for queries
crap.query.PostsQuery options (filters, order_by, limit, offset)
crap.hook_fn.PostsHook function signature

For globals: crap.global_data.*, crap.global_doc.*, crap.hook.global_*.

For array fields: crap.array_row.* with the sub-field types.

Select fields become union types: "draft" | "published" | "archived".

Function overloads are generated so crap.collections.find("posts", ...) returns crap.find_result.Posts instead of the generic crap.FindResult.

IDE Setup

Add a .luarc.json in your config directory:

{
  "runtime": { "version": "Lua 5.4" },
  "workspace": { "library": ["./types"] }
}

LuaLS (used by VS Code, Neovim, etc.) will then provide:

  • Autocompletion on all document fields
  • Type checking for field values
  • Inline errors for typos and type mismatches
  • Hover documentation showing field types
  • Smart overloads on crap.collections.find() per collection

Example Generated Output

For a posts collection with title, slug, status (select), content (richtext):

---@class crap.data.Posts
---@field title string
---@field slug string
---@field status "draft" | "published" | "archived"
---@field content? string

---@class crap.doc.Posts
---@field id string
---@field title string
---@field slug string
---@field status "draft" | "published" | "archived"
---@field content? string
---@field created_at? string
---@field updated_at? string

---@class crap.hook.Posts
---@field collection "posts"
---@field operation "create" | "update"
---@field data crap.data.Posts

Why Generic Struct?

The Document.fields is google.protobuf.Struct (not per-collection messages) because:

  1. Single binary — the proto file is compiled into the binary. Per-collection proto messages would require recompilation when schemas change.
  2. Lua is the schema source — schemas live in Lua files, not proto definitions. The proto layer is a transport, not a schema system.
  3. Dynamic schemas — collections can be added, removed, or modified by editing Lua files without touching the binary or proto.
  4. DescribeCollection fills the gap — runtime schema discovery gives clients everything they need to build typed wrappers, without coupling the proto to specific schemas.

Live Updates

Crap CMS supports real-time event streaming for mutation notifications. When documents are created, updated, or deleted, events are broadcast to connected subscribers.

Technology

  • gRPC Server Streaming (Subscribe RPC) for API consumers
  • SSE (GET /admin/events) for the admin UI
  • Internal bus: tokio::sync::broadcast channel

Configuration

In crap.toml:

[live]
enabled = true           # default: true
channel_capacity = 1024  # default: 1024
# max_sse_connections = 1000        # max concurrent SSE connections (0 = unlimited)
# max_subscribe_connections = 1000  # max concurrent gRPC Subscribe streams (0 = unlimited)

Set enabled = false to disable live updates entirely. Both SSE and gRPC Subscribe will be unavailable.

Connection limits protect against resource exhaustion. When the limit is reached, new SSE connections receive 503 Service Unavailable and new gRPC Subscribe calls receive UNAVAILABLE status. Existing connections are not affected.

Per-Collection Control

Each collection (and global) can control whether it emits events via the live field:

-- Broadcast all events (default when absent)
crap.collections.define("posts", { ... })

-- Disable broadcasting entirely
crap.collections.define("audit_log", {
    live = false,
    ...
})

-- Dynamic: Lua function decides per-event
crap.collections.define("posts", {
    live = "hooks.posts.should_broadcast",
    ...
})

The function receives { collection, operation, data } and returns true to broadcast or false/nil to suppress.

Event Structure

Each event contains:

FieldDescription
sequenceMonotonic sequence number
timestampISO 8601 timestamp
target"collection" or "global"
operation"create", "update", or "delete"
collectionCollection or global slug
document_idDocument ID
dataFull document fields (empty for delete)

Event Pipeline

Transaction:
  before-hooks → DB operation → after-hooks → commit

After commit:
  -> publish_event()
       1. live setting check
       2. before_broadcast hooks
       3. EventBus.publish()
            -> gRPC Subscribe stream
            -> Admin SSE stream

Limitations (V1)

  • Events are ephemeral — missed events are not replayed
  • Access is snapshotted at subscribe time — permission changes require reconnect
  • No field-level subscription filters
  • No event persistence or replay
  • before_broadcast hooks have no CRUD access (fires after commit)

gRPC Subscribe RPC

The Subscribe RPC provides a server-streaming endpoint for real-time mutation events.

Request

message SubscribeRequest {
  repeated string collections = 1;  // empty = all accessible
  repeated string globals = 2;      // empty = all accessible
  repeated string operations = 3;   // "create","update","delete" — empty = all
  string token = 4;                 // auth token from Login RPC
}

Response Stream

message MutationEvent {
  uint64 sequence = 1;
  string timestamp = 2;
  string target = 3;          // "collection" or "global"
  string operation = 4;       // "create", "update", "delete"
  string collection = 5;
  string document_id = 6;
  google.protobuf.Struct data = 7;
}

Usage with grpcurl

# Subscribe to all collections
grpcurl -plaintext -d '{}' localhost:50051 crap.ContentAPI/Subscribe

# Subscribe to specific collections with auth
grpcurl -plaintext -d '{
  "collections": ["posts"],
  "operations": ["create", "update"],
  "token": "your-jwt-token"
}' localhost:50051 crap.ContentAPI/Subscribe

Access Control

  • Authentication via token field (same token as Login response)
  • Read access is checked at subscribe time for each requested collection/global
  • Collections/globals without read access are silently excluded
  • Returns PERMISSION_DENIED if no collections or globals are accessible
  • Returns UNAVAILABLE if live updates are disabled in config

Reconnection

If the stream is interrupted, clients should reconnect. Events missed during disconnection are not replayed. Use the sequence field to detect gaps.

Connection Limits

The maximum number of concurrent Subscribe streams is controlled by max_subscribe_connections in [live] (default: 1000). When the limit is reached, new subscriptions receive UNAVAILABLE status. Set to 0 for unlimited.

Backpressure

The internal broadcast channel has a configurable capacity (default 1024). If a subscriber falls behind, events are dropped and the stream continues from the latest event (logged as a warning on the server).

Admin SSE Endpoint

The admin UI includes a Server-Sent Events (SSE) endpoint for real-time mutation notifications.

Endpoint

GET /admin/events

Protected by admin auth middleware (requires valid session cookie).

Event Format

Events are sent with event type mutation:

event: mutation
id: 42
data: {"sequence":42,"timestamp":"2024-01-15T10:30:00Z","target":"collection","operation":"create","collection":"posts","document_id":"abc123","edited_by":"user_456"}

The data payload is JSON with the same fields as the gRPC MutationEvent (excluding the full document data for efficiency), plus an edited_by field containing the user ID of the authenticated user who made the change (or null for unauthenticated operations).

Admin UI Integration

The admin UI automatically connects to the SSE endpoint on all authenticated pages. When a mutation event is received, a toast notification is shown via the <crap-toast> component.

The SSE connection:

  • Auto-reconnects on disconnection (native EventSource behavior)
  • Sends keepalive pings every 30 seconds
  • Only activates on pages with the admin layout

Access Control

Same as gRPC Subscribe: read access is checked at connection time per collection/global. Events for inaccessible collections are filtered out.

Note: Access control is snapshotted at subscribe time. If a user’s permissions change after they subscribe to the SSE stream (e.g., their role is updated or access rules are modified), they will continue receiving events based on the original permissions until the SSE connection is closed. To force a re-evaluation, the client must reconnect.

Connection Limits

The maximum number of concurrent SSE connections is controlled by max_sse_connections in [live] (default: 1000). When the limit is reached, new connections receive 503 Service Unavailable. Set to 0 for unlimited.

Custom Integration

If you override the admin templates, the SSE listener is in static/components/live-events.js. You can customize or replace it by placing your own static/components/live-events.js in your config dir’s static/ folder.

Live Update Hooks

before_broadcast

A lifecycle event that fires after the write transaction has committed, before the event reaches the EventBus. Hooks can suppress events or transform the broadcast data.

Collection-Level

crap.collections.define("posts", {
    hooks = {
        before_broadcast = { "hooks.posts.filter_broadcast" },
    },
})

The hook function receives { collection, operation, data } and returns:

  • The context table (possibly with modified data) to continue broadcasting
  • false or nil to suppress the event entirely
-- hooks/posts.lua
local M = {}

function M.filter_broadcast(ctx)
    if ctx.operation == "delete" then return ctx end
    if ctx.data.status == "published" then
        return ctx  -- broadcast
    end
    return false  -- suppress draft changes
end

return M

Registered Hooks

Global registered hooks also fire for before_broadcast:

-- init.lua
crap.hooks.register("before_broadcast", function(ctx)
    -- Strip sensitive fields from all broadcast data
    ctx.data._password_hash = nil
    ctx.data._reset_token = nil
    return ctx
end)

Execution Order

  1. Collection-level before_broadcast hooks (string refs from definition)
  2. Global registered before_broadcast hooks (crap.hooks.register)

If any hook returns false/nil, the event is suppressed and no further hooks run.

CRUD Access

before_broadcast hooks run after the transaction has committed and do not have CRUD access.

live Setting Functions

When live is a string (Lua function reference), the function is called before before_broadcast hooks:

crap.collections.define("posts", {
    live = "hooks.posts.should_broadcast",
})
function M.should_broadcast(ctx)
    -- Only broadcast published posts
    return ctx.data.status == "published"
end

The function receives { collection, operation, data } and returns true/false. This is a fast gate — before_broadcast hooks only run if the live check passes.

Admin UI

Crap CMS includes a built-in admin UI served via Axum with Handlebars templates and HTMX.

Access

Default: http://localhost:3000/admin

Access to the admin panel is controlled by two gates:

  1. require_auth (default: true) — when no auth collection exists, the admin shows a “Setup Required” page (HTTP 503) instead of being open. Set require_auth = false in [admin] for fully open dev mode.
  2. access (optional Lua function ref) — checked after successful authentication. Gates which authenticated users can access the admin panel. Return true to allow, false/nil to show “Access Denied” (HTTP 403).
[admin]
require_auth = true                     # block admin if no auth collection (default)
access = "access.admin_panel"           # only allow users passing this function
-- access/admin_panel.lua
return function(ctx)
    return ctx.user and ctx.user.role == "admin"
end

When auth collections are configured and no access function is set, any authenticated user can access the admin.

Security features:

  • Content-Security-Policy header with configurable per-directive source lists (see [admin.csp])
  • CSRF protection on all forms and HTMX requests (double-submit cookie pattern)
  • Secure flag on session cookies in production (dev_mode = false)
  • Rate limiting on login (configurable max attempts and lockout duration)
  • X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy, Permissions-Policy headers

Technology

  • Axum web framework for routing and middleware
  • Handlebars templates with partial inheritance
  • HTMX for dynamic page updates without JavaScript frameworks
  • Plain CSS with custom properties (no preprocessor, no build step)
  • Web Components with Shadow DOM (<crap-toast>, <crap-confirm>)

Routes

RouteDescription
/healthLiveness check (public)
/readyReadiness check (public)
/adminDashboard
/admin/loginLogin page (public)
/admin/logoutLogout (public)
/admin/forgot-passwordForgot password page (public)
/admin/reset-passwordReset password page (public)
/admin/verify-emailEmail verification (public)
/admin/collectionsCollection list
/admin/collections/{slug}Collection items list
/admin/collections/{slug}/createCreate form
/admin/collections/{slug}/{id}Edit form
/admin/collections/{slug}/{id}/deleteDelete confirmation
/admin/collections/{slug}/{id}/versionsVersion history
/admin/collections/{slug}/{id}/versions/{version_id}/restoreRestore a version
/admin/collections/{slug}/validateInline validation (POST)
/admin/collections/{slug}/evaluate-conditionsDisplay condition evaluation (POST)
/admin/globals/{slug}Global edit form
/admin/globals/{slug}/validateGlobal inline validation (POST)
/admin/globals/{slug}/versionsGlobal version history
/admin/globals/{slug}/versions/{version_id}/restoreRestore global version
/admin/eventsSSE live update stream
/admin/api/search/{slug}Relationship search endpoint
/admin/api/session-refreshSession token refresh (POST)
/admin/api/localeSave locale preference (POST)
/admin/api/user-settings/{slug}Save user settings (POST)
/api/upload/{slug}File upload endpoint (POST)
/mcpMCP HTTP endpoint (POST, if enabled)
/static/*Static assets (public)
/uploads/{collection_slug}/{filename}Uploaded files

Versioning & Drafts Workflow

For collections with versions = { drafts = true }, the admin UI provides a draft/publish workflow:

List view:

  • Shows all documents (both draft and published) with status badges
  • A “Status” column displays published or draft per row

Create form:

  • Publish (primary button) — creates as published, enforces required field validation
  • Save as Draft (secondary button) — creates as draft, skips required field validation

Edit form:

  • Draft document: “Publish” (primary) + “Save Draft” (secondary) buttons
  • Published document: “Update” (primary) + “Save Draft” (secondary) + “Unpublish” (ghost) buttons
  • Draft saves create a version snapshot only — the main (published) document is not modified until you publish

Sidebar:

  • Status badge showing current document status
  • Version history panel listing recent versions with version number, status, date, and a “Restore” button
  • Restoring a version writes the snapshot data back to the main table and creates a new version entry

Collections without versions configured work exactly as before — a single “Create” or “Update” button with no status management.

See Versions & Drafts for the full configuration and behavioral reference.

CSS Architecture

  • Custom properties in :root (colors, spacing, fonts, shadows)
  • Separate files per concern: layout.css, buttons.css, cards.css, forms.css, tables.css
  • Composed via @import in styles.css
  • BEM-ish naming (.block, .block__element, .block--modifier)
  • Geist font family (variable weight)

JavaScript

  • No build step, no npm, no bundler — browser-native ES modules
  • static/components/index.js entry point loaded with <script type="module">
  • Each feature is a separate module under static/components/ (individually overridable)
  • JSDoc annotations for all types
  • Web Components (Shadow DOM, CSS variables for theming):
    • <crap-toast> — toast notifications (listens for htmx:afterRequest, reads X-Crap-Toast header)
    • <crap-confirm> — confirmation dialogs (wraps forms, intercepts submit)
    • <crap-confirm-dialog> — standalone confirm for hx-confirm attributes (replaces native window.confirm)
    • <crap-richtext> — ProseMirror WYSIWYG editor

Template Overlay

The admin UI uses a template overlay system: templates in your config directory override the compiled defaults.

How It Works

When rendering a template, Crap CMS checks:

  1. Config directory<config_dir>/templates/<name>.hbs
  2. Compiled defaults — built into the binary at compile time

If a file exists in the config directory, it’s used. Otherwise, the compiled default is used.

Dev Mode

When admin.dev_mode = true in crap.toml, templates are reloaded from disk on every request. This enables live editing without restarting.

When dev_mode = false, templates are cached after first load (production mode).

Template Inheritance

Templates use Handlebars partial inheritance:

{{#> layout/base}}
    <h1>My Custom Page</h1>
    <p>Content here</p>
{{/layout/base}}

The layout/base partial provides the HTML shell (head, sidebar, header).

Field Partials

Each field type has a partial in templates/fields/:

  • fields/text.hbs
  • fields/number.hbs
  • fields/textarea.hbs
  • fields/richtext.hbs
  • fields/select.hbs
  • fields/checkbox.hbs
  • fields/date.hbs
  • fields/email.hbs
  • fields/json.hbs
  • fields/relationship.hbs
  • fields/array.hbs
  • fields/blocks.hbs
  • fields/password.hbs
  • fields/radio.hbs
  • fields/code.hbs
  • fields/upload.hbs
  • fields/join.hbs
  • fields/group.hbs
  • fields/collapsible.hbs
  • fields/tabs.hbs
  • fields/row.hbs

The edit form iterates field definitions and renders the matching partial.

Overriding Templates

To customize a template, create the same file path under your config directory’s templates/ folder:

my-project/
└── templates/
    └── fields/
        └── richtext.hbs   # overrides the default richtext field template

Available Template Variables

Templates receive context data from the Axum handlers. Common variables include:

  • collection — collection definition
  • document — document data (edit forms)
  • docs — document list (list views)
  • fields — field definitions
  • global — global definition (global edit pages)
  • user — authenticated user (if auth is configured)

Template Context API

Every admin page receives a structured context object built by the ContextBuilder. When overriding templates, you can access any of these variables in your Handlebars templates.

Top-Level Keys

KeyTypePagesDescription
crapobjectallApp metadata (version, dev mode, auth status)
pageobjectallCurrent page info (title, type, breadcrumbs)
navobjectall (except auth)Navigation data for sidebar
userobjectauthenticatedCurrent user (email, id, collection)
collectionobjectcollection pagesFull collection definition with metadata
globalobjectglobal pagesFull global definition with metadata
documentobjectedit pagesCurrent document with raw data
fieldsarrayedit/create/global editProcessed field contexts for main content area
sidebar_fieldsarrayedit/create/global editField contexts for sidebar panel (fields with admin.position = "sidebar")
docsarraycollection itemsDocument list with enriched data
editingbooleanedit/createtrue when editing, false when creating
paginationobjectitems, versionsPagination state
versionsarrayedit (versioned)Recent version entries
has_more_versionsbooleanedit (versioned)Whether more versions exist beyond the shown 3
uploadobjectupload collectionsUpload file metadata and preview
collection_cardsarraydashboardCollection summary cards with counts
global_cardsarraydashboardGlobal summary cards
searchstringitemsCurrent search query
customobjectallCustom data injected by before_render hooks

Base Context (Every Page)

crap — App Metadata

{{crap.version}}      {{!-- "0.1.0" --}}
{{crap.build_hash}}   {{!-- Git commit hash at build time --}}
{{crap.dev_mode}}     {{!-- true/false --}}
{{crap.auth_enabled}} {{!-- true if any auth collection exists --}}

page — Page Info

{{page.title}}  {{!-- "Edit Post", "Dashboard", etc. --}}
{{page.type}}   {{!-- "collection_edit", "dashboard", etc. --}}

{{#each page.breadcrumbs}}
  {{#if this.url}}
    <a href="{{this.url}}">{{this.label}}</a>
  {{else}}
    <span>{{this.label}}</span>
  {{/if}}
{{/each}}

Page Types

TypeRoute
dashboard/admin
collection_list/admin/collections
collection_items/admin/collections/{slug}
collection_edit/admin/collections/{slug}/{id}
collection_create/admin/collections/{slug}/create
collection_delete/admin/collections/{slug}/{id}/delete
collection_versions/admin/collections/{slug}/{id}/versions
global_edit/admin/globals/{slug}
global_versions/admin/globals/{slug}/versions
auth_login/admin/login
auth_forgot/admin/forgot-password
auth_reset/admin/reset-password
error_403(forbidden)
error_404(not found)
error_500(server error)

Available on all authenticated pages. Auth pages use ContextBuilder::auth() which does not include nav.

{{#each nav.collections}}
  <a href="/admin/collections/{{this.slug}}">{{this.display_name}}</a>
  {{!-- Also available: this.is_auth, this.is_upload --}}
{{/each}}

{{#each nav.globals}}
  <a href="/admin/globals/{{this.slug}}">{{this.display_name}}</a>
{{/each}}

Each nav collection entry:

FieldTypeDescription
slugstringCollection slug
display_namestringHuman-readable name
is_authbooleanWhether this is an auth collection
is_uploadbooleanWhether this is an upload collection

Each nav global entry:

FieldTypeDescription
slugstringGlobal slug
display_namestringHuman-readable name

user — Current User

Present when the user is authenticated (JWT session valid). Absent on auth pages and error pages.

{{#if user}}
  Logged in as {{user.email}} ({{user.collection}})
{{/if}}
FieldTypeDescription
emailstringUser’s email address
idstringUser document ID
collectionstringAuth collection slug (e.g., "users")

Collection Pages

collection — Collection Definition

Available on all collection page types (collection_items, collection_edit, collection_create, collection_delete, collection_versions).

{{collection.slug}}
{{collection.display_name}}
{{collection.singular_name}}
{{collection.title_field}}
{{collection.timestamps}}       {{!-- boolean --}}
{{collection.is_auth}}          {{!-- boolean --}}
{{collection.is_upload}}        {{!-- boolean --}}
{{collection.has_drafts}}       {{!-- boolean --}}
{{collection.has_versions}}     {{!-- boolean --}}

collection.admin

{{collection.admin.use_as_title}}          {{!-- field name or null --}}
{{collection.admin.default_sort}}          {{!-- e.g., "-created_at" or null --}}
{{collection.admin.hidden}}                {{!-- boolean --}}
{{collection.admin.list_searchable_fields}} {{!-- array of field names --}}

collection.upload

null unless the collection has upload enabled.

{{#if collection.upload}}
  Accepts: {{collection.upload.mime_types}}
  Max size: {{collection.upload.max_file_size}}
  Thumbnail: {{collection.upload.admin_thumbnail}}
{{/if}}

collection.versions

null unless the collection has versioning enabled.

{{#if collection.versions}}
  Drafts: {{collection.versions.drafts}}
  Max versions: {{collection.versions.max_versions}}
{{/if}}

collection.auth

null unless the collection is an auth collection.

{{#if collection.auth}}
  Local login: {{#if (not collection.auth.disable_local)}}enabled{{/if}}
  Email verification: {{collection.auth.verify_email}}
{{/if}}

collection.fields_meta

Array of raw field definitions. Useful for JavaScript conditional logic or embedding metadata.

{{#each collection.fields_meta}}
  {{this.name}} — {{this.field_type}}
  Required: {{this.required}}, Localized: {{this.localized}}
  Label: {{this.admin.label}}
{{/each}}

{{!-- Serialize to JSON for JavaScript --}}
<script>
  const fields = {{{json collection.fields_meta}}};
</script>

Each entry in fields_meta:

FieldTypeDescription
namestringField name
field_typestringtext, number, select, relationship, etc.
requiredbooleanWhether the field is required
uniquebooleanWhether the field has a unique constraint
localizedbooleanWhether the field is localized
admin.labelstring/nullDisplay label
admin.hiddenbooleanWhether the field is hidden in admin
admin.readonlybooleanWhether the field is readonly
admin.widthnumber/nullColumn width hint
admin.descriptionstring/nullHelp text
admin.placeholderstring/nullPlaceholder text

Dashboard

Page type: dashboard

Additional keys:

KeyTypeDescription
collection_cardsarrayOne entry per collection with document count
global_cardsarrayOne entry per global
{{#each collection_cards}}
  <a href="/admin/collections/{{this.slug}}">
    {{this.display_name}} ({{this.count}} items)
  </a>
{{/each}}

{{#each global_cards}}
  <a href="/admin/globals/{{this.slug}}">{{this.display_name}}</a>
{{/each}}

Collection card fields: slug, display_name, singular_name, count, last_updated, is_auth, is_upload, has_versions.

Global card fields: slug, display_name, last_updated, has_versions.


Collection Items (List)

Page type: collection_items

Additional keys beyond collection:

KeyTypeDescription
docsarrayDocument list
searchstring/nullCurrent search query
paginationobjectPagination state
has_draftsbooleanShorthand for collection.has_drafts

docs

{{#each docs}}
  <tr>
    <td>{{this.title_value}}</td>
    <td>{{this.status}}</td>
    <td>{{this.created_at}}</td>
    <td>{{this.updated_at}}</td>
    {{#if this.thumbnail_url}}
      <td><img src="{{this.thumbnail_url}}" /></td>
    {{/if}}
  </tr>
{{/each}}

Each doc:

FieldTypeDescription
idstringDocument ID
title_valuestringValue of the title field (falls back to filename for uploads, then ID)
created_atstring/nullCreation timestamp
updated_atstring/nullLast update timestamp
thumbnail_urlstring/nullThumbnail URL (upload collections with images only)

pagination

{{#if pagination.has_prev}}
  <a href="{{pagination.prev_url}}">Previous</a>
{{/if}}
<span>Page {{pagination.page}} of {{pagination.total_pages}}</span>
{{#if pagination.has_next}}
  <a href="{{pagination.next_url}}">Next</a>
{{/if}}
FieldTypeDescription
pageintegerCurrent page number (1-based)
per_pageintegerItems per page
totalintegerTotal document count
total_pagesintegerTotal number of pages
has_prevbooleanWhether a previous page exists
has_nextbooleanWhether a next page exists
prev_urlstringURL for the previous page
next_urlstringURL for the next page

Collection Edit

Page type: collection_edit

Additional keys beyond collection:

KeyTypeDescription
documentobjectCurrent document data
fieldsarrayProcessed field contexts for form rendering
editingbooleanAlways true
has_draftsbooleanShorthand for collection.has_drafts
has_versionsbooleanShorthand for collection.has_versions
versionsarrayUp to 3 most recent version entries
has_more_versionsbooleantrue if more than 3 versions exist
uploadobjectUpload context (upload collections only)

document

{{document.id}}
{{document.created_at}}
{{document.updated_at}}
{{document.status}}        {{!-- "published" or "draft" --}}

{{!-- Raw field values --}}
{{document.data.title}}
{{document.data.category}}
FieldTypeDescription
idstringDocument ID
created_atstring/nullCreation timestamp
updated_atstring/nullLast update timestamp
statusstring"published" or "draft" (when drafts enabled)
dataobjectRaw field values as key-value pairs

Draft loading: When a collection has drafts enabled and the latest version is a draft, the edit page loads the document from the draft version snapshot. This means document.data contains the draft values, including block and array data — not the published main-table values.

fields

Processed field context objects used by the {{render_field}} helper or custom form rendering. See Field Context below.

versions

{{#each versions}}
  v{{this.version}} — {{this.status}}
  {{#if this.latest}} (latest) {{/if}}
  Created: {{this.created_at}}
{{/each}}
{{#if has_more_versions}}
  <a href="/admin/collections/{{collection.slug}}/{{document.id}}/versions">View all</a>
{{/if}}

Each version entry: id, version (number), status ("published" or "draft"), latest (boolean), created_at.

upload

Present only on upload collection edit/create pages.

{{#if collection.is_upload}}
  {{#if upload.preview}}
    <img src="{{upload.preview}}" />
  {{/if}}
  {{#if upload.info}}
    {{upload.info.filename}}
    {{upload.info.filesize_display}}  {{!-- e.g., "2.4 MB" --}}
    {{upload.info.dimensions}}        {{!-- e.g., "1920x1080" --}}
  {{/if}}
  {{#if upload.accept}}
    <input type="file" accept="{{upload.accept}}" />
  {{/if}}
{{/if}}
FieldTypeDescription
acceptstring/nullMIME type filter for file input (e.g., "image/*")
previewstring/nullPreview image URL (images only, uses admin_thumbnail)
infoobject/nullFile info for existing uploads
info.filenamestringOriginal filename
info.filesize_displaystringHuman-readable file size
info.dimensionsstring/nullImage dimensions (e.g., "1920x1080")

Collection Create

Page type: collection_create

Same structure as collection edit, with these differences:

  • editing is false
  • document is absent
  • versions, has_more_versions are absent
  • Password field is added for auth collections (required)

Collection Delete

Page type: collection_delete

KeyTypeDescription
collectionobjectCollection definition
document_idstringID of the document to delete
title_valuestring/nullDisplay title of the document

Collection Versions

Page type: collection_versions

Full version history page with pagination.

KeyTypeDescription
collectionobjectCollection definition
documentobjectStub with id only
doc_titlestringDocument title for breadcrumbs
versionsarrayPaginated version entries
paginationobjectPagination state

Collection List

Page type: collection_list

KeyTypeDescription
collectionsarrayAll registered collections

Each entry: slug, display_name, field_count.


Global Edit

Page type: global_edit

KeyTypeDescription
globalobjectGlobal definition
fieldsarrayProcessed field contexts (main area)
sidebar_fieldsarrayProcessed field contexts (sidebar)
has_draftsbooleanWhether the global has drafts enabled
has_versionsbooleanWhether the global has versions enabled
versionsarrayRecent version entries (up to 3)
has_more_versionsbooleantrue if more than 3 versions exist
restore_url_prefixstringURL prefix for version restore actions
versions_urlstringURL to the full versions list page
doc_statusstringDocument status ("published" or "draft")

global

{{global.slug}}
{{global.display_name}}
{{#each global.fields_meta}}
  {{this.name}} — {{this.field_type}}
{{/each}}
FieldTypeDescription
slugstringGlobal slug
display_namestringHuman-readable name
fields_metaarraySame structure as collection.fields_meta

Auth Pages

Auth pages use a minimal context builder (ContextBuilder::auth()) — no nav or user.

Login (auth_login)

KeyTypeDescription
collectionsarrayAuth collections (slug + display_name)
show_collection_pickerbooleantrue if more than one auth collection
disable_localbooleantrue if all auth collections disable local login
show_forgot_passwordbooleantrue if email is configured and any collection enables forgot password
errorstring/nullError message (e.g., “Invalid email or password”)
successstring/nullSuccess message (e.g., after password reset)
emailstring/nullPre-filled email (on error re-render)

Forgot Password (auth_forgot)

KeyTypeDescription
collectionsarrayAuth collections
show_collection_pickerbooleantrue if more than one auth collection
successboolean/nulltrue after form submission (always, to avoid leaking user existence)

Reset Password (auth_reset)

KeyTypeDescription
tokenstring/nullReset token (if valid)
errorstring/nullError message (invalid/expired token, validation errors)

Error Pages

Error pages receive the base context (crap, nav, page) plus:

KeyTypeDescription
messagestringError description

Page types: error_403, error_404, error_500.

<h1>{{page.title}}</h1>
<p>{{message}}</p>

Locale Context

When localization is enabled in crap.toml, edit/create pages receive additional keys merged into the top level:

KeyTypeDescription
has_localesbooleanAlways true when locale is enabled
current_localestringCurrently selected locale (e.g., "en")
localesarrayAll configured locales with selection state
{{#if has_locales}}
  {{#each locales}}
    <option value="{{this.value}}" {{#if this.selected}}selected{{/if}}>
      {{this.label}}
    </option>
  {{/each}}
{{/if}}

Each locale entry: value (e.g., "en"), label (e.g., "EN"), selected (boolean).

When editing a non-default locale, non-localized fields are rendered as readonly (locale-locked).

Editor Locale (Content Locale)

In addition to the UI translation locale (has_locales/current_locale/locales), edit/create pages also receive content editor locale variables when locale is enabled:

KeyTypeDescription
has_editor_localesbooleanAlways true when locale is enabled
editor_localestringCurrently selected content locale (e.g., "en")
editor_localesarrayAll configured locales with selection state

These control which content locale is being edited, separate from the admin UI translation locale.


Field Context

The fields array contains processed field context objects, one per field. These are used by {{{render_field field}}} or can be iterated manually.

Common Fields

Every field context object has:

FieldTypeDescription
namestringField name (HTML form input name)
field_typestringType identifier (e.g., "text", "select", "blocks")
labelstringDisplay label (from admin.label or auto-generated)
requiredbooleanWhether the field is required
valuestringCurrent value (stringified)
placeholderstring/nullPlaceholder text
descriptionstring/nullHelp text
readonlybooleanWhether the field is readonly
localizedbooleanWhether the field is localized
locale_lockedbooleantrue when editing a non-default locale and the field is not localized
errorstring/nullValidation error message (on re-render after failed save)
positionstring/nullField position: "main" (default) or "sidebar". Fields with "sidebar" appear in sidebar_fields instead of fields
condition_visibleboolean/nullInitial visibility from display condition evaluation. false = hidden on page load
condition_jsonobject/nullClient-side condition table (JSON). Present for condition functions that return a table
condition_refstring/nullServer-side condition function reference. Present for condition functions that return a boolean

Select Fields

Additional keys:

FieldTypeDescription
optionsarrayAvailable options

Each option: label, value, selected (boolean).

Checkbox Fields

FieldTypeDescription
checkedbooleanWhether the checkbox is checked

Date Fields

FieldTypeDescription
picker_appearancestring"dayOnly", "dayAndTime", "timeOnly", or "monthOnly"
date_only_valuestringDate portion only, e.g., "2026-01-15" (dayOnly)
datetime_local_valuestringDate+time, e.g., "2026-01-15T09:00" (dayAndTime)

Relationship Fields

FieldTypeDescription
relationship_collectionstringRelated collection slug
has_manybooleanWhether this is a has-many relationship
relationship_optionsarrayAvailable documents from the related collection

Each option: value (document ID), label (title field value), selected (boolean).

Upload Fields

FieldTypeDescription
relationship_collectionstringUpload collection slug
relationship_optionsarrayAvailable uploads with thumbnail info
selected_preview_urlstring/nullPreview URL of the currently selected upload
selected_filenamestring/nullFilename of the currently selected upload

Each option: value, label, selected, thumbnail_url (if image), is_image (boolean), filename.

Group Fields

FieldTypeDescription
sub_fieldsarraySub-field contexts (same structure as top-level fields)
collapsedbooleanWhether the group starts collapsed

Sub-field name is formatted as group__subfield (double underscore).

Array Fields

FieldTypeDescription
sub_fieldsarraySub-field definitions (template for new rows)
rowsarrayExisting row data
row_countintegerNumber of existing rows
template_idstringUnique ID for DOM targeting (count badge, row container, templates)
label_fieldstring/nullSub-field name used for dynamic row labels (from admin.label_field)
max_rowsinteger/nullMaximum number of rows allowed (from max_rows on field definition)
min_rowsinteger/nullMinimum number of rows required (from min_rows on field definition)
init_collapsedbooleanWhether existing rows render collapsed by default (from admin.collapsed, default: true)
add_labelstring/nullCustom singular label for the add button (from admin.labels.singular, e.g., “Slide” → “Add Slide”)

Each row: index (integer), sub_fields (array of field contexts with indexed names like items[0][title]), custom_label (string/null — computed label from row_label or label_field).

The <fieldset> element includes a data-label-field attribute when label_field is set, enabling JavaScript to update row titles live as the user types.

Blocks Fields

FieldTypeDescription
block_definitionsarrayAvailable block types with their fields
rowsarrayExisting block instances
row_countintegerNumber of existing blocks
template_idstringUnique ID for DOM targeting (count badge, row container, templates)
label_fieldstring/nullField-level admin.label_field (shared fallback for all block types)
max_rowsinteger/nullMaximum number of blocks allowed (from max_rows on field definition)
min_rowsinteger/nullMinimum number of blocks required (from min_rows on field definition)
init_collapsedbooleanWhether existing block rows render collapsed by default (from admin.collapsed, default: true)
add_labelstring/nullCustom singular label for the add button (from admin.labels.singular, e.g., “Section” → “Add Section”)

Each block definition: block_type, label, label_field (string/null — per-block-type label field), fields (array of sub-field contexts).

Each row: index, _block_type, block_label, custom_label (string/null — computed label from row_label, block label_field, or field label_field), sub_fields (array of field contexts with indexed names like content[0][heading]).


Handlebars Helpers

In addition to the standard Handlebars helpers, these custom helpers are available:

Logic Helpers

HelperUsageDescription
eq{{#if (eq a b)}}Equality check (any types)
not{{#if (not val)}}Boolean negation
and{{#if (and a b)}}Logical AND
or{{#if (or a b)}}Logical OR

Comparison Helpers

HelperUsageDescription
gt{{#if (gt a b)}}Greater than (numeric)
lt{{#if (lt a b)}}Less than (numeric)
gte{{#if (gte a b)}}Greater than or equal (numeric)
lte{{#if (lte a b)}}Less than or equal (numeric)
contains{{#if (contains haystack needle)}}Array/string contains

Utility Helpers

HelperUsageDescription
json{{{json value}}}Serialize to JSON string (use triple braces)
default{{default val "fallback"}}Fallback for falsy values
concat{{concat a b c}}String concatenation (variadic)
t{{t "key"}}Translation lookup
render_field{{{render_field field}}}Render a field partial (use triple braces)

Translation Helper

Supports interpolation:

{{t "welcome"}}                     {{!-- simple lookup --}}
{{t "greeting" name="World"}}       {{!-- replaces {{name}} in translation string --}}

Truthiness

Helpers like not, and, or, and default use Handlebars truthiness:

  • Falsy: null, false, 0, "" (empty string), [] (empty array)
  • Truthy: everything else (including empty objects {})

Composition

Helpers can be composed as sub-expressions:

{{#if (and (not collection.is_auth) (gt pagination.total 0))}}
  Showing {{pagination.total}} items
{{/if}}

{{#if (or collection.has_drafts collection.has_versions)}}
  This collection supports versioning
{{/if}}

<a href="{{concat "/admin/collections/" collection.slug "/create"}}">New</a>

before_render Hook

You can inject custom data into every admin page context using the before_render hook:

-- init.lua
crap.hooks.register("before_render", function(context)
  context.custom = {
    announcement = "Maintenance tonight at 10pm",
    feature_flags = { new_editor = true },
  }
  return context
end)

Then in your templates:

{{#if custom.announcement}}
  <div class="announcement">{{custom.announcement}}</div>
{{/if}}

{{#if custom.feature_flags.new_editor}}
  {{!-- show new editor --}}
{{/if}}

The before_render hook:

  • Fires on every admin page render (GET and POST error re-renders)
  • Receives the full template context as a Lua table
  • Must return the (possibly modified) context
  • Has no CRUD access (no database operations) — keeps it fast
  • Use the custom key by convention for injected data
  • Can read and modify any context key (not just custom), but modifying built-in keys may break default templates
  • On error: logs a warning and returns the original context unmodified

Example: Conditional Navigation

crap.hooks.register("before_render", function(context)
  -- Add environment indicator
  local env = crap.env.get("APP_ENV") or "development"
  context.custom = context.custom or {}
  context.custom.environment = env
  context.custom.is_production = (env == "production")
  return context
end)
{{#if custom.is_production}}
  <div class="env-badge env-badge--production">PRODUCTION</div>
{{else}}
  <div class="env-badge">{{custom.environment}}</div>
{{/if}}

Full Example: Custom List Template

A complete example overriding the items list to add a custom column:

{{!-- <config_dir>/templates/collections/items.hbs --}}
{{#> layout/base}}

<h1>{{page.title}}</h1>

{{#if custom.announcement}}
  <div class="alert">{{custom.announcement}}</div>
{{/if}}

<table>
  <thead>
    <tr>
      <th>Title</th>
      <th>Status</th>
      {{#if collection.is_upload}}<th>Preview</th>{{/if}}
      <th>Updated</th>
    </tr>
  </thead>
  <tbody>
    {{#each docs}}
      <tr>
        <td>
          <a href="/admin/collections/{{../collection.slug}}/{{this.id}}">
            {{this.title_value}}
          </a>
        </td>
        <td>{{this.status}}</td>
        {{#if ../collection.is_upload}}
          <td>
            {{#if this.thumbnail_url}}
              <img src="{{this.thumbnail_url}}" width="40" />
            {{/if}}
          </td>
        {{/if}}
        <td>{{this.updated_at}}</td>
      </tr>
    {{/each}}
  </tbody>
</table>

{{#if pagination.has_prev}}
  <a href="{{pagination.prev_url}}">Previous</a>
{{/if}}
{{#if pagination.has_next}}
  <a href="{{pagination.next_url}}">Next</a>
{{/if}}

{{!-- Embed field metadata for client-side use --}}
<script>
  const collectionDef = {{{json collection}}};
</script>

{{/layout/base}}

Static Files

Static files (CSS, JS, fonts, images) use the same overlay pattern as templates.

Overlay Pattern

  1. Config directory<config_dir>/static/ is served via tower_http::ServeDir
  2. Compiled defaults — built into the binary via include_dir! macro

If a file exists in both locations, the config directory version wins.

Accessing Static Files

All static files are served under /static/:

<link rel="stylesheet" href="/static/styles.css">
<script type="module" src="/static/components/index.js"></script>

Overriding Static Files

Place files in your config directory’s static/ folder:

my-project/
└── static/
    └── css/
        └── custom.css    # served at /static/css/custom.css

To override a built-in file (e.g., the main stylesheet or a JS component), use the same path:

my-project/
└── static/
    ├── styles.css                  # overrides the compiled-in stylesheet
    └── components/
        └── toast.js                # overrides just the toast component

Compiled-In Files

Default static files are compiled into the binary using the include_dir! macro. This means:

  • The binary is self-contained — no external files needed for the default admin UI
  • After modifying compiled-in static files, you must run cargo build for changes to take effect
  • Config directory overrides don’t require rebuilding

MIME Types

Content-Type headers are automatically detected from file extensions using the mime_guess crate.

Technical Note

The embedded fallback handler uses axum::http::Uri extraction (not axum::extract::Path) because it runs as a ServeDir fallback service where no route parameters are defined.

Display Conditions

Display conditions let you show or hide fields in the admin UI based on the values of other fields. This is useful for context-dependent forms — for example, showing a URL field only when the post type is “link”.

Configuration

Add admin.condition to a field definition, referencing a Lua function:

crap.fields.text({
    name = "external_url",
    admin = {
        condition = "hooks.posts.show_external_url",
    },
}),

The condition references a Lua function using the standard hook ref format (hooks.<collection>.<name>). The function receives the current form data and returns either a condition table (client-side) or a boolean (server-side).

The data parameter is typed per-collection (crap.data.Posts, crap.global_data.SiteSettings) for IDE autocomplete. The type generator emits these types automatically.

Use crap-cms make hook with --type condition to scaffold condition hooks:

crap-cms -C ./config make hook show_external_url \
    -t condition -c posts -l table -F post_type

Condition Functions

Client-Side (Condition Table)

When the function returns a table, it is serialized to JSON and embedded in the HTML. JavaScript evaluates it instantly on field changes — no server round-trip.

-- hooks/posts/show_external_url.lua
---@param data crap.data.Posts
---@return table
return function(data)
    return { field = "post_type", equals = "link" }
end

Server-Side (Boolean)

When the function returns a boolean, the field visibility is re-evaluated on the server via a debounced fetch (300ms delay after the last input change). Use this for complex logic that can’t be expressed as a simple condition table.

-- hooks/posts/show_premium_options.lua
---@param data crap.data.Posts
---@return boolean
return function(data)
    -- Complex logic that needs server-side evaluation
    local tags = data.tags or {}
    for _, tag in ipairs(tags) do
        if tag == "premium" then return true end
    end
    return false
end

Performance tip: Prefer condition tables over booleans whenever possible. Tables evaluate instantly in the browser; booleans require a server round-trip on every field change.

Condition Table Operators

OperatorExampleDescription
equals{ field = "type", equals = "link" }Exact match
not_equals{ field = "type", not_equals = "draft" }Not equal
in{ field = "type", ["in"] = {"link", "video"} }Value in list
not_in{ field = "type", not_in = {"a", "b"} }Value not in list
is_truthy{ field = "has_image", is_truthy = true }Non-empty, non-nil, non-false
is_falsy{ field = "has_image", is_falsy = true }Empty, nil, or false

Multiple Conditions (AND)

Return an array of condition tables to require all conditions to be true:

-- hooks/posts/show_advanced.lua
---@param data crap.data.Posts
---@return table
return function(data)
    return {
        { field = "post_type", not_equals = "link" },
        { field = "excerpt", is_truthy = true },
    }
end

How It Works

Page Load

  1. The server calls the Lua condition function with the current document data
  2. Based on the return type:
    • Table: serialized as a data-condition JSON attribute on the field wrapper; initial visibility computed server-side
    • Boolean: result sets initial visibility; function reference stored as data-condition-ref
  3. Fields with false conditions render with display: none (no flash of content)

Client-Side Reactivity (Condition Tables)

When the user changes a form field:

  1. JavaScript reads the data-condition JSON from each conditional field
  2. Evaluates the condition against current form values
  3. Shows or hides the field instantly

Server-Side Reactivity (Boolean Functions)

When the user changes a form field:

  1. JavaScript debounces for 300ms
  2. POSTs current form data to /admin/collections/{slug}/evaluate-conditions
  3. Server calls each boolean condition function
  4. Response updates field visibility

Display conditions work on fields in any position, including sidebar fields (admin.position = "sidebar").

Safe Defaults

  • If a condition function throws an error, the field remains visible (safe default)
  • If the condition returns nil, the field remains visible
  • On page load, fields are hidden server-side before rendering (no flash)

Themes

The admin UI includes built-in theme support with multiple color schemes.

Built-In Themes

ThemeTypeDescription
LightlightDefault theme. Clean blue-accented light design.
Rosé Pine DawnlightWarm, muted light theme based on the Rosé Pine palette.
Tokyo NightdarkCool blue-purple dark theme.
Catppuccin MochadarkPastel-accented dark theme from the Catppuccin palette.
Gruvbox DarkdarkWarm retro dark theme with orange accents.

Switching Themes

Click the palette icon in the admin header to open the theme picker. The selected theme is persisted in localStorage and restored on page load (no flash of unstyled content).

How Themes Work

The default Light theme is defined in :root CSS custom properties in styles.css. Additional themes live in themes.css and override these variables using html[data-theme="<name>"] selectors.

/* Default light theme (styles.css) */
:root {
  color-scheme: light;
  --color-primary: #1677ff;
  --bg-body: #f4f7fc;
  --text-primary: rgba(0, 0, 0, 0.88);
  /* ... */
}

/* Dark theme override (themes.css) */
html[data-theme="tokyo-night"] {
  color-scheme: dark;
  --color-primary: #7aa2f7;
  --bg-body: #1a1b26;
  --text-primary: #c0caf5;
  /* ... */
}

Custom Themes

To create a custom theme, add a CSS file in your config directory’s static/ folder and override the theme picker template.

1. Create the theme CSS

Create static/themes-custom.css in your config directory:

html[data-theme="my-theme"] {
  color-scheme: light; /* or dark */

  /* Primary accent */
  --color-primary: #6366f1;
  --color-primary-hover: #818cf8;
  --color-primary-active: #4f46e5;
  --color-primary-bg: rgba(99, 102, 241, 0.08);

  /* Danger/error */
  --color-danger: #ef4444;
  --color-danger-hover: #f87171;
  --color-danger-active: #dc2626;
  --color-danger-bg: rgba(239, 68, 68, 0.08);

  /* Success */
  --color-success: #22c55e;
  --color-success-bg: rgba(34, 197, 94, 0.08);

  /* Warning */
  --color-warning: #f59e0b;
  --color-warning-bg: rgba(245, 158, 11, 0.08);

  /* Text */
  --text-primary: #1e293b;
  --text-secondary: #475569;
  --text-tertiary: #94a3b8;
  --text-on-primary: #ffffff;

  /* Surfaces */
  --bg-body: #f8fafc;
  --bg-surface: #ffffff;
  --bg-elevated: #f1f5f9;
  --bg-hover: rgba(0, 0, 0, 0.04);

  /* Borders */
  --border-color: rgba(0, 0, 0, 0.08);
  --border-color-hover: rgba(0, 0, 0, 0.15);

  /* Shadows */
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
  --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);

  /* Inputs */
  --input-bg: #ffffff;
  --input-border: rgba(0, 0, 0, 0.12);

  /* Header */
  --header-bg: #f1f5f9;
  --header-border: rgba(0, 0, 0, 0.08);

  /* Sidebar */
  --sidebar-bg: transparent;
  --sidebar-active-bg: rgba(99, 102, 241, 0.1);
  --sidebar-active-text: #6366f1;
}

2. Import in a styles override

Create static/styles.css in your config directory (the static overlay will serve it instead of the built-in version). Copy the original and add your import at the bottom:

@import url("themes-custom.css");

3. Add the picker option

Override templates/layout/header.hbs and add a button to the .theme-picker__dropdown:

<button type="button" class="theme-picker__option" data-theme-value="my-theme">
  <span class="theme-picker__swatch" style="background:#f8fafc"></span>
  My Theme
</button>

CSS Custom Properties Reference

All variables that themes should override:

VariableDescription
Colors
--color-primaryPrimary accent color
--color-primary-hoverPrimary hover state
--color-primary-activePrimary active/pressed state
--color-primary-bgPrimary tinted background (low opacity)
--color-dangerError/destructive color
--color-danger-hoverDanger hover state
--color-danger-activeDanger active state
--color-danger-bgDanger tinted background
--color-successSuccess color
--color-success-bgSuccess tinted background
--color-warningWarning color
--color-warning-bgWarning tinted background
Text
--text-primaryPrimary text
--text-secondarySecondary/muted text
--text-tertiaryDisabled/hint text
--text-on-primaryText on primary-colored backgrounds
Surfaces
--bg-bodyPage background
--bg-surfaceCard/panel background
--bg-elevatedElevated surface (modals, popovers)
--bg-hoverGeneric hover background
Borders
--border-colorDefault border color
--border-color-hoverBorder hover color
Shadows
--shadow-smSmall shadow (cards)
--shadow-mdMedium shadow (dropdowns)
--shadow-lgLarge shadow (modals)
Inputs
--input-bgForm input background
--input-borderForm input border
--select-arrowSelect dropdown arrow (SVG data URL)
Layout
--header-bgHeader background
--header-borderHeader bottom border
--sidebar-bgSidebar background
--sidebar-active-bgActive sidebar item background
--sidebar-active-textActive sidebar item text color

Variables not typically overridden by themes (inherited from :root): --radius-*, --space-*, --transition-*, --text-xs through --text-2xl, --sidebar-width, --input-height.

Database

Crap CMS uses SQLite as its database, accessed via rusqlite with an r2d2 connection pool.

Configuration

[database]
path = "data/crap.db"   # relative to config dir, or absolute

WAL Mode

The database runs in WAL (Write-Ahead Logging) mode for better concurrent read performance. This is set automatically when the connection pool is created.

Schema

Collection Tables

Each collection gets a table named after its slug:

CREATE TABLE posts (
    id TEXT PRIMARY KEY,
    title TEXT NOT NULL,
    slug TEXT NOT NULL UNIQUE,
    status TEXT DEFAULT 'draft',
    content TEXT,
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
);

Column types are determined by field types:

Field TypeSQLite Type
text, textarea, richtext, select, date, email, jsonTEXT
numberREAL
checkboxINTEGER
relationship (has-one)TEXT

Auth collections also get a _password_hash TEXT column.

Global Tables

Named _global_{slug}, always have a single row with id = 'default':

CREATE TABLE _global_site_settings (
    id TEXT PRIMARY KEY,
    site_name TEXT,
    tagline TEXT,
    created_at TEXT DEFAULT (datetime('now')),
    updated_at TEXT DEFAULT (datetime('now'))
);

Junction Tables

Has-many relationships and arrays use join tables:

-- Has-many relationship: posts_tags
CREATE TABLE posts_tags (
    parent_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    related_id TEXT NOT NULL,
    _order INTEGER NOT NULL DEFAULT 0,
    PRIMARY KEY (parent_id, related_id)
);

-- Array field: posts_slides
CREATE TABLE posts_slides (
    id TEXT PRIMARY KEY,
    parent_id TEXT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    _order INTEGER NOT NULL DEFAULT 0,
    title TEXT,
    image_url TEXT,
    caption TEXT
);

Metadata Table

CREATE TABLE _crap_meta (
    key TEXT PRIMARY KEY,
    value TEXT NOT NULL,
    updated_at TEXT DEFAULT (datetime('now'))
);

Dynamic Schema Sync

On startup, Crap CMS compares Lua definitions against the database schema:

  1. Missing tables — created with all columns
  2. Missing columns — added via ALTER TABLE ADD COLUMN
  3. Missing junction tables — created for new has-many/array fields
  4. Removed columns — logged as warnings (not dropped)
  5. Missing _password_hash — added to auth collections

Schema sync runs in a single transaction. If anything fails, all changes are rolled back.

Connection Pool

The r2d2 pool provides connections for both reads and writes:

  • Read operationsdb/ops.rs gets a connection from the pool, calls query::* functions
  • Write operations — callers get a connection, open a transaction, call query::*, then commit
  • Hook CRUD — hooks share the caller’s transaction via the TxContext pattern

Transaction Pattern

All write operations follow this pattern:

1. Get connection from pool
2. Begin transaction
3. Run before-hooks (with transaction access)
4. Execute query (inside same transaction)
5. Run after-hooks (inside same transaction, errors roll back)
6. Commit transaction

API Surface Comparison

This document compares the three API surfaces — Admin UI, gRPC API, and Lua CRUD (hook local API) — to track feature consistency.

CREATE Lifecycle

StepAdmingRPCLua CRUD
Access control (collection-level)YesYesYes (overrideAccess)
Field-level write strippingYesYesYes (overrideAccess)
Password extraction (auth)YesYesYes
before_validate (field + collection + registered)YesYesYes
ValidationYesYesYes
before_change (field + collection + registered)YesYesYes
DB insertYesYesYes
Join table data (arrays, blocks, has-many)YesYesYes
Password hash + storeYesYesYes
Versioning (status + snapshot + prune)YesYesYes
after_change (field + collection + registered)YesYesYes
Publish event (SSE/WebSocket)YesYesNo (in-transaction)
Verification email (auth + verify_email)YesYesNo

UPDATE Lifecycle

StepAdmingRPCLua CRUD
Access control (collection-level)YesYesYes (overrideAccess)
Field-level write strippingYesYesYes (overrideAccess)
Password extraction (auth)YesYesYes
Unpublish pathYesYesYes
before_validate (field + collection + registered)YesYesYes
ValidationYesYesYes
before_change (field + collection + registered)YesYesYes
DB update (or draft-only version save)YesYesYes
Join table dataYesYesYes
Password hash + store (normal path)YesYesYes
Versioning (status + snapshot + prune)YesYesYes
after_change (field + collection + registered)YesYesYes
Publish eventYesYesNo (in-transaction)

DELETE Lifecycle

StepAdmingRPCLua CRUD
Access controlYesYesYes (overrideAccess)
before_delete (collection + registered)YesYesYes
DB deleteYesYesYes
after_delete (collection + registered)YesYesYes
Upload file cleanupYesYesYes
Publish eventYesYesNo (in-transaction)

FIND Lifecycle

StepAdmingRPCLua CRUD
Access control (collection-level)YesYesYes (overrideAccess)
Constraint filter mergingYesYesYes
Draft-aware filteringYesYesYes
before_read hooksYesYesYes
DB query + countYesYesYes
Hydrate join tablesYesYesYes
Upload sizes assemblyYesYesYes
after_read hooks (field + collection + registered)YesYesYes
Relationship population (depth)YesYesYes
Select field strippingYesYesYes
Field-level read strippingYesYesYes (overrideAccess)

FIND_BY_ID Lifecycle

StepAdmingRPCLua CRUD
Access control (collection-level)YesYesYes (overrideAccess)
before_read hooksYesYesYes
Draft version overlayYesYesYes
Hydrate join tablesYesYesYes
Upload sizes assemblyYesYesYes
after_read hooks (field + collection + registered)YesYesYes
Relationship population (depth)YesYesYes
Select field strippingYesYesYes
Field-level read strippingYesYesYes (overrideAccess)

Remaining By-Design Differences

FeatureAdmingRPCLua CRUDReason
Event publishingYesYesNoLua runs inside the caller’s transaction; event publishing is fire-and-forget after commit. The caller (admin/gRPC) publishes the event.
Upload file cleanup on deleteYesYesYesLua CRUD reads ConfigDir from Lua app_data; admin/gRPC clean up after commit.
Verification email on createYesYesNoEmail sending is async, post-commit.
Locale from requestYesYesExplicit optAdmin/gRPC infer from request; Lua passes explicitly via opts.locale.
Default depthVariesConfig0Lua defaults to 0 to avoid N+1 in hooks. Callers pass depth explicitly.