# Progress

## This attempt (#3, Topcoat)

445 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 · done

`git clone` works against a public repository, for anyone, with no credentials.
`GitProtocolServer` (CGI-shaped) with `GitHttpBackend` behind it, the `serve_git` use
case, and three routes under `/{handle}/repos/{name}.git/`. Bodies stream both
directions.

**Verified against a real client**, not only by unit test: an anonymous `git clone` of a
201-ref repository returns 201 commits and 203 refs with `HEAD` matching the origin, on
protocol v2 and on v0; a private repository answers 404 to an anonymous clone but 200 to
its owner's browser session; `git push` is refused with 403; and unknown repo, unknown
handle, missing `service`, an unknown service, a missing `.git` suffix, and a
dumb-protocol object path all answer 404 while the repository page still answers 200.

`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.
- **The router is the allowlist.** Only `info/refs`, `git-upload-pack` and
  `git-receive-pack` are routed. Handed any other path, `http-backend` serves
  dumb-protocol object files straight off disk — a read of a repository nothing
  authorized. Verified: `/…​.git/objects/info/packs` answers 404.
- **The endpoint is named by the route, not parsed from the path.** Three routes, three
  literal `GitEndpoint` values, and `serve_git` rebuilds `path_info` from the validated
  handle and name. The string that decides authorization and the string handed to git
  are therefore the same string.
- **Existence is settled before permission.** A push to a repository the actor cannot
  see answers 404, not 403 — a 403 would confirm a private repository by that name
  exists. Costs nothing to get right at the start and is invisible to test later.
- **`BufReader` is what makes the header/body split safe.** The reader keeps whatever it
  read past the blank line, so handing the reader itself back as the response body
  carries the already-buffered first bytes of the pack with it. Parsing headers into a
  separate buffer and then streaming the rest would silently drop them.
- **The child's exit code cannot gate the response.** A protocol failure exits non-zero
  *after* a complete, successful-looking header block has been written. By the time the
  status is known it has been sent, so the exit code goes to the log and nowhere else.
- **Stderr must be drained, not merely piped.** An unread pipe fills and blocks the
  backend mid-transfer. It is read in the same task that reaps the child.
- **`body_limit` does not exist** in `topcoat-router` 0.5.0 — the warning carried from
  [0001](decisions/0001-git-over-http-not-ssh.md) is stale. Bodies are read by the
  handler with a caller-chosen limit via `to_bytes`, and the git routes take `Body`
  unbuffered so no limit applies at all.
- **`impl<B> IntoResponse for http::Response<B>`** means a handler can return its own
  `http_body::Body` and Topcoat re-bodies it. That is what lets the pack stream without
  a framework-specific body type.

### Milestone 4b — Push and tokens · done

`git push` works over HTTP for the owner, and a private repository is clonable by
someone holding a token for it. `PersonalAccessToken` with both storage adapters,
`issue_token` / `list_tokens` / `revoke_token` / `authenticate_token`, HTTP Basic on the
git routes, and token management at `/{handle}/settings/tokens`. Decisions recorded in
[0007](decisions/0007-tokens-over-http-basic.md).

**Verified end to end**, issuing the token through the UI rather than seeding one:
push of 201 refs to a public repo and to a private one; clone of the private repo
returning 201 commits; anonymous clone of the public repo still open with no prompt;
401 with `WWW-Authenticate: Basic` for a private repo, a **nonexistent** repo, an
unknown handle and a push advertisement alike; a wrong token challenged rather than
accepted; and after revoking, both clone and push answer 401 while the public repo stays
open. The token is shown once and never again on reload, the revoke button disappears
from the list, and the page is 403 for anyone else.

#### Decisions worth remembering

- **`http-backend` refuses `receive-pack` by default, and Steid authorizing the push is
  not enough.** The symptom is a 403 that looks like Steid's own refusal but is git's:
  `Service not enabled: 'receive-pack'`. It needs `-c http.receivepack=true` **before**
  the subcommand. That flag is set from a `GitRequest` field the use case turns on only
  after the authorization check passes, so git remains a second refusal behind Steid's
  rather than being switched on wholesale — if the rules are ever wrong, git still says
  no.
- **The uniform 401 is what makes authenticated cloning possible at all.** A git client
  offers a credential only after a 401, so the 4a behaviour of answering 404 for a
  private repository made an authenticated private clone unreachable. Extending the 401
  to repositories that do not exist is what keeps it from leaking which private names
  are real.
- **A bad credential falls through to anonymous rather than failing.** The caller then
  gets the same challenge as someone who presented nothing and can try again, which is
  also how a stale session cookie behaves.
- **Basic accepts the token in the password field, or in the username with no password.**
  Git puts it in the password; people paste it into the username. The alternative is an
  authentication failure with nothing to explain it.
- **Issuing a token deliberately does not redirect**, unlike every other form here. The
  secret exists only in that response, and surviving a redirect would mean putting a live
  credential in a URL — browser history, logs, referrers. Reloading issues a second
  token, which is harmless and visible in the list.
- **Revoking someone else's token is `NotFound`, not `Forbidden`.** That a token id
  exists but belongs to another user is not a fact worth confirming.
- **Tokens do not expire.** A credential pasted into a machine and forgotten is worth
  less if it stops working silently; revocation is the control that matters.
- **`in_memory.rs` keeps `mod tests` in the middle of the file**, like `sqlite.rs`.
  Appending an implementation to the end lands it inside a later impl block.

#### Measured, for Milestone 5

Taken at the end of 4b, on a 201-commit repository, 50 runs averaged per command:

| Command | Per call |
|---|---|
| `git rev-parse HEAD` | 11.2 ms |
| `git ls-tree -l HEAD` | 11.7 ms |
| `git cat-file -p HEAD:` | 11.6 ms |
| `git log -20 --format=…` | 11.8 ms |
| `git for-each-ref` | 14.4 ms |

**The cost is starting git, not the query** — every command lands in the same band
regardless of the work it does, matching the ~13ms `git init --bare` from Milestone 3.
A three-call page is therefore ~35ms of pure overhead, and a per-file last-commit column
at one call per entry would be ~230ms for twenty files. This is the input
[0006](decisions/0006-git-binary-behind-narrow-ports.md) asked for before reconsidering
`gix` on the read path.

### Milestone 5 — Repo browsing · done

A repository is readable on the web: the file tree at a revision, a file's contents with
line numbers, and the commit log. `ObjectId` / `RefName` / `RepoPath` / `EntryKind` /
`TreeEntry` / `CommitSummary` in the domain, a `GitQuery` port with `DiskGitQuery` and an
in-memory fake behind it, `browse_repo` and `repo_log` read models, and pages at
`/{handle}/repos/{name}`, `/tree/{rev}`, `/tree/{rev}/-/{path}`, `/log` and `/log/{rev}`.
The read-path decision and its upgrade ladder are in
[0006](decisions/0006-git-binary-behind-narrow-ports.md#amendment--20260829-the-milestone-5-read-path).

**Verified against Steid's own repository, hosted on Steid.** A freshly created repo
shows push instructions; after pushing 57 commits the page lists the tree; directories
descend and breadcrumb back; `src/domain/session.rs` renders with line numbers and
correct HTML escaping; the log shows 50 commits with author and relative time. Unknown
revision, unknown path, `../etc/passwd`, unknown repo and unknown handle all 404, clone
still works, and a private repository answers 404 to an anonymous visitor on **every**
browse route while its owner sees it.

#### Decisions worth remembering

- **`cat-file --batch-check` with the spec on stdin is the lookup primitive.** It exits
  **0** and prints `<spec> missing` for anything unresolvable, which is what makes "not
  found" a *value read off stdout* rather than an exit code to interpret. The rule the
  adapter follows: **a non-zero exit is always an error; absence is a marker in the
  output** (`missing`, `ambiguous`, `dangling`, `notdir`). The obvious alternatives are
  worse — `rev-parse --verify --quiet` returns 1 for an unknown ref but 128 for a missing
  repository, and `ls-tree` pointed at a blob is a `fatal:` for what is, to a visitor, a
  404. Passing the spec on **stdin** also means no revision or path can ever be read as a
  flag, whatever validation upstream does or stops doing.
- **`git log` in a repository with no commits is a fatal error, not empty output**, hence
  resolving the revision first. An extra fork, in exchange for not reading meaning out of
  a localised stderr string.
- **`%s` is git's *subject*, not the first line** — with no blank line in the message it
  joins the whole first paragraph with spaces. The adapter takes `.lines().next()` rather
  than trusting that.
- **Negative `%ct` exists** in imported histories and would panic `UNIX_EPOCH + Duration`.
- **A symlink is indistinguishable from a file in the object store** — both are blobs — so
  `read_blob` returns a symlink's target path as its content. Deliberate; making it
  `Ok(None)` would cost an extra `ls-tree` of the parent.
- **The empty repository is a first-class state, not an error.** Steid creates
  repositories empty and Milestone 4 made it easy to have one never pushed to, so
  `default_branch` returning `None` means "no commits" and the page offers the three
  commands to push rather than a broken listing.
- **`%2F` in a revision segment survives routing.** Topcoat matches on the raw path and
  percent-decodes after, so a slashed branch works as a single segment — which is what
  makes the `/-/` separator sufficient without a ref lookup.
- **Three agents worked in parallel on disjoint files**, with the port, the fake and a
  non-panicking stub adapter written first so neither branch could break the other's
  build. The stub answering "nothing there" rather than `todo!()` is what let the pages
  be developed and run before the adapter existed.

### Milestone 5b — Deployable by anyone · in progress

Distributed as a binary plus its `assets/` directory, installed by one command, behind
Caddy for automatic HTTPS. `release.sh`, `install.sh`, `deploy/`, a `README.md`, and the
operability the service needs: `/healthz`, `STEID_SETUP_TOKEN`, and rate limiting.

#### Topcoat findings, both load-bearing

- **`topcoat::start()` already handles SIGTERM.** `serve()` calls `serve_until(…,
  shutdown_signal())`, and that selects on Ctrl+C *and* SIGTERM on Unix: it stops
  accepting, drops the listener so a replacement process can bind the port, and drains
  in-flight requests up to `shutdown_timeout`. So systemd stop/restart does not cut a
  clone mid-pack, and **no wiring was needed** — the work was finding this out rather
  than building it.
- **The peer socket address is not reachable from a handler.** `internal_serve` discards
  it at accept time (`let (stream, _remote) = accepted?;`) and never puts it on the
  request extensions; the context exposes only parts, method, uri, headers and
  extensions. **Any IP-based decision in Steid is therefore header-based by necessity**,
  not by choice, and closing that would take an upstream change.

#### Decisions worth remembering

- **The rate limiter keys on the *rightmost* `X-Forwarded-For` entry.** A proxy
  *appends* the address it accepted from, so the last entry is the one the nearest proxy
  wrote and the only one a client cannot forge. The leftmost — what "the real client IP"
  usually means — is exactly the attacker-controlled one. Two proxy hops collapse clients
  onto the inner proxy's address, which is stricter, so being wrong that way is safe.
- **A global cap backs the per-key one**, because without a peer address a forged key
  cannot be disproved. It turns key forgery from a total bypass into a modest speed-up.
  The cost is that a flood can lock the login form for a minute — a recoverable denial
  against an unrecoverable guessed password.
- **The limiter's map is bounded.** An unbounded map keyed by attacker-controlled values
  is itself the denial of service; at the cap it sweeps expired entries and otherwise
  falls through to the global window, which is stricter rather than permissive.
- **glibc on bullseye, not static musl.** musl failed on `ring` (Debian's `musl-gcc`
  rejects `-m64`), but the decisive argument is that **musl buys a dependency-free binary
  and Steid hard-requires `git` on PATH** — the portability is unusable. Bullseye pins the
  glibc floor at 2.31, covering Debian 11+ and Ubuntu 20.04+; bookworm would need 2.36
  and silently exclude Ubuntu 22.04.
- **The runtime binary links a TLS stack it never uses.** `ring` ← `rustls` ← `ureq` ←
  Topcoat's `icon-iconify`/`tailwind`, whose `ureq` downloads the Tailwind CLI *at build
  time*. Those features are on the normal dependency as well as the build one, so the
  crypto ships too. Moving them would shrink the binary and drop an unused dependency
  from the attack surface. Not attempted; it is also what made the musl build fail where
  it did.
- **`STEID_SETUP_TOKEN` does not undermine [0002](decisions/0002-first-run-claim-not-config-bootstrap.md).**
  What that ADR refused to put in configuration was the owner's *password* — long-lived,
  goes stale, ends up in a repository. This is the one-time claim secret the operator was
  already copying out of a log line. It is validated for strength, never printed, and
  ignored entirely once claimed.

### Gap-filling while 5b was blocked on DNS · done

Built while the domain transfer was in flight, so none of it belongs to a milestone.
Three things: repository settings, browse usability, and README rendering — the last of
which is Milestone 6's markdown pipeline arriving early because a repository page needed
it.

#### Repository settings — a hole, not a feature

Repositories were **create-only**. No edit, no delete, and therefore **no way to
un-publish something published by accident**. Now: description and visibility are
editable, and a repository can be deleted behind a type-the-name confirmation.

- **Delete writes the row first, then the directory best-effort.** An orphaned directory
  only blocks reusing that name and is already `create_repo`'s documented failure mode;
  an orphaned row is a repository that lists on the profile and 404s when clicked. The
  visible failure is the worse one, so the ordering avoids it.
- **Two different refusals, deliberately.** A repository the actor may not *see* is
  `NotFound` (a 403 would confirm a private repo by that name exists); one they can see
  but do not own is `Forbidden`, matching `create_repo`, because the resource is public
  anyway. The page collapses both to 404.
- **Renaming is still impossible, now with a comment saying why.** The bare repo lives at
  `{data_dir}/{handle}/{name}.git`, so a rename is a directory move that breaks every
  existing clone — and doing the row half only breaks them silently.

#### Markdown — raw HTML is structurally impossible, not merely disabled

- **`pulldown-cmark` does not sanitise URLs.** Its `escape_href` only percent-escapes, so
  `javascript:` would have survived into a rendered README. The renderer uses a scheme
  **allowlist** (`http`, `https`, `mailto`, `ftp`, `ftps`, `tel`) and strips ASCII control
  characters *before* the check as well as on output, because browsers strip them too and
  `java&#9;script:` is otherwise a live bypass.
- **Adding the crate with `default-features = false` turned off its `html` feature**, so
  `push_html` does not exist in this build. The renderer therefore walks the event stream
  and writes every tag itself. Forced rather than chosen, and better: the emittable tag
  set is exactly what the writer spells out, so raw HTML cannot pass through by
  construction. `Event::Html` is written as *text*, so `<script>` is visible and inert
  rather than silently vanishing.
- **Escaping goes through `topcoat::view::HtmlContext`**, the same escaper `view!` uses —
  not a hand-rolled one.
- **Smart punctuation is off**: it rewrites `--flag` to an en dash, quietly corrupting CLI
  flags in README prose.
- Relative *links* are rewritten into tree URLs; relative *images* are deliberately left
  alone, because a tree URL serves a page and rewriting would swap a 404 for a broken
  image. Pointing them at `/raw/` is the obvious follow-up now that route exists.

#### Browse — the switcher and raw files

- **`for-each-ref` asks only for `%(refname)`.** `%(objecttype)` is `commit` for both a
  branch and a lightweight tag, so the *namespace* is the only thing that answers
  branch-versus-tag.
- **Raw files are served as `application/octet-stream`, always** — never the file's own
  type and never guessed from an extension — with `nosniff`, `Content-Disposition:
  attachment` and `default-src 'none'; sandbox`. A repository-supplied `.html` or `.svg`
  served as its real type on this origin is stored XSS against the viewer's session.
  `text/plain` was rejected because browsers render it and have been talked into sniffing
  it as HTML. The filename is repository content arriving in a header, so it is reduced to
  `[A-Za-z0-9._-]` against header injection.
- **The switcher costs one more fork (~13ms) on tree and log pages**, measured
  interleaved against `ls-tree` to cancel out load — same band, confirming again that the
  fork is the cost. It is not called on the repository page or an empty repository.

#### Verified

Against a running instance with a deliberately hostile README: **zero real `<script>` or
`<img>` tags in the output**, the markup present once as escaped inert text, the
`javascript:` link stripped of its `href` while `https://example.com` survived, the table
rendered, raw bytes SHA-256 identical with the headers above, the switcher listing a
branch and a tag, and the Settings link visible to the owner and absent for anonymous.

### The profile page, rebuilt · done

Flat navigation, per [ui.md](ui.md#the-profile-page). Tabs instead of stacked sections,
one lead item distinguished by weight and space rather than size, hairlines instead of
boxes, and a `/{handle}/repos` index for the tab to point at.

#### Decisions worth remembering

- **Existing rows were stamped with the migration time, not left at 0.** A repository
  pushed to for months would otherwise read "updated 56 years ago" on the very page the
  column exists to order. Wrong by a bounded amount and self-correcting on the first
  push, against wrong forever and looking broken.
- **The single-pin invariant lives in the use case, not in a constraint.** The rule is
  "pinning this unpins that", and a unique index can only *refuse*, never unpin. The two
  writes are not one transaction; a crash between them leaves nothing pinned, which is
  the harmless direction.
- **`updated_at` is touched on the authorized write path**, before the protocol is
  reached — so a clone never moves it and neither does a refused push. It records that a
  push was *authorized*, not that it succeeded; waiting for the subprocess would be a
  much larger change for a small gain. A failed touch is logged and does not fail the
  push.
- **The lead is picked out of the listing**, not fetched separately, so the lead and the
  list cannot disagree about which repository is pinned.
- **Ordering changed from name to recency**, and two existing tests had been passing by
  accident: everything created inside one second tied and fell back to the alphabet.

#### Two Topcoat traps, both silent

- **`let profile = …` in a module with `#[page] async fn profile` is a unit-struct
  pattern, not a binding.** The page's name is a unit struct in module scope. The error
  points at neither the page nor the shadowing.
- **Forgetting `topcoat asset bundle` after `cargo build` produces a broken-looking
  page, not an error.** New utility classes are simply absent, so gaps collapse and type
  falls back to browser defaults — it reads as a design mistake. Cost one confused
  screenshot.

### Security review before going public · done

Attempted with three blind reviewer agents; all three died to the machine sleeping, so
the review was done directly instead. **Findings were verified by attacking a running
instance, not by reading comments** — the codebase argues confidently for its own
safety and those arguments are exactly what needed testing.

#### What held, demonstrated rather than assumed

| Probe | Result |
|---|---|
| Private repo: page, tree, log, settings, raw, `/api` | 404 on every surface; `/api` does not leak the name |
| `rev=--upload-pack=touch /tmp/pwned` and five other injections | 404, no execution |
| Four path-traversal spellings incl. `%2e%2e` and `....//` | 404 |
| Login brute force | 10 attempts then 429 |
| Error bodies | bare "not found", nothing internal |
| Raw `.html` and `.svg` from a repo | `octet-stream` + `nosniff` + `attachment` + sandbox CSP |
| Filename `ev"il; drop.txt` in a header | sanitised to `ev_il__drop.txt`, no header injection |
| `<script>` in a viewed file | escaped, zero live tags |

**The production cookie is `__Host-session; HttpOnly; Secure; SameSite=Lax; Path=/`** —
the strictest cookie form available, and it forbids a `Domain` attribute so a
compromised subdomain cannot inject one.

**A near-miss worth recording:** the first check appeared to show production serving
`steid-dev-session` with no `Secure`. It was a testing error — **`dotenvy` reads `.env`
from the working directory**, and running the binary from the repo root picked up the
gitignored dev `.env`. Verify from a neutral directory, and note the same trap applies
to the deployment: the systemd unit's `WorkingDirectory=/opt/steid` means a stray `.env`
there would silently override the environment file.

#### The one real finding: no security headers on HTML

Fixed with a layer, deliberately a layer rather than a per-handler concern — a page
added without them would simply not have them and nothing would fail.

- **Steid renders no JavaScript at all**, not a script tag on any page, so
  `default-src 'none'` is a policy the product can genuinely keep. That is far stricter
  than a typical CSP and worth defending: if a feature ever needs script, weakening it
  should be a deliberate decision.
- `'unsafe-inline'` is granted for **styles only**, and only because Topcoat's icon
  macro emits `style="vertical-align: -0.125em"`. A framework constraint, not a choice.
- **`Referrer-Policy: no-referrer`**, not the usual `strict-origin-when-cross-origin`: a
  private repository's URL contains its name, and a README may link anywhere, so a
  referrer would hand that name to whatever the visitor clicked.
- Headers are set with `entry().or_insert()`, never `insert()` — the raw endpoint's own
  stricter `default-src 'none'; sandbox` must not be relaxed by a blanket overwrite.
  Verified that it survives.

#### Not covered, and why

- **The member-but-not-owner path could not be demonstrated.** There is no registration,
  so a second account cannot exist until Milestone 7. It is covered by use-case tests and
  nothing else — re-verify for real the moment a second account is possible.
- **No automated test asserts the headers.** There is no HTTP-level test harness in this
  project, so a regression here would be silent. That is a gap, not a decision.
- No dependency audit (`cargo audit` was not run), and no review of the deployment
  scripts beyond the earlier container dry-run.

---

## 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.
