steid

@jamesgill /

feat: expand the reserved-handle list to twenty

Adds dashboard, docs, legal, notifications, privacy, security, status,
steid, support and terms.

steid is reserved so nobody can hold the project's own name and impersonate
the software. The legal cluster is there because a publicly hosted instance
conventionally wants those pages at the root.

Deliberately not expanded toward GitHub's 590. GitHub's namespace is flat,
which is why its list is that size; ours groups routes, so login, logout,
signup, register, new, profile and account need no reservation -- they live
under /auth/ and /{handle}/. Copying a flat-namespace list into a grouped
design imports the cost without the cause. The test for adding an entry is
now documented on the constant: could this ever be a top-level route?

Conventional root paths containing . or _ need no entry either --
/_topcoat/, /.well-known/, robots.txt, favicon.ico are already unreachable
as handles via the character rules.

Reserving "steid" broke seven tests using it as a sample handle, which is
the reservation doing its job. Fixtures moved to "acme".

Decision 0004 gains a prior-art section: GitHub absorbs the cost with 590+
names and no published list; GitLab's reservation is depth-dependent exactly
as ours is, and they built RenameReservedPathsMigration so adding a word can
forcibly rename whoever holds it. We are not building a rename path -- with
one user who is also the operator, adding a word means renaming yourself. It
becomes worth having at Milestone 7.

96 tests.

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

4 files changed+84 −19

plans/decisions/0004-root-handles-grouped-routes.md+43 −4View file
@@ -37,11 +37,19 @@ A lean denylist in `OrgName::new` reserves the prefixes, checked against the nor
3737 lowercase form so `API` and `api` are the same handle:
3838
3939 ```
40about admin api assets auth explore help search settings static
40+about admin api assets auth dashboard docs explore help legal
41+notifications privacy search security settings static status steid support terms
4142 ```
4243
43Adding `/auth/reset-password` costs nothing. Only a genuinely new area — `/explore` —
44would add an entry, and the likely ones are reserved already.
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.
4553
4654 ## Alternatives considered
4755
@@ -76,4 +84,35 @@ would add an entry, and the likely ones are reserved already.
7684 from `/org/`; with one root namespace, both are just `/{handle}`. It may still be
7785 worth having for display, but it is not load-bearing and is dropped from Milestone 2.
7886 - Topcoat serves its assets from `/_topcoat/`, which the character rules already exclude,
79 so it needs no reservation.
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.
src/domain/org.rs+36 −10View file
@@ -20,10 +20,36 @@ pub struct OrgName(String);
2020 /// whoever already holds that handle — their account has to be renamed and their links
2121 /// break. Entries here cost nothing while unclaimed.
2222 ///
23/// Topcoat's own assets live under `/_topcoat/`, which the character rules already
24/// exclude, so it needs no entry.
23+/// The test for adding one: could this ever be a *top-level* route? Under grouping,
24+/// almost nothing is — a health check is `/api/health`, sign-up is `/auth/register` —
25+/// so words that would live under a prefix do not belong here. GitHub reserves 590+
26+/// because its namespace is flat; copying that list would import the cost without the
27+/// cause.
28+///
29+/// Paths containing characters a handle cannot hold need no entry: `/_topcoat/`,
30+/// `/.well-known/`, `robots.txt`, `favicon.ico` are all excluded by the rules above.
2531 const RESERVED: &[&str] = &[
26 "about", "admin", "api", "assets", "auth", "explore", "help", "search", "settings", "static",
32+ "about",
33+ "admin",
34+ "api",
35+ "assets",
36+ "auth",
37+ "dashboard",
38+ "docs",
39+ "explore",
40+ "help",
41+ "legal",
42+ "notifications",
43+ "privacy",
44+ "search",
45+ "security",
46+ "settings",
47+ "static",
48+ "status",
49+ // The project's own name, so nobody can hold it and impersonate the software.
50+ "steid",
51+ "support",
52+ "terms",
2753 ];
2854
2955 impl OrgName {
@@ -125,7 +151,7 @@ mod tests {
125151
126152 #[test]
127153 fn accepts_ordinary_handles() {
128 for input in ["james", "steid", "a", "my-org", "org2026", "a-b-c"] {
154+ for input in ["james", "acme", "a", "my-org", "org2026", "a-b-c"] {
129155 assert!(
130156 OrgName::new(input).is_ok(),
131157 "expected {input:?} to be accepted"
@@ -234,25 +260,25 @@ mod tests {
234260
235261 #[test]
236262 fn label_falls_back_to_the_handle() {
237 let org = Organization::new(OrgId::generate(), "steid", None).expect("valid");
263+ let org = Organization::new(OrgId::generate(), "acme", None).expect("valid");
238264
239 assert_eq!(org.label(), "steid");
265+ assert_eq!(org.label(), "acme");
240266 }
241267
242268 #[test]
243269 fn label_prefers_the_display_name() {
244270 let org =
245 Organization::new(OrgId::generate(), "steid", Some("Steid".to_owned())).expect("valid");
271+ Organization::new(OrgId::generate(), "acme", Some("Acme".to_owned())).expect("valid");
246272
247 assert_eq!(org.label(), "Steid");
273+ assert_eq!(org.label(), "Acme");
248274 }
249275
250276 #[test]
251277 fn blank_display_names_are_treated_as_unset() {
252278 let org =
253 Organization::new(OrgId::generate(), "steid", Some(" ".to_owned())).expect("valid");
279+ Organization::new(OrgId::generate(), "acme", Some(" ".to_owned())).expect("valid");
254280
255281 assert_eq!(org.display_name, None);
256 assert_eq!(org.label(), "steid");
282+ assert_eq!(org.label(), "acme");
257283 }
258284 }
src/infrastructure/repository/in_memory.rs+3 −3View file
@@ -201,7 +201,7 @@ mod tests {
201201 #[tokio::test]
202202 async fn orgs_are_found_by_id_and_name() {
203203 let repo = InMemoryOrgRepo::new();
204 let org = Organization::new(OrgId::generate(), "steid", None).expect("valid org");
204+ let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
205205 repo.save(&org).await.expect("save");
206206
207207 assert_eq!(
@@ -217,11 +217,11 @@ mod tests {
217217 #[tokio::test]
218218 async fn org_lookup_by_name_respects_normalisation() {
219219 let repo = InMemoryOrgRepo::new();
220 let org = Organization::new(OrgId::generate(), "Steid", None).expect("valid org");
220+ let org = Organization::new(OrgId::generate(), "Acme", None).expect("valid org");
221221 repo.save(&org).await.expect("save");
222222
223223 let found = repo
224 .find_by_name(&OrgName::new("STEID").expect("valid"))
224+ .find_by_name(&OrgName::new("ACME").expect("valid"))
225225 .await
226226 .expect("lookup");
227227
src/infrastructure/repository/sqlite.rs+2 −2View file
@@ -294,7 +294,7 @@ mod tests {
294294 #[tokio::test]
295295 async fn an_org_round_trips_with_its_display_name() {
296296 let repos = repos().await;
297 let org = Organization::new(OrgId::generate(), "steid", Some("Steid".to_owned()))
297+ let org = Organization::new(OrgId::generate(), "acme", Some("Acme".to_owned()))
298298 .expect("valid org");
299299 repos.orgs.save(&org).await.expect("save");
300300
@@ -306,7 +306,7 @@ mod tests {
306306 .expect("org should exist");
307307
308308 assert_eq!(found, org);
309 assert_eq!(found.label(), "Steid");
309+ assert_eq!(found.label(), "Acme");
310310 }
311311
312312 #[tokio::test]