@jpgilldev / steid

steid/plans/decisions/0003-scoped-urls.md
3.9 KBRaw
1# 0003 — Scope every URL under an explicit prefix
2
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.
11
12## Context
13
14Milestone 2 introduces the profile page, which fixes the shape of every URL after it.
15Forges conventionally put handles at the root — `/james`, `/james/my-repo` — following
16GitHub, GitLab, and Gitea.
17
18Root-level handles share one namespace with the application's own routes. `/setup`,
19`/login`, `/logout`, and `/api` already exist, and nothing stops a user claiming the
20handle `login`. That forces a reserved-word denylist which must grow every time a
21top-level route is added, and forgetting once means a profile shadows a real route or
22the reverse. It also makes a 404 ambiguous: unknown handle and unknown page are
23indistinguishable.
24
25## Decision
26
27Scope every URL under an explicit prefix. Handles are never at the root.
28
29```
30/user/{handle} personal profile
31/user/{handle}/repos/{name} repository
32/user/{handle}/posts/{slug} writing
33/org/{handle} shared organisation (Milestone 7)
34
35/login /logout /setup /api/... application routes, free to grow
36```
37
38Every segment says what the next one means, at every level. This is a general
39preference, not a one-off: prefer an explicit scope over an implicit one wherever the
40choice arises.
41
42Two prefixes rather than one because in Steid every handle is an `Organization` — a
43user's handle is their personal org's name. `/user/acme-corp` would be wrong once real
44organisations exist. Distinguishing them requires a `kind` discriminator on `orgs`
45(`personal` | `shared`), which is better modelling regardless: the two differ in
46whether they belong to exactly one user.
47
48## Alternatives considered
49
50- **Root handles plus a denylist** (GitHub, Gitea). Shortest URLs and the familiar
51 shape. Rejected: the denylist is unbounded maintenance, and the failure mode is a
52 silent collision rather than an error.
53- **Root handles plus a sigil for system routes** (`/-/login`, as GitLab moved to).
54 One reserved prefix instead of a growing list, and keeps `/james` clean. Rejected as
55 less consistent — it scopes the application's routes but leaves content unscoped, so
56 `/james/my-repo` and `/james/my-post` still share a namespace and can collide with
57 each other.
58- **A single neutral prefix for all handles** (`/o/{handle}`). Avoids needing a `kind`
59 discriminator. Rejected: opaque to read, and the discriminator is worth having on its
60 own merits.
61
62## Consequences
63
64- **No reserved words.** Any top-level route can be added forever without checking a
65 list, and handle validation stays purely about format.
66- **404s are unambiguous.** An unknown handle under `/user/` is a missing user; an
67 unknown top-level path is a missing page.
68- **Content types can't collide with each other.** `/user/james/repos/x` and
69 `/user/james/posts/x` coexist. Root-level schemes have to arbitrate.
70- **URLs are longer, and that costs something real.** `/user/james` is an app URL where
71 `/james` is a CV URL, and Steid is portfolio-first, so this is a genuine trade against
72 the product's own framing. Clone URLs inherit it:
73 `git clone https://host/user/james/repos/steid.git`.
74- **`orgs` needs a `kind` column** before Milestone 7. Adding it at Milestone 2, while
75 there is one row, is free.
76- Expensive to reverse once URLs are public — which is why it is being decided before
77 the first real page rather than after.