steid

@jamesgill /

steid/plans/runbook.md
13.2 KBCode·Blame·Raw
ab7fea9chore: plans setup1mo
1# Runbook
2
3> Attempt #2's only setup instructions lived in a plan file describing an architecture
4> that had already been deleted, so they were actively wrong. Keep this file honest:
5> if a command here doesn't work, fix it or delete it.
6
7## Status
8
d7b99d9docs: record milestone 0 progress and routing findings1mo
9The app boots and serves. Everything marked **(#2)** is carried from the previous
10attempt and has **not** been re-verified against Topcoat — treat it as a sketch.
11
12## Requirements
13
14- **rustc ≥ 1.95** — Topcoat 0.5 requires it, and on an older toolchain `cargo add
15 topcoat` silently resolves to an empty `topcoat v0.0.0` placeholder rather than
16 failing. Verified on 1.97.1.
ca76e1bdocs: fix milestone cross-references after the reorder24d
17- `git` on `PATH` (from Milestone 3 — `git init --bare` creates repos, and from
02eb2e4feat: GitStorage port and DiskGitStorage24d
18 Milestone 4 `git http-backend` serves the protocol). **`cargo test` needs it too**:
19 `DiskGitStorage`'s tests run real `git init`, so a machine without `git` fails the
20 suite, not just the app.
ab7fea9chore: plans setup1mo
21
22## Dev setup
23
24```bash
d7b99d9docs: record milestone 0 progress and routing findings1mo
25cargo run # serves on http://127.0.0.1:3000
26cargo test
27```
28
29```bash
30cargo install topcoat-cli # dev server: watch, rebuild, asset bundling
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
31topcoat dev # working
ab7fea9chore: plans setup1mo
32```
33
d7b99d9docs: record milestone 0 progress and routing findings1mo
34`topcoat dev` builds, bundles assets, watches sources, and live-reloads pages that
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
35include `topcoat::dev::script()`. Press `r` to force a rebuild.
d7b99d9docs: record milestone 0 progress and routing findings1mo
36
37## Configuration
38
39`STEID_`-prefixed env vars via `dotenvy` + `envy`, read into `AppConfig`. Every value
40has a default, so a bare `cargo run` works with no environment at all.
41
42Live now:
43
44```
45STEID_DATABASE_URL=sqlite:steid.db?mode=rwc # default
02eb2e4feat: GitStorage port and DiskGitStorage24d
46STEID_DATA_DIR=./data # default; bare repos, read from M3
88583f2docs: bring tracking docs up to date with milestone 11mo
47STEID_INSECURE_COOKIES=false # default; see below
d7b99d9docs: record milestone 0 progress and routing findings1mo
48```
49
88583f2docs: bring tracking docs up to date with milestone 11mo
50There is deliberately **no owner password in configuration** — the owner is created
51through the claim flow instead. See
52[0002]decisions/0002-first-run-claim-not-config-bootstrap.md.
53
d7b99d9docs: record milestone 0 progress and routing findings1mo
54The bind address is **not** a `STEID_` variable — Topcoat owns it:
55
56```bash
57HOST=0.0.0.0 PORT=8080 cargo run
58```
ab7fea9chore: plans setup1mo
59
d7b99d9docs: record milestone 0 progress and routing findings1mo
60That supersedes attempt #2's `STEID_LISTEN_ADDR`.
ab7fea9chore: plans setup1mo
61
88583f2docs: bring tracking docs up to date with milestone 11mo
62### `STEID_INSECURE_COOKIES` — development only
ab7fea9chore: plans setup1mo
63
88583f2docs: bring tracking docs up to date with milestone 11mo
64Topcoat's session cookie is `__Host-` prefixed and `Secure`. `Secure` means the browser
65only keeps it over a trustworthy origin, and browsers disagree about whether
66plain-HTTP `localhost` qualifies. Where it doesn't, **the failure is completely
67silent**: the server issues a session and records the row, the browser discards the
68cookie, and every page renders signed out with no error anywhere. This cost an
69afternoon; the symptom looks exactly like broken auth logic.
70
71Setting `STEID_INSECURE_COOKIES=true` swaps in `InsecureCookieTokenStore` — the same
72cookie without `Secure` and without the prefix, named `steid-dev-session` so it can
73never be confused with a hardened one. `HttpOnly` and `SameSite=Lax` are kept. Boot
74prints a warning while it's on.
75
76**Never set this on a deployed instance.** Without `Secure` the session cookie travels
77unencrypted and anyone on the network path can lift it and become that user. Behind
78TLS, leave it unset.
79
80A gitignored `.env` in the repo root sets it for local work. Keep `.env` and
81`.env.prod` out of git — both are gitignored.
82
83## First run
84
85```bash
86cargo run # or: topcoat dev
ab7fea9chore: plans setup1mo
87```
88
1a58e00docs: runbook named routes that no longer exist24d
89An unclaimed instance prints a setup token and redirects every route to `/auth/setup`.
88583f2docs: bring tracking docs up to date with milestone 11mo
90Paste the token, choose a handle, email, and password, and the owner is created and
91signed in.
92
93The token is **held in memory only**, so every restart mints a new one — including
94each rebuild under `topcoat dev`. Use the most recent one printed. Once claimed, no
1a58e00docs: runbook named routes that no longer exist24d
95token is minted at all and `/auth/setup` redirects away.
88583f2docs: bring tracking docs up to date with milestone 11mo
96
1a58e00docs: runbook named routes that no longer exist24d
97Sign in at `/auth/login` with the **email**, not the handle.
ab7fea9chore: plans setup1mo
98
1a58e00docs: runbook named routes that no longer exist24d
99## Repo layout on disk (Milestone 3)
ab7fea9chore: plans setup1mo
100
02eb2e4feat: GitStorage port and DiskGitStorage24d
101Bare repos at `{STEID_DATA_DIR}/{handle}/{name}.git`, created with no template (so no
102`.sample` hooks) and `HEAD` pinned to `refs/heads/main` regardless of the host's
103`init.defaultBranch`. See [0006]decisions/0006-git-binary-behind-narrow-ports.md.
104
105Created empty — no initial commit and no branch, like GitHub.
106
107Creating one refuses rather than reusing a directory that already exists, so an orphan
108left by a create that died mid-way blocks that name until it is removed by hand.
ab7fea9chore: plans setup1mo
109
ca76e1bdocs: fix milestone cross-references after the reorder24d
110## Git transport (Milestone 4)
ab7fea9chore: plans setup1mo
111
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
112Smart HTTP, delegated to `git http-backend`, authenticated with personal access tokens
113over HTTP Basic — see [0001]decisions/0001-git-over-http-not-ssh.md. No SSH, no host
114keys, no `authorized_keys`.
ab7fea9chore: plans setup1mo
115
116```bash
1a58e00docs: runbook named routes that no longer exist24d
117git clone http://host/{handle}/repos/{name}.git
ab7fea9chore: plans setup1mo
118```
119
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
120Fill in the token workflow and the `body_limit` setting once this is built.
121
dce0bf3feat: browse a repository's files and history8d
122## Deployment (container)
123
124A `Dockerfile` at the repo root builds a self-contained image. Two stages: `rust:1.97-bookworm`
125compiles and bundles, `debian:bookworm-slim` runs. ~206 MB.
126
127```bash
128docker build -t steid .
129docker volume create steid-data
130docker run -d --name steid -p 3000:3000 -v steid-data:/data steid
131docker logs steid # the setup token is here, and only here
132```
133
134Verified end to end on 2026-08-29: the image builds, boots, applies migrations,
135creates `/data/steid.db`, serves `/auth/setup` with its stylesheet, and `git init
136--bare` succeeds inside `/data/repos` as the non-root user.
137
138### The build needs `topcoat asset bundle`, not just `cargo build`
139
140`cargo build --release` alone produces a binary that **fails to boot**. `main` calls
141`AssetBundle::load()`, which walks up from the executable looking for
142`assets/manifest.toml`; without one it returns `NotFound` and the process exits before
143serving anything. `build.rs` does not write that bundle — it only runs Tailwind and
144stages icons into `OUT_DIR`, where they are embedded in the binary.
145
146The bundle comes from the CLI, which the builder stage installs:
147
148```bash
149cargo install topcoat-cli --version 0.5.0 --locked
150topcoat asset bundle --release # runs `cargo build --release` itself, then bundles
151```
152
153It writes `target/assets/`, which must be copied **next to the binary** in the runtime
154image — `/app/steid` finds `/app/assets`. This is the same step `topcoat dev` performs
155for you, and the reason a hand-built binary serves stale CSS.
156
157Two build-time consequences worth knowing:
158
159- The build **needs network access**: `build.rs` downloads the standalone Tailwind CLI
160 from GitHub releases, and the bundler downloads any remote asset.
161- The image is **Debian, not Alpine, on both sides**. Those Tailwind binaries are
162 glibc-linked, so a musl builder fails during `cargo build`.
163
164The builder mounts the cargo registry and `target/` as BuildKit caches. There is
165deliberately **no dummy-`main.rs` dependency-caching trick**: `build.rs` scans the real
166sources for Tailwind classes, and a faked source tree yields a stale stylesheet — a
167wrong answer that still builds, which is the worst kind.
168
169### Configuration in a container
170
171The image sets these defaults, so the `docker run` above needs no `-e` flags at all:
172
173```
174STEID_DATABASE_URL=sqlite:/data/steid.db?mode=rwc
175STEID_DATA_DIR=/data/repos
176HOST=0.0.0.0 # Topcoat's, not STEID_-prefixed — see Configuration above
177PORT=3000
178```
179
180No public URL is configured anywhere: the origin is derived from the `Host` header and
181`X-Forwarded-Proto`, so a proxy that forwards both needs nothing further.
182
183**`STEID_INSECURE_COOKIES` is deliberately unset in the image and must stay unset.**
184The session cookie is `Secure`, which means the deployment needs TLS — assume a
185terminating proxy in front (Caddy, nginx, a platform router). Setting the variable to
186paper over a missing certificate hands every session cookie to anyone on the network
187path. `.dockerignore` excludes `.env` for the same reason: the dev `.env` sets it, and
188copying it in would silently unharden a deployed image.
189
190### State is one volume
191
192Everything that must survive a restart lives under `/data`: the SQLite database as a
193file directly in it, the bare repositories under `/data/repos`. `VOLUME ["/data"]` is
194declared, so a container started without `-v` still keeps its state — in an anonymous
195volume that is easy to lose track of. Name it.
196
197`/data` itself must be writable, not just the database file: SQLite creates `-wal` and
198`-shm` siblings next to it.
199
200The container runs as uid **10001** (`steid`). A named volume inherits that ownership
201from the image on first use. A **bind mount does not**`-v /srv/steid:/data` starts
202root-owned and the app fails to write, so `chown 10001:10001 /srv/steid` on the host
203first.
204
205### Claiming a deployed instance
206
207The setup token is printed to **stdout only, and only while the instance is
208unclaimed**. It is held in memory, so every restart — including every redeploy —
209mints a new one, and a claimed instance mints none at all.
210
211There is no way to recover it other than the platform's logs:
212
213```bash
214docker logs steid | tail -20
215```
216
217Read the token from the **most recent** boot, then claim at `https://your-host/auth/setup`.
218Until it is claimed every route redirects there, so an instance left unclaimed on a
219public address is an open door — claim it immediately after the first deploy.
220
ab7fea9chore: plans setup1mo
221## Manual verification checklist (#2)
222
223Attempt #2 verified these by hand each milestone but never wrote down the steps. They
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
224are the smoke test for Milestones 4–5. The auth rows assumed SSH keys; the shape of
225the check still holds with tokens substituted:
ab7fea9chore: plans setup1mo
226
64222c3docs: record the icon staging trap and tick the repo-creation check24d
227- [x] Create a repo via the web UI → bare repo appears at
228 `{data_dir}/{handle}/{name}.git` *(Milestone 3; also checked that the name
229 normalises, that a duplicate re-renders the form, and that a private repo 404s
230 for a signed-out visitor)*
ab7fea9chore: plans setup1mo
231- [ ] `git clone` an empty repo → succeeds
232- [ ] `git clone` a repo with history → succeeds
233- [ ] `git clone` a non-existent repo → clean error, not a hang or panic
234- [ ] First push to an empty repo → succeeds
235- [ ] Push to a repo with history → succeeds
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
236- [ ] Push a repo large enough to exercise the `body_limit` cap → succeeds
237- [ ] Clone with no credentials → rejected, and the prompt is comprehensible
238- [ ] Clone with a valid token → succeeds
239- [ ] Revoke the token → subsequent clone rejected at auth
ab7fea9chore: plans setup1mo
240- [ ] Clone a private repo as a non-member → rejected
241- [ ] Push as a non-owner member → rejected
242
243Worth automating as an integration test rather than re-running by hand a fourth time.
244
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
245## `.gitignore`
ab7fea9chore: plans setup1mo
246
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo
247Applied: `/target`, `/data`, `*.db*`, `.env`, `.env.prod`.
ab7fea9chore: plans setup1mo
248
249**Do not add `/plans`.** Attempt #2 did, and that is why these docs had to be
250hand-carried between repos.
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
251
252## Deploying this instance
253
254The operator's path, as opposed to `README.md`, which is written for a stranger
8ed4e5afeat: the root is the owner's profile1d
255installing their own. `jpgilldev.com` serves two roles from one box: this instance,
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
256and the place everyone else downloads Steid from.
257
258### Order matters
259
2601. **Provision** — Debian 12, x86_64. Open 22, 80 and 443. Port 80 is not optional:
261 Let's Encrypt validates over it.
8ed4e5afeat: the root is the owner's profile1d
2622. **DNS first, then install.** `dig +short jpgilldev.com` must return the box's IP
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
263 *before* `install.sh` runs. Caddy requests a certificate on startup; if DNS has not
264 propagated it fails and backs off, and the resulting error points nowhere useful.
2653. **First install uses `--tarball`.** There is a bootstrap: this instance is what will
266 serve the releases, so at that moment there is nowhere to download from.
267
268```sh
269scp dist/steid-0.1.0-x86_64-unknown-linux-gnu.tar.gz install.sh root@<ip>:/root/
8ed4e5afeat: the root is the owner's profile1d
270ssh root@<ip> './install.sh --domain jpgilldev.com \
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
271 --tarball ./steid-0.1.0-x86_64-unknown-linux-gnu.tar.gz'
272```
273
8ed4e5afeat: the root is the owner's profile1d
274Then `curl https://jpgilldev.com/healthz` — over https, with a real certificate.
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
275
276### Becoming the distribution host
277
278Only this instance does this. Uncomment the two `handle` blocks in
279[deploy/Caddyfile]../deploy/Caddyfile and rsync the artefacts into a **versioned**
280directory, matching the URL `install.sh` builds
281(`${RELEASE_BASE_URL}/v${VERSION}/…`):
282
283```sh
284rsync dist/*.tar.gz dist/*.sha256 root@<ip>:/var/lib/steid/dist/v0.1.0/
285rsync install.sh root@<ip>:/var/lib/steid/dist/
286```
287
288**Those Caddy paths shadow Steid.** Nothing lives at
289`/{handle}/repos/{name}/releases` today, so nothing breaks — but when Steid grows a real
290release feature at that URL, Caddy will keep winning and the feature will look broken.
291Delete the blocks then. The URL is deliberately the one that feature will use, so links
292published now survive it.
293
294`/install.sh` at the root is safe permanently rather than by luck: `OrgName` allows only
295`[a-z0-9-]`, so no handle can contain a dot and none can ever collide with it. A
296root-level `/releases` would **not** be safe — it is a valid handle shape.
297
298### What is verified, and what is not
299
300Verified in a Debian 12 container with `systemctl` stubbed: prerequisites install, the
301tarball extracts, `/opt/steid` and `/var/lib/steid` get the right owners and modes, the
302`steid` user is created with `nologin`, the env file and unit and Caddyfile are written,
303and **the binary starts as the `steid` user**. The artifact itself was booted on Debian
30411 (glibc 2.31) and served pages.
305
306**Not verified anywhere but a real box:** the systemd unit lifecycle, and Caddy's ACME
307certificate issuance. Expect the first real run to need a fix or two.
308
309### One bug already found this way
310
311`install.sh` defaulted to a `musl` target while `release.sh` had moved to `gnu`. The
312symptom was `checksum mismatch` — because the installer was looking for a tarball that
313was never built. The two defaults must agree; both now say so in a comment.