# TodoMVC: Go + Rastrillo

The bakeoff's TodoMVC built the way Rastrillo v0.28 recommends: a
server-rendered app that works with JavaScript off, plus one small ES
module that makes it behave like the reference TodoMVC when JavaScript is
on. Live at
[https://todo-go-rastrillo.bfj.oncarlos.com](https://todo-go-rastrillo.bfj.oncarlos.com).

## The stack, and why

| Piece | Choice | Why |
|---|---|---|
| Framework | [Rastrillo](https://rastrillo.org) v0.28.0 | The CARLOS framework. `rastrillo.Resolve` + `Serve` speak the platform contract (unix socket, `/healthz`, `/api/version`, SIGTERM drain, baseline security headers), so the app doesn't hand-roll any of it |
| Router | `github.com/go-chi/chi/v5` | Rastrillo's router: `net/http` handlers, middleware per group |
| Data | GORM over `rastrillo/db` | Rastrillo's default data layer. `db.Open` sets up SQLite (modernc, no cgo) in WAL mode with one writer connection and a reader pool |
| Migrations | `rastrillo/migrate`, generated by `rastrillo migration generate` | Ledgered, applied once at boot, additive only. `make test` runs `rastrillo migration check` |
| Visitors | `rastrillo/sessions` | Each browser's list is an anonymous session (below) |
| Scoping | `rastrillo/scope` | Every todo query goes through one `owned(r)` seam that adds `WHERE user_id = ?` |
| Forms | `rastrillo/form` | `form.Parse` reads and trims each field by name; nothing is bound onto a model |
| Templates | `html/template`, embedded | Titles are always escaped text |
| Static files | `rastrillo.NewAssets` | Pages link fingerprinted, cache-forever URLs; the bare names (`/static/base.css`) are served too, byte for byte |
| JavaScript | `static/app.js`, one ES module, no build step | Posts the page's own forms with `fetch` and swaps in the server's markup |

The UI is TodoMVC's own markup and CSS (`base.css`, `index.css`), not
Rastrillo's design system: the bakeoff requires it. `static/app.css` adds
one rule that matters, `.todoapp form { display: contents }`, so the forms
that make every action work without JavaScript don't change the layout.

### How the anonymous lists work

Rastrillo's `sessions` stores signed-in state as SQLite rows keyed by
the SHA-256 of a 256-bit random cookie token. This app treats "being
given a list" as the sign-in: the first request with no live session
creates a `visitors` row and calls `sessions.SignIn` with that row's id as
the subject (method `anonymous`). From then on `sessions.UserID(r)` is
the visitor's id and `scope.Owned` filters on it, exactly as it would for
a signed-in user. An unknown or garbled cookie resolves to no session, so
that browser gets a fresh, empty list. The cookie is
`HttpOnly; SameSite=Lax; Path=/`, and on https it's `Secure` with the
`__Host-` prefix. Lists live 400 days from the first visit instead of
sessions' default of 30.

### Cross-site protection: the contract's Origin check, not `csrf.Protect`

Rastrillo's `csrf.Protect` refuses any POST that carries none of
`Sec-Fetch-Site`, `Origin` or `Referer`. The bakeoff's shared tests and
load tests post exactly like that, and the contract allows it. Protect
also trusts `Sec-Fetch-Site` ahead of `Origin`, so it would let through a
request claiming `same-origin` that carries a foreign `Origin`. So
`origin.go` does the contract's check instead: a POST whose `Origin`
header is not exactly this app's origin gets 403, and a POST with no
`Origin` header is allowed. There's no CSRF token, as the contract says.

Form posts are capped at 1 MiB and parsed before any handler runs. A
body that's too big gets 413 and a malformed one gets 400, and neither
changes anything.

### JavaScript on and off

Every action is a plain form post that answers 303. With JavaScript off,
`<noscript>` loads `static/nojs.css`, which shows the contract's submit
controls (`[data-action="toggle"]`, `[data-action="toggle-all"]` and the
`a[data-action="edit"]` link) as transparent buttons laid over the circle,
the ❯ and the title. The page looks the same, and clicking where you'd
click with JavaScript does the same thing through a page load.

With JavaScript on, `app.js` sends the same forms with an
`X-Todo-Fragment` header, and the server answers with the new contents
of `section.todoapp` instead of a redirect. The checkboxes, double-click
editing, Enter, blur and Escape behave as in TodoMVC. Without that header,
nothing changes.

## Build, run, test, deploy

Go 1.25 or newer. On docs-t1, `source ../../scripts/env.sh` first.

```sh
make build          # dist/app for this machine
make build-arm64    # dist/app-linux-arm64, static, what ships
make test           # vet, gofmt, go test with Rastrillo's time budgets, migration check
make perf           # every GET screen under 150ms to first byte, cold boot under 500ms
```

Run it locally. It refuses any TCP address not on `127.0.0.1`, and
defaults to `127.0.0.1:8080`:

```sh
dist/app --listen 127.0.0.1:8080 --db "$TMPDIR/go-rastrillo.db"
../../scripts/test-app.sh http://127.0.0.1:8080 "$TMPDIR/go-rastrillo-suite.json"
```

On Carloku the platform starts it as `app --socket <path> --db <path>` and
sets `CARLOS_HOST`; the app's origin is then `https://$CARLOS_HOST`.
`--listen` is the bakeoff's name for Rastrillo's `--addr`, and `main.go`
translates it before `rastrillo.Resolve` reads the arguments.

```sh
make build-arm64
../../scripts/boot-check.sh dist/app-linux-arm64
make deploy MESSAGE="What changed"
```

The version `/api/version` reports is the git short SHA, stamped into
`rastrillo.BuildVersion` at build time.

`make clean` removes `dist/`. `make clean-deps` does the same, because
Go keeps compiled dependencies in the shared `GOCACHE`, which stays
untouched on this machine: `make build-arm64 COLD=1` rebuilds everything
from scratch with `-a` instead.

## Honest trade-offs

- **Trust class C (2026-10-23).** Todo titles are stored as plain text in
  SQLite, and the server can read them.
- **No sign-in.** A list belongs to a cookie. Clear it, or use another
  browser, and that list is gone for you.
- **GORM costs some speed.** Rastrillo's own measurements put GORM reads
  at about twice the time of plain `database/sql`. Rastrillo recommends it
  for CRUD apps like this one, so this entry uses it.
