1use 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, ProductOrder, PurgeTenant};
15use monetize_product::EntitlementFact;
16use serde::Deserialize;
17
18pub use monetize_estate_side::pump::Addressee;
20
21#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
23#[serde(default)]
24pub struct Recharge {
25 pub main: String,
27 pub twin: String,
29 pub accept_lost: bool,
30 pub epoch: u64,
31 pub fence: Option<String>,
34 pub fenced: Option<String>,
36 pub case: Option<String>,
37}
38
39#[derive(Debug, Clone, PartialEq)]
41pub enum Order {
42 Entitlement(EntitlementFact),
44 RestateEntitlements(Snapshot),
46 PurgeTenant(PurgeTenant),
48 GrowRoom(GrowRoom),
50 GrowGoAhead(GrowGoAhead),
52 RechargeFromTwin(Recharge),
53 FencePrimary {
54 primary: String,
55 epoch: u64,
56 },
57 AdoptTwin {
58 twin: String,
59 principal: String,
60 },
61 BecomeTwin {
63 primary: String,
64 },
65 ReviewTwin,
66 AddPrincipal(AddPrincipal),
69 SignInAgain(SignInAgain),
71 Refeed(Refeed),
74 Product {
77 kind: String,
78 body: ProductOrder,
79 },
80}
81
82pub mod refeed_key {
85 pub const SMTP_URL: &str = "secret.smtp_url";
86 pub const SMTP_FROM: &str = "secret.smtp_from";
87 pub const SMTP_CA: &str = "secret.smtp_ca";
88 pub const PUBLIC_REPOSITORIES: &str = "public_repositories";
89 pub const PUBLIC_SITE: &str = "console_public_site";
94 pub const KNOWN: [&str; 5] = [
95 SMTP_URL,
96 SMTP_FROM,
97 SMTP_CA,
98 PUBLIC_REPOSITORIES,
99 PUBLIC_SITE,
100 ];
101 pub const AS_SETTINGS: [&str; 1] = [PUBLIC_SITE];
104}
105
106pub const PUBLIC_SITE_SETTING: &str = "console.public-site";
111
112#[derive(Clone, PartialEq, Eq, Default, Deserialize, serde::Serialize)]
115#[serde(default)]
116pub struct Refeed {
117 pub keys: std::collections::BTreeMap<String, String>,
118}
119
120impl std::fmt::Debug for Refeed {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 f.debug_struct("Refeed")
123 .field("keys", &self.keys.keys().collect::<Vec<_>>())
124 .finish()
125 }
126}
127
128impl Refeed {
129 pub fn get(&self, key: &str) -> Option<&str> {
131 self.keys
132 .get(key)
133 .map(|v| v.trim())
134 .filter(|v| !v.is_empty())
135 }
136
137 pub fn public_site(&self) -> Option<bool> {
140 match self
141 .keys
142 .get(refeed_key::PUBLIC_SITE)
143 .map(|v| v.trim().to_ascii_lowercase())
144 .as_deref()
145 {
146 Some("on") => Some(true),
147 Some("off") => Some(false),
148 _ => None,
149 }
150 }
151
152 pub fn settings(&self) -> Vec<(&'static str, Vec<u8>)> {
155 self.public_site()
156 .map(|on| {
157 vec![(
158 PUBLIC_SITE_SETTING,
159 if on { b"on".to_vec() } else { b"off".to_vec() },
160 )]
161 })
162 .into_iter()
163 .flatten()
164 .collect()
165 }
166
167 pub fn kept_keys(&self) -> Refeed {
170 Refeed {
171 keys: self
172 .keys
173 .iter()
174 .filter(|(k, _)| !refeed_key::AS_SETTINGS.contains(&k.as_str()))
175 .map(|(k, v)| (k.clone(), v.clone()))
176 .collect(),
177 }
178 }
179}
180
181#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
184#[serde(default)]
185pub struct SignInAgain {
186 pub account: String,
188 pub to: String,
190 pub host: String,
192 pub ttl_secs: u64,
194}
195
196pub mod holger_kind {
199 pub const BECOME_TWIN: &str = "become_twin";
200 pub const DOWNSTREAM_ENROL: &str = "downstream_enrol";
205 pub const DOWNSTREAM_INVITE: &str = "downstream_invite";
207 pub const DOWNSTREAM_REVOKE: &str = "downstream_revoke";
209 pub const DOWNSTREAM_LIST: &str = "downstream_list";
211 pub const SYNC_BOARD: &str = "sync_board";
213 pub const SYNC_KEY: &str = "sync_key";
215 pub const ESTATE_BOARD: &str = "estate_board";
217 pub const BUNDLE_PUT: &str = "bundle_put";
220 pub const PRODUCT: [&str; 8] = [
222 DOWNSTREAM_ENROL,
223 DOWNSTREAM_INVITE,
224 DOWNSTREAM_REVOKE,
225 DOWNSTREAM_LIST,
226 SYNC_BOARD,
227 SYNC_KEY,
228 ESTATE_BOARD,
229 BUNDLE_PUT,
230 ];
231 pub const REFEED: &str = "refeed";
234}
235
236#[derive(Deserialize, Default)]
237#[serde(default)]
238struct PrimaryBody {
239 primary: String,
240}
241
242#[derive(Debug, Clone, PartialEq)]
244pub enum Verdict {
245 Forged(String),
247 Refused(String),
249 Run(Order),
250}
251
252#[derive(Deserialize)]
253struct RestateBody {
254 snapshot: Snapshot,
255}
256
257#[derive(Deserialize, Default)]
258#[serde(default)]
259struct FenceBody {
260 primary: String,
261 epoch: u64,
262}
263
264#[derive(Deserialize, Default)]
265#[serde(default)]
266struct AdoptBody {
267 twin: String,
268 principal: String,
269}
270
271pub fn accept(
275 o: &SignedOrder,
276 me: &Addressee,
277 key: &VerifyingKey,
278 last_seq: u64,
279 now_unix_ms: u64,
280) -> Verdict {
281 match monetize_estate_side::pump::check(o, me, key, last_seq, now_unix_ms) {
282 Checked::Forged(why) => Verdict::Forged(why),
283 Checked::Refused(why) => Verdict::Refused(why),
284 Checked::Fresh => match decode(o.seq, &o.kind, &o.body) {
285 Ok(order) => Verdict::Run(order),
286 Err(why) => Verdict::Refused(why),
287 },
288 }
289}
290
291pub fn decode(seq: u64, kind: &str, body: &str) -> Result<Order, String> {
293 let body = if body.trim().is_empty() { "{}" } else { body };
294 let bad =
295 |e: serde_json::Error| format!("order {seq} ({kind}) has a body that does not decode: {e}");
296 Ok(match kind {
297 pkind::ENTITLEMENT => Order::Entitlement(serde_json::from_str(body).map_err(bad)?),
298 kind::RESTATE_ENTITLEMENTS => Order::RestateEntitlements(serde_json::from_str::<RestateBody>(body).map_err(bad)?.snapshot),
299 pkind::PURGE_TENANT => {
300 let p: PurgeTenant = serde_json::from_str(body).map_err(bad)?;
301 if p.tenant.trim().is_empty() {
302 return Err(format!("order {seq} is a purge_tenant that names no repository"));
303 }
304 Order::PurgeTenant(p)
305 }
306 pkind::GROW_ROOM => {
307 let g: GrowRoom = serde_json::from_str(body).map_err(bad)?;
308 if g.need_bytes == 0 {
309 return Err(format!("order {seq} is a grow_room that wants nothing"));
310 }
311 Order::GrowRoom(g)
312 }
313 kind::GROW_GO_AHEAD => {
314 let g: GrowGoAhead = serde_json::from_str(body).map_err(bad)?;
315 if g.go_ahead.trim().is_empty() || g.data_gib == 0 {
316 return Err(format!("order {seq} is a grow_go_ahead without monetize's signed step or a size"));
317 }
318 Order::GrowGoAhead(g)
319 }
320 kind::RECHARGE_FROM_TWIN => {
321 let mut r: Recharge = serde_json::from_str(body).map_err(bad)?;
322 r.fence = r.fence.filter(|f| !f.trim().is_empty());
323 r.fenced = r.fenced.filter(|f| !f.trim().is_empty());
324 if r.main.trim().is_empty() || r.twin.trim().is_empty() {
325 return Err(format!("order {seq} is a recharge_from_twin that names no main or no twin"));
326 }
327 Order::RechargeFromTwin(r)
328 }
329 kind::FENCE_PRIMARY => {
330 let b: FenceBody = serde_json::from_str(body).map_err(bad)?;
331 Order::FencePrimary { primary: b.primary, epoch: b.epoch }
332 }
333 kind::ADOPT_TWIN => {
334 let b: AdoptBody = serde_json::from_str(body).map_err(bad)?;
335 if b.twin.trim().is_empty() {
336 return Err(format!("order {seq} is an adopt_twin that names no twin"));
337 }
338 Order::AdoptTwin { twin: b.twin, principal: b.principal }
339 }
340 holger_kind::BECOME_TWIN => {
341 let b: PrimaryBody = serde_json::from_str(body).map_err(bad)?;
342 if b.primary.trim().is_empty() {
343 return Err(format!("order {seq} is a {kind} that names no primary"));
344 }
345 Order::BecomeTwin { primary: b.primary }
346 }
347 kind::REVIEW_TWIN => Order::ReviewTwin,
348 pkind::ADD_PRINCIPAL => {
349 let a: AddPrincipal = serde_json::from_str(body).map_err(bad)?;
350 if a.username.trim().is_empty() || a.signup.trim().is_empty() {
351 return Err(format!("order {seq} is an add_principal that names no repository or no signup"));
352 }
353 Order::AddPrincipal(a)
354 }
355 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")),
356 kind::SIGN_IN_AGAIN => {
357 let b: SignInAgain = serde_json::from_str(body).map_err(bad)?;
358 if let Err(why) = crate::sign_in::check(&b.account, &b.to, &b.host) {
359 return Err(format!("order {seq} is a sign_in_again this box cannot mail: {why}"));
360 }
361 Order::SignInAgain(b)
362 }
363 holger_kind::REFEED => {
364 let r: Refeed = serde_json::from_str(body).map_err(|_| format!("order {seq} (refeed) has a body that does not decode as estate keys"))?;
366 if r.keys.is_empty() {
367 return Err(format!("order {seq} is a refeed that names no estate key"));
368 }
369 if let Some(k) = r.keys.keys().find(|k| !refeed_key::KNOWN.contains(&k.as_str())) {
370 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(", ")));
371 }
372 match (r.get(refeed_key::SMTP_URL), r.get(refeed_key::SMTP_FROM)) {
373 (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)),
374 (Some(_), Some(_)) => {}
375 (None, None) if r.get(refeed_key::SMTP_CA).is_none() => {}
376 _ => return Err(format!("order {seq} is a refeed with half a mail relay ({} and {} go together)", refeed_key::SMTP_URL, refeed_key::SMTP_FROM)),
377 }
378 if let Some(v) = r.keys.get(refeed_key::PUBLIC_SITE) {
380 if r.public_site().is_none() {
381 return Err(format!("order {seq} is a refeed of {} = {:?}, which is neither `on` nor `off`: nothing was kept", refeed_key::PUBLIC_SITE, v.trim()));
382 }
383 }
384 if let Some(list) = r.keys.get(refeed_key::PUBLIC_REPOSITORIES).filter(|l| !l.trim().is_empty()) {
386 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'.'))) {
387 return Err(format!("order {seq} is a refeed whose {} names {bad:?}: a repository is one plain word", refeed_key::PUBLIC_REPOSITORIES));
388 }
389 }
390 Order::Refeed(r)
391 }
392 k if holger_kind::PRODUCT.contains(&k) => {
393 let body: ProductOrder = serde_json::from_str(body).map_err(|_| format!("order {seq} ({k}) has a body that does not decode as a product order"))?;
395 if k == holger_kind::BUNDLE_PUT && body.fetch.is_none() {
396 return Err(format!("order {seq} is a {k} that carries no file"));
397 }
398 Order::Product { kind: k.to_string(), body }
399 }
400 other => return Err(format!("order {seq}: Holger does not know the order kind {other:?}")),
401 })
402}
403
404#[cfg(test)]
405pub(crate) mod tests {
406 use super::*;
407 use ed25519_dalek::Signer as _;
408 use monetize_embed::fleet::order_message;
409
410 pub const MONETIZE_SEED: [u8; 32] = [7u8; 32];
411
412 pub fn me(role: &str) -> Addressee {
413 Addressee {
414 product: "holger".into(),
415 instance: "acme".into(),
416 role: role.into(),
417 }
418 }
419
420 pub fn monetize_key() -> VerifyingKey {
421 ed25519_dalek::SigningKey::from_bytes(&MONETIZE_SEED).verifying_key()
422 }
423
424 pub fn signed(seq: u64, role: &str, kind: &str, body: &str, signer: [u8; 32]) -> SignedOrder {
425 let mut o = SignedOrder {
426 seq,
427 product: "holger".into(),
428 instance: "acme".into(),
429 role: role.into(),
430 kind: kind.into(),
431 body: body.into(),
432 issued_unix_ms: 9_000,
433 not_after_unix_ms: 0,
434 signature: Vec::new(),
435 };
436 o.signature = ed25519_dalek::SigningKey::from_bytes(&signer)
437 .sign(&order_message(&o))
438 .to_bytes()
439 .to_vec();
440 o
441 }
442
443 #[test]
444 fn a_forged_order_is_forged_whatever_it_claims() {
445 let o = signed(9, "main", "review_twin", "{}", [9u8; 32]);
446 assert!(matches!(
447 accept(&o, &me("main"), &monetize_key(), 0, 10_000),
448 Verdict::Forged(_)
449 ));
450 }
451
452 #[test]
453 fn an_order_for_the_twin_a_replay_and_an_expired_one_are_refused_by_name() {
454 let k = monetize_key();
455 let twin = signed(2, "twin", "review_twin", "{}", MONETIZE_SEED);
456 assert!(
457 matches!(accept(&twin, &me("main"), &k, 0, 10_000), Verdict::Refused(w) if w.contains("twin"))
458 );
459 let old = signed(2, "main", "review_twin", "{}", MONETIZE_SEED);
460 assert!(
461 matches!(accept(&old, &me("main"), &k, 2, 10_000), Verdict::Refused(w) if w.contains("replay"))
462 );
463 let mut late = signed(3, "main", "review_twin", "{}", MONETIZE_SEED);
464 late.not_after_unix_ms = 5_000;
465 late.signature = ed25519_dalek::SigningKey::from_bytes(&MONETIZE_SEED)
466 .sign(&order_message(&late))
467 .to_bytes()
468 .to_vec();
469 assert!(
470 matches!(accept(&late, &me("main"), &k, 0, 10_000), Verdict::Refused(w) if w.contains("expired"))
471 );
472 }
473
474 #[test]
478 fn a_refeed_carries_the_public_site_switch_and_a_switch_that_is_neither_on_nor_off_is_refused()
479 {
480 for (body, want) in [
481 (r#"{"keys":{"console_public_site":"on"}}"#, true),
482 (r#"{"keys":{"console_public_site":" OFF "}}"#, false),
483 (
484 r#"{"keys":{"console_public_site":"on","public_repositories":"crates"}}"#,
485 true,
486 ),
487 ] {
488 let o = signed(7, "main", "refeed", body, MONETIZE_SEED);
489 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
490 Verdict::Run(Order::Refeed(r)) => {
491 assert_eq!(r.public_site(), Some(want), "{body}");
492 assert_eq!(
493 r.settings(),
494 vec![(
495 PUBLIC_SITE_SETTING,
496 if want {
497 b"on".to_vec()
498 } else {
499 b"off".to_vec()
500 }
501 )],
502 "{body}: the switch is the console's generic setting"
503 );
504 assert!(
505 !r.kept_keys().keys.contains_key(refeed_key::PUBLIC_SITE),
506 "{body}: the switch is no key kept beside the feed"
507 );
508 }
509 other => panic!("{body}: {other:?}"),
510 }
511 }
512 let o = signed(
513 8,
514 "main",
515 "refeed",
516 r#"{"keys":{"console_public_site":"maybe"}}"#,
517 MONETIZE_SEED,
518 );
519 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
520 Verdict::Refused(w) => assert!(
521 w.contains("refeed") && w.contains("console_public_site") && w.contains("maybe"),
522 "{w}"
523 ),
524 other => panic!("{other:?}"),
525 }
526 }
527
528 #[test]
533 fn a_refeed_is_run_with_the_keys_it_carries_and_a_key_holger_does_not_take_is_refused_by_name()
534 {
535 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"}}"#;
536 let o = signed(4, "main", "refeed", body, MONETIZE_SEED);
537 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
538 Verdict::Run(Order::Refeed(r)) => {
539 assert_eq!(
540 (
541 r.get(refeed_key::SMTP_FROM),
542 r.get(refeed_key::PUBLIC_REPOSITORIES)
543 ),
544 (Some("Holger <holger@mock.invalid>"), Some("crates,npm"))
545 );
546 assert!(!format!("{r:?}").contains("t0kenVALUE"), "{r:?}");
547 }
548 other => panic!("{other:?}"),
549 }
550 let o = signed(
551 5,
552 "main",
553 "refeed",
554 r#"{"keys":{"public_repositories":"crates"}}"#,
555 MONETIZE_SEED,
556 );
557 assert!(
558 matches!(
559 accept(&o, &me("main"), &monetize_key(), 0, 10_000),
560 Verdict::Run(Order::Refeed(_))
561 ),
562 "the public list alone"
563 );
564 let o = signed(
566 5,
567 "main",
568 "refeed",
569 r#"{"keys":{"public_repositories":""}}"#,
570 MONETIZE_SEED,
571 );
572 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
573 Verdict::Run(Order::Refeed(r)) => assert_eq!(
574 r.keys
575 .get(refeed_key::PUBLIC_REPOSITORIES)
576 .map(String::as_str),
577 Some(""),
578 "none public"
579 ),
580 other => panic!("a cleared public list is refused: {other:?}"),
581 }
582 for (bad, word) in [
583 (r#"{"keys":{}}"#, "names no estate key"),
584 (
585 r#"{"keys":{"console_admin_token":"t0kenVALUE"}}"#,
586 "console_admin_token",
587 ),
588 (
589 r#"{"keys":{"secret.smtp_url":"smtps://u:t0kenVALUE@r.mock.invalid"}}"#,
590 "half a mail relay",
591 ),
592 (
593 r#"{"keys":{"secret.smtp_url":"http://u:t0kenVALUE@r.mock.invalid","secret.smtp_from":"h@mock.invalid"}}"#,
594 "not smtp://",
595 ),
596 (
597 r#"{"keys":{"public_repositories":"crates,../x"}}"#,
598 "one plain word",
599 ),
600 (r#"{"keys":7}"#, "does not decode"),
601 ] {
602 let o = signed(6, "main", "refeed", bad, MONETIZE_SEED);
603 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
604 Verdict::Refused(w) => assert!(
605 w.contains("refeed") && w.contains(word) && !w.contains("t0kenVALUE"),
606 "{bad}: {w}"
607 ),
608 other => panic!("{bad}: {other:?}"),
609 }
610 }
611 }
612
613 #[test]
614 fn holgers_missing_concepts_are_refused_by_name() {
615 let o = signed(
616 1,
617 "main",
618 "add_principal",
619 r#"{"username":"crates","kind":"org","signup":"s-1"}"#,
620 MONETIZE_SEED,
621 );
622 assert!(
623 matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Run(Order::AddPrincipal(a)) if a.username == "crates" && a.signup == "s-1"),
624 "a signup is a repository on a plan"
625 );
626 let o = signed(
627 2,
628 "main",
629 "add_principal",
630 r#"{"username":"","signup":"s-1"}"#,
631 MONETIZE_SEED,
632 );
633 assert!(
634 matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Refused(w) if w.contains("names no repository"))
635 );
636 for (kind, word) in [("add_users", "add_users"), ("make_coffee", "make_coffee")] {
637 let o = signed(
638 1,
639 "main",
640 kind,
641 r#"{"username":"a","signup":"s"}"#,
642 MONETIZE_SEED,
643 );
644 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
645 Verdict::Refused(w) => assert!(w.contains(word), "{w}"),
646 other => panic!("{kind}: {other:?}"),
647 }
648 }
649 }
650
651 #[test]
655 fn a_sign_in_again_is_run_and_a_body_a_link_cannot_carry_is_refused() {
656 let body = r#"{"account":"acme","to":"buyer@mock.invalid","host":"acme.holger.mock.invalid","ttl_secs":86400}"#;
657 let o = signed(5, "main", "sign_in_again", body, MONETIZE_SEED);
658 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
659 Verdict::Run(Order::SignInAgain(s)) => assert_eq!(
660 (
661 s.account.as_str(),
662 s.to.as_str(),
663 s.host.as_str(),
664 s.ttl_secs
665 ),
666 (
667 "acme",
668 "buyer@mock.invalid",
669 "acme.holger.mock.invalid",
670 86_400
671 )
672 ),
673 other => panic!("{other:?}"),
674 }
675 for bad in [
676 r#"{"account":"a&user=x","to":"b@mock.invalid","host":"h.mock.invalid"}"#,
677 r#"{"account":"a","to":"b@mock.invalid\r\nBcc: x@mock.invalid","host":"h.mock.invalid"}"#,
678 r#"{"account":"a","to":"b@mock.invalid","host":""}"#,
679 ] {
680 let o = signed(6, "main", "sign_in_again", bad, MONETIZE_SEED);
681 assert!(
682 matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Refused(w) if w.contains("sign_in_again")),
683 "{bad}"
684 );
685 }
686 }
687}