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, 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}
75
76pub 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 PUBLIC_SITE: &str = "console_public_site";
88 pub const KNOWN: [&str; 5] = [
89 SMTP_URL,
90 SMTP_FROM,
91 SMTP_CA,
92 PUBLIC_REPOSITORIES,
93 PUBLIC_SITE,
94 ];
95 pub const AS_SETTINGS: [&str; 1] = [PUBLIC_SITE];
98}
99
100pub const PUBLIC_SITE_SETTING: &str = "console.public-site";
105
106#[derive(Clone, PartialEq, Eq, Default, Deserialize, serde::Serialize)]
109#[serde(default)]
110pub struct Refeed {
111 pub keys: std::collections::BTreeMap<String, String>,
112}
113
114impl std::fmt::Debug for Refeed {
115 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116 f.debug_struct("Refeed")
117 .field("keys", &self.keys.keys().collect::<Vec<_>>())
118 .finish()
119 }
120}
121
122impl Refeed {
123 pub fn get(&self, key: &str) -> Option<&str> {
125 self.keys
126 .get(key)
127 .map(|v| v.trim())
128 .filter(|v| !v.is_empty())
129 }
130
131 pub fn public_site(&self) -> Option<bool> {
134 match self
135 .keys
136 .get(refeed_key::PUBLIC_SITE)
137 .map(|v| v.trim().to_ascii_lowercase())
138 .as_deref()
139 {
140 Some("on") => Some(true),
141 Some("off") => Some(false),
142 _ => None,
143 }
144 }
145
146 pub fn settings(&self) -> Vec<(&'static str, Vec<u8>)> {
149 self.public_site()
150 .map(|on| {
151 vec![(
152 PUBLIC_SITE_SETTING,
153 if on { b"on".to_vec() } else { b"off".to_vec() },
154 )]
155 })
156 .into_iter()
157 .flatten()
158 .collect()
159 }
160
161 pub fn kept_keys(&self) -> Refeed {
164 Refeed {
165 keys: self
166 .keys
167 .iter()
168 .filter(|(k, _)| !refeed_key::AS_SETTINGS.contains(&k.as_str()))
169 .map(|(k, v)| (k.clone(), v.clone()))
170 .collect(),
171 }
172 }
173}
174
175#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize)]
178#[serde(default)]
179pub struct SignInAgain {
180 pub account: String,
182 pub to: String,
184 pub host: String,
186 pub ttl_secs: u64,
188}
189
190pub mod holger_kind {
193 pub const BECOME_TWIN: &str = "become_twin";
194 pub const REFEED: &str = "refeed";
197}
198
199#[derive(Deserialize, Default)]
200#[serde(default)]
201struct PrimaryBody {
202 primary: String,
203}
204
205#[derive(Debug, Clone, PartialEq)]
207pub enum Verdict {
208 Forged(String),
210 Refused(String),
212 Run(Order),
213}
214
215#[derive(Deserialize)]
216struct RestateBody {
217 snapshot: Snapshot,
218}
219
220#[derive(Deserialize, Default)]
221#[serde(default)]
222struct FenceBody {
223 primary: String,
224 epoch: u64,
225}
226
227#[derive(Deserialize, Default)]
228#[serde(default)]
229struct AdoptBody {
230 twin: String,
231 principal: String,
232}
233
234pub fn accept(
238 o: &SignedOrder,
239 me: &Addressee,
240 key: &VerifyingKey,
241 last_seq: u64,
242 now_unix_ms: u64,
243) -> Verdict {
244 match monetize_estate_side::pump::check(o, me, key, last_seq, now_unix_ms) {
245 Checked::Forged(why) => Verdict::Forged(why),
246 Checked::Refused(why) => Verdict::Refused(why),
247 Checked::Fresh => match decode(o.seq, &o.kind, &o.body) {
248 Ok(order) => Verdict::Run(order),
249 Err(why) => Verdict::Refused(why),
250 },
251 }
252}
253
254pub fn decode(seq: u64, kind: &str, body: &str) -> Result<Order, String> {
256 let body = if body.trim().is_empty() { "{}" } else { body };
257 let bad =
258 |e: serde_json::Error| format!("order {seq} ({kind}) has a body that does not decode: {e}");
259 Ok(match kind {
260 pkind::ENTITLEMENT => Order::Entitlement(serde_json::from_str(body).map_err(bad)?),
261 kind::RESTATE_ENTITLEMENTS => Order::RestateEntitlements(serde_json::from_str::<RestateBody>(body).map_err(bad)?.snapshot),
262 pkind::PURGE_TENANT => {
263 let p: PurgeTenant = serde_json::from_str(body).map_err(bad)?;
264 if p.tenant.trim().is_empty() {
265 return Err(format!("order {seq} is a purge_tenant that names no repository"));
266 }
267 Order::PurgeTenant(p)
268 }
269 pkind::GROW_ROOM => {
270 let g: GrowRoom = serde_json::from_str(body).map_err(bad)?;
271 if g.need_bytes == 0 {
272 return Err(format!("order {seq} is a grow_room that wants nothing"));
273 }
274 Order::GrowRoom(g)
275 }
276 kind::GROW_GO_AHEAD => {
277 let g: GrowGoAhead = serde_json::from_str(body).map_err(bad)?;
278 if g.go_ahead.trim().is_empty() || g.data_gib == 0 {
279 return Err(format!("order {seq} is a grow_go_ahead without monetize's signed step or a size"));
280 }
281 Order::GrowGoAhead(g)
282 }
283 kind::RECHARGE_FROM_TWIN => {
284 let mut r: Recharge = serde_json::from_str(body).map_err(bad)?;
285 r.fence = r.fence.filter(|f| !f.trim().is_empty());
286 r.fenced = r.fenced.filter(|f| !f.trim().is_empty());
287 if r.main.trim().is_empty() || r.twin.trim().is_empty() {
288 return Err(format!("order {seq} is a recharge_from_twin that names no main or no twin"));
289 }
290 Order::RechargeFromTwin(r)
291 }
292 kind::FENCE_PRIMARY => {
293 let b: FenceBody = serde_json::from_str(body).map_err(bad)?;
294 Order::FencePrimary { primary: b.primary, epoch: b.epoch }
295 }
296 kind::ADOPT_TWIN => {
297 let b: AdoptBody = serde_json::from_str(body).map_err(bad)?;
298 if b.twin.trim().is_empty() {
299 return Err(format!("order {seq} is an adopt_twin that names no twin"));
300 }
301 Order::AdoptTwin { twin: b.twin, principal: b.principal }
302 }
303 holger_kind::BECOME_TWIN => {
304 let b: PrimaryBody = serde_json::from_str(body).map_err(bad)?;
305 if b.primary.trim().is_empty() {
306 return Err(format!("order {seq} is a {kind} that names no primary"));
307 }
308 Order::BecomeTwin { primary: b.primary }
309 }
310 kind::REVIEW_TWIN => Order::ReviewTwin,
311 pkind::ADD_PRINCIPAL => {
312 let a: AddPrincipal = serde_json::from_str(body).map_err(bad)?;
313 if a.username.trim().is_empty() || a.signup.trim().is_empty() {
314 return Err(format!("order {seq} is an add_principal that names no repository or no signup"));
315 }
316 Order::AddPrincipal(a)
317 }
318 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")),
319 kind::SIGN_IN_AGAIN => {
320 let b: SignInAgain = serde_json::from_str(body).map_err(bad)?;
321 if let Err(why) = crate::sign_in::check(&b.account, &b.to, &b.host) {
322 return Err(format!("order {seq} is a sign_in_again this box cannot mail: {why}"));
323 }
324 Order::SignInAgain(b)
325 }
326 holger_kind::REFEED => {
327 let r: Refeed = serde_json::from_str(body).map_err(|_| format!("order {seq} (refeed) has a body that does not decode as estate keys"))?;
329 if r.keys.is_empty() {
330 return Err(format!("order {seq} is a refeed that names no estate key"));
331 }
332 if let Some(k) = r.keys.keys().find(|k| !refeed_key::KNOWN.contains(&k.as_str())) {
333 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(", ")));
334 }
335 match (r.get(refeed_key::SMTP_URL), r.get(refeed_key::SMTP_FROM)) {
336 (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)),
337 (Some(_), Some(_)) => {}
338 (None, None) if r.get(refeed_key::SMTP_CA).is_none() => {}
339 _ => return Err(format!("order {seq} is a refeed with half a mail relay ({} and {} go together)", refeed_key::SMTP_URL, refeed_key::SMTP_FROM)),
340 }
341 if let Some(v) = r.keys.get(refeed_key::PUBLIC_SITE) {
343 if r.public_site().is_none() {
344 return Err(format!("order {seq} is a refeed of {} = {:?}, which is neither `on` nor `off`: nothing was kept", refeed_key::PUBLIC_SITE, v.trim()));
345 }
346 }
347 if let Some(list) = r.keys.get(refeed_key::PUBLIC_REPOSITORIES).filter(|l| !l.trim().is_empty()) {
349 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'.'))) {
350 return Err(format!("order {seq} is a refeed whose {} names {bad:?}: a repository is one plain word", refeed_key::PUBLIC_REPOSITORIES));
351 }
352 }
353 Order::Refeed(r)
354 }
355 other => return Err(format!("order {seq}: Holger does not know the order kind {other:?}")),
356 })
357}
358
359#[cfg(test)]
360pub(crate) mod tests {
361 use super::*;
362 use ed25519_dalek::Signer as _;
363 use monetize_embed::fleet::order_message;
364
365 pub const MONETIZE_SEED: [u8; 32] = [7u8; 32];
366
367 pub fn me(role: &str) -> Addressee {
368 Addressee {
369 product: "holger".into(),
370 instance: "acme".into(),
371 role: role.into(),
372 }
373 }
374
375 pub fn monetize_key() -> VerifyingKey {
376 ed25519_dalek::SigningKey::from_bytes(&MONETIZE_SEED).verifying_key()
377 }
378
379 pub fn signed(seq: u64, role: &str, kind: &str, body: &str, signer: [u8; 32]) -> SignedOrder {
380 let mut o = SignedOrder {
381 seq,
382 product: "holger".into(),
383 instance: "acme".into(),
384 role: role.into(),
385 kind: kind.into(),
386 body: body.into(),
387 issued_unix_ms: 9_000,
388 not_after_unix_ms: 0,
389 signature: Vec::new(),
390 };
391 o.signature = ed25519_dalek::SigningKey::from_bytes(&signer)
392 .sign(&order_message(&o))
393 .to_bytes()
394 .to_vec();
395 o
396 }
397
398 #[test]
399 fn a_forged_order_is_forged_whatever_it_claims() {
400 let o = signed(9, "main", "review_twin", "{}", [9u8; 32]);
401 assert!(matches!(
402 accept(&o, &me("main"), &monetize_key(), 0, 10_000),
403 Verdict::Forged(_)
404 ));
405 }
406
407 #[test]
408 fn an_order_for_the_twin_a_replay_and_an_expired_one_are_refused_by_name() {
409 let k = monetize_key();
410 let twin = signed(2, "twin", "review_twin", "{}", MONETIZE_SEED);
411 assert!(
412 matches!(accept(&twin, &me("main"), &k, 0, 10_000), Verdict::Refused(w) if w.contains("twin"))
413 );
414 let old = signed(2, "main", "review_twin", "{}", MONETIZE_SEED);
415 assert!(
416 matches!(accept(&old, &me("main"), &k, 2, 10_000), Verdict::Refused(w) if w.contains("replay"))
417 );
418 let mut late = signed(3, "main", "review_twin", "{}", MONETIZE_SEED);
419 late.not_after_unix_ms = 5_000;
420 late.signature = ed25519_dalek::SigningKey::from_bytes(&MONETIZE_SEED)
421 .sign(&order_message(&late))
422 .to_bytes()
423 .to_vec();
424 assert!(
425 matches!(accept(&late, &me("main"), &k, 0, 10_000), Verdict::Refused(w) if w.contains("expired"))
426 );
427 }
428
429 #[test]
433 fn a_refeed_carries_the_public_site_switch_and_a_switch_that_is_neither_on_nor_off_is_refused()
434 {
435 for (body, want) in [
436 (r#"{"keys":{"console_public_site":"on"}}"#, true),
437 (r#"{"keys":{"console_public_site":" OFF "}}"#, false),
438 (
439 r#"{"keys":{"console_public_site":"on","public_repositories":"crates"}}"#,
440 true,
441 ),
442 ] {
443 let o = signed(7, "main", "refeed", body, MONETIZE_SEED);
444 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
445 Verdict::Run(Order::Refeed(r)) => {
446 assert_eq!(r.public_site(), Some(want), "{body}");
447 assert_eq!(
448 r.settings(),
449 vec![(
450 PUBLIC_SITE_SETTING,
451 if want {
452 b"on".to_vec()
453 } else {
454 b"off".to_vec()
455 }
456 )],
457 "{body}: the switch is the console's generic setting"
458 );
459 assert!(
460 !r.kept_keys().keys.contains_key(refeed_key::PUBLIC_SITE),
461 "{body}: the switch is no key kept beside the feed"
462 );
463 }
464 other => panic!("{body}: {other:?}"),
465 }
466 }
467 let o = signed(
468 8,
469 "main",
470 "refeed",
471 r#"{"keys":{"console_public_site":"maybe"}}"#,
472 MONETIZE_SEED,
473 );
474 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
475 Verdict::Refused(w) => assert!(
476 w.contains("refeed") && w.contains("console_public_site") && w.contains("maybe"),
477 "{w}"
478 ),
479 other => panic!("{other:?}"),
480 }
481 }
482
483 #[test]
488 fn a_refeed_is_run_with_the_keys_it_carries_and_a_key_holger_does_not_take_is_refused_by_name()
489 {
490 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"}}"#;
491 let o = signed(4, "main", "refeed", body, MONETIZE_SEED);
492 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
493 Verdict::Run(Order::Refeed(r)) => {
494 assert_eq!(
495 (
496 r.get(refeed_key::SMTP_FROM),
497 r.get(refeed_key::PUBLIC_REPOSITORIES)
498 ),
499 (Some("Holger <holger@mock.invalid>"), Some("crates,npm"))
500 );
501 assert!(!format!("{r:?}").contains("t0kenVALUE"), "{r:?}");
502 }
503 other => panic!("{other:?}"),
504 }
505 let o = signed(
506 5,
507 "main",
508 "refeed",
509 r#"{"keys":{"public_repositories":"crates"}}"#,
510 MONETIZE_SEED,
511 );
512 assert!(
513 matches!(
514 accept(&o, &me("main"), &monetize_key(), 0, 10_000),
515 Verdict::Run(Order::Refeed(_))
516 ),
517 "the public list alone"
518 );
519 let o = signed(
521 5,
522 "main",
523 "refeed",
524 r#"{"keys":{"public_repositories":""}}"#,
525 MONETIZE_SEED,
526 );
527 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
528 Verdict::Run(Order::Refeed(r)) => assert_eq!(
529 r.keys
530 .get(refeed_key::PUBLIC_REPOSITORIES)
531 .map(String::as_str),
532 Some(""),
533 "none public"
534 ),
535 other => panic!("a cleared public list is refused: {other:?}"),
536 }
537 for (bad, word) in [
538 (r#"{"keys":{}}"#, "names no estate key"),
539 (
540 r#"{"keys":{"console_admin_token":"t0kenVALUE"}}"#,
541 "console_admin_token",
542 ),
543 (
544 r#"{"keys":{"secret.smtp_url":"smtps://u:t0kenVALUE@r.mock.invalid"}}"#,
545 "half a mail relay",
546 ),
547 (
548 r#"{"keys":{"secret.smtp_url":"http://u:t0kenVALUE@r.mock.invalid","secret.smtp_from":"h@mock.invalid"}}"#,
549 "not smtp://",
550 ),
551 (
552 r#"{"keys":{"public_repositories":"crates,../x"}}"#,
553 "one plain word",
554 ),
555 (r#"{"keys":7}"#, "does not decode"),
556 ] {
557 let o = signed(6, "main", "refeed", bad, MONETIZE_SEED);
558 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
559 Verdict::Refused(w) => assert!(
560 w.contains("refeed") && w.contains(word) && !w.contains("t0kenVALUE"),
561 "{bad}: {w}"
562 ),
563 other => panic!("{bad}: {other:?}"),
564 }
565 }
566 }
567
568 #[test]
569 fn holgers_missing_concepts_are_refused_by_name() {
570 let o = signed(
571 1,
572 "main",
573 "add_principal",
574 r#"{"username":"crates","kind":"org","signup":"s-1"}"#,
575 MONETIZE_SEED,
576 );
577 assert!(
578 matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Run(Order::AddPrincipal(a)) if a.username == "crates" && a.signup == "s-1"),
579 "a signup is a repository on a plan"
580 );
581 let o = signed(
582 2,
583 "main",
584 "add_principal",
585 r#"{"username":"","signup":"s-1"}"#,
586 MONETIZE_SEED,
587 );
588 assert!(
589 matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Refused(w) if w.contains("names no repository"))
590 );
591 for (kind, word) in [("add_users", "add_users"), ("make_coffee", "make_coffee")] {
592 let o = signed(
593 1,
594 "main",
595 kind,
596 r#"{"username":"a","signup":"s"}"#,
597 MONETIZE_SEED,
598 );
599 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
600 Verdict::Refused(w) => assert!(w.contains(word), "{w}"),
601 other => panic!("{kind}: {other:?}"),
602 }
603 }
604 }
605
606 #[test]
610 fn a_sign_in_again_is_run_and_a_body_a_link_cannot_carry_is_refused() {
611 let body = r#"{"account":"acme","to":"buyer@mock.invalid","host":"acme.holger.mock.invalid","ttl_secs":86400}"#;
612 let o = signed(5, "main", "sign_in_again", body, MONETIZE_SEED);
613 match accept(&o, &me("main"), &monetize_key(), 0, 10_000) {
614 Verdict::Run(Order::SignInAgain(s)) => assert_eq!(
615 (
616 s.account.as_str(),
617 s.to.as_str(),
618 s.host.as_str(),
619 s.ttl_secs
620 ),
621 (
622 "acme",
623 "buyer@mock.invalid",
624 "acme.holger.mock.invalid",
625 86_400
626 )
627 ),
628 other => panic!("{other:?}"),
629 }
630 for bad in [
631 r#"{"account":"a&user=x","to":"b@mock.invalid","host":"h.mock.invalid"}"#,
632 r#"{"account":"a","to":"b@mock.invalid\r\nBcc: x@mock.invalid","host":"h.mock.invalid"}"#,
633 r#"{"account":"a","to":"b@mock.invalid","host":""}"#,
634 ] {
635 let o = signed(6, "main", "sign_in_again", bad, MONETIZE_SEED);
636 assert!(
637 matches!(accept(&o, &me("main"), &monetize_key(), 0, 10_000), Verdict::Refused(w) if w.contains("sign_in_again")),
638 "{bad}"
639 );
640 }
641 }
642}