| 1 | # Current |
| 2 | |
| 3 | > Keep this file short. One active step, one ordered backlog. Completed work moves to |
| 4 | > [progress.md](progress.md). If this file starts reading like a changelog, it has |
| 5 | > drifted — that's exactly what went wrong last time. |
| 6 | |
| 7 | ## Active: Milestone 5b — Deployable by anyone |
| 8 | |
| 9 | **Goal:** a stranger can install Steid on a fresh Linux box in one command and reach it |
| 10 | over HTTPS, and the author can publish an instance and push Steid's own source to it. |
| 11 | Distributed as **a binary plus its assets**, not a container. |
| 12 | |
| 13 | **Out of scope:** push-to-release and any CI/CD (8+ — see |
| 14 | [ROADMAP.md](ROADMAP.md#milestone-ladder)), multi-user registration (7), and hosting |
| 15 | release artefacts on Steid itself, which it has no feature for. |
| 16 | |
| 17 | ### Steps |
| 18 | |
| 19 | - [x] Release build producing `steid-<version>-<target>.tar.gz` — binary, `assets/`, |
| 20 | README — and verified to boot from a clean extraction |
| 21 | - [x] `install.sh` — download, systemd unit, Caddy with automatic HTTPS, idempotent |
| 22 | - [x] `README.md` — the repo has none, and it is the front door of a portfolio project |
| 23 | - [x] Operability: `/healthz`, graceful shutdown on SIGTERM |
| 24 | - [x] `STEID_SETUP_TOKEN` as an optional override, so claiming is not a race with |
| 25 | `journalctl` |
| 26 | - [x] Rate limiting on `/auth/login` and `/auth/setup` |
| 27 | - [x] Backup and restore, documented — two paths, rsync is enough |
| 28 | |
| 29 | ### Done when |
| 30 | |
| 31 | `curl … | sh -s -- --domain git.example.com` on a fresh VPS yields a working HTTPS |
| 32 | instance, and Steid's own source is pushed to it and browsable there. |
| 33 | |
| 34 | ### Settled |
| 35 | |
| 36 | - **A binary plus `assets/`, not a container.** Topcoat can embed the asset *manifest* |
| 37 | (`Manifest::parse` + `include_str!`, for WASM) but not the asset *bytes* — that path |
| 38 | pairs with `AssetConfig::hosted_at`, which expects a CDN. A single self-contained file |
| 39 | would mean fighting the framework, so the artifact is a directory. The Dockerfile stays |
| 40 | as a secondary path. |
| 41 | - **A TLS proxy is mandatory, not conventional.** Topcoat 0.5 has no TLS: no rustls, no |
| 42 | ACME, no HTTPS listener — checked, not assumed. Since git authenticates over HTTP |
| 43 | Basic, without TLS a personal access token crosses the network in cleartext on every |
| 44 | push. Caddy is chosen because automatic certificates are its headline feature and the |
| 45 | config is two lines. |
| 46 | - **glibc, built on Debian bullseye — not static musl.** Two reasons, the second |
| 47 | decisive. musl failed on `ring` (Debian's `musl-gcc` wrapper rejects `-m64`) and would |
| 48 | need a real cross toolchain. But **musl's whole point is a binary with no runtime |
| 49 | dependencies, and Steid hard-requires `git` on `PATH`** — anyone installing it already |
| 50 | has a package manager, so the portability musl buys cannot be used. Building on |
| 51 | bullseye pins the glibc floor at 2.31, covering Debian 11+ and Ubuntu 20.04+; building |
| 52 | on bookworm would need 2.36 and silently exclude Ubuntu 22.04, which is still |
| 53 | everywhere. Verified: a release build in `rust:1.97-slim-bullseye` succeeds, 11.5 MB. |
| 54 | - **The runtime binary links a TLS stack it never uses.** `ring` ← `rustls` ← `ureq` ← |
| 55 | Topcoat's `icon-iconify`/`tailwind` features, whose `ureq` exists to download the |
| 56 | Tailwind CLI *at build time*. Those features are enabled on the normal dependency as |
| 57 | well as the build one, so the crypto comes along for the ride. Moving them to |
| 58 | build-dependencies only would shrink the binary and drop an unused dependency from the |
| 59 | attack surface — worth trying, not yet attempted, and it is what made the musl attempt |
| 60 | fail where it did. |
| 61 | - **Rsync is the deployment mechanism for now**, and that is enough while the artifact is |
| 62 | two paths. Push-to-release is the eventual answer and is parked at 8+. |
| 63 | |
| 64 | ### Open |
| 65 | |
| 66 | - **Whether to add `STEID_TRUSTED_PROXY`.** The rate limiter keys on `X-Forwarded-For` |
| 67 | because **Topcoat 0.5 discards the peer address at accept time and never exposes it** — |
| 68 | a handler sees headers and nothing else. Behind a proxy that is fine. Exposed directly, |
| 69 | a caller can vary the header for a fresh budget, leaving only the global cap. An |
| 70 | explicit "trust forwarded headers" flag defaulting to off would close it, at the cost |
| 71 | of one more thing an operator must get right. Not picked. |
| 72 | - **`MAX_RAW_BYTES` is 10 MiB**, chosen rather than derived. It bounds what one raw |
| 73 | request can hold in memory, because `GitQuery` reads bytes rather than streaming them. |
| 74 | Raising it means a handful of concurrent requests can hold that much each. |
| 75 | - **Renaming a repository** is still impossible, and now needs its own use case plus an |
| 76 | answer for moving the directory under every existing clone. |
| 77 | - **The profile has no links.** `Organization` carries a display name and a bio and |
| 78 | nothing else, so the design's links row is not backed by data and was left out rather |
| 79 | than faked. A `links` field plus a settings control is the small feature that fixes it. |
| 80 | - **`create_repo` and `serve_git` read the clock internally** rather than taking a `now`, |
| 81 | which is what `issue_token` does. Two call-site edits, or a `Clock` port if a third |
| 82 | case appears. |
| 83 | - **A push touches `updated_at` twice** — once for the advertisement, once for the RPC. |
| 84 | Harmless, same second, but two writes per push. |
| 85 | - **A licence.** A public portfolio repository probably wants one, and the README |
| 86 | deliberately says nothing about licensing rather than guessing. |
| 87 | - ~~Blocked on a domain transfer~~ — **transferred 2026-08-29**, so Phases 1–6 of the |
| 88 | deployment runbook are unblocked. |
| 89 | - **(historic) Blocked on a domain transfer** (noted 2026-08-29). `jpgill.dev` is the |
| 90 | intended host for both the instance and the release downloads; the transfer is in |
| 91 | flight. Phases 1–6 of the deployment runbook cannot start until DNS resolves, because |
| 92 | Caddy requests a certificate on startup. Nothing else is blocked by it — the artifact |
| 93 | is built and verified, and `install.sh` still needs its container dry-run. |
| 94 | - **A domain.** Caddy needs a real hostname to obtain a certificate. This is the one |
| 95 | blocker that is DNS rather than code. |
| 96 | |
| 97 | ### Carried over — small, unblocked |
| 98 | |
| 99 | - **A client that disappears mid-request leaves the body-copy task waiting.** The copy |
| 100 | into git's stdin runs in its own task and nothing cancels it if the connection drops. |
| 101 | Bounded by the backend exiting and closing the pipe, but not by anything deliberate. |
| 102 | - **`REMOTE_USER` is not set on the backend**, so a push is recorded in the repository's |
| 103 | reflog without naming who made it. Steid knows the actor by then; it simply is not |
| 104 | passed through. Small, and worth doing before anything reads reflogs. |
| 105 | - **No automated test asserts the security headers.** There is no HTTP-level test |
| 106 | harness, so losing the CSP would be silent. The likeliest regression is someone using |
| 107 | `insert()` instead of `or_insert()` and flattening the raw endpoint's stricter policy. |
| 108 | - **`cargo audit` has never been run**, and the dependency tree has not been reviewed. |
| 109 | - **No rate limiting on token authentication.** A token is 256 bits so guessing is not |
| 110 | the worry; unbounded hashing on an open endpoint is. |
| 111 | - **Tokens have no expiry and no last-used timestamp.** Both deliberate omissions for |
| 112 | now — see [0007](decisions/0007-tokens-over-http-basic.md) — but a token list with no |
| 113 | "last used" makes it hard to know which are safe to revoke. |
| 114 | - **A subprocess per git request.** Unlike Milestone 3's once-per-creation, this is on a |
| 115 | hot path and has not been measured. Milestone 5 is where that bill comes due. |
| 116 | - **Streaming is by construction, not by measurement.** The response body is never |
| 117 | collected, but no clone large enough to prove it has been run. |
| 118 | - **An orphaned repo directory is possible** if the process dies between the record |
| 119 | write and the filesystem write, and it then blocks re-creating that name. The durable |
| 120 | fix is a reconciliation sweep on boot |
| 121 | ([architecture.md](architecture.md#db-plus-filesystem-writes)); clearing one is a |
| 122 | manual `rm` today, since repo deletion does not exist. |
| 123 | - **The duplicate-name check races.** The loser is caught by `init_bare` or the unique |
| 124 | constraint, but surfaces as an opaque storage error rather than "name taken". |
| 125 | - **Bare repos created on macOS carry `ignorecase = true`.** A migration gotcha if the |
| 126 | data directory ever moves to Linux. |
| 127 | - **Light mode is still untested**, and now there is much more surface to get it wrong |
| 128 | on — the file tree, the blob view and the log all shipped without anyone looking at |
| 129 | them in light mode. |
| 130 | - **Submodule rendering was never seen**, only compiled: no fixture contained one. |
| 131 | - **The `/log` page's switcher opens with nothing marked current** when no revision is |
| 132 | in the URL, because `repo_log` still does not report the revision it resolved. Now more |
| 133 | visible than before, since there is a switcher to look wrong. |
| 134 | - **Task-list items keep their bullet** and footnotes render in place rather than |
| 135 | collected at the end. Both cosmetic. |
| 136 | - **A per-file last-commit column is still absent**, deliberately — see |
| 137 | [0006](decisions/0006-git-binary-behind-narrow-ports.md#amendment--20260829-the-milestone-5-read-path). |
| 138 | Wanting it is the trigger to move to a kept-alive `cat-file --batch`, not to reopen |
| 139 | `gix`. |
| 140 | - **Fonts are not loaded.** The theme names Geist and IBM Plex Mono; both fall back |
| 141 | today. Topcoat's `font-fontsource` feature handles it. |
| 142 | - **Light mode is untested.** The palette defines it; nobody has looked at it. |
| 143 | - **No rate limiting** on `/auth/login` or `/auth/setup`. |
| 144 | - **`sweep_expired` is never called**, so expired session rows accumulate. Expiry is |
| 145 | enforced on read, so this is tidiness, not a hole. |
| 146 | - **CSRF.** `SameSite=Lax` covers the common case. Forms now exist, so this is decidable |
| 147 | rather than hypothetical. |
| 148 | |
| 149 | ## Backlog |
| 150 | |
| 151 | Ordered. Pull from the top. |
| 152 | |
| 153 | 1. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Open when it |
| 154 | starts: which markdown crate, and whether raw HTML in markdown is trusted — safe for a |
| 155 | single author, a stored-XSS hole the moment Milestone 7 adds a second user. A |
| 156 | repository's README rendering on its page falls out of the same pipeline. |
| 157 | |
| 158 | ## Open questions |
| 159 | |
| 160 | - **Topcoat is early** (v0.5.0, first released 2026-07-22, breaking changes expected |
| 161 | by its own authors). Expect churn that isn't feature work. |
| 162 | - Topcoat ships Tailwind without Node, which reopens the design system attempt #1 |
| 163 | dropped purely to avoid an npm build step — see [ui.md](ui.md). |
| 164 | |
| 165 | ## Routing findings (Milestone 0) |
| 166 | |
| 167 | - **Topcoat 0.5 requires rustc ≥ 1.95.** On an older toolchain `cargo add topcoat` |
| 168 | silently resolves to an empty `topcoat v0.0.0` placeholder instead of failing. Local |
| 169 | stable is now 1.97.1. |
| 170 | - `Router::builder().discover()` collects `#[page]`-annotated items **at link time**, |
| 171 | so pages can live in any module. Layering is our choice, not the framework's. |
| 172 | - `module_router!` derives each URL from the module tree rather than a path string. |
| 173 | Still deferred. Application routes now group cleanly (`auth/login`, `api/me`), but |
| 174 | handles sit at the root ([0004](decisions/0004-root-handles-grouped-routes.md)), so a |
| 175 | parameterised root segment still has to coexist with static ones. Worth checking how |
| 176 | `module_router!` handles that before committing to it. |
| 177 | - Path and query params are read from `Cx` via `path_param!` / `#[query_params]`, not |
| 178 | injected as handler arguments. Parses are memoized per request. |
| 179 | - Layouts wrap by path prefix and nest outermost-first, and a layout can catch a page's |
| 180 | `NotFoundError` to render a branded 404. |
| 181 | - `HOST` / `PORT` configure the bind address, so `STEID_LISTEN_ADDR` is gone. |
| 182 | - `Body` is a boxed `http_body::Body` used for both requests and responses, with |
| 183 | `into_data_stream()` to read and `Body::new()` to wrap a stream — pack data can |
| 184 | stream both directions without buffering. This is what makes Milestone 4 viable. |