crates/monetize-site-buy/src/relay.rs
//! **The relay: the site's own buy doors, over the shop.**
//!
//! Each door is a plain async function — `(relay, ask, body) → Answer` — so a site with its
//! own router and extractors (Gunnar's console) calls them from its handlers, and a site
//! without one mounts [`router`]. The JSON contract is Gunnar's, path for path; a refusal is
//! `{"error": "<sentence>"}`.
//!
//! | door | shop call |
//! |---|---|
//! | `GET /auth/signup/ladder[?market=]` | `Signup.Plans` |
//! | `GET /auth/signup/name?u=` | `Signup.CheckName` |
//! | `POST /auth/signup/start` | `Signup.Start`, the ways back on the site |
//! | `GET /auth/signup/status?session=` | `Signup.Status` (+ the product's account) |
//! | `POST /auth/signup/rename` | `Signup.Rename` |
//! | `POST /auth/signup/sign-in-again` | `Signup.SendSignInAgain` |
//! | `GET /auth/private/ladder[?company=]` | `EstateOrder.Ladder` |
//! | `POST /auth/private/start` | `EstateOrder.Start` (a visitor pays once; signed in: the account's `estate` ticket, no e-mail) |
//! | `POST /auth/private/change` | `EstateOrder.ChangePlan` |
//! | `GET /auth/private/status?company=` | `EstateOrder.Status` |
//! | `POST /auth/private/sign-in-again` | `EstateOrder.SendSignInAgain` |
//! | `POST /auth/private/renew {company}` | `EstateOrder.Renew` (Renew <co>) |
//! | `POST /auth/private/cancel {company}` | `EstateOrder.Cancel` (Stop renewing) |
//! | `POST /auth/private/terminate {company, confirm}` | `EstateOrder.Terminate` (a fresh login, the name typed back) |
//! | `POST /auth/private/restore-front {company}` | `EstateOrder.RestoreFront` |
//! | `GET /paid/estate/{co}[/{change}]` | none: 303 to the order's watch on the site |
//! | `GET /api/account/subscription/bundles` | `Purchase.Bundles` (the account's grow) |
//! | `POST /api/account/subscription/buy-bundle` | `Purchase.Start` with the bundle |
//! | `GET /api/account/subscription/status?reference=` | `Purchase.Status` (the way back from the vendor) |
//! | `GET /api/account/subscription` | `Purchase.Quote{renew}` (what another period comes to) |
//! | `POST /api/account/subscription/renew` | `Purchase.Start{renew}` (Prolong) |
//! | `GET /api/account/subscription/history` | `Purchase.History` (Past purchases) |
//! | `POST /api/account/subscription/cancel {reference}` | `Purchase.Cancel` ("Cancel order", an order not yet paid) |
//! | `GET /api/account/purchases` | `EstateOrder.Purchases` (Your purchases: the instances, Grow, Manage) |
//! | `GET /auth/buy/offer` | who asks, and what the shop sells here (`/auth/status`'s shop fields) |
use std::collections::{BTreeMap, HashMap};
use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use axum::extract::{Path, Query, State};
use axum::http::{HeaderMap, StatusCode};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use monetize_proto as pb;
use serde_json::{json, Value};
use crate::link::ShopLink;
use crate::rules::{self, words_for_a_visitor, RULE_IN_WORDS};
use crate::site::{Account, BuySite, Buyer, Refusal, Who, PURPOSE_CANCEL, STEP_UP_CODE, PURPOSE_ESTATE, PURPOSE_ORDER, PURPOSE_READ, PURPOSE_RENEW};
use crate::urls;
/// The `tracing` target every audit line of a buy lands on (route it with
/// `RUST_LOG=monetize_site_buy::audit=info`). The events are Gunnar's console's, by name.
pub const AUDIT_TARGET: &str = "monetize_site_buy::audit";
/// What a buyer reads when the ONE box does not answer the plan list.
pub const SHOP_UNAVAILABLE: &str = "The shop is unavailable right now — try again in a few minutes.";
/// What a buyer reads where the shop sells no account here.
pub const ACCOUNT_NOT_SOLD_HERE: &str = "An account is not sold here right now.";
/// What a buyer reads when monetize did not mint a checkout in time.
pub const START_TOO_SLOW: &str = "The payment page did not open in time. Nothing has been charged — please try again in a minute.";
/// A session id that is not one.
pub const NOT_A_SESSION: &str = "that is not a checkout session this console can ask about";
/// **The relay**: the product's site and its link to the shop.
pub struct Relay<S> {
pub site: S,
pub shop: ShopLink,
}
impl<S: BuySite> Relay<S> {
pub fn new(site: S, shop: ShopLink) -> Relay<S> {
Relay { site, shop }
}
}
/// **What a door was asked with**: the request's headers (the person's session, the browser's
/// languages) and the client's address as the site knows it.
#[derive(Clone, Debug, Default)]
pub struct Ask {
pub headers: HeaderMap,
pub ip: String,
}
impl Ask {
pub fn new(headers: HeaderMap, ip: impl Into<String>) -> Ask {
Ask { headers, ip: ip.into() }
}
fn accept_language(&self) -> Option<&str> {
self.headers.get(axum::http::header::ACCEPT_LANGUAGE).and_then(|v| v.to_str().ok())
}
}
/// **A door's answer**: JSON with a status, or a 303 to a path on the site.
#[derive(Clone, Debug, PartialEq)]
pub struct Answer {
pub status: StatusCode,
pub body: Value,
/// A `303 See Other` to this path instead of a body.
pub see_other: Option<String>,
}
impl Answer {
pub fn ok(body: Value) -> Answer {
Answer { status: StatusCode::OK, body, see_other: None }
}
pub fn refuse(status: StatusCode, words: &str) -> Answer {
Answer { status, body: json!({ "error": words }), see_other: None }
}
pub fn see_other(to: String) -> Answer {
Answer { status: StatusCode::SEE_OTHER, body: Value::Null, see_other: Some(to) }
}
fn of(r: Refusal) -> Answer {
Answer::refuse(r.status, &r.words)
}
/// The refusal's sentence, when this is one.
pub fn error(&self) -> Option<&str> {
self.body.get("error").and_then(Value::as_str)
}
}
impl IntoResponse for Answer {
fn into_response(self) -> Response {
match self.see_other {
Some(to) => (StatusCode::SEE_OTHER, [(axum::http::header::LOCATION, to), (axum::http::header::CACHE_CONTROL, "no-store".to_owned())]).into_response(),
None => (self.status, [(axum::http::header::CACHE_CONTROL, "no-store")], Json(self.body)).into_response(),
}
}
}
pub(crate) fn now_ms() -> u64 {
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
}
// ── sign-up ────────────────────────────────────────────────────────────────────────────
/// `GET /auth/signup/ladder[?market=]` → `{plans:[{id, name, resources_words, price_words,
/// period_days}], market}`: what a new account costs, in ONE market. A shop that does not
/// answer: 503 with the last offer it gave, read only.
pub async fn signup_plans<S: BuySite>(r: &Relay<S>, ask: &Ask, market: Option<&str>) -> Answer {
let market = match rules::market_for(market, ask.accept_language()) {
Ok(m) => m,
Err(why) => return Answer::refuse(StatusCode::BAD_REQUEST, &why),
};
if !r.site.allowed("private", &ask.ip, "plans") {
return Answer::refuse(StatusCode::TOO_MANY_REQUESTS, "too many requests from here; wait a moment");
}
// From the site's kept ladders (LADDER-2): never a wait on the ONE box past the first read.
r.shop.caches().ladders().answer(&r.shop, &crate::ladders::signup_key(market as i32)).await
}
/// The account plans page's body of a `Signup.Plans` answer.
pub(crate) fn plans_body(a: pb::SignupPlansResponse) -> Value {
json!({
"plans": a.plans.iter().map(|p| json!({
"id": p.id, "name": p.name, "resources_words": p.resources_words,
"price_words": p.price_words, "period_days": p.period_days,
})).collect::<Vec<_>>(),
"market": rules::market_word(a.market),
})
}
/// `GET /auth/signup/name?u=` → `{free, reason}`.
pub async fn signup_name<S: BuySite>(r: &Relay<S>, ask: &Ask, name: &str) -> Answer {
let name = name.trim();
if !rules::username(name) {
return Answer::ok(json!({ "free": false, "reason": format!("a username is {RULE_IN_WORDS}") }));
}
if !r.site.allowed("signup_name", &ask.ip, name) {
tracing::warn!(target: AUDIT_TARGET, event = "signup.name_rate_limited", ip = %ask.ip, "a signup name check was rate-limited");
return Answer::refuse(StatusCode::TOO_MANY_REQUESTS, "too many names checked from here; wait a moment");
}
match r.shop.signup().check_name(pb::CheckNameRequest { username: name.to_owned(), product: r.shop.product().to_owned() }).await {
Ok(a) => {
let a = a.into_inner();
Answer::ok(json!({ "free": a.free, "reason": a.reason }))
}
Err(s) => {
tracing::warn!(target: AUDIT_TARGET, event = "signup.name_failed", code = ?s.code(), message = %s.message(), "monetize could not answer a signup name check");
Answer::refuse(StatusCode::BAD_GATEWAY, words_for_a_visitor(&s))
}
}
}
/// What `POST /auth/signup/start` is given (Gunnar's `StartBody`).
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct SignupStart {
pub username: String,
#[serde(default)]
pub email: String,
#[serde(default)]
pub email_confirm: String,
#[serde(default)]
pub market: Option<String>,
#[serde(default)]
pub plan: String,
#[serde(default)]
pub next: String,
}
/// The member's refusal of a sign-up.
fn member_buys_nothing(m: &crate::site::Member) -> String {
format!("{} A member buys nothing: {}'s room is its admin's to buy.", m.line, m.account)
}
/// `POST /auth/signup/start` → `{url}`: a checkout for a new account (the free account's card
/// check, a paid plan). The e-mail is asked here, once, at account creation.
pub async fn signup_start<S: BuySite>(r: &Relay<S>, ask: &Ask, body: &SignupStart) -> Answer {
match r.site.who(&ask.headers).await {
Ok(Who::Person(Buyer { member: Some(m), .. })) => return Answer::refuse(StatusCode::FORBIDDEN, &member_buys_nothing(&m)),
Ok(_) => {}
Err(why) => return Answer::of(why),
}
let name = body.username.trim();
if !rules::username(name) {
return Answer::refuse(StatusCode::BAD_REQUEST, &format!("a username is {RULE_IN_WORDS}"));
}
let email = body.email.trim();
let confirm = if body.email_confirm.trim().is_empty() { email } else { body.email_confirm.trim() };
if let Some(why) = rules::email_refusal(email, confirm) {
return Answer::refuse(StatusCode::BAD_REQUEST, why);
}
let plan = body.plan.trim();
if !rules::plan_id(plan) {
return Answer::refuse(StatusCode::BAD_REQUEST, "that is not a plan this console can ask for");
}
let market = match rules::market(body.market.as_deref()) {
Ok(m) => m,
Err(why) => return Answer::refuse(StatusCode::BAD_REQUEST, &why),
};
if !r.site.allowed("signup_name", &ask.ip, name) {
tracing::warn!(target: AUDIT_TARGET, event = "signup.start_rate_limited", ip = %ask.ip, "a signup checkout request was rate-limited");
return Answer::refuse(StatusCode::TOO_MANY_REQUESTS, "too many signup attempts from here; wait a moment");
}
let began = std::time::Instant::now();
let origin = r.site.origin();
let origin = origin.trim_end_matches('/');
let next = rules::safe_next(body.next.trim());
let mut client = r.shop.signup_minting();
let asked = client.start(pb::StartSignupRequest {
username: name.to_owned(),
product: r.shop.product().to_owned(),
plan: plan.to_owned(),
vendor: r.shop.vendor().to_owned(),
success_url: urls::signup_success(origin, next.as_deref()),
cancel_url: urls::signup_cancel(origin, body.next.trim(), &r.site.pages()),
email: email.to_owned(),
market: market as i32,
});
let deadline = r.shop.deadlines().mint;
let started = match tokio::time::timeout(deadline, asked).await {
Ok(answer) => answer,
Err(_) => {
tracing::warn!(target: AUDIT_TARGET, event = "signup.start_timed_out", user = %name, waited_ms = began.elapsed().as_millis() as u64, deadline_ms = deadline.as_millis() as u64, "monetize minted no signup checkout within the deadline; the buyer was told nothing was charged");
return Answer::refuse(StatusCode::GATEWAY_TIMEOUT, START_TOO_SLOW);
}
};
let monetize_ms = began.elapsed().as_millis() as u64;
match started {
Ok(a) => {
let url = a.into_inner().url;
let own_welcome = url.starts_with(&format!("{origin}/welcome?"));
if rules::https_url(&url).is_none() && !own_welcome {
tracing::error!(target: AUDIT_TARGET, event = "signup.start_bad_url", "monetize answered a checkout URL that is not https");
return Answer::refuse(StatusCode::BAD_GATEWAY, "the checkout address is not one this console will send a browser to");
}
tracing::info!(target: AUDIT_TARGET, event = "signup.start", user = %name, monetize_ms, "a signup checkout was minted");
Answer::ok(json!({ "url": url }))
}
Err(s) if s.code() == tonic::Code::Unimplemented => match r.site.fixed_buy_url() {
// The typed address rides along as the link's pre-filled e-mail.
Some(fixed) => {
tracing::info!(target: AUDIT_TARGET, event = "signup.start_fallback", "monetize mints no checkout; the configured buy link was used");
Answer::ok(json!({ "url": buy_url_for(&fixed, email) }))
}
None => {
tracing::warn!(target: AUDIT_TARGET, event = "signup.not_sold", "this site cannot sell an account: monetize mints no checkout and no fixed buy link is configured");
Answer::refuse(StatusCode::NOT_IMPLEMENTED, ACCOUNT_NOT_SOLD_HERE)
}
},
Err(s) => {
tracing::warn!(target: AUDIT_TARGET, event = "signup.start_failed", code = ?s.code(), monetize_ms, message = %s.message(), "monetize would not mint a signup checkout");
let code = match s.code() {
tonic::Code::FailedPrecondition | tonic::Code::InvalidArgument => StatusCode::CONFLICT,
tonic::Code::PermissionDenied => StatusCode::FORBIDDEN,
_ => StatusCode::BAD_GATEWAY,
};
Answer::refuse(code, words_for_a_visitor(&s))
}
}
}
/// The fixed buy link with the typed address pre-filled (Stripe Payment Links read
/// `prefilled_email`). A link that already carries a query keeps it.
pub fn buy_url_for(fixed: &str, email: &str) -> String {
let sep = if fixed.contains('?') { '&' } else { '?' };
format!("{fixed}{sep}prefilled_email={}", rules::query_value(email))
}
fn state_name(wire: i32) -> &'static str {
use pb::signup_status_response::State;
match State::try_from(wire) {
Ok(State::Pending) => "pending",
Ok(State::Created) => "created",
Ok(State::Refused) => "refused",
_ => "unknown",
}
}
fn renameable(wire: i32) -> bool {
matches!(pb::signup_status_response::Refusal::try_from(wire), Ok(pb::signup_status_response::Refusal::Name))
}
/// A sign-up that is not an account (yet): monetize's customer sentence and nothing else.
fn not_created(a: &pb::SignupStatusResponse) -> Answer {
if !a.reason.is_empty() {
tracing::info!(target: AUDIT_TARGET, event = "signup.not_created", state = state_name(a.state), reason = %a.reason, "monetize's operator reason for a signup that is not an account");
}
// RA4-19: a PENDING answer carries the box's sentence for its own silence, and when it is
// due, so the page keeps no settle bound and no settle words of its own.
Answer::ok(json!({ "state": state_name(a.state), "username": "", "invite_url": Value::Null, "words": a.words, "renameable": renameable(a.refusal), "words_if_silent": a.words_if_silent, "silent_after_ms": a.silent_after_ms }))
}
/// The account exists: the link that registers its passkey, while it holds none.
async fn created<S: BuySite>(r: &Relay<S>, session: &str, a: &pb::SignupStatusResponse) -> Answer {
let product = r.shop.product();
if !a.product.is_empty() && a.product != product {
return Answer::ok(json!({ "state": "refused", "username": "", "invite_url": Value::Null, "words": format!("that order is for {:?} and this console is {product}'s", a.product), "renameable": false }));
}
let user = a.username.trim();
match r.site.signup_account(user).await {
Ok(Account::NoPasskey) => {
tracing::info!(target: AUDIT_TARGET, event = "signup.link", user = %user, "a paid signup's account is ready for its passkey");
Answer::ok(json!({ "state": "created", "username": a.username, "invite_url": urls::invite_url(session, user), "words": "", "renameable": false }))
}
Ok(Account::HasPasskey) => Answer::ok(json!({ "state": "created", "username": a.username, "invite_url": Value::Null, "words": "this signup's registration link has already been used", "renameable": false })),
Err(why) => {
tracing::error!(target: AUDIT_TARGET, event = "signup.link_failed", user = %user, reason = %why.words, "an account exists but the site could not ready its registration");
Answer::of(why)
}
}
}
/// `GET /auth/signup/status?session=` → `{state, username, invite_url, words, renameable}`, and
/// for PENDING `{words_if_silent, silent_after_ms}` (RA4-19).
pub async fn signup_status<S: BuySite>(r: &Relay<S>, ask: &Ask, session: &str) -> Answer {
if !rules::session(session) {
return Answer::refuse(StatusCode::BAD_REQUEST, NOT_A_SESSION);
}
if !r.site.allowed("signup", &ask.ip, session) {
tracing::warn!(target: AUDIT_TARGET, event = "signup.rate_limited", ip = %ask.ip, "a signup status poll was rate-limited");
return Answer::refuse(StatusCode::TOO_MANY_REQUESTS, "too many signup lookups from here; wait a moment");
}
let a = match r.shop.signup().status(pb::SignupStatusRequest { vendor: r.shop.vendor().to_owned(), session: session.to_owned() }).await {
Ok(a) => a.into_inner(),
Err(s) => {
tracing::warn!(target: AUDIT_TARGET, event = "signup.status_failed", code = ?s.code(), message = %s.message(), "monetize could not answer a signup status poll");
return Answer::refuse(StatusCode::BAD_GATEWAY, words_for_a_visitor(&s));
}
};
if state_name(a.state) != "created" {
return not_created(&a);
}
created(r, session, &a).await
}
/// What `POST /auth/signup/rename` is given.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct SignupRename {
pub session: String,
pub username: String,
}
/// `POST /auth/signup/rename` → the status's own answer: a sign-up refused over its NAME,
/// finished under another one.
pub async fn signup_rename<S: BuySite>(r: &Relay<S>, ask: &Ask, body: &SignupRename) -> Answer {
let (session, name) = (body.session.trim(), body.username.trim());
if !rules::session(session) {
return Answer::refuse(StatusCode::BAD_REQUEST, NOT_A_SESSION);
}
if !rules::username(name) {
return Answer::refuse(StatusCode::BAD_REQUEST, &format!("a username is {RULE_IN_WORDS}"));
}
if !r.site.allowed("signup", &ask.ip, session) {
tracing::warn!(target: AUDIT_TARGET, event = "signup.rename_rate_limited", ip = %ask.ip, "a signup rename was rate-limited");
return Answer::refuse(StatusCode::TOO_MANY_REQUESTS, "too many signup attempts from here; wait a moment");
}
let a = match r.shop.signup().rename(pb::RenameSignupRequest { vendor: r.shop.vendor().to_owned(), session: session.to_owned(), username: name.to_owned() }).await {
Ok(a) => a.into_inner().status.unwrap_or_default(),
Err(s) => {
tracing::warn!(target: AUDIT_TARGET, event = "signup.rename_failed", code = ?s.code(), user = %name, message = %s.message(), "monetize would not rename a signup");
let code = match s.code() {
tonic::Code::InvalidArgument | tonic::Code::FailedPrecondition => StatusCode::CONFLICT,
tonic::Code::NotFound => StatusCode::NOT_FOUND,
tonic::Code::PermissionDenied => StatusCode::FORBIDDEN,
_ => StatusCode::BAD_GATEWAY,
};
return Answer::refuse(code, words_for_a_visitor(&s));
}
};
if state_name(a.state) != "created" {
return not_created(&a);
}
tracing::info!(target: AUDIT_TARGET, event = "signup.renamed", user = %a.username, "a signup refused over its name was finished under another one");
created(r, session, &a).await
}
/// What `POST /auth/signup/sign-in-again` is given.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct SignInAgain {
#[serde(default)]
pub session: String,
#[serde(default)]
pub next: String,
}
/// `POST /auth/signup/sign-in-again` → `{words}`: the welcome page's [Send again].
pub async fn signup_sign_in_again<S: BuySite>(r: &Relay<S>, ask: &Ask, body: &SignInAgain) -> Answer {
let session = body.session.trim();
if !rules::session(session) {
return Answer::refuse(StatusCode::BAD_REQUEST, NOT_A_SESSION);
}
if !r.site.allowed("signup", &ask.ip, session) {
return Answer::refuse(StatusCode::TOO_MANY_REQUESTS, "too many signup lookups from here; wait a moment");
}
let failed = |s: tonic::Status| {
let code = match s.code() {
tonic::Code::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS,
tonic::Code::NotFound | tonic::Code::FailedPrecondition => StatusCode::CONFLICT,
tonic::Code::InvalidArgument => StatusCode::BAD_REQUEST,
tonic::Code::PermissionDenied => StatusCode::FORBIDDEN,
_ => StatusCode::BAD_GATEWAY,
};
let words = if s.code() == tonic::Code::ResourceExhausted { s.message() } else { words_for_a_visitor(&s) };
Answer::refuse(code, words)
};
let a = match r.shop.signup().status(pb::SignupStatusRequest { vendor: r.shop.vendor().to_owned(), session: session.to_owned() }).await {
Ok(a) => a.into_inner(),
Err(s) => return failed(s),
};
if state_name(a.state) != "created" {
return Answer::refuse(StatusCode::CONFLICT, "This account is not made yet: its sign-in link is mailed once it is.");
}
match r.site.signup_account(a.username.trim()).await {
Ok(Account::NoPasskey) => {}
Ok(Account::HasPasskey) => return Answer::refuse(StatusCode::CONFLICT, "This account already has a security key: sign in with it."),
Err(why) => return Answer::of(why),
}
let next = rules::safe_next(body.next.trim());
let origin = r.site.origin();
match r.shop.signup().send_sign_in_again(pb::SignupSignInAgainRequest { product: r.shop.product().to_owned(), session: session.to_owned(), welcome_url: urls::signup_success(&origin, next.as_deref()) }).await {
Ok(a) => Answer::ok(json!({ "words": a.into_inner().words })),
Err(s) => failed(s),
}
}
// ── a private instance ─────────────────────────────────────────────────────────────────
/// The most bytes of a product's own order a page may send (its JSON).
pub const ORDER_MAX: usize = 4096;
/// What a private-instance door is given (Gunnar's `SizesBody`).
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct Sizes {
#[serde(default)]
pub company: String,
/// A visitor's: the new account's address. Signed in, none is sent: monetize mails the
/// account's own.
#[serde(default)]
pub email: String,
#[serde(default)]
pub domain: String,
#[serde(default)]
pub front_plan: String,
#[serde(default)]
pub main_plan: String,
#[serde(default)]
pub main_data_gib: u64,
/// ★ **The product's own order** (v2.0 P3, the parametric order): the values its order form
/// names (a JSON object), passed on as they came; the ONE box checks them against the
/// product's form. Absent: the product's default order.
#[serde(default)]
pub order: serde_json::Value,
/// A visitor's: the name the NEW account is to have. Signed in: ignored.
#[serde(default)]
pub account: String,
}
impl Sizes {
fn overlong(&self) -> Option<&'static str> {
if self.email.trim().len() > 254 {
return Some("that email address is too long");
}
if self.domain.trim().len() > 253 {
return Some("that domain is too long");
}
if self.front_plan.trim().len() > 64 || self.main_plan.trim().len() > 64 {
return Some("which size? that plan is not one sold here");
}
if !(self.order.is_null() || self.order.is_object()) || self.order.to_string().len() > ORDER_MAX {
return Some("which order? an order is a short set of named values");
}
None
}
fn sizes(&self) -> pb::EstateSizes {
let order = if self.order.is_null() { String::new() } else { self.order.to_string() };
pb::EstateSizes { front_plan: self.front_plan.trim().to_owned(), main_plan: self.main_plan.trim().to_owned(), main_data_gib: self.main_data_gib, order }
}
}
/// What a visitor is told when they order a private instance: it is bought by an account here.
pub fn sign_in_first(word: &str, host: &str) -> String {
format!("A private {word} is bought by your account at {host}, where you see and grow what you bought. Sign in, or Create your account at {host} first.")
}
fn host_of(origin: &str) -> String {
origin.split_once("://").map(|(_, rest)| rest.split(['/', ':']).next().unwrap_or_default().to_owned()).unwrap_or_default()
}
pub(crate) fn from_status(s: tonic::Status) -> Answer {
let code = rules::http_status_of(s.code());
if code == StatusCode::BAD_GATEWAY || code == StatusCode::SERVICE_UNAVAILABLE {
tracing::warn!(target: AUDIT_TARGET, event = "private.monetize_failed", code = ?s.code(), message = %s.message(), "monetize did not finish a private-instance call");
}
Answer::refuse(rules::http_status_of(s.code()), &rules::buyer_words(&s))
}
async fn private_door<S: BuySite>(r: &Relay<S>, ask: &Ask, key: &str) -> Result<(), Answer> {
if !r.site.allowed("private", &ask.ip, key) {
tracing::warn!(target: AUDIT_TARGET, event = "private.rate_limited", ip = %ask.ip, "a private-instance request was rate-limited");
return Err(Answer::refuse(StatusCode::TOO_MANY_REQUESTS, "too many requests from here; wait a moment"));
}
if !r.shop.sells_estates().await {
return Err(Answer::refuse(StatusCode::NOT_FOUND, &format!("A private {} is not sold here.", r.site.product_word())));
}
Ok(())
}
/// **The signed-in ACCOUNT, proven**: the person's own account and its `estate` ticket. A
/// member is refused; nobody signed in is asked to sign in or make the account first.
async fn account_ticket<S: BuySite>(r: &Relay<S>, ask: &Ask) -> Result<(String, Vec<u8>), Answer> {
let buyer = match r.site.who(&ask.headers).await.map_err(Answer::of)? {
Who::Person(b) => b,
Who::Visitor | Who::Machine => return Err(Answer::refuse(StatusCode::UNAUTHORIZED, &sign_in_first(&r.site.product_word(), &host_of(&r.site.origin())))),
};
if let Some(m) = &buyer.member {
let buys = if m.admin.is_empty() { &m.account } else { &m.admin };
return Err(Answer::refuse(StatusCode::FORBIDDEN, &format!("{} Members don’t buy: {buys} buys for {}.", m.line, m.account)));
}
let ticket = r.site.ticket(&ask.headers, &buyer, PURPOSE_ESTATE, &BTreeMap::new()).await.map_err(Answer::of)?;
Ok((buyer.account, ticket))
}
fn company_of(q: Option<&str>) -> Option<String> {
let c = q.map(|c| c.trim().to_ascii_lowercase()).unwrap_or_default();
(!c.is_empty() && c.len() <= 16 && c.chars().all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit())).then_some(c)
}
/// `GET /auth/private/ladder[?company=]` → monetize's ladder, as is. For an instance its
/// account's `estate` ticket rides along.
pub async fn private_ladder<S: BuySite>(r: &Relay<S>, ask: &Ask, company: Option<&str>) -> Answer {
// A new instance's ladder is the site's, kept (LADDER-1/2): answered at once with its age,
// never a wait on the ONE box's pricing; the sells word is asked only while nothing is kept.
if company.is_none() {
if !r.site.allowed("private", &ask.ip, "ladder") {
return Answer::refuse(StatusCode::TOO_MANY_REQUESTS, "too many requests from here; wait a moment");
}
if let Some(kept) = r.shop.caches().ladders().kept(crate::ladders::PRIVATE) {
return kept;
}
// Nothing kept: the sells word and the first read together, each bounded, so a stuck ONE
// box costs the page about a second and a shop that sells none still says so at once.
let (sells, kept) = tokio::join!(tokio::time::timeout(crate::ladders::FIRST_WAIT, r.shop.sells_estates()), r.shop.caches().ladders().answer(&r.shop, crate::ladders::PRIVATE));
if matches!(sells, Ok(false)) {
return Answer::refuse(StatusCode::NOT_FOUND, &format!("A private {} is not sold here.", r.site.product_word()));
}
return kept;
}
if let Err(a) = private_door(r, ask, "ladder").await {
return a;
}
let (company, actor_ticket) = match company_of(company) {
None if company.is_some() => return Answer::refuse(StatusCode::BAD_REQUEST, "which instance? ?company=<name>"),
None => (String::new(), Vec::new()),
Some(co) => match account_ticket(r, ask).await {
Ok((_, t)) => (co, t),
Err(a) => return a,
},
};
match r.shop.estate_order().ladder(pb::EstateOrderLadderRequest { company, actor_ticket }).await {
Ok(l) => Answer::ok(ladder_body(l.into_inner())),
Err(s) => from_status(s),
}
}
/// The ladder page's body of an `EstateOrder.Ladder` answer.
pub(crate) fn ladder_body(l: pb::EstateOrderLadderResponse) -> Value {
json!({
"steps": l.steps.iter().map(|s| {
let sizes = s.sizes.clone().unwrap_or_default();
json!({
"name": s.name,
"sizes": { "front_plan": sizes.front_plan, "main_plan": sizes.main_plan, "main_data_gib": sizes.main_data_gib, "order": serde_json::from_str::<serde_json::Value>(&sizes.order).unwrap_or(serde_json::Value::Null) },
"twin_plan": s.twin_plan,
"machines": s.machines.iter().map(|m| json!({"role": m.role, "plan": m.plan, "cores": m.cores, "memory_mib": m.memory_mib, "disks_gib": m.disks_gib})).collect::<Vec<_>>(),
"resources_words": s.resources_words, "monthly_words": s.monthly_words, "total_words": s.total_words,
"pay_words": s.pay_words, "available": s.available, "reason": s.reason,
})
}).collect::<Vec<_>>(),
"start": l.start, "floor": l.floor, "laid": l.laid, "current": l.current, "apex": l.apex, "term_months": l.term_months,
})
}
/// `POST /auth/private/start` → `{url, state, words, price_words}`. A visitor pays ONCE (the
/// order names the new account and its e-mail; monetize makes it when paid); signed in, the
/// order is the account's, proven by its `estate` ticket, and no e-mail is sent.
pub async fn private_start<S: BuySite>(r: &Relay<S>, ask: &Ask, body: &Sizes) -> Answer {
let company = body.company.trim().to_ascii_lowercase();
if !rules::company(&company) {
return Answer::refuse(StatusCode::BAD_REQUEST, "which instance? a company name: lowercase letters and digits, a letter first, at most 16");
}
if let Some(why) = body.overlong() {
return Answer::refuse(StatusCode::BAD_REQUEST, why);
}
if let Err(a) = private_door(r, ask, &format!("start:{company}")).await {
return a;
}
let origin = r.site.origin();
let pages = r.site.pages();
let visitor = matches!(r.site.who(&ask.headers).await, Ok(Who::Visitor));
let asked = if visitor {
let account = body.account.trim().to_ascii_lowercase();
if account.is_empty() || account.len() > 32 {
return Answer::refuse(StatusCode::BAD_REQUEST, "which account? the name your new account is to have");
}
if body.email.trim().is_empty() {
return Answer::refuse(StatusCode::BAD_REQUEST, "the e-mail address your new account's sign-in link goes to");
}
pb::EstateOrderStartRequest {
company: company.clone(),
email: body.email.trim().to_owned(),
domain: body.domain.trim().to_owned(),
sizes: Some(body.sizes()),
account,
actor_ticket: Vec::new(),
new_account: true,
welcome_url: urls::private_welcome(&origin, &pages, &company),
cancel_url: urls::private_cancel(&origin, &pages),
}
} else {
let (account, ticket) = match account_ticket(r, ask).await {
Ok(t) => t,
Err(a) => return a,
};
// The e-mail is asked once, at account creation, never in a buy (owner 2026-10-22): the
// order mails the account's own address (monetize `account_email`), whatever a page sent.
pb::EstateOrderStartRequest {
company: company.clone(),
email: String::new(),
domain: body.domain.trim().to_owned(),
sizes: Some(body.sizes()),
account,
actor_ticket: ticket,
cancel_url: urls::private_cancel(&origin, &pages),
..Default::default()
}
};
let mut client = r.shop.estate_order_minting();
match tokio::time::timeout(r.shop.deadlines().mint, client.start(asked)).await {
Err(_) => Answer::refuse(StatusCode::GATEWAY_TIMEOUT, START_TOO_SLOW),
Ok(Err(s)) => from_status(s),
Ok(Ok(a)) => {
let a = a.into_inner();
if rules::https_url(&a.checkout_url).is_none() {
return Answer::refuse(StatusCode::BAD_GATEWAY, rules::PAY_URL_REFUSED);
}
tracing::info!(target: AUDIT_TARGET, event = "private.start", company = %company, "a private-instance hold was asked for");
Answer::ok(json!({ "url": a.checkout_url, "state": a.state, "words": a.words, "price_words": a.price_words }))
}
}
}
/// `POST /auth/private/change` → `{url, status, words, price_words, reference}`: grow the
/// account's own instance by a step of the ladder.
pub async fn private_change<S: BuySite>(r: &Relay<S>, ask: &Ask, body: &Sizes) -> Answer {
let company = body.company.trim().to_ascii_lowercase();
if !rules::company(&company) {
return Answer::refuse(StatusCode::BAD_REQUEST, "which instance? a company name");
}
if let Some(why) = body.overlong() {
return Answer::refuse(StatusCode::BAD_REQUEST, why);
}
if let Err(a) = private_door(r, ask, &format!("change:{company}")).await {
return a;
}
let (_, actor_ticket) = match account_ticket(r, ask).await {
Ok(t) => t,
Err(a) => return a,
};
// A grow mints a vendor page: the minting channel, and the buyer told at its deadline.
let mut client = r.shop.estate_order_minting();
match tokio::time::timeout(r.shop.deadlines().mint, client.change_plan(pb::EstateOrderChangePlanRequest { company: company.clone(), sizes: Some(body.sizes()), actor_ticket, test_order: false })).await {
Err(_) => Answer::refuse(StatusCode::GATEWAY_TIMEOUT, START_TOO_SLOW),
Ok(Err(s)) => from_status(s),
Ok(Ok(c)) => {
let c = c.into_inner();
let Some(url) = rules::checked_pay_url(c.checkout_url) else {
return Answer::refuse(StatusCode::BAD_GATEWAY, rules::PAY_URL_REFUSED);
};
tracing::info!(target: AUDIT_TARGET, event = "private.change", company = %company, reference = %c.reference, "an instance's change was asked for");
Answer::ok(json!({ "url": url, "status": c.status, "words": c.words, "price_words": c.price_words, "reference": c.reference }))
}
}
}
/// `GET /auth/private/status?company=` → `{state, words, url, paid_until, records,
/// change_status, change_words}`: the whole order for its account, the state word for anyone
/// else.
pub async fn private_status<S: BuySite>(r: &Relay<S>, ask: &Ask, company: &str) -> Answer {
let company = company.trim().to_ascii_lowercase();
if !rules::company(&company) {
return Answer::refuse(StatusCode::BAD_REQUEST, "which instance? ?company=<name>");
}
if !r.site.allowed("private", &ask.ip, &format!("status:{company}")) {
return Answer::refuse(StatusCode::TOO_MANY_REQUESTS, "too many requests from here; wait a moment");
}
let actor_ticket = match account_ticket(r, ask).await {
Ok((_, t)) => t,
Err(a) if a.status.is_server_error() => return a,
Err(_) => Vec::new(),
};
match r.shop.estate_order().status(pb::EstateOrderStatusRequest { company, actor_ticket }).await {
Ok(s) => {
let s = s.into_inner();
Answer::ok(json!({
"state": s.state, "words": s.words, "url": s.url, "paid_until": s.paid_until,
"records": s.dns_records.iter().map(|d| json!({"name": d.name, "kind": d.kind, "value": d.value})).collect::<Vec<_>>(),
"change_status": s.change_status, "change_words": s.change_words,
}))
}
Err(s) => from_status(s),
}
}
/// What a door naming an instance is given.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct CompanyBody {
#[serde(default)]
pub company: String,
}
/// `POST /auth/private/sign-in-again` → `{words}`: the ready instance's first sign-in link,
/// mailed again to the order's address.
pub async fn private_sign_in_again<S: BuySite>(r: &Relay<S>, ask: &Ask, body: &CompanyBody) -> Answer {
let company = body.company.trim().to_ascii_lowercase();
if !rules::company(&company) {
return Answer::refuse(StatusCode::BAD_REQUEST, "which instance? a company name");
}
if !r.site.allowed("private", &ask.ip, &format!("sign-in-again:{company}")) {
return Answer::refuse(StatusCode::TOO_MANY_REQUESTS, "too many requests from here; wait a moment");
}
let (_, actor_ticket) = match account_ticket(r, ask).await {
Ok(t) => t,
Err(a) => return a,
};
match r.shop.estate_order().send_sign_in_again(pb::EstateOrderSignInAgainRequest { company: company.clone(), actor_ticket }).await {
Ok(a) => {
tracing::info!(target: AUDIT_TARGET, event = "private.sign_in_again", company = %company, "a new sign-in link was asked for by its account");
Answer::ok(json!({ "words": a.into_inner().words }))
}
Err(s) => from_status(s),
}
}
// ── managing an instance (Gunnar's `auth::private` renew / cancel / terminate / restore-front) ──
/// The budget a managing door spends (Gunnar's `door`: offered here and inside budget; these
/// doors act on an instance already sold, so the shop's estates offer is not asked).
fn manage_budget<S: BuySite>(r: &Relay<S>, ask: &Ask, key: &str) -> Result<(), Answer> {
if !r.site.allowed("private", &ask.ip, key) {
tracing::warn!(target: AUDIT_TARGET, event = "private.rate_limited", ip = %ask.ip, "a private-instance request was rate-limited");
return Err(Answer::refuse(StatusCode::TOO_MANY_REQUESTS, "too many requests from here; wait a moment"));
}
Ok(())
}
fn company_named(body: &CompanyBody) -> Result<String, Answer> {
let company = body.company.trim().to_ascii_lowercase();
if rules::company(&company) {
Ok(company)
} else {
Err(Answer::refuse(StatusCode::BAD_REQUEST, "which instance? a company name"))
}
}
/// `POST /auth/private/renew {company}` → `{url, words, price_words, paid_until}`: **Renew <co>**,
/// the signed-in account's own instance by its `estate` ticket; monetize prices it and answers
/// the payment page.
pub async fn private_renew<S: BuySite>(r: &Relay<S>, ask: &Ask, body: &CompanyBody) -> Answer {
let company = match company_named(body) {
Ok(c) => c,
Err(a) => return a,
};
if let Err(a) = manage_budget(r, ask, &format!("renew:{company}")) {
return a;
}
let (_, actor_ticket) = match account_ticket(r, ask).await {
Ok(t) => t,
Err(a) => return a,
};
// A renewal mints a vendor page: the minting channel, and the buyer told at its deadline.
let mut client = r.shop.estate_order_minting();
match tokio::time::timeout(r.shop.deadlines().mint, client.renew(pb::EstateOrderRenewRequest { company: company.clone(), actor_ticket })).await {
Err(_) => Answer::refuse(StatusCode::GATEWAY_TIMEOUT, START_TOO_SLOW),
Ok(Err(s)) => from_status(s),
Ok(Ok(a)) => {
let a = a.into_inner();
if rules::https_url(&a.checkout_url).is_none() {
return Answer::refuse(StatusCode::BAD_GATEWAY, rules::PAY_URL_REFUSED);
}
tracing::info!(target: AUDIT_TARGET, event = "private.renew", company = %company, reference = %a.reference, "an instance's renewal was asked for");
Answer::ok(json!({ "url": a.checkout_url, "words": a.words, "price_words": a.price_words, "paid_until": a.paid_until }))
}
}
}
/// `POST /auth/private/cancel {company}` → `{words, paid_until, delete_on}`: **Stop renewing**:
/// the instance runs to the end of its paid period, is then stopped with its data kept, and
/// deleted after the grace.
pub async fn private_stop_renewing<S: BuySite>(r: &Relay<S>, ask: &Ask, body: &CompanyBody) -> Answer {
let company = match company_named(body) {
Ok(c) => c,
Err(a) => return a,
};
if let Err(a) = manage_budget(r, ask, &format!("cancel:{company}")) {
return a;
}
let (_, actor_ticket) = match account_ticket(r, ask).await {
Ok(t) => t,
Err(a) => return a,
};
match r.shop.estate_order().cancel(pb::EstateOrderCancelRequest { company: company.clone(), actor_ticket }).await {
Err(s) => from_status(s),
Ok(c) => {
let c = c.into_inner();
tracing::info!(target: AUDIT_TARGET, event = "private.cancel", company = %company, "an instance's renewal was stopped by its account");
Answer::ok(json!({ "words": c.words, "paid_until": c.paid_until, "delete_on": c.delete_on }))
}
}
}
/// `POST /auth/private/restore-front {company}` → `{words, moved, public_ipv4}`: **a lost front
/// made again**; monetize restores it from its daily backup (or lays it again and says so).
pub async fn private_restore_front<S: BuySite>(r: &Relay<S>, ask: &Ask, body: &CompanyBody) -> Answer {
let company = match company_named(body) {
Ok(c) => c,
Err(a) => return a,
};
if let Err(a) = manage_budget(r, ask, &format!("restore-front:{company}")) {
return a;
}
let (_, actor_ticket) = match account_ticket(r, ask).await {
Ok(t) => t,
Err(a) => return a,
};
match r.shop.estate_order().restore_front(pb::EstateOrderRestoreFrontRequest { company: company.clone(), actor_ticket }).await {
Err(s) => from_status(s),
Ok(f) => {
let f = f.into_inner();
tracing::info!(target: AUDIT_TARGET, event = "private.restore_front", company = %company, moved = f.moved, "an instance's lost front was restored by its account");
Answer::ok(json!({ "words": f.words, "moved": f.moved, "public_ipv4": f.public_ipv4 }))
}
}
}
/// What `POST /auth/private/terminate` is given: which instance and the name typed back. No
/// backup is offered, ever (A32-27): an older page's `backup` word is not read.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct TerminateBody {
#[serde(default)]
pub company: String,
#[serde(default)]
pub confirm: String,
}
/// Pure: **the name typed back names the instance** — its company, or a host under it.
/// monetize checks it again, exactly; this only keeps a slip from dialling anything.
pub fn typed_back(company: &str, typed: &str) -> bool {
let typed = typed.trim().to_ascii_lowercase();
!typed.is_empty() && (typed == company || typed.starts_with(&format!("{company}.")))
}
/// `POST /auth/private/terminate {company, confirm}` → `{words, done}`: **Terminate <co>**. The
/// account's own `estate` ticket, a recent user-verified login ([`BuySite::stepped_up`]), the
/// name typed back; no backup is taken.
pub async fn private_terminate<S: BuySite>(r: &Relay<S>, ask: &Ask, body: &TerminateBody) -> Answer {
let company = body.company.trim().to_ascii_lowercase();
if !rules::company(&company) {
return Answer::refuse(StatusCode::BAD_REQUEST, "which instance? a company name");
}
if !typed_back(&company, &body.confirm) {
return Answer::refuse(StatusCode::BAD_REQUEST, &format!("Type the instance's name, {company}, to terminate it. Nothing was ended."));
}
if let Err(a) = manage_budget(r, ask, &format!("terminate:{company}")) {
return a;
}
// A recent fresh login: a person whose user-verified login is older than the site's window
// logs in again first. Nobody signed in is told so by `account_ticket`.
if let Ok(Who::Person(_)) = r.site.who(&ask.headers).await {
if let Err(why) = r.site.stepped_up(&ask.headers).await {
let words = format!("{why} Nothing was ended.");
return Answer { status: StatusCode::FORBIDDEN, body: json!({ "error": words, "code": STEP_UP_CODE, "message": words }), see_other: None };
}
}
let (_, actor_ticket) = match account_ticket(r, ask).await {
Ok(t) => t,
Err(a) => return a,
};
match r.shop.estate_order().terminate(pb::EstateOrderTerminateRequest { company: company.clone(), actor_ticket, confirm: body.confirm.trim().to_owned() }).await {
Err(s) => from_status(s),
Ok(t) => {
let t = t.into_inner();
tracing::warn!(target: AUDIT_TARGET, event = "private.terminate", company = %company, done = t.done, "an instance's terminate was asked for by its account");
Answer::ok(json!({ "words": t.words, "done": t.done }))
}
}
}
/// `GET /paid/estate/{company}` → 303 to the order's watch on the site.
pub fn paid_estate<S: BuySite>(r: &Relay<S>, company: &str) -> Answer {
Answer::see_other(urls::paid_estate_to(&r.site.pages(), company))
}
/// `GET /paid/estate/{company}/{change}` → 303 to the order's watch, told it is a change.
pub fn paid_estate_change<S: BuySite>(r: &Relay<S>, company: &str, change: &str) -> Answer {
Answer::see_other(urls::paid_change_to(&r.site.pages(), company, change))
}
// ── the account's grow (Gunnar's `api::subscription` bundles / buy-bundle / status) ──
async fn person<S: BuySite>(r: &Relay<S>, ask: &Ask) -> Result<Buyer, Answer> {
match r.site.who(&ask.headers).await.map_err(Answer::of)? {
Who::Person(b) => match &b.member {
Some(m) => Err(Answer::refuse(StatusCode::FORBIDDEN, &member_buys_nothing(m))),
None => Ok(b),
},
_ => Err(Answer::refuse(StatusCode::UNAUTHORIZED, "Sign in to the account that buys first.")),
}
}
fn refused_call(s: tonic::Status) -> Answer {
Answer::refuse(rules::http_status_of(s.code()), &rules::buyer_words(&s))
}
fn json_of<T: serde::Serialize>(v: &T) -> Answer {
Answer::ok(serde_json::to_value(v).unwrap_or(Value::Null))
}
async fn listing<S: BuySite>(r: &Relay<S>, ask: &Ask, buyer: &Buyer, market: pb::Market) -> Result<pb::ListBundlesResponse, Answer> {
let ticket = r.site.ticket(&ask.headers, buyer, PURPOSE_READ, &BTreeMap::new()).await.map_err(Answer::of)?;
r.shop
.purchase()
.bundles(pb::ListBundlesRequest { product: r.shop.product().to_owned(), tenant: buyer.tenant().to_owned(), market: market as i32, actor_ticket: ticket })
.await
.map(tonic::Response::into_inner)
.map_err(refused_call)
}
/// `GET /api/account/subscription/bundles` → [`Window`](crate::purchase::Window): "Buy a plan"
/// (no plan held) or "Grow your account", only what the box can serve.
pub async fn buy_bundles<S: BuySite>(r: &Relay<S>, ask: &Ask) -> Answer {
let market = match rules::market_for(None, ask.accept_language()) {
Ok(m) => m,
Err(why) => return Answer::refuse(StatusCode::BAD_REQUEST, &why),
};
let buyer = match person(r, ask).await {
Ok(b) => b,
Err(a) => return a,
};
if let Err(a) = grow_offered(r).await {
return a;
}
match listing(r, ask, &buyer, market).await {
Ok(w) => json_of(&crate::purchase::Window { market: rules::market_word(market as i32).to_owned(), ..crate::purchase::window_of(w) }),
Err(a) => a,
}
}
/// What `POST /api/account/subscription/buy-bundle` is given: one opaque id.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct BundleOrder {
pub bundle: String,
}
/// `POST /api/account/subscription/buy-bundle {bundle}` → [`Attempt`](crate::purchase::Attempt):
/// "Upgrade". The delta is monetize's window's, the held caps the product's, and the ticket
/// covers exactly the caps the order resolves to.
pub async fn buy_bundle<S: BuySite>(r: &Relay<S>, ask: &Ask, order: &BundleOrder) -> Answer {
let market = match rules::market_for(None, ask.accept_language()) {
Ok(m) => m,
Err(why) => return Answer::refuse(StatusCode::BAD_REQUEST, &why),
};
let buyer = match person(r, ask).await {
Ok(b) => b,
Err(a) => return a,
};
if let Err(a) = grow_offered(r).await {
return a;
}
let held = match r.site.held_caps(&ask.headers, &buyer).await {
Ok(h) => h,
Err(why) => return Answer::of(why),
};
let window = match listing(r, ask, &buyer, market).await {
Ok(w) => w,
Err(a) => return a,
};
let Some(chosen) = window.bundles.into_iter().find(|b| b.id == order.bundle) else {
return Answer::refuse(StatusCode::BAD_REQUEST, "that is not something this account can order right now. Reload the page for what is available.");
};
let caps = crate::purchase::order_caps(&chosen, &held);
let ticket = if crate::purchase::is_renewal(&chosen) {
r.site.ticket(&ask.headers, &buyer, PURPOSE_RENEW, &BTreeMap::new()).await
} else {
r.site.ticket(&ask.headers, &buyer, PURPOSE_ORDER, &caps).await
};
let ticket = match ticket {
Ok(t) => t,
Err(why) => return Answer::of(why),
};
let mut client = r.shop.purchase_minting();
let asked = client.start(pb::StartPurchaseRequest {
product: r.shop.product().to_owned(),
tenant: buyer.tenant().to_owned(),
caps: Default::default(),
vendor: String::new(),
payer: buyer.payer.clone(),
starts_unix_ms: 0,
market: market as i32,
renew: false,
bundle: order.bundle.clone(),
actor_ticket: ticket,
users: None,
});
let started = match tokio::time::timeout(r.shop.deadlines().mint + crate::link::MINT_GRACE, asked).await {
Err(_) => return Answer::refuse(StatusCode::GATEWAY_TIMEOUT, START_TOO_SLOW),
Ok(Err(s)) => return refused_call(s),
Ok(Ok(a)) => a.into_inner(),
};
let Some(url) = rules::checked_pay_url(started.redirect_url.clone()) else {
return Answer::refuse(StatusCode::BAD_GATEWAY, rules::PAY_URL_REFUSED);
};
json_of(&crate::purchase::bundle_attempt(started, &chosen, &held, &caps, url))
}
/// `GET /api/account/subscription/status?reference=` → [`Attempt`](crate::purchase::Attempt):
/// the way back from the vendor (`/paid/{reference}`, `/cancel/{reference}`), scoped by
/// monetize to the buyer's tenant.
pub async fn buy_status<S: BuySite>(r: &Relay<S>, ask: &Ask, reference: &str) -> Answer {
let buyer = match person(r, ask).await {
Ok(b) => b,
Err(a) => return a,
};
if let Err(a) = grow_offered(r).await {
return a;
}
let ticket = match r.site.ticket(&ask.headers, &buyer, PURPOSE_READ, &BTreeMap::new()).await {
Ok(t) => t,
Err(why) => return Answer::of(why),
};
match r.shop.purchase().status(pb::PurchaseStatusRequest { reference: reference.to_owned(), product: r.shop.product().to_owned(), tenant: buyer.tenant().to_owned(), actor_ticket: ticket }).await {
Ok(p) => {
let p = p.into_inner();
let Some(url) = rules::checked_pay_url(p.redirect_url.clone()) else {
return Answer::refuse(StatusCode::BAD_GATEWAY, rules::PAY_URL_REFUSED);
};
json_of(&crate::purchase::status_attempt(p, url))
}
Err(s) => refused_call(s),
}
}
// ── the account's own renewal and history (Gunnar's `api::subscription` offer / renew / history) ──
/// What a buyer reads where the shop does not verify the product's renewal tickets: buyer
/// words only (BUYFLOW-4; the reason, that the shop cannot check who renews, is the operator's).
pub const RENEWAL_NOT_VERIFIED: &str = "This account cannot be renewed here right now. Nothing has been charged — please contact us and we will set it right.";
/// **Whether the shop verifies this product's tenant renewal tickets** (`Products.List`
/// `tenant_renewal`; Gunnar's `offered`): a renewal door is not there on a shop that cannot
/// check the ticket — the same answer as a site with no monetize at all (501). Asked after who
/// asks: a member, or nobody signed in, is told that first.
async fn offered<S: BuySite>(r: &Relay<S>) -> Result<(), Answer> {
// A shop with no `Products` service cannot say it verifies them: it does not (Gunnar's
// `offered`), the same 501 as a shop that says no.
let answer = match r.shop.products().list(pb::ListProductsRequest {}).await {
Ok(a) => a.into_inner(),
Err(s) if s.code() == tonic::Code::Unimplemented => return Err(Answer::refuse(StatusCode::NOT_IMPLEMENTED, RENEWAL_NOT_VERIFIED)),
Err(s) => return Err(refused_call(s)),
};
if answer.products.iter().any(|p| p.id == r.shop.product() && p.tenant_renewal) {
Ok(())
} else {
Err(Answer::refuse(StatusCode::NOT_IMPLEMENTED, RENEWAL_NOT_VERIFIED))
}
}
/// **The grow doors' gate** (A32-9: every site, no knob): [`offered`] — a grow door needs a
/// checked, unspent ONE box ticket (owner decision 2026-10-26, item 1).
async fn grow_offered<S: BuySite>(r: &Relay<S>) -> Result<(), Answer> {
offered(r).await
}
/// `GET /api/account/subscription` → [`Renewal`](crate::purchase::Renewal): what another period
/// would come to. Quotes and moves nothing; the `renew` ticket rides on the quote too, so a
/// person who may not renew is told so before a price. A refusal is an answer, not a failure.
pub async fn account_offer<S: BuySite>(r: &Relay<S>, ask: &Ask) -> Answer {
let buyer = match person(r, ask).await {
Ok(b) => b,
Err(a) => return a,
};
if let Err(a) = offered(r).await {
return a;
}
let ticket = match r.site.ticket(&ask.headers, &buyer, PURPOSE_RENEW, &BTreeMap::new()).await {
Ok(t) => t,
Err(why) => return Answer::of(why),
};
let quote = r
.shop
.purchase()
.quote(pb::QuoteRequest { product: r.shop.product().to_owned(), tenant: buyer.tenant().to_owned(), caps: Default::default(), starts_unix_ms: 0, market: pb::Market::Unspecified as i32, renew: true, bundle: String::new(), actor_ticket: ticket })
.await;
match quote {
Ok(q) => json_of(&crate::purchase::renewal_of(q.into_inner())),
Err(s) if s.code() == tonic::Code::FailedPrecondition => {
let nothing = matches!(r.site.held_caps(&ask.headers, &buyer).await, Ok(held) if held.is_empty());
json_of(&crate::purchase::refused_renewal(s.message(), rules::customer_words(&s), nothing))
}
Err(s) => refused_call(s),
}
}
/// `POST /api/account/subscription/renew` → [`Attempt`](crate::purchase::Attempt): **Prolong**,
/// another period of what is held. No vendor named: monetize picks the one serving the market.
pub async fn account_renew<S: BuySite>(r: &Relay<S>, ask: &Ask) -> Answer {
let buyer = match person(r, ask).await {
Ok(b) => b,
Err(a) => return a,
};
if let Err(a) = offered(r).await {
return a;
}
let ticket = match r.site.ticket(&ask.headers, &buyer, PURPOSE_RENEW, &BTreeMap::new()).await {
Ok(t) => t,
Err(why) => return Answer::of(why),
};
let asked = pb::StartPurchaseRequest { product: r.shop.product().to_owned(), tenant: buyer.tenant().to_owned(), caps: Default::default(), vendor: String::new(), payer: buyer.payer.clone(), starts_unix_ms: 0, market: pb::Market::Unspecified as i32, renew: true, bundle: String::new(), actor_ticket: ticket, users: None };
let mut client = r.shop.purchase_minting();
let started = match tokio::time::timeout(r.shop.deadlines().mint, client.start(asked)).await {
Err(_) => return Answer::refuse(StatusCode::GATEWAY_TIMEOUT, START_TOO_SLOW),
Ok(Ok(s)) => s.into_inner(),
Ok(Err(s)) => return refused_call(s),
};
let Some(url) = rules::checked_pay_url(started.redirect_url.clone()) else {
return Answer::refuse(StatusCode::BAD_GATEWAY, rules::PAY_URL_REFUSED);
};
tracing::info!(target: AUDIT_TARGET, event = "subscription.renewed", by = "tenant", who = %buyer.account, namespace = %buyer.tenant(), reference = %started.reference, state = crate::purchase::state_name(started.state), "a tenant renewed their own subscription");
json_of(&crate::purchase::renew_attempt(started, url))
}
/// `GET /api/account/subscription/history` → `[Past]`: what this account has bought. A `read`
/// ticket: a read buys nothing, and must not spend the one the person came to renew with.
pub async fn account_history<S: BuySite>(r: &Relay<S>, ask: &Ask) -> Answer {
let buyer = match person(r, ask).await {
Ok(b) => b,
Err(a) => return a,
};
if let Err(a) = offered(r).await {
return a;
}
let ticket = match r.site.ticket(&ask.headers, &buyer, PURPOSE_READ, &BTreeMap::new()).await {
Ok(t) => t,
Err(why) => return Answer::of(why),
};
match r.shop.purchase().history(pb::PurchaseHistoryRequest { product: r.shop.product().to_owned(), tenant: buyer.tenant().to_owned(), actor_ticket: ticket, limit: 0 }).await {
Ok(h) => json_of(&h.into_inner().attempts.into_iter().map(crate::purchase::past_of).collect::<Vec<_>>()),
Err(s) => refused_call(s),
}
}
/// The ledger's reason for a buyer's own cancel (Gunnar's console's, word for word).
pub const CANCELLED_BY_CUSTOMER: &str = "the customer cancelled the order before paying";
/// What `POST /api/account/subscription/cancel` is given: the order's reference.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct Following {
pub reference: String,
}
/// `POST /api/account/subscription/cancel {reference}` → [`Attempt`](crate::purchase::Attempt):
/// **the buyer's "Cancel order"**, for an order not yet paid (Gunnar's
/// `api::subscription::cancel`, moved as it is). monetize's `Purchase.Cancel`, scoped by
/// monetize: the tenant is the buyer's own, the product's `cancel` ticket (covering no amount)
/// vouches for it, and monetize decides — an order with nothing captured is closed (any vendor
/// hold released), a captured one is refused, another tenant's reference is NOT_FOUND.
pub async fn buy_cancel<S: BuySite>(r: &Relay<S>, ask: &Ask, which: &Following) -> Answer {
let buyer = match person(r, ask).await {
Ok(b) => b,
Err(a) => return a,
};
if let Err(a) = grow_offered(r).await {
return a;
}
let ticket = match r.site.ticket(&ask.headers, &buyer, PURPOSE_CANCEL, &BTreeMap::new()).await {
Ok(t) => t,
Err(why) => return Answer::of(why),
};
let ask_shop = pb::CancelPurchaseRequest { product: r.shop.product().to_owned(), tenant: buyer.tenant().to_owned(), actor_ticket: ticket, reference: which.reference.clone(), reason: CANCELLED_BY_CUSTOMER.to_owned() };
match r.shop.purchase().cancel(ask_shop).await {
Ok(closed) => {
let closed = closed.into_inner();
tracing::info!(target: AUDIT_TARGET, event = "subscription.cancelled", by = "tenant", who = %buyer.account, namespace = %buyer.tenant(), reference = %which.reference, already_closed = closed.already_closed, "a tenant cancelled an order they had not paid");
json_of(&crate::purchase::cancelled_attempt(closed))
}
Err(s) => refused_call(s),
}
}
/// `GET /api/account/purchases` → [`Purchases`](crate::purchase::Purchases): the account's own,
/// asked at monetize with its `estate` ticket.
pub async fn buy_purchases<S: BuySite>(r: &Relay<S>, ask: &Ask) -> Answer {
let buyer = match person(r, ask).await {
Ok(b) => b,
Err(a) => return a,
};
let ticket = match r.site.ticket(&ask.headers, &buyer, PURPOSE_ESTATE, &BTreeMap::new()).await {
Ok(t) => t,
Err(why) => return Answer::of(why),
};
let answer = match r.shop.estate_order().purchases(pb::EstateOrderPurchasesRequest { account: buyer.tenant().to_owned(), actor_ticket: ticket }).await {
Ok(a) => a.into_inner(),
// A shop that sells no instances, or predates the read: nothing it can say.
Err(s) if s.code() == tonic::Code::Unimplemented => pb::EstateOrderPurchasesResponse::default(),
Err(s) => return refused_call(s),
};
let offer = if crate::purchase::without_room(&answer) { r.shop.account_offer().await } else { None };
json_of(&crate::purchase::purchases_of(answer, &r.site.pages(), &r.site.product_word(), &host_of(&r.site.origin()), offer))
}
/// `GET /auth/buy/offer` → `{signed_in, account, member, account_url, account_offer, private_offered,
/// private_url}`: what a site's pages draw before anything is chosen (Gunnar's `/auth/status`
/// shop fields). The links are the site's own pages.
pub async fn buy_offer<S: BuySite>(r: &Relay<S>, ask: &Ask) -> Answer {
let who = match r.site.who(&ask.headers).await {
Ok(w) => w,
Err(why) => return Answer::of(why),
};
let private = r.shop.sells_estates().await;
let pages = r.site.pages();
// What an account is, as monetize sells it here ("5GB"): the visitor's account pill
// (Gunnar's `/auth/status` `account_offer`). Absent signed in, and where none is sold.
let account_offer = match &who {
Who::Visitor => r.shop.account_offer().await,
_ => None,
};
let (signed_in, account, member) = match &who {
Who::Person(b) => (true, b.account.clone(), b.member.as_ref().map(|m| m.line.clone())),
_ => (false, String::new(), None),
};
let mut offer = json!({
"signed_in": signed_in,
"account": account,
"member": member,
"account_url": pages.account,
"private_offered": private,
"private_url": if private { pages.private } else { String::new() },
});
// Only where one is sold (absent otherwise, as Gunnar's `/auth/status` leaves it).
if let Some(a) = account_offer {
offer["account_offer"] = json!(a);
}
Answer::ok(offer)
}
// ── the router ─────────────────────────────────────────────────────────────────────────
/// **The client's address** (Gunnar's console's rule, `security::client_ip_from`): the
/// right-most `X-Forwarded-For` hop that no trusted proxy wrote, and only when the socket's
/// `peer` is loopback or one of `trusted`; otherwise the peer itself; `unknown` without a peer
/// (one shared bucket: there is nothing to judge the header by). Each proxy appends the
/// address it saw, so only the right of the chain is vouched for; a visitor wrote the left.
pub fn client_ip(headers: &HeaderMap, peer: Option<IpAddr>, trusted: &[IpAddr]) -> String {
let believable = peer.is_some_and(|p| p.is_loopback() || trusted.contains(&p));
let forwarded = believable
.then(|| {
let hops: Vec<IpAddr> = headers.get_all("x-forwarded-for").iter().filter_map(|v| v.to_str().ok()).flat_map(|v| v.split(',')).filter_map(|hop| hop.trim().parse().ok()).collect();
hops.into_iter().rev().find(|hop| !hop.is_loopback() && !trusted.contains(hop))
})
.flatten();
match forwarded.or(peer) {
Some(ip) => ip.to_string(),
None => "unknown".to_owned(),
}
}
type Shared<S> = State<Arc<Relay<S>>>;
/// The doors read the client from the first `X-Forwarded-For` hop, which [`router`]'s
/// [`one_client_hop`] layer has already set to [`client_ip`]'s answer.
fn ask_of(headers: HeaderMap) -> Ask {
let ip = headers.get("x-forwarded-for").and_then(|v| v.to_str().ok()).unwrap_or("unknown").to_owned();
Ask { headers, ip }
}
/// The router's first step: decide the client once, from the socket (`ConnectInfo`) and the
/// site's [`BuySite::trusted_proxies`], and leave exactly that one hop in `X-Forwarded-For`
/// (what a trusted proxy in front would write). `X-Real-IP` is dropped: no door believes it.
async fn one_client_hop<S: BuySite>(State(r): Shared<S>, mut req: axum::extract::Request, next: axum::middleware::Next) -> Response {
let peer = req.extensions().get::<axum::extract::ConnectInfo<SocketAddr>>().map(|c| c.0.ip());
let ip = client_ip(req.headers(), peer, r.site.trusted_proxies());
let headers = req.headers_mut();
headers.remove("x-real-ip");
headers.remove("x-forwarded-for");
if let Ok(v) = ip.parse() {
headers.insert("x-forwarded-for", v);
}
next.run(req).await
}
// ── "Add users" (Gunnar's account admin; A32-25, moved from gunnar-ui-server `api::subscription`) ──
/// What `POST /api/account/users` is given: the account the users join, how many, the room
/// each brings (GiB), and for ONE named user its name and the address its invite goes to.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct UsersOrder {
#[serde(default)]
pub account: String,
#[serde(default)]
pub count: u32,
#[serde(default)]
pub room_gib: u64,
#[serde(default)]
pub name: String,
#[serde(default)]
pub email: String,
}
/// Pure: **what an "Add users" ticket covers**, exactly monetize's `UsersAsk::ticket_caps`:
/// the users and the room they bring.
pub fn users_caps(count: u32, room_bytes: u64) -> BTreeMap<String, u64> {
BTreeMap::from([("users".to_owned(), u64::from(count)), ("user_room_bytes".to_owned(), room_bytes)])
}
/// `POST /api/account/users {account, count, room_gib, name, email}`: **"Add users"** where the
/// site sells room. ONE monetize call, `Purchase.Start` with `users`, under an ORDER ticket the
/// product mints for exactly these users at this room ([`users_caps`]) on the account named,
/// so the PRODUCT decides who may (Gunnar mints it for the account's admin only). monetize asks
/// the product whether the users fit in the account's unused room and grants them at once if
/// they do; otherwise it sells exactly the rest through ONE checkout, or refuses naming what is
/// missing. The named user's invite is redeemed at the site's own origin. The answer is the
/// console's (`state`, `reference`, `checkout_url`, `url`, `words`, `users`, `principal`, the
/// seats and room, `mailed`); a refusal is `{code, message}`.
/// **How many users "Add users" buys** (RA4-21): the count asked; none (or 0) is a refusal
/// by name, never one user bought for a malformed ask.
pub fn users_asked(count: u32) -> Result<u32, &'static str> {
if count == 0 {
return Err("say how many users");
}
Ok(count)
}
pub async fn add_users<S: BuySite>(r: &Relay<S>, ask: &Ask, order: &UsersOrder) -> Answer {
let account = order.account.trim();
if account.is_empty() {
return operator_refusal(StatusCode::BAD_REQUEST, "invalidargument", "name the account the users join");
}
// A member of the account may well be its admin: whether this person may add its users is
// the product's ticket, not the relay's guess.
let buyer = match r.site.who(&ask.headers).await {
Ok(Who::Person(b)) => Buyer { tenant: account.to_owned(), ..b },
Ok(_) => return operator_refusal(StatusCode::UNAUTHORIZED, "unauthenticated", "Sign in to the account first."),
Err(why) => return operator_refusal(why.status, "unavailable", &why.words),
};
// After who asks (a stranger is told to sign in first, whatever the count).
let count = match users_asked(order.count) {
Ok(n) => n,
Err(words) => return operator_refusal(StatusCode::BAD_REQUEST, "invalidargument", words),
};
let users = pb::UsersOrder { count, room_bytes: order.room_gib.saturating_mul(1 << 30), name: order.name.trim().to_owned(), email: order.email.trim().to_owned(), invite_origin: r.site.origin() };
let caps = users_caps(users.count, users.room_bytes);
let ticket = match r.site.ticket(&ask.headers, &buyer, PURPOSE_ORDER, &caps).await {
Ok(t) => t,
Err(why) => {
let code = if why.status == StatusCode::FORBIDDEN { "permissiondenied" } else if why.status == StatusCode::UNAUTHORIZED { "unauthenticated" } else { "unavailable" };
return operator_refusal(why.status, code, &why.words);
}
};
let mut client = r.shop.purchase_minting();
let asked = client.start(pb::StartPurchaseRequest {
product: r.shop.product().to_owned(),
tenant: account.to_owned(),
market: pb::Market::Unspecified as i32,
users: Some(users),
actor_ticket: ticket,
..Default::default()
});
let started = match tokio::time::timeout(r.shop.deadlines().mint + crate::link::MINT_GRACE, asked).await {
Err(_) => return operator_refusal(StatusCode::GATEWAY_TIMEOUT, "deadlineexceeded", START_TOO_SLOW),
Ok(Err(s)) => return operator_refused(s),
Ok(Ok(a)) => a.into_inner(),
};
let checkout = match operator_pay_url(started.redirect_url.clone()) {
Ok(u) => u,
Err(a) => return a,
};
let grant = started.users.clone();
let granted = grant.as_ref().is_some_and(|u| u.granted);
tracing::info!(target: AUDIT_TARGET, event = "account.users_ordered", account, reference = %started.reference, state = crate::purchase::state_name(started.state), "an account's users were ordered");
Answer::ok(json!({
"state": if granted { "added" } else { crate::purchase::state_name(started.state) },
"reference": started.reference,
"checkout_url": checkout,
"url": grant.as_ref().map(|u| u.invite_url.clone()).unwrap_or_default(),
"words": started.words,
"users": grant.clone().map(crate::purchase::UsersLine::from),
"principal": grant.as_ref().filter(|u| u.granted && !u.principal.is_empty()).map(|u| json!({ "id": u.principal, "name": u.principal })),
"seats": grant.as_ref().map_or(0, |u| u.seats),
"seats_open": grant.as_ref().map_or(0, |u| u.seats_open),
"room_bytes": grant.as_ref().map_or(0, |u| u.room_bytes),
"invite": "",
"mailed": grant.as_ref().map(|u| u.invited.clone()).unwrap_or_default(),
}))
}
// ── an operator's orders for a tenant (Gunnar's console's `api::increase`) ────────────────
/// **An operator door's refusal**, in the console's `{code, message}` as well as `error`: the
/// code is the shop's own (`failedprecondition`), the words the shop's, verbatim (an operator
/// reads them; a buyer's doors put [`words_for_a_visitor`] there instead).
pub fn operator_refusal(status: StatusCode, code: &str, words: &str) -> Answer {
Answer { status, body: json!({ "error": words, "code": code, "message": words }), see_other: None }
}
/// Pure: **the HTTP status and code word of a refused shop call on an operator door**
/// (Gunnar's console's `grpc::Fail::http_status`, for the codes a shop answers).
pub fn operator_status_of(code: tonic::Code) -> (StatusCode, String) {
let word = format!("{code:?}").to_lowercase();
let status = match code {
tonic::Code::NotFound => StatusCode::NOT_FOUND,
tonic::Code::AlreadyExists => StatusCode::CONFLICT,
tonic::Code::InvalidArgument | tonic::Code::FailedPrecondition | tonic::Code::OutOfRange => StatusCode::BAD_REQUEST,
tonic::Code::Unimplemented => StatusCode::NOT_IMPLEMENTED,
tonic::Code::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
tonic::Code::ResourceExhausted => StatusCode::TOO_MANY_REQUESTS,
tonic::Code::DeadlineExceeded => StatusCode::GATEWAY_TIMEOUT,
tonic::Code::PermissionDenied => StatusCode::FORBIDDEN,
_ => StatusCode::BAD_GATEWAY,
};
(status, word)
}
fn operator_refused(s: tonic::Status) -> Answer {
let (status, code) = operator_status_of(s.code());
operator_refusal(status, &code, s.message())
}
/// The vendor's link as a browser may be given it, or the console's 502 (`badpayurl`).
fn operator_pay_url(raw: String) -> Result<String, Answer> {
rules::checked_pay_url(raw).ok_or_else(|| {
tracing::error!(target: AUDIT_TARGET, event = "purchase.bad_pay_url", "monetize answered a checkout link that is not https");
operator_refusal(StatusCode::BAD_GATEWAY, "badpayurl", rules::PAY_URL_REFUSED)
})
}
/// **Who orders, for which tenant**: the site's operator ([`BuySite::operator`]) and the tenant
/// in the PATH, never one in a body. Refused before the shop is dialled.
async fn operator_for<S: BuySite>(r: &Relay<S>, ask: &Ask, tenant: &str) -> Result<crate::site::Operator, Answer> {
let op = r.site.operator(&ask.headers).await.map_err(|why| {
let code = match why.status {
StatusCode::FORBIDDEN => "permissiondenied",
StatusCode::UNAUTHORIZED => "unauthenticated",
_ => "",
};
operator_refusal(why.status, code, &why.words)
})?;
if tenant.is_empty() {
return Err(operator_refusal(StatusCode::BAD_REQUEST, "invalidargument", "an order needs a namespace"));
}
Ok(op)
}
fn operator_market(ask: &Ask, named: Option<&str>) -> Result<pb::Market, Answer> {
rules::market_for(named, ask.accept_language()).map_err(|why| operator_refusal(StatusCode::BAD_REQUEST, "invalidargument", &why))
}
/// What the operator's typed order and offer are given: one meter and the cap to END UP with
/// (a target, as monetize's order message is), and the market (absent: the browser's, else
/// monetize's default).
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct TypedOrder {
pub meter: String,
pub cap: u64,
#[serde(default)]
pub market: Option<String>,
}
/// What the operator's window order is given: one opaque id, and the market.
#[derive(Clone, Debug, Default, serde::Deserialize)]
pub struct OperatorBundleOrder {
pub bundle: String,
#[serde(default)]
pub market: Option<String>,
}
/// `GET /api/namespaces/{name}/increase` → [`Purchasable`](crate::purchase::Purchasable): what
/// this product sells more of. A failure to ASK is answered with its sentence, not propagated:
/// the door is there, so the deployment sells, and the reader is owed why there is nothing.
pub async fn operator_purchasable<S: BuySite>(r: &Relay<S>) -> Answer {
use crate::purchase::{sellable, Purchasable};
let answer = match r.shop.products().list(pb::ListProductsRequest {}).await {
Ok(a) => a.into_inner(),
Err(e) => {
return json_of(&Purchasable { meters: Vec::new(), unavailable: Some(format!("this console could not reach the billing service to ask what is for sale: {}", e.message())) });
}
};
let meters = sellable(&answer, r.shop.product());
let unavailable = meters.is_empty().then(|| "the billing service answered, and lists nothing priced and enforced for this product right now".to_owned());
json_of(&Purchasable { meters, unavailable })
}
/// `GET /api/namespaces/{name}/increase/offer?meter=&cap=` → [`Offer`](crate::purchase::Offer):
/// an order priced, not placed. Never a renewal, no bundle, and NO ticket: the operator path.
pub async fn operator_offer<S: BuySite>(r: &Relay<S>, ask: &Ask, tenant: &str, order: &TypedOrder) -> Answer {
if let Err(a) = operator_for(r, ask, tenant).await {
return a;
}
let mut client = r.shop.purchase();
let quote = client.quote(pb::QuoteRequest {
product: r.shop.product().to_owned(),
tenant: tenant.to_owned(),
caps: [(order.meter.clone(), order.cap)].into_iter().collect(),
market: pb::Market::Unspecified as i32,
starts_unix_ms: 0,
renew: false,
bundle: String::new(),
actor_ticket: Vec::new(),
});
match quote.await {
Ok(q) => json_of(&crate::purchase::render_offer(q.into_inner())),
Err(s) => operator_refused(s),
}
}
/// One operator order: `caps` named (typed) or `bundle` (the window), never both.
async fn operator_start<S: BuySite>(r: &Relay<S>, ask: &Ask, tenant: &str, market: Option<&str>, caps: BTreeMap<String, u64>, bundle: &str) -> Answer {
let op = match operator_for(r, ask, tenant).await {
Ok(op) => op,
Err(a) => return a,
};
let market = match operator_market(ask, market) {
Ok(m) => m,
Err(a) => return a,
};
let mut client = r.shop.purchase_minting();
let asked = client.start(pb::StartPurchaseRequest {
product: r.shop.product().to_owned(),
tenant: tenant.to_owned(),
caps: caps.into_iter().collect(),
// Empty on purpose: monetize answers the free provider at price 0, else the one vendor
// serving the market; a site guessing between two would be choosing who gets paid.
vendor: String::new(),
payer: op.payer.clone(),
starts_unix_ms: 0,
market: market as i32,
renew: false,
bundle: bundle.to_owned(),
actor_ticket: Vec::new(),
users: None,
});
let started = match asked.await {
Ok(a) => a.into_inner(),
Err(s) => return operator_refused(s),
};
let state = crate::purchase::state_name(started.state);
tracing::info!(target: AUDIT_TARGET, event = "increase.started", who = %op.who, tenant = %tenant, bundle = %bundle, reference = %started.reference, state, "an operator placed an order for a tenant");
let url = match operator_pay_url(started.redirect_url) {
Ok(u) => u,
Err(a) => return a,
};
json_of(&crate::purchase::Placed { reference: started.reference, state, url, reason: started.reason })
}
/// `POST /api/namespaces/{name}/increase {meter, cap, market}` → [`Placed`](crate::purchase::Placed):
/// the operator's typed order (the target cap), placed.
pub async fn operator_order<S: BuySite>(r: &Relay<S>, ask: &Ask, tenant: &str, order: &TypedOrder) -> Answer {
operator_start(r, ask, tenant, order.market.as_deref(), [(order.meter.clone(), order.cap)].into(), "").await
}
/// `POST /api/namespaces/{name}/increase/bundle {bundle, market}` → [`Placed`](crate::purchase::Placed):
/// one from the window, by its opaque id and nothing else (no caps: monetize refuses both).
pub async fn operator_bundle<S: BuySite>(r: &Relay<S>, ask: &Ask, tenant: &str, order: &OperatorBundleOrder) -> Answer {
operator_start(r, ask, tenant, order.market.as_deref(), BTreeMap::new(), &order.bundle).await
}
/// `GET /api/namespaces/{name}/increase/bundles[?market=]` → [`Window`](crate::purchase::Window):
/// what this deployment sells the tenant, priced, only what it can serve. No ticket.
pub async fn operator_bundles<S: BuySite>(r: &Relay<S>, ask: &Ask, tenant: &str, market: Option<&str>) -> Answer {
let market = match operator_market(ask, market) {
Ok(m) => m,
Err(a) => return a,
};
if let Err(a) = operator_for(r, ask, tenant).await {
return a;
}
match r.shop.purchase().bundles(pb::ListBundlesRequest { product: r.shop.product().to_owned(), tenant: tenant.to_owned(), market: market as i32, actor_ticket: Vec::new() }).await {
Ok(w) => json_of(&crate::purchase::Window { market: rules::market_word(market as i32).to_owned(), ..crate::purchase::window_of(w.into_inner()) }),
Err(s) => operator_refused(s),
}
}
/// `GET /api/namespaces/{name}/increase/status?reference=` → [`Placed`](crate::purchase::Placed):
/// an order followed, scoped by monetize to this product and the path's tenant (one uniform
/// NOT_FOUND otherwise). The outcome is the ledger's, never a return URL's.
pub async fn operator_status<S: BuySite>(r: &Relay<S>, ask: &Ask, tenant: &str, reference: &str) -> Answer {
if let Err(a) = operator_for(r, ask, tenant).await {
return a;
}
let progress = match r.shop.purchase().status(pb::PurchaseStatusRequest { reference: reference.to_owned(), product: r.shop.product().to_owned(), tenant: tenant.to_owned(), actor_ticket: Vec::new() }).await {
Ok(p) => p.into_inner(),
Err(s) => return operator_refused(s),
};
let url = match operator_pay_url(progress.redirect_url) {
Ok(u) => u,
Err(a) => return a,
};
json_of(&crate::purchase::Placed { reference: progress.reference, state: crate::purchase::state_name(progress.state), url, reason: progress.reason })
}
/// **Which groups of doors a site mounts** ([`router_of`]). A site that sells nothing to start
/// (a sold Gunnar) still reads an account's own; a site that sells no accounts has no sign-up.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Doors {
/// `/auth/signup/*`: a new account, its name, its status, its link again.
pub signup: bool,
/// `/auth/private/*` and `/paid/estate/*`: a private instance, ordered and managed.
pub private: bool,
/// The account's own reads and its cancel: `/api/account/subscription` (the quote),
/// `…/status`, `…/cancel`, `…/bundles`, `…/history` and `/api/account/purchases`.
pub account: bool,
/// The doors that START an order: `…/buy-bundle` and `…/renew`.
pub orders: bool,
/// `/auth/buy/offer`: what the pages draw before anything is chosen.
pub offer: bool,
/// **An operator's reads for a tenant named in the path** (Gunnar's console):
/// `GET /api/namespaces/{name}/increase`, `…/offer`, `…/status`, `…/bundles`. Never in
/// [`Doors::ALL`]: a site names it, and says who may ([`BuySite::operator`]).
pub operator: bool,
/// **The operator's two that START an order**: `POST /api/namespaces/{name}/increase` (typed)
/// and `…/increase/bundle` (from the window). Mounted with [`Doors::operator`] only.
pub operator_orders: bool,
/// **"Add users"** (Gunnar's account admin, A32-25): `POST /api/account/users`
/// ([`add_users`]). Never in [`Doors::ALL`]: a site that sells room for users names it.
pub users: bool,
}
impl Doors {
/// Every buyer's door ([`router`]); no operator's (a site names those itself).
pub const ALL: Doors = Doors { signup: true, private: true, account: true, orders: true, offer: true, operator: false, operator_orders: false, users: false };
/// None (a site adds the groups it sells: `Doors { private: true, ..Doors::NONE }`).
pub const NONE: Doors = Doors { signup: false, private: false, account: false, orders: false, offer: false, operator: false, operator_orders: false, users: false };
}
/// **Every door, mounted**: the routes of the module's table, for a site that has no router of
/// its own for them (Holger's front). Merge it in front of the site's own pages.
pub fn router<S: BuySite>(relay: Arc<Relay<S>>) -> Router {
router_of::<S, ()>(relay, Doors::ALL)
}
/// **The doors of the groups `doors` names, mounted** (Gunnar's console: the sign-up and
/// private doors where it sells, the account's reads where it has a monetize, the orders where
/// its buyers buy). A door of a group not named is not there at all: no route, axum's 404.
///
/// The answer is a `Router<T>` for the site's own router state `T`, so a site whose router
/// holds a state of its own (Gunnar's `AppState`) merges it before its layers and
/// `with_state`, and its own middleware (sign-in, CSRF, role floors) runs in front of the doors.
pub fn router_of<S: BuySite, T: Clone + Send + Sync + 'static>(relay: Arc<Relay<S>>, doors: Doors) -> Router<T> {
// The plans pages' ladders are read as soon as the doors exist (LADDER-2): the first view
// after a front starts finds them kept, never a ONE box pricing them for seconds.
let mut warm = Vec::new();
if doors.signup {
warm.push(crate::ladders::signup_key(pb::Market::Unspecified as i32));
}
if doors.private {
warm.push(crate::ladders::PRIVATE.to_string());
}
if !warm.is_empty() {
relay.shop.caches().ladders().warm(&relay.shop, &warm);
}
let mut r: Router<Arc<Relay<S>>> = Router::new();
if doors.signup {
r = r
.route("/auth/signup/ladder", get(|State(r): Shared<S>, h: HeaderMap, Query(q): Query<HashMap<String, String>>| async move { signup_plans(&r, &ask_of(h), q.get("market").map(String::as_str)).await }))
.route("/auth/signup/name", get(|State(r): Shared<S>, h: HeaderMap, Query(q): Query<HashMap<String, String>>| async move { signup_name(&r, &ask_of(h), q.get("u").map(String::as_str).unwrap_or("")).await }))
.route("/auth/signup/start", post(|State(r): Shared<S>, h: HeaderMap, Json(b): Json<SignupStart>| async move { signup_start(&r, &ask_of(h), &b).await }))
.route("/auth/signup/status", get(|State(r): Shared<S>, h: HeaderMap, Query(q): Query<HashMap<String, String>>| async move { signup_status(&r, &ask_of(h), q.get("session").map(String::as_str).unwrap_or("")).await }))
.route("/auth/signup/rename", post(|State(r): Shared<S>, h: HeaderMap, Json(b): Json<SignupRename>| async move { signup_rename(&r, &ask_of(h), &b).await }))
.route("/auth/signup/sign-in-again", post(|State(r): Shared<S>, h: HeaderMap, Json(b): Json<SignInAgain>| async move { signup_sign_in_again(&r, &ask_of(h), &b).await }));
}
if doors.private {
r = r
.route("/auth/private/ladder", get(|State(r): Shared<S>, h: HeaderMap, Query(q): Query<HashMap<String, String>>| async move { private_ladder(&r, &ask_of(h), q.get("company").map(String::as_str)).await }))
.route("/auth/private/start", post(|State(r): Shared<S>, h: HeaderMap, Json(b): Json<Sizes>| async move { private_start(&r, &ask_of(h), &b).await }))
.route("/auth/private/change", post(|State(r): Shared<S>, h: HeaderMap, Json(b): Json<Sizes>| async move { private_change(&r, &ask_of(h), &b).await }))
.route("/auth/private/status", get(|State(r): Shared<S>, h: HeaderMap, Query(q): Query<HashMap<String, String>>| async move { private_status(&r, &ask_of(h), q.get("company").map(String::as_str).unwrap_or("")).await }))
.route("/auth/private/renew", post(|State(r): Shared<S>, h: HeaderMap, Json(b): Json<CompanyBody>| async move { private_renew(&r, &ask_of(h), &b).await }))
.route("/auth/private/cancel", post(|State(r): Shared<S>, h: HeaderMap, Json(b): Json<CompanyBody>| async move { private_stop_renewing(&r, &ask_of(h), &b).await }))
.route("/auth/private/terminate", post(|State(r): Shared<S>, h: HeaderMap, Json(b): Json<TerminateBody>| async move { private_terminate(&r, &ask_of(h), &b).await }))
.route("/auth/private/restore-front", post(|State(r): Shared<S>, h: HeaderMap, Json(b): Json<CompanyBody>| async move { private_restore_front(&r, &ask_of(h), &b).await }))
.route("/auth/private/sign-in-again", post(|State(r): Shared<S>, h: HeaderMap, Json(b): Json<CompanyBody>| async move { private_sign_in_again(&r, &ask_of(h), &b).await }))
.route("/paid/estate/{company}", get(|State(r): Shared<S>, Path(co): Path<String>| async move { paid_estate(&r, &co) }))
.route("/paid/estate/{company}/{change}", get(|State(r): Shared<S>, Path((co, ch)): Path<(String, String)>| async move { paid_estate_change(&r, &co, &ch) }));
}
if doors.account {
r = r
.route("/api/account/purchases", get(|State(r): Shared<S>, h: HeaderMap| async move { buy_purchases(&r, &ask_of(h)).await }))
.route("/api/account/subscription/bundles", get(|State(r): Shared<S>, h: HeaderMap| async move { buy_bundles(&r, &ask_of(h)).await }))
.route("/api/account/subscription", get(|State(r): Shared<S>, h: HeaderMap| async move { account_offer(&r, &ask_of(h)).await }))
.route("/api/account/subscription/history", get(|State(r): Shared<S>, h: HeaderMap| async move { account_history(&r, &ask_of(h)).await }))
.route("/api/account/subscription/cancel", post(|State(r): Shared<S>, h: HeaderMap, Json(b): Json<Following>| async move { buy_cancel(&r, &ask_of(h), &b).await }))
.route("/api/account/subscription/status", get(|State(r): Shared<S>, h: HeaderMap, Query(q): Query<HashMap<String, String>>| async move { buy_status(&r, &ask_of(h), q.get("reference").map(String::as_str).unwrap_or("")).await }));
}
if doors.orders {
r = r
.route("/api/account/subscription/buy-bundle", post(|State(r): Shared<S>, h: HeaderMap, Json(b): Json<BundleOrder>| async move { buy_bundle(&r, &ask_of(h), &b).await }))
.route("/api/account/subscription/renew", post(|State(r): Shared<S>, h: HeaderMap| async move { account_renew(&r, &ask_of(h)).await }));
}
if doors.offer {
r = r.route("/auth/buy/offer", get(|State(r): Shared<S>, h: HeaderMap| async move { buy_offer(&r, &ask_of(h)).await }));
}
if doors.users {
r = r.route("/api/account/users", post(|State(r): Shared<S>, h: HeaderMap, Json(b): Json<UsersOrder>| async move { add_users(&r, &ask_of(h), &b).await }));
}
if doors.operator {
let purchasable = get(|State(r): Shared<S>| async move { operator_purchasable(&r).await });
let purchasable = if doors.operator_orders { purchasable.post(|State(r): Shared<S>, h: HeaderMap, Path(t): Path<String>, Json(b): Json<TypedOrder>| async move { operator_order(&r, &ask_of(h), &t, &b).await }) } else { purchasable };
r = r
.route("/api/namespaces/{name}/increase", purchasable)
.route("/api/namespaces/{name}/increase/offer", get(|State(r): Shared<S>, h: HeaderMap, Path(t): Path<String>, Query(q): Query<TypedOrder>| async move { operator_offer(&r, &ask_of(h), &t, &q).await }))
.route("/api/namespaces/{name}/increase/status", get(|State(r): Shared<S>, h: HeaderMap, Path(t): Path<String>, Query(q): Query<HashMap<String, String>>| async move { operator_status(&r, &ask_of(h), &t, q.get("reference").map(String::as_str).unwrap_or("")).await }))
.route("/api/namespaces/{name}/increase/bundles", get(|State(r): Shared<S>, h: HeaderMap, Path(t): Path<String>, Query(q): Query<HashMap<String, String>>| async move { operator_bundles(&r, &ask_of(h), &t, q.get("market").map(String::as_str)).await }));
if doors.operator_orders {
r = r.route("/api/namespaces/{name}/increase/bundle", post(|State(r): Shared<S>, h: HeaderMap, Path(t): Path<String>, Json(b): Json<OperatorBundleOrder>| async move { operator_bundle(&r, &ask_of(h), &t, &b).await }));
}
}
r.layer(axum::extract::DefaultBodyLimit::max(BUY_BODY_MAX)).layer(axum::middleware::from_fn_with_state(relay.clone(), one_client_hop::<S>)).with_state(relay)
}
/// **The most a buy door reads** (P5-SEC): every POST body here is a few named values (an
/// order is at most [`ORDER_MAX`]), so anything over this is refused 413 before it is parsed,
/// whatever the site's own router allows. axum's 2 MiB default would otherwise be the bound.
/// Room enough that a field over its own rule (100 KB of company name) is still judged and
/// answered 400 by name, as a site's console tests it (gunnar-ui's private start).
pub const BUY_BODY_MAX: usize = 256 * 1024;
/// The paths [`router`] answers, for a site that routes by path before it reaches the relay.
pub fn is_relay_path(path: &str) -> bool {
path.starts_with("/auth/signup/") || path.starts_with("/auth/private/") || path == "/auth/buy/offer" || path == "/api/account/subscription" || path.starts_with("/api/account/subscription/") || path == "/api/account/purchases" || path == "/api/account/users" || path.starts_with("/paid/estate/")
}
#[cfg(test)]
mod tests {
use super::*;
/// ★ RA4-21 RED: **"Add users" with no count (or 0) is refused by name**, never one user
/// bought for a malformed ask.
#[test]
fn add_users_with_no_count_is_refused_by_name() {
assert_eq!(users_asked(0), Err("say how many users"));
assert_eq!(users_asked(3), Ok(3));
}
#[test]
fn the_relay_answers_only_its_own_paths() {
assert!(is_relay_path("/auth/signup/start") && is_relay_path("/paid/estate/acme") && is_relay_path("/api/account/subscription/status") && is_relay_path("/api/account/users"));
assert!(!is_relay_path("/paid/purchase-1") && !is_relay_path("/-/signup") && !is_relay_path("/auth/passkey/login/start"));
}
#[test]
fn the_client_is_the_peer_unless_a_trusted_proxy_vouches() {
let ip = |s: &str| s.parse::<IpAddr>().unwrap();
let mut h = HeaderMap::new();
h.insert("x-forwarded-for", "203.0.113.9, 10.0.0.1".parse().unwrap());
// An untrusted peer: the header is ignored.
assert_eq!(client_ip(&h, Some(ip("198.51.100.4")), &[]), "198.51.100.4");
// Loopback: the right-most hop no trusted proxy wrote.
assert_eq!(client_ip(&h, Some(ip("127.0.0.1")), &[]), "10.0.0.1");
// A named proxy, and its own hop skipped.
assert_eq!(client_ip(&h, Some(ip("10.0.0.2")), &[ip("10.0.0.2"), ip("10.0.0.1")]), "203.0.113.9");
// Not an address: skipped; nothing left: the peer.
h.insert("x-forwarded-for", "nonsense, 127.0.0.1".parse().unwrap());
assert_eq!(client_ip(&h, Some(ip("::1")), &[]), "::1");
assert_eq!(client_ip(&h, None, &[]), "unknown");
}
#[test]
fn every_group_is_named_and_none_is_none() {
assert_eq!(Doors::ALL, Doors { signup: true, private: true, account: true, orders: true, offer: true, operator: false, operator_orders: false, users: false });
assert_eq!(Doors::NONE, Doors { signup: false, private: false, account: false, orders: false, offer: false, operator: false, operator_orders: false, users: false });
assert_eq!(Doors { private: true, ..Doors::NONE }, Doors { signup: false, private: true, account: false, orders: false, offer: false, operator: false, operator_orders: false, users: false });
}
#[test]
fn an_operator_door_reads_the_shops_code_and_words() {
assert_eq!(operator_status_of(tonic::Code::FailedPrecondition), (StatusCode::BAD_REQUEST, "failedprecondition".to_owned()));
assert_eq!(operator_status_of(tonic::Code::ResourceExhausted), (StatusCode::TOO_MANY_REQUESTS, "resourceexhausted".to_owned()));
assert_eq!(operator_status_of(tonic::Code::Internal), (StatusCode::BAD_GATEWAY, "internal".to_owned()));
let a = operator_refused(tonic::Status::not_found("no such order for acme"));
assert_eq!((a.status, a.body), (StatusCode::NOT_FOUND, json!({"error": "no such order for acme", "code": "notfound", "message": "no such order for acme"})));
}
#[test]
fn a_refusal_is_the_pages_error_shape() {
let a = Answer::refuse(StatusCode::CONFLICT, "username \"x14\" is taken; choose another");
assert_eq!(a.body, json!({"error": "username \"x14\" is taken; choose another"}));
}
}