mirror of
https://github.com/block/buzz.git
synced 2026-11-01 06:51:17 +00:00
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co> Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
222 lines
7.2 KiB
Rust
222 lines
7.2 KiB
Rust
//! Community-scoped archived identity persistence (NIP-IA).
|
|
//!
|
|
//! 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 _};
|
|
|
|
use crate::error::Result;
|
|
|
|
/// A single archived identity record.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ArchivedIdentity {
|
|
/// 64-char lowercase hex pubkey of the archived identity.
|
|
pub pubkey: String,
|
|
/// Consent path that authorized the archive: `"self"`, `"owner"`, or `"admin"`.
|
|
pub consent_path: String,
|
|
/// 64-char lowercase hex pubkey of the actor that requested the archive.
|
|
pub actor: String,
|
|
/// Optional human-readable archive reason.
|
|
pub reason: Option<String>,
|
|
/// Optional 64-char lowercase hex pubkey replacing this identity.
|
|
pub replaced_by: Option<String>,
|
|
/// Hex event ID of the archive request that created this row.
|
|
pub request_event_id: String,
|
|
/// When the identity was archived.
|
|
pub archived_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// 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<bool> {
|
|
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 in `community_id`.
|
|
///
|
|
/// Returns `true` if the row was inserted, `false` if the identity was already
|
|
/// archived in that community. Re-archiving is idempotent and does not mutate
|
|
/// the existing row.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn archive(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
pubkey: &str,
|
|
consent_path: &str,
|
|
actor: &str,
|
|
reason: Option<&str>,
|
|
replaced_by: Option<&str>,
|
|
request_event_id: &str,
|
|
) -> Result<bool> {
|
|
let result = sqlx::query(
|
|
"INSERT INTO archived_identities \
|
|
(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)
|
|
.bind(reason)
|
|
.bind(replaced_by)
|
|
.bind(request_event_id)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
Ok(result.rows_affected() > 0)
|
|
}
|
|
|
|
/// Unarchives an identity from `community_id`.
|
|
///
|
|
/// 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<bool> {
|
|
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 identities archived in `community_id`, ordered by archive time ascending.
|
|
pub async fn list_archived(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
) -> Result<Vec<ArchivedIdentity>> {
|
|
let rows = sqlx::query(
|
|
"SELECT pubkey, consent_path, actor, reason, replaced_by, request_event_id, archived_at \
|
|
FROM archived_identities WHERE community_id = $1 ORDER BY archived_at ASC",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.fetch_all(pool)
|
|
.await?;
|
|
|
|
rows.into_iter()
|
|
.map(row_to_archived_identity)
|
|
.collect::<std::result::Result<Vec<_>, sqlx::Error>>()
|
|
.map_err(crate::error::DbError::from)
|
|
}
|
|
|
|
fn row_to_archived_identity(
|
|
row: sqlx::postgres::PgRow,
|
|
) -> std::result::Result<ArchivedIdentity, sqlx::Error> {
|
|
Ok(ArchivedIdentity {
|
|
pubkey: row.try_get("pubkey")?,
|
|
consent_path: row.try_get("consent_path")?,
|
|
actor: row.try_get("actor")?,
|
|
reason: row.try_get("reason")?,
|
|
replaced_by: row.try_get("replaced_by")?,
|
|
request_event_id: row.try_get("request_event_id")?,
|
|
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"));
|
|
}
|
|
}
|