kcode-kennedy-roots 0.1.0

Kennedy application root bootstrap and identity reconciliation
Documentation
//! Kennedy application root bootstrap and identity-directory reconciliation.

#![deny(missing_docs)]
#![forbid(unsafe_code)]

use std::{path::Path, sync::Arc};

use anyhow::Context as _;
use chrono::Utc;
use kcode_kweb_db::{Config, KwebDb, NodeData, NodeId, Owner, Provenance};
use kcode_kweb_manager::{CreateProvenance, KwebManager, NodeContents, NodeWrite};
use kcode_server_object_envelopes::StoredProvenance;
use rusqlite::{Connection, OptionalExtension, params};
use tokio::sync::Mutex;
use uuid::Uuid;

/// Kennedy's canonical application-owned roots.
#[derive(Clone, Copy, Debug)]
pub struct SystemRoots {
    /// Primary user's canonical Kweb root.
    pub user: NodeId,
    /// Kennedy's canonical Kweb root.
    pub kennedy: NodeId,
}

/// Opens Kweb, bootstraps or validates both system roots, and transfers the
/// sole database handle into Kweb Manager.
pub fn open(
    kweb_root: &Path,
    config: Config,
    identity_database: &Path,
) -> anyhow::Result<(KwebManager, SystemRoots)> {
    let mut identity = Connection::open(identity_database).with_context(|| {
        format!(
            "opening identity database {} for system roots",
            identity_database.display()
        )
    })?;
    identity.execute_batch(
        "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA busy_timeout=15000;
         CREATE TABLE IF NOT EXISTS kmap_system_roots (
             role TEXT PRIMARY KEY CHECK(role IN ('user','kennedy')),
             root_node_id TEXT NOT NULL UNIQUE CHECK(length(root_node_id)=8),
             created_at TEXT NOT NULL
         );",
    )?;
    let database = KwebDb::open(kweb_root, config).map_err(anyhow::Error::new)?;
    let roots = match (
        system_root(&identity, "user")?,
        system_root(&identity, "kennedy")?,
    ) {
        (Some(user), Some(kennedy)) => SystemRoots {
            user: user
                .parse::<NodeId>()
                .with_context(|| format!("invalid stored user root node ID {user:?}"))?,
            kennedy: kennedy
                .parse::<NodeId>()
                .with_context(|| format!("invalid stored Kennedy root node ID {kennedy:?}"))?,
        },
        (None, None) => create_system_roots(&database, &mut identity)?,
        _ => anyhow::bail!("the system-root directory contains only one of its two required roles"),
    };
    database.get_node(roots.user).map_err(anyhow::Error::new)?;
    database
        .get_node(roots.kennedy)
        .map_err(anyhow::Error::new)?;
    let kmap = KwebManager::open(database, identity_database)
        .map_err(anyhow::Error::new)
        .context("opening Kmap application service")?;
    Ok((kmap, roots))
}

fn create_system_roots(
    database: &KwebDb,
    identity: &mut Connection,
) -> anyhow::Result<SystemRoots> {
    let mut transaction = database
        .start_transaction(Provenance {
            author: "system-bootstrap".into(),
            source: "system-bootstrap".into(),
            source_created_at: Utc::now(),
            data: "Initial Kweb system-root bootstrap.".into(),
        })
        .map_err(anyhow::Error::new)?;
    let user = transaction
        .create_node(root_data(
            "Initial User Root",
            "The root of the primary user's Kmap knowledge.",
            "This root anchors durable knowledge associated with the primary Kennedy user.",
        ))
        .map_err(anyhow::Error::new)?;
    let kennedy = transaction
        .create_node(root_data(
            "Kennedy's Root",
            "The root of Kennedy's own Kmap knowledge.",
            "This is Kennedy's root node. It anchors Kennedy's own durable knowledge and learned lessons in the Kmap.",
        ))
        .map_err(anyhow::Error::new)?;
    transaction.finalize().map_err(anyhow::Error::new)?;
    let sql = identity.transaction()?;
    let now = Utc::now().to_rfc3339();
    sql.execute(
        "INSERT INTO kmap_system_roots(role,root_node_id,created_at) VALUES('user',?1,?2)",
        params![user.to_string(), now],
    )?;
    sql.execute(
        "INSERT INTO kmap_system_roots(role,root_node_id,created_at) VALUES('kennedy',?1,?2)",
        params![kennedy.to_string(), now],
    )?;
    sql.commit()?;
    Ok(SystemRoots { user, kennedy })
}

fn system_root(identity: &Connection, role: &str) -> anyhow::Result<Option<String>> {
    Ok(identity
        .query_row(
            "SELECT root_node_id FROM kmap_system_roots WHERE role=?1",
            [role],
            |row| row.get(0),
        )
        .optional()?)
}

fn root_data(short_name: &str, short_description: &str, long_description: &str) -> NodeData {
    NodeData {
        short_name: short_name.into(),
        short_description: short_description.into(),
        long_description: long_description.into(),
        owner: Owner::SelfNode,
        fixed_connections: Vec::new(),
        recent_connections: Vec::new(),
        objects: Vec::new(),
    }
}

/// Reconciles Kennedy-owned Kmap roots into Telegram's identity directory.
#[derive(Clone)]
pub struct DirectoryRoots {
    kmap: KwebManager,
    directory: Arc<kcode_telegram_identity::Directory>,
    web_user_handle: String,
    system_user_root: NodeId,
    writer: Arc<Mutex<()>>,
}

impl DirectoryRoots {
    /// Constructs root reconciliation over the application's existing owners
    /// and shared global writer lane.
    pub fn new(
        kmap: KwebManager,
        directory: Arc<kcode_telegram_identity::Directory>,
        web_user_handle: impl Into<String>,
        system_user_root: NodeId,
        writer: Arc<Mutex<()>>,
    ) -> Self {
        Self {
            kmap,
            directory,
            web_user_handle: web_user_handle.into(),
            system_user_root,
            writer,
        }
    }

    /// Completes every currently pending user and group root assignment.
    pub async fn reconcile_pending(&self) -> anyhow::Result<()> {
        for user in self
            .directory
            .provisioning_users()
            .map_err(directory_error)?
        {
            let handle = user.handle;
            if handles_match(&handle, &self.web_user_handle) {
                self.directory
                    .complete_handle_root(&handle, self.system_user_root)
                    .map_err(directory_error)?;
            } else {
                let _guard = self.writer.lock().await;
                let root = self.create_root(None)?;
                self.directory
                    .complete_handle_root(&handle, root)
                    .map_err(directory_error)?;
            }
        }
        for group in self
            .directory
            .provisioning_groups()
            .map_err(directory_error)?
        {
            let _guard = self.writer.lock().await;
            let root = self.create_root(Some("Group Root"))?;
            self.directory
                .complete_group_root(&group.group_id, root)
                .map_err(directory_error)?;
        }
        Ok(())
    }

    /// Returns a ready Telegram user, creating and assigning its root when
    /// necessary.
    pub async fn ensure_user(
        &self,
        telegram_user_id: i64,
    ) -> anyhow::Result<kcode_telegram_identity::User> {
        let user = self
            .directory
            .user(telegram_user_id)
            .map_err(directory_error)?;
        if user.root_ready {
            return Ok(user);
        }
        let _guard = self.writer.lock().await;
        let user = self
            .directory
            .user(telegram_user_id)
            .map_err(directory_error)?;
        if user.root_ready {
            return Ok(user);
        }
        let root = self.create_root(None)?;
        self.directory
            .complete_user_root(telegram_user_id, root)
            .map_err(directory_error)
    }

    /// Returns a ready Telegram group, creating and assigning its root when
    /// necessary.
    pub async fn ensure_group(
        &self,
        group_id: &str,
    ) -> anyhow::Result<kcode_telegram_identity::Group> {
        let group = self.directory.group(group_id).map_err(directory_error)?;
        if group.root_ready {
            return Ok(group);
        }
        let _guard = self.writer.lock().await;
        let group = self.directory.group(group_id).map_err(directory_error)?;
        if group.root_ready {
            return Ok(group);
        }
        let root = self.create_root(Some("Group Root"))?;
        self.directory
            .complete_group_root(group_id, root)
            .map_err(directory_error)
    }

    fn create_root(&self, short_name: Option<&str>) -> anyhow::Result<NodeId> {
        let (short_name, short_description, long_description) = root_metadata(short_name);
        let source_created_at = Utc::now();
        let provenance_id = self
            .kmap
            .create_provenance(CreateProvenance {
                idempotency_id: idempotency_id(),
                value: StoredProvenance {
                    data: "Automatically provisioned Kmap root node.".into(),
                    source: "system-bootstrap".into(),
                    source_created_at,
                    artifacts: Vec::new(),
                },
                storage_provenance: Provenance {
                    author: "kennedy-provenance".into(),
                    source: "system-bootstrap".into(),
                    source_created_at,
                    data: "Stored provenance for a Kennedy Kmap mutation.".into(),
                },
            })
            .map_err(kmap_error)?;
        self.kmap
            .create_node(NodeWrite {
                idempotency_id: idempotency_id(),
                provenance_id,
                author: "system-bootstrap".into(),
                contents: NodeContents {
                    short_name: short_name.into(),
                    short_description: short_description.into(),
                    long_description: long_description.into(),
                    owner: Owner::SelfNode,
                    fixed_connections: Vec::new(),
                    recent_connections: Vec::new(),
                },
            })
            .map(|node| node.id)
            .map_err(kmap_error)
    }
}

fn handles_match(left: &str, right: &str) -> bool {
    left.trim_start_matches('@')
        .eq_ignore_ascii_case(right.trim_start_matches('@'))
}

fn root_metadata(short_name: Option<&str>) -> (&str, &'static str, &'static str) {
    match short_name {
        Some("Group Root") => (
            "Group Root",
            "The root of this Telegram group's shared Kmap knowledge.",
            "This root anchors durable knowledge shared in this Telegram group.",
        ),
        Some(short_name) => (
            short_name,
            "An automatically provisioned Kmap root.",
            "This root anchors durable Kmap knowledge.",
        ),
        None => (
            "User Root",
            "The root of this Telegram user's Kmap knowledge.",
            "This root anchors durable knowledge associated with this Telegram user.",
        ),
    }
}

fn idempotency_id() -> String {
    Uuid::new_v4().simple().to_string()
}

fn kmap_error(error: kcode_kweb_manager::Error) -> anyhow::Error {
    anyhow::anyhow!(error.to_string())
}

fn directory_error(error: kcode_telegram_identity::Error) -> anyhow::Error {
    anyhow::anyhow!(error.message().to_owned())
}

#[cfg(test)]
mod tests {
    use super::*;
    use kcode_kweb_db::{NoopGossip, WriterId};

    fn config() -> Config {
        let signing_key = rand::random::<[u8; 32]>();
        Config {
            signing_key,
            writers_by_priority: vec![WriterId::from_signing_key(&signing_key)],
            gossip: Arc::new(NoopGossip),
        }
    }

    #[test]
    fn initializes_canonical_system_roots_and_hands_database_to_kmap() {
        let directory = std::env::temp_dir().join(format!("kennedy-roots-{}", Uuid::new_v4()));
        std::fs::create_dir_all(&directory).unwrap();
        let (kmap, roots) = open(
            &directory.join("kweb"),
            config(),
            &directory.join("users.sqlite3"),
        )
        .unwrap();
        assert_eq!(roots.user.to_string().len(), 8);
        assert_eq!(roots.kennedy.to_string().len(), 8);
        kmap.get_node(roots.user).unwrap();
        kmap.get_node(roots.kennedy).unwrap();
        drop(kmap);
        std::fs::remove_dir_all(directory).unwrap();
    }

    #[test]
    fn configured_web_handle_matching_is_case_and_at_sign_insensitive() {
        assert!(handles_match("@Taek42", "taek42"));
        assert!(!handles_match("@other", "@taek42"));
    }

    #[test]
    fn provisioned_roots_keep_complete_application_descriptions() {
        let (_, user_short, user_long) = root_metadata(None);
        let (_, group_short, group_long) = root_metadata(Some("Group Root"));
        assert!(user_short.contains("Telegram user"));
        assert!(user_long.contains("associated with this Telegram user"));
        assert!(group_short.contains("Telegram group"));
        assert!(group_long.contains("shared in this Telegram group"));
    }
}