steid

@jamesgill /

docs: scope every URL under an explicit prefix

Decision 0003. Handles move under /user/{handle} rather than sitting at the
root, and content types get their own segment: /user/{handle}/repos/{name},
/user/{handle}/posts/{slug}.

This removes the reserved-handle problem entirely rather than managing it.
Root-level handles share a namespace with /login, /setup and /api, which
forces a denylist that grows with every new top-level route and fails
silently when someone forgets. Scoping also stops content types colliding
with each other, which the sigil approach (GitLab's /-/) does not.

The cost is real and worth naming: /james is a CV URL, /user/james is an app
URL, and Steid is portfolio-first. Clone URLs inherit the length too.

Two prefixes rather than one because every handle in Steid is an
Organization -- a user's handle is their personal org's name -- so
/user/acme-corp would be wrong once real orgs exist. /org/{handle} covers
those, which needs an orgs.kind discriminator. Adding that at milestone 2,
with one row in the table, is free; adding it at milestone 7 is a migration.

Also note in current.md that scoping makes module_router! a better fit than
it was: the module tree and the URL tree now line up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesPatrickGill authored 1 month agoparent10ecb99Browse files7addd535e4b337a0ec0f05ce806656ba86de844e

3 files changed+89 −16

plans/ROADMAP.md+2 −1View file
@@ -37,6 +37,7 @@ Starting intent, not settled decisions. Each one gets a record in
3737 | Git auth | personal access tokens over HTTP Basic | [0001](decisions/0001-git-over-http-not-ssh.md) |
3838 | Crate layout | single crate | — |
3939 | Ownership | personal org owns repos | — |
40+| URLs | scoped: `/user/{handle}/...` | [0003](decisions/0003-scoped-urls.md) |
4041 | Database | SQLite via sqlx | — |
4142
4243 Architecture and conventions: [architecture.md](architecture.md).
@@ -51,7 +52,7 @@ a baseline.
5152 |---|---|---|
5253 | 0 | **Skeleton** — Topcoat app boots, config, one page, SQLite wired | done |
5354 | 1 | **Identity, thin** — claim on first run, login, session | done |
54| 2 | **Profile page** — `/{owner}` as the real home page | active |
55+| 2 | **Profile page** — `/user/{handle}` as the real profile | active |
5556 | 3 | **Writing** — posts, markdown | not started |
5657 | 4 | **Repo model** — records + bare repos on disk | not started |
5758 | 5 | **Git over HTTP** — `git http-backend`, PATs for auth | not started |
plans/current.md+17 −15View file
@@ -6,7 +6,7 @@
66
77 ## Active: Milestone 2 — Profile page
88
9**Goal:** `/{owner}` becomes the real home page, replacing the Milestone 0
9+**Goal:** `/user/{handle}` becomes the real profile page, replacing the Milestone 0
1010 placeholder. This is the frame the rest of the product hangs in — repos, writing, and
1111 projects all appear on it later.
1212
@@ -16,26 +16,28 @@ stays small.
1616
1717 ### Steps
1818
19- [ ] Settle routing: `module_router!` vs explicit `#[page]` paths, and `path_param!`
20 for `{owner}` — deferred from Milestone 0, and the URL space is about to grow
21- [ ] `/{owner}` — public page, resolves the org by handle, 404 when unknown
19+- [x] Settle URL shape — scoped under `/user/{handle}`, see
20+ [0003](decisions/0003-scoped-urls.md)
21+- [ ] Settle routing mechanics: `module_router!` vs explicit `#[page]` paths, and
22+ `path_param!` for `{handle}` — deferred from Milestone 0
23+- [ ] Migration: `orgs.kind` (`personal` | `shared`), backfilled `personal`
24+- [ ] `/user/{handle}` — public page, resolves the org by handle, 404 when unknown
2225 - [ ] `/` redirects to the owner's profile once claimed
2326 - [ ] Owner-only affordances visible when the viewer is the owner
24- [ ] `/api/orgs/{owner}` — the API surface for the same read model
27+- [ ] `/api/users/{handle}` — the API surface for the same read model
2528
2629 ### Done when
2730
28`/{owner}` renders for a signed-out visitor, shows extra affordances to the owner, and
29an unknown handle 404s rather than erroring.
31+`/user/{handle}` renders for a signed-out visitor, shows extra affordances to the
32+owner, and an unknown handle 404s rather than erroring.
3033
3134 ### Watch for
3235
3336 - **Handle lookups are case-insensitive** in storage (`collate nocase`) and lowercased
3437 by `OrgName::new`. A URL with different casing must resolve, not 404.
35- **Reserved handles.** `/setup`, `/login`, `/logout`, `/api` are real routes. A user
36 claiming the handle `login` would shadow or be shadowed by one. Nothing prevents
37 this yet — decide before multi-user registration in Milestone 7, or sooner if it's
38 cheap.
38+- ~~**Reserved handles.**~~ Resolved by [0003](decisions/0003-scoped-urls.md): handles
39+ are scoped under `/user/`, so they cannot collide with application routes and no
40+ denylist is needed.
3941 - **A profile is public.** It is the first page rendering for anonymous visitors by
4042 design, so anything private must be gated explicitly rather than by assuming a
4143 session exists.
@@ -58,7 +60,7 @@ an unknown handle 404s rather than erroring.
5860
5961 Ordered. Pull from the top.
6062
611. **Milestone 3 — Writing.** Posts, markdown rendering, `/{owner}/{slug}`.
63+1. **Milestone 3 — Writing.** Posts, markdown rendering, `/user/{handle}/posts/{slug}`.
6264 *Open question: is writing actually the first portfolio feature, or is it
6365 projects/showcases?*
6466 2. **Milestone 4 — Repo model.** `Repository` entity, `Visibility`, `create_repo`,
@@ -85,9 +87,9 @@ Ordered. Pull from the top.
8587 - `Router::builder().discover()` collects `#[page]`-annotated items **at link time**,
8688 so pages can live in any module. Layering is our choice, not the framework's.
8789 - `module_router!` derives each URL from the module tree rather than a path string.
88 Still deferred — Steid's URL space is parameterised at the root (`/{owner}`,
89 `/{owner}/{repo}`), which means `path_param!` declarations inside route modules.
90 Worth designing at Milestone 2 when the profile page makes it concrete.
90+ Still deferred. Now that URLs are scoped ([0003](decisions/0003-scoped-urls.md)) the
91+ module tree and the URL tree line up — `user/handle/repos/name` — which makes
92+ `module_router!` a much better fit than it was under root-level handles.
9193 - Path and query params are read from `Cx` via `path_param!` / `#[query_params]`, not
9294 injected as handler arguments. Parses are memoized per request.
9395 - Layouts wrap by path prefix and nest outermost-first, and a layout can catch a page's
plans/decisions/0003-scoped-urls.md+70 −0View file
@@ -0,0 +1,70 @@
1+# 0003 — Scope every URL under an explicit prefix
2+
3+**Status:** accepted · **Date:** 2026-08-04
4+
5+## Context
6+
7+Milestone 2 introduces the profile page, which fixes the shape of every URL after it.
8+Forges conventionally put handles at the root — `/james`, `/james/my-repo` — following
9+GitHub, GitLab, and Gitea.
10+
11+Root-level handles share one namespace with the application's own routes. `/setup`,
12+`/login`, `/logout`, and `/api` already exist, and nothing stops a user claiming the
13+handle `login`. That forces a reserved-word denylist which must grow every time a
14+top-level route is added, and forgetting once means a profile shadows a real route or
15+the reverse. It also makes a 404 ambiguous: unknown handle and unknown page are
16+indistinguishable.
17+
18+## Decision
19+
20+Scope every URL under an explicit prefix. Handles are never at the root.
21+
22+```
23+/user/{handle} personal profile
24+/user/{handle}/repos/{name} repository
25+/user/{handle}/posts/{slug} writing
26+/org/{handle} shared organisation (Milestone 7)
27+
28+/login /logout /setup /api/... application routes, free to grow
29+```
30+
31+Every segment says what the next one means, at every level. This is a general
32+preference, not a one-off: prefer an explicit scope over an implicit one wherever the
33+choice arises.
34+
35+Two prefixes rather than one because in Steid every handle is an `Organization` — a
36+user's handle is their personal org's name. `/user/acme-corp` would be wrong once real
37+organisations exist. Distinguishing them requires a `kind` discriminator on `orgs`
38+(`personal` | `shared`), which is better modelling regardless: the two differ in
39+whether they belong to exactly one user.
40+
41+## Alternatives considered
42+
43+- **Root handles plus a denylist** (GitHub, Gitea). Shortest URLs and the familiar
44+ shape. Rejected: the denylist is unbounded maintenance, and the failure mode is a
45+ silent collision rather than an error.
46+- **Root handles plus a sigil for system routes** (`/-/login`, as GitLab moved to).
47+ One reserved prefix instead of a growing list, and keeps `/james` clean. Rejected as
48+ less consistent — it scopes the application's routes but leaves content unscoped, so
49+ `/james/my-repo` and `/james/my-post` still share a namespace and can collide with
50+ each other.
51+- **A single neutral prefix for all handles** (`/o/{handle}`). Avoids needing a `kind`
52+ discriminator. Rejected: opaque to read, and the discriminator is worth having on its
53+ own merits.
54+
55+## Consequences
56+
57+- **No reserved words.** Any top-level route can be added forever without checking a
58+ list, and handle validation stays purely about format.
59+- **404s are unambiguous.** An unknown handle under `/user/` is a missing user; an
60+ unknown top-level path is a missing page.
61+- **Content types can't collide with each other.** `/user/james/repos/x` and
62+ `/user/james/posts/x` coexist. Root-level schemes have to arbitrate.
63+- **URLs are longer, and that costs something real.** `/user/james` is an app URL where
64+ `/james` is a CV URL, and Steid is portfolio-first, so this is a genuine trade against
65+ the product's own framing. Clone URLs inherit it:
66+ `git clone https://host/user/james/repos/steid.git`.
67+- **`orgs` needs a `kind` column** before Milestone 7. Adding it at Milestone 2, while
68+ there is one row, is free.
69+- Expensive to reverse once URLs are public — which is why it is being decided before
70+ the first real page rather than after.