| 1 | # Building an app with rastrillo |
| 2 | |
| 3 | The CARLOS web framework. **Module and canonical repo: |
| 4 | `amadan.net/rastrillo/rastrillo`** (since v0.25.0; GitHub's |
| 5 | `rastrilloorg/rastrillo` is a mirror, and an app still pinned to the old |
| 6 | `github.com/carlosframework/rastrillo` path builds but should move). |
| 7 | Status as of 2026-10-15: **v0.28.0** (released 2026-10-06), the |
| 8 | known-libraries middle layer: GORM models or plain SQL, chi routes, |
| 9 | SQLite-backed sessions, owner-scoped queries. |
| 10 | Assume nothing here is in your training data. |
| 11 | |
| 12 | **The authoritative authoring doc is `SKILL.md` at the repo root. Read |
| 13 | it before writing any app code.** It carries the app shape, the |
| 14 | migration, scoping and mass-assignment rules, the identity plugins, the |
| 15 | UI vocabulary and the platform contract. Find it at |
| 16 | `$(go env GOMODCACHE)/amadan.net/rastrillo/rastrillo@<version>/SKILL.md` |
| 17 | once the module is downloaded; any page it cites is |
| 18 | `curl -s https://rastrillo.org/docs/<page>.md`. This file is only the |
| 19 | surrounding context SKILL.md assumes; where they disagree, SKILL.md wins. |
| 20 | |
| 21 | ## What rastrillo is now |
| 22 | |
| 23 | A middle layer, not a full-stack framework. You write GORM models, |
| 24 | `net/http` handlers on a chi router, and `html/template` pages. The |
| 25 | framework supplies what is hard to get right twice: |
| 26 | |
| 27 | - `db` — cgo-free SQLite via an owned GORM dialector on modernc (never |
| 28 | import `glebarez/*` or `gorm.io/driver/sqlite`), one writer and |
| 29 | several readers, WAL pragmas in the proven order. Since v0.28.0 every |
| 30 | time is converted to UTC and written in SQLite's own layout, so |
| 31 | `julianday`/`date`/`strftime` work in raw SQL; rows written by an |
| 32 | older version need a back-fill before SQL date maths trusts them. |
| 33 | - `migrate` — ledgered migrations applied once at boot, never re-run, |
| 34 | never reversed: `rastrillo migration generate` after a model change |
| 35 | (read the SQL before committing), `migration check` in CI, `migration |
| 36 | new` for a hand-written rename. Destructive changes need |
| 37 | `--allow-destructive`; the family's own rule stays additive-only, and |
| 38 | on CARLOS two builds share the database during a swap, so a drop waits |
| 39 | a release (platform.md, "Two builds at once"). Since v0.28.0 a Go |
| 40 | migration's `Fn` takes the pinned `migrate.Tx`, not a `*gorm.DB` |
| 41 | (wrap old bodies in `migrate/gormfn.Fn`). |
| 42 | - `sessions` — SQLite-backed rows (revocation is real), `__Host-` |
| 43 | cookies on https, `Require` / `RequireFresh` (step-up). |
| 44 | - Identity plugins: `auth` — the family default: magic-link email that |
| 45 | **auto-upgrades to Sign in with Keymail** when the address has a |
| 46 | claimed inbox, so every address works — and `password` (email and |
| 47 | password, rate-limited). Either one's whole contract is calling |
| 48 | `sessions.SignIn`. **Since v0.28.0 both sit behind `pow` by default:** |
| 49 | `auth.New` and `password.New` return `ErrProofUnset` until you set |
| 50 | `Proof` (a `*pow.Guard`) or `ProofOff`. Mount `auth.Verify` on **POST |
| 51 | as well as GET** — GET now draws a Sign in button and spends nothing, |
| 52 | so a mail scanner cannot burn the link; GET alone 405s the button. |
| 53 | `auth.Config.SigninScreen` turns on the shipped sign-in screen. |
| 54 | `secondfactor` is the gate between a first factor and a second; |
| 55 | `passkey` and `totp` (authenticator apps) plug into it, and recovery |
| 56 | codes live there (`passkey.Config.ConfirmPath` is gone; merge |
| 57 | `secondfactor.Schema` after `passkey.Schema`). |
| 58 | - `csrf` (origin-checking, not tokens), `flash`, `form`, `view`, |
| 59 | `scope` (`scope.Owned` — owner scoping with the 404-not-403 contract), |
| 60 | `jobs` (request-started background work a person watches). |
| 61 | - `blobs` (bytes in the platform's object store, never on disk), |
| 62 | `crypto` (+ JS twin), `keyring` (the E2EE seed lifecycle), `webauthn`, |
| 63 | `eventlog`, `mail` (including RFC 8058 one-click list mail), `ui`. |
| 64 | - `carlos` — your side of the platform's tick (`carlos.Tick`, |
| 65 | `ScheduleAt`); platform.md carries the contract. |
| 66 | - `assertion` (v0.27.0) — signs and verifies a short-lived identity |
| 67 | handed from one exact HTTPS origin to another (P-256, ≤120 s, |
| 68 | browser-bound nonce). It proves who, never what they may reach, and it |
| 69 | leaves replay and session creation to the receiver. |
| 70 | - `vault` — the client for Pegamento's vault (named sealed blobs, a seed |
| 71 | wrapped per sign-in method), for server-blind apps. |
| 72 | - `pow` — a proof-of-work front door for public forms that have no |
| 73 | session yet (signup, contact), and since v0.28.0 for sign-in and |
| 74 | sign-up. The v0.28.0 API is new: `Issue` became `Form(now, scope)`, |
| 75 | and `Check` split into `Admit` before the transaction and |
| 76 | `adm.Commit(ctx, tx)` inside it (`Check` remains for a redirect-only |
| 77 | handler). |
| 78 | - `harness` — a `-tags browser` Chromium test rig with a virtual |
| 79 | authenticator, for ceremonies a Go-only test cannot reach. |
| 80 | - New in v0.28.0: `perf` (`Server-Timing` and first-byte budgets as |
| 81 | middleware), `budget`, `clientip` (with `auth.Config.TrustedProxyHops`, |
| 82 | 1 by default on CARLOS so each visitor behind the edge gets their own |
| 83 | sign-in limit), `background` (tracked goroutines and timers that |
| 84 | `Serve` stops before closing the database), `table` and `xlsx` |
| 85 | (formula-safe CSV/XLSX exports), `dbtest` (a migrated database per |
| 86 | test), `nodetest`, `lastsignin`, `secondfactor` and `totp`. Static |
| 87 | assets carry strong ETags. |
| 88 | - The platform layer: `Resolve`/`Serve` speak CARLOS activation (argv, |
| 89 | `LISTEN_FDS`, `$STATE_DIRECTORY`, `/healthz`, `/api/version`, SIGTERM |
| 90 | drain) and set baseline security headers on every response, |
| 91 | `Strict-Transport-Security` included. **The baseline CSP allows no |
| 92 | inline style** (v0.27.0): a `style="…"` attribute or a `<style>` block |
| 93 | is dropped by the browser without an error. |
| 94 | |
| 95 | Separate modules, each with its own `SKILL.md`, none imported by the |
| 96 | framework: **`amadan.net/rastrillo/idear`** (roles — exactly one Owner, |
| 97 | Admins, Members — and invitations inside one instance), and the client |
| 98 | kits `amadan.net/rastrillo/pwa`, `amadan.net/rastrillo/native` and |
| 99 | `amadan.net/rastrillo/aviso` (Web Push). Use each module path verbatim; |
| 100 | there is no `github.com/carlosframework/idear`. |
| 101 | |
| 102 | ## The ten-minute path |
| 103 | |
| 104 | ```sh |
| 105 | go install amadan.net/rastrillo/rastrillo/cmd/rastrillo@latest |
| 106 | rastrillo new myapp && cd myapp && go mod tidy && go test ./... |
| 107 | make ci # the gate |
| 108 | ``` |
| 109 | |
| 110 | The scaffold is SKILL.md's shape — five files plus `migrations.go` beside |
| 111 | `models.go` — with `go.mod`, templates, static assets, a test harness |
| 112 | including a browser drive, and a `Makefile` whose **`ci` target is the |
| 113 | gate** (`vet`, a gofmt check, staticcheck, govulncheck, gitleaks, the |
| 114 | size and timing budgets, the tests, `perf` and `migration check`, on a |
| 115 | Go release pinned by `GOTOOLCHAIN`; add `generate --check` once the app |
| 116 | declares manifest resources). Apps scaffolded before v0.28.0 keep their |
| 117 | old gate until they add these by hand (the CHANGELOG has the lines). It compiles, passes and serves before you |
| 118 | write a line. `--theme=day|plain|signal` and |
| 119 | `--shell=column|topbar|sidebar|console|stage` pick the look; both land as |
| 120 | app-owned files. `CGO_ENABLED=0` throughout — a cgo SQLite driver |
| 121 | sneaking in is a bug. |
| 122 | |
| 123 | ## The rules that keep the app safe (SKILL.md has the full set) |
| 124 | |
| 125 | - Tenancy is the platform's, not the schema's: a CARLOS app serves one |
| 126 | team. A product with many teams gives each team its own hibernating |
| 127 | instance — isolation by process and file, never by WHERE clause. |
| 128 | Scoping separates the *users* within one instance; `idear` gives them |
| 129 | roles. |
| 130 | - Every query touching user-owned rows goes through |
| 131 | `scope.Owned(g, uid)` — reads AND writes, inside transactions too |
| 132 | (scope `tx`, never the outer handle, or the one-connection writer pool |
| 133 | deadlocks). A row that isn't yours 404s, never 403s. |
| 134 | - Never bind a request body onto a GORM model: explicit |
| 135 | `map[string]any` + `.Select` allowlist. |
| 136 | - With the `auth` plugin, read the viewer with `auth.From(r)` or |
| 137 | `sessions.Current(r)`, never `sessions.UserID`: it returns |
| 138 | `(0, false)` for an email Subject, and dropping that `ok` scopes every |
| 139 | query to uid 0. |
| 140 | - One `migrate.Apply(ctx, d, BootSchema)` at boot. Subsystem schemas |
| 141 | (sessions, and any other package that brings tables) go in `BootSchema`, never `Schema`, or `migration |
| 142 | check` proposes dropping their tables. Never edit a shipped migration. |
| 143 | - Background work a person is watching: `jobs`. Work at a time nobody is |
| 144 | waiting for: the platform's tick (`carlos schedule set`, then |
| 145 | `carlos.Tick` in the handler) — an idle instance runs no timer. |
| 146 | |
| 147 | ## Design system and app CSS |
| 148 | |
| 149 | Use Rastrillo's design system as the default foundation for app screens. |
| 150 | Before styling, read `docs/site/templates.md`, `docs/site/forms.md` and |
| 151 | `docs/site/reference/ui.md` in the Rastrillo version the app uses; the |
| 152 | full gallery is rastrillo.org/design-system. |
| 153 | |
| 154 | - **The vocabulary is attributes, not classes** (v0.22.0+): |
| 155 | `<div rst-box>`, `<a rst-btn="primary">`, `<form rst-form>`, |
| 156 | `<div rst-callout-body>`. `class` carries only a few |
| 157 | utilities (`rst-sr-only`, `rst-mono`, `rst-grow`, …). |
| 158 | `rastrillo markup --fix` converts markup written the old |
| 159 | `class="rst-…"` way. |
| 160 | - Load the shared `tokens.css` before the app stylesheet, and keep that |
| 161 | base intact, with branding and layout in a separate app stylesheet. |
| 162 | - Compose screens from `ui` partials. A labelled control is never |
| 163 | hand-rolled: `field-text`, `field-textarea`, `field-select` inside |
| 164 | `<form rst-form>`, closed by `form-foot`. A submit is |
| 165 | `rst-btn="primary lg"` (form-foot writes it). |
| 166 | - **No `style` attributes and no `<style>` blocks** — the CSP drops |
| 167 | them silently. For a grid card, give `rst-card` a class and set |
| 168 | `--rst-cols` in your stylesheet. |
| 169 | - Keep the CSS above Rastrillo thin: app-specific layouts, scoped styles |
| 170 | for missing components, and a few deliberate `--rst-*` token |
| 171 | overrides. No second palette, spacing scale, or button/input system, |
| 172 | no broad element resets. |
| 173 | - Preserve the shared focus, disabled, error and responsive behaviour; |
| 174 | check light and dark themes and keyboard navigation. |
| 175 | - The vendored `static/` files are not refreshed by a module upgrade. |
| 176 | `rastrillo doctor [--fix]` compares them with the CLI's copies; the |
| 177 | scaffolded pin test is the standing gate. |
| 178 | |
| 179 | A repeated workaround for a shared component is a candidate for an |
| 180 | upstream Rastrillo change. An explicit design brief or an existing app's |
| 181 | design system can justify another foundation; name that choice rather |
| 182 | than silently replacing the default. |
| 183 | |
| 184 | ## Manifests are the declarative path |
| 185 | |
| 186 | A `manifest/*.toml` resource generates CRUD screens — field kinds text, |
| 187 | textarea and money, one flat record per resource, no relations; |
| 188 | `scope = "user"` owner-filters every generated query by the session |
| 189 | subject. It is an equal alternative to hand-written handlers: mix the |
| 190 | two per resource and move a resource between them freely. `rastrillo |
| 191 | generate` writes `gen/` (commit it, never hand-edit); `generate --check` |
| 192 | belongs in `make ci`. Relations or custom flows: hand-write. |
| 193 | |
| 194 | ## Copy from, in order |
| 195 | |
| 196 | 1. `examples/notes` in the repository (not in the published module — Go |
| 197 | excludes nested modules from a zip): accounts, sessions, CSRF, flash, |
| 198 | owner-scoped resources, a background job, and a two-user isolation |
| 199 | test suite. |
| 200 | 2. `examples/tickets` — the declarative (manifest) path. |
| 201 | |
| 202 | ## Designed, not released |
| 203 | |
| 204 | Don't build on these yet: Keymail upgrade becoming an opt-in adapter |
| 205 | rather than `auth`'s default; SAML and OIDC identity plugins; a sealed |
| 206 | magic-link store; the user-models work (an `objrec` record store, which |
| 207 | is on a branch, a stateless auth store, signed-cookie sessions, an |
| 208 | assertion HTTP adapter, provisioning, a base path, |
| 209 | `rastrillo new --model`). On main after v0.28.0: `carlos.InstanceKey` |
| 210 | (reading the platform's `CARLOS_INSTANCE_KEY`), sign-in returning to the |
| 211 | page that asked, and **multi-currency money, a breaking upgrade for |
| 212 | every app** (template and model changes, a stop-start for apps that |
| 213 | store money). Wait for the release and its upgrade steps; never build |
| 214 | against main. |
| 215 | |
| 216 | Deploying: stamp |
| 217 | `-ldflags "-X amadan.net/rastrillo/rastrillo.BuildVersion=<sha>"` or |
| 218 | `/api/version` reports `dev`. |
| 219 | |