tangle


git clone https://radroots.dev/git/tangle.git
Log | Files | Refs | README | LICENSE

commit 95621e2c32cbdf5daa3bab398074cef62a360561
parent 84e4f486bf2851ee25e9c0406454fc4d0462801d
Author: triesap <tyson@radroots.org>
Date:   Sat,  6 Jun 2026 12:54:51 -0700

ops: add local surrealdb stack

- add local SurrealDB Compose stack and runtime config
- support explicit root credentials for remote SurrealDB modes
- route runtime store connections through the unified SurrealDB connector
- cover stack config and remote credential validation

Diffstat:
MCargo.lock | 86+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/tangle_runtime/src/lib.rs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
Mcrates/tangle_store_surreal/Cargo.toml | 2+-
Mcrates/tangle_store_surreal/src/lib.rs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Ascripts/local-surrealdb-down.sh | 4++++
Ascripts/local-surrealdb-up.sh | 4++++
6 files changed, 335 insertions(+), 31 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -1759,13 +1759,16 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", "futures-channel", "futures-util", "http", "http-body", "hyper", + "ipnet", "libc", + "percent-encoding", "pin-project-lite", "socket2", "tokio", @@ -2255,6 +2258,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] name = "minimal-lexical" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3103,6 +3116,41 @@ dependencies = [ ] [[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] name = "revision" version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3747,6 +3795,7 @@ dependencies = [ "indexmap", "js-sys", "path-clean", + "reqwest", "ring", "rustls-pki-types", "semver", @@ -3756,6 +3805,7 @@ dependencies = [ "surrealdb-types", "surrealdb-types-derive", "tokio", + "tokio-tungstenite 0.28.0", "tokio-tungstenite-wasm", "tokio-util", "tracing", @@ -3953,6 +4003,7 @@ dependencies = [ "papaya", "rand 0.9.4", "regex", + "reqwest", "rust_decimal", "semver", "serde", @@ -4026,6 +4077,9 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] [[package]] name = "synstructure" @@ -4474,6 +4528,24 @@ dependencies = [ ] [[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] name = "tower-layer" version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4591,6 +4663,7 @@ dependencies = [ "rand 0.9.4", "sha1", "thiserror", + "url", "utf-8", ] @@ -4857,6 +4930,19 @@ dependencies = [ ] [[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] name = "wasmparser" version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/crates/tangle_runtime/src/lib.rs b/crates/tangle_runtime/src/lib.rs @@ -39,8 +39,8 @@ use tangle_store_surreal::{ ForumThreadProjectionOutcome, ForumThreadProjectionQuery, LabelProjectionOutcome, LabelProjectionQuery, ListingProjectionQuery, LongFormProjectionOutcome, MigrationApplyOutcome, ReactionProjectionOutcome, ReportProjectionOutcome, ReportProjectionQuery, SearchDocumentQuery, - SellerProfileProjectionOutcome, SurrealConnectionConfig, SurrealConnectionMode, - SurrealMetricsSnapshot, SurrealStore, base_migration_plan, + SellerProfileProjectionOutcome, SurrealConnectionConfig, SurrealMetricsSnapshot, SurrealStore, + base_migration_plan, }; use tokio::net::TcpListener; use tokio::sync::broadcast; @@ -1065,18 +1065,9 @@ pub async fn run_runtime_server( async fn connect_runtime_store( config: &TangleRuntimeConfig, ) -> Result<SurrealStore, RuntimeCommandError> { - match config.database_config().mode() { - SurrealConnectionMode::Memory | SurrealConnectionMode::RocksDb { .. } => { - SurrealStore::connect_local(config.database_config()) - .await - .map_err(|error| RuntimeCommandError::store(error.to_string())) - } - SurrealConnectionMode::Http { .. } | SurrealConnectionMode::WebSocket { .. } => { - Err(RuntimeCommandError::unsupported( - "runtime commands currently support memory or rocksdb SurrealDB configs only", - )) - } - } + SurrealStore::connect(config.database_config()) + .await + .map_err(|error| RuntimeCommandError::store(error.to_string())) } #[derive(Clone)] @@ -1671,6 +1662,8 @@ struct RuntimeDatabaseConfigDocument { mode: RuntimeDatabaseModeDocument, endpoint: Option<String>, path: Option<String>, + username: Option<String>, + password: Option<String>, namespace: String, database: String, } @@ -1832,6 +1825,11 @@ fn database_config_from_document( "database.path must be omitted for memory mode", )); } + if document.username.is_some() || document.password.is_some() { + return Err(RuntimeConfigError::invalid( + "database credentials must be omitted for memory mode", + )); + } SurrealConnectionConfig::memory(&document.namespace, &document.database) } RuntimeDatabaseModeDocument::RocksDb => { @@ -1840,22 +1838,33 @@ fn database_config_from_document( "database.endpoint must be omitted for rocksdb mode", )); } + if document.username.is_some() || document.password.is_some() { + return Err(RuntimeConfigError::invalid( + "database credentials must be omitted for rocksdb mode", + )); + } SurrealConnectionConfig::rocksdb( &required_path(document.path, "rocksdb")?, &document.namespace, &document.database, ) } - RuntimeDatabaseModeDocument::Http => SurrealConnectionConfig::http( - &required_endpoint(document.endpoint, "http")?, - &document.namespace, - &document.database, - ), - RuntimeDatabaseModeDocument::WebSocket => SurrealConnectionConfig::websocket( - &required_endpoint(document.endpoint, "websocket")?, - &document.namespace, - &document.database, - ), + RuntimeDatabaseModeDocument::Http => { + let endpoint = required_endpoint(document.endpoint, "http")?; + let username = required_database_credential(document.username, "username", "http")?; + let password = required_database_credential(document.password, "password", "http")?; + SurrealConnectionConfig::http(&endpoint, &document.namespace, &document.database) + .and_then(|config| config.with_root_credentials(&username, &password)) + } + RuntimeDatabaseModeDocument::WebSocket => { + let endpoint = required_endpoint(document.endpoint, "websocket")?; + let username = + required_database_credential(document.username, "username", "websocket")?; + let password = + required_database_credential(document.password, "password", "websocket")?; + SurrealConnectionConfig::websocket(&endpoint, &document.namespace, &document.database) + .and_then(|config| config.with_root_credentials(&username, &password)) + } } .map_err(|error| RuntimeConfigError::invalid(error.to_string())) } @@ -1872,6 +1881,16 @@ fn required_path(value: Option<String>, mode: &str) -> Result<String, RuntimeCon }) } +fn required_database_credential( + value: Option<String>, + field: &str, + mode: &str, +) -> Result<String, RuntimeConfigError> { + value.ok_or_else(|| { + RuntimeConfigError::invalid(format!("database.{field} is required for {mode} mode")) + }) +} + fn durable_write_rate_limit_from_document( document: &RuntimePolicyConfigDocument, ) -> Result<Option<RateLimitConfig>, RuntimeConfigError> { @@ -4838,6 +4857,8 @@ mod tests { "database": { "mode": "web_socket", "endpoint": "ws://127.0.0.1:8000", + "username": "root", + "password": "root", "namespace": "tangle", "database": "relay" }, @@ -4860,10 +4881,47 @@ mod tests { endpoint: "ws://127.0.0.1:8000".to_owned() } ); + let credentials = config + .database_config() + .root_credentials() + .expect("root credentials"); + assert_eq!(credentials.username(), "root"); + assert_eq!(credentials.password(), "root"); assert_eq!(config.limits(), RuntimeLimits::default()); } #[test] + fn runtime_config_loader_parses_local_surrealdb_stack_config() { + let config = parse_runtime_config_json(include_str!( + "../../../ops/local/surrealdb/tangle-runtime.json" + )) + .expect("local stack config"); + + assert_eq!(config.listen_addr().to_string(), "127.0.0.1:7000"); + assert_eq!( + config.database_config().mode(), + &SurrealConnectionMode::Http { + endpoint: "http://127.0.0.1:8000".to_owned() + } + ); + let credentials = config + .database_config() + .root_credentials() + .expect("root credentials"); + assert_eq!(credentials.username(), "root"); + assert_eq!(credentials.password(), "root"); + assert_eq!(config.database_config().namespace(), "tangle_local"); + assert_eq!(config.database_config().database(), "relay"); + assert!(config.tracing_config().enabled()); + assert_eq!(config.tracing_config().format(), RuntimeTracingFormat::Json); + assert_eq!( + config.durable_write_rate_limit(), + Some(RateLimitConfig::new(60, 60).expect("write limit")) + ); + assert!(config.admission_policy().require_write_auth()); + } + + #[test] fn runtime_config_loader_parses_observability_tracing_config() { let config = parse_runtime_config_json( r#"{ @@ -4953,6 +5011,30 @@ mod tests { }"#, ) .expect_err("endpoint"); + let missing_credentials = parse_runtime_config_json( + r#"{ + "server": { + "listen_addr": "127.0.0.1:7000", + "relay_url": "ws://127.0.0.1:7000" + }, + "database": { + "mode": "http", + "endpoint": "http://127.0.0.1:8000", + "namespace": "tangle", + "database": "relay" + }, + "auth": { + "challenge_ttl_seconds": 300 + }, + "limits": { + "message_rate_limit": { + "limit": 120, + "window_seconds": 60 + } + } + }"#, + ) + .expect_err("credentials"); let empty_tracing_filter = parse_runtime_config_json( r#"{ "server": { @@ -5000,6 +5082,11 @@ mod tests { missing_endpoint.message(), "database.endpoint is required for http mode" ); + assert_eq!(missing_credentials.kind(), RuntimeConfigErrorKind::Invalid); + assert_eq!( + missing_credentials.message(), + "database.username is required for http mode" + ); assert_eq!(empty_tracing_filter.kind(), RuntimeConfigErrorKind::Invalid); assert_eq!( empty_tracing_filter.message(), diff --git a/crates/tangle_store_surreal/Cargo.toml b/crates/tangle_store_surreal/Cargo.toml @@ -10,7 +10,7 @@ description = "SurrealDB storage backend for tangle" [dependencies] serde_json = "1" sha2 = "0.10" -surrealdb = { version = "3.1.3", default-features = false, features = ["kv-mem", "kv-rocksdb"] } +surrealdb = { version = "3.1.3", default-features = false, features = ["kv-mem", "kv-rocksdb", "protocol-http", "protocol-ws"] } tangle_nips = { path = "../tangle_nips" } tangle_protocol = { path = "../tangle_protocol" } tangle_store = { path = "../tangle_store" } diff --git a/crates/tangle_store_surreal/src/lib.rs b/crates/tangle_store_surreal/src/lib.rs @@ -4,7 +4,8 @@ use core::fmt; use sha2::{Digest, Sha256}; use std::collections::BTreeSet; use surrealdb::Surreal; -use surrealdb::engine::local::{Db, Mem, RocksDb}; +use surrealdb::engine::any::{Any, connect as connect_any}; +use surrealdb::opt::auth::Root; use tangle_nips::{ CommentEvent, DeletionTarget, ForumThreadEvent, LabelEvent, ListingProjection, ListingProjectionEvaluation, LongFormEvent, LongFormKind, NIP99_DRAFT_LISTING_KIND, @@ -29,6 +30,7 @@ pub struct SurrealConnectionConfig { mode: SurrealConnectionMode, namespace: String, database: String, + root_credentials: Option<SurrealRootCredentials>, } impl SurrealConnectionConfig { @@ -83,6 +85,19 @@ impl SurrealConnectionConfig { &self.database } + pub fn root_credentials(&self) -> Option<&SurrealRootCredentials> { + self.root_credentials.as_ref() + } + + pub fn with_root_credentials( + mut self, + username: &str, + password: &str, + ) -> Result<Self, SurrealConfigError> { + self.root_credentials = Some(SurrealRootCredentials::new(username, password)?); + Ok(self) + } + fn new( mode: SurrealConnectionMode, namespace: &str, @@ -92,11 +107,35 @@ impl SurrealConnectionConfig { mode, namespace: normalized_identifier(namespace, "namespace")?, database: normalized_identifier(database, "database")?, + root_credentials: None, }) } } #[derive(Debug, Clone, PartialEq, Eq)] +pub struct SurrealRootCredentials { + username: String, + password: String, +} + +impl SurrealRootCredentials { + pub fn new(username: &str, password: &str) -> Result<Self, SurrealConfigError> { + Ok(Self { + username: normalized_secret(username, "username")?, + password: normalized_secret(password, "password")?, + }) + } + + pub fn username(&self) -> &str { + &self.username + } + + pub fn password(&self) -> &str { + &self.password + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct SurrealConfigError { message: String, } @@ -205,6 +244,20 @@ fn normalized_endpoint(value: &str, field: &str) -> Result<String, SurrealConfig Ok(trimmed.to_owned()) } +fn normalized_secret(value: &str, field: &str) -> Result<String, SurrealConfigError> { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(SurrealConfigError::new(&format!( + "surreal root {field} must not be empty" + ))); + } + Ok(trimmed.to_owned()) +} + +fn rocksdb_endpoint(path: &str) -> String { + format!("rocksdb://{path}") +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SurrealMigration { name: String, @@ -1421,7 +1474,7 @@ impl SellerProfileQuery { #[derive(Clone)] pub struct SurrealStore { - db: Surreal<Db>, + db: Surreal<Any>, } impl fmt::Debug for SurrealStore { @@ -1433,13 +1486,25 @@ impl fmt::Debug for SurrealStore { } impl SurrealStore { + pub async fn connect(config: &SurrealConnectionConfig) -> Result<Self, SurrealStoreError> { + match config.mode() { + SurrealConnectionMode::Memory | SurrealConnectionMode::RocksDb { .. } => { + Self::connect_local(config).await + } + SurrealConnectionMode::Http { endpoint } + | SurrealConnectionMode::WebSocket { endpoint } => { + Self::connect_remote(config, endpoint).await + } + } + } + pub async fn connect_local( config: &SurrealConnectionConfig, ) -> Result<Self, SurrealStoreError> { match config.mode() { SurrealConnectionMode::Memory => Self::connect_memory(config).await, SurrealConnectionMode::RocksDb { path } => { - let db = Surreal::new::<RocksDb>(path) + let db = connect_any(rocksdb_endpoint(path)) .await .map_err(SurrealStoreError::from)?; db.use_ns(config.namespace()) @@ -1464,9 +1529,32 @@ impl SurrealStore { "surreal memory connection requires memory mode config", )); } - let db = Surreal::new::<Mem>(()) + let db = connect_any("memory") + .await + .map_err(SurrealStoreError::from)?; + db.use_ns(config.namespace()) + .use_db(config.database()) + .await + .map_err(SurrealStoreError::from)?; + Ok(Self { db }) + } + + async fn connect_remote( + config: &SurrealConnectionConfig, + endpoint: &str, + ) -> Result<Self, SurrealStoreError> { + let credentials = config.root_credentials().ok_or_else(|| { + SurrealStoreError::new("surreal remote connection requires root credentials") + })?; + let db = connect_any(endpoint) .await .map_err(SurrealStoreError::from)?; + db.signin(Root { + username: credentials.username().to_owned(), + password: credentials.password().to_owned(), + }) + .await + .map_err(SurrealStoreError::from)?; db.use_ns(config.namespace()) .use_db(config.database()) .await @@ -1474,7 +1562,7 @@ impl SurrealStore { Ok(Self { db }) } - pub fn database(&self) -> &Surreal<Db> { + pub fn database(&self) -> &Surreal<Any> { &self.db } @@ -5228,7 +5316,9 @@ mod tests { let rocksdb = SurrealConnectionConfig::rocksdb(" /tmp/tangle-rocksdb ", "ns", "db") .expect("rocksdb config"); let http = SurrealConnectionConfig::http(" http://127.0.0.1:8000 ", "ns", "db") - .expect("http config"); + .expect("http config") + .with_root_credentials(" root ", " root ") + .expect("http credentials"); let websocket = SurrealConnectionConfig::websocket(" ws://127.0.0.1:8000 ", "ns", "db") .expect("websocket config"); @@ -5244,6 +5334,9 @@ mod tests { endpoint: "http://127.0.0.1:8000".to_owned() } ); + let credentials = http.root_credentials().expect("http credentials"); + assert_eq!(credentials.username(), "root"); + assert_eq!(credentials.password(), "root"); assert_eq!( websocket.mode(), &SurrealConnectionMode::WebSocket { @@ -5284,6 +5377,22 @@ mod tests { .to_string(), "surreal websocket endpoint must not be empty" ); + assert_eq!( + SurrealConnectionConfig::http("http://127.0.0.1:8000", "ns", "db") + .expect("http config") + .with_root_credentials("", "root") + .expect_err("username error") + .to_string(), + "surreal root username must not be empty" + ); + assert_eq!( + SurrealConnectionConfig::http("http://127.0.0.1:8000", "ns", "db") + .expect("http config") + .with_root_credentials("root", " ") + .expect_err("password error") + .to_string(), + "surreal root password must not be empty" + ); } #[test] @@ -5402,6 +5511,20 @@ mod tests { } #[tokio::test] + async fn remote_connection_requires_root_credentials_before_network_use() { + let config = + SurrealConnectionConfig::http("http://127.0.0.1:8000", "ns", "db").expect("config"); + let error = SurrealStore::connect(&config) + .await + .expect_err("missing credentials"); + + assert_eq!( + error.message(), + "surreal remote connection requires root credentials" + ); + } + + #[tokio::test] async fn migration_tracking_schema_applies_idempotently() { let store = memory_store().await; let plan = base_migration_plan(); diff --git a/scripts/local-surrealdb-down.sh b/scripts/local-surrealdb-down.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +docker compose -f ops/local/surrealdb/compose.yml down diff --git a/scripts/local-surrealdb-up.sh b/scripts/local-surrealdb-up.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +docker compose -f ops/local/surrealdb/compose.yml up -d --wait