OXYGENHUB

A new hub gives administrator only to whoever brings its setup code c8684d44d549

On a hub with no accounts, /register made the first account an
administrator with no invitation, whatever the registration mode: the fresh
GCP hub at gcp.oxygenhub.io was open like this for an hour until Andy
stopped it. Now, with no accounts, the hub keeps a random one-time setup
code in <data>/setup-code (owner-only, never served) and logs where it is at
start, or takes [registration] setup_code (OXYHUB_REGISTRATION_SETUP_CODE)
for automation. The first registration must bring it (compared in constant
time) and spends it; a sign-in provider never makes the first account; once
there are accounts the file is removed. oxygenhub admin user create --admin
needs no code. The tests give their hubs a known code; a new test checks the
made code, its file's mode, wrong and missing codes, and spending it.

Closes #245
Author
Andy McCall @andymccall <[email protected]>
via
agent Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
When
· 2026-10-16 21:10 UTC
Signature
✔ signed by Claude Code
Hash
c8684d44d549e0c7c40f0202032904814d318fbb5e75df1c5624225a06dc7668
Depends on
0024ccb1b050 10708ab8f54d 145306044896 1870e4fa9808 18e381ea88c2 19bb31ff1f5c 1f78655a0466 2a83f5303458 2bfe5ef66b0c 34beb1a7e376 38833ed78707 3f4b0ea6eabf 3ff52534f69b 42fa95540468 4ac01a732552 62ceb2eec18c 65be9f9c3074 66fd9d6b1452 6a1780558353 6ca0b971ff07 6e7dfa0a42b9 760a7dd30d6f 76f262e1a666 7974adb0be66 7b9f4570daf5 83940d53664f 8933faff2043 8a3334b7a364 9008bf6c2b8c 92574625ee9a 93501da2a162 93be9ce4653e 961c10bee174 9756c67d3aec 9a8e3b4419e0 aa0bfbd11f02 b64e2537d702 b6b7e4cf6b24 d0fbea2eb46e d23464c0ac60 d7d182467382 de505df5cf8d df71c7892d27 e271a37b629a ea85398a51bc ef20fdc14e8b
Depended on by
737de0fac722 e57765dee61a c92a99490cba
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
Edits
88 atoms, 1 tree operation, 20 files

Browse the tree at this change

added crates/hub/src/setup.rs +77 −0
1 +
//! The first administrator (oxygenhub#245). A hub with no accounts gives
2 +
//! administrator to the first account registered, so that account must
3 +
//! bring a one-time setup code: the one in `[registration] setup_code`
4 +
//! (for automation), or else a random one this module keeps in
5 +
//! `<data>/setup-code` (owner-only) and logs at start. It is never served
6 +
//! over the web. Registering with it spends it; once there are accounts
7 +
//! there is no code.
8 +
9 +
use crate::db::accounts;
10 +
use crate::Shared;
11 +
use std::path::PathBuf;
12 +
13 +
pub fn path(app: &Shared) -> PathBuf {
14 +
    app.config.data.join("setup-code")
15 +
}
16 +
17 +
/// At start: with no accounts, make sure there is a code and say where it
18 +
/// is; with accounts, make sure none is left lying about.
19 +
pub async fn ensure(app: &Shared) -> anyhow::Result<()> {
20 +
    let file = path(app);
21 +
    if accounts::account_count(&app.db).await? > 0 {
22 +
        let _ = std::fs::remove_file(&file);
23 +
        return Ok(());
24 +
    }
25 +
    let code = match expected(app) {
26 +
        Some(code) => code,
27 +
        None => {
28 +
            let code = crate::auth::random_hex(12);
29 +
            write_private(&file, &code)?;
30 +
            code
31 +
        }
32 +
    };
33 +
    tracing::warn!(
34 +
        "this hub has no administrator yet: register the first account at {}/register with setup code {code} (also in {}); it works once",
35 +
        app.config.base_url(),
36 +
        file.display()
37 +
    );
38 +
    Ok(())
39 +
}
40 +
41 +
/// The code the first registration must bring, if there is one.
42 +
pub fn expected(app: &Shared) -> Option<String> {
43 +
    let configured = app.config.registration.setup_code.trim();
44 +
    if !configured.is_empty() {
45 +
        return Some(configured.to_string());
46 +
    }
47 +
    std::fs::read_to_string(path(app)).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty())
48 +
}
49 +
50 +
/// Whether `given` is the setup code, compared in constant time.
51 +
pub fn matches(app: &Shared, given: &str) -> bool {
52 +
    match expected(app) {
53 +
        Some(code) => {
54 +
            let (a, b) = (code.as_bytes(), given.trim().as_bytes());
55 +
            a.len() == b.len() && a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
56 +
        }
57 +
        None => false,
58 +
    }
59 +
}
60 +
61 +
/// The first administrator exists: the code is spent.
62 +
pub fn spend(app: &Shared) {
63 +
    let _ = std::fs::remove_file(path(app));
64 +
}
65 +
66 +
fn write_private(file: &std::path::Path, code: &str) -> std::io::Result<()> {
67 +
    use std::io::Write;
68 +
    let mut options = std::fs::OpenOptions::new();
69 +
    options.write(true).create(true).truncate(true);
70 +
    #[cfg(unix)]
71 +
    {
72 +
        use std::os::unix::fs::OpenOptionsExt;
73 +
        options.mode(0o600);
74 +
    }
75 +
    let mut f = options.open(file)?;
76 +
    writeln!(f, "{code}")
77 +
}
modified crates/hub/src/config.rs +8 −1
…
153 unchanged lines
154 154
pub struct Registration {
155 155
    /// `open`, `invite` or `closed`; the admin page can override it.
156 156
    pub mode: String,
157 +
    /// The setup code the first account must bring (oxygenhub#245), for
158 +
    /// automation; empty, the hub makes one and keeps it in
159 +
    /// `<data>/setup-code`.
160 +
    pub setup_code: String,
157 161
}
158 162
159 163
impl Default for Registration {
160 164
    fn default() -> Self {
161 -
        Registration { mode: "invite".into() }
165 +
        Registration { mode: "invite".into(), setup_code: String::new() }
162 166
    }
163 167
}
164 168
…
191 unchanged lines
356 360
        if let Some(v) = get("OXYHUB_REGISTRATION_MODE") {
357 361
            self.registration.mode = v;
358 362
        }
363 +
        if let Some(v) = get("OXYHUB_REGISTRATION_SETUP_CODE") {
364 +
            self.registration.setup_code = v;
365 +
        }
359 366
        if let Some(v) = get("OXYHUB_AUTH_PUSH_TOKENS") {
360 367
            self.auth.push_tokens = v.split(',').map(str::trim).filter(|s| !s.is_empty()).map(String::from).collect();
361 368
        }
…
75 unchanged lines
modified crates/hub/src/lib.rs +2 −0
…
31 unchanged lines
32 32
pub mod reports;
33 33
pub mod repos;
34 34
pub mod secrets;
35 +
pub mod setup;
35 36
pub mod signin;
36 37
pub mod social;
37 38
pub mod storage;
…
121 unchanged lines
159 160
            }
160 161
        }
161 162
    });
163 +
    setup::ensure(&app).await?;
162 164
    Ok(app)
163 165
}
164 166
…
144 unchanged lines
modified crates/hub/src/web/session.rs +11 −0
…
245 unchanged lines
246 246
    password: String,
247 247
    #[serde(default)]
248 248
    invite: String,
249 +
    /// The first account's setup code (oxygenhub#245).
250 +
    #[serde(default)]
251 +
    setup: String,
249 252
}
250 253
251 254
pub async fn register(State(app): State<Shared>, viewer: Viewer, Form(f): Form<RegisterForm>) -> Result<Response, Error> {
…
18 unchanged lines
270 273
    }
271 274
    if let Err(e) = auth::password_ok(&f.password) {
272 275
        return Ok(fail(e));
276 +
    }
277 +
    if first && !crate::setup::matches(&app, &f.setup) {
278 +
        return Ok(fail(
279 +
            "This hub has no administrator yet. The first account needs the setup code the server logged when it started (also in setup-code in its data directory).",
280 +
        ));
273 281
    }
274 282
    if !first {
275 283
        match mode.as_str() {
…
20 unchanged lines
296 304
        .map_err(|e| Error::from_db(shell.clone(), e))?;
297 305
    if !first && mode == "invite" {
298 306
        accounts::use_invitation(&app.db, f.invite.trim(), account.id).await.ok();
307 +
    }
308 +
    if first {
309 +
        crate::setup::spend(&app);
299 310
    }
300 311
    accounts::audit(&app.db, Some(&account), "register", &username, if first { "first account: administrator" } else { "" }).await.ok();
301 312
    if let Err(e) = super::recovery::send_verification(&app, &account).await {
…
16 unchanged lines
modified crates/hub/src/web/signin.rs +5 −0
…
223 unchanged lines
224 224
    let username = signin::free_username(&app.db, &who.login, p.id).await?;
225 225
    let hash = auth::hash_password(&auth::random_hex(24)).unwrap_or_default();
226 226
    let first = accounts::account_count(&app.db).await? == 0;
227 +
    // The first account brings the setup code (oxygenhub#245): never one a
228 +
    // sign-in provider vouches for.
229 +
    if first {
230 +
        return Err(sqlx::Error::Protocol("this hub has no administrator yet: register the first account with its setup code".into()));
231 +
    }
227 232
    let account =
228 233
        accounts::create_account(&app.db, &username, who.email.as_deref().unwrap_or(""), &hash, &auth::random_hex(16), first).await?;
229 234
    if who.email.is_some() {
…
116 unchanged lines
modified crates/hub/tests/accounts.rs +69 −11
…
36 unchanged lines
37 37
    hermetic();
38 38
    let dir = tempfile::tempdir().unwrap();
39 39
    let mut config = Config::load(dir.path()).unwrap();
40 +
    // The first account's setup code (oxygenhub#245).
41 +
    config.registration.setup_code = "test-setup".into();
40 42
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
41 43
    let port = listener.local_addr().unwrap().port();
42 44
    config.server.base_url = format!("http://127.0.0.1:{port}");
…
55 unchanged lines
98 100
99 101
/// Register (or sign in) and return the session cookie value.
100 102
async fn register(app: &Shared, user: &str, invite: &str) -> String {
101 -
    let r =
102 -
        post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&invite={invite}"))
103 -
            .await;
103 +
    let r = post(
104 +
        app,
105 +
        "/register",
106 +
        None,
107 +
        &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup&invite={invite}"),
108 +
    )
109 +
    .await;
104 110
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
105 111
    r.cookie.expect("session cookie")
106 112
}
…
13 unchanged lines
120 126
    let me = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap();
121 127
    assert!(me.is_admin);
122 128
    // A second registration needs an invitation.
123 -
    let r = post(app, "/register", None, "username=mallory&email=m%40example.com&password=correct-horse-battery&invite=").await;
129 +
    let r = post(app, "/register", None, "username=mallory&email=m%40example.com&password=correct-horse-battery&setup=test-setup&invite=")
130 +
        .await;
124 131
    assert_eq!(r.status, StatusCode::OK);
125 132
    assert!(r.body.contains("invitation"), "{}", r.body);
126 133
    let admin_page = get(app, "/admin", Some(&andy)).await;
…
166 unchanged lines
293 300
    let code = oxygenhub::auth::totp::current(&secret, now).unwrap();
294 301
    let r = post(app, "/settings/security/totp/enable", Some(&fresh), &format!("_csrf={csrf}&secret={secret}&code={code}")).await;
295 302
    assert!(r.body.contains("Recovery codes"), "{}", r.body);
296 -
    let r = post(app, "/login", None, "username=andy&password=correct-horse-battery").await;
303 +
    let r = post(app, "/login", None, "username=andy&password=correct-horse-battery&setup=test-setup").await;
297 304
    assert!(r.body.contains("authenticator"), "second step asked: {}", r.body);
298 305
    // Submit the second step as a browser would: every field of the
299 306
    // rendered form, each name once (a repeated name is refused).
…
238 unchanged lines
538 545
    };
539 546
    let login = |user: &'static str| {
540 547
        let app = app.clone();
541 -
        async move { post(&app, "/login", None, &format!("username={user}&password=correct-horse-battery")).await }
548 +
        async move { post(&app, "/login", None, &format!("username={user}&password=correct-horse-battery&setup=test-setup")).await }
542 549
    };
543 550
544 551
    // Disabled: sign-in says so, the old session no longer counts.
…
27 unchanged lines
572 579
    assert_eq!(get(app, "/settings", Some(&bob)).await.status, StatusCode::SEE_OTHER);
573 580
    // Its name stays retired.
574 581
    let code = invite(app.clone(), andy.clone()).await;
575 -
    let r = post(app, "/register", None, &format!("username=bob&email=b%40example.com&password=correct-horse-battery&invite={code}")).await;
582 +
    let r = post(
583 +
        app,
584 +
        "/register",
585 +
        None,
586 +
        &format!("username=bob&email=b%40example.com&password=correct-horse-battery&setup=test-setup&invite={code}"),
587 +
    )
588 +
    .await;
576 589
    assert!(r.body.contains("That name is taken"), "{}", r.body);
577 590
    // The last admin can be neither disabled nor deleted.
578 591
    assert!(act("andy", "delete", "andy").await.contains("Not on yourself"));
…
355 unchanged lines
934 947
    let (mock, seen) = mock_provider().await;
935 948
    let dir = tempfile::tempdir().unwrap();
936 949
    let mut config = Config::load(dir.path()).unwrap();
950 +
    // The first account's setup code (oxygenhub#245).
951 +
    config.registration.setup_code = "test-setup".into();
937 952
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
938 953
    let port = listener.local_addr().unwrap().port();
939 954
    config.server.base_url = format!("http://127.0.0.1:{port}");
…
266 unchanged lines
1206 1221
    hermetic();
1207 1222
    let dir = tempfile::tempdir().unwrap();
1208 1223
    let mut config = Config::load(dir.path()).unwrap();
1224 +
    // The first account's setup code (oxygenhub#245).
1225 +
    config.registration.setup_code = "test-setup".into();
1209 1226
    config.server.base_url = "https://hub.example.org".into();
1210 1227
    config.registration.mode = "open".into();
1211 1228
    let app = oxygenhub::build(config).await.unwrap();
…
25 unchanged lines
1237 1254
        "POST",
1238 1255
        "/register",
1239 1256
        Some(("x-forwarded-proto", "https")),
1240 -
        Some("username=andy&email=a%40example.org&password=correct-horse-battery"),
1257 +
        Some("username=andy&email=a%40example.org&password=correct-horse-battery&setup=test-setup"),
1241 1258
    )
1242 1259
    .await;
1243 1260
    let cookie = r.headers().get(header::SET_COOKIE).expect("signed in").to_str().unwrap();
…
5 unchanged lines
1249 1266
    hermetic();
1250 1267
    let dir = tempfile::tempdir().unwrap();
1251 1268
    let mut config = Config::load(dir.path()).unwrap();
1269 +
    // The first account's setup code (oxygenhub#245).
1270 +
    config.registration.setup_code = "test-setup".into();
1252 1271
    config.server.behind_proxy = true;
1253 1272
    config.registration.mode = "open".into();
1254 1273
    let app = oxygenhub::build(config).await.unwrap();
…
11 unchanged lines
1266 1285
        }
1267 1286
    };
1268 1287
    register(&app, "andy", "").await;
1269 -
    let right = || "username=andy&password=correct-horse-battery".to_string();
1288 +
    let right = || "username=andy&password=correct-horse-battery&setup=test-setup".to_string();
1270 1289
1271 1290
    // An account: five wrong passwords cost nothing, the sixth starts a
1272 1291
    // wait, and then even the right password waits (told when to retry).
…
94 unchanged lines
1367 1386
    let app = &h.app;
1368 1387
    let a = register(app, "andy", "").await;
1369 1388
    assert_eq!(a.len(), 64, "a random id, not the account's name");
1370 -
    let b = post(app, "/login", None, "username=andy&password=correct-horse-battery").await.cookie.expect("signed in again");
1389 +
    let b =
1390 +
        post(app, "/login", None, "username=andy&password=correct-horse-battery&setup=test-setup").await.cookie.expect("signed in again");
1371 1391
    assert_ne!(a, b, "each sign-in is a new session");
1372 1392
1373 1393
    let page = get(app, "/settings/security", Some(&a)).await.body;
…
15 unchanged lines
1389 1409
    assert!(!signed_in(a.clone()).await, "idle too long");
1390 1410
1391 1411
    // Signing out ends the record itself, not just the cookie.
1392 -
    let c = post(app, "/login", None, "username=andy&password=correct-horse-battery").await.cookie.unwrap();
1412 +
    let c = post(app, "/login", None, "username=andy&password=correct-horse-battery&setup=test-setup").await.cookie.unwrap();
1393 1413
    let csrf = csrf_of(&get(app, "/settings/security", Some(&c)).await.body);
1394 1414
    post(app, "/logout", Some(&c), &format!("_csrf={csrf}")).await;
1395 1415
    assert!(!signed_in(c).await, "the old cookie no longer works");
…
7 unchanged lines
1403 1423
    let (mock, _) = mock_provider().await;
1404 1424
    let dir = tempfile::tempdir().unwrap();
1405 1425
    let mut config = Config::load(dir.path()).unwrap();
1426 +
    // The first account's setup code (oxygenhub#245).
1427 +
    config.registration.setup_code = "test-setup".into();
1406 1428
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
1407 1429
    let port = listener.local_addr().unwrap().port();
1408 1430
    config.server.base_url = format!("http://127.0.0.1:{port}");
…
39 unchanged lines
1448 1470
    assert_eq!(r.status, StatusCode::BAD_REQUEST);
1449 1471
    assert!(r.body.contains("That invitation has been used or does not exist."), "{}", r.body);
1450 1472
    assert_eq!(get(app, "/login/github?invite=not-a.code", None).await.status, StatusCode::BAD_REQUEST);
1473 +
}
1474 +
1475 +
/// A new hub hands administrator only to whoever brings its setup code
1476 +
/// (oxygenhub#245): the one it made, kept owner-only in its data directory,
1477 +
/// never on the web; once spent, the file is gone.
1478 +
#[tokio::test(flavor = "multi_thread")]
1479 +
async fn the_first_account_needs_the_setup_code_the_hub_made() {
1480 +
    hermetic();
1481 +
    let dir = tempfile::tempdir().unwrap();
1482 +
    let config = Config::load(dir.path()).unwrap();
1483 +
    let app = oxygenhub::build(config).await.unwrap();
1484 +
    let file = dir.path().join("setup-code");
1485 +
    let code = std::fs::read_to_string(&file).expect("an empty hub makes a setup code").trim().to_string();
1486 +
    assert_eq!(code.len(), 24);
1487 +
    #[cfg(unix)]
1488 +
    {
1489 +
        use std::os::unix::fs::PermissionsExt;
1490 +
        assert_eq!(std::fs::metadata(&file).unwrap().permissions().mode() & 0o777, 0o600);
1491 +
    }
1492 +
    let page = get(&app, "/register", None).await.body;
1493 +
    assert!(page.contains("name=\"setup\"") && !page.contains(&code), "asks for the code, never shows it");
1494 +
1495 +
    let form = |setup: &str| format!("username=mallory&email=m%40example.com&password=correct-horse-battery&setup={setup}");
1496 +
    for wrong in ["", "0123456789abcdef01234567"] {
1497 +
        let r = post(&app, "/register", None, &form(wrong)).await;
1498 +
        assert!(r.cookie.is_none() && r.body.contains("needs the setup code"), "{}", r.body);
1499 +
    }
1500 +
    assert_eq!(db::accounts::account_count(&app.db).await.unwrap(), 0);
1501 +
1502 +
    let r = post(&app, "/register", None, &form(&code).replace("mallory", "andy")).await;
1503 +
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
1504 +
    assert!(db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().is_admin);
1505 +
    assert!(!file.exists(), "the code is spent");
1506 +
    // The next account is an ordinary one, under the registration mode.
1507 +
    let r = post(&app, "/register", None, &form(&code)).await;
1508 +
    assert!(r.body.contains("invitation"), "{}", r.body);
1451 1509
}
modified crates/hub/tests/api.rs +200 −62
…
41 unchanged lines
42 42
    hermetic();
43 43
    let dir = tempfile::tempdir().unwrap();
44 44
    let mut config = Config::load(dir.path()).unwrap();
45 +
    // The first account's setup code (oxygenhub#245).
46 +
    config.registration.setup_code = "test-setup".into();
45 47
    config.auth.push_tokens = vec!["site".into()];
46 48
    config.registration.mode = "open".into();
47 49
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
…
101 unchanged lines
149 151
    let h = hub().await;
150 152
    let app = &h.app;
151 153
    // Accounts and tokens through the pages.
152 -
    let r =
153 -
        call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await;
154 +
    let r = call(
155 +
        app,
156 +
        "POST",
157 +
        "/register",
158 +
        None,
159 +
        None,
160 +
        None,
161 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
162 +
    )
163 +
    .await;
154 164
    let andy = r.cookie.unwrap();
155 -
    let r =
156 -
        call(app, "POST", "/register", None, None, None, Some("username=bob&email=b%40example.com&password=correct-horse-battery")).await;
165 +
    let r = call(
166 +
        app,
167 +
        "POST",
168 +
        "/register",
169 +
        None,
170 +
        None,
171 +
        None,
172 +
        Some("username=bob&email=b%40example.com&password=correct-horse-battery&setup=test-setup"),
173 +
    )
174 +
    .await;
157 175
    let bob = r.cookie.unwrap();
158 176
    let mint = |cookie: String, scopes: &'static str| {
159 177
        let app = app.clone();
…
354 unchanged lines
514 532
async fn api_issue_triage_and_labels() {
515 533
    let h = hub().await;
516 534
    let app = &h.app;
517 -
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
518 -
        .await
519 -
        .cookie
520 -
        .unwrap();
521 -
    let bob = call(app, "POST", "/register", None, None, None, Some("username=bob&email=b%40example.com&password=correct-horse-battery"))
522 -
        .await
523 -
        .cookie
524 -
        .unwrap();
535 +
    let andy = call(
536 +
        app,
537 +
        "POST",
538 +
        "/register",
539 +
        None,
540 +
        None,
541 +
        None,
542 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
543 +
    )
544 +
    .await
545 +
    .cookie
546 +
    .unwrap();
547 +
    let bob = call(
548 +
        app,
549 +
        "POST",
550 +
        "/register",
551 +
        None,
552 +
        None,
553 +
        None,
554 +
        Some("username=bob&email=b%40example.com&password=correct-horse-battery&setup=test-setup"),
555 +
    )
556 +
    .await
557 +
    .cookie
558 +
    .unwrap();
525 559
    let mint = |cookie: String, scopes: &'static str| {
526 560
        let app = app.clone();
527 561
        async move {
…
133 unchanged lines
661 695
async fn webhook_formats_editing_and_web_issue_events() {
662 696
    let h = hub().await;
663 697
    let app = &h.app;
664 -
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
665 -
        .await
666 -
        .cookie
667 -
        .unwrap();
698 +
    let andy = call(
699 +
        app,
700 +
        "POST",
701 +
        "/register",
702 +
        None,
703 +
        None,
704 +
        None,
705 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
706 +
    )
707 +
    .await
708 +
    .cookie
709 +
    .unwrap();
668 710
    let page = call(app, "GET", "/settings/tokens", None, Some(&andy), None, None).await;
669 711
    let r = call(
670 712
        app,
…
84 unchanged lines
755 797
async fn api_projects() {
756 798
    let h = hub().await;
757 799
    let app = &h.app;
758 -
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
759 -
        .await
760 -
        .cookie
761 -
        .unwrap();
762 -
    let bob = call(app, "POST", "/register", None, None, None, Some("username=bob&email=b%40example.com&password=correct-horse-battery"))
763 -
        .await
764 -
        .cookie
765 -
        .unwrap();
800 +
    let andy = call(
801 +
        app,
802 +
        "POST",
803 +
        "/register",
804 +
        None,
805 +
        None,
806 +
        None,
807 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
808 +
    )
809 +
    .await
810 +
    .cookie
811 +
    .unwrap();
812 +
    let bob = call(
813 +
        app,
814 +
        "POST",
815 +
        "/register",
816 +
        None,
817 +
        None,
818 +
        None,
819 +
        Some("username=bob&email=b%40example.com&password=correct-horse-battery&setup=test-setup"),
820 +
    )
821 +
    .await
822 +
    .cookie
823 +
    .unwrap();
766 824
    let mint = |cookie: String, scopes: &'static str| {
767 825
        let app = app.clone();
768 826
        async move {
…
67 unchanged lines
836 894
async fn api_required_checks() {
837 895
    let h = hub().await;
838 896
    let app = &h.app;
839 -
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
840 -
        .await
841 -
        .cookie
842 -
        .unwrap();
897 +
    let andy = call(
898 +
        app,
899 +
        "POST",
900 +
        "/register",
901 +
        None,
902 +
        None,
903 +
        None,
904 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
905 +
    )
906 +
    .await
907 +
    .cookie
908 +
    .unwrap();
843 909
    let page = call(app, "GET", "/settings/tokens", None, Some(&andy), None, None).await;
844 910
    let csrf = csrf_of(&page.body);
845 911
    let mint = |scopes: &'static str| {
…
42 unchanged lines
888 954
async fn flow_is_switched_per_repository_by_site_admins() {
889 955
    let h = hub().await;
890 956
    let app = &h.app;
891 -
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
892 -
        .await
893 -
        .cookie
894 -
        .unwrap();
957 +
    let andy = call(
958 +
        app,
959 +
        "POST",
960 +
        "/register",
961 +
        None,
962 +
        None,
963 +
        None,
964 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
965 +
    )
966 +
    .await
967 +
    .cookie
968 +
    .unwrap();
895 969
    let page = call(app, "GET", "/new", None, Some(&andy), None, None).await;
896 970
    call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await;
897 971
    let row = || async { oxygenhub::db::repos::get(&app.db, "andy", "demo").await.unwrap().unwrap() };
…
45 unchanged lines
943 1017
async fn bookmarks_are_deleted_by_push_and_on_the_web() {
944 1018
    let h = hub().await;
945 1019
    let app = &h.app;
946 -
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
947 -
        .await
948 -
        .cookie
949 -
        .unwrap();
1020 +
    let andy = call(
1021 +
        app,
1022 +
        "POST",
1023 +
        "/register",
1024 +
        None,
1025 +
        None,
1026 +
        None,
1027 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
1028 +
    )
1029 +
    .await
1030 +
    .cookie
1031 +
    .unwrap();
950 1032
    let page = call(app, "GET", "/new", None, Some(&andy), None, None).await;
951 1033
    call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await;
952 1034
    let url = format!("{}/andy/demo", h.base);
…
78 unchanged lines
1031 1113
async fn changes_signed_by_unlinked_keys_say_so() {
1032 1114
    let h = hub().await;
1033 1115
    let app = &h.app;
1034 -
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
1035 -
        .await
1036 -
        .cookie
1037 -
        .unwrap();
1116 +
    let andy = call(
1117 +
        app,
1118 +
        "POST",
1119 +
        "/register",
1120 +
        None,
1121 +
        None,
1122 +
        None,
1123 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
1124 +
    )
1125 +
    .await
1126 +
    .cookie
1127 +
    .unwrap();
1038 1128
    let page = call(app, "GET", "/new", None, Some(&andy), None, None).await;
1039 1129
    call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await;
1040 1130
    let url = format!("{}/andy/demo", h.base);
…
28 unchanged lines
1069 1159
async fn a_function_has_its_own_history() {
1070 1160
    let h = hub().await;
1071 1161
    let app = &h.app;
1072 -
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
1073 -
        .await
1074 -
        .cookie
1075 -
        .unwrap();
1162 +
    let andy = call(
1163 +
        app,
1164 +
        "POST",
1165 +
        "/register",
1166 +
        None,
1167 +
        None,
1168 +
        None,
1169 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
1170 +
    )
1171 +
    .await
1172 +
    .cookie
1173 +
    .unwrap();
1076 1174
    let page = call(app, "GET", "/new", None, Some(&andy), None, None).await;
1077 1175
    call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await;
1078 1176
    let url = format!("{}/andy/demo", h.base);
…
33 unchanged lines
1112 1210
async fn a_bookmark_shows_as_swimlanes() {
1113 1211
    let h = hub().await;
1114 1212
    let app = &h.app;
1115 -
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
1116 -
        .await
1117 -
        .cookie
1118 -
        .unwrap();
1213 +
    let andy = call(
1214 +
        app,
1215 +
        "POST",
1216 +
        "/register",
1217 +
        None,
1218 +
        None,
1219 +
        None,
1220 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
1221 +
    )
1222 +
    .await
1223 +
    .cookie
1224 +
    .unwrap();
1119 1225
    let page = call(app, "GET", "/new", None, Some(&andy), None, None).await;
1120 1226
    call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await;
1121 1227
    let url = format!("{}/andy/demo", h.base);
…
33 unchanged lines
1155 1261
async fn a_proposal_board_plans_squashes_and_rebases() {
1156 1262
    let h = hub().await;
1157 1263
    let app = &h.app;
1158 -
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
1159 -
        .await
1160 -
        .cookie
1161 -
        .unwrap();
1264 +
    let andy = call(
1265 +
        app,
1266 +
        "POST",
1267 +
        "/register",
1268 +
        None,
1269 +
        None,
1270 +
        None,
1271 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
1272 +
    )
1273 +
    .await
1274 +
    .cookie
1275 +
    .unwrap();
1162 1276
    let page = call(app, "GET", "/new", None, Some(&andy), None, None).await;
1163 1277
    call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await;
1164 1278
    let url = format!("{}/andy/demo", h.base);
…
65 unchanged lines
1230 1344
async fn proposals_and_issues_show_their_chain() {
1231 1345
    let h = hub().await;
1232 1346
    let app = &h.app;
1233 -
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
1234 -
        .await
1235 -
        .cookie
1236 -
        .unwrap();
1347 +
    let andy = call(
1348 +
        app,
1349 +
        "POST",
1350 +
        "/register",
1351 +
        None,
1352 +
        None,
1353 +
        None,
1354 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
1355 +
    )
1356 +
    .await
1357 +
    .cookie
1358 +
    .unwrap();
1237 1359
    let page = call(app, "GET", "/new", None, Some(&andy), None, None).await;
1238 1360
    call(app, "POST", "/new", None, Some(&andy), None, Some(&format!("_csrf={}&owner=andy&name=demo", csrf_of(&page.body)))).await;
1239 1361
    let url = format!("{}/andy/demo", h.base);
…
97 unchanged lines
1337 1459
async fn device_sign_in() {
1338 1460
    let h = hub().await;
1339 1461
    let app = &h.app;
1340 -
    let r =
1341 -
        call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await;
1462 +
    let r = call(
1463 +
        app,
1464 +
        "POST",
1465 +
        "/register",
1466 +
        None,
1467 +
        None,
1468 +
        None,
1469 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
1470 +
    )
1471 +
    .await;
1342 1472
    let andy = r.cookie.unwrap();
1343 1473
1344 1474
    // An unknown scope is refused.
…
63 unchanged lines
1408 1538
async fn api_assistants_at_each_level() {
1409 1539
    let h = hub().await;
1410 1540
    let app = &h.app;
1411 -
    let andy = call(app, "POST", "/register", None, None, None, Some("username=andy&email=a%40example.com&password=correct-horse-battery"))
1412 -
        .await
1413 -
        .cookie
1414 -
        .unwrap();
1541 +
    let andy = call(
1542 +
        app,
1543 +
        "POST",
1544 +
        "/register",
1545 +
        None,
1546 +
        None,
1547 +
        None,
1548 +
        Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"),
1549 +
    )
1550 +
    .await
1551 +
    .cookie
1552 +
    .unwrap();
1415 1553
    let page = call(app, "GET", "/settings/tokens", None, Some(&andy), None, None).await;
1416 1554
    let csrf = csrf_of(&page.body);
1417 1555
    let mint = |scopes: &'static str| {
…
58 unchanged lines
modified crates/hub/tests/apt.rs +2 −0
…
66 unchanged lines
67 67
    }
68 68
    let dir = tempfile::tempdir().unwrap();
69 69
    let mut config = Config::load(dir.path()).unwrap();
70 +
    // The first account's setup code (oxygenhub#245).
71 +
    config.registration.setup_code = "test-setup".into();
70 72
    config.apt.repos = vec!["andy/oxy".into(), "andy/hidden".into()];
71 73
    // The signing key, in the hub's data as on Earth.
72 74
    let home = dir.path().join("apt-gnupg");
…
101 unchanged lines
modified crates/hub/tests/backups.rs +4 −0
…
34 unchanged lines
35 35
    hermetic();
36 36
    let dir = tempfile::tempdir().unwrap();
37 37
    let mut config = Config::load(dir.path()).unwrap();
38 +
    // The first account's setup code (oxygenhub#245).
39 +
    config.registration.setup_code = "test-setup".into();
38 40
    config.auth.push_tokens = vec!["site".into()];
39 41
    config.backups.directories = vec![backups.to_path_buf()];
40 42
    if let Some(g) = google {
…
650 unchanged lines
691 693
    hermetic();
692 694
    let dir = tempfile::tempdir().unwrap();
693 695
    let mut config = Config::load(dir.path()).unwrap();
696 +
    // The first account's setup code (oxygenhub#245).
697 +
    config.registration.setup_code = "test-setup".into();
694 698
    config.auth.push_tokens = vec!["site".into()];
695 699
    config.backups.directories = vec![backups.to_path_buf()];
696 700
    config.backups.aws_sts_url = aws.to_string();
…
194 unchanged lines
modified crates/hub/tests/browse.rs +2 −0
…
38 unchanged lines
39 39
    hermetic();
40 40
    let dir = tempfile::tempdir().unwrap();
41 41
    let mut config = Config::load(dir.path()).unwrap();
42 +
    // The first account's setup code (oxygenhub#245).
43 +
    config.registration.setup_code = "test-setup".into();
42 44
    config.auth.push_tokens = push_tokens;
43 45
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
44 46
    let port = listener.local_addr().unwrap().port();
…
259 unchanged lines
modified crates/hub/tests/edits.rs +22 −4
…
104 unchanged lines
105 105
    hermetic();
106 106
    let dir = tempfile::tempdir().unwrap();
107 107
    let mut config = Config::load(dir.path()).unwrap();
108 +
    // The first account's setup code (oxygenhub#245).
109 +
    config.registration.setup_code = "test-setup".into();
108 110
    config.auth.push_tokens = vec!["site".into()];
109 111
    config.registration.mode = "open".into();
110 112
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
…
5 unchanged lines
116 118
    let base = format!("http://127.0.0.1:{port}");
117 119
    let app = &app;
118 120
119 -
    let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await;
121 +
    let r =
122 +
        send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"))
123 +
            .await;
120 124
    let andy = r.cookie.unwrap();
121 125
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
122 126
    let repo = RepoName::parse("andy/demo").unwrap();
…
365 unchanged lines
488 492
    hermetic();
489 493
    let dir = tempfile::tempdir().unwrap();
490 494
    let mut config = Config::load(dir.path()).unwrap();
495 +
    // The first account's setup code (oxygenhub#245).
496 +
    config.registration.setup_code = "test-setup".into();
491 497
    config.auth.push_tokens = vec!["site".into()];
492 498
    config.registration.mode = "open".into();
493 499
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
…
4 unchanged lines
498 504
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
499 505
    let base = format!("http://127.0.0.1:{port}");
500 506
    let app = &app;
501 -
    let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await;
507 +
    let r =
508 +
        send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"))
509 +
            .await;
502 510
    assert!(r.cookie.is_some());
503 511
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
504 512
    let repo = RepoName::parse("andy/demo").unwrap();
…
75 unchanged lines
580 588
    hermetic();
581 589
    let dir = tempfile::tempdir().unwrap();
582 590
    let mut config = Config::load(dir.path()).unwrap();
591 +
    // The first account's setup code (oxygenhub#245).
592 +
    config.registration.setup_code = "test-setup".into();
583 593
    config.auth.push_tokens = vec!["site".into()];
584 594
    config.registration.mode = "open".into();
585 595
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
…
4 unchanged lines
590 600
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
591 601
    let base = format!("http://127.0.0.1:{port}");
592 602
    let app = &app;
593 -
    let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await;
603 +
    let r =
604 +
        send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"))
605 +
            .await;
594 606
    let andy = r.cookie.unwrap();
595 607
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
596 608
    db::accounts::create_org(&app.db, "doomsday", "Doomsday One", andy_id).await.unwrap();
…
67 unchanged lines
664 676
    hermetic();
665 677
    let dir = tempfile::tempdir().unwrap();
666 678
    let mut config = Config::load(dir.path()).unwrap();
679 +
    // The first account's setup code (oxygenhub#245).
680 +
    config.registration.setup_code = "test-setup".into();
667 681
    config.auth.push_tokens = vec!["site".into()];
668 682
    config.registration.mode = "open".into();
669 683
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
…
4 unchanged lines
674 688
    tokio::spawn(async move { axum::serve(listener, router).await.unwrap() });
675 689
    let base = format!("http://127.0.0.1:{port}");
676 690
    let app = &app;
677 -
    let r = send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery")).await;
691 +
    let r =
692 +
        send(app, "POST", "/register", None, Some("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"))
693 +
            .await;
678 694
    let andy = r.cookie.unwrap();
679 695
    let andy_id = db::accounts::account_by_name(&app.db, "andy").await.unwrap().unwrap().id;
680 696
    let repo = RepoName::parse("andy/demo").unwrap();
…
124 unchanged lines
805 821
    hermetic();
806 822
    let dir = tempfile::tempdir().unwrap();
807 823
    let mut config = Config::load(dir.path()).unwrap();
824 +
    // The first account's setup code (oxygenhub#245).
825 +
    config.registration.setup_code = "test-setup".into();
808 826
    config.auth.push_tokens = vec!["site".into()];
809 827
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
810 828
    let port = listener.local_addr().unwrap().port();
…
35 unchanged lines
modified crates/hub/tests/flow.rs +9 −7
…
40 unchanged lines
41 41
    hermetic();
42 42
    let dir = tempfile::tempdir().unwrap();
43 43
    let mut config = Config::load(dir.path()).unwrap();
44 +
    // The first account's setup code (oxygenhub#245).
45 +
    config.registration.setup_code = "test-setup".into();
44 46
    config.auth.push_tokens = vec!["site".into()];
45 47
    config.flow.runner_tokens = vec!["runner-secret".into(), "runner-two".into(), "runner-three".into()];
46 48
    config.flow.repos = vec!["andy/*".into()];
…
875 unchanged lines
922 924
        Request::post("/register").header(header::CONTENT_TYPE, "application/x-www-form-urlencoded").body(Body::from(f)).unwrap()
923 925
    };
924 926
    let res = oxygenhub::router(h.app.clone())
925 -
        .oneshot(form("username=andy&email=a%40example.com&password=correct-horse-battery".into()))
927 +
        .oneshot(form("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup".into()))
926 928
        .await
927 929
        .unwrap();
928 930
    let cookie = res.headers().get(header::SET_COOKIE).unwrap().to_str().unwrap().split(';').next().unwrap().to_string();
…
352 unchanged lines
1281 1283
        .oneshot(
1282 1284
            Request::post("/register")
1283 1285
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1284 -
                .body(Body::from("username=carol&email=carol%40example.com&password=correct-horse-battery"))
1286 +
                .body(Body::from("username=carol&email=carol%40example.com&password=correct-horse-battery&setup=test-setup"))
1285 1287
                .unwrap(),
1286 1288
        )
1287 1289
        .await
…
207 unchanged lines
1495 1497
        .oneshot(
1496 1498
            Request::post("/register")
1497 1499
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1498 -
                .body(Body::from("username=andy&email=andy%40example.com&password=correct-horse-battery"))
1500 +
                .body(Body::from("username=andy&email=andy%40example.com&password=correct-horse-battery&setup=test-setup"))
1499 1501
                .unwrap(),
1500 1502
        )
1501 1503
        .await
…
485 unchanged lines
1987 1989
        .oneshot(
1988 1990
            Request::post("/register")
1989 1991
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
1990 -
                .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery"))
1992 +
                .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"))
1991 1993
                .unwrap(),
1992 1994
        )
1993 1995
        .await
…
76 unchanged lines
2070 2072
        .oneshot(
2071 2073
            Request::post("/register")
2072 2074
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
2073 -
                .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery"))
2075 +
                .body(Body::from("username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup"))
2074 2076
                .unwrap(),
2075 2077
        )
2076 2078
        .await
…
1046 unchanged lines
3123 3125
        .oneshot(
3124 3126
            Request::post("/register")
3125 3127
                .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
3126 -
                .body(Body::from("username=carol&email=carol%40example.com&password=correct-horse-battery"))
3128 +
                .body(Body::from("username=carol&email=carol%40example.com&password=correct-horse-battery&setup=test-setup"))
3127 3129
                .unwrap(),
3128 3130
        )
3129 3131
        .await
…
967 unchanged lines
4097 4099
    let register = |name: &'static str| {
4098 4100
        let app = h.app.clone();
4099 4101
        async move {
4100 -
            let body = format!("username={name}&email={name}%40example.com&password=correct-horse-battery");
4102 +
            let body = format!("username={name}&email={name}%40example.com&password=correct-horse-battery&setup=test-setup");
4101 4103
            let res = oxygenhub::router(app)
4102 4104
                .oneshot(
4103 4105
                    Request::post("/register")
…
66 unchanged lines
modified crates/hub/tests/issues.rs +9 −1
…
38 unchanged lines
39 39
    hermetic();
40 40
    let dir = tempfile::tempdir().unwrap();
41 41
    let mut config = Config::load(dir.path()).unwrap();
42 +
    // The first account's setup code (oxygenhub#245).
43 +
    config.registration.setup_code = "test-setup".into();
42 44
    config.auth.push_tokens = vec!["site".into()];
43 45
    config.registration.mode = "open".into();
44 46
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
…
47 unchanged lines
92 94
}
93 95
94 96
async fn register(app: &Shared, user: &str) -> String {
95 -
    let r = post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery")).await;
97 +
    let r = post(
98 +
        app,
99 +
        "/register",
100 +
        None,
101 +
        &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"),
102 +
    )
103 +
    .await;
96 104
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
97 105
    r.cookie.unwrap()
98 106
}
…
1085 unchanged lines
modified crates/hub/tests/maintenance.rs +2 −0
…
26 unchanged lines
27 27
    hermetic();
28 28
    let dir = tempfile::tempdir().unwrap();
29 29
    let mut config = Config::load(dir.path()).unwrap();
30 +
    // The first account's setup code (oxygenhub#245).
31 +
    config.registration.setup_code = "test-setup".into();
30 32
    config.auth.push_tokens = vec!["site".into()];
31 33
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
32 34
    let port = listener.local_addr().unwrap().port();
…
40 unchanged lines
modified crates/hub/tests/proposals.rs +9 −1
…
38 unchanged lines
39 39
    hermetic();
40 40
    let dir = tempfile::tempdir().unwrap();
41 41
    let mut config = Config::load(dir.path()).unwrap();
42 +
    // The first account's setup code (oxygenhub#245).
43 +
    config.registration.setup_code = "test-setup".into();
42 44
    config.auth.push_tokens = vec!["site".into()];
43 45
    config.registration.mode = "open".into();
44 46
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
…
63 unchanged lines
108 110
}
109 111
110 112
async fn register(app: &Shared, user: &str) -> String {
111 -
    let r = post(app, "/register", None, &format!("username={user}&email={user}%40example.com&password=correct-horse-battery")).await;
113 +
    let r = post(
114 +
        app,
115 +
        "/register",
116 +
        None,
117 +
        &format!("username={user}&email={user}%40example.com&password=correct-horse-battery&setup=test-setup"),
118 +
    )
119 +
    .await;
112 120
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
113 121
    r.cookie.unwrap()
114 122
}
…
611 unchanged lines
modified crates/hub/tests/recovery.rs +5 −3
…
30 unchanged lines
31 31
    let mailbox = dir.path().join("mailbox");
32 32
    std::fs::create_dir_all(&mailbox).unwrap();
33 33
    let mut config = Config::load(dir.path()).unwrap();
34 +
    // The first account's setup code (oxygenhub#245).
35 +
    config.registration.setup_code = "test-setup".into();
34 36
    config.server.base_url = "http://hub.test".into();
35 37
    config.registration.mode = "open".into();
36 38
    if mail {
…
78 unchanged lines
115 117
async fn verification_and_reset() {
116 118
    let h = hub(true).await;
117 119
    let app = &h.app;
118 -
    let r = post(app, "/register", None, "username=andy&email=andy%40example.com&password=correct-horse-battery").await;
120 +
    let r = post(app, "/register", None, "username=andy&email=andy%40example.com&password=correct-horse-battery&setup=test-setup").await;
119 121
    assert_eq!(r.status, StatusCode::SEE_OTHER, "{}", r.body);
120 122
    let old_session = r.cookie.unwrap();
121 123
…
41 unchanged lines
163 165
    assert!(get(app, &reset, None).await.body.contains("expired or was already used"), "single use");
164 166
    let stale = get(app, "/settings", Some(&old_session)).await;
165 167
    assert!(stale.status.is_redirection() || !stale.body.contains("name=\"_csrf\""), "the old session ended");
166 -
    let wrong = post(app, "/login", None, "username=andy&password=correct-horse-battery").await;
168 +
    let wrong = post(app, "/login", None, "username=andy&password=correct-horse-battery&setup=test-setup").await;
167 169
    assert!(wrong.body.contains("Wrong username or password"));
168 170
    let signed = post(app, "/login", None, "username=andy&password=new-password-long-enough").await;
169 171
    assert_eq!(signed.status, StatusCode::SEE_OTHER);
…
27 unchanged lines
197 199
    assert!(again.body.contains("less than two minutes ago"), "{}", again.body);
198 200
199 201
    // An administrator's link, for an account without a verified email.
200 -
    post(app, "/register", None, "username=bob&email=&password=bob-password-long-enough").await;
202 +
    post(app, "/register", None, "username=bob&email=&password=bob-password-long-enough&setup=test-setup").await;
201 203
    let admin = get(app, "/admin", Some(&session)).await;
202 204
    assert!(admin.body.contains("value=\"reset_link\""));
203 205
    let r = post(app, "/admin/account", Some(&session), &format!("_csrf={}&username=bob&action=reset_link", csrf_of(&admin.body))).await;
…
18 unchanged lines
modified crates/hub/tests/releases.rs +3 −1
…
89 unchanged lines
90 90
    hermetic();
91 91
    let dir = tempfile::tempdir().unwrap();
92 92
    let mut config = Config::load(dir.path()).unwrap();
93 +
    // The first account's setup code (oxygenhub#245).
94 +
    config.registration.setup_code = "test-setup".into();
93 95
    config.auth.push_tokens = vec!["site".into()];
94 96
    config.registration.mode = "open".into();
95 97
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
…
11 unchanged lines
107 109
        None,
108 110
        None,
109 111
        Some("application/x-www-form-urlencoded"),
110 -
        b"username=andy&email=a%40example.com&password=correct-horse-battery".to_vec(),
112 +
        b"username=andy&email=a%40example.com&password=correct-horse-battery&setup=test-setup".to_vec(),
111 113
    )
112 114
    .await
113 115
    .cookie
…
249 unchanged lines
modified crates/hub/tests/storage.rs +10 −2
…
33 unchanged lines
34 34
    hermetic();
35 35
    let dir = tempfile::tempdir().unwrap();
36 36
    let mut config = Config::load(dir.path()).unwrap();
37 +
    // The first account's setup code (oxygenhub#245).
38 +
    config.registration.setup_code = "test-setup".into();
37 39
    config.auth.push_tokens = vec!["site".into()];
38 40
    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
39 41
    let port = listener.local_addr().unwrap().port();
…
43 unchanged lines
83 85
}
84 86
85 87
async fn andy(app: &Shared) -> String {
86 -
    let (st, body, cookie) =
87 -
        send(app, "POST", "/register", None, Some("username=andy&email=andy%40example.com&password=correct-horse-battery&invite=")).await;
88 +
    let (st, body, cookie) = send(
89 +
        app,
90 +
        "POST",
91 +
        "/register",
92 +
        None,
93 +
        Some("username=andy&email=andy%40example.com&password=correct-horse-battery&setup=test-setup&invite="),
94 +
    )
95 +
    .await;
88 96
    assert_eq!(st, StatusCode::SEE_OTHER, "{body}");
89 97
    cookie.expect("session")
90 98
}
…
171 unchanged lines
modified docs/spec.md +1 −1
…
168 unchanged lines
169 169
170 170
### 5.1 Accounts
171 171
172 -
An account has a username (same grammar as an owner name), a display name, an email (verified before it is used for anything), an Argon2id password hash, an optional TOTP secret, a session salt (rotating it signs out every session), and a set of linked Oxygen identities (§5.3). The first account created becomes the site administrator. Registration is open, invitation only, or closed, per configuration.
172 +
An account has a username (same grammar as an owner name), a display name, an email (verified before it is used for anything), an Argon2id password hash, an optional TOTP secret, a session salt (rotating it signs out every session), and a set of linked Oxygen identities (§5.3). The first account created becomes the site administrator, and only with the hub's one-time setup code (oxygenhub#245): with no accounts, the hub keeps a random code in `<data>/setup-code` (owner-only, never served) and logs it at start, or uses `[registration] setup_code` (`OXYHUB_REGISTRATION_SETUP_CODE`) for automation; registering with it spends it, and a sign-in provider never makes the first account. `oxygenhub admin user create --admin` needs no code. Registration is open, invitation only, or closed, per configuration.
173 173
174 174
Sessions are server-side records (oxygenhub#223): the cookie `oxyhub=<id>` holds 32 random bytes in hex, and the hub keeps only the id's BLAKE3 hash, with the account, the account's session salt at sign-in, the times it was made and last used, and the address and browser it came from. A session is good while its salt is still the account's, it has been used in the last 14 days, and it is under 30 days old; the cookie is `HttpOnly`, `Secure` when served over HTTPS, `SameSite=Lax`. Every sign-in (after the code, with two-factor) makes a new session; signing out deletes its record. Password changes, resets and "sign out everywhere" rotate the salt, which ends every session at once; Settings › Security lists the signed-in browsers and signs any one out. Ended sessions are swept nightly. CSRF tokens are bound to the session id. Sign-in is also slowed against guessing (§15.6, oxygenhub#221). Passkeys (WebAuthn) are planned for 1.0 as a second factor or password replacement.
175 175
…
584 unchanged lines
modified templates/register.html +1 −0
…
13 unchanged lines
14 14
  <label for="username">Username</label><input type="text" id="username" name="username" autocomplete="username" required pattern="[A-Za-z0-9][A-Za-z0-9._-]{0,63}">
15 15
  <label for="email">Email</label><input type="email" id="email" name="email" autocomplete="email">
16 16
  <label for="password">Password (at least 10 characters)</label><input type="password" id="password" name="password" autocomplete="new-password" required minlength="10">
17 +
  {% if first %}<label for="setup">Setup code <small>logged by the server when it started, and in <code>setup-code</code> in its data directory</small></label><input type="text" id="setup" name="setup" autocomplete="off" required>{% endif %}
17 18
  {% if !first && mode == "invite" %}<label for="invite">Invitation code</label><input type="text" id="invite" name="invite" value="{{ invite }}" required>{% endif %}
18 19
  <button type="submit">CREATE ACCOUNT</button>
19 20
</form>
…
5 unchanged lines