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
26 files changed,
+3022
−4
.amadan/README.md+15 −0.amadan/ci.d/10-gate+57 −0AGENTS.md+56 −0cmd/docs/devauth_flag.go+32 −0cmd/docs/devauth_flag_release.go+55 −0cmd/docs/main.go+103 −0docs/superpowers/plans/2026-08-31-app-first-screens.md+556 −0go.mod+22 −2go.sum+89 −2internal/docs/app.go+311 −0internal/docs/app_test.go+73 −0internal/docs/devauth.go+81 −0internal/docs/devauth_test.go+80 −0internal/docs/handlers.go+241 −0internal/docs/list_test.go+149 −0internal/docs/migrations/0001_documents.sql+18 −0internal/docs/models.go+68 −0internal/docs/origin_test.go+84 −0internal/docs/render.go+191 −0internal/docs/render_test.go+56 −0internal/docs/store.go+264 −0internal/docs/store_test.go+128 −0internal/docs/testing.go+49 −0internal/docs/views/list.html+101 −0internal/docs/views/show.html+84 −0scripts/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}" |