steid

@jamesgill /

steid/plans/progress.md
9.1 KBCode·Blame·Raw
1# Progress
2
3## This attempt (#3, Topcoat)
4
5111 tests. Active milestone in [current.md]current.md.
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
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
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
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.
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.
61
62### Milestone 2 — Profile page · phase 1 done
63
64`/{handle}` is the real profile page: label, handle, bio, and the section frame for
65repositories, writing, and projects. Public, renders signed out, 404s on an unknown
66handle, and resolves regardless of casing. `/` forwards a signed-in owner to their own
67profile. `/api/users/{handle}` serves the same read model as JSON.
68
69URLs 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
91---
92
93## Reference: what attempt #2 proved
94
95Not this repo's progress. This is a catalogue of what was built and **verified working**
96in `steid-backup-2026-07-31`, so the rebuild can crib rather than rediscover.
97
98Final state: single crate, ~4,200 LOC, 60 passing tests, five milestones.
99
100### Identity
101
102Domain model (User, Organization, Membership, Actor, Role), `Email` and
103`PasswordHash` value objects, typed IDs, `DomainError`, four repository ports with
104in-memory and SQLite implementations each. `RegistrationPolicy` (Personal / Invite /
105Open) driving which routes exist. Argon2 hashing behind a `PasswordHasher` port with a
106stub for tests. Use cases: `bootstrap_owner`, `register_user`, `login`, `create_invite`.
107Signed-cookie sessions via an `AuthUser` extractor.
108
109**Gotcha:** organizations must be saved before users — the FK runs that direction.
110Both `bootstrap_owner` and `register_user` had to be fixed for this.
111
112### Repo model
113
114`Repository` entity, `RepoId`, `Visibility` (Public/Private), `RepoRepository` port,
115migration `006_create_repositories.sql`. `create_repo` use case validates the name,
116rejects duplicates, and initialises the bare repo on disk in the same call. Bare repos
117live at `{data_dir}/{org}/{repo}.git`, `data_dir` defaulting to `./data`. Repos are
118created empty, no initial commit, like GitHub.
119
120### Git over SSH
121
122`GitStorage` port (`init_bare`, `repo_path`) with `DiskGitStorage` shelling out to
123`git init --bare`. `GitProtocolServer` port (`upload_pack`, `receive_pack`) with
124`GitBinary` spawning `git upload-pack` / `git receive-pack` via
125`tokio::process::Command` and pumping stdio with `tokio::io::copy`.
126
127`serve_clone` and `serve_push` use cases enforce visibility and actor checks **before
128any protocol byte flows** — that ordering is the whole point of putting them in the
129application layer.
130
131#### SSH channel bridging
132
133The fiddly part, and worth re-reading before Milestone 3:
134
135- Store `Channel<Msg>` per `ChannelId` in the handler's map on `channel_open_session`
136- On `exec_request`, take the channel, split it with `into_stream()` +
137 `tokio::io::split`
138- Take stderr via `make_writer_ext(Some(1))` **before** `into_stream()` — that call
139 consumes the channel, so the order is not optional
140- No `data()` or `channel_eof()` handlers needed once the streams are split
141
142An earlier iteration used mpsc channels, custom `ChannelReader`/`ChannelWriter`, and a
143`spawn_blocking` thread. All of it was deleted and the result was simpler.
144
145### SSH key auth and authorization
146
147`SshKey { id, user_id, name, fingerprint, openssh }` + port, migration
148`007_create_ssh_keys.sql` (`fingerprint` UNIQUE). Fingerprints are SHA256 via
149`russh::keys::ssh_key::PublicKey::fingerprint(HashAlg::Sha256)`, stored as `SHA256:…`.
150`add_ssh_key` parses the openssh blob, dedupes on fingerprint, and re-encodes to a
151canonical form before storing.
152
153`auth_none` rejects. `auth_publickey` fingerprints the offered key, looks it up, and
154on a match stores `user_id` on the handler; `exec_request` builds the real `Actor`
155from it.
156
157Authorization rules as shipped:
158
159| Operation | Requirement |
160|---|---|
161| Clone, public repo | open |
162| Clone, private repo | any membership in the repo's org |
163| Push | `Role::Owner` membership in the repo's org |
164
165Web UI at `/{owner}/keys` — owner-only, lists fingerprints, accepts openssh via
166textarea, revokes per-row.
167
168**Verified end-to-end:** clone with an unregistered key → `Permission denied` (exit
169128); register via web UI → clone and push both succeed; second unregistered key →
170rejected at auth; revoke via web UI → subsequent clone rejected at auth.
171
172### Security notes
173
174Attempt #2 ran with a **named, deliberate backdoor** between milestones: SSH accepted
175any connection and passed a placeholder `Actor` (`UserId("ssh-anonymous")`), leaving
176push open to anyone who could reach the port. It was recorded with an explicit
177tightening point (`ssh.rs::exec_request`) and a closing milestone, and it did close.
178
179That practice is worth keeping. When this attempt opens a hole to make progress, name
180it, name the line that closes it, and name the milestone.
181
182### Never built
183
184Repo browsing (tree/blob/log), HTTP smart protocol, personal access tokens, flash
185messages, issues, PRs, blogs, pages, project showcases. Milestones 5–8 in
186[ROADMAP.md]ROADMAP.md are all greenfield.