| 1 | # Progress |
| 2 | |
| 3 | ## This attempt (#3, Topcoat) |
| 4 | |
| 5 | Nothing shipped yet. Milestone 0 in progress — see [current.md](current.md). |
| 6 | |
| 7 | --- |
| 8 | |
| 9 | ## Reference: what attempt #2 proved |
| 10 | |
| 11 | Not this repo's progress. This is a catalogue of what was built and **verified working** |
| 12 | in `steid-backup-2026-07-31`, so the rebuild can crib rather than rediscover. |
| 13 | |
| 14 | Final state: single crate, ~4,200 LOC, 60 passing tests, five milestones. |
| 15 | |
| 16 | ### Identity |
| 17 | |
| 18 | Domain model (User, Organization, Membership, Actor, Role), `Email` and |
| 19 | `PasswordHash` value objects, typed IDs, `DomainError`, four repository ports with |
| 20 | in-memory and SQLite implementations each. `RegistrationPolicy` (Personal / Invite / |
| 21 | Open) driving which routes exist. Argon2 hashing behind a `PasswordHasher` port with a |
| 22 | stub for tests. Use cases: `bootstrap_owner`, `register_user`, `login`, `create_invite`. |
| 23 | Signed-cookie sessions via an `AuthUser` extractor. |
| 24 | |
| 25 | **Gotcha:** organizations must be saved before users — the FK runs that direction. |
| 26 | Both `bootstrap_owner` and `register_user` had to be fixed for this. |
| 27 | |
| 28 | ### Repo model |
| 29 | |
| 30 | `Repository` entity, `RepoId`, `Visibility` (Public/Private), `RepoRepository` port, |
| 31 | migration `006_create_repositories.sql`. `create_repo` use case validates the name, |
| 32 | rejects duplicates, and initialises the bare repo on disk in the same call. Bare repos |
| 33 | live at `{data_dir}/{org}/{repo}.git`, `data_dir` defaulting to `./data`. Repos are |
| 34 | created empty, no initial commit, like GitHub. |
| 35 | |
| 36 | ### Git over SSH |
| 37 | |
| 38 | `GitStorage` port (`init_bare`, `repo_path`) with `DiskGitStorage` shelling out to |
| 39 | `git init --bare`. `GitProtocolServer` port (`upload_pack`, `receive_pack`) with |
| 40 | `GitBinary` spawning `git upload-pack` / `git receive-pack` via |
| 41 | `tokio::process::Command` and pumping stdio with `tokio::io::copy`. |
| 42 | |
| 43 | `serve_clone` and `serve_push` use cases enforce visibility and actor checks **before |
| 44 | any protocol byte flows** — that ordering is the whole point of putting them in the |
| 45 | application layer. |
| 46 | |
| 47 | #### SSH channel bridging |
| 48 | |
| 49 | The fiddly part, and worth re-reading before Milestone 3: |
| 50 | |
| 51 | - Store `Channel<Msg>` per `ChannelId` in the handler's map on `channel_open_session` |
| 52 | - On `exec_request`, take the channel, split it with `into_stream()` + |
| 53 | `tokio::io::split` |
| 54 | - Take stderr via `make_writer_ext(Some(1))` **before** `into_stream()` — that call |
| 55 | consumes the channel, so the order is not optional |
| 56 | - No `data()` or `channel_eof()` handlers needed once the streams are split |
| 57 | |
| 58 | An earlier iteration used mpsc channels, custom `ChannelReader`/`ChannelWriter`, and a |
| 59 | `spawn_blocking` thread. All of it was deleted and the result was simpler. |
| 60 | |
| 61 | ### SSH key auth and authorization |
| 62 | |
| 63 | `SshKey { id, user_id, name, fingerprint, openssh }` + port, migration |
| 64 | `007_create_ssh_keys.sql` (`fingerprint` UNIQUE). Fingerprints are SHA256 via |
| 65 | `russh::keys::ssh_key::PublicKey::fingerprint(HashAlg::Sha256)`, stored as `SHA256:…`. |
| 66 | `add_ssh_key` parses the openssh blob, dedupes on fingerprint, and re-encodes to a |
| 67 | canonical form before storing. |
| 68 | |
| 69 | `auth_none` rejects. `auth_publickey` fingerprints the offered key, looks it up, and |
| 70 | on a match stores `user_id` on the handler; `exec_request` builds the real `Actor` |
| 71 | from it. |
| 72 | |
| 73 | Authorization rules as shipped: |
| 74 | |
| 75 | |
| 76 | |
| 77 | | Clone, public repo | open | |
| 78 | | Clone, private repo | any membership in the repo's org | |
| 79 | | Push | `Role::Owner` membership in the repo's org | |
| 80 | |
| 81 | Web UI at `/{owner}/keys` — owner-only, lists fingerprints, accepts openssh via |
| 82 | textarea, revokes per-row. |
| 83 | |
| 84 | **Verified end-to-end:** clone with an unregistered key → `Permission denied` (exit |
| 85 | 128); register via web UI → clone and push both succeed; second unregistered key → |
| 86 | rejected at auth; revoke via web UI → subsequent clone rejected at auth. |
| 87 | |
| 88 | ### Security notes |
| 89 | |
| 90 | Attempt #2 ran with a **named, deliberate backdoor** between milestones: SSH accepted |
| 91 | any connection and passed a placeholder `Actor` (`UserId("ssh-anonymous")`), leaving |
| 92 | push open to anyone who could reach the port. It was recorded with an explicit |
| 93 | tightening point (`ssh.rs::exec_request`) and a closing milestone, and it did close. |
| 94 | |
| 95 | That practice is worth keeping. When this attempt opens a hole to make progress, name |
| 96 | it, name the line that closes it, and name the milestone. |
| 97 | |
| 98 | ### Never built |
| 99 | |
| 100 | Repo browsing (tree/blob/log), HTTP smart protocol, personal access tokens, flash |
| 101 | messages, issues, PRs, blogs, pages, project showcases. Milestones 5–8 in |
| 102 | [ROADMAP.md](ROADMAP.md) are all greenfield. |