Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

crates/gunnar-ui-server/src/api/forges.rs

//! **Connecting a forge**, which is the step before a mirror exists.
//!
//! Nothing in this module reaches gunnar. It talks to github.com, codeberg.org
//! or a GitLab over their own HTTPS REST APIs through `gunnar-forge`, because
//! "what repositories does this account have" is a question the git protocol
//! cannot express: git starts from a URL you already know and has no concept of
//! an account. [`super::mirrors`] is where the answer turns into `Mirror.Add`.
//!
//! # Two doors, and the plain one is first on purpose
//!
//! **Paste a token.** Works on every forge, self-hosted included, needs no app
//! registration, and needs nothing of the operator running this console. It is
//! the honest baseline: a person with a personal access token can import
//! repositories on a box that has never heard of OAuth.
//!
//! **OAuth.** Nicer, and conditional on somebody having registered an
//! application and given this process its client id and secret. A forge with no
//! application configured has **no button** — absent, not disabled, the same
//! shape the monetize routes and the guest identity use. A disabled button is a
//! promise printed at somebody who cannot act on it.
//!
//! Both doors end in the same place: an access token in
//! [`crate::forges::ForgeSessions`], server-side, keyed to the session, and
//! never in a response body.
//!
//! # Every network call here is blocking, and that is deliberate
//!
//! `gunnar-forge` is built on `ureq` — pure Rust, rustls, no async runtime
//! dragged into a crate whose job is "issue a few GETs and stop". So every call
//! goes through [`tokio::task::spawn_blocking`], and the token is moved into
//! that closure rather than borrowed across an await point. One HTTP client in
//! the tree, and the one that already has `tests/token_never_leaks.rs` behind
//! it.

use axum::extract::{Path, Query, State};
use axum::response::{IntoResponse, Redirect, Response};
use axum::Json;
use gunnar_forge::{ForgeKind, Repo};
use serde::{Deserialize, Serialize};

use crate::api::{ApiError, ApiResult, Importer};
use crate::app::AppState;
use crate::forges::{self, Connected};
use crate::grpc::Fail;

/// One repository as the picker shows it.
///
/// `gunnar_forge::Repo`'s fields, unchanged and unrenamed. The crate already
/// flattened three forges into one shape and picked these fields *for* a
/// picker; a second vocabulary here would mean a person comparing `gunnar
/// mirror import` in a terminal with this table has to work out which of ours
/// is which of its.
#[derive(Debug, Clone, Serialize)]
pub struct ForgeRepo {
    /// `github` | `codeberg` | `gitlab`.
    pub forge: &'static str,
    /// `owner/name`, whole — GitLab's can be `group/subgroup/project`, and
    /// truncating it produces a name that does not resolve.
    pub full_name: String,
    /// The name a mirror would take in a namespace: the LAST segment.
    pub name: String,
    pub clone_url_https: String,
    pub clone_url_ssh: String,
    pub default_branch: String,
    pub private: bool,
    pub archived: bool,
    /// `None` on GitLab, which reports nothing unless the caller is an
    /// administrator asking for statistics. A `0` would make every GitLab
    /// repository look empty in a table sorted by size, so the absence is
    /// spelled out.
    pub size_kib: Option<u64>,
    pub description: Option<String>,
}

impl From<&Repo> for ForgeRepo {
    fn from(r: &Repo) -> ForgeRepo {
        ForgeRepo {
            forge: r.forge.as_str(),
            full_name: r.full_name.clone(),
            name: r
                .full_name
                .rsplit('/')
                .next()
                .unwrap_or(&r.full_name)
                .to_owned(),
            clone_url_https: r.clone_url_https.clone(),
            clone_url_ssh: r.clone_url_ssh.clone(),
            default_branch: r.default_branch.clone(),
            private: r.private,
            archived: r.archived,
            size_kib: r.size_kib,
            description: r.description.clone(),
        }
    }
}

/// What `GET /api/forges` answers.
#[derive(Debug, Clone, Serialize)]
pub struct Forges {
    /// What this session has connected. **Never a token.**
    pub connected: Vec<Connected>,
    /// Which forges this console has an OAuth application for. The console
    /// renders exactly these buttons and no others.
    pub oauth: Vec<&'static str>,
    /// Every forge the token door works for, which is all of them.
    pub tokens: [&'static str; 3],
}

/// `GET /api/forges` — which forges are connected, and which doors exist.
pub async fn list(who: Importer, State(state): State<AppState>) -> ApiResult<Forges> {
    state.forge_apps.refuse_unless_on()?;
    Ok(Json(Forges {
        connected: state.forges.connected(&who.who),
        oauth: state.forge_apps.doors(),
        tokens: ["github", "codeberg", "gitlab"],
    }))
}

/// What the console sends to connect with a pasted token.
#[derive(Debug, Clone, Deserialize)]
pub struct NewConnection {
    /// `github` | `codeberg` | `gitlab`. `codeberg` also names every other
    /// Forgejo/Gitea, which is why a `base` can accompany it.
    pub forge: String,
    /// The site root of a self-hosted instance, e.g. `https://git.example.com`.
    /// Empty means the public one.
    #[serde(default)]
    pub base: String,
    /// The personal access token. It goes into
    /// [`crate::forges::ForgeSessions`] and comes back out of no route.
    #[serde(default)]
    pub token: String,
}

/// `POST /api/forges/connect` — the token door.
///
/// **The token is verified by using it.** There is no "check this token"
/// endpoint on any of the three forges, and inventing a cheaper probe would
/// mean a connection that succeeds and a picker that then fails. So the listing
/// IS the verification: a 401 comes straight back as the forge's own refusal
/// and nothing is held.
///
/// The response carries the connection summary and the repository count. It
/// carries no token, and there is no field on [`Connected`] one could arrive
/// in — `mirroring::the_token_is_in_no_response_body_and_in_no_log_line` drives
/// a distinctive token through this route and asserts the bytes appear in no
/// body this console sends and in no line it logs.
pub async fn connect(
    who: Importer,
    State(state): State<AppState>,
    Json(new): Json<NewConnection>,
) -> ApiResult<Connected> {
    state.forge_apps.refuse_unless_on()?;
    let kind = kind(&new.forge)?;
    let token = new.token.trim().to_owned();
    if token.is_empty() {
        return Err(ApiError(Fail {
            code: "invalidargument".to_owned(),
            message: "paste the personal access token you want this console to list with"
                .to_owned(),
        }));
    }
    let base = new.base.trim().trim_end_matches('/').to_owned();
    vet(&base, state.forge_apps.private_bases_allowed()).await?;
    let repos = fetch(kind, base.clone(), token.clone()).await?;
    let summary = state.forges.connect(
        &who.who,
        kind,
        base,
        forges::Granted::pasted(token),
        "token",
        repos,
    );
    tracing::info!(
        target: crate::auth::AUDIT_TARGET,
        event = "forge.connected",
        user = %who.who,
        forge = summary.forge,
        door = "token",
        account = %summary.account,
        repos = summary.repos,
        "a forge was connected"
    );
    Ok(Json(summary))
}

/// `DELETE /api/forges/{forge}` — forget the connection and the token with it.
pub async fn forget(
    who: Importer,
    State(state): State<AppState>,
    Path(forge): Path<String>,
) -> ApiResult<Forgotten> {
    state.forge_apps.refuse_unless_on()?;
    let kind = kind(&forge)?;
    let held = state.forges.forget(&who.who, kind);
    let gone = held.is_some();
    if let Some(granted) = held {
        // **And tell the forge, where the forge has somewhere to be told.**
        // Dropping the token made this console stop holding a credential; it
        // did nothing to the GRANT behind it, which stays live at the forge
        // until its own expiry — up to a year for a GitHub OAuth app — and
        // keeps appearing in the person's authorized-applications list as an
        // access they believe they revoked by pressing Disconnect here.
        //
        // Best effort, and after the local drop rather than before: the
        // console's promise is that it holds nothing, and that promise must not
        // depend on a third party's endpoint being up. A refusal is logged with
        // the forge's status and never with the token.
        let revoked = match state.forge_apps.get(kind) {
            Some(app) => revoke_at_forge(kind, app, granted).await,
            // A pasted personal access token, or a forge this console has no
            // application for. There is nothing to revoke with: the credential
            // is the person's own and the place to retire it is the forge's own
            // settings page.
            None => Revoked::NotOurs,
        };
        tracing::info!(
            target: crate::auth::AUDIT_TARGET,
            event = "forge.forgotten",
            user = %who.who,
            forge = kind.as_str(),
            revoked = revoked.as_str(),
            "a forge connection was dropped"
        );
    }
    // Forgetting a connection that is not there is a SUCCESS: the caller asked
    // for the console to hold nothing for that forge and it holds nothing.
    Ok(Json(Forgotten { forgotten: gone }))
}

/// What became of the grant at the forge when a connection was dropped.
///
/// Four outcomes and not a bool, because "we did not try" and "we tried and it
/// said no" are different facts about somebody's account and an operator
/// reading the audit line needs to know which.
#[derive(Debug, PartialEq, Eq)]
enum Revoked {
    /// The forge accepted the revocation.
    Yes,
    /// The forge was asked and refused, or could not be reached.
    Refused,
    /// This forge has no revocation endpoint this console knows.
    NoEndpoint,
    /// Not this console's grant to revoke: a pasted token, or no OAuth app.
    NotOurs,
}

impl Revoked {
    fn as_str(&self) -> &'static str {
        match self {
            Revoked::Yes => "yes",
            Revoked::Refused => "refused",
            Revoked::NoEndpoint => "no-endpoint",
            Revoked::NotOurs => "not-ours",
        }
    }
}

/// **Where a forge takes a revocation, and what it wants sent there.**
///
/// Pure and separate from the dialling, so the decision can be asserted without
/// a forge — and the decision is the part with the per-vendor knowledge in it:
///
/// * **GitLab** implements RFC 7009 at `<site>/oauth/revoke`: a form carrying
///   the client credentials and the token. The refresh token is sent when there
///   is one, because revoking a refresh token revokes the access tokens minted
///   from it and the reverse is not true.
/// * **GitHub** does not implement RFC 7009. It has
///   `DELETE /applications/{client_id}/grant`, authorised with the application's
///   OWN credentials as HTTP Basic, which deletes the whole grant — every token
///   and the authorization itself. That is the right verb here: this is
///   Disconnect, not "expire my current token".
/// * **Codeberg/Forgejo/Gitea** publishes no revocation endpoint. Saying so is
///   the honest answer; inventing a URL would produce a 404 that looks like a
///   refusal and an audit line that says the wrong thing.
fn revocation(kind: ForgeKind, app: &forges::OAuthApp, granted: &forges::Granted) -> Option<Call> {
    let site = site_root(&app.authorize_url);
    match kind {
        ForgeKind::GitLab => Some(Call::Form {
            url: format!("{site}/oauth/revoke"),
            fields: vec![
                ("client_id".to_owned(), app.client_id.clone()),
                ("client_secret".to_owned(), app.secret().to_owned()),
                (
                    "token".to_owned(),
                    match granted.refresh.is_empty() {
                        true => granted.token.clone(),
                        false => granted.refresh.clone(),
                    },
                ),
            ],
        }),
        ForgeKind::GitHub => Some(Call::DeleteGrant {
            url: format!(
                "https://api.github.com/applications/{}/grant",
                app.client_id
            ),
            basic: (app.client_id.clone(), app.secret().to_owned()),
            access_token: granted.token.clone(),
        }),
        _ => None,
    }
}

/// The two shapes a revocation takes, so one function can dial either.
#[derive(Debug, PartialEq, Eq)]
enum Call {
    /// RFC 7009: a form post.
    Form {
        url: String,
        fields: Vec<(String, String)>,
    },
    /// GitHub's: a DELETE with the app's own Basic credentials and the token in
    /// the body.
    DeleteGrant {
        url: String,
        basic: (String, String),
        access_token: String,
    },
}

/// Dial the revocation, if there is one to dial.
///
/// Thirty seconds like [`exchange`], on `spawn_blocking` for the same reason:
/// `ureq` is blocking and this is an async handler. Errors are swallowed into
/// [`Revoked::Refused`] and never rendered — `ureq`'s `Display` embeds the URL,
/// and these URLs carry a client secret in the form body.
async fn revoke_at_forge(
    kind: ForgeKind,
    app: &forges::OAuthApp,
    granted: forges::Granted,
) -> Revoked {
    let Some(call) = revocation(kind, app, &granted) else {
        return Revoked::NoEndpoint;
    };
    let done = tokio::task::spawn_blocking(move || {
        // ureq 3 (2026-10-02) with ureq 2's defaults: no proxy from the
        // environment and at most 5 redirects.
        let agent = ureq::Agent::config_builder()
            .timeout_global(Some(std::time::Duration::from_secs(30)))
            .proxy(None)
            .max_redirects(5)
            .build()
            .new_agent();
        match call {
            Call::Form { url, fields } => {
                let form: Vec<(&str, &str)> = fields
                    .iter()
                    .map(|(k, v)| (k.as_str(), v.as_str()))
                    .collect();
                agent.post(&url).send_form(form).is_ok()
            }
            Call::DeleteGrant {
                url,
                basic,
                access_token,
            } => {
                use base64::Engine as _;
                let credential = base64::engine::general_purpose::STANDARD
                    .encode(format!("{}:{}", basic.0, basic.1));
                // The body by hand rather than through ureq's `json` feature,
                // which this crate does not enable: it is one field, and a
                // feature flag turned on for one call site is a dependency
                // surface turned on for the whole build.
                let body = serde_json::json!({ "access_token": access_token }).to_string();
                // GitHub's grant revocation is a DELETE *with* a JSON body; ureq 3
                // only lets a DELETE carry one when asked to.
                agent
                    .delete(&url)
                    .header("Authorization", &format!("Basic {credential}"))
                    .header("Accept", "application/vnd.github+json")
                    .header("Content-Type", "application/json")
                    .force_send_body()
                    .send(&body)
                    .is_ok()
            }
        }
    })
    .await;
    match done {
        Ok(true) => Revoked::Yes,
        _ => Revoked::Refused,
    }
}

/// What a forget answered.
#[derive(Debug, Clone, Serialize)]
pub struct Forgotten {
    /// False when there was nothing to forget, which is not a failure.
    pub forgotten: bool,
}

/// What `GET /api/forges/{forge}/repos` answers.
#[derive(Debug, Clone, Serialize)]
pub struct Listing {
    pub connection: Connected,
    pub repos: Vec<ForgeRepo>,
}

/// `GET /api/forges/{forge}/repos` — the account's repositories.
///
/// Served from the listing the connection already made rather than re-asking
/// the forge, and that is not only a cache. **The import resolves a chosen
/// `full_name` against this same held list**, so the clone URL a mirror is
/// pointed at comes from the forge's own answer and never from the browser. A
/// browser that could name the URL could point a mirror at a host of its
/// choosing; `?refresh=true` re-asks the forge when a person has just made a
/// repository.
///
/// Archived repositories are in the answer, marked. Hiding them here would make
/// the toggle in the picker a second round trip, and a mirror of an archived
/// repository is often exactly what somebody wants.
///
/// There is no `?refresh=`. Re-listing would need the token *without taking
/// it*, and the whole design of [`crate::forges::ForgeSessions::take_token`] is
/// that there is no such accessor — a `token()` that handed out a copy would
/// leave the credential reachable for as long as the connection lived. A person
/// who has just made a repository upstream connects again, which is one paste
/// and re-lists as a side effect.
pub async fn repos(
    who: Importer,
    State(state): State<AppState>,
    Path(forge): Path<String>,
) -> ApiResult<Listing> {
    state.forge_apps.refuse_unless_on()?;
    let kind = kind(&forge)?;
    let (connection, held) = state
        .forges
        .listing(&who.who, kind)
        .ok_or_else(|| no_connection(kind))?;
    Ok(Json(Listing {
        connection,
        repos: held.iter().map(ForgeRepo::from).collect(),
    }))
}

// ───────────────────────────────────────────────────────────────────────────
// The OAuth door
// ───────────────────────────────────────────────────────────────────────────

/// `GET /api/forge/{forge}/authorize` — start the dance.
///
/// A 302 to the forge, carrying a `state` this process minted and is holding
/// against this session. The response is a redirect and not JSON because the
/// browser has to *navigate*: an XHR to github.com would be blocked by CORS and
/// would not show the person the approval screen, which is the entire point of
/// the flow.
pub async fn authorize(
    who: Importer,
    State(state): State<AppState>,
    Path(forge): Path<String>,
) -> Response {
    if let Err(off) = state.forge_apps.refuse_unless_on() {
        return off.into_response();
    }
    let kind = match kind(&forge) {
        Ok(kind) => kind,
        Err(e) => return e.into_response(),
    };
    let Some(app) = state.forge_apps.get(kind) else {
        // Not a 501 with an explanation: a console that has no application for
        // this forge renders no button for it, so reaching this at all means a
        // hand-typed URL.
        return ApiError(Fail {
            code: "unimplemented".to_owned(),
            message: format!(
                "this console has no OAuth application for {}. Paste a personal access token \
                 instead — it works on every forge.",
                kind.as_str()
            ),
        })
        .into_response();
    };
    let begun = state.forges.begin_oauth(&who.who, kind);
    let url = authorize_url(app, &state.forge_apps.redirect_uri(kind), &begun);
    Redirect::to(&url).into_response()
}

/// **The URL the browser is sent to, with the dance pinned to a secret this
/// process keeps.**
///
/// Pure, so the parameters can be asserted without a browser — which matters
/// because the two that were missing are invisible from every other angle: a
/// console with no PKCE looks and behaves exactly like one with it, right up
/// until an authorization code is intercepted.
///
/// `code_challenge` + `code_challenge_method=S256` (RFC 7636). A forge that
/// does not implement PKCE ignores both, which is why they can be sent
/// unconditionally to all three: an unknown query parameter on an authorize URL
/// is not an error anywhere, and the alternative — a per-forge switch — is a
/// list to keep in step with three vendors' release notes.
fn authorize_url(app: &forges::OAuthApp, redirect_uri: &str, begun: &forges::Begun) -> String {
    format!(
        "{}?client_id={}&redirect_uri={}&scope={}&state={}&response_type=code\
         &code_challenge={}&code_challenge_method=S256",
        app.authorize_url,
        forges::query_value(&app.client_id),
        forges::query_value(redirect_uri),
        forges::query_value(app.scope),
        forges::query_value(&begun.state),
        forges::query_value(&begun.challenge),
    )
}

/// What the forge sends back.
#[derive(Debug, Clone, Deserialize)]
pub struct Callback {
    #[serde(default)]
    pub code: String,
    #[serde(default)]
    pub state: String,
}

/// `GET /api/forge/{forge}/callback` — the way back.
///
/// **The `state` is checked before the code is exchanged for anything.** It was
/// minted by [`authorize`], is held server-side against this session and this
/// forge, is single-use, and expires in ten minutes. A callback carrying one
/// this process did not mint — the cross-site request forgery this parameter
/// exists for — is refused here, and the refusal costs the forge nothing
/// because no exchange happens.
///
/// The exchange itself is the BFF's: the client secret is in this process and
/// the access token comes back to this process. Neither ever reaches the
/// browser, which is the whole reason the authorization-code flow is the one
/// worth implementing rather than the implicit one.
///
/// It answers with a redirect rather than JSON because the browser arrived here
/// by navigating. Failures redirect too, carrying a short word from a closed
/// set — never the forge's message, which would be putting somebody else's text
/// into this console's address bar and from there into history.
pub async fn callback(
    who: Importer,
    State(state): State<AppState>,
    Path(forge): Path<String>,
    Query(back): Query<Callback>,
) -> Response {
    if let Err(off) = state.forge_apps.refuse_unless_on() {
        return off.into_response();
    }
    let kind = match kind(&forge) {
        Ok(kind) => kind,
        Err(e) => return e.into_response(),
    };
    let Some(app) = state.forge_apps.get(kind) else {
        return landing(kind, Some("no-app"));
    };
    let verifier = match back.state.is_empty() {
        true => None,
        false => state.forges.redeem_oauth(&who.who, kind, &back.state),
    };
    let Some(verifier) = verifier else {
        tracing::warn!(
            target: crate::auth::AUDIT_TARGET,
            event = "forge.oauth_state_refused",
            user = %who.who,
            forge = kind.as_str(),
            "an OAuth callback arrived with a state this console did not mint, or one already \
             used; no code was exchanged"
        );
        return landing(kind, Some("bad-state"));
    };
    if back.code.is_empty() {
        return landing(kind, Some("no-code"));
    }

    let exchange = exchange(
        app.token_url.clone(),
        app.client_id.clone(),
        app.secret().to_owned(),
        back.code.clone(),
        state.forge_apps.redirect_uri(kind),
        verifier,
    )
    .await;
    let granted = match exchange {
        Ok(granted) => granted,
        Err(word) => return landing(kind, Some(word)),
    };

    // A self-hosted instance's base is the one its application was configured
    // with, which `authorize_url` already carries. Derived from there and never
    // from a query parameter: a base the browser could set is a base that
    // decides which host this console sends the token it just obtained to.
    let base = site_root(&app.authorize_url);
    match fetch(kind, base.clone(), granted.token.clone()).await {
        Ok(repos) => {
            let summary = state
                .forges
                .connect(&who.who, kind, base, granted, "oauth", repos);
            tracing::info!(
                target: crate::auth::AUDIT_TARGET,
                event = "forge.connected",
                user = %who.who,
                forge = summary.forge,
                door = "oauth",
                account = %summary.account,
                repos = summary.repos,
                "a forge was connected"
            );
            landing(kind, None)
        }
        Err(_) => landing(kind, Some("listing-refused")),
    }
}

/// Where the browser lands after the dance, with a word for what happened.
///
/// The word comes from a closed set and never from a forge: putting somebody
/// else's message into this console's address bar puts it into history and into
/// every referrer.
fn landing(kind: ForgeKind, problem: Option<&str>) -> Response {
    let to = match problem {
        None => format!("/mirrors/import?forge={}", kind.as_str()),
        Some(word) => format!("/mirrors/import?forge={}&problem={word}", kind.as_str()),
    };
    Redirect::to(&to).into_response()
}

/// The site root an authorize URL belongs to: `https://host`.
fn site_root(authorize_url: &str) -> String {
    let (scheme, rest) = match authorize_url.split_once("://") {
        Some(split) => split,
        None => return String::new(),
    };
    let host = rest.split('/').next().unwrap_or_default();
    let root = format!("{scheme}://{host}");
    // github.com is `Forge::github()`'s business, not a base: the public API is
    // at api.github.com and only the enterprise constructor takes a URL.
    if root == "https://github.com"
        || root == "https://gitlab.com"
        || root == "https://codeberg.org"
    {
        String::new()
    } else {
        root
    }
}

/// **Exchange an authorization code for an access token.**
///
/// One implementation for all three forges, because they agree about this part:
/// a form-encoded POST with `Accept: application/json`, answered with an object
/// carrying `access_token`. GitHub ignores `grant_type`, GitLab and Forgejo
/// require it, so it is always sent.
///
/// Errors come back as a **word**, never as the forge's text: this value ends
/// up in a redirect, and a redirect is an address bar.
async fn exchange(
    token_url: String,
    client_id: String,
    client_secret: String,
    code: String,
    redirect_uri: String,
    verifier: String,
) -> Result<forges::Granted, &'static str> {
    let answer = tokio::task::spawn_blocking(move || {
        // ureq 3 (2026-10-02) with ureq 2's defaults: no proxy from the
        // environment and at most 5 redirects.
        ureq::Agent::config_builder()
            .timeout_global(Some(std::time::Duration::from_secs(30)))
            .proxy(None)
            .max_redirects(5)
            .build()
            .new_agent()
            .post(&token_url)
            .header("Accept", "application/json")
            .send_form([
                ("grant_type", "authorization_code"),
                ("client_id", &client_id),
                ("client_secret", &client_secret),
                ("code", &code),
                ("redirect_uri", &redirect_uri),
                // The other half of the PKCE pair. The forge hashes it and
                // compares against the `code_challenge` it pinned at
                // `authorize`; a code lifted out of a redirect chain by
                // somebody who does not have this string cannot be spent.
                ("code_verifier", &verifier),
            ])
            // The body, and NOT the error: ureq's own `Display` for a transport
            // failure embeds the URL, and this URL had a client secret in its
            // form body a moment ago. The same rule `gunnar_forge::error`
            // states, restated because this is the one HTTP call that crate
            // does not make for us.
            .map_err(|_| "exchange-failed")
            .and_then(|r| r.into_body().read_to_string().map_err(|_| "exchange-unreadable"))
    })
    .await
    .map_err(|_| "exchange-failed")??;

    parse_granted(&answer)
}

/// **What the token endpoint said, all three parts of it.**
///
/// Separate and pure so it can be asserted without a forge. Two of the three
/// fields were being parsed past and dropped:
///
/// * `refresh_token` — the other half of the credential this console was given.
///   Dropping it left a live grant at the forge that nothing here could name,
///   which is why `forget` could not revoke anything.
/// * `expires_in` — the only thing that distinguishes a live access token from
///   a dead one. Without it the console held an hour-old GitLab token as though
///   it were a GitHub classic PAT, handed it to gunnar, and the mirror failed
///   later with an "unauthorized" that could equally have meant a revoked
///   application or a deleted account.
///
/// A forge that answers 200 with `{"error":"bad_verification_code"}` — which
/// GitHub does — has no `access_token` and lands on `exchange-refused`. The
/// word is ours; the forge's own text is deliberately dropped rather than
/// carried into a redirect.
fn parse_granted(body: &str) -> Result<forges::Granted, &'static str> {
    let parsed: serde_json::Value =
        serde_json::from_str(body).map_err(|_| "exchange-unreadable")?;
    let token = parsed
        .get("access_token")
        .and_then(serde_json::Value::as_str)
        .filter(|t| !t.is_empty())
        .ok_or("exchange-refused")?
        .to_owned();
    Ok(forges::Granted {
        token,
        refresh: parsed
            .get("refresh_token")
            .and_then(serde_json::Value::as_str)
            .unwrap_or_default()
            .to_owned(),
        // A number, or a string of digits: GitLab sends the first and some
        // Gitea builds send the second, and a console that understood only one
        // of them would silently treat the other as "never expires".
        expires_in_secs: parsed.get("expires_in").and_then(|v| {
            v.as_u64()
                .or_else(|| v.as_str().and_then(|s| s.parse().ok()))
        }),
    })
}

// ───────────────────────────────────────────────────────────────────────────
// Shared
// ───────────────────────────────────────────────────────────────────────────

// ───────────────────────────────────────────────────────────────────────────
// Where a self-hosted forge is allowed to be
// ───────────────────────────────────────────────────────────────────────────

/// **The one address in this module a BROWSER chooses, and it is dialled by
/// the server.**
///
/// `NewConnection.base` is the site root of a self-hosted instance, and
/// [`forges::forge_at`] hands it straight to `gunnar-forge`, which issues a GET
/// from inside whatever network this console runs on. Unchecked, that is a
/// read-side SSRF with the answer returned to the caller: `http://169.254.169.254`
/// is a cloud instance's credential endpoint, `http://127.0.0.1:9000` is
/// whatever else the box is running, and `10.0.0.0/8` is the rest of the
/// estate. The route needs admin, which is why the finding is Medel and not
/// higher — but an admin of the CONSOLE is not the same person as an
/// administrator of the network it sits in, and "connect your Forgejo" is not a
/// request to probe the datacentre.
///
/// Two checks, and they answer different questions:
///
/// * **the syntax** ([`parse_base`]) — https only, a host and nothing
///   pretending to be one, no userinfo smuggled into the authority;
/// * **the address** — every address the name resolves to must be public.
///
/// `allow_private` is [`crate::forges::ForgeApps::private_bases_allowed`], and
/// it turns BOTH off: "our Forgejo is at `https://git.internal`" is a real
/// deployment, so there is a named, deployment-wide way to say so
/// (`GUNNAR_UI_FORGE_ALLOW_PRIVATE_BASE`). It is off unless an operator asked,
/// and turning it on is turning this guard off.
///
/// # What this does NOT close, said out loud
///
/// The name is resolved here and resolved AGAIN by `ureq` when it dials, so a
/// DNS answer that changes between the two — a rebinding record with a
/// one-second TTL — passes this check and connects somewhere else. Closing that
/// needs the resolved address to be the thing dialled, which means a custom
/// connector inside `gunnar-forge`, not a check in front of it. This raises the
/// cost from "type a URL" to "run a DNS server", which is worth having; it is
/// not a wall.
async fn vet(base: &str, allow_private: bool) -> Result<(), ApiError> {
    if base.is_empty() {
        // The public instance. No caller-named host is dialled at all.
        return Ok(());
    }
    let (host, port) = parse_base(base, allow_private)?;
    // `ToSocketAddrs` is the platform resolver and it BLOCKS, which is the same
    // reason every other call in this module goes through `spawn_blocking`.
    let named = host.clone();
    let resolved = tokio::task::spawn_blocking(move || {
        use std::net::ToSocketAddrs as _;
        (named.as_str(), port)
            .to_socket_addrs()
            .map(|addrs| addrs.map(|a| a.ip()).collect::<Vec<_>>())
    })
    .await
    .map_err(|_| {
        refuse(
            "internal",
            "the console could not resolve that host".to_owned(),
        )
    })?
    .map_err(|_| {
        refuse(
            "invalidargument",
            format!("{host} does not resolve to an address from this console"),
        )
    })?;

    if resolved.is_empty() {
        return Err(refuse(
            "invalidargument",
            format!("{host} resolves to no address at all"),
        ));
    }
    // EVERY address, not the first: a name with one public A record and one
    // private one is the cheapest way past a check that stops at the first.
    for address in &resolved {
        if !is_public(*address) && !allow_private {
            return Err(refuse(
                "invalidargument",
                format!(
                    "{host} resolves to {address}, which is a private, loopback or link-local \
                     address. This console will not fetch from inside its own network on a URL \
                     a browser chose."
                ),
            ));
        }
    }
    Ok(())
}

/// The host and port of a base URL, or the refusal that says what was wrong.
///
/// Pure, so the whole table below is testable without a network.
fn parse_base(base: &str, allow_private: bool) -> Result<(String, u16), ApiError> {
    let rest =
        match base.split_once("://") {
            Some(("https", rest)) => rest,
            // Plain http only where the deployment has already said it may dial
            // its own network: that is the "our Forgejo is on the LAN" case and
            // it is the same named decision, not a second one.
            Some(("http", rest)) if allow_private => rest,
            // Refused rather than upgraded. A personal access token travels on this
            // connection, and a console that quietly rewrote `http` to `https`
            // would be answering a question the operator did not ask; one that
            // accepted `http` would put the token on the wire in clear.
            Some(("http", _)) => return Err(refuse(
                "invalidargument",
                "a forge base must be https://. The token you paste travels on this connection, \
                 and http:// puts it on the wire in clear."
                    .to_owned(),
            )),
            Some((scheme, _)) => {
                return Err(refuse(
                    "invalidargument",
                    format!("{scheme}:// is not a scheme this console will dial; use https://"),
                ))
            }
            None => {
                return Err(refuse(
                    "invalidargument",
                    format!(
                        "{base:?} is not a URL. A self-hosted forge's base is its site root, \
                         e.g. https://git.example.com"
                    ),
                ))
            }
        };
    let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
    if authority.is_empty() {
        return Err(refuse(
            "invalidargument",
            "that base has no host in it".to_owned(),
        ));
    }
    // Userinfo in a base is a credential going somewhere this console did not
    // put it, and it is also how `https://github.com@evil.test/` is spelled.
    if authority.contains('@') {
        return Err(refuse(
            "invalidargument",
            "a forge base carries no user name or password: paste the token in the token field"
                .to_owned(),
        ));
    }
    let (host, port) = split_host_port(authority)?;
    // Names that are not on the public internet by definition. `.local` is
    // mDNS, `.internal` is what cloud providers hand out inside a VPC, and
    // `.home.arpa` is the home-network name RFC 8375 reserved.
    let lower = host.to_ascii_lowercase();
    let private_name = lower == "localhost"
        || [
            ".localhost",
            ".local",
            ".internal",
            ".home.arpa",
            ".localdomain",
        ]
        .iter()
        .any(|suffix| lower.ends_with(suffix));
    if private_name && !allow_private {
        return Err(refuse(
            "invalidargument",
            format!(
                "{host} is a name inside this machine or its network, not a forge on the \
                     internet"
            ),
        ));
    }
    Ok((host, port))
}

/// `host`, `host:port`, `[::1]` or `[::1]:443`.
fn split_host_port(authority: &str) -> Result<(String, u16), ApiError> {
    let bad = |what: &str| {
        refuse(
            "invalidargument",
            format!("{authority:?} is not a host this console can dial: {what}"),
        )
    };
    if let Some(rest) = authority.strip_prefix('[') {
        let (inside, after) = rest.split_once(']').ok_or_else(|| bad("unclosed ["))?;
        let port = match after {
            "" => 443,
            p => p
                .strip_prefix(':')
                .and_then(|p| p.parse().ok())
                .ok_or_else(|| bad("that is not a port"))?,
        };
        return Ok((inside.to_owned(), port));
    }
    match authority.rsplit_once(':') {
        Some((host, port)) => {
            let port = port.parse().map_err(|_| bad("that is not a port"))?;
            Ok((host.to_owned(), port))
        }
        None => Ok((authority.to_owned(), 443)),
    }
}

/// **Is this an address on the public internet?**
///
/// Written out rather than taken from `IpAddr::is_global`, which is still
/// unstable. Erring towards "no" is the whole point: an address family this
/// console has never heard of is not a forge.
fn is_public(address: std::net::IpAddr) -> bool {
    use std::net::IpAddr;
    match address {
        IpAddr::V4(v4) => {
            let [a, b, ..] = v4.octets();
            !(v4.is_private()             // 10/8, 172.16/12, 192.168/16
                || v4.is_loopback()           // 127/8
                || v4.is_link_local()         // 169.254/16 — the metadata address
                || v4.is_broadcast()
                || v4.is_documentation()
                || v4.is_unspecified()
                || v4.is_multicast()
                || (a == 100 && (64..128).contains(&b))   // 100.64/10, carrier NAT
                || (a == 192 && b == 0)                   // 192.0.0/24, protocol assignments
                || (a == 198 && (18..20).contains(&b))    // 198.18/15, benchmarking
                || a >= 240) // 240/4, reserved
        }
        // An IPv4 address wearing an IPv6 hat is still that address, and
        // `::ffff:127.0.0.1` is the oldest way past a check that forgets it.
        IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
            Some(v4) => is_public(IpAddr::V4(v4)),
            None => {
                let first = v6.segments()[0];
                !(v6.is_loopback()
                    || v6.is_unspecified()
                    || v6.is_multicast()
                    || (first & 0xfe00) == 0xfc00      // fc00::/7, unique local
                    || (first & 0xffc0) == 0xfe80) // fe80::/10, link local
            }
        },
    }
}

fn refuse(code: &str, message: String) -> ApiError {
    ApiError(Fail {
        code: code.to_owned(),
        message,
    })
}

/// Parse a forge name, or refuse by name.
fn kind(name: &str) -> Result<ForgeKind, ApiError> {
    forges::kind_from_name(name.trim()).ok_or_else(|| {
        ApiError(Fail {
            code: "invalidargument".to_owned(),
            message: format!(
                "{name:?} is not a forge this console knows. It knows github, codeberg (and \
                 every other Forgejo/Gitea) and gitlab."
            ),
        })
    })
}

/// The refusal for a forge that was never connected.
fn no_connection(kind: ForgeKind) -> ApiError {
    ApiError(Fail {
        code: "notfound".to_owned(),
        message: format!(
            "this console is not holding a {} connection for you. Connect one — paste a token, \
             or use the button if this console has an application for it.",
            kind.as_str()
        ),
    })
}

/// List an account's repositories, off the async runtime.
///
/// `gunnar-forge` is blocking, so this is the seam. The token is **moved** into
/// the closure rather than borrowed: there is no await point it could be held
/// across, and no way for it to end up captured by a future that outlives the
/// call.
async fn fetch(kind: ForgeKind, base: String, token: String) -> Result<Vec<Repo>, ApiError> {
    tokio::task::spawn_blocking(move || forges::forge_at(kind, &base).list_repos(&token))
        .await
        .map_err(|_| {
            ApiError(Fail {
                code: "internal".to_owned(),
                message: "the console could not run the forge listing".to_owned(),
            })
        })?
        .map_err(|e| ApiError(forges::fail(&e)))
}

#[cfg(test)]
mod tests {
    use super::*;

    // ── Where a self-hosted forge may be (GUI-007) ──────────────────────────

    /// ★ **The console will not be pointed at its own network.**
    ///
    /// **Seen red.** Before [`vet`], `connect` took `base` exactly as the body
    /// spelled it and handed it to `forges::forge_at`, which dials. Every line
    /// below reached `ureq` and came back to the caller as either a forge
    /// listing or an error carrying what the address said — a read-side SSRF
    /// with the answer returned. `169.254.169.254` is the row that matters
    /// most: on every cloud this could ever run on, that address hands out
    /// credentials.
    #[tokio::test]
    async fn a_base_inside_the_network_is_refused_before_anything_is_dialled() {
        for base in [
            "https://127.0.0.1",
            "https://127.0.0.1:9000",
            "https://169.254.169.254",
            "https://10.1.2.3",
            "https://192.168.1.1",
            "https://172.16.0.5",
            "https://100.64.0.1",
            "https://[::1]",
            "https://[::1]:8443",
            // The oldest way past a check that forgets IPv4-mapped addresses.
            "https://[::ffff:127.0.0.1]",
            "https://localhost:3000",
            "https://forge.internal",
            "https://nas.local",
        ] {
            let refused = vet(base, false)
                .await
                .err()
                .unwrap_or_else(|| panic!("{base} was accepted"));
            assert_eq!(
                refused.0.http_status(),
                http::StatusCode::BAD_REQUEST,
                "{base}: {}",
                refused.0.message
            );
        }
    }

    /// The other half of the same gate: a scheme that is not https, and a
    /// userinfo smuggled into the authority.
    #[test]
    fn only_https_is_dialled_and_a_base_carries_no_credential() {
        for (base, expected) in [
            ("http://git.example.com", "in clear"),
            ("file:///etc/passwd", "not a scheme"),
            ("gopher://git.example.com", "not a scheme"),
            ("git.example.com", "not a URL"),
            // `https://github.com@evil.test/` reads as github.com to a human
            // and dials evil.test.
            ("https://github.com@evil.test", "no user name or password"),
        ] {
            let refused = parse_base(base, false)
                .err()
                .unwrap_or_else(|| panic!("{base} was accepted"));
            assert!(
                refused.0.message.contains(expected),
                "{base} was refused for the wrong reason: {}",
                refused.0.message
            );
        }
    }

    /// And an ordinary self-hosted forge still parses, port and all. A gate
    /// that refuses everything is the identity-value trap.
    #[test]
    fn an_ordinary_self_hosted_base_still_parses() {
        assert_eq!(
            parse_base("https://git.example.com", false).expect("accepted"),
            ("git.example.com".to_owned(), 443)
        );
        assert_eq!(
            parse_base("https://git.example.com:8443/gitlab", false).expect("accepted"),
            ("git.example.com".to_owned(), 8443)
        );
        assert_eq!(
            parse_base("https://[2606:4700::1111]:8443", false).expect("accepted"),
            ("2606:4700::1111".to_owned(), 8443)
        );
    }

    /// ★ **And the deployment that really is on a private network can say so,
    /// once, by name.**
    ///
    /// The guard would otherwise refuse the honest case — "our Forgejo is at
    /// `https://git.internal`" — and a guard that refuses what the feature is
    /// for gets turned off wholesale by the first person it inconveniences.
    /// `GUNNAR_UI_FORGE_ALLOW_PRIVATE_BASE` is that switch, it is a deployment
    /// decision rather than a per-request one, and turning it on turns this
    /// guard off. `crate::mirroring`'s harness is its other user: the forge
    /// there is a mock server on loopback.
    #[tokio::test]
    async fn a_deployment_that_says_so_may_dial_its_own_network() {
        assert!(vet("https://127.0.0.1:9000", true).await.is_ok());
        assert_eq!(
            parse_base("http://192.168.1.10:3000", true).expect("accepted"),
            ("192.168.1.10".to_owned(), 3000),
            "the LAN case is plain http as often as not, and it is the same decision"
        );
        // …and it is OFF by default, which is the half that matters.
        assert!(!crate::forges::ForgeApps::default().private_bases_allowed());
    }

    /// The empty base is the public instance and dials no caller-named host at
    /// all, so it is not gated — and must not be, or the ordinary
    /// github.com/codeberg.org/gitlab.com connection would need DNS to work.
    #[tokio::test]
    async fn the_public_instance_needs_no_vetting() {
        assert!(vet("", false).await.is_ok());
    }

    #[test]
    fn a_real_forges_address_is_public() {
        use std::net::IpAddr;
        // github.com and codeberg.org as they resolved on 2026-09-11, so the
        // predicate is proved to say YES to something as well as NO.
        for public in ["140.82.121.4", "217.197.91.145", "2606:4700::6810:85e5"] {
            assert!(
                is_public(public.parse::<IpAddr>().expect("an address")),
                "{public} was called private"
            );
        }
    }

    #[test]
    fn a_repositorys_mirror_name_is_its_last_segment() {
        // GitLab's `path_with_namespace` can be `group/subgroup/project`, and a
        // mirror takes a namespace of its own, so the name is the leaf.
        let deep = ForgeRepo::from(&Repo {
            forge: ForgeKind::GitLab,
            full_name: "group/subgroup/project".to_owned(),
            clone_url_https: "https://gitlab.com/group/subgroup/project.git".to_owned(),
            clone_url_ssh: "git@gitlab.com:group/subgroup/project.git".to_owned(),
            default_branch: "main".to_owned(),
            private: true,
            archived: false,
            size_kib: None,
            description: None,
        });

        assert_eq!(deep.full_name, "group/subgroup/project");
        assert_eq!(deep.name, "project");
        assert_eq!(deep.size_kib, None, "GitLab reports no size; 0 would lie");
    }

    #[test]
    fn a_public_instances_authorize_url_yields_no_base() {
        // `Forge::github()` and `Forge::github_enterprise()` are the whole
        // difference, and it is decided by whether there is a base at all.
        assert_eq!(site_root("https://github.com/login/oauth/authorize"), "");
        assert_eq!(site_root("https://codeberg.org/login/oauth/authorize"), "");
        assert_eq!(
            site_root("https://git.example.com/login/oauth/authorize"),
            "https://git.example.com"
        );
    }

    fn an_app() -> forges::OAuthApp {
        forges::OAuthApp::at(
            ForgeKind::GitHub,
            "Iv1.the-client",
            "the-client-secret",
            "https://github.com/login/oauth/authorize",
            "https://github.com/login/oauth/access_token",
            "repo",
        )
    }

    /// ★ **The authorize URL pins the dance to a secret the browser never
    /// sees.**
    ///
    /// Without PKCE an authorization code is a bearer credential for the length
    /// of one exchange, and it travels where the browser travels: an address
    /// bar, a history entry, a `Referer`, a proxy log, an operating system's
    /// URL handler. `state` does not help — it proves the console began the
    /// dance, not that the party finishing it is the one that began it.
    ///
    /// So `code_challenge=S256(verifier)` goes out with the redirect and the
    /// verifier stays here. The challenge is asserted to BE the hash of what
    /// the store kept, because a challenge that is merely present and unrelated
    /// would pass every other test and protect nothing.
    ///
    /// Red before: the URL carried `client_id`, `redirect_uri`, `scope`,
    /// `state` and `response_type` — and neither PKCE parameter.
    #[test]
    fn the_authorize_url_carries_a_pkce_challenge_that_matches_the_kept_verifier() {
        let sessions = forges::ForgeSessions::new();
        let begun = sessions.begin_oauth("alice", ForgeKind::GitHub);
        let url = authorize_url(
            &an_app(),
            "https://console.example/api/forge/github/callback",
            &begun,
        );

        assert!(
            url.contains("code_challenge_method=S256"),
            "the plain method puts the verifier in the address bar it is protecting: {url}"
        );
        assert!(
            url.contains(&format!(
                "code_challenge={}",
                forges::query_value(&begun.challenge)
            )),
            "{url}"
        );
        // The verifier itself is NOT in the URL — which is the whole point.
        let verifier = sessions
            .redeem_oauth("alice", ForgeKind::GitHub, &begun.state)
            .expect("the state redeems");
        assert!(
            !url.contains(&verifier),
            "the verifier travelled with the browser, so it protects nothing: {url}"
        );
        assert_eq!(
            forges::code_challenge(&verifier),
            begun.challenge,
            "the challenge is not the hash of the verifier that was kept, so the forge \
             would refuse every exchange"
        );
        assert!(
            !verifier.is_empty() && verifier.len() >= 43,
            "RFC 7636 wants 43-128 characters of entropy, and got {}",
            verifier.len()
        );
    }

    /// ★ **The token response is read whole: the refresh token and the expiry
    /// too.**
    ///
    /// Both were parsed past. The expiry is what tells a live access token from
    /// a dead one — without it a one-hour GitLab token was held like a GitHub
    /// classic PAT and handed to gunnar hours later, and the mirror failed with
    /// an "unauthorized" that could equally have been a revoked app. The
    /// refresh token is the half that names the grant; dropping it is why
    /// Disconnect could not revoke anything.
    ///
    /// Red before: `exchange` returned a bare `String` and there was nothing to
    /// assert either field on.
    #[test]
    fn a_token_response_is_read_whole_and_not_just_its_access_token() {
        let granted = parse_granted(
            r#"{"access_token":"gho_live","refresh_token":"ghr_later","expires_in":3600}"#,
        )
        .expect("a well-formed token response");
        assert_eq!(granted.token, "gho_live");
        assert_eq!(granted.refresh, "ghr_later");
        assert_eq!(granted.expires_in_secs, Some(3600));

        // Some Gitea builds send the number as a string. A console that read
        // only one of the two spellings would treat the other as "never
        // expires", which is the failure this exists to prevent.
        let as_text = parse_granted(r#"{"access_token":"t","expires_in":"7200"}"#)
            .expect("a string expiry is still an expiry");
        assert_eq!(as_text.expires_in_secs, Some(7200));

        // And no expiry stated is not expiry. A GitHub classic PAT has none.
        let forever = parse_granted(r#"{"access_token":"ghp_classic"}"#).expect("no expiry");
        assert_eq!(forever.expires_in_secs, None);
        assert_eq!(forever.refresh, "");

        // A 200 that is really a refusal, which is what GitHub sends.
        assert_eq!(
            parse_granted(r#"{"error":"bad_verification_code"}"#).unwrap_err(),
            "exchange-refused"
        );
    }

    /// ★ **An expired access token is not handed onward.**
    ///
    /// `has_token` is what the panel renders and what the import path asks
    /// before it takes the credential. Reading it as "a token is present"
    /// rather than "a token that would work is present" is how a dead
    /// credential got sealed into gunnar by `Mirror.SetCredential`, to fail at
    /// the next sync with a message naming none of the several possible causes.
    #[test]
    fn an_access_token_past_its_stated_life_is_not_a_token_any_more() {
        let sessions = forges::ForgeSessions::new();
        let summary = sessions.connect(
            "alice",
            ForgeKind::GitHub,
            String::new(),
            forges::Granted {
                token: "gho_live".to_owned(),
                refresh: String::new(),
                // Already over by the time it is stored.
                expires_in_secs: Some(0),
            },
            "oauth",
            Vec::new(),
        );
        assert!(
            !summary.holds_token,
            "a connection whose token expired still reported that it held one"
        );

        let live = sessions.connect(
            "alice",
            ForgeKind::GitLab,
            String::new(),
            forges::Granted::pasted("glpat_no_expiry".to_owned()),
            "token",
            Vec::new(),
        );
        assert!(
            live.holds_token,
            "a token with no stated lifetime was treated as expired, which would break \
             every pasted personal access token"
        );
    }

    /// ★ **Disconnect tells the forge, where the forge has somewhere to be
    /// told.**
    ///
    /// Dropping the token made the console hold nothing; it left the GRANT live
    /// at the forge — up to a year for a GitHub OAuth app — and still listed
    /// among the person's authorized applications as an access they believe
    /// they revoked.
    ///
    /// Asserted on the request that would be sent rather than on a forge
    /// answering it: the per-vendor knowledge is the decision, and the two
    /// vendors disagree about everything except that there is one. GitHub
    /// deletes the whole grant with the application's own credentials; GitLab
    /// implements RFC 7009 and is given the REFRESH token, because revoking a
    /// refresh token revokes what was minted from it and the reverse is not
    /// true.
    ///
    /// Red before: `ForgeSessions::forget` answered a bool and dropped the
    /// credential, so there was nothing left to revoke with and no function to
    /// assert on.
    #[test]
    fn disconnecting_revokes_the_grant_where_the_forge_has_an_endpoint_for_it() {
        let granted = forges::Granted {
            token: "gho_live".to_owned(),
            refresh: "ghr_later".to_owned(),
            expires_in_secs: Some(3600),
        };

        let github = revocation(ForgeKind::GitHub, &an_app(), &granted).expect("github revokes");
        assert_eq!(
            github,
            Call::DeleteGrant {
                url: "https://api.github.com/applications/Iv1.the-client/grant".to_owned(),
                basic: ("Iv1.the-client".to_owned(), "the-client-secret".to_owned()),
                access_token: "gho_live".to_owned(),
            },
            "GitHub does not implement RFC 7009; the grant is what Disconnect means"
        );

        let gitlab_app = forges::OAuthApp::at(
            ForgeKind::GitLab,
            "the-app",
            "the-secret",
            "https://gitlab.example/oauth/authorize",
            "https://gitlab.example/oauth/token",
            "api",
        );
        let gitlab = revocation(ForgeKind::GitLab, &gitlab_app, &granted).expect("gitlab revokes");
        let Call::Form { url, fields } = gitlab else {
            panic!("gitlab takes a form post");
        };
        assert_eq!(url, "https://gitlab.example/oauth/revoke");
        assert!(
            fields.contains(&("token".to_owned(), "ghr_later".to_owned())),
            "the refresh token is what revokes the family: {fields:?}"
        );

        // Codeberg/Forgejo publishes none. Saying so is the honest answer; a
        // guessed URL would 404 and be logged as a refusal, which says
        // something false about somebody's account.
        let codeberg_app = forges::OAuthApp::at(
            ForgeKind::Codeberg,
            "app",
            "secret",
            "https://codeberg.org/login/oauth/authorize",
            "https://codeberg.org/login/oauth/access_token",
            "write:repository",
        );
        assert!(revocation(ForgeKind::Codeberg, &codeberg_app, &granted).is_none());
    }

    #[test]
    fn an_unknown_forge_is_refused_by_name() {
        let refused = kind("bitbucket").expect_err("not one of the three");

        assert_eq!(refused.0.code, "invalidargument");
        assert!(refused.0.message.contains("bitbucket"));
    }
}