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 |