feat: spinel builder output

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev fe71014079445ada958f5088434444f23ccd39ff parent 2a07621parent e344882
17 files changed, +2558 −0
  • apps/spinel/.gitignore +2 −0
  • apps/spinel/AGENTS.md +32 −0
  • apps/spinel/DEPS +2 −0
  • apps/spinel/Makefile +147 −0
  • apps/spinel/README.md +89 −0
  • apps/spinel/STATUS.md +64 −0
  • apps/spinel/app.rb +547 −0
  • apps/spinel/assets/static/app.css +64 −0
  • apps/spinel/assets/static/app.js +80 −0
  • apps/spinel/assets/static/base.css +141 −0
  • apps/spinel/assets/static/index.css +393 −0
  • apps/spinel/patches/1-tep-carlos.patch +198 −0
  • apps/spinel/patches/2-tep-bakeoff.patch +529 −0
  • apps/spinel/scripts/arm64-cc +21 −0
  • apps/spinel/scripts/regen-patch +16 −0
  • apps/spinel/scripts/spinel-cross +8 −0
  • apps/spinel/test/run.sh +225 −0
diff --git a/apps/spinel/.gitignore b/apps/spinel/.gitignore
new file mode 100644
index 0000000..4ff8203
--- /dev/null
+++ b/apps/spinel/.gitignore
@@ -0,0 +1,2 @@
+.app.tep.rb
+version.rb
diff --git a/apps/spinel/AGENTS.md b/apps/spinel/AGENTS.md
new file mode 100644
index 0000000..6f5ed76
--- /dev/null
+++ b/apps/spinel/AGENTS.md
@@ -0,0 +1,32 @@
+# 🤖 AGENTS.md: todo-spinel
+
+The one rule: **this app is Ruby compiled ahead of time, and nothing else
+runs in production.** Everything the server does is written in the
+Spinel-compilable subset of Ruby (`app.rb`, via tep) or in the small C
+shims tep already ships. No Ruby interpreter, no shared library beyond
+libc, no client framework, no JS build step.
+
+## Tenancy
+
+- **One instance for the whole app.** A single process and a single
+ SQLite file (`--db`) serve every visitor.
+- **Each visitor's list is kept separately by an anonymous cookie.** The
+ first visit sets `tid=<22 base64url characters>` (128 bits from the
+ kernel's `getrandom`),
+ `HttpOnly; SameSite=Lax; Path=/`, plus `Secure` over https. Every query
+ is scoped by that value; an id from someone else's list is a 404. The
+ `visitors` table holds every token the server has issued; any other
+ cookie value is replaced with a fresh one.
+- **No sign-in.** There are no accounts, passwords or e-mail addresses.
+- **Trust class C.** Todo text is stored as plain text in SQLite. That's
+ a deliberate choice for a demo app: nothing in it is meant to be
+ secret, and the platform's at-rest protection of the database is the
+ only protection it gets.
+
+## Working here
+
+- Work only in `apps/spinel/`. `BRIEF.md` and `CONTRACT.md` at the repo
+ root are binding.
+- The toolchain checkouts (spinel, tep, spinelkit) are read-only. The
+ Makefile copies tep into `.cache/` and applies `patches/*.patch` there.
+- Servers bind `127.0.0.1` only. Scratch goes in `$TMPDIR`. No `sudo`.
diff --git a/apps/spinel/DEPS b/apps/spinel/DEPS
new file mode 100644
index 0000000..a18bd24
--- /dev/null
+++ b/apps/spinel/DEPS
@@ -0,0 +1,2 @@
+tep https://github.com/OriPekelman/tep a27bdcb (patched: patches/)
+sqlite 3.50.4 https://www.sqlite.org/2025/sqlite-amalgamation-3500400.zip
diff --git a/apps/spinel/Makefile b/apps/spinel/Makefile
new file mode 100644
index 0000000..990e717
--- /dev/null
+++ b/apps/spinel/Makefile
@@ -0,0 +1,147 @@
+# todo-spinel. See README.md for what each piece is.
+#
+# make build dist/app for this machine (x86-64, dynamic glibc)
+# make build-arm64 dist/app-linux-arm64 (static, aarch64)
+# make test test/run.sh against dist/app, plain and under GC stress
+# make deploy ship dist/app-linux-arm64 to Carloku
+
+SHELL := bash
+.SHELLFLAGS := -euo pipefail -c
+.DELETE_ON_ERROR:
+
+TC ?= $(HOME)/.local/share/bakeoff-toolchains
+SPINEL_HOST ?= $(TC)/spinel-src/spinel/bin/spinel
+SPINEL_ARM64?= $(TC)/spinel-arm64/bin/spinel
+TEP_SRC ?= $(TC)/spinel-src/tep
+SPINELKIT ?= $(TC)/spinel-src/spinelkit
+RUBY ?= $(shell PATH="$(HOME)/.pixi/bin:$$PATH" command -v ruby)
+ARM64 := $(abspath ../../scripts/arm64.sh)
+
+SQLITE_VER := 3500400
+SQLITE_ZIP := .cache/src/sqlite-amalgamation-$(SQLITE_VER).zip
+SQLITE_URL := https://www.sqlite.org/2025/sqlite-amalgamation-$(SQLITE_VER).zip
+SQLITE_SHA := 1d3049dd0f830a025a53105fc79fd2ab9431aea99e137809d064d8ee8356b032
+SQLITE_DIR := .cache/src/sqlite-amalgamation-$(SQLITE_VER)
+# One connection, used from one thread; no extension loading (static glibc
+# can't dlopen anyway).
+SQLITE_DEFS := -DSQLITE_THREADSAFE=0 -DSQLITE_OMIT_LOAD_EXTENSION -DSQLITE_DQS=0 \
+ -DSQLITE_DEFAULT_MEMSTATUS=0 -DSQLITE_LIKE_DOESNT_MATCH_BLOBS
+
+PATCHES := $(sort $(wildcard patches/*.patch))
+SOURCES := app.rb $(wildcard assets/static/*)
+VERSION := $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
+
+export LANG := C.UTF-8
+export LC_ALL := C.UTF-8
+export SPINELKIT_DIR := $(SPINELKIT)
+export TEP_QUIET := 1
+
+.PHONY: build build-arm64 test clean clean-deps bump unbump deploy deps FORCE
+
+build: dist/app
+build-arm64: dist/app-linux-arm64
+
+# ---- version stamp ---------------------------------------------------------
+# Rewritten only when the commit changes, so it doesn't force rebuilds.
+version.rb: FORCE
+ @printf '# Written by make; not checked in.\nAPP_VERSION = "%s"\n' '$(VERSION)' > $@.tmp
+ @if cmp -s $@.tmp $@; then rm $@.tmp; else mv $@.tmp $@; fi
+
+# ---- third-party sources (kept by clean-deps) -------------------------------
+$(SQLITE_ZIP):
+ mkdir -p $(@D)
+ curl -sSfL -o $@.part $(SQLITE_URL)
+ echo "$(SQLITE_SHA) $@.part" | sha256sum -c --quiet
+ mv $@.part $@
+
+$(SQLITE_DIR)/sqlite3.c: $(SQLITE_ZIP)
+ python3 -I -m zipfile -e $< .cache/src
+ touch $@
+
+# ---- per-target compiled deps (removed by clean-deps) -----------------------
+# A patched copy of tep per target: tep bakes the paths of its C shims
+# (sphttp.o, tep_sqlite.o) into the translated source, so each target's
+# copy holds that target's objects.
+.cache/%/tep/.patched: $(PATCHES)
+ rm -rf .cache/$*/tep
+ mkdir -p .cache/$*
+ cp -a $(TEP_SRC) .cache/$*/tep
+ rm -rf .cache/$*/tep/.git
+ for p in $(PATCHES); do patch -s -p1 -d .cache/$*/tep < $$p; done
+ touch $@
+
+# Host (x86-64): the system cc.
+.cache/x86/libsqlite3.a: $(SQLITE_DIR)/sqlite3.c
+ mkdir -p $(@D)
+ cc -O2 $(SQLITE_DEFS) -c $< -o .cache/x86/sqlite3.o
+ ar rcs $@ .cache/x86/sqlite3.o
+
+.cache/x86/tep/tep/sphttp.o: .cache/x86/tep/.patched
+ cc -O2 -c .cache/x86/tep/tep/sphttp.c -o $@
+
+.cache/x86/tep/native/sqlite/tep_sqlite.o: .cache/x86/tep/.patched $(SQLITE_DIR)/sqlite3.c
+ cc -O2 -I$(SQLITE_DIR) -c .cache/x86/tep/native/sqlite/tep_sqlite.c -o $@
+
+# arm64: gcc inside the emulated arm64 root (slow: SQLite takes minutes).
+.cache/arm64/libsqlite3.a: $(SQLITE_DIR)/sqlite3.c
+ mkdir -p $(@D)
+ $(ARM64) cc -O2 $(SQLITE_DEFS) -c $< -o .cache/arm64/sqlite3.o
+ $(ARM64) ar rcs $@ .cache/arm64/sqlite3.o
+
+.cache/arm64/tep/tep/sphttp.o: .cache/arm64/tep/.patched
+ $(ARM64) cc -O2 -c .cache/arm64/tep/tep/sphttp.c -o $@
+
+.cache/arm64/tep/native/sqlite/tep_sqlite.o: .cache/arm64/tep/.patched $(SQLITE_DIR)/sqlite3.c
+ $(ARM64) cc -O2 -I$(SQLITE_DIR) -c .cache/arm64/tep/native/sqlite/tep_sqlite.c -o $@
+
+X86_DEPS := .cache/x86/libsqlite3.a .cache/x86/tep/tep/sphttp.o .cache/x86/tep/native/sqlite/tep_sqlite.o
+ARM64_DEPS := .cache/arm64/libsqlite3.a .cache/arm64/tep/tep/sphttp.o .cache/arm64/tep/native/sqlite/tep_sqlite.o
+
+deps: $(X86_DEPS) $(ARM64_DEPS)
+
+# ---- the app ---------------------------------------------------------------
+# tep translates app.rb (writing .app.tep.rb beside it) and runs Spinel.
+dist/app: $(SOURCES) version.rb $(X86_DEPS)
+ mkdir -p dist .cache/x86/tmp
+ TMPDIR=$(abspath .cache/x86/tmp) SPINEL=$(SPINEL_HOST) LIBRARY_PATH=$(abspath .cache/x86) \
+ $(RUBY) .cache/x86/tep/bin/tep build app.rb -o $@
+
+# The host Spinel turns Ruby into C (fast, native); scripts/arm64-cc then
+# compiles and links that C statically inside the arm64 root.
+dist/app-linux-arm64: $(SOURCES) version.rb $(ARM64_DEPS)
+ mkdir -p dist .cache/arm64/tmp
+ TMPDIR=$(abspath .cache/arm64/tmp) SPINEL=$(abspath scripts/spinel-cross) \
+ SPINEL_HOST=$(SPINEL_HOST) SPINEL_ARM64=$(SPINEL_ARM64) \
+ ARM64_WORK=$(abspath .cache/arm64/work) ARM64_LIB_DIR=$(abspath .cache/arm64) \
+ $(RUBY) .cache/arm64/tep/bin/tep build app.rb -o $@
+ file $@ | grep -q 'ARM aarch64.*statically linked'
+
+# ---- the rest ----------------------------------------------------------------
+# The second run collects garbage every few KB, which shakes out values
+# the generated code forgot to root (one such bug is why tep is patched).
+test: dist/app
+ test/run.sh dist/app
+ SPINEL_GC_STRESS=1 test/run.sh dist/app
+
+clean:
+ rm -rf dist .app.tep.rb version.rb .cache/x86/tmp .cache/arm64/tmp .cache/arm64/work
+
+# Every compiled third-party artifact goes; the downloaded SQLite source stays.
+clean-deps: clean
+ rm -rf .cache/x86 .cache/arm64
+
+BUMP_FROM := BUILD_NOTE = "todo-spinel"
+BUMP_TO := BUILD_NOTE = "todo-spinel (bumped)"
+
+bump:
+ grep -qF '$(BUMP_FROM)' app.rb
+ sed -i 's/^$(BUMP_FROM)$$/$(BUMP_TO)/' app.rb
+
+unbump:
+ grep -qF '$(BUMP_TO)' app.rb
+ sed -i 's/^$(BUMP_TO)$$/$(BUMP_FROM)/' app.rb
+
+MESSAGE ?= Deploy todo-spinel $(VERSION)
+
+deploy: dist/app-linux-arm64
+ carlos deploy --account bfj --app todo-spinel --target linux-arm64 --message "$(MESSAGE)" dist/app-linux-arm64
diff --git a/apps/spinel/README.md b/apps/spinel/README.md
new file mode 100644
index 0000000..4a43c70
--- /dev/null
+++ b/apps/spinel/README.md
@@ -0,0 +1,89 @@
+# todo-spinel
+
+TodoMVC for the CARLOS bakeoff, written in Ruby and compiled ahead of time
+by [Spinel](https://github.com/matz/spinel), matz's Ruby-to-C compiler. The
+result is one static linux-arm64 binary with SQLite inside it and no Ruby at
+runtime. It's live at
+[https://todo-spinel.bfj.oncarlos.com](https://todo-spinel.bfj.oncarlos.com).
+
+## The stack
+
+**[tep](https://github.com/OriPekelman/tep)** is the web layer. You write a
+Sinatra-style `app.rb`; tep translates it into plain Ruby that Spinel can
+compile, inlines its own HTTP server, and drives Spinel to a binary. It's the
+only web framework that targets Spinel, and it already powers a CARLOS app
+(lpay), so it was the obvious pick. Spinel compiles a subset of Ruby, which is
+why `app.rb` reads a little old-fashioned: `while` loops, string
+concatenation, small top-level functions, no metaprogramming.
+
+**SQLite** comes from the official amalgamation, compiled by the Makefile and
+linked statically. tep's own SQLite shim talks to it. Lists live in one
+`todos` table keyed by the visitor's cookie, and a `visitors` table records
+every token the server has issued, so a cookie it didn't issue gets a fresh
+one. Migrations are numbered by
+`PRAGMA user_version` and only ever add.
+
+**HTML** is built in Ruby functions in `app.rb`, escaped by hand. tep has
+build-time ERB, but its templates can only be called from a route block and
+every value has to go through a per-request string bag, which made the
+item and fragment renderers clumsier than plain functions.
+
+**JavaScript** is one ES module, `assets/static/app.js`, with no framework.
+The page works fully without it: every action is a real form POST that
+answers 303. With JS on, the script sends the same forms with `fetch` and an
+`X-Fragment: 1` header, and swaps in the `section.main` and `footer.footer`
+the server sends back. The no-JS controls sit invisibly over their TodoMVC
+counterparts and disappear when the script adds the `js` class.
+
+tep is copied into `.cache/` and patched rather than used as-is.
+`patches/1-tep-carlos.patch` is lpay's: the `--socket` contract and a
+`Content-Length` that counts bytes. `patches/2-tep-bakeoff.patch` adds
+`--listen 127.0.0.1:<port>`, which refuses anything but a loopback address,
+and removes tep's fallback to listening on every interface: with neither
+`--socket` nor `--listen` the binary exits. It gives each request a
+10-second deadline for the whole exchange, so a slow or idle client can't
+hold the single worker. It reads bodies whole up to 256 KiB (tep stopped at
+64 KiB and carried on with a truncated title), answers 413 above that, and
+answers 400 without running any handler when a body is short of its
+`Content-Length` or chunked. Smaller changes: the
+`303 See Other` reason phrase, and a return-type fix in `Tep::Assets._add`
+that otherwise breaks the C compile. Last, it replaces tep's form decoding.
+spinelkit's `Url.parse_query` keeps a decoded key in an unrooted temporary
+while it decodes the value, so a garbage collection in between loses the
+key. On arm64 that dropped any todo title over a thousand or so
+characters; on x86-64 it only shows under `SPINEL_GC_STRESS=1`. The patch
+decodes in C and keeps every intermediate in a local, and `make test` runs
+a second pass under GC stress to catch anything like it. Edit
+`.cache/x86/tep` and run `scripts/regen-patch` to change the second patch.
+
+## Build, run, test
+
+```
+source ../../scripts/env.sh
+make build # dist/app, for this machine
+dist/app --listen 127.0.0.1:8080 --db $TMPDIR/todo.db
+make test # test/run.sh: curl checks of the contract
+../../scripts/test-app.sh http://127.0.0.1:8080 # the shared browser suite
+```
+
+`make build` takes about a minute and a half, nearly all of it Spinel.
+
+`make build-arm64` makes `dist/app-linux-arm64`. The host Spinel turns Ruby
+into C natively, then `scripts/arm64-cc` compiles that C inside the emulated
+arm64 root (`scripts/arm64.sh`) and links it statically against the root's
+glibc, OpenSSL (tep's HTTP layer links it) and our SQLite. Running all of
+Spinel under emulation also works but takes three times as long. Expect five
+minutes warm, and another four the first time while SQLite compiles.
+
+`make clean-deps` removes the compiled SQLite and tep copies; the downloaded
+SQLite zip stays in `.cache/src`.
+
+## Deploy
+
+```
+make deploy MESSAGE="what changed"
+```
+
+This ships `dist/app-linux-arm64` to the `todo-spinel` app on account `bfj`.
+The version is the commit's short SHA, so commit before you build. Before a
+first deploy, run `../../scripts/boot-check.sh dist/app-linux-arm64`.
diff --git a/apps/spinel/STATUS.md b/apps/spinel/STATUS.md
new file mode 100644
index 0000000..7dd8f21
--- /dev/null
+++ b/apps/spinel/STATUS.md
@@ -0,0 +1,64 @@
+LIVE https://todo-spinel.bfj.oncarlos.com 6692def
+
+## Where it stands
+
+- The shared suite passes on the live host: 67 passed, 3 skipped (the two
+ JS-only edit tests with JS off, and the screenshot test with JS off).
+ It passes locally too, against the x86-64 build and against the arm64
+ binary under qemu, both with `SPINEL_GC_STRESS=1`.
+- `make test` (test/run.sh, 86 checks of the contract over curl and raw
+ sockets, run plain and again under GC stress) passes, against the x86-64
+ build and against the arm64 binary under qemu.
+- `scripts/boot-check.sh dist/app-linux-arm64` passes.
+- `dist/app-linux-arm64` is a static aarch64 ELF, 8.8 MB, no shared
+ libraries.
+
+## Worth knowing
+
+- **Build route.** The host Spinel compiles Ruby to C; only the C compile
+ and static link run in the emulated arm64 root (`scripts/spinel-cross`,
+ `scripts/arm64-cc`). Running the arm64 Spinel itself under emulation
+ also works but took 14 minutes for hello-world, against about 3 to 5
+ minutes this way. Both Spinels are the same commit (`abf54280`).
+- **A GC bug, fixed in the tep patch.** spinelkit's `Url.parse_query`
+ holds a decoded key in an unrooted temporary while it decodes the
+ value. On arm64 a collection in between dropped every todo title longer
+ than about a thousand characters (the first live deploy, 947e5fe,
+ failed the long-title test for this reason). On x86-64 it only shows
+ under `SPINEL_GC_STRESS=1`. `patches/2-tep-bakeoff.patch` decodes forms
+ in C instead. Worth reporting upstream to spinelkit/tep.
+- **tep rewrites words inside string literals.** Its translator turns
+ `request` into `req` even inside strings, so a 403 message reading
+ "cross-origin request refused" came out as "cross-origin req refused".
+ The app's strings avoid those words.
+- **The arm64 root** now has `libssl-dev` installed (tep's HTTP layer
+ links OpenSSL, and the static link needs `libssl.a`; it also pulls in
+ `-lz -lzstd`).
+- **One worker.** tep's server here is a single blocking process that
+ closes each connection after its response. Each request has a 10-second
+ deadline for the whole exchange, so a slow or idle client can't hold it.
+
+## Review findings (all four fixed, deployed as 6692def)
+
+1. **Per-read timeout.** Replaced by one 10-second deadline per request:
+ before every read and write, the socket timeouts are set to the time
+ left. A test trickles one byte a second and is cut off within 12 s.
+2. **Bodies capped at 64 KiB.** tep's C shim now reads bodies whole, up to
+ 256 KiB, into a growing buffer. Above that the answer is 413. A body
+ short of its `Content-Length`, carrying a NUL, or sent chunked gets 400
+ before any handler runs, so nothing changes. tep's SQLite column
+ buffer grew from 64 KiB to 256 KiB to match. A 10,000-emoji title
+ (about 120 KB encoded) round-trips locally and on the live host.
+3. **Unissued cookies accepted.** A new `visitors` table records every
+ token the server issues. A cookie that isn't in it gets a fresh random
+ token. A migration backfills the tokens of lists that already existed,
+ so current visitors keep their lists (checked against a database at the
+ old schema).
+4. **`--listen` on any address.** The binary now accepts only loopback
+ (`127.0.0.0/8`, `::1`, `localhost`) and exits with status 2 for any
+ other address, a missing port, or neither `--socket` nor `--listen`.
+ tep's every-interface fallback is gone.
+
+## Review findings not fixed
+
+None.
diff --git a/apps/spinel/app.rb b/apps/spinel/app.rb
new file mode 100644
index 0000000..cfe3735
--- /dev/null
+++ b/apps/spinel/app.rb
@@ -0,0 +1,547 @@
+# todo-spinel: TodoMVC, server-rendered, compiled to one native binary.
+#
+# tep (`bin/tep build app.rb`) translates this Sinatra-style source and
+# drives Spinel, matz's ahead-of-time Ruby compiler, to produce a static
+# executable. There is no Ruby at runtime, so this file sticks to the
+# subset Spinel compiles: explicit loops, String/Integer values, and
+# tep's request/response objects.
+#
+# app --socket <path> --db <path> (on Carloku)
+# app --listen 127.0.0.1:<port> --db <path>
+require 'sinatra' # documentation only; the tep translator ignores it
+
+require_relative "version" # APP_VERSION, written by the Makefile
+
+# `make bump` rewrites this string so the next build has to recompile the
+# file holding the GET / handler. It ends up in an HTML comment.
+BUILD_NOTE = "todo-spinel"
+
+COOKIE_NAME = "tid"
+
+# ---------------------------------------------------------------------------
+# Command line and environment.
+
+# The value after `flag` in ARGV, or "" when it isn't there.
+def arg_value(flag)
+ v = ""
+ i = 0
+ while i < ARGV.length
+ if ARGV[i] == flag && i + 1 < ARGV.length
+ v = ARGV[i + 1]
+ end
+ i += 1
+ end
+ v
+end
+
+# This app's own origin, which a POST's Origin header must match:
+# https://$CARLOS_HOST on Carloku, http://<listen address> locally.
+def own_origin
+ host = ENV["CARLOS_HOST"].to_s
+ if host.length > 0
+ return "https://" + host
+ end
+ listen = arg_value("--listen")
+ if listen.length > 0
+ return "http://" + listen
+ end
+ ""
+end
+
+def served_over_https(req)
+ if ENV["CARLOS_HOST"].to_s.length > 0
+ return true
+ end
+ req.headers["x-forwarded-proto"].to_s == "https"
+end
+
+# ---------------------------------------------------------------------------
+# Storage. One SQLite file in WAL mode. Migrations only ever add.
+
+DB = Tep::SQLite.new
+
+MIGRATIONS = [
+ "CREATE TABLE todos (id INTEGER PRIMARY KEY, list TEXT NOT NULL, title TEXT NOT NULL, completed INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL DEFAULT (unixepoch()))",
+ "CREATE INDEX todos_list ON todos (list, id)",
+ # Tokens this server has issued; a cookie not in here is not a list.
+ "CREATE TABLE visitors (token TEXT PRIMARY KEY, created_at INTEGER NOT NULL DEFAULT (unixepoch())) WITHOUT ROWID",
+ # Lists that existed before the table did were all issued by us.
+ "INSERT OR IGNORE INTO visitors (token) SELECT DISTINCT list FROM todos"
+]
+
+def open_db(path)
+ if path.length == 0
+ puts "todo-spinel: --db <path> is required"
+ exit(2)
+ end
+ if !DB.open(path)
+ puts "todo-spinel: cannot open " + path
+ exit(1)
+ end
+ DB.exec("PRAGMA journal_mode=WAL")
+ DB.exec("PRAGMA synchronous=NORMAL")
+ DB.exec("PRAGMA busy_timeout=5000")
+ version = DB.first_int("PRAGMA user_version", "")
+ while version < MIGRATIONS.length
+ DB.exec("BEGIN")
+ if !DB.exec(MIGRATIONS[version])
+ DB.exec("ROLLBACK")
+ puts "todo-spinel: migration " + (version + 1).to_s + " failed"
+ exit(1)
+ end
+ version += 1
+ DB.exec("PRAGMA user_version=" + version.to_s)
+ DB.exec("COMMIT")
+ end
+end
+
+configure do
+ open_db(arg_value("--db"))
+end
+
+# One visitor's todos, read in id order into parallel arrays (tep's
+# SQLite shim has a single cursor, so rows are copied out before any
+# other query runs).
+class TodoList
+ attr_accessor :ids, :titles, :done
+
+ def initialize
+ @ids = [0]
+ @ids.delete_at(0)
+ @titles = [""]
+ @titles.delete_at(0)
+ @done = [0]
+ @done.delete_at(0)
+ end
+
+ def self.load(list)
+ l = TodoList.new
+ DB.prepare("SELECT id, title, completed FROM todos WHERE list = ? ORDER BY id")
+ DB.bind_str(1, list)
+ while DB.step == 1
+ l.ids.push(DB.col_int(0))
+ l.titles.push(DB.col_str(1) + "")
+ l.done.push(DB.col_int(2))
+ end
+ DB.finalize
+ l
+ end
+
+ def size
+ @ids.length
+ end
+
+ def completed_count
+ n = 0
+ i = 0
+ while i < @done.length
+ if @done[i] != 0
+ n += 1
+ end
+ i += 1
+ end
+ n
+ end
+end
+
+def run_sql(sql, list, id)
+ DB.prepare(sql)
+ DB.bind_str(1, list)
+ if id > 0
+ DB.bind_int(2, id)
+ end
+ DB.step
+ DB.finalize
+end
+
+def todo_exists(list, id)
+ DB.prepare("SELECT 1 FROM todos WHERE list = ? AND id = ?")
+ DB.bind_str(1, list)
+ DB.bind_int(2, id)
+ found = DB.step == 1
+ DB.finalize
+ found
+end
+
+def add_todo(list, title)
+ DB.prepare("INSERT INTO todos (list, title) VALUES (?, ?)")
+ DB.bind_str(1, list)
+ DB.bind_str(2, title)
+ DB.step
+ DB.finalize
+end
+
+def rename_todo(list, id, title)
+ DB.prepare("UPDATE todos SET title = ? WHERE list = ? AND id = ?")
+ DB.bind_str(1, title)
+ DB.bind_str(2, list)
+ DB.bind_int(3, id)
+ DB.step
+ DB.finalize
+end
+
+# ---------------------------------------------------------------------------
+# Visitors. Each one's list is keyed by an anonymous cookie holding 128
+# random bits (22 base64url characters from getrandom).
+
+def valid_token(t)
+ if t.length != 22
+ return false
+ end
+ i = 0
+ while i < t.length
+ c = t[i]
+ ok = (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || (c >= "0" && c <= "9") || c == "-" || c == "_"
+ if !ok
+ return false
+ end
+ i += 1
+ end
+ true
+end
+
+def issued_token(t)
+ DB.prepare("SELECT 1 FROM visitors WHERE token = ?")
+ DB.bind_str(1, t)
+ found = DB.step == 1
+ DB.finalize
+ found
+end
+
+def record_token(t)
+ DB.prepare("INSERT INTO visitors (token) VALUES (?)")
+ DB.bind_str(1, t)
+ DB.step
+ DB.finalize
+end
+
+# The caller's list token. A missing cookie, or one this server never
+# issued (garbled, made up, or from another deployment), gets a fresh
+# random token, and so a fresh, empty list, set on this response.
+def visitor(req, res)
+ t = req.cookies[COOKIE_NAME].to_s
+ if valid_token(t) && issued_token(t)
+ return t
+ end
+ t = Crypto.sp_crypto_random_b64url(16).to_s
+ record_token(t)
+ opts = Tep.str_hash
+ opts["Path"] = "/"
+ opts["Max-Age"] = "31536000"
+ opts["HttpOnly"] = ""
+ opts["SameSite"] = "Lax"
+ if served_over_https(req)
+ opts["Secure"] = ""
+ end
+ res.set_cookie(COOKIE_NAME, t, opts)
+ t
+end
+
+# ---------------------------------------------------------------------------
+# Rendering.
+
+def h(s)
+ s.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;").gsub("\"", "&quot;").gsub("'", "&#39;")
+end
+
+def filter_name(f)
+ if f == "active" || f == "completed"
+ return f
+ end
+ "all"
+end
+
+def filter_path(f)
+ if f == "active"
+ return "/active"
+ end
+ if f == "completed"
+ return "/completed"
+ end
+ "/"
+end
+
+def filter_hidden(f)
+ "<input type=\"hidden\" name=\"filter\" value=\"" + f + "\">"
+end
+
+def shown(filter, done)
+ if filter == "active"
+ return done == 0
+ end
+ if filter == "completed"
+ return done != 0
+ end
+ true
+end
+
+def render_item(id, title, done, filter, editing)
+ sid = id.to_s
+ et = h(title)
+ cls = ""
+ if done != 0
+ cls = "completed"
+ end
+ if editing
+ cls = (cls + " editing").strip
+ end
+ out = "<li data-id=\"" + sid + "\""
+ if cls.length > 0
+ out = out + " class=\"" + cls + "\""
+ end
+ out = out + ">\n<div class=\"view\">\n"
+ out = out + "<input class=\"toggle\" type=\"checkbox\" form=\"toggle-" + sid + "\""
+ if done != 0
+ out = out + " checked"
+ end
+ out = out + ">\n<label>" + et + "</label>\n"
+ out = out + "<button class=\"nojs toggle-submit\" data-action=\"toggle\" form=\"toggle-" + sid + "\">Toggle</button>\n"
+ out = out + "<a class=\"nojs edit-link\" data-action=\"edit\" href=\"" + filter_path(filter) + "?edit=" + sid + "\">edit</a>\n"
+ out = out + "<button class=\"destroy\" form=\"destroy-" + sid + "\" title=\"Delete\"></button>\n</div>\n"
+ out = out + "<form class=\"edit-form\" method=\"post\" action=\"/todos/" + sid + "/edit\">"
+ out = out + "<input class=\"edit\" name=\"title\" value=\"" + et + "\""
+ if editing
+ out = out + " autofocus"
+ end
+ out = out + ">" + filter_hidden(filter) + "</form>\n"
+ out = out + "<form id=\"toggle-" + sid + "\" method=\"post\" action=\"/todos/" + sid + "/toggle\" hidden>" + filter_hidden(filter) + "</form>\n"
+ out = out + "<form id=\"destroy-" + sid + "\" method=\"post\" action=\"/todos/" + sid + "/destroy\" hidden>" + filter_hidden(filter) + "</form>\n"
+ out + "</li>\n"
+end
+
+def filter_link(f, label, current)
+ sel = ""
+ if f == current
+ sel = " class=\"selected\""
+ end
+ "<li>\n<a" + sel + " href=\"" + filter_path(f) + "\">" + label + "</a>\n</li>\n"
+end
+
+# section.main and footer.footer for one filter: the part of the page an
+# action changes, and what the JavaScript swaps in after each POST.
+# Empty when the list is.
+def render_lists(list, filter, edit_id)
+ l = TodoList.load(list)
+ if l.size == 0
+ return ""
+ end
+ done = l.completed_count
+ left = l.size - done
+ out = "<section class=\"main\">\n"
+ out = out + "<form id=\"toggle-all-form\" method=\"post\" action=\"/todos/toggle-all\" hidden>" + filter_hidden(filter) + "</form>\n"
+ out = out + "<input id=\"toggle-all\" class=\"toggle-all\" type=\"checkbox\" form=\"toggle-all-form\""
+ if left == 0
+ out = out + " checked"
+ end
+ out = out + ">\n<label for=\"toggle-all\">Mark all as complete</label>\n"
+ out = out + "<button class=\"nojs toggle-all-submit\" data-action=\"toggle-all\" form=\"toggle-all-form\">Mark all as complete</button>\n"
+ out = out + "<ul class=\"todo-list\">\n"
+ i = 0
+ while i < l.size
+ if shown(filter, l.done[i])
+ out = out + render_item(l.ids[i], l.titles[i], l.done[i], filter, l.ids[i] == edit_id)
+ end
+ i += 1
+ end
+ out = out + "</ul>\n</section>\n<footer class=\"footer\">\n"
+ word = "items"
+ if left == 1
+ word = "item"
+ end
+ out = out + "<span class=\"todo-count\"><strong>" + left.to_s + "</strong> " + word + " left</span>\n"
+ out = out + "<ul class=\"filters\">\n" + filter_link("all", "All", filter) + filter_link("active", "Active", filter) + filter_link("completed", "Completed", filter) + "</ul>\n"
+ if done > 0
+ out = out + "<form id=\"clear-completed-form\" method=\"post\" action=\"/todos/clear-completed\" hidden>" + filter_hidden(filter) + "</form>\n"
+ out = out + "<button class=\"clear-completed\" form=\"clear-completed-form\">Clear completed</button>\n"
+ end
+ out + "</footer>\n"
+end
+
+# ---------------------------------------------------------------------------
+# Actions. Every POST answers 303 back to its filter, or, when the page's
+# own script asks with `X-Fragment: 1`, 200 with the fresh fragment.
+
+def origin_allowed(req)
+ o = req.headers["origin"].to_s
+ if o.length == 0
+ return true
+ end
+ o == own_origin
+end
+
+def finish_post(req, res, list)
+ f = filter_name(req.params["filter"].to_s)
+ if req.headers["x-fragment"].to_s == "1"
+ res.headers["Cache-Control"] = "no-store"
+ return render_lists(list, f, 0)
+ end
+ res.set_status(303)
+ res.headers["Location"] = filter_path(f)
+ ""
+end
+
+def refuse(res, code, msg)
+ res.set_status(code)
+ res.headers["Content-Type"] = "text/plain; charset=utf-8"
+ msg
+end
+
+# The numeric id in the path, or 0 when it isn't one.
+def path_id(req)
+ s = req.params["id"].to_s
+ if s.length == 0 || s.length > 18
+ return 0
+ end
+ i = 0
+ while i < s.length
+ if s[i] < "0" || s[i] > "9"
+ return 0
+ end
+ i += 1
+ end
+ s.to_i
+end
+
+# The whole page. Its markup follows TodoMVC's own template
+# (shared/reference.html); render_lists fills in the middle.
+def layout(filter, lists, autofocus)
+ out = "<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n"
+ out = out + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
+ out = out + "<title>TodoMVC</title>\n"
+ out = out + "<link rel=\"stylesheet\" href=\"/static/base.css\">\n"
+ out = out + "<link rel=\"stylesheet\" href=\"/static/index.css\">\n"
+ out = out + "<link rel=\"stylesheet\" href=\"/static/app.css\">\n"
+ out = out + "<script>document.documentElement.classList.add(\"js\")</script>\n"
+ out = out + "<script type=\"module\" src=\"/static/app.js\"></script>\n"
+ out = out + "<!-- " + BUILD_NOTE + " -->\n</head>\n<body>\n"
+ out = out + "<section class=\"todoapp\">\n<header class=\"header\">\n<h1>todos</h1>\n"
+ out = out + "<form method=\"post\" action=\"/todos\">\n"
+ out = out + "<input class=\"new-todo\" name=\"title\" placeholder=\"What needs to be done?\" autocomplete=\"off\""
+ if autofocus
+ out = out + " autofocus"
+ end
+ out = out + ">\n" + filter_hidden(filter) + "\n</form>\n</header>\n"
+ out = out + lists
+ out = out + "</section>\n<footer class=\"info\">\n<p>Double-click to edit a todo</p>\n"
+ out = out + "<p>Created by <a href=\"https://bakeoff.bfj.oncarlos.com\">the CARLOS bakeoff</a> \u00b7 Spinel</p>\n"
+ out = out + "<p>Part of <a href=\"http://todomvc.com\">TodoMVC</a></p>\n</footer>\n</body>\n</html>\n"
+ out
+end
+
+def page(req, res, filter)
+ list = visitor(req, res)
+ edit_id = 0
+ e = req.params["edit"].to_s
+ if e.length > 0 && e.length < 19
+ edit_id = e.to_i
+ end
+ res.headers["Cache-Control"] = "no-store"
+ lists = render_lists(list, filter, edit_id)
+ # The item being edited takes the focus; otherwise the new-todo box.
+ layout(filter, lists, !lists.include?(" autofocus>"))
+end
+
+# ---------------------------------------------------------------------------
+# Routes.
+
+get '/' do
+ page(request, response, "all")
+end
+
+get '/active' do
+ page(request, response, "active")
+end
+
+get '/completed' do
+ page(request, response, "completed")
+end
+
+get '/healthz' do
+ content_type 'text/plain; charset=utf-8'
+ "ok"
+end
+
+get '/api/version' do
+ content_type 'text/plain; charset=utf-8'
+ res.headers["Cache-Control"] = "no-store"
+ APP_VERSION
+end
+
+post '/todos' do
+ if !origin_allowed(request)
+ return refuse(response, 403, "cross-origin POST refused")
+ end
+ list = visitor(request, response)
+ title = request.params["title"].to_s.strip
+ if title.length > 0
+ add_todo(list, title)
+ end
+ finish_post(request, response, list)
+end
+
+post '/todos/toggle-all' do
+ if !origin_allowed(request)
+ return refuse(response, 403, "cross-origin POST refused")
+ end
+ list = visitor(request, response)
+ l = TodoList.load(list)
+ if l.size > 0 && l.completed_count == l.size
+ run_sql("UPDATE todos SET completed = 0 WHERE list = ?", list, 0)
+ else
+ run_sql("UPDATE todos SET completed = 1 WHERE list = ?", list, 0)
+ end
+ finish_post(request, response, list)
+end
+
+post '/todos/clear-completed' do
+ if !origin_allowed(request)
+ return refuse(response, 403, "cross-origin POST refused")
+ end
+ list = visitor(request, response)
+ run_sql("DELETE FROM todos WHERE list = ? AND completed != 0", list, 0)
+ finish_post(request, response, list)
+end
+
+post '/todos/:id/toggle' do
+ if !origin_allowed(request)
+ return refuse(response, 403, "cross-origin POST refused")
+ end
+ list = visitor(request, response)
+ id = path_id(request)
+ if id == 0 || !todo_exists(list, id)
+ return refuse(response, 404, "no such todo")
+ end
+ run_sql("UPDATE todos SET completed = 1 - completed WHERE list = ? AND id = ?", list, id)
+ finish_post(request, response, list)
+end
+
+post '/todos/:id/edit' do
+ if !origin_allowed(request)
+ return refuse(response, 403, "cross-origin POST refused")
+ end
+ list = visitor(request, response)
+ id = path_id(request)
+ if id == 0 || !todo_exists(list, id)
+ return refuse(response, 404, "no such todo")
+ end
+ title = request.params["title"].to_s.strip
+ if title.length > 0
+ rename_todo(list, id, title)
+ else
+ run_sql("DELETE FROM todos WHERE list = ? AND id = ?", list, id)
+ end
+ finish_post(request, response, list)
+end
+
+post '/todos/:id/destroy' do
+ if !origin_allowed(request)
+ return refuse(response, 403, "cross-origin POST refused")
+ end
+ list = visitor(request, response)
+ id = path_id(request)
+ if id == 0 || !todo_exists(list, id)
+ return refuse(response, 404, "no such todo")
+ end
+ run_sql("DELETE FROM todos WHERE list = ? AND id = ?", list, id)
+ finish_post(request, response, list)
+end
diff --git a/apps/spinel/assets/static/app.css b/apps/spinel/assets/static/app.css
new file mode 100644
index 0000000..7cb340c
--- /dev/null
+++ b/apps/spinel/assets/static/app.css
@@ -0,0 +1,64 @@
+/* Controls for when JavaScript is off. Each sits where its TodoMVC
+ counterpart is, so the page looks the same either way; the script
+ adds the `js` class to <html> and these disappear. */
+.js .nojs {
+ display: none;
+}
+
+.nojs {
+ margin: 0;
+ padding: 0;
+ border: 0;
+ background: none;
+ font: inherit;
+ color: inherit;
+ cursor: pointer;
+}
+
+/* Over the "mark all" chevron. */
+.toggle-all-submit {
+ position: absolute;
+ top: -65px;
+ left: 0;
+ width: 45px;
+ height: 65px;
+ opacity: 0;
+}
+
+/* Over the round checkbox at the start of each item. */
+.toggle-submit {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ width: 40px;
+ opacity: 0;
+}
+
+/* A small "edit" link, left of where the delete cross appears. */
+.edit-link {
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ right: 50px;
+ height: 20px;
+ margin: auto 0;
+ font-size: 14px;
+ line-height: 20px;
+ color: #949494;
+ text-decoration: none;
+}
+
+.edit-link:hover,
+.edit-link:focus {
+ color: #4d4d4d;
+ text-decoration: underline;
+}
+
+html:not(.js) .todo-list li label {
+ padding-right: 100px;
+}
+
+.edit-form {
+ margin: 0;
+}
diff --git a/apps/spinel/assets/static/app.js b/apps/spinel/assets/static/app.js
new file mode 100644
index 0000000..3648464
--- /dev/null
+++ b/apps/spinel/assets/static/app.js
@@ -0,0 +1,80 @@
+// TodoMVC behaviour on top of the server-rendered page. Every change is
+// still one of the page's own form POSTs; this script sends it with
+// fetch and an `X-Fragment: 1` header, and the server answers with the
+// fresh section.main and footer.footer, which replace the old ones.
+// Without this script the same forms submit normally.
+
+const app = document.querySelector('.todoapp');
+const header = app.querySelector('.header');
+
+let queue = Promise.resolve();
+
+function render(html) {
+ for (const el of app.querySelectorAll(':scope > .main, :scope > .footer')) el.remove();
+ header.insertAdjacentHTML('afterend', html);
+}
+
+// POSTs run one after another, in the order they were made.
+function send(form) {
+ const body = new URLSearchParams(new FormData(form));
+ queue = queue.then(async () => {
+ try {
+ const res = await fetch(form.action, {
+ method: 'POST',
+ body,
+ headers: { 'X-Fragment': '1' },
+ credentials: 'same-origin',
+ });
+ if (!res.ok) throw new Error(`${res.status}`);
+ render(await res.text());
+ } catch {
+ location.reload();
+ }
+ });
+ return queue;
+}
+
+app.addEventListener('submit', (e) => {
+ const form = e.target;
+ e.preventDefault();
+ if (form.classList.contains('edit-form')) {
+ const li = form.closest('li');
+ if (!li.classList.contains('editing')) return;
+ li.classList.remove('editing');
+ }
+ send(form);
+ const input = form.querySelector('.new-todo');
+ if (input) input.value = '';
+});
+
+// The checkboxes belong to their item's toggle form (and the toggle-all
+// form) through the `form` attribute; a change submits that form.
+app.addEventListener('change', (e) => {
+ const box = e.target;
+ if (box.matches('.toggle, .toggle-all') && box.form) box.form.requestSubmit();
+});
+
+app.addEventListener('dblclick', (e) => {
+ const label = e.target.closest('.todo-list li label');
+ if (!label) return;
+ const li = label.closest('li');
+ const input = li.querySelector('.edit');
+ input.value = label.textContent;
+ li.classList.add('editing');
+ input.focus();
+ input.setSelectionRange(input.value.length, input.value.length);
+});
+
+app.addEventListener('keydown', (e) => {
+ if (e.key !== 'Escape' || !e.target.matches('.edit')) return;
+ const li = e.target.closest('li');
+ e.target.value = li.querySelector('label').textContent;
+ li.classList.remove('editing');
+});
+
+// Leaving the editor saves, as in TodoMVC. focusout bubbles; blur doesn't.
+app.addEventListener('focusout', (e) => {
+ if (!e.target.matches('.edit')) return;
+ const li = e.target.closest('li');
+ if (li && li.classList.contains('editing')) e.target.form.requestSubmit();
+});
diff --git a/apps/spinel/assets/static/base.css b/apps/spinel/assets/static/base.css
new file mode 100644
index 0000000..da65968
--- /dev/null
+++ b/apps/spinel/assets/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/spinel/assets/static/index.css b/apps/spinel/assets/static/index.css
new file mode 100644
index 0000000..2c0b4b6
--- /dev/null
+++ b/apps/spinel/assets/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/spinel/patches/1-tep-carlos.patch b/apps/spinel/patches/1-tep-carlos.patch
new file mode 100644
index 0000000..ef334f5
--- /dev/null
+++ b/apps/spinel/patches/1-tep-carlos.patch
@@ -0,0 +1,198 @@
+diff --git a/bin/tep b/bin/tep
+index a66fcb1..1c34e08 100755
+--- a/bin/tep
++++ b/bin/tep
+@@ -898,6 +898,7 @@ def translate(input_path, lax: ENV["TEP_LAX"] == "1")
+ out << <<~RB
+ __port = #{config[:port] || 4567}
+ __workers = #{config[:workers] || 1}
++ __sockpath = ""
+ __quiet = false
+ __scheduled = #{scheduled ? 'true' : 'false'}
+ __i = 0
+@@ -909,6 +910,18 @@ def translate(input_path, lax: ENV["TEP_LAX"] == "1")
+ elsif __a == "-w" && __i + 1 < ARGV.length
+ __workers = ARGV[__i + 1].to_i
+ __i += 2
++ elsif __a == "--socket" && __i + 1 < ARGV.length
++ # Serve over this unix-domain socket path instead of TCP (the
++ # argv deployment platforms like CARLOS spawn instances with).
++ __sockpath = ARGV[__i + 1]
++ __i += 2
++ elsif __a == "--db" && __i + 1 < ARGV.length
++ # Platform-supplied DB path; tolerated so a platform can pass it
++ # unconditionally. Apps that want it read it from ARGV themselves.
++ __i += 2
++ elsif __a == "serve"
++ # Tolerated no-op: platform unit templates default to `<bin> serve`.
++ __i += 1
+ elsif __a == "-q"
+ __quiet = true
+ __i += 1
+@@ -921,7 +934,11 @@ def translate(input_path, lax: ENV["TEP_LAX"] == "1")
+ __i += 1
+ end
+ end
+- Tep.run!(__port, __workers, __quiet, __scheduled)
++ if __sockpath.length > 0
++ Tep.run_unix!(__sockpath, __quiet)
++ else
++ Tep.run!(__port, __workers, __quiet, __scheduled)
++ end
+ RB
+ out << ""
+
+diff --git a/tep.rb b/tep.rb
+index 92a8ae4..7fb112a 100644
+--- a/tep.rb
++++ b/tep.rb
+@@ -903,6 +903,11 @@ module Tep
+ # accepts the call without the 4th arg only because it supports
+ # default-value params; without this, the 3-arg call silently
+ # miscompiled (matz/spinel arity-warning shape, tep#13).
++ # Unix-socket twin of run! -- see Server#run_unix.
++ def self.run_unix!(path, quiet)
++ Server.new(APP).run_unix(path, quiet)
++ end
++
+ def self.run!(port, workers, quiet, scheduled = false)
+ if scheduled
+ Server::Scheduled.new(APP).run(port, workers, quiet)
+diff --git a/tep/app.rb b/tep/app.rb
+index 7668be8..500da09 100644
+--- a/tep/app.rb
++++ b/tep/app.rb
+@@ -63,6 +63,13 @@ module Tep
+ # Late-seeded for the same reason as openai_backend.
+ attr_accessor :openai_events
+ attr_accessor :asset_bodies, :asset_mimes, :asset_etags
++ # Serve every response with Connection: close (no keep-alive reuse).
++ # Set by Server#run_unix: that mode is a single blocking worker, and
++ # a reverse proxy's idle keep-alive connection would wedge it — the
++ # worker blocks reading the idle socket while every other connection
++ # queues behind it (observed live behind CARLOS's activator proxy:
++ # intermittent hangs + dropped POSTs until Go's 90s idle timeout).
++ attr_accessor :close_per_request
+ attr_accessor :sched_fibers, :sched_wake_at, :sched_current
+ attr_accessor :sched_io_fd, :sched_io_mode, :sched_io_ready
+ # Tep::Job background-worker idempotency flag. App-level so a
+@@ -113,6 +120,7 @@ module Tep
+ @asset_mimes = Tep.str_hash # by Tep::Assets._add lines
+ # the bin/tep translator emits)
+ @asset_etags = Tep.str_hash # path -> content-hash ETag (#152)
++ @close_per_request = false
+ # FiberSlot array for the cooperative scheduler. Initialise
+ # with a noop-bodied slot to pin the array element type, then
+ # drop it. Each slot holds one Fiber + a timer entry in the
+diff --git a/tep/net.rb b/tep/net.rb
+index 45d22e9..7a20724 100644
+--- a/tep/net.rb
++++ b/tep/net.rb
+@@ -24,6 +24,9 @@ module Sock
+ ffi_lib "crypto"
+
+ ffi_func :sphttp_listen, [:int, :int], :int
++ # Unix-domain listener: bind+listen on a filesystem path (see
++ # sphttp.c). -1 on failure; accept/read/write reuse the same fd paths.
++ ffi_func :sphttp_listen_unix, [:str], :int
+ ffi_func :sphttp_accept, [:int], :int
+ # Non-blocking accept variant used by Tep::Server::Scheduled.
+ # Listen fd must be in non-blocking mode (sphttp_set_nonblock).
+diff --git a/tep/server.rb b/tep/server.rb
+index 1bec928..8e81dea 100644
+--- a/tep/server.rb
++++ b/tep/server.rb
+@@ -96,6 +96,31 @@ module Tep
+ end
+ end
+
++
++ # Serve over a unix-domain socket path: the shape deployment
++ # platforms hand an instance app (`--socket <path>`). One process,
++ # one listener -- prefork/SO_REUSEPORT does not apply to AF_UNIX,
++ # and socket-path apps are typically hibernated single instances.
++ def run_unix(path, quiet)
++ if !quiet
++ puts "[tep " + VERSION + "] listening on unix:" + path
++ end
++ # One blocking worker serves this socket: an idle kept-alive proxy
++ # connection would starve every other connection. Close per request;
++ # re-dialing a unix socket is effectively free.
++ Tep::APP.close_per_request = true
++ Sock.sphttp_install_term_handlers
++ sfd = Sock.sphttp_listen_unix(path)
++ if sfd < 0
++ puts "tep: bind failed on " + path
++ exit(1)
++ end
++ worker_loop(sfd)
++ if Sock.sphttp_shutdown_requested != 0
++ Tep.on_shutdown
++ end
++ end
++
+ def worker_loop(sfd)
+ loop do
+ client = Sock.sphttp_accept(sfd)
+@@ -151,7 +176,7 @@ module Tep
+ res = Response.new
+ @app.dispatch(req, res)
+
+- keep_alive = req.keep_alive? && !res.halted_close?
++ keep_alive = req.keep_alive? && !res.halted_close? && !Tep::APP.close_per_request
+ write_response(client, req, res, keep_alive)
+ keep_going = keep_alive
+ end
+@@ -240,7 +265,12 @@ module Tep
+ if !res.headers.key?("Content-Type") && res.status != 204 && res.status != 304
+ res.headers["Content-Type"] = "text/html; charset=utf-8"
+ end
+- res.headers["Content-Length"] = res.body.length.to_s
++ # bytesize, not length: Content-Length is an OCTET count, and a
++ # body with multi-byte UTF-8 (an embedded asset, upstream API
++ # text) otherwise declares fewer bytes than are written -- the
++ # client truncates the tail (seen live: a bundled .js losing its
++ # closing braces, killing the whole script).
++ res.headers["Content-Length"] = res.body.bytesize.to_s
+ if keep_alive
+ res.headers["Connection"] = "keep-alive"
+ else
+diff --git a/tep/sphttp.c b/tep/sphttp.c
+index 6b7863e..7af1b04 100644
+--- a/tep/sphttp.c
++++ b/tep/sphttp.c
+@@ -24,6 +24,7 @@
+ #include <sys/wait.h>
+ #include <sys/stat.h>
+ #include <netinet/in.h>
++#include <sys/un.h>
+ #include <netinet/tcp.h>
+ #include <arpa/inet.h>
+ #include <signal.h>
+@@ -126,6 +127,26 @@ int sphttp_sleep_ms(int ms) {
+ * term-flag-aware pre-check + EINTR handling as the old body; the
+ * shutdown flag is now sp_net's (set via sp_net_install_term_handlers,
+ * which sphttp_install_term_handlers delegates to). */
++
++/* Unix-domain listener (deployment platforms that hand an app a socket
++ * path -- e.g. CARLOS spawns instances as `<bin> --socket <path>` and
++ * proxies HTTP over it). Unlinks a stale path, binds, listens. Single
++ * fd, no SO_REUSEPORT (kernel load-balancing is a TCP concern; unix
++ * instance apps run one process). Returns the listening fd or -1. */
++int sphttp_listen_unix(const char *path) {
++ struct sockaddr_un addr;
++ if (strlen(path) >= sizeof(addr.sun_path)) return -1;
++ int fd = socket(AF_UNIX, SOCK_STREAM, 0);
++ if (fd < 0) return -1;
++ memset(&addr, 0, sizeof(addr));
++ addr.sun_family = AF_UNIX;
++ strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1);
++ unlink(path);
++ if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { close(fd); return -1; }
++ if (listen(fd, 128) < 0) { close(fd); unlink(path); return -1; }
++ return fd;
++}
++
+ int sphttp_listen(int port, int reuseport) { return sp_net_listen(port, reuseport); }
+ int sphttp_accept(int sfd) { return sp_net_accept(sfd); }
+ int sphttp_accept_nb(int sfd) { return sp_net_accept_nb(sfd); }
diff --git a/apps/spinel/patches/2-tep-bakeoff.patch b/apps/spinel/patches/2-tep-bakeoff.patch
new file mode 100644
index 0000000..61a08a4
--- /dev/null
+++ b/apps/spinel/patches/2-tep-bakeoff.patch
@@ -0,0 +1,529 @@
+--- a/bin/tep
++++ b/bin/tep
+@@ -899,6 +899,8 @@
+ __port = #{config[:port] || 4567}
+ __workers = #{config[:workers] || 1}
+ __sockpath = ""
++ __listen_host = ""
++ __listen_bad = false
+ __quiet = false
+ __scheduled = #{scheduled ? 'true' : 'false'}
+ __i = 0
+@@ -915,6 +917,21 @@
+ # argv deployment platforms like CARLOS spawn instances with).
+ __sockpath = ARGV[__i + 1]
+ __i += 2
++ elsif __a == "--listen" && __i + 1 < ARGV.length
++ # host:port -- bind TCP on exactly that address. Loopback only
++ # (127.0.0.0/8, ::1, localhost): anything else is refused below.
++ __hp = ARGV[__i + 1]
++ __colon = __hp.rindex(":")
++ if __colon == nil
++ __listen_bad = true
++ else
++ __listen_host = __hp[0, __colon]
++ __port = __hp[__colon + 1, __hp.length - __colon - 1].to_i
++ if !Tep.loopback_host?(__listen_host) || __port < 1 || __port > 65535
++ __listen_bad = true
++ end
++ end
++ __i += 2
+ elsif __a == "--db" && __i + 1 < ARGV.length
+ # Platform-supplied DB path; tolerated so a platform can pass it
+ # unconditionally. Apps that want it read it from ARGV themselves.
+@@ -934,10 +951,19 @@
+ __i += 1
+ end
+ end
++ # Never fall back to tep's every-interface listener: the process
++ # serves a unix socket (--socket) or a loopback address (--listen).
++ if __listen_bad
++ puts "tep: --listen needs a loopback address and a port, e.g. 127.0.0.1:8080"
++ exit(2)
++ end
+ if __sockpath.length > 0
+ Tep.run_unix!(__sockpath, __quiet)
++ elsif __listen_host.length > 0
++ Tep.run_host!(__listen_host, __port, __quiet)
+ else
+- Tep.run!(__port, __workers, __quiet, __scheduled)
++ puts "tep: give --socket <path> or --listen 127.0.0.1:<port>"
++ exit(2)
+ end
+ RB
+ out << ""
+--- a/native/sqlite/tep_sqlite.c
++++ b/native/sqlite/tep_sqlite.c
+@@ -44,7 +44,7 @@
+ #include <sqlite3.h>
+
+ #define TEP_SQLITE_MAX_HANDLES 16
+-#define TEP_SQLITE_COL_BUFSIZE 65536
++#define TEP_SQLITE_COL_BUFSIZE (256 * 1024 + 1) /* a whole request body (SPHTTP_BODY_MAX) fits */
+ #define TEP_SQLITE_COL_BUF_SLOTS 16
+ #define TEP_SQLITE_CACHE_SLOTS 64 /* prepare-statement cache */
+ #define TEP_SQLITE_CACHE_SQL_MAX 512 /* longest cached SQL */
+--- a/sig/tep/request.rbs
++++ b/sig/tep/request.rbs
+@@ -14,6 +14,7 @@
+ # an ivar slot with Response.
+ attr_accessor req_headers: Hash[String, String]
+ attr_accessor raw_body: String
++ attr_accessor body_status: Integer
+ attr_accessor cookies: Hash[String, String]
+ attr_accessor session: Tep::Session
+ attr_accessor remote_host: String
+--- a/tep/assets.rb
++++ b/tep/assets.rb
+@@ -25,8 +25,11 @@
+ # `Tep.public_dir` instead.
+ module Tep
+ class Assets
++ # Returns 0, as sig/tep/assets.rbs declares (add_asset returns the
++ # ETag String, which otherwise leaks out as this method's value).
+ def self._add(path, body, mime)
+ Tep::APP.add_asset(path, body, mime)
++ 0
+ end
+
+ def self.has?(path)
+--- a/tep/net.rb
++++ b/tep/net.rb
+@@ -27,6 +27,14 @@
+ # Unix-domain listener: bind+listen on a filesystem path (see
+ # sphttp.c). -1 on failure; accept/read/write reuse the same fd paths.
+ ffi_func :sphttp_listen_unix, [:str], :int
++ # TCP listener on one address (`--listen 127.0.0.1:<port>`).
++ ffi_func :sphttp_listen_host, [:str, :int], :int
++ # Form-rules URL decoding in C (see sphttp.c and Tep.parse_form).
++ ffi_func :sphttp_url_unescape, [:str], :str
++ # Whole-request deadline + complete-body reads (see sphttp.c).
++ ffi_func :sphttp_set_deadline, [:int, :int], :int
++ ffi_func :sphttp_body_max, [], :int
++ ffi_func :sphttp_body_incomplete, [], :int
+ ffi_func :sphttp_accept, [:int], :int
+ # Non-blocking accept variant used by Tep::Server::Scheduled.
+ # Listen fd must be in non-blocking mode (sphttp_set_nonblock).
+--- a/tep/parser.rb
++++ b/tep/parser.rb
+@@ -33,7 +33,7 @@
+ else
+ req.path = req.raw_path[0, qmark]
+ qstring = req.raw_path[qmark + 1, req.raw_path.length - qmark - 1]
+- req.query = SpinelKit::Url.parse_query(qstring)
++ req.query = Tep.parse_form(qstring)
+ end
+
+ i = 1
+@@ -66,7 +66,8 @@
+ if eq > 0
+ cname = pair[0, eq].strip
+ cvalue = pair[eq + 1, pair.length - eq - 1].strip
+- req.cookies[cname] = SpinelKit::Url.unescape(cvalue)
++ cdecoded = Sock.sphttp_url_unescape(cvalue)
++ req.cookies[cname] = cdecoded
+ end
+ end
+ end
+--- a/tep/request.rb
++++ b/tep/request.rb
+@@ -4,6 +4,8 @@
+ attr_accessor :verb, :path, :raw_path, :http_version
+ attr_accessor :params, :query, :req_headers, :raw_body, :cookies, :session
+ attr_accessor :remote_host
++ # 0 when the body was read whole; else the status to refuse it with.
++ attr_accessor :body_status
+ attr_accessor :ivars
+ # Set by the auth-filter (Tep::AuthFilter, run before the user's
+ # before-filter -- see Tep::App#auth_filter). Always populated:
+@@ -29,6 +31,7 @@
+ @session = Session.new # signed cookie store
+ @raw_body = "" # same reasoning as req_headers
+ @remote_host = ""
++ @body_status = 0
+ @passed = false # `pass` flag: skip to the next matching route
+ @ivars = Tep.str_hash # per-request bag for `@name = ...`
+ # set by handlers and `before` filters,
+@@ -136,12 +139,33 @@
+ # Other content types leave @raw_body intact for handlers that
+ # want to consume it directly.
+ def consume_body(client_fd)
++ if @req_headers.key?("transfer-encoding")
++ @body_status = 400
++ return 0
++ end
+ cl = content_length
+- already = @raw_body.length
++ if cl < 0
++ @body_status = 400
++ return 0
++ end
++ if cl > Sock.sphttp_body_max
++ @body_status = 413
++ return 0
++ end
++ already = @raw_body.bytesize
+ if cl > already
+ rest = Sock.sphttp_drain_body(client_fd, cl - already)
++ if Sock.sphttp_body_incomplete != 0
++ @body_status = 400
++ return 0
++ end
+ @raw_body = @raw_body + rest
+ end
++ # Short (or carrying a NUL, which the C string boundary cuts off).
++ if @raw_body.bytesize < cl
++ @body_status = 400
++ return 0
++ end
+ parse_form_body
+ 0
+ end
+@@ -190,7 +214,7 @@
+ # @raw_body to Content-Length.
+ def parse_form_body
+ if form?
+- SpinelKit::Url.parse_query(@raw_body).each do |k, v|
++ Tep.parse_form(@raw_body).each do |k, v|
+ @params[k] = v
+ end
+ elsif multipart?
+--- a/tep/router.rb
++++ b/tep/router.rb
+@@ -77,7 +77,8 @@
+ pp = pat[i]
+ if pp.length > 0 && pp[0] == ":"
+ name = @r_params[pi]
+- req.params[name] = SpinelKit::Url.unescape(rp[i])
++ pdecoded = Sock.sphttp_url_unescape(rp[i])
++ req.params[name] = pdecoded
+ pi += 1
+ end
+ i += 1
+--- a/tep/server.rb
++++ b/tep/server.rb
+@@ -8,11 +8,13 @@
+ if status == 204; return "No Content"; end
+ if status == 301; return "Moved Permanently"; end
+ if status == 302; return "Found"; end
++ if status == 303; return "See Other"; end
+ if status == 304; return "Not Modified"; end
+ if status == 400; return "Bad Request"; end
+ if status == 401; return "Unauthorized"; end
+ if status == 403; return "Forbidden"; end
+ if status == 404; return "Not Found"; end
++ if status == 413; return "Content Too Large"; end
+ if status == 500; return "Internal Server Error"; end
+ "OK"
+ end
+@@ -121,6 +123,25 @@
+ end
+ end
+
++ # Serve TCP on one address (`--listen 127.0.0.1:<port>`), with the
++ # same single-worker, close-per-request shape as run_unix.
++ def run_host(host, port, quiet)
++ if !quiet
++ puts "[tep " + VERSION + "] listening on http://" + host + ":" + port.to_s
++ end
++ Tep::APP.close_per_request = true
++ Sock.sphttp_install_term_handlers
++ sfd = Sock.sphttp_listen_host(host, port)
++ if sfd < 0
++ puts "tep: bind failed on " + host + ":" + port.to_s
++ exit(1)
++ end
++ worker_loop(sfd)
++ if Sock.sphttp_shutdown_requested != 0
++ Tep.on_shutdown
++ end
++ end
++
+ def worker_loop(sfd)
+ loop do
+ client = Sock.sphttp_accept(sfd)
+@@ -152,6 +173,12 @@
+ end
+ Sock.sphttp_set_recv_timeout(client, 0)
+ end
++ # Close-per-request mode serves one connection at a time, so a
++ # client that connects and then sends nothing, or trickles bytes,
++ # must not hold the worker: the whole request gets 10 seconds.
++ if Tep::APP.close_per_request
++ Sock.sphttp_set_deadline(client, 10000)
++ end
+ handle_connection(client)
+ end
+ # Explicit Integer return: the `loop` falls through as nil (boxed),
+@@ -178,6 +205,12 @@
+ end
+
+ req.consume_body(client)
++ # A body that is too large, short of its Content-Length, or chunked
++ # (unsupported here) never reaches a handler, so nothing mutates.
++ if req.body_status != 0
++ send_simple(client, req.body_status, "the body was refused")
++ break
++ end
+
+ res = Response.new
+ @app.dispatch(req, res)
+--- a/tep/sphttp.c
++++ b/tep/sphttp.c
+@@ -85,6 +85,7 @@
+ extern int sp_net_install_term_handlers(void);
+ extern int sp_net_shutdown_requested(void);
+ extern int sp_net_listen(int port, int reuseport);
++extern int sp_net_listen_host(const char *host, int port, int backlog);
+ extern int sp_net_accept(int sfd);
+ extern int sp_net_accept_nb(int sfd);
+ extern int sp_net_connect(const char *host, int port);
+@@ -147,19 +148,111 @@
+ return fd;
+ }
+
++/* TCP listener bound to one address (e.g. 127.0.0.1) rather than
++ * INADDR_ANY, for `--listen host:port`. -1 on failure. */
++int sphttp_listen_host(const char *host, int port) {
++ int fd = sp_net_listen_host(host, port, 1024);
++ if (fd >= 0) {
++ int one = 1;
++ setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
++ }
++ return fd;
++}
++
+ int sphttp_listen(int port, int reuseport) { return sp_net_listen(port, reuseport); }
++
++/* URL-decode with form rules ('+' is a space). Decoding byte by byte in
++ * Ruby allocates a string per input byte, and SpinelKit::Url.parse_query
++ * holds the decoded key in an unrooted temporary while it decodes the
++ * value, so a collection in between loses the key (seen on aarch64 with
++ * long form values, and on x86-64 under SPINEL_GC_STRESS). Returns a
++ * static buffer; the FFI copies it into a Ruby string. */
++static char *sphttp_unesc_buf = NULL;
++static size_t sphttp_unesc_cap = 0;
++
++static int sphttp_hexval(int c) {
++ if (c >= '0' && c <= '9') return c - '0';
++ if (c >= 'a' && c <= 'f') return c - 'a' + 10;
++ if (c >= 'A' && c <= 'F') return c - 'A' + 10;
++ return -1;
++}
++
++const char *sphttp_url_unescape(const char *s) {
++ size_t n = strlen(s);
++ if (n + 1 > sphttp_unesc_cap) {
++ char *nb = (char *)realloc(sphttp_unesc_buf, n + 1);
++ if (!nb) return "";
++ sphttp_unesc_buf = nb;
++ sphttp_unesc_cap = n + 1;
++ }
++ size_t o = 0;
++ for (size_t i = 0; i < n; i++) {
++ unsigned char c = (unsigned char)s[i];
++ if (c == '+') {
++ sphttp_unesc_buf[o++] = ' ';
++ } else if (c == '%' && i + 2 < n &&
++ sphttp_hexval((unsigned char)s[i + 1]) >= 0 &&
++ sphttp_hexval((unsigned char)s[i + 2]) >= 0) {
++ sphttp_unesc_buf[o++] = (char)(sphttp_hexval((unsigned char)s[i + 1]) * 16 +
++ sphttp_hexval((unsigned char)s[i + 2]));
++ i += 2;
++ } else {
++ sphttp_unesc_buf[o++] = (char)c;
++ }
++ }
++ sphttp_unesc_buf[o] = '\0';
++ return sphttp_unesc_buf;
++}
+ int sphttp_accept(int sfd) { return sp_net_accept(sfd); }
+ int sphttp_accept_nb(int sfd) { return sp_net_accept_nb(sfd); }
+
+ /* Read until end-of-headers ("\r\n\r\n") or the buffer fills. Subsequent
+ * recv()s for the body are the caller's job (we expose a length helper).
+ * Returns the parsed length (>0), 0 on clean EOF, -1 on error. */
++/* Whole-request deadline (close-per-request mode). Before every blocking
++ * read or write on the connection, SO_RCVTIMEO/SO_SNDTIMEO are set to the
++ * time left, so a client trickling one byte at a time can hold the single
++ * worker for at most the deadline, not a timeout per read. */
++static struct timespec sphttp_deadline;
++static int sphttp_deadline_on = 0;
++
++int sphttp_set_deadline(int fd, int ms) {
++ (void)fd;
++ if (ms <= 0) { sphttp_deadline_on = 0; return 0; }
++ clock_gettime(CLOCK_MONOTONIC, &sphttp_deadline);
++ sphttp_deadline.tv_sec += ms / 1000;
++ sphttp_deadline.tv_nsec += (long)(ms % 1000) * 1000000L;
++ if (sphttp_deadline.tv_nsec >= 1000000000L) {
++ sphttp_deadline.tv_sec += 1;
++ sphttp_deadline.tv_nsec -= 1000000000L;
++ }
++ sphttp_deadline_on = 1;
++ return 0;
++}
++
++/* Arms the socket timeouts with the time left; -1 once it has run out. */
++static int sphttp_arm_deadline(int fd) {
++ if (!sphttp_deadline_on) return 0;
++ struct timespec now;
++ clock_gettime(CLOCK_MONOTONIC, &now);
++ long long left_ms = (long long)(sphttp_deadline.tv_sec - now.tv_sec) * 1000LL +
++ (sphttp_deadline.tv_nsec - now.tv_nsec) / 1000000L;
++ if (left_ms <= 0) { errno = ETIMEDOUT; return -1; }
++ struct timeval tv;
++ tv.tv_sec = (time_t)(left_ms / 1000);
++ tv.tv_usec = (suseconds_t)((left_ms % 1000) * 1000);
++ setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
++ setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
++ return 0;
++}
++
+ int sphttp_read_request(int fd) {
+ SSL *ssl = sphttp_ssl_for(fd); /* inbound TLS: decrypt via SSL_read */
+ sphttp_req_len = 0;
+ sphttp_req_buf[0] = '\0';
+ while (sphttp_req_len < SPHTTP_BUFSIZE - 1) {
+ int want = SPHTTP_BUFSIZE - 1 - sphttp_req_len;
++ if (!ssl && sphttp_arm_deadline(fd) < 0) return -1;
+ ssize_t n = ssl
+ ? (ssize_t)SSL_read(ssl, sphttp_req_buf + sphttp_req_len, want)
+ : recv(fd, sphttp_req_buf + sphttp_req_len, (size_t)want, 0);
+@@ -190,23 +283,42 @@
+ * computes remaining = content_length - already_in_buf; this gulps
+ * those into a Ruby-visible string buffer. We round-trip via a
+ * static buffer to avoid hand-rolling write_str FFI. */
+-static char sphttp_body_buf[SPHTTP_BUFSIZE];
++/* Bodies are read whole, up to SPHTTP_BODY_MAX, into a growing buffer
++ * (this used to stop at 64 KiB, silently truncating longer bodies).
++ * sphttp_body_incomplete() reports whether the last drain came up short:
++ * the peer closed or the deadline passed before total_len bytes arrived. */
++#define SPHTTP_BODY_MAX (256 * 1024)
++static char *sphttp_body_buf = NULL;
++static size_t sphttp_body_cap = 0;
++static int sphttp_body_short = 0;
++
++int sphttp_body_max(void) { return SPHTTP_BODY_MAX; }
++int sphttp_body_incomplete(void) { return sphttp_body_short; }
+
+ const char *sphttp_drain_body(int fd, int total_len) {
+ SSL *ssl = sphttp_ssl_for(fd);
+ int n = total_len;
++ sphttp_body_short = 0;
+ if (n < 0) n = 0;
+- if (n >= SPHTTP_BUFSIZE) n = SPHTTP_BUFSIZE - 1;
++ if (n > SPHTTP_BODY_MAX) { sphttp_body_short = 1; return ""; }
++ if ((size_t)n + 1 > sphttp_body_cap) {
++ char *nb = (char *)realloc(sphttp_body_buf, (size_t)n + 1);
++ if (!nb) { sphttp_body_short = 1; return ""; }
++ sphttp_body_buf = nb;
++ sphttp_body_cap = (size_t)n + 1;
++ }
+ int got = 0;
+ while (got < n) {
++ if (!ssl && sphttp_arm_deadline(fd) < 0) break;
+ ssize_t r = ssl ? (ssize_t)SSL_read(ssl, sphttp_body_buf + got, n - got)
+ : recv(fd, sphttp_body_buf + got, n - got, 0);
+ if (r <= 0) {
+- if (!ssl && errno == EINTR) continue;
++ if (!ssl && r < 0 && errno == EINTR) continue;
+ break;
+ }
+ got += (int)r;
+ }
++ if (got < n) sphttp_body_short = 1;
+ sphttp_body_buf[got] = '\0';
+ return sphttp_body_buf;
+ }
+@@ -221,6 +333,7 @@
+ if (w <= 0) return -1;
+ off += (size_t)w;
+ } else {
++ if (sphttp_arm_deadline(fd) < 0) return -1;
+ ssize_t n = send(fd, s + off, len - off, 0);
+ if (n <= 0) {
+ if (errno == EINTR) continue;
+--- a/tep.rb
++++ b/tep.rb
+@@ -166,6 +166,68 @@
+ # narrowing model). Removing it would require a mechanical
+ # `< 0` -> `.nil?` refactor across http.rb / parser.rb / url.rb /
+ # jwt.rb / app.rb. Worth doing eventually; not urgent.
++ # Parse an application/x-www-form-urlencoded string (a body or a query
++ # string). Replaces SpinelKit::Url.parse_query, which can lose keys to
++ # the GC (see sphttp_url_unescape); every intermediate here is a local.
++ def self.parse_form(s)
++ h = Tep.str_hash
++ if s.length == 0
++ return h
++ end
++ pairs = s.split("&")
++ i = 0
++ while i < pairs.length
++ pair = pairs[i]
++ if pair.length > 0
++ k = ""
++ v = ""
++ eq = Tep.str_find(pair, "=", 0)
++ if eq < 0
++ k = Sock.sphttp_url_unescape(pair)
++ else
++ kraw = pair[0, eq]
++ vraw = pair[eq + 1, pair.length - eq - 1]
++ k = Sock.sphttp_url_unescape(kraw)
++ v = Sock.sphttp_url_unescape(vraw)
++ end
++ h[k] = v
++ end
++ i += 1
++ end
++ h
++ end
++
++ # True for the addresses `--listen` may bind: localhost, ::1 and
++ # dotted-quad 127.0.0.0/8.
++ def self.loopback_host?(h)
++ if h == "localhost" || h == "::1"
++ return true
++ end
++ parts = h.split(".")
++ if parts.length != 4 || parts[0] != "127"
++ return false
++ end
++ i = 0
++ while i < 4
++ p = parts[i]
++ if p.length == 0 || p.length > 3
++ return false
++ end
++ j = 0
++ while j < p.length
++ if p[j] < "0" || p[j] > "9"
++ return false
++ end
++ j += 1
++ end
++ if p.to_i > 255
++ return false
++ end
++ i += 1
++ end
++ true
++ end
++
+ def self.str_find(s, needle, start)
+ nlen = needle.length
+ slen = s.length
+@@ -300,6 +362,11 @@
+ Server.new(APP).run_unix(path, quiet)
+ end
+
++ # TCP twin bound to one host address -- see Server#run_host.
++ def self.run_host!(host, port, quiet)
++ Server.new(APP).run_host(host, port, quiet)
++ end
++
+ def self.run!(port, workers, quiet, scheduled = false)
+ if scheduled
+ Server::Scheduled.new(APP).run(port, workers, quiet)
diff --git a/apps/spinel/scripts/arm64-cc b/apps/spinel/scripts/arm64-cc
new file mode 100755
index 0000000..dc1e21f
--- /dev/null
+++ b/apps/spinel/scripts/arm64-cc
@@ -0,0 +1,21 @@
+#!/usr/bin/env bash
+# The C compiler Spinel calls for an arm64 build (see spinel-cross).
+# Rewrites the host Spinel's runtime paths to the arm64 Spinel's, copies
+# the generated C into the work directory (which the arm64 root can see),
+# and runs a static link with the arm64 root's gcc and glibc.
+set -euo pipefail
+here=$(cd "$(dirname "$0")" && pwd)
+repo=$(cd "$here/../../.." && pwd)
+host_lib="$(dirname "${SPINEL_HOST:?}")/../lib"
+arm_lib="$(dirname "${SPINEL_ARM64:?}")/../lib"
+work=${ARM64_WORK:?}
+mkdir -p "$work"
+args=()
+for a in "$@"; do
+ case "$a" in
+ *"$host_lib"*) a="${a//"$host_lib"/"$arm_lib"}" ;;
+ */spinel_out_*.c) cp "$a" "$work/app.c"; a="$work/app.c" ;;
+ esac
+ args+=("$a")
+done
+exec "$repo/scripts/arm64.sh" cc -static -s -L"${ARM64_LIB_DIR:?}" "${args[@]}" -lz -lzstd
diff --git a/apps/spinel/scripts/regen-patch b/apps/spinel/scripts/regen-patch
new file mode 100755
index 0000000..74793dd
--- /dev/null
+++ b/apps/spinel/scripts/regen-patch
@@ -0,0 +1,16 @@
+#!/usr/bin/env bash
+# Regenerates patches/2-tep-bakeoff.patch from the edited tep copy in
+# .cache/x86/tep (maintainer tool; the build only applies patches).
+set -euo pipefail
+cd "$(dirname "$0")/.."
+src=${TEP_SRC:-$HOME/.local/share/bakeoff-toolchains/spinel-src/tep}
+base=$(mktemp -d -p "${TMPDIR:-/tmp}")
+trap 'rm -rf "$base"' EXIT
+mkdir -p "$base/a"
+cp -a "$src/." "$base/a/"
+rm -rf "$base/a/.git"
+patch -s -p1 -d "$base/a" < patches/1-tep-carlos.patch
+cp -a .cache/x86/tep "$base/b"
+find "$base/b" -name '*.o' -delete
+rm -f "$base/b/.patched"
+(cd "$base" && diff -ruN a b || true) | sed -E 's#^(---|\+\+\+) ([^\t]*)\t.*#\1 \2#; /^diff -ruN /d' > patches/2-tep-bakeoff.patch
diff --git a/apps/spinel/scripts/spinel-cross b/apps/spinel/scripts/spinel-cross
new file mode 100755
index 0000000..425fcb7
--- /dev/null
+++ b/apps/spinel/scripts/spinel-cross
@@ -0,0 +1,8 @@
+#!/usr/bin/env bash
+# Stand-in for `spinel` that tep calls when building for linux-arm64.
+# The host (x86-64) Spinel does the slow part, Ruby -> C, natively; the
+# C compiler it then calls is scripts/arm64-cc, which compiles and links
+# that C inside the emulated arm64 root. Both Spinels are the same commit.
+set -euo pipefail
+here=$(cd "$(dirname "$0")" && pwd)
+exec "${SPINEL_HOST:?}" "$@" "--cc=$here/arm64-cc"
diff --git a/apps/spinel/test/run.sh b/apps/spinel/test/run.sh
new file mode 100755
index 0000000..607422d
--- /dev/null
+++ b/apps/spinel/test/run.sh
@@ -0,0 +1,225 @@
+#!/usr/bin/env bash
+# The app's own tests: start the compiled binary, drive it with curl, and
+# check the contract's wire-level rules (status codes, redirects, cookies,
+# escaping, Content-Length, static files, isolation, Origin, the unix
+# socket and a clean SIGTERM exit). The browser-level behaviour is the
+# shared suite's job (scripts/test-app.sh at the repo root).
+#
+# test/run.sh dist/app
+set -uo pipefail
+
+bin=$(cd "$(dirname "${1:?usage: test/run.sh <binary>}")" && pwd)/$(basename "$1")
+here=$(cd "$(dirname "$0")/.." && pwd)
+work=$(mktemp -d -p "${TMPDIR:-/tmp}")
+pid=""
+cleanup() {
+ [ -n "$pid" ] && kill "$pid" 2>/dev/null
+ rm -rf "$work"
+}
+trap cleanup EXIT
+
+pass=0
+fail=0
+ok() { pass=$((pass + 1)); }
+bad() { fail=$((fail + 1)); echo "FAIL: $*"; }
+check() { # check <description> <expected> <actual>
+ if [ "$2" == "$3" ]; then ok; else bad "$1: expected [$2], got [$3]"; fi
+}
+contains() { # contains <description> <needle> <haystack>
+ if [[ "$3" == *"$2"* ]]; then ok; else bad "$1: [$2] not found"; fi
+}
+lacks() {
+ if [[ "$3" != *"$2"* ]]; then ok; else bad "$1: [$2] found"; fi
+}
+
+port=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1])')
+base="http://127.0.0.1:$port"
+"$bin" --listen "127.0.0.1:$port" --db "$work/app.db" >"$work/app.log" 2>&1 &
+pid=$!
+for _ in $(seq 50); do curl -sf "$base/healthz" >/dev/null && break; sleep 0.1; done
+
+status() { curl -s -o /dev/null -w '%{http_code}' "$@"; }
+jar="$work/jar"
+other="$work/other"
+
+# --- platform routes
+check "healthz" 200 "$(status "$base/healthz")"
+check "version" 200 "$(status "$base/api/version")"
+check "version body" "$(cat "$here/version.rb" | sed -n 's/^APP_VERSION = "\(.*\)"$/\1/p')" "$(curl -s "$base/api/version")"
+check "unknown path" 404 "$(status "$base/nope")"
+
+# --- listens only where it was told to
+listeners=$(ss -ltnH "sport = :$port" | awk '{print $4}')
+check "bound to 127.0.0.1 only" "127.0.0.1:$port" "$listeners"
+
+# --- first visit: cookie, empty list, no main/footer
+headers=$(curl -s -D - -o "$work/page" -c "$jar" "$base/")
+contains "cookie set" "Set-Cookie: tid=" "$headers"
+contains "cookie HttpOnly" "HttpOnly" "$headers"
+contains "cookie SameSite" "SameSite=Lax" "$headers"
+contains "cookie Path" "Path=/" "$headers"
+lacks "no Secure over http" "Secure" "$headers"
+token=$(awk '$6 == "tid" {print $7}' "$jar")
+check "token is 22 base64url chars" 1 "$([[ "$token" =~ ^[A-Za-z0-9_-]{22}$ ]] && echo 1 || echo 0)"
+page=$(cat "$work/page")
+lacks "empty list hides main" 'class="main"' "$page"
+lacks "empty list hides footer" 'class="footer"' "$page"
+contains "language in footer" "the CARLOS bakeoff</a> · Spinel" "$page"
+
+# --- add: trimmed, blank ignored, 303 to the filter
+post() { curl -s -o /dev/null -w '%{http_code} %{redirect_url}' -b "$jar" -c "$jar" "$@"; }
+check "add" "303 $base/" "$(post --data-urlencode 'title= buy milk ' -d filter=all "$base/todos")"
+check "add from active" "303 $base/active" "$(post --data-urlencode 'title=walk the dog' -d filter=active "$base/todos")"
+check "blank add" "303 $base/completed" "$(post --data-urlencode 'title= ' -d filter=completed "$base/todos")"
+check "unknown filter" "303 $base/" "$(post --data-urlencode 'title=write' -d filter=bogus "$base/todos")"
+page=$(curl -s -b "$jar" "$base/")
+contains "trimmed title" "<label>buy milk</label>" "$page"
+check "three items" 3 "$(grep -c '<li data-id=' <<<"$page")"
+contains "counter" "<strong>3</strong> items left" "$page"
+lacks "no clear-completed yet" "clear-completed" "$page"
+
+id1=$(grep -o '<li data-id="[0-9]*"' <<<"$page" | head -1 | grep -o '[0-9]*')
+id2=$(grep -o '<li data-id="[0-9]*"' <<<"$page" | sed -n 2p | grep -o '[0-9]*')
+
+# --- toggle, filters, toggle-all, clear-completed
+check "toggle" "303 $base/active" "$(post -d filter=active "$base/todos/$id1/toggle")"
+page=$(curl -s -b "$jar" "$base/")
+contains "completed class" "<li data-id=\"$id1\" class=\"completed\">" "$page"
+contains "singular counter" "<strong>2</strong> items left" "$page"
+contains "clear-completed shown" 'class="clear-completed"' "$page"
+active=$(curl -s -b "$jar" "$base/active")
+lacks "active filter hides completed" "data-id=\"$id1\"" "$active"
+contains "active link selected" '<a class="selected" href="/active">' "$active"
+completed=$(curl -s -b "$jar" "$base/completed")
+check "completed filter" 1 "$(grep -c '<li data-id=' <<<"$completed")"
+check "toggle-all" "303 $base/" "$(post -d filter=all "$base/todos/toggle-all")"
+check "all completed" 3 "$(curl -s -b "$jar" "$base/" | grep -c 'class="completed"')"
+contains "toggle-all checked" 'class="toggle-all" type="checkbox" form="toggle-all-form" checked' "$(curl -s -b "$jar" "$base/")"
+post -d filter=all "$base/todos/toggle-all" >/dev/null
+check "toggle-all again clears" 0 "$(curl -s -b "$jar" "$base/" | grep -c 'class="completed"')"
+post -d filter=all "$base/todos/$id1/toggle" >/dev/null
+check "clear-completed" "303 $base/" "$(post -d filter=all "$base/todos/clear-completed")"
+lacks "completed item gone" "data-id=\"$id1\"" "$(curl -s -b "$jar" "$base/")"
+
+# --- edit view, rename, empty rename deletes
+edit=$(curl -s -b "$jar" "$base/active?edit=$id2")
+contains "editing class" "<li data-id=\"$id2\" class=\"editing\">" "$edit"
+contains "edit form" "action=\"/todos/$id2/edit\"" "$edit"
+contains "edit link keeps filter" "href=\"/active?edit=$id2\"" "$edit"
+check "rename" "303 $base/active" "$(post --data-urlencode 'title= walk the cat ' -d filter=active "$base/todos/$id2/edit")"
+contains "renamed" "<label>walk the cat</label>" "$(curl -s -b "$jar" "$base/")"
+check "empty rename" "303 $base/" "$(post --data-urlencode 'title= ' -d filter=all "$base/todos/$id2/edit")"
+lacks "empty rename deletes" "data-id=\"$id2\"" "$(curl -s -b "$jar" "$base/")"
+
+# --- escaping, UTF-8, Content-Length, long titles
+post --data-urlencode 'title=<img src=x onerror=alert(1)> & "q" '"'"'s' -d filter=all "$base/todos" >/dev/null
+post --data-urlencode 'title=naïve 🚀 日本語' -d filter=all "$base/todos" >/dev/null
+headers=$(curl -s -D - -o "$work/page" -b "$jar" "$base/")
+page=$(cat "$work/page")
+contains "escaped title" "&lt;img src=x onerror=alert(1)&gt; &amp; &quot;q&quot; &#39;s" "$page"
+lacks "no raw img" "<img" "$page"
+contains "UTF-8 kept" "naïve 🚀 日本語" "$page"
+declared=$(tr -d '\r' <<<"$headers" | sed -n 's/^Content-Length: //p')
+check "Content-Length is bytes" "$(wc -c <"$work/page")" "$declared"
+long=$(printf 'é%.0s' $(seq 10000))
+post --data-urlencode "title=$long" -d filter=all "$base/todos" >/dev/null
+contains "10,000-char title" "<label>$long</label>" "$(curl -s -b "$jar" "$base/")"
+
+# --- isolation, foreign ids, garbled cookies, Origin
+check "foreign id from another visitor" 404 "$(status -c "$other" -b "$other" -d filter=all "$base/todos/$id1/toggle")"
+own=$(curl -s -b "$jar" "$base/" | grep -o '<li data-id="[0-9]*"' | head -1 | grep -o '[0-9]*')
+check "someone else's toggle" 404 "$(status -c "$other" -b "$other" -d filter=all "$base/todos/$own/toggle")"
+check "someone else's edit" 404 "$(status -b "$other" -d title=x -d filter=all "$base/todos/$own/edit")"
+check "someone else's destroy" 404 "$(status -b "$other" -d filter=all "$base/todos/$own/destroy")"
+lacks "other visitor sees nothing" "<li data-id" "$(curl -s -b "$other" "$base/")"
+check "non-numeric id" 404 "$(status -b "$jar" -d filter=all "$base/todos/abc/toggle")"
+check "garbled cookie" 200 "$(status -b 'tid=%%%' "$base/")"
+lacks "garbled cookie gets empty list" "<li data-id" "$(curl -s -b 'tid=%%%' "$base/")"
+contains "garbled cookie replaced" "Set-Cookie: tid=" "$(curl -s -D - -o /dev/null -b 'tid=%%%' "$base/")"
+check "cross-site POST" 403 "$(status -b "$jar" -H 'Origin: https://evil.example' --data-urlencode title=evil "$base/todos")"
+lacks "cross-site POST changed nothing" "evil" "$(curl -s -b "$jar" "$base/")"
+check "same-origin POST" 303 "$(status -b "$jar" -H "Origin: $base" --data-urlencode title=fine "$base/todos")"
+
+# --- the script's fragment answers
+frag=$(curl -s -b "$jar" -H 'X-Fragment: 1' -d filter=active --data-urlencode title=fragment "$base/todos")
+check "fragment starts with main" '<section class="main">' "$(head -1 <<<"$frag")"
+lacks "fragment is not a page" "<html" "$frag"
+contains "fragment keeps the filter" '<a class="selected" href="/active">' "$frag"
+
+# --- static files, byte for byte
+for f in base.css index.css; do
+ curl -s "$base/static/$f" -o "$work/$f"
+ if cmp -s "$work/$f" "$here/../../shared/$f"; then ok; else bad "/static/$f differs from shared/$f"; fi
+done
+contains "css type" "Content-Type: text/css" "$(curl -s -D - -o /dev/null "$base/static/index.css")"
+
+# --- cookies must be ones this server issued
+made_up="AAAAAAAAAAAAAAAAAAAAAA"
+hdr=$(curl -s -D - -o /dev/null -H "Cookie: tid=$made_up" -d filter=all --data-urlencode title=shared "$base/todos")
+contains "made-up token replaced" "Set-Cookie: tid=" "$hdr"
+lacks "made-up token not kept" "tid=$made_up" "$hdr"
+lacks "made-up token holds no list" "<label>shared</label>" "$(curl -s -H "Cookie: tid=$made_up" "$base/")"
+
+# --- long bodies arrive whole; short, chunked and oversized ones change nothing
+rockets=$(python3 -c 'print("\U0001F680" * 10000, end="")')
+check "10,000-emoji title" 303 "$(status -b "$jar" --data-urlencode "title=$rockets" -d filter=all "$base/todos")"
+contains "10,000 emoji kept whole" "<label>$rockets</label>" "$(curl -s -b "$jar" "$base/")"
+before=$(curl -s -b "$jar" "$base/" | grep -c '<li data-id=')
+raw() { # raw <python body>: talk to the server over a raw socket, print the status line
+ python3 -I -c "
+import socket, sys, time
+s = socket.create_connection(('127.0.0.1', $port))
+h = 'POST /todos HTTP/1.1\\r\\nHost: x\\r\\nCookie: tid=$token\\r\\nContent-Type: application/x-www-form-urlencoded\\r\\n'
+$1
+s.settimeout(30)
+print(s.recv(100).split(b'\\r\\n')[0].decode())
+"
+}
+contains "short body refused" " 400 " "$(raw "s.sendall((h + 'Content-Length: 5000\\r\\n\\r\\n').encode() + b'title=' + b'y' * 1000); s.shutdown(socket.SHUT_WR)")"
+contains "chunked body refused" " 400 " "$(raw "s.sendall((h + 'Transfer-Encoding: chunked\\r\\n\\r\\n5\\r\\ntitle\\r\\n0\\r\\n\\r\\n').encode())")"
+contains "oversized body refused" " 413 " "$(raw "s.sendall((h + 'Content-Length: 300000\\r\\n\\r\\n').encode())")"
+check "refused bodies changed nothing" "$before" "$(curl -s -b "$jar" "$base/" | grep -c '<li data-id=')"
+
+# --- a client trickling its body is cut off at the 10-second request deadline
+start=$(date +%s)
+raw "s.sendall((h + 'Content-Length: 100\\r\\n\\r\\n').encode())
+for _ in range(30):
+ try: s.sendall(b'x'); time.sleep(1)
+ except OSError: break" >/dev/null 2>&1
+took=$(( $(date +%s) - start ))
+check "trickling client cut off within 12s" 1 "$([ "$took" -le 12 ] && echo 1 || echo 0)"
+check "server still answers after a trickler" 200 "$(status "$base/healthz")"
+
+# --- only loopback listeners, and no silent every-interface fallback
+timeout 5 "$bin" --listen "0.0.0.0:$((port + 1))" --db "$work/x.db" >/dev/null 2>&1
+check "--listen 0.0.0.0 refused" 2 "$?"
+timeout 5 "$bin" --listen "10.1.2.3:$((port + 1))" --db "$work/x.db" >/dev/null 2>&1
+check "--listen 10.1.2.3 refused" 2 "$?"
+timeout 5 "$bin" --db "$work/x.db" -p "$((port + 1))" >/dev/null 2>&1
+check "no listener refused" 2 "$?"
+
+# --- clean exit on SIGTERM, and the data survives a restart
+kill -TERM "$pid"
+code=0
+for _ in $(seq 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
+if kill -0 "$pid" 2>/dev/null; then bad "still running after SIGTERM"; else wait "$pid"; code=$?; check "SIGTERM exit status" 0 "$code"; fi
+pid=""
+
+# --- the unix-socket contract, with CARLOS_HOST
+sock="$work/app.sock"
+CARLOS_HOST=todo.example.test "$bin" --socket "$sock" --db "$work/app.db" >>"$work/app.log" 2>&1 &
+pid=$!
+for _ in $(seq 50); do [ -S "$sock" ] && break; sleep 0.1; done
+u=(curl -s --unix-socket "$sock")
+check "socket healthz" 200 "$("${u[@]}" -o /dev/null -w '%{http_code}' http://x/healthz)"
+contains "data survived restart" "<label>fragment</label>" "$("${u[@]}" -H "Cookie: tid=$token" http://x/)"
+contains "Secure cookie on Carloku" "Secure" "$("${u[@]}" -D - -o /dev/null http://x/)"
+check "Carloku origin accepted" 303 "$("${u[@]}" -o /dev/null -w '%{http_code}' -H "Cookie: tid=$token" -H 'Origin: https://todo.example.test' -d title=x http://x/todos)"
+check "local origin refused on Carloku" 403 "$("${u[@]}" -o /dev/null -w '%{http_code}' -H "Cookie: tid=$token" -H "Origin: $base" -d title=x http://x/todos)"
+kill -TERM "$pid"
+for _ in $(seq 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.1; done
+if kill -0 "$pid" 2>/dev/null; then bad "socket server still running after SIGTERM"; else wait "$pid"; check "socket SIGTERM exit status" 0 "$?"; fi
+pid=""
+
+echo "$pass passed, $fail failed"
+[ "$fail" -eq 0 ]