From f85039367cea877e4e1c8cc603c31d4f5c1e0295 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Mon, 18 May 2026 18:36:03 +0200 Subject: [PATCH] Live sync KV stores over Iroh --- AGENTS.md | 10 +- README.md | 6 +- crates/geth-cli/src/lib.rs | 26 +++ crates/geth-control/src/lib.rs | 84 +++++++++- crates/geth-kv/src/lib.rs | 7 + crates/geth-node/src/lib.rs | 291 ++++++++++++++++++++++++++++++++- crates/geth-store/src/lib.rs | 47 +++++- docs/architecture.md | 10 +- docs/roadmap.md | 5 + 9 files changed, 473 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index baf2f4c..f6c89a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -109,9 +109,9 @@ Roadmap items should be actionable and checkable: and `geth peer auth-check` over Iroh, signed peer-card LAN discovery payloads, authorized `geth cas fetch`, `geth ssh cert sync`, and `geth ssh revocation sync` over the Iroh control ALPN, a background SSH - metadata live-sync loop with per-peer cursors in `module_state`, untrusted - discovery-backend trait, custom relay-map config, and Iroh local-network - discovery toggle exist. + metadata and KV live-sync loop with per-peer cursors in `module_state`, + untrusted discovery-backend trait, custom relay-map config, and Iroh + local-network discovery toggle exist. - Canonical signed-operation envelopes exist for keychain/auth signature payloads. The keychain reducer builds an active identity view for admin keys, users, devices, nodes, agents, and endpoint bindings. @@ -138,7 +138,9 @@ Roadmap items should be actionable and checkable: present. The DB crate and daemon can extract typed read-only `crsql_changes` batches through `geth db changes` for future sync messages. cr-sqlite loading, applying remote changes, and sync are still roadmap work. -- KV stores support local SQLite-backed create/set/get. Iroh Documents +- KV stores support local SQLite-backed create/set/get plus authorized + `geth kv sync ` over Iroh. Background live-sync refreshes + local KV stores from known peers with per-peer/per-KV cursors. Iroh Documents replication and command-level prefix-capability enforcement are still roadmap work. The auth evaluator already understands `kv.write_prefix:` grants for `kv.write_key:` requests. diff --git a/README.md b/README.md index 99acda5..6bdd982 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,7 @@ The bootstrap implementation provides: `geth db changes ` for future sync - local SQLite-backed KV commands: `geth kv create/set/get`; `kv set` accepts `--subject ` to exercise local capability checks for non-local - callers + callers; `geth kv sync ` pulls authorized remote updates - local JSON document commands: `geth document create/status/set/get` - local daemon-lifetime pubsub snapshots: `geth pubsub pub/sub` - SSH certificate flow metadata: @@ -142,6 +142,10 @@ approval/signing workflows. While the daemon is running, it also performs a background live-sync tick for known peers every 30 seconds. Live-sync stores per-peer high-water cursors in local metadata so repeated ticks request only newer SSH certificate-flow and revocation records. +Named KV stores participate in the same live-sync loop once they exist locally: +manual `geth kv sync ` and background ticks require `kv.read` +on the remote `resource:kv:` and import only remote entries that are not +older than the local value. Importing or pinging a peer card never grants capabilities by itself. When `[iroh].local_discovery = true`, the daemon also advertises and discovers signed peer cards on LAN using a geth-specific mDNS TXT payload. That payload is diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index e3546d2..1389a19 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -304,6 +304,10 @@ pub enum KvCommand { name: String, key: String, }, + Sync { + node: String, + name: String, + }, } #[derive(Debug, Subcommand)] @@ -617,6 +621,7 @@ fn request_for_command(command: Command) -> Result { subject, }, KvCommand::Get { name, key } => ControlRequest::KvGet { name, key }, + KvCommand::Sync { node, name } => ControlRequest::KvSync { node, name }, }, Command::Pubsub { command } => match command { PubsubCommand::Pub { topic, message } => ControlRequest::PubsubPub { topic, message }, @@ -1309,6 +1314,27 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { println!("not found"); } } + ControlResponse::KvSynced { + peer_node_id, + peer_agent_id, + endpoint_id, + name, + entries_imported, + allowed, + reason, + note, + } => { + if allowed { + println!("synced kv {name} from {peer_node_id}: {entries_imported} entries"); + } else { + println!("kv sync denied for {name} by {peer_node_id}"); + } + println!("agent: {peer_agent_id}"); + println!("endpoint: {endpoint_id}"); + println!("allowed: {allowed}"); + println!("reason: {reason}"); + println!("note: {note}"); + } ControlResponse::DocumentCreated { document } => { println!("created document: {}", document.name); println!("id: {}", document.id); diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index ae4e97f..ba1f9e0 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -4,7 +4,7 @@ use geth_db::{CrSqliteChangeBatch, DbResource}; use geth_discovery::{DiscoveredPeer, PeerCard}; use geth_document::{DocumentResource, DocumentState}; use geth_keychain::KeychainOp; -use geth_kv::{KvEntry, KvResource}; +use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_pipe::{PipeConnection, PipeListener}; use geth_pubsub::PubsubMessage; use geth_resource::ResourceDescriptor; @@ -196,6 +196,10 @@ pub enum ControlRequest { name: String, key: String, }, + KvSync { + node: String, + name: String, + }, DocumentCreate { name: String, }, @@ -422,6 +426,16 @@ pub enum ControlResponse { KvGet { entry: Option, }, + KvSynced { + peer_node_id: String, + peer_agent_id: String, + endpoint_id: String, + name: String, + entries_imported: usize, + allowed: bool, + reason: String, + note: String, + }, DocumentCreated { document: DocumentResource, }, @@ -521,6 +535,12 @@ pub enum PeerControlRequest { since_ms: i64, nonce: String, }, + KvSync { + peer_card: PeerCard, + name: String, + since_ms: i64, + nonce: String, + }, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -589,6 +609,20 @@ pub enum PeerControlResponse { nonce: String, note: String, }, + KvSynced { + node_id: String, + agent_id: String, + endpoint_id: String, + remote_endpoint_id: String, + name: String, + entries: Vec, + high_water_ms: i64, + allowed: bool, + reason: String, + evaluated_ops: usize, + nonce: String, + note: String, + }, Error { message: String, }, @@ -747,6 +781,30 @@ mod tests { response ); + let request = ControlRequest::KvSync { + node: "node:peer".to_owned(), + name: "prefs".to_owned(), + }; + assert_eq!( + decode_request(&encode_request(&request).expect("encode")).expect("decode"), + request + ); + + let response = ControlResponse::KvSynced { + peer_node_id: "node:peer".to_owned(), + peer_agent_id: "agent:peer".to_owned(), + endpoint_id: "endpoint:peer".to_owned(), + name: "prefs".to_owned(), + entries_imported: 1, + allowed: true, + reason: "direct grant".to_owned(), + note: "kv sync".to_owned(), + }; + assert_eq!( + decode_response(&encode_response(&response).expect("encode")).expect("decode"), + response + ); + let request = ControlRequest::DocumentSet { name: "notes".to_owned(), state_json: r#"{"title":"notes"}"#.to_owned(), @@ -908,5 +966,29 @@ mod tests { .expect("decode"), response ); + + let response = PeerControlResponse::KvSynced { + node_id: "node:peer".to_owned(), + agent_id: "agent:peer".to_owned(), + endpoint_id: "endpoint:peer".to_owned(), + remote_endpoint_id: "endpoint:caller".to_owned(), + name: "prefs".to_owned(), + entries: vec![KvSyncEntry { + key: "apps/foo/theme".to_owned(), + value: "dark".to_owned(), + updated_at_ms: 42, + }], + high_water_ms: 43, + allowed: true, + reason: "direct grant".to_owned(), + evaluated_ops: 1, + nonce: "nonce".to_owned(), + note: "kv sync".to_owned(), + }; + assert_eq!( + decode_peer_response(&encode_peer_response(&response).expect("encode")) + .expect("decode"), + response + ); } } diff --git a/crates/geth-kv/src/lib.rs b/crates/geth-kv/src/lib.rs index f5e8f8d..ad88c1e 100644 --- a/crates/geth-kv/src/lib.rs +++ b/crates/geth-kv/src/lib.rs @@ -16,6 +16,13 @@ pub struct KvEntry { pub value: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct KvSyncEntry { + pub key: String, + pub value: String, + pub updated_at_ms: i64, +} + #[derive(Debug, thiserror::Error)] pub enum KvError { #[error("invalid kv store name: {0}")] diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index dbd5ccf..38db66f 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -20,7 +20,7 @@ use geth_discovery::{ use geth_document::{DocumentResource, DocumentState}; use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; use geth_keychain::{KeychainOp, KeychainOpKind}; -use geth_kv::{KvEntry, KvResource}; +use geth_kv::{KvEntry, KvResource, KvSyncEntry}; use geth_pipe::{PipeConnection, PipeListener}; use geth_pubsub::PubsubMessage; use geth_resource::ResourceDescriptor; @@ -259,6 +259,10 @@ pub async fn handle_request_async( ControlRequest::SshRevocationSync { node: peer_node } => { ssh_revocation_sync_from_peer(node, &peer_node).await } + ControlRequest::KvSync { + node: peer_node, + name, + } => kv_sync_from_peer(node, &peer_node, &name).await, other => handle_request(node, other), } } @@ -534,7 +538,8 @@ async fn peer_ping(node: &LocalNode, peer_node: &str) -> Result Err(NodeError::IrohPeer( + | PeerControlResponse::SshRevocationSynced { .. } + | PeerControlResponse::KvSynced { .. } => Err(NodeError::IrohPeer( "peer returned wrong response type to ping request".to_owned(), )), } @@ -641,7 +646,8 @@ async fn peer_auth_check( )), PeerControlResponse::CasFetched { .. } | PeerControlResponse::SshCertSynced { .. } - | PeerControlResponse::SshRevocationSynced { .. } => Err(NodeError::IrohPeer( + | PeerControlResponse::SshRevocationSynced { .. } + | PeerControlResponse::KvSynced { .. } => Err(NodeError::IrohPeer( "peer returned wrong response type to auth-check request".to_owned(), )), } @@ -777,7 +783,8 @@ async fn cas_fetch_from_peer( PeerControlResponse::Pong { .. } | PeerControlResponse::AuthChecked { .. } | PeerControlResponse::SshCertSynced { .. } - | PeerControlResponse::SshRevocationSynced { .. } => Err(NodeError::IrohPeer( + | PeerControlResponse::SshRevocationSynced { .. } + | PeerControlResponse::KvSynced { .. } => Err(NodeError::IrohPeer( "peer returned wrong response type to CAS fetch".to_owned(), )), } @@ -919,6 +926,90 @@ async fn ssh_revocation_sync_from_peer( } } +async fn kv_sync_from_peer( + node: &LocalNode, + peer_node: &str, + name: &str, +) -> Result { + geth_kv::validate_kv_name(name).map_err(|_| NodeError::InvalidKvName(name.to_owned()))?; + let stream = format!("kv:{name}"); + let since_ms = + load_live_sync_cursor(&Store::open(&node.paths.metadata_db())?, peer_node, &stream)?; + let response = request_peer_control(node, peer_node, "kv-sync", |peer_card, nonce| { + PeerControlRequest::KvSync { + peer_card, + name: name.to_owned(), + since_ms, + nonce, + } + }) + .await?; + match response { + PeerControlResponse::KvSynced { + node_id, + agent_id, + endpoint_id, + name: response_name, + entries, + high_water_ms, + allowed, + reason, + note, + .. + } if response_name == name => { + if !allowed { + return Ok(ControlResponse::KvSynced { + peer_node_id: node_id, + peer_agent_id: agent_id, + endpoint_id, + name: response_name, + entries_imported: 0, + allowed, + reason, + note, + }); + } + let store = Store::open(&node.paths.metadata_db())?; + let kv = ensure_local_kv_store(&store, name)?; + let mut entries_imported = 0; + for entry in entries { + geth_kv::validate_kv_key(&entry.key) + .map_err(|_| NodeError::InvalidKvKey(entry.key.clone()))?; + let should_import = store + .get_kv_entry(&kv.kv_id, &entry.key)? + .is_none_or(|local| entry.updated_at_ms >= local.updated_at_ms); + if should_import { + store.set_kv_entry(&StoredKvEntry { + kv_id: kv.kv_id.clone(), + key: entry.key, + value: entry.value, + updated_at_ms: entry.updated_at_ms, + })?; + entries_imported += 1; + } + } + store_live_sync_cursor(&store, peer_node, &stream, high_water_ms)?; + Ok(ControlResponse::KvSynced { + peer_node_id: node_id, + peer_agent_id: agent_id, + endpoint_id, + name: response_name, + entries_imported, + allowed, + reason, + note, + }) + } + PeerControlResponse::KvSynced { .. } => Err(NodeError::IrohPeer( + "peer KV sync response did not match request".to_owned(), + )), + PeerControlResponse::Error { message } => Err(NodeError::IrohPeer(message)), + _ => Err(NodeError::IrohPeer( + "peer returned wrong response type to KV sync".to_owned(), + )), + } +} + fn live_sync_cursor_key(peer_node: &str, stream: &str) -> String { format!("live-sync:{peer_node}:{stream}") } @@ -1011,6 +1102,10 @@ async fn request_peer_control( | PeerControlResponse::SshRevocationSynced { nonce: response_nonce, .. + } + | PeerControlResponse::KvSynced { + nonce: response_nonce, + .. } if response_nonce == &nonce => Ok(response), PeerControlResponse::Error { .. } => Ok(response), _ => Err(NodeError::IrohPeer(format!( @@ -1058,6 +1153,7 @@ async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> { return Ok(()); } let peers = Store::open(&node.paths.metadata_db())?.list_peer_cards()?; + let kv_stores = Store::open(&node.paths.metadata_db())?.list_kv_stores()?; for peer in peers { if let Err(error) = ssh_cert_sync_from_peer(node, &peer.peer_id).await { tracing::debug!(peer = %peer.peer_id, %error, "SSH cert live sync failed"); @@ -1065,6 +1161,11 @@ async fn run_live_sync_once(node: &LocalNode) -> Result<(), NodeError> { if let Err(error) = ssh_revocation_sync_from_peer(node, &peer.peer_id).await { tracing::debug!(peer = %peer.peer_id, %error, "SSH revocation live sync failed"); } + for kv in &kv_stores { + if let Err(error) = kv_sync_from_peer(node, &peer.peer_id, &kv.name).await { + tracing::debug!(peer = %peer.peer_id, kv = %kv.name, %error, "KV live sync failed"); + } + } } Ok(()) } @@ -1334,6 +1435,68 @@ async fn handle_iroh_control_connection( note: "SSH revocation sync authenticated endpoint/card binding and required ssh_revocation.sync on resource:ssh:revocations".to_owned(), } } + PeerControlRequest::KvSync { + peer_card, + name, + since_ms, + nonce, + } => { + geth_kv::validate_kv_name(&name).map_err(|_| NodeError::InvalidKvName(name.clone()))?; + peer_card.validate_candidate()?; + ensure_peer_card_matches_endpoint(&peer_card, &remote_endpoint_id)?; + let discovered = DiscoveredPeer::candidate( + peer_card.clone(), + UnixMillis(geth_store::now_ms()), + DiscoverySource::PeerExchange, + )?; + let store = Store::open(&node.paths.metadata_db())?; + store.upsert_peer_card(&StoredPeerCard { + peer_id: peer_card.node_id.to_string(), + card_json: serde_json::to_string(&peer_card)?, + updated_at_ms: discovered.discovered_at.0, + })?; + if let Some(kv) = store.get_kv_store_by_name(&name)? { + let capability = "kv.read".to_owned(); + let high_water_ms = geth_store::now_ms(); + let explanation = geth_auth::explain_auth_ops( + &load_auth_ops_for_resource(&store, &kv.resource_id)?, + PrincipalId::new(peer_card.node_id.to_string()), + ResourceId::new(kv.resource_id.clone()), + Capability::new(capability), + ); + let entries = if explanation.allowed { + store + .list_kv_entries_since(&kv.kv_id, since_ms)? + .into_iter() + .map(|entry| KvSyncEntry { + key: entry.key, + value: entry.value, + updated_at_ms: entry.updated_at_ms, + }) + .collect() + } else { + Vec::new() + }; + PeerControlResponse::KvSynced { + node_id: node.node_id.clone(), + agent_id: node.agent_id.clone(), + endpoint_id: node.iroh_status.endpoint_id.clone().unwrap_or_default(), + remote_endpoint_id, + name, + entries, + high_water_ms, + allowed: explanation.allowed, + reason: explanation.reason, + evaluated_ops: explanation.evaluated_ops, + nonce, + note: "KV sync authenticated endpoint/card binding and required kv.read on the remote KV resource".to_owned(), + } + } else { + PeerControlResponse::Error { + message: format!("kv store not found: {name}"), + } + } + } }; send.write_all(geth_control::encode_peer_response(&response)?.as_bytes()) .await @@ -1472,6 +1635,7 @@ pub fn handle_request( ControlRequest::CasFetch { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::SshCertSync { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::SshRevocationSync { .. } => Err(NodeError::IrohEndpointUnavailable), + ControlRequest::KvSync { .. } => Err(NodeError::IrohEndpointUnavailable), ControlRequest::ResourceList => Ok(ControlResponse::ResourceList { resources: store .list_resources()? @@ -2482,6 +2646,19 @@ fn kv_entry_from_stored(stored: StoredKvEntry) -> KvEntry { } } +fn ensure_local_kv_store(store: &Store, name: &str) -> Result { + if let Some(kv) = store.get_kv_store_by_name(name)? { + return Ok(kv); + } + let stored = StoredKvStore { + kv_id: format!("kv:{name}"), + resource_id: format!("resource:kv:{name}"), + name: name.to_owned(), + }; + store.insert_kv_store(&stored)?; + Ok(stored) +} + fn document_resource_from_stored(stored: &StoredDocumentResource) -> DocumentResource { DocumentResource { id: stored.document_id.clone().into(), @@ -2985,6 +3162,23 @@ mod tests { ControlResponse::SshRevocationAdded { revocation } => revocation.id, other => panic!("unexpected SSH revocation response: {other:?}"), }; + handle_request( + &right, + ControlRequest::KvCreate { + name: "prefs".to_owned(), + }, + ) + .expect("right kv create"); + handle_request( + &right, + ControlRequest::KvSet { + name: "prefs".to_owned(), + key: "apps/foo/theme".to_owned(), + value: "dark".to_owned(), + subject: None, + }, + ) + .expect("right kv set"); let ping = handle_request_async( &left, @@ -3063,6 +3257,29 @@ mod tests { other => panic!("unexpected denied CAS fetch response: {other:?}"), } + let denied_kv_sync = handle_request_async( + &left, + ControlRequest::KvSync { + node: right_card.node_id.to_string(), + name: "prefs".to_owned(), + }, + ) + .await + .expect("denied kv sync"); + match denied_kv_sync { + ControlResponse::KvSynced { + allowed, + entries_imported, + reason, + .. + } => { + assert!(!allowed); + assert_eq!(entries_imported, 0); + assert!(reason.contains("no active direct or group grant")); + } + other => panic!("unexpected denied KV sync response: {other:?}"), + } + handle_request( &right, ControlRequest::AuthGrant { @@ -3073,6 +3290,16 @@ mod tests { }, ) .expect("grant left peer"); + handle_request( + &right, + ControlRequest::AuthGrant { + subject: left.node_id.clone(), + resource: "resource:kv:prefs".to_owned(), + capability: "kv.read".to_owned(), + grant_id: Some("grant:left-kv-read".to_owned()), + }, + ) + .expect("grant left kv read"); let allowed = handle_request_async( &left, @@ -3131,6 +3358,45 @@ mod tests { other => panic!("unexpected allowed CAS fetch response: {other:?}"), } + let kv_synced = handle_request_async( + &left, + ControlRequest::KvSync { + node: right_card.node_id.to_string(), + name: "prefs".to_owned(), + }, + ) + .await + .expect("allowed kv sync"); + match kv_synced { + ControlResponse::KvSynced { + allowed, + entries_imported, + reason, + note, + .. + } => { + assert!(allowed); + assert_eq!(entries_imported, 1); + assert!(reason.contains("direct grant")); + assert!(note.contains("kv.read")); + } + other => panic!("unexpected allowed KV sync response: {other:?}"), + } + let synced_theme = handle_request( + &left, + ControlRequest::KvGet { + name: "prefs".to_owned(), + key: "apps/foo/theme".to_owned(), + }, + ) + .expect("left kv get synced"); + match synced_theme { + ControlResponse::KvGet { entry: Some(entry) } => { + assert_eq!(entry.value, "dark"); + } + other => panic!("unexpected synced KV get response: {other:?}"), + } + let denied_cert_sync = handle_request_async( &left, ControlRequest::SshCertSync { @@ -3280,6 +3546,16 @@ mod tests { ControlResponse::SshRevocationAdded { revocation } => revocation.id, other => panic!("unexpected second SSH revocation response: {other:?}"), }; + handle_request( + &right, + ControlRequest::KvSet { + name: "prefs".to_owned(), + key: "apps/foo/live".to_owned(), + value: "synced".to_owned(), + subject: None, + }, + ) + .expect("right second kv set"); run_live_sync_once(&left) .await @@ -3299,6 +3575,13 @@ mod tests { .iter() .any(|revocation| revocation.revocation_id == second_revocation_id.as_str()) ); + assert_eq!( + left_store + .get_kv_entry("kv:prefs", "apps/foo/live") + .expect("get live-synced kv") + .map(|entry| entry.value), + Some("synced".to_owned()) + ); left_endpoint.shutdown().await; right_endpoint.shutdown().await; diff --git a/crates/geth-store/src/lib.rs b/crates/geth-store/src/lib.rs index bbe548c..6001116 100644 --- a/crates/geth-store/src/lib.rs +++ b/crates/geth-store/src/lib.rs @@ -335,6 +335,21 @@ impl Store { } } + pub fn list_kv_stores(&self) -> Result, StoreError> { + let mut stmt = self + .conn + .prepare("SELECT kv_id, resource_id, name FROM kv_stores ORDER BY name")?; + let rows = stmt.query_map([], |row| { + Ok(StoredKvStore { + kv_id: row.get(0)?, + resource_id: row.get(1)?, + name: row.get(2)?, + }) + })?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + pub fn set_kv_entry(&self, entry: &StoredKvEntry) -> Result<(), StoreError> { self.conn.execute( r#"INSERT OR REPLACE INTO kv_entries(kv_id, key, value, updated_at_ms) @@ -366,6 +381,29 @@ impl Store { } } + pub fn list_kv_entries_since( + &self, + kv_id: &str, + since_ms: i64, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + r#"SELECT kv_id, key, value, updated_at_ms + FROM kv_entries + WHERE kv_id = ?1 AND updated_at_ms >= ?2 + ORDER BY updated_at_ms, key"#, + )?; + let rows = stmt.query_map(params![kv_id, since_ms], |row| { + Ok(StoredKvEntry { + kv_id: row.get(0)?, + key: row.get(1)?, + value: row.get(2)?, + updated_at_ms: row.get(3)?, + }) + })?; + rows.collect::, _>>() + .map_err(StoreError::from) + } + pub fn insert_document_resource( &self, document: &StoredDocumentResource, @@ -1518,6 +1556,7 @@ mod tests { store.get_kv_store_by_name("prefs").expect("get kv"), Some(kv.clone()) ); + assert_eq!(store.list_kv_stores().expect("list kv"), vec![kv.clone()]); let entry = StoredKvEntry { kv_id: kv.kv_id.clone(), @@ -1530,7 +1569,13 @@ mod tests { store .get_kv_entry("kv:prefs", "apps/foo/theme") .expect("get entry"), - Some(entry) + Some(entry.clone()) + ); + assert_eq!( + store + .list_kv_entries_since("kv:prefs", 1) + .expect("list entries since"), + vec![entry] ); } diff --git a/docs/architecture.md b/docs/architecture.md index 859730e..7b9b713 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -141,8 +141,14 @@ are future work. through `kv create/set/get`. `kv set --subject ` evaluates local auth ops for `kv.write_key:` so prefix grants can be tested before networked callers exist. The local node/agent retains owner access for administration. -Iroh Documents namespaces, remote caller identity, and replication are future -work. +Iroh Documents namespaces remain the target backend, but the bootstrap can sync +named KV stores over the protected Iroh control ALPN. `geth kv sync +` requires `kv.read` on the remote `resource:kv:`, transfers entries +at or beyond a per-peer/per-KV high-water cursor, and imports only values that +are at least as new as the local entry timestamp. The daemon background +live-sync loop runs the same KV sync for local KV stores and known peers. +Private value encryption should use resource secret epochs before payloads are +exposed to remote peers. `geth-document` currently registers local document resources and stores validated JSON state in the local SQLite metadata store through diff --git a/docs/roadmap.md b/docs/roadmap.md index a6ba182..62214ad 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -264,6 +264,11 @@ authorization and durable-state boundaries clear. - `[x]` Tests cover allowed and denied prefix-scoped KV write explanations. - `[x]` `geth kv set --subject ` enforces local capability decisions for non-local test callers. + - `[x]` `geth kv sync ` pulls authorized updates from an + imported peer over Iroh. + - `[x]` Remote KV sync requires `kv.read` on `resource:kv:`. + - `[x]` Background live-sync refreshes local KV stores from known peers using + per-peer/per-KV high-water cursors. - `[ ]` KV metadata is replicated through Iroh Documents. - `[~]` Iroh-gossip pubsub integration.