crates/monetize-product/src/factory.rs
//! **The registration seam — how a product gets into a server without the
//! server knowing its name.**
//!
//! The same shape `monetize_pay::factory` gives a vendor and
//! `monetize_cloud::factory` gives a cloud, for the same reason: the open
//! server matches on no product name. It is handed a list of
//! [`ProductFactory`] values and looks a configured `[products.<id>]` up in
//! that list. A build registers the plugins it links (`list.push(FACTORY)`);
//! an id in the config that no factory answers for is refused at boot, by
//! name, exactly as an unlinked vendor is.
//!
//! A factory is a `const`: a name and two plain `fn`s — the constructor, and
//! the identity minter `--identity-print` calls (a product whose plugin
//! carries no identity of its own refuses that call by name rather than
//! minting a key nothing will ever verify). No trait, no generics: three
//! fields are the whole contract.
//!
//! A WASM guest host slots in behind the same seam: a factory whose `build`
//! loads a `.wasm` and wraps it in one `Box<dyn Product>` is a factory like
//! any other, and the server cannot tell the difference — which is the point.
use std::path::{Path, PathBuf};
/// **The address a PULLED product's plugin is built with** (`[products.<id>] instance`, no
/// `endpoint`; monetize-server `registry`): monetize never dials an estate, but a plugin builds
/// its lazy channel from an address and refuses an empty one. Never resolvable, never dialled.
pub const PULLED_NEVER_DIALLED: &str = "http://pulled.invalid:1";
/// **What a product is built from** — the `[products.<id>]` block, typed.
/// The fields are the generic credential shapes the server keeps as config
/// keys; a plugin reads the ones it understands and ignores the rest. A plugin
/// whose key lives in the environment (`api_key_env`) reads that variable
/// itself, as a vendor reads its own `config_envs`.
#[derive(Clone, Copy, Debug)]
pub struct ProductBuild<'a> {
/// The configured id — the factory's own, echoed so an error can say
/// `[products.<id>]`.
pub id: &'a str,
/// The product's control plane: a gRPC address or an HTTPS base.
pub endpoint: &'a str,
/// A private key file monetize signs calls TO the product with, when the
/// plugin authenticates that way.
pub identity_key: Option<&'a Path>,
/// The product APPLIANCE's public key (hex), when the plugin verifies
/// tenant tickets with one.
pub appliance_key: Option<&'a str>,
/// A bearer presented on every call, when the plugin authenticates that way.
pub bearer_token: Option<&'a str>,
/// The NAME of the environment variable carrying the product's API key,
/// when the plugin authenticates that way. The value is never in the config.
pub api_key_env: Option<&'a str>,
/// **The TWIN's control plane**, when the deployment has one:
/// `[products.<id>] restoring_from_twin`.
///
/// The **twin growth** (T21) needs it: the standby is grown exactly as main
/// is grown — the same verbs, on the twin's own control plane. A product
/// told no twin refuses it BY NAME. (A restore from the twin is no longer
/// driven from here: it is an order the estate pulls, 5.4.)
pub restoring_from_twin: Option<&'a str>,
/// **The CA a `https://` endpoint is verified against** —
/// `[products.<id>] tls_ca = "/etc/monetize/<product>-ca.pem"`.
///
/// `None` is the compiled-in Mozilla root set, which is what a publicly
/// issued certificate verifies against; an estate with its own certificate
/// authority names its PEM here rather than installing it system-wide, so
/// verification does not depend on what a particular box's trust store
/// happens to hold. Naming one for a PLAINTEXT endpoint is refused by
/// name: a block that wrote it believes its traffic is verified.
pub tls_ca: Option<&'a std::path::Path>,
/// **The client identity monetize presents** on an `https://` endpoint —
/// `[products.<id>] tls_client_cert` + `tls_client_key` (PEM paths): an
/// estate that REQUIRES a client certificate (gunnar's own issuer; an
/// estate attached from another cloud that trusts the central's attach CA,
/// f-grpc-tls) is reached with mutual TLS. `None`: no identity, as before.
pub tls_client: Option<(&'a std::path::Path, &'a std::path::Path)>,
/// **The signed `.wasm` this product is served by**, when the block says
/// `guest = "<path>.wasm"`. `None` is a product a linked plugin builds. A
/// factory that is not the guest host ignores it; the guest host refuses a
/// block without it, by name.
pub guest: Option<&'a GuestSpec>,
}
/// **A product served by a signed `.wasm` guest** — the `guest*` keys of a
/// `[products.<id>]` block, typed. What the guest host (`monetize-product-wasm`)
/// is built from; the open server reads them, the host enforces them.
///
/// A guest nobody vouched for does not load: at least one of [`GuestSpec::digest`]
/// (the pinned blake3-256 of the module bytes) and [`GuestSpec::trust_roots`]
/// (DER roots its detached CMS signature must chain to) must be set.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct GuestSpec {
/// `guest = "<path>.wasm"`.
pub module: PathBuf,
/// `guest_digest = "<hex blake3-256>"` — the module bytes must hash to this.
pub digest: Option<String>,
/// `guest_trust_roots = ["<root>.der", …]` — the `.p7s` beside the module
/// must verify against one of these.
pub trust_roots: Vec<PathBuf>,
/// `grants = []` — the host doors this guest may import, from its kind's
/// own set (a product guest's is empty). An import outside the grant is not
/// defined on the linker, so the module is refused at load, by name.
pub grants: Vec<String>,
/// `http_hosts = ["api.example.se", "http://lab:8080"]` — the only hosts
/// `host_http` will reach; `https` unless the entry is written `http://`.
pub http_hosts: Vec<String>,
/// `guest_grpc_hosts = ["10.0.0.2:50051"]` — the OTHER control planes this
/// guest may dial, beside the block's own `endpoint`, named by an operator
/// or not dialled: a guest that could reach an address nobody wrote down
/// could take a tenant's log somewhere else.
pub grpc_hosts: Vec<String>,
/// `guest_fuel` — instructions one call may execute; narrows the kind's
/// ceiling, never widens it. `0` is refused.
pub fuel: Option<u64>,
/// `guest_call_timeout_ms` — the wall-clock ceiling on one call, host
/// doors included. `0` is refused.
pub call_timeout_ms: Option<u64>,
/// `guest_max_memory_bytes` — the linear-memory ceiling. `0` is refused.
pub max_memory_bytes: Option<u64>,
/// `[[<block>.http_auth]]` — the credential the HOST adds to a request to
/// one of `http_hosts`, read from a secret file at every call. The guest
/// never sees it, and cannot choose it.
pub http_auth: Vec<HttpAuth>,
/// `hmac_secrets = ["stripe_webhook"]` — the secrets `host_hmac_verify`
/// may check a message against, by name.
pub hmac_secrets: Vec<String>,
/// Where `secret.<name>` files are read from: the server's `secrets_dir`,
/// default `<data_dir>/secrets`. `None` means no secret can be read, and a
/// block that names one is refused at load.
pub secrets_dir: Option<PathBuf>,
/// `plugin = "gunnar"` (G15) — the id the guest's `id` op answers, when the
/// block's own id is another: `[products.gunnar-test2] plugin = "gunnar"`
/// serves the gunnar guest as product `gunnar-test2`. `None` is the block's
/// id, as it always was.
pub plugin: Option<String>,
}
/// **One `[[<block>.http_auth]]` entry**: what the host adds to a request to
/// `host`, from a secret file the guest never sees.
///
/// * `secret` alone: `<header>: <prefix><secret>` (header default `Authorization`);
/// * `basic_user` / `basic_user_secret` with `secret`: `Authorization: Basic base64(user:secret)`;
/// * `client_cert_secret`: a PEM file (certificate chain + private key) presented
/// as the TLS client certificate to THIS host only.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct HttpAuth {
/// Spelled as an `http_hosts` entry, and it must be one.
pub host: String,
pub header: Option<String>,
pub prefix: String,
pub secret: Option<String>,
pub basic_user: Option<String>,
pub basic_user_secret: Option<String>,
pub client_cert_secret: Option<String>,
/// **Secrets the host may write INTO the request body** for this host: a
/// guest writes the placeholder `{{secret:<name>}}` and the host substitutes
/// the file's value on the way out (Klarna's push URL carries its callback
/// credentials in the body). Only the names listed here, only for this host.
pub body_secrets: Vec<String>,
}
impl HttpAuth {
/// Every secret name this entry reads.
pub fn secret_names(&self) -> Vec<&str> {
[
&self.secret,
&self.basic_user_secret,
&self.client_cert_secret,
]
.into_iter()
.flatten()
.map(String::as_str)
.chain(self.body_secrets.iter().map(String::as_str))
.collect()
}
}
/// The placeholder a guest writes where the host is to put a body secret.
pub fn body_secret_placeholder(name: &str) -> String {
format!("{{{{secret:{name}}}}}")
}
impl ProductBuild<'_> {
/// `[products.<id>] <what>` — the prefix every refusal from a build wears.
pub fn err(&self, what: impl std::fmt::Display) -> String {
format!("[products.{}] {what}", self.id)
}
}
/// A product's build answers its facts' source: monetize serves it as a [`crate::pulled::Pulled`].
pub type ProductBuilder = fn(&ProductBuild<'_>) -> Result<crate::pulled::FactsSource, String>;
/// Mint the plugin's identity key if it is not there yet, and answer the
/// public line to register with the product. A plugin with no identity of its
/// own answers `Err`, by name.
pub type IdentityMinter = fn(&ProductBuild<'_>) -> Result<String, String>;
/// One linkable product plugin: its name, its constructor, its identity minter, and the estate
/// guardian it links, if any (the host holds it beside the product, A32-13).
#[derive(Clone, Copy)]
pub struct ProductFactory {
pub name: &'static str,
pub build: ProductBuilder,
pub mint_identity: IdentityMinter,
pub guardian: Option<&'static dyn crate::guardian::EstateGuardian>,
}
impl std::fmt::Debug for ProductFactory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("ProductFactory")
.field("name", &self.name)
.finish_non_exhaustive()
}
}
/// **Register a factory, replacing one of the same name.** Last writer wins,
/// deliberately — the rule `monetize_pay::factory::register` applies, so a
/// build that links a richer plugin for an open name registers after the open
/// set and takes the name over.
pub fn register(list: &mut Vec<ProductFactory>, f: ProductFactory) {
if let Some(slot) = list.iter_mut().find(|g| g.name == f.name) {
*slot = f;
} else {
list.push(f);
}
}
/// The factory registered under `name`, if any.
pub fn find<'a>(list: &'a [ProductFactory], name: &str) -> Option<&'a ProductFactory> {
list.iter().find(|f| f.name == name)
}
#[cfg(test)]
mod tests {
use super::*;
fn refuse(b: &ProductBuild<'_>) -> Result<crate::pulled::FactsSource, String> {
Err(b.err("no"))
}
fn no_identity(b: &ProductBuild<'_>) -> Result<String, String> {
Err(b.err("nothing to mint"))
}
#[test]
fn register_replaces_a_name_and_find_answers_by_name() {
let mut list = Vec::new();
register(
&mut list,
ProductFactory {
name: "a",
build: refuse,
mint_identity: no_identity,
guardian: None,
},
);
register(
&mut list,
ProductFactory {
name: "b",
build: refuse,
mint_identity: no_identity,
guardian: None,
},
);
register(
&mut list,
ProductFactory {
name: "a",
build: refuse,
mint_identity: no_identity,
guardian: None,
},
);
assert_eq!(
list.len(),
2,
"a second registration of a name replaces, never duplicates"
);
assert!(find(&list, "a").is_some() && find(&list, "b").is_some());
assert!(find(&list, "c").is_none());
}
#[test]
fn a_refusal_names_the_block() {
let b = ProductBuild {
id: "acme",
endpoint: "x",
identity_key: None,
appliance_key: None,
bearer_token: None,
api_key_env: None,
restoring_from_twin: None,
tls_ca: None,
tls_client: None,
guest: None,
};
assert_eq!(refuse(&b).err().unwrap(), "[products.acme] no");
assert_eq!(
no_identity(&b).err().unwrap(),
"[products.acme] nothing to mint"
);
}
}