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/clans.rs

//! **You → Clans** (owner, 2026-10-03: "ordinary accounts can group with
//! other in a clan, where group created repos are administered by the group
//! admin"; 2026-10-05: "each member contributes storage to the clan").
//!
//! Every handler here is one `Clans.*` call signed AS THE PERSON ([`Actor`]),
//! and gunnar decides everything: who the clan's admin is, whether an account
//! may join (an enterprise account's employee may not), whether a contribution
//! fits the person's own room, how low it may go while the clan stores what it
//! does, whether the last admin may leave. This console relays what was typed
//! and renders gunnar's answer — its numbers and its refusals, verbatim. It
//! keeps no clan, no member and no rule of its own.
//!
//! Under `/api/account/`, so `role_floor` lets any signed-in session reach the
//! handlers; the clan is the person's, and gunnar says whose.

use axum::extract::{Path, State};
use axum::Json;
use serde::{Deserialize, Serialize};

use crate::api::{Actor, ApiError};
use crate::app::AppState;
use crate::grpc::Fail;
use crate::pb;

/// One member, as the page draws it.
#[derive(Debug, Serialize)]
pub struct Member {
    pub id: String,
    pub admin: bool,
    pub contribution_bytes: u64,
    /// Its share is out of force: its paid period has ended (round 2).
    pub lapsed: bool,
}

/// A repository offered out of a clan to an account (round 2).
#[derive(Debug, Serialize)]
pub struct Offer {
    pub clan: String,
    pub repo: String,
    pub to: String,
    pub by: String,
    /// What it stores, packs and LFS; `null` when gunnar did not measure it.
    pub stored_bytes: Option<u64>,
}

/// gunnar's offer onto the page's shape.
pub fn offer_view(offer: pb::RepoOffer) -> Offer {
    Offer {
        clan: offer.clan,
        repo: offer.repo,
        to: offer.to_account,
        by: offer.offered_by,
        stored_bytes: offer.stored_bytes,
    }
}

/// One invitation.
#[derive(Debug, Serialize)]
pub struct Invite {
    pub id: String,
    pub invited_by: String,
}

/// **A clan as gunnar answered it for this person.** Field names are the
/// page's; every value is gunnar's, and an absent one stays absent (`null`),
/// never a 0 this console made up.
#[derive(Debug, Serialize)]
pub struct ClanView {
    pub name: String,
    pub created_by: String,
    /// `admin`, `member`, `invited`, or empty.
    pub role: &'static str,
    /// The clan's room: the sum of its members' contributions.
    pub room_bytes: u64,
    /// What the clan stores; `null` when gunnar did not measure it.
    pub used_bytes: Option<u64>,
    pub your_contribution_bytes: u64,
    /// How low the person's contribution may go; `null` when not a member or
    /// unmeasured.
    pub your_floor_bytes: Option<u64>,
    /// What the person's own account could still give; `null` when nothing
    /// caps it on this box.
    pub your_room_left_bytes: Option<u64>,
    pub members: Vec<Member>,
    pub invites: Vec<Invite>,
    /// The room the door holds the clan to now: lapsed shares dropped.
    pub room_in_force_bytes: u64,
    /// Repositories offered out of the clan (its admin sees them).
    pub offers: Vec<Offer>,
}

fn role_word(role: i32) -> &'static str {
    match pb::ClanRole::try_from(role).unwrap_or_default() {
        pb::ClanRole::Admin => "admin",
        pb::ClanRole::Member => "member",
        pb::ClanRole::Invited => "invited",
        pb::ClanRole::Unspecified => "",
    }
}

/// gunnar's clan onto the page's shape.
pub fn view(clan: pb::Clan) -> ClanView {
    ClanView {
        role: role_word(clan.your_role),
        room_bytes: clan.room_bytes,
        used_bytes: clan.used_bytes,
        your_contribution_bytes: clan.your_contribution_bytes,
        your_floor_bytes: clan.your_floor_bytes,
        your_room_left_bytes: clan.your_room_left_bytes,
        members: clan
            .members
            .into_iter()
            .map(|m| Member {
                id: m.principal_id,
                admin: m.admin,
                contribution_bytes: m.contribution_bytes,
                lapsed: m.lapsed,
            })
            .collect(),
        invites: clan
            .invites
            .into_iter()
            .map(|i| Invite {
                id: i.principal_id,
                invited_by: i.invited_by,
            })
            .collect(),
        room_in_force_bytes: clan.room_in_force_bytes,
        offers: clan.offers.into_iter().map(offer_view).collect(),
        name: clan.name,
        created_by: clan.created_by,
    }
}

/// `GET /api/account/clans`.
#[derive(Debug, Serialize)]
pub struct Clans {
    pub clans: Vec<ClanView>,
    /// Repositories offered to the person's own account (round 2).
    pub offers: Vec<Offer>,
}

/// GiB as typed, in bytes. Refused when it is not a number of GiB at all —
/// whether it FITS is gunnar's question, not this console's.
fn gib(typed: f64) -> Result<u64, ApiError> {
    if !typed.is_finite() || typed < 0.0 {
        return Err(ApiError(Fail {
            code: "invalidargument".to_owned(),
            message: format!("{typed} is not an amount of room: give a number of GiB, 0 or more"),
        }));
    }
    Ok((typed * (1u64 << 30) as f64).round() as u64)
}

fn answered(answer: pb::ClanResponse) -> Result<Json<ClanView>, ApiError> {
    answer.clan.map(|clan| Json(view(clan))).ok_or_else(|| {
        ApiError(Fail {
            code: "internal".to_owned(),
            message: "Gunnar answered with no clan".to_owned(),
        })
    })
}

fn audit(actor: &Actor, event: &'static str, clan: &str, other: &str) {
    tracing::info!(
        target: crate::auth::AUDIT_TARGET,
        event,
        user = %actor.who,
        clan = %clan,
        other = %other,
        "a clan verb was relayed to gunnar as the person"
    );
}

/// `GET /api/account/clans` — the person's clans and invitations.
pub async fn list(actor: Actor) -> Result<Json<Clans>, ApiError> {
    let answer = actor
        .gunnar
        .clans()
        .list(pb::ListClansRequest {})
        .await
        .map_err(ApiError::from)?
        .into_inner();
    Ok(Json(Clans {
        clans: answer.clans.into_iter().map(view).collect(),
        offers: answer.offers.into_iter().map(offer_view).collect(),
    }))
}

/// The body of `POST /api/account/clans`.
#[derive(Debug, Deserialize)]
pub struct NewClan {
    pub name: String,
    #[serde(default)]
    pub contribution_gib: f64,
}

/// `POST /api/account/clans` — make a clan; the person is its admin.
pub async fn create(State(state): State<AppState>, actor: Actor, Json(body): Json<NewClan>) -> Result<Json<ClanView>, ApiError> {
    let name = body.name.trim().to_owned();
    let answer = actor
        .gunnar
        .clans()
        .create(pb::CreateClanRequest {
            name: name.clone(),
            contribution_bytes: gib(body.contribution_gib)?,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    // A new namespace is the person's to see now.
    state.caches.invalidate_repos().await;
    audit(&actor, "clan.create", &name, "");
    answered(answer)
}

/// `GET /api/account/clans/{clan}`.
pub async fn get(actor: Actor, Path(clan): Path<String>) -> Result<Json<ClanView>, ApiError> {
    answered(
        actor
            .gunnar
            .clans()
            .get(pb::GetClanRequest { clan })
            .await
            .map_err(ApiError::from)?
            .into_inner(),
    )
}

/// `{ "account": … }` — whom a verb is about.
#[derive(Debug, Deserialize)]
pub struct Whom {
    #[serde(default)]
    pub account: String,
}

/// `{ "contribution_gib": … }`.
#[derive(Debug, Deserialize)]
pub struct Give {
    #[serde(default)]
    pub contribution_gib: f64,
}

/// `POST /api/account/clans/{clan}/invite` — the clan admin's.
pub async fn invite(actor: Actor, Path(clan): Path<String>, Json(body): Json<Whom>) -> Result<Json<ClanView>, ApiError> {
    let account = body.account.trim().to_owned();
    let answer = actor
        .gunnar
        .clans()
        .invite(pb::InviteToClanRequest {
            clan: clan.clone(),
            principal_id: account.clone(),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    audit(&actor, "clan.invite", &clan, &account);
    answered(answer)
}

/// `POST /api/account/clans/{clan}/accept` — join, giving room of one's own.
pub async fn accept(
    State(state): State<AppState>,
    actor: Actor,
    Path(clan): Path<String>,
    Json(body): Json<Give>,
) -> Result<Json<ClanView>, ApiError> {
    let answer = actor
        .gunnar
        .clans()
        .accept(pb::AcceptClanInviteRequest {
            clan: clan.clone(),
            contribution_bytes: gib(body.contribution_gib)?,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    state.caches.invalidate_repos().await;
    audit(&actor, "clan.accept", &clan, "");
    answered(answer)
}

/// `POST /api/account/clans/{clan}/decline` — one's own invitation, or (the
/// admin) `account`'s.
pub async fn decline(actor: Actor, Path(clan): Path<String>, Json(body): Json<Whom>) -> Result<Json<ClanView>, ApiError> {
    let account = body.account.trim().to_owned();
    let answer = actor
        .gunnar
        .clans()
        .decline(pb::DeclineClanInviteRequest {
            clan: clan.clone(),
            principal_id: account.clone(),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    audit(&actor, "clan.decline", &clan, &account);
    answered(answer)
}

/// `POST /api/account/clans/{clan}/contribution` — what the person gives.
pub async fn contribution(actor: Actor, Path(clan): Path<String>, Json(body): Json<Give>) -> Result<Json<ClanView>, ApiError> {
    let answer = actor
        .gunnar
        .clans()
        .set_contribution(pb::SetClanContributionRequest {
            clan: clan.clone(),
            contribution_bytes: gib(body.contribution_gib)?,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    audit(&actor, "clan.contribution", &clan, "");
    answered(answer)
}

/// `POST /api/account/clans/{clan}/leave`.
pub async fn leave(State(state): State<AppState>, actor: Actor, Path(clan): Path<String>) -> Result<Json<ClanView>, ApiError> {
    let answer = actor
        .gunnar
        .clans()
        .leave(pb::LeaveClanRequest { clan: clan.clone() })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    state.caches.invalidate_repos().await;
    audit(&actor, "clan.leave", &clan, "");
    answered(answer)
}

/// `POST /api/account/clans/{clan}/remove` — the clan admin's.
pub async fn remove(actor: Actor, Path(clan): Path<String>, Json(body): Json<Whom>) -> Result<Json<ClanView>, ApiError> {
    let account = body.account.trim().to_owned();
    let answer = actor
        .gunnar
        .clans()
        .remove_member(pb::RemoveClanMemberRequest {
            clan: clan.clone(),
            principal_id: account.clone(),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    audit(&actor, "clan.remove", &clan, &account);
    answered(answer)
}

/// `{ "account": …, "admin": … }`.
#[derive(Debug, Deserialize)]
pub struct MakeAdmin {
    pub account: String,
    pub admin: bool,
}

/// `POST /api/account/clans/{clan}/admin` — the clan admin's.
pub async fn admin(actor: Actor, Path(clan): Path<String>, Json(body): Json<MakeAdmin>) -> Result<Json<ClanView>, ApiError> {
    let account = body.account.trim().to_owned();
    let answer = actor
        .gunnar
        .clans()
        .set_admin(pb::SetClanAdminRequest {
            clan: clan.clone(),
            principal_id: account.clone(),
            admin: body.admin,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    audit(&actor, if body.admin { "clan.admin" } else { "clan.unadmin" }, &clan, &account);
    answered(answer)
}

/// `POST /api/account/clans/{clan}/dissolve` — the clan admin's, of an empty
/// clan.
pub async fn dissolve(State(state): State<AppState>, actor: Actor, Path(clan): Path<String>) -> Result<Json<ClanView>, ApiError> {
    let answer = actor
        .gunnar
        .clans()
        .dissolve(pb::DissolveClanRequest { clan: clan.clone() })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    state.caches.invalidate_repos().await;
    audit(&actor, "clan.dissolve", &clan, "");
    answered(pb::ClanResponse { clan: answer.clan })
}

/// `{ "repo": …, "account": … }`.
#[derive(Debug, Deserialize)]
pub struct OfferBody {
    pub repo: String,
    pub account: String,
}

/// `POST /api/account/clans/{clan}/offer` — round 2: the clan admin offers a
/// repository to an account, to move it out.
pub async fn offer(actor: Actor, Path(clan): Path<String>, Json(body): Json<OfferBody>) -> Result<Json<ClanView>, ApiError> {
    let (repo, account) = (body.repo.trim().to_owned(), body.account.trim().to_owned());
    let answer = actor
        .gunnar
        .clans()
        .offer_repo(pb::OfferRepoRequest {
            clan: clan.clone(),
            repo: repo.clone(),
            account: account.clone(),
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    audit(&actor, "clan.offer", &clan, &format!("{repo} to {account}"));
    answered(answer)
}

/// `{ "accept": … }`.
#[derive(Debug, Deserialize)]
pub struct AnswerBody {
    #[serde(default)]
    pub accept: bool,
}

/// What an answered offer comes back as.
#[derive(Debug, Serialize)]
pub struct Answered {
    pub offer: Option<Offer>,
    /// `<account>/<repo>`, when it moved.
    pub moved_to: Option<String>,
}

/// `POST /api/account/clans/{clan}/offers/{repo}` — the offered account's
/// admin takes it (gunnar moves it) or declines; the clan admin withdraws.
pub async fn answer_offer(
    State(state): State<AppState>,
    actor: Actor,
    Path((clan, repo)): Path<(String, String)>,
    Json(body): Json<AnswerBody>,
) -> Result<Json<Answered>, ApiError> {
    let answer = actor
        .gunnar
        .clans()
        .answer_repo_offer(pb::AnswerRepoOfferRequest {
            clan: clan.clone(),
            repo: repo.clone(),
            accept: body.accept,
        })
        .await
        .map_err(ApiError::from)?
        .into_inner();
    let moved_to = answer
        .repo
        .and_then(|r| r.path)
        .map(|p| format!("{}/{}", p.namespace, p.repo));
    if moved_to.is_some() {
        state.caches.invalidate_repos().await;
    }
    audit(&actor, if body.accept { "clan.offer_taken" } else { "clan.offer_closed" }, &clan, &repo);
    Ok(Json(Answered {
        offer: answer.offer.map(offer_view),
        moved_to,
    }))
}

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

    /// What was typed is sent in bytes; whether it FITS is never asked here.
    #[test]
    fn room_is_sent_as_typed_and_never_judged_here() {
        assert_eq!(gib(0.0).unwrap(), 0);
        assert_eq!(gib(1.0).unwrap(), 1 << 30);
        assert_eq!(gib(0.5).unwrap(), 1 << 29);
        assert_eq!(gib(100_000.0).unwrap(), 100_000u64 << 30, "a huge ask goes to gunnar, which refuses it");
        assert!(gib(-1.0).is_err() && gib(f64::NAN).is_err());
    }

    /// gunnar's absent numbers stay absent on the page's JSON.
    #[test]
    fn an_unmeasured_number_is_null_never_zero() {
        let page = serde_json::to_value(view(pb::Clan {
            name: "fellows".into(),
            your_role: pb::ClanRole::Member as i32,
            room_bytes: 3 << 30,
            used_bytes: None,
            ..Default::default()
        }))
        .unwrap();
        assert_eq!(page["role"], "member");
        assert_eq!(page["room_bytes"], 3u64 << 30);
        assert!(page["used_bytes"].is_null() && page["your_floor_bytes"].is_null() && page["your_room_left_bytes"].is_null());
    }
}