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());
}
}