OXYGENHUB

POST /api/v1/user/runner-token: a phone registers itself as a runner (refs oxygenhub-android#8) 5714347a6233

A one-time registration token for a runner serving the caller's own
repositories and no one else's (scope owner = the caller), with
repo:write; it lasts a day like the settings pages' tokens and is
audited. hub-api RunnerToken. Test: a read-only token is refused, the
token registers an Android runner scoped to the caller.
Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key e04b5a66045b
When
· 2026-10-25 14:51 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
5714347a62336e67d4918ac400276b2972ac95c172f4e0bd9bf54c93b978c728
Depends on
42fa95540468 60f11b71a740 7974adb0be66 a7e6e8e6094c b5668108487f b7d2b0b30b4a d23464c0ac60 ecac6e8d9b41 f4e138301198
Depended on by
8f1d76f4169a 3d086bc3d69b 4dfa4599b3f7 5d818ec7d5fc 80642fde09cc de4467cb1eca
In bookmarks
api-unknown-fields diffusion-pin-bookmark draft-ready main proposal-list-fast
In tags
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
5 atoms, 0 tree operations, 4 files

Browse the tree at this change

modified CHANGELOG.md +1 −0
…
3 unchanged lines
4 4
5 5
## Unreleased
6 6
7 +
- **`POST /api/v1/user/runner-token` (repo:write):** a one-time registration token for a runner that serves the caller's own repositories and no one else's, as the Android app offers the phone as a runner (oxygenhub-android#8); it lasts a day, like the settings pages' tokens, and is audited.
7 8
## 0.9.0 (2026-10-25)
8 9
9 10
- **`GET /api/v1/user/proposals` (repo:read):** the proposals the web dashboard lists, for apps: `review` (open ones by others in repositories the caller belongs to, not yet reviewed by them) and `authored` (their own open and drafts, in repositories they can still see), each with its repository, state, conflicts and web address. The Android app's dashboard shows them (oxygenhub-android#18).
…
301 unchanged lines
modified crates/hub-api/src/lib.rs +11 −0
…
677 unchanged lines
678 678
    pub members: Vec<String>,
679 679
}
680 680
681 +
/// `POST /user/runner-token`: a one-time token that registers a runner for
682 +
/// the caller's own repositories, as a phone does when its owner offers it
683 +
/// as a runner (oxygenhub-android#8). Spent by `flow runner register` (or
684 +
/// the app), within 24 hours.
685 +
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
686 +
pub struct RunnerToken {
687 +
    pub token: String,
688 +
    /// Whose repositories the runner will serve.
689 +
    pub owner: String,
690 +
}
691 +
681 692
/// `GET /user/proposals`: the caller's proposals to act on, as the web
682 693
/// dashboard lists them: open ones by others in repositories they belong
683 694
/// to that they have not reviewed yet, and their own open and draft ones.
…
834 unchanged lines
modified crates/hub/src/api/mod.rs +17 −0
…
95 unchanged lines
96 96
        .route("/user/repos", get(user_repos))
97 97
        .route("/user/orgs", get(user_orgs))
98 98
        .route("/user/proposals", get(user_proposals))
99 +
        .route("/user/runner-token", post(user_runner_token))
99 100
        .route("/user/token", axum::routing::delete(revoke_own_token))
100 101
        .route("/version", get(version))
101 102
        .route("/notifications", get(notifications))
…
505 unchanged lines
607 608
/// `GET /user/repos` (#302): the caller's repositories, their
608 609
/// organisations' and those they collaborate on, by `owner/name`, a page
609 610
/// at a time (`?after=owner/name&limit=`, at most 100, default 50).
611 +
/// `POST /user/runner-token` (repo:write): a one-time registration token
612 +
/// for a runner that serves the caller's own repositories and no one
613 +
/// else's, as a phone registers itself (oxygenhub-android#8). Like the
614 +
/// settings pages' tokens it lasts a day.
615 +
async fn user_runner_token(State(app): State<Shared>, headers: HeaderMap) -> Result<(StatusCode, Json<RunnerToken>), ApiError> {
616 +
    let c = caller(&app, &headers).await?;
617 +
    let me = c.require("repo:write")?.clone();
618 +
    let token = format!("flowreg_{}", crate::auth::random_hex(24));
619 +
    let scope = db::runners::Scope { owner: me.username.clone(), repo_id: None };
620 +
    db::runners::add_registration(&app.db, &blake3::hash(token.as_bytes()).to_hex(), &me.username, &scope).await?;
621 +
    db::accounts::audit(&app.db, Some(&me), "flow.runner.token", &me.username, "registration token for the caller's repositories (API)")
622 +
        .await
623 +
        .ok();
624 +
    Ok((StatusCode::CREATED, Json(RunnerToken { token, owner: me.username })))
625 +
}
626 +
610 627
/// `GET /user/proposals` (repo:read): what the dashboard lists, for apps.
611 628
async fn user_proposals(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<UserProposals>, ApiError> {
612 629
    let c = caller(&app, &headers).await?;
…
2372 unchanged lines
modified crates/hub/tests/flow.rs +31 −0
…
1554 unchanged lines
1555 1555
    assert_eq!(claim_as(only_demo).await, None, "still nothing of its own");
1556 1556
}
1557 1557
1558 +
/// oxygenhub-android#8: an app asks for a registration token for its
1559 +
/// user's own repositories; the runner it registers takes their jobs and
1560 +
/// no one else's.
1561 +
#[tokio::test(flavor = "multi_thread")]
1562 +
async fn a_user_registers_a_runner_for_their_own_repositories() {
1563 +
    use oxy_core::identity::SecretKey;
1564 +
    let h = hub().await;
1565 +
    create(&h.app, "andy", "demo").await;
1566 +
    let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "", "salt", false).await.unwrap();
1567 +
    let pat = "oxyhub_phone_test_token";
1568 +
    db::accounts::create_token(&h.app.db, andy.id, "phone", &oxygenhub::auth::token_hash(pat), "repo:read,repo:write", "", None)
1569 +
        .await
1570 +
        .unwrap();
1571 +
    let read_only = "oxyhub_read_only_token";
1572 +
    db::accounts::create_token(&h.app.db, andy.id, "ro", &oxygenhub::auth::token_hash(read_only), "repo:read", "", None).await.unwrap();
1573 +
    assert_eq!(call(&h.app, "POST", "/api/v1/user/runner-token", Some(read_only), None).await.0, StatusCode::FORBIDDEN);
1574 +
    assert_eq!(call(&h.app, "POST", "/api/v1/user/runner-token", None, None).await.0, StatusCode::UNAUTHORIZED);
1575 +
    let (st, body) = call(&h.app, "POST", "/api/v1/user/runner-token", Some(pat), None).await;
1576 +
    assert_eq!(st, StatusCode::CREATED, "{body}");
1577 +
    let got: serde_json::Value = serde_json::from_str(&body).unwrap();
1578 +
    let token = got["token"].as_str().unwrap().to_string();
1579 +
    assert!(token.starts_with("flowreg_") && got["owner"] == "andy", "{body}");
1580 +
    let identity = SecretKey::generate().identity("andy's phone", "", 1);
1581 +
    let info = serde_json::json!({ "arch": "aarch64", "os": "android", "version": "0.3.1", "labels": ["android"], "max_jobs": 1 });
1582 +
    let reg = serde_json::json!({ "token": token, "name": "pixel", "identity": hex::encode(identity.encode()), "info": info });
1583 +
    let (st, body) = call(&h.app, "POST", "/api/v1/flow/runners/register", None, Some(reg.to_string())).await;
1584 +
    assert_eq!(st, StatusCode::OK, "{body}");
1585 +
    let phone = db::runners::list(&h.app.db).await.unwrap().into_iter().find(|r| r.name == "pixel").unwrap();
1586 +
    assert_eq!((phone.scope_owner.as_str(), phone.scope_repo_id), ("andy", None), "andy's repositories, all of them, and no one else's");
1587 +
}
1588 +
1558 1589
/// Settings → Runners (oxygenhub#41, part 2b): a repository's admin adds a
1559 1590
/// runner for that repository alone, sees which runners serve it, and
1560 1591
/// removes only its own.
…
4849 unchanged lines