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