@jpgilldev / steid

steid/plans/decisions/0001-git-over-http-not-ssh.md
3.8 KBRaw
1# 0001 — Serve git over smart HTTP, not SSH
2
3**Status:** accepted · **Date:** 2026-07-31
4
5## Context
6
7The previous attempt served git over an embedded russh server and reached working
8clone, push, and public-key authentication. Carrying that forward raised three
9problems at once:
10
111. **Unknown coexistence risk.** Topcoat's `topcoat dev` builds, watches sources, and
12 restarts the app. Whether a long-lived embedded SSH listener survives that loop was
13 untested, and it sat under four milestones of planned work.
142. **A layering question with no cheap answer.** Topcoat's grain is async components
15 that query the database and check permissions inline. An SSH channel handler has no
16 `Cx`, no request, and no cookies, so any authorization expressed in a component is
17 invisible to `serve_push`. Reconciling the two needed a deliberate architectural
18 position before any domain code could be written.
193. **A whole subsystem for key management.** SSH public keys need a table, SHA256
20 fingerprinting, canonical re-encoding, dedup, and revocation UI — roughly a
21 milestone of work serving one transport.
22
23Git's smart HTTP protocol is an alternative transport with the same capabilities.
24Notably, the original roadmap called for HTTP first; SSH was an unplanned detour.
25
26## Decision
27
28Serve git over smart HTTP as the primary transport, delegating the protocol to
29`git http-backend` — the CGI that ships with git and implements `info/refs`,
30`upload-pack`, `receive-pack`, and dumb-protocol fallback.
31
32Authenticate with personal access tokens over HTTP Basic. SSH may return later as an
33optional transport; it is not load-bearing.
34
35Verified before accepting: `topcoat_router::Body` is a boxed `http_body::Body` used
36for both requests and responses, with `into_data_stream()` for reading and
37`Body::new()` wrapping any stream for writing — so pack data streams in and out
38without buffering.
39
40## Alternatives considered
41
42- **Embedded SSH (russh), as attempt #2 did.** Better developer experience — key-based
43 auth with no credential prompts. Rejected for the three costs above; the deciding
44 factor was that it was the only context-less transport, and removing it dissolves an
45 architectural question rather than answering it.
46- **Implement smart HTTP directly.** More control, no subprocess. Rejected for the same
47 reason gitoxide was rejected: protocol details we would own and get wrong, against a
48 binary that already handles every case correctly.
49- **Both transports from the start.** Twice the surface before the product exists.
50
51## Consequences
52
53- **The layering question disappears.** Every transport now has a `Cx`. There is no
54 context-less caller, so a transport-neutral use-case layer is a matter of taste
55 rather than necessity. See [architecture.md](../architecture.md#data-access).
56- **No russh, no host keys, no coexistence risk.** One protocol, one port, and TLS
57 terminates the way it does for any web app.
58- **SSH key management collapses into personal access tokens** — smaller, and needed
59 for `/api` regardless, so the work is shared rather than additional.
60- **Worse developer experience, chosen knowingly.** `git clone https://…` prompts for
61 credentials unless a credential helper is configured. SSH keys are nicer. GitHub ran
62 HTTPS-plus-token as its primary path for years, so the path is well-trodden, but this
63 is a real regression against attempt #2.
64- **`git` remains a runtime dependency**, now including `git-http-backend`.
65- **Body size limits will bite.** `topcoat-router` has a `body_limit` layer; a large
66 push will be rejected until the cap is raised on the git routes. Expect to hit this
67 as a confusing failure rather than a clear one.
68- Reversible. `GitProtocolServer` stays a port, so adding SSH later means an adapter
69 plus a key-management subsystem — not a rewrite.