Skip to main content

kcode_kennedy_roots/
lib.rs

1//! Kennedy application root bootstrap and identity-directory reconciliation.
2
3#![deny(missing_docs)]
4#![forbid(unsafe_code)]
5
6use std::{path::Path, sync::Arc};
7
8use anyhow::Context as _;
9use chrono::Utc;
10use kcode_kweb_db::{Config, KwebDb, NodeData, NodeId, Owner, Provenance};
11use kcode_kweb_manager::{CreateProvenance, KwebManager, NodeContents, NodeWrite};
12use kcode_server_object_envelopes::StoredProvenance;
13use rusqlite::{Connection, OptionalExtension, params};
14use tokio::sync::Mutex;
15use uuid::Uuid;
16
17/// Kennedy's canonical application-owned roots.
18#[derive(Clone, Copy, Debug)]
19pub struct SystemRoots {
20    /// Primary user's canonical Kweb root.
21    pub user: NodeId,
22    /// Kennedy's canonical Kweb root.
23    pub kennedy: NodeId,
24}
25
26/// Opens Kweb, bootstraps or validates both system roots, and transfers the
27/// sole database handle into Kweb Manager.
28pub fn open(
29    kweb_root: &Path,
30    config: Config,
31    identity_database: &Path,
32) -> anyhow::Result<(KwebManager, SystemRoots)> {
33    let mut identity = Connection::open(identity_database).with_context(|| {
34        format!(
35            "opening identity database {} for system roots",
36            identity_database.display()
37        )
38    })?;
39    identity.execute_batch(
40        "PRAGMA foreign_keys=ON; PRAGMA journal_mode=WAL; PRAGMA busy_timeout=15000;
41         CREATE TABLE IF NOT EXISTS kmap_system_roots (
42             role TEXT PRIMARY KEY CHECK(role IN ('user','kennedy')),
43             root_node_id TEXT NOT NULL UNIQUE CHECK(length(root_node_id)=8),
44             created_at TEXT NOT NULL
45         );",
46    )?;
47    let database = KwebDb::open(kweb_root, config).map_err(anyhow::Error::new)?;
48    let roots = match (
49        system_root(&identity, "user")?,
50        system_root(&identity, "kennedy")?,
51    ) {
52        (Some(user), Some(kennedy)) => SystemRoots {
53            user: user
54                .parse::<NodeId>()
55                .with_context(|| format!("invalid stored user root node ID {user:?}"))?,
56            kennedy: kennedy
57                .parse::<NodeId>()
58                .with_context(|| format!("invalid stored Kennedy root node ID {kennedy:?}"))?,
59        },
60        (None, None) => create_system_roots(&database, &mut identity)?,
61        _ => anyhow::bail!("the system-root directory contains only one of its two required roles"),
62    };
63    database.get_node(roots.user).map_err(anyhow::Error::new)?;
64    database
65        .get_node(roots.kennedy)
66        .map_err(anyhow::Error::new)?;
67    let kmap = KwebManager::open(database, identity_database)
68        .map_err(anyhow::Error::new)
69        .context("opening Kmap application service")?;
70    Ok((kmap, roots))
71}
72
73fn create_system_roots(
74    database: &KwebDb,
75    identity: &mut Connection,
76) -> anyhow::Result<SystemRoots> {
77    let mut transaction = database
78        .start_transaction(Provenance {
79            author: "system-bootstrap".into(),
80            source: "system-bootstrap".into(),
81            source_created_at: Utc::now(),
82            data: "Initial Kweb system-root bootstrap.".into(),
83        })
84        .map_err(anyhow::Error::new)?;
85    let user = transaction
86        .create_node(root_data(
87            "Initial User Root",
88            "The root of the primary user's Kmap knowledge.",
89            "This root anchors durable knowledge associated with the primary Kennedy user.",
90        ))
91        .map_err(anyhow::Error::new)?;
92    let kennedy = transaction
93        .create_node(root_data(
94            "Kennedy's Root",
95            "The root of Kennedy's own Kmap knowledge.",
96            "This is Kennedy's root node. It anchors Kennedy's own durable knowledge and learned lessons in the Kmap.",
97        ))
98        .map_err(anyhow::Error::new)?;
99    transaction.finalize().map_err(anyhow::Error::new)?;
100    let sql = identity.transaction()?;
101    let now = Utc::now().to_rfc3339();
102    sql.execute(
103        "INSERT INTO kmap_system_roots(role,root_node_id,created_at) VALUES('user',?1,?2)",
104        params![user.to_string(), now],
105    )?;
106    sql.execute(
107        "INSERT INTO kmap_system_roots(role,root_node_id,created_at) VALUES('kennedy',?1,?2)",
108        params![kennedy.to_string(), now],
109    )?;
110    sql.commit()?;
111    Ok(SystemRoots { user, kennedy })
112}
113
114fn system_root(identity: &Connection, role: &str) -> anyhow::Result<Option<String>> {
115    Ok(identity
116        .query_row(
117            "SELECT root_node_id FROM kmap_system_roots WHERE role=?1",
118            [role],
119            |row| row.get(0),
120        )
121        .optional()?)
122}
123
124fn root_data(short_name: &str, short_description: &str, long_description: &str) -> NodeData {
125    NodeData {
126        short_name: short_name.into(),
127        short_description: short_description.into(),
128        long_description: long_description.into(),
129        owner: Owner::SelfNode,
130        fixed_connections: Vec::new(),
131        recent_connections: Vec::new(),
132        objects: Vec::new(),
133    }
134}
135
136/// Reconciles Kennedy-owned Kmap roots into Telegram's identity directory.
137#[derive(Clone)]
138pub struct DirectoryRoots {
139    kmap: KwebManager,
140    directory: Arc<kcode_telegram_identity::Directory>,
141    web_user_handle: String,
142    system_user_root: NodeId,
143    writer: Arc<Mutex<()>>,
144}
145
146impl DirectoryRoots {
147    /// Constructs root reconciliation over the application's existing owners
148    /// and shared global writer lane.
149    pub fn new(
150        kmap: KwebManager,
151        directory: Arc<kcode_telegram_identity::Directory>,
152        web_user_handle: impl Into<String>,
153        system_user_root: NodeId,
154        writer: Arc<Mutex<()>>,
155    ) -> Self {
156        Self {
157            kmap,
158            directory,
159            web_user_handle: web_user_handle.into(),
160            system_user_root,
161            writer,
162        }
163    }
164
165    /// Completes every currently pending user and group root assignment.
166    pub async fn reconcile_pending(&self) -> anyhow::Result<()> {
167        for user in self
168            .directory
169            .provisioning_users()
170            .map_err(directory_error)?
171        {
172            let handle = user.handle;
173            if handles_match(&handle, &self.web_user_handle) {
174                self.directory
175                    .complete_handle_root(&handle, self.system_user_root)
176                    .map_err(directory_error)?;
177            } else {
178                let _guard = self.writer.lock().await;
179                let root = self.create_root(None)?;
180                self.directory
181                    .complete_handle_root(&handle, root)
182                    .map_err(directory_error)?;
183            }
184        }
185        for group in self
186            .directory
187            .provisioning_groups()
188            .map_err(directory_error)?
189        {
190            let _guard = self.writer.lock().await;
191            let root = self.create_root(Some("Group Root"))?;
192            self.directory
193                .complete_group_root(&group.group_id, root)
194                .map_err(directory_error)?;
195        }
196        Ok(())
197    }
198
199    /// Returns a ready Telegram user, creating and assigning its root when
200    /// necessary.
201    pub async fn ensure_user(
202        &self,
203        telegram_user_id: i64,
204    ) -> anyhow::Result<kcode_telegram_identity::User> {
205        let user = self
206            .directory
207            .user(telegram_user_id)
208            .map_err(directory_error)?;
209        if user.root_ready {
210            return Ok(user);
211        }
212        let _guard = self.writer.lock().await;
213        let user = self
214            .directory
215            .user(telegram_user_id)
216            .map_err(directory_error)?;
217        if user.root_ready {
218            return Ok(user);
219        }
220        let root = self.create_root(None)?;
221        self.directory
222            .complete_user_root(telegram_user_id, root)
223            .map_err(directory_error)
224    }
225
226    /// Returns a ready Telegram group, creating and assigning its root when
227    /// necessary.
228    pub async fn ensure_group(
229        &self,
230        group_id: &str,
231    ) -> anyhow::Result<kcode_telegram_identity::Group> {
232        let group = self.directory.group(group_id).map_err(directory_error)?;
233        if group.root_ready {
234            return Ok(group);
235        }
236        let _guard = self.writer.lock().await;
237        let group = self.directory.group(group_id).map_err(directory_error)?;
238        if group.root_ready {
239            return Ok(group);
240        }
241        let root = self.create_root(Some("Group Root"))?;
242        self.directory
243            .complete_group_root(group_id, root)
244            .map_err(directory_error)
245    }
246
247    fn create_root(&self, short_name: Option<&str>) -> anyhow::Result<NodeId> {
248        let (short_name, short_description, long_description) = root_metadata(short_name);
249        let source_created_at = Utc::now();
250        let provenance_id = self
251            .kmap
252            .create_provenance(CreateProvenance {
253                idempotency_id: idempotency_id(),
254                value: StoredProvenance {
255                    data: "Automatically provisioned Kmap root node.".into(),
256                    source: "system-bootstrap".into(),
257                    source_created_at,
258                    artifacts: Vec::new(),
259                },
260                storage_provenance: Provenance {
261                    author: "kennedy-provenance".into(),
262                    source: "system-bootstrap".into(),
263                    source_created_at,
264                    data: "Stored provenance for a Kennedy Kmap mutation.".into(),
265                },
266            })
267            .map_err(kmap_error)?;
268        self.kmap
269            .create_node(NodeWrite {
270                idempotency_id: idempotency_id(),
271                provenance_id,
272                author: "system-bootstrap".into(),
273                contents: NodeContents {
274                    short_name: short_name.into(),
275                    short_description: short_description.into(),
276                    long_description: long_description.into(),
277                    owner: Owner::SelfNode,
278                    fixed_connections: Vec::new(),
279                    recent_connections: Vec::new(),
280                },
281            })
282            .map(|node| node.id)
283            .map_err(kmap_error)
284    }
285}
286
287fn handles_match(left: &str, right: &str) -> bool {
288    left.trim_start_matches('@')
289        .eq_ignore_ascii_case(right.trim_start_matches('@'))
290}
291
292fn root_metadata(short_name: Option<&str>) -> (&str, &'static str, &'static str) {
293    match short_name {
294        Some("Group Root") => (
295            "Group Root",
296            "The root of this Telegram group's shared Kmap knowledge.",
297            "This root anchors durable knowledge shared in this Telegram group.",
298        ),
299        Some(short_name) => (
300            short_name,
301            "An automatically provisioned Kmap root.",
302            "This root anchors durable Kmap knowledge.",
303        ),
304        None => (
305            "User Root",
306            "The root of this Telegram user's Kmap knowledge.",
307            "This root anchors durable knowledge associated with this Telegram user.",
308        ),
309    }
310}
311
312fn idempotency_id() -> String {
313    Uuid::new_v4().simple().to_string()
314}
315
316fn kmap_error(error: kcode_kweb_manager::Error) -> anyhow::Error {
317    anyhow::anyhow!(error.to_string())
318}
319
320fn directory_error(error: kcode_telegram_identity::Error) -> anyhow::Error {
321    anyhow::anyhow!(error.message().to_owned())
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use kcode_kweb_db::{NoopGossip, WriterId};
328
329    fn config() -> Config {
330        let signing_key = rand::random::<[u8; 32]>();
331        Config {
332            signing_key,
333            writers_by_priority: vec![WriterId::from_signing_key(&signing_key)],
334            gossip: Arc::new(NoopGossip),
335        }
336    }
337
338    #[test]
339    fn initializes_canonical_system_roots_and_hands_database_to_kmap() {
340        let directory = std::env::temp_dir().join(format!("kennedy-roots-{}", Uuid::new_v4()));
341        std::fs::create_dir_all(&directory).unwrap();
342        let (kmap, roots) = open(
343            &directory.join("kweb"),
344            config(),
345            &directory.join("users.sqlite3"),
346        )
347        .unwrap();
348        assert_eq!(roots.user.to_string().len(), 8);
349        assert_eq!(roots.kennedy.to_string().len(), 8);
350        kmap.get_node(roots.user).unwrap();
351        kmap.get_node(roots.kennedy).unwrap();
352        drop(kmap);
353        std::fs::remove_dir_all(directory).unwrap();
354    }
355
356    #[test]
357    fn configured_web_handle_matching_is_case_and_at_sign_insensitive() {
358        assert!(handles_match("@Taek42", "taek42"));
359        assert!(!handles_match("@other", "@taek42"));
360    }
361
362    #[test]
363    fn provisioned_roots_keep_complete_application_descriptions() {
364        let (_, user_short, user_long) = root_metadata(None);
365        let (_, group_short, group_long) = root_metadata(Some("Group Root"));
366        assert!(user_short.contains("Telegram user"));
367        assert!(user_long.contains("associated with this Telegram user"));
368        assert!(group_short.contains("Telegram group"));
369        assert!(group_long.contains("shared in this Telegram group"));
370    }
371}