steid

@jamesgill /

steid/plans/current.md
10.8 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 3 — Repo model
8
9**Goal:** repositories exist as records and as bare git repos on disk, and they appear
10on the profile. No git protocol yet — that is milestone 4. This milestone fills the
11Repositories section and gets the storage layout right before anything serves it.
12
13**Out of scope:** clone, push, browsing a tree, README rendering, forks, stars.
14Deleting a repo — worth having, but it makes the filesystem/database consistency
15problem twice as interesting, so not in the first pass.
16
17### Steps
18
19- [x] Domain: `RepoId`, `RepoName`, `Visibility` (Public/Private), `Repository`
20- [x] Domain: `RepoRepository` port — `find_by_id`, `find_by_org_and_name`,
21 `list_by_org`, `save`
22- [x] Infrastructure: in-memory + SQLite implementations, migration
23- [x] Application: `GitStorage` port — `init_bare`, `remove`, `repo_path`
24- [x] Infrastructure: `DiskGitStorage`, shelling out to `git init --bare`
25- [x] Application: `create_repo` use case — owner only, validates, creates record and
26 bare repo
27- [x] Application: `view_repo` read model — visibility-aware
28- [x] Web: `/{handle}/repos/new` form, `/{handle}/repos/{name}` page
29- [x] Application: `list_repos` + the profile's Repositories section listing what the
30 viewer may see
31- [x] `/api/users/{handle}/repos`
32
33### Done when
34
35The owner creates a repo through the UI, a bare repo appears at
36`{data_dir}/{handle}/{name}.git`, and it is listed on the profile. A private repo is
37invisible to a signed-out visitor. `git clone` does **not** work yet — that is
38milestone 4.
39
40### Settled
41
42- **`list_repos` returns `Option<Vec<_>>`.** `None` is an unknown handle,
43 `Some(vec![])` a handle whose repositories the viewer cannot see. `/api` needs that
44 difference to answer 404 rather than `[]` for a user who does not exist.
45- **`RepoSummary` is separate from `RepoView`.** The owning handle and
46 `viewer_is_owner` are constant across a listing and already known to the page, so a
47 listing type carries neither.
48- **`list_repos` was split from `view_repo`** and moved to the listing step. `view_repo`
49 had a consumer immediately; `list_repos` would have been a third read model with no
50 caller, which is what the previous three steps already were.
51- **An invisible private repo is `None`, not `Forbidden`.** `view_repo` answers the same
52 way for "does not exist" and "not allowed to see", and the page 404s identically. A
53 403 would confirm the repository exists and leak its name.
54- **Private repos are visible to any member**, not only the owner — seeing is weaker
55 than changing, matching attempt #2's clone rule.
56
57- **Repo name rules:** `OrgName`'s, plus `.` and `_` for names like `.github` and
58 `foo.js`. Lowercased, max 100. Also rejects a name of nothing but dots and any name
59 ending `.git` — the first is traversal, the second would live at `foo.git.git`.
60- **Reserved repo names:** `import`, `new`, `search`. Only names directly under
61 `/{handle}/repos/` can collide.
62- **Visibility defaults to public**, matching a portfolio-first product.
63- **Repositories carry an optional description**, capped at 300 characters — a sentence
64 for the profile listing, not a README. Kept deliberately: portfolio-first is the
65 tie-break, and this milestone's own "Done when" puts repositories on the profile, so
66 the consumer is inside the milestone rather than hypothetical. The per-repo analogue
67 of `Organization::bio`.
68- **`/api/users/{handle}/repos` answers with a bare array**, not an
69 `{"repositories": […]}` envelope, matching `/api/users/{handle}` returning a bare
70 object. The cost is knowingly taken: adding pagination later means a wrapper and a
71 breaking change. Personal-first means a handful of repositories, and the API has no
72 consumers to break yet.
73- **`/api` resolves the handle without loading a profile.** `handle_param` was split
74 out of `profile_for` so the listing route can 404 on `list_repos`' own `None` rather
75 than on a profile lookup it does not otherwise need — which is what that `Option` was
76 given a meaning for.
77
78- **`list_by_org` returns every repository regardless of visibility.** Filtering is an
79 authorization decision and belongs to the use case, so the page and `/api` cannot end
80 up applying different rules. The cost is that a private repo is briefly in memory
81 before being filtered, which is fine in-process.
82
83### Open
84
85Nothing open. `GitStorage`'s shape and how git is invoked are recorded in
86[0006]decisions/0006-git-binary-behind-narrow-ports.md.
87
88### Watch for
89
90- **`redirect()` is a 307 and re-POSTs.** Post/redirect/get needs a 303. `see_other()`
91 is the right status but is a *response* type, and `#[page]` must return a view so the
92 layout can wrap a failure re-render. The way to get both from one handler is a
93 `StatusCode` and a `Location` pair inside `view!` — wrapped as
94 `context::location`. This shipped broken in Milestone 2's settings form and was
95 caught by browser verification, not by any test.
96
97- **An orphaned directory is indistinguishable from a duplicate to the visitor.**
98 `create_repo` maps `GitStorageError::AlreadyExists` to "that name is taken", which is
99 true from outside but hides the inconsistency from the operator. There is no logging
100 story yet for it to surface in. The durable fix is the reconciliation sweep in
101 [architecture.md]architecture.md#db-plus-filesystem-writes.
102- **The duplicate check races.** Two concurrent creates of the same name can both pass
103 `find_by_org_and_name`; the loser is then stopped by `init_bare` or, failing that, by
104 the `unique (org_id, name)` constraint — which surfaces as an opaque storage error
105 rather than "name taken". Correct, just ugly, and single-user for now.
106
107- **The database and the filesystem cannot share a transaction.** Creating a repo
108 writes a row and a directory. Neither previous attempt solved this properly — see
109 [architecture.md]architecture.md#db-plus-filesystem-writes. A compensating delete is
110 good enough to ship, but write down that an orphaned directory is possible if the
111 process dies between the two, rather than rediscovering it.
112- **Path traversal.** `{data_dir}/{handle}/{name}.git` is built from user input. A name
113 containing `..` or `/` must be impossible before it reaches the filesystem, and
114 `RepoName` is the place to make it impossible rather than sanitising at the call site.
115- **Visibility is an authorization decision**, so it belongs in the use case. A private
116 repo must be absent from listings, not merely unlinked — and `/api` must agree with
117 the page.
118- **`git` is a dependency of the test suite too**, not only of the runtime —
119 `DiskGitStorage`'s tests run real `git init`. A machine without `git` fails
120 `cargo test`, not just the app.
121- **A handle rename is a directory move.** The layout is keyed by handle for
122 legibility, so whenever renaming arrives it has to move the tree; it cannot be a row
123 update. Nothing renames handles today.
124- **Bare repos created on macOS carry `ignorecase = true`** in their config, because
125 git probes the filesystem at init. Correct where it was created, wrong if the data
126 directory is ever moved to Linux. A migration gotcha, not a bug.
127- **An orphaned directory blocks re-creating that name.** `init_bare` refuses rather
128 than adopting what is already there, and repo deletion is out of scope this
129 milestone, so clearing one is a manual `rm` for now.
130
131### Carried over — small, unblocked
132
133- **Fonts are not loaded.** The theme names Geist and IBM Plex Mono; both fall back
134 today. Topcoat's `font-fontsource` feature handles it.
135- **Light mode is untested.** The palette defines it; nobody has looked at it.
136- **No rate limiting** on `/auth/login` or `/auth/setup`.
137- **`sweep_expired` is never called**, so expired session rows accumulate. Expiry is
138 enforced on read, so this is tidiness, not a hole.
139- **CSRF.** `SameSite=Lax` covers the common case. Forms now exist, so this is decidable
140 rather than hypothetical.
141
142## Backlog
143
144Ordered. Pull from the top.
145
1461. **Milestone 4 — Git over HTTP.** `git http-backend` subprocess, PATs over HTTP
147 Basic. See [0001]decisions/0001-git-over-http-not-ssh.md. The `body_limit` cap will
148 reject large pushes until raised.
1492. **Milestone 5 — Repo browsing.** Tree, blob, commit log. **Start with domain value
150 objects**`ObjectId`, `RefName`, `TreeEntry` — before any adapter. A query port
151 returning `String`s is an anaemic pass-through that pushes validation into the page.
152 Also the point to measure fork/exec cost per page view, and to reconsider `gix` for
153 the read path ([0006]decisions/0006-git-binary-behind-narrow-ports.md).
1543. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open
155 whether writing or projects/showcases is the better first portfolio feature.
156
157## Open questions
158
159- **Topcoat is early** (v0.5.0, first released 2026-07-22, breaking changes expected
160 by its own authors). Expect churn that isn't feature work.
161- Body size limits will reject large pushes at Milestone 4 — `topcoat-router` has a
162 `body_limit` layer that needs raising on the git routes. Recorded here because it
163 will surface as a confusing failure rather than a clear one.
164- Topcoat ships Tailwind without Node, which reopens the design system attempt #1
165 dropped purely to avoid an npm build step — see [ui.md]ui.md.
166
167## Routing findings (Milestone 0)
168
169- **Topcoat 0.5 requires rustc ≥ 1.95.** On an older toolchain `cargo add topcoat`
170 silently resolves to an empty `topcoat v0.0.0` placeholder instead of failing. Local
171 stable is now 1.97.1.
172- `Router::builder().discover()` collects `#[page]`-annotated items **at link time**,
173 so pages can live in any module. Layering is our choice, not the framework's.
174- `module_router!` derives each URL from the module tree rather than a path string.
175 Still deferred. Application routes now group cleanly (`auth/login`, `api/me`), but
176 handles sit at the root ([0004]decisions/0004-root-handles-grouped-routes.md), so a
177 parameterised root segment still has to coexist with static ones. Worth checking how
178 `module_router!` handles that before committing to it.
179- Path and query params are read from `Cx` via `path_param!` / `#[query_params]`, not
180 injected as handler arguments. Parses are memoized per request.
181- Layouts wrap by path prefix and nest outermost-first, and a layout can catch a page's
182 `NotFoundError` to render a branded 404.
183- `HOST` / `PORT` configure the bind address, so `STEID_LISTEN_ADDR` is gone.
184- `Body` is a boxed `http_body::Body` used for both requests and responses, with
185 `into_data_stream()` to read and `Body::new()` to wrap a stream — pack data can
186 stream both directions without buffering. This is what makes Milestone 4 viable.