steid

@jamesgill /

steid/plans/progress.md
7.4 KBCode·Blame·Raw
ab7fea9chore: plans setup1mo
1# Progress
2
3## This attempt (#3, Topcoat)
4
076dbc9docs: close milestone 1, open milestone 21mo
591 tests. Active milestone in [current.md]current.md.
88583f2docs: bring tracking docs up to date with milestone 11mo
6
7### Milestone 0 — Skeleton · done
8
9Topcoat 0.5 app serving pages, `AppConfig` from `STEID_*` env, SQLite pool in app
10context. Split into a library plus a thin binary — the domain layer had no consumers
11yet and read as ~30 dead-code warnings in a bare binary, and it unlocks `tests/`.
12Topcoat'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
15an empty `topcoat v0.0.0` placeholder instead of failing.
16
076dbc9docs: close milestone 1, open milestone 21mo
17### Milestone 1 — Identity, thin · done
88583f2docs: bring tracking docs up to date with milestone 11mo
18
19**Domain.** Typed IDs, `Email`, `PasswordHash`, `OrgName`, `Organization`, `User`,
20`Membership`, `Role`, `Actor`, `Session`, `SetupToken`, `DomainError`. Value objects
21pair `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
076dbc9docs: close milestone 1, open milestone 21mo
27SQLite implementations of every port. Root layout, `/setup`, `/login`, `/logout`, home,
28and `/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
32transport-neutral" was an assertion with one caller behind it.
88583f2docs: bring tracking docs up to date with milestone 11mo
33
34**Verified in a browser and by curl:** wrong token refused with nothing written;
35correct token creates org + user + owner membership and signs the owner in; session
36authenticates; logout clears cookie and row; wrong password bounces; right one signs
37in; 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.
ab7fea9chore: plans setup1mo
61
62---
63
64## Reference: what attempt #2 proved
65
66Not this repo's progress. This is a catalogue of what was built and **verified working**
67in `steid-backup-2026-07-31`, so the rebuild can crib rather than rediscover.
68
69Final state: single crate, ~4,200 LOC, 60 passing tests, five milestones.
70
71### Identity
72
73Domain model (User, Organization, Membership, Actor, Role), `Email` and
74`PasswordHash` value objects, typed IDs, `DomainError`, four repository ports with
75in-memory and SQLite implementations each. `RegistrationPolicy` (Personal / Invite /
76Open) driving which routes exist. Argon2 hashing behind a `PasswordHasher` port with a
77stub for tests. Use cases: `bootstrap_owner`, `register_user`, `login`, `create_invite`.
78Signed-cookie sessions via an `AuthUser` extractor.
79
80**Gotcha:** organizations must be saved before users — the FK runs that direction.
81Both `bootstrap_owner` and `register_user` had to be fixed for this.
82
83### Repo model
84
85`Repository` entity, `RepoId`, `Visibility` (Public/Private), `RepoRepository` port,
86migration `006_create_repositories.sql`. `create_repo` use case validates the name,
87rejects duplicates, and initialises the bare repo on disk in the same call. Bare repos
88live at `{data_dir}/{org}/{repo}.git`, `data_dir` defaulting to `./data`. Repos are
89created empty, no initial commit, like GitHub.
90
91### Git over SSH
92
93`GitStorage` port (`init_bare`, `repo_path`) with `DiskGitStorage` shelling out to
94`git init --bare`. `GitProtocolServer` port (`upload_pack`, `receive_pack`) with
95`GitBinary` spawning `git upload-pack` / `git receive-pack` via
96`tokio::process::Command` and pumping stdio with `tokio::io::copy`.
97
98`serve_clone` and `serve_push` use cases enforce visibility and actor checks **before
99any protocol byte flows** — that ordering is the whole point of putting them in the
100application layer.
101
102#### SSH channel bridging
103
104The fiddly part, and worth re-reading before Milestone 3:
105
106- Store `Channel<Msg>` per `ChannelId` in the handler's map on `channel_open_session`
107- On `exec_request`, take the channel, split it with `into_stream()` +
108 `tokio::io::split`
109- Take stderr via `make_writer_ext(Some(1))` **before** `into_stream()` — that call
110 consumes the channel, so the order is not optional
111- No `data()` or `channel_eof()` handlers needed once the streams are split
112
113An earlier iteration used mpsc channels, custom `ChannelReader`/`ChannelWriter`, and a
114`spawn_blocking` thread. All of it was deleted and the result was simpler.
115
116### SSH key auth and authorization
117
118`SshKey { id, user_id, name, fingerprint, openssh }` + port, migration
119`007_create_ssh_keys.sql` (`fingerprint` UNIQUE). Fingerprints are SHA256 via
120`russh::keys::ssh_key::PublicKey::fingerprint(HashAlg::Sha256)`, stored as `SHA256:…`.
121`add_ssh_key` parses the openssh blob, dedupes on fingerprint, and re-encodes to a
122canonical form before storing.
123
124`auth_none` rejects. `auth_publickey` fingerprints the offered key, looks it up, and
125on a match stores `user_id` on the handler; `exec_request` builds the real `Actor`
126from it.
127
128Authorization rules as shipped:
129
130| Operation | Requirement |
131|---|---|
132| Clone, public repo | open |
133| Clone, private repo | any membership in the repo's org |
134| Push | `Role::Owner` membership in the repo's org |
135
136Web UI at `/{owner}/keys` — owner-only, lists fingerprints, accepts openssh via
137textarea, revokes per-row.
138
139**Verified end-to-end:** clone with an unregistered key → `Permission denied` (exit
140128); register via web UI → clone and push both succeed; second unregistered key →
141rejected at auth; revoke via web UI → subsequent clone rejected at auth.
142
143### Security notes
144
145Attempt #2 ran with a **named, deliberate backdoor** between milestones: SSH accepted
146any connection and passed a placeholder `Actor` (`UserId("ssh-anonymous")`), leaving
147push open to anyone who could reach the port. It was recorded with an explicit
148tightening point (`ssh.rs::exec_request`) and a closing milestone, and it did close.
149
150That practice is worth keeping. When this attempt opens a hole to make progress, name
151it, name the line that closes it, and name the milestone.
152
153### Never built
154
155Repo browsing (tree/blob/log), HTTP smart protocol, personal access tokens, flash
156messages, issues, PRs, blogs, pages, project showcases. Milestones 5–8 in
157[ROADMAP.md]ROADMAP.md are all greenfield.