# Progress

## This attempt (#3, Topcoat)

190 tests. Active milestone in [current.md](current.md).

### Milestone 0 — Skeleton · done

Topcoat 0.5 app serving pages, `AppConfig` from `STEID_*` env, SQLite pool in app
context. Split into a library plus a thin binary — the domain layer had no consumers
yet and read as ~30 dead-code warnings in a bare binary, and it unlocks `tests/`.
Topcoat's link-time page discovery works from a library; that was checked, not assumed.

**Requires rustc ≥ 1.95.** On older toolchains `cargo add topcoat` silently resolves to
an empty `topcoat v0.0.0` placeholder instead of failing.

### Milestone 1 — Identity, thin · done

**Domain.** Typed IDs, `Email`, `PasswordHash`, `OrgName`, `Organization`, `User`,
`Membership`, `Role`, `Actor`, `Session`, `SetupToken`, `DomainError`. Value objects
pair `new()` (validates) with `from_trusted()` (skips, for rows already validated).

**Application.** `claim_instance`, `login`, `resolve_actor`, `record_session`,
`end_session`, `sweep_expired`. `PasswordHasher` port with Argon2 and a stub.

**Infrastructure.** Migrations for orgs, users, memberships, sessions. In-memory and
SQLite implementations of every port. Root layout, `/setup`, `/login`, `/logout`, home,
and `/api/me`.

`describe_identity` is the first use case with two consumers — the home page and
`/api/me` both read through it. Until that existed, "the application layer is
transport-neutral" was an assertion with one caller behind it.

**Verified in a browser and by curl:** wrong token refused with nothing written;
correct token creates org + user + owner membership and signs the owner in; session
authenticates; logout clears cookie and row; wrong password bounces; right one signs
in; re-claiming a claimed instance is refused.

#### Decisions worth remembering

- **`Actor` is an enum with an explicit `Anonymous`**, not `Option<UserId>`. Attempt #2
  used a placeholder `UserId("ssh-anonymous")` and it became a security hole. A variant
  can't be forgotten the way a sentinel can.
- **`login` verifies a dummy hash when no user matched.** Returning early on the
  unknown-email path makes it measurably faster and leaks which addresses have
  accounts. A test pins that the dummy stays parseable — if it stops being, `verify`
  bails early and the defence dies silently.
- **`SetupToken` compares in constant time.** An early-return comparison leaks how much
  of the token is right, which recovers it a character at a time.
- **The setup token is only in app context while unclaimed**, so a claimed instance has
  nothing for a claim attempt to match.
- **SQLite ignores foreign keys unless asked**, per connection. `foreign_keys(true)`
  plus a test that a user pointing at a missing org is refused.
- **`sqlx migrate add` stamps versions to the second** — three calls in one second
  collide, leaving apply order ambiguous between tables that reference each other.
- **An unparseable role surfaces as an error**, never as "no membership". The latter
  silently downgrades an owner to no access.
- **`Secure` session cookies over plain-HTTP localhost fail silently.** See
  [runbook.md](runbook.md#steid_insecure_cookies--development-only). This one actually
  bit, and it looked exactly like broken auth logic.

### Milestone 2 — Profile page · done

`/{handle}` is the real profile page: label, handle, bio, and the section frame for
repositories, writing, and projects. Public, renders signed out, 404s on an unknown
handle, and resolves regardless of casing. `/` forwards a signed-in owner to their own
profile. `/api/users/{handle}` serves the same read model as JSON.

URLs settled as root handles with grouped application routes
([0004](decisions/0004-root-handles-grouped-routes.md)), superseding
[0003](decisions/0003-scoped-urls.md) the same day. A twenty-word reserved list in
`OrgName::new` keeps handles from shadowing routes.

#### Decisions worth remembering

- **`PublicProfile` has no email field, deliberately.** `Identity` does, and `/api/me`
  returns it, because that endpoint describes the caller to themselves. Giving the type
  that reaches the page nowhere to put an email makes the leak impossible rather than
  merely avoided.
- **`viewer_is_owner` is decided in the use case**, so the web form and `/api` cannot
  disagree about who may edit. Tested for a signed-in stranger and a non-owner member —
  "signed in" quietly becoming "allowed" is the usual failure.
- **`Organization::update_profile` clears on blank input** rather than storing
  whitespace, so cleared and never-set are one state and the page renders one case. A
  rejected edit applies nothing.
- **Bio length counts characters, not bytes.** A byte check would reject a bio of
  accented text well under the limit.
- **`path_param` is an attribute macro in 0.5**, not function-like. The vendored crate
  is the authority for the pinned version, not the docs on `main`.
- **Components are invoked bare inside `view!`**, and `if`/`match`/`for`/`let` are
  native to the macro.
- **`#[query_params]` needs `error = …`** to work with `?`; otherwise the error borrows
  from `cx` and escapes the handler.
- **Forms re-render on failure and redirect on success.** Redirecting after a
  validation error throws away what was typed and hides the reason.
- **Styling is Tailwind via Topcoat's build script**, with registry components copied
  in rather than depended on ([0005](decisions/0005-tailwind-and-copied-components.md)).
  Components reference theme tokens, never raw colours.

### Milestone 3 — Repo model · done

Repositories exist as records and as bare repos on disk, and they appear on the
profile. Domain, both persistence adapters, `GitStorage` with `DiskGitStorage` behind
it, `create_repo` / `view_repo` / `list_repos`, the `/{handle}/repos/new` form and
`/{handle}/repos/{name}` page, the profile's Repositories section, and
`/api/users/{handle}/repos`. How git is invoked is recorded in
[0006](decisions/0006-git-binary-behind-narrow-ports.md).

**Verified in a browser and by curl:** the owner creates a repo through the form, a
bare repo appears at `{data_dir}/{handle}/{name}.git`, and it lists on the profile; a
private repo is absent for a signed-out visitor on both the page and `/api`; an unknown
handle 404s rather than answering `[]`. `git clone` does not work yet — Milestone 4.

#### Decisions worth remembering

- **`git init` on an existing repository exits 0 and re-initialises in silence.**
  Measured, not assumed. So `AlreadyExists` has to be our own `path.exists()` check —
  there is no exit code to key off. Refusing rather than adopting matters because a
  directory with no matching row is an orphan from a crashed create, and re-initialising
  it would resurface a private repository's objects under a fresh record.
- **`git init` creates missing parent directories itself**, so there is no
  `create_dir_all` before it. This was in the plan and the probe removed it.
- **`--template=` takes a new bare repo from 18 files to 2.** The default seeds sixteen
  `.sample` hooks. Timed at 15.2ms against 13.1ms across 20 runs — so the ~2ms is not
  the reason; Steid installs its own hooks later and the samples would be noise to work
  around.
- **`--initial-branch=main` is explicit** so the host's `init.defaultBranch` cannot
  decide it. This machine's git already says `main`, which is precisely why a drift
  would go unnoticed — hence the test.
- **`GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` are pointed at `/dev/null`**, and the
  five `GIT_*` variables that redirect object storage are removed from the child
  environment. `GIT_DIR` was checked and does *not* override an explicit path argument,
  but `GIT_OBJECT_DIRECTORY` does redirect where objects land, and the failure is
  silent — the repository just looks empty.
- **One `run_git` owns the invocation.** With a single caller this looks premature; it
  is a private function rather than a public abstraction for that reason. The point is
  that Milestone 4's `http-backend` spawn cannot quietly disagree about isolation.
- **`tokio::process` and `tokio::fs`, never the `std` equivalents.** `init_bare` is not
  hot — ~13ms, once per repository — but `remove_dir_all` on a repo with real history
  walks every loose object and would stall a runtime worker. The performance that
  matters is Milestone 4's per-request spawn, not this.
- **`tempfile` for test fixtures, not `target/`.** Parallel-safe by construction and
  self-cleaning on panic. Debris under `target/` would be actively harmful here, since
  `init_bare` refuses a path that already exists.
- **Membership is resolved once per listing, not once per row.** Obvious in hindsight;
  the shape that invites the mistake is filtering inside a loop that can `await`.
- **One empty state serves "no repositories" and "none you may see".** A distinct
  message for the second — or any count — leaks that private repositories exist and how
  many. Tested, because it is the kind of thing a later "helpful" tweak would undo.
- **`redirect()` is a 307, and 307 preserves the method.** Post/redirect/get needs a
  303, or the browser re-POSTs the form to its redirect target. Milestone 2's settings
  form shipped with this and nothing caught it — every test passed, because the tests
  are on the use case and the bug is in the reply. Found by following the redirect with
  curl. The fix is a `StatusCode::SEE_OTHER` plus a `Location` pair inside `view!`,
  wrapped as `web::context::location`, because `see_other()` is a response type and
  `#[page]` must return a view for the layout to wrap the failure re-render.
  `RedirectError::new` is private, so a 303 cannot be built as an error, and the
  error-to-response path only downcasts topcoat's own error types — a custom one
  becomes a 500.
- **`#[page]` returns a view; `#[route]` returns a response.** That is the whole reason
  the redirect is spelled awkwardly: a form handler needs both a redirect and a
  full-page re-render, and only the view path gets the layout.
- **Boolean HTML attributes take an explicit value in `view!`** — `required=(true)`, not
  bare `required`, which fails to parse. `false` omits the attribute entirely, so
  `selected=(bool)` on an `<option>` is correct rather than rendering `selected="false"`.
- **`topcoat ui add select` needs the `icon-iconify` feature and a staged icon set.**
  The chevron comes from `feather`, staged in `build.rs`. No new crates, but the build
  fails with a clear message until the set is staged.
- **`is_org_owner` moved to `application/authz.rs`** on its second caller. Owner-ness
  gates the profile edit, repo creation, and later PATs and push; two copies of an
  authorization predicate drift, and the direction they drift is open.
- **The compensating transaction is safe to do by path.** `remove` after a failed save
  can only ever delete what `init_bare` just created, because the loser of a concurrent
  create never gets past `init_bare`. Non-obvious enough that it is commented in the
  code as well as here.
- **Compensation is best-effort.** If the removal also fails, the caller still gets the
  error that started it — an orphaned directory is the documented failure mode, and
  replacing the real error with the cleanup's error would hide the cause.
- **`InMemoryGitStorage` enforces `AlreadyExists` too.** A permissive fake would let
  `create_repo` pass while the real adapter refused. The fake mirroring the rule is the
  point of having two implementations.
- **`/api` resolves the handle without loading a profile.** `handle_param` split out
  of `profile_for` so the repo listing route 404s on `list_repos`' own `None`. Routing
  it through the profile would have made that `None` unreachable and left the page and
  `/api` disagreeing about what an empty portfolio means — the exact duplication
  `architecture.md` says to watch for.
- **The `/api` listing is a bare array, not an envelope.** Matches `/api/users/{handle}`
  returning a bare object. Pagination later means a wrapper and a breaking change; taken
  knowingly, since personal-first means a handful of repositories and there are no
  consumers yet.
- **`Repository::description` stays.** Added unrequested and flagged; kept on review
  because this milestone's own "Done when" puts repositories on the profile, which
  makes it a consumer inside the milestone rather than speculation. Worth noting the
  window that closed: the repositories migration had not yet been applied to the dev
  database, so removing the column would have been a free in-place edit rather than a
  second migration.

### Milestone 4a — Clone over HTTP · in progress

`git http-backend`'s contract, probed against git 2.50.1 by driving the CGI from a
throwaway server and cloning through it. Everything below is measured, not read.

#### The contract

- **`HTTP_CONTENT_ENCODING`, not `CONTENT_ENCODING`.** CGI gives only `Content-Type` and
  `Content-Length` unprefixed names; every other request header is `HTTP_`-prefixed, and
  `http-backend` looks for the prefixed one. **This is the finding that would have cost a
  day.** With the wrong name, `http-backend` hands the still-compressed body to
  `upload-pack`, which dies with `bad line length character` and the client reports
  `fatal: expected 'packfile'` — nothing names gzip, or the environment, anywhere in the
  failure. And it only happens once a repository has enough refs for the client to bother
  compressing: a one-ref test repo passes.
- **`HTTP_GIT_PROTOCOL`** carries `version=2` through to `upload-pack`. Clones verified
  on v2 and on `protocol.version=0`, both 201 commits, both gzipped.
- **`GIT_PROJECT_ROOT` plus `PATH_INFO`**, where `PATH_INFO` is the on-disk path relative
  to the root. Steid's URL and its storage layout differ — `/{handle}/repos/{name}.git/…`
  against `{data_dir}/{handle}/{name}.git` — so the adapter rewrites the middle segment
  out. `GIT_PROJECT_ROOT` is the data directory.
- **`GIT_HTTP_EXPORT_ALL=1` is required.** Without it every repository answers `Status:
  404 Not Found` and `Repository not exported`, unless a `git-daemon-export-ok` marker
  file sits in the bare repo (confirmed: dropping that file in re-enables it).
- **Headers are CRLF-terminated and end at `\r\n\r\n`.** No bare-LF variant was
  observed, but the adapter should accept one rather than hang.
- **`Status:` appears only on failure.** Its absence means 200, and it must be
  translated, not forwarded as a header.
- **`http-backend` sets its own `Content-Type` and cache headers** — `Expires: Fri, 01
  Jan 1980`, `Pragma: no-cache`, `Cache-Control: no-cache, max-age=0, must-revalidate`.
  Steid forwards them rather than inventing its own.

#### Decisions worth remembering

- **`GIT_HTTP_EXPORT_ALL`, never `git-daemon-export-ok`.** The marker file is git's own
  visibility mechanism and it looks tempting, but visibility lives in the `repositories`
  table and the use case is what enforces it. A marker file would be a second source of
  truth for the same question, free to drift from the first, and the drift direction is
  "private repository still clonable". Steid decides; git is told to stop asking.
- **`receive-pack` is refused by default** — `Status: 403 Forbidden`, `Service not
  enabled: 'receive-pack'`, without any configuration. Convenient for 4a, but the routes
  still refuse writes explicitly rather than leaning on it: a default that helpfully
  changes is not an authorization decision.
- **A non-zero exit can arrive after the headers are already out.** The gzip failure
  exited 1 having emitted a complete, successful-looking header block. So the exit code
  cannot gate the response — by the time it is known, the status is sent. It belongs in
  the log.
- **A missing repository is `Status: 404` with exit 0.** Failure is reported in the
  CGI stream, not the exit code, and the two disagree in both directions.

---

## Reference: what attempt #2 proved

Not this repo's progress. This is a catalogue of what was built and **verified working**
in `steid-backup-2026-07-31`, so the rebuild can crib rather than rediscover.

Final state: single crate, ~4,200 LOC, 60 passing tests, five milestones.

### Identity

Domain model (User, Organization, Membership, Actor, Role), `Email` and
`PasswordHash` value objects, typed IDs, `DomainError`, four repository ports with
in-memory and SQLite implementations each. `RegistrationPolicy` (Personal / Invite /
Open) driving which routes exist. Argon2 hashing behind a `PasswordHasher` port with a
stub for tests. Use cases: `bootstrap_owner`, `register_user`, `login`, `create_invite`.
Signed-cookie sessions via an `AuthUser` extractor.

**Gotcha:** organizations must be saved before users — the FK runs that direction.
Both `bootstrap_owner` and `register_user` had to be fixed for this.

### Repo model

`Repository` entity, `RepoId`, `Visibility` (Public/Private), `RepoRepository` port,
migration `006_create_repositories.sql`. `create_repo` use case validates the name,
rejects duplicates, and initialises the bare repo on disk in the same call. Bare repos
live at `{data_dir}/{org}/{repo}.git`, `data_dir` defaulting to `./data`. Repos are
created empty, no initial commit, like GitHub.

### Git over SSH

`GitStorage` port (`init_bare`, `repo_path`) with `DiskGitStorage` shelling out to
`git init --bare`. `GitProtocolServer` port (`upload_pack`, `receive_pack`) with
`GitBinary` spawning `git upload-pack` / `git receive-pack` via
`tokio::process::Command` and pumping stdio with `tokio::io::copy`.

`serve_clone` and `serve_push` use cases enforce visibility and actor checks **before
any protocol byte flows** — that ordering is the whole point of putting them in the
application layer.

#### SSH channel bridging

The fiddly part, and worth re-reading if SSH ever returns as a transport
(milestone 8+ — [0001](decisions/0001-git-over-http-not-ssh.md) chose HTTP):

- Store `Channel<Msg>` per `ChannelId` in the handler's map on `channel_open_session`
- On `exec_request`, take the channel, split it with `into_stream()` +
  `tokio::io::split`
- Take stderr via `make_writer_ext(Some(1))` **before** `into_stream()` — that call
  consumes the channel, so the order is not optional
- No `data()` or `channel_eof()` handlers needed once the streams are split

An earlier iteration used mpsc channels, custom `ChannelReader`/`ChannelWriter`, and a
`spawn_blocking` thread. All of it was deleted and the result was simpler.

### SSH key auth and authorization

`SshKey { id, user_id, name, fingerprint, openssh }` + port, migration
`007_create_ssh_keys.sql` (`fingerprint` UNIQUE). Fingerprints are SHA256 via
`russh::keys::ssh_key::PublicKey::fingerprint(HashAlg::Sha256)`, stored as `SHA256:…`.
`add_ssh_key` parses the openssh blob, dedupes on fingerprint, and re-encodes to a
canonical form before storing.

`auth_none` rejects. `auth_publickey` fingerprints the offered key, looks it up, and
on a match stores `user_id` on the handler; `exec_request` builds the real `Actor`
from it.

Authorization rules as shipped:

| Operation | Requirement |
|---|---|
| Clone, public repo | open |
| Clone, private repo | any membership in the repo's org |
| Push | `Role::Owner` membership in the repo's org |

Web UI at `/{owner}/keys` — owner-only, lists fingerprints, accepts openssh via
textarea, revokes per-row.

**Verified end-to-end:** clone with an unregistered key → `Permission denied` (exit
128); register via web UI → clone and push both succeed; second unregistered key →
rejected at auth; revoke via web UI → subsequent clone rejected at auth.

### Security notes

Attempt #2 ran with a **named, deliberate backdoor** between milestones: SSH accepted
any connection and passed a placeholder `Actor` (`UserId("ssh-anonymous")`), leaving
push open to anyone who could reach the port. It was recorded with an explicit
tightening point (`ssh.rs::exec_request`) and a closing milestone, and it did close.

That practice is worth keeping. When this attempt opens a hole to make progress, name
it, name the line that closes it, and name the milestone.

### Never built

Repo browsing (tree/blob/log), HTTP smart protocol, personal access tokens, flash
messages, issues, PRs, blogs, pages, project showcases. Milestones 5–8 in
[ROADMAP.md](ROADMAP.md) are all greenfield.
