cufflink-cli 0.17.0

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
# 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)]` and `service!` proc macros generate a JSON manifest from Rust structs
- **CLI** — `cufflink deploy` builds 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.toml` pins 1.93.1; rustup installs it on the first `cargo` command
- **Docker + Docker Compose**
- The `wasm32-unknown-unknown` target, only if you build WASM services: `rustup target add wasm32-unknown-unknown`

### 1. Start the platform

```bash
docker compose up -d
curl localhost:8080/health
# 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

```bash
cargo install cufflink-cli          # from crates.io
# or, from this checkout:
cargo build -p cufflink-cli         # binary at target/debug/cufflink
```

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:

```toml
[service]
default_env = "local"

[environments.local]
api_url = "http://localhost:8080"
tenant = "acme"
keycloak_url = "http://localhost:8180"
keycloak_realm = "acme"
```

```bash
cd examples/todo-service
cufflink login
```

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

```bash
cufflink 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`):

```bash
export KEY=ck_...
BASE=http://localhost:8080/svc/acme/todo-service/todos

# Create — user_id is set from the caller, not the body
curl -X POST $BASE -H "Authorization: ApiKey $KEY" -H "Content-Type: application/json" \
  -d '{"title": "Buy groceries", "completed": false}'

# List (sorting, pagination, search, filter)
curl -H "Authorization: ApiKey $KEY" "$BASE?sort=-created_at&per_page=10"
curl -H "Authorization: ApiKey $KEY" "$BASE?search=groceries"
curl -H "Authorization: ApiKey $KEY" --get $BASE \
  --data-urlencode 'filter=[{"n":"completed","f":"=","v":"false"}]'

# Get / update / delete by ID
curl -H "Authorization: ApiKey $KEY" $BASE/{id}
curl -X PUT $BASE/{id} -H "Authorization: ApiKey $KEY" -H "Content-Type: application/json" \
  -d '{"completed": true}'
curl -X DELETE -H "Authorization: ApiKey $KEY" $BASE/{id}
```

List responses look like this:

```json
{
  "results": [
    {"id": "…", "user_id": "platform-acme", "title": "Buy groceries", "completed": false,
     "created_at": "…", "updated_at": "…"}
  ],
  "pagination": {"current_page": 1, "per_page": 10, "total_pages": 1, "total_records": 1}
}
```

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

```rust
use cufflink::prelude::*;

#[derive(Table, Serialize, Deserialize, Clone)]
#[table(name = "todos", auth_required, owner_field = "user_id")]
pub struct Todo {
    #[key]
    pub id: Uuid,
    pub user_id: String,
    pub title: String,
    pub completed: bool,
    #[timestamp]
    pub created_at: DateTime<Utc>,
    #[timestamp]
    pub updated_at: DateTime<Utc>,
}

cufflink::service! {
    name: "todo-service",
    tables: [Todo],
}
```

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

```rust
#[hooks(before_create = "validate_item", after_create = "audit_item_created", before_delete = "check_owner_can_delete")]
```

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

```rust
cufflink::service! {
    name: "orders",
    mode: wasm,                       // crud (default) | wasm | container
    tables: [Order, LineItem],
    authorization: [
        areas: [("orders", ["create", "view", "edit", "delete"])],
        default_roles: [
            ("admin", "Full access", ["orders:*"]),
            ("viewer", "Read-only", ["orders:view"]),
        ],
    ],
    custom_routes: [
        // (method, path, handler, posture[, job(...)])
        ("GET",  "/health",   "health",   public("unauthenticated probe")),
        ("POST", "/checkout", "checkout", auth),
        ("POST", "/refund",   "refund",   perm("orders", "edit")),
        ("GET",  "/mine",     "mine",     owner("user_id")),
        ("POST", "/export",   "export",   auth, job(timeout = 900, max_attempts = 3)),
    ],
    subscriptions: [("orders.>", "on_order_event", wasm)],   // wasm | webhook | delete_cascade | update_field
    on_migrate: "backfill",
    publishes: ["orders.created", "orders.refunded"],
}
```

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](docs/setup.md).

See [docs/wasm-guide.md](docs/wasm-guide.md) for handlers, [docs/authorization.md](docs/authorization.md) for RBAC, and [docs/guides.md](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](docs/setup.md#api-reference).

## 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:

```bash
docker compose up -d
docker compose stop platform

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 \
cargo run -p cufflink-platform
```

`KEYCLOAK_ISSUER` is not needed natively because `KEYCLOAK_URL` is already the browser-facing address.

```bash
cargo build --workspace
cargo test --workspace
```

## Documentation

- [Setup & operations]docs/setup.md — services, environment variables, tenants, production checklist, full API reference
- [Guides]docs/guides.md — feature walkthroughs
- [WASM guide]docs/wasm-guide.md and [WASM workers]docs/wasm-workers.md
- [Authentication]docs/authentication.md and [Authorization]docs/authorization.md
- [Building on Cufflink]docs/building-on-cufflink.md
- [Observability]docs/observability.md, [Scaling]docs/scaling.md, [Audit]docs/audit.md
- [Backup]docs/backup.md, [Workspace]docs/workspace.md, [Packages]docs/packages.md

## Contributing

Bug reports, fixes, and proposals are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md).
Security issues should be reported privately per [SECURITY.md](SECURITY.md).

## License

Licensed under either of

- Apache License, Version 2.0 ([LICENSE-APACHE]LICENSE-APACHE or https://www.apache.org/licenses/LICENSE-2.0)
- MIT license ([LICENSE-MIT]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.