@jpgilldev / steid

steid/plans/decisions/0004-root-handles-grouped-routes.md
6.1 KBRaw
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 dashboard docs explore help legal
41notifications privacy search security settings static status steid support terms
42```
43
44Adding `/auth/reset-password` costs nothing. Only a genuinely new *area* adds an entry.
45
46`steid` is reserved so nobody can hold the project's own name and impersonate the
47software. The legal cluster (`privacy`, `terms`, `legal`, `security`) is there because a
48publicly hosted instance conventionally wants those at the root.
49
50**The test for adding one:** could this ever be a top-level route? Under grouping,
51almost nothing is — a health check is `/api/health`, sign-up is `/auth/register`. Words
52that would live under a prefix do not belong on the list.
53
54## Alternatives considered
55
56- **`/user/{handle}` scoping** ([0003](0003-scoped-urls.md)). Zero denylist, no
57 ambiguity. Rejected for the URL it produces; the maintenance it avoided turned out to
58 be small once routes were grouped.
59- **A sigil for system routes** (`/-/login`, as GitLab uses). Genuinely zero denylist —
60 `OrgName` already rejects handles starting or ending with a hyphen, so `-` is
61 structurally unclaimable. Rejected because `/auth/login` says what it is and `/-/login`
62 makes you learn a convention, and readable URLs are worth a ten-word list on a product
63 where URLs are part of the presentation.
64- **Root handles with a per-route denylist** (GitHub, Gitea). What grouping exists to
65 avoid: the list grows every time a route is added and fails silently when someone
66 forgets.
67
68## Consequences
69
70- **`/james` is the profile URL.** Clone URLs come out as
71 `https://host/james/repos/steid.git`, comparable to GitHub's.
72- **Content types cannot collide with each other**, because everything below a handle is
73 grouped: `/james/repos/x` and `/james/posts/x` coexist, and a repo named `posts` is
74 fine at `/james/repos/posts`.
75- **A small denylist exists and must be maintained**, unlike under 0003. It is enforced
76 in `OrgName::new` with tests, so it fails loudly at claim time rather than producing a
77 shadowed route.
78- **Reserve early.** Adding an entry later is a breaking change for whoever holds that
79 handle — the account must be renamed and its links break. Reserving while unclaimed is
80 free, which is why the list covers areas that do not exist yet.
81- **An unknown root path is ambiguous** — `/jmaes` could be a typo'd handle or a missing
82 page. Both 404, so this costs little in practice.
83- **`orgs.kind` is no longer needed for routing.** 0003 required it to tell `/user/`
84 from `/org/`; with one root namespace, both are just `/{handle}`. It may still be
85 worth having for display, but it is not load-bearing and is dropped from Milestone 2.
86- Topcoat serves its assets from `/_topcoat/`, which the character rules already exclude,
87 so it needs no reservation. The same holds for `/.well-known/` (federation,
88 `security.txt`), `robots.txt`, `favicon.ico` and `sitemap.xml` — every conventional
89 root path that contains a `.` or `_` is unreachable as a handle for free.
90
91## How this ages — prior art
92
93GitHub's namespace is flat, and it pays for it: community mirrors of its reserved list
94run to [590+ names](https://github.com/shouldbee/reserved-usernames), with no official
95list published. You discover a name is taken when signup rejects it.
96
97GitLab is the more useful comparison, because they hit it hard enough to build
98machinery. Their reserved names live in `namespace_validator.rb` and
99`project_path_validator.rb`, and their docs state plainly that as new functionality is
100introduced, more restrictions will be added — the list is expected to grow forever. Two
101details bear on us:
102
103- **Their reservation is depth-dependent.** A group named `api` is rejected at the top
104 level but allowed nested under another namespace, where nothing collides. That is
105 exactly the property grouping gives us, and it is what our substring and nesting tests
106 pin down.
107- **They built `RenameReservedPathsMigration`** — tooling to forcibly rename users who
108 already hold a newly-reserved path. That is the escape hatch for the breaking change
109 described above, and the reason their list can keep growing.
110
111The cost runs both ways: GitLab has a long-standing open issue,
112[*Rename GitLab-reserved paths that our users want to use*](https://gitlab.com/gitlab-org/gitlab/-/issues/16854),
113trying to give paths back. Over-reserving is not free either.
114
115**Deliberately not built yet:** a rename path. With a single user who is also the
116operator, adding a reserved word means renaming yourself. It becomes worth having around
117Milestone 7, when multi-user registration means handles stop being ours to reassign
118freely — but building it now would be machinery for a problem that does not exist.