steid

@jamesgill /

feat: root handles, grouped routes, reserved-handle denylist

Decision 0004, superseding 0003 the same day. 0003 scoped handles under
/user/{handle} to remove the reserved-word problem entirely, but paid for it
with the short profile URL -- and for a portfolio-first tool that URL is part
of the product, not an implementation detail.

Grouping the application's routes instead gets both. /james stays at the
root; /auth/login, /auth/logout, /auth/setup and /api/... group by function.
The denylist then grows per functional area rather than per route, which
makes it ten words that change rarely instead of unbounded maintenance.
Content below a handle groups too, so /james/repos/x and /james/posts/x
cannot collide.

RESERVED is checked against the normalised lowercase form, so API and api
are the same handle. Tests cover the list itself as well as its effect:
substrings like "apidocs" stay allowed, the list must be sorted and
deduplicated so a typo can't silently reserve nothing, and every entry must
be a shape that could actually be a handle -- reserving something already
impossible is dead weight that suggests a misreading of what the list is for.

Reserved early and generously: adding an entry later is a breaking change for
whoever holds that handle. Free now, with one row in the table.

Topcoat serves assets from /_topcoat/, which the character rules already
exclude, so it needs no reservation.

Verified: /setup now 404s, /auth/setup serves, claiming the handle "api" is
refused with nothing written, and claim/login/logout still work end to end.

96 tests.

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

8 files changed+207 −31

plans/ROADMAP.md+2 −2View file
@@ -37,7 +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) |
40+| URLs | root handles, grouped routes | [0004](decisions/0004-root-handles-grouped-routes.md) |
4141 | Database | SQLite via sqlx | — |
4242
4343 Architecture and conventions: [architecture.md](architecture.md).
@@ -52,7 +52,7 @@ a baseline.
5252 |---|---|---|
5353 | 0 | **Skeleton** — Topcoat app boots, config, one page, SQLite wired | done |
5454 | 1 | **Identity, thin** — claim on first run, login, session | done |
55| 2 | **Profile page** — `/user/{handle}` as the real profile | active |
55+| 2 | **Profile page** — `/{handle}` as the real profile | active |
5656 | 3 | **Writing** — posts, markdown | not started |
5757 | 4 | **Repo model** — records + bare repos on disk | not started |
5858 | 5 | **Git over HTTP** — `git http-backend`, PATs for auth | not started |
plans/current.md+14 −13View file
@@ -6,7 +6,7 @@
66
77 ## Active: Milestone 2 — Profile page
88
9**Goal:** `/user/{handle}` becomes the real profile page, replacing the Milestone 0
9+**Goal:** `/{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,28 +16,28 @@ stays small.
1616
1717 ### Steps
1818
19- [x] Settle URL shape — scoped under `/user/{handle}`, see
20 [0003](decisions/0003-scoped-urls.md)
19+- [x] Settle URL shape — handles at the root, routes grouped under prefixes, see
20+ [0004](decisions/0004-root-handles-grouped-routes.md)
21+- [x] Reserved-handle denylist in `OrgName::new`; auth routes moved under `/auth/`
2122 - [ ] Settle routing mechanics: `module_router!` vs explicit `#[page]` paths, and
2223 `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
24+- [ ] `/{handle}` — public page, resolves the org by handle, 404 when unknown
2525 - [ ] `/` redirects to the owner's profile once claimed
2626 - [ ] Owner-only affordances visible when the viewer is the owner
2727 - [ ] `/api/users/{handle}` — the API surface for the same read model
2828
2929 ### Done when
3030
31`/user/{handle}` renders for a signed-out visitor, shows extra affordances to the
31+`/{handle}` renders for a signed-out visitor, shows extra affordances to the
3232 owner, and an unknown handle 404s rather than erroring.
3333
3434 ### Watch for
3535
3636 - **Handle lookups are case-insensitive** in storage (`collate nocase`) and lowercased
3737 by `OrgName::new`. A URL with different casing must resolve, not 404.
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.
38+- **Reserve handles early.** Adding to the denylist later is a breaking change for
39+ whoever holds that handle. It is free while unclaimed, so reserve an area before it
40+ exists — see [0004](decisions/0004-root-handles-grouped-routes.md).
4141 - **A profile is public.** It is the first page rendering for anonymous visitors by
4242 design, so anything private must be gated explicitly rather than by assuming a
4343 session exists.
@@ -60,7 +60,7 @@ owner, and an unknown handle 404s rather than erroring.
6060
6161 Ordered. Pull from the top.
6262
631. **Milestone 3 — Writing.** Posts, markdown rendering, `/user/{handle}/posts/{slug}`.
63+1. **Milestone 3 — Writing.** Posts, markdown rendering, `/{handle}/posts/{slug}`.
6464 *Open question: is writing actually the first portfolio feature, or is it
6565 projects/showcases?*
6666 2. **Milestone 4 — Repo model.** `Repository` entity, `Visibility`, `create_repo`,
@@ -87,9 +87,10 @@ Ordered. Pull from the top.
8787 - `Router::builder().discover()` collects `#[page]`-annotated items **at link time**,
8888 so pages can live in any module. Layering is our choice, not the framework's.
8989 - `module_router!` derives each URL from the module tree rather than a path string.
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.
90+ Still deferred. Application routes now group cleanly (`auth/login`, `api/me`), but
91+ handles sit at the root ([0004](decisions/0004-root-handles-grouped-routes.md)), so a
92+ parameterised root segment still has to coexist with static ones. Worth checking how
93+ `module_router!` handles that before committing to it.
9394 - Path and query params are read from `Cx` via `path_param!` / `#[query_params]`, not
9495 injected as handler arguments. Parses are memoized per request.
9596 - Layouts wrap by path prefix and nest outermost-first, and a layout can catch a page's
plans/decisions/0003-scoped-urls.md+8 −1View file
@@ -1,6 +1,13 @@
11 # 0003 — Scope every URL under an explicit prefix
22
3**Status:** accepted · **Date:** 2026-08-04
3+**Status:** superseded by [0004](0004-root-handles-grouped-routes.md) · **Date:** 2026-08-04
4+
5+> **Superseded the same day.** The scoping principle held, but applying it to the
6+> handle itself cost the short profile URL — which is part of the product for a
7+> portfolio-first tool. [0004](0004-root-handles-grouped-routes.md) keeps handles at the
8+> root and groups the *application's* routes instead, which turns out to shrink the
9+> denylist to a rarely-changing ten words rather than eliminating it. Kept for the
10+> reasoning, which still applies below the handle.
411
512 ## Context
613
plans/decisions/0004-root-handles-grouped-routes.md+79 −0View file
@@ -0,0 +1,79 @@
1+# 0004 — Handles at the root, application routes grouped under prefixes
2+
3+**Status:** accepted · **Date:** 2026-08-04 · **Supersedes:** [0003](0003-scoped-urls.md)
4+
5+## Context
6+
7+[0003](0003-scoped-urls.md) scoped handles under `/user/{handle}` to eliminate the
8+reserved-word problem. It worked, but it paid for that with the thing Steid is
9+supposedly about: `/james` is a URL you put on a CV, `/user/james` is a URL an app
10+gives you. For a portfolio-first product the profile URL is part of the product, and
11+0003 traded it away for an implementation concern.
12+
13+The reserved-word problem is real, though. Handles at the root share a namespace with
14+application routes, so `/login` and a user called `login` cannot coexist.
15+
16+The insight that makes both possible: the denylist only has to grow per *route* if
17+routes live at the root. Group them under functional prefixes and the list grows per
18+*area* instead — rarely, and predictably.
19+
20+## Decision
21+
22+Handles live at the root. Application routes are grouped under functional prefixes, and
23+content below a handle is grouped by type.
24+
25+```
26+/james profile
27+/james/repos/{name} repository
28+/james/posts/{slug} writing
29+
30+/auth/login sign in
31+/auth/logout
32+/auth/setup first-run claim
33+/api/... JSON
34+```
35+
36+A lean denylist in `OrgName::new` reserves the prefixes, checked against the normalised
37+lowercase form so `API` and `api` are the same handle:
38+
39+```
40+about admin api assets auth explore help search settings static
41+```
42+
43+Adding `/auth/reset-password` costs nothing. Only a genuinely new area — `/explore` —
44+would add an entry, and the likely ones are reserved already.
45+
46+## Alternatives considered
47+
48+- **`/user/{handle}` scoping** ([0003](0003-scoped-urls.md)). Zero denylist, no
49+ ambiguity. Rejected for the URL it produces; the maintenance it avoided turned out to
50+ be small once routes were grouped.
51+- **A sigil for system routes** (`/-/login`, as GitLab uses). Genuinely zero denylist —
52+ `OrgName` already rejects handles starting or ending with a hyphen, so `-` is
53+ structurally unclaimable. Rejected because `/auth/login` says what it is and `/-/login`
54+ makes you learn a convention, and readable URLs are worth a ten-word list on a product
55+ where URLs are part of the presentation.
56+- **Root handles with a per-route denylist** (GitHub, Gitea). What grouping exists to
57+ avoid: the list grows every time a route is added and fails silently when someone
58+ forgets.
59+
60+## Consequences
61+
62+- **`/james` is the profile URL.** Clone URLs come out as
63+ `https://host/james/repos/steid.git`, comparable to GitHub's.
64+- **Content types cannot collide with each other**, because everything below a handle is
65+ grouped: `/james/repos/x` and `/james/posts/x` coexist, and a repo named `posts` is
66+ fine at `/james/repos/posts`.
67+- **A small denylist exists and must be maintained**, unlike under 0003. It is enforced
68+ in `OrgName::new` with tests, so it fails loudly at claim time rather than producing a
69+ shadowed route.
70+- **Reserve early.** Adding an entry later is a breaking change for whoever holds that
71+ handle — the account must be renamed and its links break. Reserving while unclaimed is
72+ free, which is why the list covers areas that do not exist yet.
73+- **An unknown root path is ambiguous** — `/jmaes` could be a typo'd handle or a missing
74+ page. Both 404, so this costs little in practice.
75+- **`orgs.kind` is no longer needed for routing.** 0003 required it to tell `/user/`
76+ from `/org/`; with one root namespace, both are just `/{handle}`. It may still be
77+ worth having for display, but it is not load-bearing and is dropped from Milestone 2.
78+- Topcoat serves its assets from `/_topcoat/`, which the character rules already exclude,
79+ so it needs no reservation.
src/domain/org.rs+90 −1View file
@@ -10,6 +10,22 @@ use super::{DomainError, OrgId};
1010 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
1111 pub struct OrgName(String);
1212
13+/// Handles that would collide with an application route.
14+///
15+/// Handles live at the root (`/{handle}`), so anything here would shadow a real route
16+/// or be shadowed by one. Kept deliberately lean: routes are grouped under functional
17+/// prefixes (`/auth/login`, not `/login`), so this grows per *area*, not per route.
18+///
19+/// **Reserve generously and early.** Adding an entry later is a breaking change for
20+/// whoever already holds that handle — their account has to be renamed and their links
21+/// break. Entries here cost nothing while unclaimed.
22+///
23+/// Topcoat's own assets live under `/_topcoat/`, which the character rules already
24+/// exclude, so it needs no entry.
25+const RESERVED: &[&str] = &[
26+ "about", "admin", "api", "assets", "auth", "explore", "help", "search", "settings", "static",
27+];
28+
1329 impl OrgName {
1430 pub const MAX_LEN: usize = 39;
1531
@@ -36,7 +52,14 @@ impl OrgName {
3652 return Err(invalid("must not start or end with a hyphen"));
3753 }
3854
39 Ok(Self(trimmed.to_lowercase()))
55+ let normalised = trimmed.to_lowercase();
56+
57+ // Checked against the normalised form: `API` and `api` are the same handle.
58+ if RESERVED.contains(&normalised.as_str()) {
59+ return Err(invalid("is reserved"));
60+ }
61+
62+ Ok(Self(normalised))
4063 }
4164
4265 /// Wraps a value already validated on the way into the database.
@@ -143,6 +166,72 @@ mod tests {
143166 assert!(OrgName::new("a".repeat(OrgName::MAX_LEN)).is_ok());
144167 }
145168
169+ #[test]
170+ fn rejects_reserved_handles() {
171+ for reserved in RESERVED {
172+ assert!(
173+ OrgName::new(*reserved).is_err(),
174+ "expected {reserved:?} to be reserved"
175+ );
176+ }
177+ }
178+
179+ #[test]
180+ fn reservation_ignores_case() {
181+ // Handles normalise to lowercase, so a differently-cased reserved word is the
182+ // same handle and must be refused too.
183+ assert!(OrgName::new("API").is_err());
184+ assert!(OrgName::new("Auth").is_err());
185+ }
186+
187+ #[test]
188+ fn reservation_does_not_leak_into_substrings() {
189+ // Only whole handles are reserved. `apidocs` shadows nothing.
190+ for allowed in [
191+ "apidocs",
192+ "authors",
193+ "administrator",
194+ "helpful",
195+ "settings-app",
196+ ] {
197+ assert!(
198+ OrgName::new(allowed).is_ok(),
199+ "expected {allowed:?} to be allowed"
200+ );
201+ }
202+ }
203+
204+ #[test]
205+ fn the_reserved_list_is_sorted_and_unique() {
206+ // Sorted so additions are easy to review; unique so a duplicate doesn't hide a
207+ // typo'd entry that reserves nothing.
208+ let mut sorted = RESERVED.to_vec();
209+ sorted.sort_unstable();
210+ sorted.dedup();
211+
212+ assert_eq!(
213+ sorted.as_slice(),
214+ RESERVED,
215+ "keep RESERVED sorted and unique"
216+ );
217+ }
218+
219+ #[test]
220+ fn reserved_entries_are_themselves_valid_handle_shapes() {
221+ // A reserved word that could never be a handle anyway is dead weight and
222+ // suggests a misunderstanding of what the list is for.
223+ for reserved in RESERVED {
224+ assert!(
225+ !reserved.is_empty()
226+ && reserved.len() <= OrgName::MAX_LEN
227+ && reserved.chars().all(|c| c.is_ascii_lowercase())
228+ && !reserved.starts_with('-')
229+ && !reserved.ends_with('-'),
230+ "{reserved:?} could never be a handle, so reserving it is pointless"
231+ );
232+ }
233+ }
234+
146235 #[test]
147236 fn label_falls_back_to_the_handle() {
148237 let org = Organization::new(OrgId::generate(), "steid", None).expect("valid");
src/infrastructure/web/pages.rs+3 −3View file
@@ -17,7 +17,7 @@ use super::context::{claimed, identity};
1717 #[page("/")]
1818 async fn home(cx: &Cx) -> Result {
1919 if !claimed(cx).await? {
20 return Err(redirect("/setup").into());
20+ return Err(redirect("/auth/setup").into());
2121 }
2222
2323 view! {
@@ -26,13 +26,13 @@ async fn home(cx: &Cx) -> Result {
2626 Some(identity) => view! {
2727 <p>"Signed in as " <strong>(identity.handle.as_str())</strong></p>
2828 <p>(identity.email.as_str())</p>
29 <form method="post" action="/logout">
29+ <form method="post" action="/auth/logout">
3030 <button type="submit">"Sign out"</button>
3131 </form>
3232 },
3333 None => view! {
3434 <p>"Not signed in."</p>
35 <p><a href="/login">"Sign in"</a></p>
35+ <p><a href="/auth/login">"Sign in"</a></p>
3636 },
3737 }?)
3838 }
src/infrastructure/web/setup.rs+10 −10View file
@@ -38,7 +38,7 @@ struct LoginForm {
3838 password: String,
3939 }
4040
41#[page("/setup")]
41+#[page("/auth/setup")]
4242 async fn setup_page(cx: &Cx) -> Result {
4343 if claimed(cx).await? {
4444 return Err(redirect("/").into());
@@ -47,7 +47,7 @@ async fn setup_page(cx: &Cx) -> Result {
4747 view! {
4848 <h1>"Claim this instance"</h1>
4949 <p>"The setup token was printed to the server log at startup."</p>
50 <form method="post" action="/setup">
50+ <form method="post" action="/auth/setup">
5151 <p><label>"Setup token " <input type="text" name="token" required="true" /></label></p>
5252 <p><label>"Handle " <input type="text" name="handle" required="true" /></label></p>
5353 <p><label>"Email " <input type="email" name="email" required="true" /></label></p>
@@ -57,7 +57,7 @@ async fn setup_page(cx: &Cx) -> Result {
5757 }
5858 }
5959
60#[route(POST "/setup")]
60+#[route(POST "/auth/setup")]
6161 async fn claim(cx: &Cx, Form(form): Form<ClaimForm>) -> Result<SeeOther> {
6262 // Absent once claimed, so a claimed instance cannot be re-claimed even if the
6363 // use case were somehow reached.
@@ -85,7 +85,7 @@ async fn claim(cx: &Cx, Form(form): Form<ClaimForm>) -> Result<SeeOther> {
8585 .await
8686 .map_err(|error| {
8787 eprintln!("steid: claim rejected: {error}");
88 redirect("/setup")
88+ redirect("/auth/setup")
8989 })?;
9090
9191 sign_in(cx, &actor, &sessions).await?;
@@ -93,15 +93,15 @@ async fn claim(cx: &Cx, Form(form): Form<ClaimForm>) -> Result<SeeOther> {
9393 Ok(see_other("/"))
9494 }
9595
96#[page("/login")]
96+#[page("/auth/login")]
9797 async fn login_page(cx: &Cx) -> Result {
9898 if !claimed(cx).await? {
99 return Err(redirect("/setup").into());
99+ return Err(redirect("/auth/setup").into());
100100 }
101101
102102 view! {
103103 <h1>"Sign in"</h1>
104 <form method="post" action="/login">
104+ <form method="post" action="/auth/login">
105105 <p><label>"Email " <input type="email" name="email" required="true" /></label></p>
106106 <p><label>"Password " <input type="password" name="password" required="true" /></label></p>
107107 <button type="submit">"Sign in"</button>
@@ -109,7 +109,7 @@ async fn login_page(cx: &Cx) -> Result {
109109 }
110110 }
111111
112#[route(POST "/login")]
112+#[route(POST "/auth/login")]
113113 async fn sign_in_route(cx: &Cx, Form(form): Form<LoginForm>) -> Result<SeeOther> {
114114 let pool = pool(cx).clone();
115115 let users = SqliteUserRepo::new(pool.clone());
@@ -119,7 +119,7 @@ async fn sign_in_route(cx: &Cx, Form(form): Form<LoginForm>) -> Result<SeeOther>
119119 .await
120120 .map_err(|error| {
121121 eprintln!("steid: login rejected: {error}");
122 redirect("/login")
122+ redirect("/auth/login")
123123 })?;
124124
125125 sign_in(cx, &actor, &sessions).await?;
@@ -127,7 +127,7 @@ async fn sign_in_route(cx: &Cx, Form(form): Form<LoginForm>) -> Result<SeeOther>
127127 Ok(see_other("/"))
128128 }
129129
130#[route(POST "/logout")]
130+#[route(POST "/auth/logout")]
131131 async fn logout(cx: &Cx) -> Result<SeeOther> {
132132 // Both halves: the client discards its token, and the record goes. Doing only the
133133 // first leaves the session valid server-side.
src/main.rs+1 −1View file
@@ -63,7 +63,7 @@ fn session_config(insecure_cookies: bool) -> SessionConfig {
6363 /// Prints the claim instructions. The only time the token is ever revealed.
6464 fn announce_setup(token: &SetupToken) {
6565 println!();
66 println!(" This steid has no owner yet. Claim it at /setup with:");
66+ println!(" This steid has no owner yet. Claim it at /auth/setup with:");
6767 println!();
6868 println!(" {}", token.reveal());
6969 println!();