1#![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#[derive(Clone, Copy, Debug)]
19pub struct SystemRoots {
20 pub user: NodeId,
22 pub kennedy: NodeId,
24}
25
26pub 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#[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 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 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 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 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}