Skip to main content

holger_fleet/
order.rs

1//! **The orders a Holger box pulls**, decoded and checked.
2//!
3//! The kinds are monetize's (`monetize_embed::fleet::kind` for the repair verbs,
4//! `monetize_product::pull::order` for the product's). The signature is monetize-embed's
5//! `monetize_embed::fleet::order_message` under the product's monetize key: an order that does not verify is
6//! FORGED — never run, never acked, and it never moves the sequence (a forged high seq
7//! must not silence the real ones). Everything else that is wrong with an order is a
8//! REFUSAL, acked by name so the guardian learns why.
9
10use monetize_embed::fleet::{kind, GrowGoAhead, SignedOrder};
11use monetize_embed::signing::Snapshot;
12use monetize_embed::VerifyingKey;
13use monetize_estate_side::pump::Checked;
14use monetize_product::pull::{order as pkind, AddPrincipal, GrowRoom, PurgeTenant};
15use monetize_product::EntitlementFact;
16use serde::Deserialize;
17
18/// Who this box is on the wire: the shared pump's own (P4a).
19pub use monetize_estate_side::pump::Addressee;
20
21/// A recharge, as ordered.
22#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
23#[serde(default)]
24pub struct Recharge {
25    /// The main to fill (its control plane, `host:port`).
26    pub main: String,
27    /// The twin it is filled from.
28    pub twin: String,
29    pub accept_lost: bool,
30    pub epoch: u64,
31    /// The old primary THIS box fences first; the recharge runs only when that fence held
32    /// ([`crate::exec`]).
33    pub fence: Option<String>,
34    /// The guardian's proof the old primary is fenced AT THE CLOUD.
35    pub fenced: Option<String>,
36    pub case: Option<String>,
37}
38
39/// A decoded order body.
40#[derive(Debug, Clone, PartialEq)]
41pub enum Order {
42    /// One repository's signed entitlement fact (in holger a tenant IS a repository).
43    Entitlement(EntitlementFact),
44    /// Monetize's books put back on a recharged main.
45    RestateEntitlements(Snapshot),
46    /// Delete a repository for non-payment (or ask how its purge stands).
47    PurgeTenant(PurgeTenant),
48    /// Room was sold past the set: queued, the operator told.
49    GrowRoom(GrowRoom),
50    /// A sold estate's paid data step.
51    GrowGoAhead(GrowGoAhead),
52    RechargeFromTwin(Recharge),
53    FencePrimary {
54        primary: String,
55        epoch: u64,
56    },
57    AdoptTwin {
58        twin: String,
59        principal: String,
60    },
61    /// A freshly laid box is TOLD it is the twin of `primary` (holger installs with no role).
62    BecomeTwin {
63        primary: String,
64    },
65    ReviewTwin,
66    /// A paid signup's account: in holger, a REPOSITORY on a plan (`username` names it; the
67    /// plan arrives as its entitlement fact). Idempotent per `signup`.
68    AddPrincipal(AddPrincipal),
69    /// A sold estate's resend sign-in link (Gunnar's S10): mint and mail it here.
70    SignInAgain(SignInAgain),
71    /// **Estate keys fed again** (monetize MAIL-RELAY + NIGHT-PUBREPO, 2026-10-28): each one
72    /// Holger takes again ([`refeed_key::KNOWN`]), kept beside the feed the box was laid with.
73    Refeed(Refeed),
74}
75
76/// **The estate keys a `refeed` may carry that Holger takes again** (monetize-embed
77/// `fleet::refeed`, named here: a lone clone's crates.io monetize-embed predates it).
78pub mod refeed_key {
79    pub const SMTP_URL: &str = "secret.smtp_url";
80    pub const SMTP_FROM: &str = "secret.smtp_from";
81    pub const SMTP_CA: &str = "secret.smtp_ca";
82    pub const PUBLIC_REPOSITORIES: &str = "public_repositories";
83    pub const KNOWN: [&str; 4] = [SMTP_URL, SMTP_FROM, SMTP_CA, PUBLIC_REPOSITORIES];
84}
85
86/// Body of a `refeed` order (monetize-embed `fleet::Refeed`, named here): key → value, the lay's
87/// feed words. **A `secret.` value is a secret**: the `Debug` names the keys only.
88#[derive(Clone, PartialEq, Eq, Default, Deserialize, serde::Serialize)]
89#[serde(default)]
90pub struct Refeed {
91    pub keys: std::collections::BTreeMap<String, String>,
92}
93
94impl std::fmt::Debug for Refeed {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        f.debug_struct("Refeed")
97            .field("keys", &self.keys.keys().collect::<Vec<_>>())
98            .finish()
99    }
100}
101
102impl Refeed {
103    /// A key's value, trimmed; blank is unset.
104    pub fn get(&self, key: &str) -> Option<&str> {
105        self.keys
106            .get(key)
107            .map(|v| v.trim())
108            .filter(|v| !v.is_empty())
109    }
110}
111
112/// Body of a `sign_in_again` order (monetize-embed `fleet::SignInAgain`, named here: a lone
113/// clone's crates.io monetize-embed predates it). Never a secret.
114#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
115#[serde(default)]
116pub struct SignInAgain {
117    /// The account whose first passkey the invite enrols (the order's account).
118    pub account: String,
119    /// The order's address: where the estate mails the link.
120    pub to: String,
121    /// The console's relying-party host the link points at (`<company>.<apex>`).
122    pub host: String,
123    /// How long the invite lives (0: the default).
124    pub ttl_secs: u64,
125}
126
127/// Holger's own order kinds beside monetize-embed's repair verbs (monetize
128/// `products/holger/src/guardian.rs`).
129pub mod holger_kind {
130    pub const BECOME_TWIN: &str = "become_twin";
131    /// monetize-product `pull::order::REFEED` (a core kind since MAIL-RELAY 2026-10-28), named
132    /// here: a lone clone's crates.io monetize-product predates it.
133    pub const REFEED: &str = "refeed";
134}
135
136#[derive(Deserialize, Default)]
137#[serde(default)]
138struct PrimaryBody {
139    primary: String,
140}
141
142/// What [`accept`] made of one wire order.
143#[derive(Debug, Clone, PartialEq)]
144pub enum Verdict {
145    /// Not monetize's: never run, never acked, the sequence does not move.
146    Forged(String),
147    /// Monetize's, but not for this box to run now: acked not-ok with these words.
148    Refused(String),
149    Run(Order),
150}
151
152#[derive(Deserialize)]
153struct RestateBody {
154    snapshot: Snapshot,
155}
156
157#[derive(Deserialize, Default)]
158#[serde(default)]
159struct FenceBody {
160    primary: String,
161    epoch: u64,
162}
163
164#[derive(Deserialize, Default)]
165#[serde(default)]
166struct AdoptBody {
167    twin: String,
168    principal: String,
169}
170
171/// **The box's check**: the shared pump's ([`monetize_estate_side::pump::check`]: signed by
172/// monetize, for this box, newer than the last order taken, inside its window), then a kind
173/// Holger runs ([`decode`]).
174pub fn accept(
175    o: &SignedOrder,
176    me: &Addressee,
177    key: &VerifyingKey,
178    last_seq: u64,
179    now_unix_ms: u64,
180) -> Verdict {
181    match monetize_estate_side::pump::check(o, me, key, last_seq, now_unix_ms) {
182        Checked::Forged(why) => Verdict::Forged(why),
183        Checked::Refused(why) => Verdict::Refused(why),
184        Checked::Fresh => match decode(o.seq, &o.kind, &o.body) {
185            Ok(order) => Verdict::Run(order),
186            Err(why) => Verdict::Refused(why),
187        },
188    }
189}
190
191/// **The body of one kind**, or the refusal by name.
192pub fn decode(seq: u64, kind: &str, body: &str) -> Result<Order, String> {
193    let body = if body.trim().is_empty() { "{}" } else { body };
194    let bad =
195        |e: serde_json::Error| format!("order {seq} ({kind}) has a body that does not decode: {e}");
196    Ok(match kind {
197        pkind::ENTITLEMENT => Order::Entitlement(serde_json::from_str(body).map_err(bad)?),
198        kind::RESTATE_ENTITLEMENTS => Order::RestateEntitlements(serde_json::from_str::<RestateBody>(body).map_err(bad)?.snapshot),
199        pkind::PURGE_TENANT => {
200            let p: PurgeTenant = serde_json::from_str(body).map_err(bad)?;
201            if p.tenant.trim().is_empty() {
202                return Err(format!("order {seq} is a purge_tenant that names no repository"));
203            }
204            Order::PurgeTenant(p)
205        }
206        pkind::GROW_ROOM => {
207            let g: GrowRoom = serde_json::from_str(body).map_err(bad)?;
208            if g.need_bytes == 0 {
209                return Err(format!("order {seq} is a grow_room that wants nothing"));
210            }
211            Order::GrowRoom(g)
212        }
213        kind::GROW_GO_AHEAD => {
214            let g: GrowGoAhead = serde_json::from_str(body).map_err(bad)?;
215            if g.go_ahead.trim().is_empty() || g.data_gib == 0 {
216                return Err(format!("order {seq} is a grow_go_ahead without monetize's signed step or a size"));
217            }
218            Order::GrowGoAhead(g)
219        }
220        kind::RECHARGE_FROM_TWIN => {
221            let mut r: Recharge = serde_json::from_str(body).map_err(bad)?;
222            r.fence = r.fence.filter(|f| !f.trim().is_empty());
223            r.fenced = r.fenced.filter(|f| !f.trim().is_empty());
224            if r.main.trim().is_empty() || r.twin.trim().is_empty() {
225                return Err(format!("order {seq} is a recharge_from_twin that names no main or no twin"));
226            }
227            Order::RechargeFromTwin(r)
228        }
229        kind::FENCE_PRIMARY => {
230            let b: FenceBody = serde_json::from_str(body).map_err(bad)?;
231            Order::FencePrimary { primary: b.primary, epoch: b.epoch }
232        }
233        kind::ADOPT_TWIN => {
234            let b: AdoptBody = serde_json::from_str(body).map_err(bad)?;
235            if b.twin.trim().is_empty() {
236                return Err(format!("order {seq} is an adopt_twin that names no twin"));
237            }
238            Order::AdoptTwin { twin: b.twin, principal: b.principal }
239        }
240        holger_kind::BECOME_TWIN => {
241            let b: PrimaryBody = serde_json::from_str(body).map_err(bad)?;
242            if b.primary.trim().is_empty() {
243                return Err(format!("order {seq} is a {kind} that names no primary"));
244            }
245            Order::BecomeTwin { primary: b.primary }
246        }
247        kind::REVIEW_TWIN => Order::ReviewTwin,
248        pkind::ADD_PRINCIPAL => {
249            let a: AddPrincipal = serde_json::from_str(body).map_err(bad)?;
250            if a.username.trim().is_empty() || a.signup.trim().is_empty() {
251                return Err(format!("order {seq} is an add_principal that names no repository or no signup"));
252            }
253            Order::AddPrincipal(a)
254        }
255        pkind::ADD_USERS => return Err(format!("order {seq}: Holger has no seats to grant on an account (a Holger tenant is a repository), so add_users is refused")),
256        kind::SIGN_IN_AGAIN => {
257            let b: SignInAgain = serde_json::from_str(body).map_err(bad)?;
258            if let Err(why) = crate::sign_in::check(&b.account, &b.to, &b.host) {
259                return Err(format!("order {seq} is a sign_in_again this box cannot mail: {why}"));
260            }
261            Order::SignInAgain(b)
262        }
263        holger_kind::REFEED => {
264            // The body may carry the relay's token: its decode error is not echoed.
265            let r: Refeed = serde_json::from_str(body).map_err(|_| format!("order {seq} (refeed) has a body that does not decode as estate keys"))?;
266            if r.keys.is_empty() {
267                return Err(format!("order {seq} is a refeed that names no estate key"));
268            }
269            if let Some(k) = r.keys.keys().find(|k| !refeed_key::KNOWN.contains(&k.as_str())) {
270                return Err(format!("order {seq} is a refeed of the estate key {k:?}, which Holger does not take again (it takes {}): nothing was kept", refeed_key::KNOWN.join(", ")));
271            }
272            match (r.get(refeed_key::SMTP_URL), r.get(refeed_key::SMTP_FROM)) {
273                (Some(url), Some(_)) if !(url.starts_with("smtp://") || url.starts_with("smtps://")) => return Err(format!("order {seq} is a refeed whose {} is not smtp:// or smtps://", refeed_key::SMTP_URL)),
274                (Some(_), Some(_)) => {}
275                (None, None) if r.get(refeed_key::SMTP_CA).is_none() => {}
276                _ => return Err(format!("order {seq} is a refeed with half a mail relay ({} and {} go together)", refeed_key::SMTP_URL, refeed_key::SMTP_FROM)),
277            }
278            // An empty list is "none public" (RA5-18: the ONE box feeds a cleared list as "").
279            if let Some(list) = r.keys.get(refeed_key::PUBLIC_REPOSITORIES).filter(|l| !l.trim().is_empty()) {
280                if let Some(bad) = list.split(',').map(str::trim).find(|n| n.is_empty() || !n.bytes().all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_' | b'.'))) {
281                    return Err(format!("order {seq} is a refeed whose {} names {bad:?}: a repository is one plain word", refeed_key::PUBLIC_REPOSITORIES));
282                }
283            }
284            Order::Refeed(r)
285        }
286        other => return Err(format!("order {seq}: Holger does not know the order kind {other:?}")),
287    })
288}
289
290#[cfg(test)]
291pub(crate) mod tests {
292    use super::*;
293    use ed25519_dalek::Signer as _;
294    use monetize_embed::fleet::order_message;
295
296    pub const MONETIZE_SEED: [u8; 32] = [7u8; 32];
297
298    pub fn me(role: &str) -> Addressee {
299        Addressee {
300            product: "holger".into(),
301            instance: "acme".into(),
302            role: role.into(),
303        }
304    }
305
306    pub fn monetize_key() -> VerifyingKey {
307        ed25519_dalek::SigningKey::from_bytes(&MONETIZE_SEED).verifying_key()
308    }
309
310    pub fn signed(seq: u64, role: &str, kind: &str, body: &str, signer: [u8; 32]) -> SignedOrder {
311        let mut o = SignedOrder {
312            seq,
313            product: "holger".into(),
314            instance: "acme".into(),
315            role: role.into(),
316            kind: kind.into(),
317            body: body.into(),
318            issued_unix_ms: 9_000,
319            not_after_unix_ms: 0,
320            signature: Vec::new(),
321        };
322        o.signature = ed25519_dalek::SigningKey::from_bytes(&signer)
323            .sign(&order_message(&o))
324            .to_bytes()
325            .to_vec();
326        o
327    }
328
329    #[test]
330    fn a_forged_order_is_forged_whatever_it_claims() {
331        let o = signed(9, "main", "review_twin", "{}", [9u8; 32]);
332        assert!(matches!(
333            accept(&o, &me("main"), &monetize_key(), 0, 10_000),
334            Verdict::Forged(_)
335        ));
336    }
337
338    #[test]
339    fn an_order_for_the_twin_a_replay_and_an_expired_one_are_refused_by_name() {
340        let k = monetize_key();
341        let twin = signed(2, "twin", "review_twin", "{}", MONETIZE_SEED);
342        assert!(
343            matches!(accept(&twin, &me("main"), &k, 0, 10_000), Verdict::Refused(w) if w.contains("twin"))
344        );
345        let old = signed(2, "main", "review_twin", "{}", MONETIZE_SEED);
346        assert!(
347            matches!(accept(&old, &me("main"), &k, 2, 10_000), Verdict::Refused(w) if w.contains("replay"))
348        );
349        let mut late = signed(3, "main", "review_twin", "{}", MONETIZE_SEED);
350        late.not_after_unix_ms = 5_000;
351        late.signature = ed25519_dalek::SigningKey::from_bytes(&MONETIZE_SEED)
352            .sign(&order_message(&late))
353            .to_bytes()
354            .to_vec();
355        assert!(
356            matches!(accept(&late, &me("main"), &k, 0, 10_000), Verdict::Refused(w) if w.contains("expired"))
357        );
358    }
359
360    /// ★ **MAIL-RELAY + NIGHT-PUBREPO: a `refeed` is run with the keys it carries**, each taken
361    /// by name: the relay and the public repositories. A key Holger does not take again, half a
362    /// relay, a relay that is not `smtp://`/`smtps://`, a repository that is not one plain word,
363    /// and no key at all are refused by name, never echoing the token; Debug names keys only.
364    #[test]
365    fn a_refeed_is_run_with_the_keys_it_carries_and_a_key_holger_does_not_take_is_refused_by_name()
366    {
367        let body = r#"{"keys":{"secret.smtp_url":"smtps://holger%40holger.rs:t0kenVALUE@relay.mock.invalid:465","secret.smtp_from":"Holger <holger@mock.invalid>","public_repositories":"crates,npm"}}"#;
368        let o = signed(4, "main", "refeed", body, MONETIZE_SEED);
369        match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
370            Verdict::Run(Order::Refeed(r)) => {
371                assert_eq!(
372                    (
373                        r.get(refeed_key::SMTP_FROM),
374                        r.get(refeed_key::PUBLIC_REPOSITORIES)
375                    ),
376                    (Some("Holger <holger@mock.invalid>"), Some("crates,npm"))
377                );
378                assert!(!format!("{r:?}").contains("t0kenVALUE"), "{r:?}");
379            }
380            other => panic!("{other:?}"),
381        }
382        let o = signed(
383            5,
384            "main",
385            "refeed",
386            r#"{"keys":{"public_repositories":"crates"}}"#,
387            MONETIZE_SEED,
388        );
389        assert!(
390            matches!(
391                accept(&o, &me("main"), &monetize_key(), 0, 10_000),
392                Verdict::Run(Order::Refeed(_))
393            ),
394            "the public list alone"
395        );
396        // RA5-18: a cleared list ("": none public, monetize 523a41ac) is a list, never refused.
397        let o = signed(
398            5,
399            "main",
400            "refeed",
401            r#"{"keys":{"public_repositories":""}}"#,
402            MONETIZE_SEED,
403        );
404        match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
405            Verdict::Run(Order::Refeed(r)) => assert_eq!(
406                r.keys
407                    .get(refeed_key::PUBLIC_REPOSITORIES)
408                    .map(String::as_str),
409                Some(""),
410                "none public"
411            ),
412            other => panic!("a cleared public list is refused: {other:?}"),
413        }
414        for (bad, word) in [
415            (r#"{"keys":{}}"#, "names no estate key"),
416            (
417                r#"{"keys":{"console_admin_token":"t0kenVALUE"}}"#,
418                "console_admin_token",
419            ),
420            (
421                r#"{"keys":{"secret.smtp_url":"smtps://u:t0kenVALUE@r.mock.invalid"}}"#,
422                "half a mail relay",
423            ),
424            (
425                r#"{"keys":{"secret.smtp_url":"http://u:t0kenVALUE@r.mock.invalid","secret.smtp_from":"h@mock.invalid"}}"#,
426                "not smtp://",
427            ),
428            (
429                r#"{"keys":{"public_repositories":"crates,../x"}}"#,
430                "one plain word",
431            ),
432            (r#"{"keys":7}"#, "does not decode"),
433        ] {
434            let o = signed(6, "main", "refeed", bad, MONETIZE_SEED);
435            match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
436                Verdict::Refused(w) => assert!(
437                    w.contains("refeed") && w.contains(word) && !w.contains("t0kenVALUE"),
438                    "{bad}: {w}"
439                ),
440                other => panic!("{bad}: {other:?}"),
441            }
442        }
443    }
444
445    #[test]
446    fn holgers_missing_concepts_are_refused_by_name() {
447        let o = signed(
448            1,
449            "main",
450            "add_principal",
451            r#"{"username":"crates","kind":"org","signup":"s-1"}"#,
452            MONETIZE_SEED,
453        );
454        assert!(
455            matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Run(Order::AddPrincipal(a)) if a.username == "crates" && a.signup == "s-1"),
456            "a signup is a repository on a plan"
457        );
458        let o = signed(
459            2,
460            "main",
461            "add_principal",
462            r#"{"username":"","signup":"s-1"}"#,
463            MONETIZE_SEED,
464        );
465        assert!(
466            matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Refused(w) if w.contains("names no repository"))
467        );
468        for (kind, word) in [("add_users", "add_users"), ("make_coffee", "make_coffee")] {
469            let o = signed(
470                1,
471                "main",
472                kind,
473                r#"{"username":"a","signup":"s"}"#,
474                MONETIZE_SEED,
475            );
476            match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
477                Verdict::Refused(w) => assert!(w.contains(word), "{w}"),
478                other => panic!("{kind}: {other:?}"),
479            }
480        }
481    }
482
483    /// ★ **A sold estate's resend sign-in link is run here** (A16, Gunnar's S10): monetize's
484    /// signed `sign_in_again` names whose passkey, which address and which console host — never
485    /// a secret; a body a link or a mail header cannot carry is refused by name.
486    #[test]
487    fn a_sign_in_again_is_run_and_a_body_a_link_cannot_carry_is_refused() {
488        let body = r#"{"account":"acme","to":"buyer@mock.invalid","host":"acme.holger.mock.invalid","ttl_secs":86400}"#;
489        let o = signed(5, "main", "sign_in_again", body, MONETIZE_SEED);
490        match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
491            Verdict::Run(Order::SignInAgain(s)) => assert_eq!(
492                (
493                    s.account.as_str(),
494                    s.to.as_str(),
495                    s.host.as_str(),
496                    s.ttl_secs
497                ),
498                (
499                    "acme",
500                    "buyer@mock.invalid",
501                    "acme.holger.mock.invalid",
502                    86_400
503                )
504            ),
505            other => panic!("{other:?}"),
506        }
507        for bad in [
508            r#"{"account":"a&user=x","to":"b@mock.invalid","host":"h.mock.invalid"}"#,
509            r#"{"account":"a","to":"b@mock.invalid\r\nBcc: x@mock.invalid","host":"h.mock.invalid"}"#,
510            r#"{"account":"a","to":"b@mock.invalid","host":""}"#,
511        ] {
512            let o = signed(6, "main", "sign_in_again", bad, MONETIZE_SEED);
513            assert!(
514                matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Refused(w) if w.contains("sign_in_again")),
515                "{bad}"
516            );
517        }
518    }
519}