steid

@jamesgill /

steid/plans/progress.md
62.6 KBCode·Blame·Raw
ab7fea9chore: plans setup1mo
1# Progress
2
3## This attempt (#3, Topcoat)
4
fb21638docs: the section 1 wave, checked as one thing rather than five15h
5598 tests. Active milestone in [current.md]current.md.
88583f2docs: bring tracking docs up to date with milestone 11mo
6
fb21638docs: the section 1 wave, checked as one thing rather than five15h
7### Section 1 wave — read-only browsing · done
c974d80feat: every read-side git process is bounded, and a timeout is not a fault17h
8
9**Every read-side `git` process is now bounded at 20 s** (`GIT_TIMEOUT` in
10`git_query.rs`), and the child is `kill_on_drop` so a cancelled request takes git with
11it. Added before blame and grep arrived, because those are the first reads that can
12run long on a big repository, and five agents each adding their own bound would have
13produced five. `GitQueryError::is_timeout()` is how a page tells "asked too much"
14(render a state, offer less) from "git failed" (a 500). Tokio's `time` feature was
15enabled for it.
16
6f0a608docs: fold the highlighting handover into plans17h
17**Syntax highlighting (merged 2026-09-05).** `syntect` with the pure-Rust `default-fancy`
18engine plus `two-face` (`syntect-fancy` feature) for the grammars the default set lacks —
19TOML, Dockerfile, TypeScript — at +0.59 MiB on the binary. Decisions worth keeping:
20
21- **Classes, never inline colours.** Output is `ClassStyle::SpacedPrefixed { prefix: "hl-" }`
22 and every colour is a `--syntax-*` token in `styles.css`, so the palette follows the
23 theme. `ClassedHTMLGenerator` cannot be used because its spans cross line boundaries
24 and the blob view is one table row per line; `highlight.rs` drives `ParseState` +
25 `ScopeStack` itself, carrying parse state across lines but closing and reopening the
26 markup at each line end.
27- **`hl-` classes are scope atoms, not positional**, so `.hl-function` alone would colour
28 every `meta.function` body. Single-atom selectors only for an atom that leads its scope;
29 the rest are compounds. Rule order in `styles.css` is load-bearing: `.hl-string` is
30 written after `.hl-punctuation` so a quote reads as its string, and the comment rule is
31 last.
32- **The trailing newline is dropped by character, not `trim_end`** — the `_newlines`
33 syntaxes need it fed in, and it comes back escaped inside the output, sometimes before a
34 closing span. Left in, every row renders double-spaced.
35- **Caps are 512 KiB and 10,000 lines**, both, because cost is per byte and per line.
36 Plain text is returned as `Plain` lines, not wrapped in colourless spans.
37- **~87 ms per 1,255-line file in release**, uncached, paid on every view; debug is ~
38 slower and not the number to plan against. If it matters: cache per blob object id.
39
9b1feb6docs: fold the branches and tags handover into plans16h
40**Branches and tags pages (merged 2026-09-05).** `/branches` and `/tags`, one
41`for-each-ref` each, and the counts beside the switcher and in the sidebar are links.
42
43- **`%(HEAD)` is why the page is one process**: `for-each-ref` marks the default branch,
44 so nothing calls `symbolic-ref` on the happy path, and the compare link's base is read
45 from the pinned row.
46- **Keep empty fields when splitting `for-each-ref` output.** A lightweight tag's
47 `%(*objectname)` is empty; filtering empties shifts its date into the id slot. There is
48 a test for exactly this.
49- **Ordering is git's (`--sort=-committerdate`), pinning is the use case's.** The default
50 branch is a display decision, a stable partition in `list_branches`.
51- **A lightweight tag's `%(contents:subject)` is the commit's subject**, so it is read only
52 when `%(objecttype)` is `tag`. `annotated` is its own field because an annotated tag may
53 carry an empty message. The row shows the peeled `%(*objectname)` so the sha is browsable.
54- **"No tags yet" and "nothing pushed yet" are different sentences**; telling them apart
55 costs one `default_branch` call, only when the list is empty.
56- **`refs.rs::timed_out` is the first web-layer place that renders a timeout as a state**
57 rather than a 500. When a second page wants it, move it to `web::context`.
58
4e4ba31docs: fold the archive and search handover into plans16h
59**Archive download and code search (merged 2026-09-05).** `/archive/{rev}.tar.gz`
60and `.zip` streamed from `git archive`; `/search?q=&rev=` over `git grep -F`.
61
62- **`GitArchive` is a fourth narrow port, not a `GitQuery` method.** `GitQuery`'s contract
63 is capped bytes, collected; an archive is as large as the repository. It lives in
64 `infrastructure/git.rs` beside the protocol server and reuses `web::git::GitBody`, so
65 "one subprocess's stdout becomes a response body" is written once and used twice.
66- **The archive use case is two steps, which is what makes the `ETag` cheap.**
67 `archive_repo` authorizes and resolves into an `ArchiveTarget` nothing else can
68 construct; `open_archive` runs git. A conditional request is answered from step one,
69 so re-downloading a tag costs one `cat-file`. The `ETag` is the resolved commit.
70- **`git grep` is the one command whose exit status is an answer** (1 = no matches).
71 `run_within_allowing` takes an allowed-exit list and `NO_MATCHES` names the exception
72 at the call site, so the module's rule stays visible where it is broken.
73- **`git grep -z` is not optional**: a path may contain colons, so the colon-separated
74 form has no unambiguous reading. The parser walks NULs.
75- **`RepoPath` refuses a colon**, so `a:b.rs` is unsearchable as well as unbrowsable; the
76 parser drops those hits rather than link to a 404. Pinned by a test.
77- **A search timeout is a page state** (`Searched::TimedOut`), the first real consumer of
78 `GitQueryError::is_timeout`.
79- **Archive and search 404 a private repository for a stranger**, matching every other
80 read page rather than 0007's uniform 401, which exists so a git client can offer a
81 credential; a browser link gets no benefit from it.
82- `topcoat dev` in a worktree exited rather than restarting after some source edits and
83 had to be relaunched by hand. Possibly two instances sharing a `target/`. Watch it.
84
64f6f0ddocs: fold the commit page and compare handover into plans16h
85**Commit page and compare view (merged 2026-09-05).** `/commits/{sha}`, `/compare`
86(form) and `/compare/{base}...{head}`, three-dot. Every sha in the log and the
87latest-commit bar links to the commit page.
88
89- **The counts and the patch come from one `git` process**: `diff-tree --numstat -p`
90 writes numstat *first*, so a patch too large to draw still lists every file with real
91 `+a −b`. A second `--numstat` call would be paid on every page for the rare oversized
92 one.
93- **`run_capped` reads through a pipe and kills the child** at `max_bytes + 1`; the exit
94 status is checked only when the cap was not hit, since a killed process says so.
95- **`run_allowing` bends the "non-zero exit is a fault" rule for exactly two commands**
96 whose codes git's manual promises as answers: `grep` (1 = matched nothing) and
97 `merge-base` (1 = no common ancestor, 128 = broken). Both arrived in the same wave with
98 their own copy; the merge kept one. A third caller needs the same scrutiny.
99- **Compare is three-dot** (merge-base to head): two-dot undoes everything base gained
100 since the branch left, a wall of deletions nobody made, and three-dot is what a pull
101 request needs. `...` is unambiguous because `RefName` refuses `..`.
102- **The patch parser tracks hunk line counts**, never matching `diff --git`/`+++`/`---`
103 prefixes anywhere: a patch *of a patch* contains those as content.
104- **Paths come from `---`/`+++`/`rename from`, not the `diff --git` header**, whose
105 `a/<old> b/<new>` has no defined split when names contain spaces. C-quoted names are
106 unquoted.
107- **The compare form GETs to `/compare?base=&head=` and 307s to the path form**, so the
108 shareable URL is always the path one and no JavaScript is needed.
109- **The file header is `sticky`, which is why the diff panel is not `overflow-hidden`**:
110 a hidden overflow makes an ancestor a scroll container and sticky sticks to nothing.
111
691bf98docs: fold the blame handover into plans16h
112**Blame (merged 2026-09-05).** `/blame/{rev}/-/{path}`, with `Code · Blame · Raw` in the
113file header of both views.
114
115- **The porcelain parser is pure, in `application/blame.rs`.** `git blame --porcelain`
116 writes a commit's header block only the *first* time the commit appears; later runs are
117 a bare sha line looked up from an accumulated map. A code line can look exactly like a
118 header line, and **the leading tab is the only thing that tells them apart**. Both are
119 pinned by tests. `parse_blame` never fails: an unrecognised record is skipped.
120- **`filename` is per commit, not per hunk**, carried on `BlameCommit`; that is what makes
121 the "moved from" marker work.
122- **The use case reads the blob before blaming**, so binary, too-large and not-there are
123 decided by the blob page's rules from the same cap. Blame's own output cannot tell: a
124 binary file blames into garbage rather than reporting itself. Costs one `cat-file`.
125- **The commit cell is one line tall, on purpose**: two lines and blame and blob disagree
126 about where line 400 is. Row height measured at 19.5px in both.
127- **The age tint is linear in time, not rank**, five steps between the file's oldest and
128 newest commit, as `color-mix` of the primary in plain CSS. A file whose history is
129 bunched shows only the end steps; rank quintiles would always look richer and lie about
130 the gaps. A file written in one commit takes the top step.
131- **Blame is 4 processes** (two `batch-check`s asking the same existence question, one
132 from the use case and one inside the adapter). A kept-alive `cat-file --batch`
133 collapses both — the same upgrade 0006 defers.
134- **Headless Chrome writes screenshots to files; the puppeteer MCP tool does not.**
135 `--headless --hide-scrollbars --window-size=W,H --screenshot=path URL`. And `topcoat
136 dev` sometimes keeps printing "no changes; application up to date" while serving the
137 previous binary; `pkill -f "topcoat dev"`, wait for the port, restart.
138
fb21638docs: the section 1 wave, checked as one thing rather than five15h
139**The integration pass (2026-09-05).** The five branches merged, run as one app against
140Steid's own history — 9 branches, 2 annotated tags, a public repository and a private
141one — and every link crossing a feature boundary followed in a browser, in dark and in
142light. **Nothing was broken at a seam**, which is the finding: five agents that could
143not see each other agreed on URL shapes because the shapes were written down in
144`ui.md` and in each other's handovers before the code was.
145
146- **A private repository 404s on all eleven read routes anonymously** — landing, log,
147 branches, tags, commit, search, tree, blame, raw, archive, compare. Every use case
148 takes an `Actor` and answers `Ok(None)`, and each page turns that into `not_found()`;
149 the uniformity is the architecture rule paying off rather than eleven separate
150 decisions.
151- **Two gaps found, both entry points rather than breaks.** The About sidebar's Download
152 links can only ever offer the default branch, because the sidebar renders on the one
153 repository page that has no `{rev}`. And blame renders its lines unhighlighted while
154 the blob highlights them, `highlight.rs` and `blame.rs` having shipped on separate
155 branches. Both are in [current.md]current.md.
156- **A wave needs a divergent branch to test compare with.** Every feature branch was an
157 ancestor of `main` after merging, so every branch row's Compare link correctly said
158 there was nothing to compare and exercised none of the diff. `git commit-tree` makes
159 one without a working tree.
160- **Follow-ups the handovers named, done here:** blame's revision switcher (`Switch`
161 gained a `Blame` arm), `web::context::timed_out` shared by three pages, `.jsx` aliased
162 onto JavaScript, `token.rs`'s phantom `bg-muted`, and the two exit-status comments
163 that each claimed to be the only one.
164
768d679build: release 0.3.0, the section 1 wave2h
165**Released as 0.3.0 (2026-09-06)** — the first version carrying the section 1 wave.
166No migrations between 0.2.0 and 0.3.0, so the two binaries share a database and
167`deploy.sh`'s rollback is a real one. First Linux release build with `syntect` and
11a2668docs: 0.3.0 is live, and what the first probes measured2h
168`two-face`; both are pure Rust behind the fancy-regex features — the bullseye container
169build took ~2 minutes and needed no changes. Deployed 2026-09-06 12:07 UTC+1, health
170check passed first time. Probed live afterwards: landing, branches, tags, search, log,
171commit and compare all under 0.25 s; the 113 KB `git_query.rs` blob and its blame both
172~0.65 s (highlighting, uncached, on the Lightsail box); `archive/main.tar.gz` answers with
173the sha as `ETag`; an unknown repository 404s.
768d679build: release 0.3.0, the section 1 wave2h
174
88583f2docs: bring tracking docs up to date with milestone 11mo
175### Milestone 0 — Skeleton · done
176
177Topcoat 0.5 app serving pages, `AppConfig` from `STEID_*` env, SQLite pool in app
178context. Split into a library plus a thin binary — the domain layer had no consumers
179yet and read as ~30 dead-code warnings in a bare binary, and it unlocks `tests/`.
180Topcoat's link-time page discovery works from a library; that was checked, not assumed.
181
182**Requires rustc ≥ 1.95.** On older toolchains `cargo add topcoat` silently resolves to
183an empty `topcoat v0.0.0` placeholder instead of failing.
184
076dbc9docs: close milestone 1, open milestone 21mo
185### Milestone 1 — Identity, thin · done
88583f2docs: bring tracking docs up to date with milestone 11mo
186
187**Domain.** Typed IDs, `Email`, `PasswordHash`, `OrgName`, `Organization`, `User`,
188`Membership`, `Role`, `Actor`, `Session`, `SetupToken`, `DomainError`. Value objects
189pair `new()` (validates) with `from_trusted()` (skips, for rows already validated).
190
191**Application.** `claim_instance`, `login`, `resolve_actor`, `record_session`,
192`end_session`, `sweep_expired`. `PasswordHasher` port with Argon2 and a stub.
193
194**Infrastructure.** Migrations for orgs, users, memberships, sessions. In-memory and
076dbc9docs: close milestone 1, open milestone 21mo
195SQLite implementations of every port. Root layout, `/setup`, `/login`, `/logout`, home,
196and `/api/me`.
197
198`describe_identity` is the first use case with two consumers — the home page and
199`/api/me` both read through it. Until that existed, "the application layer is
200transport-neutral" was an assertion with one caller behind it.
88583f2docs: bring tracking docs up to date with milestone 11mo
201
202**Verified in a browser and by curl:** wrong token refused with nothing written;
203correct token creates org + user + owner membership and signs the owner in; session
204authenticates; logout clears cookie and row; wrong password bounces; right one signs
205in; re-claiming a claimed instance is refused.
206
207#### Decisions worth remembering
208
209- **`Actor` is an enum with an explicit `Anonymous`**, not `Option<UserId>`. Attempt #2
210 used a placeholder `UserId("ssh-anonymous")` and it became a security hole. A variant
211 can't be forgotten the way a sentinel can.
212- **`login` verifies a dummy hash when no user matched.** Returning early on the
213 unknown-email path makes it measurably faster and leaks which addresses have
214 accounts. A test pins that the dummy stays parseable — if it stops being, `verify`
215 bails early and the defence dies silently.
216- **`SetupToken` compares in constant time.** An early-return comparison leaks how much
217 of the token is right, which recovers it a character at a time.
218- **The setup token is only in app context while unclaimed**, so a claimed instance has
219 nothing for a claim attempt to match.
220- **SQLite ignores foreign keys unless asked**, per connection. `foreign_keys(true)`
221 plus a test that a user pointing at a missing org is refused.
222- **`sqlx migrate add` stamps versions to the second** — three calls in one second
223 collide, leaving apply order ambiguous between tables that reference each other.
224- **An unparseable role surfaces as an error**, never as "no membership". The latter
225 silently downgrades an owner to no access.
226- **`Secure` session cookies over plain-HTTP localhost fail silently.** See
227 [runbook.md]runbook.md#steid_insecure_cookies--development-only. This one actually
228 bit, and it looked exactly like broken auth logic.
ab7fea9chore: plans setup1mo
229
c50d9adfeat: profile settings with flash feedback24d
230### Milestone 2 — Profile page · done
3954b45docs: record milestone 2 phase 125d
231
232`/{handle}` is the real profile page: label, handle, bio, and the section frame for
233repositories, writing, and projects. Public, renders signed out, 404s on an unknown
234handle, and resolves regardless of casing. `/` forwards a signed-in owner to their own
235profile. `/api/users/{handle}` serves the same read model as JSON.
236
237URLs settled as root handles with grouped application routes
238([0004]decisions/0004-root-handles-grouped-routes.md), superseding
239[0003]decisions/0003-scoped-urls.md the same day. A twenty-word reserved list in
240`OrgName::new` keeps handles from shadowing routes.
241
242#### Decisions worth remembering
243
244- **`PublicProfile` has no email field, deliberately.** `Identity` does, and `/api/me`
245 returns it, because that endpoint describes the caller to themselves. Giving the type
246 that reaches the page nowhere to put an email makes the leak impossible rather than
247 merely avoided.
248- **`viewer_is_owner` is decided in the use case**, so the web form and `/api` cannot
249 disagree about who may edit. Tested for a signed-in stranger and a non-owner member —
250 "signed in" quietly becoming "allowed" is the usual failure.
251- **`Organization::update_profile` clears on blank input** rather than storing
252 whitespace, so cleared and never-set are one state and the page renders one case. A
253 rejected edit applies nothing.
254- **Bio length counts characters, not bytes.** A byte check would reject a bio of
255 accented text well under the limit.
256- **`path_param` is an attribute macro in 0.5**, not function-like. The vendored crate
257 is the authority for the pinned version, not the docs on `main`.
c50d9adfeat: profile settings with flash feedback24d
258- **Components are invoked bare inside `view!`**, and `if`/`match`/`for`/`let` are
259 native to the macro.
260- **`#[query_params]` needs `error = …`** to work with `?`; otherwise the error borrows
261 from `cx` and escapes the handler.
262- **Forms re-render on failure and redirect on success.** Redirecting after a
263 validation error throws away what was typed and hides the reason.
264- **Styling is Tailwind via Topcoat's build script**, with registry components copied
265 in rather than depended on ([0005]decisions/0005-tailwind-and-copied-components.md).
266 Components reference theme tokens, never raw colours.
3954b45docs: record milestone 2 phase 125d
267
6a2a925docs: close Milestone 3, plan Milestone 4a8d
268### Milestone 3 — Repo model · done
02eb2e4feat: GitStorage port and DiskGitStorage24d
269
6a2a925docs: close Milestone 3, plan Milestone 4a8d
270Repositories exist as records and as bare repos on disk, and they appear on the
271profile. Domain, both persistence adapters, `GitStorage` with `DiskGitStorage` behind
272it, `create_repo` / `view_repo` / `list_repos`, the `/{handle}/repos/new` form and
273`/{handle}/repos/{name}` page, the profile's Repositories section, and
274`/api/users/{handle}/repos`. How git is invoked is recorded in
02eb2e4feat: GitStorage port and DiskGitStorage24d
275[0006]decisions/0006-git-binary-behind-narrow-ports.md.
276
6a2a925docs: close Milestone 3, plan Milestone 4a8d
277**Verified in a browser and by curl:** the owner creates a repo through the form, a
278bare repo appears at `{data_dir}/{handle}/{name}.git`, and it lists on the profile; a
279private repo is absent for a signed-out visitor on both the page and `/api`; an unknown
280handle 404s rather than answering `[]`. `git clone` does not work yet — Milestone 4.
281
02eb2e4feat: GitStorage port and DiskGitStorage24d
282#### Decisions worth remembering
283
284- **`git init` on an existing repository exits 0 and re-initialises in silence.**
285 Measured, not assumed. So `AlreadyExists` has to be our own `path.exists()` check —
286 there is no exit code to key off. Refusing rather than adopting matters because a
287 directory with no matching row is an orphan from a crashed create, and re-initialising
288 it would resurface a private repository's objects under a fresh record.
289- **`git init` creates missing parent directories itself**, so there is no
290 `create_dir_all` before it. This was in the plan and the probe removed it.
291- **`--template=` takes a new bare repo from 18 files to 2.** The default seeds sixteen
292 `.sample` hooks. Timed at 15.2ms against 13.1ms across 20 runs — so the ~2ms is not
293 the reason; Steid installs its own hooks later and the samples would be noise to work
294 around.
295- **`--initial-branch=main` is explicit** so the host's `init.defaultBranch` cannot
296 decide it. This machine's git already says `main`, which is precisely why a drift
297 would go unnoticed — hence the test.
298- **`GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` are pointed at `/dev/null`**, and the
299 five `GIT_*` variables that redirect object storage are removed from the child
300 environment. `GIT_DIR` was checked and does *not* override an explicit path argument,
301 but `GIT_OBJECT_DIRECTORY` does redirect where objects land, and the failure is
302 silent — the repository just looks empty.
303- **One `run_git` owns the invocation.** With a single caller this looks premature; it
304 is a private function rather than a public abstraction for that reason. The point is
305 that Milestone 4's `http-backend` spawn cannot quietly disagree about isolation.
306- **`tokio::process` and `tokio::fs`, never the `std` equivalents.** `init_bare` is not
307 hot — ~13ms, once per repository — but `remove_dir_all` on a repo with real history
308 walks every loose object and would stall a runtime worker. The performance that
309 matters is Milestone 4's per-request spawn, not this.
310- **`tempfile` for test fixtures, not `target/`.** Parallel-safe by construction and
311 self-cleaning on panic. Debris under `target/` would be actively harmful here, since
312 `init_bare` refuses a path that already exists.
0c5ca49feat: list repositories on the profile8d
313- **Membership is resolved once per listing, not once per row.** Obvious in hindsight;
314 the shape that invites the mistake is filtering inside a loop that can `await`.
315- **One empty state serves "no repositories" and "none you may see".** A distinct
316 message for the second — or any count — leaks that private repositories exist and how
317 many. Tested, because it is the kind of thing a later "helpful" tweak would undo.
285f5fdfeat: create and view repositories through the browser24d
318- **`redirect()` is a 307, and 307 preserves the method.** Post/redirect/get needs a
319 303, or the browser re-POSTs the form to its redirect target. Milestone 2's settings
320 form shipped with this and nothing caught it — every test passed, because the tests
321 are on the use case and the bug is in the reply. Found by following the redirect with
322 curl. The fix is a `StatusCode::SEE_OTHER` plus a `Location` pair inside `view!`,
323 wrapped as `web::context::location`, because `see_other()` is a response type and
324 `#[page]` must return a view for the layout to wrap the failure re-render.
325 `RedirectError::new` is private, so a 303 cannot be built as an error, and the
326 error-to-response path only downcasts topcoat's own error types — a custom one
327 becomes a 500.
328- **`#[page]` returns a view; `#[route]` returns a response.** That is the whole reason
329 the redirect is spelled awkwardly: a form handler needs both a redirect and a
330 full-page re-render, and only the view path gets the layout.
331- **Boolean HTML attributes take an explicit value in `view!`**`required=(true)`, not
332 bare `required`, which fails to parse. `false` omits the attribute entirely, so
333 `selected=(bool)` on an `<option>` is correct rather than rendering `selected="false"`.
334- **`topcoat ui add select` needs the `icon-iconify` feature and a staged icon set.**
335 The chevron comes from `feather`, staged in `build.rs`. No new crates, but the build
336 fails with a clear message until the set is staged.
11e7a39feat: create_repo use case24d
337- **`is_org_owner` moved to `application/authz.rs`** on its second caller. Owner-ness
338 gates the profile edit, repo creation, and later PATs and push; two copies of an
339 authorization predicate drift, and the direction they drift is open.
340- **The compensating transaction is safe to do by path.** `remove` after a failed save
341 can only ever delete what `init_bare` just created, because the loser of a concurrent
342 create never gets past `init_bare`. Non-obvious enough that it is commented in the
343 code as well as here.
344- **Compensation is best-effort.** If the removal also fails, the caller still gets the
345 error that started it — an orphaned directory is the documented failure mode, and
346 replacing the real error with the cleanup's error would hide the cause.
347- **`InMemoryGitStorage` enforces `AlreadyExists` too.** A permissive fake would let
348 `create_repo` pass while the real adapter refused. The fake mirroring the rule is the
349 point of having two implementations.
6a2a925docs: close Milestone 3, plan Milestone 4a8d
350- **`/api` resolves the handle without loading a profile.** `handle_param` split out
351 of `profile_for` so the repo listing route 404s on `list_repos`' own `None`. Routing
352 it through the profile would have made that `None` unreachable and left the page and
353 `/api` disagreeing about what an empty portfolio means — the exact duplication
354 `architecture.md` says to watch for.
355- **The `/api` listing is a bare array, not an envelope.** Matches `/api/users/{handle}`
356 returning a bare object. Pagination later means a wrapper and a breaking change; taken
357 knowingly, since personal-first means a handful of repositories and there are no
358 consumers yet.
02eb2e4feat: GitStorage port and DiskGitStorage24d
359- **`Repository::description` stays.** Added unrequested and flagged; kept on review
360 because this milestone's own "Done when" puts repositories on the profile, which
361 makes it a consumer inside the milestone rather than speculation. Worth noting the
362 window that closed: the repositories migration had not yet been applied to the dev
363 database, so removing the column would have been a free in-place edit rather than a
364 second migration.
365
e0856ebfeat: clone a public repository over HTTP8d
366### Milestone 4a — Clone over HTTP · done
367
368`git clone` works against a public repository, for anyone, with no credentials.
369`GitProtocolServer` (CGI-shaped) with `GitHttpBackend` behind it, the `serve_git` use
370case, and three routes under `/{handle}/repos/{name}.git/`. Bodies stream both
371directions.
372
373**Verified against a real client**, not only by unit test: an anonymous `git clone` of a
374201-ref repository returns 201 commits and 203 refs with `HEAD` matching the origin, on
375protocol v2 and on v0; a private repository answers 404 to an anonymous clone but 200 to
376its owner's browser session; `git push` is refused with 403; and unknown repo, unknown
377handle, missing `service`, an unknown service, a missing `.git` suffix, and a
378dumb-protocol object path all answer 404 while the repository page still answers 200.
8ed37b0docs: probe git http-backend's CGI contract8d
379
380`git http-backend`'s contract, probed against git 2.50.1 by driving the CGI from a
381throwaway server and cloning through it. Everything below is measured, not read.
382
383#### The contract
384
385- **`HTTP_CONTENT_ENCODING`, not `CONTENT_ENCODING`.** CGI gives only `Content-Type` and
386 `Content-Length` unprefixed names; every other request header is `HTTP_`-prefixed, and
387 `http-backend` looks for the prefixed one. **This is the finding that would have cost a
388 day.** With the wrong name, `http-backend` hands the still-compressed body to
389 `upload-pack`, which dies with `bad line length character` and the client reports
390 `fatal: expected 'packfile'` — nothing names gzip, or the environment, anywhere in the
391 failure. And it only happens once a repository has enough refs for the client to bother
392 compressing: a one-ref test repo passes.
393- **`HTTP_GIT_PROTOCOL`** carries `version=2` through to `upload-pack`. Clones verified
394 on v2 and on `protocol.version=0`, both 201 commits, both gzipped.
395- **`GIT_PROJECT_ROOT` plus `PATH_INFO`**, where `PATH_INFO` is the on-disk path relative
396 to the root. Steid's URL and its storage layout differ — `/{handle}/repos/{name}.git/…`
397 against `{data_dir}/{handle}/{name}.git` — so the adapter rewrites the middle segment
398 out. `GIT_PROJECT_ROOT` is the data directory.
399- **`GIT_HTTP_EXPORT_ALL=1` is required.** Without it every repository answers `Status:
400 404 Not Found` and `Repository not exported`, unless a `git-daemon-export-ok` marker
401 file sits in the bare repo (confirmed: dropping that file in re-enables it).
402- **Headers are CRLF-terminated and end at `\r\n\r\n`.** No bare-LF variant was
403 observed, but the adapter should accept one rather than hang.
404- **`Status:` appears only on failure.** Its absence means 200, and it must be
405 translated, not forwarded as a header.
406- **`http-backend` sets its own `Content-Type` and cache headers**`Expires: Fri, 01
407 Jan 1980`, `Pragma: no-cache`, `Cache-Control: no-cache, max-age=0, must-revalidate`.
408 Steid forwards them rather than inventing its own.
409
410#### Decisions worth remembering
411
412- **`GIT_HTTP_EXPORT_ALL`, never `git-daemon-export-ok`.** The marker file is git's own
413 visibility mechanism and it looks tempting, but visibility lives in the `repositories`
414 table and the use case is what enforces it. A marker file would be a second source of
415 truth for the same question, free to drift from the first, and the drift direction is
416 "private repository still clonable". Steid decides; git is told to stop asking.
417- **`receive-pack` is refused by default**`Status: 403 Forbidden`, `Service not
418 enabled: 'receive-pack'`, without any configuration. Convenient for 4a, but the routes
419 still refuse writes explicitly rather than leaning on it: a default that helpfully
420 changes is not an authorization decision.
421- **A non-zero exit can arrive after the headers are already out.** The gzip failure
422 exited 1 having emitted a complete, successful-looking header block. So the exit code
423 cannot gate the response — by the time it is known, the status is sent. It belongs in
424 the log.
425- **A missing repository is `Status: 404` with exit 0.** Failure is reported in the
426 CGI stream, not the exit code, and the two disagree in both directions.
e0856ebfeat: clone a public repository over HTTP8d
427- **The router is the allowlist.** Only `info/refs`, `git-upload-pack` and
428 `git-receive-pack` are routed. Handed any other path, `http-backend` serves
429 dumb-protocol object files straight off disk — a read of a repository nothing
430 authorized. Verified: `/…​.git/objects/info/packs` answers 404.
431- **The endpoint is named by the route, not parsed from the path.** Three routes, three
432 literal `GitEndpoint` values, and `serve_git` rebuilds `path_info` from the validated
433 handle and name. The string that decides authorization and the string handed to git
434 are therefore the same string.
435- **Existence is settled before permission.** A push to a repository the actor cannot
436 see answers 404, not 403 — a 403 would confirm a private repository by that name
437 exists. Costs nothing to get right at the start and is invisible to test later.
438- **`BufReader` is what makes the header/body split safe.** The reader keeps whatever it
439 read past the blank line, so handing the reader itself back as the response body
440 carries the already-buffered first bytes of the pack with it. Parsing headers into a
441 separate buffer and then streaming the rest would silently drop them.
442- **The child's exit code cannot gate the response.** A protocol failure exits non-zero
443 *after* a complete, successful-looking header block has been written. By the time the
444 status is known it has been sent, so the exit code goes to the log and nowhere else.
445- **Stderr must be drained, not merely piped.** An unread pipe fills and blocks the
446 backend mid-transfer. It is read in the same task that reaps the child.
447- **`body_limit` does not exist** in `topcoat-router` 0.5.0 — the warning carried from
448 [0001]decisions/0001-git-over-http-not-ssh.md is stale. Bodies are read by the
449 handler with a caller-chosen limit via `to_bytes`, and the git routes take `Body`
450 unbuffered so no limit applies at all.
451- **`impl<B> IntoResponse for http::Response<B>`** means a handler can return its own
452 `http_body::Body` and Topcoat re-bodies it. That is what lets the pack stream without
453 a framework-specific body type.
8ed37b0docs: probe git http-backend's CGI contract8d
454
a814db5feat: push and clone private repositories with a token8d
455### Milestone 4b — Push and tokens · done
456
457`git push` works over HTTP for the owner, and a private repository is clonable by
458someone holding a token for it. `PersonalAccessToken` with both storage adapters,
459`issue_token` / `list_tokens` / `revoke_token` / `authenticate_token`, HTTP Basic on the
460git routes, and token management at `/{handle}/settings/tokens`. Decisions recorded in
461[0007]decisions/0007-tokens-over-http-basic.md.
462
463**Verified end to end**, issuing the token through the UI rather than seeding one:
464push of 201 refs to a public repo and to a private one; clone of the private repo
465returning 201 commits; anonymous clone of the public repo still open with no prompt;
466401 with `WWW-Authenticate: Basic` for a private repo, a **nonexistent** repo, an
467unknown handle and a push advertisement alike; a wrong token challenged rather than
468accepted; and after revoking, both clone and push answer 401 while the public repo stays
469open. The token is shown once and never again on reload, the revoke button disappears
470from the list, and the page is 403 for anyone else.
471
472#### Decisions worth remembering
473
474- **`http-backend` refuses `receive-pack` by default, and Steid authorizing the push is
475 not enough.** The symptom is a 403 that looks like Steid's own refusal but is git's:
476 `Service not enabled: 'receive-pack'`. It needs `-c http.receivepack=true` **before**
477 the subcommand. That flag is set from a `GitRequest` field the use case turns on only
478 after the authorization check passes, so git remains a second refusal behind Steid's
479 rather than being switched on wholesale — if the rules are ever wrong, git still says
480 no.
481- **The uniform 401 is what makes authenticated cloning possible at all.** A git client
482 offers a credential only after a 401, so the 4a behaviour of answering 404 for a
483 private repository made an authenticated private clone unreachable. Extending the 401
484 to repositories that do not exist is what keeps it from leaking which private names
485 are real.
486- **A bad credential falls through to anonymous rather than failing.** The caller then
487 gets the same challenge as someone who presented nothing and can try again, which is
488 also how a stale session cookie behaves.
489- **Basic accepts the token in the password field, or in the username with no password.**
490 Git puts it in the password; people paste it into the username. The alternative is an
491 authentication failure with nothing to explain it.
492- **Issuing a token deliberately does not redirect**, unlike every other form here. The
493 secret exists only in that response, and surviving a redirect would mean putting a live
494 credential in a URL — browser history, logs, referrers. Reloading issues a second
495 token, which is harmless and visible in the list.
496- **Revoking someone else's token is `NotFound`, not `Forbidden`.** That a token id
497 exists but belongs to another user is not a fact worth confirming.
498- **Tokens do not expire.** A credential pasted into a machine and forgotten is worth
499 less if it stops working silently; revocation is the control that matters.
500- **`in_memory.rs` keeps `mod tests` in the middle of the file**, like `sqlite.rs`.
501 Appending an implementation to the end lands it inside a later impl block.
502
338102bdocs: plan Milestone 5, with the fork/exec cost measured8d
503#### Measured, for Milestone 5
504
505Taken at the end of 4b, on a 201-commit repository, 50 runs averaged per command:
506
507| Command | Per call |
508|---|---|
509| `git rev-parse HEAD` | 11.2 ms |
510| `git ls-tree -l HEAD` | 11.7 ms |
511| `git cat-file -p HEAD:` | 11.6 ms |
512| `git log -20 --format=…` | 11.8 ms |
513| `git for-each-ref` | 14.4 ms |
514
515**The cost is starting git, not the query** — every command lands in the same band
516regardless of the work it does, matching the ~13ms `git init --bare` from Milestone 3.
517A three-call page is therefore ~35ms of pure overhead, and a per-file last-commit column
518at one call per entry would be ~230ms for twenty files. This is the input
519[0006]decisions/0006-git-binary-behind-narrow-ports.md asked for before reconsidering
520`gix` on the read path.
521
dce0bf3feat: browse a repository's files and history8d
522### Milestone 5 — Repo browsing · done
523
524A repository is readable on the web: the file tree at a revision, a file's contents with
525line numbers, and the commit log. `ObjectId` / `RefName` / `RepoPath` / `EntryKind` /
526`TreeEntry` / `CommitSummary` in the domain, a `GitQuery` port with `DiskGitQuery` and an
527in-memory fake behind it, `browse_repo` and `repo_log` read models, and pages at
528`/{handle}/repos/{name}`, `/tree/{rev}`, `/tree/{rev}/-/{path}`, `/log` and `/log/{rev}`.
529The read-path decision and its upgrade ladder are in
530[0006]decisions/0006-git-binary-behind-narrow-ports.md#amendment--20260829-the-milestone-5-read-path.
531
532**Verified against Steid's own repository, hosted on Steid.** A freshly created repo
533shows push instructions; after pushing 57 commits the page lists the tree; directories
534descend and breadcrumb back; `src/domain/session.rs` renders with line numbers and
535correct HTML escaping; the log shows 50 commits with author and relative time. Unknown
536revision, unknown path, `../etc/passwd`, unknown repo and unknown handle all 404, clone
537still works, and a private repository answers 404 to an anonymous visitor on **every**
538browse route while its owner sees it.
539
540#### Decisions worth remembering
541
542- **`cat-file --batch-check` with the spec on stdin is the lookup primitive.** It exits
543 **0** and prints `<spec> missing` for anything unresolvable, which is what makes "not
544 found" a *value read off stdout* rather than an exit code to interpret. The rule the
545 adapter follows: **a non-zero exit is always an error; absence is a marker in the
546 output** (`missing`, `ambiguous`, `dangling`, `notdir`). The obvious alternatives are
547 worse — `rev-parse --verify --quiet` returns 1 for an unknown ref but 128 for a missing
548 repository, and `ls-tree` pointed at a blob is a `fatal:` for what is, to a visitor, a
549 404. Passing the spec on **stdin** also means no revision or path can ever be read as a
550 flag, whatever validation upstream does or stops doing.
551- **`git log` in a repository with no commits is a fatal error, not empty output**, hence
552 resolving the revision first. An extra fork, in exchange for not reading meaning out of
553 a localised stderr string.
554- **`%s` is git's *subject*, not the first line** — with no blank line in the message it
555 joins the whole first paragraph with spaces. The adapter takes `.lines().next()` rather
556 than trusting that.
557- **Negative `%ct` exists** in imported histories and would panic `UNIX_EPOCH + Duration`.
558- **A symlink is indistinguishable from a file in the object store** — both are blobs — so
559 `read_blob` returns a symlink's target path as its content. Deliberate; making it
560 `Ok(None)` would cost an extra `ls-tree` of the parent.
561- **The empty repository is a first-class state, not an error.** Steid creates
562 repositories empty and Milestone 4 made it easy to have one never pushed to, so
563 `default_branch` returning `None` means "no commits" and the page offers the three
564 commands to push rather than a broken listing.
565- **`%2F` in a revision segment survives routing.** Topcoat matches on the raw path and
566 percent-decodes after, so a slashed branch works as a single segment — which is what
567 makes the `/-/` separator sufficient without a ref lookup.
568- **Three agents worked in parallel on disjoint files**, with the port, the fake and a
569 non-panicking stub adapter written first so neither branch could break the other's
570 build. The stub answering "nothing there" rather than `todo!()` is what let the pages
571 be developed and run before the adapter existed.
572
3dd7293feat: ship Steid as an installable binary8d
573### Milestone 5b — Deployable by anyone · in progress
574
575Distributed as a binary plus its `assets/` directory, installed by one command, behind
576Caddy for automatic HTTPS. `release.sh`, `install.sh`, `deploy/`, a `README.md`, and the
577operability the service needs: `/healthz`, `STEID_SETUP_TOKEN`, and rate limiting.
578
579#### Topcoat findings, both load-bearing
580
581- **`topcoat::start()` already handles SIGTERM.** `serve()` calls `serve_until(…,
582 shutdown_signal())`, and that selects on Ctrl+C *and* SIGTERM on Unix: it stops
583 accepting, drops the listener so a replacement process can bind the port, and drains
584 in-flight requests up to `shutdown_timeout`. So systemd stop/restart does not cut a
585 clone mid-pack, and **no wiring was needed** — the work was finding this out rather
586 than building it.
587- **The peer socket address is not reachable from a handler.** `internal_serve` discards
588 it at accept time (`let (stream, _remote) = accepted?;`) and never puts it on the
589 request extensions; the context exposes only parts, method, uri, headers and
590 extensions. **Any IP-based decision in Steid is therefore header-based by necessity**,
591 not by choice, and closing that would take an upstream change.
592
593#### Decisions worth remembering
594
595- **The rate limiter keys on the *rightmost* `X-Forwarded-For` entry.** A proxy
596 *appends* the address it accepted from, so the last entry is the one the nearest proxy
597 wrote and the only one a client cannot forge. The leftmost — what "the real client IP"
598 usually means — is exactly the attacker-controlled one. Two proxy hops collapse clients
599 onto the inner proxy's address, which is stricter, so being wrong that way is safe.
600- **A global cap backs the per-key one**, because without a peer address a forged key
601 cannot be disproved. It turns key forgery from a total bypass into a modest speed-up.
602 The cost is that a flood can lock the login form for a minute — a recoverable denial
603 against an unrecoverable guessed password.
604- **The limiter's map is bounded.** An unbounded map keyed by attacker-controlled values
605 is itself the denial of service; at the cap it sweeps expired entries and otherwise
606 falls through to the global window, which is stricter rather than permissive.
607- **glibc on bullseye, not static musl.** musl failed on `ring` (Debian's `musl-gcc`
608 rejects `-m64`), but the decisive argument is that **musl buys a dependency-free binary
609 and Steid hard-requires `git` on PATH** — the portability is unusable. Bullseye pins the
610 glibc floor at 2.31, covering Debian 11+ and Ubuntu 20.04+; bookworm would need 2.36
611 and silently exclude Ubuntu 22.04.
612- **The runtime binary links a TLS stack it never uses.** `ring``rustls``ureq`
613 Topcoat's `icon-iconify`/`tailwind`, whose `ureq` downloads the Tailwind CLI *at build
614 time*. Those features are on the normal dependency as well as the build one, so the
615 crypto ships too. Moving them would shrink the binary and drop an unused dependency
616 from the attack surface. Not attempted; it is also what made the musl build fail where
617 it did.
618- **`STEID_SETUP_TOKEN` does not undermine [0002]decisions/0002-first-run-claim-not-config-bootstrap.md.**
619 What that ADR refused to put in configuration was the owner's *password* — long-lived,
620 goes stale, ends up in a repository. This is the one-time claim secret the operator was
621 already copying out of a log line. It is validated for strength, never printed, and
622 ignored entirely once claimed.
623
4ef3945feat: repository settings, README rendering, branch switcher, raw files7d
624### Gap-filling while 5b was blocked on DNS · done
625
626Built while the domain transfer was in flight, so none of it belongs to a milestone.
627Three things: repository settings, browse usability, and README rendering — the last of
628which is Milestone 6's markdown pipeline arriving early because a repository page needed
629it.
630
631#### Repository settings — a hole, not a feature
632
633Repositories were **create-only**. No edit, no delete, and therefore **no way to
634un-publish something published by accident**. Now: description and visibility are
635editable, and a repository can be deleted behind a type-the-name confirmation.
636
637- **Delete writes the row first, then the directory best-effort.** An orphaned directory
638 only blocks reusing that name and is already `create_repo`'s documented failure mode;
639 an orphaned row is a repository that lists on the profile and 404s when clicked. The
640 visible failure is the worse one, so the ordering avoids it.
641- **Two different refusals, deliberately.** A repository the actor may not *see* is
642 `NotFound` (a 403 would confirm a private repo by that name exists); one they can see
643 but do not own is `Forbidden`, matching `create_repo`, because the resource is public
644 anyway. The page collapses both to 404.
645- **Renaming is still impossible, now with a comment saying why.** The bare repo lives at
646 `{data_dir}/{handle}/{name}.git`, so a rename is a directory move that breaks every
647 existing clone — and doing the row half only breaks them silently.
648
649#### Markdown — raw HTML is structurally impossible, not merely disabled
650
651- **`pulldown-cmark` does not sanitise URLs.** Its `escape_href` only percent-escapes, so
652 `javascript:` would have survived into a rendered README. The renderer uses a scheme
653 **allowlist** (`http`, `https`, `mailto`, `ftp`, `ftps`, `tel`) and strips ASCII control
654 characters *before* the check as well as on output, because browsers strip them too and
655 `java&#9;script:` is otherwise a live bypass.
656- **Adding the crate with `default-features = false` turned off its `html` feature**, so
657 `push_html` does not exist in this build. The renderer therefore walks the event stream
658 and writes every tag itself. Forced rather than chosen, and better: the emittable tag
659 set is exactly what the writer spells out, so raw HTML cannot pass through by
660 construction. `Event::Html` is written as *text*, so `<script>` is visible and inert
661 rather than silently vanishing.
662- **Escaping goes through `topcoat::view::HtmlContext`**, the same escaper `view!` uses —
663 not a hand-rolled one.
664- **Smart punctuation is off**: it rewrites `--flag` to an en dash, quietly corrupting CLI
665 flags in README prose.
666- Relative *links* are rewritten into tree URLs; relative *images* are deliberately left
667 alone, because a tree URL serves a page and rewriting would swap a 404 for a broken
668 image. Pointing them at `/raw/` is the obvious follow-up now that route exists.
669
670#### Browse — the switcher and raw files
671
672- **`for-each-ref` asks only for `%(refname)`.** `%(objecttype)` is `commit` for both a
673 branch and a lightweight tag, so the *namespace* is the only thing that answers
674 branch-versus-tag.
675- **Raw files are served as `application/octet-stream`, always** — never the file's own
676 type and never guessed from an extension — with `nosniff`, `Content-Disposition:
677 attachment` and `default-src 'none'; sandbox`. A repository-supplied `.html` or `.svg`
678 served as its real type on this origin is stored XSS against the viewer's session.
679 `text/plain` was rejected because browsers render it and have been talked into sniffing
680 it as HTML. The filename is repository content arriving in a header, so it is reduced to
681 `[A-Za-z0-9._-]` against header injection.
682- **The switcher costs one more fork (~13ms) on tree and log pages**, measured
683 interleaved against `ls-tree` to cancel out load — same band, confirming again that the
684 fork is the cost. It is not called on the repository page or an empty repository.
685
686#### Verified
687
688Against a running instance with a deliberately hostile README: **zero real `<script>` or
689`<img>` tags in the output**, the markup present once as escaped inert text, the
690`javascript:` link stripped of its `href` while `https://example.com` survived, the table
691rendered, raw bytes SHA-256 identical with the headers above, the switcher listing a
692branch and a tag, and the Settings link visible to the owner and absent for anonymous.
693
ef23868feat: rebuild the profile page on flat navigation7d
694### The profile page, rebuilt · done
695
696Flat navigation, per [ui.md]ui.md#the-profile-page. Tabs instead of stacked sections,
697one lead item distinguished by weight and space rather than size, hairlines instead of
698boxes, and a `/{handle}/repos` index for the tab to point at.
699
700#### Decisions worth remembering
701
702- **Existing rows were stamped with the migration time, not left at 0.** A repository
703 pushed to for months would otherwise read "updated 56 years ago" on the very page the
704 column exists to order. Wrong by a bounded amount and self-correcting on the first
705 push, against wrong forever and looking broken.
706- **The single-pin invariant lives in the use case, not in a constraint.** The rule is
707 "pinning this unpins that", and a unique index can only *refuse*, never unpin. The two
708 writes are not one transaction; a crash between them leaves nothing pinned, which is
709 the harmless direction.
710- **`updated_at` is touched on the authorized write path**, before the protocol is
711 reached — so a clone never moves it and neither does a refused push. It records that a
712 push was *authorized*, not that it succeeded; waiting for the subprocess would be a
713 much larger change for a small gain. A failed touch is logged and does not fail the
714 push.
715- **The lead is picked out of the listing**, not fetched separately, so the lead and the
716 list cannot disagree about which repository is pinned.
717- **Ordering changed from name to recency**, and two existing tests had been passing by
718 accident: everything created inside one second tied and fell back to the alphabet.
719
720#### Two Topcoat traps, both silent
721
722- **`let profile = …` in a module with `#[page] async fn profile` is a unit-struct
723 pattern, not a binding.** The page's name is a unit struct in module scope. The error
724 points at neither the page nor the shadowing.
725- **Forgetting `topcoat asset bundle` after `cargo build` produces a broken-looking
726 page, not an error.** New utility classes are simply absent, so gaps collapse and type
727 falls back to browser defaults — it reads as a design mistake. Cost one confused
728 screenshot.
729
0f19654feat: security headers on every response1d
730### Security review before going public · done
731
732Attempted with three blind reviewer agents; all three died to the machine sleeping, so
733the review was done directly instead. **Findings were verified by attacking a running
734instance, not by reading comments** — the codebase argues confidently for its own
735safety and those arguments are exactly what needed testing.
736
737#### What held, demonstrated rather than assumed
738
739| Probe | Result |
740|---|---|
741| Private repo: page, tree, log, settings, raw, `/api` | 404 on every surface; `/api` does not leak the name |
742| `rev=--upload-pack=touch /tmp/pwned` and five other injections | 404, no execution |
743| Four path-traversal spellings incl. `%2e%2e` and `....//` | 404 |
744| Login brute force | 10 attempts then 429 |
745| Error bodies | bare "not found", nothing internal |
746| Raw `.html` and `.svg` from a repo | `octet-stream` + `nosniff` + `attachment` + sandbox CSP |
747| Filename `ev"il; drop.txt` in a header | sanitised to `ev_il__drop.txt`, no header injection |
748| `<script>` in a viewed file | escaped, zero live tags |
749
750**The production cookie is `__Host-session; HttpOnly; Secure; SameSite=Lax; Path=/`**
751the strictest cookie form available, and it forbids a `Domain` attribute so a
752compromised subdomain cannot inject one.
753
754**A near-miss worth recording:** the first check appeared to show production serving
755`steid-dev-session` with no `Secure`. It was a testing error — **`dotenvy` reads `.env`
756from the working directory**, and running the binary from the repo root picked up the
757gitignored dev `.env`. Verify from a neutral directory, and note the same trap applies
758to the deployment: the systemd unit's `WorkingDirectory=/opt/steid` means a stray `.env`
759there would silently override the environment file.
760
761#### The one real finding: no security headers on HTML
762
763Fixed with a layer, deliberately a layer rather than a per-handler concern — a page
764added without them would simply not have them and nothing would fail.
765
766- **Steid renders no JavaScript at all**, not a script tag on any page, so
767 `default-src 'none'` is a policy the product can genuinely keep. That is far stricter
768 than a typical CSP and worth defending: if a feature ever needs script, weakening it
769 should be a deliberate decision.
770- `'unsafe-inline'` is granted for **styles only**, and only because Topcoat's icon
771 macro emits `style="vertical-align: -0.125em"`. A framework constraint, not a choice.
772- **`Referrer-Policy: no-referrer`**, not the usual `strict-origin-when-cross-origin`: a
773 private repository's URL contains its name, and a README may link anywhere, so a
774 referrer would hand that name to whatever the visitor clicked.
775- Headers are set with `entry().or_insert()`, never `insert()` — the raw endpoint's own
776 stricter `default-src 'none'; sandbox` must not be relaxed by a blanket overwrite.
777 Verified that it survives.
778
779#### Not covered, and why
780
781- **The member-but-not-owner path could not be demonstrated.** There is no registration,
782 so a second account cannot exist until Milestone 7. It is covered by use-case tests and
783 nothing else — re-verify for real the moment a second account is possible.
784- **No automated test asserts the headers.** There is no HTTP-level test harness in this
785 project, so a regression here would be silent. That is a gap, not a decision.
786- No dependency audit (`cargo audit` was not run), and no review of the deployment
787 scripts beyond the earlier container dry-run.
788
4414bbadocs: record the first real deployment22h
789### Deployed · jpgill.dev, 2026-09-04
790
791Steid runs on the public internet at **https://jpgill.dev**, on AWS Lightsail (Debian 13,
792x86_64, 1 GB), behind Caddy with a Let's Encrypt certificate, hosting its own source.
793Installed with `install.sh --tarball`, since the instance that will serve the releases is
794the one being installed.
795
796#### What the first real deployment taught
797
798- **A domain transfer moves the registrar, not the nameservers.** The transfer to Amazon
799 Registrar completed, the Route 53 zone held the right records, and every public resolver
800 returned nothing — because the `.dev` registry still delegated to the old provider's
801 nameservers. Diagnosed with `dig +trace`, which shows the delegation the world actually
802 follows rather than the zone you are looking at.
803- **The domain was `jpgill.dev`, not `jpgilldev.com`.** Inferred from an email address and
804 never confirmed, and by then it was baked into `install.sh`'s release URL and the
805 systemd unit. Ask what the domain is.
806- **`.dev` is HSTS-preloaded.** Browsers refuse plain HTTP with no click-through, so the
807 certificate is not optional — until Caddy has one the site is simply unreachable.
808- **The uniform 401 cost real time.** Pushing to a mistyped handle answered
809 `Authentication failed` rather than "no such repository", because
810 [0007]decisions/0007-tokens-over-http-basic.md deliberately makes an absent repository
811 indistinguishable from a private one. That trade was recorded as costing "a prompt on a
812 typo"; in practice it cost ten minutes and a confident wrong diagnosis, with someone
813 watching who knew the design. A stranger has nobody to ask.
814- **An upgrade silently discarded the operator's Caddyfile.** `install.sh` kept
815 `steid.env` across a re-run but rewrote the Caddyfile unconditionally, so a hand-added
816 `www` redirect vanished on the next upgrade and took the hostname with it. Now written
817 on first install only, with `--force-caddyfile` and `--www`. Found by testing the
818 upgrade path rather than waiting for it to happen.
819- **The upgrade path works and is verified**: claim, database, repositories, `steid.env`
820 and Caddyfile all survive `install.sh` re-run against a newer tarball.
821
822#### Renaming a handle, done by hand
823
824`current.md` recorded a rename as "a directory move, not a row update". That was exercised
825for real, renaming `jpgilldev` to `jamesgill` on the live instance: **one `UPDATE orgs SET
826name` and one `mv` of `{data_dir}/{handle}/`**. Everything else — users, repositories,
827tokens, sessions — keys off ids, so nothing else moved, and the account, password and
828access token all survived.
829
830So a rename feature is genuinely two writes. What makes it more than that is the
831consequence: every existing clone URL breaks, and there is no redirect from the old
832handle. That is the part to design, not the mechanics.
833
5d3dfa5feat: a global top bar, and pages choose their own width22h
834### The shell: top bar and per-page widths · done (2026-09-05)
835
836The root layout gained a slim global top bar — wordmark to `/`, and either "Sign in"
837or `@handle` / Settings / Sign out — and stopped imposing a width. Pages now choose
838their own column via two components in `layout.rs`: `narrow` (48rem — profile, forms,
839settings) and `wide` (80rem — tree, blob, log, the repo page). Verified in the
840browser across visitor and owner states, including sign-out.
841
842#### Decisions worth remembering
843
844- **A slim bar plus contextual tabs, deliberately not an app shell.** The reasoning
845 lives in [ui.md]ui.md#the-shell; the short form is that Steid's first audience is
846 an anonymous reader of someone's site, and GitLab-style rail chrome tells that
847 visitor they've landed in an admin tool.
848- **A `#[layout]` may take `cx: &Cx`** alongside `slot` — both recognized by name, no
849 other parameters. Documented in the vendored `topcoat-router-macro` docs, not
850 obvious from the README example.
851- **The layout imposes no width; pages wrap themselves.** Same philosophy as request
852 helpers being functions: a page that forgets a container renders full-bleed, which
853 is immediately visible, rather than silently inheriting a wrong one.
854- **The top bar costs an identity lookup on every page render** (session hash plus a
855 user row). Fine over local SQLite; worth remembering if page queries are ever
856 counted.
857
0aca94efeat: a repository is one place, and its landing page says what it is17h
858### The repository landing page · done (2026-09-05)
859
860`/{handle}/repos/{name}` became two columns — the file list and README on the left, an
861About sidebar on the right — and every repository page (tree, blob, log) picked up the
862same header and `Code` / `Commits` tab strip. New: a toolbar row carrying the revision
863switcher and branch/tag counts, a latest-commit bar, an About block with the
864description and detected licence, a facts list (commits, branches, tags, latest tag,
865pushed), and the clone URL moved into the sidebar.
866
867The layout and the map of where the next features slot in are in
868[ui.md]ui.md#the-repository-page. What is here is what the next session should not
869have to rediscover.
870
871**Verified** with the app running from a worktree on port 3100: this repository's own
872history pushed into it with two annotated tags and three branches, plus an empty
873repository and one with no README and no licence. Screenshots taken headless at 1280px
874and 800px, in both colour schemes, of the landing page, the empty and sparse states, a
875tree subpath, a blob and the log; the owner's view (with the Settings button) checked
876signed in. `cargo test` 467 passing, clippy clean.
877
878#### Decisions worth remembering
879
880- **The subprocess count was measured, not estimated.** A `git` shim on `PATH` logging
881 every invocation: **15 processes** for a repository with a README and a licence, **10**
882 without them, **1** for an empty one — up from 7. Worth repeating the trick; it took
883 two minutes and contradicted a careful hand-count.
884- **The description has exactly one home, and it is the sidebar.** Putting it in the
885 header too makes the header tallest on the repositories with the most to say, which
886 pushes the code below the fold on exactly the pages that deserve it least.
887- **`repo_summary` takes the revision and the root listing the page already read.** A
888 self-contained use case would have spent two more processes re-reading what the caller
889 had in hand. The five calls it does make run under one `tokio::try_join!`.
890- **`organise_refs` was split out of `list_refs`** so the landing page gets the counts
891 and the switcher's list from one `for-each-ref`. Two places deciding ref order is two
892 places to change it.
893- **A `#[component]`'s name is a unit struct in module scope, and the copied-in
894 components are in that scope too.** A parameter named `label` inside `repo.rs` does
895 not bind — it parses as the `label` component's unit struct, and the error says
896 "expected `str`, found `label`" without naming the component. The fix was renaming the
897 parameter to `term`. This is the same trap `CLAUDE.md` records for pages; registry
898 components widen it to every file that imports one.
899- **`bg-muted` is not a token.** Three `<pre>` blocks have been rendering with no
900 background since they were written, silently — the exact failure `ui.md` describes for
901 Tailwind classes the build never ships. `bg-surface` is the token. Two were fixed here;
902 `token.rs` still has one.
903- **The clone URL wraps rather than scrolls.** In an 18rem column an `overflow-x-auto`
904 URL looks like a truncated one, and the part hidden is the repository's own name.
905- **Light mode was checked by flipping the layout's `class="dark"` and rebuilding**,
906 because there is no toggle. Nothing was wrong on these pages. That is the cheapest way
907 to check it until a toggle exists, and it is worth doing before each UI step rather
908 than after several.
909
ab7fea9chore: plans setup1mo
910---
911
912## Reference: what attempt #2 proved
913
914Not this repo's progress. This is a catalogue of what was built and **verified working**
915in `steid-backup-2026-07-31`, so the rebuild can crib rather than rediscover.
916
917Final state: single crate, ~4,200 LOC, 60 passing tests, five milestones.
918
919### Identity
920
921Domain model (User, Organization, Membership, Actor, Role), `Email` and
922`PasswordHash` value objects, typed IDs, `DomainError`, four repository ports with
923in-memory and SQLite implementations each. `RegistrationPolicy` (Personal / Invite /
924Open) driving which routes exist. Argon2 hashing behind a `PasswordHasher` port with a
925stub for tests. Use cases: `bootstrap_owner`, `register_user`, `login`, `create_invite`.
926Signed-cookie sessions via an `AuthUser` extractor.
927
928**Gotcha:** organizations must be saved before users — the FK runs that direction.
929Both `bootstrap_owner` and `register_user` had to be fixed for this.
930
931### Repo model
932
933`Repository` entity, `RepoId`, `Visibility` (Public/Private), `RepoRepository` port,
934migration `006_create_repositories.sql`. `create_repo` use case validates the name,
935rejects duplicates, and initialises the bare repo on disk in the same call. Bare repos
936live at `{data_dir}/{org}/{repo}.git`, `data_dir` defaulting to `./data`. Repos are
937created empty, no initial commit, like GitHub.
938
939### Git over SSH
940
941`GitStorage` port (`init_bare`, `repo_path`) with `DiskGitStorage` shelling out to
942`git init --bare`. `GitProtocolServer` port (`upload_pack`, `receive_pack`) with
943`GitBinary` spawning `git upload-pack` / `git receive-pack` via
944`tokio::process::Command` and pumping stdio with `tokio::io::copy`.
945
946`serve_clone` and `serve_push` use cases enforce visibility and actor checks **before
947any protocol byte flows** — that ordering is the whole point of putting them in the
948application layer.
949
950#### SSH channel bridging
951
ca76e1bdocs: fix milestone cross-references after the reorder24d
952The fiddly part, and worth re-reading if SSH ever returns as a transport
953(milestone 8+ — [0001]decisions/0001-git-over-http-not-ssh.md chose HTTP):
ab7fea9chore: plans setup1mo
954
955- Store `Channel<Msg>` per `ChannelId` in the handler's map on `channel_open_session`
956- On `exec_request`, take the channel, split it with `into_stream()` +
957 `tokio::io::split`
958- Take stderr via `make_writer_ext(Some(1))` **before** `into_stream()` — that call
959 consumes the channel, so the order is not optional
960- No `data()` or `channel_eof()` handlers needed once the streams are split
961
962An earlier iteration used mpsc channels, custom `ChannelReader`/`ChannelWriter`, and a
963`spawn_blocking` thread. All of it was deleted and the result was simpler.
964
965### SSH key auth and authorization
966
967`SshKey { id, user_id, name, fingerprint, openssh }` + port, migration
968`007_create_ssh_keys.sql` (`fingerprint` UNIQUE). Fingerprints are SHA256 via
969`russh::keys::ssh_key::PublicKey::fingerprint(HashAlg::Sha256)`, stored as `SHA256:…`.
970`add_ssh_key` parses the openssh blob, dedupes on fingerprint, and re-encodes to a
971canonical form before storing.
972
973`auth_none` rejects. `auth_publickey` fingerprints the offered key, looks it up, and
974on a match stores `user_id` on the handler; `exec_request` builds the real `Actor`
975from it.
976
977Authorization rules as shipped:
978
979| Operation | Requirement |
980|---|---|
981| Clone, public repo | open |
982| Clone, private repo | any membership in the repo's org |
983| Push | `Role::Owner` membership in the repo's org |
984
985Web UI at `/{owner}/keys` — owner-only, lists fingerprints, accepts openssh via
986textarea, revokes per-row.
987
988**Verified end-to-end:** clone with an unregistered key → `Permission denied` (exit
989128); register via web UI → clone and push both succeed; second unregistered key →
990rejected at auth; revoke via web UI → subsequent clone rejected at auth.
991
992### Security notes
993
994Attempt #2 ran with a **named, deliberate backdoor** between milestones: SSH accepted
995any connection and passed a placeholder `Actor` (`UserId("ssh-anonymous")`), leaving
996push open to anyone who could reach the port. It was recorded with an explicit
997tightening point (`ssh.rs::exec_request`) and a closing milestone, and it did close.
998
999That practice is worth keeping. When this attempt opens a hole to make progress, name
1000it, name the line that closes it, and name the milestone.
1001
1002### Never built
1003
1004Repo browsing (tree/blob/log), HTTP smart protocol, personal access tokens, flash
1005messages, issues, PRs, blogs, pages, project showcases. Milestones 5–8 in
1006[ROADMAP.md]ROADMAP.md are all greenfield.