steid

@jamesgill /

steid/plans/progress.md
12.6 KBCode·Blame·Raw
ab7fea9chore: plans setup1mo
1# Progress
2
3## This attempt (#3, Topcoat)
4
02eb2e4feat: GitStorage port and DiskGitStorage24d
5158 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
c50d9adfeat: profile settings with flash feedback25d
62### Milestone 2 — Profile page · done
3954b45docs: record milestone 2 phase 125d
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`.
c50d9adfeat: profile settings with flash feedback25d
90- **Components are invoked bare inside `view!`**, and `if`/`match`/`for`/`let` are
91 native to the macro.
92- **`#[query_params]` needs `error = …`** to work with `?`; otherwise the error borrows
93 from `cx` and escapes the handler.
94- **Forms re-render on failure and redirect on success.** Redirecting after a
95 validation error throws away what was typed and hides the reason.
96- **Styling is Tailwind via Topcoat's build script**, with registry components copied
97 in rather than depended on ([0005]decisions/0005-tailwind-and-copied-components.md).
98 Components reference theme tokens, never raw colours.
3954b45docs: record milestone 2 phase 125d
99
02eb2e4feat: GitStorage port and DiskGitStorage24d
100### Milestone 3 — Repo model · in progress
101
102Domain, both persistence adapters, and now `GitStorage` with `DiskGitStorage` behind
103it. How git is invoked is recorded in
104[0006]decisions/0006-git-binary-behind-narrow-ports.md.
105
106#### Decisions worth remembering
107
108- **`git init` on an existing repository exits 0 and re-initialises in silence.**
109 Measured, not assumed. So `AlreadyExists` has to be our own `path.exists()` check —
110 there is no exit code to key off. Refusing rather than adopting matters because a
111 directory with no matching row is an orphan from a crashed create, and re-initialising
112 it would resurface a private repository's objects under a fresh record.
113- **`git init` creates missing parent directories itself**, so there is no
114 `create_dir_all` before it. This was in the plan and the probe removed it.
115- **`--template=` takes a new bare repo from 18 files to 2.** The default seeds sixteen
116 `.sample` hooks. Timed at 15.2ms against 13.1ms across 20 runs — so the ~2ms is not
117 the reason; Steid installs its own hooks later and the samples would be noise to work
118 around.
119- **`--initial-branch=main` is explicit** so the host's `init.defaultBranch` cannot
120 decide it. This machine's git already says `main`, which is precisely why a drift
121 would go unnoticed — hence the test.
122- **`GIT_CONFIG_GLOBAL` and `GIT_CONFIG_SYSTEM` are pointed at `/dev/null`**, and the
123 five `GIT_*` variables that redirect object storage are removed from the child
124 environment. `GIT_DIR` was checked and does *not* override an explicit path argument,
125 but `GIT_OBJECT_DIRECTORY` does redirect where objects land, and the failure is
126 silent — the repository just looks empty.
127- **One `run_git` owns the invocation.** With a single caller this looks premature; it
128 is a private function rather than a public abstraction for that reason. The point is
129 that Milestone 4's `http-backend` spawn cannot quietly disagree about isolation.
130- **`tokio::process` and `tokio::fs`, never the `std` equivalents.** `init_bare` is not
131 hot — ~13ms, once per repository — but `remove_dir_all` on a repo with real history
132 walks every loose object and would stall a runtime worker. The performance that
133 matters is Milestone 4's per-request spawn, not this.
134- **`tempfile` for test fixtures, not `target/`.** Parallel-safe by construction and
135 self-cleaning on panic. Debris under `target/` would be actively harmful here, since
136 `init_bare` refuses a path that already exists.
137- **`Repository::description` stays.** Added unrequested and flagged; kept on review
138 because this milestone's own "Done when" puts repositories on the profile, which
139 makes it a consumer inside the milestone rather than speculation. Worth noting the
140 window that closed: the repositories migration had not yet been applied to the dev
141 database, so removing the column would have been a free in-place edit rather than a
142 second migration.
143
ab7fea9chore: plans setup1mo
144---
145
146## Reference: what attempt #2 proved
147
148Not this repo's progress. This is a catalogue of what was built and **verified working**
149in `steid-backup-2026-07-31`, so the rebuild can crib rather than rediscover.
150
151Final state: single crate, ~4,200 LOC, 60 passing tests, five milestones.
152
153### Identity
154
155Domain model (User, Organization, Membership, Actor, Role), `Email` and
156`PasswordHash` value objects, typed IDs, `DomainError`, four repository ports with
157in-memory and SQLite implementations each. `RegistrationPolicy` (Personal / Invite /
158Open) driving which routes exist. Argon2 hashing behind a `PasswordHasher` port with a
159stub for tests. Use cases: `bootstrap_owner`, `register_user`, `login`, `create_invite`.
160Signed-cookie sessions via an `AuthUser` extractor.
161
162**Gotcha:** organizations must be saved before users — the FK runs that direction.
163Both `bootstrap_owner` and `register_user` had to be fixed for this.
164
165### Repo model
166
167`Repository` entity, `RepoId`, `Visibility` (Public/Private), `RepoRepository` port,
168migration `006_create_repositories.sql`. `create_repo` use case validates the name,
169rejects duplicates, and initialises the bare repo on disk in the same call. Bare repos
170live at `{data_dir}/{org}/{repo}.git`, `data_dir` defaulting to `./data`. Repos are
171created empty, no initial commit, like GitHub.
172
173### Git over SSH
174
175`GitStorage` port (`init_bare`, `repo_path`) with `DiskGitStorage` shelling out to
176`git init --bare`. `GitProtocolServer` port (`upload_pack`, `receive_pack`) with
177`GitBinary` spawning `git upload-pack` / `git receive-pack` via
178`tokio::process::Command` and pumping stdio with `tokio::io::copy`.
179
180`serve_clone` and `serve_push` use cases enforce visibility and actor checks **before
181any protocol byte flows** — that ordering is the whole point of putting them in the
182application layer.
183
184#### SSH channel bridging
185
ca76e1bdocs: fix milestone cross-references after the reorder24d
186The fiddly part, and worth re-reading if SSH ever returns as a transport
187(milestone 8+ — [0001]decisions/0001-git-over-http-not-ssh.md chose HTTP):
ab7fea9chore: plans setup1mo
188
189- Store `Channel<Msg>` per `ChannelId` in the handler's map on `channel_open_session`
190- On `exec_request`, take the channel, split it with `into_stream()` +
191 `tokio::io::split`
192- Take stderr via `make_writer_ext(Some(1))` **before** `into_stream()` — that call
193 consumes the channel, so the order is not optional
194- No `data()` or `channel_eof()` handlers needed once the streams are split
195
196An earlier iteration used mpsc channels, custom `ChannelReader`/`ChannelWriter`, and a
197`spawn_blocking` thread. All of it was deleted and the result was simpler.
198
199### SSH key auth and authorization
200
201`SshKey { id, user_id, name, fingerprint, openssh }` + port, migration
202`007_create_ssh_keys.sql` (`fingerprint` UNIQUE). Fingerprints are SHA256 via
203`russh::keys::ssh_key::PublicKey::fingerprint(HashAlg::Sha256)`, stored as `SHA256:…`.
204`add_ssh_key` parses the openssh blob, dedupes on fingerprint, and re-encodes to a
205canonical form before storing.
206
207`auth_none` rejects. `auth_publickey` fingerprints the offered key, looks it up, and
208on a match stores `user_id` on the handler; `exec_request` builds the real `Actor`
209from it.
210
211Authorization rules as shipped:
212
213| Operation | Requirement |
214|---|---|
215| Clone, public repo | open |
216| Clone, private repo | any membership in the repo's org |
217| Push | `Role::Owner` membership in the repo's org |
218
219Web UI at `/{owner}/keys` — owner-only, lists fingerprints, accepts openssh via
220textarea, revokes per-row.
221
222**Verified end-to-end:** clone with an unregistered key → `Permission denied` (exit
223128); register via web UI → clone and push both succeed; second unregistered key →
224rejected at auth; revoke via web UI → subsequent clone rejected at auth.
225
226### Security notes
227
228Attempt #2 ran with a **named, deliberate backdoor** between milestones: SSH accepted
229any connection and passed a placeholder `Actor` (`UserId("ssh-anonymous")`), leaving
230push open to anyone who could reach the port. It was recorded with an explicit
231tightening point (`ssh.rs::exec_request`) and a closing milestone, and it did close.
232
233That practice is worth keeping. When this attempt opens a hole to make progress, name
234it, name the line that closes it, and name the milestone.
235
236### Never built
237
238Repo browsing (tree/blob/log), HTTP smart protocol, personal access tokens, flash
239messages, issues, PRs, blogs, pages, project showcases. Milestones 5–8 in
240[ROADMAP.md]ROADMAP.md are all greenfield.