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

nordisk/monetize

crates/monetize-ui-server/src/plugins.rs

//! **THE BOX'S PLUGINS** (owner 2026-10-17) — the console's "Update plugins": the release's
//! signed plugins taken from the release bucket by monetize as a QUEUED job (`Plugins.Update`),
//! and where that job stands (`Plugins.Status`). Thin: monetize fetches, verifies, writes and
//! says per plugin what became of it; this side only carries it.
//!
//! ```text
//! POST /api/plugins/update {apply}   → PluginJobJson   (queued at once; never one long call)
//! GET  /api/plugins/status?job=…     → PluginJobJson   (empty job: the newest)
//! ```
//!
//! The POST is a mutation under `/api/`: the console's own gate floors it at admin with a
//! fresh passkey touch; monetize refuses anyone but the operator a second time.

use axum::extract::State;
use axum::Json;
use monetize_proto as pb;
use serde::{Deserialize, Serialize};

use crate::api::ApiError;
use crate::app::AppState;
use crate::grpc::Fail;

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginRowJson {
    pub name: String,
    pub role: String,
    pub outcome: String,
    pub detail: String,
    pub blake3: String,
}

#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginJobJson {
    pub job: String,
    pub state: String,
    pub apply: bool,
    pub rows: Vec<PluginRowJson>,
    pub error: String,
}

/// Pure: the job for the browser, as monetize said it.
pub fn job_json(j: pb::PluginUpdateJob) -> PluginJobJson {
    PluginJobJson {
        job: j.job,
        state: j.state,
        apply: j.apply,
        rows: j.rows.into_iter().map(|r| PluginRowJson { name: r.name, role: r.role, outcome: r.outcome, detail: r.detail, blake3: r.blake3 }).collect(),
        error: j.error,
    }
}

#[derive(Debug, Clone, Deserialize)]
pub struct UpdateBody {
    /// The button's "update": write what verifies. false: only look.
    #[serde(default)]
    pub apply: bool,
}

#[derive(Debug, Clone, Default, Deserialize)]
pub struct JobQuery {
    #[serde(default)]
    pub job: String,
}

/// The console's client with the OPERATOR bearer.
fn operator(state: &AppState) -> Result<&crate::grpc::Monetize, ApiError> {
    state.operator.as_ref().ok_or_else(|| ApiError(Fail { code: "unavailable".into(), message: "this console holds no operator credential, so it cannot update the plugins; nothing was sent".into() }))
}

/// `POST /api/plugins/update` — queue the update (`Plugins.Update`); answers the job at once.
pub async fn update(State(state): State<AppState>, Json(b): Json<UpdateBody>) -> Result<Json<PluginJobJson>, ApiError> {
    let r = operator(&state)?.plugins().update(pb::PluginUpdateRequest { apply: b.apply }).await?.into_inner();
    Ok(Json(job_json(r)))
}

/// `GET /api/plugins/status?job=` — where the job stands (`Plugins.Status`).
pub async fn status(State(state): State<AppState>, axum::extract::Query(q): axum::extract::Query<JobQuery>) -> Result<Json<PluginJobJson>, ApiError> {
    let r = operator(&state)?.plugins().status(pb::PluginUpdateStatusRequest { job: q.job.trim().to_owned() }).await?.into_inner();
    Ok(Json(job_json(r)))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// ★ The console carries monetize's job and decides nothing: its id, state, every row's
    /// name, role, outcome and words, and a whole-job refusal, as monetize said them.
    #[test]
    fn the_console_carries_the_plugin_job_as_monetize_said_it() {
        let j = job_json(pb::PluginUpdateJob {
            job: "plugins-7".into(),
            state: "done".into(),
            apply: true,
            rows: vec![
                pb::PluginRow { name: "stripe.wasm".into(), role: "vendor".into(), outcome: "replaced".into(), detail: "[vendors.stripe.gunnar]".into(), blake3: "eb45".into() },
                pb::PluginRow { name: "upcloud.wasm".into(), role: "cloud".into(), outcome: "refused".into(), detail: "[plugin-unsigned] did not verify".into(), blake3: "3d6c".into() },
            ],
            error: String::new(),
        });
        assert_eq!((j.job.as_str(), j.state.as_str(), j.apply, j.rows.len()), ("plugins-7", "done", true, 2));
        assert_eq!(j.rows[0], PluginRowJson { name: "stripe.wasm".into(), role: "vendor".into(), outcome: "replaced".into(), detail: "[vendors.stripe.gunnar]".into(), blake3: "eb45".into() });
        assert_eq!((j.rows[1].outcome.as_str(), j.rows[1].detail.as_str()), ("refused", "[plugin-unsigned] did not verify"));
        let failed = job_json(pb::PluginUpdateJob { job: "plugins-8".into(), state: "failed".into(), error: "[plugins-list-unsigned] x".into(), ..Default::default() });
        assert_eq!((failed.state.as_str(), failed.error.as_str()), ("failed", "[plugins-list-unsigned] x"));
    }
}