steid

@jamesgill /

docs: put git next, plan the repo model

Reverses the original ordering, which put writing at milestone 3 and repos
behind it. Two reasons, both only visible once the profile page existed: it
has a Repositories section a visitor expects to be full, and a gitforge whose
repo list is permanently empty is its own kind of wrong; and git-over-HTTP is
the least-understood work left, so learning what Topcoat makes awkward is
worth more now than after three more features are stacked on top.

The risk is recorded rather than glossed: disappearing into protocol work is
what ended both previous attempts. Milestone 3 is deliberately small and
ships something visible; if milestone 4 sprawls, that is the signal to bank
it and go back to writing.

Three things the plan flags to decide during rather than assume. Repo names
conventionally allow dots and underscores where OrgName does not, and that
choice drags in case-insensitive uniqueness. /{handle}/repos/new is static
and static beats parameterised, so a repo named "new" would be unreachable --
same problem as handles, same fix, and reserving costs nothing before the
first repo exists. And whether repos default public or private is a genuine
product call rather than a default to copy.

Path traversal is called out explicitly: {data_dir}/{handle}/{name}.git is
built from user input, and RepoName is where ".." must become impossible
rather than sanitising at the call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 24 days agoparentc50d9adBrowse files2eb8681ed07e549c15d963834d70cc72537182f0

2 files changed+86 −118

plans/ROADMAP.md+21 −11View file
@@ -53,10 +53,10 @@ a baseline.
5353 | 0 | **Skeleton** — Topcoat app boots, config, one page, SQLite wired | done |
5454 | 1 | **Identity, thin** — claim on first run, login, session | done |
5555 | 2 | **Profile page** — `/{handle}` as the real profile | done |
56| 3 | **Writing** — posts, markdown | active |
57| 4 | **Repo model** — records + bare repos on disk | not started |
58| 5 | **Git over HTTP** — `git http-backend`, PATs for auth | not started |
59| 6 | **Repo browsing** — tree, blob, commit log | not started |
56+| 3 | **Repo model** — records + bare repos on disk | active |
57+| 4 | **Git over HTTP** — `git http-backend`, PATs for auth | not started |
58+| 5 | **Repo browsing** — tree, blob, commit log | not started |
59+| 6 | **Writing** — posts, markdown | not started |
6060 | 7 | **Identity, full** — multi-user, orgs, invites, registration policy | not started |
6161 | 8+ | Projects/showcases · issues & PRs · SSH transport · federation | not started |
6262
@@ -73,13 +73,23 @@ shipped identity, repo records, clone, push, and key auth — and not one portfo
7373 feature. Attempt #2 ended with all the git infrastructure working and nothing that
7474 distinguishes it from Gitea.
7575
76So the differentiator ships early (2–3), and git — which is well-understood and has a
77working reference implementation — comes after. Full multi-user identity is deferred
78to 7, because personal-first means one user, and there's nothing to collaborate on
79until there's something worth collaborating on.
80
81**Open:** milestone 3 is written as *writing/posts*, but *projects & showcases* is the
82other candidate for the first portfolio feature. Unresolved.
76+The differentiator therefore shipped early: the profile page is milestone 2, not
77+milestone 6. Full multi-user identity stays deferred to 7 — personal-first means one
78+user, and there is nothing to collaborate on until there is something worth
79+collaborating on.
80+
81+**Git moved ahead of writing after milestone 2 shipped**, deliberately reversing the
82+original order. Two reasons. The profile has a Repositories section that a visitor
83+expects to be full, and a gitforge whose repo list is permanently empty is its own kind
84+of wrong. And git-over-HTTP is the least-understood work left — everything else is CRUD
85+over SQLite, while that is pack data streamed through a subprocess — so learning what
86+Topcoat makes awkward is worth more now than after three more features are stacked on
87+top.
88+
89+The risk being run knowingly: disappearing into protocol work is what ended both
90+previous attempts. Milestone 3 is deliberately small and ships something visible on the
91+profile; if milestone 4 starts to sprawl, that is the signal to bank it and go back to
92+writing.
8393
8494 **One numbering scheme.** The previous attempt kept two (a roadmap ladder and a
8595 separate build log with conflicting numbers) and they drifted apart within three
plans/current.md+65 −107View file
@@ -4,130 +4,88 @@
44 > [progress.md](progress.md). If this file starts reading like a changelog, it has
55 > drifted — that's exactly what went wrong last time.
66
7## Active: Milestone 2 — Profile page
8
9**Goal:** `/{handle}` is the real profile page — public, working signed out, and the
10frame that repos, writing, and projects hang off later. It replaces the Milestone 0
11placeholder.
12
13**Out of scope:** repos, posts, and projects don't exist yet, so there is nothing to
14list. Avatars, following, and anything social. Organisation profiles beyond what falls
15out for free.
16
17### Phase 1 — the page
18
19Shippable on its own: a public profile that renders signed out.
20
21- [x] Settle URL shape — handles at the root, routes grouped under prefixes
22 ([0004](decisions/0004-root-handles-grouped-routes.md))
23- [x] Reserved-handle denylist in `OrgName::new`; auth routes moved under `/auth/`
24- [x] Explicit `#[page]` paths for now; `module_router!` still unexamined, and four
25 routes is too few to judge it against
26- [x] `PublicProfile` read model + `view_profile` use case — no email field, by design
27- [x] `/{handle}` page: label, handle, bio, and the section frame; 404 on unknown,
28 case-insensitive
29- [x] Asserted `/auth/login` and `/api/me` still route with `/{handle}` at the root —
30 static beats parameterised
31- [x] `/` redirects to the owner's profile once claimed, retiring the placeholder
32- [x] `/api/users/{handle}` — same read model, public JSON
33- [x] Migration `orgs.bio`, pulled forward so the page had a field to render
34
35### Phase 2 — make it yours
36
37A profile you can't change is a stub. This is what makes it a portfolio page.
38
39- [x] Styling: Tailwind via Topcoat, theme retuned, primitives copied in, `flash`
40 written by hand ([0005](decisions/0005-tailwind-and-copied-components.md))
41- [x] Flash messages — `flash` component, hand-written; the registry has no alert
42- [x] `/{handle}/settings` — edit display name and bio, owner only, enforced in the use
43 case (settings belong to the org, and this scales to organisations)
44- [x] Owner-only affordances on the profile (edit link)
7+## Active: Milestone 3 — Repo model
8+
9+**Goal:** repositories exist as records and as bare git repos on disk, and they appear
10+on the profile. No git protocol yet — that is milestone 4. This milestone fills the
11+Repositories 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.
14+Deleting a repo — worth having, but it makes the filesystem/database consistency
15+problem twice as interesting, so not in the first pass.
16+
17+### Steps
18+
19+- [ ] Domain: `RepoId`, `RepoName`, `Visibility` (Public/Private), `Repository`
20+- [ ] Domain: `RepoRepository` port — `find_by_id`, `find_by_org_and_name`,
21+ `list_by_org`, `save`
22+- [ ] Infrastructure: in-memory + SQLite implementations, migration
23+- [ ] Application: `GitStorage` port — `init_bare`, `repo_path`
24+- [ ] Infrastructure: `DiskGitStorage`, shelling out to `git init --bare`
25+- [ ] Application: `create_repo` use case — owner only, validates, creates record and
26+ bare repo
27+- [ ] Application: `list_repos` / `view_repo` read models — visibility-aware
28+- [ ] Web: `/{handle}/repos/new` form, `/{handle}/repos/{name}` page
29+- [ ] Web: the profile's Repositories section lists what the viewer may see
30+- [ ] `/api/users/{handle}/repos`
4531
4632 ### Done when
4733
48Signed out, `/{handle}` renders the owner's display name and handle and nothing
49private. An unknown handle 404s. The owner can set a display name and bio and see them
50on the page. `/api/users/{handle}` returns the same public view.
51
52### Findings — phase 2
53
54- **Components are invoked bare inside `view!`** — `label(attrs: …, "Text")`, not
55 `(label(…)?)`. `if`, `match`, `for` and `let` are native to the macro too, so the
56 wrapper-block pattern is unnecessary.
57- **`#[query_params]` needs `error = …`** to be usable with `?`. Without it the `Err`
58 side borrows from `cx` and the borrow escapes the handler.
59- **Post-redirect-get on success, re-render on failure.** A redirect after an error
60 would discard what was typed and lose the reason — the exact problem `flash` exists
61 to solve.
62- `PublicProfile` carries `display_name` separately from `label`, so an edit form can
63 leave the field empty rather than prefilling the handle.
64
65### Findings — phase 1
66
67- **`path_param` is an attribute in Topcoat 0.5**, applied to a tuple struct
68 (`#[path_param] struct Handle(str);`), not the function-like `path_param!(handle)`
69 that the docs on `main` describe. **Read the vendored crate, not GitHub `main`** —
70 the framework is two weeks old and the two have already diverged.
71- A `str` inner type yields the raw percent-decoded segment with no parsing, which
72 suits validating through `OrgName` and 404ing what fails.
73- **Static routes beat parameterised ones**, so `/auth/login` and `/api/me` still work
74 with `/{handle}` registered at the root. Verified, not assumed.
75- Topcoat serves bundled assets from `/_topcoat/assets/…` with content-hashed URLs.
34+The owner creates a repo through the UI, a bare repo appears at
35+`{data_dir}/{handle}/{name}.git`, and it is listed on the profile. A private repo is
36+invisible to a signed-out visitor. `git clone` does **not** work yet — that is
37+milestone 4.
38+
39+### Decide during
40+
41+- **Repo name rules.** `OrgName` allows `[a-z0-9-]` and lowercases. Repo names
42+ conventionally allow dots and underscores (`.github`, `my_repo`, `foo.js`) and are
43+ often case-preserving. Following `OrgName` exactly is simplest and rejects names
44+ people will reasonably want; allowing more means deciding about case-insensitive
45+ uniqueness and about names that are awkward on disk.
46+- **`new` collides.** `/{handle}/repos/new` is a static route and static beats
47+ parameterised, so a repo actually named `new` would be unreachable at its own URL.
48+ Same problem as handles, same fix: a small reserved list on `RepoName`. Reserve
49+ before the first repo exists.
50+- **Visibility default.** Public matches a portfolio-first product; private matches
51+ every forge people are used to.
7652
7753 ### Watch for
7854
79- **Do not leak email.** `describe_identity` carries email, and `/api/me` returns it —
80 correctly, because that endpoint describes the caller to themselves. The public
81 profile needs its **own** read model; reusing `Identity` would publish the owner's
82 email address to anonymous visitors. This is the single most likely mistake in this
83 milestone.
84- **Route precedence** between static routes and `/{handle}`. The reserved list stops a
85 user *owning* `auth`, but it does not stop the router matching `/api/me` against
86 `/{handle}/{x}` and shadowing the real route. Static-over-parameterised is near
87 universal, so this is an assertion when the route lands, not a blocking spike.
88- **Case-insensitive handles.** Storage is `collate nocase` and `OrgName::new`
89 lowercases, so `/JamesGill` must resolve rather than 404.
90- **Authorization on settings** belongs in the use case, taking an `Actor` — not in the
91 page. Otherwise `/api` gets a different answer from the web form.
92- **A profile is public.** This is the first page rendering for anonymous visitors by
93 design, so anything private must be gated explicitly rather than by assuming a
94 session exists.
95- **Reserve handles early.** Adding to the denylist later is a breaking change for
96 whoever holds that handle ([0004](decisions/0004-root-handles-grouped-routes.md)).
97
98### Settled
99
100- **Settings live at `/{handle}/settings`.** They belong to the organisation, which
101 scales to real organisations in Milestone 7 without moving.
102- **Render the full frame from the start**, including sections with nothing in them.
103 A profile page that renders almost nothing is a poor start for a portfolio-first
104 product; the shape of the page is part of what is being built, not scaffolding for
105 it.
55+- **The database and the filesystem cannot share a transaction.** Creating a repo
56+ writes a row and a directory. Neither previous attempt solved this properly — see
57+ [architecture.md](architecture.md#db-plus-filesystem-writes). A compensating delete is
58+ good enough to ship, but write down that an orphaned directory is possible if the
59+ process dies between the two, rather than rediscovering it.
60+- **Path traversal.** `{data_dir}/{handle}/{name}.git` is built from user input. A name
61+ containing `..` or `/` must be impossible before it reaches the filesystem, and
62+ `RepoName` is the place to make it impossible rather than sanitising at the call site.
63+- **Visibility is an authorization decision**, so it belongs in the use case. A private
64+ repo must be absent from listings, not merely unlinked — and `/api` must agree with
65+ the page.
66+- **`git` becomes a runtime dependency** from this milestone. The runbook should say so.
10667
10768 ### Carried over — small, unblocked
10869
70+- **Fonts are not loaded.** The theme names Geist and IBM Plex Mono; both fall back
71+ today. Topcoat's `font-fontsource` feature handles it.
72+- **Light mode is untested.** The palette defines it; nobody has looked at it.
10973 - **No rate limiting** on `/auth/login` or `/auth/setup`.
11074 - **`sweep_expired` is never called**, so expired session rows accumulate. Expiry is
11175 enforced on read, so this is tidiness, not a hole.
112- **CSRF.** `SameSite=Lax` covers the common case; whether forms also want tokens is
113 still undecided. Phase 2 adds a form, so this is the natural time to settle it.
114- **Light mode is untested.** The palette defines it, but every page has been looked at
115 dark-only. There is no toggle yet either.
116- **Fonts are not loaded.** The theme names Geist and IBM Plex Mono; neither is
117 installed, so both fall back. `topcoat`'s `font-fontsource` feature handles this.
76+- **CSRF.** `SameSite=Lax` covers the common case. Forms now exist, so this is decidable
77+ rather than hypothetical.
11878
11979 ## Backlog
12080
12181 Ordered. Pull from the top.
12282
1231. **Milestone 3 — Writing.** Posts, markdown rendering, `/{handle}/posts/{slug}`.
124 *Open question: is writing actually the first portfolio feature, or is it
125 projects/showcases?*
1262. **Milestone 4 — Repo model.** `Repository` entity, `Visibility`, `create_repo`,
127 bare repo on disk at `{data_dir}/{org}/{repo}.git`. Watch the DB-plus-filesystem
128 atomicity problem — see [architecture.md](architecture.md#db-plus-filesystem-writes).
1293. **Milestone 5 — Git over HTTP.** `git http-backend` subprocess, PATs over HTTP
130 Basic. See [0001](decisions/0001-git-over-http-not-ssh.md).
83+1. **Milestone 4 — Git over HTTP.** `git http-backend` subprocess, PATs over HTTP
84+ Basic. See [0001](decisions/0001-git-over-http-not-ssh.md). The `body_limit` cap will
85+ reject large pushes until raised.
86+2. **Milestone 5 — Repo browsing.** Tree, blob, commit log.
87+3. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open
88+ whether writing or projects/showcases is the better first portfolio feature.
13189
13290 ## Open questions
13391