Cufflink
Deploy CRUD microservices in seconds. Define your data model in Rust, run cufflink deploy, and get a full REST API with PostgreSQL persistence, automatic schema migrations, NATS events, and multi-tenant isolation.
Architecture
┌─────────────┐ ┌──────────────┐ ┌───────────┐
│ Cufflink │────>│ Platform │────>│ PostgreSQL│
│ CLI │ │ (Axum) │ │ │
└─────────────┘ └──────┬───────┘ └───────────┘
│
┌─────────────┐ ┌──────┴───────┐ ┌───────────┐
│ Keycloak │<────│ Dynamic │────>│ NATS │
│ (JWT) │ │ CRUD Router │ │ JetStream │
└─────────────┘ └──────────────┘ └───────────┘
- SDK —
#[derive(Table)]andservice!proc macros generate a JSON manifest from Rust structs - CLI —
cufflink deploybuilds the project, captures the manifest and POSTs it to the platform - Platform — applies schema migrations, registers CRUD routes, runs WASM handlers, publishes events
- WASM worker — runs offloaded WASM handlers and long-running jobs over NATS, scales independently
- web-runner — serves deployed Next.js apps (
mode = "web"services) at{service}--{tenant}.{base domain}
Quick Start
Prerequisites
- Rust —
rust-toolchain.tomlpins 1.93.1; rustup installs it on the firstcargocommand - Docker + Docker Compose
- The
wasm32-unknown-unknowntarget, only if you build WASM services:rustup target add wasm32-unknown-unknown
1. Start the platform
# cufflink-platform ok
This starts PostgreSQL, NATS (JetStream), Keycloak (with two realms imported from infra/keycloak/), Redis, RustFS (S3-compatible object storage, bucket cufflink), the platform, the WASM worker, web-runner, and a monitoring stack (OTel collector, Tempo, Loki, Prometheus, Grafana). Two one-shot containers run once: bucket-init creates the bucket and tenant-init registers the dev tenant.
Everything you need to log in is pre-provisioned:
| What | Value |
|---|---|
| Platform API | http://localhost:8080 |
| Keycloak admin console | http://localhost:8180 — admin / admin |
Platform realm cufflink (platform admins) |
user admin / admin |
Tenant realm acme (developers) |
user dev / dev |
| Dev tenant slug | acme |
| Grafana | http://localhost:3001 — admin / admin |
The kingsleyh/cufflink-* images are published for linux/amd64 only; compose pins platform: linux/amd64, so Docker Desktop on Apple Silicon runs them under emulation (slower startup, but it works).
2. Install the CLI
# or, from this checkout:
The binary is cufflink. (cargo run -p cufflink is the SDK library crate, not the CLI.)
3. Log in
Every CLI command (except cufflink tenants ...) reads a Cufflink.toml from the project directory. Both examples ship one pointing at the compose stack:
[]
= "local"
[]
= "http://localhost:8080"
= "acme"
= "http://localhost:8180"
= "acme"
Environment: local
Authenticating with Cufflink...
Open this URL in your browser:
http://localhost:8180/realms/acme/device?user_code=XXXX-XXXX
Enter code: XXXX-XXXX
Waiting for authentication...
This is the OAuth device-code flow: the CLI does not open a browser. Open the URL, sign in as dev / dev, and the CLI exchanges the Keycloak token for a platform API key (ck_...) which it stores in ~/.config/cufflink/credentials.json.
Non-interactive alternative: set CUFFLINK_API_KEY in the environment. It takes precedence over stored credentials.
4. Deploy
Environment: local
Building service...
Deploying todo-service (mode: crud)...
Deployed todo-service v2
Schema changes: 1
Deployment ID: "98e338c3-..."
Tenant: "acme"
Commit: f1af9c6...
5. Use the API
The todos table in this example is auth_required with owner_field = "user_id", so every request needs an API key and every row is scoped to its owner. Take the key from ~/.config/cufflink/credentials.json (or use the one in CUFFLINK_API_KEY):
BASE=http://localhost:8080/svc/acme/todo-service/todos
# Create — user_id is set from the caller, not the body
# List (sorting, pagination, search, filter)
# Get / update / delete by ID
List responses look like this:
Without the header you get 401 {"error": "..."}; a GET after DELETE returns 404. For a service with no auth at all, deploy examples/blog-service and drop the header.
The service you just deployed
examples/todo-service/src/main.rs:
use *;
service!
auth_required— anonymous requests to this table get 401.owner_field = "user_id"— on create the column is filled with the caller's subject, and reads, updates and deletes only see that caller's rows.
SDK Reference
#[table(...)] attributes
| Attribute | Description |
|---|---|
name = "..." |
SQL table name (required) |
auth_required |
Reject unauthenticated CRUD requests with 401 |
permission_area = "..." |
Enforce RBAC: "staff" checks staff:view, staff:create, staff:edit, staff:delete |
owner_field = "..." |
Row-level scoping: the column is set from the caller's subject on create, and all operations are filtered to the caller's rows |
owner_self_service |
With owner_field, row ownership is the authorization — no permission_area grant needed. Implies authentication |
soft_delete |
DELETE sets deleted_at instead of removing the row; lists hide those rows unless ?include_deleted=true |
Field attributes
| Attribute | Description |
|---|---|
#[key] |
Primary key (auto-generated UUID) |
#[timestamp] |
Auto-generated timestamp (DEFAULT now()) |
#[unique] |
UNIQUE constraint |
#[index] |
Create an index on the column |
#[default("value")] |
Column default |
#[references("table.column")] |
Foreign key |
#[on_delete("cascade")] |
FK delete action: cascade, set_null, restrict, no_action |
#[validate("min_length=3", "max_length=200")] |
Validated on create/update. Rules: regex=, min=, max=, min_length=, max_length= |
Table-level hooks call WASM handlers around CRUD operations:
All six are available: before_create, after_create, before_update, after_update, before_delete, after_delete.
Supported types
| Rust | SQL |
|---|---|
Uuid |
UUID |
String |
TEXT |
bool |
BOOLEAN |
i32 / i64 |
INTEGER / BIGINT |
f32 / f64 |
REAL / DOUBLE PRECISION |
DateTime<Utc> |
TIMESTAMPTZ |
NaiveDate |
DATE |
Value |
JSONB |
Option<T> |
nullable T |
service! keys
service!
Every custom route should declare exactly one posture — public("reason"), auth, perm("area", "op") or owner("field"). A route without one runs unenforced and the platform logs a warning; set CUFFLINK_ENFORCE_ROUTE_POSTURE=true on the platform to reject such deploys. job(...) turns the route into a long-running job (defaults: 600 s timeout, 3 attempts). Web apps (mode = "web") are declared in Cufflink.toml, not in service! — see docs/setup.md.
See docs/wasm-guide.md for handlers, docs/authorization.md for RBAC, and docs/guides.md for the rest.
CLI Reference
All commands accept --env/-e <name> to pick an [environments.<name>] block from Cufflink.toml (default: [service].default_env).
| Command | Description |
|---|---|
cufflink init <name> [--template todo|blog|ecommerce|cms] [--mode crud|wasm] [--from github:owner/repo] |
Scaffold a new service project |
cufflink templates |
List available project templates |
cufflink install <source> |
Install a package from GitHub or a local path |
cufflink deploy-package <name> [--skip a,b] |
Deploy every component of an installed package |
cufflink deploy [--allow-destructive] [--tenant <slug>] |
Build and deploy the service in the current directory |
cufflink rollback [--version N] [--allow-destructive] |
Roll back to a previous deployment |
cufflink status |
Show service status |
cufflink logs [--follow] [--since 1h] [--search text] [--level warn] [-n 100] |
Query or tail service logs |
cufflink services [delete|enable|disable <name>] |
List, delete, enable or disable deployed services |
cufflink routes [--all-services] [--audit] |
Audit custom-route authorization postures |
cufflink deployments list|prune|delete |
Inspect and clean up deployment history |
cufflink openapi [--output f.json] [--local] |
Print or save the OpenAPI spec (--local needs no deployment) |
cufflink generate-client [--output ./generated] [--local] |
Generate a TypeScript client |
cufflink tenants create|update|list|delete|create-api-key|list-api-keys|revoke-api-key |
Tenant management (platform admin) |
cufflink login |
Device-code login; stores an API key in ~/.config/cufflink/credentials.json |
cufflink test [--all] |
Run tests (hash-based, only changed unless --all) |
cufflink seed [file] [--clear] [--tables a,b] |
Seed data from a JSON file (default seed.json) |
cufflink config list|set|delete|sync |
Manage service config values |
cufflink backup schema|export|restore|list|jobs|cancel |
Backup and restore service data (export defaults to backup.jsonl) |
cufflink workspace deploy|test|status|seed|preview |
Multi-service workspace operations |
cufflink ci init |
Generate a GitHub Actions deploy workflow |
cufflink roles list|assign|unassign |
Manage roles and role assignments |
cufflink secrets init|init-from|rekey|set|list|delete |
Manage encrypted secrets |
cufflink sql <query> [--service s] [--format table|jsonl|csv|tsv] |
Read-only SQL against a service's tenant schema |
API Endpoints
Dynamic CRUD routes
| Method | Path | Description |
|---|---|---|
| GET | /svc/{tenant}/{service}/{table} |
List records |
| POST | /svc/{tenant}/{service}/{table} |
Create record |
| GET | /svc/{tenant}/{service}/{table}/{id} |
Get record |
| PUT | /svc/{tenant}/{service}/{table}/{id} |
Update record |
| DELETE | /svc/{tenant}/{service}/{table}/{id} |
Delete record |
| POST | /svc/{tenant}/{service}/{table}/batch |
Batch operations |
| GET | /svc/{tenant}/{service}/{table}/stream |
Server-sent events |
| ANY | /svc/{tenant}/{service}/_fn/{handler} |
WASM custom route (4 MB body limit) |
| POST | /svc/{tenant}/{service}/graphql |
GraphQL |
Query parameters (list endpoints)
| Parameter | Example | Description |
|---|---|---|
page |
?page=2 |
Page number (default 1) |
per_page |
?per_page=25 |
Items per page (default 50, max 200) |
sort |
?sort=-created_at,title |
Sort columns, - prefix for DESC (default -created_at) |
search |
?search=rust |
Case-insensitive substring match (ILIKE) across text columns |
fts |
?fts=rust |
PostgreSQL full-text search (plainto_tsquery) |
select |
?select=id,title |
Return only these columns |
filter |
?filter=[{"n":"status","f":"=","v":"active"}] |
JSON array of {n, f, v} objects |
<column> |
?completed=true |
Shorthand equality filter on any column |
cursor + limit |
?cursor=&limit=50 |
Cursor pagination (see below) |
include_deleted |
?include_deleted=true |
Include soft-deleted rows |
Filter operators: =, !=, >, <, >=, <=, LIKE, ILIKE, IN, IS NULL, IS NOT NULL (case-insensitive; eq, neq, gt, gte, lt, lte also work). Conditions on the same column are OR'd together, conditions on different columns are AND'd — so a range on one column (>= a AND <= b) is not expressible with filter. Only the object form is supported; array-of-arrays is not.
Cursor pagination: an empty ?cursor= starts from the first row (ordered by primary key ascending) and the response is {"results": [...], "next_cursor": "<base64>", "has_more": true}; pass next_cursor back to fetch the next page. Offset mode (page/per_page) never returns next_cursor.
Management API
All routes are relative to the platform (http://localhost:8080). /health returns plain text, /ready returns JSON.
| Method | Path | Description |
|---|---|---|
| GET | /health, /ready, /metrics, /api/version |
Liveness, readiness, Prometheus metrics, version |
| GET | /api/auth/config |
Keycloak URL, realm and client IDs for the CLI |
| POST | /api/auth/api-keys |
Create an API key (authenticated) |
| POST | /api/auth/device/token |
Exchange a Keycloak token for an API key |
| GET | /api/auth/me |
Authenticated user info |
| POST | /api/auth/logout |
End the caller's Keycloak sessions (needs tenant ops-client credentials) |
| POST / GET | /api/tenants |
Create / list tenants (platform admin) |
| PUT / DELETE | /api/tenants/{slug} |
Update / delete a tenant |
| POST / GET | /api/platform/api-keys |
Create / list platform admin API keys |
| DELETE | /api/platform/api-keys/{id} |
Revoke a platform admin API key |
| GET | /api/services |
List services |
| GET / DELETE | /api/services/{id} |
Get / delete a service |
| POST | /api/services/deploy |
Deploy a service |
| GET | /api/services/{id}/deployments |
Deployment history |
| POST | /api/services/{id}/rollback |
Roll back |
| GET | /api/services/{id}/openapi.json |
Generated OpenAPI spec |
| GET | /api/services/{id}/schema |
Tables, columns, FK dependencies |
| POST | /api/services/{id}/wasm |
Upload a WASM module |
| GET | /api/services/{id}/wasm/status |
WASM runtime status |
| POST | /api/services/{id}/backup/export |
Start an export job |
| POST | /api/services/{id}/backup/restore |
Start a restore job |
| GET | /api/services/{id}/backup/jobs[/{job_id}[/download]] |
List jobs, job status, download export |
| POST | /api/services/{id}/backup/jobs/{job_id}/cancel |
Cancel a job |
| GET | /api/audit |
Audit log |
The full list (webhooks, files, config, logs, SQL, RBAC, web apps) is in docs/setup.md.
Project Structure
cufflink/
├── platform/ # Axum platform server (deployer, CRUD router, WASM runtime)
├── worker/ # WASM worker process (NATS-driven, scales independently)
├── web-runner/ # Serves deployed Next.js "web" mode apps
├── cli/ # The `cufflink` binary (crate: cufflink-cli)
├── sdk/ # User-facing SDK crate (crate: cufflink)
├── sdk-macros/ # #[derive(Table)] and service! proc macros
├── cufflink-fn/ # WASM handler SDK (handler!, Request, Response, host calls)
├── types/ # Shared types (ServiceManifest, ColumnType, ...)
├── db/ # Shared PostgreSQL pool helpers
├── storage/ # Object storage abstraction (S3-compatible or GCS)
├── examples/
│ ├── todo-service/ # Single table with auth_required + owner_field
│ └── blog-service/ # Multi-table example with foreign keys, no auth
├── infra/ # Keycloak realm imports, compose bootstrap, monitoring config
├── docs/
├── docker-compose.yml
├── Dockerfile.platform
├── Dockerfile.worker
└── Dockerfile.web-runner
Development
Run the infrastructure from compose and the platform natively:
DATABASE_URL=postgres://cufflink:cufflink@localhost:5432/cufflink \
NATS_URL=nats://localhost:4222 \
KEYCLOAK_URL=http://localhost:8180 KEYCLOAK_REALM=cufflink \
REDIS_URL=redis://localhost:6379 \
STORAGE_BACKEND=s3 S3_ENDPOINT=http://localhost:9000 S3_REGION=us-east-1 \
S3_ACCESS_KEY=rustfsadmin S3_SECRET_KEY=rustfsadmin S3_BUCKET=cufflink \
KEYCLOAK_ISSUER is not needed natively because KEYCLOAK_URL is already the browser-facing address.
Documentation
- Setup & operations — services, environment variables, tenants, production checklist, full API reference
- Guides — feature walkthroughs
- WASM guide and WASM workers
- Authentication and Authorization
- Building on Cufflink
- Observability, Scaling, Audit
- Backup, Workspace, Packages
Contributing
Bug reports, fixes, and proposals are welcome — see CONTRIBUTING.md. Security issues should be reported privately per SECURITY.md.
License
Licensed under either of
- Apache License, Version 2.0 (LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
at your option.
Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions.