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

nordisk/monetize

crates/monetize-site-buy/src/link.rs

//! **The site's channel to the ONE monetize**, carrying the site's own bearer.
//!
//! The ONE box knows which site asks by that bearer (monetize-server `auth::estate_or`, the
//! `CallerEstate`), so no call here names a site: the success and cancel pages, the account's
//! e-mail and the plans are the calling site's.

use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use monetize_proto::estate_order_client::EstateOrderClient;
use monetize_proto::products_client::ProductsClient;
use monetize_proto::purchase_client::PurchaseClient;
use monetize_proto::signup_client::SignupClient;
use tonic::metadata::{Ascii, MetadataValue};
use tonic::service::interceptor::InterceptedService;
use tonic::transport::Channel;

/// `authorization: Bearer <token>` on every call. Holds the header value, never prints it.
#[derive(Clone)]
pub struct Bearer(MetadataValue<Ascii>);

impl Bearer {
    /// The bearer of `token` (refused empty, or with a byte a header cannot carry).
    pub fn of(token: &str) -> Result<Bearer, String> {
        let token = token.trim();
        if token.is_empty() {
            return Err("the shop bearer is empty: monetize would take this site for an unauthenticated caller, so it is not dialled at all".into());
        }
        format!("Bearer {token}").parse().map(Bearer).map_err(|_| "the shop bearer holds a byte a header cannot carry".to_string())
    }

    /// From a header value already formatted (`Bearer …`).
    pub fn from_header(value: MetadataValue<Ascii>) -> Bearer {
        Bearer(value)
    }
}

impl std::fmt::Debug for Bearer {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("Bearer(..)")
    }
}

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 is built on.
pub type Svc = InterceptedService<Channel, Bearer>;

/// **How long the site waits on the shop**: any call, and a checkout's mint (one vendor round
/// trip, measured up to 33 s live; the buyer is answered in words at this deadline).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Deadlines {
    pub connect: Duration,
    pub call: Duration,
    pub mint: Duration,
}

impl Default for Deadlines {
    fn default() -> Deadlines {
        Deadlines { connect: Duration::from_secs(3), call: Duration::from_secs(10), mint: Duration::from_secs(20) }
    }
}

/// How long past [`Deadlines::mint`] the minting channel cuts a call itself.
pub const MINT_GRACE: Duration = Duration::from_secs(2);
/// How long the shop's word on selling private instances (and on what an account is) is kept.
pub const SELLS_FRESH: Duration = Duration::from_secs(60);
/// How long a page waits for that word before it answers without it.
pub const SELLS_WAIT: Duration = Duration::from_secs(2);
/// After no answer, how soon the shop is asked again.
pub const SELLS_RETRY: Duration = Duration::from_secs(10);

/// The shop's last word on what an account is, and when it said it.
type AccountWord = (Option<String>, Instant);

/// **What the shop last said, kept between asks** (one per site, shared by every clone of its
/// link): whether it sells private instances, what an account is, and the last plan list per
/// market (shown read only while the shop does not answer).
#[derive(Clone)]
pub struct Caches {
    estates: Arc<Mutex<Option<(bool, Instant)>>>,
    accounts: Arc<Mutex<Option<AccountWord>>>,
    /// The process's ONE copy (Gunnar's `LAST_OFFER`): a console rebuilt in the same process
    /// still shows the last offer it was given.
    pub(crate) last_offer: Arc<Mutex<std::collections::HashMap<i32, (serde_json::Value, u64)>>>,
    /// The plans pages' kept ladders (LADDER-2), refreshed by a jera job.
    ladders: crate::ladders::Ladders,
}

type Offers = Arc<Mutex<std::collections::HashMap<i32, (serde_json::Value, u64)>>>;
static LAST_OFFER: std::sync::LazyLock<Offers> = std::sync::LazyLock::new(Offers::default);

impl Caches {
    /// The plans pages' kept ladders (LADDER-2).
    pub fn ladders(&self) -> &crate::ladders::Ladders {
        &self.ladders
    }
}

impl Default for Caches {
    fn default() -> Caches {
        Caches { estates: Arc::default(), accounts: Arc::default(), last_offer: LAST_OFFER.clone(), ladders: crate::ladders::Ladders::default() }
    }
}

/// The vendor whose checkout sessions a sign-up is asked about: none named by the site (A32-15)
/// — the shop answers for the calling estate's own vendor instance, the one that minted them.
pub const VENDOR: &str = "";

/// **Where the shop is dialled** (`url` its door, `https://monetize.ignalina.dk`): over TLS
/// under the public roots or the fed CA; `at` (a bench's seam, `ip:port`) dials elsewhere and
/// verifies under the door's own name. Plain `http://` only to this box's loopback.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Door {
    pub url: String,
    pub at: Option<String>,
    pub ca_pem: Option<String>,
}

/// **The site's link to the shop.**
#[derive(Clone)]
pub struct ShopLink {
    channel: Channel,
    minting: Channel,
    bearer: Bearer,
    product: String,
    vendor: String,
    deadlines: Deadlines,
    caches: Caches,
}

impl std::fmt::Debug for ShopLink {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ShopLink").field("product", &self.product).finish_non_exhaustive()
    }
}

/// Pure: the product id rule (a lowercase letter, then lowercase letters, digits and `-`, at
/// most 63).
pub fn product_ok(id: &str) -> bool {
    let mut chars = id.chars();
    chars.next().is_some_and(|c| c.is_ascii_lowercase()) && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') && id.len() <= 63
}

impl ShopLink {
    /// **A lazy link to `door`** for `product`, with the site's `bearer` token.
    pub fn connect_lazy(door: &Door, bearer: &str, product: &str) -> Result<ShopLink, String> {
        let bearer = Bearer::of(bearer)?;
        let product = product.trim();
        if !product_ok(product) {
            return Err(format!("{product:?} is not a monetize product id"));
        }
        let deadlines = Deadlines::default();
        let origin: axum::http::Uri = door.url.parse().map_err(|e| format!("the shop door {}: {e}", door.url))?;
        let name = origin.host().unwrap_or_default().to_owned();
        let dial = match door.at.as_deref().map(str::trim).filter(|a| !a.is_empty()) {
            Some(at) => format!("{}://{at}", origin.scheme_str().unwrap_or("https")),
            None => door.url.clone(),
        };
        let mut ep = Channel::from_shared(dial.clone()).map_err(|e| format!("the shop door {} at {dial}: {e}", door.url))?.origin(origin.clone());
        ep = ep.connect_timeout(deadlines.connect).tcp_keepalive(Some(Duration::from_secs(30))).http2_keep_alive_interval(Duration::from_secs(15)).keep_alive_timeout(Duration::from_secs(10)).keep_alive_while_idle(true);
        match origin.scheme_str() {
            Some("https") => {
                let tls = match door.ca_pem.as_deref().filter(|c| !c.trim().is_empty()) {
                    Some(pem) => tonic::transport::ClientTlsConfig::new().ca_certificate(tonic::transport::Certificate::from_pem(pem)),
                    None => tonic::transport::ClientTlsConfig::new().with_webpki_roots(),
                };
                ep = ep.tls_config(tls.domain_name(name)).map_err(|e| format!("the shop door {}: {e}", door.url))?;
            }
            Some("http") => {
                let host = dial.trim_start_matches("http://").split(['/', ':']).next().unwrap_or("");
                let local = host == "localhost" || host.trim_matches(['[', ']']).parse::<std::net::IpAddr>().is_ok_and(|ip| ip.is_loopback());
                if !local {
                    return Err(format!("the shop door {} would read this site's bearer in clear text: dial it over https://", door.url));
                }
            }
            _ => return Err(format!("the shop door {} is not an http(s) URL", door.url)),
        }
        let channel = ep.clone().timeout(deadlines.call).connect_lazy();
        let minting = ep.timeout(deadlines.mint + MINT_GRACE).connect_lazy();
        Ok(ShopLink::from_channels(channel, minting, bearer, product, deadlines))
    }

    /// **From channels a site already holds** (Gunnar's console keeps its own).
    pub fn from_channels(channel: Channel, minting: Channel, bearer: Bearer, product: &str, deadlines: Deadlines) -> ShopLink {
        ShopLink { channel, minting, bearer, product: product.to_owned(), vendor: VENDOR.to_owned(), deadlines, caches: Caches::default() }
    }

    /// The same link, keeping its words in `caches` (a site that rebuilds its link keeps them).
    pub fn with_caches(mut self, caches: Caches) -> ShopLink {
        self.caches = caches;
        self
    }

    pub fn caches(&self) -> &Caches {
        &self.caches
    }

    /// The vendor a sign-up's session is asked about (default [`VENDOR`]; a test's mock).
    pub fn with_vendor(mut self, vendor: &str) -> ShopLink {
        self.vendor = vendor.to_owned();
        self
    }

    pub fn with_deadlines(mut self, deadlines: Deadlines) -> ShopLink {
        self.deadlines = deadlines;
        self
    }

    pub fn product(&self) -> &str {
        &self.product
    }

    pub fn vendor(&self) -> &str {
        &self.vendor
    }

    pub fn deadlines(&self) -> Deadlines {
        self.deadlines
    }

    fn svc(&self, minting: bool) -> Svc {
        InterceptedService::new(if minting { self.minting.clone() } else { self.channel.clone() }, self.bearer.clone())
    }

    pub fn signup(&self) -> SignupClient<Svc> {
        SignupClient::new(self.svc(false))
    }

    pub fn signup_minting(&self) -> SignupClient<Svc> {
        SignupClient::new(self.svc(true))
    }

    pub fn estate_order(&self) -> EstateOrderClient<Svc> {
        EstateOrderClient::new(self.svc(false))
    }

    pub fn estate_order_minting(&self) -> EstateOrderClient<Svc> {
        EstateOrderClient::new(self.svc(true))
    }

    pub fn products(&self) -> ProductsClient<Svc> {
        ProductsClient::new(self.svc(false))
    }

    pub fn purchase(&self) -> PurchaseClient<Svc> {
        PurchaseClient::new(self.svc(false))
    }

    pub fn purchase_minting(&self) -> PurchaseClient<Svc> {
        PurchaseClient::new(self.svc(true))
    }

    /// **Whether the shop sells private instances here** (`EstateOrder.Sells`): its word is kept
    /// [`SELLS_FRESH`]; asked again after that, within [`SELLS_WAIT`]. A shop that does not
    /// answer keeps its last word; one that never answered — or knows no such call — sells none.
    pub async fn sells_estates(&self) -> bool {
        let held = *self.caches.estates.lock().unwrap_or_else(|p| p.into_inner());
        if let Some((sells, at)) = held {
            if at.elapsed() < SELLS_FRESH {
                return sells;
            }
        }
        let mut client = self.estate_order();
        let asked = client.sells(monetize_proto::EstateOrderSellsRequest {});
        let word = match tokio::time::timeout(SELLS_WAIT, asked).await {
            Ok(Ok(answer)) => Some(answer.into_inner().estates),
            Ok(Err(status)) if status.code() == tonic::Code::Unimplemented => Some(false),
            _ => None,
        };
        match word {
            Some(sells) => {
                *self.caches.estates.lock().unwrap_or_else(|p| p.into_inner()) = Some((sells, Instant::now()));
                sells
            }
            None => {
                let sells = held.is_some_and(|(sells, _)| sells);
                let at = Instant::now().checked_sub(SELLS_FRESH.saturating_sub(SELLS_RETRY)).unwrap_or_else(Instant::now);
                *self.caches.estates.lock().unwrap_or_else(|p| p.into_inner()) = Some((sells, at));
                sells
            }
        }
    }

    /// ★ **The shop's word that it sells private instances, as a kept ladder gave it** (LADDER-2):
    /// kept [`SELLS_FRESH`], as [`ShopLink::sells_estates`] keeps its own answer.
    pub(crate) fn note_sells_estates(&self, sells: bool) {
        *self.caches.estates.lock().unwrap_or_else(|p| p.into_inner()) = Some((sells, Instant::now()));
    }

    /// **What an account is, as the shop sells it here**: its first plan's room, "10GB"
    /// (`Signup.Plans`), else its name. `None` where no account is sold — no plan, no such
    /// call, or no answer. Kept [`SELLS_FRESH`] like [`ShopLink::sells_estates`].
    pub async fn account_offer(&self) -> Option<String> {
        let held = self.caches.accounts.lock().unwrap_or_else(|p| p.into_inner()).clone();
        if let Some((offer, at)) = &held {
            if at.elapsed() < SELLS_FRESH {
                return offer.clone();
            }
        }
        let mut client = self.signup();
        let asked = client.plans(monetize_proto::SignupPlansRequest { product: self.product.clone(), market: monetize_proto::Market::Unspecified as i32 });
        let word = match tokio::time::timeout(SELLS_WAIT, asked).await {
            Ok(Ok(answer)) => Some(answer.into_inner().plans.first().map(|p| if p.cap_bytes > 0 && p.cap_bytes % (1 << 30) == 0 { format!("{}GB", p.cap_bytes >> 30) } else { p.name.clone() })),
            Ok(Err(status)) if status.code() == tonic::Code::Unimplemented => Some(None),
            _ => None,
        };
        match word {
            Some(offer) => {
                *self.caches.accounts.lock().unwrap_or_else(|p| p.into_inner()) = Some((offer.clone(), Instant::now()));
                offer
            }
            None => {
                let offer = held.and_then(|(o, _)| o);
                let at = Instant::now().checked_sub(SELLS_FRESH.saturating_sub(SELLS_RETRY)).unwrap_or_else(Instant::now);
                *self.caches.accounts.lock().unwrap_or_else(|p| p.into_inner()) = Some((offer.clone(), at));
                offer
            }
        }
    }
}

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

    #[tokio::test]
    async fn the_bearer_is_never_sent_in_clear_and_never_empty() {
        let door = |u: &str| Door { url: u.into(), ..Door::default() };
        assert!(ShopLink::connect_lazy(&door("https://monetize.mock.invalid"), "", "holger").is_err());
        assert!(ShopLink::connect_lazy(&door("http://monetize.mock.invalid"), "t", "holger").is_err());
        assert!(ShopLink::connect_lazy(&door("http://127.0.0.1:1"), "t", "holger").is_ok());
        assert!(ShopLink::connect_lazy(&door("https://monetize.mock.invalid"), "t", "Holger").is_err());
        let l = ShopLink::connect_lazy(&door("https://monetize.mock.invalid"), "t", "holger").unwrap();
        assert!(!format!("{l:?}").contains('t') || !format!("{l:?}").contains("Bearer t"));
        assert_eq!(format!("{:?}", Bearer::of("secret-token").unwrap()), "Bearer(..)");
    }
}