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

nordisk/gunnar-ui

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