crates/gunnar-ui-server/src/monetize.rs
//! **The second server this process dials, and the only one that moves money.**
//!
//! gunnar is reached over [`crate::grpc::Gunnar`]: a channel whose interceptor
//! signs every call with an `sshsig` credential. monetize is reached over a
//! channel whose interceptor sends a bearer token. They are different
//! protocols, and this module keeps them different TYPES.
//!
//! # Why a separate type and not a second `Gunnar`
//!
//! Because the confused deputy this console already had once
//! ([`crate::api::Reader`], and the note on [`crate::app::App::guest`]) was not
//! a missing check — it was a credential reaching a caller nobody meant it to
//! reach, through code that compiled perfectly. The BFF now holds three
//! credentials: the console's gunnar **admin** key, the grantless **guest**
//! key, and monetize's bearer. Making the third a distinct type with a distinct
//! constructor means the mistake that matters here — presenting one of them
//! where another was meant — is a type error at the call site rather than a
//! review comment. There is no `From`, no `Into`, and no constructor that takes
//! a [`crate::signer::Signer`].
//!
//! # Why the token is required, and never inherited
//!
//! monetize-server's interceptor treats an EMPTY bearer as an open server
//! (`monetize-server/src/auth.rs`, `nornir_service::auth_interceptor`). That is
//! right for a dev box and wrong as a default here: a console that silently
//! dialled an unauthenticated monetize would work in development and fail, at
//! the moment a customer pressed the button, everywhere else. So
//! [`Monetize::connect_lazy`] refuses an empty token, and the absence of a
//! token is what makes the route not exist at all — the same shape as the guest
//! key, which is absent on most deployments and simply removes a surface rather
//! than degrading one.
use tonic::metadata::{Ascii, MetadataValue};
use tonic::service::interceptor::InterceptedService;
use tonic::transport::Channel;
use crate::mpb::products_client::ProductsClient;
use crate::mpb::purchase_client::PurchaseClient;
use crate::mpb::signup_client::SignupClient;
use crate::mpb::estate_order_client::EstateOrderClient;
/// `authorization: Bearer <token>` on every call.
///
/// Holds the header value rather than the token so the secret is formatted
/// once, at construction, and never rebuilt per request — and so a `Debug` of
/// this struct cannot print a `String` the way one holding the raw token
/// eventually would.
#[derive(Clone)]
pub struct Bearer(MetadataValue<Ascii>);
impl tonic::service::Interceptor for Bearer {
fn call(&mut self, mut req: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
req.metadata_mut().insert("authorization", self.0.clone());
Ok(req)
}
}
/// The intercepted channel every client below is built on.
pub type Svc = InterceptedService<Channel, Bearer>;
/// A lazy channel to monetize-server, carrying this console's bearer.
#[derive(Clone)]
pub struct Monetize {
endpoint: tonic::transport::Endpoint,
/// Every call but a checkout's mint: [`Deadlines::call`] each.
channel: Channel,
/// A checkout's mint (`Signup.Start`, `EstateOrder.Start`,
/// `Purchase.Start`): [`Deadlines::mint`], plus [`MINT_GRACE`].
minting: Channel,
deadlines: Deadlines,
bearer: Bearer,
/// **The operator's own address for an order's receipt** (g-receipts):
/// the payer an order names when the OPERATOR places it. `None`: nothing is
/// named and monetize's fallback decides (the address the tenant signed up
/// with).
receipts_to: Option<String>,
/// **Whose shop this console's purchases are** (one shop, owner
/// 2026-10-07): `None` — this estate's own monetize sells; `Some(site)` —
/// a SOLD (provided) Gunnar, whose own monetize only executes: nothing is
/// bought here, its owner buys at `site` (`GUNNAR_UI_PROVIDER`, the kit's
/// sold-by feed, read when the handle is made — gunnar-front sets it before
/// it builds the console).
provider: Option<String>,
/// **The product this console sells** (G15, round 5.4): its id in the
/// monetize it dials, sent on every `Signup.*` / `Purchase.*` /
/// `Subscription.*` call and the one an order must be for. Config —
/// [`ENV_PRODUCT`], set per estate by the kit — never a constant: one
/// monetize serves gunnar.rs AND test2.gunnar.rs, so the two cannot both
/// be `gunnar`.
product: String,
/// **What the shop announced it is doing to this Gunnar now** (one shop
/// 4, 2026-10-07): a resize that stops its machines, and until when
/// (unix ms), as this estate's own monetize heard it (`EstateOrder.Notice`,
/// asked by [`watch_notice`]). While it holds, a Gunnar that does not
/// answer is down for MAINTENANCE ([`sold_maintenance`]).
notice: std::sync::Arc<std::sync::Mutex<Option<(String, u64)>>>,
/// **Whether monetize sells private estates** (`EstateOrder.Sells`; the
/// sign-up page, owner 2026-10-08) and when it last said so: kept
/// [`SELLS_FRESH`], so `/auth/status` asks at most once in that window.
/// and what an account is, and the last plan list per market — kept by the shared buy
/// flow's link ([`monetize_site_buy::link::Caches`]), so every clone of this handle and
/// every [`Monetize::shop`] link it hands out keep one word.
caches: monetize_site_buy::link::Caches,
}
/// **How long this console waits on monetize** (stinky 2026-10-10 #2: only
/// signup start and the sells/offer asks had a deadline, and a monetize that
/// held the socket open held a status, a rename or a tenant's purchase with
/// it).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Deadlines {
/// The TCP connection.
pub connect: std::time::Duration,
/// Any one call but a checkout's mint.
pub call: std::time::Duration,
/// A checkout's mint: one vendor round trip, under a second when healthy.
/// MEASURED on test2 2026-09-30 (UpCloud, live Stripe): 25.5 s, 33 s, and
/// once more than 100 s. The signup and private starts answer the buyer in
/// words at this deadline; every other mint is cut by its channel
/// [`MINT_GRACE`] later.
pub mint: std::time::Duration,
}
/// What a visitor reads where monetize did not finish a call: the console's
/// own sentence.
pub const SHOP_DID_NOT_ANSWER: &str = monetize_site_buy::rules::SHOP_DID_NOT_ANSWER;
/// **What of a failed monetize call a visitor may read** (stinky 2026-10-10
/// #6). A VERDICT — a name taken, a plan not sold, a session unknown — is
/// monetize's sentence to the buyer and is passed whole. Every other code is
/// the call not finishing (UNAVAILABLE, DEADLINE_EXCEEDED, INTERNAL, …), and
/// its message is the transport's — an address, a TLS error, a stack's words —
/// so the visitor reads [`SHOP_DID_NOT_ANSWER`] and the journal keeps the rest.
pub fn words_for_a_visitor(status: &tonic::Status) -> &str {
monetize_site_buy::rules::words_for_a_visitor(status)
}
/// **One message of this console's vendored monetize wire as the shared buy flow's** (the
/// same `.proto`, field for field — `tests/vendored_monetize_protos.rs` holds them equal):
/// re-encoded, so a field this copy does not know yet is simply absent.
pub fn wire<A: prost::Message, B: prost::Message + Default>(a: &A) -> B {
B::decode(a.encode_to_vec().as_slice()).unwrap_or_default()
}
/// **Where this console's product id is configured** (G15, round 5.4): one
/// field, set by the kit (private-gunnar-ops: `gunnar` on every estate — the ONE
/// monetize tells the sites apart by the estate's bearer, UW-1). There is no default (OM-9, ONE
/// monetize): a console that sells and names no product refuses to start.
pub const ENV_PRODUCT: &str = "GUNNAR_UI_PRODUCT";
/// Pure: **the product id a configured value names** — a monetize product id
/// (a lowercase letter, then lowercase letters, digits and `-`, at most 63).
/// Blank or unset is NO product (OM-9): one monetize sells every estate's
/// product, so a guessed id would sell under another estate's.
///
/// # Errors
///
/// The value is unset, blank, or not a product id — each refused by name.
pub fn product_named(id: Option<&str>) -> Result<String, String> {
let id = id.map(str::trim).unwrap_or_default();
if id.is_empty() {
return Err(format!(
"{ENV_PRODUCT} is not set: name the product this estate sells under at the one monetize \
(the kit sets it: `gunnar` on every estate); \
a console that sells refuses to start without it"
));
}
let mut chars = id.chars();
let first_ok = chars.next().is_some_and(|c| c.is_ascii_lowercase());
let rest_ok = chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-');
if first_ok && rest_ok && id.len() <= 63 {
Ok(id.to_owned())
} else {
Err(format!("{ENV_PRODUCT}={id:?} is not a monetize product id: a lowercase letter, then lowercase letters, digits and `-`, at most 63 (e.g. `gunnar`, `gunnar-test2`)"))
}
}
/// **Where this console's monetize is configured**: `host:port` or a full
/// `http://`/`https://` URI ([`monetize_uri`]'s rule) — the ONE monetize
/// (`https://monetize.ignalina.dk`), set per estate by the kit.
pub const ENV_MONETIZE: &str = "GUNNAR_UI_MONETIZE";
/// Pure: **the monetize a configured value names**. There is no default
/// (OM-10, ONE monetize): no estate runs a monetize, so a loopback guess
/// (the removed embedded monetize's `127.0.0.1:50061`) dials nothing that sells.
///
/// # Errors
///
/// The value is unset or blank — refused by [`ENV_MONETIZE`]'s name.
pub fn monetize_named(addr: Option<&str>) -> Result<String, String> {
let addr = addr.map(str::trim).unwrap_or_default();
if addr.is_empty() {
return Err(format!(
"{ENV_MONETIZE} is not set: name the ONE monetize this console sells through \
(`https://monetize.ignalina.dk`; the kit sets it); no estate runs a monetize of its own, \
so there is none on this box to default to and a console that sells refuses to start without it"
));
}
Ok(addr.to_owned())
}
/// How long past [`Deadlines::mint`] the minting channel cuts a call itself:
/// the caller that answers in words at the deadline answers first.
pub const MINT_GRACE: std::time::Duration = std::time::Duration::from_secs(2);
impl Default for Deadlines {
fn default() -> Deadlines {
Deadlines { connect: std::time::Duration::from_secs(3), call: std::time::Duration::from_secs(10), mint: std::time::Duration::from_secs(20) }
}
}
impl Deadlines {
/// The connect deadline and the keepalives a silent peer is found by.
fn bound(&self, endpoint: tonic::transport::Endpoint) -> tonic::transport::Endpoint {
endpoint
.connect_timeout(self.connect)
.tcp_keepalive(Some(std::time::Duration::from_secs(30)))
.http2_keep_alive_interval(std::time::Duration::from_secs(15))
.keep_alive_timeout(std::time::Duration::from_secs(10))
.keep_alive_while_idle(true)
}
}
/// **Where monetize is dialled, and whether over TLS** (stinky #25): the
/// bearer goes in every call's metadata, so plaintext `http://` (a bare
/// `host:port` is that) is allowed to this box's loopback alone and anything
/// else must be `https://` (TLS, the web PKI's roots).
fn monetize_uri(addr: &str) -> Result<(String, bool), String> {
let uri = if addr.contains("://") { addr.to_owned() } else { format!("http://{addr}") };
let parsed: http::Uri = uri.parse().map_err(|e| format!("bad monetize address {addr:?}: {e}"))?;
let host = parsed.host().unwrap_or_default();
let loopback = host == "localhost" || host.trim_start_matches('[').trim_end_matches(']').parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback());
match parsed.scheme_str() {
Some("https") => Ok((uri, true)),
Some("http") if loopback => Ok((uri, false)),
_ => Err(format!(
"monetize at {addr:?} would read this console's bearer in clear text: dial it over https://, or on this box's loopback"
)),
}
}
/// Pure: **where a console dials its monetize** — `(dial, origin, server name)`. `at` is the
/// mock's seam (gunnar-server `feed::FEED_FLEET_AT`, an `ip:port` the installer feeds a bench
/// box only): dialled there, verified under the door's own name. `None`: the door itself.
pub fn dial_of(addr: &str, at: Option<&str>) -> Result<(String, String, String), String> {
let (uri, _) = monetize_uri(addr)?;
let parsed: http::Uri = uri.parse().map_err(|e| format!("bad monetize address {addr:?}: {e}"))?;
let name = parsed.host().unwrap_or_default().to_owned();
match at.map(str::trim).filter(|a| !a.is_empty()) {
None => Ok((uri.clone(), uri, name)),
Some(at) => Ok((format!("{}://{at}", parsed.scheme_str().unwrap_or("https")), uri, name)),
}
}
/// How long monetize's word on selling private estates is kept.
pub const SELLS_FRESH: std::time::Duration = monetize_site_buy::link::SELLS_FRESH;
/// How long `/auth/status` waits for that word before it answers without it.
pub const SELLS_WAIT: std::time::Duration = monetize_site_buy::link::SELLS_WAIT;
/// After no answer, how soon monetize is asked again.
pub const SELLS_RETRY: std::time::Duration = monetize_site_buy::link::SELLS_RETRY;
impl std::fmt::Debug for Monetize {
/// Never renders the bearer. The struct is inside [`crate::app::App`],
/// which a panic message or a `tracing` field could otherwise print.
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Monetize").finish_non_exhaustive()
}
}
impl Monetize {
/// `addr` is `host:port` or a full `http://`/`https://` URI; the connection
/// is made on first use, so a monetize that is not up yet does not stop
/// this console from starting. Plaintext is for this box's loopback only
/// ([`monetize_uri`]).
///
/// # Errors
///
/// The address cannot be parsed or is plaintext off loopback, the token is
/// empty, the token contains a byte that cannot go in a header, or
/// `product` is not a product id ([`product_named`]; the caller reads it
/// from [`ENV_PRODUCT`]). An empty token is refused rather than sent — see
/// the module docs.
pub fn connect_lazy(addr: &str, token: &str, product: &str) -> Result<Monetize, String> {
let token = token.trim();
if token.is_empty() {
return Err(
"the monetize bearer is empty; monetize would treat this console as an \
unauthenticated caller, so the console refuses to dial it at all"
.to_owned(),
);
}
let (uri, tls) = monetize_uri(addr)?;
let deadlines = Deadlines::default();
let mut endpoint = Channel::from_shared(uri).map_err(|e| format!("bad monetize address {addr:?}: {e}"))?;
if tls {
endpoint = endpoint
.tls_config(tonic::transport::ClientTlsConfig::new().with_webpki_roots())
.map_err(|e| format!("monetize at {addr:?} cannot be dialled over TLS: {e}"))?;
}
let channel = deadlines.bound(endpoint.clone()).timeout(deadlines.call).connect_lazy();
let minting = deadlines.bound(endpoint.clone()).timeout(deadlines.mint + MINT_GRACE).connect_lazy();
let bearer = Bearer(
MetadataValue::try_from(format!("Bearer {token}"))
.map_err(|e| format!("the monetize bearer is not a header value: {e}"))?,
);
let product = product_named(Some(product))?;
Ok(Monetize { endpoint, channel, minting, deadlines, bearer, receipts_to: None, provider: crate::auth::routes::provider(), product, notice: std::sync::Arc::default(), caches: Default::default() })
}
/// **This console sells `id`** ([`product_named`]'s rule; G15). The
/// handle is made with its product ([`Monetize::connect_lazy`]); a test
/// (or a front that builds the console itself) may name another here.
///
/// # Errors
///
/// `id` is not a product id.
pub fn with_product(self, id: &str) -> Result<Monetize, String> {
Ok(Monetize { product: product_named(Some(id))?, ..self })
}
/// The product this console sells, as monetize knows it.
pub fn product(&self) -> &str {
&self.product
}
/// **Where the operator's own orders send Stripe's receipt** (g-receipts,
/// release-3 2026-09-29: every order's checkout asked `receipt_email =
/// None`). The fed address, when it is one; anything else is dropped with a
/// warning — a value that is not an address is never sent to a payment
/// vendor.
pub fn with_receipts_to(mut self, to: Option<&str>) -> Monetize {
self.receipts_to = to.map(str::trim).filter(|t| !t.is_empty()).and_then(|t| {
if crate::auth::validate::address(t) {
Some(t.to_owned())
} else {
tracing::warn!("console: the fed receipt address is not an e-mail address; the operator's orders name no payer");
None
}
});
self
}
/// **The provider this console's purchases belong to** — as
/// [`crate::auth::routes::provider_named`] reads a site; anything else is
/// none. The handle takes the feed's word when it is made; a test says it
/// here.
pub fn with_provider(mut self, site: Option<&str>) -> Monetize {
self.provider = crate::auth::routes::provider_named(site);
self
}
/// [`Monetize::connect_lazy`], dialled at `via`'s `(at, ca_pem)` when the installer fed the
/// mock's seam ([`dial_of`]): the door's name is still what its certificate is checked
/// under, against `ca_pem` (the bench's CA) or the public roots.
pub fn connect_lazy_via(addr: &str, token: &str, product: &str, via: Option<(&str, Option<&str>)>) -> Result<Monetize, String> {
let m = Self::connect_lazy(addr, token, product)?;
let Some((at, ca_pem)) = via else { return Ok(m) };
let (dial, origin, name) = dial_of(addr, Some(at))?;
let origin: http::Uri = origin.parse().map_err(|e| format!("bad monetize address {addr:?}: {e}"))?;
let tls = tonic::transport::ClientTlsConfig::new().domain_name(name);
let tls = match ca_pem {
Some(pem) => tls.ca_certificate(tonic::transport::Certificate::from_pem(pem)),
None => tls.with_webpki_roots(),
};
let endpoint = Channel::from_shared(dial.clone())
.map_err(|e| format!("bad monetize address {dial:?}: {e}"))?
.origin(origin)
.tls_config(tls)
.map_err(|e| format!("monetize at {addr:?} (dialled at {dial}) cannot be dialled over TLS: {e}"))?;
let mut m = m;
m.endpoint = endpoint;
let deadlines = m.deadlines;
Ok(m.with_deadlines(deadlines))
}
/// The same monetize, waited on for `deadlines` instead.
pub fn with_deadlines(mut self, deadlines: Deadlines) -> Monetize {
self.channel = deadlines.bound(self.endpoint.clone()).timeout(deadlines.call).connect_lazy();
self.minting = deadlines.bound(self.endpoint.clone()).timeout(deadlines.mint + MINT_GRACE).connect_lazy();
self.deadlines = deadlines;
self
}
/// How long this console waits on monetize.
pub fn deadlines(&self) -> Deadlines {
self.deadlines
}
/// `Some(site)` on a SOLD (provided) Gunnar: its shop is `site`'s.
pub fn provider(&self) -> Option<&str> {
self.provider.as_deref()
}
/// **Whether this monetize sells private estates** (`EstateOrder.Sells`;
/// owner 2026-10-08: no "Private Gunnar" choice, and no sentence about
/// `[estate_orders]`, where none is sold). Its word is kept
/// [`SELLS_FRESH`]; asked again after that, within [`SELLS_WAIT`]. A
/// monetize that does not answer keeps its last word; one that never
/// answered — or knows no such call (`UNIMPLEMENTED`) — sells none: the
/// console offers nothing it could not finish.
pub async fn sells_estates(&self) -> bool {
self.shop().sells_estates().await
}
/// **What an account is, as this monetize sells it** (round 5.3.1): its first plan's room,
/// "10GB" (Signup.Plans), for the sign-up page's account pill and Storage's buy line.
/// `None` where monetize sells no account here — no plan, no such call, or no answer —
/// and nothing is offered. Kept [`SELLS_FRESH`] like [`Monetize::sells_estates`].
pub async fn account_offer(&self) -> Option<String> {
self.shop().account_offer().await
}
/// **This console's link in the shared buy flow** (monetize-site-buy, A12): the same
/// channels, bearer, product and deadlines as this handle, and its kept words.
pub fn shop(&self) -> monetize_site_buy::ShopLink {
let d = monetize_site_buy::link::Deadlines { connect: self.deadlines.connect, call: self.deadlines.call, mint: self.deadlines.mint };
monetize_site_buy::ShopLink::from_channels(self.channel.clone(), self.minting.clone(), monetize_site_buy::link::Bearer::from_header(self.bearer.0.clone()), &self.product, d).with_caches(self.caches.clone())
}
/// Keep what the shop announced (`None`: nothing).
pub fn set_notice(&self, notice: Option<(String, u64)>) {
*self.notice.lock().unwrap_or_else(|p| p.into_inner()) = notice;
}
/// **Whether the shop's announcement holds at `now_ms`** — only on a SOLD
/// Gunnar (a console that sells has no shop above it).
pub fn notice_holds(&self, now_ms: u64) -> bool {
self.provider.is_some() && self.notice.lock().unwrap_or_else(|p| p.into_inner()).as_ref().is_some_and(|(_, until)| *until > now_ms)
}
/// **The payer an order names** (g-receipts): the OPERATOR — the
/// break-glass bearer, the open console on loopback, or a passkey holding
/// the admin role — is paying, so the receipt goes to the operator's
/// address; a signed-in tenant names nothing and monetize fills in the
/// address they signed up with (`SignupStore::email_of`). Never invented.
pub fn payer_for(&self, caller: Option<&crate::auth::middleware::Caller>) -> String {
use crate::auth::middleware::Caller;
let operator = match caller {
None | Some(Caller::AdminToken) => true,
Some(Caller::Operator(session)) => session.role.allows(crate::role::Role::Admin),
};
if operator { self.receipts_to.clone().unwrap_or_default() } else { String::new() }
}
fn svc(&self) -> Svc {
InterceptedService::new(self.channel.clone(), self.bearer.clone())
}
fn minting_svc(&self) -> Svc {
InterceptedService::new(self.minting.clone(), self.bearer.clone())
}
/// What this deployment sells, and which meters it sells it on.
pub fn products(&self) -> ProductsClient<Svc> {
ProductsClient::new(self.svc())
}
/// Quote, start, and follow one order.
pub fn purchase(&self) -> PurchaseClient<Svc> {
PurchaseClient::new(self.svc())
}
/// [`Monetize::purchase`] for `Purchase.Start` alone, as
/// [`Monetize::signup_minting`].
pub fn purchase_minting(&self) -> PurchaseClient<Svc> {
PurchaseClient::new(self.minting_svc())
}
/// **Where one paid signup has got to** — the only verb here asked about
/// somebody who is not a tenant yet.
///
/// It carries this console's bearer like every other call on this channel,
/// even though the HTTP route in front of it is answered to a visitor with
/// no session at all. That is the whole point of the seam: the STRANGER is
/// anonymous, the question this process asks monetize on their behalf is
/// not, and the credential never crosses back — the visitor learns nothing
/// but a state and, once, an invite minted for the name they paid under.
pub fn signup_client(&self) -> SignupClient<Svc> {
SignupClient::new(self.svc())
}
/// [`Monetize::signup_client`] for `Signup.Start` alone: the mint a vendor round
/// trip makes slow, bounded by [`Deadlines::mint`] rather than
/// [`Deadlines::call`].
pub fn signup_minting(&self) -> SignupClient<Svc> {
SignupClient::new(self.minting_svc())
}
/// **A private gunnar, ordered**: the buy page's Quote, Start and Status.
pub fn estate_order(&self) -> EstateOrderClient<Svc> {
EstateOrderClient::new(self.svc())
}
/// [`Monetize::estate_order`] for `EstateOrder.Start` alone, as
/// [`Monetize::signup_minting`].
pub fn estate_order_minting(&self) -> EstateOrderClient<Svc> {
EstateOrderClient::new(self.minting_svc())
}
}
// ── a sold Gunnar's maintenance line (one shop 4, 2026-10-07) ────────────────
/// How often a sold Gunnar's console asks its monetize what the shop announced.
pub const NOTICE_EVERY: std::time::Duration = std::time::Duration::from_secs(20);
fn now_ms() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_millis() as u64)
}
/// **Ask, every [`NOTICE_EVERY`], what the shop announced** (a SOLD Gunnar
/// only): its own monetize's `EstateOrder.Notice`. A monetize that does not
/// answer keeps the last word until it runs out.
pub async fn watch_notice(m: Monetize) {
if m.provider().is_none() {
return;
}
loop {
if let Ok(n) = m.estate_order().notice(crate::mpb::EstateNoticeRequest {}).await {
let n = n.into_inner();
m.set_notice((!n.words.is_empty() && n.until_unix_ms > 0).then_some((n.words, n.until_unix_ms as u64)));
}
tokio::time::sleep(NOTICE_EVERY).await;
}
}
/// Pure: **a Gunnar that does not answer while its shop resizes it is down
/// for MAINTENANCE** — an `/api/` answer of 503 on a sold Gunnar whose shop's
/// announcement holds. Nothing else is: the word stays reserved.
pub fn is_sold_maintenance(status: http::StatusCode, path: &str, notice_holds: bool) -> bool {
notice_holds && status == http::StatusCode::SERVICE_UNAVAILABLE && path.starts_with("/api/")
}
/// The layer: such an answer goes out as the console's one maintenance word
/// (`{"code":"maintenance"}`, the browser's "Gunnar is temporarily down for
/// maintenance. Please try again shortly."); every other answer as it was.
pub async fn sold_maintenance(axum::extract::State(state): axum::extract::State<crate::app::AppState>, request: axum::extract::Request, next: axum::middleware::Next) -> axum::response::Response {
use axum::response::IntoResponse;
let path = request.uri().path().to_owned();
let response = next.run(request).await;
let holds = state.monetize.as_ref().is_some_and(|m| m.notice_holds(now_ms()));
if is_sold_maintenance(response.status(), &path, holds) {
return (http::StatusCode::SERVICE_UNAVAILABLE, axum::Json(serde_json::json!({ "code": "maintenance", "message": "" }))).into_response();
}
response
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn an_empty_bearer_is_refused_rather_than_sent_as_nothing() {
// monetize reads an empty bearer as "open server". A console that
// dialled it that way would work on a dev box and be refused in
// production, at the worst possible moment.
let refusal =
Monetize::connect_lazy("127.0.0.1:50061", " ", "gunnar").expect_err("empty is refused");
assert!(refusal.contains("empty"), "{refusal}");
assert!(Monetize::connect_lazy("127.0.0.1:50061", "a-token", "gunnar").is_ok());
}
/// ★ **A console sells the product its estate is configured as** (G15,
/// round 5.4: gunnar.rs and test2.gunnar.rs both sold `gunnar`, one id in
/// one monetize). Unset is NO product (OM-9, ONE monetize: a console
/// whose kit forgot it would otherwise sell under prod gunnar's id) —
/// refused by the config's name, as is anything that is not a product id.
#[tokio::test]
async fn a_console_sells_the_product_its_config_names_and_a_bad_id_is_refused() {
let m = Monetize::connect_lazy("127.0.0.1:50061", "t", "gunnar").unwrap();
let m = m.with_product("gunnar-test2").expect("a product id");
assert_eq!(m.product(), "gunnar-test2");
for unset in [None, Some(""), Some(" ")] {
let why = product_named(unset).expect_err("unset is no product, never gunnar's");
assert!(why.contains(ENV_PRODUCT) && why.contains("not set"), "{why}");
}
assert_eq!(product_named(Some(" gunnar-test2 ")).unwrap(), "gunnar-test2");
for bad in ["Gunnar", "gunnar test2", "-gunnar", "2gunnar", "gunnar/x", "gunnar_x"] {
let why = product_named(Some(bad)).expect_err(bad);
assert!(why.contains(ENV_PRODUCT) && why.contains(bad), "{why}");
assert!(Monetize::connect_lazy("127.0.0.1:50061", "t", "gunnar").unwrap().with_product(bad).is_err(), "{bad}");
}
}
/// ★ **A selling console names the ONE monetize it dials** (OM-10, ONE
/// monetize): there is no monetize on this box to default to — unset or
/// blank is refused by [`ENV_MONETIZE`]'s name, never a loopback guess.
#[test]
fn a_selling_console_names_its_monetize_and_unset_is_refused() {
for unset in [None, Some(""), Some(" ")] {
let why = monetize_named(unset).expect_err("no loopback default");
assert!(why.contains(ENV_MONETIZE) && why.contains("not set"), "{why}");
assert!(!why.contains("127.0.0.1"), "{why}");
}
assert_eq!(monetize_named(Some(" https://monetize.ignalina.dk ")).unwrap(), "https://monetize.ignalina.dk");
}
/// ★ **No module but this one names the product** (G15): every call to
/// monetize takes it from the handle ([`Monetize::product`]), so a
/// console configured as `gunnar-test2` never says `gunnar` anywhere.
#[test]
fn no_module_but_this_one_names_the_product() {
fn walk(dir: &std::path::Path, out: &mut Vec<std::path::PathBuf>) {
for e in std::fs::read_dir(dir).unwrap().flatten() {
let p = e.path();
if p.is_dir() {
walk(&p, out);
} else if p.extension().is_some_and(|x| x == "rs") {
out.push(p);
}
}
}
let mut files = Vec::new();
walk(&std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src"), &mut files);
let named: Vec<String> = files
.iter()
.filter(|f| !f.ends_with("src/monetize.rs"))
.flat_map(|f| {
let src = std::fs::read_to_string(f).unwrap();
src.lines().enumerate().filter(|(_, l)| l.contains(concat!("const ", "PRODUCT:"))).map(|(n, _)| format!("{}:{}", f.display(), n + 1)).collect::<Vec<_>>()
})
.collect();
assert!(named.is_empty(), "the product is the handle's, not a constant: {named:?}");
}
#[tokio::test]
async fn a_token_that_cannot_be_a_header_is_refused_at_construction() {
// Not at call time, when the failure would be one refused purchase
// with an opaque message.
assert!(Monetize::connect_lazy("127.0.0.1:50061", "tok\nen", "gunnar").is_err());
assert!(Monetize::connect_lazy("not a uri at all", "t", "gunnar").is_err());
}
/// ★ **The bearer crosses no network in clear text** (stinky #25): plain
/// `http://` (or a bare `host:port`) is this box's loopback only; any other
/// monetize is dialled over `https://` or not at all, refused when the
/// console starts rather than leaking the token on the first call.
#[tokio::test]
async fn the_bearer_crosses_no_network_in_clear_text() {
for addr in ["127.0.0.1:50061", "http://127.0.0.1:50061", "http://127.8.9.10:50061", "localhost:50061", "http://[::1]:50061", "https://monetize.example", "https://10.0.0.5:50061"] {
assert!(Monetize::connect_lazy(addr, "t", "gunnar").is_ok(), "{addr}");
}
for addr in ["monetize.example:50061", "http://monetize.example:50061", "http://10.0.0.5:50061", "http://[2001:db8::1]:50061", "http://localhost.monetize.example:1", "ftp://127.0.0.1:1"] {
let refusal = Monetize::connect_lazy(addr, "t", "gunnar").expect_err(addr);
assert!(refusal.contains("https://"), "{addr}: {refusal}");
}
}
/// ★ **A monetize that knows no `EstateOrder.Sells` sells no private Gunnar** (wiring
/// tracer B 2026-10-08: the console's fail-closed rule had no test). An older monetize
/// answers UNIMPLEMENTED for the call: the console offers nothing it could not finish.
#[tokio::test]
async fn a_monetize_that_knows_no_sells_call_sells_no_estates() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("a loopback port");
let addr = listener.local_addr().expect("its address");
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(crate::mpb::signup_server::SignupServer::new(crate::auth::signup::tests::FakeMonetize::default()))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await
});
let m = Monetize::connect_lazy(&format!("http://{addr}"), crate::auth::signup::tests::BEARER, "gunnar").expect("a channel");
assert!(!m.sells_estates().await, "UNIMPLEMENTED sells nothing");
assert!(!m.sells_estates().await, "and the word is kept");
}
/// ★ **A monetize that does not answer sells nothing, within the wait** — a console at
/// boot whose monetize accepts the connection and never speaks (a slow provider pricing
/// every step, a hung box): no "Private Gunnar" choice, and the status is not held.
#[tokio::test]
async fn a_monetize_that_does_not_answer_sells_no_estates_within_the_wait() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("a loopback port");
let addr = listener.local_addr().expect("its address");
let held = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let keep = held.clone();
tokio::spawn(async move {
while let Ok((socket, _)) = listener.accept().await {
keep.lock().expect("held sockets").push(socket); // accepted, never answered
}
});
let m = Monetize::connect_lazy(&format!("http://{addr}"), crate::auth::signup::tests::BEARER, "gunnar").expect("a channel");
let started = std::time::Instant::now();
assert!(!m.sells_estates().await, "silence sells nothing");
assert!(started.elapsed() < SELLS_WAIT + std::time::Duration::from_secs(2), "the status waited {:?}", started.elapsed());
}
/// ★ **The account offer is monetize's first plan** (round 5.3.1): its room in whole GiB,
/// "10GB"; a monetize that answers no plan sells no account.
#[tokio::test]
async fn the_account_offer_is_monetizes_first_plan_and_none_without_one() {
for (fake, want) in [
(crate::auth::signup::tests::FakeMonetize::default(), Some("10GB".to_owned())),
(crate::auth::signup::tests::FakeMonetize { no_plans: true, ..Default::default() }, None),
] {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("a loopback port");
let addr = listener.local_addr().expect("its address");
tokio::spawn(async move {
tonic::transport::Server::builder()
.add_service(crate::mpb::signup_server::SignupServer::new(fake))
.serve_with_incoming(tokio_stream::wrappers::TcpListenerStream::new(listener))
.await
});
let m = Monetize::connect_lazy(&format!("http://{addr}"), crate::auth::signup::tests::BEARER, "gunnar").expect("a channel");
assert_eq!(m.account_offer().await, want);
}
}
/// ★ **Every call on the monetize channel ends on its own** (stinky
/// 2026-10-10 #2): only signup start and the sells/offer asks had a
/// deadline; a status, a name check, a rename, the plans, an estate order
/// or a tenant's purchase waited as long as a silent monetize kept the
/// socket open (test2 2026-09-30: more than 100 s).
#[tokio::test]
async fn a_call_to_a_monetize_that_never_answers_ends_on_its_own() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.expect("a loopback port");
let addr = listener.local_addr().expect("its address");
let held = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
let keep = held.clone();
tokio::spawn(async move {
while let Ok((socket, _)) = listener.accept().await {
keep.lock().expect("held sockets").push(socket); // accepted, never answered
}
});
let short = Deadlines { connect: std::time::Duration::from_millis(300), call: std::time::Duration::from_millis(300), mint: std::time::Duration::from_millis(300) };
let m = Monetize::connect_lazy(&format!("http://{addr}"), crate::auth::signup::tests::BEARER, "gunnar").expect("a channel").with_deadlines(short);
let mut client = m.signup_client();
let asked = client.status(crate::mpb::SignupStatusRequest::default());
let answered = tokio::time::timeout(std::time::Duration::from_secs(3), asked).await;
assert!(answered.is_ok(), "the call was still waiting after 3 s");
assert!(answered.unwrap().is_err(), "silence is no answer");
let mut client = m.purchase_minting();
let asked = client.start(crate::mpb::StartPurchaseRequest::default());
let answered = tokio::time::timeout(std::time::Duration::from_secs(5), asked).await;
assert!(answered.is_ok_and(|a| a.is_err()), "a mint ends too, a grace past its deadline");
let defaults = Deadlines::default();
assert!(defaults.call <= std::time::Duration::from_secs(10) && defaults.connect <= std::time::Duration::from_secs(5), "{defaults:?}");
}
#[tokio::test]
async fn the_bearer_is_not_printed() {
let monetize = Monetize::connect_lazy("127.0.0.1:50061", "s3cret", "gunnar").expect("a channel");
assert!(!format!("{monetize:?}").contains("s3cret"));
}
}
#[cfg(test)]
mod sold_maintenance_tests {
use super::*;
/// ★ **A sold Gunnar resized by its shop is down for maintenance** (one
/// shop 4): a 503 from `/api/` while the shop's announcement holds — and
/// nothing else: no announcement, another status, a page that is not the
/// API, a console that sells.
#[tokio::test]
async fn a_sold_gunnar_resized_by_its_shop_is_down_for_maintenance_and_nothing_else_is() {
let at = http::StatusCode::SERVICE_UNAVAILABLE;
assert!(is_sold_maintenance(at, "/api/repos", true));
assert!(!is_sold_maintenance(at, "/api/repos", false), "no announcement: Gunnar is not responding, as before");
assert!(!is_sold_maintenance(http::StatusCode::CONFLICT, "/api/repos", true));
assert!(!is_sold_maintenance(at, "/auth/status", true));
let sold = Monetize::connect_lazy("127.0.0.1:1", "t", "gunnar").unwrap().with_provider(Some("https://gunnar.rs"));
assert!(!sold.notice_holds(1_000), "nothing announced");
sold.set_notice(Some(("acme.gunnar.rs is being resized at gunnar.rs".into(), 5_000)));
assert!(sold.notice_holds(1_000) && !sold.notice_holds(5_000), "while it holds, and not after");
let selling = Monetize::connect_lazy("127.0.0.1:1", "t", "gunnar").unwrap().with_provider(None);
selling.set_notice(Some(("x".into(), 5_000)));
assert!(!selling.notice_holds(1_000), "a console that sells has no shop above it");
}
}
#[cfg(test)]
mod dial_tests {
use super::*;
/// ★ i54a run29 A2: the console on the mock dialled `monetize-test.ignalina.dk` by name
/// (`dns error`, the paid signup 502): on a bench it dials the fed seam and checks the
/// door's name there; elsewhere the door itself.
#[test]
fn a_console_dials_the_fed_seam_and_checks_the_doors_name() {
let (dial, origin, name) = dial_of("https://monetize-test.ignalina.dk", Some("10.0.2.2:35219")).unwrap();
assert_eq!((dial.as_str(), name.as_str()), ("https://10.0.2.2:35219", "monetize-test.ignalina.dk"));
assert!(origin.contains("monetize-test.ignalina.dk"), "{origin}");
let (dial, origin, name) = dial_of("https://monetize-test.ignalina.dk", None).unwrap();
assert_eq!(dial, origin);
assert_eq!(name, "monetize-test.ignalina.dk");
assert!(dial_of("http://monetize.example", Some("10.0.2.2:1")).is_err(), "never in clear text off loopback");
}
#[tokio::test]
async fn a_console_made_with_the_seam_is_made() {
let pem = "-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n";
assert!(Monetize::connect_lazy_via("https://monetize-test.ignalina.dk", "b0", "gunnar", Some(("10.0.2.2:35219", Some(pem)))).is_ok());
assert!(Monetize::connect_lazy_via("https://monetize-test.ignalina.dk", "b0", "gunnar", None).is_ok());
}
}