| 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 | |
3dd7293feat: ship Steid as an installable binary8d | 7 | ## Active: Milestone 5b — Deployable by anyone |
a814db5feat: push and clone private repositories with a token8d | 8 | |
3dd7293feat: ship Steid as an installable binary8d | 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. |
a814db5feat: push and clone private repositories with a token8d | 12 | |
3dd7293feat: ship Steid as an installable binary8d | 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+. |
8608574feat: personal access tokens, as a domain type8d | 63 | |
be4fac5docs: correct the Topcoat guidance in CLAUDE.md24d | 64 | ### Open |
| 65 | |
3dd7293feat: ship Steid as an installable binary8d | 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. |
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d | 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. |
ef23868feat: rebuild the profile page on flat navigation7d | 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. |
3dd7293feat: ship Steid as an installable binary8d | 85 | - **A licence.** A public portfolio repository probably wants one, and the README |
| 86 | deliberately says nothing about licensing rather than guessing. |
0f19654feat: security headers on every response1d | 87 | - ~~Blocked on a domain transfer~~ — **transferred 2026-08-29**, so Phases 1–6 of the |
| 88 | deployment runbook are unblocked. |
7da29e0fix: the domain is jpgill.dev, not jpgilldev.com1d | 89 | - **(historic) Blocked on a domain transfer** (noted 2026-08-29). `jpgill.dev` is the |
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d | 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. |
3dd7293feat: ship Steid as an installable binary8d | 94 | - **A domain.** Caddy needs a real hostname to obtain a certificate. This is the one |
| 95 | blocker that is DNS rather than code. |
be4fac5docs: correct the Topcoat guidance in CLAUDE.md24d | 96 | |
4414bbadocs: record the first real deployment23h | 97 | ### Open |
| 98 | |
| 99 | - **Whether an absent *public* repository can 404 while private ones still 401.** The |
| 100 | uniform 401 from [0007](decisions/0007-tokens-over-http-basic.md) is what makes a |
| 101 | private repository indistinguishable from one that does not exist — but it also turns |
| 102 | every mistyped URL into "Authentication failed", which cost real time on the first |
| 103 | deployment. The leak being prevented concerns *private* names only, so answering 404 for |
| 104 | a repository that would be public if it existed may cost nothing. Needs thought about |
| 105 | whether that is actually true. |
607da17docs: licence Steid under AGPL-3.023h | 106 | - **Zero-downtime deploys — deferred, deliberately.** `deploy.sh` currently stops the |
| 107 | service, swaps the binary and starts it: about two seconds. The plan, when it is worth |
| 108 | building: a systemd **template unit** `steid@.service` where the instance name *is* the |
| 109 | port, with `/opt/steid/3000` and `/opt/steid/3001` as separate install directories. |
| 110 | Deploy alternates — install to the idle port, start it, health-check it **directly on |
| 111 | loopback**, rewrite Caddy's upstream and `caddy reload` (graceful; existing connections |
| 112 | finish), then stop and disable the old one and enable the new so a reboot brings back |
| 113 | the right one. |
| 114 | |
| 115 | Two things make this more than a shell script, and they are the reason it is deferred |
| 116 | rather than half-built: |
| 117 | |
| 118 | - **Both processes share one SQLite database during the overlap.** WAL allows that, and |
| 119 | Steid's writes are short, so the overlap itself is fine. **Migrations are not.** The |
| 120 | new binary migrates on boot while the old one is still serving, so any schema change |
| 121 | that the old binary cannot tolerate — a new `NOT NULL` column, a dropped column — |
| 122 | breaks the still-live version. That is a constraint on how migrations are written |
| 123 | (**expand, deploy, contract**), not something the deploy script can solve. |
| 124 | - **An in-flight `git push` is cut when the old process stops.** `topcoat::start` |
| 125 | already drains on SIGTERM, so the fix is to stop the old one *after* Caddy has |
| 126 | repointed and let it finish what it has. |
| 127 | |
| 128 | Worth building when instances belong to other people. At two seconds on a personal site, |
| 129 | it buys nothing today. |
4414bbadocs: record the first real deployment23h | 130 | - **No scheduled backups.** `/var/lib/steid` is the whole of the state and copies of it |
| 131 | exist only because they were taken by hand. This is the largest gap now that the |
| 132 | instance is live. |
| 133 | - **Releases are not served from the instance yet.** The Caddy blocks are written and |
| 134 | commented in `deploy/Caddyfile`, and `install.sh`'s `RELEASE_BASE_URL` points at |
| 135 | `/jamesgill/repos/steid/releases`, but nothing has been rsynced there — so the |
| 136 | one-command install a stranger would run does not work yet. |
| 137 | |
6f0a608docs: fold the highlighting handover into plans18h | 138 | ### Opened by the section 1 wave |
| 139 | |
| 140 | - **Highlighting caches nothing.** The same file is re-highlighted on every view at |
8daff1cfeat: blame reads as code, the same way the blob does16h | 141 | ~87 ms per thousand lines in release. Cache per blob object id when it shows. Blame |
| 142 | now pays this too, on top of being the most expensive read in `GitQuery` — the two |
| 143 | costs land on the same page, which is where it will show first. |
6f0a608docs: fold the highlighting handover into plans18h | 144 | - **Diffs, READMEs and markdown code fences are unhighlighted.** The README goes through |
| 145 | `web/markdown.rs`, which writes its own `<pre><code>`; wiring `highlight.rs` into it is |
8daff1cfeat: blame reads as code, the same way the blob does16h | 146 | a small follow-up. Blame was the fourth of these and is now done, so the adapter is |
| 147 | reached from two pages and the pattern for a third is set: build the file's text in |
| 148 | line order, call `source_lines` **once**, render `Classed` through `Unescaped`. |
610ab38feat: a .jsx file reads as JavaScript rather than as plain text17h | 149 | - ~~`.jsx` is plain~~ — **aliased onto JavaScript.** `highlight.rs` gained an `ALIASES` |
| 150 | table, consulted only after the syntax set's own answer, so an alias can never |
| 151 | overrule a real grammar. It has one entry and should stay small. |
6f0a608docs: fold the highlighting handover into plans18h | 152 | - **`\r\n` files keep the `\r` inside highlighted markup.** Invisible under |
| 153 | `white-space: pre`; noted rather than fixed. |
| 154 | - **The blob header does not name the detected language.** It is only visible as colour. |
| 155 | |
9b1feb6docs: fold the branches and tags handover into plans18h | 156 | - **No ahead/behind counts on the branches page**, by decision: a `rev-list` per branch |
| 157 | is twenty forks for twenty branches. Waits for a kept-alive git. |
| 158 | - **Neither refs page is paginated.** A thousand branches render a thousand rows. |
| 159 | - **A repository with tags but no branches** says "No branches"; real but exotic, not |
| 160 | fixture-tested. |
| 161 | - **`web::browse::{encode, timestamp}` are now `pub(super)`**, and `repo_stats` takes |
00d9c06refactor: one place decides that git ran out of time17h | 162 | `handle` and `name` so the sidebar counts can link. `timed_out` has since moved to |
| 163 | `web::context`, as this said it should. |
9b1feb6docs: fold the branches and tags handover into plans18h | 164 | |
4e4ba31docs: fold the archive and search handover into plans17h | 165 | - **`git grep` output is collected whole before it is capped.** A one-letter query on a |
| 166 | big repository allocates all of git's output to keep 200 hits. Bounded by the 20 s |
| 167 | timeout, not by anything deliberate. |
| 168 | - **The archive endpoint has no rate limiting** and is outside the read timeout, on |
| 169 | purpose (a large repository legitimately takes longer to pack). It is the most |
| 170 | expensive anonymous request in Steid: a full pack per hit. First thing to look at under |
| 171 | load. A late `git archive` failure is a truncated download, logged like the protocol |
| 172 | server's. |
| 173 | - **Search has no revision switcher on the results page**, no paging, `trim()`s the |
| 174 | query, cuts matched lines at 500 characters (which can remove the match on a minified |
| 175 | line), and ignores `GrepHit::column` when marking. |
| 176 | |
64f6f0ddocs: fold the commit page and compare handover into plans17h | 177 | - **The commit page's timeout state has never been rendered**, like search's and the |
00d9c06refactor: one place decides that git ran out of time17h | 178 | refs pages'. The predicate itself is now `web::context::timed_out`, shared by `refs`, |
| 179 | `commit` and `blame`; each page still writes its own panel, and `search_repo` still |
| 180 | answers in the application layer. |
64f6f0ddocs: fold the commit page and compare handover into plans17h | 181 | - **`Diff::files` is emptied wholesale when the patch is truncated**, falling back to the |
| 182 | numstat list, so a 10 MiB commit shows no lines rather than the first few files. |
| 183 | numstat itself could in principle be truncated at ~300,000 changed files. |
| 184 | - **A merge commit is diffed against its first parent only**, and merge rendering was |
| 185 | compiled, not seen: no fixture contains one. |
| 186 | - **`MAX_RAW_BYTES` now does a second job** as the diff cap; retune both together. |
| 187 | - **`bg-surface` is nearly `bg-background` in light mode**, so a file header reads only |
| 188 | by its border there. Pre-existing, but the commit page has one per file. |
| 189 | - **No paging anywhere in history**: `/log` shows 50, a comparison 100, both say so. |
| 190 | - **The compare form's `<datalist>` costs a `list_refs`** rendered inline on every visit. |
| 191 | - ~~Nothing links to a commit by its sha yet~~ — done. |
| 192 | |
8804ad9feat: blame can change revision, like every other page under Code17h | 193 | - ~~The blame page has no revision switcher~~ — **added**, and `Switch` in |
| 194 | `web/browse.rs` now has a `Blame` arm, so switching branch keeps you on blame at the |
| 195 | same path. It costs the page one `list_refs`, which is what the wave deferred. |
691bf98docs: fold the blame handover into plans17h | 196 | - **`BlameCommit::boundary` and `author_name` reach only the tooltip.** The row is sha, |
| 197 | summary, date and must stay one line. On a multi-user instance the author is the first |
| 198 | thing to revisit. |
| 199 | - **Blame is the most expensive read in `GitQuery`** and the likeliest to meet the 20 s |
| 200 | timeout. Nothing but this page calls it. |
fb21638docs: the section 1 wave, checked as one thing rather than five17h | 201 | - ~~Cross-branch links were verified only after the merge~~ — **walked, 2026-09-05**, |
| 202 | see the integration pass below. |
| 203 | |
| 204 | #### The integration pass — 2026-09-05 |
| 205 | |
| 206 | The five branches merged and run as one app, on a real instance holding Steid's own |
| 207 | history: 9 branches, 2 annotated tags, one public repository and one private. Every |
| 208 | link that crosses a feature boundary was followed in a browser, and **nothing was |
| 209 | broken at a seam.** Landing sha → commit page; log sha → commit; commit's "Browse |
| 210 | files" → tree at that sha and its parent sha → the parent's page; the counts and the |
| 211 | sidebar → branches and tags; a branch row → tree, log and a compare with a real diff |
| 212 | (a merged branch correctly says there is nothing to compare); a search hit's line |
| 213 | number → `blob#L<n>` with the anchor present and landing; `Code · Blame · Raw` in both |
| 214 | directions; blame's sha → the commit page and its line number → the blob anchor; |
| 215 | `zip` and `tar.gz` for `main` and for a tag, both extracting under `steid-<rev>/`. |
| 216 | **A private repository 404s on all eleven routes anonymously** — landing, log, |
| 217 | branches, tags, commit, search, tree, blame, raw, archive, compare. Highlighting still |
| 218 | renders after the blame toggle and the anchor edits landed on the same table: Rust and |
| 219 | TOML coloured, a plain file not. Screenshots of the whole flow in dark and light are |
| 220 | in `target/shots/`. |
| 221 | |
| 222 | Two things the pass found, neither a break: |
| 223 | |
| 224 | - **The sidebar's Download links can only ever offer the default branch.** |
| 225 | `clone_block` takes a revision and its doc says the links are "for the revision being |
| 226 | viewed", but the sidebar renders only on the landing page, which has no `{rev}` — and |
| 227 | the tree and blob pages, which do, are single-column by design. So a tag's tarball is |
| 228 | reachable only by typing `/archive/v0.2.0.zip`. Both formats work at both revisions; |
| 229 | it is the entry point that is missing, and putting it on the tree page means deciding |
| 230 | whether that page gets a sidebar, which [ui.md](ui.md#the-repository-page) settled the |
| 231 | other way. |
8daff1cfeat: blame reads as code, the same way the blob does16h | 232 | - ~~Blame renders its lines unhighlighted while the blob highlights them~~ — **closed.** |
| 233 | Blame's code cell now goes through `source_lines`, the blob's own adapter, so the two |
| 234 | views share classes, caps and plain fallback. Both render the same file to an |
| 235 | identical set of `hl-` spans, and rows stay 19.5px — the only variance is at a run's |
| 236 | hairline, which is `border-collapse` splitting that 1px, not the markup. |
691bf98docs: fold the blame handover into plans17h | 237 | |
e0856ebfeat: clone a public repository over HTTP8d | 238 | ### Carried over — small, unblocked |
d7b99d9docs: record milestone 0 progress and routing findings1mo | 239 | |
47db238feat: serve the git protocol through http-backend, behind a port8d | 240 | - **A client that disappears mid-request leaves the body-copy task waiting.** The copy |
e0856ebfeat: clone a public repository over HTTP8d | 241 | into git's stdin runs in its own task and nothing cancels it if the connection drops. |
47db238feat: serve the git protocol through http-backend, behind a port8d | 242 | Bounded by the backend exiting and closing the pipe, but not by anything deliberate. |
a814db5feat: push and clone private repositories with a token8d | 243 | - **`REMOTE_USER` is not set on the backend**, so a push is recorded in the repository's |
| 244 | reflog without naming who made it. Steid knows the actor by then; it simply is not |
| 245 | passed through. Small, and worth doing before anything reads reflogs. |
0f19654feat: security headers on every response1d | 246 | - **No automated test asserts the security headers.** There is no HTTP-level test |
| 247 | harness, so losing the CSP would be silent. The likeliest regression is someone using |
| 248 | `insert()` instead of `or_insert()` and flattening the raw endpoint's stricter policy. |
| 249 | - **`cargo audit` has never been run**, and the dependency tree has not been reviewed. |
a814db5feat: push and clone private repositories with a token8d | 250 | - **No rate limiting on token authentication.** A token is 256 bits so guessing is not |
| 251 | the worry; unbounded hashing on an open endpoint is. |
| 252 | - **Tokens have no expiry and no last-used timestamp.** Both deliberate omissions for |
| 253 | now — see [0007](decisions/0007-tokens-over-http-basic.md) — but a token list with no |
| 254 | "last used" makes it hard to know which are safe to revoke. |
e0856ebfeat: clone a public repository over HTTP8d | 255 | - **A subprocess per git request.** Unlike Milestone 3's once-per-creation, this is on a |
| 256 | hot path and has not been measured. Milestone 5 is where that bill comes due. |
| 257 | - **Streaming is by construction, not by measurement.** The response body is never |
| 258 | collected, but no clone large enough to prove it has been run. |
6a2a925docs: close Milestone 3, plan Milestone 4a8d | 259 | - **An orphaned repo directory is possible** if the process dies between the record |
| 260 | write and the filesystem write, and it then blocks re-creating that name. The durable |
| 261 | fix is a reconciliation sweep on boot |
| 262 | ([architecture.md](architecture.md#db-plus-filesystem-writes)); clearing one is a |
| 263 | manual `rm` today, since repo deletion does not exist. |
| 264 | - **The duplicate-name check races.** The loser is caught by `init_bare` or the unique |
| 265 | constraint, but surfaces as an opaque storage error rather than "name taken". |
| 266 | - **Bare repos created on macOS carry `ignorecase = true`.** A migration gotcha if the |
| 267 | data directory ever moves to Linux. |
0aca94efeat: a repository is one place, and its landing page says what it is18h | 268 | - ~~**Light mode is still untested**~~ — **looked at 2026-09-05** on the repository |
| 269 | landing page, a tree and a blob, by temporarily flipping the layout's `class="dark"` |
| 270 | and screenshotting. Nothing was wrong: every colour on these pages already comes from |
| 271 | a token. The profile, the settings pages and the log have still not been checked, and |
| 272 | **there is still no way for a visitor to choose** — the layout hardcodes `dark`. |
dce0bf3feat: browse a repository's files and history8d | 273 | - **Submodule rendering was never seen**, only compiled: no fixture contained one. |
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d | 274 | - **The `/log` page's switcher opens with nothing marked current** when no revision is |
| 275 | in the URL, because `repo_log` still does not report the revision it resolved. Now more |
| 276 | visible than before, since there is a switcher to look wrong. |
| 277 | - **Task-list items keep their bullet** and footnotes render in place rather than |
| 278 | collected at the end. Both cosmetic. |
0aca94efeat: a repository is one place, and its landing page says what it is18h | 279 | - **The landing page now makes 15 `git` processes** for a repository with a README and |
| 280 | a licence (10 without, 1 for an empty one), up from 7. Five are the About sidebar's |
| 281 | and run concurrently, so the *latency* is roughly one call — but it is fifteen forks |
| 282 | per view, and this is now the most expensive page in Steid. The fix is 0006's |
| 283 | kept-alive `cat-file --batch`, not trimming the sidebar. |
| 284 | - **`count_commits` costs two processes, not one.** It resolves the revision before |
| 285 | running `rev-list --count`, because `rev-list` is fatal on an empty repository and on |
| 286 | an unknown branch, and this module's rule is that a non-zero exit from git is always a |
| 287 | real fault. `--ignore-missing` was tried: it covers a bad object id, not a bad |
| 288 | revision *name*. |
| 289 | - **`bg-muted` is not a token and never was.** Three `<pre>` blocks used it, so they |
| 290 | have been rendering with no background at all — silently, exactly as `ui.md` warns |
c3ca6dafix: the new token sits on a background that exists17h | 291 | about classes Tailwind never ships. Fixed on the clone block, the empty-repository |
| 292 | push snippet and, last, `token.rs`'s new-token block. All three now use `bg-surface`, |
| 293 | and `bg-muted` appears nowhere in `src/` — only `bg-muted-foreground`, which is real. |
0aca94efeat: a repository is one place, and its landing page says what it is18h | 294 | - **The licence sniffer reads 1 KiB, not the 200 bytes first sketched.** BSD-2 and |
| 295 | BSD-3 differ only by a third clause about 900 bytes in. `LICENSE-MIT` and other |
| 296 | suffixed spellings are not detected: the filename list is deliberately short, because |
| 297 | every extra candidate is another speculative blob read. |
dce0bf3feat: browse a repository's files and history8d | 298 | - **A per-file last-commit column is still absent**, deliberately — see |
| 299 | [0006](decisions/0006-git-binary-behind-narrow-ports.md#amendment--20260829-the-milestone-5-read-path). |
| 300 | Wanting it is the trigger to move to a kept-alive `cat-file --batch`, not to reopen |
| 301 | `gix`. |
2eb8681docs: put git next, plan the repo model24d | 302 | - **Fonts are not loaded.** The theme names Geist and IBM Plex Mono; both fall back |
| 303 | today. Topcoat's `font-fontsource` feature handles it. |
5d3dfa5feat: a global top bar, and pages choose their own width23h | 304 | - **The auth pages are unstyled.** `/auth/setup` and `/auth/login` are still bare |
| 305 | milestone-1 HTML, and the styled top bar now sits right above them making it obvious. |
| 306 | - **The top bar's wordmark says "steid"**, not the owner's identity — see the open |
| 307 | question at the end of [ui.md](ui.md#the-shell). |
2eb8681docs: put git next, plan the repo model24d | 308 | - **Light mode is untested.** The palette defines it; nobody has looked at it. |
f0444b7docs: plan milestone 2 in two phases1mo | 309 | - **No rate limiting** on `/auth/login` or `/auth/setup`. |
076dbc9docs: close milestone 1, open milestone 21mo | 310 | - **`sweep_expired` is never called**, so expired session rows accumulate. Expiry is |
| 311 | enforced on read, so this is tidiness, not a hole. |
2eb8681docs: put git next, plan the repo model24d | 312 | - **CSRF.** `SameSite=Lax` covers the common case. Forms now exist, so this is decidable |
| 313 | rather than hypothetical. |
| 314 | |
| 315 | ## Backlog |
| 316 | |
| 317 | Ordered. Pull from the top. |
| 318 | |
3dd7293feat: ship Steid as an installable binary8d | 319 | 1. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Open when it |
| 320 | starts: which markdown crate, and whether raw HTML in markdown is trusted — safe for a |
| 321 | single author, a stored-XSS hole the moment Milestone 7 adds a second user. A |
| 322 | repository's README rendering on its page falls out of the same pipeline. |
| 323 | |
31a6a7edocs: record the forge feature survey and what can run in parallel19h | 324 | ### Forge feature candidates (surveyed 2026-09-05) |
| 325 | |
| 326 | Not yet ordered against Milestone 6. Grouped by what they touch, because the plan is to |
| 327 | run several agents at once and the grouping is what decides what can run together. |
| 328 | **Section 1 is being tackled first.** Shared hotspots: `GitQuery` in `port.rs` (every |
| 329 | browsing feature appends a method), `git_query.rs`, the repo sub-nav, and `plans/` |
| 330 | itself. New tables touch `sqlite.rs` + `in_memory.rs` and should merge one at a time. |
| 331 | |
fb21638docs: the section 1 wave, checked as one thing rather than five17h | 332 | **Section 1 is done**, apart from the per-file last-commit column — which is not a |
| 333 | gap but a deliberate wait for the kept-alive `cat-file --batch` |
| 334 | ([0006](decisions/0006-git-binary-behind-narrow-ports.md)). Shipped: the two-column |
| 335 | repository landing page, syntax highlighting, branches and tags pages, archive |
| 336 | download, code search, the commit page, compare, and blame. All eight were merged and |
| 337 | then walked together as one app; see the integration pass above. See |
| 338 | [ui.md](ui.md#the-repository-page) for the layout and the entry-point map, and |
| 339 | [progress.md](progress.md) for what each cost. |
| 340 | |
| 341 | 1. ~~**Read-only browsing**~~ — **done**, except the **per-file last-commit column**, |
| 342 | which stays blocked on the |
| 343 | [0006](decisions/0006-git-binary-behind-narrow-ports.md) amendment: wanting it is the |
| 344 | trigger for a kept-alive `cat-file --batch`, not for reopening `gix`. |
31a6a7edocs: record the forge feature survey and what can run in parallel19h | 345 | 2. **Repo model** (a column or use case each; merge serially): rename · default branch |
| 346 | setting · archived flag · topics and pinned repos on the profile · orphan-directory |
| 347 | reconciliation sweep on boot. |
| 348 | 3. **Collaboration** (8+): issues · pull requests (needs the commit and compare pages, |
| 349 | plus a merge on the bare repo) · labels and milestones. |
| 350 | 4. **Transport and git ops**: import from URL (mirror clone) · post-receive events and |
| 351 | webhooks (first brick of CI) · `REMOTE_USER` passthrough · LFS (not yet) · SSH |
| 352 | (would reverse [0001](decisions/0001-git-over-http-not-ssh.md)). |
| 353 | 5. **Identity and auth**: token expiry and last-used · rate limiting on token auth · |
| 354 | CSRF (touches every form — run alone) · Milestone 7 multi-user (run alone). |
| 355 | 6. **Portfolio**: posts (Milestone 6) · profile links · Atom feeds for posts and commits. |
| 356 | 7. **Platform and quality**: scheduled backups and served releases (closes 5b) · |
| 357 | HTTP-level test harness plus security-header tests and `cargo audit` · `/api` |
| 358 | coverage for existing use cases · styled auth pages, light mode, fonts. |
| 359 | |
fb21638docs: the section 1 wave, checked as one thing rather than five17h | 360 | Process decisions, now answered by the wave rather than open: agents **do** get |
| 361 | worktrees and short-lived branches, which is a deliberate bend in the commit-to-main |
| 362 | rule and worth keeping for a wave; and agents do **not** edit `plans/` — each writes a |
| 363 | handover the merging session folds in, because five agents appending to `current.md` |
| 364 | conflict every time. The integration pass afterwards is not optional: it is the only |
| 365 | place a cross-feature link is ever exercised. |
31a6a7edocs: record the forge feature survey and what can run in parallel19h | 366 | |
| 367 | ## Open questions |
| 368 | |
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo | 369 | - **Topcoat is early** (v0.5.0, first released 2026-07-22, breaking changes expected |
| 370 | by its own authors). Expect churn that isn't feature work. |
| 371 | - Topcoat ships Tailwind without Node, which reopens the design system attempt #1 |
| 372 | dropped purely to avoid an npm build step — see [ui.md](ui.md). |
| 373 | |
| 374 | ## Routing findings (Milestone 0) |
| 375 | |
| 376 | - **Topcoat 0.5 requires rustc ≥ 1.95.** On an older toolchain `cargo add topcoat` |
| 377 | silently resolves to an empty `topcoat v0.0.0` placeholder instead of failing. Local |
| 378 | stable is now 1.97.1. |
| 379 | - `Router::builder().discover()` collects `#[page]`-annotated items **at link time**, |
| 380 | so pages can live in any module. Layering is our choice, not the framework's. |
| 381 | - `module_router!` derives each URL from the module tree rather than a path string. |
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo | 382 | Still deferred. Application routes now group cleanly (`auth/login`, `api/me`), but |
| 383 | handles sit at the root ([0004](decisions/0004-root-handles-grouped-routes.md)), so a |
| 384 | parameterised root segment still has to coexist with static ones. Worth checking how |
| 385 | `module_router!` handles that before committing to it. |
bd48b4bdocs: serve git over smart HTTP, reorder roadmap portfolio-first1mo | 386 | - Path and query params are read from `Cx` via `path_param!` / `#[query_params]`, not |
| 387 | injected as handler arguments. Parses are memoized per request. |
| 388 | - Layouts wrap by path prefix and nest outermost-first, and a layout can catch a page's |
| 389 | `NotFoundError` to render a branded 404. |
| 390 | - `HOST` / `PORT` configure the bind address, so `STEID_LISTEN_ADDR` is gone. |
| 391 | - `Body` is a boxed `http_body::Body` used for both requests and responses, with |
| 392 | `into_data_stream()` to read and `Body::new()` to wrap a stream — pack data can |
ca76e1bdocs: fix milestone cross-references after the reorder24d | 393 | stream both directions without buffering. This is what makes Milestone 4 viable. |