diff --git a/AGENTS.md b/AGENTS.md index df5dcd6..168f81c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -123,6 +123,8 @@ Roadmap items should be actionable and checkable: and sync are still roadmap work. - KV stores support local SQLite-backed create/set/get. Iroh Documents replication and prefix-capability enforcement are still roadmap work. +- Document resources can be registered locally with empty JSON state and + local-only status. Automerge editing/state and sync are still roadmap work. - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future diff --git a/Cargo.lock b/Cargo.lock index d3234f7..0b00b02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1117,6 +1117,7 @@ version = "0.1.0" dependencies = [ "geth-auth", "geth-db", + "geth-document", "geth-keychain", "geth-kv", "geth-resource", @@ -1169,6 +1170,7 @@ version = "0.1.0" dependencies = [ "geth-types", "serde", + "thiserror 2.0.18", ] [[package]] @@ -1214,6 +1216,7 @@ dependencies = [ "geth-control", "geth-crypto", "geth-db", + "geth-document", "geth-iroh", "geth-keychain", "geth-kv", diff --git a/README.md b/README.md index c454413..5366667 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ The bootstrap implementation provides: - local DB resource registration: `geth db add ` and `geth db status ` - local SQLite-backed KV commands: `geth kv create/set/get` +- local document resource registration: `geth document create/status` - SSH certificate flow metadata: - `geth ssh cert request --public-key --principal ` - `geth ssh cert requests` @@ -94,8 +95,8 @@ The bootstrap implementation provides: - `geth ssh revocation list` - `geth ssh revocation export --out ` -Other command groups exist as explicit stubs: `pipe`, `document`, `pubsub`, -`secret`, and `ssh`. +Other command groups exist as explicit stubs: `pipe`, `pubsub`, `secret`, and +`ssh`. ## Resource Modules diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index 5044c09..fb029e7 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -424,9 +424,9 @@ fn request_for_command(command: Command) -> Result { DbCommand::Add { name, path } => ControlRequest::DbAdd { name, path }, DbCommand::Status { name } => ControlRequest::DbStatus { name }, }, - Command::Document { command } => ControlRequest::ModuleStub { - module: "document".to_owned(), - command: format!("{command:?}"), + Command::Document { command } => match command { + DocumentCommand::Create { name } => ControlRequest::DocumentCreate { name }, + DocumentCommand::Status { name } => ControlRequest::DocumentStatus { name }, }, Command::Ssh { command } => match command { SshCommand::Proxy { node } => ControlRequest::ModuleStub { @@ -783,6 +783,20 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("not found"); } } + ControlResponse::DocumentCreated { document } => { + println!("created document: {}", document.name); + println!("id: {}", document.id); + println!("resource: {}", document.resource); + println!("sync_status: {}", document.sync_status); + println!("state_bytes: {}", document.state_bytes); + } + ControlResponse::DocumentStatus { document } => { + println!("document: {}", document.name); + println!("id: {}", document.id); + println!("resource: {}", document.resource); + println!("sync_status: {}", document.sync_status); + println!("state_bytes: {}", document.state_bytes); + } ControlResponse::NotImplemented { module, command } => { println!("{module} {command}: not implemented yet"); } diff --git a/crates/geth-control/Cargo.toml b/crates/geth-control/Cargo.toml index e0271f0..196aa31 100644 --- a/crates/geth-control/Cargo.toml +++ b/crates/geth-control/Cargo.toml @@ -11,6 +11,7 @@ serde_json.workspace = true thiserror.workspace = true geth-auth = { path = "../geth-auth" } geth-db = { path = "../geth-db" } +geth-document = { path = "../geth-document" } geth-keychain = { path = "../geth-keychain" } geth-kv = { path = "../geth-kv" } geth-resource = { path = "../geth-resource" } diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index 7e8244d..2814e1e 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -1,5 +1,6 @@ use geth_auth::{AuthExplanation, AuthOp}; use geth_db::DbResource; +use geth_document::DocumentResource; use geth_keychain::KeychainOp; use geth_kv::{KvEntry, KvResource}; use geth_resource::ResourceDescriptor; @@ -111,6 +112,12 @@ pub enum ControlRequest { name: String, key: String, }, + DocumentCreate { + name: String, + }, + DocumentStatus { + name: String, + }, ModuleStub { module: String, command: String, @@ -205,6 +212,12 @@ pub enum ControlResponse { KvGet { entry: Option, }, + DocumentCreated { + document: DocumentResource, + }, + DocumentStatus { + document: DocumentResource, + }, NotImplemented { module: String, command: String, diff --git a/crates/geth-document/Cargo.toml b/crates/geth-document/Cargo.toml index bb16f82..27f4b00 100644 --- a/crates/geth-document/Cargo.toml +++ b/crates/geth-document/Cargo.toml @@ -7,4 +7,5 @@ license.workspace = true [dependencies] serde.workspace = true +thiserror.workspace = true geth-types = { path = "../geth-types" } diff --git a/crates/geth-document/src/lib.rs b/crates/geth-document/src/lib.rs index 672cfdb..5723d61 100644 --- a/crates/geth-document/src/lib.rs +++ b/crates/geth-document/src/lib.rs @@ -6,9 +6,43 @@ pub struct DocumentResource { pub id: DocumentId, pub resource: ResourceId, pub name: String, + pub sync_status: String, + pub state_bytes: u64, +} + +#[derive(Debug, thiserror::Error)] +pub enum DocumentError { + #[error("invalid document name: {0}")] + InvalidName(String), +} + +pub fn validate_document_name(name: &str) -> Result<(), DocumentError> { + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) + { + return Err(DocumentError::InvalidName(name.to_owned())); + } + Ok(()) } #[must_use] pub fn automerge_roadmap() -> &'static str { "future documents use Automerge sync over Iroh with resource-local authorization" } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn document_name_validation_rejects_paths_and_empty_names() { + assert!(validate_document_name("notes").is_ok()); + assert!(validate_document_name("notes.v1").is_ok()); + assert!(validate_document_name("").is_err()); + assert!(validate_document_name("../notes").is_err()); + assert!(validate_document_name("notes/main").is_err()); + assert!(validate_document_name("notes main").is_err()); + } +} diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index 426dc8b..41aa200 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -16,6 +16,7 @@ geth-config = { path = "../geth-config" } geth-control = { path = "../geth-control" } geth-crypto = { path = "../geth-crypto" } geth-db = { path = "../geth-db" } +geth-document = { path = "../geth-document" } geth-iroh = { path = "../geth-iroh" } geth-keychain = { path = "../geth-keychain" } geth-kv = { path = "../geth-kv" } diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index 8064042..f4a3ae0 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -9,6 +9,7 @@ use geth_control::{ }; use geth_crypto::AgentKey; use geth_db::DbResource; +use geth_document::DocumentResource; use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; use geth_keychain::{KeychainOp, KeychainOpKind}; use geth_kv::{KvEntry, KvResource}; @@ -19,8 +20,8 @@ use geth_ssh_identity::{ certificate_id, revocation_id, ssh_public_key_fingerprint, }; use geth_store::{ - Store, StoredAuthOp, StoredDbResource, StoredKeychainOp, StoredKvEntry, StoredKvStore, - StoredResource, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, + Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredKeychainOp, StoredKvEntry, + StoredKvStore, StoredResource, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, }; use geth_types::{ AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, @@ -62,6 +63,10 @@ pub enum NodeError { InvalidKvKey(String), #[error("kv store not found: {0}")] KvNotFound(String), + #[error("invalid document name: {0}")] + InvalidDocumentName(String), + #[error("document not found: {0}")] + DocumentNotFound(String), #[error("invalid ssh certificate kind: {0}")] InvalidSshCertKind(String), #[error("invalid ssh certificate request status: {0}")] @@ -651,6 +656,40 @@ pub fn handle_request( .map(kv_entry_from_stored); Ok(ControlResponse::KvGet { entry }) } + ControlRequest::DocumentCreate { name } => { + geth_document::validate_document_name(&name) + .map_err(|_| NodeError::InvalidDocumentName(name.clone()))?; + let resource_id = format!("resource:document:{name}"); + let document_id = format!("document:{name}"); + let resource = StoredResource { + resource_id: resource_id.clone(), + kind: ResourceKind::Document.to_string(), + name: name.clone(), + status: "active".to_owned(), + }; + store.insert_resource(&resource)?; + let stored = StoredDocumentResource { + document_id, + resource_id, + name, + state_json: "{}".to_owned(), + updated_at_ms: geth_store::now_ms(), + }; + store.insert_document_resource(&stored)?; + Ok(ControlResponse::DocumentCreated { + document: document_resource_from_stored(&stored), + }) + } + ControlRequest::DocumentStatus { name } => { + geth_document::validate_document_name(&name) + .map_err(|_| NodeError::InvalidDocumentName(name.clone()))?; + let stored = store + .get_document_resource_by_name(&name)? + .ok_or_else(|| NodeError::DocumentNotFound(name.clone()))?; + Ok(ControlResponse::DocumentStatus { + document: document_resource_from_stored(&stored), + }) + } ControlRequest::ModuleStub { module, command } => { Ok(ControlResponse::NotImplemented { module, command }) } @@ -715,6 +754,16 @@ fn kv_entry_from_stored(stored: StoredKvEntry) -> KvEntry { } } +fn document_resource_from_stored(stored: &StoredDocumentResource) -> DocumentResource { + DocumentResource { + id: stored.document_id.clone().into(), + resource: stored.resource_id.clone().into(), + name: stored.name.clone(), + sync_status: "local-only".to_owned(), + state_bytes: stored.state_json.len() as u64, + } +} + fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> { store.insert_auth_op(&StoredAuthOp { op_id: op.id.to_string(), diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index 351fb66..6375844 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -120,7 +120,9 @@ impl Store { CREATE TABLE IF NOT EXISTS document_resources ( document_id TEXT PRIMARY KEY, resource_id TEXT NOT NULL, - name TEXT NOT NULL + name TEXT NOT NULL, + state_json TEXT NOT NULL DEFAULT '{}', + updated_at_ms INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS peer_cards ( peer_id TEXT PRIMARY KEY, @@ -339,6 +341,47 @@ impl Store { } } + pub fn insert_document_resource( + &self, + document: &StoredDocumentResource, + ) -> Result<(), StoreError> { + self.conn.execute( + r#"INSERT OR REPLACE INTO document_resources( + document_id, resource_id, name, state_json, updated_at_ms + ) VALUES (?1, ?2, ?3, ?4, ?5)"#, + params![ + document.document_id, + document.resource_id, + document.name, + document.state_json, + document.updated_at_ms + ], + )?; + Ok(()) + } + + pub fn get_document_resource_by_name( + &self, + name: &str, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT document_id, resource_id, name, state_json, updated_at_ms + FROM document_resources WHERE name = ?1"#, + )?; + let mut rows = stmt.query(params![name])?; + if let Some(row) = rows.next()? { + Ok(Some(StoredDocumentResource { + document_id: row.get(0)?, + resource_id: row.get(1)?, + name: row.get(2)?, + state_json: row.get(3)?, + updated_at_ms: row.get(4)?, + })) + } else { + Ok(None) + } + } + pub fn record_cas_object( &self, hash: &str, @@ -701,6 +744,15 @@ pub struct StoredKvEntry { pub updated_at_ms: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StoredDocumentResource { + pub document_id: String, + pub resource_id: String, + pub name: String, + pub state_json: String, + pub updated_at_ms: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CasObject { pub hash: String, @@ -991,4 +1043,33 @@ mod tests { Some(entry) ); } + + #[test] + fn document_resource_roundtrip_by_name() { + let store = Store::open_memory().expect("open"); + let resource = StoredResource { + resource_id: "resource:document:notes".to_owned(), + kind: "document".to_owned(), + name: "notes".to_owned(), + status: "active".to_owned(), + }; + store.insert_resource(&resource).expect("insert resource"); + let document = StoredDocumentResource { + document_id: "document:notes".to_owned(), + resource_id: resource.resource_id, + name: resource.name, + state_json: "{}".to_owned(), + updated_at_ms: 1, + }; + store + .insert_document_resource(&document) + .expect("insert document"); + + assert_eq!( + store + .get_document_resource_by_name("notes") + .expect("get document"), + Some(document) + ); + } } diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 2fccd8a..cfa0632 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -551,6 +551,64 @@ fn kv_create_set_get_use_local_store() { ); } +#[test] +fn document_create_and_status_use_local_store() { + let home = tempfile::tempdir().expect("tempdir"); + let paths = geth_config::GethPaths::from_home(home.path()); + let node = geth_node::init_node(&paths).expect("init node"); + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::DocumentCreate { + name: "notes".to_owned(), + }, + ) + .expect("create document"); + match response { + geth_control::ControlResponse::DocumentCreated { document } => { + assert_eq!(document.name, "notes"); + assert_eq!(document.sync_status, "local-only"); + assert_eq!(document.state_bytes, 2); + } + other => panic!("unexpected response: {other:?}"), + } + + let response = geth_node::handle_request( + &node, + geth_control::ControlRequest::DocumentStatus { + name: "notes".to_owned(), + }, + ) + .expect("document status"); + match response { + geth_control::ControlResponse::DocumentStatus { document } => { + assert_eq!(document.id.to_string(), "document:notes"); + assert_eq!(document.resource.to_string(), "resource:document:notes"); + assert_eq!(document.state_bytes, 2); + } + other => panic!("unexpected response: {other:?}"), + } + + assert!( + geth_node::handle_request( + &node, + geth_control::ControlRequest::DocumentCreate { + name: "../bad".to_owned(), + }, + ) + .is_err() + ); + assert!( + geth_node::handle_request( + &node, + geth_control::ControlRequest::DocumentStatus { + name: "missing".to_owned(), + }, + ) + .is_err() + ); +} + #[test] fn ssh_cert_request_approval_and_revocation_export_use_local_state() { let home = tempfile::tempdir().expect("tempdir"); diff --git a/docs/architecture.md b/docs/architecture.md index 3b22684..52a2550 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -101,8 +101,12 @@ loading, change extraction, and DB sync are future work. through `kv create/set/get`. Iroh Documents namespaces, prefix authorization enforcement, and replication are future work. -`geth-document`, `geth-pubsub`, `geth-pipe`, and `geth-ssh-proxy` currently -define types, command shape, and roadmap stubs. +`geth-document` currently registers local document resources with an empty JSON +state placeholder and local-only sync status. Automerge state, editing, and sync +are future work. + +`geth-pubsub`, `geth-pipe`, and `geth-ssh-proxy` currently define types, +command shape, and roadmap stubs. `geth-ssh-identity` defines SSH trust namespaces plus certificate request, approval, certificate import, and revocation-list data models. The bootstrap diff --git a/docs/roadmap.md b/docs/roadmap.md index df28808..153cca8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -292,11 +292,12 @@ Automerge documents. - Schema mismatch is detected before applying changes. - Optional CAS-backed snapshots or batches are documented if used. -- `[ ]` Automerge document resource. +- `[~]` Automerge document resource. Acceptance criteria: - - `geth document create/status` works for local documents. - - Document state is stored durably. - - Tests cover create, update, save, and reload. + - `[x]` `geth document create/status` works for local documents. + - `[x]` Document metadata and empty JSON state are stored durably. + - `[ ]` Automerge document state is stored durably. + - `[ ]` Tests cover create, update, save, and reload. - `[ ]` Automerge sync over Iroh. Acceptance criteria: