| | @@ -6,55 +6,84 @@ |
| 6 | 6 | |
| 7 | 7 | ## Active: Milestone 2 — Profile page |
| 8 | 8 | |
| 9 | | −**Goal:** `/{handle}` becomes the real profile page, replacing the Milestone 0 |
| 10 | | −placeholder. This is the frame the rest of the product hangs in — repos, writing, and |
| 11 | | −projects all appear on it later. |
| 9 | +**Goal:** `/{handle}` is the real profile page — public, working signed out, and the |
| 10 | +frame that repos, writing, and projects hang off later. It replaces the Milestone 0 |
| 11 | +placeholder. |
| 12 | 12 | |
| 13 | | −**Explicitly out of scope:** repos and posts don't exist yet, so the page shows |
| 14 | | −identity plus empty sections. Editing display name / bio is Milestone 2 only if it |
| 15 | | −stays small. |
| 13 | +**Out of scope:** repos, posts, and projects don't exist yet, so there is nothing to |
| 14 | +list. Avatars, following, and anything social. Organisation profiles beyond what falls |
| 15 | +out for free. |
| 16 | 16 | |
| 17 | | −### Steps |
| 17 | +### Phase 1 — the page |
| 18 | 18 | |
| 19 | | −- [x] Settle URL shape — handles at the root, routes grouped under prefixes, see |
| 20 | | − [0004](decisions/0004-root-handles-grouped-routes.md) |
| 19 | +Shippable on its own: a public profile that renders signed out. |
| 20 | + |
| 21 | +- [x] Settle URL shape — handles at the root, routes grouped under prefixes |
| 22 | + ([0004](decisions/0004-root-handles-grouped-routes.md)) |
| 21 | 23 | - [x] Reserved-handle denylist in `OrgName::new`; auth routes moved under `/auth/` |
| 22 | | −- [ ] Settle routing mechanics: `module_router!` vs explicit `#[page]` paths, and |
| 23 | | − `path_param!` for `{handle}` — deferred from Milestone 0 |
| 24 | | −- [ ] `/{handle}` — public page, resolves the org by handle, 404 when unknown |
| 25 | | −- [ ] `/` redirects to the owner's profile once claimed |
| 26 | | −- [ ] Owner-only affordances visible when the viewer is the owner |
| 27 | | −- [ ] `/api/users/{handle}` — the API surface for the same read model |
| 24 | +- [ ] **Spike: route precedence.** Does `/auth/login` still win once `/{handle}` exists |
| 25 | + at the root? Most routers prefer a static segment over a parameterised one, but |
| 26 | + this is load-bearing and unverified — check before designing around it |
| 27 | +- [ ] Decide `module_router!` vs explicit `#[page]` paths, informed by the spike |
| 28 | +- [ ] `PublicProfile` read model + `view_profile` use case — **must not carry email** |
| 29 | +- [ ] `/{handle}` page: renders label and handle, 404 on unknown, case-insensitive |
| 30 | +- [ ] `/` redirects to the owner's profile once claimed, retiring the placeholder |
| 31 | +- [ ] `/api/users/{handle}` — same read model, public JSON |
| 32 | + |
| 33 | +### Phase 2 — make it yours |
| 34 | + |
| 35 | +A profile you can't change is a stub. This is what makes it a portfolio page. |
| 36 | + |
| 37 | +- [ ] Migration: `orgs.bio` |
| 38 | +- [ ] Flash messages — the first edit form needs success and failure feedback, and |
| 39 | + every form after it inherits whatever we build here |
| 40 | +- [ ] `/{handle}/settings` — edit display name and bio, owner only, enforced in the use |
| 41 | + case |
| 42 | +- [ ] Owner-only affordances on the profile (edit link) |
| 28 | 43 | |
| 29 | 44 | ### Done when |
| 30 | 45 | |
| 31 | | −`/{handle}` renders for a signed-out visitor, shows extra affordances to the |
| 32 | | −owner, and an unknown handle 404s rather than erroring. |
| 46 | +Signed out, `/{handle}` renders the owner's display name and handle and nothing |
| 47 | +private. An unknown handle 404s. The owner can set a display name and bio and see them |
| 48 | +on the page. `/api/users/{handle}` returns the same public view. |
| 33 | 49 | |
| 34 | 50 | ### Watch for |
| 35 | 51 | |
| 36 | | −- **Handle lookups are case-insensitive** in storage (`collate nocase`) and lowercased |
| 37 | | − by `OrgName::new`. A URL with different casing must resolve, not 404. |
| 38 | | −- **Reserve handles early.** Adding to the denylist later is a breaking change for |
| 39 | | − whoever holds that handle. It is free while unclaimed, so reserve an area before it |
| 40 | | − exists — see [0004](decisions/0004-root-handles-grouped-routes.md). |
| 41 | | −- **A profile is public.** It is the first page rendering for anonymous visitors by |
| 52 | +- **Do not leak email.** `describe_identity` carries email, and `/api/me` returns it — |
| 53 | + correctly, because that endpoint describes the caller to themselves. The public |
| 54 | + profile needs its **own** read model; reusing `Identity` would publish the owner's |
| 55 | + email address to anonymous visitors. This is the single most likely mistake in this |
| 56 | + milestone. |
| 57 | +- **Route precedence** between `/auth/login` and `/{handle}`. Verify, don't assume. |
| 58 | +- **Case-insensitive handles.** Storage is `collate nocase` and `OrgName::new` |
| 59 | + lowercases, so `/JamesGill` must resolve rather than 404. |
| 60 | +- **Authorization on settings** belongs in the use case, taking an `Actor` — not in the |
| 61 | + page. Otherwise `/api` gets a different answer from the web form. |
| 62 | +- **A profile is public.** This is the first page rendering for anonymous visitors by |
| 42 | 63 | design, so anything private must be gated explicitly rather than by assuming a |
| 43 | 64 | session exists. |
| 65 | +- **Reserve handles early.** Adding to the denylist later is a breaking change for |
| 66 | + whoever holds that handle ([0004](decisions/0004-root-handles-grouped-routes.md)). |
| 67 | + |
| 68 | +### Open questions |
| 69 | + |
| 70 | +- **Where do settings live?** `/{handle}/settings` is consistent with grouping and |
| 71 | + scales to organisations; `/settings` is simpler for a single user but would need |
| 72 | + another reserved word (already reserved). Leaning `/{handle}/settings`. |
| 73 | +- **Empty sections or none?** Rendering "Repositories (none yet)" makes the frame |
| 74 | + visible but is speculative scaffolding for things that don't exist. Leaning towards |
| 75 | + omitting them until the content type lands. |
| 44 | 76 | |
| 45 | | −### Carried over — small, unblocked, worth doing alongside |
| 77 | +### Carried over — small, unblocked |
| 46 | 78 | |
| 47 | | −- **Flash messages.** A wrong password or setup token bounces with no explanation. |
| 48 | | − Deliberate on the security side, but indistinguishable from a broken form. Every |
| 49 | | − form added from here inherits the problem. |
| 50 | | −- **No rate limiting** on `/login` or `/setup`. |
| 79 | +- **No rate limiting** on `/auth/login` or `/auth/setup`. |
| 51 | 80 | - **`sweep_expired` is never called**, so expired session rows accumulate. Expiry is |
| 52 | 81 | enforced on read, so this is tidiness, not a hole. |
| 53 | 82 | - **CSRF.** `SameSite=Lax` covers the common case; whether forms also want tokens is |
| 54 | | − still undecided. |
| 83 | + still undecided. Phase 2 adds a form, so this is the natural time to settle it. |
| 55 | 84 | - **Styling.** Everything is unstyled HTML. Topcoat ships Tailwind without Node, and |
| 56 | | − `steid-backup/AGENTS/UI.md` has a full OKLCH system to mine. Cheaper at two pages |
| 57 | | − than at ten — see [ui.md](ui.md). |
| 85 | + `steid-backup/AGENTS/UI.md` has a full OKLCH system to mine. Cheaper now than at ten |
| 86 | + pages — see [ui.md](ui.md). |
| 58 | 87 | |
| 59 | 88 | ## Backlog |
| 60 | 89 | |