| 1 | # Current |
| 2 | |
| 3 | > Keep this file short. One active step, one ordered backlog. Completed work moves to |
| 4 | > [progress.md](progress.md). If this file starts reading like a changelog, it has |
| 5 | > drifted — that's exactly what went wrong last time. |
| 6 | |
| 7 | ## Active: Milestone 2 — Profile page |
| 8 | |
| 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 | |
| 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 | |
| 17 | ### Phase 1 — the page |
| 18 | |
| 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)) |
| 23 | - [x] Reserved-handle denylist in `OrgName::new`; auth routes moved under `/auth/` |
| 24 | - [ ] Decide `module_router!` vs explicit `#[page]` paths |
| 25 | - [ ] `PublicProfile` read model + `view_profile` use case — **must not carry email** |
| 26 | - [ ] `/{handle}` page: label, handle, bio, and the section frame; 404 on unknown, |
| 27 | case-insensitive |
| 28 | - [ ] Assert `/auth/login` and `/api/me` still route once `/{handle}` exists at the root |
| 29 | - [ ] `/` redirects to the owner's profile once claimed, retiring the placeholder |
| 30 | - [ ] `/api/users/{handle}` — same read model, public JSON |
| 31 | |
| 32 | ### Phase 2 — make it yours |
| 33 | |
| 34 | A profile you can't change is a stub. This is what makes it a portfolio page. |
| 35 | |
| 36 | - [ ] Migration: `orgs.bio` |
| 37 | - [ ] Flash messages — the first edit form needs success and failure feedback, and |
| 38 | every form after it inherits whatever we build here |
| 39 | - [ ] `/{handle}/settings` — edit display name and bio, owner only, enforced in the use |
| 40 | case (settings belong to the org, and this scales to organisations) |
| 41 | - [ ] Owner-only affordances on the profile (edit link) |
| 42 | |
| 43 | ### Done when |
| 44 | |
| 45 | Signed out, `/{handle}` renders the owner's display name and handle and nothing |
| 46 | private. An unknown handle 404s. The owner can set a display name and bio and see them |
| 47 | on the page. `/api/users/{handle}` returns the same public view. |
| 48 | |
| 49 | ### Watch for |
| 50 | |
| 51 | - **Do not leak email.** `describe_identity` carries email, and `/api/me` returns it — |
| 52 | correctly, because that endpoint describes the caller to themselves. The public |
| 53 | profile needs its **own** read model; reusing `Identity` would publish the owner's |
| 54 | email address to anonymous visitors. This is the single most likely mistake in this |
| 55 | milestone. |
| 56 | - **Route precedence** between static routes and `/{handle}`. The reserved list stops a |
| 57 | user *owning* `auth`, but it does not stop the router matching `/api/me` against |
| 58 | `/{handle}/{x}` and shadowing the real route. Static-over-parameterised is near |
| 59 | universal, so this is an assertion when the route lands, not a blocking spike. |
| 60 | - **Case-insensitive handles.** Storage is `collate nocase` and `OrgName::new` |
| 61 | lowercases, so `/JamesGill` must resolve rather than 404. |
| 62 | - **Authorization on settings** belongs in the use case, taking an `Actor` — not in the |
| 63 | page. Otherwise `/api` gets a different answer from the web form. |
| 64 | - **A profile is public.** This is the first page rendering for anonymous visitors by |
| 65 | design, so anything private must be gated explicitly rather than by assuming a |
| 66 | session exists. |
| 67 | - **Reserve handles early.** Adding to the denylist later is a breaking change for |
| 68 | whoever holds that handle ([0004](decisions/0004-root-handles-grouped-routes.md)). |
| 69 | |
| 70 | ### Settled |
| 71 | |
| 72 | - **Settings live at `/{handle}/settings`.** They belong to the organisation, which |
| 73 | scales to real organisations in Milestone 7 without moving. |
| 74 | - **Render the full frame from the start**, including sections with nothing in them. |
| 75 | A profile page that renders almost nothing is a poor start for a portfolio-first |
| 76 | product; the shape of the page is part of what is being built, not scaffolding for |
| 77 | it. |
| 78 | |
| 79 | ### Carried over — small, unblocked |
| 80 | |
| 81 | - **No rate limiting** on `/auth/login` or `/auth/setup`. |
| 82 | - **`sweep_expired` is never called**, so expired session rows accumulate. Expiry is |
| 83 | enforced on read, so this is tidiness, not a hole. |
| 84 | - **CSRF.** `SameSite=Lax` covers the common case; whether forms also want tokens is |
| 85 | still undecided. Phase 2 adds a form, so this is the natural time to settle it. |
| 86 | - **Styling.** Everything is unstyled HTML. Topcoat ships Tailwind without Node, and |
| 87 | `steid-backup/AGENTS/UI.md` has a full OKLCH system to mine. Cheaper now than at ten |
| 88 | pages — see [ui.md](ui.md). |
| 89 | |
| 90 | ## Backlog |
| 91 | |
| 92 | Ordered. Pull from the top. |
| 93 | |
| 94 | 1. **Milestone 3 — Writing.** Posts, markdown rendering, `/{handle}/posts/{slug}`. |
| 95 | *Open question: is writing actually the first portfolio feature, or is it |
| 96 | projects/showcases?* |
| 97 | 2. **Milestone 4 — Repo model.** `Repository` entity, `Visibility`, `create_repo`, |
| 98 | bare repo on disk at `{data_dir}/{org}/{repo}.git`. Watch the DB-plus-filesystem |
| 99 | atomicity problem — see [architecture.md](architecture.md#db-plus-filesystem-writes). |
| 100 | 3. **Milestone 5 — Git over HTTP.** `git http-backend` subprocess, PATs over HTTP |
| 101 | Basic. See [0001](decisions/0001-git-over-http-not-ssh.md). |
| 102 | |
| 103 | ## Open questions |
| 104 | |
| 105 | - **Topcoat is early** (v0.5.0, first released 2026-07-22, breaking changes expected |
| 106 | by its own authors). Expect churn that isn't feature work. |
| 107 | - Body size limits will reject large pushes at Milestone 5 — `topcoat-router` has a |
| 108 | `body_limit` layer that needs raising on the git routes. Recorded here because it |
| 109 | will surface as a confusing failure rather than a clear one. |
| 110 | - Topcoat ships Tailwind without Node, which reopens the design system attempt #1 |
| 111 | dropped purely to avoid an npm build step — see [ui.md](ui.md). |
| 112 | |
| 113 | ## Routing findings (Milestone 0) |
| 114 | |
| 115 | - **Topcoat 0.5 requires rustc ≥ 1.95.** On an older toolchain `cargo add topcoat` |
| 116 | silently resolves to an empty `topcoat v0.0.0` placeholder instead of failing. Local |
| 117 | stable is now 1.97.1. |
| 118 | - `Router::builder().discover()` collects `#[page]`-annotated items **at link time**, |
| 119 | so pages can live in any module. Layering is our choice, not the framework's. |
| 120 | - `module_router!` derives each URL from the module tree rather than a path string. |
| 121 | Still deferred. Application routes now group cleanly (`auth/login`, `api/me`), but |
| 122 | handles sit at the root ([0004](decisions/0004-root-handles-grouped-routes.md)), so a |
| 123 | parameterised root segment still has to coexist with static ones. Worth checking how |
| 124 | `module_router!` handles that before committing to it. |
| 125 | - Path and query params are read from `Cx` via `path_param!` / `#[query_params]`, not |
| 126 | injected as handler arguments. Parses are memoized per request. |
| 127 | - Layouts wrap by path prefix and nest outermost-first, and a layout can catch a page's |
| 128 | `NotFoundError` to render a branded 404. |
| 129 | - `HOST` / `PORT` configure the bind address, so `STEID_LISTEN_ADDR` is gone. |
| 130 | - `Body` is a boxed `http_body::Body` used for both requests and responses, with |
| 131 | `into_data_stream()` to read and `Body::new()` to wrap a stream — pack data can |
| 132 | stream both directions without buffering. This is what makes Milestone 5 viable. |