# simplepages A static-site deployment target for CI. Your build job pushes a directory; the site switches over to it in one step. The problem it exists to solve is the one `rsync` has: rsync writes the new build into the live directory file by file, and for the length of that transfer visitors get a **half-updated site** — new HTML asking for JS that is not there yet, hashed asset names that do not resolve, images 404ing. simplepages closes that window. A deployment is uploaded and verified in full first, and only then does one atomic pointer store make it live. **The guarantee:** every request sees the whole old deployment or the whole new one, never a mixture. That includes requests already in flight — a download that started before the switch keeps reading the deployment it started on, all the way to its last byte, even if that deployment is deleted meanwhile. Rolling back is the same operation in reverse, so it costs one pointer store too: the old deployment's files are still on disk. ## How it fits together ``` CI runner pages-server visitor ───────── ──────────── ─────── pages deploy ./dist ──manifest──▶ "I am missing 11 of these 142" ──blobs─────▶ content-addressed store ──finalize──▶ assemble tree, fsync, rename ──activate──▶ ┌──────────────────────────┐ │ atomic.Pointer[Deployment]│◀── GET /~demo/ └──────────────────────────┘ $WEBROOT/~demo -> …/dpl_a1b2… ``` Uploads are **content-addressed and incremental**: the CLI sends a manifest of paths, SHA-256 digests and sizes, and the server answers with the digests it does not already have. A rebuild that changes one file uploads one file, no matter how big the site is, and identical files are stored once across every deployment and every project. `$WEBROOT/~PROJECT` is kept pointing at the live deployment's directory so nginx or any other external consumer can serve it directly, but the server serves the site itself and does not depend on that symlink being correct. ## Quick start ```sh make build # -> bin/pages-server and bin/pages export PAGES_DATA=$(mktemp -d) PAGES_WEBROOT=$(mktemp -d) bin/pages-server --data-dir "$PAGES_DATA" --webroot "$PAGES_WEBROOT" & ``` On first start the server mints an admin key and writes the token to `$DATA_DIR/bootstrap-token`, mode 0600. That is the only place it ever writes a token to disk; delete the file once you have taken the token. ```sh export PAGES_SERVER=http://127.0.0.1:8081 export PAGES_TOKEN=$(cat "$PAGES_DATA/bootstrap-token") bin/pages project create demo export PAGES_TOKEN=$(bin/pages key create --project demo -o json | jq -r .token) mkdir -p dist/assets echo '

v1

' > dist/index.html echo 'console.log(1)' > dist/assets/app.js bin/pages deploy ./dist --project demo curl -s localhost:8080/~demo/ #

v1

… ``` Change one file and deploy again — the CLI reports how much the manifest negotiation saved: ``` 142 files, 3.1 MiB; 1 new blob, 402 KiB to upload ``` Give CI a **project-scoped** key, never the admin one. A project key can create, upload, finalize, activate and delete deployments in its own project and read that project's settings, and nothing else anywhere. ## Two binaries | | | |---|---| | `pages-server` | The server: management API on `--api-listen` (default `127.0.0.1:8081`), static content on `--listen` (default `:8080`). Storage is SQLite plus a content-addressed blob store under `--data-dir`. | | `pages` | The CLI. Pure HTTP client — no SQLite, no cgo, no server packages linked in. It is downloaded by CI runners, so keeping it that way is a feature, and `cmd/pages/deps_test.go` fails the build if a server dependency creeps in. | ## CLI ``` pages deploy upload a directory and switch to it pages project create|list|show|update|delete pages deployment list|show|activate|delete # activate an older one = rollback pages key create|list|revoke pages whoami which key am I actually using pages system info|gc|fsck admin only pages config show|set|path pages version ``` Settings resolve flag → `PAGES_*` environment variable → config file → built-in default. In CI, pass the token as `PAGES_TOKEN` or `--token-file`; `--token` puts the secret in argv, which on a shared runner is world-readable through `/proc//cmdline`. Every `pages deployment` command works with a project key, so a CI job can roll its own project back without an admin credential. ## Building and testing ```sh make build # both binaries, -trimpath -ldflags '-s -w' make test # go test ./... make race # go test -race ./... make atomicity # the switch-is-atomic proof, -race -count=20 make vet fmt tidy ``` `make atomicity` is the test that guards the one thing this project sells: 64 readers hammer three files plus a 512 KiB body while a writer activates 50 deployments back to back, and any reader that observes two versions at once fails the run. ## Security Read [`docs/operations.md`](docs/operations.md) §7 before hosting anything you care about. The headlines: - **Path routing is not a security boundary.** `/~a/` and `/~b/` share one origin, so project A's JavaScript can read project B's files, cookies and `localStorage`, and can register a service worker that intercepts *every* project on the host. This is fine for mutually trusting projects, which is what v1 targets. Hosting untrusted sites needs one domain per project. - Client-supplied hashes are never trusted: the server rehashes every uploaded byte and rejects a mismatch, which is what makes cross-project dedup safe. - Tokens appear once, at creation. They are never logged, never accepted in a query string, and never included in an error message. - Blobs and assembled files are `0444`, directories `0755`; upload permissions are ignored entirely, so a setuid bit cannot reach disk. - A project key can tell whether a digest it already knows exists on the server (a blob existence oracle). Known, accepted, and documented in §7. ## Documentation [`docs/operations.md`](docs/operations.md) — installation, configuration, deploying, garbage collection and retention, the security model and its limits, scaling limits, troubleshooting and backups. [`AGENTS.md`](AGENTS.md) — repository map, the invariants that hold the guarantee up, and what to run before calling a change done.