crates/monetize-site-buy/src/site.rs
//! **The provider trait a product site implements** (the plug-in shape of monetize-cloud's
//! `DnsWriter`): who is signed in, the actor tickets the product vouches for its buyer with,
//! the caps the buyer holds, and whether a paid sign-up's account has its passkey yet.
//! Everything else in a buy — the calls, the URLs, the refusals, the words — is the relay's.
use std::collections::BTreeMap;
use std::future::Future;
use axum::http::{HeaderMap, StatusCode};
use crate::urls::Pages;
/// The ticket purposes a buy asks for (monetize-embed `ticket::PURPOSE_*`).
pub const PURPOSE_ESTATE: &str = "estate";
pub const PURPOSE_ORDER: &str = "order";
pub const PURPOSE_RENEW: &str = "renew";
pub const PURPOSE_READ: &str = "read";
/// A cancel of an order not yet paid: covers no amount.
pub const PURPOSE_CANCEL: &str = "cancel";
/// **A refusal of the site's own** (not the shop's): the HTTP status and the sentence the
/// buyer reads. Never an internal word: the site writes it for the buyer.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Refusal {
pub status: StatusCode,
pub words: String,
}
impl Refusal {
pub fn new(status: StatusCode, words: impl Into<String>) -> Refusal {
Refusal { status, words: words.into() }
}
}
/// **A member of an account** (an employee, an admin-appointed member): buys nothing; the
/// account's admin buys for it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Member {
/// The account they are a member of.
pub account: String,
/// Its admin (empty: not known).
pub admin: String,
/// The site's sentence for this membership ("You are a member of acme.").
pub line: String,
}
/// **The person signed in.**
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Buyer {
/// The account a private instance is ordered by (`x14`).
pub account: String,
/// The tenant the account's room is metered as at monetize (Gunnar: its namespace; Holger:
/// the account's repository). Empty: the account.
pub tenant: String,
/// Set when the person is a member of an account and not its buyer.
pub member: Option<Member>,
/// The payer an order names (Gunnar: the operator's receipts address when the operator
/// buys; empty for a tenant, and monetize uses the address the account signed up with).
pub payer: String,
}
impl Buyer {
/// The tenant, else the account.
pub fn tenant(&self) -> &str {
if self.tenant.is_empty() {
&self.account
} else {
&self.tenant
}
}
}
/// **Who asks.**
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Who {
/// Nobody signed in.
Visitor,
/// A credential that is no person (a break-glass bearer, an operator token): buys nothing.
Machine,
/// A person signed in.
Person(Buyer),
}
/// **An administrator placing an order on a tenant's behalf** (Gunnar's console's operator):
/// the operator door group's caller, as the site vouches for it.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Operator {
/// Who, for the audit line.
pub who: String,
/// The payer the order names (Gunnar: the operator's receipts address; empty: monetize uses
/// the address the tenant signed up with).
pub payer: String,
}
/// What a site that names no operator answers on the operator's doors.
pub const NO_OPERATOR_HERE: &str = "an operator's orders are not taken here";
/// **Whether a paid sign-up's account may register its passkey now.**
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Account {
/// It exists and holds no passkey: the invite link is handed out.
NoPasskey,
/// It holds one already: sign in with it.
HasPasskey,
}
/// The code a refusal carries when the person must sign in again with a fresh touch of their
/// key first (Gunnar's console's `STEP_UP_CODE`): the page offers the login, not a retry.
pub const STEP_UP_CODE: &str = "stepup";
/// What a site that cannot prove a fresh login says (Gunnar's step-up sentence, its window).
pub const STEP_UP_WORDS: &str = "This needs a fresh touch of your key (a user-verified login from the last 10 minutes).";
/// **The product site.** Implemented once per product (Gunnar's console, Holger's front).
pub trait BuySite: Send + Sync + 'static {
/// This site's origin, `https://holger.rs` (its passkeys' relying party): every way back
/// from the vendor is on it.
fn origin(&self) -> String;
/// The product as the buyer reads it: "Gunnar", "Holger".
fn product_word(&self) -> String;
/// The site's sign-up choices as its page spells them.
fn pages(&self) -> Pages {
Pages::default()
}
/// The site's budget for `door` (`plans`, `name`, `start`, `status`, …) per client address
/// `ip` and `key`; `false` answers 429 before the shop is dialled. Default: no budget.
fn allowed(&self, door: &str, ip: &str, key: &str) -> bool {
let _ = (door, ip, key);
true
}
/// **The proxies whose `X-Forwarded-For` [`router`](crate::router) believes**, besides
/// loopback, which is always believed (a reverse proxy on the same box: Gunnar's caddy).
/// From any other peer the header is ignored and the socket's address is the client's
/// (Gunnar's `GUNNAR_UI_TRUSTED_PROXIES` rule). Default: none, loopback only.
fn trusted_proxies(&self) -> &[std::net::IpAddr] {
&[]
}
/// **The fixed buy link** a site falls back to where the shop mints no checkout for a
/// sign-up (`UNIMPLEMENTED`; Gunnar's `GUNNAR_UI_BUY_URL`). Default: none.
fn fixed_buy_url(&self) -> Option<String> {
None
}
/// **Whether the person signed in on this request touched their key recently** (a
/// user-verified login inside the site's step-up window): what ending an instance needs.
/// `Err(words)`: they sign in again first. Default: no site proves it, so it is refused.
fn stepped_up(&self, headers: &HeaderMap) -> impl Future<Output = Result<(), String>> + Send {
let _ = headers;
async { Err(STEP_UP_WORDS.to_owned()) }
}
/// **Who may order for a tenant named in the path** (the operator door group, mounted only
/// where a site names it: [`Doors::operator`](crate::Doors)): an administrator, as the site
/// decides it from the request's session. The order then carries no actor ticket: nobody
/// on these doors claims to hold the tenant. Default: nobody (403).
fn operator(&self, headers: &HeaderMap) -> impl Future<Output = Result<Operator, Refusal>> + Send {
let _ = headers;
async { Err(Refusal::new(StatusCode::FORBIDDEN, NO_OPERATOR_HERE)) }
}
/// **Who asks**, from the request's own session (cookie, bearer).
fn who(&self, headers: &HeaderMap) -> impl Future<Output = Result<Who, Refusal>> + Send;
/// **An actor ticket for the buyer** (purpose [`PURPOSE_ESTATE`], [`PURPOSE_ORDER`] with the
/// exact target `caps`, [`PURPOSE_RENEW`], [`PURPOSE_READ`], [`PURPOSE_CANCEL`]): the product vouches for the
/// person over its own key. `headers` is the request (the person's own session).
fn ticket(&self, headers: &HeaderMap, buyer: &Buyer, purpose: &str, caps: &BTreeMap<String, u64>) -> impl Future<Output = Result<Vec<u8>, Refusal>> + Send;
/// **The caps the buyer's tenant holds now** (an `order` ticket names the target).
fn held_caps(&self, headers: &HeaderMap, buyer: &Buyer) -> impl Future<Output = Result<BTreeMap<String, u64>, Refusal>> + Send;
/// **The paid sign-up's account `user`, as the product holds it**: its passkey, or a
/// refusal (no such person here yet: a 409 the welcome page keeps polling through).
fn signup_account(&self, user: &str) -> impl Future<Output = Result<Account, Refusal>> + Send;
}