Files
pages/AGENTS.md
T
2026-08-15 07:13:00 +00:00

9.0 KiB
Raw Blame History

Working on simplepages

Read README.md first for what this is, and docs/operations.md for how it is run. This file is for whoever — human or agent — is changing the code.

Everything here exists to protect one property: a request sees the whole old deployment or the whole new one, never a mixture. If a change makes that property harder to reason about, it is the wrong change however much code it saves.

Before you call a change done

make fmt vet test
make race
make atomicity                            # -race -count=20, takes ~80s
go test ./cmd/pages -run TestCLIImportGraph

Run the last two whenever you touch internal/site, internal/deploy, or anything the CLI imports. make race is not optional for changes in those packages — the interesting failures here are all interleavings.

Layout

Path
api/ Wire types, error codes, path builders. Stdlib only — it is shared with the CLI, and that is the whole reason the CLI stays dependency-free.
cmd/pages-server/ Assembly: config, storage, recovery, listeners, background workers, signals. Also holds the end-to-end tests.
cmd/pages/ CLI entry point plus deps_test.go, which enforces the import graph.
internal/store/ SQLite: dual pool, migrations, queries, fsck.
internal/cas/ Content-addressed blob store.
internal/site/ ★ The read path: registry, immutable deployment snapshots, URL resolution, the HTTP handler.
internal/deploy/ Deployment lifecycle, assembly, startup recovery, reconciler, GC.
internal/webroot/ $WEBROOT/~name symlink maintenance.
internal/auth/ Token mint/parse/verify, middleware, failed-auth rate limiting.
internal/adminapi/ Management API routes and handlers.
internal/config/, internal/httpx/, internal/cache/, internal/pathutil/, internal/version/ Support.
internal/client/, internal/clicmd/, internal/cliutil/ CLI: HTTP client and deploy flow, commands, command tree and rendering.

Invariants

Each of these is load-bearing. Changing one is a design decision, not a refactor.

1. Load p.Active() exactly once per request. internal/site/serve.go takes the snapshot at the top and every later access uses that value. A second Active() call in the same request is the bug this whole design prevents: the two loads could straddle an activation and serve a mixed page. Deployment is immutable after construction, so holding the pointer is free and correct.

2. Activation order is fixed. Project lock → require ready → build the snapshot → commit to SQLiteatomic.Pointer.Store → best-effort webroot.Point. Database before memory, so a crash between the two restarts into the state the database already recorded. The reverse order leaves a process serving A while the database says B. Building the snapshot before the commit means a failure there changes nothing at all.

3. Never trust a client-supplied hash. cas.Store.Put rehashes the received stream and compares against the claimed digest before linking the blob into place; a mismatch is a 400 and the temp file is discarded. Without this a client could claim another project's digest, upload arbitrary bytes, and poison that blob for every project referencing it. This check is what makes cross-project dedup safe — do not move it, skip it for "already known" digests, or trust a digest because the transport was TLS.

4. Tokens never appear in logs, query strings, or error messages. There is a test that runs a request through the middleware into a buffer and greps for the token; keep it passing. The CLI prefers PAGES_TOKEN and --token-file over --token because argv is world-readable via /proc/<pid>/cmdline on a shared runner, and the help text says so. The bootstrap token file and the CLI config file are 0600.

5. All writes go through db.Tx. store.DB exposes Reader() for queries and Tx(ctx, fn) for everything else. There is deliberately no Writer() — the single write connection plus BEGIN IMMEDIATE plus busy retries is the only thing keeping SQLITE_BUSY off the hot path. Tests write through db.Tx too.

6. Upload paths pass both fs.ValidPath and filepath.Localize, and are additionally rejected for NUL or control bytes, a segment over 255 bytes, a total over 4096, duplicates, and case-insensitive collisions (the assembled tree may land on a case-insensitive filesystem, where a collision silently overwrites). One check is not enough: fs.ValidPath allows backslashes and Windows reserved names, Localize allows things ValidPath rejects.

7. The read path serves from the CAS by digest. The only filesystem path constructed while serving is cas/ab/cd/<64 hex>, derived from a [32]byte that came out of a map lookup. No user-controlled string reaches the filesystem, so read-path traversal is not defended against — it is structurally impossible. Serving from the assembled directory instead would give that back.

8. No archive extraction, ever. Files arrive one at a time; the server never unpacks or decompresses anything. That is what makes zip bombs and tar-slip inapplicable rather than mitigated. "Just accept a tarball, it's fewer round trips" reopens both — it needs a fresh security review, not a patch.

9. RequireProject compares resolved project IDs, never the name string from the URL. Names can be reused after a delete; IDs cannot. Same rule for deployments: confirm {id} really belongs to {name} before changing anything.

10. webroot.Reconcile only removes entries that are symlinks pointing inside $DATA_DIR/deployments. $WEBROOT belongs to the operator and will contain files that are none of our business.

11. Assembled files and blobs are 0444, directories 0755, and client-supplied modes are ignored. There is no mode column in the schema on purpose, so a setuid bit has nowhere to be stored even if someone tries to send one. On a hardlinking filesystem the assembled file is the blob's inode — writing to a file under $WEBROOT corrupts it for every project that references it.

12. The CLI's dependency graph is enforced, not documented. cmd/pages/deps_test.go fails if go list -deps ./cmd/pages reaches modernc.org/sqlite, modernc.org/libc, database/sql, github.com/BurntSushi/toml, net/http/httptest, or internal/{store,site,deploy,cas,auth,adminapi}. database/sql is a probe: it can only appear if server code leaked in. If you need a type in both the CLI and the server, it belongs in api/ (stdlib only) or internal/pathutil (stdlib only).

Conventions

Comments explain why, not what. The code already says what it does. A comment earns its place by recording the reasoning that is not recoverable from the code — why this order, why not the obvious alternative, what breaks if someone changes it. Match the density of the surrounding file; several of the sharper decisions are commented in place precisely so a later reader does not "optimize" them away (internal/cache's note about not caching file handles is the canonical example).

Do not over-design. No metrics nobody reads, no abstraction with one implementation, no configuration knob without a caller who needs it. If a simpler thing works, ship the simpler thing.

Dependencies. Three direct ones, on purpose: modernc.org/sqlite (pure Go, so CGO_ENABLED=0 cross-compiles), github.com/BurntSushi/toml (server config only), golang.org/x/sync/errgroup (bounded concurrency, both sides). No web framework, router, ORM, migration tool, CLI framework, logging library (log/slog), or UUID library (crypto/rand + hex). Adding a fourth needs a reason that survives being written down.

Tests are table-driven with names that read as sentences (TestAnInFlightRequestSurvivesTheContentBeingCollected). Two gotchas worth knowing before you debug an unexpected pass: retention tests must patch retention_grace_s = 0, because a deployment that was never activated is aged from created_at and is otherwise protected for the full grace period; and blob collection tests need a negative Service.BlobGrace, since zero means "use the one-hour default", not "collect now".

CLI rendering: cliutil.Bool prints yes/no, and cliutil.Truncate(s, n) returns n-1 characters plus an ellipsis. Both have caught tests out.

Scope

v1 (M0M5) is complete: deploy, atomic switch, rollback, crash recovery, retention and GC, and the atomicity proof tests.

Deferred, deliberately — do not build these speculatively: precompressed br/gzip variants (the deployment_files.encoding column is already there for it), per-project header rules, host routing (one domain per project, which is also the fix for the same-origin limit), autoindex, a metrics endpoint, a resident-manifest cap, and packaging.

Known limit: one server process per database. The in-memory registry is updated by the process that wrote the change, so a second process on the same SQLite file serves stale content until it restarts. Horizontal scaling needs a change-notification mechanism first.