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

nordisk/gunnar

crates/gunnar-grpc/src/service/clans.rs

//! `Clans`: ordinary accounts grouped, each giving part of its own room
//! (owner, 2026-10-03 and 2026-10-05).
//!
//! # Why every verb is the self-scope
//!
//! A clan verb is always the caller acting on its OWN standing: making a clan,
//! answering its own invitation, giving or taking back its own room, leaving —
//! or, as the clan's admin, administering the clan. None of that is a grant on
//! the server's table, so none of it is asked of [`ServiceContext::require`]:
//! [`ServiceContext::require_self`] keeps what is not a grant (a browse
//! replica's refusal, a narrowed principal's list, the audit record) and the
//! identity table decides the rest from the clan itself — who its admin is,
//! whether an account is an ordinary one, whether the room fits. A server
//! admin is not a clan's admin: the clan's verbs refuse it like anybody else.
//!
//! What only the stores know is measured HERE and handed in, as
//! `Accounts.EnrollEmployee` does with its room: the caller's own cap and what
//! it stores ([`account_room`]), and what the clan stores (its namespace's
//! `pack_bytes`, the meter a push is refused against). A clan whose usage
//! could not be read is never treated as empty.
//!
//! [`ServiceContext::require`]: crate::service::ServiceContext::require
//! [`ServiceContext::require_self`]: crate::service::ServiceContext::require_self

use gunnar_proto::clans_server::Clans;
use gunnar_proto::{
    AcceptClanInviteRequest, Clan, ClanResponse, ClanRole, CreateClanRequest,
    DeclineClanInviteRequest, DissolveClanRequest, DissolveClanResponse, GetClanRequest,
    GrantLevel, InviteToClanRequest, LeaveClanRequest, ListClansRequest, ListClansResponse,
    RemoveClanMemberRequest, SetClanAdminRequest, SetClanContributionRequest,
};
use gunnar_proto::{AnswerRepoOfferRequest, AnswerRepoOfferResponse, OfferRepoRequest};
use tonic::{Request, Response, Status};

use crate::auth::Caller;
use crate::clans::{ClanPlane, ClanRoom};
use crate::service::accounts::account_room;
use crate::service::ClansService;

/// A required name, trimmed, or `INVALID_ARGUMENT`.
fn named(raw: &str, field: &'static str) -> Result<String, Status> {
    let trimmed = raw.trim();
    if trimmed.is_empty() {
        return Err(Status::invalid_argument(format!("`{field}` is required")));
    }
    Ok(trimmed.to_owned())
}

impl ClansService {
    /// The plane's clan half, or UNIMPLEMENTED — never an empty answer.
    fn plane(&self) -> Result<&dyn ClanPlane, Status> {
        self.ctx
            .control
            .clans()
            .ok_or_else(|| Status::unimplemented("Clans: this server keeps no clans"))
    }

    /// The caller's own account's room, measured: its cap and what it
    /// stores. Uncapped (`cap: None`) wherever nothing caps it.
    fn room(&self, account: &str) -> Result<ClanRoom, Status> {
        Ok(match account_room(&self.ctx, account)? {
            Some((cap, stored)) => ClanRoom {
                cap: Some(cap),
                stored,
            },
            None => ClanRoom::default(),
        })
    }

    /// What the clan stores: its namespace's `pack_bytes` AND `lfs_bytes`
    /// (round 2, owner 2026-10-05: "LFS counts in clan room") — what a push
    /// or an LFS upload into it is refused against. `None` when it could not
    /// be measured.
    fn used(&self, clan: &str) -> Option<u64> {
        match self.ctx.control.usage(Some(clan)) {
            Ok(rows) => rows
                .into_iter()
                .find(|row| row.namespace == clan)
                .map(|row| row.pack_bytes.saturating_add(row.lfs_bytes)),
            Err(_) => None,
        }
    }

    /// One line on the audit target for a repository offered out of a clan,
    /// taken, declined or withdrawn (round 2) — beside the `Clans.*` record
    /// every call already leaves in the audit ledger — and the entry in the
    /// ACCOUNT audit logs it belongs to (2026-10-06): the clan's own, the
    /// offering admin's account's (`offered_by`), the receiving account's
    /// (`to`) and the acting account's, each log once.
    fn audit_offer(
        &self,
        caller: &Caller,
        event: &'static str,
        clan: &str,
        repo: &str,
        to: &str,
        offered_by: &str,
    ) {
        tracing::info!(
            target: crate::auth::AUDIT_TARGET,
            event,
            principal = %caller.principal_id,
            clan = %clan,
            repo = %repo,
            to = %to,
            "a repository offered out of a clan"
        );
        let detail = if event == "clan.repo_moved" {
            format!("clan {clan}, repository {repo}, now {to}/{repo}")
        } else {
            format!("clan {clan}, repository {repo}, account {to}")
        };
        let mut logs = vec![clan.to_owned()];
        let offerer = self.ctx.account_of_principal(offered_by);
        let actor = self.ctx.account_of_principal(&caller.principal_id);
        for account in [offerer, Some(to.to_owned()), actor].into_iter().flatten() {
            if !logs.contains(&account) {
                logs.push(account);
            }
        }
        let target = format!("{clan}/{repo}");
        for account in logs {
            self.ctx.account_event(
                Some(account),
                crate::enterprise::AccountAuditEvent::by(caller, event, target.clone())
                    .detail(detail.clone()),
            );
        }
    }

    /// **The clan as the caller sees it**: its standing, its own
    /// contribution, how low that may go, what its account could still give
    /// — and the invitations an admin sees (a plain member sees none, an
    /// invited account its own).
    fn seen(&self, caller: &str, mut clan: Clan, room_left: Option<u64>) -> Clan {
        let role = match clan.members.iter().find(|m| m.principal_id == caller) {
            Some(m) if m.admin => ClanRole::Admin,
            Some(_) => ClanRole::Member,
            None if clan.invites.iter().any(|i| i.principal_id == caller) => ClanRole::Invited,
            None => ClanRole::Unspecified,
        };
        if role != ClanRole::Admin {
            clan.invites.retain(|i| i.principal_id == caller);
            clan.offers.clear();
        }
        let own = clan
            .members
            .iter()
            .find(|m| m.principal_id == caller)
            .map_or(0, |m| m.contribution_bytes);
        let used = self.used(&clan.name);
        clan.used_bytes = used;
        clan.your_role = role as i32;
        clan.your_contribution_bytes = own;
        // The floor counts the OTHERS' shares in force (round 2): a lapsed
        // share holds nothing up, and a lapsed member's own is out of force.
        let me_lapsed = clan
            .members
            .iter()
            .any(|m| m.principal_id == caller && m.lapsed);
        let others_in_force: u64 = clan
            .members
            .iter()
            .filter(|m| m.principal_id != caller && !m.lapsed)
            .map(|m| m.contribution_bytes)
            .fold(0, u64::saturating_add);
        clan.your_floor_bytes = match role {
            ClanRole::Admin | ClanRole::Member if me_lapsed => used.map(|_| 0),
            ClanRole::Admin | ClanRole::Member => {
                used.map(|used| used.saturating_sub(others_in_force))
            }
            _ => None,
        };
        clan.your_room_left_bytes = room_left;
        clan
    }

    /// What the caller could still give, or `None` (uncapped, or the room
    /// could not be read — the number is a reading for the page, never a
    /// decision; every decision measures again).
    fn room_left(&self, caller: &str) -> Option<u64> {
        let room = self.room(caller).ok()?;
        self.plane()
            .ok()?
            .clan_room_left(caller, room)
            .ok()
            .flatten()
    }

    /// The answer every write gives: the clan as the caller now sees it.
    fn answer(&self, caller: &Caller, clan: Clan) -> Response<ClanResponse> {
        let left = self.room_left(&caller.principal_id);
        Response::new(ClanResponse {
            clan: Some(self.seen(&caller.principal_id, clan, left)),
        })
    }

    /// One line on the audit target per clan write, naming who and what —
    /// and the entry in the ACCOUNT audit logs it belongs to (clans + the
    /// enterprise list, 2026-10-05): the clan's own (a clan is an account to
    /// its admins), the acting account's own, and the other account's when
    /// the verb named one (invited, removed, made admin).
    fn audit(&self, caller: &Caller, event: &'static str, clan: &str, other: &str, bytes: u64) {
        tracing::info!(
            target: crate::auth::AUDIT_TARGET,
            event,
            principal = %caller.principal_id,
            clan = %clan,
            other = %other,
            bytes,
            "a clan changed"
        );
        let detail = match (other.is_empty(), event) {
            (_, "clan.created" | "clan.joined" | "clan.contribution") => {
                format!("clan {clan}, giving {bytes} bytes")
            }
            (false, _) => format!("clan {clan}, account {other}"),
            (true, _) => format!("clan {clan}"),
        };
        let target = if other.is_empty() { clan } else { other };
        let entry = |target: &str| {
            crate::enterprise::AccountAuditEvent::by(caller, event, target).detail(detail.clone())
        };
        self.ctx.account_event(Some(clan.to_owned()), entry(target));
        let mine = self.ctx.account_of_principal(&caller.principal_id);
        if let Some(own) = mine.clone().filter(|own| own != clan) {
            self.ctx.account_event(Some(own), entry(target));
        }
        if !other.is_empty() && other != caller.principal_id {
            if let Some(theirs) = self
                .ctx
                .account_of_principal(other)
                .filter(|a| a != clan && Some(a) != mine.as_ref())
            {
                self.ctx.account_event(Some(theirs), entry(clan));
            }
        }
    }
}

#[tonic::async_trait]
impl Clans for ClansService {
    /// **`Clans.Create`**: a free name becomes the clan and its private
    /// namespace; the caller is its admin and gives `contribution_bytes` of
    /// its own room. A console's reserved name is refused as for an employee.
    async fn create(
        &self,
        request: Request<CreateClanRequest>,
    ) -> Result<Response<ClanResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require_self(&caller, GrantLevel::Admin, "Clans.Create")?;
        let request = request.into_inner();
        let name = named(&request.name, "name")?;
        if crate::service::access::is_reserved_principal_name(&name) {
            return Err(crate::service::access::reserved_name_refusal(
                &name, "a clan",
            ));
        }
        // The name filter (clans + the enterprise list, 2026-10-05): a clan's
        // name is a namespace, named here — refused when offensive, and a
        // borderline one is refused too (a new clan has no admin to ask yet).
        self.ctx
            .filter_name(&caller, "namespace", &name, None, false)?;
        let room = self.room(&caller.principal_id)?;
        let clan = self.plane()?.clan_create(
            &name,
            &caller.principal_id,
            request.contribution_bytes,
            room,
        )?;
        self.audit(
            &caller,
            "clan.created",
            &name,
            "",
            request.contribution_bytes,
        );
        Ok(self.answer(&caller, clan))
    }

    /// **`Clans.Get`**: to a member or an invited account; to anybody else
    /// the clan does not exist, in the words a clan that does not exist gets.
    async fn get(
        &self,
        request: Request<GetClanRequest>,
    ) -> Result<Response<ClanResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require_self(&caller, GrantLevel::Read, "Clans.Get")?;
        let name = named(&request.into_inner().clan, "clan")?;
        let absent =
            || Status::not_found(format!("there is no clan {name} you are in or invited to"));
        let clan = self.plane()?.clan_get(&name)?.ok_or_else(absent)?;
        let inside = clan
            .members
            .iter()
            .any(|m| m.principal_id == caller.principal_id)
            || clan
                .invites
                .iter()
                .any(|i| i.principal_id == caller.principal_id);
        if !inside {
            return Err(absent());
        }
        Ok(self.answer(&caller, clan))
    }

    /// **`Clans.List`**: the caller's clans and invitations.
    async fn list(
        &self,
        request: Request<ListClansRequest>,
    ) -> Result<Response<ListClansResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require_self(&caller, GrantLevel::Read, "Clans.List")?;
        let left = self.room_left(&caller.principal_id);
        let plane = self.plane()?;
        let clans = plane
            .clans_of(&caller.principal_id)?
            .into_iter()
            .map(|clan| self.seen(&caller.principal_id, clan, left))
            .collect();
        // Repositories offered to the caller's OWN account (round 2).
        let offers = plane.clan_offers_to(&caller.principal_id)?;
        Ok(Response::new(ListClansResponse { clans, offers }))
    }

    /// **`Clans.Invite`**: the clan's admin invites an ordinary account.
    async fn invite(
        &self,
        request: Request<InviteToClanRequest>,
    ) -> Result<Response<ClanResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require_self(&caller, GrantLevel::Admin, "Clans.Invite")?;
        let request = request.into_inner();
        let clan = named(&request.clan, "clan")?;
        let principal = named(&request.principal_id, "principal_id")?;
        let row = self
            .plane()?
            .clan_invite(&clan, &caller.principal_id, &principal)?;
        self.audit(&caller, "clan.invited", &clan, &principal, 0);
        Ok(self.answer(&caller, row))
    }

    /// **`Clans.Accept`**: the invited account joins, giving
    /// `contribution_bytes` of its own room.
    async fn accept(
        &self,
        request: Request<AcceptClanInviteRequest>,
    ) -> Result<Response<ClanResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require_self(&caller, GrantLevel::Admin, "Clans.Accept")?;
        let request = request.into_inner();
        let clan = named(&request.clan, "clan")?;
        let room = self.room(&caller.principal_id)?;
        let row = self.plane()?.clan_answer(
            &clan,
            &caller.principal_id,
            true,
            request.contribution_bytes,
            room,
        )?;
        self.audit(
            &caller,
            "clan.joined",
            &clan,
            "",
            request.contribution_bytes,
        );
        Ok(self.answer(&caller, row))
    }

    /// **`Clans.Decline`**: the caller declines its own invitation — or, as
    /// the clan's admin, withdraws another account's.
    async fn decline(
        &self,
        request: Request<DeclineClanInviteRequest>,
    ) -> Result<Response<ClanResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require_self(&caller, GrantLevel::Admin, "Clans.Decline")?;
        let request = request.into_inner();
        let clan = named(&request.clan, "clan")?;
        let target = match request.principal_id.trim() {
            "" => caller.principal_id.clone(),
            other => other.to_owned(),
        };
        let plane = self.plane()?;
        if target == caller.principal_id {
            let row =
                plane.clan_answer(&clan, &caller.principal_id, false, 0, ClanRoom::default())?;
            self.audit(&caller, "clan.declined", &clan, "", 0);
            // The caller is no longer invited: it sees the clan no more.
            let mut row = self.seen(&caller.principal_id, row, None);
            row.members.clear();
            return Ok(Response::new(ClanResponse { clan: Some(row) }));
        }
        let row = plane.clan_withdraw(&clan, &caller.principal_id, &target)?;
        self.audit(&caller, "clan.invite_withdrawn", &clan, &target, 0);
        Ok(self.answer(&caller, row))
    }

    /// **`Clans.SetContribution`**: up out of the member's own room; down no
    /// lower than what the clan stores leaves room for.
    async fn set_contribution(
        &self,
        request: Request<SetClanContributionRequest>,
    ) -> Result<Response<ClanResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require_self(&caller, GrantLevel::Admin, "Clans.SetContribution")?;
        let request = request.into_inner();
        let clan = named(&request.clan, "clan")?;
        let room = self.room(&caller.principal_id)?;
        let used = self.used(&clan);
        let row = self.plane()?.clan_set_contribution(
            &clan,
            &caller.principal_id,
            request.contribution_bytes,
            room,
            used,
        )?;
        self.audit(
            &caller,
            "clan.contribution",
            &clan,
            "",
            request.contribution_bytes,
        );
        Ok(self.answer(&caller, row))
    }

    /// **`Clans.Leave`**: the contribution goes back — refused while the clan
    /// uses it, and to the clan's last admin.
    async fn leave(
        &self,
        request: Request<LeaveClanRequest>,
    ) -> Result<Response<ClanResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require_self(&caller, GrantLevel::Admin, "Clans.Leave")?;
        let clan = named(&request.into_inner().clan, "clan")?;
        let used = self.used(&clan);
        let row = self
            .plane()?
            .clan_leave(&clan, &caller.principal_id, used)?;
        self.audit(&caller, "clan.left", &clan, "", 0);
        // Out of it now: what the caller may still see is that it left.
        let mut row = self.seen(&caller.principal_id, row, None);
        row.members.clear();
        row.invites.clear();
        Ok(Response::new(ClanResponse { clan: Some(row) }))
    }

    /// **`Clans.RemoveMember`**: the clan's admin removes a member.
    async fn remove_member(
        &self,
        request: Request<RemoveClanMemberRequest>,
    ) -> Result<Response<ClanResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require_self(&caller, GrantLevel::Admin, "Clans.RemoveMember")?;
        let request = request.into_inner();
        let clan = named(&request.clan, "clan")?;
        let member = named(&request.principal_id, "principal_id")?;
        let used = self.used(&clan);
        let row = self
            .plane()?
            .clan_remove(&clan, &caller.principal_id, &member, used)?;
        self.audit(&caller, "clan.member_removed", &clan, &member, 0);
        Ok(self.answer(&caller, row))
    }

    /// **`Clans.SetAdmin`**: the clan's admin makes a member admin, or not.
    async fn set_admin(
        &self,
        request: Request<SetClanAdminRequest>,
    ) -> Result<Response<ClanResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require_self(&caller, GrantLevel::Admin, "Clans.SetAdmin")?;
        let request = request.into_inner();
        let clan = named(&request.clan, "clan")?;
        let member = named(&request.principal_id, "principal_id")?;
        let row =
            self.plane()?
                .clan_set_admin(&clan, &caller.principal_id, &member, request.admin)?;
        self.audit(
            &caller,
            if request.admin {
                "clan.admin_made"
            } else {
                "clan.admin_unmade"
            },
            &clan,
            &member,
            0,
        );
        Ok(self.answer(&caller, row))
    }

    /// **`Clans.Dissolve`**: the clan's admin ends an EMPTY clan.
    async fn dissolve(
        &self,
        request: Request<DissolveClanRequest>,
    ) -> Result<Response<DissolveClanResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require_self(&caller, GrantLevel::Admin, "Clans.Dissolve")?;
        let clan = named(&request.into_inner().clan, "clan")?;
        let row = self.plane()?.clan_dissolve(&clan, &caller.principal_id)?;
        self.audit(&caller, "clan.dissolved", &clan, "", 0);
        Ok(Response::new(DissolveClanResponse { clan: Some(row) }))
    }

    /// **`Clans.OfferRepo`** (round 2): the clan's admin offers one of the
    /// clan's repositories to an account, to move it out.
    async fn offer_repo(
        &self,
        request: Request<OfferRepoRequest>,
    ) -> Result<Response<ClanResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require_self(&caller, GrantLevel::Admin, "Clans.OfferRepo")?;
        let request = request.into_inner();
        let clan = named(&request.clan, "clan")?;
        let repo = named(&request.repo, "repo")?;
        let account = named(&request.account, "account")?;
        let row = self
            .plane()?
            .clan_offer_repo(&clan, &caller.principal_id, &repo, &account)?;
        self.audit_offer(
            &caller,
            "clan.repo_offered",
            &clan,
            &repo,
            &account,
            &caller.principal_id,
        );
        Ok(self.answer(&caller, row))
    }

    /// **`Clans.AnswerRepoOffer`** (round 2): the offered account's admin —
    /// gunnar's own rule, Admin over the account's namespace — takes the
    /// repository (it moves now, if the account's room holds it) or declines;
    /// the clan's admin withdraws. To anybody else there is no such offer.
    async fn answer_repo_offer(
        &self,
        request: Request<AnswerRepoOfferRequest>,
    ) -> Result<Response<AnswerRepoOfferResponse>, Status> {
        let caller = self.ctx.caller(&request)?;
        self.ctx
            .require_self(&caller, GrantLevel::Admin, "Clans.AnswerRepoOffer")?;
        let request = request.into_inner();
        let clan = named(&request.clan, "clan")?;
        let repo = named(&request.repo, "repo")?;
        let plane = self.plane()?;
        let absent =
            || Status::not_found(format!("there is no offer of {clan}/{repo} you may answer"));
        let offer = plane.clan_repo_offer(&clan, &repo)?.ok_or_else(absent)?;
        let to = offer.to_account.clone();
        let offered_by = offer.offered_by.clone();
        let account_admin = self
            .ctx
            .check(
                &caller,
                crate::auth::Access::namespace(&to),
                GrantLevel::Admin,
            )?
            .is_none();
        if !account_admin {
            // Not the offered account's: the clan's admin may withdraw, and
            // may not take it for the account; anybody else meets no offer.
            let clan_admin = plane.clan_role(&caller.principal_id, &clan)? == Some(ClanRole::Admin);
            if !clan_admin {
                return Err(absent());
            }
            if request.accept {
                return Err(Status::permission_denied(format!(
                    "{clan}/{repo} is offered to account {to}: only its admin takes it"
                )));
            }
            plane.clan_withdraw_offer(&clan, &caller.principal_id, &repo)?;
            self.audit_offer(
                &caller,
                "clan.repo_offer_withdrawn",
                &clan,
                &repo,
                &to,
                &offered_by,
            );
            return Ok(Response::new(AnswerRepoOfferResponse {
                offer: Some(offer),
                repo: None,
            }));
        }
        // The account's admin, asked again as the verb, so the ledger holds it.
        self.ctx.require(
            &caller,
            crate::auth::Access::namespace(&to),
            GrantLevel::Admin,
            "Clans.AnswerRepoOffer",
        )?;
        if !request.accept {
            let offer = plane.clan_decline_offer(&clan, &repo, &to)?;
            self.audit_offer(
                &caller,
                "clan.repo_offer_declined",
                &clan,
                &repo,
                &to,
                &offered_by,
            );
            return Ok(Response::new(AnswerRepoOfferResponse {
                offer: Some(offer),
                repo: None,
            }));
        }
        // The account's room must hold what the repository stores: it comes
        // out of the account's own room, as a contribution does.
        let room = self.room(&to)?;
        if let (Some(stored), Some(left)) = (offer.stored_bytes, plane.clan_room_left(&to, room)?) {
            if stored > left {
                return Err(Status::failed_precondition(format!(
                    "account {to} has {left} bytes of its own room unused, and {clan}/{repo} holds {stored}: \
                     {} bytes short. Add room to the account, or free some, before taking it",
                    stored - left
                )));
            }
        }
        let (offer, moved) = plane.clan_take_offer(&clan, &repo, &to, &caller.principal_id)?;
        // To everything watching: a name stopped existing, another began.
        self.ctx
            .bus
            .publish(gunnar_proto::event::Payload::RepoDeleted(
                gunnar_proto::RepoDeleted {
                    repo: Some(gunnar_proto::RepoPath::new(&clan, &repo)),
                    principal_id: caller.principal_id.clone(),
                },
            ));
        self.ctx
            .bus
            .publish(gunnar_proto::event::Payload::RepoCreated(
                gunnar_proto::RepoCreated {
                    repo: Some(moved.clone()),
                    principal_id: caller.principal_id.clone(),
                },
            ));
        self.audit_offer(&caller, "clan.repo_moved", &clan, &repo, &to, &offered_by);
        Ok(Response::new(AnswerRepoOfferResponse {
            offer: Some(offer),
            repo: Some(moved),
        }))
    }
}