boatramp-types 0.2.2

Shared, wasm-clean wire types + routing/config logic for boatramp (used by the server, CLI, and the edge Worker so the wire format and routing can't drift)
Documentation
//! Ledger of DNS records boatramp created on the operator's behalf, so they can
//! be **retracted** when a custom domain is detached (`domain rm`) or its site
//! deleted. Persisted in the control-plane KV under
//! `project/<proj>/dnsmanaged/<site>/<host>` (project-scoped, 0.2.0).
//!
//! The record set is stored string-typed (a serializable mirror of the acme
//! `DnsRecord`) so this crate needn't depend on `boatramp-acme`; the retraction
//! side rebuilds the provider named here and deletes each record.

use serde::{Deserialize, Serialize};

/// KV key for the managed-records ledger of `host` under `site`. The host is
/// normalized (lowercased, no `*.`/trailing dot), matching the verification key.
/// `project` is a bare `&str` (this crate is wasm-clean).
pub fn dnsmanaged_key(project: &str, site: &str, host: &str) -> String {
    format!(
        "project/{project}/dnsmanaged/{site}/{}",
        crate::domain_verify::normalize_host(host)
    )
}

/// The KV key prefix for a site's managed-record ledgers (for enumeration).
pub fn dnsmanaged_site_prefix(project: &str, site: &str) -> String {
    format!("project/{project}/dnsmanaged/{site}/")
}

/// One DNS record boatramp created — a serializable mirror of the acme
/// `DnsRecord` (kept string-typed to avoid a dependency cycle).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ManagedRecord {
    /// Record type token: `A` / `AAAA` / `CNAME` / `TXT`.
    pub kind: String,
    /// Fully-qualified record name.
    pub name: String,
    /// Record value (address, target host, or TXT payload).
    pub value: String,
    /// Time-to-live in seconds.
    pub ttl: u32,
}

/// The records boatramp manages for one host, plus the provider that owns them
/// (so a retraction rebuilds the same provider). Persisted per `(site, host)`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ManagedDns {
    /// Schema version, pinned at [`crate::SCHEMA_VERSION`].
    pub version: u32,
    /// The host these records point at the server (normalized).
    pub host: String,
    /// The DNS provider that owns them (the `--provider` spelling, e.g.
    /// `cloudflare`, `digitalocean`).
    pub provider: String,
    /// The records to retract when the host is detached.
    pub records: Vec<ManagedRecord>,
    /// Unix seconds the records were last written.
    pub updated_at_unix: u64,
}

impl Default for ManagedDns {
    fn default() -> Self {
        Self {
            version: crate::SCHEMA_VERSION,
            host: String::new(),
            provider: String::new(),
            records: Vec::new(),
            updated_at_unix: 0,
        }
    }
}

impl ManagedDns {
    /// A ledger entry for `host` managed by `provider`, carrying `records`.
    pub fn new(host: &str, provider: &str, records: Vec<ManagedRecord>, now_unix: u64) -> Self {
        Self {
            version: crate::SCHEMA_VERSION,
            host: crate::domain_verify::normalize_host(host),
            provider: provider.to_string(),
            records,
            updated_at_unix: now_unix,
        }
    }

    /// Parse from the KV JSON representation.
    pub fn from_json(bytes: &[u8]) -> Result<Self, crate::error::ConfigError> {
        serde_json::from_slice(bytes)
            .map_err(|err| crate::error::ConfigError::parse(err.to_string()))
    }

    /// Serialize to JSON for KV storage.
    pub fn to_json(&self) -> Result<Vec<u8>, crate::error::ConfigError> {
        serde_json::to_vec(self).map_err(|err| crate::error::ConfigError::parse(err.to_string()))
    }
}

/// The DNS reconcile actions for one site's custom domains.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ReconcilePlan {
    /// Verified + attached hosts to (re)assert the A/CNAME for (idempotent).
    pub to_point: Vec<String>,
    /// Hosts with a ledger entry but no longer attached — retract their records.
    pub to_retract: Vec<String>,
}

/// Plan the leader-only DNS reconcile for one site. `verified` is the set of
/// hosts currently verified + attached to the site; `managed` is the set of
/// hosts that already have a ledger entry. Every verified host is (re)pointed
/// (upsert is idempotent, so this self-heals drift); every managed-but-no-longer-
/// verified host is retracted. Hosts are compared normalized, so a wildcard and
/// its base don't churn.
pub fn plan_reconcile(verified: &[String], managed: &[String]) -> ReconcilePlan {
    use std::collections::BTreeSet;
    let verified: BTreeSet<String> = verified
        .iter()
        .map(|h| crate::domain_verify::normalize_host(h))
        .collect();
    let managed: BTreeSet<String> = managed
        .iter()
        .map(|h| crate::domain_verify::normalize_host(h))
        .collect();
    ReconcilePlan {
        to_point: verified.iter().cloned().collect(),
        to_retract: managed.difference(&verified).cloned().collect(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn key_normalizes_the_host() {
        assert_eq!(
            dnsmanaged_key("default", "blog", "*.WWW.Example.com."),
            "project/default/dnsmanaged/blog/www.example.com"
        );
        assert_eq!(
            dnsmanaged_site_prefix("default", "blog"),
            "project/default/dnsmanaged/blog/"
        );
    }

    #[test]
    fn json_roundtrip() {
        let ledger = ManagedDns::new(
            "www.example.com",
            "cloudflare",
            vec![ManagedRecord {
                kind: "A".into(),
                name: "www.example.com".into(),
                value: "203.0.113.7".into(),
                ttl: 300,
            }],
            42,
        );
        let bytes = ledger.to_json().unwrap();
        assert_eq!(ManagedDns::from_json(&bytes).unwrap(), ledger);
    }

    #[test]
    fn new_normalizes_host_and_pins_version() {
        let ledger = ManagedDns::new("*.Example.com", "route53", Vec::new(), 1);
        assert_eq!(ledger.host, "example.com");
        assert_eq!(ledger.version, crate::SCHEMA_VERSION);
        assert_eq!(ledger.provider, "route53");
    }

    #[test]
    fn plan_points_verified_and_retracts_orphans() {
        let plan = plan_reconcile(
            &["a.example.com".into(), "b.example.com".into()],
            &["b.example.com".into(), "old.example.com".into()],
        );
        assert_eq!(plan.to_point, vec!["a.example.com", "b.example.com"]);
        assert_eq!(plan.to_retract, vec!["old.example.com"]);
    }

    #[test]
    fn plan_normalizes_before_comparing() {
        // A wildcard verified host and its managed base normalize equal → no churn.
        let plan = plan_reconcile(&["*.Example.com".into()], &["example.com".into()]);
        assert_eq!(plan.to_point, vec!["example.com"]);
        assert!(plan.to_retract.is_empty());
    }
}