| 1 | # Progress |
| 2 | |
| 3 | ## This attempt (#3, Topcoat) |
| 4 | |
| 5 | 190 tests. Active milestone in [current.md](current.md). |
| 6 | |
| 7 | ### Milestone 0 — Skeleton · done |
| 8 | |
| 9 | Topcoat 0.5 app serving pages, `AppConfig` from `STEID_*` env, SQLite pool in app |
| 10 | context. Split into a library plus a thin binary — the domain layer had no consumers |
| 11 | yet and read as ~30 dead-code warnings in a bare binary, and it unlocks `tests/`. |
| 12 | Topcoat's link-time page discovery works from a library; that was checked, not assumed. |
| 13 | |
| 14 | **Requires rustc ≥ 1.95.** On older toolchains `cargo add topcoat` silently resolves to |
| 15 | an empty `topcoat v0.0.0` placeholder instead of failing. |
| 16 | |
| 17 | ### Milestone 1 — Identity, thin · done |
| 18 | |
| 19 | **Domain.** Typed IDs, `Email`, `PasswordHash`, `OrgName`, `Organization`, `User`, |
| 20 | `Membership`, `Role`, `Actor`, `Session`, `SetupToken`, `DomainError`. Value objects |
| 21 | pair `new()` (validates) with `from_trusted()` (skips, for rows already validated). |
| 22 | |
| 23 | **Application.** `claim_instance`, `login`, `resolve_actor`, `record_session`, |
| 24 | `end_session`, `sweep_expired`. `PasswordHasher` port with Argon2 and a stub. |
| 25 | |
| 26 | **Infrastructure.** Migrations for orgs, users, memberships, sessions. In-memory and |
| 27 | SQLite implementations of every port. Root layout, `/setup`, `/login`, `/logout`, home, |
| 28 | and `/api/me`. |
| 29 | |
| 30 | `describe_identity` is the first use case with two consumers — the home page and |
| 31 | `/api/me` both read through it. Until that existed, "the application layer is |
| 32 | transport-neutral" was an assertion with one caller behind it. |
| 33 | |
| 34 | **Verified in a browser and by curl:** wrong token refused with nothing written; |
| 35 | correct token creates org + user + owner membership and signs the owner in; session |
| 36 | authenticates; logout clears cookie and row; wrong password bounces; right one signs |
| 37 | in; re-claiming a claimed instance is refused. |
| 38 | |
| 39 | #### Decisions worth remembering |
| 40 | |
| 41 | - **`Actor` is an enum with an explicit `Anonymous`**, not `Option<UserId>`. Attempt #2 |
| 42 | used a placeholder `UserId("ssh-anonymous")` and it became a security hole. A variant |
| 43 | can't be forgotten the way a sentinel can. |
| 44 | - **`login` verifies a dummy hash when no user matched.** Returning early on the |
| 45 | unknown-email path makes it measurably faster and leaks which addresses have |
| 46 | accounts. A test pins that the dummy stays parseable — if it stops being, `verify` |
| 47 | bails early and the defence dies silently. |
| 48 | - **`SetupToken` compares in constant time.** An early-return comparison leaks how much |
| 49 | of the token is right, which recovers it a character at a time. |
| 50 | - **The setup token is only in app context while unclaimed**, so a claimed instance has |
| 51 | nothing for a claim attempt to match. |
| 52 | - **SQLite ignores foreign keys unless asked**, per connection. `foreign_keys(true)` |
| 53 | plus a test that a user pointing at a missing org is refused. |
| 54 | - **`sqlx migrate add` stamps versions to the second** — three calls in one second |
| 55 | collide, leaving apply order ambiguous between tables that reference each other. |
| 56 | - **An unparseable role surfaces as an error**, never as "no membership". The latter |
| 57 | silently downgrades an owner to no access. |
| 58 | - **`Secure` session cookies over plain-HTTP localhost fail silently.** See |
| 59 | [runbook.md](runbook.md#steid_insecure_cookies--development-only). This one actually |
| 60 | bit, and it looked exactly like broken auth logic. |
| 61 | |
| 62 | ### Milestone 2 — Profile page · done |
| 63 | |
| 64 | `/{handle}` is the real profile page: label, handle, bio, and the section frame for |
| 65 | repositories, writing, and projects. Public, renders signed out, 404s on an unknown |
| 66 | handle, and resolves regardless of casing. `/` forwards a signed-in owner to their own |
| 67 | profile. `/api/users/{handle}` serves the same read model as JSON. |
| 68 | |
| 69 | URLs settled as root handles with grouped application routes |
| 70 | ([0004](decisions/0004-root-handles-grouped-routes.md)), superseding |
| 71 | [0003](decisions/0003-scoped-urls.md) the same day. A twenty-word reserved list in |
| 72 | `OrgName::new` keeps handles from shadowing routes. |
| 73 | |
| 74 | #### Decisions worth remembering |
| 75 | |
| 76 | - **`PublicProfile` has no email field, deliberately.** `Identity` does, and `/api/me` |
| 77 | returns it, because that endpoint describes the caller to themselves. Giving the type |
| 78 | that reaches the page nowhere to put an email makes the leak impossible rather than |
| 79 | merely avoided. |
| 80 | - **`viewer_is_owner` is decided in the use case**, so the web form and `/api` cannot |
| 81 | disagree about who may edit. Tested for a signed-in stranger and a non-owner member — |
| 82 | "signed in" quietly becoming "allowed" is the usual failure. |
| 83 | - **`Organization::update_profile` clears on blank input** rather than storing |
| 84 | whitespace, so cleared and never-set are one state and the page renders one case. A |
| 85 | rejected edit applies nothing. |
| 86 | - **Bio length counts characters, not bytes.** A byte check would reject a bio of |
| 87 | accented text well under the limit. |
| 88 | - **`path_param` is an attribute macro in 0.5**, not function-like. The vendored crate |
| 89 | is the authority for the pinned version, not the docs on `main`. |
| 90 | - **Components are invoked bare inside `view!`**, and `if`/`match`/`for`/`let` are |
| 91 | native to the macro. |
| 92 | - **`#[query_params]` needs `error = …`** to work with `?`; otherwise the error borrows |
| 93 | from `cx` and escapes the handler. |
| 94 | - **Forms re-render on failure and redirect on success.** Redirecting after a |
| 95 | validation error throws away what was typed and hides the reason. |
| 96 | - **Styling is Tailwind via Topcoat's build script**, with registry components copied |
| 97 | in rather than depended on ([0005](decisions/0005-tailwind-and-copied-components.md)). |
| 98 | Components reference theme tokens, never raw colours. |
| 99 | |
| 100 | ### Milestone 3 — Repo model · in progress |
| 101 | |
| 102 | Domain, both persistence adapters, and now `GitStorage` with `DiskGitStorage` behind |
| 103 | it. How git is invoked is recorded in |
| 104 | [0006](decisions/0006-git-binary-behind-narrow-ports.md). |
| 105 | |
| 106 | #### Decisions worth remembering |
| 107 | |
| 108 | - **`git init` on an existing repository exits 0 and re-initialises in silence.** |
| 109 | Measured, not assumed. So `AlreadyExists` has to be our own `path.exists()` check — |
| 110 | there is no exit code to key off. Refusing rather than adopting matters because a |
| 111 | directory with no matching row is an orphan from a crashed create, and re-initialising |
| 112 | it would resurface a private repository's objects under a fresh record. |
| 113 | - **`git init` creates missing parent directories itself**, so there is no |
| 114 | `create_dir_all` before it. This was in the plan and the probe removed it. |
| 115 | - **`--template=` takes a new bare repo from 18 files to 2.** The default seeds sixteen |
| 116 | `.sample` hooks. Timed at 15.2ms against 13.1ms across 20 runs — so the ~2ms is not |
| 117 | the reason; Steid installs its own hooks later and the samples would be noise to work |
| 118 | around. |
| 119 | - **`--initial-branch=main` is explicit** so the host's `init.defaultBranch` cannot |
| 120 | decide it. This machine's git already says `main`, which is precisely why a drift |
| 121 | would go unnoticed — hence the test. |
| 122 | - **`GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` are pointed at `/dev/null`**, and the |
| 123 | five `GIT_*` variables that redirect object storage are removed from the child |
| 124 | environment. `GIT_DIR` was checked and does *not* override an explicit path argument, |
| 125 | but `GIT_OBJECT_DIRECTORY` does redirect where objects land, and the failure is |
| 126 | silent — the repository just looks empty. |
| 127 | - **One `run_git` owns the invocation.** With a single caller this looks premature; it |
| 128 | is a private function rather than a public abstraction for that reason. The point is |
| 129 | that Milestone 4's `http-backend` spawn cannot quietly disagree about isolation. |
| 130 | - **`tokio::process` and `tokio::fs`, never the `std` equivalents.** `init_bare` is not |
| 131 | hot — ~13ms, once per repository — but `remove_dir_all` on a repo with real history |
| 132 | walks every loose object and would stall a runtime worker. The performance that |
| 133 | matters is Milestone 4's per-request spawn, not this. |
| 134 | - **`tempfile` for test fixtures, not `target/`.** Parallel-safe by construction and |
| 135 | self-cleaning on panic. Debris under `target/` would be actively harmful here, since |
| 136 | `init_bare` refuses a path that already exists. |
| 137 | - **Membership is resolved once per listing, not once per row.** Obvious in hindsight; |
| 138 | the shape that invites the mistake is filtering inside a loop that can `await`. |
| 139 | - **One empty state serves "no repositories" and "none you may see".** A distinct |
| 140 | message for the second — or any count — leaks that private repositories exist and how |
| 141 | many. Tested, because it is the kind of thing a later "helpful" tweak would undo. |
| 142 | - **`redirect()` is a 307, and 307 preserves the method.** Post/redirect/get needs a |
| 143 | 303, or the browser re-POSTs the form to its redirect target. Milestone 2's settings |
| 144 | form shipped with this and nothing caught it — every test passed, because the tests |
| 145 | are on the use case and the bug is in the reply. Found by following the redirect with |
| 146 | curl. The fix is a `StatusCode::SEE_OTHER` plus a `Location` pair inside `view!`, |
| 147 | wrapped as `web::context::location`, because `see_other()` is a response type and |
| 148 | `#[page]` must return a view for the layout to wrap the failure re-render. |
| 149 | `RedirectError::new` is private, so a 303 cannot be built as an error, and the |
| 150 | error-to-response path only downcasts topcoat's own error types — a custom one |
| 151 | becomes a 500. |
| 152 | - **`#[page]` returns a view; `#[route]` returns a response.** That is the whole reason |
| 153 | the redirect is spelled awkwardly: a form handler needs both a redirect and a |
| 154 | full-page re-render, and only the view path gets the layout. |
| 155 | - **Boolean HTML attributes take an explicit value in `view!`** — `required=(true)`, not |
| 156 | bare `required`, which fails to parse. `false` omits the attribute entirely, so |
| 157 | `selected=(bool)` on an `<option>` is correct rather than rendering `selected="false"`. |
| 158 | - **`topcoat ui add select` needs the `icon-iconify` feature and a staged icon set.** |
| 159 | The chevron comes from `feather`, staged in `build.rs`. No new crates, but the build |
| 160 | fails with a clear message until the set is staged. |
| 161 | - **`is_org_owner` moved to `application/authz.rs`** on its second caller. Owner-ness |
| 162 | gates the profile edit, repo creation, and later PATs and push; two copies of an |
| 163 | authorization predicate drift, and the direction they drift is open. |
| 164 | - **The compensating transaction is safe to do by path.** `remove` after a failed save |
| 165 | can only ever delete what `init_bare` just created, because the loser of a concurrent |
| 166 | create never gets past `init_bare`. Non-obvious enough that it is commented in the |
| 167 | code as well as here. |
| 168 | - **Compensation is best-effort.** If the removal also fails, the caller still gets the |
| 169 | error that started it — an orphaned directory is the documented failure mode, and |
| 170 | replacing the real error with the cleanup's error would hide the cause. |
| 171 | - **`InMemoryGitStorage` enforces `AlreadyExists` too.** A permissive fake would let |
| 172 | `create_repo` pass while the real adapter refused. The fake mirroring the rule is the |
| 173 | point of having two implementations. |
| 174 | - **`Repository::description` stays.** Added unrequested and flagged; kept on review |
| 175 | because this milestone's own "Done when" puts repositories on the profile, which |
| 176 | makes it a consumer inside the milestone rather than speculation. Worth noting the |
| 177 | window that closed: the repositories migration had not yet been applied to the dev |
| 178 | database, so removing the column would have been a free in-place edit rather than a |
| 179 | second migration. |
| 180 | |
| 181 | --- |
| 182 | |
| 183 | ## Reference: what attempt #2 proved |
| 184 | |
| 185 | Not this repo's progress. This is a catalogue of what was built and **verified working** |
| 186 | in `steid-backup-2026-07-31`, so the rebuild can crib rather than rediscover. |
| 187 | |
| 188 | Final state: single crate, ~4,200 LOC, 60 passing tests, five milestones. |
| 189 | |
| 190 | ### Identity |
| 191 | |
| 192 | Domain model (User, Organization, Membership, Actor, Role), `Email` and |
| 193 | `PasswordHash` value objects, typed IDs, `DomainError`, four repository ports with |
| 194 | in-memory and SQLite implementations each. `RegistrationPolicy` (Personal / Invite / |
| 195 | Open) driving which routes exist. Argon2 hashing behind a `PasswordHasher` port with a |
| 196 | stub for tests. Use cases: `bootstrap_owner`, `register_user`, `login`, `create_invite`. |
| 197 | Signed-cookie sessions via an `AuthUser` extractor. |
| 198 | |
| 199 | **Gotcha:** organizations must be saved before users — the FK runs that direction. |
| 200 | Both `bootstrap_owner` and `register_user` had to be fixed for this. |
| 201 | |
| 202 | ### Repo model |
| 203 | |
| 204 | `Repository` entity, `RepoId`, `Visibility` (Public/Private), `RepoRepository` port, |
| 205 | migration `006_create_repositories.sql`. `create_repo` use case validates the name, |
| 206 | rejects duplicates, and initialises the bare repo on disk in the same call. Bare repos |
| 207 | live at `{data_dir}/{org}/{repo}.git`, `data_dir` defaulting to `./data`. Repos are |
| 208 | created empty, no initial commit, like GitHub. |
| 209 | |
| 210 | ### Git over SSH |
| 211 | |
| 212 | `GitStorage` port (`init_bare`, `repo_path`) with `DiskGitStorage` shelling out to |
| 213 | `git init --bare`. `GitProtocolServer` port (`upload_pack`, `receive_pack`) with |
| 214 | `GitBinary` spawning `git upload-pack` / `git receive-pack` via |
| 215 | `tokio::process::Command` and pumping stdio with `tokio::io::copy`. |
| 216 | |
| 217 | `serve_clone` and `serve_push` use cases enforce visibility and actor checks **before |
| 218 | any protocol byte flows** — that ordering is the whole point of putting them in the |
| 219 | application layer. |
| 220 | |
| 221 | #### SSH channel bridging |
| 222 | |
| 223 | The fiddly part, and worth re-reading if SSH ever returns as a transport |
| 224 | (milestone 8+ — [0001](decisions/0001-git-over-http-not-ssh.md) chose HTTP): |
| 225 | |
| 226 | - Store `Channel<Msg>` per `ChannelId` in the handler's map on `channel_open_session` |
| 227 | - On `exec_request`, take the channel, split it with `into_stream()` + |
| 228 | `tokio::io::split` |
| 229 | - Take stderr via `make_writer_ext(Some(1))` **before** `into_stream()` — that call |
| 230 | consumes the channel, so the order is not optional |
| 231 | - No `data()` or `channel_eof()` handlers needed once the streams are split |
| 232 | |
| 233 | An earlier iteration used mpsc channels, custom `ChannelReader`/`ChannelWriter`, and a |
| 234 | `spawn_blocking` thread. All of it was deleted and the result was simpler. |
| 235 | |
| 236 | ### SSH key auth and authorization |
| 237 | |
| 238 | `SshKey { id, user_id, name, fingerprint, openssh }` + port, migration |
| 239 | `007_create_ssh_keys.sql` (`fingerprint` UNIQUE). Fingerprints are SHA256 via |
| 240 | `russh::keys::ssh_key::PublicKey::fingerprint(HashAlg::Sha256)`, stored as `SHA256:…`. |
| 241 | `add_ssh_key` parses the openssh blob, dedupes on fingerprint, and re-encodes to a |
| 242 | canonical form before storing. |
| 243 | |
| 244 | `auth_none` rejects. `auth_publickey` fingerprints the offered key, looks it up, and |
| 245 | on a match stores `user_id` on the handler; `exec_request` builds the real `Actor` |
| 246 | from it. |
| 247 | |
| 248 | Authorization rules as shipped: |
| 249 | |
| 250 | |
| 251 | |
| 252 | | Clone, public repo | open | |
| 253 | | Clone, private repo | any membership in the repo's org | |
| 254 | | Push | `Role::Owner` membership in the repo's org | |
| 255 | |
| 256 | Web UI at `/{owner}/keys` — owner-only, lists fingerprints, accepts openssh via |
| 257 | textarea, revokes per-row. |
| 258 | |
| 259 | **Verified end-to-end:** clone with an unregistered key → `Permission denied` (exit |
| 260 | 128); register via web UI → clone and push both succeed; second unregistered key → |
| 261 | rejected at auth; revoke via web UI → subsequent clone rejected at auth. |
| 262 | |
| 263 | ### Security notes |
| 264 | |
| 265 | Attempt #2 ran with a **named, deliberate backdoor** between milestones: SSH accepted |
| 266 | any connection and passed a placeholder `Actor` (`UserId("ssh-anonymous")`), leaving |
| 267 | push open to anyone who could reach the port. It was recorded with an explicit |
| 268 | tightening point (`ssh.rs::exec_request`) and a closing milestone, and it did close. |
| 269 | |
| 270 | That practice is worth keeping. When this attempt opens a hole to make progress, name |
| 271 | it, name the line that closes it, and name the milestone. |
| 272 | |
| 273 | ### Never built |
| 274 | |
| 275 | Repo browsing (tree/blob/log), HTTP smart protocol, personal access tokens, flash |
| 276 | messages, issues, PRs, blogs, pages, project showcases. Milestones 5–8 in |
| 277 | [ROADMAP.md](ROADMAP.md) are all greenfield. |