steid

@jamesgill /

docs: plan milestone 2 in two phases

Phase 1 is a public profile that renders signed out and is shippable alone.
Phase 2 makes it editable, which is what turns a stub into a portfolio page.

Flash messages move into phase 2 rather than staying a carried-over nicety:
the settings form is the first thing that genuinely needs success and failure
feedback, and every form after it inherits whatever gets built there.

The most likely mistake in this milestone is now written down explicitly.
describe_identity carries email and /api/me returns it, correctly, because
that endpoint describes the caller to themselves. Reusing it for the public
profile would publish the owner's email to anonymous visitors, so the public
view needs its own read model.

Route precedence between /auth/login and /{handle} is a spike rather than an
assumption -- static segments usually win over parameterised ones, but it is
load-bearing here and unverified.

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

1 file changed+60 −31

plans/current.md+60 −31View file
@@ -6,55 +6,84 @@
66
77 ## Active: Milestone 2 — Profile page
88
9**Goal:** `/{handle}` becomes the real profile page, replacing the Milestone 0
10placeholder. This is the frame the rest of the product hangs in — repos, writing, and
11projects 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.
1212
13**Explicitly out of scope:** repos and posts don't exist yet, so the page shows
14identity plus empty sections. Editing display name / bio is Milestone 2 only if it
15stays 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.
1616
17### Steps
17+### Phase 1 — the page
1818
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))
2123 - [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)
2843
2944 ### Done when
3045
31`/{handle}` renders for a signed-out visitor, shows extra affordances to the
32owner, 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.
3349
3450 ### Watch for
3551
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
4263 design, so anything private must be gated explicitly rather than by assuming a
4364 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.
4476
45### Carried over — small, unblocked, worth doing alongside
77+### Carried over — small, unblocked
4678
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`.
5180 - **`sweep_expired` is never called**, so expired session rows accumulate. Expiry is
5281 enforced on read, so this is tidiness, not a hole.
5382 - **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.
5584 - **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).
5887
5988 ## Backlog
6089