steid

@jamesgill /

feat: claim the instance on first run instead of bootstrapping from config

Replaces bootstrap_owner with a token-gated claim_instance use case.
Decision 0002 has the reasoning; the short version is that an owner password
in config is a long-lived secret held for a one-time operation, readable
from the process environment, .env, shell history, docker inspect and CI
logs -- and it goes stale the moment the owner changes their password.

The tell was a test written in the previous commit without registering what
it meant: a_second_boot_does_not_overwrite_a_changed_password. Config that
has to be ignored to stay correct is config holding the wrong thing.

SetupToken is 32 bytes of OS randomness, hex-encoded, held in memory only so
a restart rotates it. Comparison is constant-time via subtle: an early-return
byte comparison leaks how much of the token is correct, which is enough to
recover it a character at a time. Debug is redacted, and the value is
reachable only through reveal().

The token is checked before the is-claimed check, so a wrong token cannot be
used to probe whether an installation has an owner. There's a test asserting
both states answer InvalidCredentials rather than one leaking AlreadyExists.

Claiming returns the owner as an authenticated Actor, so the web layer can
start a session directly rather than bouncing through a login form.

64 tests.

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

9 files changed+519 −250

Cargo.lock+2 −0View file
@@ -1821,8 +1821,10 @@ dependencies = [
18211821 "argon2",
18221822 "dotenvy",
18231823 "envy",
1824+ "rand 0.10.2",
18241825 "serde",
18251826 "sqlx",
1827+ "subtle",
18261828 "tokio",
18271829 "topcoat",
18281830 "uuid",
Cargo.toml+2 −0View file
@@ -7,8 +7,10 @@ edition = "2024"
77 argon2 = "0.5.3"
88 dotenvy = "0.15.7"
99 envy = "0.4.2"
10+rand = "0.10.2"
1011 serde = { version = "1.0.229", features = ["derive"] }
1112 sqlx = { version = "0.9.0", default-features = false, features = ["runtime-tokio", "sqlite", "macros"] }
13+subtle = "2.6.1"
1214 tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros"] }
1315 topcoat = "0.5.0"
1416 uuid = { version = "1.24.0", features = ["v4"] }
plans/current.md+27 −12View file
@@ -6,35 +6,50 @@
66
77 ## Active: Milestone 1 — Identity, thin
88
9**Goal:** the app knows who you are. One owner, bootstrapped from config, who can log
10in and out. Enough identity to hang a profile page off, and no more.
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.
1112
1213 **Explicitly out of scope** — these are Milestone 7: multi-user registration,
1314 invite codes, `RegistrationPolicy`, organisation management UI, roles beyond owner.
1415
1516 ### Steps
1617
17- [ ] Domain: typed IDs, `Email`, `PasswordHash`, `User`, `Organization`,
18+- [x] Domain: typed IDs, `Email`, `PasswordHash`, `User`, `Organization`,
1819 `Membership`, `Role`, `Actor`, `DomainError`
19- [ ] Domain: repository traits — `UserRepository`, `OrgRepository`,
20+- [x] Domain: repository traits — `UserRepository`, `OrgRepository`,
2021 `MembershipRepository`
21- [ ] Infrastructure: in-memory implementations (these are what make use cases
22+- [x] Infrastructure: in-memory implementations (these are what make use cases
2223 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`
2329 - [ ] Infrastructure: migrations `001`–`003`, SQLite implementations
24- [ ] Application: `PasswordHasher` port + Argon2 adapter, stub hasher for tests
25- [ ] Application: `bootstrap_owner` use case — creates org, then user, then
26 membership, idempotent on reboot
27- [ ] Application: `login` use case — verifies credentials, returns an `Actor`
28- [ ] Web: login page, logout, session cookie, `current_actor(cx)` helper
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
2934 - [ ] `/api/me` — first `/api` route, proves the use case layer has two consumers
3035
3136 ### Done when
3237
33A fresh database boots into an owner account from `STEID_OWNER_*`; logging in through
34the web UI sets a session; `/api/me` returns that identity; logging out clears it.
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.
3541
3642 ### Watch for
3743
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.
48+- **CSRF.** `SameSite=Lax` blocks cross-site POSTs, which covers the common case.
49+ 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.
3853 - **Foreign key ordering.** The org must be saved before the user — attempt #2 had to
3954 fix this in both `bootstrap_owner` and `register_user`. See
4055 [progress.md](progress.md#identity).
plans/decisions/0002-first-run-claim-not-config-bootstrap.md+68 −0View file
@@ -0,0 +1,68 @@
1+# 0002 — Claim the instance on first run, don't bootstrap the owner from config
2+
3+**Status:** accepted · **Date:** 2026-07-31
4+
5+## Context
6+
7+The previous attempt created the owner account from environment variables —
8+`STEID_OWNER_EMAIL`, `STEID_OWNER_PASSWORD`, `STEID_OWNER_USERNAME` — read on every
9+boot and applied when no users existed. This build started by reproducing that.
10+
11+Two problems surfaced while writing it.
12+
13+**The password is a long-lived secret held for a one-time operation.** An environment
14+variable is readable from the process environment, a `.env` file on disk, shell
15+history, `docker inspect`, systemd units, and CI logs. It stays there for the life of
16+the deployment even though it is needed exactly once. Attempt #2's `.env.dev` shows the
17+gravitational pull of the pattern: `STEID_OWNER_PASSWORD=changeme`.
18+
19+**Config describes something mutable, so it goes stale by design.** The tell was a test
20+written without registering what it meant:
21+
22+```rust
23+async fn a_second_boot_does_not_overwrite_a_changed_password()
24+```
25+
26+Once the owner changes their password in the app, the config value is wrong, still
27+readable, and rotating it does nothing. Config that must be ignored to stay correct is
28+config holding the wrong thing.
29+
30+## Decision
31+
32+An unclaimed installation serves a first-run claim flow. On boot with no owner, Steid
33+generates a **one-time setup token**, prints it to stdout, and serves `/setup`.
34+Presenting the token lets a visitor choose handle, email, and password; that creates the
35+owner and starts their session.
36+
37+The token lives in memory only, so restarting an unclaimed instance rotates it. The
38+owner password is gone from configuration entirely.
39+
40+## Alternatives considered
41+
42+- **Keep config bootstrap.** Unattended, container-friendly, already written. Rejected
43+ for the two problems above. It is not being kept as a parallel automation path
44+ either — no such deployment exists yet, and a second bootstrap route is a second
45+ thing that can create an owner. Easy to add back if demand is real.
46+- **Claim with no token.** Simpler, and what several forges do. Rejected because the
47+ first visitor to an exposed instance becomes its owner, and the window lasts until
48+ someone notices.
49+- **CLI subcommand** (`steid init-owner`, prompting on a TTY). No exposure window at
50+ all and no web layer needed. Rejected because it needs shell access to the host,
51+ which is awkward in a container, and it means building a CLI surface that does not
52+ otherwise exist. Worth revisiting if the claim flow proves awkward to operate.
53+
54+## Consequences
55+
56+- **No password in configuration.** The only secret at rest is the Argon2 hash.
57+- **The exposure window is closed by the token**, and rotated by a restart.
58+- **Milestone 1 reorders.** Config bootstrap needed no web layer and would have been
59+ verifiable first; a claim flow needs pages and sessions. The first user-visible
60+ behaviour becomes `/setup` rather than a row appearing in `steid.db`.
61+- **Operators must read stdout on first boot.** That is a real usability cost — a
62+ crash-looping container reprints a new token each time. The log line should say
63+ plainly what it is and that it rotates.
64+- **The `any_exist` check before writing is TOCTOU.** Two simultaneous claims could
65+ both pass it. The `UNIQUE` constraints on email and organisation name are what
66+ actually serialise it, so the second claim fails at the database rather than
67+ silently creating a second owner. Worth an integration test once SQLite lands.
68+- Unattended deployment is unsupported until someone needs it.
src/application/bootstrap.rs+0 −236
@@ -1,236 +0,0 @@
1use crate::domain::{
2 Email, Membership, MembershipId, OrgId, Organization, Role, User, UserId,
3 repository::{MembershipRepository, OrgRepository, UserRepository},
4};
5
6use super::{error::Result, port::PasswordHasher};
7
8/// The owner account to create on an empty installation.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct OwnerSpec {
11 pub handle: String,
12 pub email: String,
13 pub password: String,
14}
15
16/// What `bootstrap_owner` did.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum Bootstrap {
19 /// The owner was created.
20 Created,
21 /// Users already existed, so nothing happened.
22 AlreadyBootstrapped,
23}
24
25/// Creates the installation's owner if there isn't one yet.
26///
27/// Runs on every boot, so it must be idempotent — the `any_exist` check is what makes
28/// restarting safe rather than a duplicate-key error.
29pub async fn bootstrap_owner(
30 spec: &OwnerSpec,
31 users: &impl UserRepository,
32 orgs: &impl OrgRepository,
33 memberships: &impl MembershipRepository,
34 hasher: &impl PasswordHasher,
35) -> Result<Bootstrap> {
36 if users.any_exist().await? {
37 return Ok(Bootstrap::AlreadyBootstrapped);
38 }
39
40 let email = Email::new(&spec.email)?;
41 let org = Organization::new(OrgId::generate(), &spec.handle, None)?;
42 let password_hash = hasher.hash(&spec.password)?;
43
44 let user = User::new(UserId::generate(), email, password_hash, org.id.clone());
45 let membership = Membership::new(
46 MembershipId::generate(),
47 org.id.clone(),
48 user.id.clone(),
49 Role::Owner,
50 );
51
52 // Order matters: the user references the org and the membership references both,
53 // so anything else trips the foreign keys. Attempt #2 had to fix exactly this.
54 orgs.save(&org).await?;
55 users.save(&user).await?;
56 memberships.save(&membership).await?;
57
58 Ok(Bootstrap::Created)
59}
60
61#[cfg(test)]
62mod tests {
63 use super::*;
64 use crate::{
65 application::error::Error,
66 domain::{DomainError, OrgName},
67 infrastructure::{
68 password::StubHasher,
69 repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryUserRepo},
70 },
71 };
72
73 struct Fixture {
74 users: InMemoryUserRepo,
75 orgs: InMemoryOrgRepo,
76 memberships: InMemoryMembershipRepo,
77 hasher: StubHasher,
78 }
79
80 impl Fixture {
81 fn new() -> Self {
82 Self {
83 users: InMemoryUserRepo::new(),
84 orgs: InMemoryOrgRepo::new(),
85 memberships: InMemoryMembershipRepo::new(),
86 hasher: StubHasher::new(),
87 }
88 }
89
90 async fn run(&self, spec: &OwnerSpec) -> Result<Bootstrap> {
91 bootstrap_owner(
92 spec,
93 &self.users,
94 &self.orgs,
95 &self.memberships,
96 &self.hasher,
97 )
98 .await
99 }
100 }
101
102 fn spec() -> OwnerSpec {
103 OwnerSpec {
104 handle: "james".to_owned(),
105 email: "dev@example.com".to_owned(),
106 password: "hunter2".to_owned(),
107 }
108 }
109
110 #[tokio::test]
111 async fn creates_org_user_and_owner_membership() {
112 let fixture = Fixture::new();
113
114 let outcome = fixture.run(&spec()).await.expect("bootstrap");
115
116 assert_eq!(outcome, Bootstrap::Created);
117
118 let org = fixture
119 .orgs
120 .find_by_name(&OrgName::new("james").unwrap())
121 .await
122 .expect("lookup")
123 .expect("org should exist");
124 let user = fixture
125 .users
126 .find_by_email(&Email::new("dev@example.com").unwrap())
127 .await
128 .expect("lookup")
129 .expect("user should exist");
130 let membership = fixture
131 .memberships
132 .find(&org.id, &user.id)
133 .await
134 .expect("lookup")
135 .expect("membership should exist");
136
137 assert_eq!(user.personal_org_id, org.id);
138 assert_eq!(membership.role, Role::Owner);
139 assert!(membership.can_write());
140 }
141
142 #[tokio::test]
143 async fn stores_a_hash_never_the_plaintext() {
144 let fixture = Fixture::new();
145 fixture.run(&spec()).await.expect("bootstrap");
146
147 let user = fixture
148 .users
149 .find_by_email(&Email::new("dev@example.com").unwrap())
150 .await
151 .expect("lookup")
152 .expect("user should exist");
153
154 assert_ne!(user.password_hash.as_str(), "hunter2");
155 assert!(
156 fixture
157 .hasher
158 .verify("hunter2", &user.password_hash)
159 .expect("verify")
160 );
161 }
162
163 #[tokio::test]
164 async fn is_idempotent_across_reboots() {
165 let fixture = Fixture::new();
166 fixture.run(&spec()).await.expect("first boot");
167
168 let outcome = fixture.run(&spec()).await.expect("second boot");
169
170 assert_eq!(outcome, Bootstrap::AlreadyBootstrapped);
171 }
172
173 #[tokio::test]
174 async fn a_second_boot_does_not_overwrite_a_changed_password() {
175 let fixture = Fixture::new();
176 fixture.run(&spec()).await.expect("first boot");
177
178 let changed = OwnerSpec {
179 password: "different".to_owned(),
180 ..spec()
181 };
182 fixture.run(&changed).await.expect("second boot");
183
184 let user = fixture
185 .users
186 .find_by_email(&Email::new("dev@example.com").unwrap())
187 .await
188 .expect("lookup")
189 .expect("user should exist");
190
191 assert!(
192 fixture
193 .hasher
194 .verify("hunter2", &user.password_hash)
195 .expect("verify"),
196 "the original password should still be the one that works"
197 );
198 }
199
200 #[tokio::test]
201 async fn rejects_an_invalid_handle_without_writing_anything() {
202 let fixture = Fixture::new();
203 let bad = OwnerSpec {
204 handle: "not a handle".to_owned(),
205 ..spec()
206 };
207
208 let error = fixture.run(&bad).await.expect_err("should reject");
209
210 assert!(matches!(
211 error,
212 Error::Domain(DomainError::Validation { .. })
213 ));
214 assert!(
215 !fixture.users.any_exist().await.expect("any_exist"),
216 "nothing should be written when validation fails"
217 );
218 }
219
220 #[tokio::test]
221 async fn rejects_an_invalid_email_without_writing_anything() {
222 let fixture = Fixture::new();
223 let bad = OwnerSpec {
224 email: "not-an-email".to_owned(),
225 ..spec()
226 };
227
228 let error = fixture.run(&bad).await.expect_err("should reject");
229
230 assert!(matches!(
231 error,
232 Error::Domain(DomainError::Validation { .. })
233 ));
234 assert!(!fixture.users.any_exist().await.expect("any_exist"));
235 }
236}
src/application/claim.rs+312 −0View file
@@ -0,0 +1,312 @@
1+use crate::domain::{
2+ Actor, DomainError, Email, Membership, MembershipId, OrgId, Organization, Role, SetupToken,
3+ User, UserId,
4+ repository::{MembershipRepository, OrgRepository, UserRepository},
5+};
6+
7+use super::{error::Result, port::PasswordHasher};
8+
9+/// The owner account a visitor is asking to create.
10+#[derive(Debug, Clone, PartialEq, Eq)]
11+pub struct OwnerSpec {
12+ pub handle: String,
13+ pub email: String,
14+ pub password: String,
15+}
16+
17+/// Whether an installation has an owner yet.
18+///
19+/// Drives whether `/setup` is served and whether other routes redirect to it.
20+pub async fn is_claimed(users: &impl UserRepository) -> Result<bool> {
21+ Ok(users.any_exist().await?)
22+}
23+
24+/// Creates the owner of an unclaimed installation and returns them as an actor.
25+///
26+/// Gated on the one-time setup token printed at boot — see `plans/decisions/0002`.
27+/// Both the token check and the unclaimed check happen before any write.
28+pub async fn claim_instance(
29+ presented_token: &str,
30+ setup_token: &SetupToken,
31+ spec: &OwnerSpec,
32+ users: &impl UserRepository,
33+ orgs: &impl OrgRepository,
34+ memberships: &impl MembershipRepository,
35+ hasher: &impl PasswordHasher,
36+) -> Result<Actor> {
37+ if !setup_token.matches(presented_token) {
38+ return Err(DomainError::InvalidCredentials.into());
39+ }
40+
41+ // Checked after the token, so a wrong token cannot be used to probe whether an
42+ // instance has been claimed.
43+ if is_claimed(users).await? {
44+ return Err(DomainError::AlreadyExists { entity: "owner" }.into());
45+ }
46+
47+ let email = Email::new(&spec.email)?;
48+ let org = Organization::new(OrgId::generate(), &spec.handle, None)?;
49+ let password_hash = hasher.hash(&spec.password)?;
50+
51+ let user = User::new(UserId::generate(), email, password_hash, org.id.clone());
52+ let membership = Membership::new(
53+ MembershipId::generate(),
54+ org.id.clone(),
55+ user.id.clone(),
56+ Role::Owner,
57+ );
58+
59+ // Order matters: the user references the org and the membership references both,
60+ // so anything else trips the foreign keys. Attempt #2 had to fix exactly this.
61+ orgs.save(&org).await?;
62+ users.save(&user).await?;
63+ memberships.save(&membership).await?;
64+
65+ Ok(Actor::User(user.id))
66+}
67+
68+#[cfg(test)]
69+mod tests {
70+ use super::*;
71+ use crate::{
72+ application::error::Error,
73+ domain::OrgName,
74+ infrastructure::{
75+ password::StubHasher,
76+ repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryUserRepo},
77+ },
78+ };
79+
80+ struct Fixture {
81+ token: SetupToken,
82+ users: InMemoryUserRepo,
83+ orgs: InMemoryOrgRepo,
84+ memberships: InMemoryMembershipRepo,
85+ hasher: StubHasher,
86+ }
87+
88+ impl Fixture {
89+ fn new() -> Self {
90+ Self {
91+ token: SetupToken::generate(),
92+ users: InMemoryUserRepo::new(),
93+ orgs: InMemoryOrgRepo::new(),
94+ memberships: InMemoryMembershipRepo::new(),
95+ hasher: StubHasher::new(),
96+ }
97+ }
98+
99+ async fn claim_with(&self, presented: &str, spec: &OwnerSpec) -> Result<Actor> {
100+ claim_instance(
101+ presented,
102+ &self.token,
103+ spec,
104+ &self.users,
105+ &self.orgs,
106+ &self.memberships,
107+ &self.hasher,
108+ )
109+ .await
110+ }
111+
112+ async fn claim(&self, spec: &OwnerSpec) -> Result<Actor> {
113+ self.claim_with(self.token.reveal(), spec).await
114+ }
115+ }
116+
117+ fn spec() -> OwnerSpec {
118+ OwnerSpec {
119+ handle: "james".to_owned(),
120+ email: "dev@example.com".to_owned(),
121+ password: "hunter2".to_owned(),
122+ }
123+ }
124+
125+ #[tokio::test]
126+ async fn claiming_creates_org_user_and_owner_membership() {
127+ let fixture = Fixture::new();
128+
129+ let actor = fixture.claim(&spec()).await.expect("claim");
130+
131+ let org = fixture
132+ .orgs
133+ .find_by_name(&OrgName::new("james").unwrap())
134+ .await
135+ .expect("lookup")
136+ .expect("org should exist");
137+ let user = fixture
138+ .users
139+ .find_by_email(&Email::new("dev@example.com").unwrap())
140+ .await
141+ .expect("lookup")
142+ .expect("user should exist");
143+ let membership = fixture
144+ .memberships
145+ .find(&org.id, &user.id)
146+ .await
147+ .expect("lookup")
148+ .expect("membership should exist");
149+
150+ assert_eq!(actor, Actor::User(user.id.clone()));
151+ assert_eq!(user.personal_org_id, org.id);
152+ assert_eq!(membership.role, Role::Owner);
153+ assert!(membership.can_write());
154+ }
155+
156+ #[tokio::test]
157+ async fn the_owner_is_returned_signed_in() {
158+ let fixture = Fixture::new();
159+
160+ let actor = fixture.claim(&spec()).await.expect("claim");
161+
162+ assert!(
163+ actor.is_authenticated(),
164+ "claiming should hand back a session-able actor, not require a second login"
165+ );
166+ }
167+
168+ #[tokio::test]
169+ async fn stores_a_hash_never_the_plaintext() {
170+ let fixture = Fixture::new();
171+ fixture.claim(&spec()).await.expect("claim");
172+
173+ let user = fixture
174+ .users
175+ .find_by_email(&Email::new("dev@example.com").unwrap())
176+ .await
177+ .expect("lookup")
178+ .expect("user should exist");
179+
180+ assert_ne!(user.password_hash.as_str(), "hunter2");
181+ assert!(
182+ fixture
183+ .hasher
184+ .verify("hunter2", &user.password_hash)
185+ .expect("verify")
186+ );
187+ }
188+
189+ #[tokio::test]
190+ async fn a_wrong_token_is_rejected_and_writes_nothing() {
191+ let fixture = Fixture::new();
192+
193+ let error = fixture
194+ .claim_with(SetupToken::generate().reveal(), &spec())
195+ .await
196+ .expect_err("should reject");
197+
198+ assert!(matches!(
199+ error,
200+ Error::Domain(DomainError::InvalidCredentials)
201+ ));
202+ assert!(!fixture.users.any_exist().await.expect("any_exist"));
203+ }
204+
205+ #[tokio::test]
206+ async fn an_empty_token_is_rejected() {
207+ let fixture = Fixture::new();
208+
209+ let error = fixture
210+ .claim_with("", &spec())
211+ .await
212+ .expect_err("should reject");
213+
214+ assert!(matches!(
215+ error,
216+ Error::Domain(DomainError::InvalidCredentials)
217+ ));
218+ }
219+
220+ #[tokio::test]
221+ async fn a_claimed_instance_cannot_be_claimed_again() {
222+ let fixture = Fixture::new();
223+ fixture.claim(&spec()).await.expect("first claim");
224+
225+ let intruder = OwnerSpec {
226+ handle: "intruder".to_owned(),
227+ email: "intruder@example.com".to_owned(),
228+ password: "letmein".to_owned(),
229+ };
230+ let error = fixture.claim(&intruder).await.expect_err("should reject");
231+
232+ assert!(matches!(
233+ error,
234+ Error::Domain(DomainError::AlreadyExists { entity: "owner" })
235+ ));
236+ }
237+
238+ #[tokio::test]
239+ async fn a_wrong_token_cannot_probe_whether_the_instance_is_claimed() {
240+ let unclaimed = Fixture::new();
241+ let claimed = Fixture::new();
242+ claimed.claim(&spec()).await.expect("claim");
243+
244+ let wrong = SetupToken::generate();
245+ let from_unclaimed = unclaimed
246+ .claim_with(wrong.reveal(), &spec())
247+ .await
248+ .expect_err("should reject");
249+ let from_claimed = claimed
250+ .claim_with(wrong.reveal(), &spec())
251+ .await
252+ .expect_err("should reject");
253+
254+ // Both must be InvalidCredentials. If the claimed instance answered
255+ // AlreadyExists, a wrong token would reveal the installation's state.
256+ assert!(matches!(
257+ from_unclaimed,
258+ Error::Domain(DomainError::InvalidCredentials)
259+ ));
260+ assert!(matches!(
261+ from_claimed,
262+ Error::Domain(DomainError::InvalidCredentials)
263+ ));
264+ }
265+
266+ #[tokio::test]
267+ async fn rejects_an_invalid_handle_without_writing_anything() {
268+ let fixture = Fixture::new();
269+ let bad = OwnerSpec {
270+ handle: "not a handle".to_owned(),
271+ ..spec()
272+ };
273+
274+ let error = fixture.claim(&bad).await.expect_err("should reject");
275+
276+ assert!(matches!(
277+ error,
278+ Error::Domain(DomainError::Validation { .. })
279+ ));
280+ assert!(
281+ !fixture.users.any_exist().await.expect("any_exist"),
282+ "nothing should be written when validation fails"
283+ );
284+ }
285+
286+ #[tokio::test]
287+ async fn rejects_an_invalid_email_without_writing_anything() {
288+ let fixture = Fixture::new();
289+ let bad = OwnerSpec {
290+ email: "not-an-email".to_owned(),
291+ ..spec()
292+ };
293+
294+ let error = fixture.claim(&bad).await.expect_err("should reject");
295+
296+ assert!(matches!(
297+ error,
298+ Error::Domain(DomainError::Validation { .. })
299+ ));
300+ assert!(!fixture.users.any_exist().await.expect("any_exist"));
301+ }
302+
303+ #[tokio::test]
304+ async fn is_claimed_reports_the_installation_state() {
305+ let fixture = Fixture::new();
306+ assert!(!is_claimed(&fixture.users).await.expect("is_claimed"));
307+
308+ fixture.claim(&spec()).await.expect("claim");
309+
310+ assert!(is_claimed(&fixture.users).await.expect("is_claimed"));
311+ }
312+}
src/application/mod.rs+2 −2View file
@@ -3,13 +3,13 @@
33 //! Every use case takes an actor or a credential plus the ports it needs, and enforces
44 //! the rules before any side effect. Nothing here knows about HTTP or Topcoat.
55
6pub mod bootstrap;
6+pub mod claim;
77 pub mod config;
88 pub mod error;
99 pub mod login;
1010 pub mod port;
1111
12pub use bootstrap::{Bootstrap, OwnerSpec, bootstrap_owner};
12+pub use claim::{OwnerSpec, claim_instance, is_claimed};
1313 pub use config::AppConfig;
1414 pub use error::{Error, Result};
1515 pub use login::login;
src/domain/mod.rs+2 −0View file
@@ -11,6 +11,7 @@ pub mod membership;
1111 pub mod org;
1212 pub mod password;
1313 pub mod repository;
14+pub mod setup_token;
1415 pub mod user;
1516
1617 pub use actor::Actor;
@@ -20,4 +21,5 @@ pub use id::{MembershipId, OrgId, UserId};
2021 pub use membership::{Membership, Role};
2122 pub use org::{OrgName, Organization};
2223 pub use password::PasswordHash;
24+pub use setup_token::SetupToken;
2325 pub use user::User;
src/domain/setup_token.rs+104 −0View file
@@ -0,0 +1,104 @@
1+use std::fmt;
2+
3+use rand::Rng;
4+use subtle::ConstantTimeEq;
5+
6+/// The one-time secret that authorises claiming an unclaimed installation.
7+///
8+/// Held in memory only: restarting an unclaimed instance rotates it, and it never
9+/// reaches the database. See `plans/decisions/0002`.
10+#[derive(Clone)]
11+pub struct SetupToken(String);
12+
13+impl SetupToken {
14+ /// Bytes of entropy. 32 is the same width as a session token.
15+ const BYTES: usize = 32;
16+
17+ /// Mints a fresh token from the OS random source.
18+ pub fn generate() -> Self {
19+ let mut bytes = [0u8; Self::BYTES];
20+ rand::rng().fill_bytes(&mut bytes);
21+
22+ Self(bytes.iter().map(|byte| format!("{byte:02x}")).collect())
23+ }
24+
25+ /// Whether a presented value matches.
26+ ///
27+ /// Compared in constant time. A byte-by-byte comparison that returns early leaks
28+ /// how much of the token is correct, which is enough to recover it one character
29+ /// at a time.
30+ pub fn matches(&self, presented: &str) -> bool {
31+ self.0.as_bytes().ct_eq(presented.as_bytes()).into()
32+ }
33+
34+ /// The token, for printing to the operator exactly once.
35+ pub fn reveal(&self) -> &str {
36+ &self.0
37+ }
38+}
39+
40+/// Redacted, so the token cannot reach a log line except through [`reveal`](Self::reveal).
41+impl fmt::Debug for SetupToken {
42+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43+ f.write_str("SetupToken(redacted)")
44+ }
45+}
46+
47+#[cfg(test)]
48+mod tests {
49+ use super::*;
50+
51+ #[test]
52+ fn a_token_matches_itself() {
53+ let token = SetupToken::generate();
54+
55+ assert!(token.matches(token.reveal()));
56+ }
57+
58+ #[test]
59+ fn a_different_token_does_not_match() {
60+ let token = SetupToken::generate();
61+ let other = SetupToken::generate();
62+
63+ assert!(!token.matches(other.reveal()));
64+ }
65+
66+ #[test]
67+ fn tokens_are_unique_per_generation() {
68+ assert_ne!(
69+ SetupToken::generate().reveal(),
70+ SetupToken::generate().reveal()
71+ );
72+ }
73+
74+ #[test]
75+ fn tokens_carry_full_entropy_as_hex() {
76+ let token = SetupToken::generate();
77+
78+ assert_eq!(token.reveal().len(), SetupToken::BYTES * 2);
79+ assert!(token.reveal().chars().all(|c| c.is_ascii_hexdigit()));
80+ }
81+
82+ #[test]
83+ fn a_prefix_of_the_token_does_not_match() {
84+ let token = SetupToken::generate();
85+ let prefix = &token.reveal()[..16];
86+
87+ assert!(!token.matches(prefix));
88+ }
89+
90+ #[test]
91+ fn the_empty_string_does_not_match() {
92+ assert!(!SetupToken::generate().matches(""));
93+ }
94+
95+ #[test]
96+ fn debug_output_redacts_the_token() {
97+ let token = SetupToken::generate();
98+
99+ let rendered = format!("{token:?}");
100+
101+ assert_eq!(rendered, "SetupToken(redacted)");
102+ assert!(!rendered.contains(token.reveal()));
103+ }
104+}