feat(go-rastrillo): TodoMVC on Rastrillo v0.28

Server-rendered TodoMVC with anonymous per-visitor lists on Rastrillo
sessions, owner-scoped GORM queries, generated migrations, the
contract's Origin check over csrf.Protect, and one ES module for the
JavaScript-on behaviour.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 14e74cb18f2904aa092a31d0653a6e162fea5eb1 parent 2eeda2a
30 files changed, +2334 −0
  • apps/go-rastrillo/.rastrillo/budgets.txt +11 −0
  • apps/go-rastrillo/AGENTS.md +34 −0
  • apps/go-rastrillo/DEPS +3 −0
  • apps/go-rastrillo/Makefile +57 −0
  • apps/go-rastrillo/README.md +113 −0
  • apps/go-rastrillo/cmd/todo/main.go +96 −0
  • apps/go-rastrillo/cmd/todo/main_test.go +31 −0
  • apps/go-rastrillo/go.mod +40 −0
  • apps/go-rastrillo/go.sum +95 −0
  • apps/go-rastrillo/internal/todo/app.go +104 −0
  • apps/go-rastrillo/internal/todo/handlers.go +171 −0
  • apps/go-rastrillo/internal/todo/migrations.go +22 −0
  • apps/go-rastrillo/internal/todo/migrations/0001_create_visitors.sql +6 −0
  • apps/go-rastrillo/internal/todo/migrations/schema.sql +9 −0
  • apps/go-rastrillo/internal/todo/models.go +31 −0
  • apps/go-rastrillo/internal/todo/origin.go +31 −0
  • apps/go-rastrillo/internal/todo/render.go +66 −0
  • apps/go-rastrillo/internal/todo/static/app.css +22 −0
  • apps/go-rastrillo/internal/todo/static/app.js +89 −0
  • apps/go-rastrillo/internal/todo/static/base.css +141 −0
  • apps/go-rastrillo/internal/todo/static/index.css +393 −0
  • apps/go-rastrillo/internal/todo/static/nojs.css +53 −0
  • apps/go-rastrillo/internal/todo/templates/errors.html +13 −0
  • apps/go-rastrillo/internal/todo/templates/index.html +68 −0
  • apps/go-rastrillo/internal/todo/templates/layout.html +22 −0
  • apps/go-rastrillo/internal/todo/view.go +96 −0
  • apps/go-rastrillo/internal/todo/visitor.go +39 −0
  • apps/go-rastrillo/internal/todotest/harness_test.go +124 −0
  • apps/go-rastrillo/internal/todotest/perf_test.go +74 −0
  • apps/go-rastrillo/internal/todotest/todo_test.go +280 −0
diff --git a/apps/go-rastrillo/.rastrillo/budgets.txt b/apps/go-rastrillo/.rastrillo/budgets.txt
new file mode 100644
index 0000000..d7dac99
--- /dev/null
+++ b/apps/go-rastrillo/.rastrillo/budgets.txt
@@ -0,0 +1,11 @@
+# Exceptions to this app's budgets. Empty is the goal.
+#
+# Defaults: 5,000 source and 8,000 test lines per directory; 10s per
+# test package; 150ms to first byte per GET screen; 500ms cold boot.
+# A record exists only where something cannot meet them, and every
+# record says why. docs/site/testing.md has the full grammar.
+#
+# size <dir> <source>|- <test>|- <reason>
+# time <dir> <duration> <reason>
+# perf <METHOD> <path> <duration>|skip <reason>
+# boot <duration> <reason>
diff --git a/apps/go-rastrillo/AGENTS.md b/apps/go-rastrillo/AGENTS.md
new file mode 100644
index 0000000..636c04d
--- /dev/null
+++ b/apps/go-rastrillo/AGENTS.md
@@ -0,0 +1,34 @@
+# 🤖 AGENTS.md: todo-go-rastrillo
+
+The one rule: **a visitor's list is reachable only through that
+visitor's own cookie.** Every read and every write of a todo goes
+through the `owned(r)` seam (`scope.Owned` on the session's subject), and
+a todo that isn't in the caller's list doesn't exist: 404, never 403.
+
+## Tenancy declaration (2026-10-23)
+
+- **One instance for the whole app.** Carloku app `todo-go-rastrillo`,
+ host `todo-go-rastrillo.bfj.oncarlos.com`, channel `edge`.
+- **Each visitor's list is kept separately by an anonymous cookie.** The
+ first request without a live session creates a `visitors` row and
+ signs that browser in to it through Rastrillo's `sessions` (method
+ `anonymous`): a 256-bit random token in an `HttpOnly; SameSite=Lax;
+ Path=/` cookie, `Secure` and `__Host-` on https. Only the token's
+ SHA-256 is stored. An unknown or garbled cookie gets a fresh, empty
+ list.
+- **No sign-in.** There are no accounts, no email, no passwords. Losing
+ the cookie loses the list.
+- **Trust class C.** Todo titles are stored as plain text in the SQLite
+ file and the server can read them. That's chosen: it's a demo app
+ holding to-do items, and it's written down here and in the README.
+
+## Working rules
+
+- The binding spec is the repo's `BRIEF.md` and `CONTRACT.md`. The UI is
+ TodoMVC's markup and CSS, not Rastrillo's design system.
+- Cross-site protection is the contract's Origin check (`origin.go`),
+ not `csrf.Protect` alone: a POST with no `Origin` header is allowed.
+- Migrations only add. Never edit a shipped file in
+ `internal/todo/migrations/`; add a new one.
+- `make test` before every commit; the shared suite before every deploy.
+- Never bind anything other than `127.0.0.1` locally.
diff --git a/apps/go-rastrillo/DEPS b/apps/go-rastrillo/DEPS
new file mode 100644
index 0000000..809ab70
--- /dev/null
+++ b/apps/go-rastrillo/DEPS
@@ -0,0 +1,3 @@
+amadan.net/rastrillo/rastrillo
+github.com/go-chi/chi/v5
+gorm.io/gorm
diff --git a/apps/go-rastrillo/Makefile b/apps/go-rastrillo/Makefile
new file mode 100644
index 0000000..49c8a3f
--- /dev/null
+++ b/apps/go-rastrillo/Makefile
@@ -0,0 +1,57 @@
+# Go + Rastrillo TodoMVC. See README.md.
+#
+# GOCACHE stays at its default (a shared machine rule): COLD=1 forces a
+# from-scratch compile with -a instead of deleting the cache.
+
+APP := todo-go-rastrillo
+ACCOUNT := bfj
+VERSION ?= $(shell git rev-parse --short HEAD)
+LDFLAGS := -s -w -X 'amadan.net/rastrillo/rastrillo.BuildVersion=$(VERSION)'
+COLDFLAG := $(if $(COLD),-a,)
+RASTRILLO := go run amadan.net/rastrillo/rastrillo/cmd/rastrillo
+MESSAGE ?= Deploy $(VERSION)
+
+# The string constant make bump edits: in handlers.go, beside GET /.
+BUMP_FILE := internal/todo/handlers.go
+BUMP_FROM := const notFoundTitle = "That item is not in your list"
+BUMP_TO := const notFoundTitle = "That item is not in your list."
+
+export CGO_ENABLED = 0
+
+.PHONY: build build-arm64 test perf migration-check clean clean-deps bump unbump deploy
+
+build:
+ go build $(COLDFLAG) -trimpath -ldflags="$(LDFLAGS)" -o dist/app ./cmd/todo
+
+build-arm64:
+ GOOS=linux GOARCH=arm64 go build $(COLDFLAG) -trimpath -ldflags="$(LDFLAGS)" -o dist/app-linux-arm64 ./cmd/todo
+
+# Rastrillo's test runner: go test plus each package's time budget.
+test:
+ go vet ./...
+ test -z "$$(gofmt -l .)" || { gofmt -l .; exit 1; }
+ $(RASTRILLO) budget test ./...
+ $(RASTRILLO) migration check
+
+# Every GET screen at 150ms to first byte, cold boot at 500ms. Serial
+# and uncached, because a measurement taken beside other work isn't one.
+perf:
+ $(RASTRILLO) budget test -no-time -require TestPerfScreens,TestPerfBoot -tags perf -count=1 -p 1 -parallel 1 -run '^TestPerf' ./...
+
+clean:
+ rm -rf dist
+
+# Go keeps compiled packages in the shared GOCACHE, which this target
+# leaves alone; `make build-arm64 COLD=1` recompiles everything with -a.
+clean-deps: clean
+
+bump:
+ sed -i 's/^$(BUMP_FROM)$$/$(BUMP_TO)/' $(BUMP_FILE)
+ grep -qxF '$(BUMP_TO)' $(BUMP_FILE)
+
+unbump:
+ sed -i 's/^$(BUMP_TO)$$/$(BUMP_FROM)/' $(BUMP_FILE)
+ grep -qxF '$(BUMP_FROM)' $(BUMP_FILE)
+
+deploy:
+ carlos deploy --account $(ACCOUNT) --app $(APP) --target linux-arm64 --message "$(MESSAGE)" dist/app-linux-arm64
diff --git a/apps/go-rastrillo/README.md b/apps/go-rastrillo/README.md
new file mode 100644
index 0000000..252d675
--- /dev/null
+++ b/apps/go-rastrillo/README.md
@@ -0,0 +1,113 @@
+# 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 Origin check, not `csrf.Protect` alone
+
+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, so
+`origin.go` wraps it: a POST with no `Origin` header goes straight
+through, and every POST that has one goes through `csrf.Protect`, so a
+foreign origin gets 403. There's no CSRF token, as the contract says.
+
+### 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 only ever binds what you give it, 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.
diff --git a/apps/go-rastrillo/cmd/todo/main.go b/apps/go-rastrillo/cmd/todo/main.go
new file mode 100644
index 0000000..b74e291
--- /dev/null
+++ b/apps/go-rastrillo/cmd/todo/main.go
@@ -0,0 +1,96 @@
+// Command todo is the bakeoff's Go + Rastrillo TodoMVC: resolve the
+// platform's activation argv, open the database, build the app, serve.
+//
+// todo --socket <path> --db <path> on Carloku
+// todo --listen 127.0.0.1:<port> --db <path> locally
+package main
+
+import (
+ "log/slog"
+ "net"
+ "os"
+ "strings"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/db"
+
+ todo "todo/internal/todo"
+)
+
+// localAddr is where the app listens when it is given neither a socket
+// nor an address: loopback only, never every interface.
+const localAddr = "127.0.0.1:8080"
+
+func main() {
+ // First, so perf's cold budget counts the whole start: a hibernated
+ // instance pays all of it on someone's click.
+ started := time.Now()
+ logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
+
+ // The bakeoff's local flag is --listen; Rastrillo's is --addr.
+ os.Args = listenAsAddr(os.Args)
+
+ // Resolve, not Run: this app opens its own database handle via
+ // db.Open, so Options.DBPath must be empty again by the Serve call
+ // below, or Serve would open a second connection to the same file.
+ opts, err := rastrillo.Resolve(rastrillo.Options{DBPath: "todo.db", Logger: logger})
+ if err != nil {
+ logger.Error("resolve activation", "err", err)
+ os.Exit(1)
+ }
+ if opts.Socket == "" && opts.Addr == "" && os.Getenv("LISTEN_FDS") == "" {
+ opts.Addr = localAddr
+ }
+
+ d, err := db.Open(opts.DBPath, logger)
+ if err != nil {
+ logger.Error("open database", "err", err)
+ os.Exit(1)
+ }
+ defer d.Close()
+
+ o := origin(opts.Addr)
+ mux, err := todo.App(d, o, logger)
+ if err != nil {
+ logger.Error("build app", "err", err)
+ os.Exit(1)
+ }
+
+ todo.Configure(&opts, mux, started)
+ opts.DBPath = ""
+ logger.Info("serving", "origin", o, "socket", opts.Socket, "addr", opts.Addr)
+ if err := rastrillo.Serve(opts); err != nil {
+ logger.Error("serve failed", "err", err)
+ os.Exit(1)
+ }
+}
+
+// origin is the app's own origin: https://$CARLOS_HOST on Carloku, and
+// http://<listen address> when run locally.
+func origin(addr string) string {
+ if host := os.Getenv("CARLOS_HOST"); host != "" {
+ return "https://" + host
+ }
+ if host, port, err := net.SplitHostPort(addr); err == nil && host != "" {
+ return "http://" + net.JoinHostPort(host, port)
+ }
+ return "http://" + localAddr
+}
+
+// listenAsAddr rewrites --listen (and -listen, --listen=x) to Rastrillo's
+// --addr, leaving every other argument alone.
+func listenAsAddr(args []string) []string {
+ out := make([]string, 0, len(args))
+ for _, a := range args {
+ name, value, hasValue := strings.Cut(a, "=")
+ if name == "--listen" || name == "-listen" {
+ a = "--addr"
+ if hasValue {
+ a += "=" + value
+ }
+ }
+ out = append(out, a)
+ }
+ return out
+}
diff --git a/apps/go-rastrillo/cmd/todo/main_test.go b/apps/go-rastrillo/cmd/todo/main_test.go
new file mode 100644
index 0000000..e5e7a15
--- /dev/null
+++ b/apps/go-rastrillo/cmd/todo/main_test.go
@@ -0,0 +1,31 @@
+package main
+
+import (
+ "os"
+ "slices"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo/budget"
+)
+
+func TestMain(m *testing.M) { os.Exit(budget.Main(m)) }
+
+func TestListenAsAddr(t *testing.T) {
+ t.Parallel()
+ got := listenAsAddr([]string{"app", "--listen", "127.0.0.1:1", "-listen=127.0.0.1:2", "--db", "x.db", "--socket", "s"})
+ want := []string{"app", "--addr", "127.0.0.1:1", "--addr=127.0.0.1:2", "--db", "x.db", "--socket", "s"}
+ if !slices.Equal(got, want) {
+ t.Errorf("got %q, want %q", got, want)
+ }
+}
+
+func TestOrigin(t *testing.T) {
+ t.Setenv("CARLOS_HOST", "")
+ if got := origin("127.0.0.1:9000"); got != "http://127.0.0.1:9000" {
+ t.Errorf("local origin %q", got)
+ }
+ t.Setenv("CARLOS_HOST", "todo-go-rastrillo.bfj.oncarlos.com")
+ if got := origin(""); got != "https://todo-go-rastrillo.bfj.oncarlos.com" {
+ t.Errorf("carloku origin %q", got)
+ }
+}
diff --git a/apps/go-rastrillo/go.mod b/apps/go-rastrillo/go.mod
new file mode 100644
index 0000000..5656330
--- /dev/null
+++ b/apps/go-rastrillo/go.mod
@@ -0,0 +1,40 @@
+module todo
+
+go 1.25.0
+
+require (
+ amadan.net/rastrillo/rastrillo v0.28.0
+ github.com/go-chi/chi/v5 v5.3.2
+ gorm.io/gorm v1.31.2
+)
+
+require (
+ amadan.net/rastrillo/rastrillo/money v0.1.0 // indirect
+ github.com/BurntSushi/toml v1.6.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/jinzhu/inflection v1.0.0 // indirect
+ github.com/jinzhu/now v1.1.5 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/mod v0.37.0 // indirect
+ golang.org/x/sys v0.46.0 // indirect
+ golang.org/x/text v0.39.0 // indirect
+ gorm.io/plugin/dbresolver v1.6.2 // indirect
+ modernc.org/libc v1.74.1 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
+ modernc.org/sqlite v1.55.0 // indirect
+)
+
+// The CLI this app's own gate runs: make migration-check, and
+// rastrillo generate once the app declares manifest resources. It is
+// declared as a tool so that go mod tidy records the CLI's build
+// dependencies in go.sum alongside the app's own. Nothing an app
+// imports reaches, say, the TOML parser behind manifest, so without
+// this line tidy prunes it and the first make ci on a clean scaffold
+// dies with "missing go.sum entry for github.com/BurntSushi/toml" —
+// a dependency that is real at go run time and invisible at tidy
+// time. Deleting this line breaks the gate, not the app.
+tool amadan.net/rastrillo/rastrillo/cmd/rastrillo
diff --git a/apps/go-rastrillo/go.sum b/apps/go-rastrillo/go.sum
new file mode 100644
index 0000000..01ab5c5
--- /dev/null
+++ b/apps/go-rastrillo/go.sum
@@ -0,0 +1,95 @@
+amadan.net/rastrillo/rastrillo v0.28.0 h1:pG7qsJ7x4+R7fMDlYa8ldKZNKQOYBNZzU6XQcEyzvXY=
+amadan.net/rastrillo/rastrillo v0.28.0/go.mod h1:WmC8TAIfqjAkSwmD+FCTqr7VuqAO5FaX03HlV49K608=
+amadan.net/rastrillo/rastrillo/money v0.1.0 h1:MxU4oqNrW8abxRTkgZBv24OYNpFnvonajNqeLIzmmaM=
+amadan.net/rastrillo/rastrillo/money v0.1.0/go.mod h1:cmgkjBEIQh1pFSYvQk0UU6oPYS8kSDypdT9+t4Flx0U=
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
+github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
+github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
+github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
+github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
+github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
+github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
+github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
+github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
+github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q=
+github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
+github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
+golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
+golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
+golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
+golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
+golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
+golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
+golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
+golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
+gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
+gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
+gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
+gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
+gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
+gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc=
+gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM=
+modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
+modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
+modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU=
+modernc.org/ccgo/v4 v4.34.6/go.mod h1:SZ8YcN9NG7XVsQYdm6jYBvi8PQP1qi+kqB6OhjqI3Fk=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
+modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.74.1 h1:bdR4VTKFMC4966QSNZ05XLGI/VwzVa2kTUX51Dm0riQ=
+modernc.org/libc v1.74.1/go.mod h1:uH4t5bOx3G3g9Xcmj10YKlTcVISlRDwv8VoQJG9n8Os=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
+modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.55.0 h1:hIFh0MCH0rGinQ/4KYb5/UbCkRkb+UP+OkLCVWa5MTM=
+modernc.org/sqlite v1.55.0/go.mod h1:4ntCLuNmnH8+GNqjka1wNg7KJd5/Hi5FYp8K+XQ7GZw=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/apps/go-rastrillo/internal/todo/app.go b/apps/go-rastrillo/internal/todo/app.go
new file mode 100644
index 0000000..29b8e8d
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/app.go
@@ -0,0 +1,104 @@
+package todo
+
+import (
+ "context"
+ "log/slog"
+ "net/http"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/perf"
+ "amadan.net/rastrillo/rastrillo/sessions"
+)
+
+// visitorTTL is how long an anonymous list lives from its first visit.
+// Sessions' default is 30 days, which suits a sign-in that can be
+// repeated; a list cookie that can't be got back should outlast that.
+const visitorTTL = 400 * 24 * time.Hour
+
+// App wires the whole app: schema, router, static files. origin is the
+// app's own external origin ("https://todo-go-rastrillo.bfj.oncarlos.com",
+// or "http://127.0.0.1:8080" locally): the Origin check's yardstick and
+// what decides the session cookie's Secure and __Host- attributes.
+func App(d *db.DB, origin string, logger *slog.Logger) (*http.ServeMux, error) {
+ r, err := Router(d, origin, logger)
+ if err != nil {
+ return nil, err
+ }
+ return Mux(r), nil
+}
+
+// Router applies the schema and builds the routes.
+func Router(d *db.DB, origin string, logger *slog.Logger) (chi.Router, error) {
+ migrated, err := migrate.Apply(context.Background(), d, BootSchema)
+ if err != nil {
+ return nil, err
+ }
+ logger.Info("migrate", "applied", migrated.Applied,
+ "skipped", migrated.Skipped, "adopted", migrated.Adopted)
+
+ writer, err := d.G.DB()
+ if err != nil {
+ return nil, err
+ }
+ sess, err := sessions.New(sessions.Config{DB: writer, Origin: origin, TTL: visitorTTL, Logger: logger})
+ if err != nil {
+ return nil, err
+ }
+
+ a := &app{db: d.G, sess: sess, logger: logger}
+
+ r := chi.NewRouter()
+ // App-wide, above every route: the contract's cross-site check.
+ r.Use(originCheck(origin))
+ r.Use(a.visitor)
+ r.Get("/", a.page(filterAll))
+ r.Get("/active", a.page(filterActive))
+ r.Get("/completed", a.page(filterCompleted))
+ r.Route("/todos", func(r chi.Router) {
+ r.Use(limitBody)
+ r.Post("/", a.create)
+ r.Post("/toggle-all", a.toggleAll)
+ r.Post("/clear-completed", a.clearCompleted)
+ r.Post("/{id}/toggle", a.toggle)
+ r.Post("/{id}/edit", a.edit)
+ r.Post("/{id}/destroy", a.destroy)
+ })
+ return r, nil
+}
+
+// Mux mounts the static files beside the router. The stylesheets are
+// served under their bare names too (/static/base.css), byte for byte,
+// which the contract requires; pages link the fingerprinted names.
+func Mux(r http.Handler) *http.ServeMux {
+ mux := http.NewServeMux()
+ mux.Handle("GET /static/", assets.Handler())
+ mux.Handle("/", r)
+ return mux
+}
+
+// Configure is the one place the serving options are set, so main.go
+// and the perf test cannot drift apart.
+func Configure(opts *rastrillo.Options, mux *http.ServeMux, started time.Time) {
+ opts.Mux = mux
+ opts.ErrorPage = ErrorPage
+ // Every page depends on the visitor's cookie, so there is nothing
+ // worth prerendering, and a prerendered GET would mint a list.
+ opts.NoSpeculationRules = true
+ var rec perf.Recorder
+ opts.Wrap = perf.Middleware(&rec, perf.Options{Started: started, Logger: opts.Logger})
+}
+
+// limitBody caps a form post at 1 MiB: room for a title far past the
+// contract's 10,000 characters, and no room for a body meant to fill
+// the disk.
+func limitBody(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ r.Body = http.MaxBytesReader(w, r.Body, 1<<20)
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/apps/go-rastrillo/internal/todo/handlers.go b/apps/go-rastrillo/internal/todo/handlers.go
new file mode 100644
index 0000000..c66a10d
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/handlers.go
@@ -0,0 +1,171 @@
+package todo
+
+import (
+ "log/slog"
+ "net/http"
+ "strconv"
+
+ "github.com/go-chi/chi/v5"
+ "gorm.io/gorm"
+
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/form"
+ "amadan.net/rastrillo/rastrillo/scope"
+ "amadan.net/rastrillo/rastrillo/sessions"
+)
+
+// notFoundTitle is the 404 page's heading: an item that isn't in your
+// list. (make bump edits this line.)
+const notFoundTitle = "That item is not in your list"
+
+// app holds what every handler needs.
+type app struct {
+ db *gorm.DB
+ sess *sessions.Sessions
+ logger *slog.Logger
+}
+
+// owned is the one seam every todo query goes through: reads and
+// writes alike carry WHERE user_id = <this visitor>.
+func (a *app) owned(r *http.Request) *gorm.DB {
+ uid, _ := sessions.UserID(r)
+ return scope.Owned(a.db.WithContext(r.Context()), uid)
+}
+
+// page serves one filter's view of the list, with ?edit=<id> opening
+// that item's editor.
+func (a *app) page(f filter) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var todos []Todo
+ if err := a.owned(r).Order("id").Find(&todos).Error; err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ editing, _ := strconv.ParseInt(r.URL.Query().Get("edit"), 10, 64)
+ w.Header().Set("Cache-Control", "no-store")
+ render(w, "index", newView(todos, f, editing))
+ }
+}
+
+func (a *app) create(w http.ResponseWriter, r *http.Request) {
+ p := form.Parse(r, form.Field{Name: "title"})
+ if title := p.String("title"); title != "" {
+ uid, _ := sessions.UserID(r)
+ if err := a.db.WithContext(r.Context()).Create(&Todo{UserID: uid, Title: title}).Error; err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ }
+ a.done(w, r)
+}
+
+func (a *app) toggle(w http.ResponseWriter, r *http.Request) {
+ id, ok := itemID(r)
+ if !ok {
+ a.notFound(w, r)
+ return
+ }
+ res := a.owned(r).Model(&Todo{}).Where("id = ?", id).
+ Update("completed", gorm.Expr("NOT completed"))
+ a.afterItemWrite(w, r, res)
+}
+
+func (a *app) edit(w http.ResponseWriter, r *http.Request) {
+ id, ok := itemID(r)
+ if !ok {
+ a.notFound(w, r)
+ return
+ }
+ p := form.Parse(r, form.Field{Name: "title"})
+ title := p.String("title")
+ var res *gorm.DB
+ if title == "" {
+ res = a.owned(r).Where("id = ?", id).Delete(&Todo{})
+ } else {
+ res = a.owned(r).Model(&Todo{}).Where("id = ?", id).Update("title", title)
+ }
+ a.afterItemWrite(w, r, res)
+}
+
+func (a *app) destroy(w http.ResponseWriter, r *http.Request) {
+ id, ok := itemID(r)
+ if !ok {
+ a.notFound(w, r)
+ return
+ }
+ a.afterItemWrite(w, r, a.owned(r).Where("id = ?", id).Delete(&Todo{}))
+}
+
+// toggleAll completes every item, or reopens them all when every one
+// already was: one statement, so it can't race a concurrent toggle.
+func (a *app) toggleAll(w http.ResponseWriter, r *http.Request) {
+ uid, _ := sessions.UserID(r)
+ err := a.owned(r).Model(&Todo{}).Where("1 = 1").Update("completed",
+ gorm.Expr("EXISTS (SELECT 1 FROM todos WHERE user_id = ? AND NOT completed)", uid)).Error
+ if err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ a.done(w, r)
+}
+
+func (a *app) clearCompleted(w http.ResponseWriter, r *http.Request) {
+ if err := a.owned(r).Where("completed").Delete(&Todo{}).Error; err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ a.done(w, r)
+}
+
+// afterItemWrite finishes a write to one item: an error is a 500, no
+// row touched means the id isn't in this visitor's list (404).
+func (a *app) afterItemWrite(w http.ResponseWriter, r *http.Request, res *gorm.DB) {
+ switch {
+ case res.Error != nil:
+ a.fail(w, r, res.Error)
+ case res.RowsAffected == 0:
+ a.notFound(w, r)
+ default:
+ a.done(w, r)
+ }
+}
+
+// done answers a successful action: 303 back to the filter the form
+// came from, or, for the page's own script, the list's new markup.
+func (a *app) done(w http.ResponseWriter, r *http.Request) {
+ f := parseFilter(r.PostFormValue("filter"))
+ if !wantsFragment(r) {
+ http.Redirect(w, r, f.path(), http.StatusSeeOther)
+ return
+ }
+ var todos []Todo
+ if err := a.owned(r).Order("id").Find(&todos).Error; err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ render(w, "app", newView(todos, f, 0))
+}
+
+func (a *app) notFound(w http.ResponseWriter, r *http.Request) {
+ errorPage(w, http.StatusNotFound, notFoundTitle)
+}
+
+// fail logs err under a fresh ref and renders the 500 page carrying it.
+func (a *app) fail(w http.ResponseWriter, r *http.Request, err error) {
+ ref := rastrillo.NewRef()
+ a.logger.Error("request failed", "ref", ref, "method", r.Method, "path", r.URL.Path, "err", err)
+ ErrorPage(w, r, http.StatusInternalServerError, ref)
+}
+
+// itemID reads {id}. A malformed id is a row that doesn't exist.
+func itemID(r *http.Request) (int64, bool) {
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ return id, err == nil && id > 0
+}
+
+// fragmentHeader is what the page's script sends to get the list's
+// markup back instead of a 303. Without it nothing changes.
+const fragmentHeader = "X-Todo-Fragment"
+
+func wantsFragment(r *http.Request) bool { return r.Header.Get(fragmentHeader) != "" }
diff --git a/apps/go-rastrillo/internal/todo/migrations.go b/apps/go-rastrillo/internal/todo/migrations.go
new file mode 100644
index 0000000..199b616
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/migrations.go
@@ -0,0 +1,22 @@
+package todo
+
+import (
+ "embed"
+
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/sessions"
+)
+
+//go:embed migrations/*.sql
+var migrationFS embed.FS
+
+// Schema is this app's own migrations, in order, and nothing else: it is
+// what `rastrillo migration generate` and `rastrillo migration check`
+// diff against Models. Migrations apply once at boot and are never
+// re-run or reversed; never edit one that has shipped, add a new one.
+var Schema = migrate.MustFromFS(migrationFS, "todo")
+
+// BootSchema is everything applied at boot: the sessions table first,
+// then the app's own. Subsystems go here, never in Schema, or check
+// would propose dropping their tables.
+var BootSchema = migrate.Merge(sessions.Schema, Schema)
diff --git a/apps/go-rastrillo/internal/todo/migrations/0001_create_visitors.sql b/apps/go-rastrillo/internal/todo/migrations/0001_create_visitors.sql
new file mode 100644
index 0000000..190a3ae
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/migrations/0001_create_visitors.sql
@@ -0,0 +1,6 @@
+CREATE TABLE `visitors` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime);
+
+CREATE TABLE `todos` (`id` integer PRIMARY KEY AUTOINCREMENT,`user_id` integer NOT NULL,`title` text,`completed` numeric NOT NULL DEFAULT false,`created_at` datetime,`updated_at` datetime);
+
+CREATE INDEX `idx_todos_user_id` ON `todos`(`user_id`);
+
diff --git a/apps/go-rastrillo/internal/todo/migrations/schema.sql b/apps/go-rastrillo/internal/todo/migrations/schema.sql
new file mode 100644
index 0000000..f239a44
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/migrations/schema.sql
@@ -0,0 +1,9 @@
+-- Generated by rastrillo migration generate; DO NOT EDIT.
+-- The schema every migration in this directory adds up to.
+
+CREATE TABLE `todos` (`id` integer PRIMARY KEY AUTOINCREMENT,`user_id` integer NOT NULL,`title` text,`completed` numeric NOT NULL DEFAULT false,`created_at` datetime,`updated_at` datetime);
+
+CREATE TABLE `visitors` (`id` integer PRIMARY KEY AUTOINCREMENT,`created_at` datetime);
+
+CREATE INDEX `idx_todos_user_id` ON `todos`(`user_id`);
+
diff --git a/apps/go-rastrillo/internal/todo/models.go b/apps/go-rastrillo/internal/todo/models.go
new file mode 100644
index 0000000..926c709
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/models.go
@@ -0,0 +1,31 @@
+package todo
+
+import "time"
+
+// Models is every model the schema generator manages. Keep it in step
+// with the structs below: `rastrillo migration generate` reads it to
+// work out what the database should look like, and
+// `rastrillo migration check` fails when it and the migrations disagree.
+var Models = []any{
+ &Visitor{},
+ &Todo{},
+}
+
+// Visitor is one anonymous browser: the owner of one list. Its ID is
+// the session subject (visitor.go), so sessions.UserID returns it and
+// scope.Owned filters on it like any signed-in user's id.
+type Visitor struct {
+ ID int64
+ CreatedAt time.Time
+}
+
+// Todo is one item in a visitor's list. UserID is the owning visitor:
+// Rastrillo's owner column, the one scope.Owned filters on.
+type Todo struct {
+ ID int64
+ UserID int64 `gorm:"index;not null"`
+ Title string
+ Completed bool `gorm:"not null;default:false"`
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
diff --git a/apps/go-rastrillo/internal/todo/origin.go b/apps/go-rastrillo/internal/todo/origin.go
new file mode 100644
index 0000000..8c9abea
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/origin.go
@@ -0,0 +1,31 @@
+package todo
+
+import (
+ "net/http"
+
+ "amadan.net/rastrillo/rastrillo/csrf"
+)
+
+// originCheck is the contract's cross-site protection: a POST carrying
+// an Origin header that isn't this app's own answers 403, and a POST
+// with no Origin header is allowed.
+//
+// It is csrf.Protect with one exception. Protect refuses a POST that
+// carries none of Sec-Fetch-Site, Origin and Referer, because no current
+// browser sends a form that way. The bakeoff's shared tests and load
+// tests do post that way, and the contract allows it, so those requests
+// pass. Everything that does carry an Origin goes through Rastrillo's
+// own check (csrf.SameOrigin), Sec-Fetch-Site first.
+func originCheck(origin string) func(http.Handler) http.Handler {
+ protect := csrf.Protect(origin)
+ return func(next http.Handler) http.Handler {
+ protected := protect(next)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Header.Get("Origin") == "" {
+ next.ServeHTTP(w, r)
+ return
+ }
+ protected.ServeHTTP(w, r)
+ })
+ }
+}
diff --git a/apps/go-rastrillo/internal/todo/render.go b/apps/go-rastrillo/internal/todo/render.go
new file mode 100644
index 0000000..168962d
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/render.go
@@ -0,0 +1,66 @@
+package todo
+
+import (
+ "bytes"
+ "embed"
+ "html/template"
+ "net/http"
+ "strconv"
+
+ "amadan.net/rastrillo/rastrillo"
+)
+
+//go:embed templates static
+var appFS embed.FS
+
+// assets fingerprints static/: {{asset "static/index.css"}} returns a
+// content-hashed URL served immutable-cacheable, and the bare name
+// (/static/index.css) is served too, never long-cached.
+var assets = rastrillo.NewAssets(appFS)
+
+var templates = template.Must(template.New("").
+ Funcs(template.FuncMap{
+ "asset": assets.Path,
+ "plural": func(n int) string {
+ if n == 1 {
+ return "item"
+ }
+ return "items"
+ },
+ }).
+ ParseFS(appFS, "templates/*.html"))
+
+// render executes a template into a buffer before anything touches the
+// wire, so a template error becomes a clean 500 and the response
+// carries an exact Content-Length.
+func render(w http.ResponseWriter, name string, data any) {
+ renderStatus(w, http.StatusOK, name, data)
+}
+
+func renderStatus(w http.ResponseWriter, status int, name string, data any) {
+ var buf bytes.Buffer
+ if err := templates.ExecuteTemplate(&buf, name, data); err != nil {
+ http.Error(w, "something went wrong", http.StatusInternalServerError)
+ return
+ }
+ h := w.Header()
+ h.Set("Content-Type", "text/html; charset=utf-8")
+ h.Set("Content-Length", strconv.Itoa(buf.Len()))
+ w.WriteHeader(status)
+ buf.WriteTo(w)
+}
+
+// ErrorPage is the rastrillo.ErrorPageFunc Options.ErrorPage points at,
+// so a panic gets the same page a handled failure does: TodoMVC's frame
+// with the status and, for a 500, the ref the log line carries.
+func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
+ title := http.StatusText(status)
+ if status == http.StatusInternalServerError {
+ title = "Something went wrong on our side"
+ }
+ renderStatus(w, status, "errors", map[string]any{"Title": title, "Ref": ref})
+}
+
+func errorPage(w http.ResponseWriter, status int, title string) {
+ renderStatus(w, status, "errors", map[string]any{"Title": title})
+}
diff --git a/apps/go-rastrillo/internal/todo/static/app.css b/apps/go-rastrillo/internal/todo/static/app.css
new file mode 100644
index 0000000..064820c
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/static/app.css
@@ -0,0 +1,22 @@
+/* The app's own layer over TodoMVC's base.css and index.css. */
+
+/* Forms wrap the controls so every action works without JavaScript.
+ display: contents takes the form boxes out of layout, so the page lays
+ out exactly as TodoMVC's own template. */
+.todoapp form {
+ display: contents;
+}
+
+/* The controls a page without JavaScript needs. nojs.css, loaded inside
+ <noscript>, shows them; with JavaScript the script drives the page's
+ own checkboxes and double-click editing instead. */
+.nojs {
+ display: none;
+}
+
+.todoapp .error p {
+ margin: 0;
+ padding: 15px 15px 15px 60px;
+ font-size: 18px;
+ line-height: 1.4;
+}
diff --git a/apps/go-rastrillo/internal/todo/static/app.js b/apps/go-rastrillo/internal/todo/static/app.js
new file mode 100644
index 0000000..912db0a
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/static/app.js
@@ -0,0 +1,89 @@
+// TodoMVC behaviour on top of the server-rendered page. Every action is
+// still the page's own form post; this script sends it with fetch and
+// the X-Todo-Fragment header, and the server answers with the new
+// contents of section.todoapp instead of a 303. Without this script the
+// forms post normally and nothing here is needed.
+
+const app = document.querySelector('.todoapp');
+
+// Posts run one at a time, in the order they were made, so a quick
+// toggle-then-delete can't land the other way round.
+let queue = Promise.resolve();
+
+function send(form) {
+ const body = new URLSearchParams(new FormData(form));
+ const addsTodo = form.querySelector('.new-todo') !== null;
+ queue = queue
+ .then(() => fetch(form.action, {
+ method: 'POST',
+ body,
+ headers: { 'X-Todo-Fragment': '1' },
+ credentials: 'same-origin',
+ }))
+ .then((res) => {
+ if (!res.ok) throw new Error(`${res.status} from ${form.action}`);
+ return res.text();
+ })
+ .then((html) => swap(html, addsTodo))
+ .catch(() => location.reload());
+}
+
+// swap replaces the list with the server's markup, keeping whatever the
+// visitor has half-typed into the new-todo field (unless that field was
+// what just posted, in which case it's cleared, as TodoMVC does).
+function swap(html, addsTodo) {
+ const input = app.querySelector('.new-todo');
+ const typing = !addsTodo && input.value;
+ const focused = addsTodo || document.activeElement === input;
+ app.innerHTML = html;
+ const next = app.querySelector('.new-todo');
+ if (typing) next.value = typing;
+ if (focused) next.focus();
+}
+
+app.addEventListener('submit', (e) => {
+ e.preventDefault();
+ send(e.target);
+});
+
+// The checkboxes are the page's own controls with JavaScript on.
+app.addEventListener('change', (e) => {
+ if (e.target.matches('.toggle, .toggle-all')) send(e.target.form);
+});
+
+// Double-click a title to edit it.
+app.addEventListener('dblclick', (e) => {
+ const label = e.target.closest('.todo-list label');
+ if (!label) return;
+ const li = label.closest('li');
+ const input = li.querySelector('.edit');
+ li.classList.add('editing');
+ input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
+});
+
+// Enter saves (the edit form's own submit), Escape cancels.
+app.addEventListener('keydown', (e) => {
+ if (!e.target.matches('.edit') || e.key !== 'Escape') return;
+ const input = e.target;
+ const li = input.closest('li');
+ input.value = input.defaultValue;
+ li.classList.remove('editing');
+ input.blur();
+});
+
+// Leaving the editor saves it, unless Escape already closed it.
+app.addEventListener('focusout', (e) => {
+ if (!e.target.matches('.edit')) return;
+ const li = e.target.closest('li');
+ if (!li.classList.contains('editing') || li.dataset.saving) return;
+ li.dataset.saving = '1';
+ send(e.target.form);
+});
+
+// Enter's submit also blurs as the list is replaced; mark it so the
+// focusout above doesn't post the same edit twice.
+app.addEventListener('submit', (e) => {
+ const li = e.target.closest('li');
+ if (li) li.dataset.saving = '1';
+}, true);
diff --git a/apps/go-rastrillo/internal/todo/static/base.css b/apps/go-rastrillo/internal/todo/static/base.css
new file mode 100644
index 0000000..da65968
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/static/base.css
@@ -0,0 +1,141 @@
+hr {
+ margin: 20px 0;
+ border: 0;
+ border-top: 1px dashed #c5c5c5;
+ border-bottom: 1px dashed #f7f7f7;
+}
+
+.learn a {
+ font-weight: normal;
+ text-decoration: none;
+ color: #b83f45;
+}
+
+.learn a:hover {
+ text-decoration: underline;
+ color: #787e7e;
+}
+
+.learn h3,
+.learn h4,
+.learn h5 {
+ margin: 10px 0;
+ font-weight: 500;
+ line-height: 1.2;
+ color: #000;
+}
+
+.learn h3 {
+ font-size: 24px;
+}
+
+.learn h4 {
+ font-size: 18px;
+}
+
+.learn h5 {
+ margin-bottom: 0;
+ font-size: 14px;
+}
+
+.learn ul {
+ padding: 0;
+ margin: 0 0 30px 25px;
+}
+
+.learn li {
+ line-height: 20px;
+}
+
+.learn p {
+ font-size: 15px;
+ font-weight: 300;
+ line-height: 1.3;
+ margin-top: 0;
+ margin-bottom: 0;
+}
+
+#issue-count {
+ display: none;
+}
+
+.quote {
+ border: none;
+ margin: 20px 0 60px 0;
+}
+
+.quote p {
+ font-style: italic;
+}
+
+.quote p:before {
+ content: '“';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ top: -20px;
+ left: 3px;
+}
+
+.quote p:after {
+ content: '”';
+ font-size: 50px;
+ opacity: .15;
+ position: absolute;
+ bottom: -42px;
+ right: 3px;
+}
+
+.quote footer {
+ position: absolute;
+ bottom: -40px;
+ right: 0;
+}
+
+.quote footer img {
+ border-radius: 3px;
+}
+
+.quote footer a {
+ margin-left: 5px;
+ vertical-align: middle;
+}
+
+.speech-bubble {
+ position: relative;
+ padding: 10px;
+ background: rgba(0, 0, 0, .04);
+ border-radius: 5px;
+}
+
+.speech-bubble:after {
+ content: '';
+ position: absolute;
+ top: 100%;
+ right: 30px;
+ border: 13px solid transparent;
+ border-top-color: rgba(0, 0, 0, .04);
+}
+
+.learn-bar > .learn {
+ position: absolute;
+ width: 272px;
+ top: 8px;
+ left: -300px;
+ padding: 10px;
+ border-radius: 5px;
+ background-color: rgba(255, 255, 255, .6);
+ transition-property: left;
+ transition-duration: 500ms;
+}
+
+@media (min-width: 899px) {
+ .learn-bar {
+ width: auto;
+ padding-left: 300px;
+ }
+
+ .learn-bar > .learn {
+ left: 8px;
+ }
+}
diff --git a/apps/go-rastrillo/internal/todo/static/index.css b/apps/go-rastrillo/internal/todo/static/index.css
new file mode 100644
index 0000000..2c0b4b6
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/static/index.css
@@ -0,0 +1,393 @@
+@charset 'utf-8';
+
+html,
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font-size: 100%;
+ vertical-align: baseline;
+ font-family: inherit;
+ font-weight: inherit;
+ color: inherit;
+ -webkit-appearance: none;
+ appearance: none;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+body {
+ font: 14px 'Helvetica Neue', Helvetica, Arial, sans-serif;
+ line-height: 1.4em;
+ background: #f5f5f5;
+ color: #111111;
+ min-width: 230px;
+ max-width: 550px;
+ margin: 0 auto;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ font-weight: 300;
+}
+
+.hidden {
+ display: none;
+}
+
+.todoapp {
+ background: #fff;
+ margin: 130px 0 40px 0;
+ position: relative;
+ box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.2),
+ 0 25px 50px 0 rgba(0, 0, 0, 0.1);
+}
+
+.todoapp input::-webkit-input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::-moz-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp input::input-placeholder {
+ font-style: italic;
+ font-weight: 400;
+ color: rgba(0, 0, 0, 0.4);
+}
+
+.todoapp h1 {
+ position: absolute;
+ top: -140px;
+ width: 100%;
+ font-size: 80px;
+ font-weight: 200;
+ text-align: center;
+ color: #b83f45;
+ -webkit-text-rendering: optimizeLegibility;
+ -moz-text-rendering: optimizeLegibility;
+ text-rendering: optimizeLegibility;
+}
+
+.new-todo,
+.edit {
+ position: relative;
+ margin: 0;
+ width: 100%;
+ font-size: 24px;
+ font-family: inherit;
+ font-weight: inherit;
+ line-height: 1.4em;
+ color: inherit;
+ padding: 6px;
+ border: 1px solid #999;
+ box-shadow: inset 0 -1px 5px 0 rgba(0, 0, 0, 0.2);
+ box-sizing: border-box;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+}
+
+.new-todo {
+ padding: 16px 16px 16px 60px;
+ height: 65px;
+ border: none;
+ background: rgba(0, 0, 0, 0.003);
+ box-shadow: inset 0 -2px 1px rgba(0,0,0,0.03);
+}
+
+.main {
+ position: relative;
+ z-index: 2;
+ border-top: 1px solid #e6e6e6;
+}
+
+.toggle-all {
+ width: 1px;
+ height: 1px;
+ border: none; /* Mobile Safari */
+ opacity: 0;
+ position: absolute;
+ right: 100%;
+ bottom: 100%;
+}
+
+.toggle-all + label {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 45px;
+ height: 65px;
+ font-size: 0;
+ position: absolute;
+ top: -65px;
+ left: -0;
+}
+
+.toggle-all + label:before {
+ content: '❯';
+ display: inline-block;
+ font-size: 22px;
+ color: #949494;
+ padding: 10px 27px 10px 27px;
+ -webkit-transform: rotate(90deg);
+ transform: rotate(90deg);
+}
+
+.toggle-all:checked + label:before {
+ color: #484848;
+}
+
+.todo-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.todo-list li {
+ position: relative;
+ font-size: 24px;
+ border-bottom: 1px solid #ededed;
+}
+
+.todo-list li:last-child {
+ border-bottom: none;
+}
+
+.todo-list li.editing {
+ border-bottom: none;
+ padding: 0;
+}
+
+.todo-list li.editing .edit {
+ display: block;
+ width: calc(100% - 43px);
+ padding: 12px 16px;
+ margin: 0 0 0 43px;
+}
+
+.todo-list li.editing .view {
+ display: none;
+}
+
+.todo-list li .toggle {
+ text-align: center;
+ width: 40px;
+ /* auto, since non-WebKit browsers doesn't support input styling */
+ height: auto;
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ margin: auto 0;
+ border: none; /* Mobile Safari */
+ -webkit-appearance: none;
+ appearance: none;
+}
+
+.todo-list li .toggle {
+ opacity: 0;
+}
+
+.todo-list li .toggle + label {
+ /*
+ Firefox requires `#` to be escaped - https://bugzilla.mozilla.org/show_bug.cgi?id=922433
+ IE and Edge requires *everything* to be escaped to render, so we do that instead of just the `#` - https://developer.microsoft.com/en-us/microsoft-edge/platform/issues/7157459/
+ */
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%23949494%22%20stroke-width%3D%223%22/%3E%3C/svg%3E');
+ background-repeat: no-repeat;
+ background-position: center left;
+}
+
+.todo-list li .toggle:checked + label {
+ background-image: url('data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20width%3D%2240%22%20height%3D%2240%22%20viewBox%3D%22-10%20-18%20100%20135%22%3E%3Ccircle%20cx%3D%2250%22%20cy%3D%2250%22%20r%3D%2250%22%20fill%3D%22none%22%20stroke%3D%22%2359A193%22%20stroke-width%3D%223%22%2F%3E%3Cpath%20fill%3D%22%233EA390%22%20d%3D%22M72%2025L42%2071%2027%2056l-4%204%2020%2020%2034-52z%22%2F%3E%3C%2Fsvg%3E');
+}
+
+.todo-list li label {
+ overflow-wrap: break-word;
+ padding: 15px 15px 15px 60px;
+ display: block;
+ line-height: 1.2;
+ transition: color 0.4s;
+ font-weight: 400;
+ color: #484848;
+}
+
+.todo-list li.completed label {
+ color: #949494;
+ text-decoration: line-through;
+}
+
+.todo-list li .destroy {
+ display: none;
+ position: absolute;
+ top: 0;
+ right: 10px;
+ bottom: 0;
+ width: 40px;
+ height: 40px;
+ margin: auto 0;
+ font-size: 30px;
+ color: #949494;
+ transition: color 0.2s ease-out;
+}
+
+.todo-list li .destroy:hover,
+.todo-list li .destroy:focus {
+ color: #C18585;
+}
+
+.todo-list li .destroy:after {
+ content: '×';
+ display: block;
+ height: 100%;
+ line-height: 1.1;
+}
+
+.todo-list li:hover .destroy {
+ display: block;
+}
+
+.todo-list li .edit {
+ display: none;
+}
+
+.todo-list li.editing:last-child {
+ margin-bottom: -1px;
+}
+
+.footer {
+ padding: 10px 15px;
+ height: 20px;
+ text-align: center;
+ font-size: 15px;
+ border-top: 1px solid #e6e6e6;
+}
+
+.footer:before {
+ content: '';
+ position: absolute;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ height: 50px;
+ overflow: hidden;
+ box-shadow: 0 1px 1px rgba(0, 0, 0, 0.2),
+ 0 8px 0 -3px #f6f6f6,
+ 0 9px 1px -3px rgba(0, 0, 0, 0.2),
+ 0 16px 0 -6px #f6f6f6,
+ 0 17px 2px -6px rgba(0, 0, 0, 0.2);
+}
+
+.todo-count {
+ float: left;
+ text-align: left;
+}
+
+.todo-count strong {
+ font-weight: 300;
+}
+
+.filters {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+ position: absolute;
+ right: 0;
+ left: 0;
+}
+
+.filters li {
+ display: inline;
+}
+
+.filters li a {
+ color: inherit;
+ margin: 3px;
+ padding: 3px 7px;
+ text-decoration: none;
+ border: 1px solid transparent;
+ border-radius: 3px;
+}
+
+.filters li a:hover {
+ border-color: #DB7676;
+}
+
+.filters li a.selected {
+ border-color: #CE4646;
+}
+
+.clear-completed,
+html .clear-completed:active {
+ float: right;
+ position: relative;
+ line-height: 19px;
+ text-decoration: none;
+ cursor: pointer;
+}
+
+.clear-completed:hover {
+ text-decoration: underline;
+}
+
+.info {
+ margin: 65px auto 0;
+ color: #4d4d4d;
+ font-size: 11px;
+ text-shadow: 0 1px 0 rgba(255, 255, 255, 0.5);
+ text-align: center;
+}
+
+.info p {
+ line-height: 1;
+}
+
+.info a {
+ color: inherit;
+ text-decoration: none;
+ font-weight: 400;
+}
+
+.info a:hover {
+ text-decoration: underline;
+}
+
+/*
+ Hack to remove background from Mobile Safari.
+ Can't use it globally since it destroys checkboxes in Firefox
+*/
+@media screen and (-webkit-min-device-pixel-ratio:0) {
+ .toggle-all,
+ .todo-list li .toggle {
+ background: none;
+ }
+
+ .todo-list li .toggle {
+ height: 40px;
+ }
+}
+
+@media (max-width: 430px) {
+ .footer {
+ height: 50px;
+ }
+
+ .filters {
+ bottom: 10px;
+ }
+}
+
+:focus,
+.toggle:focus + label,
+.toggle-all:focus + label {
+ box-shadow: 0 0 2px 2px #CF7D7D;
+ outline: 0;
+}
diff --git a/apps/go-rastrillo/internal/todo/static/nojs.css b/apps/go-rastrillo/internal/todo/static/nojs.css
new file mode 100644
index 0000000..7d150c2
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/static/nojs.css
@@ -0,0 +1,53 @@
+/* Only loaded when JavaScript is off (<noscript> in <head>). Each submit
+ control sits transparently over the thing a person would click, so the
+ page looks the same as with JavaScript and the click does the same
+ thing: a form post and a page load. */
+.todoapp .nojs {
+ display: block;
+ position: absolute;
+ z-index: 1;
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ cursor: pointer;
+}
+
+.todoapp .nojs span {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+/* Over the round checkbox at the start of the item. */
+.todo-list li .toggle-submit {
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 40px;
+}
+
+/* Over the ❯ beside the new-todo field. */
+.main .toggle-all-submit {
+ top: -65px;
+ left: 0;
+ width: 45px;
+ height: 65px;
+}
+
+/* Over the title: a click opens the editor, as a double-click does with
+ JavaScript. The delete button, later in the item, stays on top. */
+.todo-list li .edit-link {
+ top: 0;
+ bottom: 0;
+ left: 40px;
+ right: 60px;
+ cursor: text;
+}
+
+.todo-list li .destroy {
+ z-index: 2;
+}
diff --git a/apps/go-rastrillo/internal/todo/templates/errors.html b/apps/go-rastrillo/internal/todo/templates/errors.html
new file mode 100644
index 0000000..49ff857
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/templates/errors.html
@@ -0,0 +1,13 @@
+{{define "errors"}}{{template "head"}} <section class="todoapp">
+ <header class="header">
+ <h1>todos</h1>
+ </header>
+ <section class="main error">
+ <p>{{.Title}}</p>
+{{- if .Ref}}
+ <p>Reference: <code>{{.Ref}}</code></p>
+{{- end}}
+ <p><a href="/">Back to your list</a></p>
+ </section>
+ </section>
+{{template "foot"}}{{end}}
diff --git a/apps/go-rastrillo/internal/todo/templates/index.html b/apps/go-rastrillo/internal/todo/templates/index.html
new file mode 100644
index 0000000..64a3bdd
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/templates/index.html
@@ -0,0 +1,68 @@
+{{define "index"}}{{template "head"}} <section class="todoapp">{{template "app" .}}</section>
+{{template "foot"}}{{end}}
+
+{{/* app is section.todoapp's contents: the whole page body, and what
+ a fragment request gets back. Forms are display: contents (app.css),
+ so the markup lays out exactly as TodoMVC's template. */}}
+{{define "app"}}{{$f := .Filter}}
+ <header class="header">
+ <h1>todos</h1>
+ <form method="post" action="/todos">
+ <input type="hidden" name="filter" value="{{$f}}">
+ <input class="new-todo" name="title" placeholder="What needs to be done?" autocomplete="off"{{if not .Editing}} autofocus{{end}}>
+ </form>
+ </header>
+{{- if .Total}}
+ <section class="main">
+ <form method="post" action="/todos/toggle-all">
+ <input type="hidden" name="filter" value="{{$f}}">
+ <input id="toggle-all" class="toggle-all" type="checkbox"{{if .AllCompleted}} checked{{end}}>
+ <label for="toggle-all">Mark all as complete</label>
+ <button class="nojs toggle-all-submit" data-action="toggle-all"><span>Mark all as complete</span></button>
+ </form>
+ <ul class="todo-list">
+{{- range .Items}}
+ <li data-id="{{.ID}}"{{with .Class}} class="{{.}}"{{end}}>
+ <div class="view">
+ <form method="post" action="/todos/{{.ID}}/toggle">
+ <input type="hidden" name="filter" value="{{$f}}">
+ <input class="toggle" type="checkbox"{{if .Completed}} checked{{end}}>
+ <label>{{.Title}}</label>
+ <button class="nojs toggle-submit" data-action="toggle"><span>Toggle</span></button>
+ </form>
+ <a class="nojs edit-link" data-action="edit" href="{{.EditHref}}"><span>Edit</span></a>
+ <form method="post" action="/todos/{{.ID}}/destroy">
+ <input type="hidden" name="filter" value="{{$f}}">
+ <button class="destroy"></button>
+ </form>
+ </div>
+ <form method="post" action="/todos/{{.ID}}/edit">
+ <input type="hidden" name="filter" value="{{$f}}">
+ <input class="edit" name="title" value="{{.Title}}"{{if .Editing}} autofocus{{end}}>
+ </form>
+ </li>
+{{- end}}
+ </ul>
+ </section>
+ <footer class="footer">
+ <span class="todo-count"><strong>{{.Left}}</strong> {{plural .Left}} left</span>
+ <ul class="filters">
+ <li>
+ <a{{if eq $f "all"}} class="selected"{{end}} href="/">All</a>
+ </li>
+ <li>
+ <a{{if eq $f "active"}} class="selected"{{end}} href="/active">Active</a>
+ </li>
+ <li>
+ <a{{if eq $f "completed"}} class="selected"{{end}} href="/completed">Completed</a>
+ </li>
+ </ul>
+{{- if .Completed}}
+ <form method="post" action="/todos/clear-completed">
+ <input type="hidden" name="filter" value="{{$f}}">
+ <button class="clear-completed">Clear completed</button>
+ </form>
+{{- end}}
+ </footer>
+{{- end}}
+ {{end}}
diff --git a/apps/go-rastrillo/internal/todo/templates/layout.html b/apps/go-rastrillo/internal/todo/templates/layout.html
new file mode 100644
index 0000000..030b166
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/templates/layout.html
@@ -0,0 +1,22 @@
+{{define "head"}}<!doctype html>
+<html lang="en">
+ <head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>TodoMVC</title>
+ <link rel="stylesheet" href="{{asset "static/base.css"}}">
+ <link rel="stylesheet" href="{{asset "static/index.css"}}">
+ <link rel="stylesheet" href="{{asset "static/app.css"}}">
+ <noscript><link rel="stylesheet" href="{{asset "static/nojs.css"}}"></noscript>
+ <script type="module" src="{{asset "static/app.js"}}"></script>
+ </head>
+ <body>
+{{end}}
+{{define "foot"}} <footer class="info">
+ <p>Double-click to edit a todo</p>
+ <p>Created by <a href="https://bakeoff.bfj.oncarlos.com">the CARLOS bakeoff</a> · Go + Rastrillo</p>
+ <p>Part of <a href="http://todomvc.com">TodoMVC</a></p>
+ </footer>
+ </body>
+</html>
+{{end}}
diff --git a/apps/go-rastrillo/internal/todo/view.go b/apps/go-rastrillo/internal/todo/view.go
new file mode 100644
index 0000000..8864e76
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/view.go
@@ -0,0 +1,96 @@
+package todo
+
+import "strconv"
+
+// filter is which slice of the list a page shows. Its name is what forms
+// post back as `filter`; its path is where the 303 goes.
+type filter string
+
+const (
+ filterAll filter = "all"
+ filterActive filter = "active"
+ filterCompleted filter = "completed"
+)
+
+// parseFilter reads a posted filter. Missing or unknown is "all".
+func parseFilter(s string) filter {
+ switch f := filter(s); f {
+ case filterActive, filterCompleted:
+ return f
+ }
+ return filterAll
+}
+
+func (f filter) path() string {
+ if f == filterAll {
+ return "/"
+ }
+ return "/" + string(f)
+}
+
+func (f filter) shows(t Todo) bool {
+ switch f {
+ case filterActive:
+ return !t.Completed
+ case filterCompleted:
+ return t.Completed
+ }
+ return true
+}
+
+// view is everything the page template draws.
+type view struct {
+ Filter filter
+ Items []item
+ Total int
+ Left int
+ Completed int
+ Editing bool
+}
+
+type item struct {
+ ID int64
+ Title string
+ Completed bool
+ Editing bool
+ EditHref string
+}
+
+// newView sorts the list into what the page shows. Counts cover the
+// whole list whichever filter is on; editing is the item whose editor is
+// open, or 0.
+func newView(todos []Todo, f filter, editing int64) view {
+ v := view{Filter: f, Total: len(todos)}
+ for _, t := range todos {
+ if t.Completed {
+ v.Completed++
+ } else {
+ v.Left++
+ }
+ if !f.shows(t) {
+ continue
+ }
+ it := item{ID: t.ID, Title: t.Title, Completed: t.Completed,
+ Editing: t.ID == editing,
+ EditHref: f.path() + "?edit=" + strconv.FormatInt(t.ID, 10)}
+ v.Editing = v.Editing || it.Editing
+ v.Items = append(v.Items, it)
+ }
+ return v
+}
+
+// Class is the li's class attribute: completed, editing, both or none.
+func (it item) Class() string {
+ switch {
+ case it.Completed && it.Editing:
+ return "completed editing"
+ case it.Completed:
+ return "completed"
+ case it.Editing:
+ return "editing"
+ }
+ return ""
+}
+
+// AllCompleted is toggle-all's checked state.
+func (v view) AllCompleted() bool { return v.Total > 0 && v.Left == 0 }
diff --git a/apps/go-rastrillo/internal/todo/visitor.go b/apps/go-rastrillo/internal/todo/visitor.go
new file mode 100644
index 0000000..dd4abc1
--- /dev/null
+++ b/apps/go-rastrillo/internal/todo/visitor.go
@@ -0,0 +1,39 @@
+package todo
+
+import (
+ "net/http"
+ "strconv"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/sessions"
+)
+
+// visitorMethod is the session method recorded for an anonymous list:
+// nothing was verified, the browser was simply given a list.
+const visitorMethod = "anonymous"
+
+// visitor gives every request a list. A live session cookie names its
+// visitor; anything else (no cookie, an expired one, a value the store
+// has never seen, garbled or not) gets a fresh visitor row and a new
+// session, through the same sessions.SignIn an identity plugin calls.
+// After it, sessions.UserID(r) is always the visitor's id, so the
+// handlers' owned(r) seam works exactly as it does behind sess.Require.
+func (a *app) visitor(next http.Handler) http.Handler {
+ return a.sess.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if _, ok := sessions.UserID(r); !ok {
+ v := Visitor{}
+ if err := a.db.WithContext(r.Context()).Create(&v).Error; err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ now := time.Now()
+ s := sessions.Session{Subject: strconv.FormatInt(v.ID, 10), Method: visitorMethod, At: now}
+ if err := a.sess.SignIn(w, r, s); err != nil {
+ a.fail(w, r, err)
+ return
+ }
+ r = sessions.WithSession(r, s)
+ }
+ next.ServeHTTP(w, r)
+ }))
+}
diff --git a/apps/go-rastrillo/internal/todotest/harness_test.go b/apps/go-rastrillo/internal/todotest/harness_test.go
new file mode 100644
index 0000000..f505370
--- /dev/null
+++ b/apps/go-rastrillo/internal/todotest/harness_test.go
@@ -0,0 +1,124 @@
+// Package todotest tests the app from the outside: real HTTP requests
+// against the real router, wired exactly as cmd/todo/main.go wires it.
+package todotest
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "regexp"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo/budget"
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/dbtest"
+
+ todo "todo/internal/todo"
+)
+
+// testOrigin is the origin newApp wires: the app's own.
+const testOrigin = "http://app.test"
+
+// schema is migrated once per test binary and copied for each test.
+var schema = dbtest.FromSet(todo.BootSchema)
+
+func TestMain(m *testing.M) {
+ code := budget.Main(m)
+ schema.Remove()
+ os.Exit(code)
+}
+
+// newApp builds the whole app over its own copy of the migrated
+// database, exactly as main.go does.
+func newApp(t *testing.T) http.Handler {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(schema.Path(t), logger)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ mux, err := todo.App(d, testOrigin, logger)
+ if err != nil {
+ t.Fatalf("App: %v", err)
+ }
+ return mux
+}
+
+// browser is one visitor: a cookie jar in front of the app.
+type browser struct {
+ t *testing.T
+ h http.Handler
+ cookies map[string]*http.Cookie
+}
+
+func newBrowser(t *testing.T, h http.Handler) *browser {
+ return &browser{t: t, h: h, cookies: map[string]*http.Cookie{}}
+}
+
+func (b *browser) do(req *http.Request) *httptest.ResponseRecorder {
+ b.t.Helper()
+ for _, c := range b.cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ b.h.ServeHTTP(rec, req)
+ for _, c := range rec.Result().Cookies() {
+ if c.MaxAge < 0 {
+ delete(b.cookies, c.Name)
+ } else {
+ b.cookies[c.Name] = c
+ }
+ }
+ return rec
+}
+
+func (b *browser) get(target string) *httptest.ResponseRecorder {
+ b.t.Helper()
+ return b.do(httptest.NewRequest(http.MethodGet, target, nil))
+}
+
+// post sends a form the way the shared suite does: no Origin header.
+func (b *browser) post(target string, form url.Values, headers ...string) *httptest.ResponseRecorder {
+ b.t.Helper()
+ req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ for i := 0; i+1 < len(headers); i += 2 {
+ req.Header.Set(headers[i], headers[i+1])
+ }
+ return b.do(req)
+}
+
+// add posts a new todo and fails the test unless it 303s.
+func (b *browser) add(title string) {
+ b.t.Helper()
+ rec := b.post("/todos", url.Values{"title": {title}, "filter": {"all"}})
+ if rec.Code != http.StatusSeeOther {
+ b.t.Fatalf("POST /todos: status %d, want 303", rec.Code)
+ }
+}
+
+var itemRE = regexp.MustCompile(`(?s)<li data-id="(\d+)"(?: class="([^"]*)")?>.*?<label>(.*?)</label>`)
+
+type shown struct{ ID, Class, Title string }
+
+// items lists what a page shows, in order.
+func items(body string) []shown {
+ var out []shown
+ for _, m := range itemRE.FindAllStringSubmatch(body, -1) {
+ out = append(out, shown{m[1], m[2], m[3]})
+ }
+ return out
+}
+
+func titles(body string) []string {
+ var out []string
+ for _, it := range items(body) {
+ out = append(out, it.Title)
+ }
+ return out
+}
diff --git a/apps/go-rastrillo/internal/todotest/perf_test.go b/apps/go-rastrillo/internal/todotest/perf_test.go
new file mode 100644
index 0000000..5952abc
--- /dev/null
+++ b/apps/go-rastrillo/internal/todotest/perf_test.go
@@ -0,0 +1,74 @@
+//go:build perf
+
+package todotest
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/perf/perftest"
+
+ todo "todo/internal/todo"
+)
+
+// The perf lane: make perf, serial and uncached, failing only on CI.
+// Every GET screen the router serves is held to 150ms to first byte; a
+// new route fails here until it is measured or has a record in
+// .rastrillo/budgets.txt. Never t.Parallel() in this file: a perf test
+// sharing the machine measures its neighbours.
+func TestPerfScreens(t *testing.T) {
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(schema.Path(t), logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer d.Close()
+ r, err := todo.Router(d, testOrigin, logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+ opts := rastrillo.Options{Logger: logger}
+ todo.Configure(&opts, todo.Mux(r), time.Now())
+ h, closeAll, err := rastrillo.Handler(opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer closeAll()
+ // Screens behind sign-in need Signin; a {param} route needs a Paths
+ // entry whose row this test seeds first.
+ perftest.Screens(t, perftest.ScreenConfig{Routes: r, Handler: h})
+}
+
+// A cold start, from exec to the first byte of "/", held to 500ms.
+func TestPerfBoot(t *testing.T) {
+ perftest.Boot(t, perftest.BootConfig{
+ Prepare: schema.Path,
+ Build: func(path string) (http.Handler, func(), error) {
+ started := time.Now()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(path, logger)
+ if err != nil {
+ return nil, nil, err
+ }
+ mux, err := todo.App(d, testOrigin, logger)
+ if err != nil {
+ d.Close()
+ return nil, nil, err
+ }
+ opts := rastrillo.Options{Logger: logger}
+ todo.Configure(&opts, mux, started)
+ h, closeAll, err := rastrillo.Handler(opts)
+ if err != nil {
+ d.Close()
+ return nil, nil, err
+ }
+ return h, func() { closeAll(); d.Close() }, nil
+ },
+ Path: "/",
+ })
+}
diff --git a/apps/go-rastrillo/internal/todotest/todo_test.go b/apps/go-rastrillo/internal/todotest/todo_test.go
new file mode 100644
index 0000000..b82f737
--- /dev/null
+++ b/apps/go-rastrillo/internal/todotest/todo_test.go
@@ -0,0 +1,280 @@
+package todotest
+
+import (
+ "net/http"
+ "net/url"
+ "os"
+ "slices"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+func TestFirstVisitSetsListCookie(t *testing.T) {
+ t.Parallel()
+ b := newBrowser(t, newApp(t))
+ rec := b.get("/")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /: status %d", rec.Code)
+ }
+ cs := rec.Result().Cookies()
+ if len(cs) != 1 {
+ t.Fatalf("got %d cookies, want 1", len(cs))
+ }
+ c := cs[0]
+ if !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || c.Path != "/" {
+ t.Errorf("cookie %+v: want HttpOnly, SameSite=Lax, Path=/", c)
+ }
+ if len(c.Value) < 22 { // 128 bits in base64
+ t.Errorf("cookie value %q is too short to hold 128 random bits", c.Value)
+ }
+ // A second visit keeps the same list: no new cookie.
+ if got := b.get("/").Result().Cookies(); len(got) != 0 {
+ t.Errorf("second visit set cookies %v", got)
+ }
+}
+
+func TestEmptyListHidesMainAndFooter(t *testing.T) {
+ t.Parallel()
+ body := newBrowser(t, newApp(t)).get("/").Body.String()
+ for _, s := range []string{`class="main"`, `class="footer"`} {
+ if strings.Contains(body, s) {
+ t.Errorf("empty list renders %s", s)
+ }
+ }
+ if !strings.Contains(body, `class="new-todo"`) {
+ t.Error("page has no new-todo field")
+ }
+ if !strings.Contains(body, "the CARLOS bakeoff</a> · Go + Rastrillo") {
+ t.Error("footer doesn't name the setup")
+ }
+}
+
+func TestAddTrimsAndRedirectsToFilter(t *testing.T) {
+ t.Parallel()
+ b := newBrowser(t, newApp(t))
+ for filter, want := range map[string]string{"all": "/", "active": "/active", "completed": "/completed", "": "/", "bogus": "/"} {
+ rec := b.post("/todos", url.Values{"title": {" x "}, "filter": {filter}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != want {
+ t.Errorf("filter %q: %d to %q, want 303 to %q", filter, rec.Code, rec.Header().Get("Location"), want)
+ }
+ }
+ b.add(" ")
+ got := titles(b.get("/").Body.String())
+ if want := []string{"x", "x", "x", "x", "x"}; !slices.Equal(got, want) {
+ t.Errorf("titles %q, want %q", got, want)
+ }
+}
+
+func TestTitlesAreTextAndKeptExactly(t *testing.T) {
+ t.Parallel()
+ b := newBrowser(t, newApp(t))
+ b.add(`<img src=x onerror=alert(1)> & "q"`)
+ b.add("naïve 🚀 日本語")
+ long := strings.Repeat("x", 10_000)
+ b.add(long)
+ rec := b.get("/")
+ got := titles(rec.Body.String())
+ want := []string{"&lt;img src=x onerror=alert(1)&gt; &amp; &#34;q&#34;", "naïve 🚀 日本語", long}
+ if !slices.Equal(got, want) {
+ t.Errorf("titles %q, want %q", got, want)
+ }
+ if n, _ := strconv.Atoi(rec.Header().Get("Content-Length")); n != rec.Body.Len() {
+ t.Errorf("Content-Length %d, body %d bytes", n, rec.Body.Len())
+ }
+}
+
+func TestToggleEditDestroy(t *testing.T) {
+ t.Parallel()
+ b := newBrowser(t, newApp(t))
+ b.add("one")
+ b.add("two")
+ id := items(b.get("/").Body.String())[0].ID
+
+ rec := b.post("/todos/"+id+"/toggle", url.Values{"filter": {"active"}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/active" {
+ t.Fatalf("toggle: %d to %q", rec.Code, rec.Header().Get("Location"))
+ }
+ if got := titles(b.get("/active").Body.String()); !slices.Equal(got, []string{"two"}) {
+ t.Errorf("active after toggle: %q", got)
+ }
+ if got := titles(b.get("/completed").Body.String()); !slices.Equal(got, []string{"one"}) {
+ t.Errorf("completed after toggle: %q", got)
+ }
+ if !strings.Contains(b.get("/").Body.String(), "<strong>1</strong> item left") {
+ t.Error("counter doesn't read 1 item left")
+ }
+
+ b.post("/todos/"+id+"/edit", url.Values{"title": {" uno "}, "filter": {"all"}})
+ if got := titles(b.get("/").Body.String()); !slices.Equal(got, []string{"uno", "two"}) {
+ t.Errorf("after edit: %q", got)
+ }
+ b.post("/todos/"+id+"/edit", url.Values{"title": {" "}, "filter": {"all"}})
+ if got := titles(b.get("/").Body.String()); !slices.Equal(got, []string{"two"}) {
+ t.Errorf("empty edit should delete: %q", got)
+ }
+
+ id2 := items(b.get("/").Body.String())[0].ID
+ if rec := b.post("/todos/"+id2+"/destroy", url.Values{"filter": {"all"}}); rec.Code != http.StatusSeeOther {
+ t.Fatalf("destroy: %d", rec.Code)
+ }
+ if got := titles(b.get("/").Body.String()); len(got) != 0 {
+ t.Errorf("after destroy: %q", got)
+ }
+}
+
+func TestEditView(t *testing.T) {
+ t.Parallel()
+ b := newBrowser(t, newApp(t))
+ b.add("one")
+ id := items(b.get("/").Body.String())[0].ID
+ body := b.get("/active?edit=" + id).Body.String()
+ if it := items(body)[0]; it.Class != "editing" {
+ t.Errorf("li class %q, want editing", it.Class)
+ }
+ if !strings.Contains(body, `<form method="post" action="/todos/`+id+`/edit">`) {
+ t.Error("edit input isn't in a form posting to /todos/<id>/edit")
+ }
+ if !strings.Contains(body, `href="/active?edit=`+id+`"`) {
+ t.Error("edit link doesn't keep the filter")
+ }
+}
+
+func TestToggleAllAndClearCompleted(t *testing.T) {
+ t.Parallel()
+ b := newBrowser(t, newApp(t))
+ b.add("a")
+ b.add("b")
+ id := items(b.get("/").Body.String())[0].ID
+ b.post("/todos/"+id+"/toggle", url.Values{})
+
+ b.post("/todos/toggle-all", url.Values{"filter": {"all"}})
+ body := b.get("/").Body.String()
+ for _, it := range items(body) {
+ if it.Class != "completed" {
+ t.Errorf("after toggle-all, %s has class %q", it.Title, it.Class)
+ }
+ }
+ if !strings.Contains(body, `class="toggle-all" type="checkbox" checked`) {
+ t.Error("toggle-all isn't checked when everything is completed")
+ }
+ if !strings.Contains(body, `class="clear-completed"`) {
+ t.Error("clear-completed hidden with completed items")
+ }
+
+ b.post("/todos/toggle-all", url.Values{"filter": {"all"}})
+ for _, it := range items(b.get("/").Body.String()) {
+ if it.Class != "" {
+ t.Errorf("after second toggle-all, %s has class %q", it.Title, it.Class)
+ }
+ }
+ if strings.Contains(b.get("/").Body.String(), `class="clear-completed"`) {
+ t.Error("clear-completed shown with nothing completed")
+ }
+
+ b.post("/todos/"+id+"/toggle", url.Values{})
+ b.post("/todos/clear-completed", url.Values{"filter": {"completed"}})
+ if got := titles(b.get("/").Body.String()); !slices.Equal(got, []string{"b"}) {
+ t.Errorf("after clear-completed: %q", got)
+ }
+}
+
+func TestListsAreSeparate(t *testing.T) {
+ t.Parallel()
+ h := newApp(t)
+ alice, mallory := newBrowser(t, h), newBrowser(t, h)
+ alice.add("mine")
+ id := items(alice.get("/").Body.String())[0].ID
+
+ if got := titles(mallory.get("/").Body.String()); len(got) != 0 {
+ t.Fatalf("another visitor sees %q", got)
+ }
+ for _, action := range []string{"toggle", "edit", "destroy"} {
+ rec := mallory.post("/todos/"+id+"/"+action, url.Values{"title": {"x"}, "filter": {"all"}})
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("%s on another list's item: %d, want 404", action, rec.Code)
+ }
+ }
+ for _, path := range []string{"/todos/toggle-all", "/todos/clear-completed"} {
+ mallory.post(path, url.Values{})
+ }
+ if got := items(alice.get("/").Body.String()); len(got) != 1 || got[0].Title != "mine" || got[0].Class != "" {
+ t.Errorf("alice's list changed: %+v", got)
+ }
+ for _, id := range []string{"999999", "abc", "-1"} {
+ if rec := alice.post("/todos/"+id+"/toggle", url.Values{}); rec.Code != http.StatusNotFound {
+ t.Errorf("toggle %s: %d, want 404", id, rec.Code)
+ }
+ }
+}
+
+func TestUnknownCookieGetsFreshList(t *testing.T) {
+ t.Parallel()
+ h := newApp(t)
+ alice := newBrowser(t, h)
+ alice.add("mine")
+ var name string
+ for n := range alice.cookies {
+ name = n
+ }
+ for _, v := range []string{"%%%", "garbage", strings.Repeat("A", 43)} {
+ b := newBrowser(t, h)
+ b.cookies[name] = &http.Cookie{Name: name, Value: v}
+ rec := b.get("/")
+ if rec.Code != http.StatusOK || len(items(rec.Body.String())) != 0 {
+ t.Errorf("cookie %q: status %d, items %v", v, rec.Code, items(rec.Body.String()))
+ }
+ }
+}
+
+func TestOriginCheck(t *testing.T) {
+ t.Parallel()
+ b := newBrowser(t, newApp(t))
+ form := url.Values{"title": {"x"}, "filter": {"all"}}
+ if rec := b.post("/todos", form, "Origin", "https://evil.example"); rec.Code != http.StatusForbidden {
+ t.Errorf("cross-site Origin: %d, want 403", rec.Code)
+ }
+ if rec := b.post("/todos", form, "Origin", "null"); rec.Code != http.StatusForbidden {
+ t.Errorf("Origin null: %d, want 403", rec.Code)
+ }
+ if got := titles(b.get("/").Body.String()); len(got) != 0 {
+ t.Fatalf("refused posts added %q", got)
+ }
+ if rec := b.post("/todos", form, "Origin", testOrigin); rec.Code != http.StatusSeeOther {
+ t.Errorf("own Origin: %d, want 303", rec.Code)
+ }
+ if rec := b.post("/todos", form); rec.Code != http.StatusSeeOther {
+ t.Errorf("no Origin: %d, want 303", rec.Code)
+ }
+}
+
+func TestFragmentForScript(t *testing.T) {
+ t.Parallel()
+ b := newBrowser(t, newApp(t))
+ rec := b.post("/todos", url.Values{"title": {"x"}, "filter": {"active"}}, "X-Todo-Fragment", "1")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("fragment post: %d, want 200", rec.Code)
+ }
+ body := rec.Body.String()
+ if strings.Contains(body, "<html") || !strings.Contains(body, `class="todo-list"`) {
+ t.Errorf("fragment isn't the list markup alone:\n%s", body)
+ }
+ if !strings.Contains(body, `<a class="selected" href="/active">`) {
+ t.Error("fragment lost the filter")
+ }
+}
+
+func TestStylesheetsServedByteForByte(t *testing.T) {
+ t.Parallel()
+ h := newApp(t)
+ for _, name := range []string{"base.css", "index.css"} {
+ want, err := os.ReadFile("../../../../shared/" + name)
+ if err != nil {
+ t.Fatal(err)
+ }
+ rec := newBrowser(t, h).get("/static/" + name)
+ if rec.Code != http.StatusOK || rec.Body.String() != string(want) {
+ t.Errorf("/static/%s: status %d, %d bytes, want shared/%s (%d bytes)", name, rec.Code, rec.Body.Len(), name, len(want))
+ }
+ }
+}