steid

@jamesgill /

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