lib

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

commit ae4a94f98c6be16301e9f3376fc9e8a377978a38
parent fa89df195bf3925fafaad161735e33fb3e2a5dc0
Author: triesap <tyson@radroots.org>
Date:   Tue, 23 Jun 2026 07:58:59 +0000

tangle_provisioning: add adapter contract crate

- add typed community provisioning adapter input and output models
- render tenant config from narrow adapter output
- validate adapter schemas and semantic invariants
- cover materialization, rendering, and schema rejection

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

diff --git a/Cargo.lock b/Cargo.lock @@ -4745,6 +4745,15 @@ 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 @@ -38,6 +38,7 @@ members = [ "crates/runtime_paths", "crates/runtime_distribution", "crates/runtime_manager", + "crates/tangle_provisioning", "crates/sp1_guest_trade", "crates/sp1_host_trade", "crates/trade", @@ -75,6 +76,7 @@ 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 @@ -0,0 +1,16 @@ +[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 @@ -0,0 +1,13 @@ +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 @@ -0,0 +1,19 @@ +#![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 @@ -0,0 +1,300 @@ +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 @@ -0,0 +1,756 @@ +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, + } + } + +}