Add local document resource commands

This commit is contained in:
Eric Wendland 2026-05-16 22:15:18 +02:00
commit ce260625d6
14 changed files with 277 additions and 14 deletions

View file

@ -123,6 +123,8 @@ Roadmap items should be actionable and checkable:
and sync are still roadmap work. 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. Iroh Documents
replication and prefix-capability enforcement are still roadmap work. 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, - Signed peer-card LAN discovery payloads, peer auth over Iroh, cr-sqlite,
iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement, iroh-docs, iroh-gossip, iroh-blobs, Automerge sync, real auth enforcement,
OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future OpenSSH KRL generation, and Keyhive/BeeKEM-style authorization are future

3
Cargo.lock generated
View file

@ -1117,6 +1117,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"geth-auth", "geth-auth",
"geth-db", "geth-db",
"geth-document",
"geth-keychain", "geth-keychain",
"geth-kv", "geth-kv",
"geth-resource", "geth-resource",
@ -1169,6 +1170,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"geth-types", "geth-types",
"serde", "serde",
"thiserror 2.0.18",
] ]
[[package]] [[package]]
@ -1214,6 +1216,7 @@ dependencies = [
"geth-control", "geth-control",
"geth-crypto", "geth-crypto",
"geth-db", "geth-db",
"geth-document",
"geth-iroh", "geth-iroh",
"geth-keychain", "geth-keychain",
"geth-kv", "geth-kv",

View file

@ -84,6 +84,7 @@ The bootstrap implementation provides:
- local DB resource registration: `geth db add <name> <path>` and - local DB resource registration: `geth db add <name> <path>` and
`geth db status <name>` `geth db status <name>`
- local SQLite-backed KV commands: `geth kv create/set/get` - local SQLite-backed KV commands: `geth kv create/set/get`
- local document resource registration: `geth document create/status`
- SSH certificate flow metadata: - SSH certificate flow metadata:
- `geth ssh cert request --public-key <path> --principal <name>` - `geth ssh cert request --public-key <path> --principal <name>`
- `geth ssh cert requests` - `geth ssh cert requests`
@ -94,8 +95,8 @@ The bootstrap implementation provides:
- `geth ssh revocation list` - `geth ssh revocation list`
- `geth ssh revocation export --out <path>` - `geth ssh revocation export --out <path>`
Other command groups exist as explicit stubs: `pipe`, `document`, `pubsub`, Other command groups exist as explicit stubs: `pipe`, `pubsub`, `secret`, and
`secret`, and `ssh`. `ssh`.
## Resource Modules ## Resource Modules

View file

@ -424,9 +424,9 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
DbCommand::Add { name, path } => ControlRequest::DbAdd { name, path }, DbCommand::Add { name, path } => ControlRequest::DbAdd { name, path },
DbCommand::Status { name } => ControlRequest::DbStatus { name }, DbCommand::Status { name } => ControlRequest::DbStatus { name },
}, },
Command::Document { command } => ControlRequest::ModuleStub { Command::Document { command } => match command {
module: "document".to_owned(), DocumentCommand::Create { name } => ControlRequest::DocumentCreate { name },
command: format!("{command:?}"), DocumentCommand::Status { name } => ControlRequest::DocumentStatus { name },
}, },
Command::Ssh { command } => match command { Command::Ssh { command } => match command {
SshCommand::Proxy { node } => ControlRequest::ModuleStub { SshCommand::Proxy { node } => ControlRequest::ModuleStub {
@ -783,6 +783,20 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
println!("not found"); 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 } => { ControlResponse::NotImplemented { module, command } => {
println!("{module} {command}: not implemented yet"); println!("{module} {command}: not implemented yet");
} }

View file

@ -11,6 +11,7 @@ serde_json.workspace = true
thiserror.workspace = true thiserror.workspace = true
geth-auth = { path = "../geth-auth" } geth-auth = { path = "../geth-auth" }
geth-db = { path = "../geth-db" } geth-db = { path = "../geth-db" }
geth-document = { path = "../geth-document" }
geth-keychain = { path = "../geth-keychain" } geth-keychain = { path = "../geth-keychain" }
geth-kv = { path = "../geth-kv" } geth-kv = { path = "../geth-kv" }
geth-resource = { path = "../geth-resource" } geth-resource = { path = "../geth-resource" }

View file

@ -1,5 +1,6 @@
use geth_auth::{AuthExplanation, AuthOp}; use geth_auth::{AuthExplanation, AuthOp};
use geth_db::DbResource; use geth_db::DbResource;
use geth_document::DocumentResource;
use geth_keychain::KeychainOp; use geth_keychain::KeychainOp;
use geth_kv::{KvEntry, KvResource}; use geth_kv::{KvEntry, KvResource};
use geth_resource::ResourceDescriptor; use geth_resource::ResourceDescriptor;
@ -111,6 +112,12 @@ pub enum ControlRequest {
name: String, name: String,
key: String, key: String,
}, },
DocumentCreate {
name: String,
},
DocumentStatus {
name: String,
},
ModuleStub { ModuleStub {
module: String, module: String,
command: String, command: String,
@ -205,6 +212,12 @@ pub enum ControlResponse {
KvGet { KvGet {
entry: Option<KvEntry>, entry: Option<KvEntry>,
}, },
DocumentCreated {
document: DocumentResource,
},
DocumentStatus {
document: DocumentResource,
},
NotImplemented { NotImplemented {
module: String, module: String,
command: String, command: String,

View file

@ -7,4 +7,5 @@ license.workspace = true
[dependencies] [dependencies]
serde.workspace = true serde.workspace = true
thiserror.workspace = true
geth-types = { path = "../geth-types" } geth-types = { path = "../geth-types" }

View file

@ -6,9 +6,43 @@ pub struct DocumentResource {
pub id: DocumentId, pub id: DocumentId,
pub resource: ResourceId, pub resource: ResourceId,
pub name: String, 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] #[must_use]
pub fn automerge_roadmap() -> &'static str { pub fn automerge_roadmap() -> &'static str {
"future documents use Automerge sync over Iroh with resource-local authorization" "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());
}
}

View file

@ -16,6 +16,7 @@ geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" } geth-control = { path = "../geth-control" }
geth-crypto = { path = "../geth-crypto" } geth-crypto = { path = "../geth-crypto" }
geth-db = { path = "../geth-db" } geth-db = { path = "../geth-db" }
geth-document = { path = "../geth-document" }
geth-iroh = { path = "../geth-iroh" } geth-iroh = { path = "../geth-iroh" }
geth-keychain = { path = "../geth-keychain" } geth-keychain = { path = "../geth-keychain" }
geth-kv = { path = "../geth-kv" } geth-kv = { path = "../geth-kv" }

View file

@ -9,6 +9,7 @@ use geth_control::{
}; };
use geth_crypto::AgentKey; use geth_crypto::AgentKey;
use geth_db::DbResource; use geth_db::DbResource;
use geth_document::DocumentResource;
use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode}; use geth_iroh::{EndpointStatus, GethIrohConfig, GethIrohEndpoint, GethRelayMode};
use geth_keychain::{KeychainOp, KeychainOpKind}; use geth_keychain::{KeychainOp, KeychainOpKind};
use geth_kv::{KvEntry, KvResource}; use geth_kv::{KvEntry, KvResource};
@ -19,8 +20,8 @@ use geth_ssh_identity::{
certificate_id, revocation_id, ssh_public_key_fingerprint, certificate_id, revocation_id, ssh_public_key_fingerprint,
}; };
use geth_store::{ use geth_store::{
Store, StoredAuthOp, StoredDbResource, StoredKeychainOp, StoredKvEntry, StoredKvStore, Store, StoredAuthOp, StoredDbResource, StoredDocumentResource, StoredKeychainOp, StoredKvEntry,
StoredResource, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation, StoredKvStore, StoredResource, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
}; };
use geth_types::{ use geth_types::{
AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName, AuthOpId, Capability, KeyId, NodeId, PrincipalId, ResourceId, ResourceKind, ResourceName,
@ -62,6 +63,10 @@ pub enum NodeError {
InvalidKvKey(String), InvalidKvKey(String),
#[error("kv store not found: {0}")] #[error("kv store not found: {0}")]
KvNotFound(String), KvNotFound(String),
#[error("invalid document name: {0}")]
InvalidDocumentName(String),
#[error("document not found: {0}")]
DocumentNotFound(String),
#[error("invalid ssh certificate kind: {0}")] #[error("invalid ssh certificate kind: {0}")]
InvalidSshCertKind(String), InvalidSshCertKind(String),
#[error("invalid ssh certificate request status: {0}")] #[error("invalid ssh certificate request status: {0}")]
@ -651,6 +656,40 @@ pub fn handle_request(
.map(kv_entry_from_stored); .map(kv_entry_from_stored);
Ok(ControlResponse::KvGet { entry }) 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 } => { ControlRequest::ModuleStub { module, command } => {
Ok(ControlResponse::NotImplemented { 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> { fn store_auth_op(store: &Store, op: &AuthOp) -> Result<(), NodeError> {
store.insert_auth_op(&StoredAuthOp { store.insert_auth_op(&StoredAuthOp {
op_id: op.id.to_string(), op_id: op.id.to_string(),

View file

@ -120,7 +120,9 @@ impl Store {
CREATE TABLE IF NOT EXISTS document_resources ( CREATE TABLE IF NOT EXISTS document_resources (
document_id TEXT PRIMARY KEY, document_id TEXT PRIMARY KEY,
resource_id TEXT NOT NULL, 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 ( CREATE TABLE IF NOT EXISTS peer_cards (
peer_id TEXT PRIMARY KEY, 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<Option<StoredDocumentResource>, 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( pub fn record_cas_object(
&self, &self,
hash: &str, hash: &str,
@ -701,6 +744,15 @@ pub struct StoredKvEntry {
pub updated_at_ms: i64, 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct CasObject { pub struct CasObject {
pub hash: String, pub hash: String,
@ -991,4 +1043,33 @@ mod tests {
Some(entry) 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)
);
}
} }

View file

@ -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] #[test]
fn ssh_cert_request_approval_and_revocation_export_use_local_state() { fn ssh_cert_request_approval_and_revocation_export_use_local_state() {
let home = tempfile::tempdir().expect("tempdir"); let home = tempfile::tempdir().expect("tempdir");

View file

@ -101,8 +101,12 @@ loading, change extraction, and DB sync are future work.
through `kv create/set/get`. Iroh Documents namespaces, prefix authorization through `kv create/set/get`. Iroh Documents namespaces, prefix authorization
enforcement, and replication are future work. enforcement, and replication are future work.
`geth-document`, `geth-pubsub`, `geth-pipe`, and `geth-ssh-proxy` currently `geth-document` currently registers local document resources with an empty JSON
define types, command shape, and roadmap stubs. 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, `geth-ssh-identity` defines SSH trust namespaces plus certificate request,
approval, certificate import, and revocation-list data models. The bootstrap approval, certificate import, and revocation-list data models. The bootstrap

View file

@ -292,11 +292,12 @@ Automerge documents.
- Schema mismatch is detected before applying changes. - Schema mismatch is detected before applying changes.
- Optional CAS-backed snapshots or batches are documented if used. - Optional CAS-backed snapshots or batches are documented if used.
- `[ ]` Automerge document resource. - `[~]` Automerge document resource.
Acceptance criteria: Acceptance criteria:
- `geth document create/status` works for local documents. - `[x]` `geth document create/status` works for local documents.
- Document state is stored durably. - `[x]` Document metadata and empty JSON state are stored durably.
- Tests cover create, update, save, and reload. - `[ ]` Automerge document state is stored durably.
- `[ ]` Tests cover create, update, save, and reload.
- `[ ]` Automerge sync over Iroh. - `[ ]` Automerge sync over Iroh.
Acceptance criteria: Acceptance criteria: