| 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 dashboard docs explore help legal |
| 41 | notifications privacy search security settings static status steid support terms |
| 42 | ``` |
| 43 | |
| 44 | Adding `/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 |
| 47 | software. The legal cluster (`privacy`, `terms`, `legal`, `security`) is there because a |
| 48 | publicly 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, |
| 51 | almost nothing is — a health check is `/api/health`, sign-up is `/auth/register`. Words |
| 52 | that 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 | |
| 93 | GitHub's namespace is flat, and it pays for it: community mirrors of its reserved list |
| 94 | run to [590+ names](https://github.com/shouldbee/reserved-usernames), with no official |
| 95 | list published. You discover a name is taken when signup rejects it. |
| 96 | |
| 97 | GitLab is the more useful comparison, because they hit it hard enough to build |
| 98 | machinery. Their reserved names live in `namespace_validator.rb` and |
| 99 | `project_path_validator.rb`, and their docs state plainly that as new functionality is |
| 100 | introduced, more restrictions will be added — the list is expected to grow forever. Two |
| 101 | details 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 | |
| 111 | The 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), |
| 113 | trying 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 |
| 116 | operator, adding a reserved word means renaming yourself. It becomes worth having around |
| 117 | Milestone 7, when multi-user registration means handles stop being ours to reassign |
| 118 | freely — but building it now would be machinery for a problem that does not exist. |