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