steid

@jamesgill /

docs: bring tracking docs up to date with milestone 1

current.md ticks off sqlite, sessions, and the web layer, leaving only
/api/me. The two items that were flagged as watch-fors and have since been
settled move to a Resolved section: the __Host- cookie problem, which did
bite and cost an afternoon, and the claim TOCTOU, now covered by a test
rather than asserted.

New watch-fors reflect what the working feature actually lacks: no rate
limiting on /login or /setup, form errors invisible by design and so
indistinguishable from a broken form, and sweep_expired implemented and
tested but never called.

progress.md gains a real record for this attempt instead of "nothing shipped
yet", including the decisions worth not rediscovering -- Actor's explicit
Anonymous variant, the dummy-hash timing defence, constant-time token
comparison, sqlite's per-connection foreign key setting, and the sqlx
migrate add version collision.

runbook.md documents STEID_INSECURE_COOKIES with the reason it exists and
the warning never to deploy it, plus a first-run section covering the claim
flow and the fact that the in-memory token rotates on every dev rebuild.

architecture.md no longer lists russh in the infrastructure layer -- that
went with decision 0001.

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

5 files changed+119 −27

plans/ROADMAP.md+1 −1View file
@@ -50,7 +50,7 @@ a baseline.
5050 | # | Milestone | Status |
5151 |---|---|---|
5252 | 0 | **Skeleton** — Topcoat app boots, config, one page, SQLite wired | done |
53| 1 | **Identity, thin** — owner from config, login, session | active |
53+| 1 | **Identity, thin** — claim on first run, login, session | active (all but `/api/me`) |
5454 | 2 | **Profile page** — `/{owner}` as the real home page | not started |
5555 | 3 | **Writing** — posts, markdown | not started |
5656 | 4 | **Repo model** — records + bare repos on disk | not started |
plans/architecture.md+1 −1View file
@@ -16,7 +16,7 @@ web / ssh (interface) → application (use cases) → domain (entities, po
1616 |---|---|---|
1717 | `domain` | entities, value objects, typed IDs, repository traits, `DomainError` | anything infrastructural |
1818 | `application` | use cases, ports (`GitStorage`, `GitProtocolServer`, `PasswordHasher`), `AppConfig` | concrete adapters |
19| `infrastructure` | SQLite repos, `git` subprocess wrappers, Argon2, russh, web handlers | — |
19+| `infrastructure` | SQLite repos, `git` subprocess wrappers, Argon2, sessions, web handlers | — |
2020
2121 **Every operation takes an `Actor`.** Authorization is checked in the use case, before
2222 any side effect. Not in the handler, not in the adapter. The previous attempt got this
plans/current.md+27 −15View file
@@ -26,11 +26,11 @@ invite codes, `RegistrationPolicy`, organisation management UI, roles beyond own
2626 - [x] Application: `claim_instance` use case — token-gated, creates org → user →
2727 owner membership, returns the owner signed in
2828 - [x] Application: `login` use case — verifies credentials, returns an `Actor`
29- [ ] Infrastructure: migrations `001`–`003`, SQLite implementations
30- [ ] Infrastructure: `sessions` table + session storage
31- [ ] Boot: mint and print a `SetupToken` when unclaimed; register it in app context
32- [ ] Web: `/setup` claim page; every other route redirects there while unclaimed
33- [ ] Web: login page, logout, `current_actor(cx)` helper
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
3434 - [ ] `/api/me` — first `/api` route, proves the use case layer has two consumers
3535
3636 ### Done when
@@ -39,21 +39,33 @@ A fresh database prints a setup token at boot; `/setup` with that token creates
3939 owner and signs them in; logging out and back in works; `/api/me` returns that
4040 identity.
4141
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+
4254 ### Watch for
4355
44- **`__Host-` cookies need a secure context.** Topcoat's session cookie is
45 `__Host-`-prefixed and `Secure`. Browsers treat `http://localhost` as trustworthy so
46 dev over plain HTTP *should* work — verify this as soon as the login page exists,
47 because if it's wrong, login fails silently and looks like a bug in our code.
4856 - **CSRF.** `SameSite=Lax` blocks cross-site POSTs, which covers the common case.
4957 Whether forms also want tokens is an open decision, not a default to pick quietly.
50- **Claim is TOCTOU.** `is_claimed` then write is not atomic; the `UNIQUE` constraints
51 on email and org name are what actually serialise concurrent claims. Integration-test
52 this once SQLite lands.
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.
5367 - **Foreign key ordering.** The org must be saved before the user — attempt #2 had to
54 fix this in both `bootstrap_owner` and `register_user`. See
55 [progress.md](progress.md#identity).
56- **Bootstrap must be idempotent.** It runs on every boot, not just the first.
68+ fix this in two places. Enforced now: `foreign_keys(true)` plus a test.
5769 - Never log or `Debug`-print a password. `PasswordHash` is opaque on purpose.
5870
5971 ## Backlog
plans/progress.md+51 −1View file
@@ -2,7 +2,57 @@
22
33 ## This attempt (#3, Topcoat)
44
5Nothing shipped yet. Milestone 0 in progress — see [current.md](current.md).
5+87 tests. Active milestone in [current.md](current.md).
6+
7+### Milestone 0 — Skeleton · done
8+
9+Topcoat 0.5 app serving pages, `AppConfig` from `STEID_*` env, SQLite pool in app
10+context. Split into a library plus a thin binary — the domain layer had no consumers
11+yet and read as ~30 dead-code warnings in a bare binary, and it unlocks `tests/`.
12+Topcoat's link-time page discovery works from a library; that was checked, not assumed.
13+
14+**Requires rustc ≥ 1.95.** On older toolchains `cargo add topcoat` silently resolves to
15+an empty `topcoat v0.0.0` placeholder instead of failing.
16+
17+### Milestone 1 — Identity, thin · all but `/api/me`
18+
19+**Domain.** Typed IDs, `Email`, `PasswordHash`, `OrgName`, `Organization`, `User`,
20+`Membership`, `Role`, `Actor`, `Session`, `SetupToken`, `DomainError`. Value objects
21+pair `new()` (validates) with `from_trusted()` (skips, for rows already validated).
22+
23+**Application.** `claim_instance`, `login`, `resolve_actor`, `record_session`,
24+`end_session`, `sweep_expired`. `PasswordHasher` port with Argon2 and a stub.
25+
26+**Infrastructure.** Migrations for orgs, users, memberships, sessions. In-memory and
27+SQLite implementations of every port. Root layout, `/setup`, `/login`, `/logout`, home.
28+
29+**Verified in a browser and by curl:** wrong token refused with nothing written;
30+correct token creates org + user + owner membership and signs the owner in; session
31+authenticates; logout clears cookie and row; wrong password bounces; right one signs
32+in; re-claiming a claimed instance is refused.
33+
34+#### Decisions worth remembering
35+
36+- **`Actor` is an enum with an explicit `Anonymous`**, not `Option<UserId>`. Attempt #2
37+ used a placeholder `UserId("ssh-anonymous")` and it became a security hole. A variant
38+ can't be forgotten the way a sentinel can.
39+- **`login` verifies a dummy hash when no user matched.** Returning early on the
40+ unknown-email path makes it measurably faster and leaks which addresses have
41+ accounts. A test pins that the dummy stays parseable — if it stops being, `verify`
42+ bails early and the defence dies silently.
43+- **`SetupToken` compares in constant time.** An early-return comparison leaks how much
44+ of the token is right, which recovers it a character at a time.
45+- **The setup token is only in app context while unclaimed**, so a claimed instance has
46+ nothing for a claim attempt to match.
47+- **SQLite ignores foreign keys unless asked**, per connection. `foreign_keys(true)`
48+ plus a test that a user pointing at a missing org is refused.
49+- **`sqlx migrate add` stamps versions to the second** — three calls in one second
50+ collide, leaving apply order ambiguous between tables that reference each other.
51+- **An unparseable role surfaces as an error**, never as "no membership". The latter
52+ silently downgrades an owner to no access.
53+- **`Secure` session cookies over plain-HTTP localhost fail silently.** See
54+ [runbook.md](runbook.md#steid_insecure_cookies--development-only). This one actually
55+ bit, and it looked exactly like broken auth logic.
656
757 ---
858
plans/runbook.md+39 −9View file
@@ -40,9 +40,14 @@ Live now:
4040
4141 ```
4242 STEID_DATABASE_URL=sqlite:steid.db?mode=rwc # default
43STEID_DATA_DIR=./data # default; bare repos, used from M2
43+STEID_DATA_DIR=./data # default; bare repos, used from M4
44+STEID_INSECURE_COOKIES=false # default; see below
4445 ```
4546
47+There is deliberately **no owner password in configuration** — the owner is created
48+through the claim flow instead. See
49+[0002](decisions/0002-first-run-claim-not-config-bootstrap.md).
50+
4651 The bind address is **not** a `STEID_` variable — Topcoat owns it:
4752
4853 ```bash
@@ -51,17 +56,42 @@ HOST=0.0.0.0 PORT=8080 cargo run
5156
5257 That supersedes attempt #2's `STEID_LISTEN_ADDR`.
5358
54Arriving with Milestone 1 (#2) — owner bootstrap and registration policy:
59+### `STEID_INSECURE_COOKIES` — development only
5560
61+Topcoat's session cookie is `__Host-` prefixed and `Secure`. `Secure` means the browser
62+only keeps it over a trustworthy origin, and browsers disagree about whether
63+plain-HTTP `localhost` qualifies. Where it doesn't, **the failure is completely
64+silent**: the server issues a session and records the row, the browser discards the
65+cookie, and every page renders signed out with no error anywhere. This cost an
66+afternoon; the symptom looks exactly like broken auth logic.
67+
68+Setting `STEID_INSECURE_COOKIES=true` swaps in `InsecureCookieTokenStore` — the same
69+cookie without `Secure` and without the prefix, named `steid-dev-session` so it can
70+never be confused with a hardened one. `HttpOnly` and `SameSite=Lax` are kept. Boot
71+prints a warning while it's on.
72+
73+**Never set this on a deployed instance.** Without `Secure` the session cookie travels
74+unencrypted and anyone on the network path can lift it and become that user. Behind
75+TLS, leave it unset.
76+
77+A gitignored `.env` in the repo root sets it for local work. Keep `.env` and
78+`.env.prod` out of git — both are gitignored.
79+
80+## First run
81+
82+```bash
83+cargo run # or: topcoat dev
5684 ```
57STEID_REGISTRATION=personal # personal | invite | open
58STEID_OWNER_EMAIL=admin@localhost.dev
59STEID_OWNER_PASSWORD=changeme
60STEID_OWNER_USERNAME=admin
61```
6285
63In `personal` mode the owner account is bootstrapped from `STEID_OWNER_*` on first
64boot. Keep `.env` and `.env.prod` out of git — both are gitignored.
86+An unclaimed instance prints a setup token and redirects every route to `/setup`.
87+Paste the token, choose a handle, email, and password, and the owner is created and
88+signed in.
89+
90+The token is **held in memory only**, so every restart mints a new one — including
91+each rebuild under `topcoat dev`. Use the most recent one printed. Once claimed, no
92+token is minted at all and `/setup` redirects away.
93+
94+Sign in at `/login` with the **email**, not the handle.
6595
6696 ## Repo layout on disk (#2)
6797