| 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 1 — Identity, thin |
| 8 | |
| 9 | **Goal:** the app knows who you are. An unclaimed installation is claimed through |
| 10 | `/setup`, and the resulting owner can log in and out. Enough identity to hang a profile |
| 11 | page off, and no more. |
| 12 | |
| 13 | **Explicitly out of scope** — these are Milestone 7: multi-user registration, |
| 14 | invite codes, `RegistrationPolicy`, organisation management UI, roles beyond owner. |
| 15 | |
| 16 | ### Steps |
| 17 | |
| 18 | - [x] Domain: typed IDs, `Email`, `PasswordHash`, `User`, `Organization`, |
| 19 | `Membership`, `Role`, `Actor`, `DomainError` |
| 20 | - [x] Domain: repository traits — `UserRepository`, `OrgRepository`, |
| 21 | `MembershipRepository` |
| 22 | - [x] Infrastructure: in-memory implementations (these are what make use cases |
| 23 | testable without a database) |
| 24 | - [x] Application: `PasswordHasher` port + Argon2 adapter, stub hasher for tests |
| 25 | - [x] Domain: `SetupToken` — one-time claim secret, constant-time comparison |
| 26 | - [x] Application: `claim_instance` use case — token-gated, creates org → user → |
| 27 | owner membership, returns the owner signed in |
| 28 | - [x] Application: `login` use case — verifies credentials, returns an `Actor` |
| 29 | - [x] Infrastructure: migrations + SQLite implementations |
| 30 | - [x] Infrastructure: `sessions` table + session storage |
| 31 | - [x] Boot: mint and print a `SetupToken` when unclaimed; register it in app context |
| 32 | - [x] Web: `/setup` claim page; every other route redirects there while unclaimed |
| 33 | - [x] Web: login page, logout, `current_actor(cx)` helper |
| 34 | - [ ] `/api/me` — first `/api` route, proves the use case layer has two consumers |
| 35 | |
| 36 | ### Done when |
| 37 | |
| 38 | A fresh database prints a setup token at boot; `/setup` with that token creates the |
| 39 | owner and signs them in; logging out and back in works; `/api/me` returns that |
| 40 | identity. |
| 41 | |
| 42 | Everything but `/api/me` is done and verified in a browser. |
| 43 | |
| 44 | ### Resolved |
| 45 | |
| 46 | - **`__Host-` cookies need a secure context — and it bit.** The claim succeeded, the |
| 47 | server recorded sessions, and every page still rendered signed out, because the |
| 48 | browser silently discarded a `Secure` cookie served over plain HTTP. Nothing errored |
| 49 | on either side. Fixed with `InsecureCookieTokenStore` behind |
| 50 | `STEID_INSECURE_COOKIES`, off by default — see [runbook.md](runbook.md#configuration). |
| 51 | - **Claim TOCTOU** is now covered by a test that drives a real claim through the SQLite |
| 52 | repos and asserts `unique(orgs.name)` / `unique(users.email)` refuse the second. |
| 53 | |
| 54 | ### Watch for |
| 55 | |
| 56 | - **CSRF.** `SameSite=Lax` blocks cross-site POSTs, which covers the common case. |
| 57 | Whether forms also want tokens is an open decision, not a default to pick quietly. |
| 58 | Still undecided. |
| 59 | - **No rate limiting anywhere.** `/login` and `/setup` accept unlimited attempts. The |
| 60 | setup token has 256 bits so brute force is not the worry; password guessing is. |
| 61 | - **Form errors are invisible.** A wrong token or password redirects back with no |
| 62 | message — deliberate, so failures can't be used to probe, but indistinguishable from |
| 63 | a broken form. Flash messages are the fix and don't exist yet. |
| 64 | - **Session sweeping is never called.** `sweep_expired` exists and is tested but |
| 65 | nothing invokes it, so expired rows accumulate. Expiry is enforced on read, so this |
| 66 | is tidiness rather than a security hole. |
| 67 | - **Foreign key ordering.** The org must be saved before the user — attempt #2 had to |
| 68 | fix this in two places. Enforced now: `foreign_keys(true)` plus a test. |
| 69 | - Never log or `Debug`-print a password. `PasswordHash` is opaque on purpose. |
| 70 | |
| 71 | ## Backlog |
| 72 | |
| 73 | Ordered. Pull from the top. |
| 74 | |
| 75 | 1. **Milestone 2 — Profile page.** `/{owner}` becomes the real home page, replacing |
| 76 | the Milestone 0 placeholder. The frame the rest of the product hangs in. |
| 77 | 2. **Milestone 3 — Writing.** Posts, markdown rendering, `/{owner}/{slug}`. |
| 78 | *Open question: is writing actually the first portfolio feature, or is it |
| 79 | projects/showcases?* |
| 80 | 3. **Milestone 4 — Repo model.** `Repository` entity, `Visibility`, `create_repo`, |
| 81 | bare repo on disk at `{data_dir}/{org}/{repo}.git`. Watch the DB-plus-filesystem |
| 82 | atomicity problem — see [architecture.md](architecture.md#db-plus-filesystem-writes). |
| 83 | 4. **Milestone 5 — Git over HTTP.** `git http-backend` subprocess, PATs over HTTP |
| 84 | Basic. See [0001](decisions/0001-git-over-http-not-ssh.md). |
| 85 | |
| 86 | ## Open questions |
| 87 | |
| 88 | - **Topcoat is early** (v0.5.0, first released 2026-07-22, breaking changes expected |
| 89 | by its own authors). Expect churn that isn't feature work. |
| 90 | - Body size limits will reject large pushes at Milestone 5 — `topcoat-router` has a |
| 91 | `body_limit` layer that needs raising on the git routes. Recorded here because it |
| 92 | will surface as a confusing failure rather than a clear one. |
| 93 | - Topcoat ships Tailwind without Node, which reopens the design system attempt #1 |
| 94 | dropped purely to avoid an npm build step — see [ui.md](ui.md). |
| 95 | |
| 96 | ## Routing findings (Milestone 0) |
| 97 | |
| 98 | - **Topcoat 0.5 requires rustc ≥ 1.95.** On an older toolchain `cargo add topcoat` |
| 99 | silently resolves to an empty `topcoat v0.0.0` placeholder instead of failing. Local |
| 100 | stable is now 1.97.1. |
| 101 | - `Router::builder().discover()` collects `#[page]`-annotated items **at link time**, |
| 102 | so pages can live in any module. Layering is our choice, not the framework's. |
| 103 | - `module_router!` derives each URL from the module tree rather than a path string. |
| 104 | Still deferred — Steid's URL space is parameterised at the root (`/{owner}`, |
| 105 | `/{owner}/{repo}`), which means `path_param!` declarations inside route modules. |
| 106 | Worth designing at Milestone 2 when the profile page makes it concrete. |
| 107 | - Path and query params are read from `Cx` via `path_param!` / `#[query_params]`, not |
| 108 | injected as handler arguments. Parses are memoized per request. |
| 109 | - Layouts wrap by path prefix and nest outermost-first, and a layout can catch a page's |
| 110 | `NotFoundError` to render a branded 404. |
| 111 | - `HOST` / `PORT` configure the bind address, so `STEID_LISTEN_ADDR` is gone. |
| 112 | - `Body` is a boxed `http_body::Body` used for both requests and responses, with |
| 113 | `into_data_stream()` to read and `Body::new()` to wrap a stream — pack data can |
| 114 | stream both directions without buffering. This is what makes Milestone 5 viable. |