oficina / docs Public

Plan 2: the app's first screens

A web app you can open in a browser: list your documents, create one, read
one -- with the document's text produced by replaying real Yjs updates
through WebAssembly under wazero rather than read from a column.

Re-sequenced ahead of the layout engine deliberately. Spec §13 puts layout
next, but a readable document does not need pagination, so this reaches a
browser in one plan instead of two. The list row already reserves the 26x34
space a page-one thumbnail drops into.

What the whole-branch review found:

The one real seam. Store.List ordered by created_at, the grouping and
display used UpdatedAt, and ApplyUpdate never bumped updated_at. Two defects
that cancelled out until the editor touches updated_at, at which point
ordering and buckets diverge and the list renders duplicate group labels.
Task 2 chose one clock, task 3 chose another, and no task connected them.

An origin validation I ordered that missed its own likeliest input: -addr
:8099 derives http://:8099, whose Host is non-empty, so it passed the check
meant to catch exactly that. The plan's own example invocation booted
production with an unusable origin. Now refused at startup.

And our comments encoded a wrong model of rastrillo's CSRF: SameOrigin
checks Sec-Fetch-Site first and returns before comparing the origin string,
so a modern browser passes with a completely wrong DOCS_ORIGIN. The
protection still fails closed against real cross-site requests, but a
misconfigured origin is masked in manual testing rather than loud -- the
opposite of what three files claimed.

The sharpest finding: the dev-auth fortifications guard an identity that
nothing consumes. No handler calls it, no route requires sign-in, so a
release binary is a wide-open unauthenticated documents app. That is the
stated §8.1 deferral, but AGENTS.md did not say it and someone could read
the release-build hardening as evidence it is deployable. It says so now.

Security controls verified at the binary level: CSRF with no document
written on a rejection, checked by row count rather than status code; a
development identity that survived eight near-miss environment strings; and
a release build with no dev-auth code path, guarded by a gate check that was
itself proven to fail rather than hang when the protection is removed.

Deferrals are commented in code rather than silently absent: sealing,
thumbnails, pagination, real identity, UTC timestamps. Plan 1's
falsification-grade limits are named in store.go and refused, not papered
over.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
Paul Campbell pushed by paul@keymail.dev 0497497231854bffe5f3e4266db0cd0bec0a76fd parent d42c91b
26 files changed, +3022 −4
  • .amadan/README.md +15 −0
  • .amadan/ci.d/10-gate +57 −0
  • AGENTS.md +56 −0
  • cmd/docs/devauth_flag.go +32 −0
  • cmd/docs/devauth_flag_release.go +55 −0
  • cmd/docs/main.go +103 −0
  • docs/superpowers/plans/2026-08-31-app-first-screens.md +556 −0
  • go.mod +22 −2
  • go.sum +89 −2
  • internal/docs/app.go +311 −0
  • internal/docs/app_test.go +73 −0
  • internal/docs/devauth.go +81 −0
  • internal/docs/devauth_test.go +80 −0
  • internal/docs/handlers.go +241 −0
  • internal/docs/list_test.go +149 −0
  • internal/docs/migrations/0001_documents.sql +18 −0
  • internal/docs/models.go +68 −0
  • internal/docs/origin_test.go +84 −0
  • internal/docs/render.go +191 −0
  • internal/docs/render_test.go +56 −0
  • internal/docs/store.go +264 −0
  • internal/docs/store_test.go +128 −0
  • internal/docs/testing.go +49 −0
  • internal/docs/views/list.html +101 −0
  • internal/docs/views/show.html +84 −0
  • scripts/run-dev.sh +59 −0

CI passed — run details

diff --git a/.amadan/README.md b/.amadan/README.md
index ffa66c9..d63de43 100644
--- a/.amadan/README.md
+++ b/.amadan/README.md
@@ -47,3 +47,18 @@ tests will pass against it.
It also fails if regenerating the Yjs fixtures changes them. Fixtures are
the wire contract, not an output: a deliberate rule change regenerates them
in the same commit as the change.
+
+## The app, not just the core
+
+`go vet ./...` and `go test ./...` already cover `cmd/docs` and
+`internal/docs` — the rastrillo app built on top of `internal/core` — but
+neither one proves the app actually links into a runnable binary. The gate
+also runs `CGO_ENABLED=0 go build ./...` for that, and separately builds
+`cmd/docs` with `-tags release` and runs the result with `-dev-auth` on its
+command line, asserting it refuses to start: `devauth_flag_release.go`
+replaces the flag-parsing function with one that can never return true, so
+a release binary has no code path that can turn the development-only
+identity (`internal/docs/devauth.go`) on at all — a build-time closure of
+that hole, not just `guardDevAuth`'s runtime check. `scripts/run-dev.sh`
+builds and runs the ordinary (non-release) binary with `-dev-auth`, a fixed
+port, and a scratch database — one command to see the app in a browser.
diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate
index 0ae00a0..2f8d83d 100755
--- a/.amadan/ci.d/10-gate
+++ b/.amadan/ci.d/10-gate
@@ -70,4 +70,61 @@ go vet ./...
go test ./... -count=1
test -z "$(gofmt -l .)" || { echo "gate: gofmt"; gofmt -l .; exit 1; }
+echo "=== go: build ./... (the app, not just the packages tests import)"
+# go vet/go test above already type-check every package they touch,
+# but neither one proves cmd/docs itself links into a runnable binary
+# under CGO_ENABLED=0 with no -mod flag (this repo's own constraint) —
+# a plain build is the only step that actually does. CGO_ENABLED is
+# already exported above; stated again here so this line reads
+# correctly on its own.
+CGO_ENABLED=0 go build ./...
+
+echo "=== go: a release build cannot start with dev auth"
+# cmd/docs/devauth_flag_release.go (-tags release) replaces
+# extractDevAuthFlag with a version that never returns true — the
+# strongest closure available for "a -dev-auth flag left in a deploy
+# script": the release binary has no code path that can turn it on,
+# not merely a runtime check that refuses once it is. This step
+# proves both halves: the tagged build compiles at all (a file that
+# only exists under a tag most local runs never pass is otherwise
+# untested for as long as nobody happens to build it), and running
+# that binary with -dev-auth on its command line actually refuses to
+# start rather than silently doing nothing.
+gate_tmp="$(mktemp -d)"
+trap 'rm -rf "$gate_tmp"' EXIT
+CGO_ENABLED=0 go build -tags release -o "$gate_tmp/docs-release" ./cmd/docs
+
+# A release binary that (wrongly) accepted -dev-auth would go on to call
+# rastrillo.Serve and block forever, listening — a hang, not a failure.
+# "if cmd; then" alone cannot tell "refused in 5ms" apart from "still
+# running after 5 seconds": both only reach a branch once something
+# finally kills the process, and a bare invocation with no timeout never
+# would. This check exists specifically to catch that regression, so it
+# must fail loudly the day it fires rather than hang the gate itself — a
+# hang gets retried, a failure gets read. `timeout` turns "still running"
+# into its own distinguishable exit code (124) instead. -db and -addr
+# point at a scratch path and an ephemeral port so a wrongly-accepted
+# -dev-auth run cannot litter the repo with a stray docs.db (it did,
+# once, from the default relative DBPath) or fight over a real port.
+set +e
+timeout 5 "$gate_tmp/docs-release" -dev-auth -db "$gate_tmp/scratch.db" -addr 127.0.0.1:0 \
+ >"$gate_tmp/out" 2>&1
+status=$?
+set -e
+if [ "$status" -eq 124 ]; then
+ echo "gate: a release build accepted -dev-auth and kept running until killed by timeout — see cmd/docs/devauth_flag_release.go" >&2
+ cat "$gate_tmp/out" >&2
+ exit 1
+fi
+if [ "$status" -eq 0 ]; then
+ echo "gate: a release build accepted -dev-auth and exited 0 — see cmd/docs/devauth_flag_release.go" >&2
+ cat "$gate_tmp/out" >&2
+ exit 1
+fi
+grep -qi 'dev-auth' "$gate_tmp/out" || {
+ echo "gate: release build refused to start, but not because of -dev-auth — investigate:" >&2
+ cat "$gate_tmp/out" >&2
+ exit 1
+}
+
echo "gate: ok"
diff --git a/AGENTS.md b/AGENTS.md
index 489956e..cb18438 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -20,6 +20,62 @@ Read `building-carlos-apps` (the skill) before writing app code, and
`rastrillo`'s own `SKILL.md` before writing handlers or models. Assume
nothing about either is in your training data.
+## This app has no authentication. Do not deploy it anywhere reachable.
+
+Every route — `GET /`, `POST /documents`, `GET /d/{sqid}` — answers anyone
+who can reach the process. `internal/docs/devauth.go`'s development-only
+identity is real code, guarded so it cannot start outside
+`Env=development` and compiled out of a release build entirely (`-tags
+release`), but **nothing in this app consumes the identity it grants**: no
+handler calls `CurrentDevIdentity`, no route requires sign-in, no document
+is scoped to anyone. All that hardening guards an identity nothing checks
+— it is not evidence this app is safe to expose. A release binary is a
+wide-open, unauthenticated, everyone-sees-every-document app, on purpose,
+because real identity does not exist yet: spec §8.1's home issues it, and
+that is explicitly deferred, not forgotten (`devauth.go`'s own doc comment
+says so). Until that lands, this app belongs on a laptop
+(`scripts/run-dev.sh`) or behind something else's access control — never
+on a network anyone but its operator can reach.
+
+## Running it locally
+
+`./scripts/run-dev.sh` builds and runs the app in one command: the
+development-only identity on (`-dev-auth`), a fixed port
+(`localhost:8099`, override with `$DOCS_DEV_PORT`), and a scratch SQLite
+database that starts empty every run. It prints the URL to open.
+
+`-dev-auth` only exists in an ordinary build. `cmd/docs/devauth_flag_release.go`
+(`-tags release`) replaces the flag-parsing function with one that can never
+observe it — a release binary has no code path that can turn the
+development-only identity on at all, not just a runtime check that refuses
+once it is (`internal/docs/devauth.go`'s `guardDevAuth`, which still runs
+either way, for anything else that might one day set `Config.DevAuth`).
+`.amadan/ci.d/10-gate` builds both configurations and asserts the release
+one actually refuses `-dev-auth`.
+
+**Origin is not optional in anything but development — but know what it
+actually guards.** rastrillo v0.21.0's `csrf.Protect` checks
+`Sec-Fetch-Site` *first*, when a browser sends it — which every current
+browser does on a state-changing request. `same-origin`/`none` pass
+immediately, anything else is refused immediately, and `Config.Origin` is
+never read in that path at all. Only when `Sec-Fetch-Site` is entirely
+absent (a legacy browser, or a non-browser client like curl) does the
+check fall back to comparing `Origin`/`Referer` against `Config.Origin`
+exactly. So a wrong `Config.Origin` does not make a real browser's genuine
+same-origin request fail, and it does not open up a genuine cross-site
+request either — both are decided by `Sec-Fetch-Site` alone. What it
+actually breaks is the legacy fallback path, silently: a misconfigured
+origin will not show up as "the app looks broken" in ordinary manual
+testing with a real browser, which is exactly why it still needs to be
+right rather than merely untested. `cmd/docs/main.go` reads `$DOCS_ORIGIN`
+(the platform's real per-instance origin belongs there — spec §9.2's
+`<app>.<sqid>.oncarlos.com`) and falls back to deriving one from `-addr`
+only for local/dev use; with neither set, or with a value that cannot
+parse as an absolute `http(s)://host[:port]`, `internal/docs.New` refuses
+to start rather than silently defaulting or booting on something
+malformed. `run-dev.sh` never needs `$DOCS_ORIGIN` set by hand — its fixed
+`-addr` derives it.
+
## Review by the highest available capability model (Paul's rule, 2026-08-30)
**Specs and whole branches are reviewed by the highest capability model
diff --git a/cmd/docs/devauth_flag.go b/cmd/docs/devauth_flag.go
new file mode 100644
index 0000000..16ea4ca
--- /dev/null
+++ b/cmd/docs/devauth_flag.go
@@ -0,0 +1,32 @@
+//go:build !release
+
+// This file exists so a normal build (`go build ./...`, `go run`,
+// scripts/run-dev.sh — no build tags) can still reach the
+// development-only identity by spelling out -dev-auth on the command
+// line. devauth_flag_release.go is this file's mirror image, built
+// only with -tags release, where the function below does not exist at
+// all — see that file's doc comment for why a build tag is the
+// strongest closure available for this particular hole.
+package main
+
+// extractDevAuthFlag pulls a bare "-dev-auth"/"--dev-auth" token out
+// of args, returning whether it was present and everything else
+// unchanged (order preserved). rastrillo.Resolve parses os.Args[1:]
+// itself, with its own flag.FlagSet that knows only -socket/-addr/-db
+// and errors on anything else — so -dev-auth must be stripped out of
+// os.Args before Resolve ever sees it, or Resolve fails to start
+// outright rather than merely refusing dev auth. This is presence-only
+// (no "-dev-auth=false"): the deliberately narrow, explicit shape the
+// brief asks for — no config default, no env var, nothing a systemd
+// unit's boilerplate could set without someone typing this literal
+// flag.
+func extractDevAuthFlag(args []string) (enabled bool, rest []string) {
+ for _, a := range args {
+ if a == "-dev-auth" || a == "--dev-auth" {
+ enabled = true
+ continue
+ }
+ rest = append(rest, a)
+ }
+ return enabled, rest
+}
diff --git a/cmd/docs/devauth_flag_release.go b/cmd/docs/devauth_flag_release.go
new file mode 100644
index 0000000..eb1912f
--- /dev/null
+++ b/cmd/docs/devauth_flag_release.go
@@ -0,0 +1,55 @@
+//go:build release
+
+// A `-dev-auth` flag left in a deploy script or a systemd unit is a
+// residual security hole no in-process check can fully close — by the
+// time guardDevAuth (internal/docs/devauth.go) runs, the flag has
+// already been read and turned into Config.DevAuth true; a Env check
+// at that point catches "development" spelled wrong, not "someone
+// pasted the wrong invocation into a unit file that happens to set
+// Env=production correctly." Moving the closure to build time removes
+// the capability from the binary itself: a release build has no
+// extractDevAuthFlag that can ever observe -dev-auth and return true,
+// so there is no argv, env var, or config file anyone could hand this
+// specific binary that turns dev auth on. `go build -tags release
+// ./cmd/docs` is what the gate (.amadan/ci.d/10-gate) builds and
+// exercises; any real deploy pipeline must build the same way.
+//
+// What this still misses, honestly:
+//
+// 1. It only closes the argv path in *this* binary. Nothing enforces
+// that a deploy actually passes -tags release — a build run
+// without it still gets devauth_flag.go's real implementation.
+// The gate asserts the tagged build behaves correctly; it cannot
+// force a deploy script elsewhere to use it. That is a process
+// control, not a code one.
+// 2. internal/docs' devauth.go (NewWithDevAuth, guardDevAuth,
+// devAuthMiddleware, DevIdentity) is still compiled into the
+// binary either way — this file only removes the one path in
+// main() that could ever set Config.DevAuth true from outside the
+// process. guardDevAuth's runtime Env check (internal/docs/devauth.go)
+// is what still catches any *other* future path into DevAuth true
+// (a new flag added carelessly, a config field set by hand) — the
+// two guards are complementary, not substitutes for each other.
+// 3. The failure mode when someone does pass -dev-auth to a release
+// binary is a generic rastrillo.Resolve flag-parse error ("flag
+// provided but not defined: -dev-auth"), not a custom, friendlier
+// security message — deliberately: keeping this function's shape
+// identical to the non-release build (same signature, same
+// "always return false" contract when there is nothing to enable)
+// is what makes this file safe to review as "does nothing" rather
+// than as new logic that could itself have a bug. The refusal is
+// still loud (nonzero exit, a message naming the flag) — see
+// main.go's own os.Exit(1) on a Resolve error.
+package main
+
+// extractDevAuthFlag is the release build's version: it does not
+// scan args for -dev-auth at all, so `enabled` can never be anything
+// but false and `rest` is always the untouched input — a release
+// binary given "-dev-auth" on its command line has no code path that
+// turns it into Config.DevAuth true; the token simply reaches
+// rastrillo.Resolve's flag.FlagSet, which does not define it, and
+// Resolve fails to start (main.go's os.Exit(1)) exactly like any
+// other unrecognized flag.
+func extractDevAuthFlag(args []string) (enabled bool, rest []string) {
+ return false, args
+}
diff --git a/cmd/docs/main.go b/cmd/docs/main.go
new file mode 100644
index 0000000..3809558
--- /dev/null
+++ b/cmd/docs/main.go
@@ -0,0 +1,103 @@
+// Command docs runs the CARLOS Docs rastrillo app.
+package main
+
+import (
+ "log/slog"
+ "os"
+
+ "github.com/carlosframework/rastrillo"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+func main() {
+ logger := slog.Default()
+
+ devAuth, rest := extractDevAuthFlag(os.Args[1:])
+ os.Args = append(os.Args[:1], rest...)
+
+ // Resolve applies the platform's activation argv (-socket/-addr/-db,
+ // or LISTEN_FDS/$STATE_DIRECTORY under the platform) — never
+ // hand-rolled, per SKILL.md §1.
+ opts, err := rastrillo.Resolve(rastrillo.Options{
+ DBPath: "docs.db",
+ Logger: logger,
+ })
+ if err != nil {
+ logger.Error("docs: resolve", "err", err)
+ os.Exit(1)
+ }
+
+ // Origin feeds docs.Config.Origin — see its doc comment for what
+ // csrf.Protect actually does with it (fix round 3: Sec-Fetch-Site
+ // decides a modern browser's own traffic before this string is
+ // ever read; Origin only matters to the legacy Origin/Referer
+ // fallback path, and a wrong value there fails silently rather
+ // than visibly). An empty value here is not "no opinion", it is
+ // "New must refuse to start" once Env is anything but development.
+ //
+ // $DOCS_ORIGIN is the explicit, deploy-time override — the
+ // "<APP>_ORIGIN" convention rastrillo's own `new` scaffold uses
+ // (cmd/rastrillo/new.go's mainTemplate) — and always wins when set;
+ // that is where the platform's real per-instance origin (spec
+ // §9.2's <app>.<sqid>.oncarlos.com) belongs. Falling back to a TCP
+ // -addr only helps the local/dev path (scripts/run-dev.sh, or a
+ // person passing -addr by hand): that is exactly the host a
+ // browser loading this instance sends back as its own Origin
+ // header. The platform's real activation path is a unix socket or
+ // LISTEN_FDS — no -addr to derive anything from — so without
+ // $DOCS_ORIGIN set there, origin stays empty here and New refuses
+ // to start rather than silently falling back to docs.DefaultOrigin,
+ // a placeholder with no relationship to where this instance is
+ // actually reachable — that is exactly the trap this task closes,
+ // whether or not a given request would ever notice.
+ origin := os.Getenv("DOCS_ORIGIN")
+ if origin == "" && opts.Addr != "" {
+ origin = "http://" + opts.Addr
+ }
+
+ // New opens its own database handle (SKILL.md §1's "app that opens
+ // its own database"), so opts.DBPath is blanked below before Serve
+ // — otherwise Serve would open a second connection to the file New
+ // already owns.
+ //
+ // Env is "production" unless -dev-auth was given on the command
+ // line, in which case NewWithDevAuth both sets Env to
+ // "development" and asks for the development-only identity — the
+ // only way any of this ever turns on is someone spelling that flag
+ // out explicitly; running this binary with no arguments always
+ // gets "production", DevAuth false.
+ cfg := docs.Config{
+ DBPath: opts.DBPath,
+ Env: "production",
+ Origin: origin,
+ Logger: logger,
+ }
+ var app *docs.App
+ if devAuth {
+ // NewWithDevAuth itself forces DevAuth true; Env must be set
+ // to "development" here too — its guard checks both, and
+ // leaving Env at "production" would make -dev-auth refuse to
+ // start rather than silently doing nothing.
+ cfg.Env = "development"
+ app, err = docs.NewWithDevAuth(cfg)
+ } else {
+ app, err = docs.New(cfg)
+ }
+ if err != nil {
+ logger.Error("docs: new", "err", err)
+ os.Exit(1)
+ }
+ defer app.Close()
+
+ opts.Mux = app.Handler()
+ opts.DBPath = ""
+ opts.Locales = docs.Locales
+ opts.DefaultLocale = docs.DefaultLocale
+ opts.BaseCatalog = docs.Catalog
+
+ if err := rastrillo.Serve(opts); err != nil {
+ logger.Error("docs: serve", "err", err)
+ os.Exit(1)
+ }
+}
diff --git a/docs/superpowers/plans/2026-08-31-app-first-screens.md b/docs/superpowers/plans/2026-08-31-app-first-screens.md
new file mode 100644
index 0000000..2c09ff1
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-31-app-first-screens.md
@@ -0,0 +1,556 @@
+# The app's first screens — Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A rastrillo app you can open in a browser — list your documents, create one, and read one — with the document's text coming from the real CRDT core through wazero.
+
+**Architecture:** rastrillo `v0.21.0` for the app shell, routes, templates and SQLite. Documents are rows; their content is a Yjs update log applied through `internal/core` (the wazero host built in plan 1). Server-rendered throughout: no editor yet, no JavaScript beyond what rastrillo ships.
+
+**Tech Stack:** Go 1.26, rastrillo `v0.21.0`, `internal/core` (wazero + the Rust crate), SQLite via rastrillo's `db`.
+
+**Spec:** `docs/superpowers/specs/2026-08-30-docs-design.md` — §3 (system shape), §4 (data model), §6.2 (where zero-JS stops), §6.4 (the document list), §10 (design system).
+
+## Why this plan, and why it is not the layout engine
+
+Spec §13 sequences the layout engine next. **Re-sequenced deliberately**: the goal is something testable in a browser, and a readable document does not need pagination. Page geometry, PDF and thumbnails all come from the layout engine and all wait for the plan after this one.
+
+So this plan renders a document as ordinary flowing HTML. §6.4's real page-one thumbnails are **out of scope** — the list shows documents without them, and the row layout is designed so thumbnails drop in later without a rework.
+
+## Global Constraints
+
+- **`CGO_ENABLED=0` everywhere.** No cgo, ever.
+- **rastrillo pinned to `v0.21.0` exactly** — below it, the attribute grammar renders unstyled.
+- **Markup is the bare `rst-*` attribute grammar** (`<div rst-box>`), variants in the value matched with `~=`. **Adopt per screen, not per element** — mixed authoring inside one component matches neither spelling by design.
+- **Never draw entropy from `yrs`.** Document GUIDs and client ids are assigned by the app and stored. `clippy.toml` enforces this.
+- **`internal/core` is falsification-grade.** Its update log is capped at 4096 and it has no state export. This plan may create documents and apply a handful of updates; it must not assume the package is production-ready, and must not paper over `ErrUpdateLogFull`.
+- **No user-visible string is hardcoded** — every one is a catalog key (§10.3).
+- Work on a branch; push regular commits; squash-merge when done, approved, and CI green.
+
+---
+
+### Task 1: The app skeleton, served and reachable
+
+**Files:**
+- Create: `cmd/docs/main.go`, `internal/docs/app.go`, `internal/docs/handlers.go`, `internal/docs/render.go`
+- Modify: `go.mod`
+
+**Interfaces:**
+- Produces: `docs.New(cfg) (*App, error)` and an `http.Handler`; a `GET /` route rendering a page through rastrillo's shell.
+
+- [ ] **Step 1: Add rastrillo, pinned**
+
+```bash
+CGO_ENABLED=0 go get github.com/carlosframework/rastrillo@v0.21.0
+```
+
+Confirm `go.mod` records `v0.21.0` exactly, not a pseudo-version.
+
+**Read `SKILL.md` from the module before writing any app code** — it is the authoritative authoring doc and carries the app shape, scoping rules and identity plugins:
+
+```bash
+cat "$(CGO_ENABLED=0 go env GOMODCACHE)/github.com/carlosframework/rastrillo@v0.21.0/SKILL.md"
+```
+
+Follow its five-file shape rather than inventing one. If it contradicts anything in this plan, **SKILL.md wins** — say so in your report.
+
+- [ ] **Step 2: Write a failing test**
+
+`internal/docs/app_test.go`:
+
+```go
+package docs_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+func TestRootRendersAPage(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /: status %d, want 200", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); ct == "" {
+ t.Error("no Content-Type on the response")
+ }
+}
+```
+
+`NewForTest` builds an App against a temp SQLite file and is the constructor every later test uses — put it in `internal/docs/testing.go` (not `_test.go`, so other packages' tests can use it).
+
+- [ ] **Step 3: Run it and watch it fail**
+
+```bash
+CGO_ENABLED=0 go test ./internal/docs/ -run TestRootRendersAPage
+```
+
+Expected: build failure — package `docs` does not exist.
+
+- [ ] **Step 4: Implement the skeleton**
+
+Follow SKILL.md's shape. The app needs: a chi router from rastrillo, a SQLite handle via rastrillo's `db`, `html/template` views, and rastrillo's shell partials for the page chrome. Wire every route in one place so `httptest` exercises the real router.
+
+Link `ui.TokensCSS()` and `ui.ThemeCSS("day")` — take the bytes from the module, do not copy files by hand.
+
+- [ ] **Step 5: Run the test and watch it pass**
+
+```bash
+CGO_ENABLED=0 go test ./internal/docs/ -run TestRootRendersAPage -v
+```
+
+- [ ] **Step 6: Run it for real and look at it**
+
+```bash
+CGO_ENABLED=0 go build -o /tmp/docs ./cmd/docs && /tmp/docs -addr :8099
+```
+
+Then `curl -s localhost:8099/ | head -40`. Confirm it is a real HTML page with the design system's CSS linked, not a bare string. **Report the actual HTML head.**
+
+- [ ] **Step 7: Commit**
+
+```bash
+git add cmd internal/docs go.mod go.sum
+git commit -m "app: rastrillo skeleton serving a page
+
+rastrillo v0.21.0 pinned exactly -- below it the attribute grammar
+renders unstyled. Follows SKILL.md's five-file shape rather than an
+invented one."
+```
+
+---
+
+### Task 2: Documents in SQLite, with the core supplying their text
+
+**Files:**
+- Create: `internal/docs/models.go`, `internal/docs/store.go`, `internal/docs/store_test.go`
+
+**Interfaces:**
+- Consumes: `core.New`, `(*Runtime).NewDoc(ctx, guid, clientID)`, `(*Handle).Apply`, `(*Handle).Text` from plan 1.
+- Produces: `Store.Create(ctx, title) (*Document, error)`, `Store.List(ctx) ([]*Document, error)`, `Store.Get(ctx, sqid) (*Document, error)`, `Store.Text(ctx, doc) (string, error)`.
+
+- [ ] **Step 1: Write the failing test**
+
+`internal/docs/store_test.go`:
+
+```go
+package docs_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+func TestCreateListGetRoundTrip(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ ctx := context.Background()
+
+ made, err := app.Store().Create(ctx, "First document")
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ if made.Sqid == "" {
+ t.Fatal("Create returned an empty sqid")
+ }
+ if made.GUID == "" || made.ClientID == 0 {
+ t.Fatal("Create must assign a GUID and client id, never draw them from yrs")
+ }
+
+ list, err := app.Store().List(ctx)
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(list) != 1 || list[0].Title != "First document" {
+ t.Fatalf("List = %+v, want one document titled First document", list)
+ }
+
+ got, err := app.Store().Get(ctx, made.Sqid)
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if got.ID != made.ID {
+ t.Errorf("Get returned id %d, want %d", got.ID, made.ID)
+ }
+}
+
+func TestTextComesFromTheCore(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ ctx := context.Background()
+
+ doc, err := app.Store().Create(ctx, "With content")
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+
+ // The "hello" fixture is a real Yjs update produced by browser yjs.
+ update := docs.ReadFixture(t, "hello")
+ if err := app.Store().ApplyUpdate(ctx, doc, update); err != nil {
+ t.Fatalf("ApplyUpdate: %v", err)
+ }
+
+ text, err := app.Store().Text(ctx, doc)
+ if err != nil {
+ t.Fatalf("Text: %v", err)
+ }
+ if text != "hello world" {
+ t.Errorf("Text = %q, want %q — the text must come from the CRDT, not a column", text, "hello world")
+ }
+}
+```
+
+`ReadFixture` loads `fixtures/data/<name>.update.bin`, in `internal/docs/testing.go`.
+
+- [ ] **Step 2: Run it and watch it fail**
+
+```bash
+CGO_ENABLED=0 go test ./internal/docs/ -run 'TestCreateListGetRoundTrip|TestTextComesFromTheCore'
+```
+
+- [ ] **Step 3: Implement**
+
+Schema, per spec §4 but only what this plan needs:
+
+```sql
+CREATE TABLE IF NOT EXISTS documents (
+ id INTEGER PRIMARY KEY,
+ sqid TEXT NOT NULL UNIQUE,
+ title TEXT NOT NULL,
+ guid TEXT NOT NULL,
+ client_id INTEGER NOT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ deleted_at DATETIME
+);
+CREATE TABLE IF NOT EXISTS doc_updates (
+ id INTEGER PRIMARY KEY,
+ doc_id INTEGER NOT NULL,
+ seq INTEGER NOT NULL,
+ update_bin BLOB NOT NULL,
+ created_at DATETIME NOT NULL
+);
+```
+
+Migrations are idempotent `CREATE TABLE IF NOT EXISTS` plus an additive `ALTER` list — rastrillo handles this; follow its pattern.
+
+**`guid` and `client_id` are assigned by the app** (a UUID from Go's stdlib-adjacent source and a counter or random int64 from `math/rand/v2`), stored, and passed to `core.NewDoc`. They are never drawn from `yrs` — `clippy.toml` bans that on the Rust side and this is the Go side of the same rule.
+
+**Content is NOT a column.** `Store.Text` opens a core handle, replays the document's updates in `seq` order, and reads the text back. Sealing is out of scope for this plan (spec §2 is a later concern) — but leave a comment saying so, so nobody reads the plaintext blob as a decision.
+
+- [ ] **Step 4: Run the tests and watch them pass**
+
+```bash
+CGO_ENABLED=0 go test ./internal/docs/ -v
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/docs
+git commit -m "app: documents in SQLite, text from the CRDT core
+
+Content is not a column -- Text replays the update log through the
+wazero core. GUID and client id are assigned by the app and stored,
+never drawn from yrs, which is the Go side of the rule clippy.toml
+enforces in Rust."
+```
+
+---
+
+### Task 3: The document list
+
+**Files:**
+- Create: `internal/docs/views/list.html`, `internal/docs/list_test.go`
+- Modify: `internal/docs/handlers.go`, `internal/docs/render.go`
+
+**Interfaces:**
+- Consumes: `Store.List` from Task 2.
+- Produces: `GET /` rendering the list.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestListShowsDocuments(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+ if _, err := app.Store().Create(ctx, "Board minutes"); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+
+ body := rec.Body.String()
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ if !strings.Contains(body, "Board minutes") {
+ t.Error("the document's title is not on the page")
+ }
+ if strings.Contains(body, `class="rst-`) {
+ t.Error("class vocabulary found — this app writes the attribute grammar (Global Constraints)")
+ }
+}
+
+func TestEmptyListSaysSo(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ // An empty state is a designed screen, not a blank page.
+ if len(strings.TrimSpace(rec.Body.String())) < 200 {
+ t.Error("empty list rendered almost nothing — there should be an empty state")
+ }
+}
+```
+
+The class-vocabulary assertion is a real gate: it makes the markup constraint mechanical rather than remembered.
+
+- [ ] **Step 2: Run and watch fail.** `CGO_ENABLED=0 go test ./internal/docs/ -run TestListShows`
+
+- [ ] **Step 3: Implement the list**
+
+Per spec §6.4: a list, newest first, grouped by recency, one row per document showing title, who touched it and when. **Cut**, per Paul's four jank rulings: no template carousel, no "Owned by anyone" filter chrome, no grid/list/AZ toggles, no commercial or platform chrome.
+
+Design the row so a 26×34 thumbnail drops into its left edge later without reflowing anything else — thumbnails come with the layout engine.
+
+Use rastrillo's shell and partials; write the attribute grammar; every string a catalog key.
+
+- [ ] **Step 4: Run and watch pass.**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/docs
+git commit -m "app: the document list
+
+Per spec §6.4 and Paul's jank rulings: a list, newest first, no template
+carousel, no filter chrome, no view toggles. The row reserves space a
+page-one thumbnail drops into once the layout engine exists.
+
+The test asserts no class-vocabulary markup, which makes the attribute
+grammar constraint mechanical rather than remembered."
+```
+
+---
+
+### Task 4: Create and read a document
+
+**Files:**
+- Create: `internal/docs/views/show.html`
+- Modify: `internal/docs/handlers.go`, `internal/docs/list_test.go`
+
+**Interfaces:**
+- Produces: `POST /documents` (create, redirect to the new document) and `GET /d/{sqid}` (read-only render).
+
+- [ ] **Step 1: Write the failing tests**
+
+```go
+func TestCreateThenRead(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+
+ form := strings.NewReader("title=Minutes")
+ req := httptest.NewRequest(http.MethodPost, "/documents", form)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /documents: status %d, want 303", rec.Code)
+ }
+ loc := rec.Header().Get("Location")
+ if !strings.HasPrefix(loc, "/d/") {
+ t.Fatalf("Location = %q, want /d/<sqid>", loc)
+ }
+
+ req2 := httptest.NewRequest(http.MethodGet, loc, nil)
+ rec2 := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec2, req2)
+ if rec2.Code != http.StatusOK {
+ t.Fatalf("GET %s: status %d", loc, rec2.Code)
+ }
+ if !strings.Contains(rec2.Body.String(), "Minutes") {
+ t.Error("the document's title is not on its own page")
+ }
+}
+
+func TestUnknownDocument404s(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ req := httptest.NewRequest(http.MethodGet, "/d/nosuchthing", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("status %d, want 404 — a document you cannot see does not exist", rec.Code)
+ }
+}
+```
+
+The 404 test encodes spec §4.2's contract: not-yours is indistinguishable from not-there.
+
+- [ ] **Step 2: Run and watch fail.**
+
+- [ ] **Step 3: Implement**
+
+`POST /documents` creates and redirects with **303 See Other**, so a refresh does not repost. Creation is a plain form post that works with no JavaScript (§6.2).
+
+`GET /d/{sqid}` renders the document read-only: its title, and its text from `Store.Text` — the CRDT, through wazero, not a column. Render the text as flowing HTML inside a page-shaped canvas: white, centred, with margins. **No pagination** — that arrives with the layout engine, and the container is built so page breaks can be drawn into it later.
+
+Give the canvas `color-scheme: light` and paint it from literals, per spec §10.2, so it stays paper in a dark theme.
+
+- [ ] **Step 4: Run and watch pass.**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/docs
+git commit -m "app: create and read a document
+
+POST redirects 303 so a refresh does not repost; both work with no
+JavaScript. The read view takes its text from the CRDT through wazero
+rather than a column, and its canvas opts out of the theme's scheme so
+paper stays paper in the dark."
+```
+
+---
+
+### Task 5: A way in, that cannot ship
+
+**Files:**
+- Create: `internal/docs/devauth.go`, `internal/docs/devauth_test.go`
+- Modify: `cmd/docs/main.go`
+
+**Interfaces:**
+- Produces: a development-only identity so the app is reachable in a browser.
+
+Spec §8.1 puts sign-in at the home's credential origin. **The home does not exist yet.** This task provides a local stand-in and makes it structurally impossible to ship.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestDevAuthRefusesOutsideDevelopment(t *testing.T) {
+ // The guard is the point of this task: a build without the dev flag
+ // must refuse to start rather than silently authenticate everyone.
+ _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: "production"})
+ if err == nil {
+ t.Fatal("dev auth must refuse to start outside development")
+ }
+ if !strings.Contains(err.Error(), "development") {
+ t.Errorf("error should say why: %v", err)
+ }
+}
+
+func TestDevAuthGivesAnIdentityInDevelopment(t *testing.T) {
+ app, err := docs.NewForTest(t) // NewForTest sets Env: development
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code == http.StatusUnauthorized {
+ t.Error("dev auth should provide an identity in development")
+ }
+}
+```
+
+- [ ] **Step 2: Run and watch fail.**
+
+- [ ] **Step 3: Implement**
+
+A single fixed development identity, enabled only when `Env == "development"` **and** an explicit `-dev-auth` flag is set. Constructing it in any other environment returns an error that names the reason — the app refuses to start rather than starting insecure.
+
+Put a comment at the top of the file saying, in one sentence, that this exists because the home does not, and that it is deleted when §8.1's assertion verification lands.
+
+- [ ] **Step 4: Run and watch pass.**
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/docs cmd
+git commit -m "app: a development-only identity, guarded so it cannot ship
+
+Spec §8.1 puts sign-in at the home's credential origin and the home does
+not exist yet. This refuses to start outside development rather than
+silently authenticating everyone, and it is deleted when assertion
+verification lands."
+```
+
+---
+
+### Task 6: Look at it in a browser
+
+**Files:**
+- Create: `scripts/run-dev.sh`
+- Modify: `.amadan/ci.d/10-gate`, `AGENTS.md`
+
+- [ ] **Step 1: Add the run script**
+
+`scripts/run-dev.sh` builds and runs the app with dev auth on a fixed port against a scratch database, so anyone can see it in one command. Print the URL.
+
+- [ ] **Step 2: Run it and drive it**
+
+Start it, then exercise the real flows over HTTP and **report the actual output at each step**:
+
+```bash
+./scripts/run-dev.sh &
+sleep 2
+curl -s localhost:8099/ | head -30
+curl -s -X POST localhost:8099/documents -d 'title=Overnight test' -i | head -5
+curl -s localhost:8099/d/<sqid-from-the-redirect> | head -40
+```
+
+Confirm: the list renders, creating redirects, the document page shows its title, and the page links the design system's CSS.
+
+- [ ] **Step 3: Verify the markup constraint held**
+
+```bash
+curl -s localhost:8099/ | grep -c 'class="rst-' || true
+```
+
+Expected: 0 from our own templates. **If rastrillo's own partials emit classes, that is expected and fine** — they are rastrillo's output, not ours (spec §10.1.1). Report which came from where.
+
+- [ ] **Step 4: Extend the gate**
+
+Add `CGO_ENABLED=0 go build ./...` to `.amadan/ci.d/10-gate` if it is not already covered, and note the app in `.amadan/README.md`.
+
+- [ ] **Step 5: Commit and confirm CI**
+
+```bash
+git add scripts .amadan AGENTS.md
+git commit -m "app: a one-command dev run, and the gate covers the app"
+git push
+```
+
+Then confirm `amadan ci status oficina/docs -branch <branch>` is exit 0.
+
+---
+
+## Self-Review
+
+**Spec coverage.** Implements §3's shape, §4's documents and update log (minus sealing, deliberately deferred and commented), §6.2's zero-JS create-and-read, §6.4's list minus thumbnails, §10's design system and canvas scheme opt-out. Does **not** implement: the layout engine and pagination (next plan), the editor, collaboration, sharing, import/export, or real identity — §8.1 is stubbed with a guard that refuses to ship.
+
+**Placeholder scan:** no TBDs. Two things are deliberately deferred with a comment rather than a silent omission: content sealing (§2) and thumbnails (§6.4).
+
+**Type consistency:** `Store.Create/List/Get/Text/ApplyUpdate` are defined in Task 2 and consumed in Tasks 3 and 4; `NewForTest` and `ReadFixture` are defined in Task 1's step 2 and used throughout; `core.NewDoc(ctx, guid, clientID)` matches plan 1's shipped signature.
+
+**Known risk carried from plan 1:** `internal/core` caps its update log at 4096 and has no state export. This plan applies few updates per document, so it will not hit the cap — but any task that starts replaying long histories must stop and raise it rather than working around it.
diff --git a/go.mod b/go.mod
index 6398cb1..469fedf 100644
--- a/go.mod
+++ b/go.mod
@@ -2,6 +2,26 @@ module github.com/carlosframework/docs
go 1.26.5
-require github.com/tetratelabs/wazero v1.12.0
+require (
+ github.com/carlosframework/rastrillo v0.21.0
+ github.com/go-chi/chi/v5 v5.3.2
+ github.com/google/uuid v1.6.0
+ github.com/tetratelabs/wazero v1.12.0
+ gorm.io/gorm v1.31.2
+)
-require golang.org/x/sys v0.44.0 // indirect
+require (
+ github.com/dustin/go-humanize v1.0.1 // 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/sys v0.46.0 // indirect
+ golang.org/x/text v0.20.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
+)
diff --git a/go.sum b/go.sum
index a10a279..035a8ec 100644
--- a/go.sum
+++ b/go.sum
@@ -1,4 +1,91 @@
+github.com/carlosframework/rastrillo v0.21.0 h1:uqw/ZK+y/VwS+jDnIv2llAIug3AuFWesSp1xTEPS45w=
+github.com/carlosframework/rastrillo v0.21.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+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/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
-golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
-golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+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.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
+golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
+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/internal/docs/app.go b/internal/docs/app.go
new file mode 100644
index 0000000..57cec24
--- /dev/null
+++ b/internal/docs/app.go
@@ -0,0 +1,311 @@
+// Package docs is the CARLOS Docs rastrillo app: its database handle,
+// router and (for now, one) page. Follows rastrillo's SKILL.md app
+// shape (models.go, app.go, handlers.go, render.go, cmd/docs/main.go),
+// adapted into a New(cfg) (*App, error) constructor + App.Handler()
+// rather than SKILL.md's illustrative App(d, origin, logger)
+// (*http.ServeMux, error) function, because the task that specified
+// this app also specified the test harness (docs.NewForTest,
+// app.Handler().ServeHTTP) that every later task's tests depend on —
+// the constructor name and shape follow that harness; everything else
+// below follows SKILL.md.
+package docs
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/carlosframework/rastrillo/csrf"
+ "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/migrate"
+ "github.com/go-chi/chi/v5"
+
+ "github.com/carlosframework/docs/internal/core"
+)
+
+// Config configures New.
+type Config struct {
+ // DBPath is the SQLite file New opens via rastrillo's db package.
+ DBPath string
+
+ // Env is "development" or "production" ("" defaults to
+ // "production"). devauth.go's development-only identity refuses to
+ // start unless Env is exactly "development" — see DevAuth.
+ Env string
+
+ // DevAuth enables devauth.go's fixed development identity —
+ // New's guardDevAuth refuses to start unless Env is exactly
+ // "development" too. Prefer NewWithDevAuth (devauth.go) over
+ // setting this by hand; it exists as its own Config field, rather
+ // than being threaded through only that constructor, so
+ // NewForTest — which four later tasks' tests depend on — never
+ // needed a signature change once devauth.go landed.
+ DevAuth bool
+
+ // Origin is this app's own origin (scheme://host[:port], no path),
+ // what csrf.Protect (rastrillo SKILL.md §5) uses as its fallback
+ // same-origin evidence. Fix round 3 corrected a wrong model of
+ // what that check actually does — see csrf.Protect's own doc
+ // comment (rastrillo v0.21.0's csrf/csrf.go): it checks
+ // Sec-Fetch-Site FIRST when a browser sends it, which every
+ // current browser does on a state-changing request —
+ // "same-origin"/"none" pass immediately, anything else is refused
+ // immediately, and Origin is never consulted in that path at all.
+ // Only when Sec-Fetch-Site is entirely absent (a legacy browser, or
+ // a non-browser client such as curl) does the check fall back to
+ // comparing the request's Origin or Referer header against this
+ // string exactly. So a wrong-but-nonempty Origin does NOT make a
+ // real browser's genuine same-origin POST fail, and it does NOT
+ // open up a genuine cross-site request either — both of those are
+ // decided by Sec-Fetch-Site alone, never by this string. What a
+ // wrong Origin actually breaks is the legacy fallback path, and it
+ // breaks it silently: a real client that happens to omit
+ // Sec-Fetch-Site gets wrongly rejected, or — if that comparison
+ // were ever loosened or removed — stops being checked at all.
+ // Neither shows up in ordinary manual testing with a real browser,
+ // which is exactly why getting this right cannot be verified by
+ // "the page loaded and the form worked."
+ //
+ // Empty is allowed only when Env is "development" (New falls back
+ // to DefaultOrigin there, for tests and local convenience); with
+ // any other Env, empty is a startup refusal — see New.
+ //
+ // cmd/docs/main.go wires this from $DOCS_ORIGIN, the same
+ // "<APP>_ORIGIN" convention rastrillo's own `new` scaffold uses
+ // (cmd/rastrillo/new.go's mainTemplate) — the platform's real
+ // per-instance origin (spec §9.2's <app>.<sqid>.oncarlos.com)
+ // is set there at deploy time. On the unix-socket/LISTEN_FDS
+ // activation path there is no -addr to derive a fallback from, so
+ // leaving $DOCS_ORIGIN unset must fail loudly rather than quietly
+ // configuring the app with a string that has nothing to do with
+ // where it is actually reachable — that is exactly what the
+ // Env-gated default below refuses to do.
+ //
+ // A non-empty value is trimmed and then validated (validateOrigin)
+ // rather than trusted as-is: whitespace-only is not "empty" by a
+ // bare == "" check, and would otherwise sail past the guard above
+ // with nothing catching it until the legacy fallback path above
+ // tried, and failed, to match it against anything real.
+ Origin string
+
+ // Logger defaults to slog.Default().
+ Logger *slog.Logger
+}
+
+// DefaultOrigin is Config.Origin's fallback when empty — see its doc
+// comment. Exported so a caller that needs to know what origin this
+// app is protecting itself against (NewForTest's callers, notably)
+// does not have to duplicate the literal.
+const DefaultOrigin = "http://localhost:8080"
+
+// validateOrigin reports whether origin (already trimmed by the
+// caller — New) is something csrf.Protect's legacy Origin/Referer
+// fallback can actually use: an absolute http(s) URL naming a scheme
+// and a host and nothing else. "Nothing else" is checked
+// deliberately, not just "parses as a URL" — url.Parse happily
+// accepts a bare path, a value carrying a query string, or one with
+// embedded userinfo, none of which a same-origin check has any
+// business being handed, and none of which would ever equal a real
+// browser's Origin header. An app that accepted one would boot
+// looking entirely healthy — a modern browser's genuine same-origin
+// traffic passes on Sec-Fetch-Site alone and never reaches this
+// string at all (Config.Origin's own doc comment) — and only fail,
+// silently, on whatever still uses the legacy fallback.
+func validateOrigin(origin string) error {
+ if origin == "" {
+ return errors.New("origin is blank")
+ }
+ u, err := url.Parse(origin)
+ if err != nil {
+ return fmt.Errorf("origin %q does not parse as a URL: %w", origin, err)
+ }
+ if u.Scheme != "http" && u.Scheme != "https" {
+ return fmt.Errorf("origin %q must have an http or https scheme", origin)
+ }
+ // u.Host != "" is not enough: fix round 2's own bug. "http://:8099"
+ // — exactly what -addr :8099 derives (main.go's "http://"+opts.Addr,
+ // the plan's own example invocation) — has a non-empty Host
+ // (":8099") but an empty Hostname(); Host merely means "there was
+ // something between // and the next / (or the end)", not "there is
+ // a host". A reviewer booted a production-Env binary on this exact
+ // origin and watched it serve. Hostname() is what actually strips
+ // the port and answers "is there a host here at all".
+ if u.Hostname() == "" {
+ return fmt.Errorf("origin %q has no host", origin)
+ }
+ if u.Path != "" || u.RawQuery != "" || u.Fragment != "" || u.User != nil {
+ return fmt.Errorf("origin %q must be scheme://host[:port] only, with no path, query, fragment or userinfo", origin)
+ }
+ return nil
+}
+
+// App is the CARLOS Docs rastrillo app.
+type App struct {
+ cfg Config
+ db *db.DB
+ mux *http.ServeMux
+ logger *slog.Logger
+ core *core.Runtime
+ store *Store
+}
+
+// BootSchema is everything New applies at boot, in apply order.
+// migrate.Merge(sessions.Schema, ...) adds another set here once auth
+// does; for now it's just this app's own Schema (models.go).
+var BootSchema = migrate.Merge(Schema)
+
+// New opens the database, applies migrations and wires the router. It
+// is rastrillo SKILL.md's app.go shape: db.Open for the SQLite handle,
+// migrate.Apply once at boot, a chi router wrapped as an
+// *http.ServeMux so httptest exercises the real router, csrf.Protect
+// mounted app-wide over POST /documents (SKILL.md §5). It does not
+// wire rastrillo's sessions package or a real identity plugin — there
+// is no signed-in route yet; spec §8.1's home issues the real identity
+// once it exists. devauth.go's guardDevAuth runs first, so a
+// misconfigured Config.DevAuth refuses before any of the above opens.
+func New(cfg Config) (*App, error) {
+ logger := cfg.Logger
+ if logger == nil {
+ logger = slog.Default()
+ }
+ if cfg.Env == "" {
+ cfg.Env = "production"
+ }
+
+ // Fail closed, loudly: an empty Origin defaults to DefaultOrigin
+ // only in development (tests via NewForTest, a person running
+ // scripts/run-dev.sh). Anywhere else — Env left at its "production"
+ // default very much included — an empty Origin is a startup
+ // refusal, not a silent fallback to a placeholder whose only real
+ // effect is on csrf.Protect's legacy Origin/Referer fallback path
+ // (Config.Origin's own doc comment explains why that path, not
+ // "every request", is what a wrong string actually touches). This
+ // is the same fail-closed shape as guardDevAuth below: exactly one
+ // string ("development") is ever treated as "safe to default",
+ // checked before anything else opens.
+ //
+ // cfg.Origin == "" alone is not the right test: a whitespace value
+ // like $DOCS_ORIGIN=" " is non-empty, so it would sail past that
+ // check. Nothing about a modern browser's own traffic would look
+ // wrong — Sec-Fetch-Site: same-origin passes without this string
+ // ever being read — so the only visible symptom would be the
+ // legacy fallback path failing to match literal whitespace against
+ // anything real, for whichever client still exercises it. That
+ // fails safe on that one path, but silently and late (at request
+ // time, for whoever hits it), not at boot where a malformed origin
+ // belongs. validateOrigin below rejects that and any other value
+ // that cannot possibly be what csrf.Protect needs (no scheme, no
+ // host, a stray path/query/fragment/userinfo) before New ever
+ // returns a listening app.
+ trimmedOrigin := strings.TrimSpace(cfg.Origin)
+ switch {
+ case trimmedOrigin == "" && cfg.Env != "development":
+ return nil, fmt.Errorf("docs: Config.Origin is empty and Env is %q, not %q — refusing to start with an unset origin; set $DOCS_ORIGIN to this instance's real origin", cfg.Env, "development")
+ case trimmedOrigin == "":
+ cfg.Origin = DefaultOrigin
+ default:
+ if err := validateOrigin(trimmedOrigin); err != nil {
+ return nil, fmt.Errorf("docs: Config.Origin is invalid: %w", err)
+ }
+ cfg.Origin = trimmedOrigin
+ }
+
+ // Checked before anything else opens — including the database —
+ // so a misconfigured DevAuth refuses before New has any side
+ // effect to unwind. See devauth.go: this is the single choke
+ // point every path to the development-only identity passes
+ // through, NewWithDevAuth and NewForTest included.
+ if err := guardDevAuth(cfg); err != nil {
+ return nil, err
+ }
+ if cfg.DevAuth {
+ // Loud on purpose: an operator watching boot logs should
+ // never be able to miss this. It does not make the guard
+ // above any less necessary — logging is a tripwire, not a
+ // control.
+ logger.Warn("docs: development-only identity ENABLED — refuses to start outside Env=development", "env", cfg.Env)
+ }
+
+ d, err := db.Open(cfg.DBPath, logger)
+ if err != nil {
+ return nil, fmt.Errorf("docs: open database: %w", err)
+ }
+
+ if _, err := migrate.Apply(context.Background(), d, BootSchema); err != nil {
+ d.Close()
+ return nil, fmt.Errorf("docs: apply migrations: %w", err)
+ }
+
+ // One Runtime for the process (core.New compiles the wasm module
+ // once — expensive; instantiating a per-document Handle from it is
+ // not), shared by every document's Store.replay.
+ rt, err := core.New(context.Background())
+ if err != nil {
+ d.Close()
+ return nil, fmt.Errorf("docs: start core runtime: %w", err)
+ }
+ store := NewStore(d.G, rt)
+
+ render, err := newRenderer()
+ if err != nil {
+ rt.Close(context.Background())
+ d.Close()
+ return nil, fmt.Errorf("docs: build renderer: %w", err)
+ }
+
+ h := &handlers{render: render, logger: logger, store: store}
+
+ r := chi.NewRouter()
+ // Mounted above every route, per rastrillo SKILL.md §5: refuses a
+ // cross-origin POST/PUT/PATCH/DELETE by Sec-Fetch-Site/Origin/
+ // Referer evidence; GET/HEAD/OPTIONS pass untouched, so this does
+ // nothing to "/" or "/d/{sqid}" and everything to "/documents".
+ r.Use(csrf.Protect(cfg.Origin))
+ if cfg.DevAuth {
+ // Guarded above: this only ever mounts when guardDevAuth has
+ // already confirmed Env == "development".
+ r.Use(devAuthMiddleware)
+ }
+ r.Get("/", h.list)
+ r.Post("/documents", h.create)
+ r.Get("/d/{sqid}", h.show)
+ r.Handle("/static/*", render.assets.Handler())
+
+ mux := http.NewServeMux()
+ mux.Handle("/", r)
+
+ return &App{cfg: cfg, db: d, mux: mux, logger: logger, core: rt, store: store}, nil
+}
+
+// Store returns the app's document store — models.go's Document,
+// store.go's Store — over the app's migrated database and shared
+// core.Runtime.
+func (a *App) Store() *Store { return a.store }
+
+// Handler is the app's router: rastrillo.Options.Mux for real serving
+// (cmd/docs/main.go), and — per rastrillo's own doc comment on
+// Handler/Serve — exactly what a test harness should exercise directly,
+// so httptest hits the real route table rather than a hand-duplicated
+// one. *http.ServeMux, not http.Handler: it satisfies both callers
+// without forcing an unchecked type assertion on the main.go side,
+// and matches SKILL.md's own App(...) (*http.ServeMux, error) shape.
+func (a *App) Handler() *http.ServeMux { return a.mux }
+
+// Close releases the database handle and the core runtime's compiled
+// module. NewForTest registers this as a t.Cleanup.
+func (a *App) Close() error {
+ var errs []error
+ if a.core != nil {
+ if err := a.core.Close(context.Background()); err != nil {
+ errs = append(errs, err)
+ }
+ }
+ if err := a.db.Close(); err != nil {
+ errs = append(errs, err)
+ }
+ return errors.Join(errs...)
+}
diff --git a/internal/docs/app_test.go b/internal/docs/app_test.go
new file mode 100644
index 0000000..9042c61
--- /dev/null
+++ b/internal/docs/app_test.go
@@ -0,0 +1,73 @@
+package docs_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+func TestRootRendersAPage(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /: status %d, want 200", rec.Code)
+ }
+ if ct := rec.Header().Get("Content-Type"); ct == "" {
+ t.Error("no Content-Type on the response")
+ }
+}
+
+// TestNewRefusesWhitespaceOrigin is fix round 1's own regression test
+// at the New boundary (origin_test.go covers validateOrigin directly,
+// package-internal): $DOCS_ORIGIN=" " is non-empty, so a bare
+// cfg.Origin == "" check would let it through New and boot an app
+// that 403s every request forever instead of refusing to start. New
+// trims before testing for blank, so whitespace-only collapses onto
+// the same "empty origin" refusal an actually-empty Origin gets —
+// still a startup refusal either way, which is the property that
+// matters here, not which of the two error messages it is.
+func TestNewRefusesWhitespaceOrigin(t *testing.T) {
+ _, err := docs.New(docs.Config{
+ DBPath: filepath.Join(t.TempDir(), "docs.db"),
+ Env: "production",
+ Origin: " ",
+ })
+ if err == nil {
+ t.Fatal("New must refuse a whitespace-only Origin")
+ }
+ if !strings.Contains(err.Error(), "empty") {
+ t.Errorf("error should say the origin is empty: %v", err)
+ }
+}
+
+// TestNewRefusesMalformedOrigin covers the other ways a non-blank
+// Origin can still be unusable — no scheme, no host, or a URL
+// carrying more than scheme://host — at the same New boundary.
+func TestNewRefusesMalformedOrigin(t *testing.T) {
+ for _, origin := range []string{
+ "not a url at all",
+ "localhost:8080",
+ "http://example.com/path",
+ } {
+ t.Run(origin, func(t *testing.T) {
+ _, err := docs.New(docs.Config{
+ DBPath: filepath.Join(t.TempDir(), "docs.db"),
+ Env: "production",
+ Origin: origin,
+ })
+ if err == nil {
+ t.Fatalf("New must refuse malformed origin %q", origin)
+ }
+ })
+ }
+}
diff --git a/internal/docs/devauth.go b/internal/docs/devauth.go
new file mode 100644
index 0000000..65a6694
--- /dev/null
+++ b/internal/docs/devauth.go
@@ -0,0 +1,81 @@
+// This file exists only because spec §8.1's home — the shared
+// credential origin that issues a signed identity assertion — does
+// not exist yet; it hands every request one fixed development
+// identity purely so the app is reachable in a browser, and it is
+// deleted the day this app verifies a real signed assertion instead.
+package docs
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+)
+
+// devSubject is the one identity devauth ever grants. Fixed rather
+// than configurable: a real identity is exactly what this stands in
+// for, so giving it knobs would just be building a worse version of
+// the thing it replaces.
+const devSubject = "dev"
+
+// DevIdentity is what devAuthMiddleware attaches to a request's
+// context — the shape spec §8.1's verified assertion will eventually
+// fill in for real.
+type DevIdentity struct {
+ Subject string
+}
+
+type devIdentityKey struct{}
+
+// CurrentDevIdentity returns the identity devAuthMiddleware attached
+// to r, if any. Nothing in this app requires a signed-in viewer yet,
+// so no handler calls this today — it exists so a later task can ask
+// "is anyone signed in" without reaching into this file's internals,
+// and it goes away with the rest of this file once §8.1 lands.
+func CurrentDevIdentity(r *http.Request) (DevIdentity, bool) {
+ id, ok := r.Context().Value(devIdentityKey{}).(DevIdentity)
+ return id, ok
+}
+
+// devAuthMiddleware stamps every request with the fixed development
+// identity. New wires this in only after guardDevAuth has approved
+// cfg — see New and NewWithDevAuth below.
+func devAuthMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := context.WithValue(r.Context(), devIdentityKey{}, DevIdentity{Subject: devSubject})
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// guardDevAuth is the single choke point every path to the
+// development identity must pass through — New calls it directly, so
+// it applies whether an app is built via NewWithDevAuth, via New
+// itself with DevAuth set by hand, or via NewForTest.
+//
+// Fail closed: cfg.DevAuth false is always fine (there is nothing to
+// guard against), but cfg.DevAuth true demands cfg.Env be exactly
+// "development" — no allowlist of "development-like" strings to fall
+// out of, so an unrecognised value, a typo, or empty (New defaults ""
+// to "production" before this runs) all refuse identically to a
+// deliberate "production". There is exactly one string that is ever
+// permitted.
+func guardDevAuth(cfg Config) error {
+ if !cfg.DevAuth {
+ return nil
+ }
+ if cfg.Env != "development" {
+ return fmt.Errorf("docs: DevAuth requires Env %q to start development-only, insecure identity; got Env %q — refusing to start", "development", cfg.Env)
+ }
+ return nil
+}
+
+// NewWithDevAuth is New, named for the one call site that means to
+// opt into the development-only identity: cmd/docs/main.go's explicit
+// -dev-auth flag. It is not a second code path — it forces
+// cfg.DevAuth true and calls New unchanged, so the guard that
+// actually enforces "development only" (guardDevAuth, run inside New)
+// lives in exactly one place no matter which name a caller reaches it
+// through.
+func NewWithDevAuth(cfg Config) (*App, error) {
+ cfg.DevAuth = true
+ return New(cfg)
+}
diff --git a/internal/docs/devauth_test.go b/internal/docs/devauth_test.go
new file mode 100644
index 0000000..0d30ca4
--- /dev/null
+++ b/internal/docs/devauth_test.go
@@ -0,0 +1,80 @@
+package docs_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+// TestDevAuthRefusesOutsideDevelopment is the plan's own literal test:
+// the guard is the point of this task — a build without the dev flag
+// must refuse to start rather than silently authenticating everyone.
+func TestDevAuthRefusesOutsideDevelopment(t *testing.T) {
+ _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: "production"})
+ if err == nil {
+ t.Fatal("dev auth must refuse to start outside development")
+ }
+ if !strings.Contains(err.Error(), "development") {
+ t.Errorf("error should say why: %v", err)
+ }
+}
+
+// TestDevAuthGivesAnIdentityInDevelopment is the plan's own literal
+// test: NewForTest fixes Env at "development" with DevAuth enabled, so
+// a request must not come back unauthorized.
+func TestDevAuthGivesAnIdentityInDevelopment(t *testing.T) {
+ app, err := docs.NewForTest(t) // NewForTest sets Env: development
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code == http.StatusUnauthorized {
+ t.Error("dev auth should provide an identity in development")
+ }
+}
+
+// TestDevAuthRefusesEmptyEnv proves the fail-closed default: an empty
+// Env must not be treated as "development-like". New defaults "" to
+// "production" before the guard runs, so this exercises that the
+// default itself is refused, not just an explicit "production".
+func TestDevAuthRefusesEmptyEnv(t *testing.T) {
+ _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: ""})
+ if err == nil {
+ t.Fatal("dev auth must refuse to start with an empty Env")
+ }
+ if !strings.Contains(err.Error(), "development") {
+ t.Errorf("error should say why: %v", err)
+ }
+}
+
+// TestDevAuthRefusesTypoedEnv proves there is no allowlist to fall out
+// of loosely — only the exact string "development" is ever permitted,
+// so a near-miss (a plausible env-var typo) refuses exactly like a
+// deliberate "production" rather than being treated as close enough.
+func TestDevAuthRefusesTypoedEnv(t *testing.T) {
+ _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: "developement"})
+ if err == nil {
+ t.Fatal("dev auth must refuse to start with a typo'd Env")
+ }
+ if !strings.Contains(err.Error(), "development") {
+ t.Errorf("error should say why: %v", err)
+ }
+}
+
+// TestDevAuthRefusesViaNewDirectly proves the guard is a single choke
+// point: calling New itself with DevAuth set by hand, bypassing
+// NewWithDevAuth entirely, refuses exactly the same way.
+func TestDevAuthRefusesViaNewDirectly(t *testing.T) {
+ _, err := docs.New(docs.Config{DevAuth: true, Env: "production"})
+ if err == nil {
+ t.Fatal("New itself must refuse DevAuth outside development, not just NewWithDevAuth")
+ }
+ if !strings.Contains(err.Error(), "development") {
+ t.Errorf("error should say why: %v", err)
+ }
+}
diff --git a/internal/docs/handlers.go b/internal/docs/handlers.go
new file mode 100644
index 0000000..839be23
--- /dev/null
+++ b/internal/docs/handlers.go
@@ -0,0 +1,241 @@
+package docs
+
+import (
+ "errors"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "gorm.io/gorm"
+
+ "github.com/carlosframework/rastrillo"
+)
+
+// handlers holds what the app's HTTP handlers need — rastrillo
+// SKILL.md's "one handler struct holding d.G", here Store (Task 2)
+// instead: this app reads and writes documents through Store, never
+// through a *gorm.DB of its own.
+type handlers struct {
+ render *renderer
+ logger *slog.Logger
+ store *Store
+}
+
+// listPage is GET /'s view data (spec §6.4). Title and the group
+// labels are resolved through rastrillo.T from Catalog (render.go);
+// a row's Title and When are the document's own data, not authored
+// copy, so list.html prints them directly rather than through T.
+//
+// CreateLabel and CreatePlaceholder back the create form list.html
+// renders in its page header — present regardless of Groups, so it
+// reaches a person with no documents yet exactly as it reaches
+// everyone else (fix round 1: the original version of this screen
+// had no path to POST /documents at all outside a hand-typed curl).
+type listPage struct {
+ Title string
+ Groups []docGroup
+ EmptyTitle string
+ EmptyBody string
+ CreateLabel string
+ CreatePlaceholder string
+}
+
+// docGroup is one recency bucket ("Today", "This week", ...) and the
+// rows in it, in the order Store.List returned them — newest first.
+type docGroup struct {
+ Label string
+ Rows []docRow
+}
+
+// docRow is one document's row. Href points at the read-only view
+// (Task 4's GET /d/{sqid}, show below) — an ordinary link, so a click
+// works with no JavaScript.
+type docRow struct {
+ Href string
+ Title string
+ When string
+}
+
+// list serves GET /: the document list, spec §6.4 and Paul's four
+// rulings against it — newest first, grouped by recency, one row per
+// document. Cut, deliberately, not by oversight: no template
+// carousel, no "Owned by anyone"/"Last opened by me" filter chrome, no
+// grid/list/AZ/folder toggle, no commercial or platform chrome. The
+// row reserves a 26×34 slot at its left edge (list.html's
+// [docs-thumb]) for the page-one thumbnail a later plan's layout
+// engine renders there — reserved, not faked: today it is an empty,
+// bordered box.
+//
+// A row shows title and when it was last touched, not who: Document
+// carries no author column yet (Task 5 adds identity), and rendering
+// a "who" today would mean inventing one.
+func (h *handlers) list(w http.ResponseWriter, r *http.Request) {
+ docs, err := h.store.List(r.Context())
+ if err != nil {
+ h.logger.Error("docs: list documents", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+
+ data := listPage{
+ Title: rastrillo.T(r, "docs.list.title"),
+ Groups: groupByRecency(r, time.Now().UTC(), docs),
+ EmptyTitle: rastrillo.T(r, "docs.list.empty.title"),
+ EmptyBody: rastrillo.T(r, "docs.list.empty.body"),
+ CreateLabel: rastrillo.T(r, "docs.list.create.label"),
+ CreatePlaceholder: rastrillo.T(r, "docs.list.create.placeholder"),
+ }
+ if err := h.render.page(w, r, "list", data); err != nil {
+ h.logger.Error("docs: render list", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ }
+}
+
+// create serves POST /documents: a plain form post, no JavaScript
+// required (spec §6.2). It reads the permitted field by name —
+// rastrillo SKILL.md §4's "never bind a request body onto a GORM
+// model" — and Store.Create builds the row itself, so there is no
+// mass-assignment surface here at all. On success it redirects with
+// 303 See Other rather than the 302 a naive handler reaches for: a
+// visitor reloading the response, or a browser retrying the
+// navigation, replays the GET the redirect points at, never the POST
+// that created the document.
+func (h *handlers) create(w http.ResponseWriter, r *http.Request) {
+ title := strings.TrimSpace(r.PostFormValue("title"))
+ if title == "" {
+ title = rastrillo.T(r, "docs.document.untitled")
+ }
+
+ doc, err := h.store.Create(r.Context(), title)
+ if err != nil {
+ h.logger.Error("docs: create document", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+ http.Redirect(w, r, "/d/"+doc.Sqid, http.StatusSeeOther)
+}
+
+// showPage is GET /d/{sqid}'s view data (spec §6.1/§6.2). Title is the
+// document's own title — data, not authored copy, printed as-is the
+// same way docRow.Title is on the list. Text is Store.Text's
+// plain-text replay of the CRDT through wazero, never a database
+// column; a document with no updates yet (a document just created)
+// replays to "", which views/show.html must render as an ordinary
+// empty canvas rather than erroring.
+type showPage struct {
+ Title string
+ Text string
+}
+
+// show serves GET /d/{sqid}: the document read-only, its title and
+// its CRDT text inside a page-shaped canvas (views/show.html). An
+// unknown or malformed sqid 404s — spec §4.2's contract that a
+// document you cannot see is indistinguishable from one that does not
+// exist. There is no signed-in viewer yet (identity is Task 5), so
+// today "cannot see" only ever means "no such row"; the 404 is the
+// same either way, and nothing here will need to change once scoping
+// lands — it only gains another way to reach this same branch.
+func (h *handlers) show(w http.ResponseWriter, r *http.Request) {
+ sqid := r.PathValue("sqid")
+
+ doc, err := h.store.Get(r.Context(), sqid)
+ if err != nil {
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ http.Error(w, rastrillo.T(r, "docs.error.notfound"), http.StatusNotFound)
+ return
+ }
+ h.logger.Error("docs: get document", "sqid", sqid, "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+
+ text, err := h.store.Text(r.Context(), doc)
+ if err != nil {
+ h.logger.Error("docs: document text", "sqid", sqid, "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ return
+ }
+
+ data := showPage{Title: doc.Title, Text: text}
+ if err := h.render.page(w, r, "show", data); err != nil {
+ h.logger.Error("docs: render show", "err", err)
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusInternalServerError)
+ }
+}
+
+// groupByRecency buckets docs — already newest-first, per Store.List
+// — into recency groups without re-sorting: consecutive documents
+// that share a bucket join the same group, and a new bucket starts a
+// new group the moment recencyBucket's key changes from the previous
+// document's. That is safe only because recencyBucket is monotonic in
+// time (a later document's bucket never sorts before an earlier
+// one's) AND docs is ordered by the exact same clock this function
+// buckets and displays by (UpdatedAt) — Store.List orders by
+// `updated_at DESC`, not CreatedAt, specifically so that holds. Fix
+// round 2's bug was ordering by one column while bucketing by
+// another: they agreed only because nothing yet moved UpdatedAt away
+// from CreatedAt, and the moment ApplyUpdate started bumping
+// UpdatedAt, an order-by-CreatedAt list would have produced duplicate
+// group labels ("Today … Yesterday … Today") the instant an older
+// document got a new update. See store_test.go's
+// TestListOrdersByUpdatedAt.
+func groupByRecency(r *http.Request, now time.Time, docs []*Document) []docGroup {
+ var groups []docGroup
+ var key string
+ for _, d := range docs {
+ k, labelKey := recencyBucket(now, d.UpdatedAt)
+ if len(groups) == 0 || k != key {
+ groups = append(groups, docGroup{Label: rastrillo.T(r, labelKey)})
+ key = k
+ }
+ g := &groups[len(groups)-1]
+ g.Rows = append(g.Rows, docRow{
+ Href: "/d/" + d.Sqid,
+ Title: d.Title,
+ When: formatWhen(now, d.UpdatedAt),
+ })
+ }
+ return groups
+}
+
+// recencyBucket returns a stable bucket key and the catalog key for
+// its label, comparing calendar days in UTC — Document.CreatedAt and
+// UpdatedAt are both stored in UTC (Store.Create), and there is no
+// viewer time zone to convert to yet, since there is no signed-in
+// viewer (Task 5). time.Truncate(24*time.Hour) lines up with the
+// calendar day specifically because both arguments are UTC: the day
+// never shifts under DST the way it can in a zone that observes it.
+func recencyBucket(now, t time.Time) (key, labelKey string) {
+ days := int(now.Truncate(24*time.Hour).Sub(t.Truncate(24*time.Hour)).Hours() / 24)
+ switch {
+ case days <= 0:
+ return "today", "docs.list.group.today"
+ case days == 1:
+ return "yesterday", "docs.list.group.yesterday"
+ case days <= 6:
+ return "week", "docs.list.group.week"
+ default:
+ return "earlier", "docs.list.group.earlier"
+ }
+}
+
+// formatWhen renders t for a row's trailing timestamp: a time of day
+// for today, otherwise a date, with the year appended only once t
+// falls in a year other than now's.
+//
+// This is English-only, hardcoded Go layout strings rather than
+// something Intl-backed, matching this app's single declared locale
+// (Locales, above). datetime.js's date fields get away with the
+// browser's own Intl for exactly this reason; there is no Go-side
+// equivalent wired up here, so a second locale needs this to move
+// rather than simply gaining a catalog entry.
+func formatWhen(now, t time.Time) string {
+ if now.Truncate(24 * time.Hour).Equal(t.Truncate(24 * time.Hour)) {
+ return t.Format("3:04 PM")
+ }
+ if now.Year() == t.Year() {
+ return t.Format("Jan 2")
+ }
+ return t.Format("Jan 2, 2006")
+}
diff --git a/internal/docs/list_test.go b/internal/docs/list_test.go
new file mode 100644
index 0000000..9236f21
--- /dev/null
+++ b/internal/docs/list_test.go
@@ -0,0 +1,149 @@
+package docs_test
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+// classVocabPattern finds every class="rst-*" attribute on a rendered
+// page. shellClassVocab allowlists the two rastrillo's own vendored
+// column shell hard-codes outside any block this app can override —
+// the skip link (class="rst-skip") and the <main> wrapper
+// (class="rst-page"), ui/layouts/column.html in rastrillo v0.21.0.
+// That is rastrillo's output, not this app's markup, and is not
+// something render_test.go's TestOurTemplatesUseTheAttributeGrammar
+// — the test that actually gates this app's own templates — ever
+// sees, since it walks only this app's own viewsFS. This rendered-page
+// check exists as a secondary, real-page confirmation that nothing
+// else on the page slipped in the class spelling; it is not the test
+// this app depends on to catch a class in a template it authors.
+var classVocabPattern = regexp.MustCompile(`class="rst-([a-zA-Z-]+)"`)
+
+var shellClassVocab = map[string]bool{
+ "skip": true,
+ "page": true,
+}
+
+func TestListShowsDocuments(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ ctx := context.Background()
+ if _, err := app.Store().Create(ctx, "Board minutes"); err != nil {
+ t.Fatal(err)
+ }
+
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+
+ body := rec.Body.String()
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ if !strings.Contains(body, "Board minutes") {
+ t.Error("the document's title is not on the page")
+ }
+ for _, m := range classVocabPattern.FindAllStringSubmatch(body, -1) {
+ if !shellClassVocab[m[1]] {
+ t.Errorf("class vocabulary found (class=%q) outside rastrillo's own shell — this app writes the attribute grammar (Global Constraints)", `rst-`+m[1])
+ }
+ }
+}
+
+func TestEmptyListSaysSo(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status %d", rec.Code)
+ }
+ // An empty state is a designed screen, not a blank page.
+ if len(strings.TrimSpace(rec.Body.String())) < 200 {
+ t.Error("empty list rendered almost nothing — there should be an empty state")
+ }
+}
+
+// TestCreateThenRead posts as a real browser would: NewForTest's app
+// carries no explicit Config.Origin, so it protects itself with
+// docs.DefaultOrigin (New's own fallback), and a browser submitting a
+// same-origin <form method="post"> to that origin sends exactly this
+// Origin header — csrf.Protect checks it and must let the request
+// through. Fix round 1: the original version of this test sent no
+// Origin/Referer/Sec-Fetch-Site at all, which is not what any browser
+// sends and would (correctly) be refused once CSRF protection was
+// mounted — the test was the wrong model of a browser, not the
+// protection.
+func TestCreateThenRead(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+
+ form := strings.NewReader("title=Minutes")
+ req := httptest.NewRequest(http.MethodPost, "/documents", form)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", docs.DefaultOrigin)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /documents: status %d, want 303", rec.Code)
+ }
+ loc := rec.Header().Get("Location")
+ if !strings.HasPrefix(loc, "/d/") {
+ t.Fatalf("Location = %q, want /d/<sqid>", loc)
+ }
+
+ req2 := httptest.NewRequest(http.MethodGet, loc, nil)
+ rec2 := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec2, req2)
+ if rec2.Code != http.StatusOK {
+ t.Fatalf("GET %s: status %d", loc, rec2.Code)
+ }
+ if !strings.Contains(rec2.Body.String(), "Minutes") {
+ t.Error("the document's title is not on its own page")
+ }
+}
+
+// TestCreateWrongOriginRejected proves csrf.Protect is actually wired
+// rather than silently a no-op: a cross-origin POST — an attacker's
+// page submitting to this app in a signed-in visitor's browser, the
+// exact shape CSRF protection exists to stop — must be refused, not
+// merely "not tested for success".
+func TestCreateWrongOriginRejected(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+
+ form := strings.NewReader("title=Minutes")
+ req := httptest.NewRequest(http.MethodPost, "/documents", form)
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", "https://evil.example")
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("POST /documents with wrong Origin: status %d, want 403", rec.Code)
+ }
+
+ // And confirm nothing was actually created — the refusal is real,
+ // not just a wrong status code on an otherwise-successful write.
+ rows, err := app.Store().List(context.Background())
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rows) != 0 {
+ t.Errorf("a cross-origin POST created %d document(s); want 0", len(rows))
+ }
+}
+
+func TestUnknownDocument404s(t *testing.T) {
+ app, _ := docs.NewForTest(t)
+ req := httptest.NewRequest(http.MethodGet, "/d/nosuchthing", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Errorf("status %d, want 404 — a document you cannot see does not exist", rec.Code)
+ }
+}
diff --git a/internal/docs/migrations/0001_documents.sql b/internal/docs/migrations/0001_documents.sql
new file mode 100644
index 0000000..936b66d
--- /dev/null
+++ b/internal/docs/migrations/0001_documents.sql
@@ -0,0 +1,18 @@
+CREATE TABLE IF NOT EXISTS documents (
+ id INTEGER PRIMARY KEY,
+ sqid TEXT NOT NULL UNIQUE,
+ title TEXT NOT NULL,
+ guid TEXT NOT NULL,
+ client_id INTEGER NOT NULL,
+ created_at DATETIME NOT NULL,
+ updated_at DATETIME NOT NULL,
+ deleted_at DATETIME
+);
+
+CREATE TABLE IF NOT EXISTS doc_updates (
+ id INTEGER PRIMARY KEY,
+ doc_id INTEGER NOT NULL,
+ seq INTEGER NOT NULL,
+ update_bin BLOB NOT NULL,
+ created_at DATETIME NOT NULL
+);
diff --git a/internal/docs/models.go b/internal/docs/models.go
new file mode 100644
index 0000000..d1447c9
--- /dev/null
+++ b/internal/docs/models.go
@@ -0,0 +1,68 @@
+package docs
+
+import (
+ "embed"
+ "time"
+
+ "github.com/carlosframework/rastrillo/migrate"
+)
+
+// migrationFS embeds this app's own numbered migrations — per
+// rastrillo SKILL.md's app shape ("Two *migrate.Set values live in
+// internal/<app>/migrations.go, beside models.go"; kept in this file
+// instead of a separate migrations.go because this task's file list
+// is models.go, store.go, store_test.go and a fourth file would only
+// split one small declaration across two places for no benefit).
+//
+//go:embed migrations/*.sql
+var migrationFS embed.FS
+
+// Schema is this app's own migrations — spec §4's documents and
+// doc_updates tables, only what this plan needs. It is idempotent
+// (CREATE TABLE IF NOT EXISTS today; later plans add further numbered
+// files, additive ALTERs, never edits to 0001) per rastrillo's
+// migration pattern.
+var Schema = migrate.MustFromFS(migrationFS, "docs")
+
+// Document is a document's row. Its text is deliberately not a
+// column: GUID and ClientID are what let Store open the real CRDT
+// core (internal/core) and replay doc_updates into it, which is where
+// the text actually comes from — see Store.Text.
+//
+// GUID and ClientID are assigned by the app (Store.Create) and stored
+// here; they are never drawn from yrs itself — on wasm the CRDT
+// library's entropy is a constant seed, so a value it generates is
+// identical in every process, and a repeated client id or GUID
+// corrupts the document. This is the Go side of the rule clippy.toml
+// enforces in Rust (disallowed-methods on yrs::Doc::new /
+// Doc::with_client_id).
+type Document struct {
+ ID int64 `gorm:"primaryKey"`
+ Sqid string `gorm:"column:sqid;uniqueIndex;not null"`
+ Title string `gorm:"column:title;not null"`
+
+ GUID string `gorm:"column:guid;not null"`
+ ClientID uint64 `gorm:"column:client_id;not null"`
+
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+ UpdatedAt time.Time `gorm:"column:updated_at;not null"`
+ DeletedAt *time.Time `gorm:"column:deleted_at"`
+}
+
+// DocUpdate is one Yjs update in a document's log, applied in Seq
+// order to rebuild the document's CRDT state (Store.replay).
+//
+// UpdateBin is plaintext in this table. Sealing it (spec §2 — per-row
+// AES-GCM, same as doc titles and everything else that is document
+// content) is out of scope for this plan; this comment exists so the
+// next reader does not mistake that for a decision that it stays
+// plaintext.
+type DocUpdate struct {
+ ID int64 `gorm:"primaryKey"`
+ DocID int64 `gorm:"column:doc_id;not null;index"`
+ Seq int64 `gorm:"column:seq;not null"`
+
+ UpdateBin []byte `gorm:"column:update_bin;not null"`
+
+ CreatedAt time.Time `gorm:"column:created_at;not null"`
+}
diff --git a/internal/docs/origin_test.go b/internal/docs/origin_test.go
new file mode 100644
index 0000000..570d2b2
--- /dev/null
+++ b/internal/docs/origin_test.go
@@ -0,0 +1,84 @@
+package docs
+
+import (
+ "strings"
+ "testing"
+)
+
+// TestValidateOriginRejectsWhitespace is fix round 1's own regression
+// test: DOCS_ORIGIN=" " (all whitespace) is non-empty, so a bare
+// cfg.Origin == "" check in New lets it through, and csrf.Protect then
+// compares every request's real Origin/Referer against literal
+// whitespace — never matching, so the app boots looking healthy and
+// 403s all traffic instead of refusing to start. validateOrigin must
+// catch it (New trims before calling this, but the function itself
+// stays correct against an untrimmed caller too).
+func TestValidateOriginRejectsWhitespace(t *testing.T) {
+ for _, origin := range []string{"", " ", "\t\n "} {
+ if err := validateOrigin(origin); err == nil {
+ t.Errorf("validateOrigin(%q) = nil, want an error", origin)
+ }
+ }
+}
+
+// TestValidateOriginRejectsMalformed covers the other ways a
+// non-blank string can still be unusable as csrf.Protect's
+// comparison origin: no scheme, the wrong scheme, no host, and a URL
+// carrying more than scheme://host (a path, a query, a fragment,
+// embedded userinfo) — none of which a real browser's Origin header
+// would ever equal.
+func TestValidateOriginRejectsMalformed(t *testing.T) {
+ cases := []string{
+ "not a url at all",
+ "localhost:8080", // no recognized scheme
+ "ftp://example.com", // wrong scheme
+ "http://", // no host
+ "http://:8099", // Host is ":8099", Hostname() is "" — see TestValidateOriginRejectsHostlessAddr
+ "http://example.com/path", // path
+ "http://example.com?x=1", // query
+ "http://example.com#frag", // fragment
+ "http://user:pass@example.com", // userinfo
+ }
+ for _, origin := range cases {
+ t.Run(origin, func(t *testing.T) {
+ if err := validateOrigin(origin); err == nil {
+ t.Errorf("validateOrigin(%q) = nil, want an error", origin)
+ }
+ })
+ }
+}
+
+// TestValidateOriginRejectsHostlessAddr is fix round 2's own
+// regression test: -addr :8099 — the plan's own example invocation —
+// makes main.go derive "http://" + ":8099" = "http://:8099". Its
+// url.Host is ":8099" (non-empty), so a `u.Host == ""` check let it
+// through; its url.Hostname() is "" (Host minus the port, and there
+// is nothing before the colon), which is the actual answer to "is
+// there a host here at all". A reviewer booted a production-Env
+// binary on exactly this origin and watched it serve before this fix.
+func TestValidateOriginRejectsHostlessAddr(t *testing.T) {
+ const origin = "http://:8099"
+ err := validateOrigin(origin)
+ if err == nil {
+ t.Fatalf("validateOrigin(%q) = nil, want an error — Host is non-empty but Hostname() is empty", origin)
+ }
+ if !strings.Contains(err.Error(), "no host") {
+ t.Errorf("error should say there is no host: %v", err)
+ }
+}
+
+// TestValidateOriginAcceptsGoodOrigins proves the check above is not
+// so strict it rejects the values this app actually uses:
+// DefaultOrigin, an -addr-derived origin, and a real platform origin
+// with a port.
+func TestValidateOriginAcceptsGoodOrigins(t *testing.T) {
+ for _, origin := range []string{
+ DefaultOrigin,
+ "http://localhost:8099",
+ "https://docs.bds.oncarlos.com",
+ } {
+ if err := validateOrigin(origin); err != nil {
+ t.Errorf("validateOrigin(%q) = %v, want nil", origin, err)
+ }
+ }
+}
diff --git a/internal/docs/render.go b/internal/docs/render.go
new file mode 100644
index 0000000..c353638
--- /dev/null
+++ b/internal/docs/render.go
@@ -0,0 +1,191 @@
+package docs
+
+import (
+ "bytes"
+ "embed"
+ "fmt"
+ "html/template"
+ "io/fs"
+ "net/http"
+ "testing/fstest"
+
+ "github.com/carlosframework/rastrillo"
+ "github.com/carlosframework/rastrillo/ui"
+)
+
+// dayTheme and shellLayout pick the design system's day theme and
+// column shell — rastrillo new's own defaults, and what the design
+// spec (docs/superpowers/specs/2026-08-30-docs-design.md, §10) asks
+// for.
+const (
+ dayTheme = "day"
+ shellLayout = "column"
+)
+
+// Catalog is CARLOS Docs' own English strings. Every user-visible
+// string this app writes is a key here (never a literal in a
+// template or a handler), resolved through rastrillo.T at render
+// time. cmd/docs/main.go wires it in as rastrillo.Options.BaseCatalog.
+var Catalog = rastrillo.Catalog{
+ // docs.list.* — GET /, spec §6.4. Title is the screen's noun, per
+ // page-header's own contract ("Posts", not "Home"). The four group
+ // labels are recency buckets (handlers.go's recencyBucket); a
+ // document's own title and its row's timestamp are data, not
+ // authored copy, so neither is a catalog key.
+ "docs.list.title": "Documents",
+ "docs.list.group.today": "Today",
+ "docs.list.group.yesterday": "Yesterday",
+ "docs.list.group.week": "This week",
+ "docs.list.group.earlier": "Earlier",
+
+ // docs.list.empty.* — the empty state (Paul's ruling: "An empty
+ // list is a designed screen, not a blank page").
+ "docs.list.empty.title": "No documents yet",
+ "docs.list.empty.body": "Documents you create will show up here, newest first.",
+
+ // docs.list.create.* — the create control in list.html's page
+ // header, present whether or not Groups is empty (fix round 1:
+ // the plan had scoped a create *control* out of every task, so the
+ // screen this plan exists for could not be driven from a browser
+ // at all). label is both the submit button's text and the title
+ // field's accessible name; placeholder is that field's hint text.
+ "docs.list.create.label": "New document",
+ "docs.list.create.placeholder": "Title",
+
+ // docs.error.generic is the one fallback error string this task
+ // has: a render failure with nowhere styled to land yet (SKILL.md
+ // §7's view.Fail/ErrorPage wiring is later work). Still a catalog
+ // key, never a literal, even though it is plain text today.
+ "docs.error.generic": "Something went wrong.",
+
+ // docs.error.notfound answers GET /d/{sqid} for an unknown sqid —
+ // spec §4.2's 404-not-403 contract. Same "nowhere styled to land
+ // yet" caveat as docs.error.generic above.
+ "docs.error.notfound": "Not found.",
+
+ // docs.document.untitled is POST /documents' fallback title when
+ // the submitted one is empty or all whitespace — boilerplate copy
+ // this app writes, not the caller's own input, so it is a catalog
+ // key rather than a hardcoded default.
+ "docs.document.untitled": "Untitled document",
+}
+
+// Locales and DefaultLocale declare this app's locale set for
+// cmd/docs/main.go's rastrillo.Options — one locale today. Without
+// Options.Locales set, rastrillo installs no locale middleware and
+// rastrillo.T falls back to returning its key verbatim (documented,
+// harmless — it's what a direct App.Handler() call in a test sees,
+// never having gone through that middleware); main.go wiring these in
+// is what makes Catalog's strings actually resolve on the real page.
+var Locales = []string{"en"}
+
+const DefaultLocale = "en"
+
+// viewsFS embeds this app's own page templates — SKILL.md's "one
+// *template.Template per page (layout + page)" pattern, the page half
+// as a real file per Task 3's file list rather than an inline Go
+// string (as Task 1's home page was): a page with a group loop and a
+// style block reads better as markup than as a quoted constant.
+//
+//go:embed views/*.html
+var viewsFS embed.FS
+
+// pageTemplates maps a page name to its file under views/.
+var pageTemplates = map[string]string{
+ "list": "views/list.html",
+ "show": "views/show.html",
+}
+
+// renderer owns the app's parsed templates and its fingerprinted
+// static-asset registry.
+type renderer struct {
+ pages map[string]*template.Template
+ assets *rastrillo.Assets
+}
+
+// newRenderer builds the renderer: the vendored design-system CSS/JS
+// as an in-memory static tree, rastrillo's shell partials and layout,
+// and this app's own pages, each cloned from the same base so every
+// page shares helpers and the shell without sharing a "content"
+// definition.
+func newRenderer() (*renderer, error) {
+ staticFS, err := vendoredStaticFS()
+ if err != nil {
+ return nil, err
+ }
+ assets := rastrillo.NewAssets(staticFS)
+
+ base := template.New("").Funcs(ui.Funcs()).Funcs(template.FuncMap{
+ "asset": assets.Path,
+ })
+ base, err = base.ParseFS(ui.Templates(), "*.html")
+ if err != nil {
+ return nil, fmt.Errorf("docs: parse rastrillo ui partials: %w", err)
+ }
+
+ layout, ok := ui.Layout(shellLayout)
+ if !ok {
+ return nil, fmt.Errorf("docs: rastrillo ui: shell %q not shipped", shellLayout)
+ }
+ base, err = base.Parse(string(layout))
+ if err != nil {
+ return nil, fmt.Errorf("docs: parse rastrillo shell %q: %w", shellLayout, err)
+ }
+
+ pages := make(map[string]*template.Template, len(pageTemplates))
+ for name, path := range pageTemplates {
+ page, err := base.Clone()
+ if err != nil {
+ return nil, fmt.Errorf("docs: clone base template for %q: %w", name, err)
+ }
+ if page, err = page.ParseFS(viewsFS, path); err != nil {
+ return nil, fmt.Errorf("docs: parse page %q: %w", name, err)
+ }
+ pages[name] = page
+ }
+
+ return &renderer{pages: pages, assets: assets}, nil
+}
+
+// page renders the named page's "layout" template — its own content
+// wrapped in rastrillo's shell — into w.
+func (rd *renderer) page(w http.ResponseWriter, r *http.Request, name string, data any) error {
+ tmpl, ok := rd.pages[name]
+ if !ok {
+ return fmt.Errorf("docs: unknown page %q", name)
+ }
+ // Render into a buffer first: a mid-template error must not leave
+ // a half-written 200 on the wire.
+ var buf bytes.Buffer
+ if err := tmpl.ExecuteTemplate(&buf, "layout", data); err != nil {
+ return fmt.Errorf("docs: render page %q: %w", name, err)
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, err := buf.WriteTo(w)
+ return err
+}
+
+// vendoredStaticFS builds the static/ tree rastrillo.Assets serves —
+// tokens.css and the day theme via ui.TokensCSS()/ui.ThemeCSS("day")
+// as the brief requires, plus the three scripts the shell's <head>
+// links unconditionally (ui.ShimJS/SelectJS/DatetimeJS) — as bytes
+// taken straight from the rastrillo module. Nothing here is copied to
+// static/ by hand: rastrillo new would normally vendor these to disk
+// once at scaffold time (ui.VendoredAssets's own doc comment), but
+// this app was hand-built rather than scaffolded, so it takes the
+// same bytes at process start instead, from an in-memory fs.FS.
+// testing/fstest.MapFS is the standard library's only in-memory
+// fs.FS — used here as a data structure, not as a test helper.
+func vendoredStaticFS() (fs.FS, error) {
+ theme, ok := ui.ThemeCSS(dayTheme)
+ if !ok {
+ return nil, fmt.Errorf("docs: rastrillo ui: theme %q not shipped", dayTheme)
+ }
+ return fstest.MapFS{
+ "static/tokens.css": {Data: ui.TokensCSS()},
+ "static/theme.css": {Data: theme},
+ "static/rastrillo.js": {Data: ui.ShimJS()},
+ "static/select.js": {Data: ui.SelectJS()},
+ "static/datetime.js": {Data: ui.DatetimeJS()},
+ }, nil
+}
diff --git a/internal/docs/render_test.go b/internal/docs/render_test.go
new file mode 100644
index 0000000..e6bcc77
--- /dev/null
+++ b/internal/docs/render_test.go
@@ -0,0 +1,56 @@
+package docs
+
+import (
+ "io/fs"
+ "strings"
+ "testing"
+)
+
+// TestOurTemplatesUseTheAttributeGrammar holds the markup constraint
+// at the source, not the rendered page: every HTML file this app
+// authors under views/ (embedded as viewsFS) must write the bare
+// rst-* attribute grammar (<div rst-box>), never class="rst-*".
+//
+// This is deliberately a package-internal test, not a docs_test one,
+// because it needs viewsFS itself — walking our own template sources
+// rather than a rendered response is the whole point: rastrillo's own
+// partials and shell emit class="rst-*", and that is their output,
+// not this app's markup, so it must never appear in this walk in the
+// first place, rather than being screened out after the fact.
+//
+// This replaces what used to be a rendered-page assertion
+// ("no class=\"rst-\" anywhere in the response body"), which could
+// not tell the two apart: rastrillo's column shell hard-codes
+// class="rst-skip" and class="rst-page" outside any block this app
+// can override, so that blanket check failed on every page this app
+// serves regardless of what its own templates wrote — see git history
+// on this file for the reverted fix (rewriting rastrillo's shell
+// bytes at parse time) and why it was wrong: a string substitution
+// over a dependency's markup fails silently the day rastrillo changes
+// those two spots, which it has already said it will before 1.0.
+// Checking our own sources instead needs no such rewrite, is correct
+// (rastrillo's vendored output is not ours to hold to our own
+// convention), and stays stable across a rastrillo upgrade that
+// changes its shell's own spelling, because it does not depend on
+// rastrillo's internal template text at all.
+func TestOurTemplatesUseTheAttributeGrammar(t *testing.T) {
+ err := fs.WalkDir(viewsFS, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if d.IsDir() {
+ return nil
+ }
+ b, err := fs.ReadFile(viewsFS, path)
+ if err != nil {
+ return err
+ }
+ if strings.Contains(string(b), `class="rst-`) {
+ t.Errorf("%s: class vocabulary found — this app writes the attribute grammar (Global Constraints)", path)
+ }
+ return nil
+ })
+ if err != nil {
+ t.Fatalf("walk viewsFS: %v", err)
+ }
+}
diff --git a/internal/docs/store.go b/internal/docs/store.go
new file mode 100644
index 0000000..8ba25e3
--- /dev/null
+++ b/internal/docs/store.go
@@ -0,0 +1,264 @@
+package docs
+
+import (
+ "context"
+ crand "crypto/rand"
+ "errors"
+ "fmt"
+ mrand "math/rand/v2"
+ "time"
+
+ "github.com/google/uuid"
+ "gorm.io/gorm"
+
+ "github.com/carlosframework/docs/internal/core"
+)
+
+// Store is documents' persistence: rows in SQLite for identity and
+// the update log, and the document's actual text read back through
+// the real CRDT core rather than any column here — see Text.
+type Store struct {
+ db *gorm.DB
+ core *core.Runtime
+}
+
+// NewStore builds a Store over an already-migrated *gorm.DB and a
+// shared *core.Runtime — one Runtime compiles the wasm module once
+// and is reused for every document (core.New's doc comment); each
+// call into it below opens its own per-document instance (a
+// *core.Handle) and closes it again.
+func NewStore(db *gorm.DB, rt *core.Runtime) *Store {
+ return &Store{db: db, core: rt}
+}
+
+// sqidAlphabet avoids visually ambiguous characters (0/O, 1/l/I) —
+// sqids appear in URLs a person may read aloud or retype.
+const sqidAlphabet = "23456789abcdefghjkmnpqrstuvwxyz"
+
+const sqidLength = 10
+
+// newSqid generates a random opaque document identifier, distinct
+// from the row's integer id, safe to expose in a URL. len(sqidAlphabet)
+// is 31 (8 digits, 23 letters — 0/O, 1/l/I excluded), not a power of
+// two, so taking each random byte mod 31 is not perfectly uniform:
+// 256 % 31 == 8, so the first eight symbols (the digits '2'-'9', the
+// low end of sqidAlphabet) come out of this loop about 3% more often
+// than the rest. Fix round 3 corrected this comment, which used to
+// assert a false "32, so no bias" — a wrong claim about a
+// security-adjacent property is worse than admitting the real one.
+// The bias itself is harmless at sqidLength characters: it narrows
+// the effective keyspace by a small, bounded fraction, nowhere near
+// enough to make ten random symbols from a 31-letter alphabet
+// guessable.
+func newSqid() (string, error) {
+ raw := make([]byte, sqidLength)
+ if _, err := crand.Read(raw); err != nil {
+ return "", fmt.Errorf("docs: generate sqid: %w", err)
+ }
+ out := make([]byte, sqidLength)
+ for i, b := range raw {
+ out[i] = sqidAlphabet[int(b)%len(sqidAlphabet)]
+ }
+ return string(out), nil
+}
+
+// maxSqidAttempts bounds Create's retry loop on a sqid collision. At
+// sqidLength characters over sqidAlphabet's 31 symbols, a collision is
+// astronomically unlikely; this exists only so a UNIQUE violation is
+// handled rather than surprising, not because collisions are
+// expected.
+const maxSqidAttempts = 5
+
+// Create makes a new document: a GUID and a client id assigned here
+// by the app (never drawn from yrs — see Document's doc comment) and
+// stored, an app-generated sqid, and the given title. It does not
+// touch the CRDT core at all: an empty document needs no update log
+// entry, and Text/ApplyUpdate are what open a core handle when there
+// is work for one to do.
+func (s *Store) Create(ctx context.Context, title string) (*Document, error) {
+ guid := uuid.NewString()
+ // math/rand/v2's Int64 returns a non-negative value in [0, 1<<63),
+ // so converting to uint64 never wraps — the brief's "a counter or
+ // random int64 from math/rand/v2".
+ clientID := uint64(mrand.Int64())
+ now := time.Now().UTC()
+
+ var lastErr error
+ for attempt := 0; attempt < maxSqidAttempts; attempt++ {
+ sqid, err := newSqid()
+ if err != nil {
+ return nil, err
+ }
+ doc := &Document{
+ Sqid: sqid,
+ Title: title,
+ GUID: guid,
+ ClientID: clientID,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ err = s.db.WithContext(ctx).Create(doc).Error
+ if err == nil {
+ return doc, nil
+ }
+ lastErr = err
+ if !errors.Is(err, gorm.ErrDuplicatedKey) {
+ break
+ }
+ }
+ return nil, fmt.Errorf("docs: create document: %w", lastErr)
+}
+
+// List returns every non-deleted document, newest first — "newest"
+// meaning most recently touched (UpdatedAt), the same clock
+// handlers.go's groupByRecency/formatWhen bucket and display, and the
+// one ApplyUpdate now bumps on every applied update. Ordering by
+// CreatedAt instead (fix round 2's own bug: they agreed only by
+// accident, because nothing bumped UpdatedAt away from CreatedAt) let
+// this list and its own recency buckets disagree the moment a
+// document's UpdatedAt ever moved without a matching reorder here —
+// see store_test.go's TestListOrdersByUpdatedAt.
+func (s *Store) List(ctx context.Context) ([]*Document, error) {
+ var docs []*Document
+ err := s.db.WithContext(ctx).
+ Where("deleted_at IS NULL").
+ Order("updated_at DESC, id DESC").
+ Find(&docs).Error
+ if err != nil {
+ return nil, fmt.Errorf("docs: list documents: %w", err)
+ }
+ return docs, nil
+}
+
+// Get looks up a document by its sqid.
+func (s *Store) Get(ctx context.Context, sqid string) (*Document, error) {
+ var doc Document
+ err := s.db.WithContext(ctx).
+ Where("sqid = ? AND deleted_at IS NULL", sqid).
+ First(&doc).Error
+ if err != nil {
+ return nil, fmt.Errorf("docs: get document %q: %w", sqid, err)
+ }
+ return &doc, nil
+}
+
+// replay opens a fresh core.Handle for doc (guid and client id come
+// from the stored row, never from yrs — see Document) and applies
+// every update in doc_updates, in seq order, rebuilding the
+// document's CRDT state from scratch. The caller owns the returned
+// Handle and must Close it.
+//
+// This is a deliberate O(n) full replay on every call, matching
+// internal/core's own recovery strategy (its package doc comment:
+// "Rebuilding is log replay, not snapshot restore" — ABI v1 exports
+// no state-encode function, so there is no snapshot to load instead).
+// That is fine at this plan's scale (a handful of updates per
+// document); a document with a long history needs compaction
+// (ErrUpdateLogFull's doc comment) or ABI v2 snapshot support,
+// neither of which exists yet — replaying long histories here would
+// be papering over that gap rather than living within it.
+func (s *Store) replay(ctx context.Context, doc *Document) (*core.Handle, error) {
+ h, err := s.core.NewDoc(ctx, doc.GUID, doc.ClientID)
+ if err != nil {
+ return nil, fmt.Errorf("docs: open core handle for document %d: %w", doc.ID, err)
+ }
+
+ var updates []DocUpdate
+ if err := s.db.WithContext(ctx).
+ Where("doc_id = ?", doc.ID).
+ Order("seq ASC").
+ Find(&updates).Error; err != nil {
+ h.Close(ctx)
+ return nil, fmt.Errorf("docs: load updates for document %d: %w", doc.ID, err)
+ }
+
+ for _, u := range updates {
+ if err := h.Apply(ctx, u.UpdateBin); err != nil {
+ h.Close(ctx)
+ // Surfaced, not swallowed: whether this is a clean
+ // rejection, core.ErrUpdateLogFull, or a *core.RecoveredError
+ // (the specific update's outcome unknown, never retried —
+ // see Handle.Apply's doc comment), the log this Store
+ // itself wrote can no longer be trusted to reproduce the
+ // document, and callers must see that rather than silently
+ // getting a wrong or truncated text back.
+ return nil, fmt.Errorf("docs: replay update seq %d for document %d: %w", u.Seq, doc.ID, err)
+ }
+ }
+ return h, nil
+}
+
+// Text returns doc's current text — read from the real CRDT core by
+// replaying its update log, never from a database column. Sealing
+// (spec §2 — per-row encryption of update_bin, same as document
+// titles) is out of scope for this plan; this plaintext replay is not
+// a decision that content stays unsealed.
+func (s *Store) Text(ctx context.Context, doc *Document) (string, error) {
+ h, err := s.replay(ctx, doc)
+ if err != nil {
+ return "", err
+ }
+ defer h.Close(ctx)
+
+ text, err := h.Text(ctx)
+ if err != nil {
+ return "", fmt.Errorf("docs: read text for document %d: %w", doc.ID, err)
+ }
+ return text, nil
+}
+
+// ApplyUpdate applies a new Yjs update to doc: it replays the
+// existing log to rebuild current state, applies update against that
+// live core instance, and only once the core has accepted it does it
+// persist the update as the next row in doc_updates. A rejection from
+// the core (a clean rejection, core.ErrUpdateLogFull, or a
+// *core.RecoveredError) is returned as-is and the update is never
+// persisted — see the errors.Is-friendly wrapping in replay's doc
+// comment and Handle.Apply's for why an uncertain outcome must not be
+// written down as a fact.
+func (s *Store) ApplyUpdate(ctx context.Context, doc *Document, update []byte) error {
+ h, err := s.replay(ctx, doc)
+ if err != nil {
+ return err
+ }
+ defer h.Close(ctx)
+
+ if err := h.Apply(ctx, update); err != nil {
+ return fmt.Errorf("docs: apply update to document %d: %w", doc.ID, err)
+ }
+
+ var maxSeq int64
+ if err := s.db.WithContext(ctx).
+ Model(&DocUpdate{}).
+ Where("doc_id = ?", doc.ID).
+ Select("COALESCE(MAX(seq), 0)").
+ Scan(&maxSeq).Error; err != nil {
+ return fmt.Errorf("docs: determine next seq for document %d: %w", doc.ID, err)
+ }
+
+ row := &DocUpdate{
+ DocID: doc.ID,
+ Seq: maxSeq + 1,
+ UpdateBin: update,
+ CreatedAt: time.Now().UTC(),
+ }
+ if err := s.db.WithContext(ctx).Create(row).Error; err != nil {
+ return fmt.Errorf("docs: persist update for document %d: %w", doc.ID, err)
+ }
+
+ // Bumping documents.updated_at here — not just appending the
+ // doc_updates row above — is what keeps Store.List's ordering and
+ // handlers.go's groupByRecency/formatWhen honest about the same
+ // clock. Skipping this was fix round 2's bug: with it skipped,
+ // UpdatedAt only ever equalled CreatedAt (Store.Create sets both
+ // to the same `now`), so List ordering by either column looked
+ // identical and the divergence stayed invisible until something
+ // — this method — actually moved UpdatedAt away from CreatedAt.
+ if err := s.db.WithContext(ctx).
+ Model(&Document{}).
+ Where("id = ?", doc.ID).
+ Update("updated_at", time.Now().UTC()).Error; err != nil {
+ return fmt.Errorf("docs: bump updated_at for document %d: %w", doc.ID, err)
+ }
+ return nil
+}
diff --git a/internal/docs/store_test.go b/internal/docs/store_test.go
new file mode 100644
index 0000000..88071cb
--- /dev/null
+++ b/internal/docs/store_test.go
@@ -0,0 +1,128 @@
+package docs_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+func TestCreateListGetRoundTrip(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ ctx := context.Background()
+
+ made, err := app.Store().Create(ctx, "First document")
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+ if made.Sqid == "" {
+ t.Fatal("Create returned an empty sqid")
+ }
+ if made.GUID == "" || made.ClientID == 0 {
+ t.Fatal("Create must assign a GUID and client id, never draw them from yrs")
+ }
+
+ list, err := app.Store().List(ctx)
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(list) != 1 || list[0].Title != "First document" {
+ t.Fatalf("List = %+v, want one document titled First document", list)
+ }
+
+ got, err := app.Store().Get(ctx, made.Sqid)
+ if err != nil {
+ t.Fatalf("Get: %v", err)
+ }
+ if got.ID != made.ID {
+ t.Errorf("Get returned id %d, want %d", got.ID, made.ID)
+ }
+}
+
+func TestTextComesFromTheCore(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ ctx := context.Background()
+
+ doc, err := app.Store().Create(ctx, "With content")
+ if err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+
+ // The "hello" fixture is a real Yjs update produced by browser yjs.
+ update := docs.ReadFixture(t, "hello")
+ if err := app.Store().ApplyUpdate(ctx, doc, update); err != nil {
+ t.Fatalf("ApplyUpdate: %v", err)
+ }
+
+ text, err := app.Store().Text(ctx, doc)
+ if err != nil {
+ t.Fatalf("Text: %v", err)
+ }
+ if text != "hello world" {
+ t.Errorf("Text = %q, want %q — the text must come from the CRDT, not a column", text, "hello world")
+ }
+}
+
+// TestListOrdersByUpdatedAt is fix round 2's own regression test: List
+// used to order by CreatedAt while handlers.go's groupByRecency/
+// formatWhen bucket and display UpdatedAt, and ApplyUpdate never
+// touched UpdatedAt at all — the two clocks only ever agreed because
+// Store.Create sets both columns to the same timestamp, so nothing
+// exercised the divergence until an update actually moved UpdatedAt
+// away from CreatedAt. This creates an older document, then a newer
+// one, confirms the newer one lists first (ordinary creation order),
+// applies an update to the *older* document, and asserts it jumps to
+// the top — the assertion that would have caught the bug: it fails
+// against an order-by-CreatedAt List even though ApplyUpdate bumps
+// UpdatedAt, and it fails against an ApplyUpdate that does not bump
+// UpdatedAt even though List orders by it. Both halves of the fix are
+// required to pass it.
+func TestListOrdersByUpdatedAt(t *testing.T) {
+ app, err := docs.NewForTest(t)
+ if err != nil {
+ t.Fatalf("NewForTest: %v", err)
+ }
+ ctx := context.Background()
+
+ older, err := app.Store().Create(ctx, "Older document")
+ if err != nil {
+ t.Fatalf("Create(older): %v", err)
+ }
+ // A real gap between the two rows' timestamps: two time.Now()
+ // calls back to back can land in the same tick on a coarse clock,
+ // and this test's whole point is ordering, not simultaneity.
+ time.Sleep(5 * time.Millisecond)
+ newer, err := app.Store().Create(ctx, "Newer document")
+ if err != nil {
+ t.Fatalf("Create(newer): %v", err)
+ }
+
+ list, err := app.Store().List(ctx)
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(list) != 2 || list[0].Sqid != newer.Sqid || list[1].Sqid != older.Sqid {
+ t.Fatalf("List before any update = %+v, want [newer, older] by plain creation order", list)
+ }
+
+ time.Sleep(5 * time.Millisecond)
+ update := docs.ReadFixture(t, "hello")
+ if err := app.Store().ApplyUpdate(ctx, older, update); err != nil {
+ t.Fatalf("ApplyUpdate(older): %v", err)
+ }
+
+ list, err = app.Store().List(ctx)
+ if err != nil {
+ t.Fatalf("List after update: %v", err)
+ }
+ if len(list) != 2 || list[0].Sqid != older.Sqid || list[1].Sqid != newer.Sqid {
+ t.Fatalf("List after applying an update to the older document = %+v, want [older, newer] — applying an update must move a document to the top of the list", list)
+ }
+}
diff --git a/internal/docs/testing.go b/internal/docs/testing.go
new file mode 100644
index 0000000..ad91d27
--- /dev/null
+++ b/internal/docs/testing.go
@@ -0,0 +1,49 @@
+package docs
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+// NewForTest builds an App against a temporary SQLite database. It is
+// the constructor every later task's tests use — deliberately not in a
+// _test.go file, so packages other than this one's own tests can reach
+// it too.
+//
+// Env is fixed at "development" with DevAuth enabled: a later task
+// (devauth.go) adds a development-only identity that refuses to start
+// outside development, and every test built on NewForTest — this
+// task's own and four later tasks' — assumes that identity is already
+// reachable rather than needing its own opt-in.
+func NewForTest(t *testing.T) (*App, error) {
+ t.Helper()
+
+ app, err := New(Config{
+ DBPath: filepath.Join(t.TempDir(), "docs.db"),
+ Env: "development",
+ DevAuth: true,
+ })
+ if err != nil {
+ return nil, err
+ }
+ t.Cleanup(func() {
+ if cerr := app.Close(); cerr != nil {
+ t.Errorf("docs.App.Close: %v", cerr)
+ }
+ })
+ return app, nil
+}
+
+// ReadFixture loads fixtures/data/<name>.update.bin — a real Yjs
+// update produced by browser yjs (fixtures/generate.mjs), never
+// hand-edited. Not in a _test.go file for the same reason as
+// NewForTest: other packages' tests may need it too.
+func ReadFixture(t *testing.T, name string) []byte {
+ t.Helper()
+ b, err := os.ReadFile(filepath.Join("..", "..", "fixtures", "data", name+".update.bin"))
+ if err != nil {
+ t.Fatalf("docs.ReadFixture(%q): %v", name, err)
+ }
+ return b
+}
diff --git a/internal/docs/views/list.html b/internal/docs/views/list.html
new file mode 100644
index 0000000..fd1167f
--- /dev/null
+++ b/internal/docs/views/list.html
@@ -0,0 +1,101 @@
+{{/* list — GET /, the document list (spec §6.4). handlers.go's list
+ builds the view data: Title and the group labels already resolved
+ through rastrillo.T, EmptyTitle/EmptyBody the same; a row's Title
+ and When are the document's own data and are printed as-is.
+
+ The header's <form> is the app's one create control (fix round
+ 1): a plain POST to /documents, no JavaScript, sitting beside the
+ titles the same way page-header's own single-link ActionHref
+ would — [rst-page-header]'s flex/space-between rule lays it out
+ as the second flex child without any markup of its own. It is
+ outside the {{if .Groups}} branch below on purpose: the header
+ renders unconditionally, so the control reaches a person with no
+ documents yet exactly the same way it reaches everyone else,
+ rather than only appearing once there is something to list.
+
+ Writes the bare rst-* attribute grammar throughout — the rst-
+ vocabulary always as a bare attribute, never as a class — which is
+ this app's whole-screen markup convention (Task 1's home page set
+ it). render_test.go's TestOurTemplatesUseTheAttributeGrammar walks
+ this file's own bytes to hold that; rastrillo's vendored shell and
+ partials keep their own class spelling, which is their output, not
+ this app's, and is out of that test's reach on purpose. The
+ [docs-*] selectors below are this app's own vocabulary, not
+ rastrillo's — SKILL.md §7's "ui is rastrillo's vocabulary, not
+ yours" is why they are not spelled rst-*, and they carry their
+ own tiny stylesheet here rather than a static/ file this
+ hand-built app does not otherwise have one of. */}}
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}<style>
+ /* [docs-thumb] is reserved space, not a placeholder image: a later
+ plan's layout engine draws a real rendering of the document's
+ page one here, at exactly this box, so nothing else on the row
+ reflows the day that lands. 26×34 is literal — the brief's own
+ figure, close to a US-letter page's aspect ratio at row scale. */
+ [docs-thumb] {
+ display: block;
+ flex: none;
+ inline-size: 26px;
+ block-size: 34px;
+ border: 1px solid var(--rst-line-strong);
+ border-radius: var(--rst-radius-sm);
+ background: var(--rst-surface);
+ }
+ [docs-group-label] {
+ margin: 0;
+ padding: var(--rst-sp-3) var(--rst-sp-3) var(--rst-sp-1);
+ font-size: var(--rst-fs-xs);
+ font-weight: 700;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--rst-text-muted);
+ }
+ [docs-row-when] {
+ flex: none;
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-sm);
+ }
+ /* [docs-create-form]: the title field and its submit button as one
+ inline row, matching page-header's own single-action slot — it is
+ the header's second flex child, so [rst-page-header]'s
+ space-between rule places it opposite the titles with no layout
+ rule of its own beyond this. */
+ [docs-create-form] {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--rst-sp-2);
+ }
+ [docs-create-form] [rst-input] {
+ inline-size: 14rem;
+ max-inline-size: 100%;
+ }
+</style>
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>{{.Title}}</h1>
+ </div>
+ <form docs-create-form method="post" action="/documents">
+ <input rst-input type="text" name="title" placeholder="{{.CreatePlaceholder}}" aria-label="{{.CreateLabel}}" autocomplete="off">
+ <button rst-btn="primary" type="submit">{{.CreateLabel}}</button>
+ </form>
+</header>
+{{if .Groups}}
+<div rst-list>
+ {{range .Groups}}
+ <h2 docs-group-label>{{.Label}}</h2>
+ {{range .Rows}}
+ <div rst-row>
+ <span docs-thumb aria-hidden="true"></span>
+ <span rst-row-main><a href="{{.Href}}">{{.Title}}</a></span>
+ <span docs-row-when>{{.When}}</span>
+ </div>
+ {{end}}
+ {{end}}
+</div>
+{{else}}
+<div rst-empty>
+ <h2 rst-empty-title>{{.EmptyTitle}}</h2>
+ <p rst-empty-body>{{.EmptyBody}}</p>
+</div>
+{{end}}
+{{end}}
diff --git a/internal/docs/views/show.html b/internal/docs/views/show.html
new file mode 100644
index 0000000..d7584e5
--- /dev/null
+++ b/internal/docs/views/show.html
@@ -0,0 +1,84 @@
+{{/* show — GET /d/{sqid}, the document read view (spec §6.1/§6.2).
+ handlers.go's show builds the view data: Title is the document's
+ own title, printed as-is the same way list.html's row titles are
+ (data, not authored copy — not resolved through rastrillo.T);
+ Text is Store.Text's plain-text replay of the CRDT through wazero,
+ never a database column, and is empty for a document with no
+ updates yet — an ordinary empty canvas below, not an error.
+
+ [docs-canvas]/[docs-page]/[docs-text] are this app's own
+ vocabulary (SKILL.md §7: "ui is rastrillo's vocabulary, not
+ yours"), not rastrillo's, so they are not spelled rst-*, same
+ reasoning as list.html's [docs-thumb].
+
+ The canvas opts out of the surrounding theme on purpose (spec
+ §10.2, the brief's canvas constraint): a document is a piece of
+ paper, and paper does not follow the reader's dark-mode
+ preference the way the chrome around it does. color-scheme:
+ light plus literal colours below — never var(--rst-surface) or
+ var(--rst-text), which would make the "page" repaint itself dark
+ along with everything else on screen.
+
+ [docs-page] is deliberately one page-shaped container with no
+ page-break logic inside it yet: the brief is explicit that
+ pagination arrives with a later plan's layout engine, and this
+ shape (one box, its own padding, ready to hold more than one
+ sibling) is what lets that land without restructuring this
+ markup — a later version of this template repeats [docs-page]
+ once per page rather than replacing it with something else.
+
+ Writes the bare rst-* attribute grammar throughout — see
+ list.html's own comment on why, and
+ render_test.go's TestOurTemplatesUseTheAttributeGrammar, which
+ holds it against this file too. */}}
+{{define "title"}}{{.Title}}{{end}}
+{{define "content"}}<style>
+ /* [docs-canvas] is the tray the page sits in — it is chrome around
+ paper, so it stays close to the surrounding theme's tone, but it
+ still opts out of the scheme (color-scheme: light) because its
+ own background below is a literal, not a token: a scheme without
+ a matching literal is what makes dark-mode form controls repaint
+ themselves onto a light strip. */
+ [docs-canvas] {
+ color-scheme: light;
+ display: flex;
+ justify-content: center;
+ padding: var(--rst-sp-6) var(--rst-sp-4);
+ background: #d8d8dc;
+ }
+ /* [docs-page]: white, centred, with margins — literal colours per
+ spec §10.2, not var(--rst-surface)/var(--rst-text): a document
+ stored as dark paper by its author must still read as dark paper
+ to a reader in light mode, and vice versa, so nothing here may
+ depend on which theme is currently active. Sized to a US Letter
+ page's proportions at 96dpi; a real page-geometry constant
+ arrives with the layout engine, not this task. */
+ [docs-page] {
+ box-sizing: border-box;
+ inline-size: min(100%, 8.5in);
+ min-block-size: 11in;
+ margin-block-end: var(--rst-sp-6);
+ padding: 1in;
+ background: #ffffff;
+ color: #1a1a1a;
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 12px rgba(0, 0, 0, 0.12);
+ }
+ [docs-text] {
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+ font-family: Georgia, "Times New Roman", serif;
+ font-size: 12pt;
+ line-height: 1.5;
+ }
+</style>
+<header rst-page-header>
+ <div rst-page-header-titles>
+ <h1>{{.Title}}</h1>
+ </div>
+</header>
+<div docs-canvas>
+ <div docs-page>
+ <div docs-text>{{.Text}}</div>
+ </div>
+</div>
+{{end}}
diff --git a/scripts/run-dev.sh b/scripts/run-dev.sh
new file mode 100755
index 0000000..b560598
--- /dev/null
+++ b/scripts/run-dev.sh
@@ -0,0 +1,59 @@
+#!/usr/bin/env bash
+# One command to see the app in a browser: build it with the
+# development-only identity (internal/docs/devauth.go) turned on,
+# against a scratch database that starts empty every run, on a fixed
+# local port so the URL below is always the same one to open.
+#
+# Never for anything but a laptop. -dev-auth only exists in a normal
+# build — cmd/docs/devauth_flag_release.go compiles it out entirely
+# with `-tags release`, which is what an actual deploy must build
+# instead; see that file for what a build tag closes and what it does
+# not.
+set -euo pipefail
+cd "$(dirname "$0")/.."
+
+port="${DOCS_DEV_PORT:-8099}"
+addr="localhost:${port}"
+
+# A scratch database per run, not docs.db in the repo root: this
+# script exists so anyone can see the app from nothing, not so it
+# accumulates state nobody wrote down. mktemp -d rather than a fixed
+# path under /tmp, so two concurrent runs (two people, or a person and
+# a CI job) never collide or clobber each other's database.
+#
+# The EXIT trap below only ever fires while this is still a running
+# bash script — up through a failed build. It is deliberately gone the
+# moment exec below replaces this process with the server (see that
+# line's own comment): fix round 1 found that running the server as a
+# plain foreground subprocess instead left the wrapper's PID and the
+# server's PID different, so killing `./scripts/run-dev.sh &` — the
+# exact pattern this script invites — fired this trap and deleted the
+# scratch directory while orphaning a server still holding the port,
+# undeletable and needing `kill -9` by hand. exec fixes the orphan at
+# the cost of this cleanup after the server itself is running; a
+# scratch directory under $TMPDIR left behind by a killed dev server is
+# a smaller problem than a leaked listener nothing can reach again.
+scratch="$(mktemp -d "${TMPDIR:-/tmp}/docs-dev.XXXXXX")"
+db="${scratch}/docs.db"
+trap 'rm -rf "$scratch"' EXIT
+
+export CGO_ENABLED=0
+go build -o "${scratch}/docs" ./cmd/docs
+
+# No $DOCS_ORIGIN needed here: -addr is a TCP address, so
+# cmd/docs/main.go derives the origin from it directly (the same host
+# a browser hitting this address sends back as its own Origin header).
+# That derivation only ever fires on this local/dev path — the real
+# platform activation is a unix socket with no -addr to derive
+# anything from, which is exactly why $DOCS_ORIGIN must be set
+# explicitly there (internal/docs/app.go's Config.Origin doc comment).
+echo "docs: dev database at ${db}"
+echo "docs: http://${addr}/"
+
+# exec, not a plain invocation: this script's own PID becomes the
+# server's PID, so a signal sent to it (Ctrl-C, or `kill` on the
+# backgrounded `./scripts/run-dev.sh &`) reaches the server directly —
+# rastrillo.Serve's own SIGTERM/SIGINT drain runs instead of orphaning
+# a listener nothing can reach or clean up afterward. See the comment
+# above the trap for what this costs.
+exec "${scratch}/docs" -dev-auth -addr "${addr}" -db "${db}"