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 --checkJswithout 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
| Component | Technology |
|---|---|
| Language | Rust (edition 2024) |
| Web framework | Axum |
| gRPC | Tonic + Prost |
| Database | SQLite via rusqlite, r2d2 pool, WAL mode |
| Templates | Handlebars + HTMX |
| Hooks | Lua 5.4 via mlua |
| IDs | nanoid |
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:
| File | Platform |
|---|---|
crap-cms-linux-x86_64 | Linux x86_64 (musl, fully static) |
crap-cms-linux-aarch64 | Linux ARM64 (musl, fully static) |
crap-cms-windows-x86_64.exe | Windows 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:
- Admin port (default: 3000)
- gRPC port (default: 50051)
- Localization — enable and choose locales (e.g.,
en,de,fr) - Auth collection — creates a
userscollection with email/password login - First admin user — prompts for email and password right away
- Upload collection — creates a
mediacollection for file/image uploads - 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:
- Admin UI at http://localhost:3000/admin
- gRPC API at
localhost:50051
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
crap.tomlis loaded first (or defaults are used if absent)collections/*.luafiles are loaded alphabeticallyglobals/*.luafiles are loaded alphabeticallyinit.luais 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
| Field | Type | Default | Description |
|---|---|---|---|
crap_version | string | — | Expected 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 ofVAR. Startup fails ifVARis not set.${VAR:-default}— replaced withVARif set and non-empty, otherwise usesdefault.
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 = 0database.connection_timeout = 0hooks.vm_pool_size = 0server.admin_portorserver.grpc_portis0server.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 executeauth.secretis set but shorter than 32 charactersdepth.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]
| Field | Type | Default | Description |
|---|---|---|---|
admin_port | integer | 3000 | Port for the Axum admin UI |
grpc_port | integer | 50051 | Port for the Tonic gRPC API |
host | string | "0.0.0.0" | Bind address for both servers |
h2c | boolean | false | Enable 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_proxy | boolean | false | Trust 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(). |
compression | string | "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_reflection | boolean | false | Enable 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_url | string | — | Public-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_requests | integer | 0 | Maximum 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_window | integer/string | 60 ("1m") | Sliding window duration for rate limiting. Accepts seconds (integer) or human-readable ("1m", "30s"). |
grpc_max_message_size | integer/string | 16777216 ("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_timeout | integer/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_timeout | integer/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]
| Field | Type | Default | Description |
|---|---|---|---|
path | string | "data/crap.db" | SQLite database path. Relative paths are resolved from the config directory. Absolute paths are used as-is. |
pool_max_size | integer | 32 | Maximum number of connections in the SQLite connection pool. |
busy_timeout | duration | 30000 ("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_timeout | duration | 5 | Pool checkout timeout in seconds. How long pool.get() waits for a free connection before returning an error. |
[admin]
| Field | Type | Default | Description |
|---|---|---|---|
dev_mode | boolean | false | When 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_auth | boolean | true | When 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. |
access | string | — | Lua 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_timezone | string | "" | 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". |
csp | table | (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.).
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Enable the CSP header. Set to false to disable entirely. |
default_src | string[] | ["'self'"] | Fallback for any directive not explicitly set. |
script_src | string[] | ["'self'", "'unsafe-inline'", "https://unpkg.com"] | Allowed script sources. Includes 'unsafe-inline' for theme bootstrap and CSRF injection scripts. |
style_src | string[] | ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"] | Allowed stylesheet sources. Includes 'unsafe-inline' for Web Component Shadow DOM styles. |
font_src | string[] | ["'self'", "https://fonts.gstatic.com"] | Allowed font sources. Includes Google Fonts for Material Symbols icons. |
img_src | string[] | ["'self'", "data:"] | Allowed image sources. Includes data: for inline SVGs. |
connect_src | string[] | ["'self'"] | Allowed targets for fetch, XHR, and WebSocket connections. |
frame_ancestors | string[] | ["'none'"] | Who can embed this page in a frame. 'none' prevents clickjacking. |
form_action | string[] | ["'self'"] | Allowed form submission targets. |
base_uri | string[] | ["'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]
| Field | Type | Default | Description |
|---|---|---|---|
secret | string | "" (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_expiry | integer/string | 7200 ("2h") | Default JWT token lifetime. Accepts seconds (integer) or human-readable ("2h", "30m"). Can be overridden per auth collection. |
max_login_attempts | integer | 5 | Maximum failed login attempts per email before temporary lockout. |
max_ip_login_attempts | integer | 20 | Maximum 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_seconds | integer/string | 300 ("5m") | Duration of lockout after max_login_attempts or max_ip_login_attempts is reached. Accepts seconds or human-readable. |
reset_token_expiry | integer/string | 3600 ("1h") | Password reset token expiry. The “Forgot password” email link expires after this duration. Accepts seconds or human-readable. |
max_forgot_password_attempts | integer | 3 | Maximum forgot-password requests per email address before rate limiting. Further requests silently return success without sending email. |
forgot_password_window_seconds | integer/string | 900 ("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).
| Field | Type | Default | Description |
|---|---|---|---|
min_length | integer | 8 | Minimum 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_length | integer | 128 | Maximum password length in bytes. Prevents DoS via Argon2 on huge inputs. Uses byte count (not characters) to bound hashing cost. |
require_uppercase | boolean | false | Require at least one uppercase letter (A-Z). |
require_lowercase | boolean | false | Require at least one lowercase letter (a-z). |
require_digit | boolean | false | Require at least one digit (0-9). |
require_special | boolean | false | Require at least one special (non-alphanumeric) character. |
[depth]
| Field | Type | Default | Description |
|---|---|---|---|
default_depth | integer | 1 | Default population depth for FindByID. Find always defaults to 0. |
max_depth | integer | 10 | Maximum allowed depth for any request. Hard cap to prevent excessive queries. |
populate_cache | boolean | false | Enable 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_secs | integer | 0 | Periodic 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]
| Field | Type | Default | Description |
|---|---|---|---|
default_limit | integer | 20 | Default page size applied to Find queries when no limit is specified. |
max_limit | integer | 1000 | Hard cap on limit. Requests above this value are clamped to max_limit. |
mode | string | "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]
| Field | Type | Default | Description |
|---|---|---|---|
max_file_size | integer/string | 52428800 ("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]
| Field | Type | Default | Description |
|---|---|---|---|
smtp_host | string | "" (empty) | SMTP server hostname. Empty = email disabled — all send attempts log a warning and return Ok. |
smtp_port | integer | 587 | SMTP port. 587 is the standard STARTTLS port. |
smtp_user | string | "" | SMTP authentication username. |
smtp_pass | string | "" | SMTP authentication password. |
smtp_tls | string | "starttls" | TLS mode: "starttls" (default, port 587), "tls" (implicit TLS, port 465), "none" (plain, for testing). |
from_address | string | "noreply@example.com" | Sender email address for outgoing mail. |
from_name | string | "Crap CMS" | Sender display name. |
smtp_timeout | integer/string | 30 | SMTP 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]
| Field | Type | Default | Description |
|---|---|---|---|
on_init | string[] | [] | Lua function refs to execute at startup. These run synchronously with CRUD access — failure aborts startup. |
max_depth | integer | 3 | Maximum hook recursion depth. When Lua CRUD in hooks triggers more hooks, this caps the chain. 0 = never run hooks from Lua CRUD. |
vm_pool_size | integer | max(cpus, 4) capped at 32 | Number 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_instructions | integer | 10000000 | Maximum Lua instructions per hook invocation. 0 = unlimited. |
max_memory | integer/string | 52428800 (50 MB) | Maximum Lua memory per VM in bytes. Accepts integer or filesize string ("50MB", "100MB"). 0 = unlimited. |
allow_private_networks | boolean | false | Allow crap.http.request to reach private/loopback/link-local IPs. |
http_max_response_bytes | integer/string | 10485760 (10 MB) | Maximum HTTP response body size. Accepts integer or filesize string ("10MB", "1GB"). |
[live]
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Enable live event streaming (SSE + gRPC Subscribe). |
channel_capacity | integer | 1024 | Internal broadcast channel buffer size. Increase if subscribers lag. |
max_sse_connections | integer | 1000 | Maximum concurrent SSE connections. When reached, new connections receive 503 Service Unavailable. 0 = unlimited. |
max_subscribe_connections | integer | 1000 | Maximum concurrent gRPC Subscribe streams. When reached, new subscriptions receive UNAVAILABLE status. 0 = unlimited. |
See Live Updates for full documentation.
[locale]
| Field | Type | Default | Description |
|---|---|---|---|
default_locale | string | "en" | Default locale code. Content without an explicit locale uses this. |
locales | string[] | [] (empty) | Supported locale codes. Empty = localization disabled. When empty, all fields behave as before (single value, no locale columns). |
fallback | boolean | true | When reading a non-default locale, fall back to the default locale value if the requested locale field is NULL. Uses COALESCE in SQL. |
[jobs]
| Field | Type | Default | Description |
|---|---|---|---|
max_concurrent | integer | 10 | Maximum concurrent job executions across all queues. |
poll_interval | integer/string | 1 ("1s") | How often to poll for pending jobs. Accepts seconds or human-readable. |
cron_interval | integer/string | 60 ("1m") | How often to evaluate cron schedules. Accepts seconds or human-readable. |
heartbeat_interval | integer/string | 10 ("10s") | How often running jobs update their heartbeat. Used to detect stale jobs. Accepts seconds or human-readable. |
auto_purge | integer/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_size | integer | 10 | Number of pending image format conversions to claim per scheduler poll cycle. Increase for higher throughput on capable hardware. |
[access]
| Field | Type | Default | Description |
|---|---|---|---|
default_deny | boolean | false | When 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]
| Field | Type | Default | Description |
|---|---|---|---|
allowed_origins | string[] | [] (empty) | Origins allowed to make cross-origin requests. Empty = CORS disabled (no layer added, default). Use ["*"] to allow any origin. |
allowed_methods | string[] | ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"] | HTTP methods allowed in CORS preflight. |
allowed_headers | string[] | ["Content-Type", "Authorization"] | Request headers allowed in CORS requests. |
exposed_headers | string[] | [] (empty) | Response headers the browser is allowed to access. |
max_age | integer/string | 3600 ("1h") | How long browsers may cache preflight results. Accepts seconds or human-readable. |
allow_credentials | boolean | false | Allow 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]
| Field | Type | Default | Description |
|---|---|---|---|
enabled | boolean | false | Enable the MCP (Model Context Protocol) server. Required for both stdio and HTTP transports. |
http | boolean | false | Mount POST /mcp on the admin server for HTTP-based MCP access. |
config_tools | boolean | false | Enable config generation tools (read_config_file, write_config_file, list_config_files). Opt-in because they allow filesystem writes. |
api_key | string | "" (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_collections | string[] | [] (empty) | Only expose these collections via MCP. Empty = all collections. Enforced at both tool listing and execution time. |
exclude_collections | string[] | [] (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]
| Field | Type | Default | Description |
|---|---|---|---|
file | boolean | false | Enable file-based logging. When false (default), logs go to stdout only. Auto-enabled when running with --detach (where stdout is unavailable). |
path | string | "data/logs" | Log directory path. Relative paths are resolved from the config directory. Use an absolute path to log elsewhere. |
rotation | string | "daily" | Log rotation strategy: "daily" (one file per day), "hourly" (one file per hour), or "never" (single file, no rotation). |
max_files | integer | 30 | Maximum 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
| Field | Default | Description |
|---|---|---|
default_locale | "en" | Default locale code. Content without an explicit locale uses this. |
locales | [] | Supported locale codes. Empty = localization disabled. |
fallback | true | Fall 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
titlewith locales["en", "de"]becomes columnstitle__enandtitle__de - Non-localized fields keep their single column
requiredis only enforced on the default locale column (title__en)uniquechecks the locale-specific column being written to (e.g., writing locale"de"checkstitle__de)- Junction tables (arrays, blocks, has-many) get a
_localecolumn
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,
})
| Scenario | Result |
|---|---|
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 parameter | Checks 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 Parameter | Behavior |
|---|---|
| Omitted | Returns 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 emptylocales= feature completely disabled - No
localized = trueon 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
| Flag | Description |
|---|---|
-C, --config <PATH> | Path to the config directory (overrides auto-detection) |
-V, --version | Print version and exit |
-h, --help | Print help |
Config Directory Resolution
Most commands need a config directory (the folder containing crap.toml). The CLI resolves it in this order:
--config/-Cflag — explicit path, highest priorityCRAP_CONFIG_DIRenvironment variable — useful for CI/Docker- 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]
| Flag | Description |
|---|---|
-d, --detach | Run in the background (prints PID and exits) |
--stop | Stop a running detached instance (SIGTERM, then SIGKILL after 10s) |
--restart | Restart a running detached instance (stop + start) |
--status | Show whether a detached instance is running (PID, uptime) |
--json | Output logs as structured JSON (for log aggregation) |
--only <admin|api> | Start only the specified server. Omit to start both. |
--no-scheduler | Disable 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>]...
| Flag | Short | Default | Description |
|---|---|---|---|
--collection | -c | users | Auth collection slug |
--email | -e | — | User email (prompted if omitted) |
--password | -p | — | User password (prompted if omitted) |
--field | -f | — | Extra 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]
| Flag | Short | Description |
|---|---|---|
--collection | -c | Auth collection slug (default: users) |
--email | -e | User email |
--id | — | User ID |
--confirm | -y | Skip 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:
| Prompt | Default | Description |
|---|---|---|
| Admin port | 3000 | Port for the admin UI |
| gRPC port | 50051 | Port for the gRPC API |
| Enable localization? | No | If yes, prompts for default locale and additional locales |
| Default locale | en | Default locale code (only if localization enabled) |
| Additional locales | — | Comma-separated (e.g., de,fr) |
| Create auth collection? | Yes | Creates a users collection with email/password login |
| Create first admin user? | Yes | Prompts for email and password immediately |
| Create upload collection? | Yes | Creates a media collection for file/image uploads |
| Create another collection? | No | Repeat 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]
| Flag | Short | Description |
|---|---|---|
--fields | -F | Inline field shorthand (see below) |
--no-timestamps | -T | Set timestamps = false |
--auth | — | Enable auth (email/password login) |
--upload | — | Enable uploads (file upload collection) |
--versions | — | Enable versioning (draft/publish workflow) |
--no-input | — | Non-interactive mode — skip all prompts, use flags and defaults only |
--force | -f | Overwrite 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:
| Modifier | Description |
|---|---|
required | Field is required |
localized | Field 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]
| Flag | Short | Description |
|---|---|---|
--fields | -F | Inline field shorthand (same syntax as make collection) |
--force | -f | Overwrite 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]
| Flag | Short | Description |
|---|---|---|
--type | -t | Hook type: collection, field, access, or condition |
--collection | -c | Target collection or global slug |
--position | -l | Lifecycle position (e.g., before_change, after_read) |
--field | -F | Target field name (field hooks only; watched field for condition hooks) |
--force | — | Overwrite 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:
| Type | Positions |
|---|---|
collection | before_validate, before_change, after_change, before_read, after_read, before_delete, after_delete, before_broadcast |
field | before_validate, before_change, after_change, after_read |
access | read, create, update, delete |
condition | table, 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]
| Flag | Short | Default | Description |
|---|---|---|---|
--schedule | -s | — | Cron expression (e.g., "0 3 * * *") |
--queue | -q | default | Queue name |
--retries | -r | 0 | Max retry attempts |
--timeout | -t | 60 | Timeout in seconds |
--force | -f | — | Overwrite 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]
| Flag | Description |
|---|---|
--confirm | Actually 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>]
| Flag | Short | Description |
|---|---|---|
--collection | -c | Export only this collection (default: all) |
--output | -o | Output 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>]
| Flag | Short | Description |
|---|---|---|
--collection | -c | Import 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>]
| Flag | Short | Default | Description |
|---|---|---|---|
--lang | -l | lua | Output 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>
| Subcommand | Description |
|---|---|
create <NAME> | Generate a new migration file (e.g., backfill_slugs) |
up | Sync schema + run pending migrations |
down [-s|--steps N] | Roll back last N migrations (default: 1) |
list | Show 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]
| Flag | Short | Description |
|---|---|---|
--output | -o | Output directory (default: <config>/backups/) |
--include-uploads | -i | Also compress the uploads directory |
crap-cms backup
crap-cms backup -o /tmp/backups -i
restore — Restore from backup
crap-cms restore <BACKUP> [-i] [-y]
| Flag | Short | Description |
|---|---|---|
--include-uploads | -i | Also restore uploads from uploads.tar.gz if present |
--confirm | -y | Required — 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]
| Flag | Short | Description |
|---|---|---|
--type | -t | Filter: templates or static (default: both) |
--verbose | -v | Show 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]
| Flag | Short | Description |
|---|---|---|
--all | -a | Extract all files |
--type | -t | Filter: templates or static (only with --all) |
--force | -f | Overwrite 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>]
| Flag | Short | Default | Description |
|---|---|---|---|
--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>]
| Flag | Short | Default | Description |
|---|---|---|---|
--id | — | — | Show details for a specific run |
--slug | -s | — | Filter by job slug |
--limit | -l | 20 | Max 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>]
| Flag | Default | Description |
|---|---|---|
--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>]
| Flag | Default | Description |
|---|---|---|
--older-than | 7d | Delete 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>]
| Flag | Short | Default | Description |
|---|---|---|---|
--status | -s | — | Filter by status: pending, processing, completed, failed |
--limit | -l | 20 | Max 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]
| Flag | Short | Description |
|---|---|---|
--id | — | Retry a specific failed entry by ID |
--all | — | Retry all failed entries |
--confirm | -y | Required with --all |
images purge
crap-cms images purge [--older-than <DURATION>]
| Flag | Default | Description |
|---|---|---|
--older-than | 7d | Delete 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).
| Flag | Description |
|---|---|
-f, --follow | Follow log output in real time (like tail -f) |
-n, --lines <N> | Number of lines to show (default: 100) |
Subcommands:
| Subcommand | Description |
|---|---|
clear | Remove 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
| Variable | Description |
|---|---|
CRAP_CONFIG_DIR | Path to the config directory (same as --config flag; flag takes priority) |
RUST_LOG | Controls log verbosity. Default: crap_cms=debug,info for serve, crap_cms=error for all other commands. Example: RUST_LOG=crap_cms=trace |
CRAP_LOG_FORMAT | Set 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
slugbecomes 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):
| Field | Type | Description |
|---|---|---|
id | TEXT PRIMARY KEY | Auto-generated nanoid |
created_at | TEXT | ISO 8601 timestamp (if timestamps = true) |
updated_at | TEXT | ISO 8601 timestamp (if timestamps = true) |
Auth collections also get a hidden _password_hash TEXT column.
Versioned collections with drafts = true also get:
| Field | Type | Description |
|---|---|---|
_status | TEXT | "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
| Property | Type | Default | Description |
|---|---|---|---|
labels | table | {} | Display names for the admin UI |
labels.singular | string | slug | Singular name (e.g., “Post”) |
labels.plural | string | slug | Plural name (e.g., “Posts”) |
timestamps | boolean | true | Auto-manage created_at and updated_at |
fields | FieldDefinition[] | {} | Field definitions (see Fields) |
admin | table | {} | Admin UI options |
hooks | table | {} | Lifecycle hook references |
auth | boolean or table | nil | Authentication config (see Auth Collections) |
upload | boolean or table | nil | Upload config (see Uploads) |
access | table | {} | Access control function refs |
versions | boolean or table | nil | Versioning and drafts config (see Versions & Drafts) |
soft_delete | boolean | false | Enable soft deletes (see Soft Deletes) |
soft_delete_retention | string | nil | Auto-purge retention period (e.g., "30d"). Requires soft_delete = true. |
live | boolean or string | nil | Live update broadcasting (see Live Updates) |
mcp | table | {} | MCP tool config. { description = "..." } for MCP tool descriptions. |
indexes | IndexDefinition[] | {} | Compound indexes (see Indexes below) |
admin
| Property | Type | Default | Description |
|---|---|---|---|
use_as_title | string | nil | Field name to display as the row label in admin lists |
default_sort | string | nil | Default sort field. Prefix with - for descending (e.g., "-created_at") |
hidden | boolean | false | Hide this collection from the admin sidebar |
list_searchable_fields | string[] | {} | Fields to search when using the admin list search bar |
hooks
All hook values are arrays of string references in module.function format.
| Property | Type | Description |
|---|---|---|
before_validate | string[] | Runs before field validation. Has CRUD access. |
before_change | string[] | Runs after validation, before write. Has CRUD access. |
after_change | string[] | Runs after create/update (inside transaction). Has CRUD access. Errors roll back. |
before_read | string[] | Runs before returning read results. No CRUD access. |
after_read | string[] | Runs after read, before response. No CRUD access. |
before_delete | string[] | Runs before delete. Has CRUD access. |
after_delete | string[] | Runs after delete (inside transaction). Has CRUD access. Errors roll back. |
before_broadcast | string[] | 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
| Property | Type | Description |
|---|---|---|
read | string | Lua function ref for read access. |
create | string | Lua function ref for create access. |
update | string | Lua function ref for update access. |
delete | string | Lua 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,
}
| Property | Type | Default | Description |
|---|---|---|---|
drafts | boolean | true | Enable draft/publish workflow with _status field |
max_versions | integer | 0 | Max 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 },
}
| Property | Type | Default | Description |
|---|---|---|---|
fields | string[] | required | Column names to include in the index. |
unique | boolean | false | Create 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
| Property | Type | Default | Description |
|---|---|---|---|
drafts | boolean | true | Enable draft/publish workflow with _status field |
max_versions | integer | 0 | Max 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
| Action | Result |
|---|---|
| Create (publish) | Document inserted with _status = 'published' + version snapshot |
| Create (draft) | Document inserted with _status = 'draft' + version snapshot |
Updating Documents
| Action | Result |
|---|---|
| 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 Call | Default Behavior |
|---|---|
Find | Returns only _status = 'published' documents |
Find with draft = true | Returns all documents (published + draft) |
FindByID | Returns the main table document (published version) |
FindByID with draft = true | Returns 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 State | Primary Button | Secondary Button | Extra |
|---|---|---|---|
| Create (new) | Publish | Save as Draft | |
| Editing (draft) | Publish | Save Draft | |
| Editing (published) | Update | Save Draft | Unpublish |
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_attimestamp 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):
| Action | Permission | Fallback | Description |
|---|---|---|---|
| Move to trash | access.trash | access.update | Soft-delete a document |
| Restore from trash | access.trash | access.update | Un-delete a trashed document |
| Delete permanently | access.delete | (blocks if not set) | Permanently remove a document |
| Empty trash | access.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.deleteis configured) - Empty trash button — permanently removes all trashed documents (only shown when
access.deleteis 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.deletepermission
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.
Factory Functions (Recommended)
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.luafile ships per-type LuaLS classes (e.g.,crap.SelectField,crap.ArrayField). When you usecrap.fields.select({...}), your editor autocompletes only the properties that apply to select fields. With plain tables, the singlecrap.FieldDefinitionclass shows every possible property.
Common Properties
Every field type accepts these properties:
| Property | Type | Default | Description |
|---|---|---|---|
name | string | required | Column name. Must be a valid SQL identifier (alphanumeric + underscore). |
required | boolean | false | Validation: must have a non-empty value on create/update. |
unique | boolean | false | Unique constraint. Checked in the current transaction. For localized fields, enforced per locale. |
index | boolean | false | Create a B-tree index on this column. Skipped when unique = true (already indexed by SQLite). |
localized | boolean | false | Enable per-locale values. Requires localization to be configured. |
validate | string | nil | Lua function ref for custom validation (see below). |
default_value | any | nil | Default value applied on create if no value provided. |
admin | table | {} | Admin UI display options. |
hooks | table | {} | Per-field lifecycle hooks. |
access | table | {} | Per-field access control. |
Supported Types
| Type | SQLite Column | Description |
|---|---|---|
text | TEXT | Single-line string (has_many for tag input) |
number | REAL | Integer or float (has_many for tag input) |
textarea | TEXT | Multi-line text |
richtext | TEXT | Rich text (HTML string) |
select | TEXT | Single value from predefined options |
radio | TEXT | Single value from predefined options (radio button UI) |
checkbox | INTEGER | Boolean (0 or 1) |
date | TEXT | Date/datetime/time/month with picker_appearance control |
email | TEXT | Email address |
json | TEXT | Arbitrary JSON blob |
code | TEXT | Code string with syntax-highlighted editor |
relationship | TEXT (has-one) or join table (has-many) | Reference to one or more collections; supports polymorphic (collection = { "posts", "pages" }) |
array | join table | Repeatable group of sub-fields |
group | prefixed columns | Visual grouping of sub-fields (no extra table) |
upload | TEXT (has-one) or join table (has-many) | File reference to upload collection; supports has_many for multi-file |
blocks | join table | Flexible content blocks with different schemas |
join | (none) | Virtual reverse relationship (read-only, computed at read time) |
admin Properties
| Property | Type | Default | Description |
|---|---|---|---|
label | string | table | nil | UI label (defaults to title-cased field name). Supports localized strings. |
placeholder | string | table | nil | Input placeholder text. Supports localized strings. |
description | string | table | nil | Help text displayed below the input. Supports localized strings. |
hidden | boolean | false | Hide from admin UI forms |
readonly | boolean | false | Display but don’t allow editing |
width | string | nil | Field width: "full" (default), "half", or "third" |
position | string | "main" | Form layout position: "main" or "sidebar" |
condition | string | nil | Lua function ref for conditional visibility (see Conditions) |
step | string | nil | Step attribute for number inputs (e.g., "1", "0.01", "any") |
rows | integer | nil | Visible rows for textarea fields |
collapsed | boolean | true | Default 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:
nilortrue— validfalse— invalid with a generic messagestring— 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:
| Field | Type | Description |
|---|---|---|
collection | string | Collection slug |
field_name | string | Name of the field being validated |
data | table | Full document data |
user | table/nil | Authenticated user document (nil if unauthenticated) |
ui_locale | string/nil | Admin 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_lengthvalidate each individual valuemin_rows/max_rowsvalidate 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
NULLin 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/maxvalidate each individual valuemin_rows/max_rowsvalidate 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
| Option | Type | Default | Description |
|---|---|---|---|
rows | integer | 8 | Number of visible rows |
resizable | boolean | true | Allow 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 | |
|---|---|---|
| Storage | Raw HTML string | ProseMirror doc.toJSON() |
| Round-trip fidelity | Loses some structural info | Lossless |
| Programmatic manipulation | Parse HTML | Walk JSON tree |
| FTS search | Indexed as-is | Plain text extracted automatically |
| API response | HTML string | JSON 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
| Feature | Description |
|---|---|
bold | Bold text (Ctrl+B) |
italic | Italic text (Ctrl+I) |
code | Inline code (Ctrl+`) |
link | Hyperlinks |
heading | H1, H2, H3 headings |
blockquote | Block quotes |
orderedList | Numbered lists |
bulletList | Bullet lists |
codeBlock | Code blocks (```) |
horizontalRule | Horizontal 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
| Option | Type | Description |
|---|---|---|
label | string | Display label (defaults to node name) |
inline | boolean | Inline vs block-level (default: false) |
attrs | table[] | Attribute definitions (see below) |
searchable_attrs | string[] | Attr names included in FTS search index |
render | function | Server-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.
| Type | Admin Input |
|---|---|
text | Text input |
number | Number input |
textarea | Multi-line textarea |
select | Dropdown with options |
radio | Radio button group |
checkbox | Checkbox |
date | Date picker |
email | Email input |
json | Monospace textarea |
code | Monospace 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:
| Feature | Effect |
|---|---|
admin.hidden | Attribute is not rendered in the modal (value preserved) |
admin.readonly | Input is read-only / disabled |
admin.width | CSS width on the field container (e.g. "50%") |
admin.step | step attribute on number inputs (e.g. "0.01") |
admin.rows | Number of rows for textarea/code/json fields |
admin.language | Language label suffix for code fields (e.g. "JSON") |
admin.placeholder | Placeholder text on inputs |
admin.description | Help text below the input |
min / max | Min/max on number inputs |
min_length / max_length | Minlength/maxlength on text/textarea inputs |
min_date / max_date | Min/max on date inputs |
picker_appearance | Date 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:
| Check | Description |
|---|---|
required | Attribute must have a non-empty value |
validate | Custom Lua validation function |
min_length / max_length | Text length bounds |
min / max | Numeric bounds |
min_date / max_date | Date bounds |
| email format | Valid email for email type attrs |
| option validity | Value 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:
| Feature | Reason |
|---|---|
hooks.before_change | No per-attr write lifecycle |
hooks.after_change | No per-attr write lifecycle |
hooks.after_read | No per-attr read lifecycle |
access (read/create/update) | No per-attr access control |
unique | No DB column |
index | No DB column |
localized | Richtext field itself is localized or not |
mcp.description | Not exposed as MCP fields |
has_many | Doesn’t apply to scalar node attrs |
admin.condition | Not 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
FTS search
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:
| Property | Type | Description |
|---|---|---|
label | string | Display text in the admin UI |
value | string | Stored 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:
| Property | Type | Description |
|---|---|---|
label | string | Display text in the admin UI |
value | string | Stored 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
requiredproperty is effectively ignored for checkboxes — an unchecked checkbox is always valid - Default value is
0at 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:
| Value | HTML Input | Storage Format | Example |
|---|---|---|---|
"dayOnly" (default) | <input type="date"> | YYYY-MM-DDT12:00:00.000Z | 2026-01-15T12:00:00.000Z |
"dayAndTime" | <input type="datetime-local"> | YYYY-MM-DDTHH:MM:SS.000Z | 2026-01-15T09:30:00.000Z |
"timeOnly" | <input type="time"> | HH:MM | 14:30 |
"monthOnly" | <input type="month"> | YYYY-MM | 2026-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 asYYYY-MM-DDTHH:MM:SS.000Z - datetime-local (
2026-01-15T09:00) → treated as UTC, formatted asYYYY-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
- Admin UI: A timezone dropdown appears next to the date input. The user selects a timezone and enters a local time.
- 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. - 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:
| Column | Type | Example |
|---|---|---|
start_date | TEXT | 2026-05-02T12:00:00.000Z (UTC) |
start_date_tz | TEXT | America/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
_tzcolumn (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 = trueto an existing field creates the_tzcolumn viaALTER TABLE ADD COLUMNwith NULL default. No data migration needed. - Lua plugins: The
timezoneanddefault_timezoneproperties survive roundtrips throughcrap.collections.config.list()andcrap.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 = truewithdayOnly, 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_appearanceoption controls whether the picker shows date-only or date+time
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
validatefunctions 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
| Property | Type | Default | Description |
|---|---|---|---|
collection | string | string[] | required | Target collection slug, or an array of slugs for polymorphic relationships |
has_many | boolean | false | Use a junction table for many-to-many |
max_depth | integer | nil | Per-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:
| Column | Type | Description |
|---|---|---|
id | TEXT PRIMARY KEY | Nanoid for each row |
parent_id | TEXT NOT NULL | Foreign key to the parent document |
_order | INTEGER NOT NULL | Sort order (0-indexed) |
| sub-fields | varies | One 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
row_labelLua function (if set and returns a non-empty string)label_fieldsub-field value (if set and the field has a value)- Default: field label + row index (e.g., “Slides 0”)
Note:
row_labelis only evaluated server-side. Rows added via JavaScript in the browser fall back tolabel_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_rowsis 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 TEXTseo__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 TEXTlastname 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 TEXTmeta_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
| Feature | Group | Row | Collapsible |
|---|---|---|---|
| Column prefix | group__subfield | none | none |
| API nesting | nested object | flat | flat |
| Admin layout | collapsible fieldset | horizontal row | collapsible section |
| Use case | Namespaced fields | Side-by-side fields | Toggleable 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 TEXTbody TEXTmeta_title TEXTmeta_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 textdescription(optional) — help text shown inside the tab panelfields— 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
| Feature | Group | Row | Collapsible | Tabs |
|---|---|---|---|---|
| Column prefix | group__subfield | none | none | none |
| API nesting | nested object | flat | flat | flat |
| Admin layout | collapsible fieldset | horizontal row | collapsible section | tabbed panels |
| Use case | Namespaced fields | Side-by-side fields | Toggleable sections | Organized 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
| Property | Type | Default | Description |
|---|---|---|---|
admin.language | string | "json" | Language mode for syntax highlighting. |
Supported Languages
| Value | Language |
|---|---|
json | JSON |
javascript or js | JavaScript |
html | HTML |
css | CSS |
python or py | Python |
plain | No 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_lengthandmax_lengthvalidation (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
| Property | Type | Required | Description |
|---|---|---|---|
name | string | yes | Field name (display only, no column created) |
type | "join" | yes | Must be "join" |
collection | string | yes | Target collection slug to query |
on | string | yes | Field 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. Atdepth = 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_tosyntax is deprecated for upload fields too. Userelationship = { 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:
| Column | Type | Description |
|---|---|---|
id | TEXT PRIMARY KEY | Nanoid for each row |
parent_id | TEXT NOT NULL | Foreign key to the parent document |
_order | INTEGER NOT NULL | Sort order (0-indexed) |
_block_type | TEXT NOT NULL | Block type identifier |
data | TEXT NOT NULL | JSON 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:
| Property | Type | Description |
|---|---|---|
type | string | Required. Block type identifier. |
label | string | Display label (defaults to type name). |
label_field | string | Sub-field name to use as row label for this block type. |
group | string | Group name for organizing blocks in the picker dropdown. |
image_url | string | Image URL for icon/thumbnail in the block picker. |
fields | FieldDefinition[] | 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
row_labelLua function (if set and returns a non-empty string)- Per-block
label_fieldon theBlockDefinition - Field-level
admin.label_field(shared across all block types) - Default: block type label + row index (e.g., “Hero Section 0”)
Note:
row_labelis only evaluated server-side. Rows added via JavaScript in the browser fall back tolabel_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_rowsis 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
| Property | Type | Default | Description |
|---|---|---|---|
labels | table | {} | Display names |
labels.singular | string | slug | Singular name (e.g., “Site Settings”) |
labels.plural | string | slug | Plural name |
fields | FieldDefinition[] | {} | Field definitions |
hooks | table | {} | Same lifecycle hooks as collections |
access | table | {} | Same access control as collections |
versions | boolean or table | nil | Versioning config (same as collections) |
live | boolean or string | nil | Live update broadcasting (same as collections) |
mcp | table | {} | 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
| Feature | Collections | Globals |
|---|---|---|
| Documents | Multiple | Exactly one |
| Table name | {slug} | _global_{slug} |
| CRUD operations | find, find_by_id, create, update, delete | get, update |
| Timestamps | Optional (timestamps = true) | Always enabled |
| Auth / Upload | Supported | Not supported |
| Versions | Supported | Supported |
| Live updates | Supported | Supported |
| MCP | Supported | Supported |
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:
| Type | Storage | Tracked |
|---|---|---|
| Has-one relationship | Column on parent table | Yes |
| Has-many relationship | Junction table | Yes |
| Polymorphic (has-one/many) | collection/id format | Yes |
| Localized relationships | Per-locale columns | Yes |
| Upload fields | Same as relationship | Yes |
| Array sub-field refs | Column in array table | Yes |
| Block sub-field refs | JSON in blocks table | Yes |
| Global outgoing refs | Global table columns | Yes |
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
| Depth | Behavior |
|---|---|
0 | IDs only. Has-one = string ID. Has-many = array of string IDs. |
1 | Populate immediate relationships. Replace IDs with full document objects. |
2+ | Recursively populate relationships within populated documents. |
Defaults
| Operation | Default 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:
Findwithdepth >= 1collects all referenced IDs across all returned documents per relationship field and fetches them in a singleIN (...)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:
FindByIDpopulates a single document. Join fields (reverse lookups) also use per-document queries since they require aWHEREclause per parent.
Query Cost
| Scenario | Extra Queries |
|---|---|
depth=0 | 0 |
depth=1, Find returning N docs, M relationship fields | M queries (one batch per field) |
depth=1, FindByID, M relationship fields | M queries |
depth=2, Find, M fields at level 1, K fields at level 2 | M + (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=0for list endpoints.Finddefaults todepth=0for this reason. Fetch related data when displaying a single document instead. - Use
selectto limit populated fields. Non-selected relationship fields are skipped entirely during population. - Set per-field
max_depthon relationship fields that don’t need deep population. - If you need related data in a list, use
depth=1withselectto 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
| Operator | Lua | gRPC (where) | SQL |
|---|---|---|---|
| Equals | status = "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. Usenot_existsfor IS NULL (not{ exists = false }).gRPC shorthand limitation: In Lua, bare values like
{ count = 42 }or{ active = true }are coerced to string equals. The gRPCwhereJSON 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:
selectis 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
selectare 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.
Full-Text Search
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). searchcan be combined withwherefilters, pagination, sorting, and all other query parameters.- Collections without text fields silently ignore the
searchparameter. - The
searchparameter also works withCountto get the total number of matching documents.
Indexed fields are determined by:
admin.list_searchable_fieldsif configured on the collection.- 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)
idcreated_at(if timestamps enabled)updated_at(if timestamps enabled)_status(ifversions.draftsenabled)
Additionally, you can filter on sub-fields using dot notation:
- Group sub-fields:
group_name.sub_field(syntactic sugar forgroup_name__sub_field) - Array sub-fields:
array_name.sub_fieldorarray_name.group.sub_field(group-in-array) - Block sub-fields:
blocks_name.field,blocks_name._block_type, orblocks_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
- Field-level hooks — per-field value transformers. Defined on individual
FieldDefinitionentries. - Collection-level hooks — per-collection lifecycle hooks. Defined on
CollectionDefinitionorGlobalDefinition. - Globally registered hooks — fire for all collections. Registered via
crap.hooks.register()ininit.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 blocking —
crap.http.requestresolves hostnames and rejects private/loopback/link-local IPs unlessallow_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.*orcrap.globals.*to persist to the database.
Lifecycle Events
Nine lifecycle events fire during CRUD operations and admin page rendering.
Event Reference
| Event | Fires On | Mutable Data | CRUD Access | Notes |
|---|---|---|---|---|
before_validate | create, update, update_many | Yes | Yes | Normalize inputs before validation |
before_change | create, update, update_many | Yes | Yes | Transform data after validation passes |
after_change | create, update, update_many | Yes | Yes | Runs inside the transaction. Audit logs, counters, side-effects. Errors roll back the entire operation. |
before_read | find, find_by_id | No | No* | Can abort the read by returning an error |
after_read | find, find_by_id | Yes | No | Transform data before it reaches the client |
before_delete | delete, delete_many | No | Yes | Can abort the delete. CRUD access for cascading deletes. |
after_delete | delete, delete_many | No | Yes | Runs inside the transaction. Cleanup, cascading deletes. Errors roll back the entire operation. |
before_broadcast | create, update, delete | Yes (data) | No | Can suppress or transform live update events. See Live Updates. |
before_render | admin page render | Yes (context) | No | Runs 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
Updateinstead. - Hook-modified data is captured and written (hooks can transform the data).
- Set
hooks = falseto 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:
hooks.fields.uppercase(field: title)hooks.fields.normalize_slug(field: slug)hooks.posts.set_defaults(collection)hooks.posts.validate_business_rules(collection)- 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
| Parameter | Type | Description |
|---|---|---|
value | any | Current field value |
context | table | See context fields below |
Return value: The new field value. This replaces the existing value in the data.
Context Table
| Field | Type | Description |
|---|---|---|
field_name | string | Name of the field being processed |
collection | string | Collection slug |
operation | string | "create", "update", "find", "find_by_id" |
data | table | Full document data (read-only snapshot) |
user | table/nil | Authenticated user document (nil if unauthenticated) |
ui_locale | string/nil | Admin 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.Postshasdata: crap.data.Posts - Globals:
crap.field_hook.global_{slug}— e.g.,crap.field_hook.global_site_settingshasdata: 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
| Event | CRUD Access | Use Case |
|---|---|---|
before_validate | Yes | Normalize values before validation (trim, lowercase, etc.) |
before_change | Yes | Transform values after validation (compute derived fields) |
after_change | Yes | Side effects after write with CRUD access (logging, cascades) |
after_read | No | Transform 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.
| Parameter | Type | Description |
|---|---|---|
event | string | One of the lifecycle events |
fn | function | Hook 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 viarun_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.Postshasdata: crap.data.Postsandcollection: "posts"(literal) - Globals:
crap.hook.global_{slug}— e.g.,crap.hook.global_site_settingshasdata: 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
datakey — the data is replaced - A table without a
datakey — the original data is kept - A non-table value — the original context is kept
System Fields in Data
| Field | Present When | Description |
|---|---|---|
id | update, delete, read | Document ID |
created_at | read, update | ISO 8601 timestamp |
updated_at | read, update | ISO 8601 timestamp |
user | write hooks, after_read (nil if unauthenticated) | Authenticated user document |
ui_locale | write 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:
| Value | Meaning |
|---|---|
true | This is a draft save (required field validation is skipped) |
false | This is a publish save (full validation applied) |
nil | Collection 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:
| Value | Meaning |
|---|---|
0 | Top-level call from gRPC API or admin UI |
1 | Called 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_validatetobefore_changewithout 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
contexttable is not the same as module-level variables. Module-level variables persist across requests on the same VM (see Hooks Overview), whilecontextis 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?
| Event | CRUD Access | Reason |
|---|---|---|
before_validate | Yes | Runs inside the write transaction |
before_change | Yes | Runs inside the write transaction |
after_change | Yes | Runs inside the write transaction, after the DB operation |
before_read | No | Read operations don’t open a write transaction |
after_read | No | Fire-and-forget, no transaction |
before_delete | Yes | Runs inside the delete transaction |
after_delete | Yes | Runs 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
| Field | Type | Default | Description |
|---|---|---|---|
handler | string | (required) | Lua function ref (e.g., "jobs.cleanup.run") |
schedule | string | nil | Cron expression for automatic scheduling |
queue | string | "default" | Queue name for grouping |
retries | integer | 0 | Max retry attempts on failure |
timeout | integer | 60 | Seconds before job is marked failed |
concurrency | integer | 1 | Max concurrent runs of this job |
skip_if_running | boolean | true | Skip cron trigger if previous run still active |
labels | table | nil | Display labels ({ singular = "..." }) |
access | string | nil | Lua 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_concurrentincrap.toml(default: 10) - Per-job concurrency:
concurrencyfield on the definition - Timeout: Jobs running longer than
timeoutare 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 jobsTriggerJob(slug, data_json?)— queue a job, returns the run IDGetJobRun(id)— get details of a specific runListJobRuns(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
- Collection and global definition files (
collections/*.lua,globals/*.lua) are auto-loaded first. init.luaruns after all definitions are registered.- Plugins are
require()-d frominit.luaand 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
| Function | Description |
|---|---|
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_sessioncookie includes theSecureflag in production (whendev_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.accesscan further restrict which authenticated users can access the admin panel.
Quick Setup
- 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" },
}}),
},
})
- Bootstrap the first user:
crap-cms -C ./my-project user create -e admin@example.com
- (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)
- (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"
- (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:
- User clicks “Forgot password?” and enters their email
- Server generates a single-use reset token (nanoid, stored in DB with 1-hour expiry). Forgot-password rate limiting applies — after
max_forgot_password_attemptsrequests withinforgot_password_window_seconds, further requests are silently accepted without sending email. - Reset email is sent with a link to
/admin/reset-password?token=xxx - User sets a new password via the form
- 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:
- A verification email is automatically sent when a user is created (admin UI or gRPC)
- The email contains a link to
/admin/verify-email?token=xxx - Verification tokens expire after 24 hours
- Until verified, the user cannot log in (returns “Please verify your email” error)
- 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
| Property | Type | Default | Description |
|---|---|---|---|
token_expiry | integer | 7200 | JWT token lifetime in seconds. Overrides the global [auth] token_expiry. |
disable_local | boolean | false | When true, the password login form is hidden. Only custom strategies can authenticate. |
verify_email | boolean | false | When true, new users must verify their email before logging in. Requires email configuration. |
forgot_password | boolean | true | When true, enables the “Forgot password?” flow. Requires email configuration. |
strategies | AuthStrategy[] | {} | 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:
- Extracted from the data before hooks run
- Hashed with Argon2id after the document is written
- Stored in the
_password_hashcolumn
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
_lockedafter resolving the user from the token. A locked user’s token is effectively revoked, even if it hasn’t expired. MeRPC — returns anunauthenticatederror 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:
| Claim | Description |
|---|---|
sub | User document ID |
collection | Auth collection slug (e.g., “users”) |
email | User email |
exp | Expiration timestamp (Unix) |
iat | Issued-at timestamp (Unix) |
Login Flow
Admin UI Flow
- User visits any
/admin/*route - Gate 1:
require_authcheck — if no auth collections exist andrequire_authistrue(default), returns a “Setup Required” page (HTTP 503). Setrequire_auth = falsein[admin]for open dev mode. - Auth middleware checks for
crap_sessionHttpOnly cookie (includesSecureflag whendev_mode = false) - If no valid cookie, tries custom auth strategies, then redirects to
/admin/login - Gate 2:
admin.accesscheck — if anaccessLua function is configured in[admin], it runs after successful authentication. If the function returnsfalse/nil, the user sees an “Access Denied” page (HTTP 403) with a logout button. - User submits email + password (protected by CSRF double-submit cookie)
- Server checks rate limiting — too many failed attempts for this email triggers a temporary lockout
- Server verifies credentials against the auth collection (constant-time, even for non-existent users)
- On success: clears rate limit counter, sets
crap_sessioncookie with JWT, redirects to/admin - 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:
- JWT cookie check (fast path)
- Custom strategies in definition order
- 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 forlogin_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_csrfcookie (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-Tokenheader (used by HTMX requests)_csrfform 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
- User clicks “Forgot password?” on the login page
- Enters their email address and selects the auth collection
- Server generates a nanoid reset token with 1-hour expiry
- Reset email is sent with a link to
/admin/reset-password?token=xxx - User clicks the link, enters a new password
- 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
- User is created (via admin form or gRPC)
- Verification email is sent automatically with a link to
/admin/verify-email?token=xxx - Verification tokens expire after 24 hours
- User clicks the verification link (expired tokens show an error)
- 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
| Property | Type | Description |
|---|---|---|
name | string | Strategy name for logging and identification |
authenticate | string | Lua 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
| Field | Type | Description |
|---|---|---|
headers | table | HTTP request headers (lowercase keys, string values) |
collection | string | Auth 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:
- JWT cookie check (fast path — always runs first)
- Custom strategies in definition order
- 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
LogingRPC 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
| Flag | Short | Description |
|---|---|---|
--collection | -c | Auth collection to create the user in (default: users) |
--email | -e | User email (prompted if omitted) |
--password | -p | User password (prompted if omitted) |
--field | -f | Extra 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— allowedfalseornil— 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
- Admin panel-level —
admin.accessincrap.toml. A Lua function that gates access to the entire admin UI, checked after login. See Admin UI. - Collection-level — controls who can read, create, update, or delete documents in a collection. See Collection-Level.
- 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
| Field | Type | Present When | Description |
|---|---|---|---|
user | table or nil | Always | Full user document from the auth collection. nil if no auth or anonymous. |
id | string or nil | update, delete, find_by_id | Document ID |
data | table or nil | create, update | Incoming 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, passoverrideAccess = trueto 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).
| Property | Controls | Fallback |
|---|---|---|
read | Find and FindByID operations | — |
create | Create operation | — |
update | Update operation | — |
trash | Soft-delete (move to trash) and restore. Only relevant when soft_delete = true. | update |
delete | Permanent deletion, empty trash. For collections without soft_delete, this is the only delete permission. | — |
Note: When
soft_delete = true,trashanddeleteare separate permissions.trashcontrols the reversible action (low privilege),deletecontrols the destructive action (high privilege). Iftrashis not set, it falls back toupdate. Ifdeleteis 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 Value | Effect |
|---|---|
true | Operation is allowed |
false or nil | Operation is denied (403/permission error) |
| table | Read 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",
},
-- ...
})
| Property | Controls |
|---|---|
read | Whether the field appears in API responses |
create | Whether the field can be set on create |
update | Whether 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
| Property | Type | Default | Description |
|---|---|---|---|
mime_types | string[] | {} (any) | MIME type allowlist. Supports glob patterns ("image/*"). Empty = allow all. |
max_file_size | integer/string | global default | Max file size. Accepts bytes (integer) or human-readable ("10MB", "1GB"). Overrides [upload] max_file_size in crap.toml. |
image_sizes | ImageSize[] | {} | Resize definitions for image uploads. See Image Processing. |
admin_thumbnail | string | nil | Name of an image_sizes entry to use as thumbnail in admin lists. |
format_options | table | {} | Auto-generate format variants. See Image Processing. |
Auto-Injected Fields
When uploads are enabled, these fields are automatically injected before your custom fields:
| Field | Type | Hidden | Description |
|---|---|---|---|
filename | text | No (readonly) | Sanitized filename with unique prefix |
mime_type | text | Yes | MIME type of the uploaded file |
filesize | number | Yes | File size in bytes |
width | number | Yes | Image width (images only) |
height | number | Yes | Image height (images only) |
url | text | Yes | URL path to the original file |
focal_x | number | Yes | Focal point X coordinate (0.0–1.0, default center) |
focal_y | number | Yes | Focal point Y coordinate (0.0–1.0, default center) |
For each image size, additional fields are injected:
| Field Pattern | Type | Description |
|---|---|---|
{size}_url | text | URL to the resized image |
{size}_width | number | Actual width after resize |
{size}_height | number | Actual height after resize |
{size}_webp_url | text | URL to WebP variant (if enabled) |
{size}_avif_url | text | URL 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
| Pattern | Matches |
|---|---|
"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:
- AVIF — served if the client sends
Accept: image/avifand a.avifvariant exists - WebP — served if the client sends
Accept: image/webpand a.webpvariant exists - 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
| Property | Type | Default | Description |
|---|---|---|---|
name | string | required | Size identifier. Used in URLs and field names. |
width | integer | required | Target width in pixels |
height | integer | required | Target height in pixels |
fit | string | "cover" | Resize fit mode |
Fit Modes
| Mode | Behavior |
|---|---|
cover | Resize to fill the target dimensions, then center-crop. No empty space. Aspect ratio preserved. |
contain | Resize to fit within the target dimensions. May be smaller than target. Aspect ratio preserved. |
inside | Same as contain — resize to fit within bounds, preserving aspect ratio. |
fill | Stretch 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
}
| Format | Quality Range | Notes |
|---|---|---|
webp | 1-100 | Lossy WebP via libwebp |
avif | 1-100 | AVIF 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:
- The upload completes immediately without generating that format variant
- A queue entry is inserted into the
_crap_image_queuetable - The scheduler picks up pending entries and processes them in the background
- 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:
- Original — saved as-is to
uploads/<collection>/<id>_<filename> - Image dimensions — read from the decoded image
- Per-size variants — resized according to fit mode, saved in the original format
- 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
| Method | Route | Description |
|---|---|---|
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:
- Upload the file — POST a multipart form to create a document in the upload collection
- 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
| Field | Type | Description |
|---|---|---|
_file | file | The file to upload (required) |
| Any other field | text | Custom 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"
}
| Status | Cause |
|---|---|
400 | Bad request (no file, invalid MIME type, file too large, validation error) |
403 | Access denied (missing or invalid token, access control denied) |
404 | Collection or document not found |
500 | Server error |
Server Processing
When the server receives an upload:
- Validates the MIME type against the collection’s
mime_typesallowlist - Checks file size against
max_file_size - Sanitizes the filename (lowercase, hyphens, unique prefix)
- Saves the original file to
uploads/{collection}/{nanoid}_{filename}(a random 10-character nanoid prefix, not the document ID) - Resizes images according to
image_sizes(if configured) - Generates WebP/AVIF variants (if
format_optionsconfigured) - Runs before-hooks within a transaction
- Creates a document with all metadata fields populated
- 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
| Access | Cache-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
| Namespace | Description |
|---|---|
crap.collections | Collection definition and CRUD operations |
crap.globals | Global definition and get/update operations |
crap.fields | Field factory functions (crap.fields.text(), etc.) |
crap.hooks | Global hook registration |
crap.jobs | Job definition |
crap.log | Structured logging |
crap.util | Utility functions |
crap.auth | Password hashing and verification (Argon2id) |
crap.env | Read-only environment variable access |
crap.http | Outbound HTTP requests (blocking) |
crap.config | Read-only access to crap.toml values |
crap.locale | Locale configuration queries |
crap.email | Send email via configured SMTP |
crap.crypto | Cryptographic utilities (HMAC, random bytes, hashing) |
crap.schema | Runtime schema introspection |
crap.richtext | Custom 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_validatehooks — Yesbefore_changehooks — Yesbefore_deletehooks — Yesafter_changehooks — Yes (runs inside the same transaction viarun_hooks_with_conn)after_deletehooks — Yes (runs inside the same transaction viarun_hooks_with_conn)after_readhooks — No (no transaction)before_readhooks — 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:
- Startup VM — a single VM that loads collection/global definitions and runs
init.lua. Used only during initialization, then discarded. - 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 thecrap.*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
| Field | Type | Default | Description |
|---|---|---|---|
where | table | {} | Field filters. See Filter Operators. Supports ["or"] key for OR groups. |
order_by | string | nil | Sort field. Prefix with - for descending. |
limit | integer | nil | Max results to return. |
page | integer | 1 | Page number (1-based). Converted to offset internally. |
offset | integer | nil | Number of results to skip (backward compat alias for page). |
after_cursor | string | nil | Forward 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_cursor | string | nil | Backward 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. |
depth | integer | 0 | Population depth for relationship fields. |
select | string[] | nil | Fields to return. nil = all fields. Always includes id. When specified, created_at and updated_at are only included if explicitly listed. |
draft | boolean | false | Include draft documents. Only affects versioned collections with drafts = true. |
locale | string | nil | Locale code for localized fields (e.g., "en", "de"). |
overrideAccess | boolean | false | Bypass access control checks. Set to true to skip collection-level and field-level access for the current user. |
search | string | nil | FTS5 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
| Field | Type | Default | Description |
|---|---|---|---|
depth | integer | 0 | Population depth for relationship fields. |
select | string[] | nil | Fields to return. nil = all fields. Always includes id. |
draft | boolean | false | Return the latest draft version snapshot instead of the published main-table data. Only affects versioned collections with drafts = true. |
locale | string | nil | Locale code for localized fields (e.g., "en", "de"). |
overrideAccess | boolean | false | Bypass 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
| Field | Type | Default | Description |
|---|---|---|---|
locale | string | nil | Locale code for localized fields. |
draft | boolean | false | Create as draft. Skips required field validation. Only affects versioned collections with drafts = true. |
overrideAccess | boolean | false | Bypass access control checks. Set to true to skip collection-level and field-level access for the current user. |
hooks | boolean | true | Run 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
| Field | Type | Default | Description |
|---|---|---|---|
locale | string | nil | Locale code for localized fields. |
draft | boolean | false | Version-only save. Creates a draft version snapshot without modifying the main table. Only affects versioned collections with drafts = true. |
unpublish | boolean | false | Set document status to draft and create a draft version snapshot. Ignores the data fields when unpublishing. Only affects versioned collections. |
overrideAccess | boolean | false | Bypass access control checks. Set to true to skip collection-level and field-level access for the current user. |
hooks | boolean | true | Run 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
passwordkey, it is extracted before hooks run, hashed with Argon2id, and stored in the hidden_password_hashcolumn. Hooks never see the raw password. - On update, same pattern — if
passwordis 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
| Field | Type | Default | Description |
|---|---|---|---|
overrideAccess | boolean | false | Bypass access control checks. Set to true to skip access.trash (soft delete) or access.delete (permanent delete) checks. |
hooks | boolean | true | Run lifecycle hooks. Set to false to skip before_delete and after_delete hooks. |
forceHardDelete | boolean | false | Permanently 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_changeupdate: before_validate → validate → before_change → DB update → after_changeupdate_many: per-document: before_validate → validate → before_change → DB update → after_changedelete: before_delete → DB delete → upload file cleanup → after_deletedelete_many: per-document: before_delete → DB delete → upload file cleanup → after_deletefind/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 incrap.toml), hooks are automatically skipped but the DB operation still executes - Use
ctx.hook_depthin 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
truetofalse. If you have hooks that call CRUD functions without specifyingoverrideAccess, they now enforce access control. AddoverrideAccess = trueto 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. Forcreate/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
| Field | Type | Default | Description |
|---|---|---|---|
where | table | {} | Field filters. Same syntax as find. |
locale | string | nil | Locale code for localized fields. |
overrideAccess | boolean | false | Bypass access control checks. |
draft | boolean | false | Include draft documents. |
search | string | nil | FTS5 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)
| Field | Type | Default | Description |
|---|---|---|---|
where | table | {} | Field filters to match documents. |
Options (4th argument)
| Field | Type | Default | Description |
|---|---|---|---|
locale | string | nil | Locale code for localized fields. |
overrideAccess | boolean | false | Bypass access control checks. |
draft | boolean | false | Include draft documents. |
hooks | boolean | true | Run 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)
| Field | Type | Default | Description |
|---|---|---|---|
where | table | {} | Field filters to match documents. |
Options (3rd argument)
| Field | Type | Default | Description |
|---|---|---|---|
overrideAccess | boolean | false | Bypass access control checks. |
hooks | boolean | true | Run per-document lifecycle hooks. Set to false to skip before_delete and after_delete hooks. |
locale | string | nil | Locale code for localized fields. |
draft | boolean | false | Include 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
| Parameter | Type | Description |
|---|---|---|
slug | string | Global slug |
opts | table (optional) | Options table |
Options
| Key | Type | Description |
|---|---|---|
locale | string | Locale 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
| Parameter | Type | Description |
|---|---|---|
slug | string | Global slug |
data | table | Fields to update |
opts | table (optional) | Options table |
Options
| Key | Type | Description |
|---|---|---|
locale | string | Locale 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
| Parameter | Type | Description |
|---|---|---|
event | string | Lifecycle event name |
fn | function | Hook function receiving a context table |
Events
| Event | Description |
|---|---|
before_validate | Before field validation on create/update |
before_change | After validation, before write on create/update |
after_change | After create/update (runs in transaction, has CRUD access) |
before_read | Before returning read results |
after_read | After read, before response (no CRUD access) |
before_delete | Before delete |
after_delete | After delete (runs in transaction, has CRUD access) |
before_broadcast | Before live event broadcast (can suppress or transform) |
before_render | Before 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
| Parameter | Type | Description |
|---|---|---|
event | string | Lifecycle event name |
fn | function | The 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
| Parameter | Type | Description |
|---|---|---|
event | string | Lifecycle 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
| Parameter | Type | Description |
|---|---|---|
msg | string | Log 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"}'
| Parameter | Type | Description |
|---|---|---|
value | any | Lua value to encode |
| Returns | string | JSON 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
| Parameter | Type | Description |
|---|---|---|
str | string | JSON string |
| Returns | any | Decoded 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"
| Parameter | Type | Description |
|---|---|---|
str | string | Input string |
| Returns | string | URL-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()andcrap.json.decode()are aliases — see crap.json.
local json = crap.util.json_encode({ name = "test", count = 42 })
-- '{"count":42,"name":"test"}'
| Parameter | Type | Description |
|---|---|---|
value | any | Lua value to encode |
| Returns | string | JSON 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
| Parameter | Type | Description |
|---|---|---|
str | string | JSON string |
| Returns | any | Decoded 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:
| Prefix | Purpose |
|---|---|
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
nilfor 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:
| Key | Type | Default |
|---|---|---|
server.admin_port | integer | 3000 |
server.grpc_port | integer | 50051 |
server.host | string | “0.0.0.0” |
database.path | string | “data/crap.db” |
admin.dev_mode | boolean | false |
auth.secret | string | “” |
auth.token_expiry | integer | 7200 |
depth.default_depth | integer | 1 |
depth.max_depth | integer | 10 |
upload.max_file_size | integer | 52428800 |
hooks.on_init | string[] | [] |
hooks.max_depth | integer | 3 |
hooks.vm_pool_size | integer | (auto) |
hooks.max_instructions | integer | 10000000 |
hooks.max_memory | integer | 52428800 |
hooks.allow_private_networks | boolean | false |
hooks.http_max_response_bytes | integer | 10485760 |
pagination.default_limit | integer | 20 |
pagination.max_limit | integer | 1000 |
pagination.mode | string | “page” |
locale.default_locale | string | “en” |
locale.locales | string[] | [] |
locale.fallback | boolean | true |
email.smtp_host | string | “” |
live.enabled | boolean | true |
access.default_deny | boolean | false |
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.tomlafter startup won’t be reflected until the process restarts. - Available in both init.lua and hooks.
- Returns
nilfor 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:
| Field | Type | Required | Description |
|---|---|---|---|
to | string | yes | Recipient email address |
subject | string | yes | Email subject line |
html | string | yes | HTML email body |
text | string | no | Plain 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.secretincrap.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
| Field | Type | Description |
|---|---|---|
slug | string | Collection slug. |
labels | table | { singular?, plural? } display names. |
timestamps | boolean | Whether created_at/updated_at are enabled. |
has_auth | boolean | Whether authentication is enabled. |
has_upload | boolean | Whether file uploads are enabled. |
has_versions | boolean | Whether versioning is enabled. |
has_drafts | boolean | Whether draft/publish workflow is enabled (versioned + drafts). |
fields | table[] | Array of field definitions (see below). |
Field Schema
Each field table contains:
| Field | Type | Description |
|---|---|---|
name | string | Field name. |
type | string | Field type (text, number, relationship, etc.). |
required | boolean | Whether the field is required. |
localized | boolean | Whether the field has per-locale values. |
unique | boolean | Whether the field has a unique constraint. |
relationship | table? | { collection, has_many } for relationship fields. |
options | table[]? | { label, value } for select fields. |
fields | table[]? | Sub-fields for array/group types (recursive). |
blocks | table[]? | 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:
| Field | Type | Default | Description |
|---|---|---|---|
label | string | name | Display label in the editor toolbar |
inline | boolean | false | Whether this is an inline node (vs block) |
attrs | FieldDefinition[] | {} | Node attributes via crap.fields.* (scalar types only) |
searchable_attrs | string[] | {} | Attribute names included in full-text search |
render | function | nil | Custom 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.luaso they’re available to all VMs. - Custom nodes appear in the rich text editor toolbar for fields that include them.
- The
renderfunction is called duringcrap.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 identifierconfig(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 nameretries(integer, default: 0) — Max retry attemptstimeout(integer, default: 60) — Seconds before timeoutconcurrency(integer, default: 1) — Max concurrent runsskip_if_running(boolean, default: true) — Skip cron if still runninglabels(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
| Operator | Lua Syntax | SQL | Description |
|---|---|---|---|
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 NULL | Field is not null (value is ignored — only the key matters) |
not_exists | { not_exists = true } | field IS NULL | Field is null (value is ignored — use not_exists for IS NULL, not { exists = false }) |
Note:
inis 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:
oris 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:
| Tool | Description |
|---|---|
find_posts | Query documents with filters, ordering, pagination |
find_by_id_posts | Get a single document by ID |
create_posts | Create a new document |
update_posts | Update an existing document |
delete_posts | Delete 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):
| Tool | Description |
|---|---|
global_read_settings | Read the global document |
global_update_settings | Update the global document |
Schema Introspection
Always available:
| Tool | Description |
|---|---|
list_collections | List all collections with their labels and capabilities |
describe_collection | Get full field schema for a collection or global |
list_field_types | List all field types with descriptions and capabilities |
cli_reference | Get CLI command reference (all or specific command) |
Config Generation Tools (opt-in)
When config_tools = true:
| Tool | Description |
|---|---|
read_config_file | Read a file from the config directory |
write_config_file | Write a Lua file to the config directory |
list_config_files | List 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_keysetting. An API key is required whenhttp = 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:
| URI | Description |
|---|---|
crap://schema/collections | Full schema of all collections as JSON |
crap://schema/globals | Full schema of all globals as JSON |
crap://config | Current configuration (secrets sanitized: auth.secret, email.smtp_pass, mcp.api_key) |
Query Parameters
The find_* tools accept these parameters:
| Parameter | Type | Description |
|---|---|---|
where | object | Filter conditions (same syntax as gRPC/Lua API) |
order_by | string | Sort field (prefix with - for descending, e.g., "-created_at") |
limit | integer | Max results per page |
page | integer | Page number, 1-indexed (page mode only) |
after_cursor | string | Forward cursor (cursor mode only, mutually exclusive with page and before_cursor) |
before_cursor | string | Backward cursor (cursor mode only, mutually exclusive with page and after_cursor) |
depth | integer | Relationship population depth |
search | string | Full-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 usesgreater_than_or_equalandless_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
UpdateManycall processes at most 10,000 documents. Use paginated calls (with awhereclause) 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
DeleteManycall processes at most 10,000 documents. Use paginated calls (with awhereclause) 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
| Operator | JSON Syntax | SQL |
|---|---|---|
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 JSONwhereclause (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 forlogin_lockout_seconds(default: 300). Per-IP rate limiting (max_ip_login_attemptsin[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
secretis set incrap.toml, an auto-generated secret is persisted todata/.jwt_secretso tokens survive server restarts. - Account locking — when a user’s
_lockedfield is truthy, all authenticated requests (includingMe) are rejected withunauthenticatedstatus. 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:
| Field | Type | Description |
|---|---|---|
name | string | Column name |
type | string | Field type: text, number, select, relationship, etc. |
required | bool | Whether the field is required |
unique | bool | Whether the field has a uniqueness constraint |
options | SelectOptionInfo[] | Options for select fields (label + value) |
relationship_collection | string? | Target collection slug for relationship fields |
relationship_has_many | bool? | Whether it’s a many-to-many relationship |
relationship_max_depth | int? | Per-field population depth cap |
fields | FieldInfo[] | 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:
| Type | Purpose |
|---|---|
crap.data.Posts | Input fields (for Create/Update data) |
crap.doc.Posts | Full document (fields + id + timestamps) |
crap.hook.Posts | Typed hook context (collection, operation, data) |
crap.find_result.Posts | Find result (documents[] + total) |
crap.filters.Posts | Filter keys for queries |
crap.query.Posts | Query options (filters, order_by, limit, offset) |
crap.hook_fn.Posts | Hook 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:
- Single binary — the proto file is compiled into the binary. Per-collection proto messages would require recompilation when schemas change.
- Lua is the schema source — schemas live in Lua files, not proto definitions. The proto layer is a transport, not a schema system.
- Dynamic schemas — collections can be added, removed, or modified by editing Lua files without touching the binary or proto.
- 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 (
SubscribeRPC) for API consumers - SSE (
GET /admin/events) for the admin UI - Internal bus:
tokio::sync::broadcastchannel
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:
| Field | Description |
|---|---|
sequence | Monotonic sequence number |
timestamp | ISO 8601 timestamp |
target | "collection" or "global" |
operation | "create", "update", or "delete" |
collection | Collection or global slug |
document_id | Document ID |
data | Full 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_broadcasthooks 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
tokenfield (same token asLoginresponse) - Read access is checked at subscribe time for each requested collection/global
- Collections/globals without read access are silently excluded
- Returns
PERMISSION_DENIEDif no collections or globals are accessible - Returns
UNAVAILABLEif 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
EventSourcebehavior) - 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 falseornilto 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
- Collection-level
before_broadcasthooks (string refs from definition) - Global registered
before_broadcasthooks (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:
require_auth(default:true) — when no auth collection exists, the admin shows a “Setup Required” page (HTTP 503) instead of being open. Setrequire_auth = falsein[admin]for fully open dev mode.access(optional Lua function ref) — checked after successful authentication. Gates which authenticated users can access the admin panel. Returntrueto allow,false/nilto 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)
Secureflag 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-Policyheaders
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
| Route | Description |
|---|---|
/health | Liveness check (public) |
/ready | Readiness check (public) |
/admin | Dashboard |
/admin/login | Login page (public) |
/admin/logout | Logout (public) |
/admin/forgot-password | Forgot password page (public) |
/admin/reset-password | Reset password page (public) |
/admin/verify-email | Email verification (public) |
/admin/collections | Collection list |
/admin/collections/{slug} | Collection items list |
/admin/collections/{slug}/create | Create form |
/admin/collections/{slug}/{id} | Edit form |
/admin/collections/{slug}/{id}/delete | Delete confirmation |
/admin/collections/{slug}/{id}/versions | Version history |
/admin/collections/{slug}/{id}/versions/{version_id}/restore | Restore a version |
/admin/collections/{slug}/validate | Inline validation (POST) |
/admin/collections/{slug}/evaluate-conditions | Display condition evaluation (POST) |
/admin/globals/{slug} | Global edit form |
/admin/globals/{slug}/validate | Global inline validation (POST) |
/admin/globals/{slug}/versions | Global version history |
/admin/globals/{slug}/versions/{version_id}/restore | Restore global version |
/admin/events | SSE live update stream |
/admin/api/search/{slug} | Relationship search endpoint |
/admin/api/session-refresh | Session token refresh (POST) |
/admin/api/locale | Save locale preference (POST) |
/admin/api/user-settings/{slug} | Save user settings (POST) |
/api/upload/{slug} | File upload endpoint (POST) |
/mcp | MCP 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
publishedordraftper 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
@importinstyles.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.jsentry 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 forhtmx:afterRequest, readsX-Crap-Toastheader)<crap-confirm>— confirmation dialogs (wraps forms, intercepts submit)<crap-confirm-dialog>— standalone confirm forhx-confirmattributes (replaces nativewindow.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:
- Config directory —
<config_dir>/templates/<name>.hbs - 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.hbsfields/number.hbsfields/textarea.hbsfields/richtext.hbsfields/select.hbsfields/checkbox.hbsfields/date.hbsfields/email.hbsfields/json.hbsfields/relationship.hbsfields/array.hbsfields/blocks.hbsfields/password.hbsfields/radio.hbsfields/code.hbsfields/upload.hbsfields/join.hbsfields/group.hbsfields/collapsible.hbsfields/tabs.hbsfields/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 definitiondocument— document data (edit forms)docs— document list (list views)fields— field definitionsglobal— 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
| Key | Type | Pages | Description |
|---|---|---|---|
crap | object | all | App metadata (version, dev mode, auth status) |
page | object | all | Current page info (title, type, breadcrumbs) |
nav | object | all (except auth) | Navigation data for sidebar |
user | object | authenticated | Current user (email, id, collection) |
collection | object | collection pages | Full collection definition with metadata |
global | object | global pages | Full global definition with metadata |
document | object | edit pages | Current document with raw data |
fields | array | edit/create/global edit | Processed field contexts for main content area |
sidebar_fields | array | edit/create/global edit | Field contexts for sidebar panel (fields with admin.position = "sidebar") |
docs | array | collection items | Document list with enriched data |
editing | boolean | edit/create | true when editing, false when creating |
pagination | object | items, versions | Pagination state |
versions | array | edit (versioned) | Recent version entries |
has_more_versions | boolean | edit (versioned) | Whether more versions exist beyond the shown 3 |
upload | object | upload collections | Upload file metadata and preview |
collection_cards | array | dashboard | Collection summary cards with counts |
global_cards | array | dashboard | Global summary cards |
search | string | items | Current search query |
custom | object | all | Custom 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
| Type | Route |
|---|---|
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) |
nav — Navigation
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:
| Field | Type | Description |
|---|---|---|
slug | string | Collection slug |
display_name | string | Human-readable name |
is_auth | boolean | Whether this is an auth collection |
is_upload | boolean | Whether this is an upload collection |
Each nav global entry:
| Field | Type | Description |
|---|---|---|
slug | string | Global slug |
display_name | string | Human-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}}
| Field | Type | Description |
|---|---|---|
email | string | User’s email address |
id | string | User document ID |
collection | string | Auth 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:
| Field | Type | Description |
|---|---|---|
name | string | Field name |
field_type | string | text, number, select, relationship, etc. |
required | boolean | Whether the field is required |
unique | boolean | Whether the field has a unique constraint |
localized | boolean | Whether the field is localized |
admin.label | string/null | Display label |
admin.hidden | boolean | Whether the field is hidden in admin |
admin.readonly | boolean | Whether the field is readonly |
admin.width | number/null | Column width hint |
admin.description | string/null | Help text |
admin.placeholder | string/null | Placeholder text |
Dashboard
Page type: dashboard
Additional keys:
| Key | Type | Description |
|---|---|---|
collection_cards | array | One entry per collection with document count |
global_cards | array | One 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:
| Key | Type | Description |
|---|---|---|
docs | array | Document list |
search | string/null | Current search query |
pagination | object | Pagination state |
has_drafts | boolean | Shorthand 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:
| Field | Type | Description |
|---|---|---|
id | string | Document ID |
title_value | string | Value of the title field (falls back to filename for uploads, then ID) |
created_at | string/null | Creation timestamp |
updated_at | string/null | Last update timestamp |
thumbnail_url | string/null | Thumbnail 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}}
| Field | Type | Description |
|---|---|---|
page | integer | Current page number (1-based) |
per_page | integer | Items per page |
total | integer | Total document count |
total_pages | integer | Total number of pages |
has_prev | boolean | Whether a previous page exists |
has_next | boolean | Whether a next page exists |
prev_url | string | URL for the previous page |
next_url | string | URL for the next page |
Collection Edit
Page type: collection_edit
Additional keys beyond collection:
| Key | Type | Description |
|---|---|---|
document | object | Current document data |
fields | array | Processed field contexts for form rendering |
editing | boolean | Always true |
has_drafts | boolean | Shorthand for collection.has_drafts |
has_versions | boolean | Shorthand for collection.has_versions |
versions | array | Up to 3 most recent version entries |
has_more_versions | boolean | true if more than 3 versions exist |
upload | object | Upload 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}}
| Field | Type | Description |
|---|---|---|
id | string | Document ID |
created_at | string/null | Creation timestamp |
updated_at | string/null | Last update timestamp |
status | string | "published" or "draft" (when drafts enabled) |
data | object | Raw 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.datacontains 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}}
| Field | Type | Description |
|---|---|---|
accept | string/null | MIME type filter for file input (e.g., "image/*") |
preview | string/null | Preview image URL (images only, uses admin_thumbnail) |
info | object/null | File info for existing uploads |
info.filename | string | Original filename |
info.filesize_display | string | Human-readable file size |
info.dimensions | string/null | Image dimensions (e.g., "1920x1080") |
Collection Create
Page type: collection_create
Same structure as collection edit, with these differences:
editingisfalsedocumentis absentversions,has_more_versionsare absent- Password field is added for auth collections (required)
Collection Delete
Page type: collection_delete
| Key | Type | Description |
|---|---|---|
collection | object | Collection definition |
document_id | string | ID of the document to delete |
title_value | string/null | Display title of the document |
Collection Versions
Page type: collection_versions
Full version history page with pagination.
| Key | Type | Description |
|---|---|---|
collection | object | Collection definition |
document | object | Stub with id only |
doc_title | string | Document title for breadcrumbs |
versions | array | Paginated version entries |
pagination | object | Pagination state |
Collection List
Page type: collection_list
| Key | Type | Description |
|---|---|---|
collections | array | All registered collections |
Each entry: slug, display_name, field_count.
Global Edit
Page type: global_edit
| Key | Type | Description |
|---|---|---|
global | object | Global definition |
fields | array | Processed field contexts (main area) |
sidebar_fields | array | Processed field contexts (sidebar) |
has_drafts | boolean | Whether the global has drafts enabled |
has_versions | boolean | Whether the global has versions enabled |
versions | array | Recent version entries (up to 3) |
has_more_versions | boolean | true if more than 3 versions exist |
restore_url_prefix | string | URL prefix for version restore actions |
versions_url | string | URL to the full versions list page |
doc_status | string | Document status ("published" or "draft") |
global
{{global.slug}}
{{global.display_name}}
{{#each global.fields_meta}}
{{this.name}} — {{this.field_type}}
{{/each}}
| Field | Type | Description |
|---|---|---|
slug | string | Global slug |
display_name | string | Human-readable name |
fields_meta | array | Same structure as collection.fields_meta |
Auth Pages
Auth pages use a minimal context builder (ContextBuilder::auth()) — no nav or user.
Login (auth_login)
| Key | Type | Description |
|---|---|---|
collections | array | Auth collections (slug + display_name) |
show_collection_picker | boolean | true if more than one auth collection |
disable_local | boolean | true if all auth collections disable local login |
show_forgot_password | boolean | true if email is configured and any collection enables forgot password |
error | string/null | Error message (e.g., “Invalid email or password”) |
success | string/null | Success message (e.g., after password reset) |
email | string/null | Pre-filled email (on error re-render) |
Forgot Password (auth_forgot)
| Key | Type | Description |
|---|---|---|
collections | array | Auth collections |
show_collection_picker | boolean | true if more than one auth collection |
success | boolean/null | true after form submission (always, to avoid leaking user existence) |
Reset Password (auth_reset)
| Key | Type | Description |
|---|---|---|
token | string/null | Reset token (if valid) |
error | string/null | Error message (invalid/expired token, validation errors) |
Error Pages
Error pages receive the base context (crap, nav, page) plus:
| Key | Type | Description |
|---|---|---|
message | string | Error 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:
| Key | Type | Description |
|---|---|---|
has_locales | boolean | Always true when locale is enabled |
current_locale | string | Currently selected locale (e.g., "en") |
locales | array | All 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:
| Key | Type | Description |
|---|---|---|
has_editor_locales | boolean | Always true when locale is enabled |
editor_locale | string | Currently selected content locale (e.g., "en") |
editor_locales | array | All 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:
| Field | Type | Description |
|---|---|---|
name | string | Field name (HTML form input name) |
field_type | string | Type identifier (e.g., "text", "select", "blocks") |
label | string | Display label (from admin.label or auto-generated) |
required | boolean | Whether the field is required |
value | string | Current value (stringified) |
placeholder | string/null | Placeholder text |
description | string/null | Help text |
readonly | boolean | Whether the field is readonly |
localized | boolean | Whether the field is localized |
locale_locked | boolean | true when editing a non-default locale and the field is not localized |
error | string/null | Validation error message (on re-render after failed save) |
position | string/null | Field position: "main" (default) or "sidebar". Fields with "sidebar" appear in sidebar_fields instead of fields |
condition_visible | boolean/null | Initial visibility from display condition evaluation. false = hidden on page load |
condition_json | object/null | Client-side condition table (JSON). Present for condition functions that return a table |
condition_ref | string/null | Server-side condition function reference. Present for condition functions that return a boolean |
Select Fields
Additional keys:
| Field | Type | Description |
|---|---|---|
options | array | Available options |
Each option: label, value, selected (boolean).
Checkbox Fields
| Field | Type | Description |
|---|---|---|
checked | boolean | Whether the checkbox is checked |
Date Fields
| Field | Type | Description |
|---|---|---|
picker_appearance | string | "dayOnly", "dayAndTime", "timeOnly", or "monthOnly" |
date_only_value | string | Date portion only, e.g., "2026-01-15" (dayOnly) |
datetime_local_value | string | Date+time, e.g., "2026-01-15T09:00" (dayAndTime) |
Relationship Fields
| Field | Type | Description |
|---|---|---|
relationship_collection | string | Related collection slug |
has_many | boolean | Whether this is a has-many relationship |
relationship_options | array | Available documents from the related collection |
Each option: value (document ID), label (title field value), selected (boolean).
Upload Fields
| Field | Type | Description |
|---|---|---|
relationship_collection | string | Upload collection slug |
relationship_options | array | Available uploads with thumbnail info |
selected_preview_url | string/null | Preview URL of the currently selected upload |
selected_filename | string/null | Filename of the currently selected upload |
Each option: value, label, selected, thumbnail_url (if image), is_image (boolean), filename.
Group Fields
| Field | Type | Description |
|---|---|---|
sub_fields | array | Sub-field contexts (same structure as top-level fields) |
collapsed | boolean | Whether the group starts collapsed |
Sub-field name is formatted as group__subfield (double underscore).
Array Fields
| Field | Type | Description |
|---|---|---|
sub_fields | array | Sub-field definitions (template for new rows) |
rows | array | Existing row data |
row_count | integer | Number of existing rows |
template_id | string | Unique ID for DOM targeting (count badge, row container, templates) |
label_field | string/null | Sub-field name used for dynamic row labels (from admin.label_field) |
max_rows | integer/null | Maximum number of rows allowed (from max_rows on field definition) |
min_rows | integer/null | Minimum number of rows required (from min_rows on field definition) |
init_collapsed | boolean | Whether existing rows render collapsed by default (from admin.collapsed, default: true) |
add_label | string/null | Custom 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
| Field | Type | Description |
|---|---|---|
block_definitions | array | Available block types with their fields |
rows | array | Existing block instances |
row_count | integer | Number of existing blocks |
template_id | string | Unique ID for DOM targeting (count badge, row container, templates) |
label_field | string/null | Field-level admin.label_field (shared fallback for all block types) |
max_rows | integer/null | Maximum number of blocks allowed (from max_rows on field definition) |
min_rows | integer/null | Minimum number of blocks required (from min_rows on field definition) |
init_collapsed | boolean | Whether existing block rows render collapsed by default (from admin.collapsed, default: true) |
add_label | string/null | Custom 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
| Helper | Usage | Description |
|---|---|---|
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
| Helper | Usage | Description |
|---|---|---|
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
| Helper | Usage | Description |
|---|---|---|
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
customkey 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
- Config directory —
<config_dir>/static/is served viatower_http::ServeDir - 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 buildfor 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
| Operator | Example | Description |
|---|---|---|
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
- The server calls the Lua condition function with the current document data
- Based on the return type:
- Table: serialized as a
data-conditionJSON attribute on the field wrapper; initial visibility computed server-side - Boolean: result sets initial visibility; function reference stored as
data-condition-ref
- Table: serialized as a
- Fields with
falseconditions render withdisplay: none(no flash of content)
Client-Side Reactivity (Condition Tables)
When the user changes a form field:
- JavaScript reads the
data-conditionJSON from each conditional field - Evaluates the condition against current form values
- Shows or hides the field instantly
Server-Side Reactivity (Boolean Functions)
When the user changes a form field:
- JavaScript debounces for 300ms
- POSTs current form data to
/admin/collections/{slug}/evaluate-conditions - Server calls each boolean condition function
- Response updates field visibility
Sidebar Fields
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
| Theme | Type | Description |
|---|---|---|
| Light | light | Default theme. Clean blue-accented light design. |
| Rosé Pine Dawn | light | Warm, muted light theme based on the Rosé Pine palette. |
| Tokyo Night | dark | Cool blue-purple dark theme. |
| Catppuccin Mocha | dark | Pastel-accented dark theme from the Catppuccin palette. |
| Gruvbox Dark | dark | Warm 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:
| Variable | Description |
|---|---|
| Colors | |
--color-primary | Primary accent color |
--color-primary-hover | Primary hover state |
--color-primary-active | Primary active/pressed state |
--color-primary-bg | Primary tinted background (low opacity) |
--color-danger | Error/destructive color |
--color-danger-hover | Danger hover state |
--color-danger-active | Danger active state |
--color-danger-bg | Danger tinted background |
--color-success | Success color |
--color-success-bg | Success tinted background |
--color-warning | Warning color |
--color-warning-bg | Warning tinted background |
| Text | |
--text-primary | Primary text |
--text-secondary | Secondary/muted text |
--text-tertiary | Disabled/hint text |
--text-on-primary | Text on primary-colored backgrounds |
| Surfaces | |
--bg-body | Page background |
--bg-surface | Card/panel background |
--bg-elevated | Elevated surface (modals, popovers) |
--bg-hover | Generic hover background |
| Borders | |
--border-color | Default border color |
--border-color-hover | Border hover color |
| Shadows | |
--shadow-sm | Small shadow (cards) |
--shadow-md | Medium shadow (dropdowns) |
--shadow-lg | Large shadow (modals) |
| Inputs | |
--input-bg | Form input background |
--input-border | Form input border |
--select-arrow | Select dropdown arrow (SVG data URL) |
| Layout | |
--header-bg | Header background |
--header-border | Header bottom border |
--sidebar-bg | Sidebar background |
--sidebar-active-bg | Active sidebar item background |
--sidebar-active-text | Active 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 Type | SQLite Type |
|---|---|
| text, textarea, richtext, select, date, email, json | TEXT |
| number | REAL |
| checkbox | INTEGER |
| 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:
- Missing tables — created with all columns
- Missing columns — added via
ALTER TABLE ADD COLUMN - Missing junction tables — created for new has-many/array fields
- Removed columns — logged as warnings (not dropped)
- 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 operations —
db/ops.rsgets a connection from the pool, callsquery::*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
| Step | Admin | gRPC | Lua CRUD |
|---|---|---|---|
| Access control (collection-level) | Yes | Yes | Yes (overrideAccess) |
| Field-level write stripping | Yes | Yes | Yes (overrideAccess) |
| Password extraction (auth) | Yes | Yes | Yes |
| before_validate (field + collection + registered) | Yes | Yes | Yes |
| Validation | Yes | Yes | Yes |
| before_change (field + collection + registered) | Yes | Yes | Yes |
| DB insert | Yes | Yes | Yes |
| Join table data (arrays, blocks, has-many) | Yes | Yes | Yes |
| Password hash + store | Yes | Yes | Yes |
| Versioning (status + snapshot + prune) | Yes | Yes | Yes |
| after_change (field + collection + registered) | Yes | Yes | Yes |
| Publish event (SSE/WebSocket) | Yes | Yes | No (in-transaction) |
| Verification email (auth + verify_email) | Yes | Yes | No |
UPDATE Lifecycle
| Step | Admin | gRPC | Lua CRUD |
|---|---|---|---|
| Access control (collection-level) | Yes | Yes | Yes (overrideAccess) |
| Field-level write stripping | Yes | Yes | Yes (overrideAccess) |
| Password extraction (auth) | Yes | Yes | Yes |
| Unpublish path | Yes | Yes | Yes |
| before_validate (field + collection + registered) | Yes | Yes | Yes |
| Validation | Yes | Yes | Yes |
| before_change (field + collection + registered) | Yes | Yes | Yes |
| DB update (or draft-only version save) | Yes | Yes | Yes |
| Join table data | Yes | Yes | Yes |
| Password hash + store (normal path) | Yes | Yes | Yes |
| Versioning (status + snapshot + prune) | Yes | Yes | Yes |
| after_change (field + collection + registered) | Yes | Yes | Yes |
| Publish event | Yes | Yes | No (in-transaction) |
DELETE Lifecycle
| Step | Admin | gRPC | Lua CRUD |
|---|---|---|---|
| Access control | Yes | Yes | Yes (overrideAccess) |
| before_delete (collection + registered) | Yes | Yes | Yes |
| DB delete | Yes | Yes | Yes |
| after_delete (collection + registered) | Yes | Yes | Yes |
| Upload file cleanup | Yes | Yes | Yes |
| Publish event | Yes | Yes | No (in-transaction) |
FIND Lifecycle
| Step | Admin | gRPC | Lua CRUD |
|---|---|---|---|
| Access control (collection-level) | Yes | Yes | Yes (overrideAccess) |
| Constraint filter merging | Yes | Yes | Yes |
| Draft-aware filtering | Yes | Yes | Yes |
| before_read hooks | Yes | Yes | Yes |
| DB query + count | Yes | Yes | Yes |
| Hydrate join tables | Yes | Yes | Yes |
| Upload sizes assembly | Yes | Yes | Yes |
| after_read hooks (field + collection + registered) | Yes | Yes | Yes |
| Relationship population (depth) | Yes | Yes | Yes |
| Select field stripping | Yes | Yes | Yes |
| Field-level read stripping | Yes | Yes | Yes (overrideAccess) |
FIND_BY_ID Lifecycle
| Step | Admin | gRPC | Lua CRUD |
|---|---|---|---|
| Access control (collection-level) | Yes | Yes | Yes (overrideAccess) |
| before_read hooks | Yes | Yes | Yes |
| Draft version overlay | Yes | Yes | Yes |
| Hydrate join tables | Yes | Yes | Yes |
| Upload sizes assembly | Yes | Yes | Yes |
| after_read hooks (field + collection + registered) | Yes | Yes | Yes |
| Relationship population (depth) | Yes | Yes | Yes |
| Select field stripping | Yes | Yes | Yes |
| Field-level read stripping | Yes | Yes | Yes (overrideAccess) |
Remaining By-Design Differences
| Feature | Admin | gRPC | Lua CRUD | Reason |
|---|---|---|---|---|
| Event publishing | Yes | Yes | No | Lua 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 delete | Yes | Yes | Yes | Lua CRUD reads ConfigDir from Lua app_data; admin/gRPC clean up after commit. |
| Verification email on create | Yes | Yes | No | Email sending is async, post-commit. |
| Locale from request | Yes | Yes | Explicit opt | Admin/gRPC infer from request; Lua passes explicitly via opts.locale. |
| Default depth | Varies | Config | 0 | Lua defaults to 0 to avoid N+1 in hooks. Callers pass depth explicitly. |