diff --git a/Cargo.lock b/Cargo.lock index 59178c4..b3e0417 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1599,6 +1599,7 @@ name = "geth-node" version = "0.1.0" dependencies = [ "base64", + "futures", "geth-auth", "geth-cas", "geth-codec", @@ -1622,6 +1623,8 @@ dependencies = [ "hex", "iroh", "iroh-blobs", + "iroh-docs", + "iroh-gossip", "rusqlite", "serde", "serde_json", diff --git a/README.md b/README.md index 8485a48..456e444 100644 --- a/README.md +++ b/README.md @@ -153,8 +153,9 @@ The bootstrap implementation provides: `geth db sync ` - local SQLite-backed KV commands: `geth kv create/set/get`; `kv set` accepts `--subject ` to exercise local capability checks for non-local - callers; `geth kv sync [--bearer-secret ]` pulls - authorized remote updates + callers. The daemon mirrors named KV stores into Iroh Documents and + `geth kv sync [--bearer-secret ]` pulls authorized + remote updates after receiving a read-only docs ticket through geth control. - local Automerge document commands: `geth document create/status/set/get`; CLI input and output are JSON views, while the store keeps durable Automerge save bytes. `geth document sync [--bearer-secret ]` @@ -201,8 +202,10 @@ Iroh endpoint, verifies the BLAKE3 hash, stores it in local CAS, and records the serving peer as a provider visible with `geth cas providers `. `geth-iroh` is pinned to `iroh 0.95.1` and compiles the native backend libraries `iroh-blobs 0.97.0`, `iroh-docs 0.95.0`, and `iroh-gossip 0.95.0` -against the same daemon-owned endpoint generation. KV and pubsub still use their -documented bootstrap equivalents until their native protocol migrations land. +against the same daemon-owned endpoint generation. KV stores are mirrored into +native `iroh-docs` namespaces and peers receive read-only document tickets only +after geth authorization succeeds. Pubsub still uses its documented bootstrap +equivalent until the native gossip migration lands. Remote resource commands that accept `--bearer-secret` can also authorize with a resource-scoped bearer proof generated from the private bearer token returned at creation time. The persisted auth log stores a public bearer id and token @@ -239,8 +242,9 @@ can safely apply non-conflicting remote creates, updates, deletes, and renames only where local state still matches the recorded base. 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. +on the remote `resource:kv:`. Authorized sync imports from the remote +Iroh Documents namespace where available, keeps SQLite as the durable local +index, and imports only remote entries that are not older than the local value. Remote pubsub publish uses the protected Iroh control path too. The remote peer requires `pubsub.publish` on `resource:pubsub:` before recording the message in its local daemon-lifetime ring buffer. Pubsub remains lossy and is diff --git a/crates/geth-control/src/lib.rs b/crates/geth-control/src/lib.rs index d1dfdc4..96c589f 100644 --- a/crates/geth-control/src/lib.rs +++ b/crates/geth-control/src/lib.rs @@ -1205,6 +1205,7 @@ pub enum PeerControlResponse { name: String, entries: Vec, high_water_ms: i64, + docs_ticket: Option, allowed: bool, reason: String, evaluated_ops: usize, @@ -2359,6 +2360,7 @@ mod tests { updated_at_ms: 42, }], high_water_ms: 43, + docs_ticket: Some("doc-ticket".to_owned()), allowed: true, reason: "direct grant".to_owned(), evaluated_ops: 1, diff --git a/crates/geth-node/Cargo.toml b/crates/geth-node/Cargo.toml index b4ed8aa..9529fef 100644 --- a/crates/geth-node/Cargo.toml +++ b/crates/geth-node/Cargo.toml @@ -32,9 +32,12 @@ geth-ssh-identity = { path = "../geth-ssh-identity" } geth-ssh-proxy = { path = "../geth-ssh-proxy" } geth-store = { path = "../geth-store" } geth-types = { path = "../geth-types" } +futures.workspace = true hex.workspace = true iroh.workspace = true iroh-blobs.workspace = true +iroh-docs.workspace = true +iroh-gossip.workspace = true swarm-discovery.workspace = true [dev-dependencies] diff --git a/crates/geth-node/src/lib.rs b/crates/geth-node/src/lib.rs index d251b15..d93f502 100644 --- a/crates/geth-node/src/lib.rs +++ b/crates/geth-node/src/lib.rs @@ -1,6 +1,7 @@ pub mod service; use base64::Engine; +use futures::StreamExt; use geth_auth::{AUTH_SIGNATURE_NAMESPACE, AuthExplanation, AuthOp, AuthOpKind, AuthOpSignature}; use geth_cas::{ BlobInfoSummary, CasTreeEntryKind, CasTreeObject, FileConflict, FileConflictKind, @@ -52,6 +53,7 @@ use geth_types::{ ResourceName, SshCertId, SshCertRequestId, UnixMillis, UserId, }; use iroh::protocol::ProtocolHandler; +use iroh_docs::api::protocol::{AddrInfoOptions, ShareMode}; use std::collections::{BTreeMap, BTreeSet, VecDeque}; use std::path::{Component, Path, PathBuf}; use std::sync::{Arc, Mutex}; @@ -159,6 +161,8 @@ pub struct LocalNode { pub iroh_status: EndpointStatus, iroh_endpoint: Arc>>, iroh_blob_store: Arc>>, + iroh_docs: Arc>>, + iroh_gossip: Arc>>, runtime: Arc, } @@ -199,6 +203,14 @@ struct LiveSyncHealth { last_rejected: usize, } +#[derive(Debug, serde::Deserialize, serde::Serialize)] +struct KvDocsState { + name: String, + namespace_id: String, + read_ticket: String, + updated_at_ms: i64, +} + struct PipeTcpConnectWire { peer_card: PeerCard, target_addr: String, @@ -268,6 +280,8 @@ pub fn init_node(paths: &GethPaths) -> Result { iroh_status: EndpointStatus::scaffolded(), iroh_endpoint: Arc::new(Mutex::new(None)), iroh_blob_store: Arc::new(Mutex::new(None)), + iroh_docs: Arc::new(Mutex::new(None)), + iroh_gossip: Arc::new(Mutex::new(None)), runtime: Arc::new(NodeRuntime { pubsub: Mutex::new(PubsubRuntime::default()), pipes: Mutex::new(PipeRuntime::default()), @@ -760,6 +774,17 @@ pub async fn handle_request_async( ControlResponse::CasPrivateAdded { encrypted_hash, .. } => { mirror_local_blob_to_iroh_blobs(node, encrypted_hash).await?; } + ControlResponse::KvCreated { kv } => { + mirror_kv_store_to_iroh_docs(node, kv.name.as_str()).await?; + } + ControlResponse::KvSet { entry } => { + let name = entry + .store + .as_str() + .strip_prefix("kv:") + .unwrap_or(entry.store.as_str()); + mirror_kv_store_to_iroh_docs(node, name).await?; + } _ => {} } Ok(response) @@ -1849,6 +1874,7 @@ async fn kv_sync_from_peer( name: response_name, entries, high_water_ms, + docs_ticket, allowed, reason, note, @@ -1866,9 +1892,13 @@ async fn kv_sync_from_peer( note, }); } + let mut entries_imported = 0; + if let Some(docs_ticket) = docs_ticket { + entries_imported += + import_kv_entries_from_iroh_docs(node, name, &docs_ticket).await?; + } 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()))?; @@ -1886,6 +1916,8 @@ async fn kv_sync_from_peer( } } store_live_sync_cursor(&store, peer_node, &stream, high_water_ms)?; + drop(store); + mirror_kv_store_to_iroh_docs(node, name).await?; Ok(ControlResponse::KvSynced { peer_node_id: node_id, peer_agent_id: agent_id, @@ -1907,6 +1939,227 @@ async fn kv_sync_from_peer( } } +fn iroh_docs(node: &LocalNode) -> Result, NodeError> { + node.iroh_docs + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned) + .map(|docs| docs.clone()) +} + +fn iroh_gossip(node: &LocalNode) -> Result, NodeError> { + node.iroh_gossip + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned) + .map(|gossip| gossip.clone()) +} + +fn kv_docs_state_key(name: &str) -> String { + format!("kv-doc:{name}") +} + +fn kv_docs_meta_key() -> &'static str { + "__geth/kv/name" +} + +async fn ensure_kv_docs_state( + node: &LocalNode, + name: &str, +) -> Result, NodeError> { + let Some(docs) = iroh_docs(node)? else { + return Ok(None); + }; + let stored_state = Store::open(&node.paths.metadata_db())? + .get_module_state(&kv_docs_state_key(name))? + .map(|stored| serde_json::from_str::(&stored.state_json)) + .transpose()?; + if let Some(state) = stored_state { + let namespace = state + .namespace_id + .parse::() + .map_err(|error| NodeError::IrohPeer(format!("invalid KV docs namespace: {error}")))?; + if let Some(doc) = docs + .open(namespace) + .await + .map_err(|error| NodeError::IrohPeer(format!("iroh-docs open failed: {error}")))? + { + return Ok(Some((doc, state))); + } + } + + let doc = docs + .create() + .await + .map_err(|error| NodeError::IrohPeer(format!("iroh-docs create failed: {error}")))?; + let ticket = doc + .share(ShareMode::Read, AddrInfoOptions::RelayAndAddresses) + .await + .map_err(|error| NodeError::IrohPeer(format!("iroh-docs share failed: {error}")))?; + let state = KvDocsState { + name: name.to_owned(), + namespace_id: doc.id().to_string(), + read_ticket: ticket.to_string(), + updated_at_ms: geth_store::now_ms(), + }; + let store = Store::open(&node.paths.metadata_db())?; + store.put_module_state(&StoredModuleState { + module: kv_docs_state_key(name), + state_json: serde_json::to_string(&state)?, + updated_at_ms: state.updated_at_ms, + })?; + Ok(Some((doc, state))) +} + +async fn kv_docs_read_ticket(node: &LocalNode, name: &str) -> Result, NodeError> { + Ok(ensure_kv_docs_state(node, name) + .await? + .map(|(_, state)| state.read_ticket)) +} + +async fn mirror_kv_store_to_iroh_docs(node: &LocalNode, name: &str) -> Result<(), NodeError> { + let Some(docs) = iroh_docs(node)? else { + return Ok(()); + }; + let entries = { + let store = Store::open(&node.paths.metadata_db())?; + let kv = ensure_local_kv_store(&store, name)?; + store.list_kv_entries_since(&kv.kv_id, 0)? + }; + let Some((doc, _state)) = ensure_kv_docs_state(node, name).await? else { + return Ok(()); + }; + let author = docs + .author_default() + .await + .map_err(|error| NodeError::IrohPeer(format!("iroh-docs author failed: {error}")))?; + doc.set_bytes( + author, + kv_docs_meta_key().as_bytes().to_vec(), + name.as_bytes().to_vec(), + ) + .await + .map_err(|error| NodeError::IrohPeer(format!("iroh-docs set metadata failed: {error}")))?; + for entry in entries { + doc.set_bytes(author, entry.key.into_bytes(), entry.value.into_bytes()) + .await + .map_err(|error| NodeError::IrohPeer(format!("iroh-docs set failed: {error}")))?; + } + Ok(()) +} + +async fn mirror_all_kv_stores_to_iroh_docs(node: &LocalNode) -> Result { + if iroh_docs(node)?.is_none() { + return Ok(0); + } + let store = Store::open(&node.paths.metadata_db())?; + let mut mirrored = 0; + for kv in store.list_kv_stores()? { + mirror_kv_store_to_iroh_docs(node, &kv.name).await?; + mirrored += 1; + } + Ok(mirrored) +} + +async fn import_kv_entries_from_iroh_docs( + node: &LocalNode, + name: &str, + ticket: &str, +) -> Result { + let Some(docs) = iroh_docs(node)? else { + return Ok(0); + }; + let Some(blob_store) = iroh_blob_store(node)? else { + return Ok(0); + }; + let ticket = ticket + .parse::() + .map_err(|error| NodeError::IrohPeer(format!("invalid iroh-docs ticket: {error}")))?; + let doc = docs + .import(ticket.clone()) + .await + .map_err(|error| NodeError::IrohPeer(format!("iroh-docs import failed: {error}")))?; + let state = KvDocsState { + name: name.to_owned(), + namespace_id: doc.id().to_string(), + read_ticket: ticket.to_string(), + updated_at_ms: geth_store::now_ms(), + }; + let store = Store::open(&node.paths.metadata_db())?; + store.put_module_state(&StoredModuleState { + module: kv_docs_state_key(name), + state_json: serde_json::to_string(&state)?, + updated_at_ms: state.updated_at_ms, + })?; + + let mut imported = 0; + for _ in 0..5 { + imported += import_kv_doc_entries_once(node, name, &blob_store, &doc).await?; + if imported > 0 { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + Ok(imported) +} + +async fn import_kv_doc_entries_once( + node: &LocalNode, + name: &str, + blob_store: &iroh_blobs::store::fs::FsStore, + doc: &iroh_docs::api::Doc, +) -> Result { + let mut imported_entries = Vec::new(); + let entries = doc + .get_many(iroh_docs::store::Query::single_latest_per_key()) + .await + .map_err(|error| NodeError::IrohPeer(format!("iroh-docs query failed: {error}")))?; + futures::pin_mut!(entries); + while let Some(entry) = entries.next().await { + let entry = entry + .map_err(|error| NodeError::IrohPeer(format!("iroh-docs entry failed: {error}")))?; + let key = String::from_utf8(entry.key().to_vec()) + .map_err(|error| NodeError::IrohPeer(format!("invalid KV docs key: {error}")))?; + if key == kv_docs_meta_key() { + continue; + } + geth_kv::validate_kv_key(&key).map_err(|_| NodeError::InvalidKvKey(key.clone()))?; + if entry.content_len() > 1024 * 1024 { + return Err(NodeError::IrohPeer(format!( + "KV docs value for {key} is too large: {} bytes", + entry.content_len() + ))); + } + let value = blob_store + .get_bytes(entry.content_hash()) + .await + .map_err(|error| { + NodeError::IrohPeer(format!("iroh-docs blob export failed: {error}")) + })?; + let value = String::from_utf8(value.to_vec()) + .map_err(|error| NodeError::IrohPeer(format!("invalid KV docs value: {error}")))?; + let updated_at_ms = (entry.timestamp() / 1000) as i64; + imported_entries.push((key, value, updated_at_ms)); + } + + let store = Store::open(&node.paths.metadata_db())?; + let kv = ensure_local_kv_store(&store, name)?; + let mut imported = 0; + for (key, value, updated_at_ms) in imported_entries { + let should_import = store + .get_kv_entry(&kv.kv_id, &key)? + .is_none_or(|local| updated_at_ms >= local.updated_at_ms); + if should_import { + store.set_kv_entry(&StoredKvEntry { + kv_id: kv.kv_id.clone(), + key, + value, + updated_at_ms, + })?; + imported += 1; + } + } + Ok(imported) +} + async fn pubsub_publish_to_peer( node: &LocalNode, peer_node: &str, @@ -4558,6 +4811,28 @@ async fn handle_iroh_control_connection( .await .map_err(|error| NodeError::IrohPeer(format!("iroh-blobs accept failed: {error}"))); } + if alpn == display_alpn(iroh_docs::ALPN) { + let docs = iroh_docs(&node)?.ok_or_else(|| { + NodeError::IrohPeer( + "iroh-docs ALPN accepted but docs runtime is unavailable".to_owned(), + ) + })?; + return docs + .accept(conn) + .await + .map_err(|error| NodeError::IrohPeer(format!("iroh-docs accept failed: {error}"))); + } + if alpn == display_alpn(iroh_gossip::ALPN) { + let gossip = iroh_gossip(&node)?.ok_or_else(|| { + NodeError::IrohPeer( + "iroh-gossip ALPN accepted but gossip runtime is unavailable".to_owned(), + ) + })?; + return gossip + .accept(conn) + .await + .map_err(|error| NodeError::IrohPeer(format!("iroh-gossip accept failed: {error}"))); + } let (mut send, mut recv) = conn .accept_bi() .await @@ -5094,6 +5369,13 @@ async fn handle_iroh_control_connection( } else { Vec::new() }; + let docs_ticket = if explanation.allowed { + drop(store); + mirror_kv_store_to_iroh_docs(&node, &name).await?; + kv_docs_read_ticket(&node, &name).await? + } else { + None + }; PeerControlResponse::KvSynced { node_id: node.node_id.clone(), agent_id: node.agent_id.clone(), @@ -5102,6 +5384,7 @@ async fn handle_iroh_control_connection( name, entries, high_water_ms, + docs_ticket, allowed: explanation.allowed, reason: explanation.reason, evaluated_ops: explanation.evaluated_ops, @@ -7779,17 +8062,39 @@ pub fn handle_request( fn native_backend_statuses() -> Vec { geth_iroh::native_iroh_libraries() .into_iter() - .map(|library| NativeBackendStatus { - module: library.module, - current_backend: "shared-iroh-endpoint-ready".to_owned(), - target_crate: library.crate_name, - target_version: library.crate_version, - status: "ready-to-wire".to_owned(), - blocker: format!( - "none; native ALPN {} is compiled against the daemon-owned iroh {} endpoint, but the module still needs its bootstrap control-path implementation replaced", - library.alpn, - geth_iroh::IROH_VERSION - ), + .map(|library| { + let (status, blocker) = match library.module.as_str() { + "cas" => ( + "wired", + format!( + "none; CAS payload fetches use native ALPN {} after geth control authorization preflight", + library.alpn + ), + ), + "kv" => ( + "wired", + format!( + "none; KV stores mirror into Iroh Documents over native ALPN {} after geth control authorization preflight", + library.alpn + ), + ), + _ => ( + "ready-to-wire", + format!( + "none; native ALPN {} is compiled against the daemon-owned iroh {} endpoint, but the module still needs its bootstrap control-path implementation replaced", + library.alpn, + geth_iroh::IROH_VERSION + ), + ), + }; + NativeBackendStatus { + module: library.module, + current_backend: "shared-iroh-endpoint-ready".to_owned(), + target_crate: library.crate_name, + target_version: library.crate_version, + status: status.to_owned(), + blocker, + } }) .collect() } @@ -9679,6 +9984,14 @@ async fn start_daemon_iroh_endpoint( .map_err(|error| { NodeError::IrohPeer(format!("failed to open iroh-blobs store: {error}")) })?; + let gossip = iroh_gossip::net::Gossip::builder().spawn(endpoint.endpoint()); + let blob_api: iroh_blobs::api::Store = blob_store.clone().into(); + let docs = iroh_docs::protocol::Docs::persistent(node.paths.home().join("iroh-docs")) + .spawn(endpoint.endpoint(), blob_api, gossip.clone()) + .await + .map_err(|error| { + NodeError::IrohPeer(format!("failed to start iroh-docs: {error}")) + })?; let mirrored = mirror_local_cas_to_iroh_blobs(&node.paths, &blob_store).await?; tracing::info!( mirrored_blobs = mirrored, @@ -9693,6 +10006,19 @@ async fn start_daemon_iroh_endpoint( .iroh_blob_store .lock() .map_err(|_| NodeError::RuntimeLockPoisoned)? = Some(blob_store); + *node + .iroh_gossip + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)? = Some(gossip); + *node + .iroh_docs + .lock() + .map_err(|_| NodeError::RuntimeLockPoisoned)? = Some(docs); + let mirrored_kv = mirror_all_kv_stores_to_iroh_docs(node).await?; + tracing::info!( + mirrored_kv_stores = mirrored_kv, + "native iroh-docs KV store is ready" + ); Ok(Some(endpoint)) } Err(error) => { @@ -11510,6 +11836,16 @@ mod tests { } other => panic!("unexpected synced KV get response: {other:?}"), } + let left_kv_doc_state = Store::open(&left_paths.metadata_db()) + .expect("open left store for kv doc state") + .get_module_state("kv-doc:prefs") + .expect("get left kv doc state") + .expect("left imported kv docs state"); + let left_kv_doc_state: KvDocsState = + serde_json::from_str(&left_kv_doc_state.state_json).expect("parse kv docs state"); + assert_eq!(left_kv_doc_state.name, "prefs"); + assert!(!left_kv_doc_state.namespace_id.is_empty()); + assert!(left_kv_doc_state.read_ticket.starts_with("doc")); let published = handle_request_async( &left, diff --git a/crates/geth/tests/bootstrap.rs b/crates/geth/tests/bootstrap.rs index 88e7b79..d6e0b7e 100644 --- a/crates/geth/tests/bootstrap.rs +++ b/crates/geth/tests/bootstrap.rs @@ -155,11 +155,13 @@ fn geth_status_against_running_daemon() { assert!(stdout.contains("iroh relay: disabled")); assert!(stdout.contains("iroh discovery: local-network disabled")); assert!(stdout.contains( - "native backend cas: shared-iroh-endpoint-ready target iroh-blobs 0.97.0 (ready-to-wire)" - )); - assert!(stdout.contains( - "native backend kv: shared-iroh-endpoint-ready target iroh-docs 0.95.0 (ready-to-wire)" + "native backend cas: shared-iroh-endpoint-ready target iroh-blobs 0.97.0 (wired)" )); + assert!( + stdout.contains( + "native backend kv: shared-iroh-endpoint-ready target iroh-docs 0.95.0 (wired)" + ) + ); assert!(stdout.contains( "native backend pubsub: shared-iroh-endpoint-ready target iroh-gossip 0.95.0 (ready-to-wire)" )); diff --git a/docs/adr/0011-kv-iroh-documents.md b/docs/adr/0011-kv-iroh-documents.md index b0382cb..5d94f92 100644 --- a/docs/adr/0011-kv-iroh-documents.md +++ b/docs/adr/0011-kv-iroh-documents.md @@ -12,8 +12,13 @@ prefix-scoped capabilities. ## Consequences -The prototype exposes CLI shape and durable local KV state. `iroh-docs 0.95.0` -is pinned and compiles against the daemon-owned `iroh 0.95.1` endpoint -generation, so the remaining work is replacing the bootstrap control-path KV -sync with an Iroh Documents namespace implementation and resource-scoped -authorization checks around namespace access. +The prototype keeps SQLite as the durable local KV index and mirrors each named +KV store into an Iroh Documents namespace on the daemon-owned `iroh 0.95.1` +endpoint. Remote `geth kv sync` still uses geth control as the authorization +preflight. If the caller has `kv.read` on the remote `resource:kv:`, the +remote daemon returns a read-only Iroh Documents ticket and the requester imports +entries through `iroh-docs 0.95.0`. + +The daemon must not hand out Iroh Documents write capabilities as a substitute +for geth authorization. Write authority remains modeled through geth resource +capabilities such as `kv.write` and `kv.write_prefix:`. diff --git a/docs/architecture.md b/docs/architecture.md index b5db68e..0c2bb3b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -35,17 +35,19 @@ exposes their native ALPNs without creating a second daemon endpoint. CAS now registers an `iroh-blobs` provider handler on `/iroh-bytes/4`; local CAS writes are mirrored into the native blob store, and remote `geth cas fetch` performs a geth control-ALPN authorization preflight before transferring payload bytes over -`iroh-blobs`. `geth status` reports these native backend libraries. KV and -pubsub still use explicit documented bootstrap equivalents until each module is -migrated to its native protocol. +`iroh-blobs`. KV now starts `iroh-docs` with `iroh-gossip` and the same native +blob store, mirrors named KV stores into read-shared Iroh Documents namespaces, +and sends read-only docs tickets only after geth control authorization succeeds. +`geth status` reports CAS and KV as wired native backends. Pubsub still uses its +explicit documented bootstrap equivalent until it is migrated to native gossip. Module ALPNs are registered through `geth-iroh`'s protocol router scaffold. The router owns the default protocol descriptors, rejects duplicate ALPN registrations, and returns explicit unknown-ALPN errors. The current daemon accept loop dispatches geth control, pipe, SSH-proxy, and native CAS blob -streams directly; the next backend migrations should attach iroh-docs and -iroh-gossip handlers to the same endpoint instead of creating parallel -endpoints. +streams directly, plus native docs and gossip streams used by KV. The next +backend migration should attach application pubsub behavior to iroh-gossip +instead of creating parallel endpoints. The target product should use Iroh relay support for practical internet connectivity and mDNS/LAN discovery for local networks. These are connectivity @@ -203,18 +205,17 @@ or cr-sqlite extension artifact. DB sync intentionally does not use CAS-backed snapshots or batch blobs in the prototype. Those become useful when initial catch-up or large batches outgrow the protected control path. -`geth-kv` currently provides a SQLite-backed local fallback for named KV stores -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 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-kv` keeps SQLite as the durable local index for named KV stores through +`kv create/set/get`. `kv set --subject ` evaluates local auth ops for +`kv.write_key:` so prefix grants can be tested. The daemon mirrors local KV +entries and metadata into an Iroh Documents namespace per named store. `geth kv +sync ` still starts with a protected geth control request that +requires `kv.read` on the remote `resource:kv:`; if authorized, the remote +daemon returns a read-only docs ticket and the requester imports entries through +Iroh Documents. The control response still carries bootstrap entries for +compatibility. 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` registers local document resources and stores durable Automerge save bytes in the local SQLite metadata store. The CLI still accepts and returns diff --git a/docs/roadmap.md b/docs/roadmap.md index 253a765..8b2a354 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -486,7 +486,7 @@ authorization and durable-state boundaries clear. rejection. - `[x]` Docs explicitly avoid claiming forward secrecy or PCS. -- `[~]` Iroh-docs KV integration. +- `[x]` Iroh-docs KV integration. Acceptance criteria: - `[x]` `geth kv create/set/get` works against a named local KV resource. - `[x]` KV metadata and entries are durable in the local SQLite store. @@ -500,7 +500,10 @@ authorization and durable-state boundaries clear. - `[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. + - `[x]` KV metadata is replicated through Iroh Documents. + - `[x]` Authorized peers receive read-only Iroh Documents tickets, not write + capabilities, after geth control authorization succeeds. + - `[x]` Tests cover imported KV docs state after authorized remote sync. - `[~]` Iroh-gossip pubsub integration. Acceptance criteria: