Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/holger

server/lib/src/sign_in_again.rs

//! **A sold Holger's resend sign-in link** (A16, Gunnar's S10: gunnar-server `postman` and
//! `repair::sign_in_again`, moved as they are). A buyer who lost the one link the ready mail
//! carried asks the shop again; monetize queues a signed `sign_in_again` order for this
//! estate's main, never holding the link's secret. This main mints a fresh first-login invite
//! for the buyer (only while they hold no passkey) and mails the link itself, through the relay
//! the shop fed the estate's main and twin (`secret.smtp_url` / `secret.smtp_from`, monetize
//! `estate_order::relay_feed`). Nothing fed is the ordinary state: such a main refuses a resend
//! by name BEFORE it mints anything.
//!
//! A box laid behind its front (the lean estate, A19 row 13: no public IPv4) mails through the
//! front's egress door it was fed (`crate::egress::front_door`): lettre speaks no proxy, so it
//! dials a loopback forward (Skidbladnir `nordisk_pairing::egress::Forward`) carried through
//! the door, with the relay's own TLS name. The sender is the ONE shared one, monetize's
//! `monetize-mail` (P4c, A28 M11); Gunnar's postman joins it in T16.

use std::collections::BTreeMap;
use std::sync::{Arc, RwLock};

use holger_fleet::order::SignInAgain;
use holger_fleet::sign_in::SignInMail;

use crate::lock::RwLockOrRecover as _;

/// The feed keys the shop names the sold estate's relay under (monetize `estate_order::relay_feed`):
/// the shared sender's own.
pub use monetize_mail::{FEED_SMTP_FROM, FEED_SMTP_URL};

/// **How this box mails**: one plain-text mail to one address.
pub trait Postman: Send + Sync {
    fn post(&self, mail: &SignInMail) -> Result<(), String>;
    /// **Can it mail now?** Asked BEFORE anything is minted: `Err` says why in words.
    fn ready(&self) -> Result<(), String> {
        Ok(())
    }
}

/// **A configured relay**: the ONE shared sender, monetize's [`monetize_mail::Mailer`] (P4c, A28
/// M11): TLS always (STARTTLS required on 587, implicit on 465), proper MIME so the link stays
/// one line, the sender's own EHLO name, 30 s. Its `Debug` withholds the password.
#[derive(Debug)]
pub struct Relay(monetize_mail::Mailer);

impl Relay {
    /// A relay from its URL (`smtp://` or `smtps://`, `user:token@host[:port]`) and the address
    /// mail is sent AS.
    pub fn from_url(url: &str, from: &str) -> Result<Relay, String> {
        monetize_mail::Mailer::new(url, from).map(Relay)
    }

    /// The relay's host, port and whether it speaks TLS from the first byte.
    pub fn endpoint(&self) -> (&str, u16, bool) {
        let r = self.0.relay();
        (
            &r.host,
            r.port,
            r.security == monetize_mail::Security::ImplicitTls,
        )
    }
}

impl Postman for Relay {
    /// One mail. Holger's offline gate first; a box behind its front (the lean estate, A19 row
    /// 13) leaves through the front's egress door it was fed (`crate::egress::front_door`).
    fn post(&self, mail: &SignInMail) -> Result<(), String> {
        crate::egress::check("a resend sign-in link's mail", &self.0.relay().host)
            .map_err(|e| e.to_string())?;
        let door = crate::egress::front_door();
        self.0
            .notice_via(
                door.as_deref(),
                &mail.to,
                "sign-in link",
                &mail.subject,
                &mail.body,
            )
            .map(|_| ())
    }
}

/// **The relay the installer FED this box**, read at each mail from the feed in force, so a
/// feed that arrives (or changes) after the start is the one used. Nothing fed: mail is off, by
/// name, and nothing is dialled (`monetize_mail::Mailer::from_feed`).
pub struct FedRelay<F: Fn() -> BTreeMap<String, String> + Send + Sync> {
    pub fed: F,
}

impl<F: Fn() -> BTreeMap<String, String> + Send + Sync> FedRelay<F> {
    fn relay(&self) -> Result<Relay, String> {
        let fed = (self.fed)();
        // A relay on a private CA (the bench's) names its root (`secret.smtp_ca`, MAIL-RELAY).
        let get = |k: &str| fed.get(k).map(|v| v.trim()).filter(|v| !v.is_empty());
        if let (Some(url), Some(from), Some(ca)) = (
            get(FEED_SMTP_URL),
            get(FEED_SMTP_FROM),
            get(crate::refeed::FEED_SMTP_CA),
        ) {
            return monetize_mail::Mailer::with_root(url, from, Some(ca.as_bytes())).map(Relay);
        }
        monetize_mail::Mailer::from_feed(&fed).map(Relay)
    }
}

impl<F: Fn() -> BTreeMap<String, String> + Send + Sync> Postman for FedRelay<F> {
    fn post(&self, mail: &SignInMail) -> Result<(), String> {
        self.relay()?.post(mail)
    }
    fn ready(&self) -> Result<(), String> {
        self.relay().map(|_| ())
    }
}

/// The postman this process mails with (the fed relay once the pull channel starts; a test's
/// own). `None`: this box mails nothing.
static POSTMAN: RwLock<Option<Arc<dyn Postman>>> = RwLock::new(None);

/// Mail through `postman` from now on.
pub fn set_postman(postman: Arc<dyn Postman>) {
    *POSTMAN.write_or_recover() = Some(postman);
}

/// The postman in force.
pub fn postman() -> Option<Arc<dyn Postman>> {
    POSTMAN.read_or_recover().clone()
}

/// **Run one `sign_in_again`** (gunnar-server `repair::sign_in_again`): the order's words
/// checked, the relay ready BEFORE anything is minted, a fresh invite for the buyer, the mail
/// sent — and an invite whose mail did not go withdrawn. Answers the ack's words.
pub fn resend(
    store: &crate::passkey::PasskeyDoor,
    auth: &crate::auth::AuthConfig,
    s: &SignInAgain,
    postman: Option<&dyn Postman>,
) -> Result<String, String> {
    let account = s.account.as_str();
    holger_fleet::sign_in::check(account, &s.to, &s.host)
        .map_err(|why| format!("sign_in_again: {why}"))?;
    let postman = postman.ok_or_else(|| format!("this box has no mail relay ({FEED_SMTP_URL}), so it mints no sign-in link for {account} it could not mail"))?;
    postman.ready().map_err(|why| format!("this box cannot mail ({why}), so it mints no sign-in link for {account} it could not mail"))?;
    let ttl = holger_fleet::sign_in::ttl(s.ttl_secs);
    let (secret, id) = store
        .mint_sign_in_again(account, ttl, auth)
        .map_err(|(_, why)| why)?;
    let mail = holger_fleet::sign_in::mail(&s.host, account, &s.to, &secret, ttl);
    drop(secret);
    if let Err(why) = postman.post(&mail) {
        if let Err((_, e)) = store.withdraw_invite(&id) {
            log::warn!("sign_in_again {account}: the unmailed invite could not be withdrawn: {e}");
        }
        return Err(format!("the sign-in link for {account} could not be mailed to {} ({why}); the invite is withdrawn — ask again", s.to));
    }
    log::warn!("sign_in_again: a new sign-in link for {account} was minted here and mailed to the order's address ({} s)", ttl);
    Ok(format!(
        "a new sign-in link for {account} ({}) was mailed to {}",
        holger_fleet::sign_in::ttl_words(ttl),
        s.to
    ))
}

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

    #[derive(Default)]
    struct Kept(Mutex<Vec<SignInMail>>, bool);
    impl Postman for Kept {
        fn post(&self, mail: &SignInMail) -> Result<(), String> {
            if self.1 {
                return Err("the relay said no".into());
            }
            self.0.lock().unwrap().push(mail.clone());
            Ok(())
        }
    }

    fn door_at(
        dir: &std::path::Path,
        host: &str,
        auth: &crate::auth::AuthConfig,
    ) -> crate::passkey::SharedPasskeyDoor {
        let mut auth = auth.clone();
        auth.methods.push(crate::auth::AuthMethodConfig::Passkey {
            rp_id: host.into(),
            rp_origin: format!("https://{host}"),
            rp_name: "holger".into(),
            session_key_file: None,
            bootstrap_until_first_admin: true,
        });
        crate::passkey::PasskeyDoor::open(dir, &auth, Arc::new(crate::audit::MemoryAuditLog::new()))
            .unwrap()
            .unwrap()
    }

    fn estate() -> (
        crate::passkey::SharedPasskeyDoor,
        tempfile::TempDir,
        crate::auth::AuthConfig,
    ) {
        let dir = tempfile::tempdir().unwrap();
        let mut auth = crate::auth::AuthConfig {
            methods: vec![crate::auth::AuthMethodConfig::StaticToken {
                subject: "console".into(),
                token: Some("c".repeat(40)),
                token_env: None,
                token_file: None,
            }],
            roles: std::collections::HashMap::from([(
                "console".to_string(),
                crate::auth::Role::Admin,
            )]),
            group_roles: std::collections::HashMap::from([
                ("holger-admins".to_string(), crate::auth::Role::Admin),
                ("holger-writers".to_string(), crate::auth::Role::Writer),
            ]),
            ..Default::default()
        };
        assert!(!crate::auth::estate_duties(&mut auth).is_empty());
        let s = door_at(dir.path(), "acme.holger.mock.invalid", &auth);
        let fed = crate::passkey::FirstAdminInvite {
            sha256_hex: "ab".repeat(32),
            ttl_secs: 3600,
            display: "acme".into(),
        };
        s.seed_first_admin(&fed, &auth).unwrap();
        (s, dir, auth)
    }

    fn order() -> SignInAgain {
        SignInAgain {
            account: "acme".into(),
            to: "buyer@mock.invalid".into(),
            host: "acme.holger.mock.invalid".into(),
            ttl_secs: 86_400,
        }
    }

    /// ★ **The buyer's new link is minted here and mailed here, once**, the first link's shape.
    #[test]
    fn a_resend_is_minted_and_mailed_once_with_the_first_links_shape() {
        let (s, _d, auth) = estate();
        let kept = Kept::default();
        let said = resend(&s, &auth, &order(), Some(&kept)).unwrap();
        assert!(
            said.contains("was mailed to buyer@mock.invalid") && said.contains("1 day"),
            "{said}"
        );
        let mails = kept.0.lock().unwrap();
        assert_eq!(mails.len(), 1);
        assert_eq!(mails[0].to, "buyer@mock.invalid");
        assert_eq!(
            mails[0]
                .body
                .matches("https://acme.holger.mock.invalid/login?invite=")
                .count(),
            1,
            "{:?}",
            mails[0]
        );
        assert!(mails[0].body.contains("&user=acme"));
    }

    /// ★ **FIRST-LINK-1: an operator site's first admin** (holger.rs, its first admin
    /// `ignalina`, seeded from the installer's feed as a buyer's is) gets a new first sign-in
    /// link from the ONE box's signed order: the host is the bare apex, and the link signs in
    /// the account the site was fed.
    #[test]
    fn an_operator_sites_first_admin_is_sent_a_new_first_link_at_the_apex() {
        let dir = tempfile::tempdir().unwrap();
        let (_, _, auth) = estate();
        let s = door_at(dir.path(), "holger.rs", &auth);
        let fed = crate::passkey::FirstAdminInvite {
            sha256_hex: "cd".repeat(32),
            ttl_secs: 3600,
            display: "ignalina".into(),
        };
        s.seed_first_admin(&fed, &auth).unwrap();
        let kept = Kept::default();
        let order = SignInAgain {
            account: "ignalina".into(),
            to: "first-admin@mock.invalid".into(),
            host: "holger.rs".into(),
            ttl_secs: 86_400,
        };
        let said = resend(&s, &auth, &order, Some(&kept)).unwrap();
        assert!(
            said.contains("ignalina") && said.contains("was mailed to first-admin@mock.invalid"),
            "{said}"
        );
        let mails = kept.0.lock().unwrap();
        assert_eq!(mails.len(), 1);
        assert_eq!(
            mails[0]
                .body
                .matches("https://holger.rs/login?invite=")
                .count(),
            1,
            "{:?}",
            mails[0]
        );
        assert!(mails[0].body.contains("&user=ignalina"), "{:?}", mails[0]);
    }

    /// ★ **HOL-FIRST-ADMIN (holger.rs 2026-10-30): a first-login record taken in before the
    /// identity move names nobody** (`{"sha256","at","seeded"}`: no `display`, no `ttl_secs`;
    /// the account lived in the old `invites.json`, which the move did not carry over). Its main
    /// refused the ONE box's `sign_in_again` for `ignalina` ("is no account the shop named on
    /// this box"). The next start of the main, with the shop's feed, makes the record name the
    /// fed first admin (Gunnar's main makes the operator's principal from the same feed), and
    /// the ONE box's order is minted and mailed.
    #[test]
    fn a_first_login_record_from_before_the_identity_move_names_the_fed_first_admin_at_the_next_start(
    ) {
        let dir = tempfile::tempdir().unwrap();
        let (_, _, auth) = estate();
        let marker = dir.path().join(crate::auth::FIRST_ADMIN_MARKER_REL);
        std::fs::create_dir_all(marker.parent().unwrap()).unwrap();
        std::fs::write(
            &marker,
            format!(
                r#"{{"sha256":"{}","at":1792713392,"seeded":true}}"#,
                "cd".repeat(32)
            ),
        )
        .unwrap();
        let s = door_at(dir.path(), "holger.rs", &auth);
        let order = SignInAgain {
            account: "ignalina".into(),
            to: "first-admin@mock.invalid".into(),
            host: "holger.rs".into(),
            ttl_secs: 86_400,
        };
        let before = resend(&s, &auth, &order, Some(&Kept::default())).unwrap_err();
        assert!(
            before.contains("is no account the shop named"),
            "the record names nobody yet: {before}"
        );
        let fed = crate::passkey::FirstAdminInvite {
            sha256_hex: "cd".repeat(32),
            ttl_secs: 604_800,
            display: "ignalina".into(),
        };
        let said = s.seed_first_admin(&fed, &auth).unwrap();
        assert!(
            said.contains("not opened again") && said.contains("ignalina"),
            "{said}"
        );
        let kept = Kept::default();
        let said = resend(&s, &auth, &order, Some(&kept)).unwrap();
        assert!(
            said.contains("was mailed to first-admin@mock.invalid"),
            "{said}"
        );
        assert!(kept.0.lock().unwrap()[0].body.contains("&user=ignalina"));
        let record: serde_json::Value =
            serde_json::from_slice(&std::fs::read(&marker).unwrap()).unwrap();
        assert_eq!(
            (
                record["sha256"].as_str(),
                record["at"].as_i64(),
                record["seeded"].as_bool()
            ),
            (Some("cd".repeat(32).as_str()), Some(1792713392), Some(true)),
            "the rest of the record is kept: {record}"
        );
        assert!(
            record.get("used_by").is_none_or(serde_json::Value::is_null),
            "{record}"
        );
        // A record that names its first admin is never renamed by a later feed.
        let other = crate::passkey::FirstAdminInvite {
            display: "somebody-else".into(),
            ..fed
        };
        assert!(!s
            .seed_first_admin(&other, &auth)
            .unwrap()
            .contains("somebody-else"));
        let record: serde_json::Value =
            serde_json::from_slice(&std::fs::read(&marker).unwrap()).unwrap();
        assert_eq!(record["display"].as_str(), Some("ignalina"));
    }

    /// ★ **No relay: refused by name before anything is minted; a mail that did not go
    /// withdraws its invite.**
    #[test]
    fn no_relay_mints_nothing_and_an_unmailed_invite_is_withdrawn() {
        let (s, _d, auth) = estate();
        let before = s.invites_json(&auth)["invites"].as_array().unwrap().len();
        let none = resend(&s, &auth, &order(), None).unwrap_err();
        assert!(none.contains(FEED_SMTP_URL), "{none}");
        let unfed = FedRelay { fed: BTreeMap::new };
        let refused = resend(&s, &auth, &order(), Some(&unfed)).unwrap_err();
        assert!(refused.contains("cannot mail"), "{refused}");
        assert_eq!(
            s.invites_json(&auth)["invites"].as_array().unwrap().len(),
            before,
            "nothing minted"
        );
        let failing = Kept(Mutex::default(), true);
        let e = resend(&s, &auth, &order(), Some(&failing)).unwrap_err();
        assert!(e.contains("withdrawn"), "{e}");
        let invites = s.invites_json(&auth);
        let last = invites["invites"]
            .as_array()
            .unwrap()
            .last()
            .unwrap()
            .clone();
        assert_eq!(
            (last["created_by"].as_str(), last["revoked"].as_bool()),
            (Some(crate::passkey::SIGN_IN_AGAIN_BY), Some(true))
        );
    }

    /// The fed relay is read as a submission URL (the shared sender's table); its token never
    /// shows; half a relay is not ready.
    #[test]
    fn a_fed_relay_url_is_read_as_a_submission_url_and_its_token_never_shows() {
        let s = Relay::from_url(
            "smtp://estate%40acme.mock.invalid:s3cret%2Ftok@smtp.mock.invalid:2525",
            "Holger <estate@acme.mock.invalid>",
        )
        .unwrap();
        assert_eq!(s.endpoint(), ("smtp.mock.invalid", 2525, false));
        assert!(!format!("{s:?}").contains("s3cret"), "{s:?}");
        assert_eq!(
            Relay::from_url("smtps://u:p@relay.mock.invalid", "u@relay.mock.invalid")
                .unwrap()
                .endpoint(),
            ("relay.mock.invalid", 465, true)
        );
        assert_eq!(
            Relay::from_url("smtp://u:p@relay.mock.invalid:465", "u@relay.mock.invalid")
                .unwrap()
                .endpoint(),
            ("relay.mock.invalid", 465, true),
            "465 is TLS from the first byte"
        );
        assert!(
            Relay::from_url("smtp://u@relay.mock.invalid", "u@relay.mock.invalid")
                .unwrap_err()
                .contains("no password")
        );
        assert!(
            Relay::from_url("http://u:p@relay.mock.invalid", "u@relay.mock.invalid")
                .unwrap_err()
                .contains("scheme")
        );
        let fed = FedRelay {
            fed: || {
                BTreeMap::from([(
                    FEED_SMTP_URL.to_string(),
                    "smtp://u:p@relay.mock.invalid".to_string(),
                )])
            },
        };
        assert!(
            fed.ready().unwrap_err().contains(FEED_SMTP_FROM),
            "half a relay is not ready"
        );
    }

    /// ★ **Mail is OFF until a relay is fed** (owner 2026-10-25, the airgap default): with the
    /// sender linked (`metered`) and a door fed, a box fed no relay refuses the mail by name and
    /// opens no socket, to the door or anywhere.
    #[test]
    fn a_box_fed_no_relay_opens_no_socket_and_refuses_by_name() {
        let door = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
        door.set_nonblocking(true).unwrap();
        let at = door.local_addr().unwrap();
        let fed = FedRelay {
            fed: move || {
                BTreeMap::from([(
                    crate::egress::FEED_EGRESS.to_string(),
                    format!("http://{at}"),
                )])
            },
        };
        let mail = SignInMail {
            to: "buyer@mock.invalid".into(),
            subject: "s".into(),
            body: "b".into(),
        };
        let e = fed.post(&mail).unwrap_err();
        assert!(
            e.contains("mail is off") && e.contains(FEED_SMTP_URL),
            "{e}"
        );
        assert!(fed.ready().is_err());
        std::thread::sleep(std::time::Duration::from_millis(100));
        assert!(
            matches!(door.accept(), Err(ref w) if w.kind() == std::io::ErrorKind::WouldBlock),
            "nothing dialled the door"
        );
    }
}