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:
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,
- }
- }
-}