crates/monetize-dns-guest/src/lib.rs
//! **The guest side of a monetize DNS writer** — what a DNS plugin compiles against, and the
//! contract the server's DNS host (`monetize-cloud-wasm::dns`) speaks.
//!
//! The owner, 2026-10-11: the ONE box links NO provider. A DNS writer (Cloudflare) is a signed
//! `.wasm` the running server takes in — exactly as a cloud (UpCloud) and a payment vendor
//! (Stripe) are — built in monetize-impl, signed under the plugin root, pinned by the kit.
//!
//! | piece | what it is |
//! |---|---|
//! | [`DnsGuest`] | the trait a DNS plugin implements: [`monetize_cloud::dns::DnsWriter`]'s four doors over the host's |
//! | [`ops`] | the op table, 400.. (products below 100, vendors 100.., clouds 200.., signups 300..) |
//! | [`wire`] | the call envelope (the block's settings, then the op's arguments) and the answer |
//! | [`dispatch`] | the op switch — the wasm export's AND a native test's |
//! | [`export_dns!`] | the product guest's exports and host bridge with this dispatch |
//!
//! # What never crosses into the guest
//!
//! **The provider's token.** The host's HTTP door presents it (`http_auth`, the fed secret by
//! name); a guest builds requests with no `Authorization` header and never reads a secret.
pub use monetize_cloud::dns::{DnsRecord, Replaced, Written, TAKE_OVER_KINDS};
pub use monetize_product_guest as product_guest;
pub use monetize_product_guest::{abi, grants, kinds, Host, Manifest, NoHost};
/// **The op table** — one number per [`monetize_cloud::dns::DnsWriter`] door.
pub mod ops {
pub const ID: u32 = monetize_product_guest::ops::ID;
/// The block's settings judged at load (a zone named, …): refused by name before any write.
pub const CHECK: u32 = 400;
pub const WRITE: u32 = 410;
pub const REMOVE: u32 = 411;
pub const LISTED: u32 = 412;
pub const SETTLED: u32 = 413;
/// A write that may take over the untagged address records at the names it is given
/// (an operator site's own names, 2026-10-22). A guest older than it answers "not in
/// this DNS guest's op table" and the host falls back to [`WRITE`].
pub const WRITE_TAKING_OVER: u32 = 414;
/// The zone the guest writes in, as its provider names it (A32-3): the host refuses any
/// name outside it before it asks a write. A guest older than it answers "not in this DNS
/// guest's op table" and the host can refuse nothing for it.
pub const ZONE: u32 = 415;
pub const ALL: &[(u32, &str)] = &[
(ID, "id"),
(CHECK, "check"),
(WRITE, "write"),
(REMOVE, "remove"),
(LISTED, "listed"),
(SETTLED, "settled"),
(WRITE_TAKING_OVER, "write_taking_over"),
(ZONE, "zone"),
];
pub fn name(op: u32) -> Option<&'static str> {
ALL.iter().find(|(n, _)| *n == op).map(|(_, s)| *s)
}
}
/// The call envelope and the answer.
pub mod wire {
pub use monetize_product_guest::wire::{decode, encode};
/// One call: the block's settings (the host's keys removed), then the op's argument.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Call<T> {
pub settings: serde_json::Value,
pub req: T,
}
/// [`super::ops::WRITE_TAKING_OVER`]'s argument: the records, and the names whose
/// untagged A/AAAA/CNAME records may be replaced.
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct TakeOver {
pub records: Vec<super::DnsRecord>,
pub take_over: Vec<String>,
}
/// Every answer: the door's own `Result<_, String>`.
pub type Answer<T> = Result<T, String>;
}
use wire::Call;
/// **A DNS plugin**: [`monetize_cloud::dns::DnsWriter`]'s doors, each over the host's (its
/// `http` door to the provider's API, the token presented by the host) and the block's
/// settings (the zone, …). A fresh instance per call: it remembers nothing.
pub trait DnsGuest {
fn manifest(&self) -> Manifest;
/// The block's settings, judged at load.
fn check(&self, host: &dyn Host, settings: &serde_json::Value) -> Result<(), String> {
let _ = (host, settings);
Ok(())
}
fn write(
&self,
host: &dyn Host,
settings: &serde_json::Value,
records: &[DnsRecord],
) -> Result<String, String>;
/// [`monetize_cloud::dns::DnsWriter::write_taking_over`]. The default takes nothing over:
/// [`Self::write`], nothing replaced.
fn write_taking_over(
&self,
host: &dyn Host,
settings: &serde_json::Value,
records: &[DnsRecord],
take_over: &[String],
) -> Result<Written, String> {
let _ = take_over;
self.write(host, settings, records).map(|line| Written {
line,
replaced: Vec::new(),
})
}
fn remove(
&self,
host: &dyn Host,
settings: &serde_json::Value,
names: &[String],
) -> Result<String, String>;
fn listed(
&self,
host: &dyn Host,
settings: &serde_json::Value,
) -> Result<Vec<DnsRecord>, String>;
/// Written through the provider's API: settled when the write answered.
fn settled(
&self,
host: &dyn Host,
settings: &serde_json::Value,
records: &[DnsRecord],
) -> Result<bool, String> {
let _ = (host, settings, records);
Ok(true)
}
/// [`monetize_cloud::dns::DnsWriter::zone`]: the apex it writes in. The default: it
/// cannot say.
fn zone(
&self,
host: &dyn Host,
settings: &serde_json::Value,
) -> Result<Option<String>, String> {
let _ = (host, settings);
Ok(None)
}
}
/// **The op switch** — the wasm export's and a native test's.
pub fn dispatch(guest: &dyn DnsGuest, host: &dyn Host, op: u32, payload: &[u8]) -> Vec<u8> {
fn call<T: serde::de::DeserializeOwned>(op: u32, payload: &[u8]) -> Result<Call<T>, String> {
wire::decode(payload).map_err(|e| {
format!(
"op {op} ({}): undecodable request: {e}",
ops::name(op).unwrap_or("?")
)
})
}
fn ans<T: serde::Serialize>(r: Result<T, String>) -> Vec<u8> {
wire::encode(&r)
}
match op {
ops::ID => ans::<String>(Ok(guest.manifest().product)),
ops::CHECK => ans(call::<()>(op, payload).and_then(|c| guest.check(host, &c.settings))),
ops::WRITE => ans(call::<Vec<DnsRecord>>(op, payload)
.and_then(|c| guest.write(host, &c.settings, &c.req))),
ops::WRITE_TAKING_OVER => ans(call::<wire::TakeOver>(op, payload).and_then(|c| {
guest.write_taking_over(host, &c.settings, &c.req.records, &c.req.take_over)
})),
ops::REMOVE => {
ans(call::<Vec<String>>(op, payload)
.and_then(|c| guest.remove(host, &c.settings, &c.req)))
}
ops::LISTED => ans(call::<()>(op, payload).and_then(|c| guest.listed(host, &c.settings))),
ops::SETTLED => ans(call::<Vec<DnsRecord>>(op, payload)
.and_then(|c| guest.settled(host, &c.settings, &c.req))),
ops::ZONE => ans(call::<()>(op, payload).and_then(|c| guest.zone(host, &c.settings))),
other => ans::<()>(Err(format!(
"op {other} is not in this DNS guest's op table"
))),
}
}
#[macro_export]
macro_rules! export_dns {
($guest:ty, grants = [$($grant:ident),* $(,)?] $(,)?) => {
$crate::product_guest::__export_guest!($guest, $crate::DnsGuest, $crate::dispatch, grants = [$($grant),*]);
};
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct Canned;
impl DnsGuest for Canned {
fn manifest(&self) -> Manifest {
Manifest::of_kind(kinds::DNS, "canned", &[])
}
fn check(&self, _: &dyn Host, settings: &serde_json::Value) -> Result<(), String> {
settings
.get("zone_id")
.map(|_| ())
.ok_or_else(|| "names no zone_id".to_string())
}
fn write(
&self,
_: &dyn Host,
_: &serde_json::Value,
records: &[DnsRecord],
) -> Result<String, String> {
Ok(format!("{} written", records.len()))
}
fn remove(
&self,
_: &dyn Host,
_: &serde_json::Value,
names: &[String],
) -> Result<String, String> {
if names.iter().any(|n| n == "down") {
return Err("the provider is down".into());
}
Ok(format!("{} removed", names.len()))
}
fn listed(&self, _: &dyn Host, _: &serde_json::Value) -> Result<Vec<DnsRecord>, String> {
Ok(vec![DnsRecord {
name: "acme.gunnar.rs".into(),
kind: "A".into(),
value: "198.51.100.1".into(),
proxied: false,
}])
}
}
fn call<T: serde::Serialize>(op: u32, settings: serde_json::Value, req: T) -> Vec<u8> {
dispatch(&Canned, &NoHost, op, &wire::encode(&Call { settings, req }))
}
/// ★ **Every door round-trips and an error keeps its words** — a DNS guest answers the
/// four doors the shop's writer has; an op it does not know is never a success.
#[test]
fn every_door_round_trips_and_an_error_keeps_its_words() {
let s = serde_json::json!({"zone_id": "z"});
let rec = DnsRecord {
name: "acme.gunnar.rs".into(),
kind: "A".into(),
value: "198.51.100.1".into(),
proxied: false,
};
let w: wire::Answer<String> =
wire::decode(&call(ops::WRITE, s.clone(), vec![rec.clone(), rec.clone()])).unwrap();
assert_eq!(w, Ok("2 written".into()));
let t: wire::Answer<Written> = wire::decode(&call(
ops::WRITE_TAKING_OVER,
s.clone(),
wire::TakeOver {
records: vec![rec.clone()],
take_over: vec!["acme.gunnar.rs".into()],
},
))
.unwrap();
assert_eq!(
t,
Ok(Written {
line: "1 written".into(),
replaced: vec![]
}),
"the default takes nothing over"
);
let r: wire::Answer<String> =
wire::decode(&call(ops::REMOVE, s.clone(), vec!["down".to_string()])).unwrap();
assert_eq!(r, Err("the provider is down".into()));
let l: wire::Answer<Vec<DnsRecord>> =
wire::decode(&call(ops::LISTED, s.clone(), ())).unwrap();
assert_eq!(l.unwrap()[0].name, "acme.gunnar.rs");
let st: wire::Answer<bool> =
wire::decode(&call(ops::SETTLED, s.clone(), vec![rec])).unwrap();
assert_eq!(st, Ok(true));
let c: wire::Answer<()> =
wire::decode(&call(ops::CHECK, serde_json::json!({}), ())).unwrap();
assert_eq!(c, Err("names no zone_id".into()));
let u: wire::Answer<()> = wire::decode(&dispatch(&Canned, &NoHost, 999, b"{}")).unwrap();
assert!(matches!(u, Err(m) if m.contains("999")));
let bad: wire::Answer<String> =
wire::decode(&dispatch(&Canned, &NoHost, ops::WRITE, b"not json")).unwrap();
assert!(matches!(bad, Err(m) if m.contains("undecodable")));
assert_eq!(Canned.manifest().kind, kinds::DNS);
}
}