steid

@jamesgill /

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