crates/gunnar-ui-server/src/api/repos.rs
//! Namespaces and repositories.
use axum::extract::{Path, Query, State};
use axum::Json;
use serde::{Deserialize, Serialize};
use crate::api::{
hash_kind_name, repo_path, visibility_from_name, visibility_name, ApiError, ApiResult, Limit,
Page, Reader,
};
use crate::app::AppState;
use crate::pb;
/// A repository as the console shows it.
#[derive(Debug, Clone, Serialize)]
pub struct Repo {
/// `<namespace>/<repo>`, which is also the SSH path.
pub path: String,
pub namespace: String,
pub name: String,
pub hash_kind: &'static str,
/// A full reference name such as `refs/heads/main`, not a short branch.
pub default_branch: String,
pub visibility: &'static str,
/// `None` when gunnar did not record one.
pub created_at_unix_ms: Option<i64>,
pub description: String,
/// **What it stores**: the archive minus the re-derivable read cache, plus
/// the cold tier — the repository list's one size column (owner,
/// 2026-10-02). `None` when the sizes could not be read.
pub stored_bytes: Option<u64>,
/// **An encrypted repository** (gunnar 2026-10-05): the server takes
/// ciphertext only. The account admin's flag, read back from gunnar.
pub encrypted: bool,
}
/// Each repository's stored bytes, keyed `<namespace>/<repo>`, out of the
/// console's `Admin.StorageInfo`. Only the rows of `repos` — the list the
/// CALLER may read — are ever given one, so this tells nobody the size of a
/// repository they cannot see.
pub fn with_sizes(mut repos: Vec<Repo>, storage: Option<&crate::api::admin::Storage>) -> Vec<Repo> {
let Some(storage) = storage else {
return repos;
};
let sizes: std::collections::BTreeMap<&str, u64> = storage
.repos
.iter()
.map(|r| (r.repo.as_str(), r.hot_bytes.saturating_sub(r.explode_bytes).saturating_add(r.cold_bytes)))
.collect();
for repo in &mut repos {
repo.stored_bytes = sizes.get(repo.path.as_str()).copied();
}
repos
}
/// A namespace as the console shows it.
#[derive(Debug, Clone, Serialize)]
pub struct Namespace {
pub name: String,
pub description: String,
/// `None` when gunnar did not record one.
pub created_at_unix_ms: Option<i64>,
/// The namespace is public (its account admin said so). Private — the
/// default for every account — is shown to an outsider only through its
/// public repositories.
pub public: bool,
/// The account's emitted events are public. Private by default.
pub events_public: bool,
/// Employees its admin enrolled; an account with any is an enterprise
/// account (derived, never set).
pub employees: u32,
/// **gunnar's word for whose account this is** (thin layer, owner
/// 2026-10-04): the kind of the principal that owns it — `user` for a
/// person, `machine` / `group` / `deploy` for the estate's own parts,
/// `unspecified` for a namespace no principal owns. The browser tells the
/// box's own namespaces from people's by this, instead of joining against
/// the identity table (which a person may not read).
pub owner_kind: &'static str,
}
pub(crate) fn namespace_out(n: pb::Namespace) -> Namespace {
Namespace {
name: n.name,
description: n.description,
created_at_unix_ms: crate::api::recorded(n.created_at_unix_ms),
public: n.public,
events_public: n.events_public,
employees: n.employees,
owner_kind: crate::api::access::principal_kind_name(n.owner_kind),
}
}
fn render(repo: pb::Repo) -> Repo {
let path = repo.path.unwrap_or_default();
Repo {
path: format!("{}/{}", path.namespace, path.repo),
namespace: path.namespace,
name: path.repo,
hash_kind: hash_kind_name(repo.hash_kind),
default_branch: repo.default_branch,
visibility: visibility_name(repo.visibility),
created_at_unix_ms: crate::api::recorded(repo.created_at_unix_ms),
description: repo.description,
stored_bytes: None,
encrypted: repo.encrypted,
}
}
/// Ask gunnar for the catalogue, as whoever this request is.
///
/// Pulled out of [`list`] so the anonymous path and the session path send the
/// *same* request and shape the answer the *same* way, differing only in which
/// credential signs it. Two copies would be two chances for the anonymous one
/// to drift into asking for something wider.
async fn catalogue(reader: &Reader, search: &Search) -> Result<Vec<Repo>, ApiError> {
let response = reader
.gunnar
.repos()
// page_size is sent for the day gunnar honours it. Today it is
// ignored and the whole catalogue comes back regardless.
//
// **The search is gunnar's** (thin layer, owner 2026-10-04): the name
// query and "mirrors only" are applied by gunnar before anything is
// paged (gunnar 3f391ba), so a search finds the 251st repository too.
// Until then the browser filtered the first page it was handed.
.list(pb::ListReposRequest {
query: search.q.clone().unwrap_or_default().trim().to_owned(),
mirrored_only: search.imported.unwrap_or(false),
..Default::default()
})
.await
.map_err(ApiError::from)?
.into_inner();
let listed = response.repos.into_iter().map(render);
if !reader.guest {
return Ok(listed.collect());
}
// **The second wall, and the one place in this module where a check beside
// gunnar's is right.**
//
// Everywhere else, re-deciding what gunnar decided would be a second ACL
// that can disagree with the first, and the permissive one would win
// silently. This is not that: it can only ever remove rows, and what it
// defends against is not a bug in gunnar but a mistake in DEPLOYMENT. Point
// GUNNAR_UI_GUEST_IDENTITY at a key that was enrolled with a grant — the
// console's own, copied because it was to hand — and gunnar answers
// correctly for the principal it was asked about, which is every private
// repository on the box. `auth::routes::public_repos` has made exactly this
// argument since the guest identity existed, and the login page has been
// the only route that carried it; `/api/repos` is the same list, answered
// on the same credential, to the same visitor.
Ok(listed
.filter(|repo| repo.visibility == visibility_name(pb::Visibility::Public as i32))
.collect())
}
/// `GET /api/repos`
///
/// # The cache is skipped for a visitor with no session, and that is the point
///
/// [`crate::api::Caches::repos`] holds ONE value for the whole server with no
/// key for who asked, which was correct while there was one asker: the
/// console's own admin principal. It is now the sharpest edge in this file. A
/// guest reading it would be handed the admin's catalogue — every private
/// repository on the box — without a gRPC call being made at all, so gunnar
/// would never get the chance to refuse. The guest key would be perfectly
/// configured and perfectly bypassed.
///
/// So an anonymous request neither reads nor fills it, and pays one gRPC call
/// instead. gunnar narrows a grantless caller's `Repos.List` to the public
/// subset, so what comes back is already the answer a stranger is allowed to
/// have; the console does not filter it again here, because a second filter
/// beside gunnar's is a second ACL and the permissive one wins silently.
pub async fn list(
reader: Reader,
State(state): State<AppState>,
Query(limit): Query<Limit>,
Query(search): Query<Search>,
) -> ApiResult<Page<Repo>> {
// The shared cache holds the whole, unsearched catalogue only.
let all = if reader.may_cache() && search.is_empty() {
state.caches.repos.get(catalogue(&reader, &search)).await?
} else {
catalogue(&reader, &search).await?
};
let mut page = Page::of(all, limit.get());
// The size column. A failure to read the sizes is not a failure to list.
//
// **Asked as the reader** (thin layer, owner 2026-10-04): a session that
// signs with the console's own key reads the whole box once; a person is
// answered per repository on THEIR key (`StorageInfo{repo}` is a Read on
// that repository), so the console never reads the box to size a
// customer's list. A visitor with no session gets no size column: one
// anonymous GET must not cost gunnar a call per public repository.
if reader.may_cache() {
let sizes = crate::api::admin::storage_as(&reader, &state).await.ok();
page.items = with_sizes(page.items, sizes.as_ref());
} else if !reader.anonymous {
page.items = sized_as(&reader, page.items).await;
}
Ok(Json(page))
}
/// `?q=&imported=` on `GET /api/repos`: gunnar's name query and "mirrors only".
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Search {
#[serde(default)]
pub q: Option<String>,
#[serde(default)]
pub imported: Option<bool>,
}
impl Search {
fn is_empty(&self) -> bool {
self.q.as_deref().is_none_or(|q| q.trim().is_empty()) && !self.imported.unwrap_or(false)
}
}
/// How many rows of a person's list are sized, one `StorageInfo{repo}` each.
const SIZED_PER_LIST: usize = 64;
/// Each row's stored bytes, asked of gunnar AS the reader, one repository at a
/// time — the first [`SIZED_PER_LIST`] rows; the rest stay unsized.
async fn sized_as(reader: &Reader, mut repos: Vec<Repo>) -> Vec<Repo> {
let asks = repos.iter().take(SIZED_PER_LIST).map(|repo| {
crate::api::admin::repo_storage_as(reader, repo_path(&repo.namespace, &repo.name))
});
let rows = futures_util::future::join_all(asks).await;
for (repo, row) in repos.iter_mut().zip(rows) {
repo.stored_bytes = row.map(|r| {
r.hot_bytes
.saturating_sub(r.explode_bytes)
.saturating_add(r.cold_bytes)
});
}
repos
}
/// Every namespace, as the console's key reads it, out of the short-lived
/// shared cache.
pub async fn namespaces(state: &AppState) -> Result<Vec<Namespace>, ApiError> {
state
.caches
.namespaces
.get(async {
let response = state
.gunnar
.repos()
.list_namespaces(pb::ListNamespacesRequest::default())
.await
.map_err(ApiError::from)?
.into_inner();
Ok::<_, ApiError>(
response
.namespaces
.into_iter()
.map(namespace_out)
.collect::<Vec<_>>(),
)
})
.await
}
/// The namespaces as THIS request's reader sees them — never out of the shared
/// cache for a narrowed reader, which holds the console key's answer (every
/// namespace on the box).
async fn namespaces_as(reader: &Reader, state: &AppState) -> Result<Vec<Namespace>, ApiError> {
if reader.may_cache() {
return namespaces(state).await;
}
let listed = reader
.gunnar
.repos()
.list_namespaces(pb::ListNamespacesRequest::default())
.await
.map_err(ApiError::from)?
.into_inner()
.namespaces
.into_iter()
.map(namespace_out);
// The guest's second wall, as `catalogue` keeps one: a guest key enrolled
// with a grant by mistake must still show a visitor public namespaces only.
Ok(if reader.guest {
listed.filter(|ns| ns.public).collect()
} else {
listed.collect()
})
}
/// `GET /api/namespaces`
///
/// **Asked as the reader** (owner, 2026-10-03: namespaces are not public by
/// default). The console's own sessions (operator, admin) see the box; a
/// viewer is answered by gunnar as itself — the namespaces it is inside and
/// the public ones — and never out of the cache that holds the console's view.
pub async fn list_namespaces(
reader: Reader,
State(state): State<AppState>,
Query(limit): Query<Limit>,
) -> ApiResult<Page<Namespace>> {
let all = namespaces_as(&reader, &state).await?;
Ok(Json(Page::of(all, limit.get())))
}
/// What `/<ns>` shows: the namespace and the repositories this reader may see
/// in it.
#[derive(Debug, Clone, Serialize)]
pub struct NamespacePage {
pub name: String,
/// The namespace itself is public.
pub public: bool,
pub repos: Vec<Repo>,
}
/// `GET /api/namespaces/{namespace}/page` — **`/<ns>`** (owner, 2026-10-03).
///
/// Asked as the reader, so gunnar decides: a visitor (the guest) or an
/// outsider is shown a private namespace's PUBLIC repositories and nothing else
/// — not the private ones, not a count of them. A namespace the reader is not
/// inside, that is not public and holds no public repository, answers exactly
/// as a namespace that does not exist ([`crate::api::absent`]). A public
/// repository is reachable by its direct path either way.
pub async fn namespace_page(
reader: Reader,
State(state): State<AppState>,
Path(namespace): Path<String>,
) -> Result<axum::response::Response, ApiError> {
use axum::response::IntoResponse as _;
let listed = reader
.gunnar
.repos()
.list(pb::ListReposRequest {
namespace: namespace.clone(),
..Default::default()
})
.await;
let repos = match listed {
Ok(response) => response.into_inner().repos,
// A refusal is an answer about the name; it reads as "not here".
Err(status)
if matches!(status.code(), tonic::Code::NotFound | tonic::Code::PermissionDenied) =>
{
Vec::new()
}
Err(status) => return Err(ApiError::from(status)),
};
let mut repos: Vec<Repo> = repos
.into_iter()
.map(render)
.filter(|repo| repo.namespace == namespace)
.collect();
if reader.guest {
repos.retain(|repo| repo.visibility == visibility_name(pb::Visibility::Public as i32));
}
let shown = namespaces_as(&reader, &state)
.await?
.into_iter()
.find(|ns| ns.name == namespace);
if repos.is_empty() && shown.is_none() {
return Ok(crate::api::absent::not_here());
}
Ok(Json(NamespacePage {
public: shown.is_some_and(|ns| ns.public),
name: namespace,
repos,
})
.into_response())
}
/// `GET /api/repos/{namespace}/{name}`
///
/// Reachable with no session, through the guest identity. Nothing about the
/// handler changes for that case: gunnar's `Repos.Get` is scoped to the
/// repository and has answered it against `AuthTable::may` all along, so a
/// public repository is served and a private one is refused, by the same rule
/// that decides an SSH clone.
pub async fn get(
reader: Reader,
Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Repo> {
let response = reader
.gunnar
.repos()
.get(pb::GetRepoRequest {
path: Some(repo_path(&namespace, &name)),
})
.await
.map_err(ApiError::from)?
.into_inner();
Ok(Json(render(response.repo.unwrap_or_default())))
}
/// What the console sends to create a repository.
#[derive(Debug, Clone, Deserialize)]
pub struct NewRepo {
pub namespace: String,
pub name: String,
/// `"sha256"` or `"sha1"`. Absent means gunnar's own default, which is
/// sha256 for a new repository.
pub hash_kind: Option<String>,
pub default_branch: Option<String>,
pub visibility: Option<String>,
#[serde(default)]
pub description: String,
/// **Encrypted from the start** — the existing `Repos.SetEncrypted`,
/// signed as the same person right after the create (the account admin's
/// decision, as gunnar keeps it).
#[serde(default)]
pub encrypted: bool,
}
/// `POST /api/repos`
///
/// ★ **gunnar decides who may** (owner 2026-10-06): `Repos.Create` is signed
/// AS THE PERSON (`Actor`), so an account admin creates in their own account
/// and in a clan they administer, a writer or reader does not, and the name
/// filter, the plan's room and "only the account admin publishes" are gunnar's
/// — the console's role floor lets a viewer's session reach this handler and
/// nothing else of `/api/repos`. With `encrypted`, `Repos.SetEncrypted` is asked
/// next, as the same person; refused, the empty repository just made is purged
/// again, so a repository asked for encrypted never stands unencrypted.
pub async fn create(
actor: crate::api::Actor,State(state): State<AppState>, Json(new): Json<NewRepo>) -> ApiResult<Repo> {
let hash_kind = match new.hash_kind.as_deref() {
Some("sha1") => pb::HashKind::Sha1,
Some("sha256") => pb::HashKind::Sha256,
_ => pb::HashKind::Unspecified,
};
let visibility = new
.visibility
.as_deref()
.and_then(visibility_from_name)
.unwrap_or(pb::Visibility::Unspecified);
let response = actor
.gunnar
.repos()
.create(pb::CreateRepoRequest {
path: Some(repo_path(&new.namespace, &new.name)),
hash_kind: hash_kind as i32,
default_branch: new.default_branch.unwrap_or_default(),
visibility: visibility as i32,
description: new.description,
// A create from the console never adopts a directory it did not
// make (gunnar's own default, said out loud here): adopting takes
// responsibility for objects somebody else wrote, which is an
// operator's decision at the control plane, not a side effect of
// pressing New repository.
adopt: false,
// The console's New repository makes an empty one; a server-written first
// commit is the sale's welcome repository's (gunnar `Repos.Create{readme}`).
readme: String::new(),
})
.await
.map_err(ApiError::from)?
.into_inner();
let mut repo = response.repo.unwrap_or_default();
if new.encrypted {
let path = repo_path(&new.namespace, &new.name);
match actor
.gunnar
.repos()
.set_encrypted(pb::SetEncryptedRequest { path: Some(path.clone()), encrypted: true })
.await
{
Ok(answer) => {
if let Some(marked) = answer.into_inner().repo {
repo = marked;
}
}
Err(status) => {
let undone = actor
.gunnar
.repos()
.delete(pb::DeleteRepoRequest {
path: Some(path),
purge: true,
retention_days: 0,
zero_retention: true,
})
.await;
state.caches.invalidate_repos().await;
let mut fail = ApiError::from(status);
fail.0.message = match undone {
Ok(_) => format!("{} — the repository was not kept: it is not encrypted", fail.0.message),
Err(e) => format!(
"{} — and the empty repository could not be removed again ({}): it exists, NOT encrypted",
fail.0.message,
e.message()
),
};
return Err(fail);
}
}
}
state.caches.invalidate_repos().await;
Ok(Json(render(repo)))
}
/// Where this session's New repository may create, in gunnar's words.
#[derive(Debug, Clone, Serialize)]
pub struct Creatable {
/// An operator's (or an open console's) session: the namespace is typed,
/// as before, and gunnar decides at the create.
pub any_namespace: bool,
/// The accounts gunnar says this session's person administers — their own,
/// one they hold Admin over, a clan of theirs — each one publishable (only
/// the account admin publishes, and these are theirs).
pub namespaces: Vec<CreatableNamespace>,
}
#[derive(Debug, Clone, Serialize)]
pub struct CreatableNamespace {
pub name: String,
pub public: bool,
}
/// At most this many namespaces are asked about: a person's own account, the
/// accounts their grants name and their clans — a handful, never the estate.
const CREATABLE_ASKED: usize = 32;
/// `GET /api/repos/creatable` — where may this session's New repository create?
///
/// gunnar answers it, about THIS session's person (`Access.Check`, Admin over
/// the namespace — the account-admin rule `Repos.Create` keeps), for the
/// namespaces that could be theirs: their own account, the namespaces their own
/// grants name, and their clans. Nothing is decided here; a namespace gunnar
/// does not confirm is not offered. A GET, so a viewer's.
pub async fn creatable(
State(state): State<AppState>,
caller: Option<axum::Extension<crate::auth::middleware::Caller>>,
) -> ApiResult<Creatable> {
use crate::role::Role;
let caller = caller.map(|c| c.0);
let any_namespace = match &caller {
Some(caller) => caller.role().allows(Role::Operator),
None => !state.passkey_enabled() && state.admin_token.is_none(),
};
let Ok(actor) = crate::api::Actor::of(&state, caller.as_ref()).await else {
return Ok(Json(Creatable { any_namespace, namespaces: Vec::new() }));
};
let mut candidates = std::collections::BTreeSet::new();
if let Some(me) = actor.principal.clone() {
if let Ok(answer) = actor.gunnar.access().list_grants(pb::ListGrantsRequest { principal_id: me.clone(), ..Default::default() }).await {
candidates.extend(
answer
.into_inner()
.grants
.into_iter()
.filter(|g| g.principal_id == me && !g.namespace.is_empty() && g.repo.is_empty())
.map(|g| g.namespace),
);
}
if let Ok(answer) = actor.gunnar.clans().list(pb::ListClansRequest {}).await {
candidates.extend(answer.into_inner().clans.into_iter().map(|c| c.name));
}
candidates.insert(me);
}
let mut namespaces = Vec::new();
for ns in candidates.into_iter().take(CREATABLE_ASKED) {
if let Ok((true, _)) = crate::api::may(&actor.gunnar, &ns, "", pb::GrantLevel::Admin).await {
namespaces.push(CreatableNamespace { name: ns, public: true });
}
}
Ok(Json(Creatable { any_namespace, namespaces }))
}
/// `DELETE /api/repos/{namespace}/{name}`
pub async fn delete(
actor: crate::api::Actor,
State(state): State<AppState>,
Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Done> {
actor
.gunnar
.repos()
.delete(pb::DeleteRepoRequest {
path: Some(repo_path(&namespace, &name)),
// A SOFT delete, on purpose. The console's delete button is one
// click, and gunnar's tombstone is what makes that click
// undoable: `Repos.Undelete` puts the repository back until the
// retention window closes. Purging is deliberate and belongs at
// the control plane.
purge: false,
// Unset together with `zero_retention: false` means "the server's
// own retention window" — the proto spells this out because a
// plain 0 is what a client sends when it means nothing at all, and
// reading that as "reclaim immediately" would turn every default
// delete into a purge.
retention_days: 0,
zero_retention: false,
})
.await
.map_err(ApiError::from)?;
state.caches.invalidate_repos().await;
Ok(Json(Done { ok: true }))
}
/// ★ **What the Danger zone draws** (owner spec 2026-10-28): may THIS person
/// delete the repository, and for how many days can it be restored.
///
/// Nothing is decided here. `may_delete` is gunnar's `Access.Check` at Admin on
/// the repository, asked as the person (`Repos.Delete`'s own rule: Admin over
/// the repository or its namespace); `retention_days` is gunnar's default window
/// (`ListDeletedResponse.default_retention_days`, the namespace's listing asked
/// as the same person), 0 where gunnar does not say. The DELETE re-asks.
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Deletable {
pub may_delete: bool,
/// Why not, with gunnar's reason in it, when `may_delete` is false.
pub why: Option<String>,
pub retention_days: u32,
}
/// The sentence for a person gunnar does not let delete, before gunnar's reason.
pub fn not_deletable_words(path: &str) -> String {
format!("Only an admin of {path} or of its account may delete it.")
}
/// `GET /api/repos/{namespace}/{name}/deletable`. A GET, so a viewer's to ask
/// (`role_floor`); not on the anonymous allow-list, so a visitor never reaches it.
pub async fn deletable(
State(state): State<AppState>,
caller: Option<axum::Extension<crate::auth::middleware::Caller>>,
Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Deletable> {
let path = format!("{namespace}/{name}");
let refused = |reason: &str| Deletable {
may_delete: false,
why: Some(format!("{} {reason}", not_deletable_words(&path)).trim_end().to_owned()),
retention_days: 0,
};
let actor = match crate::api::Actor::of(&state, caller.as_ref().map(|c| &c.0)).await {
Ok(actor) => actor,
// No person to ask as: the refusal says which.
Err(why) => return Ok(Json(refused(&why.0.message))),
};
let (allowed, reason) = crate::api::may(&actor.gunnar, &namespace, &name, pb::GrantLevel::Admin).await?;
if !allowed {
return Ok(Json(refused(&reason)));
}
let retention_days = actor
.gunnar
.repos()
.list_deleted(pb::ListDeletedRequest { namespace: namespace.clone() })
.await
.map(|answer| answer.into_inner().default_retention_days)
.unwrap_or(0);
Ok(Json(Deletable { may_delete: true, why: None, retention_days }))
}
/// ★ **One soft-deleted repository, and the undo that is still open on it.**
///
/// The console's delete is a soft delete (`purge: false`), so every one of
/// those clicks is undoable until the retention window closes — and until now
/// the only way to undo one was `gunnar control repo undelete` on a box whose
/// PID 1 has no shell. A delete with an undo nobody can reach is a delete.
#[derive(Debug, Clone, Serialize)]
pub struct DeletedRepo {
/// `<namespace>/<repo>`, the name the undo puts back.
pub path: String,
pub namespace: String,
pub name: String,
pub deleted_at_unix_ms: i64,
/// When a sweep may reclaim the bytes. `None` = no deadline, which is what
/// a reclaim that FAILED leaves behind: bytes owed to an operator rather
/// than bytes on a schedule.
pub purge_after_unix_ms: Option<i64>,
/// Who asked for the delete. Empty when gunnar did not record one.
pub principal_id: String,
/// Bytes still on disk, and still on the bill.
pub bytes: u64,
}
/// `GET /api/repos/deleted`
///
/// `Admin.StorageInfo` already carries the tombstoned total, so Storage could
/// say "N GB held by deleted repositories" without this. It could not say WHO
/// deleted them, and — the half that matters — a row here is what the undo
/// button hangs on.
///
/// **Asked as the reader** (thin layer, owner 2026-10-04): gunnar answers an
/// every-namespace `ListDeleted` to a server-wide Read grant only, so who
/// deleted what, and how many bytes it still holds, is not handed to a
/// customer's session on the console's key.
pub async fn list_deleted(
reader: Reader,
Query(limit): Query<Limit>,
) -> ApiResult<Page<DeletedRepo>> {
let response = reader
.gunnar
.repos()
// Empty namespace: every namespace. The console is the whole server's
// window, and a per-namespace filter with nothing to filter by would
// only hide rows.
.list_deleted(pb::ListDeletedRequest::default())
.await
.map_err(ApiError::from)?
.into_inner();
let all: Vec<DeletedRepo> = response
.repos
.into_iter()
.map(|row| {
let path = row.repo.unwrap_or_default();
DeletedRepo {
path: format!("{}/{}", path.namespace, path.repo),
namespace: path.namespace,
name: path.repo,
deleted_at_unix_ms: row.deleted_at_unix_ms,
purge_after_unix_ms: crate::api::recorded(row.purge_after_unix_ms),
principal_id: row.principal_id,
bytes: row.bytes,
}
})
.collect();
Ok(Json(Page::of(all, limit.get())))
}
/// `POST /api/repos/{namespace}/{name}/undelete`
///
/// The undo. It answers with the repository as gunnar restored it — the
/// recorded hash kind above all, which is why the row is put back rather than
/// re-created: a sha256 row over a sha1 store serves refs no client can read.
pub async fn undelete(
actor: crate::api::Actor,
State(state): State<AppState>,
Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Repo> {
let response = actor
.gunnar
.repos()
.undelete(pb::UndeleteRepoRequest {
path: Some(repo_path(&namespace, &name)),
})
.await
.map_err(ApiError::from)?
.into_inner();
state.caches.invalidate_repos().await;
Ok(Json(render(response.repo.unwrap_or_default())))
}
/// ★ **A store directory with no catalog row and no tombstone**: bytes nothing
/// else on the box can see, under a name nothing else can free.
///
/// The console SHOWS them and offers no button, deliberately. Both exits —
/// `ReclaimOrphan` and `Create --adopt` — take an irreversible decision about
/// objects somebody wrote, and they belong to an operator at the control plane.
/// Invisible bytes are a billing surprise; a one-click reclaim of them is a
/// worse one.
#[derive(Debug, Clone, Serialize)]
pub struct Orphan {
pub path: String,
pub namespace: String,
pub name: String,
/// Where it is on the appliance's disk.
pub dir: String,
pub bytes: u64,
/// `"sha1"`, `"sha256"`, or empty for a store written before gunnar wrote
/// the marker. Empty is what `Create --adopt` has to be told.
pub hash_marker: String,
/// Newest mtime anywhere under it: Friday's wreckage against something a
/// push is still writing to.
pub modified_unix_ms: Option<i64>,
/// Non-empty when a tombstone says a RECLAIM FAILED here — bytes owed to an
/// operator rather than bytes nobody asked about.
pub failed_reclaim: String,
}
/// `GET /api/orphans`
///
/// The third listing. `List` reads the catalog and an orphan is not in it;
/// `ListDeleted` keys off a tombstone and an orphan has none. Together the
/// three account for every `<name>.git` directory under the repository root,
/// which is the only way an operator can reconcile what a disk holds against
/// what a bill says.
///
/// Asked as the reader, as [`list_deleted`] is: the box's orphan stores are a
/// server-wide Read in gunnar.
pub async fn list_orphans(
reader: Reader,
Query(limit): Query<Limit>,
) -> ApiResult<Page<Orphan>> {
let response = reader
.gunnar
.repos()
.list_orphans(pb::ListOrphansRequest::default())
.await
.map_err(ApiError::from)?
.into_inner();
let all: Vec<Orphan> = response
.orphans
.into_iter()
.map(|row| {
let path = row.repo.unwrap_or_default();
Orphan {
path: format!("{}/{}", path.namespace, path.repo),
namespace: path.namespace,
name: path.repo,
dir: row.dir,
bytes: row.bytes,
hash_marker: row.hash_marker,
modified_unix_ms: crate::api::recorded(row.modified_unix_ms),
failed_reclaim: row.failed_reclaim,
}
})
.collect();
Ok(Json(Page::of(all, limit.get())))
}
/// What the console sends to change a repository's settings.
#[derive(Debug, Clone, Deserialize)]
pub struct RepoSettings {
pub default_branch: Option<String>,
pub visibility: Option<String>,
/// Turn the repository's "encrypted" flag on or off (the account admin's).
#[serde(default)]
pub encrypted: Option<bool>,
}
/// `PATCH /api/repos/{namespace}/{name}`
///
/// gunnar has one RPC per field, so this makes up to two calls and returns the
/// repository as it stands afterwards.
///
/// **A visibility change is the ACCOUNT admin's** (gunnar's default for every
/// account), and since 2026-10-04 (thin layer) gunnar is the one that says so:
/// `Repos.SetVisibility` is signed AS THE PERSON ([`crate::api::Actor`]) and
/// gunnar's account-admin rule (Admin over the namespace, the account gate
/// honoured) decides. It is asked FIRST, so a refused request changes nothing
/// at all, the default branch included.
pub async fn update(
State(state): State<AppState>,
caller: Option<axum::Extension<crate::auth::middleware::Caller>>,
Path((namespace, name)): Path<(String, String)>,
Json(settings): Json<RepoSettings>,
) -> ApiResult<Repo> {
let path = repo_path(&namespace, &name);
let mut repo = None;
// Every call here is the person's (thin layer, owner 2026-10-04): their
// own key, and gunnar's grants decide.
let actor = crate::api::Actor::of(&state, caller.as_ref().map(|c| &c.0)).await?;
if let Some(visibility) = settings
.visibility
.as_deref()
.and_then(visibility_from_name)
{
repo = actor
.gunnar
.repos()
.set_visibility(pb::SetVisibilityRequest {
path: Some(path.clone()),
visibility: visibility as i32,
})
.await
.map_err(|status| {
if status.code() == tonic::Code::PermissionDenied {
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "visibility.refused",
user = %actor.who,
account = %namespace,
reason = %status.message(),
"gunnar refused a visibility change asked as the person"
);
}
ApiError::from(status)
})?
.into_inner()
.repo;
}
// **The "encrypted" flag is the account admin's too** (gunnar 2026-10-05,
// the same rule as visibility): `Repos.SetEncrypted`, signed as the
// person, asked before the default branch so a refusal changes nothing
// that came after it. The console's one door to it on an installed
// estate, whose control plane is bound to its WireGuard overlay.
if let Some(encrypted) = settings.encrypted {
repo = actor
.gunnar
.repos()
.set_encrypted(pb::SetEncryptedRequest {
path: Some(path.clone()),
encrypted,
})
.await
.map_err(|status| {
if status.code() == tonic::Code::PermissionDenied {
tracing::info!(
target: crate::auth::AUDIT_TARGET,
event = "encrypted.refused",
user = %actor.who,
account = %namespace,
reason = %status.message(),
"gunnar refused an encrypted-flag change asked as the person"
);
}
ApiError::from(status)
})?
.into_inner()
.repo;
}
if let Some(branch) = settings.default_branch.filter(|b| !b.is_empty()) {
repo = actor
.gunnar
.repos()
.set_default_branch(pb::SetDefaultBranchRequest {
path: Some(path.clone()),
default_branch: branch,
})
.await
.map_err(ApiError::from)?
.into_inner()
.repo;
}
let repo = match repo {
Some(repo) => repo,
// Nothing was asked for. Read it back rather than answer with a shape
// the console would have to special-case.
None => actor
.gunnar
.repos()
.get(pb::GetRepoRequest { path: Some(path) })
.await
.map_err(ApiError::from)?
.into_inner()
.repo
.unwrap_or_default(),
};
state.caches.invalidate_repos().await;
Ok(Json(render(repo)))
}
/// What the console sends to create a namespace.
#[derive(Debug, Clone, Deserialize)]
pub struct NewNamespace {
pub name: String,
#[serde(default)]
pub description: String,
}
/// `POST /api/namespaces`
pub async fn create_namespace(
actor: crate::api::Actor,
State(state): State<AppState>,
Json(new): Json<NewNamespace>,
) -> ApiResult<Namespace> {
let response = actor
.gunnar
.repos()
.create_namespace(pb::CreateNamespaceRequest {
name: new.name,
description: new.description,
})
.await
.map_err(ApiError::from)?
.into_inner();
let namespace = response.namespace.unwrap_or_default();
state.caches.invalidate_repos().await;
Ok(Json(namespace_out(namespace)))
}
/// `?cascade=true` on a namespace delete.
#[derive(Debug, Clone, Deserialize)]
pub struct Cascade {
#[serde(default)]
pub cascade: bool,
}
/// `DELETE /api/namespaces/{name}`
///
/// Without `?cascade=true` gunnar refuses a namespace that still holds
/// repositories, which is the refusal the console wants: deleting repositories
/// as a side effect of tidying a name is not something to do quietly.
pub async fn delete_namespace(
actor: crate::api::Actor,
State(state): State<AppState>,
Path(name): Path<String>,
Query(cascade): Query<Cascade>,
) -> ApiResult<Deleted> {
let response = actor
.gunnar
.repos()
.delete_namespace(pb::DeleteNamespaceRequest {
name,
delete_repos: cascade.cascade,
})
.await
.map_err(ApiError::from)?
.into_inner();
state.caches.invalidate_repos().await;
Ok(Json(Deleted {
repos_deleted: response.repos_deleted,
}))
}
/// A mutation that carries nothing back.
#[derive(Debug, Clone, Serialize)]
pub struct Done {
pub ok: bool,
}
/// How much a cascading delete removed.
#[derive(Debug, Clone, Serialize)]
pub struct Deleted {
pub repos_deleted: u32,
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ **The repository list carries one size column, and only for the
/// repositories the caller was listed** (owner, 2026-10-02).
#[test]
fn the_list_gets_a_size_for_each_listed_repository_and_no_other() {
let mine = |path: &str| Repo {
path: path.into(),
namespace: path.split('/').next().unwrap().into(),
name: path.split('/').nth(1).unwrap().into(),
hash_kind: "sha256",
default_branch: "refs/heads/main".into(),
visibility: "private",
created_at_unix_ms: None,
description: String::new(),
stored_bytes: None,
encrypted: false,
};
let row = |repo: &str, hot: u64, explode: u64, cold: u64| crate::api::admin::RepoStorage {
repo: repo.into(),
hash_kind: "sha256",
loose_object_count: 0,
hot_bytes: hot,
cold_bytes: cold,
cold_object_count: 0,
cold_measured: false,
last_repack_at_unix_ms: None,
ref_count: 0,
explode_bytes: explode,
explode_budget_bytes: 0,
explode_evicted_bytes: 0,
};
let storage = crate::api::admin::Storage {
object_backend: String::new(),
ref_backend: String::new(),
engine: None,
total_hot_bytes: 0,
total_cold_bytes: 0,
cold_measured: false,
repos: vec![row("anna/a", 100, 40, 0), row("olof/secret", 9_000, 0, 0)],
total_explode_bytes: 0,
total_explode_budget_bytes: 0,
total_tombstoned_bytes: 0,
tombstoned: Vec::new(),
};
let listed = with_sizes(vec![mine("anna/a"), mine("anna/new")], Some(&storage));
assert_eq!(listed.iter().map(|r| (r.path.as_str(), r.stored_bytes)).collect::<Vec<_>>(), [("anna/a", Some(60)), ("anna/new", None)]);
assert_eq!(listed.len(), 2, "a size never adds a repository to the list");
assert!(with_sizes(vec![mine("anna/a")], None)[0].stored_bytes.is_none(), "no sizes, no column values");
}
#[test]
fn a_repository_renders_with_its_ssh_path() {
let repo = render(pb::Repo {
path: Some(repo_path("vetra", "gunnar")),
hash_kind: pb::HashKind::Sha256 as i32,
default_branch: "refs/heads/main".into(),
visibility: pb::Visibility::Private as i32,
created_at_unix_ms: 1,
description: "the forge".into(),
encrypted: false,
});
assert_eq!(repo.path, "vetra/gunnar");
assert_eq!(repo.namespace, "vetra");
assert_eq!(repo.name, "gunnar");
assert_eq!(repo.hash_kind, "sha256");
assert_eq!(repo.visibility, "private");
}
#[test]
fn a_repository_without_a_path_does_not_panic() {
// gunnar always sends one, but a proto3 message field is optional on
// the wire and a missing one must not take the console down.
let repo = render(pb::Repo::default());
assert_eq!(repo.path, "/");
}
}
/// ★ **A maintenance job, as the console shows it.**
///
/// The console renders `state` and nothing else decides what it draws: a
/// `queued` job shows its place in the line, a `done` job shows what it freed,
/// a `failed` job shows why. `bytes_reclaimed` is deliberately `None` unless
/// the job is `done` — a zero on a running job reads as "it freed nothing",
/// which is a different and wrong claim.
#[derive(Debug, Clone, Serialize)]
pub struct GcJob {
/// Stable for the life of the job, so a poller can tell its own run from
/// somebody else's that started after it.
pub job_id: String,
pub path: String,
/// `queued` | `running` | `done` | `failed` | `unknown`.
pub state: &'static str,
/// How many jobs are ahead of this one. Only meaningful while `queued`.
pub queue_position: u32,
/// Bytes that left the filesystem. `None` unless `done` — see above.
pub bytes_reclaimed: Option<u64>,
/// Only set when `failed`.
pub error: Option<String>,
pub enqueued_at_unix_ms: Option<i64>,
pub finished_at_unix_ms: Option<i64>,
}
fn gc_state_name(state: i32) -> &'static str {
match pb::GcState::try_from(state) {
Ok(pb::GcState::Queued) => "queued",
Ok(pb::GcState::Running) => "running",
Ok(pb::GcState::Done) => "done",
Ok(pb::GcState::Failed) => "failed",
// An enum value this build does not know is not the same as "no job",
// and rendering it as `queued` would make a console poll forever.
_ => "unknown",
}
}
fn render_gc(job: pb::GcJob) -> GcJob {
let path = job.path.unwrap_or_default();
let state = gc_state_name(job.state);
GcJob {
job_id: job.job_id,
path: format!("{}/{}", path.namespace, path.repo),
state,
queue_position: job.queue_position,
// Only DONE carries a meaningful figure; every other state would be
// reporting the zero-initialised field as a measurement.
bytes_reclaimed: (state == "done").then_some(job.bytes_reclaimed),
error: (!job.error.is_empty()).then(|| job.error.clone()),
enqueued_at_unix_ms: crate::api::recorded(job.enqueued_at_unix_ms),
finished_at_unix_ms: crate::api::recorded(job.finished_at_unix_ms),
}
}
/// ★ **`POST /api/repos/{namespace}/{name}/gc` — the console's gc button.**
///
/// **This ENQUEUES.** It returns as soon as the server has taken the job, not
/// when the gc has run: a gc unlinks the whole exploded read cache, which on a
/// large repository is hundreds of thousands of files and pure I/O. The server
/// runs one gc at a time globally and queues the rest, so this answers with the
/// job's place in the line and the page polls [`gc_status`] for the rest.
///
/// Pressing the button twice does not enqueue twice — the server returns the
/// job that is already there. That is the server's guarantee and not this
/// handler's: a console can only serialise itself, and the CLI holds the same
/// credential.
///
/// Authorised at **Write**, not Admin. The argument is NOT "it destroys
/// nothing" — that was corrected in gunnar-ui `bd0b244`: `admin::gc` runs
/// `Engine::maintain` = `seal` -> `store.gc()` -> `drop_explode_cache`, and the
/// compaction leg really does reduce `pack_bytes`. The argument that survives
/// is narrower and still holds: nothing on that path destroys data that is not
/// re-derivable, and `--prune` is inert (`let _ = &args.prune;`). So an owner
/// may reclaim their own repository.
pub async fn gc(
actor: crate::api::Actor,
Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<GcJob> {
let job = actor
.gunnar
.repos()
.gc(pb::GcRepoRequest {
path: Some(repo_path(&namespace, &name)),
})
.await
.map_err(ApiError::from)?
.into_inner()
.job
// A success with no job is a contract violation, not an empty result:
// `Gc` promises the job it enqueued or the one already there.
.ok_or_else(|| {
ApiError(crate::grpc::Fail {
code: "internal".to_owned(),
message: "the server enqueued a gc but reported no job".to_owned(),
})
})?;
Ok(Json(render_gc(job)))
}
/// ★ **`GET /api/repos/{namespace}/{name}/gc` — where that gc got to.**
///
/// `null` when the server holds no job for this repository: none was ever asked
/// for, or the last one finished long enough ago to have been dropped. Absence
/// and "it did nothing" are different answers and this is which — so the page
/// can show an idle button rather than a permanent zero.
///
/// Authorised at **Read**: seeing that maintenance ran is not asking for it.
///
/// Asked as the reader: gunnar's Read on THIS repository decides, so another
/// account's maintenance is not a customer's to watch.
pub async fn gc_status(
reader: Reader,
Path((namespace, name)): Path<(String, String)>,
) -> ApiResult<Option<GcJob>> {
let job = reader
.gunnar
.repos()
.gc_status(pb::GcStatusRequest {
path: Some(repo_path(&namespace, &name)),
})
.await
.map_err(ApiError::from)?
.into_inner()
.job;
Ok(Json(job.map(render_gc)))
}