commit ff98dc8a5457ce71d8af9d63d99e3143dfd8139c
parent d9df23f2c5eddc300ef22851bfa8802b3ca0759c
Author: triesap <tyson@radroots.org>
Date: Sat, 6 Jun 2026 02:21:54 -0700
policy: add hidden event overlay
Diffstat:
1 file changed, 419 insertions(+), 5 deletions(-)
diff --git a/crates/tangle_store_surreal/src/lib.rs b/crates/tangle_store_surreal/src/lib.rs
@@ -705,6 +705,13 @@ pub enum SearchDocumentOutcome {
Indexed,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum HiddenEventOutcome {
+ NotFound,
+ Hidden,
+ Unhidden,
+}
+
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ListingProjectionQuery {
effective_status: Option<String>,
@@ -1896,6 +1903,147 @@ UPSERT type::record('search_doc', $doc_key) CONTENT {
response.take(0).map_err(SurrealStoreError::from)
}
+ pub async fn hide_event(
+ &self,
+ event_id: &EventId,
+ reason: &str,
+ source: &str,
+ admin_pubkey: &str,
+ created_at: UnixTimestamp,
+ ) -> Result<HiddenEventOutcome, SurrealStoreError> {
+ if self.raw_event_row(event_id).await?.is_none() {
+ return Ok(HiddenEventOutcome::NotFound);
+ }
+ let reason = required_policy_text(reason, "hidden event reason")?;
+ let source = required_policy_text(source, "hidden event source")?;
+ let admin_pubkey = required_policy_text(admin_pubkey, "admin pubkey")?;
+ self.db
+ .query(
+ r#"
+UPSERT type::record('hidden_event', $event_id) CONTENT {
+ event_id: $event_id,
+ reason: $reason,
+ source: $source,
+ created_at: $created_at,
+ admin_pubkey: $admin_pubkey
+};
+CREATE moderation_action CONTENT {
+ action_id: $action_id,
+ admin_pubkey: $admin_pubkey,
+ target_type: "event",
+ target_ref: $event_id,
+ action: "hide",
+ reason: $reason,
+ created_at: $created_at
+};
+UPDATE nostr_event SET hidden = true WHERE event_id = $event_id;
+UPDATE event_current SET hidden = true WHERE event_id = $event_id;
+UPDATE listing_current SET hidden = true WHERE event_id = $event_id;
+UPDATE search_doc SET visible = false WHERE event_id = $event_id OR current_event_id = $event_id;
+"#,
+ )
+ .bind(("event_id", event_id.as_str()))
+ .bind(("reason", reason.as_str()))
+ .bind(("source", source.as_str()))
+ .bind(("created_at", created_at.as_u64()))
+ .bind(("admin_pubkey", admin_pubkey.as_str()))
+ .bind((
+ "action_id",
+ moderation_action_id("hide", event_id.as_str(), admin_pubkey.as_str(), created_at),
+ ))
+ .await
+ .map_err(SurrealStoreError::from)?
+ .check()
+ .map_err(SurrealStoreError::from)?;
+ Ok(HiddenEventOutcome::Hidden)
+ }
+
+ pub async fn unhide_event(
+ &self,
+ event_id: &EventId,
+ reason: &str,
+ admin_pubkey: &str,
+ created_at: UnixTimestamp,
+ ) -> Result<HiddenEventOutcome, SurrealStoreError> {
+ if self.raw_event_row(event_id).await?.is_none() {
+ return Ok(HiddenEventOutcome::NotFound);
+ }
+ let reason = required_policy_text(reason, "hidden event reason")?;
+ let admin_pubkey = required_policy_text(admin_pubkey, "admin pubkey")?;
+ self.db
+ .query(
+ r#"
+DELETE type::record('hidden_event', $event_id);
+CREATE moderation_action CONTENT {
+ action_id: $action_id,
+ admin_pubkey: $admin_pubkey,
+ target_type: "event",
+ target_ref: $event_id,
+ action: "unhide",
+ reason: $reason,
+ created_at: $created_at
+};
+UPDATE nostr_event SET hidden = false WHERE event_id = $event_id;
+UPDATE event_current SET hidden = false WHERE event_id = $event_id;
+UPDATE listing_current SET hidden = false WHERE event_id = $event_id;
+UPDATE search_doc SET visible = true WHERE (event_id = $event_id OR current_event_id = $event_id) AND status = "active";
+UPDATE search_doc SET visible = false WHERE (event_id = $event_id OR current_event_id = $event_id) AND status != "active";
+"#,
+ )
+ .bind(("event_id", event_id.as_str()))
+ .bind(("reason", reason.as_str()))
+ .bind(("created_at", created_at.as_u64()))
+ .bind(("admin_pubkey", admin_pubkey.as_str()))
+ .bind((
+ "action_id",
+ moderation_action_id(
+ "unhide",
+ event_id.as_str(),
+ admin_pubkey.as_str(),
+ created_at,
+ ),
+ ))
+ .await
+ .map_err(SurrealStoreError::from)?
+ .check()
+ .map_err(SurrealStoreError::from)?;
+ Ok(HiddenEventOutcome::Unhidden)
+ }
+
+ pub async fn hidden_event_row(
+ &self,
+ event_id: &EventId,
+ ) -> Result<Option<serde_json::Value>, SurrealStoreError> {
+ let mut response = self
+ .db
+ .query("SELECT * FROM ONLY type::record('hidden_event', $event_id);")
+ .bind(("event_id", event_id.as_str()))
+ .await
+ .map_err(SurrealStoreError::from)?
+ .check()
+ .map_err(SurrealStoreError::from)?;
+ response.take(0).map_err(SurrealStoreError::from)
+ }
+
+ pub async fn moderation_action_rows(
+ &self,
+ target_type: &str,
+ target_ref: &str,
+ ) -> Result<Vec<serde_json::Value>, SurrealStoreError> {
+ let mut response = self
+ .db
+ .query(
+ "SELECT * FROM moderation_action WHERE target_type = $target_type AND target_ref = $target_ref ORDER BY created_at ASC, action_id ASC;",
+ )
+ .bind(("target_type", target_type))
+ .bind(("target_ref", target_ref))
+ .await
+ .map_err(SurrealStoreError::from)?
+ .check()
+ .map_err(SurrealStoreError::from)?;
+ response.take(0).map_err(SurrealStoreError::from)
+ }
+
async fn replace_listing_helper_rows(
&self,
table: &str,
@@ -2109,6 +2257,28 @@ fn event_tags_json(event: &Event) -> Vec<serde_json::Value> {
.collect()
}
+fn required_policy_text(value: &str, field: &str) -> Result<String, SurrealStoreError> {
+ let value = value.trim();
+ if value.is_empty() {
+ return Err(SurrealStoreError::new(&format!(
+ "{field} must not be empty"
+ )));
+ }
+ Ok(value.to_owned())
+}
+
+fn moderation_action_id(
+ action: &str,
+ target_ref: &str,
+ admin_pubkey: &str,
+ created_at: UnixTimestamp,
+) -> String {
+ checksum(&format!(
+ "{action}:{target_ref}:{admin_pubkey}:{}",
+ created_at.as_u64()
+ ))
+}
+
fn d_tag_value(event: &Event) -> Option<String> {
event
.unsigned()
@@ -2454,11 +2624,12 @@ impl From<surrealdb::Error> for SurrealStoreError {
#[cfg(test)]
mod tests {
use super::{
- CurrentEventOutcome, DeletionMarkerOutcome, ListingCurrentOutcome, ListingHelperOutcome,
- ListingProjectionQuery, ListingRevisionOutcome, MigrationApplyOutcome,
- SearchDocumentOutcome, SearchDocumentQuery, SurrealConfigError, SurrealConnectionConfig,
- SurrealConnectionMode, SurrealMigration, SurrealMigrationError, SurrealMigrationPlan,
- SurrealStore, SurrealStoreError, base_migration_plan, migration_tracking_schema,
+ CurrentEventOutcome, DeletionMarkerOutcome, HiddenEventOutcome, ListingCurrentOutcome,
+ ListingHelperOutcome, ListingProjectionQuery, ListingRevisionOutcome,
+ MigrationApplyOutcome, SearchDocumentOutcome, SearchDocumentQuery, SurrealConfigError,
+ SurrealConnectionConfig, SurrealConnectionMode, SurrealMigration, SurrealMigrationError,
+ SurrealMigrationPlan, SurrealStore, SurrealStoreError, base_migration_plan,
+ migration_tracking_schema,
};
use tangle_nips::ListingProjectionEvaluation;
use tangle_protocol::{
@@ -4341,6 +4512,249 @@ mod tests {
}
#[tokio::test]
+ async fn hidden_event_overlay_excludes_events_from_public_read_models() {
+ let store = memory_store().await;
+ store
+ .apply_plan(&base_migration_plan())
+ .await
+ .expect("apply plan");
+ let listing = build_fixture_event(&valid_public_listing_spec()).expect("listing");
+ let listing_key = format!("30402:{}:listing-a", listing.unsigned().pubkey().as_str());
+ let admin_pubkey = "a".repeat(PublicKeyHex::HEX_LENGTH);
+ let id_filter = filter_from_value(&serde_json::json!({
+ "ids": [listing.id().as_str()]
+ }))
+ .expect("id filter");
+
+ store
+ .store_raw_event(&StoredEvent::new(
+ listing.clone(),
+ UnixTimestamp::new(1_714_125_500),
+ ))
+ .await
+ .expect("raw event");
+ store
+ .maintain_current_event(&listing)
+ .await
+ .expect("current event");
+ store
+ .project_current_listing(&listing, UnixTimestamp::new(1_714_125_501))
+ .await
+ .expect("listing projection");
+ store
+ .index_listing_search_document(&listing)
+ .await
+ .expect("search document");
+
+ assert_eq!(
+ store
+ .query_raw_events(&id_filter)
+ .await
+ .expect("raw query")
+ .len(),
+ 1
+ );
+ assert_eq!(
+ store
+ .query_current_events(&id_filter)
+ .await
+ .expect("current query")
+ .len(),
+ 1
+ );
+ assert_eq!(
+ store
+ .query_current_listings(
+ &ListingProjectionQuery::new().with_effective_status("active")
+ )
+ .await
+ .expect("listing query")
+ .len(),
+ 1
+ );
+ assert_eq!(
+ store
+ .query_search_documents(
+ &SearchDocumentQuery::new()
+ .with_text("carrot")
+ .with_doc_type("listing")
+ .with_visible(true)
+ )
+ .await
+ .expect("search query")
+ .len(),
+ 1
+ );
+
+ assert_eq!(
+ store
+ .hide_event(
+ listing.id(),
+ "policy proof",
+ "admin_api",
+ &admin_pubkey,
+ UnixTimestamp::new(1_714_125_600),
+ )
+ .await
+ .expect("hide"),
+ HiddenEventOutcome::Hidden
+ );
+
+ let hidden = store
+ .hidden_event_row(listing.id())
+ .await
+ .expect("hidden row")
+ .expect("hidden row exists");
+ assert_eq!(hidden["event_id"], listing.id().as_str());
+ assert_eq!(hidden["reason"], "policy proof");
+ assert_eq!(hidden["source"], "admin_api");
+ assert_eq!(hidden["created_at"], 1_714_125_600_u64);
+ assert_eq!(hidden["admin_pubkey"], admin_pubkey);
+ assert_eq!(
+ store
+ .raw_event_row(listing.id())
+ .await
+ .expect("raw row")
+ .expect("raw row exists")["hidden"],
+ true
+ );
+ assert_eq!(
+ store
+ .current_event_row(&listing_key)
+ .await
+ .expect("current row")
+ .expect("current row exists")["hidden"],
+ true
+ );
+ assert_eq!(
+ store
+ .listing_current_row(&listing_key)
+ .await
+ .expect("listing row")
+ .expect("listing row exists")["hidden"],
+ true
+ );
+ assert_eq!(
+ store
+ .search_document_row(&listing_key)
+ .await
+ .expect("search row")
+ .expect("search row exists")["visible"],
+ false
+ );
+ assert!(
+ store
+ .query_raw_events(&id_filter)
+ .await
+ .expect("hidden raw query")
+ .is_empty()
+ );
+ assert!(
+ store
+ .query_current_events(&id_filter)
+ .await
+ .expect("hidden current query")
+ .is_empty()
+ );
+ assert!(
+ store
+ .query_current_listings(
+ &ListingProjectionQuery::new().with_effective_status("active")
+ )
+ .await
+ .expect("hidden listing query")
+ .is_empty()
+ );
+ assert!(
+ store
+ .query_search_documents(
+ &SearchDocumentQuery::new()
+ .with_text("carrot")
+ .with_doc_type("listing")
+ .with_visible(true)
+ )
+ .await
+ .expect("hidden search query")
+ .is_empty()
+ );
+ let actions = store
+ .moderation_action_rows("event", listing.id().as_str())
+ .await
+ .expect("actions");
+ assert_eq!(actions.len(), 1);
+ assert_eq!(actions[0]["action"], "hide");
+ assert_eq!(actions[0]["target_ref"], listing.id().as_str());
+
+ assert_eq!(
+ store
+ .unhide_event(
+ listing.id(),
+ "policy proof complete",
+ &admin_pubkey,
+ UnixTimestamp::new(1_714_125_700),
+ )
+ .await
+ .expect("unhide"),
+ HiddenEventOutcome::Unhidden
+ );
+ assert!(
+ store
+ .hidden_event_row(listing.id())
+ .await
+ .expect("hidden row removed")
+ .is_none()
+ );
+ assert_eq!(
+ store
+ .raw_event_row(listing.id())
+ .await
+ .expect("raw row")
+ .expect("raw row exists")["hidden"],
+ false
+ );
+ assert_eq!(
+ store
+ .search_document_row(&listing_key)
+ .await
+ .expect("search row")
+ .expect("search row exists")["visible"],
+ true
+ );
+ assert_eq!(
+ store
+ .query_search_documents(
+ &SearchDocumentQuery::new()
+ .with_text("carrot")
+ .with_doc_type("listing")
+ .with_visible(true)
+ )
+ .await
+ .expect("visible search query")
+ .len(),
+ 1
+ );
+ let actions = store
+ .moderation_action_rows("event", listing.id().as_str())
+ .await
+ .expect("actions");
+ assert_eq!(actions.len(), 2);
+ assert_eq!(actions[1]["action"], "unhide");
+ assert_eq!(
+ store
+ .hide_event(
+ &EventId::new(&"b".repeat(EventId::HEX_LENGTH)).expect("missing id"),
+ "missing",
+ "admin_api",
+ &admin_pubkey,
+ UnixTimestamp::new(1_714_125_800),
+ )
+ .await
+ .expect("missing hide"),
+ HiddenEventOutcome::NotFound
+ );
+ }
+
+ #[tokio::test]
async fn private_helpers_cover_debug_errors_and_decimal_edges() {
let store = memory_store().await;
assert!(format!("{store:?}").contains("SurrealStore"));