openlatch-client 0.6.19

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
//! Choosing the organization a machine joins (PLT-7 C-6, C-10).
//!
//! Pure: no I/O, no prompt. The sign-in flow in `cli::commands::auth` fetches
//! the list, and this module decides what a value given with `--org` or
//! `OPENLATCH_ORG` names, and how each refusal reads.
//!
//! Matching is exact and ordered: the `organization_id`, then the slug
//! (case-insensitive), then the name (case-insensitive). Never a prefix, never
//! fuzzy — a near miss enrolling a machine in the wrong organization is the
//! failure this whole change exists to remove.

use crate::error::{
    OlError, ERR_ALREADY_ENROLLED, ERR_ORG_AMBIGUOUS, ERR_ORG_CHOICE_REQUIRED,
    ERR_ORG_KEY_MISMATCH, ERR_ORG_NOT_CHOSEN, ERR_ORG_NOT_FOUND, ERR_ORG_UNVERIFIED,
};
use crate::generated::types::CliAuthOrganization;

/// The environment variable that names an organization when `--org` is absent.
pub const ORG_ENV: &str = "OPENLATCH_ORG";

/// One organization the user may enroll this machine into.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Org {
    pub id: String,
    pub slug: String,
    pub name: String,
}

impl From<CliAuthOrganization> for Org {
    fn from(o: CliAuthOrganization) -> Self {
        Self {
            id: o.organization_id,
            slug: printable(&o.slug),
            name: printable(&o.name),
        }
    }
}

/// The server's text with every control character dropped (ESC, C0, C1, DEL).
/// Names and slugs are echoed to the terminal by the picker and by every
/// message that names an organization, so an escape sequence in a name must
/// never reach it.
fn printable(s: &str) -> String {
    s.chars().filter(|c| !c.is_control()).collect()
}

/// Why a value names no single organization.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OrgMatchError {
    /// Nothing matched.
    NotFound,
    /// Two or more organizations matched (by name, in practice).
    Ambiguous(Vec<Org>),
}

/// The value naming the organization, if any: `--org` wins over
/// `OPENLATCH_ORG` (D-5). An empty or blank value names nothing.
pub fn requested(flag: Option<&str>) -> Option<String> {
    requested_from(flag, std::env::var(ORG_ENV).ok().as_deref())
}

/// [`requested`] with the environment passed in, for tests.
pub fn requested_from(flag: Option<&str>, env: Option<&str>) -> Option<String> {
    flag.or(env)
        .map(str::trim)
        .filter(|v| !v.is_empty())
        .map(str::to_string)
}

/// Resolve `value` against `orgs` with the C-6 rules.
pub fn resolve<'a>(value: &str, orgs: &'a [Org]) -> Result<&'a Org, OrgMatchError> {
    let value = value.trim();
    if let Some(org) = orgs.iter().find(|o| o.id == value) {
        return Ok(org);
    }
    let slug = |o: &Org| caseless_eq(&o.slug, value);
    let name = |o: &Org| caseless_eq(&o.name, value);
    for rule in [&slug as &dyn Fn(&Org) -> bool, &name] {
        let hits: Vec<&Org> = orgs.iter().filter(|o| rule(o)).collect();
        match hits.as_slice() {
            [] => continue,
            [one] => return Ok(one),
            many => {
                return Err(OrgMatchError::Ambiguous(
                    many.iter().map(|o| (*o).clone()).collect(),
                ))
            }
        }
    }
    Err(OrgMatchError::NotFound)
}

/// Does `value` name the organization a key or a session belongs to? The key
/// path sees only its own organization, so there is no ambiguity to report.
pub fn names(value: &str, id: &str, slug: Option<&str>, name: &str) -> bool {
    let value = value.trim();
    (!id.is_empty() && id == value)
        || slug.is_some_and(|s| !s.is_empty() && caseless_eq(s, value))
        || (!name.is_empty() && caseless_eq(name, value))
}

/// Case-insensitive equality that also folds non-ASCII letters.
fn caseless_eq(a: &str, b: &str) -> bool {
    a.to_lowercase() == b.to_lowercase()
}

/// `OpenLatch (openlatch), SuperAdmin (superadmin)`.
pub fn list(orgs: &[Org]) -> String {
    orgs.iter()
        .map(|o| format!("{} ({})", o.name, o.slug))
        .collect::<Vec<_>>()
        .join(", ")
}

/// The name an organization is shown by: its name when the list has it, else
/// the id (the pin in `bundle.meta.json` holds no name).
pub fn display_name(id: &str, orgs: &[Org]) -> String {
    orgs.iter()
        .find(|o| o.id == id)
        .map_or_else(|| id.to_string(), |o| o.name.clone())
}

/// The refusal for a value that named nothing or too much.
pub fn match_error(value: &str, orgs: &[Org], err: &OrgMatchError) -> OlError {
    match err {
        OrgMatchError::NotFound => not_found(value, orgs),
        OrgMatchError::Ambiguous(hits) => OlError::new(
            ERR_ORG_AMBIGUOUS,
            format!("\"{value}\" matches several organizations: {}", list(hits)),
        )
        .with_suggestion("Name the organization by its slug, e.g. `--org <slug>`."),
    }
}

/// `ERR_ORG_NOT_FOUND` — also what the exchange's `403 not_a_member` becomes,
/// with the fresh list.
pub fn not_found(value: &str, orgs: &[Org]) -> OlError {
    OlError::new(
        ERR_ORG_NOT_FOUND,
        format!(
            "No organization matches \"{value}\"; yours are {}",
            list(orgs)
        ),
    )
    .with_suggestion("Use one of the slugs with `--org <slug>` or `OPENLATCH_ORG`.")
}

/// Two or more organizations, nothing naming one, nobody at a terminal.
pub fn choice_required(orgs: &[Org]) -> OlError {
    OlError::new(
        ERR_ORG_CHOICE_REQUIRED,
        format!("You belong to {} organizations: {}", orgs.len(), list(orgs)),
    )
    .with_suggestion("Pass `--org <slug>` or set OPENLATCH_ORG to choose one.")
}

/// The picker was left unanswered.
pub fn not_chosen(orgs: &[Org]) -> OlError {
    OlError::new(
        ERR_ORG_NOT_CHOSEN,
        format!("No organization chosen; yours are {}", list(orgs)),
    )
    .with_suggestion("Re-run the command, or pass `--org <slug>`.")
}

/// This machine is enrolled in `current`; only `switch` moves it to `wanted`.
pub fn already_enrolled(current: &str, wanted: &str) -> OlError {
    OlError::new(
        ERR_ALREADY_ENROLLED,
        format!("This machine is connected to {current}"),
    )
    .with_suggestion(format!(
        "To move it, run `openlatch system auth switch --org {wanted}`."
    ))
}

/// `OPENLATCH_API_KEY` belongs to `key_org`, not to `wanted`.
pub fn key_mismatch(key_org: &str, wanted: &str) -> OlError {
    OlError::new(
        ERR_ORG_KEY_MISMATCH,
        format!("The API key belongs to {key_org}, not {wanted}"),
    )
    .with_suggestion(format!(
        "Remove `--org`/OPENLATCH_ORG, or use an API key from {wanted}."
    ))
}

/// An organization was named and the cloud could not confirm it.
pub fn unverified() -> OlError {
    OlError::new(
        ERR_ORG_UNVERIFIED,
        "Cannot confirm the organization while the cloud is unreachable",
    )
    .with_suggestion("Re-run when online, or drop `--org`/OPENLATCH_ORG.")
}

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

    fn org(id: &str, slug: &str, name: &str) -> Org {
        Org {
            id: id.into(),
            slug: slug.into(),
            name: name.into(),
        }
    }

    fn orgs() -> Vec<Org> {
        vec![
            org(
                "11111111-0000-4000-a000-000000000001",
                "openlatch",
                "OpenLatch",
            ),
            org(
                "11111111-0000-4000-a000-000000000002",
                "superadmin",
                "SuperAdmin",
            ),
            org("11111111-0000-4000-a000-000000000003", "acme-eu", "Acme"),
            org("11111111-0000-4000-a000-000000000004", "acme-us", "acme"),
        ]
    }

    #[test]
    fn org_choice_server_text_loses_its_control_characters() {
        let o = Org::from(CliAuthOrganization {
            name: "\u{1b}]0;x\u{7}Evil\u{1b}[31m Corp\u{9b}2J\u{7f}".into(),
            organization_id: "11111111-0000-4000-a000-000000000009".into(),
            role: None,
            slug: "evil\u{1b}[0m".into(),
        });
        assert_eq!(o.name, "]0;xEvil[31m Corp2J");
        assert_eq!(o.slug, "evil[0m");
        assert!(!o.name.chars().chain(o.slug.chars()).any(char::is_control));
    }

    #[test]
    fn org_choice_exact_id_matches() {
        let o = orgs();
        assert_eq!(
            resolve("11111111-0000-4000-a000-000000000002", &o)
                .unwrap()
                .slug,
            "superadmin"
        );
    }

    #[test]
    fn org_choice_slug_matches_in_any_case() {
        let o = orgs();
        assert_eq!(resolve("OpenLatch", &o).unwrap().slug, "openlatch");
        assert_eq!(resolve("SUPERADMIN", &o).unwrap().slug, "superadmin");
        assert_eq!(resolve("acme-us", &o).unwrap().name, "acme");
    }

    #[test]
    fn org_choice_name_matches_in_any_case_when_no_slug_does() {
        let o = vec![
            org("i1", "ol", "Open Latch"),
            org("i2", "sa", "Super Admin"),
        ];
        assert_eq!(resolve("open latch", &o).unwrap().id, "i1");
        assert_eq!(resolve("SUPER ADMIN", &o).unwrap().id, "i2");
    }

    #[test]
    fn org_choice_slug_wins_over_another_orgs_name() {
        // "beta" is one org's slug and another's name: the slug rule runs first.
        let o = vec![org("i1", "alpha", "Beta"), org("i2", "beta", "Gamma")];
        assert_eq!(resolve("beta", &o).unwrap().id, "i2");
    }

    #[test]
    fn org_choice_never_matches_a_prefix_or_a_near_miss() {
        let o = orgs();
        for v in ["open", "openlatc", "super", "acme-", "11111111-0000"] {
            assert_eq!(resolve(v, &o), Err(OrgMatchError::NotFound), "{v}");
        }
    }

    #[test]
    fn org_choice_two_name_matches_are_ambiguous() {
        let o = orgs();
        match resolve("ACME", &o) {
            Err(OrgMatchError::Ambiguous(hits)) => {
                assert_eq!(hits.len(), 2);
                let e = match_error("ACME", &o, &OrgMatchError::Ambiguous(hits));
                assert_eq!(e.code, ERR_ORG_AMBIGUOUS);
                assert_eq!(e.exit_code(), 1);
                assert!(e.message.contains("acme-eu") && e.message.contains("acme-us"));
            }
            other => panic!("expected ambiguity, got {other:?}"),
        }
    }

    #[test]
    fn org_choice_not_found_exits_3_and_lists_the_slugs() {
        let o = orgs();
        let e = match_error("nope", &o, &OrgMatchError::NotFound);
        assert_eq!(e.code, ERR_ORG_NOT_FOUND);
        assert_eq!(e.exit_code(), 3);
        assert!(e.message.contains("\"nope\""));
        assert!(e.message.contains("OpenLatch (openlatch)"));
    }

    #[test]
    fn org_choice_flag_wins_over_the_environment() {
        assert_eq!(requested_from(Some("a"), Some("b")).as_deref(), Some("a"));
        assert_eq!(requested_from(None, Some("b")).as_deref(), Some("b"));
        assert_eq!(requested_from(None, None), None);
        assert_eq!(requested_from(Some("  "), Some("b")), None);
        assert_eq!(requested_from(None, Some("")), None);
    }

    #[test]
    fn org_choice_key_identity_matches_id_slug_or_name() {
        let (id, slug, name) = ("i1", Some("openlatch"), "OpenLatch");
        assert!(names("i1", id, slug, name));
        assert!(names("OPENLATCH", id, slug, name));
        assert!(names("openlatch", id, None, name));
        assert!(!names("open", id, slug, name));
        assert!(!names("I1", id, slug, name), "ids are exact");
        assert!(!names("x", "", None, ""));
    }

    #[test]
    fn org_choice_refusals_carry_their_codes() {
        let o = orgs();
        assert_eq!(choice_required(&o).code, ERR_ORG_CHOICE_REQUIRED);
        assert!(choice_required(&o)
            .message
            .starts_with("You belong to 4 organizations"));
        assert_eq!(not_chosen(&o).code, ERR_ORG_NOT_CHOSEN);
        let e = already_enrolled("OpenLatch", "superadmin");
        assert_eq!(e.code, ERR_ALREADY_ENROLLED);
        assert!(e
            .suggestion
            .as_deref()
            .unwrap()
            .contains("openlatch system auth switch --org superadmin"));
        let e = key_mismatch("OpenLatch", "superadmin");
        assert_eq!(e.code, ERR_ORG_KEY_MISMATCH);
        assert!(e.message.contains("OpenLatch") && e.message.contains("superadmin"));
        assert_eq!(unverified().code, ERR_ORG_UNVERIFIED);
    }

    #[test]
    fn org_choice_display_name_falls_back_to_the_id() {
        let o = orgs();
        assert_eq!(
            display_name("11111111-0000-4000-a000-000000000001", &o),
            "OpenLatch"
        );
        assert_eq!(display_name("gone", &o), "gone");
    }
}