crates/gunnar-ui-server/src/api/actions.rs
//! **Åtgärder — what this account has to do something about.**
//!
//! # Why a queue and not a step in the signup
//!
//! The obvious place to tell somebody they need an SSH key is while they are
//! buying. It is also the worst place: a purchase is the one flow where an
//! extra required step turns money into no money, and the estate's own rule is
//! that a capacity refusal is free only BEFORE the card. Nothing that can fail,
//! confuse or block belongs between a buyer and a checkout.
//!
//! So the telling happens after, and continuously: one queue, one headline the
//! console can carry at the top of every page, and a person deals with it when
//! they have a minute. Owner's shape, 2026-09-18 — *"what web-gui tells people
//! who don't have a public key … a queue whose headline is visible in the top,
//! where things like payment imminent, or your disk is 85 % full, live"*.
//!
//! # What may be in it
//!
//! Only things about the CALLER'S OWN account, and only things they can act on.
//! An operator's problem is not an åtgärd — it belongs on `/admin`, where the
//! person who can fix it is already standing. A queue that mixes the two
//! teaches everybody to ignore it.
//!
//! Every item carries where to GO, because an instruction with no door is a
//! complaint. Nothing here composes a price, a size or a vendor name: the
//! money-shaped items say only that something is due and point at the pane that
//! owns those words, which keeps this file free of them and therefore scanned.
//!
//! # Severity is about time, not about volume
//!
//! `Urgent` means *something is already broken or about to stop working*;
//! `Soon` means *this will be urgent if ignored*; `Info` means *worth knowing,
//! nothing is wrong*. The console shows a headline only for the first two — an
//! `Info` that raised a banner would be the reason nobody reads the banner.
use axum::extract::State;
use axum::Json;
use serde::Serialize;
use crate::api::account::Me;
use crate::api::ApiResult;
use crate::app::AppState;
/// How much of a hurry this is in. See the module's last paragraph.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Urgency {
/// Worth knowing; nothing is wrong.
Info,
/// Will become urgent if ignored.
Soon,
/// Already broken, or about to stop working.
Urgent,
}
/// One thing to do.
#[derive(Debug, Clone, Serialize)]
pub struct Action {
/// A stable slug. The console keys off THIS and never off the title, so
/// rewording an item cannot silently change which one a page is treating
/// specially.
pub id: &'static str,
pub urgency: Urgency,
/// One line, in the imperative where there is something to do.
pub title: String,
/// The why, in a sentence or two.
pub detail: String,
/// Where the doing happens. Always set: an instruction with no door is a
/// complaint.
pub href: String,
/// The label for the link, when a generic one would be worse.
pub cta: String,
}
/// The queue, newest concern first.
#[derive(Debug, Clone, Serialize)]
pub struct Actions {
pub items: Vec<Action>,
/// **The one line the console may carry at the top of every page**, or
/// `None` when nothing needs saying. Computed here rather than in the
/// browser so that the headline and the list can never disagree about
/// what is most pressing.
pub headline: Option<String>,
}
impl Actions {
fn of(mut items: Vec<Action>) -> Actions {
// Most urgent first, and stable within a rung so the order does not
// dance between polls.
items.sort_by_key(|a| std::cmp::Reverse(a.urgency));
let headline = items
.iter()
.find(|i| i.urgency >= Urgency::Soon)
.map(|i| match items.iter().filter(|o| o.urgency >= Urgency::Soon).count() {
1 => i.title.clone(),
n => format!("{} · and {} more", i.title, n - 1),
});
Actions { items, headline }
}
}
/// `GET /api/account/actions`
///
/// Under `/api/account/`, which `role_floor` puts at `Viewer`, and for the
/// reason every other route there is: it acts on the name in the SESSION, which
/// no path segment, query parameter or body field can change. A viewer may read
/// this because the only account it can describe is their own.
///
/// **Every check is best-effort.** A queue that 500s because one of its sources
/// is down is worse than a queue missing an item: the page it feeds is every
/// page. So a check that cannot answer contributes nothing and says so in the
/// log, rather than failing the request.
pub async fn list(
Me(user): Me,
State(state): State<AppState>,
caller: Option<axum::Extension<crate::auth::middleware::Caller>>,
) -> ApiResult<Actions> {
let mut items = Vec::new();
// ── A Free account cannot push at all ───────────────────────────────────
//
// Said plainly, with the way to room (owner order 2026-10-18), and INSTEAD
// of the key item below: "add your key so you can push" was a promise a
// Free account's key cannot keep.
#[cfg(feature = "metered")]
let free = crate::api::subscription::free_cannot_push(&state, caller.as_ref().map(|c| &c.0)).await;
#[cfg(not(feature = "metered"))]
let free = {
let _ = &caller;
false
};
if free {
#[cfg(feature = "metered")]
items.push(crate::api::subscription::free_cannot_push_item());
}
// ── You cannot push yet ─────────────────────────────────────────────────
//
// The first thing a new account needs and the last thing anybody thinks to
// tell them: gunnar authenticates the KEY over SSH and has never heard of
// the name they log into the console with. Until 2026-09-18 a paid account
// could not add one at all (`api::account::add`'s attestation), so this
// item would have been an instruction nobody could carry out — it is only
// honest to raise it now that the door works.
match if free { Ok(1) } else { own_key_count(&state, &user).await } {
Ok(0) => items.push(Action {
id: "no-ssh-key",
urgency: Urgency::Urgent,
title: "Add your SSH key so you can push".to_owned(),
detail: "Git signs in with an SSH key, not with your account name. \
Until you add one, a clone or a push over SSH is refused — \
everything else here works."
.to_owned(),
href: "/account#keys".to_owned(),
cta: "Add a key".to_owned(),
}),
Ok(_) => {}
Err(why) => skipped("no-ssh-key", &user, &why),
}
// The items that exist only where this deployment sells. Their words live
// in the metered module, which is what keeps this file free of them.
#[cfg(feature = "metered")]
crate::api::subscription::actions_for(&state, &user, &mut items).await;
Ok(Json(Actions::of(items)))
}
/// How many keys this person holds on their own principal.
async fn own_key_count(state: &AppState, user: &str) -> Result<usize, String> {
let Some(principal) = state
.webauthn
.as_ref()
.and_then(|webauthn| webauthn.principal_of(user))
else {
// No principal pinned: an open console, or a session that predates
// pinning. Nothing can be said about keys that are not addressable.
return Err("no principal is bound to this session".to_owned());
};
// **Asked AS THE PERSON** (thin layer, owner 2026-10-04): gunnar's
// self-scope rule answers a principal's own key list to that principal.
let identity = crate::api::person_identity(state, user)
.await
.map_err(|why| why.to_string())?
.ok_or("this sign-in holds no key at Gunnar any more")?;
// A person's identity-core session holds no key at gunnar (IDENTITY-HOST);
// a test's planted custody key is the one key here that is not theirs.
let sessions: Vec<String> = Vec::new();
let listed = identity
.gunnar
.access()
.list_keys(crate::pb::ListKeysRequest {
principal_id: principal.clone(),
})
.await
.map_err(|s| s.to_string())?
.into_inner();
// **The session keys do not count.** gunnar enrolled them on this
// principal at the person's logins, and counting them would tell somebody
// with no key of their own that they are ready to push. Told apart by
// FINGERPRINT — the keys this console signs with for them — not by a
// comment string.
Ok(listed
.keys
.iter()
.filter(|k| {
k.principal_id == principal
&& k.fingerprint != identity.fingerprint
&& !sessions.contains(&k.fingerprint)
})
.count())
}
/// A check that could not answer contributes nothing, and says so once.
fn skipped(id: &str, user: &str, why: &str) {
tracing::debug!(
target: crate::auth::AUDIT_TARGET,
event = "actions.check_skipped",
check = %id,
user = %user,
reason = %why,
"one of this account's checks could not answer, so it contributed no item"
);
}
#[cfg(test)]
mod tests {
use super::*;
fn item(id: &'static str, urgency: Urgency) -> Action {
Action {
id,
urgency,
title: format!("{id} needs doing"),
detail: String::new(),
href: "/account".to_owned(),
cta: String::new(),
}
}
/// **The headline is the most pressing thing, and it counts the rest.** It
/// is computed here and not in the browser so the banner and the list can
/// never disagree about what matters most.
#[test]
fn the_headline_leads_with_the_worst_and_counts_the_others() {
let a = Actions::of(vec![
item("soon-one", Urgency::Soon),
item("urgent-one", Urgency::Urgent),
item("soon-two", Urgency::Soon),
]);
assert_eq!(a.items[0].id, "urgent-one", "the list is not worst-first");
assert_eq!(
a.headline.as_deref(),
Some("urgent-one needs doing · and 2 more"),
"{:?}",
a.headline
);
}
/// One thing is one thing, not "and 0 more".
#[test]
fn a_single_concern_does_not_count_its_friends() {
let a = Actions::of(vec![item("only", Urgency::Urgent)]);
assert_eq!(a.headline.as_deref(), Some("only needs doing"));
}
/// ★ **An `Info` raises no banner.** An item that interrupted every page to
/// say nothing is wrong is the reason nobody reads the banner at all — so
/// the queue may carry it and the headline may not.
#[test]
fn nothing_urgent_means_no_headline() {
let a = Actions::of(vec![item("fyi", Urgency::Info), item("also-fyi", Urgency::Info)]);
assert!(a.headline.is_none(), "an info item raised a banner: {:?}", a.headline);
assert_eq!(a.items.len(), 2, "it was also dropped from the list");
}
/// An empty queue is an empty queue, not a cheerful message. Nothing is the
/// correct thing to show somebody with nothing to do.
#[test]
fn an_empty_queue_says_nothing() {
let a = Actions::of(Vec::new());
assert!(a.items.is_empty() && a.headline.is_none());
}
/// Every item must carry a door. An instruction with nowhere to go is a
/// complaint, and this is the one property of an item that cannot be seen
/// by reading any single call site.
#[test]
fn every_item_this_module_can_raise_carries_a_door() {
for id in ["no-ssh-key", "console-key-unvouched"] {
let a = item(id, Urgency::Urgent);
assert!(!a.href.is_empty(), "{id} has nowhere to go");
}
}
}