steid

@jamesgill /

steid/plans/runbook.md
10.3 KBCode·Blame·Raw
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
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.
17- `git` on `PATH` (from Milestone 3 — `git init --bare` creates repos, and from
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.
21
22## Dev setup
23
24```bash
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
31topcoat dev # working
32```
33
34`topcoat dev` builds, bundles assets, watches sources, and live-reloads pages that
35include `topcoat::dev::script()`. Press `r` to force a rebuild.
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
46STEID_DATA_DIR=./data # default; bare repos, read from M3
47STEID_INSECURE_COOKIES=false # default; see below
48```
49
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
54The bind address is **not** a `STEID_` variable — Topcoat owns it:
55
56```bash
57HOST=0.0.0.0 PORT=8080 cargo run
58```
59
60That supersedes attempt #2's `STEID_LISTEN_ADDR`.
61
62### `STEID_INSECURE_COOKIES` — development only
63
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
87```
88
89An unclaimed instance prints a setup token and redirects every route to `/auth/setup`.
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
95token is minted at all and `/auth/setup` redirects away.
96
97Sign in at `/auth/login` with the **email**, not the handle.
98
99## Repo layout on disk (Milestone 3)
100
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.
109
110## Git transport (Milestone 4)
111
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`.
115
116```bash
117git clone http://host/{handle}/repos/{name}.git
118```
119
120Fill in the token workflow and the `body_limit` setting once this is built.
121
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
221## Manual verification checklist (#2)
222
223Attempt #2 verified these by hand each milestone but never wrote down the steps. They
224are the smoke test for Milestones 4–5. The auth rows assumed SSH keys; the shape of
225the check still holds with tokens substituted:
226
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)*
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
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
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
245## `.gitignore`
246
247Applied: `/target`, `/data`, `*.db*`, `.env`, `.env.prod`.
248
249**Do not add `/plans`.** Attempt #2 did, and that is why these docs had to be
250hand-carried between repos.