OXYGENHUB

Backups API: settings, TEST, back up now and runs 2e9188ca4f98

docs/backups.md §5 said the API mirrors the Backups pages; only the
per-repository PUT existed. Now, with the scope repo:admin, for the
caller's own repositories and for an organisation it owns (any other is
not found):

- GET/PUT /api/v1/user/backups and /api/v1/orgs/{name}/backups: the
  setting with where its runs go and the newest runs; PUT takes the
  page's fields (keep_days a number) and checks them exactly as the page
  does, through the same code;
- POST …/backups/test: write, read and remove a probe;
- POST …/backups/run: back up now, in the background (202);
- GET …/backups/runs.

Tested: refused before a setting exists, without repo:admin, and with a
bad provider; saved, probed and run to ok through the API; an
organisation's set by its owner and hidden from another account.

closes #179
Author
Andy McCall @andymccall <[email protected]>
When
· 2026-10-14 09:04 UTC
Signature
✔ signed by Andy McCall
Hash
2e9188ca4f984f24501725d93b43797faf1d7d1810ad150b737612d0eb2069a8
Depends on
101740454617 1e8a6e8bb91b 35fea4237cc9 38833ed78707 6dbd999ad794 7b9f4570daf5 b64e2537d702 d23464c0ac60 d3b048b27277 d7d182467382 df71c7892d27
Depended on by
f2d2776b2a14 1c52d940b4a0 bff9106edcba
In bookmarks
api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
In tags
v0.5.0 v0.5.1 v0.5.2 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
24 atoms, 1 tree operation, 7 files

Browse the tree at this change

added crates/hub/src/api/backups.rs +183 −0
1 +
//! The backups API (oxygenhub#179, docs/backups.md §5): what the Backups
2 +
//! pages do, for a user's own repositories and an organisation's (its
3 +
//! owners only), with the scope `repo:admin`.
4 +
5 +
use super::{caller, not_found, ApiError, Caller};
6 +
use crate::auth;
7 +
use crate::backups::{self, Run, Setting};
8 +
use crate::db::{self, accounts::Account};
9 +
use crate::web::backups::{setting_of, SettingInput};
10 +
use crate::Shared;
11 +
use axum::extract::{Path, State};
12 +
use axum::http::{HeaderMap, StatusCode};
13 +
use axum::Json;
14 +
use serde::{Deserialize, Serialize};
15 +
16 +
/// A setting as the API takes it: the page's fields, `keep_days` a number.
17 +
#[derive(Deserialize)]
18 +
pub struct Body {
19 +
    pub every: String,
20 +
    #[serde(default)]
21 +
    pub destination: String,
22 +
    /// A directory destination: one the hub allows.
23 +
    #[serde(default)]
24 +
    pub dir: String,
25 +
    /// A GCS destination: its bucket and workload identity provider.
26 +
    #[serde(default)]
27 +
    pub bucket: String,
28 +
    #[serde(default)]
29 +
    pub provider: String,
30 +
    #[serde(default)]
31 +
    pub prefix: String,
32 +
    #[serde(default = "thirty")]
33 +
    pub keep_days: i64,
34 +
    /// An S3 destination: its bucket, region and the IAM role's ARN.
35 +
    #[serde(default)]
36 +
    pub s3_bucket: String,
37 +
    #[serde(default)]
38 +
    pub s3_region: String,
39 +
    #[serde(default)]
40 +
    pub s3_role: String,
41 +
}
42 +
43 +
fn thirty() -> i64 {
44 +
    30
45 +
}
46 +
47 +
#[derive(Serialize)]
48 +
pub struct State_ {
49 +
    pub setting: Option<Setting>,
50 +
    /// Where its runs go, as the pages say it.
51 +
    pub place: Option<String>,
52 +
    pub runs: Vec<Run>,
53 +
}
54 +
55 +
/// The owner a call acts for: the caller's own account, or an organisation
56 +
/// it owns. Someone else's is not found.
57 +
async fn owner_of(app: &Shared, c: &Caller, org: Option<&str>) -> Result<(Account, String, &'static str), ApiError> {
58 +
    let me = c.require("repo:admin")?.clone();
59 +
    match org {
60 +
        None => {
61 +
            let name = me.username.clone();
62 +
            Ok((me, name, "user"))
63 +
        }
64 +
        Some(name) => {
65 +
            let o = db::accounts::org_by_name(&app.db, name).await?.ok_or_else(|| not_found("no such organisation"))?;
66 +
            if !auth::roles::is_org_owner(&app.db, &me, o.id).await? {
67 +
                return Err(not_found("no such organisation"));
68 +
            }
69 +
            Ok((me, o.name, "org"))
70 +
        }
71 +
    }
72 +
}
73 +
74 +
async fn state(app: &Shared, owner: &str) -> Result<State_, ApiError> {
75 +
    let setting = backups::setting(&app.db, owner).await?;
76 +
    let place = setting.as_ref().map(backups::place);
77 +
    Ok(State_ { setting, place, runs: backups::runs(&app.db, owner, 10).await? })
78 +
}
79 +
80 +
async fn get(app: Shared, headers: HeaderMap, org: Option<String>) -> Result<Json<State_>, ApiError> {
81 +
    let c = caller(&app, &headers).await?;
82 +
    let (_, owner, _) = owner_of(&app, &c, org.as_deref()).await?;
83 +
    Ok(Json(state(&app, &owner).await?))
84 +
}
85 +
86 +
async fn put(app: Shared, headers: HeaderMap, org: Option<String>, b: Body) -> Result<Json<State_>, ApiError> {
87 +
    let c = caller(&app, &headers).await?;
88 +
    let (me, owner, kind) = owner_of(&app, &c, org.as_deref()).await?;
89 +
    let input = SettingInput {
90 +
        every: b.every,
91 +
        destination: b.destination,
92 +
        dir: b.dir,
93 +
        bucket: b.bucket,
94 +
        provider: b.provider,
95 +
        prefix: b.prefix,
96 +
        keep_days: b.keep_days.to_string(),
97 +
        s3_bucket: b.s3_bucket,
98 +
        s3_region: b.s3_region,
99 +
        s3_role: b.s3_role,
100 +
    };
101 +
    let s = setting_of(&app, &owner, kind, &input).map_err(|e| ApiError(StatusCode::BAD_REQUEST, e))?;
102 +
    backups::save(&app.db, &s).await?;
103 +
    db::accounts::audit(&app.db, Some(&me), "backup.setting", &owner, &format!("{} to {}", s.every, backups::place(&s))).await.ok();
104 +
    Ok(Json(state(&app, &owner).await?))
105 +
}
106 +
107 +
async fn test(app: Shared, headers: HeaderMap, org: Option<String>) -> Result<Json<serde_json::Value>, ApiError> {
108 +
    let c = caller(&app, &headers).await?;
109 +
    let (_, owner, _) = owner_of(&app, &c, org.as_deref()).await?;
110 +
    let set =
111 +
        backups::setting(&app.db, &owner).await?.ok_or_else(|| ApiError(StatusCode::CONFLICT, "save where backups go first".into()))?;
112 +
    let dest = backups::destination(&app, &set).map_err(|e| ApiError(StatusCode::CONFLICT, format!("{e:#}")))?;
113 +
    tokio::task::spawn_blocking(move || backups::probe(dest.as_ref()))
114 +
        .await
115 +
        .map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
116 +
        .map_err(|e| ApiError(StatusCode::CONFLICT, format!("TEST failed: {e:#}")))?;
117 +
    Ok(Json(serde_json::json!({ "ok": true, "place": backups::place(&set) })))
118 +
}
119 +
120 +
async fn run(app: Shared, headers: HeaderMap, org: Option<String>) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
121 +
    let c = caller(&app, &headers).await?;
122 +
    let (_, owner, _) = owner_of(&app, &c, org.as_deref()).await?;
123 +
    if backups::setting(&app.db, &owner).await?.is_none() {
124 +
        return Err(ApiError(StatusCode::CONFLICT, "save where backups go first".into()));
125 +
    }
126 +
    let (app2, owner2) = (app.clone(), owner.clone());
127 +
    tokio::spawn(async move {
128 +
        if let Err(e) = backups::run(&app2, &owner2, "now").await {
129 +
            tracing::warn!("backup of {owner2}: {e:#}");
130 +
        }
131 +
    });
132 +
    Ok((StatusCode::ACCEPTED, Json(serde_json::json!({ "owner": owner, "started": true }))))
133 +
}
134 +
135 +
async fn runs(app: Shared, headers: HeaderMap, org: Option<String>) -> Result<Json<Vec<Run>>, ApiError> {
136 +
    let c = caller(&app, &headers).await?;
137 +
    let (_, owner, _) = owner_of(&app, &c, org.as_deref()).await?;
138 +
    Ok(Json(backups::runs(&app.db, &owner, 50).await?))
139 +
}
140 +
141 +
pub async fn user_get(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<State_>, ApiError> {
142 +
    get(app, headers, None).await
143 +
}
144 +
pub async fn user_put(State(app): State<Shared>, headers: HeaderMap, Json(b): Json<Body>) -> Result<Json<State_>, ApiError> {
145 +
    put(app, headers, None, b).await
146 +
}
147 +
pub async fn user_test(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<serde_json::Value>, ApiError> {
148 +
    test(app, headers, None).await
149 +
}
150 +
pub async fn user_run(State(app): State<Shared>, headers: HeaderMap) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
151 +
    run(app, headers, None).await
152 +
}
153 +
pub async fn user_runs(State(app): State<Shared>, headers: HeaderMap) -> Result<Json<Vec<Run>>, ApiError> {
154 +
    runs(app, headers, None).await
155 +
}
156 +
pub async fn org_get(State(app): State<Shared>, headers: HeaderMap, Path(name): Path<String>) -> Result<Json<State_>, ApiError> {
157 +
    get(app, headers, Some(name)).await
158 +
}
159 +
pub async fn org_put(
160 +
    State(app): State<Shared>,
161 +
    headers: HeaderMap,
162 +
    Path(name): Path<String>,
163 +
    Json(b): Json<Body>,
164 +
) -> Result<Json<State_>, ApiError> {
165 +
    put(app, headers, Some(name), b).await
166 +
}
167 +
pub async fn org_test(
168 +
    State(app): State<Shared>,
169 +
    headers: HeaderMap,
170 +
    Path(name): Path<String>,
171 +
) -> Result<Json<serde_json::Value>, ApiError> {
172 +
    test(app, headers, Some(name)).await
173 +
}
174 +
pub async fn org_run(
175 +
    State(app): State<Shared>,
176 +
    headers: HeaderMap,
177 +
    Path(name): Path<String>,
178 +
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
179 +
    run(app, headers, Some(name)).await
180 +
}
181 +
pub async fn org_runs(State(app): State<Shared>, headers: HeaderMap, Path(name): Path<String>) -> Result<Json<Vec<Run>>, ApiError> {
182 +
    runs(app, headers, Some(name)).await
183 +
}
modified crates/hub/src/api/mod.rs +9 −0
…
14 unchanged lines
15 15
use hub_api::*;
16 16
use serde::Deserialize;
17 17
18 +
mod backups;
18 19
pub mod device;
19 20
mod images;
20 21
mod projects;
…
66 unchanged lines
87 88
        .route("/repos/{owner}/{name}/consumers", get(consumers))
88 89
        .route("/repos/{owner}/{name}/diffusion", get(diffusion).put(set_diffusion))
89 90
        .route("/repos/{owner}/{name}/backup", axum::routing::put(set_backup))
91 +
        .route("/user/backups", get(backups::user_get).put(backups::user_put))
92 +
        .route("/user/backups/test", post(backups::user_test))
93 +
        .route("/user/backups/run", post(backups::user_run))
94 +
        .route("/user/backups/runs", get(backups::user_runs))
95 +
        .route("/orgs/{name}/backups", get(backups::org_get).put(backups::org_put))
96 +
        .route("/orgs/{name}/backups/test", post(backups::org_test))
97 +
        .route("/orgs/{name}/backups/run", post(backups::org_run))
98 +
        .route("/orgs/{name}/backups/runs", get(backups::org_runs))
90 99
        .route("/repos/{owner}/{name}/history/{r}", get(history))
91 100
        .route("/repos/{owner}/{name}/changes/{hash}", get(change))
92 101
        .route("/repos/{owner}/{name}/tree/{r}", get(tree_root))
…
2456 unchanged lines
modified crates/hub/src/backups.rs +12 −8
…
157 unchanged lines
158 158
impl Overview {
159 159
    /// Where its runs go, as a person reads it.
160 160
    pub fn place(&self) -> String {
161 -
        let s = &self.setting;
162 -
        match s.destination.as_str() {
163 -
            "gcs" if s.prefix.is_empty() => format!("gs://{}", s.target),
164 -
            "gcs" => format!("gs://{}/{}", s.target, s.prefix),
165 -
            "s3" if s.prefix.is_empty() => format!("s3://{} ({})", s.target, s.region),
166 -
            "s3" => format!("s3://{}/{} ({})", s.target, s.prefix, s.region),
167 -
            _ => format!("{}/{}", s.target, s.owner),
168 -
        }
161 +
        place(&self.setting)
162 +
    }
163 +
}
164 +
165 +
/// Where a setting's runs go, as a person reads it.
166 +
pub fn place(s: &Setting) -> String {
167 +
    match s.destination.as_str() {
168 +
        "gcs" if s.prefix.is_empty() => format!("gs://{}", s.target),
169 +
        "gcs" => format!("gs://{}/{}", s.target, s.prefix),
170 +
        "s3" if s.prefix.is_empty() => format!("s3://{} ({})", s.target, s.region),
171 +
        "s3" => format!("s3://{}/{} ({})", s.target, s.prefix, s.region),
172 +
        _ => format!("{}/{}", s.target, s.owner),
169 173
    }
170 174
}
171 175
…
1581 unchanged lines
modified crates/hub/src/web/backups.rs +21 −13
…
58 unchanged lines
59 59
pub struct BackupForm {
60 60
    #[serde(rename = "_csrf")]
61 61
    csrf_token: String,
62 -
    every: String,
62 +
    #[serde(flatten)]
63 +
    input: SettingInput,
64 +
}
65 +
66 +
/// What a backup setting is made from, by the page's form or the API
67 +
/// (oxygenhub#179): checked the same way either way.
68 +
#[derive(Clone, Debug, Default, Deserialize)]
69 +
pub(crate) struct SettingInput {
70 +
    pub every: String,
63 71
    #[serde(default)]
64 -
    destination: String,
72 +
    pub destination: String,
65 73
    #[serde(default)]
66 -
    dir: String,
74 +
    pub dir: String,
67 75
    #[serde(default)]
68 -
    bucket: String,
76 +
    pub bucket: String,
69 77
    #[serde(default)]
70 -
    provider: String,
78 +
    pub provider: String,
71 79
    #[serde(default)]
72 -
    prefix: String,
80 +
    pub prefix: String,
73 81
    #[serde(default)]
74 -
    keep_days: String,
82 +
    pub keep_days: String,
75 83
    /// An S3 destination's bucket, region and the IAM role the hub assumes.
76 84
    #[serde(default)]
77 -
    s3_bucket: String,
85 +
    pub s3_bucket: String,
78 86
    #[serde(default)]
79 -
    s3_region: String,
87 +
    pub s3_region: String,
80 88
    #[serde(default)]
81 -
    s3_role: String,
89 +
    pub s3_role: String,
82 90
}
83 91
84 92
/// The form as a setting, or why it cannot be one.
85 -
fn setting_of(app: &Shared, owner: &str, kind: &str, f: &BackupForm) -> Result<Setting, String> {
93 +
pub(crate) fn setting_of(app: &Shared, owner: &str, kind: &str, f: &SettingInput) -> Result<Setting, String> {
86 94
    if !backups::EVERY.iter().any(|(v, _)| *v == f.every) {
87 95
        return Err("Pick how often: off, hourly or daily.".into());
88 96
    }
…
59 unchanged lines
148 156
}
149 157
150 158
async fn save(app: &Shared, shell: Shell, kind: &'static str, owner: &str, f: &BackupForm) -> Result<Page<BackupsPage>, Error> {
151 -
    match setting_of(app, owner, kind, f) {
159 +
    match setting_of(app, owner, kind, &f.input) {
152 160
        Ok(s) => {
153 161
            backups::save(&app.db, &s).await.map_err(|e| Error::from_db(shell.clone(), e))?;
154 162
            page(app, shell, kind, owner, Some("Saved.".into()), None).await
…
29 unchanged lines
184 192
    };
185 193
    match outcome {
186 194
        Ok(()) => {
187 -
            let place = if set.destination == "gcs" { format!("gs://{}", set.target) } else { format!("{}/{owner}", set.target) };
195 +
            let place = backups::place(&set);
188 196
            page(app, shell, kind, owner, Some(format!("TEST passed: wrote, read and removed a probe in {place}.")), None).await
189 197
        }
190 198
        Err(e) => page(app, shell, kind, owner, None, Some(format!("TEST failed: {e:#}"))).await,
…
97 unchanged lines
modified crates/hub/src/web/mod.rs +1 −1
…
5 unchanged lines
6 6
mod admin;
7 7
pub(crate) mod assistants;
8 8
mod avatar;
9 -
mod backups;
9 +
pub(crate) mod backups;
10 10
pub mod chain;
11 11
pub mod coverage;
12 12
mod device;
…
754 unchanged lines
modified crates/hub/tests/backups.rs +110 −0
…
763 unchanged lines
764 764
    assert_eq!(refused.status, "failed");
765 765
    assert!(refused.detail.contains("s3:PutObject") && refused.detail.contains(ROLE), "{}", refused.detail);
766 766
}
767 +
768 +
/// One API call with a bearer token: the status and the JSON answer.
769 +
async fn api(
770 +
    app: &Shared,
771 +
    method: &str,
772 +
    path: &str,
773 +
    token: &str,
774 +
    body: Option<serde_json::Value>,
775 +
) -> (axum::http::StatusCode, serde_json::Value) {
776 +
    use axum::body::Body;
777 +
    use http_body_util::BodyExt;
778 +
    use tower::ServiceExt;
779 +
    let mut req =
780 +
        axum::http::Request::builder().method(method).uri(format!("/api/v1{path}")).header("authorization", format!("Bearer {token}"));
781 +
    let body = match body {
782 +
        Some(b) => {
783 +
            req = req.header("content-type", "application/json");
784 +
            Body::from(b.to_string())
785 +
        }
786 +
        None => Body::empty(),
787 +
    };
788 +
    let res = oxygenhub::router(app.clone()).oneshot(req.body(body).unwrap()).await.unwrap();
789 +
    let st = res.status();
790 +
    let bytes = res.into_body().collect().await.unwrap().to_bytes();
791 +
    (st, serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null))
792 +
}
793 +
794 +
/// oxygenhub#179: the API does what the Backups pages do, for the caller's
795 +
/// own repositories and an organisation it owns, with repo:admin: the
796 +
/// setting (checked as the page checks it), TEST, back up now, the runs.
797 +
#[tokio::test(flavor = "multi_thread")]
798 +
async fn backups_are_set_up_and_run_through_the_api() {
799 +
    let store = tempfile::tempdir().unwrap();
800 +
    let h = hub(store.path()).await;
801 +
    let andy = db::accounts::create_account(&h.app.db, "andy", "[email protected]", "x", "salt", false).await.unwrap();
802 +
    let bob = db::accounts::create_account(&h.app.db, "bob", "[email protected]", "x", "salt", false).await.unwrap();
803 +
    db::accounts::create_org(&h.app.db, "club", "Club", andy.id).await.unwrap();
804 +
    create(&h.app, "andy", "demo", hub_api::Visibility::Public).await;
805 +
    create(&h.app, "club", "game", hub_api::Visibility::Private).await;
806 +
    let token = |id: i64, scopes: &'static str| {
807 +
        let db = h.app.db.clone();
808 +
        async move {
809 +
            let (t, hash) = oxygenhub::auth::new_token();
810 +
            db::accounts::create_token(&db, id, "t", &hash, scopes, "", None).await.unwrap();
811 +
            t
812 +
        }
813 +
    };
814 +
    let (andy_t, bob_t, weak) = (token(andy.id, "repo:admin").await, token(bob.id, "repo:admin").await, token(andy.id, "repo:read").await);
815 +
    let dir = store.path().display().to_string();
816 +
817 +
    let (st, empty) = api(&h.app, "GET", "/user/backups", &andy_t, None).await;
818 +
    assert_eq!(st, 200);
819 +
    assert!(empty["setting"].is_null() && empty["runs"].as_array().unwrap().is_empty(), "{empty}");
820 +
    let (st, _) = api(&h.app, "POST", "/user/backups/run", &andy_t, None).await;
821 +
    assert_eq!(st, 409, "nothing saved yet");
822 +
    let (st, _) =
823 +
        api(&h.app, "PUT", "/user/backups", &weak, Some(serde_json::json!({ "every": "hourly", "destination": "dir", "dir": dir }))).await;
824 +
    assert_eq!(st, 403, "repo:admin only");
825 +
    let (st, bad) = api(
826 +
        &h.app,
827 +
        "PUT",
828 +
        "/user/backups",
829 +
        &andy_t,
830 +
        Some(serde_json::json!({ "every": "hourly", "destination": "gcs", "bucket": "andy-backup", "provider": "nope" })),
831 +
    )
832 +
    .await;
833 +
    assert_eq!(st, 400, "checked as the page checks it: {bad}");
834 +
    let (st, saved) = api(
835 +
        &h.app,
836 +
        "PUT",
837 +
        "/user/backups",
838 +
        &andy_t,
839 +
        Some(serde_json::json!({ "every": "hourly", "destination": "dir", "dir": dir, "keep_days": 30 })),
840 +
    )
841 +
    .await;
842 +
    assert_eq!(st, 200, "{saved}");
843 +
    assert_eq!((saved["setting"]["every"].as_str(), saved["place"].as_str()), (Some("hourly"), Some(format!("{dir}/andy").as_str())));
844 +
    let (st, probe) = api(&h.app, "POST", "/user/backups/test", &andy_t, None).await;
845 +
    assert_eq!((st.as_u16(), probe["ok"].as_bool()), (200, Some(true)), "{probe}");
846 +
    let (st, started) = api(&h.app, "POST", "/user/backups/run", &andy_t, None).await;
847 +
    assert_eq!(st, 202, "{started}");
848 +
    let mut done = serde_json::Value::Null;
849 +
    for _ in 0..200 {
850 +
        let (_, runs) = api(&h.app, "GET", "/user/backups/runs", &andy_t, None).await;
851 +
        if runs[0]["status"].as_str().is_some_and(|s| s != "running") {
852 +
            done = runs[0].clone();
853 +
            break;
854 +
        }
855 +
        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
856 +
    }
857 +
    assert_eq!((done["status"].as_str(), done["repos"].as_i64(), done["trigger"].as_str()), (Some("ok"), Some(1), Some("now")), "{done}");
858 +
859 +
    // An organisation's, by its owner; another account sees no such thing.
860 +
    let (st, _) =
861 +
        api(&h.app, "PUT", "/orgs/club/backups", &bob_t, Some(serde_json::json!({ "every": "hourly", "destination": "dir", "dir": dir })))
862 +
            .await;
863 +
    assert_eq!(st, 404);
864 +
    let (st, org) = api(
865 +
        &h.app,
866 +
        "PUT",
867 +
        "/orgs/club/backups",
868 +
        &andy_t,
869 +
        Some(serde_json::json!({ "every": "daily", "destination": "dir", "dir": dir, "keep_days": 90 })),
870 +
    )
871 +
    .await;
872 +
    assert_eq!(st, 200, "{org}");
873 +
    assert_eq!((org["setting"]["kind"].as_str(), org["setting"]["keep_days"].as_i64()), (Some("org"), Some(90)));
874 +
    let (st, _) = api(&h.app, "GET", "/orgs/club/backups", &bob_t, None).await;
875 +
    assert_eq!(st, 404);
876 +
}
modified docs/backups.md +6 −2
…
125 unchanged lines
126 126
  run stays whole and restores everything. A run asked for by hand takes
127 127
  every repository not left out.
128 128
129 -
The API mirrors the pages: `GET/PUT /api/v1/user/backups`,
130 -
`GET/PUT /api/v1/orgs/{name}/backups`, the run endpoint above, and
129 +
The API mirrors the pages (built 2026-10-14, oxygenhub#179; scope
130 +
`repo:admin`, an organisation's owners only): `GET/PUT /api/v1/user/backups`
131 +
and `GET/PUT /api/v1/orgs/{name}/backups` (the setting, checked as the page
132 +
checks it, with where its runs go and the newest runs; the body takes the
133 +
page's fields, `keep_days` as a number), `POST …/backups/test` (the probe),
134 +
`POST …/backups/run` (202: back up now, in the background) and
131 135
`GET …/backups/runs`. `oh backup status|run|restore` follows.
132 136
133 137
## 6. Setting up a GCS destination (for Andy, by hand)
…
125 unchanged lines