crates/gunnar-ui-server/src/api/visibility.rs
//! **Who may change a repository's visibility** — asked of gunnar.
//!
//! gunnar's default for EVERY account (the owner, 2026-10-03: "github
//! enterprise makes it impossible for non admin to make repos public"; there is
//! no Enterprise tier and no flag — it is simply how gunnar works): only the
//! ACCOUNT admin may move a repository between private and public — not whoever
//! holds `admin` on the one repository. gunnar enforces it on
//! `Repos.SetVisibility` (`Admin` over the NAMESPACE, the account gate
//! honoured).
//!
//! Since 2026-10-04 (thin layer) the console asks nothing of its own: the PATCH
//! is signed AS THE PERSON ([`crate::api::Actor`]) and gunnar decides it, and
//! the page's hint below is gunnar's `Access.Check{namespace, ADMIN}` asked as
//! the same person — the exact rule the verb applies, said without acting.
//! Until then this module read gunnar's whole grant table with the console's
//! own key and re-implemented the rule (skipping the gate).
//!
//! Since 2026-10-06 `role_floor` puts that PATCH at viewer: a paying customer
//! is a viewer and the admin of their own account, and gunnar's rule is the
//! one that decides. `may_change` is gunnar's answer for any signed-in person.
use axum::extract::{Path, State};
use axum::Json;
use serde::Serialize;
use crate::api::{Actor, ApiError};
use crate::app::AppState;
use crate::auth::middleware::Caller;
use crate::pb;
use crate::role::Role;
/// What `GET /api/repos/{namespace}/{name}/visibility` answers: whether this
/// session may change the repository's visibility. The page draws the control
/// from this and nothing else.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Authority {
/// The account the repository belongs to — its namespace.
pub account: String,
/// gunnar says this session's person is the account's admin.
pub account_admin: bool,
/// The PATCH this session would send can succeed: account admin AND a
/// role `role_floor` lets mutate. The control is drawn on this.
pub may_change: bool,
/// Why not, in the words the panel prints, when `may_change` is false.
pub why: Option<String>,
}
/// The sentence for a session that is not the account's admin.
pub fn not_account_admin_words(account: &str) -> String {
format!(
"Only the account admin of {account} may change what a repository in it is readable \
by; an admin grant on the repository alone does not reach it."
)
}
/// **The whole answer**, for the page: gunnar's account-admin rule, asked as
/// this session's person, and the role floor the PATCH meets first.
pub async fn authority(
state: &AppState,
caller: Option<&Caller>,
account: &str,
) -> Result<Authority, ApiError> {
// The PATCH's floor is a viewer's (`role_floor`): any signed-in person may
// send it, and gunnar decides; with no session, only the open console.
let may_mutate = match caller {
Some(caller) => caller.role().allows(Role::Viewer),
None => !state.passkey_enabled() && state.admin_token.is_none(),
};
let (account_admin, refusal) = match Actor::of(state, caller).await {
Ok(actor) => {
let (allowed, reason) = crate::api::may(&actor.gunnar, account, "", pb::GrantLevel::Admin).await?;
(allowed, (!allowed).then_some(reason))
}
// No person to ask as: nobody signed in, or a sign-in bound to no
// account here. The refusal says which.
Err(why) => (false, Some(why.0.message)),
};
let why = if !account_admin {
Some(format!(
"{} {}",
not_account_admin_words(account),
refusal.unwrap_or_default()
)
.trim_end()
.to_owned())
} else if !may_mutate {
Some("Sign in to change what this repository is readable by.".to_owned())
} else {
None
};
Ok(Authority {
account: account.to_owned(),
account_admin,
may_change: account_admin && may_mutate,
why,
})
}
/// `GET /api/repos/{namespace}/{name}/visibility` — may this session change the
/// repository's visibility?
///
/// A GET, so `role_floor` puts it at viewer; it is not on the anonymous
/// allow-list, so a visitor never reaches it. gunnar answers it, about THIS
/// session's person only.
pub async fn read(
State(state): State<AppState>,
caller: Option<axum::Extension<Caller>>,
Path((namespace, _name)): Path<(String, String)>,
) -> Result<Json<Authority>, ApiError> {
Ok(Json(
authority(&state, caller.as_ref().map(|c| &c.0), &namespace).await?,
))
}