steid

@jamesgill /

steid/plans/decisions/0004-root-handles-grouped-routes.md
3.7 KBCode·Blame·Raw
aaefaabfeat: root handles, grouped routes, reserved-handle denylist1mo
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
8reserved-word problem. It worked, but it paid for that with the thing Steid is
9supposedly about: `/james` is a URL you put on a CV, `/user/james` is a URL an app
10gives you. For a portfolio-first product the profile URL is part of the product, and
110003 traded it away for an implementation concern.
12
13The reserved-word problem is real, though. Handles at the root share a namespace with
14application routes, so `/login` and a user called `login` cannot coexist.
15
16The insight that makes both possible: the denylist only has to grow per *route* if
17routes live at the root. Group them under functional prefixes and the list grows per
18*area* instead — rarely, and predictably.
19
20## Decision
21
22Handles live at the root. Application routes are grouped under functional prefixes, and
23content 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
36A lean denylist in `OrgName::new` reserves the prefixes, checked against the normalised
37lowercase form so `API` and `api` are the same handle:
38
39```
40about admin api assets auth explore help search settings static
41```
42
43Adding `/auth/reset-password` costs nothing. Only a genuinely new area — `/explore`
44would 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.