commit a8924637a85671bd35eb040bc217e2ea794dedf4
parent f4bbc80895292b97aa04f494a0128489447c2391
Author: triesap <tyson@radroots.org>
Date: Sun, 14 Jun 2026 06:13:09 -0700
config: reject ignored production fields
- Remove the unused group redaction config surface from parsing, examples, and tests.
- Reject unknown runtime and group config fields instead of silently accepting unenforced settings.
- Apply max_outbox_replay_batch as a real bounded outbox replay drain limit.
- Validated with cargo fmt --all -- --check, cargo check --workspace --all-targets, cargo test --workspace, and cargo clippy --workspace --all-targets -- -D warnings.
Diffstat:
6 files changed, 175 insertions(+), 79 deletions(-)
diff --git a/crates/tangle/tests/version.rs b/crates/tangle/tests/version.rs
@@ -112,10 +112,6 @@ fn tangle_run_starts_server_and_stays_alive_until_shutdown() {
"relay_secret": TANGLE_V2_RELAY_SECRET_HEX,
"owner_pubkeys": [FixtureKey::Owner.public_key().as_str()],
"admin_pubkeys": [FixtureKey::Admin.public_key().as_str()],
- "redaction": {
- "redact_private_tags": true,
- "redact_invite_codes": true
- },
"limits": {
"max_group_id_bytes": 128,
"max_group_tags_per_event": 8,
diff --git a/crates/tangle_groups/src/lib.rs b/crates/tangle_groups/src/lib.rs
@@ -122,37 +122,6 @@ impl fmt::Display for CanonicalRelayUrl {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
-pub struct GroupRedactionConfig {
- #[serde(default = "default_true")]
- redact_private_tags: bool,
- #[serde(default = "default_true")]
- redact_invite_codes: bool,
-}
-
-impl GroupRedactionConfig {
- pub fn strict() -> Self {
- Self {
- redact_private_tags: true,
- redact_invite_codes: true,
- }
- }
-
- pub fn redact_private_tags(&self) -> bool {
- self.redact_private_tags
- }
-
- pub fn redact_invite_codes(&self) -> bool {
- self.redact_invite_codes
- }
-}
-
-impl Default for GroupRedactionConfig {
- fn default() -> Self {
- Self::strict()
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct GroupPolicyConfig {
#[serde(default)]
@@ -205,7 +174,6 @@ impl Default for GroupPolicyConfig {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GroupRuntimeSettingsConfig {
policy: GroupPolicyConfig,
- redaction: GroupRedactionConfig,
limits: GroupLimitsConfig,
}
@@ -213,21 +181,15 @@ impl GroupRuntimeSettingsConfig {
pub fn strict() -> Self {
Self {
policy: GroupPolicyConfig::strict(),
- redaction: GroupRedactionConfig::strict(),
limits: GroupLimitsConfig::default(),
}
}
pub fn new(
policy: GroupPolicyConfig,
- redaction: GroupRedactionConfig,
limits: GroupLimitsConfig,
) -> Result<Self, GroupConfigError> {
- let value = Self {
- policy,
- redaction,
- limits,
- };
+ let value = Self { policy, limits };
value.validate()?;
Ok(value)
}
@@ -241,10 +203,6 @@ impl GroupRuntimeSettingsConfig {
self.policy
}
- pub fn redaction(&self) -> GroupRedactionConfig {
- self.redaction
- }
-
pub fn limits(&self) -> GroupLimitsConfig {
self.limits
}
@@ -257,6 +215,7 @@ impl Default for GroupRuntimeSettingsConfig {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
+#[serde(deny_unknown_fields)]
pub struct GroupLimitsConfig {
#[serde(default = "default_max_group_id_bytes")]
max_group_id_bytes: u16,
@@ -418,10 +377,6 @@ impl GroupRuntimeConfig {
self.settings.policy()
}
- pub fn redaction(&self) -> GroupRedactionConfig {
- self.settings.redaction()
- }
-
pub fn limits(&self) -> GroupLimitsConfig {
self.settings.limits()
}
@@ -453,6 +408,7 @@ impl fmt::Display for GroupConfigError {
impl std::error::Error for GroupConfigError {}
#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
struct GroupRuntimeConfigDocument {
enabled: bool,
canonical_relay_url: Option<String>,
@@ -464,8 +420,6 @@ struct GroupRuntimeConfigDocument {
#[serde(default)]
policy: GroupPolicyConfig,
#[serde(default)]
- redaction: GroupRedactionConfig,
- #[serde(default)]
limits: GroupLimitsConfig,
}
@@ -489,7 +443,7 @@ pub fn parse_group_runtime_config_json(raw: &str) -> Result<GroupRuntimeConfig,
relay_secret,
parse_pubkeys("groups.owner_pubkeys", document.owner_pubkeys)?,
parse_pubkeys("groups.admin_pubkeys", document.admin_pubkeys)?,
- GroupRuntimeSettingsConfig::new(document.policy, document.redaction, document.limits)?,
+ GroupRuntimeSettingsConfig::new(document.policy, document.limits)?,
)
}
@@ -534,10 +488,6 @@ where
Ok(())
}
-fn default_true() -> bool {
- true
-}
-
fn default_max_group_id_bytes() -> u16 {
128
}
@@ -588,7 +538,6 @@ mod tests {
"owner_pubkeys": ["{owner}"],
"admin_pubkeys": ["{admin}"],
"policy": {{"public_join": false, "invites_enabled": false}},
- "redaction": {{"redact_private_tags": true, "redact_invite_codes": true}},
"limits": {{
"max_group_id_bytes": 64,
"max_group_tags_per_event": 4,
@@ -611,8 +560,6 @@ mod tests {
assert_eq!(config.policy(), GroupPolicyConfig::strict());
assert!(!config.policy().public_join());
assert!(!config.policy().invites_enabled());
- assert!(config.redaction().redact_private_tags());
- assert!(config.redaction().redact_invite_codes());
assert_eq!(config.limits().max_group_id_bytes(), 64);
assert_eq!(config.limits().max_group_tags_per_event(), 4);
assert_eq!(config.limits().max_supported_kinds(), 32);
@@ -658,6 +605,29 @@ mod tests {
}
#[test]
+ fn group_config_rejects_removed_and_unknown_fields() {
+ let removed_redaction = parse_group_runtime_config_json(
+ r#"{"enabled": false, "redaction": {"redact_private_tags": true}}"#,
+ )
+ .expect_err("redaction");
+ assert!(
+ removed_redaction
+ .message()
+ .contains("unknown field `redaction`")
+ );
+
+ let unknown_limit = parse_group_runtime_config_json(
+ r#"{"enabled": false, "limits": {"max_unimplemented_outbox_batch": 10}}"#,
+ )
+ .expect_err("unknown limit");
+ assert!(
+ unknown_limit
+ .message()
+ .contains("unknown field `max_unimplemented_outbox_batch`")
+ );
+ }
+
+ #[test]
fn relay_secret_debug_output_is_redacted() {
let secret = RelaySecret::from_hex(&"a".repeat(64)).expect("secret");
diff --git a/crates/tangle_runtime/src/config.rs b/crates/tangle_runtime/src/config.rs
@@ -133,6 +133,7 @@ impl Default for BaseRelayTracingConfig {
}
#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
struct BaseRelayRuntimeConfigDocument {
server: BaseRelayServerConfigDocument,
pocket: BaseRelayPocketConfigDocument,
@@ -144,12 +145,14 @@ struct BaseRelayRuntimeConfigDocument {
}
#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
struct BaseRelayServerConfigDocument {
listen_addr: String,
relay_url: String,
}
#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
struct BaseRelayPocketConfigDocument {
data_directory: String,
map_size_bytes: u64,
@@ -165,23 +168,27 @@ enum BaseRelayPocketSyncPolicyDocument {
}
#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
struct BaseRelayAuthConfigDocument {
challenge_ttl_seconds: u64,
created_at_skew_seconds: u64,
}
#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
struct BaseRelayRuntimeLimitsDocument {
max_pending_events: usize,
}
#[derive(Debug, Default, Deserialize)]
+#[serde(deny_unknown_fields)]
struct BaseRelayObservabilityConfigDocument {
#[serde(default)]
tracing: BaseRelayTracingConfigDocument,
}
#[derive(Debug, Default, Deserialize)]
+#[serde(deny_unknown_fields)]
struct BaseRelayTracingConfigDocument {
enabled: Option<bool>,
filter: Option<String>,
@@ -334,4 +341,67 @@ mod tests {
"invalid: auth.created_at_skew_seconds must be greater than zero"
);
}
+
+ #[test]
+ fn base_relay_runtime_config_rejects_unknown_fields() {
+ let unknown_top_level = r#"{
+ "server": {
+ "listen_addr": "127.0.0.1:0",
+ "relay_url": "wss://relay.radroots.test"
+ },
+ "pocket": {
+ "data_directory": "runtime/pocket",
+ "map_size_bytes": 1073741824,
+ "reader_slots": 128,
+ "sync_policy": "flush_on_shutdown"
+ },
+ "groups": {
+ "enabled": false
+ },
+ "auth": {
+ "challenge_ttl_seconds": 300,
+ "created_at_skew_seconds": 600
+ },
+ "limits": {
+ "max_pending_events": 8
+ },
+ "ignored": true
+ }"#;
+ assert!(
+ parse_base_relay_runtime_config_json(unknown_top_level)
+ .expect_err("unknown top-level field")
+ .prefixed_message()
+ .contains("unknown field `ignored`")
+ );
+
+ let unknown_nested = r#"{
+ "server": {
+ "listen_addr": "127.0.0.1:0",
+ "relay_url": "wss://relay.radroots.test"
+ },
+ "pocket": {
+ "data_directory": "runtime/pocket",
+ "map_size_bytes": 1073741824,
+ "reader_slots": 128,
+ "sync_policy": "flush_on_shutdown"
+ },
+ "groups": {
+ "enabled": false
+ },
+ "auth": {
+ "challenge_ttl_seconds": 300,
+ "created_at_skew_seconds": 600
+ },
+ "limits": {
+ "max_pending_events": 8,
+ "max_unimplemented_limit": 99
+ }
+ }"#;
+ assert!(
+ parse_base_relay_runtime_config_json(unknown_nested)
+ .expect_err("unknown nested field")
+ .prefixed_message()
+ .contains("unknown field `max_unimplemented_limit`")
+ );
+ }
}
diff --git a/crates/tangle_runtime/src/groups.rs b/crates/tangle_runtime/src/groups.rs
@@ -35,6 +35,7 @@ pub(crate) struct GroupService {
policy: GroupPolicyConfig,
limits: GroupLimitsConfig,
member_snapshot_cap: u32,
+ outbox_replay_batch_cap: u32,
}
impl GroupService {
@@ -62,6 +63,7 @@ impl GroupService {
policy: config.policy(),
limits: config.limits(),
member_snapshot_cap: config.limits().max_member_list_pubkeys(),
+ outbox_replay_batch_cap: config.limits().max_outbox_replay_batch(),
};
service.derive_missing_outbox_records(store)?;
service.materialize_outbox(store)?;
@@ -232,8 +234,22 @@ impl GroupService {
&mut self,
store: &PocketStoreHandle,
) -> Result<Vec<StoreOffset>, BaseRelayError> {
- let records = self.outbox.replay_plan().records().to_vec();
- self.materialize_records(store, records)
+ let mut stored_offsets = Vec::new();
+ loop {
+ let records = self
+ .outbox
+ .replay_plan()
+ .records()
+ .iter()
+ .take(self.outbox_replay_batch_cap())
+ .cloned()
+ .collect::<Vec<_>>();
+ if records.is_empty() {
+ break;
+ }
+ stored_offsets.extend(self.materialize_records(store, records)?);
+ }
+ Ok(stored_offsets)
}
fn materialize_outbox_for_group(
@@ -241,12 +257,27 @@ impl GroupService {
store: &PocketStoreHandle,
group_id: &GroupId,
) -> Result<Vec<StoreOffset>, BaseRelayError> {
- let records = self
- .outbox
- .replay_plan_for_group(group_id)
- .records()
- .to_vec();
- self.materialize_records(store, records)
+ let mut stored_offsets = Vec::new();
+ loop {
+ let records = self
+ .outbox
+ .replay_plan_for_group(group_id)
+ .records()
+ .iter()
+ .take(self.outbox_replay_batch_cap())
+ .cloned()
+ .collect::<Vec<_>>();
+ if records.is_empty() {
+ break;
+ }
+ stored_offsets.extend(self.materialize_records(store, records)?);
+ }
+ Ok(stored_offsets)
+ }
+
+ fn outbox_replay_batch_cap(&self) -> usize {
+ usize::try_from(self.outbox_replay_batch_cap)
+ .expect("u32 outbox replay batch cap fits usize")
}
fn materialize_records(
diff --git a/crates/tangle_runtime/tests/base_relay_v2.rs b/crates/tangle_runtime/tests/base_relay_v2.rs
@@ -1321,6 +1321,23 @@ fn pending_and_retryable_group_outbox_records_materialize_on_restart() {
}
#[test]
+fn max_outbox_replay_batch_one_drains_all_pending_generated_records() {
+ let config = test_store_config("outbox-batch-one");
+ let owner_auth = authenticated(FixtureKey::Owner);
+ let mut relay =
+ BaseRelay::open_with_groups(&config, 8, &group_config_with_outbox_batch(1)).expect("relay");
+
+ accept_group_create(&mut relay, "BatchFarm", &[], 1, &owner_auth);
+
+ assert_eq!(count_kind(&relay, KIND_GROUP_METADATA), 1);
+ assert_eq!(count_kind(&relay, KIND_GROUP_ADMINS), 1);
+ let counts = outbox_status_counts(&config);
+ assert_eq!(counts.pending, 0);
+ assert_eq!(counts.retryable, 0);
+ assert_eq!(counts.stored, 2);
+}
+
+#[test]
fn already_stored_generated_events_mark_outbox_stored_without_duplication_on_restart() {
let config = test_store_config("outbox-generated-already-stored");
let owner_auth = authenticated(FixtureKey::Owner);
@@ -1909,6 +1926,22 @@ fn group_config_with_public_join() -> GroupRuntimeConfig {
.expect("groups")
}
+fn group_config_with_outbox_batch(batch: u32) -> GroupRuntimeConfig {
+ parse_group_runtime_config_json(&format!(
+ r#"{{
+ "enabled": true,
+ "canonical_relay_url": "{TANGLE_V2_RELAY_URL}",
+ "relay_secret": "{TANGLE_V2_RELAY_SECRET_HEX}",
+ "owner_pubkeys": ["{}"],
+ "admin_pubkeys": ["{}"],
+ "limits": {{"max_outbox_replay_batch": {batch}}}
+ }}"#,
+ FixtureKey::Owner.public_key().as_str(),
+ FixtureKey::Admin.public_key().as_str()
+ ))
+ .expect("groups")
+}
+
fn authenticated(key: FixtureKey) -> BaseAuthState {
let auth = BaseAuthState::new(TANGLE_V2_RELAY_URL, 60, 600).expect("auth");
let mut auth = issue_challenge(auth, "challenge-a", 100);
diff --git a/crates/tangle_test_support/src/lib.rs b/crates/tangle_test_support/src/lib.rs
@@ -6,10 +6,10 @@ use k256::schnorr::{Signature, SigningKey};
use tangle_crypto::{RelaySigner, compute_event_id};
use tangle_groups::{
CanonicalRelayUrl, GroupGeneratedEventBuilder, GroupLimitsConfig, GroupOutboxPayload,
- GroupPolicyConfig, GroupRedactionConfig, GroupRuntimeConfig, GroupRuntimeSettingsConfig,
- KIND_GROUP_CREATE_GROUP, KIND_GROUP_DELETE_EVENT, KIND_GROUP_DELETE_GROUP,
- KIND_GROUP_EDIT_METADATA, KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST,
- KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER, RelaySecret,
+ GroupPolicyConfig, GroupRuntimeConfig, GroupRuntimeSettingsConfig, KIND_GROUP_CREATE_GROUP,
+ KIND_GROUP_DELETE_EVENT, KIND_GROUP_DELETE_GROUP, KIND_GROUP_EDIT_METADATA,
+ KIND_GROUP_JOIN_REQUEST, KIND_GROUP_LEAVE_REQUEST, KIND_GROUP_PUT_USER, KIND_GROUP_REMOVE_USER,
+ RelaySecret,
};
use tangle_protocol::{
Event, EventId, Kind, PublicKeyHex, SignatureHex, Tag, UnixTimestamp, UnsignedEvent,
@@ -88,12 +88,8 @@ pub fn tangle_v2_group_config(
Some(RelaySecret::from_hex(TANGLE_V2_RELAY_SECRET_HEX).map_err(|error| error.to_string())?),
vec![owner.public_key()],
admins.iter().map(|admin| admin.public_key()).collect(),
- GroupRuntimeSettingsConfig::new(
- GroupPolicyConfig::strict(),
- GroupRedactionConfig::strict(),
- GroupLimitsConfig::default(),
- )
- .map_err(|error| error.to_string())?,
+ GroupRuntimeSettingsConfig::new(GroupPolicyConfig::strict(), GroupLimitsConfig::default())
+ .map_err(|error| error.to_string())?,
)
.map_err(|error| error.to_string())
}