lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 76529e0da67e19a6570fbc1ddcd435182e3ca791
parent 8cf8aa7c6cbd3654751e4cfed60777b75c365f08
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 08:30:30 +0000

tangle_provisioning: refactor workspace membership

- remove the adapter contract crate from this workspace

- drop the workspace dependency and lockfile package entry

- keep the remaining workspace metadata resolving

Diffstat:
MCargo.lock | 9---------
MCargo.toml | 2--
Dcrates/tangle_provisioning/Cargo.toml | 16----------------
Dcrates/tangle_provisioning/src/error.rs | 13-------------
Dcrates/tangle_provisioning/src/lib.rs | 19-------------------
Dcrates/tangle_provisioning/src/schema.rs | 300-------------------------------------------------------------------------------
Dcrates/tangle_provisioning/src/types.rs | 755-------------------------------------------------------------------------------
7 files changed, 0 insertions(+), 1114 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4753,15 +4753,6 @@ dependencies = [ ] [[package]] -name = "radroots_tangle_provisioning" -version = "0.1.0-alpha.2" -dependencies = [ - "serde", - "serde_json", - "thiserror 1.0.69", -] - -[[package]] name = "radroots_test_fixtures" version = "0.1.0-alpha.2" diff --git a/Cargo.toml b/Cargo.toml @@ -39,7 +39,6 @@ members = [ "crates/runtime_paths", "crates/runtime_distribution", "crates/runtime_manager", - "crates/tangle_provisioning", "crates/sp1_guest_trade", "crates/sp1_host_trade", "crates/trade", @@ -77,7 +76,6 @@ radroots_runtime = { path = "crates/runtime", version = "0.1.0-alpha.2", default radroots_runtime_paths = { path = "crates/runtime_paths", version = "0.1.0-alpha.2", default-features = false } radroots_runtime_distribution = { path = "crates/runtime_distribution", version = "0.1.0-alpha.2", default-features = false } radroots_runtime_manager = { path = "crates/runtime_manager", version = "0.1.0-alpha.2", default-features = false } -radroots_tangle_provisioning = { path = "crates/tangle_provisioning", version = "0.1.0-alpha.2" } radroots_log = { path = "crates/log", version = "0.1.0-alpha.2", default-features = false } radroots_net = { path = "crates/net", version = "0.1.0-alpha.2", default-features = false } radroots_nostr_runtime = { path = "crates/nostr_runtime", version = "0.1.0-alpha.2", default-features = false } diff --git a/crates/tangle_provisioning/Cargo.toml b/crates/tangle_provisioning/Cargo.toml @@ -1,16 +0,0 @@ -[package] -name = "radroots_tangle_provisioning" -publish = false -version = "0.1.0-alpha.2" -edition.workspace = true -authors = ["Tyson Lupul <tyson@radroots.org>"] -rust-version.workspace = true -license.workspace = true -description = "Typed Tangle community provisioning adapter contracts" -repository.workspace = true -homepage.workspace = true - -[dependencies] -serde = { workspace = true, features = ["derive", "std"] } -serde_json = { workspace = true, features = ["std"] } -thiserror = { workspace = true } diff --git a/crates/tangle_provisioning/src/error.rs b/crates/tangle_provisioning/src/error.rs @@ -1,13 +0,0 @@ -use thiserror::Error; - -#[derive(Debug, Error)] -pub enum CommunityProvisioningError { - #[error(transparent)] - Io(#[from] std::io::Error), - - #[error(transparent)] - Json(#[from] serde_json::Error), - - #[error("invalid community provisioning fixture: {0}")] - Invalid(String), -} diff --git a/crates/tangle_provisioning/src/lib.rs b/crates/tangle_provisioning/src/lib.rs @@ -1,19 +0,0 @@ -#![forbid(unsafe_code)] - -pub mod error; -pub mod schema; -pub mod types; - -pub use error::CommunityProvisioningError; -pub use schema::{load_contract_schema, load_schema_checked_value, validate_contract_schema}; -pub use types::{ - ADAPTER_INPUT_SCHEMA, ADAPTER_OUTPUT_SCHEMA, CommunityProvisioningAdapterInput, - CommunityProvisioningAdapterOutput, DiscoverabilityMode, GROUP_CREATE_KIND, - GROUP_SEED_APPLY_MODE, PUBLIC_REPORT_SCHEMA, ProvisioningBackupExportInput, - ProvisioningCustomDomainInput, ProvisioningGroupInput, ProvisioningGroupLimitsOutput, - ProvisioningGroupPolicyOutput, ProvisioningGroupSeedOperation, ProvisioningGroupSeedOutput, - ProvisioningGroupsOutput, ProvisioningHostConfigUpdate, ProvisioningInitialGroupInput, - ProvisioningOperatorApply, ProvisioningPocketStoreOutput, ProvisioningRedactionOutput, - ProvisioningRelaySelfKeyInput, ProvisioningRelaySelfKeyOutput, ProvisioningTenantInput, - ProvisioningTenantOutput, ProvisioningValidationReport, UnsupportedFieldsPolicy, -}; diff --git a/crates/tangle_provisioning/src/schema.rs b/crates/tangle_provisioning/src/schema.rs @@ -1,300 +0,0 @@ -use std::{fs, path::Path}; - -use serde_json::Value; - -use crate::CommunityProvisioningError; - -pub fn load_contract_schema(path: &Path) -> Result<Value, CommunityProvisioningError> { - Ok(serde_json::from_str(&fs::read_to_string(path)?)?) -} - -pub fn load_schema_checked_value( - path: &Path, - schema: &Value, -) -> Result<Value, CommunityProvisioningError> { - let value: Value = serde_json::from_str(&fs::read_to_string(path)?)?; - validate_contract_schema(schema, &value).map_err(|error| { - CommunityProvisioningError::Invalid(format!( - "{} failed schema validation: {error}", - path.display() - )) - })?; - Ok(value) -} - -pub fn validate_contract_schema( - schema: &Value, - instance: &Value, -) -> Result<(), CommunityProvisioningError> { - validate_supported_schema(schema, "$").map_err(CommunityProvisioningError::Invalid)?; - validate_schema_node(schema, instance, schema, "$").map_err(CommunityProvisioningError::Invalid) -} - -fn validate_supported_schema(schema: &Value, path: &str) -> Result<(), String> { - let Some(object) = schema.as_object() else { - return Ok(()); - }; - for key in object.keys() { - if ![ - "$defs", - "$id", - "$ref", - "$schema", - "additionalProperties", - "const", - "contains", - "enum", - "items", - "minimum", - "minItems", - "minLength", - "oneOf", - "pattern", - "properties", - "required", - "title", - "type", - ] - .contains(&key.as_str()) - { - return Err(format!("{path} uses unsupported schema keyword {key}")); - } - } - if let Some(properties) = object.get("properties").and_then(Value::as_object) { - for (key, property_schema) in properties { - validate_supported_schema(property_schema, &format!("{path}.properties.{key}"))?; - } - } - if let Some(definitions) = object.get("$defs").and_then(Value::as_object) { - for (key, definition_schema) in definitions { - validate_supported_schema(definition_schema, &format!("{path}.$defs.{key}"))?; - } - } - if let Some(options) = object.get("oneOf").and_then(Value::as_array) { - for (index, option) in options.iter().enumerate() { - validate_supported_schema(option, &format!("{path}.oneOf[{index}]"))?; - } - } - if let Some(items) = object.get("items") { - validate_supported_schema(items, &format!("{path}.items"))?; - } - if let Some(contains) = object.get("contains") { - validate_supported_schema(contains, &format!("{path}.contains"))?; - } - Ok(()) -} - -fn validate_schema_node( - schema: &Value, - instance: &Value, - root: &Value, - path: &str, -) -> Result<(), String> { - if let Some(reference) = schema.get("$ref").and_then(Value::as_str) { - return validate_schema_node(resolve_ref(root, reference)?, instance, root, path); - } - if let Some(options) = schema.get("oneOf").and_then(Value::as_array) { - let matches = options - .iter() - .filter(|option| validate_schema_node(option, instance, root, path).is_ok()) - .count(); - if matches != 1 { - return Err(format!("{path} matched {matches} oneOf branches")); - } - } - if let Some(expected) = schema.get("const") - && instance != expected - { - return Err(format!("{path} did not match const {expected}")); - } - if let Some(values) = schema.get("enum").and_then(Value::as_array) - && !values.iter().any(|value| value == instance) - { - return Err(format!("{path} did not match enum")); - } - if let Some(expected_type) = schema.get("type").and_then(Value::as_str) { - validate_json_type(expected_type, instance, path)?; - } - if let Some(minimum) = schema.get("minimum").and_then(Value::as_f64) { - let value = instance - .as_f64() - .ok_or_else(|| format!("{path} must be number for minimum"))?; - if value < minimum { - return Err(format!("{path} is below {minimum}")); - } - } - if let Some(min_length) = schema.get("minLength").and_then(Value::as_u64) { - let value = instance - .as_str() - .ok_or_else(|| format!("{path} must be string for minLength"))?; - if value.len() < min_length as usize { - return Err(format!("{path} length is below {min_length}")); - } - } - if let Some(pattern) = schema.get("pattern").and_then(Value::as_str) { - let value = instance - .as_str() - .ok_or_else(|| format!("{path} must be string for pattern"))?; - if !matches_contract_pattern(pattern, value)? { - return Err(format!("{path} did not match pattern {pattern}")); - } - } - if let Some(min_items) = schema.get("minItems").and_then(Value::as_u64) { - let value = instance - .as_array() - .ok_or_else(|| format!("{path} must be array for minItems"))?; - if value.len() < min_items as usize { - return Err(format!("{path} item count is below {min_items}")); - } - } - if let Some(object) = instance.as_object() - && let Some(properties) = schema.get("properties").and_then(Value::as_object) - { - if let Some(required) = schema.get("required").and_then(Value::as_array) { - for key in required { - let key = key - .as_str() - .ok_or_else(|| format!("{path} required key must be string"))?; - if !object.contains_key(key) { - return Err(format!("{path} missing required key {key}")); - } - } - } - for (key, property_schema) in properties { - if let Some(value) = object.get(key) { - validate_schema_node(property_schema, value, root, &format!("{path}.{key}"))?; - } - } - if schema.get("additionalProperties") == Some(&Value::Bool(false)) { - for key in object.keys() { - if !properties.contains_key(key) { - return Err(format!("{path} contains unknown key {key}")); - } - } - } - } - if let Some(array) = instance.as_array() { - if let Some(item_schema) = schema.get("items") { - for (index, item) in array.iter().enumerate() { - validate_schema_node(item_schema, item, root, &format!("{path}[{index}]"))?; - } - } - if let Some(contains_schema) = schema.get("contains") - && !array - .iter() - .any(|item| validate_schema_node(contains_schema, item, root, path).is_ok()) - { - return Err(format!("{path} does not contain required item")); - } - } - Ok(()) -} - -fn validate_json_type(expected_type: &str, instance: &Value, path: &str) -> Result<(), String> { - let matches = match expected_type { - "object" => instance.is_object(), - "array" => instance.is_array(), - "string" => instance.is_string(), - "boolean" => instance.is_boolean(), - "null" => instance.is_null(), - "integer" => instance.as_i64().is_some(), - "number" => instance.as_f64().is_some(), - _ => return Err(format!("{path} uses unsupported type {expected_type}")), - }; - if matches { - Ok(()) - } else { - Err(format!("{path} is not {expected_type}")) - } -} - -fn resolve_ref<'a>(root: &'a Value, reference: &str) -> Result<&'a Value, String> { - let pointer = reference - .strip_prefix('#') - .ok_or_else(|| format!("unsupported ref {reference}"))?; - root.pointer(pointer) - .ok_or_else(|| format!("unresolved ref {reference}")) -} - -fn matches_contract_pattern(pattern: &str, value: &str) -> Result<bool, String> { - match pattern { - "^[a-z0-9_][a-z0-9_-]*$" => Ok(is_contract_id(value)), - "^[a-f0-9]{64}$" => Ok(is_lower_hex_64(value)), - "^wss?://" => Ok(value.starts_with("ws://") || value.starts_with("wss://")), - "^[a-z0-9][a-z0-9.-]+$" => Ok(value.len() >= 2 - && (value.as_bytes()[0].is_ascii_lowercase() || value.as_bytes()[0].is_ascii_digit()) - && value.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'.' || byte == b'-' - })), - "^tenants/[a-z0-9_][a-z0-9_-]*[.]json$" => Ok(value - .strip_prefix("tenants/") - .and_then(|value| value.strip_suffix(".json")) - .is_some_and(is_contract_id)), - "^runtime/tenants/" => Ok(value.starts_with("runtime/tenants/")), - _ => Err(format!("unsupported schema pattern {pattern}")), - } -} - -fn is_contract_id(value: &str) -> bool { - let Some(first) = value.as_bytes().first() else { - return false; - }; - (first.is_ascii_lowercase() || first.is_ascii_digit() || *first == b'_') - && value.bytes().all(|byte| { - byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' - }) -} - -fn is_lower_hex_64(value: &str) -> bool { - value.len() == 64 && value.bytes().all(is_lower_hex) -} - -fn is_lower_hex(byte: u8) -> bool { - byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte) -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::validate_contract_schema; - - #[test] - fn schema_validation_rejects_unknown_fields() { - let schema = json!({ - "type": "object", - "required": ["name"], - "properties": { - "name": {"type": "string", "minLength": 1} - }, - "additionalProperties": false - }); - let instance = json!({"name": "Market", "unexpected": true}); - - let error = validate_contract_schema(&schema, &instance).expect_err("schema error"); - - assert!(error.to_string().contains("unknown key unexpected")); - } - - #[test] - fn schema_validation_rejects_minimum_min_items_and_contains_failures() { - let schema = json!({ - "type": "object", - "required": ["count", "tags"], - "properties": { - "count": {"type": "integer", "minimum": 1}, - "tags": { - "type": "array", - "minItems": 1, - "contains": {"const": "h"} - } - }, - "additionalProperties": false - }); - - assert!(validate_contract_schema(&schema, &json!({"count": 0, "tags": ["h"]})).is_err()); - assert!(validate_contract_schema(&schema, &json!({"count": 1, "tags": []})).is_err()); - assert!(validate_contract_schema(&schema, &json!({"count": 1, "tags": ["name"]})).is_err()); - assert!(validate_contract_schema(&schema, &json!({"count": 1, "tags": ["h"]})).is_ok()); - } -} diff --git a/crates/tangle_provisioning/src/types.rs b/crates/tangle_provisioning/src/types.rs @@ -1,755 +0,0 @@ -use std::{fs, path::Path}; - -use serde::{Deserialize, Serialize}; -use serde_json::{Value, json}; - -use crate::{ - CommunityProvisioningError, - schema::{load_schema_checked_value, validate_contract_schema}, -}; - -pub const ADAPTER_INPUT_SCHEMA: &str = "radroots.tangle.community_provisioning.adapter_input.v1"; -pub const ADAPTER_OUTPUT_SCHEMA: &str = "radroots.tangle.community_provisioning.adapter_output.v1"; -pub const PUBLIC_REPORT_SCHEMA: &str = "radroots.tangle.community_provisioning.public_report.v1"; -pub const GROUP_CREATE_KIND: u64 = 9_007; -pub const GROUP_SEED_APPLY_MODE: &str = "publish_tenant_local_relay_event"; - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CommunityProvisioningAdapterInput { - pub schema: String, - pub community_space_id: String, - pub display_name: String, - pub description: String, - pub discoverability: DiscoverabilityMode, - pub tenant: ProvisioningTenantInput, - pub groups: ProvisioningGroupInput, - pub backup_export: ProvisioningBackupExportInput, - pub relay_self_key: ProvisioningRelaySelfKeyInput, - pub custom_domain: Option<ProvisioningCustomDomainInput>, - pub unsupported_fields_policy: UnsupportedFieldsPolicy, -} - -impl CommunityProvisioningAdapterInput { - pub fn load(path: &Path) -> Result<Self, CommunityProvisioningError> { - let input: Self = serde_json::from_str(&fs::read_to_string(path)?)?; - input.validate()?; - Ok(input) - } - - pub fn load_with_schema( - path: &Path, - schema: &Value, - ) -> Result<Self, CommunityProvisioningError> { - let value = load_schema_checked_value(path, schema)?; - let input: Self = serde_json::from_value(value)?; - input.validate()?; - Ok(input) - } - - pub fn validate(&self) -> Result<(), CommunityProvisioningError> { - if self.schema != ADAPTER_INPUT_SCHEMA { - return Err(CommunityProvisioningError::Invalid(format!( - "adapter input schema must be {ADAPTER_INPUT_SCHEMA}" - ))); - } - if self.community_space_id != self.tenant.tenant_id { - return Err(CommunityProvisioningError::Invalid( - "community_space_id must match tenant_id".to_owned(), - )); - } - if !self.groups.enabled { - return Err(CommunityProvisioningError::Invalid( - "community groups must be enabled".to_owned(), - )); - } - if self.groups.initial_groups.is_empty() { - return Err(CommunityProvisioningError::Invalid( - "at least one initial group is required".to_owned(), - )); - } - if !is_lower_hex_64(&self.relay_self_key.fixture_secret_hex) { - return Err(CommunityProvisioningError::Invalid( - "relay self key fixture secret must be lowercase hex".to_owned(), - )); - } - Ok(()) - } - - #[must_use] - pub fn materialize_output(&self) -> CommunityProvisioningAdapterOutput { - let data_directory = format!("runtime/tenants/{}/pocket", self.tenant.tenant_schema); - let tenant_config_path = format!("tenants/{}.json", self.tenant.tenant_schema); - CommunityProvisioningAdapterOutput { - schema: ADAPTER_OUTPUT_SCHEMA.to_owned(), - community_space_id: self.community_space_id.clone(), - host_config_update: ProvisioningHostConfigUpdate { - tenant_config_dir: "tenants".to_owned(), - tenant_config_file: tenant_config_path, - apply_mode: "write_config_then_restart".to_owned(), - }, - tenant: ProvisioningTenantOutput { - tenant_id: self.tenant.tenant_id.clone(), - tenant_schema: self.tenant.tenant_schema.clone(), - canonical_host: self.tenant.canonical_host.clone(), - relay_url: self.tenant.relay_url.clone(), - display_name: self.display_name.clone(), - description: self.description.clone(), - discoverability: self.discoverability, - }, - pocket_store: ProvisioningPocketStoreOutput { - data_directory, - create_if_missing: true, - }, - relay_self_key: ProvisioningRelaySelfKeyOutput { - mode: self.relay_self_key.mode.clone(), - secret_ref: self.relay_self_key.secret_ref.clone(), - materialized_config_field: "groups.relay_secret".to_owned(), - }, - backup_export: self.backup_export.clone(), - groups: ProvisioningGroupsOutput { - enabled: self.groups.enabled, - canonical_relay_url: self.tenant.relay_url.clone(), - owner_pubkeys: vec![self.tenant.owner_pubkey.clone()], - admin_pubkeys: self.tenant.admin_pubkeys.clone(), - policy: ProvisioningGroupPolicyOutput { - public_join: self.groups.public_join, - invites_enabled: self.groups.invites_enabled, - }, - limits: ProvisioningGroupLimitsOutput::default(), - }, - group_seed: group_seed_output(self), - redaction: ProvisioningRedactionOutput { - redact_fields: vec!["groups.relay_secret".to_owned()], - public_report_allowed: true, - }, - validation_report: ProvisioningValidationReport { - adapter_input_schema: ADAPTER_INPUT_SCHEMA.to_owned(), - adapter_output_schema: ADAPTER_OUTPUT_SCHEMA.to_owned(), - unsupported_fields_policy: self.unsupported_fields_policy, - tangle_config_validate_required: true, - }, - operator_apply: ProvisioningOperatorApply { - instruction: - "write tenant config file, update host config reference, restart Tangle" - .to_owned(), - hot_reload: false, - remote_management_route: false, - }, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum DiscoverabilityMode { - Public, - Private, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum UnsupportedFieldsPolicy { - Reject, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningTenantInput { - pub tenant_id: String, - pub tenant_schema: String, - pub canonical_host: String, - pub relay_url: String, - pub owner_pubkey: String, - pub admin_pubkeys: Vec<String>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningGroupInput { - pub enabled: bool, - pub public_join: bool, - pub invites_enabled: bool, - pub initial_groups: Vec<ProvisioningInitialGroupInput>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningInitialGroupInput { - pub group_id: String, - pub name: String, - pub description: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningBackupExportInput { - pub backup_enabled: bool, - pub export_enabled: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningRelaySelfKeyInput { - pub mode: String, - pub secret_ref: String, - pub fixture_secret_hex: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningCustomDomainInput { - pub requested_host: String, - pub certificate_profile: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CommunityProvisioningAdapterOutput { - pub schema: String, - pub community_space_id: String, - pub host_config_update: ProvisioningHostConfigUpdate, - pub tenant: ProvisioningTenantOutput, - pub pocket_store: ProvisioningPocketStoreOutput, - pub relay_self_key: ProvisioningRelaySelfKeyOutput, - pub backup_export: ProvisioningBackupExportInput, - pub groups: ProvisioningGroupsOutput, - pub group_seed: ProvisioningGroupSeedOutput, - pub redaction: ProvisioningRedactionOutput, - pub validation_report: ProvisioningValidationReport, - pub operator_apply: ProvisioningOperatorApply, -} - -impl CommunityProvisioningAdapterOutput { - pub fn load(path: &Path) -> Result<Self, CommunityProvisioningError> { - let output: Self = serde_json::from_str(&fs::read_to_string(path)?)?; - output.validate()?; - Ok(output) - } - - pub fn load_with_schema( - path: &Path, - schema: &Value, - ) -> Result<Self, CommunityProvisioningError> { - let value = load_schema_checked_value(path, schema)?; - let output: Self = serde_json::from_value(value)?; - output.validate()?; - Ok(output) - } - - pub fn validate(&self) -> Result<(), CommunityProvisioningError> { - if self.schema != ADAPTER_OUTPUT_SCHEMA { - return Err(CommunityProvisioningError::Invalid(format!( - "adapter output schema must be {ADAPTER_OUTPUT_SCHEMA}" - ))); - } - if self.community_space_id != self.tenant.tenant_id { - return Err(CommunityProvisioningError::Invalid( - "community_space_id must match tenant_id".to_owned(), - )); - } - if self.host_config_update.tenant_config_dir != "tenants" - || self.host_config_update.tenant_config_file - != format!("tenants/{}.json", self.tenant.tenant_schema) - || self.host_config_update.apply_mode != "write_config_then_restart" - { - return Err(CommunityProvisioningError::Invalid( - "host config update must target the tenant config file".to_owned(), - )); - } - if self.operator_apply.hot_reload || self.operator_apply.remote_management_route { - return Err(CommunityProvisioningError::Invalid( - "adapter output must remain outside Tangle remote management".to_owned(), - )); - } - if self.operator_apply.instruction.is_empty() { - return Err(CommunityProvisioningError::Invalid( - "operator apply instruction is required".to_owned(), - )); - } - if self.pocket_store.data_directory - != format!("runtime/tenants/{}/pocket", self.tenant.tenant_schema) - || !self.pocket_store.create_if_missing - { - return Err(CommunityProvisioningError::Invalid( - "pocket store output must match tenant schema".to_owned(), - )); - } - if !self.groups.enabled || self.groups.canonical_relay_url != self.tenant.relay_url { - return Err(CommunityProvisioningError::Invalid( - "group output must use the tenant relay url".to_owned(), - )); - } - let Some(owner_pubkey) = self.groups.owner_pubkeys.first() else { - return Err(CommunityProvisioningError::Invalid( - "group output must include an owner pubkey".to_owned(), - )); - }; - if self.group_seed.apply_mode != GROUP_SEED_APPLY_MODE - || self.group_seed.operations.is_empty() - { - return Err(CommunityProvisioningError::Invalid( - "adapter output must include tenant-local group seed operations".to_owned(), - )); - } - if self.relay_self_key.materialized_config_field != "groups.relay_secret" { - return Err(CommunityProvisioningError::Invalid( - "relay self key must materialize into groups.relay_secret".to_owned(), - )); - } - if !self.redaction.public_report_allowed - || !self - .redaction - .redact_fields - .iter() - .any(|field| field == "groups.relay_secret") - { - return Err(CommunityProvisioningError::Invalid( - "redaction output must protect groups.relay_secret".to_owned(), - )); - } - if self.validation_report.adapter_input_schema != ADAPTER_INPUT_SCHEMA - || self.validation_report.adapter_output_schema != ADAPTER_OUTPUT_SCHEMA - || self.validation_report.unsupported_fields_policy != UnsupportedFieldsPolicy::Reject - || !self.validation_report.tangle_config_validate_required - { - return Err(CommunityProvisioningError::Invalid( - "validation report must require strict adapter validation".to_owned(), - )); - } - for operation in &self.group_seed.operations { - if operation.community_space_id != self.community_space_id - || operation.tenant_id != self.tenant.tenant_id - || operation.tenant_relay_url != self.tenant.relay_url - || operation.event_kind != GROUP_CREATE_KIND - || operation.apply_mode != GROUP_SEED_APPLY_MODE - || operation.signer_role != "tenant_owner" - || operation.signer_pubkey != *owner_pubkey - { - return Err(CommunityProvisioningError::Invalid(format!( - "invalid group seed operation for {}", - operation.group_id - ))); - } - if !operation.unsigned_event_tags.iter().any(|tag| { - tag.first().map(String::as_str) == Some("h") - && tag.get(1) == Some(&operation.group_id) - }) || !operation.unsigned_event_tags.iter().any(|tag| { - tag.first().map(String::as_str) == Some("name") - && tag.get(1) == Some(&operation.group_display_name) - }) { - return Err(CommunityProvisioningError::Invalid(format!( - "group seed operation tags do not match {}", - operation.group_id - ))); - } - } - Ok(()) - } - - pub fn render_tangle_tenant_config( - &self, - relay_secret_hex: &str, - ) -> Result<Value, CommunityProvisioningError> { - self.validate()?; - if !is_lower_hex_64(relay_secret_hex) { - return Err(CommunityProvisioningError::Invalid( - "relay self key secret must be lowercase hex".to_owned(), - )); - } - Ok(json!({ - "tenant_id": self.tenant.tenant_id, - "tenant_schema": self.tenant.tenant_schema, - "host": self.tenant.canonical_host, - "relay_url": self.tenant.relay_url, - "inactive": false, - "info": { - "name": self.tenant.display_name, - "description": self.tenant.description - }, - "pocket": { - "data_directory": self.pocket_store.data_directory, - "sync_policy": "flush_on_shutdown" - }, - "pocket_query": { - "allow_scraping": false, - "allow_scrape_if_limited_to": 100, - "allow_scrape_if_max_seconds": 3600 - }, - "groups": { - "enabled": self.groups.enabled, - "canonical_relay_url": self.groups.canonical_relay_url, - "relay_secret": relay_secret_hex, - "owner_pubkeys": self.groups.owner_pubkeys, - "admin_pubkeys": self.groups.admin_pubkeys, - "policy": { - "public_join": self.groups.policy.public_join, - "invites_enabled": self.groups.policy.invites_enabled - }, - "limits": { - "max_group_id_bytes": self.groups.limits.max_group_id_bytes, - "max_group_tags_per_event": self.groups.limits.max_group_tags_per_event, - "max_supported_kinds": self.groups.limits.max_supported_kinds, - "max_member_list_pubkeys": self.groups.limits.max_member_list_pubkeys, - "max_outbox_replay_batch": self.groups.limits.max_outbox_replay_batch - } - }, - "backup_export": { - "backup_enabled": self.backup_export.backup_enabled, - "export_enabled": self.backup_export.export_enabled - }, - "auth": { - "challenge_ttl_seconds": 300, - "created_at_skew_seconds": 600 - }, - "limits": { - "max_message_length": 1048576, - "max_subid_length": 64, - "max_subscriptions_per_connection": 64, - "max_filters_per_request": 10, - "max_tag_values_per_filter": 100, - "max_query_complexity": 2048, - "max_limit": 500, - "default_limit": 100, - "max_event_tags": 200, - "max_content_length": 65536, - "broadcast_channel_capacity": 8, - "per_connection_outbound_queue": 8 - }, - "rate_limits": { - "auth": { - "per_ip": {"window_seconds": 60, "max_hits": 120}, - "per_pubkey": {"window_seconds": 60, "max_hits": 30}, - "failures": {"window_seconds": 300, "max_hits": 5}, - "failures_per_ip": {"window_seconds": 300, "max_hits": 20} - }, - "event": { - "per_ip": {"window_seconds": 60, "max_hits": 600}, - "per_pubkey": {"window_seconds": 60, "max_hits": 120}, - "per_kind": {"window_seconds": 60, "max_hits": 1000} - }, - "group": { - "write_per_ip": {"window_seconds": 60, "max_hits": 300}, - "write_per_pubkey": {"window_seconds": 60, "max_hits": 60}, - "write_per_group": {"window_seconds": 60, "max_hits": 90}, - "write_per_kind": {"window_seconds": 60, "max_hits": 300}, - "join_flow": {"window_seconds": 300, "max_hits": 10}, - "join_flow_per_ip": {"window_seconds": 300, "max_hits": 30} - }, - "req": { - "per_ip": {"window_seconds": 60, "max_hits": 600}, - "per_connection": {"window_seconds": 60, "max_hits": 120}, - "per_pubkey": {"window_seconds": 60, "max_hits": 240}, - "per_group": {"window_seconds": 60, "max_hits": 240}, - "per_kind": {"window_seconds": 60, "max_hits": 500}, - "broad": {"window_seconds": 60, "max_hits": 30} - }, - "count": { - "per_ip": {"window_seconds": 60, "max_hits": 300}, - "per_connection": {"window_seconds": 60, "max_hits": 60}, - "per_pubkey": {"window_seconds": 60, "max_hits": 120}, - "per_group": {"window_seconds": 60, "max_hits": 120}, - "per_kind": {"window_seconds": 60, "max_hits": 240}, - "broad": {"window_seconds": 60, "max_hits": 20} - } - } - })) - } - - pub fn validate_against_schema( - &self, - schema: &Value, - ) -> Result<(), CommunityProvisioningError> { - validate_contract_schema(schema, &serde_json::to_value(self)?)?; - self.validate() - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningHostConfigUpdate { - pub tenant_config_dir: String, - pub tenant_config_file: String, - pub apply_mode: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningTenantOutput { - pub tenant_id: String, - pub tenant_schema: String, - pub canonical_host: String, - pub relay_url: String, - pub display_name: String, - pub description: String, - pub discoverability: DiscoverabilityMode, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningPocketStoreOutput { - pub data_directory: String, - pub create_if_missing: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningRelaySelfKeyOutput { - pub mode: String, - pub secret_ref: String, - pub materialized_config_field: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningGroupsOutput { - pub enabled: bool, - pub canonical_relay_url: String, - pub owner_pubkeys: Vec<String>, - pub admin_pubkeys: Vec<String>, - pub policy: ProvisioningGroupPolicyOutput, - pub limits: ProvisioningGroupLimitsOutput, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningGroupPolicyOutput { - pub public_join: bool, - pub invites_enabled: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningGroupLimitsOutput { - pub max_group_id_bytes: u64, - pub max_group_tags_per_event: u64, - pub max_supported_kinds: u64, - pub max_member_list_pubkeys: u64, - pub max_outbox_replay_batch: u64, -} - -impl Default for ProvisioningGroupLimitsOutput { - fn default() -> Self { - Self { - max_group_id_bytes: 128, - max_group_tags_per_event: 8, - max_supported_kinds: 512, - max_member_list_pubkeys: 100_000, - max_outbox_replay_batch: 1_000, - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningGroupSeedOutput { - pub apply_mode: String, - pub operations: Vec<ProvisioningGroupSeedOperation>, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningGroupSeedOperation { - pub community_space_id: String, - pub tenant_id: String, - pub tenant_relay_url: String, - pub group_id: String, - pub group_display_name: String, - pub group_description: String, - pub event_kind: u64, - pub unsigned_event_tags: Vec<Vec<String>>, - pub signer_role: String, - pub signer_pubkey: String, - pub apply_mode: String, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningRedactionOutput { - pub redact_fields: Vec<String>, - pub public_report_allowed: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningValidationReport { - pub adapter_input_schema: String, - pub adapter_output_schema: String, - pub unsupported_fields_policy: UnsupportedFieldsPolicy, - pub tangle_config_validate_required: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProvisioningOperatorApply { - pub instruction: String, - pub hot_reload: bool, - pub remote_management_route: bool, -} - -fn group_seed_output(input: &CommunityProvisioningAdapterInput) -> ProvisioningGroupSeedOutput { - ProvisioningGroupSeedOutput { - apply_mode: GROUP_SEED_APPLY_MODE.to_owned(), - operations: input - .groups - .initial_groups - .iter() - .map(|group| ProvisioningGroupSeedOperation { - community_space_id: input.community_space_id.clone(), - tenant_id: input.tenant.tenant_id.clone(), - tenant_relay_url: input.tenant.relay_url.clone(), - group_id: group.group_id.clone(), - group_display_name: group.name.clone(), - group_description: group.description.clone(), - event_kind: GROUP_CREATE_KIND, - unsigned_event_tags: vec![ - vec!["h".to_owned(), group.group_id.clone()], - vec!["name".to_owned(), group.name.clone()], - ], - signer_role: "tenant_owner".to_owned(), - signer_pubkey: input.tenant.owner_pubkey.clone(), - apply_mode: GROUP_SEED_APPLY_MODE.to_owned(), - }) - .collect(), - } -} - -fn is_lower_hex_64(value: &str) -> bool { - value.len() == 64 - && value - .bytes() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) -} - -#[cfg(test)] -mod tests { - use serde_json::json; - - use super::*; - - #[test] - fn materialized_output_is_narrow_and_renderable() { - let input = sample_input(); - let output = input.materialize_output(); - let value = serde_json::to_value(&output).expect("value"); - - output.validate().expect("valid output"); - assert!(value.get("tenant_config_json").is_none()); - assert_eq!(output.tenant.tenant_id, input.tenant.tenant_id); - assert_eq!( - output.groups.owner_pubkeys, - vec![input.tenant.owner_pubkey.clone()] - ); - - let rendered = output - .render_tangle_tenant_config(&input.relay_self_key.fixture_secret_hex) - .expect("tenant config"); - - assert_eq!(rendered["tenant_id"], output.tenant.tenant_id); - assert_eq!( - rendered["groups"]["relay_secret"], - input.relay_self_key.fixture_secret_hex - ); - } - - #[test] - fn rendering_rejects_invalid_secret_material() { - let output = sample_input().materialize_output(); - - let error = output - .render_tangle_tenant_config("not-a-secret") - .expect_err("secret error"); - - assert!(error.to_string().contains("lowercase hex")); - } - - #[test] - fn output_validation_rejects_remote_management() { - let mut output = sample_input().materialize_output(); - output.operator_apply.remote_management_route = true; - - let error = output.validate().expect_err("validation error"); - - assert!(error.to_string().contains("remote management")); - } - - #[test] - fn output_validation_rejects_group_seed_mismatch() { - let mut output = sample_input().materialize_output(); - output.group_seed.operations[0].tenant_relay_url = "ws://wrong.tangle.local".to_owned(); - - let error = output.validate().expect_err("validation error"); - - assert!(error.to_string().contains("invalid group seed operation")); - } - - #[test] - fn schema_validation_can_wrap_typed_output() { - let output = sample_input().materialize_output(); - let schema = json!({ - "type": "object", - "required": ["schema", "community_space_id", "tenant"], - "properties": { - "schema": {"const": ADAPTER_OUTPUT_SCHEMA}, - "community_space_id": {"type": "string"}, - "tenant": { - "type": "object", - "required": ["tenant_id"], - "properties": { - "tenant_id": {"type": "string"} - }, - "additionalProperties": true - } - }, - "additionalProperties": true - }); - - output.validate_against_schema(&schema).expect("schema"); - } - - fn sample_input() -> CommunityProvisioningAdapterInput { - CommunityProvisioningAdapterInput { - schema: ADAPTER_INPUT_SCHEMA.to_owned(), - community_space_id: "vancouver_local_food_association".to_owned(), - display_name: "Vancouver Local Food Association".to_owned(), - description: "Tangle virtual relay tenant for a Vancouver local food association." - .to_owned(), - discoverability: DiscoverabilityMode::Public, - tenant: ProvisioningTenantInput { - tenant_id: "vancouver_local_food_association".to_owned(), - tenant_schema: "vancouver_local_food_association".to_owned(), - canonical_host: "vancouver-local-food-association.tangle.local".to_owned(), - relay_url: "ws://vancouver-local-food-association.tangle.local".to_owned(), - owner_pubkey: "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" - .to_owned(), - admin_pubkeys: vec![ - "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5".to_owned(), - ], - }, - groups: ProvisioningGroupInput { - enabled: true, - public_join: false, - invites_enabled: false, - initial_groups: vec![ProvisioningInitialGroupInput { - group_id: "market".to_owned(), - name: "Market Coordination".to_owned(), - description: "Room for weekly market coordination and local food updates." - .to_owned(), - }], - }, - backup_export: ProvisioningBackupExportInput { - backup_enabled: true, - export_enabled: true, - }, - relay_self_key: ProvisioningRelaySelfKeyInput { - mode: "operator_supplied_secret".to_owned(), - secret_ref: "tangle/community/vancouver_local_food_association/relay_self" - .to_owned(), - fixture_secret_hex: - "9999999999999999999999999999999999999999999999999999999999999999".to_owned(), - }, - custom_domain: None, - unsupported_fields_policy: UnsupportedFieldsPolicy::Reject, - } - } -}