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