diff --git a/crates/buzz-db/src/archived_identities.rs b/crates/buzz-db/src/archived_identities.rs index 00237ccf84..728f8bd0a9 100644 --- a/crates/buzz-db/src/archived_identities.rs +++ b/crates/buzz-db/src/archived_identities.rs @@ -1,10 +1,11 @@ -//! Relay-scoped archived identity persistence (NIP-IA). +//! Community-scoped archived identity persistence (NIP-IA). //! -//! The `archived_identities` table stores a relay-local UI visibility hint for +//! The `archived_identities` table stores a community-local UI visibility hint for //! identity pubkeys. Archiving is not a ban: it does not affect membership, //! relay access, or repository permissions. //! All pubkey and event ID values are lowercase hex strings. +use buzz_core::CommunityId; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; @@ -29,21 +30,25 @@ pub struct ArchivedIdentity { pub archived_at: DateTime, } -/// Returns `true` if `pubkey` (64-char hex) is currently archived. -pub async fn is_archived(pool: &PgPool, pubkey: &str) -> Result { - let row = sqlx::query("SELECT 1 FROM archived_identities WHERE pubkey = $1") - .bind(pubkey) - .fetch_optional(pool) - .await?; +/// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. +pub async fn is_archived(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { + let row = + sqlx::query("SELECT 1 FROM archived_identities WHERE community_id = $1 AND pubkey = $2") + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(pool) + .await?; Ok(row.is_some()) } -/// Archives an identity. +/// Archives an identity in `community_id`. /// /// Returns `true` if the row was inserted, `false` if the identity was already -/// archived. Re-archiving is idempotent and does not mutate the existing row. +/// archived in that community. Re-archiving is idempotent and does not mutate +/// the existing row. pub async fn archive( pool: &PgPool, + community_id: CommunityId, pubkey: &str, consent_path: &str, actor: &str, @@ -53,10 +58,11 @@ pub async fn archive( ) -> Result { let result = sqlx::query( "INSERT INTO archived_identities \ - (pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ - VALUES ($1, $2, $3, $4, $5, $6) \ - ON CONFLICT (pubkey) DO NOTHING", + (community_id, pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) \ + ON CONFLICT (community_id, pubkey) DO NOTHING", ) + .bind(community_id.as_uuid()) .bind(pubkey) .bind(consent_path) .bind(actor) @@ -69,24 +75,31 @@ pub async fn archive( Ok(result.rows_affected() > 0) } -/// Unarchives an identity. +/// Unarchives an identity from `community_id`. /// -/// Returns `true` if a row was deleted, `false` if the identity was not archived. -pub async fn unarchive(pool: &PgPool, pubkey: &str) -> Result { - let result = sqlx::query("DELETE FROM archived_identities WHERE pubkey = $1") - .bind(pubkey) - .execute(pool) - .await?; +/// Returns `true` if a row was deleted, `false` if the identity was not archived +/// in that community. +pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { + let result = + sqlx::query("DELETE FROM archived_identities WHERE community_id = $1 AND pubkey = $2") + .bind(community_id.as_uuid()) + .bind(pubkey) + .execute(pool) + .await?; Ok(result.rows_affected() > 0) } -/// Returns all archived identities ordered by archive time ascending. -pub async fn list_archived(pool: &PgPool) -> Result> { +/// Returns all identities archived in `community_id`, ordered by archive time ascending. +pub async fn list_archived( + pool: &PgPool, + community_id: CommunityId, +) -> Result> { let rows = sqlx::query( "SELECT pubkey, consent_path, actor, reason, replaced_by, request_event_id, archived_at \ - FROM archived_identities ORDER BY archived_at ASC", + FROM archived_identities WHERE community_id = $1 ORDER BY archived_at ASC", ) + .bind(community_id.as_uuid()) .fetch_all(pool) .await?; @@ -109,3 +122,99 @@ fn row_to_archived_identity( archived_at: row.try_get("archived_at")?, }) } + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + PgPool::connect(TEST_DB_URL) + .await + .expect("connect to test DB") + } + + async fn make_community(pool: &PgPool) -> CommunityId { + let id = uuid::Uuid::new_v4(); + let host = format!("archive-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn archived_identity_state_is_community_scoped() { + let pool = setup_pool().await; + let community_a = make_community(&pool).await; + let community_b = make_community(&pool).await; + let pubkey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let actor = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let event_a = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let event_b = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + + assert!(archive( + &pool, + community_a, + pubkey, + "self", + actor, + Some("community A"), + None, + event_a, + ) + .await + .expect("archive in community A")); + + assert!(is_archived(&pool, community_a, pubkey) + .await + .expect("is_archived in A")); + assert!(!is_archived(&pool, community_b, pubkey) + .await + .expect("is_archived in B")); + assert_eq!( + list_archived(&pool, community_a) + .await + .expect("list A") + .len(), + 1 + ); + assert!(list_archived(&pool, community_b) + .await + .expect("list B") + .is_empty()); + assert!(!unarchive(&pool, community_b, pubkey) + .await + .expect("unarchive absent B")); + assert!(is_archived(&pool, community_a, pubkey) + .await + .expect("B unarchive must not affect A")); + + assert!(archive( + &pool, + community_b, + pubkey, + "self", + actor, + Some("community B"), + None, + event_b, + ) + .await + .expect("archive same pubkey in community B")); + assert!(unarchive(&pool, community_a, pubkey) + .await + .expect("unarchive A")); + assert!(!is_archived(&pool, community_a, pubkey) + .await + .expect("A removed")); + assert!(is_archived(&pool, community_b, pubkey) + .await + .expect("A unarchive must not affect B")); + } +} diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index efd96f49d8..d5325bb5f7 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -1748,14 +1748,15 @@ impl Db { relay_members::backfill_from_allowlist(&self.pool).await } - /// Returns `true` if `pubkey` (64-char hex) is currently archived. - pub async fn is_archived(&self, pubkey: &str) -> Result { - archived_identities::is_archived(&self.pool, pubkey).await + /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. + pub async fn is_archived(&self, community_id: CommunityId, pubkey: &str) -> Result { + archived_identities::is_archived(&self.pool, community_id, pubkey).await } - /// Archives an identity. Returns `true` if inserted, `false` if already archived. + /// Archives an identity in `community_id`. Returns `true` if inserted, `false` if already archived. pub async fn archive( &self, + community_id: CommunityId, pubkey: &str, consent_path: &str, actor: &str, @@ -1765,6 +1766,7 @@ impl Db { ) -> Result { archived_identities::archive( &self.pool, + community_id, pubkey, consent_path, actor, @@ -1775,14 +1777,17 @@ impl Db { .await } - /// Unarchives an identity. Returns `true` if deleted, `false` if absent. - pub async fn unarchive(&self, pubkey: &str) -> Result { - archived_identities::unarchive(&self.pool, pubkey).await + /// Unarchives an identity from `community_id`. Returns `true` if deleted, `false` if absent. + pub async fn unarchive(&self, community_id: CommunityId, pubkey: &str) -> Result { + archived_identities::unarchive(&self.pool, community_id, pubkey).await } - /// Returns all archived identities ordered by archive time ascending. - pub async fn list_archived(&self) -> Result> { - archived_identities::list_archived(&self.pool).await + /// Returns all identities archived in `community_id`, ordered by archive time ascending. + pub async fn list_archived( + &self, + community_id: CommunityId, + ) -> Result> { + archived_identities::list_archived(&self.pool, community_id).await } /// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey. diff --git a/schema/schema.sql b/schema/schema.sql index 2a839adf6b..cc30730117 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -351,11 +351,13 @@ CREATE INDEX IF NOT EXISTS idx_relay_members_role ON relay_members(role); -- ── Archived identities (NIP-IA) ────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS archived_identities ( - pubkey TEXT PRIMARY KEY, + community_id UUID NOT NULL REFERENCES communities(id), + pubkey TEXT NOT NULL, consent_path TEXT NOT NULL CHECK (consent_path IN ('self', 'owner', 'admin')), actor TEXT NOT NULL, reason TEXT, replaced_by TEXT, request_event_id TEXT NOT NULL, - archived_at TIMESTAMPTZ NOT NULL DEFAULT now() + archived_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, pubkey) );