Add local document resource commands
This commit is contained in:
parent
527090fd4c
commit
ce260625d6
14 changed files with 277 additions and 14 deletions
|
|
@ -424,9 +424,9 @@ fn request_for_command(command: Command) -> Result<ControlRequest> {
|
|||
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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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<KvEntry>,
|
||||
},
|
||||
DocumentCreated {
|
||||
document: DocumentResource,
|
||||
},
|
||||
DocumentStatus {
|
||||
document: DocumentResource,
|
||||
},
|
||||
NotImplemented {
|
||||
module: String,
|
||||
command: String,
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@ license.workspace = true
|
|||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
geth-types = { path = "../geth-types" }
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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<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(
|
||||
&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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Reference in a new issue