steid

@jamesgill /

steid/plans/current.md
10.3 KBCode·Blame·Raw
1# Current
2
3> Keep this file short. One active step, one ordered backlog. Completed work moves to
4> [progress.md]progress.md. If this file starts reading like a changelog, it has
5> drifted — that's exactly what went wrong last time.
6
7## Active: Milestone 4a — Clone over HTTP
8
9**Goal:** `git clone https://host/{handle}/repos/{name}.git` works against a public
10repository, for anyone, with no credentials. The protocol is delegated to `git
11http-backend` per [0001]decisions/0001-git-over-http-not-ssh.md.
12
13**Out of scope:** personal access tokens, HTTP Basic, push, cloning a private repo —
14all of that is 4b. Also out: browsing a tree in the UI (Milestone 5), and any repo
15statistic the clone path could tempt us into computing.
16
17### Steps
18
19- [ ] Probe `git http-backend`'s actual contract — which CGI variables it reads, how it
20 reports failure, what it does with an unauthorised path. Findings to
21 `progress.md`; no application code in this step.
22- [x] Application: `GitProtocolServer` port — a CGI-shaped request/response pair, plus
23 `GitOperation` (Read/Write) as the thing authorization is decided on
24- [x] Infrastructure: `GitHttpBackend` adapter, spawning through the existing `run_git`
25 invoker; streams stdin in and stdout out, parsing CGI headers off the front
26- [x] Application: `serve_git` use case — resolves the repository, enforces visibility,
27 refuses writes outright, and only then delegates
28- [ ] Web: the three git routes under `/{handle}/repos/{name}.git/`
29- [ ] Verify with a real `git clone` of a repo with enough refs to trigger a gzipped
30 request body
31
32### Done when
33
34`git clone http://127.0.0.1:3000/{handle}/repos/{name}.git` produces a working
35checkout of a public repository, with no credentials, and the cloned history matches
36the origin. A private repository is not clonable by anyone yet — not even its owner.
37`git push` is refused.
38
39### Settled
40
41- **`git http-backend`, not direct `--stateless-rpc`.** Both put identical bytes on the
42 wire for a modern clone of a small repo; the difference is entirely in the tail, which
43 is what wide adoption means. Measured before choosing: a client cloning a repo with
44 201 refs **gzip-compresses the POST body** (5KB here), on protocol v0 *and* v2. A
45 direct implementation must therefore inflate request bodies and forward
46 `Git-Protocol` itself, and gets neither the dumb-protocol fallback nor the header set.
47 The failure mode decided it — a direct implementation passes against a one-ref test
48 repo and breaks on the first real one.
49- **The clone URL is `/{handle}/repos/{name}.git`**, matching the page at
50 `/{handle}/repos/{name}`. Scoped rather than root-level, per
51 [0003]decisions/0003-scoped-urls.md; the `.git` suffix separates protocol from page.
52- **Only the three known endpoints are routed**`info/refs`, `git-upload-pack`,
53 `git-receive-pack`. `http-backend` will otherwise serve dumb-protocol object files
54 under any path handed to it, which would be a read of a repository nothing
55 authorized. The router is the allowlist.
56- **Authorization is decided before the subprocess is spawned**, from the service name
57 in the request, not from anything `http-backend` reports back. By the time git is
58 running it is too late to refuse.
59- **Bodies stream in both directions, rather than buffering.** A pack is arbitrarily
60 large, and buffering would bound a clone by RAM instead of by disk;
61 [0001]decisions/0001-git-over-http-not-ssh.md named streaming as the thing that made
62 this transport viable in the first place. The port therefore carries
63 `Pin<Box<dyn AsyncRead + Send>>` in both directions. **No new crate**: this needs only
64 tokio's `io-util` feature, and the `bytes` / `http-body-util` / `tokio-util` the web
65 layer will use to bridge Topcoat's `Body` are already in `Cargo.lock` as transitive
66 dependencies, so nothing new enters the build.
67- **`run_git` was split into `git_command`.** The old invoker buffers with `output()`,
68 which the protocol cannot use. Rather than a second recipe — the thing
69 [0006]decisions/0006-git-binary-behind-narrow-ports.md exists to prevent — the
70 isolation moved into a shared builder both call sites start from.
71- **`body_limit` turns out not to exist** in `topcoat-router` 0.5.0. Bodies are read by
72 the handler via `to_bytes(body, limit)` with a caller-chosen limit, so there is no
73 layer to raise — and the git routes take the body unbuffered anyway. The warning
74 carried from [0001]decisions/0001-git-over-http-not-ssh.md is stale.
75- **The endpoint is named by the route, not parsed from the path.** Three routes, three
76 literal `GitEndpoint` values. The use case then builds `path_info` itself from the
77 validated handle and name, so the string that decides authorization and the string
78 handed to git are the same string, and a URL cannot be coaxed into meaning a different
79 operation than the one that was checked.
80- **Existence is settled before permission.** A push to a repository the actor cannot
81 see answers `None` (404), not `Forbidden` — a 403 would confirm a private repository
82 by that name exists.
83- **Milestone 4 was split.** See [ROADMAP.md]ROADMAP.md#why-this-order.
84
85### Open
86
87- **What a private repository answers to an anonymous clone.** 4a has no credentials at
88 all, so 404 is the only honest answer and matches `view_repo`'s "absent, not
89 forbidden" rule. But git only sends credentials *after* a 401, so 4b will need a 401
90 with `WWW-Authenticate` on exactly the case that 404s today — which leaks that the
91 repository exists. Gitea and GitHub both accept that leak. Decide it in 4b, with the
92 tension recorded rather than rediscovered.
93
94### Watch for
95
96- **CGI header parsing sits in front of a stream.** `http-backend` writes headers, a
97 blank line, then the body. Reading the headers must not buffer the body — that is the
98 whole reason this transport was judged viable on `Body::into_data_stream`.
99- **A client that disappears mid-request leaves the body-copy task waiting.** The copy
100 into git's stdin runs in its own task; nothing cancels it if the connection drops.
101 Bounded by the backend exiting and closing the pipe, but not by anything deliberate.
102- **A subprocess per request**, unlike Milestone 3's once-per-creation. Fork/exec cost
103 now sits on a hot path; measure before assuming it is fine.
104- **`http-backend` reports failure through CGI status lines**, not exit codes alone. A
105 non-zero exit and a `404 Not Found` on stdout mean different things.
106- **The advertisement must not be cached.** `Cache-Control: no-cache` on `info/refs`, or
107 clients fetch a stale ref list and fail to find commits that exist.
108
109### Carried over — small, unblocked
110
111- **An orphaned repo directory is possible** if the process dies between the record
112 write and the filesystem write, and it then blocks re-creating that name. The durable
113 fix is a reconciliation sweep on boot
114 ([architecture.md]architecture.md#db-plus-filesystem-writes); clearing one is a
115 manual `rm` today, since repo deletion does not exist.
116- **The duplicate-name check races.** The loser is caught by `init_bare` or the unique
117 constraint, but surfaces as an opaque storage error rather than "name taken".
118- **Bare repos created on macOS carry `ignorecase = true`.** A migration gotcha if the
119 data directory ever moves to Linux.
120- **Fonts are not loaded.** The theme names Geist and IBM Plex Mono; both fall back
121 today. Topcoat's `font-fontsource` feature handles it.
122- **Light mode is untested.** The palette defines it; nobody has looked at it.
123- **No rate limiting** on `/auth/login` or `/auth/setup`.
124- **`sweep_expired` is never called**, so expired session rows accumulate. Expiry is
125 enforced on read, so this is tidiness, not a hole.
126- **CSRF.** `SameSite=Lax` covers the common case. Forms now exist, so this is decidable
127 rather than hypothetical.
128
129## Backlog
130
131Ordered. Pull from the top.
132
1331. **Milestone 4b — Push and tokens.** Personal access tokens over HTTP Basic, `git
134 push`, private clone. Open decisions when it starts: how tokens are hashed (session
135 token hashing already exists to copy), whether tokens carry scopes, and the 401-vs-404
136 tension above.
1372. **Milestone 5 — Repo browsing.** Tree, blob, commit log. **Start with domain value
138 objects**`ObjectId`, `RefName`, `TreeEntry` — before any adapter. A query port
139 returning `String`s is an anaemic pass-through that pushes validation into the page.
140 Also the point to measure fork/exec cost per page view, and to reconsider `gix` for
141 the read path ([0006]decisions/0006-git-binary-behind-narrow-ports.md).
1423. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open
143 whether writing or projects/showcases is the better first portfolio feature.
144
145## Open questions
146
147- **Topcoat is early** (v0.5.0, first released 2026-07-22, breaking changes expected
148 by its own authors). Expect churn that isn't feature work.
149- Topcoat ships Tailwind without Node, which reopens the design system attempt #1
150 dropped purely to avoid an npm build step — see [ui.md]ui.md.
151
152## Routing findings (Milestone 0)
153
154- **Topcoat 0.5 requires rustc ≥ 1.95.** On an older toolchain `cargo add topcoat`
155 silently resolves to an empty `topcoat v0.0.0` placeholder instead of failing. Local
156 stable is now 1.97.1.
157- `Router::builder().discover()` collects `#[page]`-annotated items **at link time**,
158 so pages can live in any module. Layering is our choice, not the framework's.
159- `module_router!` derives each URL from the module tree rather than a path string.
160 Still deferred. Application routes now group cleanly (`auth/login`, `api/me`), but
161 handles sit at the root ([0004]decisions/0004-root-handles-grouped-routes.md), so a
162 parameterised root segment still has to coexist with static ones. Worth checking how
163 `module_router!` handles that before committing to it.
164- Path and query params are read from `Cx` via `path_param!` / `#[query_params]`, not
165 injected as handler arguments. Parses are memoized per request.
166- Layouts wrap by path prefix and nest outermost-first, and a layout can catch a page's
167 `NotFoundError` to render a branded 404.
168- `HOST` / `PORT` configure the bind address, so `STEID_LISTEN_ADDR` is gone.
169- `Body` is a boxed `http_body::Body` used for both requests and responses, with
170 `into_data_stream()` to read and `Body::new()` to wrap a stream — pack data can
171 stream both directions without buffering. This is what makes Milestone 4 viable.