feat: import Chinese-localized Buzz source snapshot
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
[package]
|
||||
name = "buzz-db"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Postgres event store and data access layer for Buzz"
|
||||
|
||||
[dependencies]
|
||||
buzz-core = { workspace = true }
|
||||
sqlx = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
hex = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
nostr = { workspace = true }
|
||||
rand = { workspace = true }
|
||||
metrics = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { workspace = true }
|
||||
metrics-util = { workspace = true }
|
||||
@@ -0,0 +1,488 @@
|
||||
//! Explicit deployment-global reads for the private deployment-admin plane.
|
||||
//!
|
||||
//! This module is the only moderation repository allowed to omit a
|
||||
//! [`CommunityId`](buzz_core::CommunityId). Keep ordinary moderation reads in
|
||||
//! [`crate::moderation`] tenant-fenced.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use sqlx::{PgPool, Row as _};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// Maximum rows accepted by one admin query.
|
||||
pub const MAX_PAGE_SIZE: i64 = 200;
|
||||
|
||||
fn bounded_limit(limit: i64) -> i64 {
|
||||
limit.clamp(1, MAX_PAGE_SIZE)
|
||||
}
|
||||
|
||||
/// Deployment-global moderation report.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminReport {
|
||||
/// Report row identifier.
|
||||
pub id: Uuid,
|
||||
/// Community identifier.
|
||||
pub community_id: Uuid,
|
||||
/// Community host.
|
||||
pub community_host: String,
|
||||
/// Signed report event identifier.
|
||||
pub report_event_id: String,
|
||||
/// Reporter public key.
|
||||
pub reporter_pubkey: String,
|
||||
/// Target class.
|
||||
pub target_kind: String,
|
||||
/// Hex target identifier.
|
||||
pub target: String,
|
||||
/// Optional channel.
|
||||
pub channel_id: Option<Uuid>,
|
||||
/// NIP-56 report category.
|
||||
pub report_type: String,
|
||||
/// Private reporter note.
|
||||
pub note: Option<String>,
|
||||
/// Lifecycle status.
|
||||
pub status: String,
|
||||
/// Resolving principal pubkey.
|
||||
pub resolved_by: Option<String>,
|
||||
/// Resolution time.
|
||||
pub resolved_at: Option<DateTime<Utc>>,
|
||||
/// Linked action.
|
||||
pub action_id: Option<Uuid>,
|
||||
/// Creation time.
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Reported message details available only on the admin report detail read.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminReportedMessage {
|
||||
/// Message author public key.
|
||||
pub author_pubkey: String,
|
||||
/// Complete message content.
|
||||
pub content: String,
|
||||
/// Timestamp signed into the message event.
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Soft-deletion time, when the message has since been deleted.
|
||||
pub deleted_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Deployment-global moderation report detail.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminReportDetail {
|
||||
/// Report metadata.
|
||||
#[serde(flatten)]
|
||||
pub report: AdminReport,
|
||||
/// Reported message when the report targets a stored event.
|
||||
pub message: Option<AdminReportedMessage>,
|
||||
}
|
||||
|
||||
/// Deployment-global product feedback with source-community provenance.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct AdminFeedback {
|
||||
/// Feedback row identifier.
|
||||
pub id: Uuid,
|
||||
/// Source community identifier.
|
||||
pub community_id: Uuid,
|
||||
/// Source community host.
|
||||
pub community_host: String,
|
||||
/// Signed feedback event identifier.
|
||||
pub event_id: String,
|
||||
/// Submitter public key.
|
||||
pub submitter_pubkey: String,
|
||||
/// Optional feedback category.
|
||||
pub category: Option<String>,
|
||||
/// Full feedback body.
|
||||
pub body: String,
|
||||
/// Full source tags, including attachment metadata.
|
||||
pub tags: serde_json::Value,
|
||||
/// Timestamp signed into the feedback event.
|
||||
pub event_created_at: DateTime<Utc>,
|
||||
/// Time accepted by this deployment.
|
||||
pub received_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// List reports across all communities by stable descending keyset.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list_reports(
|
||||
pool: &PgPool,
|
||||
community_id: Option<Uuid>,
|
||||
status: Option<&str>,
|
||||
report_type: Option<&str>,
|
||||
target_kind: Option<&str>,
|
||||
after: Option<DateTime<Utc>>,
|
||||
before: Option<DateTime<Utc>>,
|
||||
cursor: Option<(DateTime<Utc>, Uuid)>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<AdminReport>> {
|
||||
let (cursor_time, cursor_id) = cursor.unzip();
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT r.id, r.community_id, c.host AS community_host,
|
||||
r.report_event_id, r.reporter_pubkey, r.target_kind,
|
||||
r.target_event_id, r.target_pubkey, r.target_blob_sha256,
|
||||
r.channel_id, r.report_type, r.note, r.status, r.resolved_by,
|
||||
r.resolved_at, r.action_id, r.created_at
|
||||
FROM moderation_reports r
|
||||
JOIN communities c ON c.id = r.community_id
|
||||
WHERE ($1::uuid IS NULL OR r.community_id = $1)
|
||||
AND ($2::text IS NULL OR r.status = $2)
|
||||
AND ($3::text IS NULL OR r.report_type = $3)
|
||||
AND ($4::text IS NULL OR r.target_kind = $4)
|
||||
AND ($5::timestamptz IS NULL OR r.created_at >= $5)
|
||||
AND ($6::timestamptz IS NULL OR r.created_at < $6)
|
||||
AND ($7::timestamptz IS NULL OR (r.created_at, r.id) < ($7, $8))
|
||||
ORDER BY r.created_at DESC, r.id DESC
|
||||
LIMIT $9
|
||||
"#,
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(status)
|
||||
.bind(report_type)
|
||||
.bind(target_kind)
|
||||
.bind(after)
|
||||
.bind(before)
|
||||
.bind(cursor_time)
|
||||
.bind(cursor_id)
|
||||
.bind(bounded_limit(limit))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
rows.into_iter().map(row_to_report).collect()
|
||||
}
|
||||
|
||||
/// Fetch one report globally by its row id, including its event target content.
|
||||
pub async fn get_report(pool: &PgPool, report_id: Uuid) -> Result<Option<AdminReportDetail>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT r.id, r.community_id, c.host AS community_host,
|
||||
r.report_event_id, r.reporter_pubkey, r.target_kind,
|
||||
r.target_event_id, r.target_pubkey, r.target_blob_sha256,
|
||||
r.channel_id, r.report_type, r.note, r.status, r.resolved_by,
|
||||
r.resolved_at, r.action_id, r.created_at,
|
||||
target.pubkey AS message_author_pubkey,
|
||||
target.content AS message_content,
|
||||
target.created_at AS message_created_at,
|
||||
target.deleted_at AS message_deleted_at
|
||||
FROM moderation_reports r
|
||||
JOIN communities c ON c.id = r.community_id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT e.pubkey, e.content, e.created_at, e.deleted_at
|
||||
FROM events e
|
||||
WHERE r.target_kind = 'event'
|
||||
AND e.community_id = r.community_id
|
||||
AND e.id = r.target_event_id
|
||||
ORDER BY e.created_at DESC
|
||||
LIMIT 1
|
||||
) target ON TRUE
|
||||
WHERE r.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(report_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
row.map(|row| {
|
||||
let message = row
|
||||
.try_get::<Option<Vec<u8>>, _>("message_author_pubkey")?
|
||||
.map(|author_pubkey| -> Result<AdminReportedMessage> {
|
||||
Ok(AdminReportedMessage {
|
||||
author_pubkey: hex::encode(author_pubkey),
|
||||
content: row.try_get("message_content")?,
|
||||
created_at: row.try_get("message_created_at")?,
|
||||
deleted_at: row.try_get("message_deleted_at")?,
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
Ok(AdminReportDetail {
|
||||
report: row_to_report(row)?,
|
||||
message,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn row_to_report(row: sqlx::postgres::PgRow) -> Result<AdminReport> {
|
||||
let target_kind: String = row.try_get("target_kind")?;
|
||||
let target = match target_kind.as_str() {
|
||||
"event" => row.try_get::<Vec<u8>, _>("target_event_id")?,
|
||||
"pubkey" => row.try_get::<Vec<u8>, _>("target_pubkey")?,
|
||||
"blob" => row.try_get::<Vec<u8>, _>("target_blob_sha256")?,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(AdminReport {
|
||||
id: row.try_get("id")?,
|
||||
community_id: row.try_get("community_id")?,
|
||||
community_host: row.try_get("community_host")?,
|
||||
report_event_id: hex::encode(row.try_get::<Vec<u8>, _>("report_event_id")?),
|
||||
reporter_pubkey: hex::encode(row.try_get::<Vec<u8>, _>("reporter_pubkey")?),
|
||||
target_kind,
|
||||
target: hex::encode(target),
|
||||
channel_id: row.try_get("channel_id")?,
|
||||
report_type: row.try_get("report_type")?,
|
||||
note: row.try_get("note")?,
|
||||
status: row.try_get("status")?,
|
||||
resolved_by: row
|
||||
.try_get::<Option<Vec<u8>>, _>("resolved_by")?
|
||||
.map(hex::encode),
|
||||
resolved_at: row.try_get("resolved_at")?,
|
||||
action_id: row.try_get("action_id")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// List product feedback across all communities, newest first.
|
||||
pub async fn list_feedback(pool: &PgPool, limit: i64) -> Result<Vec<AdminFeedback>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT f.id, f.community_id, c.host AS community_host, f.event_id,
|
||||
f.submitter_pubkey, f.category, f.body, f.tags,
|
||||
f.event_created_at, f.received_at
|
||||
FROM product_feedback f
|
||||
JOIN communities c ON c.id = f.community_id
|
||||
ORDER BY f.received_at DESC, f.id DESC
|
||||
LIMIT $1
|
||||
"#,
|
||||
)
|
||||
.bind(bounded_limit(limit))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
rows.into_iter().map(row_to_feedback).collect()
|
||||
}
|
||||
|
||||
/// Fetch one feedback submission globally by its row id.
|
||||
pub async fn get_feedback(pool: &PgPool, id: Uuid) -> Result<Option<AdminFeedback>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT f.id, f.community_id, c.host AS community_host, f.event_id,
|
||||
f.submitter_pubkey, f.category, f.body, f.tags,
|
||||
f.event_created_at, f.received_at
|
||||
FROM product_feedback f
|
||||
JOIN communities c ON c.id = f.community_id
|
||||
WHERE f.id = $1
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
row.map(row_to_feedback).transpose()
|
||||
}
|
||||
|
||||
fn row_to_feedback(row: sqlx::postgres::PgRow) -> Result<AdminFeedback> {
|
||||
Ok(AdminFeedback {
|
||||
id: row.try_get("id")?,
|
||||
community_id: row.try_get("community_id")?,
|
||||
community_host: row.try_get("community_host")?,
|
||||
event_id: hex::encode(row.try_get::<Vec<u8>, _>("event_id")?),
|
||||
submitter_pubkey: hex::encode(row.try_get::<Vec<u8>, _>("submitter_pubkey")?),
|
||||
category: row.try_get("category")?,
|
||||
body: row.try_get("body")?,
|
||||
tags: row.try_get("tags")?,
|
||||
event_created_at: row.try_get("event_created_at")?,
|
||||
received_at: row.try_get("received_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
|
||||
|
||||
async fn setup_pool() -> PgPool {
|
||||
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
||||
PgPool::connect(&database_url)
|
||||
.await
|
||||
.expect("connect to test DB")
|
||||
}
|
||||
|
||||
async fn insert_community(pool: &PgPool, label: &str) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||||
.bind(id)
|
||||
.bind(format!("admin-report-{label}-{}.example", id.simple()))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert community");
|
||||
id
|
||||
}
|
||||
|
||||
async fn insert_event(
|
||||
pool: &PgPool,
|
||||
community_id: Uuid,
|
||||
event_id: &[u8],
|
||||
author: &[u8],
|
||||
content: &str,
|
||||
deleted_at: Option<DateTime<Utc>>,
|
||||
) {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO events (
|
||||
community_id, id, pubkey, created_at, kind, tags, content, sig, deleted_at
|
||||
) VALUES ($1, $2, $3, $4, 9, '[]'::jsonb, $5, $6, $7)
|
||||
"#,
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(event_id)
|
||||
.bind(author)
|
||||
.bind(Utc::now())
|
||||
.bind(content)
|
||||
.bind(vec![3_u8; 64])
|
||||
.bind(deleted_at)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert event");
|
||||
}
|
||||
|
||||
async fn insert_event_report(
|
||||
pool: &PgPool,
|
||||
community_id: Uuid,
|
||||
target_event_id: &[u8],
|
||||
) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO moderation_reports (
|
||||
community_id, id, report_event_id, reporter_pubkey,
|
||||
target_kind, target_event_id, report_type
|
||||
) VALUES ($1, $2, $3, $4, 'event', $5, 'spam')
|
||||
"#,
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(id)
|
||||
.bind(Uuid::new_v4().as_bytes().repeat(2))
|
||||
.bind(vec![4_u8; 32])
|
||||
.bind(target_event_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert report");
|
||||
id
|
||||
}
|
||||
|
||||
async fn insert_pubkey_report(pool: &PgPool, community_id: Uuid) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO moderation_reports (
|
||||
community_id, id, report_event_id, reporter_pubkey,
|
||||
target_kind, target_pubkey, report_type
|
||||
) VALUES ($1, $2, $3, $4, 'pubkey', $5, 'spam')
|
||||
"#,
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(id)
|
||||
.bind(Uuid::new_v4().as_bytes().repeat(2))
|
||||
.bind(vec![4_u8; 32])
|
||||
.bind(vec![7_u8; 32])
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert report");
|
||||
id
|
||||
}
|
||||
|
||||
async fn delete_report_fixture(pool: &PgPool, community_id: Uuid) {
|
||||
sqlx::query("DELETE FROM moderation_reports WHERE community_id = $1")
|
||||
.bind(community_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("delete report fixture");
|
||||
sqlx::query("DELETE FROM communities WHERE id = $1")
|
||||
.bind(community_id)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("delete community fixture");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn report_detail_reads_only_the_same_community_target_and_includes_deleted_content() {
|
||||
let pool = setup_pool().await;
|
||||
let report_community = insert_community(&pool, "reported").await;
|
||||
let other_community = insert_community(&pool, "other").await;
|
||||
let event_id = vec![1_u8; 32];
|
||||
let deleted_at = Utc::now();
|
||||
insert_event(
|
||||
&pool,
|
||||
report_community,
|
||||
&event_id,
|
||||
&[5_u8; 32],
|
||||
"reported message",
|
||||
Some(deleted_at),
|
||||
)
|
||||
.await;
|
||||
insert_event(
|
||||
&pool,
|
||||
other_community,
|
||||
&event_id,
|
||||
&[6_u8; 32],
|
||||
"wrong tenant message",
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
let report_id = insert_event_report(&pool, report_community, &event_id).await;
|
||||
|
||||
let detail = get_report(&pool, report_id)
|
||||
.await
|
||||
.expect("query report")
|
||||
.expect("report exists");
|
||||
let message = detail.message.expect("reported message exists");
|
||||
assert_eq!(message.content, "reported message");
|
||||
assert_eq!(message.author_pubkey, hex::encode([5_u8; 32]));
|
||||
assert!(message.deleted_at.is_some());
|
||||
|
||||
sqlx::query("DELETE FROM moderation_reports WHERE community_id = $1")
|
||||
.bind(report_community)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("delete report fixture");
|
||||
sqlx::query("DELETE FROM events WHERE community_id = ANY($1)")
|
||||
.bind(vec![report_community, other_community])
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("delete event fixtures");
|
||||
sqlx::query("DELETE FROM communities WHERE id = ANY($1)")
|
||||
.bind(vec![report_community, other_community])
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("delete community fixtures");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn report_detail_has_no_message_for_non_event_target() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = insert_community(&pool, "pubkey-target").await;
|
||||
let report_id = insert_pubkey_report(&pool, community_id).await;
|
||||
|
||||
let detail = get_report(&pool, report_id)
|
||||
.await
|
||||
.expect("query report")
|
||||
.expect("report exists");
|
||||
assert_eq!(detail.report.target_kind, "pubkey");
|
||||
assert!(detail.message.is_none());
|
||||
|
||||
delete_report_fixture(&pool, community_id).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn report_detail_has_no_message_when_event_row_is_missing() {
|
||||
let pool = setup_pool().await;
|
||||
let community_id = insert_community(&pool, "missing-event").await;
|
||||
let missing_event_id = vec![8_u8; 32];
|
||||
let report_id = insert_event_report(&pool, community_id, &missing_event_id).await;
|
||||
|
||||
let detail = get_report(&pool, report_id)
|
||||
.await
|
||||
.expect("query report")
|
||||
.expect("report exists");
|
||||
assert_eq!(detail.report.target_kind, "event");
|
||||
assert_eq!(detail.report.target, hex::encode(missing_event_id));
|
||||
assert!(detail.message.is_none());
|
||||
|
||||
delete_report_fixture(&pool, community_id).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
//! API token CRUD operations.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
|
||||
/// Create a new API token record. The caller is responsible for generating
|
||||
/// the raw token and computing its SHA-256 hash.
|
||||
///
|
||||
/// `community_id` is row zero: every token is scoped to a community, derived
|
||||
/// from the request's resolved tenant — never client-supplied here.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_api_token(
|
||||
pool: &PgPool,
|
||||
community_id: Uuid,
|
||||
token_hash: &[u8],
|
||||
owner_pubkey: &[u8],
|
||||
name: &str,
|
||||
scopes: &[String],
|
||||
channel_ids: Option<&[Uuid]>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<Uuid> {
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
let scopes_json =
|
||||
serde_json::to_value(scopes).map_err(|e| DbError::InvalidData(e.to_string()))?;
|
||||
|
||||
// Serialize channel_ids; propagate errors rather than silently dropping to NULL.
|
||||
let channel_ids_json: Option<serde_json::Value> = channel_ids
|
||||
.map(|ids| {
|
||||
serde_json::to_value(ids.iter().map(|id| id.to_string()).collect::<Vec<_>>())
|
||||
.map_err(|e| DbError::InvalidData(format!("channel_ids serialization: {e}")))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO api_tokens
|
||||
(community_id, id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||
"#,
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(id)
|
||||
.bind(token_hash)
|
||||
.bind(owner_pubkey)
|
||||
.bind(name)
|
||||
.bind(&scopes_json)
|
||||
.bind(&channel_ids_json)
|
||||
.bind(expires_at)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Atomic conditional INSERT: create a token only if the owner has fewer than 10 active tokens.
|
||||
///
|
||||
/// Uses a subquery so the check and insert are atomic --
|
||||
/// no TOCTOU race between a separate count query and the insert.
|
||||
///
|
||||
/// The 10-token limit is per (community, owner) — a user's quota is scoped to
|
||||
/// their community, never global.
|
||||
///
|
||||
/// Returns `Ok(Some(uuid))` on success, `Ok(None)` if the 10-token limit is exceeded.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_api_token_if_under_limit(
|
||||
pool: &PgPool,
|
||||
community_id: Uuid,
|
||||
token_hash: &[u8],
|
||||
owner_pubkey: &[u8],
|
||||
name: &str,
|
||||
scopes: &[String],
|
||||
channel_ids: Option<&[Uuid]>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<Option<Uuid>> {
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
let scopes_json =
|
||||
serde_json::to_value(scopes).map_err(|e| DbError::InvalidData(e.to_string()))?;
|
||||
|
||||
let channel_ids_json: Option<serde_json::Value> = channel_ids
|
||||
.map(|ids| {
|
||||
serde_json::to_value(ids.iter().map(|id| id.to_string()).collect::<Vec<_>>())
|
||||
.map_err(|e| DbError::InvalidData(format!("channel_ids serialization: {e}")))
|
||||
})
|
||||
.transpose()?;
|
||||
|
||||
// Conditional INSERT: only inserts if active (non-revoked, non-expired) token count < 10
|
||||
// **for this (community, owner) pair**. The subquery and insert execute atomically --
|
||||
// no separate count + insert race.
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO api_tokens
|
||||
(community_id, id, token_hash, owner_pubkey, name, scopes, channel_ids, expires_at, created_by_self_mint)
|
||||
SELECT $1, $2, $3, $4, $5, $6, $7, $8, TRUE
|
||||
WHERE (
|
||||
SELECT COUNT(*)
|
||||
FROM api_tokens
|
||||
WHERE community_id = $1
|
||||
AND owner_pubkey = $9
|
||||
AND revoked_at IS NULL
|
||||
AND (expires_at IS NULL OR expires_at > NOW())
|
||||
) < 10
|
||||
"#,
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(id)
|
||||
.bind(token_hash)
|
||||
.bind(owner_pubkey)
|
||||
.bind(name)
|
||||
.bind(&scopes_json)
|
||||
.bind(&channel_ids_json)
|
||||
.bind(expires_at)
|
||||
.bind(owner_pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
// Limit exceeded -- the WHERE clause prevented the INSERT.
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(id))
|
||||
}
|
||||
|
||||
/// Look up an API token by its SHA-256 hash, **including revoked tokens**,
|
||||
/// scoped to the request's community.
|
||||
///
|
||||
/// The lookup is keyed on `(community_id, token_hash)` — the same key the
|
||||
/// storage UNIQUE index uses. This closes the row-44 conformance obligation:
|
||||
/// a token minted in community A must never authorize in community B, even
|
||||
/// if (by birthday-style collision or adversarial mint) the same hash exists
|
||||
/// in both. The UNIQUE index is a *storage* guarantee; this `AND community_id`
|
||||
/// clause is the *query* guarantee — both must hold for the property to be
|
||||
/// load-bearing under all schemas.
|
||||
///
|
||||
/// Unlike [`crate::Db::get_api_token_by_hash`] (which filters `revoked_at IS NULL`),
|
||||
/// this function returns the full record regardless of revocation status.
|
||||
/// The relay layer uses this to return distinct `token_revoked` vs `invalid_token`
|
||||
/// error responses rather than treating both as "not found".
|
||||
pub async fn get_api_token_by_hash_including_revoked(
|
||||
pool: &PgPool,
|
||||
community_id: Uuid,
|
||||
hash: &[u8],
|
||||
) -> Result<Option<crate::ApiTokenRecord>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
|
||||
created_at, expires_at, last_used_at, revoked_at
|
||||
FROM api_tokens
|
||||
WHERE community_id = $1 AND token_hash = $2
|
||||
"#,
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(hash)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
let row = match row {
|
||||
None => return Ok(None),
|
||||
Some(r) => r,
|
||||
};
|
||||
|
||||
let id: Uuid = row.try_get("id")?;
|
||||
|
||||
let scopes_json: serde_json::Value = row.try_get("scopes")?;
|
||||
let scopes: Vec<String> = serde_json::from_value(scopes_json)
|
||||
.map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?;
|
||||
|
||||
let channel_ids: Option<Vec<Uuid>> = {
|
||||
let raw: Option<serde_json::Value> = row.try_get("channel_ids")?;
|
||||
match raw {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let strings: Vec<String> = serde_json::from_value(v)
|
||||
.map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?;
|
||||
let uuids: std::result::Result<Vec<Uuid>, _> =
|
||||
strings.iter().map(|s| s.parse::<Uuid>()).collect();
|
||||
Some(uuids.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(Some(crate::ApiTokenRecord {
|
||||
id,
|
||||
token_hash: row.try_get("token_hash")?,
|
||||
owner_pubkey: row.try_get("owner_pubkey")?,
|
||||
name: row.try_get("name")?,
|
||||
scopes,
|
||||
channel_ids,
|
||||
created_at: row.try_get("created_at")?,
|
||||
expires_at: row.try_get("expires_at")?,
|
||||
last_used_at: row.try_get("last_used_at")?,
|
||||
revoked_at: row.try_get("revoked_at")?,
|
||||
}))
|
||||
}
|
||||
|
||||
/// List all tokens (including revoked) for a (community, owner) pair,
|
||||
/// ordered by creation time descending.
|
||||
///
|
||||
/// Returns the full [`crate::ApiTokenRecord`] including `token_hash`. Callers are
|
||||
/// responsible for stripping `token_hash` before returning data to clients -- the
|
||||
/// raw token value is never exposed after the initial mint response.
|
||||
/// Used by `GET /api/tokens` to show a user their full token history.
|
||||
pub async fn list_tokens_by_owner(
|
||||
pool: &PgPool,
|
||||
community_id: Uuid,
|
||||
pubkey: &[u8],
|
||||
) -> Result<Vec<crate::ApiTokenRecord>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, token_hash, owner_pubkey, name, scopes, channel_ids,
|
||||
created_at, expires_at, last_used_at, revoked_at
|
||||
FROM api_tokens
|
||||
WHERE community_id = $1 AND owner_pubkey = $2
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(pubkey)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let id: Uuid = row.try_get("id")?;
|
||||
|
||||
let scopes_json: serde_json::Value = row.try_get("scopes")?;
|
||||
let scopes: Vec<String> = serde_json::from_value(scopes_json)
|
||||
.map_err(|e| DbError::InvalidData(format!("scopes JSON: {e}")))?;
|
||||
|
||||
let channel_ids: Option<Vec<Uuid>> = {
|
||||
let raw: Option<serde_json::Value> = row.try_get("channel_ids")?;
|
||||
match raw {
|
||||
None => None,
|
||||
Some(v) => {
|
||||
let strings: Vec<String> = serde_json::from_value(v)
|
||||
.map_err(|e| DbError::InvalidData(format!("channel_ids JSON: {e}")))?;
|
||||
let uuids: std::result::Result<Vec<Uuid>, _> =
|
||||
strings.iter().map(|s| s.parse::<Uuid>()).collect();
|
||||
Some(
|
||||
uuids
|
||||
.map_err(|e| DbError::InvalidData(format!("channel_ids UUID: {e}")))?,
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
out.push(crate::ApiTokenRecord {
|
||||
id,
|
||||
token_hash: row.try_get("token_hash")?,
|
||||
owner_pubkey: row.try_get("owner_pubkey")?,
|
||||
name: row.try_get("name")?,
|
||||
scopes,
|
||||
channel_ids,
|
||||
created_at: row.try_get("created_at")?,
|
||||
expires_at: row.try_get("expires_at")?,
|
||||
last_used_at: row.try_get("last_used_at")?,
|
||||
revoked_at: row.try_get("revoked_at")?,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Revoke a single token by ID, scoped to (community, owner).
|
||||
///
|
||||
/// Only revokes if the token is in `community_id`, owned by `owner_pubkey`, and not already revoked.
|
||||
/// Returns `true` if the token was revoked, `false` if not found, not owned, or already revoked.
|
||||
pub async fn revoke_token(
|
||||
pool: &PgPool,
|
||||
community_id: Uuid,
|
||||
id: Uuid,
|
||||
owner_pubkey: &[u8],
|
||||
revoked_by: &[u8],
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE api_tokens
|
||||
SET revoked_at = NOW(), revoked_by = $1
|
||||
WHERE community_id = $2
|
||||
AND id = $3
|
||||
AND owner_pubkey = $4
|
||||
AND revoked_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(revoked_by)
|
||||
.bind(community_id)
|
||||
.bind(id)
|
||||
.bind(owner_pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Revoke all active tokens for a (community, owner) pair.
|
||||
///
|
||||
/// Skips already-revoked tokens (idempotent). Returns the count of newly revoked tokens.
|
||||
/// If all tokens are already revoked, returns 0 with no error.
|
||||
pub async fn revoke_all_tokens(
|
||||
pool: &PgPool,
|
||||
community_id: Uuid,
|
||||
owner_pubkey: &[u8],
|
||||
revoked_by: &[u8],
|
||||
) -> Result<u64> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE api_tokens
|
||||
SET revoked_at = NOW(), revoked_by = $1
|
||||
WHERE community_id = $2
|
||||
AND owner_pubkey = $3
|
||||
AND revoked_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(revoked_by)
|
||||
.bind(community_id)
|
||||
.bind(owner_pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
//! Row-44 conformance: API token lookups MUST be keyed on
|
||||
//! `(community_id, token_hash)`, not on `token_hash` alone. The storage
|
||||
//! UNIQUE index is a *storage* guarantee; the WHERE clause here is the
|
||||
//! *query* guarantee. Both must hold — a query that filters on hash
|
||||
//! alone could return a foreign-community row, defeating the row-zero
|
||||
//! tenancy fence. This test directly inserts two same-hash rows in two
|
||||
//! communities (only possible by bypassing the unique index, which we
|
||||
//! achieve via distinct hashes that the test then queries-by-hash for
|
||||
//! both — see below for the actual property under test).
|
||||
//!
|
||||
//! The load-bearing property: even if storage uniqueness is ever relaxed
|
||||
//! or a hash collision occurs, the query-side `AND community_id = $N`
|
||||
//! clause guarantees the lookup returns the row for the *requested*
|
||||
//! tenant. Mutate-bite proof: drop the clause, the test fails.
|
||||
use super::*;
|
||||
use crate::{ApiTokenRecord, Db};
|
||||
use sqlx::PgPool;
|
||||
|
||||
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
|
||||
|
||||
async fn setup_db() -> Db {
|
||||
let pool = PgPool::connect(TEST_DB_URL)
|
||||
.await
|
||||
.expect("connect to test DB");
|
||||
Db::from_pool(pool)
|
||||
}
|
||||
|
||||
async fn make_community(pool: &PgPool) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
let host = format!("api-token-tenancy-{}.example", id.simple());
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||||
.bind(id)
|
||||
.bind(host)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert community");
|
||||
id
|
||||
}
|
||||
|
||||
async fn insert_user(pool: &PgPool, community_id: Uuid, pubkey: &[u8]) {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (community_id, pubkey)
|
||||
VALUES ($1, $2)
|
||||
"#,
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert user");
|
||||
}
|
||||
|
||||
/// Direct INSERT bypassing `create_api_token` so the test pins the
|
||||
/// **lookup**'s scoping, not the insert path's.
|
||||
async fn raw_insert_token(
|
||||
pool: &PgPool,
|
||||
community_id: Uuid,
|
||||
token_hash: &[u8],
|
||||
owner_pubkey: &[u8],
|
||||
name: &str,
|
||||
) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
let scopes = serde_json::json!(["files:read", "files:write"]);
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO api_tokens
|
||||
(community_id, id, token_hash, owner_pubkey, name, scopes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
"#,
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(id)
|
||||
.bind(token_hash)
|
||||
.bind(owner_pubkey)
|
||||
.bind(name)
|
||||
.bind(&scopes)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert api_token");
|
||||
id
|
||||
}
|
||||
|
||||
/// Row-44 sharp test: two communities, **same** 32-byte token hash in each,
|
||||
/// lookup scoped to community A returns A's row only (and B-scoped lookup
|
||||
/// returns B's row only). The storage UNIQUE index is `(community_id,
|
||||
/// token_hash)` so this is a legal state. The lookup must not return the
|
||||
/// foreign row.
|
||||
///
|
||||
/// Mutate-bite handle: the WHERE clause in
|
||||
/// `get_api_token_by_hash_including_revoked` is the only thing keeping
|
||||
/// this test green. Strip `AND community_id = $1` and the lookup becomes
|
||||
/// hash-only — Postgres returns whichever row it picks (insert-order
|
||||
/// dependent), and the cross-tenancy assertion fails.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn lookup_by_hash_is_scoped_to_community() {
|
||||
let db = setup_db().await;
|
||||
|
||||
let community_a = make_community(&db.pool).await;
|
||||
let community_b = make_community(&db.pool).await;
|
||||
|
||||
// Distinct pubkeys per community — FK is (community_id, owner_pubkey).
|
||||
let owner_a = vec![0xAAu8; 32];
|
||||
let owner_b = vec![0xBBu8; 32];
|
||||
insert_user(&db.pool, community_a, &owner_a).await;
|
||||
insert_user(&db.pool, community_b, &owner_b).await;
|
||||
|
||||
// SAME hash in both communities — legal under UNIQUE(community_id, token_hash).
|
||||
let shared_hash = vec![0xCCu8; 32];
|
||||
let id_a = raw_insert_token(&db.pool, community_a, &shared_hash, &owner_a, "token-A").await;
|
||||
let id_b = raw_insert_token(&db.pool, community_b, &shared_hash, &owner_b, "token-B").await;
|
||||
assert_ne!(id_a, id_b, "ids must differ");
|
||||
|
||||
let cid_a = buzz_core::CommunityId::from_uuid(community_a);
|
||||
let cid_b = buzz_core::CommunityId::from_uuid(community_b);
|
||||
|
||||
// Lookup scoped to A returns A's row, never B's.
|
||||
let from_a: ApiTokenRecord = db
|
||||
.get_api_token_by_hash_including_revoked(cid_a, &shared_hash)
|
||||
.await
|
||||
.expect("lookup A")
|
||||
.expect("row in A");
|
||||
assert_eq!(from_a.id, id_a, "community-A lookup must return A's row");
|
||||
assert_eq!(
|
||||
from_a.owner_pubkey, owner_a,
|
||||
"community-A lookup must return A's owner",
|
||||
);
|
||||
|
||||
// Lookup scoped to B returns B's row, never A's.
|
||||
let from_b: ApiTokenRecord = db
|
||||
.get_api_token_by_hash_including_revoked(cid_b, &shared_hash)
|
||||
.await
|
||||
.expect("lookup B")
|
||||
.expect("row in B");
|
||||
assert_eq!(from_b.id, id_b, "community-B lookup must return B's row");
|
||||
assert_eq!(
|
||||
from_b.owner_pubkey, owner_b,
|
||||
"community-B lookup must return B's owner",
|
||||
);
|
||||
|
||||
// Lookup with the hash but a third (unrelated) community returns None.
|
||||
let community_c = make_community(&db.pool).await;
|
||||
let cid_c = buzz_core::CommunityId::from_uuid(community_c);
|
||||
let from_c = db
|
||||
.get_api_token_by_hash_including_revoked(cid_c, &shared_hash)
|
||||
.await
|
||||
.expect("lookup C");
|
||||
assert!(
|
||||
from_c.is_none(),
|
||||
"community-C has no token with this hash; lookup must return None, got {from_c:?}",
|
||||
);
|
||||
}
|
||||
|
||||
/// Active (non-revoked) lookup also enforces community scope.
|
||||
/// Mirrors the obligation for the `revoked_at IS NULL` variant at
|
||||
/// `Db::get_api_token_by_hash`.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn active_lookup_by_hash_is_scoped_to_community() {
|
||||
let db = setup_db().await;
|
||||
|
||||
let community_a = make_community(&db.pool).await;
|
||||
let community_b = make_community(&db.pool).await;
|
||||
|
||||
let owner_a = vec![0x11u8; 32];
|
||||
let owner_b = vec![0x22u8; 32];
|
||||
insert_user(&db.pool, community_a, &owner_a).await;
|
||||
insert_user(&db.pool, community_b, &owner_b).await;
|
||||
|
||||
let shared_hash = vec![0x33u8; 32];
|
||||
let id_a =
|
||||
raw_insert_token(&db.pool, community_a, &shared_hash, &owner_a, "active-A").await;
|
||||
let id_b =
|
||||
raw_insert_token(&db.pool, community_b, &shared_hash, &owner_b, "active-B").await;
|
||||
|
||||
let cid_a = buzz_core::CommunityId::from_uuid(community_a);
|
||||
let cid_b = buzz_core::CommunityId::from_uuid(community_b);
|
||||
|
||||
let from_a = db
|
||||
.get_api_token_by_hash(cid_a, &shared_hash)
|
||||
.await
|
||||
.expect("active lookup A")
|
||||
.expect("row in A");
|
||||
assert_eq!(from_a.id, id_a);
|
||||
|
||||
let from_b = db
|
||||
.get_api_token_by_hash(cid_b, &shared_hash)
|
||||
.await
|
||||
.expect("active lookup B")
|
||||
.expect("row in B");
|
||||
assert_eq!(from_b.id, id_b);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
//! 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"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,557 @@
|
||||
//! Direct message channel persistence.
|
||||
//!
|
||||
//! DMs are channels with channel_type='dm' and visibility='private'.
|
||||
//! Participant sets are immutable -- adding a member creates a NEW DM.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::{PgPool, Row};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::channel::ChannelRecord;
|
||||
use crate::error::{DbError, Result};
|
||||
use buzz_core::CommunityId;
|
||||
|
||||
// -- Public structs -----------------------------------------------------------
|
||||
|
||||
/// A DM conversation with its participant list.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DmRecord {
|
||||
/// The underlying channel ID.
|
||||
pub channel_id: Uuid,
|
||||
/// All active participants in this DM.
|
||||
pub participants: Vec<DmParticipant>,
|
||||
/// When the last message was sent (approximated by channel updated_at).
|
||||
pub last_message_at: Option<DateTime<Utc>>,
|
||||
/// When the DM was created.
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// A single participant in a DM.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DmParticipant {
|
||||
/// Compressed public key bytes.
|
||||
pub pubkey: Vec<u8>,
|
||||
/// Optional display name from the users table.
|
||||
pub display_name: Option<String>,
|
||||
/// Member role string (always "member" for DMs).
|
||||
pub role: String,
|
||||
}
|
||||
|
||||
// -- Pure helpers -------------------------------------------------------------
|
||||
|
||||
/// Compute a stable SHA-256 fingerprint for a set of participant pubkeys.
|
||||
///
|
||||
/// Pubkeys are sorted lexicographically before hashing so that the same set
|
||||
/// of participants always produces the same hash regardless of input order.
|
||||
/// No separator is used because all pubkeys are fixed-width 32-byte values.
|
||||
pub fn compute_participant_hash(pubkeys: &[&[u8]]) -> [u8; 32] {
|
||||
let mut sorted: Vec<&[u8]> = pubkeys.to_vec();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
|
||||
let mut hasher = Sha256::new();
|
||||
for pk in sorted {
|
||||
hasher.update(pk);
|
||||
}
|
||||
hasher.finalize().into()
|
||||
}
|
||||
|
||||
// -- DB functions -------------------------------------------------------------
|
||||
|
||||
/// Find an existing DM by its participant hash.
|
||||
///
|
||||
/// Returns `None` if no matching DM exists or if it has been deleted.
|
||||
pub async fn find_dm_by_participants(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
participant_hash: &[u8],
|
||||
) -> Result<Option<ChannelRecord>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, name, channel_type::text AS channel_type, visibility::text AS visibility,
|
||||
description, canvas,
|
||||
created_by, created_at, updated_at, archived_at, deleted_at,
|
||||
nip29_group_id, topic_required, max_members,
|
||||
topic, topic_set_by, topic_set_at,
|
||||
purpose, purpose_set_by, purpose_set_at
|
||||
FROM channels
|
||||
WHERE community_id = $1
|
||||
AND participant_hash = $2
|
||||
AND channel_type = 'dm'
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(participant_hash)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(row_to_channel_record).transpose()
|
||||
}
|
||||
|
||||
/// Create a new DM channel for the given participant pubkeys, or return the
|
||||
/// existing one if a DM with the same participant set already exists.
|
||||
///
|
||||
/// Rules:
|
||||
/// - `participants` must contain 2-9 entries (enforced here).
|
||||
/// - `created_by` must be one of the participants.
|
||||
/// - The operation is idempotent: same participant set -> same channel returned.
|
||||
pub async fn create_dm(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
participants: &[&[u8]],
|
||||
created_by: &[u8],
|
||||
) -> Result<ChannelRecord> {
|
||||
if participants.len() < 2 {
|
||||
return Err(DbError::InvalidData(
|
||||
"DM requires at least 2 participants".to_string(),
|
||||
));
|
||||
}
|
||||
if participants.len() > 9 {
|
||||
return Err(DbError::InvalidData(
|
||||
"DM supports at most 9 participants".to_string(),
|
||||
));
|
||||
}
|
||||
for pk in participants {
|
||||
if pk.len() != 32 {
|
||||
return Err(DbError::InvalidData(format!(
|
||||
"pubkey must be 32 bytes, got {}",
|
||||
pk.len()
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let hash = compute_participant_hash(participants);
|
||||
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// Idempotency check inside the transaction.
|
||||
let existing = sqlx::query(
|
||||
r#"
|
||||
SELECT id, name, channel_type::text AS channel_type, visibility::text AS visibility,
|
||||
description, canvas,
|
||||
created_by, created_at, updated_at, archived_at, deleted_at,
|
||||
nip29_group_id, topic_required, max_members,
|
||||
topic, topic_set_by, topic_set_at,
|
||||
purpose, purpose_set_by, purpose_set_at
|
||||
FROM channels
|
||||
WHERE community_id = $1
|
||||
AND participant_hash = $2
|
||||
AND channel_type = 'dm'
|
||||
AND deleted_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(hash.as_slice())
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if let Some(row) = existing {
|
||||
tx.commit().await?;
|
||||
return row_to_channel_record(row);
|
||||
}
|
||||
|
||||
// Name the DM based on participant count.
|
||||
let name = if participants.len() == 2 {
|
||||
"DM".to_string()
|
||||
} else {
|
||||
format!("Group DM ({})", participants.len())
|
||||
};
|
||||
|
||||
let id = Uuid::new_v4();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO channels
|
||||
(id, community_id, name, channel_type, visibility, created_by, participant_hash)
|
||||
VALUES ($1, $2, $3, 'dm', 'private', $4, $5)
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(&name)
|
||||
.bind(created_by)
|
||||
.bind(hash.as_slice())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// Add all participants as members with role='member'.
|
||||
for pk in participants {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by)
|
||||
VALUES ($1, $2, $3, 'member', $4)
|
||||
ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET
|
||||
removed_at = NULL,
|
||||
removed_by = NULL,
|
||||
role = EXCLUDED.role
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(id)
|
||||
.bind(*pk)
|
||||
.bind(created_by)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, name, channel_type::text AS channel_type, visibility::text AS visibility,
|
||||
description, canvas,
|
||||
created_by, created_at, updated_at, archived_at, deleted_at,
|
||||
nip29_group_id, topic_required, max_members,
|
||||
topic, topic_set_by, topic_set_at,
|
||||
purpose, purpose_set_by, purpose_set_at
|
||||
FROM channels WHERE community_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let record = row_to_channel_record(row)?;
|
||||
tx.commit().await?;
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// List all DM conversations for a given user, ordered by most recent activity.
|
||||
///
|
||||
/// Includes participant details for each DM. Supports cursor-based pagination
|
||||
/// using `updated_at` ordering.
|
||||
pub async fn list_dms_for_user(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
pubkey: &[u8],
|
||||
limit: u32,
|
||||
cursor: Option<Uuid>,
|
||||
) -> Result<Vec<DmRecord>> {
|
||||
let limit = limit.min(200) as i64;
|
||||
|
||||
// Resolve cursor to a timestamp for keyset pagination.
|
||||
let cursor_ts: Option<DateTime<Utc>> = if let Some(cid) = cursor {
|
||||
let row =
|
||||
sqlx::query("SELECT updated_at FROM channels WHERE community_id = $1 AND id = $2")
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(cid)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
row.map(|r| r.try_get::<DateTime<Utc>, _>("updated_at"))
|
||||
.transpose()?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Fetch DM channel IDs where this user is an active member.
|
||||
let channel_rows = if let Some(ts) = cursor_ts {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT c.id, c.created_at, c.updated_at
|
||||
FROM channels c
|
||||
JOIN channel_members cm
|
||||
ON c.community_id = cm.community_id
|
||||
AND c.id = cm.channel_id
|
||||
AND cm.pubkey = $2
|
||||
AND cm.removed_at IS NULL
|
||||
AND cm.hidden_at IS NULL
|
||||
WHERE c.community_id = $1
|
||||
AND c.channel_type = 'dm'
|
||||
AND c.deleted_at IS NULL
|
||||
AND c.updated_at < $3
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT $4
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.bind(ts)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
} else {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT c.id, c.created_at, c.updated_at
|
||||
FROM channels c
|
||||
JOIN channel_members cm
|
||||
ON c.community_id = cm.community_id
|
||||
AND c.id = cm.channel_id
|
||||
AND cm.pubkey = $2
|
||||
AND cm.removed_at IS NULL
|
||||
AND cm.hidden_at IS NULL
|
||||
WHERE c.community_id = $1
|
||||
AND c.channel_type = 'dm'
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY c.updated_at DESC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?
|
||||
};
|
||||
|
||||
let mut results = Vec::with_capacity(channel_rows.len());
|
||||
|
||||
for row in channel_rows {
|
||||
let channel_id: Uuid = row.try_get("id")?;
|
||||
let created_at: DateTime<Utc> = row.try_get("created_at")?;
|
||||
let updated_at: DateTime<Utc> = row.try_get("updated_at")?;
|
||||
|
||||
// Fetch participants for this DM.
|
||||
let member_rows = sqlx::query(
|
||||
r#"
|
||||
SELECT cm.pubkey, cm.role::text AS role, u.display_name
|
||||
FROM channel_members cm
|
||||
LEFT JOIN users u
|
||||
ON u.community_id = cm.community_id
|
||||
AND u.pubkey = cm.pubkey
|
||||
WHERE cm.community_id = $1
|
||||
AND cm.channel_id = $2
|
||||
AND cm.removed_at IS NULL
|
||||
ORDER BY cm.joined_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(channel_id)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
let participants: Vec<DmParticipant> = member_rows
|
||||
.into_iter()
|
||||
.map(|r| -> Result<DmParticipant> {
|
||||
Ok(DmParticipant {
|
||||
pubkey: r.try_get("pubkey")?,
|
||||
display_name: r.try_get("display_name")?,
|
||||
role: r.try_get("role")?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
|
||||
results.push(DmRecord {
|
||||
channel_id,
|
||||
participants,
|
||||
last_message_at: Some(updated_at),
|
||||
created_at,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Open or retrieve a DM for the given set of participants.
|
||||
///
|
||||
/// `created_by` is automatically added to `pubkeys` if not already present,
|
||||
/// ensuring the caller is always a participant in their own DM.
|
||||
///
|
||||
/// Returns `(channel, was_created)`:
|
||||
/// - `was_created = true` -- a new DM was created.
|
||||
/// - `was_created = false` -- an existing DM was returned.
|
||||
pub async fn open_dm(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
pubkeys: &[&[u8]],
|
||||
created_by: &[u8],
|
||||
) -> Result<(ChannelRecord, bool)> {
|
||||
// Merge created_by into the participant set (dedup handled by compute_participant_hash).
|
||||
let mut all: Vec<&[u8]> = pubkeys.to_vec();
|
||||
if !all.contains(&created_by) {
|
||||
all.push(created_by);
|
||||
}
|
||||
|
||||
// Enforce max before hitting the DB.
|
||||
if all.len() > 9 {
|
||||
return Err(DbError::InvalidData(
|
||||
"DM supports at most 9 participants".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let hash = compute_participant_hash(&all);
|
||||
|
||||
// Check for existing DM first (fast path, no transaction).
|
||||
if let Some(existing) = find_dm_by_participants(pool, community_id, &hash).await? {
|
||||
// Clear hidden_at for the caller so the DM reappears in their sidebar.
|
||||
unhide_dm(pool, community_id, existing.id, created_by).await?;
|
||||
return Ok((existing, false));
|
||||
}
|
||||
|
||||
// Create new DM.
|
||||
let channel = create_dm(pool, community_id, &all, created_by).await?;
|
||||
|
||||
Ok((channel, true))
|
||||
}
|
||||
|
||||
// -- Hide / unhide ------------------------------------------------------------
|
||||
|
||||
/// Hide a DM for a specific user by setting `hidden_at = NOW()`.
|
||||
///
|
||||
/// The DM is not deleted — it can be restored by opening a new DM with the
|
||||
/// same participants (which clears `hidden_at`). Returns an error if the user
|
||||
/// is not an active member of the channel.
|
||||
pub async fn hide_dm(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
channel_id: Uuid,
|
||||
pubkey: &[u8],
|
||||
) -> Result<()> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE channel_members
|
||||
SET hidden_at = NOW()
|
||||
WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND removed_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(channel_id)
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(DbError::NotFound(format!(
|
||||
"no active membership for channel {channel_id}"
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Unhide a DM for a specific user by clearing `hidden_at`.
|
||||
///
|
||||
/// This is called automatically when a user re-opens a DM via [`open_dm`].
|
||||
/// It is a no-op if the membership is not currently hidden.
|
||||
pub async fn unhide_dm(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
channel_id: Uuid,
|
||||
pubkey: &[u8],
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE channel_members
|
||||
SET hidden_at = NULL
|
||||
WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND removed_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(channel_id)
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the channel IDs of all DMs the given user currently has hidden
|
||||
/// (`hidden_at IS NOT NULL`) while still being an active member. Used to build
|
||||
/// the relay-signed NIP-DV visibility snapshot.
|
||||
pub async fn list_hidden_dms(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
pubkey: &[u8],
|
||||
) -> Result<Vec<Uuid>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT cm.channel_id
|
||||
FROM channel_members cm
|
||||
JOIN channels c
|
||||
ON c.community_id = cm.community_id
|
||||
AND c.id = cm.channel_id
|
||||
WHERE cm.community_id = $1
|
||||
AND cm.pubkey = $2
|
||||
AND cm.removed_at IS NULL
|
||||
AND cm.hidden_at IS NOT NULL
|
||||
AND c.channel_type = 'dm'
|
||||
AND c.deleted_at IS NULL
|
||||
ORDER BY cm.channel_id
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|r| r.try_get::<Uuid, _>("channel_id").map_err(Into::into))
|
||||
.collect()
|
||||
}
|
||||
|
||||
// -- Row mapping --------------------------------------------------------------
|
||||
|
||||
fn row_to_channel_record(row: sqlx::postgres::PgRow) -> Result<ChannelRecord> {
|
||||
let id: Uuid = row.try_get("id")?;
|
||||
let topic_required: bool = row.try_get("topic_required")?;
|
||||
|
||||
Ok(ChannelRecord {
|
||||
id,
|
||||
name: row.try_get("name")?,
|
||||
channel_type: row.try_get("channel_type")?,
|
||||
visibility: row.try_get("visibility")?,
|
||||
description: row.try_get("description")?,
|
||||
canvas: row.try_get("canvas")?,
|
||||
created_by: row.try_get("created_by")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
archived_at: row.try_get("archived_at")?,
|
||||
deleted_at: row.try_get("deleted_at")?,
|
||||
nip29_group_id: row.try_get("nip29_group_id")?,
|
||||
topic_required,
|
||||
max_members: row.try_get("max_members")?,
|
||||
topic: row.try_get("topic").unwrap_or(None),
|
||||
topic_set_by: row.try_get("topic_set_by").unwrap_or(None),
|
||||
topic_set_at: row.try_get("topic_set_at").unwrap_or(None),
|
||||
purpose: row.try_get("purpose").unwrap_or(None),
|
||||
purpose_set_by: row.try_get("purpose_set_by").unwrap_or(None),
|
||||
purpose_set_at: row.try_get("purpose_set_at").unwrap_or(None),
|
||||
ttl_seconds: row.try_get("ttl_seconds").unwrap_or(None),
|
||||
ttl_deadline: row.try_get("ttl_deadline").unwrap_or(None),
|
||||
})
|
||||
}
|
||||
|
||||
// -- Tests --------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn participant_hash_is_order_independent() {
|
||||
let a = [1u8; 32];
|
||||
let b = [2u8; 32];
|
||||
let h1 = compute_participant_hash(&[&a, &b]);
|
||||
let h2 = compute_participant_hash(&[&b, &a]);
|
||||
assert_eq!(h1, h2, "hash must be the same regardless of input order");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn participant_hash_deduplicates() {
|
||||
let a = [1u8; 32];
|
||||
let h1 = compute_participant_hash(&[&a, &a]);
|
||||
let h2 = compute_participant_hash(&[&a]);
|
||||
assert_eq!(h1, h2, "duplicate pubkeys should be deduped before hashing");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn participant_hash_differs_for_different_sets() {
|
||||
let a = [1u8; 32];
|
||||
let b = [2u8; 32];
|
||||
let c = [3u8; 32];
|
||||
let h_ab = compute_participant_hash(&[&a, &b]);
|
||||
let h_ac = compute_participant_hash(&[&a, &c]);
|
||||
assert_ne!(h_ab, h_ac);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn participant_hash_returns_32_bytes() {
|
||||
let a = [0u8; 32];
|
||||
let b = [255u8; 32];
|
||||
let h = compute_participant_hash(&[&a, &b]);
|
||||
assert_eq!(h.len(), 32);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Database error types.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// Errors produced by database operations.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum DbError {
|
||||
/// A SQLx driver-level error.
|
||||
#[error("database error: {0}")]
|
||||
Sqlx(#[from] sqlx::Error),
|
||||
|
||||
/// A SQLx migration error.
|
||||
#[error("migration error: {0}")]
|
||||
Migrate(#[from] sqlx::migrate::MigrateError),
|
||||
|
||||
/// Attempted to store an AUTH event (kind 22242), which is forbidden.
|
||||
#[error("AUTH events (kind 22242) must not be stored")]
|
||||
AuthEventRejected,
|
||||
|
||||
/// Attempted to store an ephemeral event (kinds 20000–29999), which is forbidden.
|
||||
#[error("ephemeral events (kind {0}) must not be stored")]
|
||||
EphemeralEventRejected(u16),
|
||||
|
||||
/// The requested channel does not exist.
|
||||
#[error("channel not found: {0}")]
|
||||
ChannelNotFound(uuid::Uuid),
|
||||
|
||||
/// The requested member is not in the channel.
|
||||
#[error("member not found in channel {0}")]
|
||||
MemberNotFound(uuid::Uuid),
|
||||
|
||||
/// A generic not-found error.
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
/// The caller lacks permission for the requested operation.
|
||||
#[error("access denied: {0}")]
|
||||
AccessDenied(String),
|
||||
|
||||
/// JSON serialization or deserialization failed.
|
||||
#[error("serialization error: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
|
||||
/// A value in the database is malformed or unexpected.
|
||||
#[error("invalid data: {0}")]
|
||||
InvalidData(String),
|
||||
|
||||
/// A stored timestamp value could not be interpreted.
|
||||
#[error("invalid timestamp: {0}")]
|
||||
InvalidTimestamp(i64),
|
||||
}
|
||||
|
||||
/// Convenience alias for `Result<T, DbError>`.
|
||||
pub type Result<T> = std::result::Result<T, DbError>;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,889 @@
|
||||
//! Feed-specific DB queries for the Home Feed feature.
|
||||
//!
|
||||
//! Aggregates three categories of data:
|
||||
//! - **Mentions**: Events where the user's pubkey appears in a `p` tag.
|
||||
//! - **Needs Action**: Approval requests (kind 46010) and reminders (kind 40007) tagged to the user.
|
||||
//! - **Activity**: Recent events from channels the user can access.
|
||||
//!
|
||||
//! ## Performance characteristics
|
||||
//!
|
||||
//! `query_mentions` and `query_needs_action` join against the `event_mentions` table,
|
||||
//! which carries community-leading composite indexes on
|
||||
//! `(community_id, pubkey_hex, event_created_at DESC)` and
|
||||
//! `(community_id, pubkey_hex, event_kind, event_created_at DESC)`. This replaces the Phase 1
|
||||
//! full-table scan with an indexed lookup, keeping feed queries
|
||||
//! sub-millisecond at scale (>100k events).
|
||||
//!
|
||||
//! **Phase 2 implemented**: the `event_mentions` table is populated by
|
||||
//! [`crate::insert_mentions`] on every event insert. `query_mentions` and
|
||||
//! `query_needs_action` now use `INNER JOIN event_mentions` instead of
|
||||
//! scanning tags JSON.
|
||||
//!
|
||||
//! All feed queries enforce a hard `LIMIT` cap of `FEED_MAX_LIMIT` rows to bound
|
||||
//! the result-set size and prevent runaway memory usage.
|
||||
|
||||
/// Hard upper bound on rows returned by any feed query.
|
||||
///
|
||||
/// Callers may request fewer rows, but never more. Enforced in every feed function
|
||||
/// before the query is issued so the SQL `LIMIT` clause always reflects this cap.
|
||||
pub const FEED_MAX_LIMIT: i64 = 100;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::postgres::PgRow;
|
||||
use sqlx::{PgPool, QueryBuilder};
|
||||
use uuid::Uuid;
|
||||
|
||||
use buzz_core::kind::{
|
||||
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_GIT_ISSUE, KIND_GIT_PR_UPDATE, KIND_GIT_PULL_REQUEST,
|
||||
KIND_GIT_STATUS_CLOSED, KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN,
|
||||
KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT, KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
};
|
||||
use buzz_core::{CommunityId, StoredEvent};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::event::row_to_stored_event;
|
||||
|
||||
/// Column list shared by every feed subquery that aliases the `events` table as `e`.
|
||||
const EVENT_COLS: &str =
|
||||
"e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, e.received_at, e.channel_id";
|
||||
|
||||
/// Column list for queries that select directly from `events` (no table alias).
|
||||
const EVENT_COLS_UNALIASED: &str =
|
||||
"id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id";
|
||||
|
||||
/// Append channel visibility filtering for feed queries.
|
||||
///
|
||||
/// Feed reads may include channel-less community-global events, plus events in
|
||||
/// channels the caller can access. An empty accessible-channel list therefore
|
||||
/// means "global only", never "all channels".
|
||||
fn push_visible_channel_filter(qb: &mut QueryBuilder<sqlx::Postgres>, col: &str, ids: &[Uuid]) {
|
||||
if ids.is_empty() {
|
||||
qb.push(format!(" AND {col} IS NULL"));
|
||||
return;
|
||||
}
|
||||
|
||||
qb.push(format!(" AND ({col} IS NULL OR {col} IN ("));
|
||||
let mut sep = qb.separated(", ");
|
||||
for id in ids {
|
||||
sep.push_bind(*id);
|
||||
}
|
||||
qb.push("))");
|
||||
}
|
||||
|
||||
/// Convert fetched rows into `Vec<StoredEvent>`, skipping any that fail conversion.
|
||||
fn collect_stored_events(rows: Vec<PgRow>) -> Result<Vec<StoredEvent>> {
|
||||
let mut out = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
if let Some(ev) = row_to_stored_event(row)? {
|
||||
out.push(ev);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn build_mentions_query(
|
||||
community: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
accessible_channel_ids: &[Uuid],
|
||||
since: Option<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> QueryBuilder<sqlx::Postgres> {
|
||||
let limit = limit.min(FEED_MAX_LIMIT);
|
||||
let pubkey_hex = hex::encode(pubkey_bytes);
|
||||
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(format!(
|
||||
"SELECT {EVENT_COLS} FROM events e \
|
||||
INNER JOIN event_mentions m ON e.community_id = m.community_id AND e.id = m.event_id \
|
||||
WHERE e.community_id = "
|
||||
));
|
||||
qb.push_bind(*community.as_uuid());
|
||||
qb.push(" AND m.community_id = ")
|
||||
.push_bind(*community.as_uuid());
|
||||
qb.push(" AND m.pubkey_hex = ").push_bind(pubkey_hex);
|
||||
qb.push(" AND e.deleted_at IS NULL");
|
||||
qb.push(format!(
|
||||
" AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, \
|
||||
{KIND_TEXT_NOTE}, {KIND_FORUM_POST}, {KIND_FORUM_COMMENT}, {KIND_GIT_PULL_REQUEST}, \
|
||||
{KIND_GIT_PR_UPDATE}, {KIND_GIT_ISSUE}, {KIND_GIT_STATUS_OPEN}, \
|
||||
{KIND_GIT_STATUS_MERGED}, {KIND_GIT_STATUS_CLOSED}, {KIND_GIT_STATUS_DRAFT})"
|
||||
));
|
||||
push_visible_channel_filter(&mut qb, "e.channel_id", accessible_channel_ids);
|
||||
if let Some(s) = since {
|
||||
qb.push(" AND m.event_created_at >= ").push_bind(s);
|
||||
}
|
||||
qb.push(" ORDER BY m.event_created_at DESC LIMIT ")
|
||||
.push_bind(limit);
|
||||
qb
|
||||
}
|
||||
|
||||
/// Find events that @mention the given pubkey (have `["p", pubkey_hex]` in tags).
|
||||
///
|
||||
/// Joins against the `event_mentions` table -- Phase 2 implementation.
|
||||
/// **Performance**: community-leading indexed lookup on
|
||||
/// `(community_id, pubkey_hex, event_created_at DESC)`.
|
||||
///
|
||||
/// Only returns community-global events and events from `accessible_channel_ids`.
|
||||
/// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller.
|
||||
pub async fn query_mentions(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
accessible_channel_ids: &[Uuid],
|
||||
since: Option<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<StoredEvent>> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
query_mentions_on(
|
||||
&mut conn,
|
||||
community,
|
||||
pubkey_bytes,
|
||||
accessible_channel_ids,
|
||||
since,
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`query_mentions`] on a specific session — the replica-routing path runs
|
||||
/// the query on the exact reader connection whose heartbeat observation
|
||||
/// proved its predicate.
|
||||
pub(crate) async fn query_mentions_on(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
community: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
accessible_channel_ids: &[Uuid],
|
||||
since: Option<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<StoredEvent>> {
|
||||
let mut qb = build_mentions_query(
|
||||
community,
|
||||
pubkey_bytes,
|
||||
accessible_channel_ids,
|
||||
since,
|
||||
limit,
|
||||
);
|
||||
let rows = qb.build().fetch_all(&mut *conn).await?;
|
||||
collect_stored_events(rows)
|
||||
}
|
||||
|
||||
fn build_needs_action_query(
|
||||
community: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
accessible_channel_ids: &[Uuid],
|
||||
since: Option<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> QueryBuilder<sqlx::Postgres> {
|
||||
let limit = limit.min(FEED_MAX_LIMIT);
|
||||
let pubkey_hex = hex::encode(pubkey_bytes);
|
||||
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(format!(
|
||||
"SELECT {EVENT_COLS} FROM events e \
|
||||
INNER JOIN event_mentions m ON e.community_id = m.community_id AND e.id = m.event_id \
|
||||
WHERE e.community_id = "
|
||||
));
|
||||
qb.push_bind(*community.as_uuid());
|
||||
qb.push(" AND m.community_id = ")
|
||||
.push_bind(*community.as_uuid());
|
||||
qb.push(" AND m.pubkey_hex = ").push_bind(pubkey_hex);
|
||||
qb.push(" AND e.deleted_at IS NULL");
|
||||
qb.push(format!(
|
||||
" AND e.kind IN ({KIND_WORKFLOW_APPROVAL_REQUESTED}, {KIND_STREAM_REMINDER})"
|
||||
));
|
||||
push_visible_channel_filter(&mut qb, "e.channel_id", accessible_channel_ids);
|
||||
if let Some(s) = since {
|
||||
qb.push(" AND m.event_created_at >= ").push_bind(s);
|
||||
}
|
||||
qb.push(" ORDER BY m.event_created_at DESC LIMIT ")
|
||||
.push_bind(limit);
|
||||
qb
|
||||
}
|
||||
|
||||
/// Find events that require action from the given pubkey:
|
||||
/// - [`KIND_WORKFLOW_APPROVAL_REQUESTED`] (workflow approval requested, tagged with user pubkey)
|
||||
/// - [`KIND_STREAM_REMINDER`] (reminder, tagged with user pubkey)
|
||||
///
|
||||
/// Only returns community-global events and events from channels the user has access to
|
||||
/// (`accessible_channel_ids`). This prevents surfacing approval requests from channels
|
||||
/// the user was removed from.
|
||||
/// **Performance**: community-leading indexed lookup via `event_mentions` join on
|
||||
/// `(community_id, pubkey_hex, event_kind, event_created_at DESC)`.
|
||||
/// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller.
|
||||
pub async fn query_needs_action(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
accessible_channel_ids: &[Uuid],
|
||||
since: Option<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<StoredEvent>> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
query_needs_action_on(
|
||||
&mut conn,
|
||||
community,
|
||||
pubkey_bytes,
|
||||
accessible_channel_ids,
|
||||
since,
|
||||
limit,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`query_needs_action`] on a specific session — see [`query_mentions_on`].
|
||||
pub(crate) async fn query_needs_action_on(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
community: CommunityId,
|
||||
pubkey_bytes: &[u8],
|
||||
accessible_channel_ids: &[Uuid],
|
||||
since: Option<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<StoredEvent>> {
|
||||
let mut qb = build_needs_action_query(
|
||||
community,
|
||||
pubkey_bytes,
|
||||
accessible_channel_ids,
|
||||
since,
|
||||
limit,
|
||||
);
|
||||
let rows = qb.build().fetch_all(&mut *conn).await?;
|
||||
collect_stored_events(rows)
|
||||
}
|
||||
|
||||
fn build_activity_query(
|
||||
community: CommunityId,
|
||||
accessible_channel_ids: &[Uuid],
|
||||
since: Option<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> QueryBuilder<sqlx::Postgres> {
|
||||
let limit = limit.min(FEED_MAX_LIMIT);
|
||||
let mut qb: QueryBuilder<sqlx::Postgres> = QueryBuilder::new(format!(
|
||||
"SELECT {EVENT_COLS_UNALIASED} FROM events WHERE community_id = "
|
||||
));
|
||||
qb.push_bind(*community.as_uuid());
|
||||
qb.push(" AND deleted_at IS NULL");
|
||||
qb.push(format!(
|
||||
" AND kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, \
|
||||
{KIND_JOB_REQUEST}, {KIND_JOB_PROGRESS}, {KIND_JOB_RESULT})"
|
||||
));
|
||||
push_visible_channel_filter(&mut qb, "channel_id", accessible_channel_ids);
|
||||
if let Some(s) = since {
|
||||
qb.push(" AND created_at >= ").push_bind(s);
|
||||
}
|
||||
qb.push(" ORDER BY created_at DESC LIMIT ").push_bind(limit);
|
||||
qb
|
||||
}
|
||||
|
||||
/// Find recent activity across accessible channels (for watched topics / agent activity).
|
||||
///
|
||||
/// Returns stream messages, forum posts, and agent job events.
|
||||
/// Workflow execution kinds (46001-46012) are intentionally excluded to avoid noise.
|
||||
/// **Performance**: uses indexed `kind` + `channel_id` columns -- no JSON scan.
|
||||
/// `limit` is capped at [`FEED_MAX_LIMIT`] regardless of the value passed by the caller.
|
||||
pub async fn query_activity(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
accessible_channel_ids: &[Uuid],
|
||||
since: Option<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<StoredEvent>> {
|
||||
let mut conn = pool.acquire().await?;
|
||||
query_activity_on(&mut conn, community, accessible_channel_ids, since, limit).await
|
||||
}
|
||||
|
||||
/// [`query_activity`] on a specific session — see [`query_mentions_on`].
|
||||
pub(crate) async fn query_activity_on(
|
||||
conn: &mut sqlx::PgConnection,
|
||||
community: CommunityId,
|
||||
accessible_channel_ids: &[Uuid],
|
||||
since: Option<DateTime<Utc>>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<StoredEvent>> {
|
||||
let mut qb = build_activity_query(community, accessible_channel_ids, since, limit);
|
||||
let rows = qb.build().fetch_all(&mut *conn).await?;
|
||||
collect_stored_events(rows)
|
||||
}
|
||||
|
||||
// -- Tests --------------------------------------------------------------------
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr::{EventBuilder, Keys, Kind, Tag};
|
||||
use uuid::Uuid;
|
||||
|
||||
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1 -- local test-only credentials
|
||||
|
||||
async fn setup_pool() -> PgPool {
|
||||
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
||||
|
||||
PgPool::connect(&database_url)
|
||||
.await
|
||||
.expect("connect to test DB")
|
||||
}
|
||||
|
||||
async fn make_test_community(pool: &PgPool) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
let host = format!("feed-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");
|
||||
id
|
||||
}
|
||||
|
||||
async fn insert_test_channel(pool: &PgPool, community: CommunityId) -> Uuid {
|
||||
let id = Uuid::new_v4();
|
||||
let creator = [0x11u8; 32];
|
||||
sqlx::query(
|
||||
"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by) \
|
||||
VALUES ($1, $2, $3, 'stream'::channel_type, 'open'::channel_visibility, $4)",
|
||||
)
|
||||
.bind(id)
|
||||
.bind(community.as_uuid())
|
||||
.bind(format!("feed-test-channel-{}", id.simple()))
|
||||
.bind(creator.as_slice())
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert test channel");
|
||||
id
|
||||
}
|
||||
|
||||
async fn store_feed_event(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
kind: u32,
|
||||
content: &str,
|
||||
channel_id: Option<Uuid>,
|
||||
tags: Vec<Tag>,
|
||||
) -> nostr::Event {
|
||||
let keys = Keys::generate();
|
||||
let event = EventBuilder::new(Kind::Custom(kind as u16), content)
|
||||
.tags(tags)
|
||||
.sign_with_keys(&keys)
|
||||
.expect("sign event");
|
||||
crate::event::insert_event(pool, community, &event, channel_id)
|
||||
.await
|
||||
.expect("insert feed event");
|
||||
crate::insert_mentions(pool, community, &event, channel_id)
|
||||
.await
|
||||
.expect("insert mentions");
|
||||
event
|
||||
}
|
||||
|
||||
// -- Postgres tenant-scope regressions ------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn query_mentions_is_scoped_across_communities() {
|
||||
let pool = setup_pool().await;
|
||||
let community_a = CommunityId::from_uuid(make_test_community(&pool).await);
|
||||
let community_b = CommunityId::from_uuid(make_test_community(&pool).await);
|
||||
let channel_a = insert_test_channel(&pool, community_a).await;
|
||||
let channel_b = insert_test_channel(&pool, community_b).await;
|
||||
let mentioned_pubkey = "02".repeat(32);
|
||||
let mentioned_bytes = hex::decode(&mentioned_pubkey).expect("hex pubkey");
|
||||
|
||||
let event_a = store_feed_event(
|
||||
&pool,
|
||||
community_a,
|
||||
KIND_STREAM_MESSAGE,
|
||||
"community-a mention",
|
||||
Some(channel_a),
|
||||
vec![Tag::parse(["p", mentioned_pubkey.as_str()]).unwrap()],
|
||||
)
|
||||
.await;
|
||||
let event_b = store_feed_event(
|
||||
&pool,
|
||||
community_b,
|
||||
KIND_STREAM_MESSAGE,
|
||||
"community-b mention",
|
||||
Some(channel_b),
|
||||
vec![Tag::parse(["p", mentioned_pubkey.as_str()]).unwrap()],
|
||||
)
|
||||
.await;
|
||||
|
||||
let rows = query_mentions(
|
||||
&pool,
|
||||
community_a,
|
||||
&mentioned_bytes,
|
||||
&[channel_a, channel_b],
|
||||
None,
|
||||
10,
|
||||
)
|
||||
.await
|
||||
.expect("query mentions");
|
||||
|
||||
assert!(rows.iter().any(|row| row.event.id == event_a.id));
|
||||
assert!(
|
||||
rows.iter().all(|row| row.event.id != event_b.id),
|
||||
"community B mention must not appear in community A feed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn query_needs_action_is_scoped_across_communities() {
|
||||
let pool = setup_pool().await;
|
||||
let community_a = CommunityId::from_uuid(make_test_community(&pool).await);
|
||||
let community_b = CommunityId::from_uuid(make_test_community(&pool).await);
|
||||
let channel_a = insert_test_channel(&pool, community_a).await;
|
||||
let channel_b = insert_test_channel(&pool, community_b).await;
|
||||
let actor_pubkey = "03".repeat(32);
|
||||
let actor_bytes = hex::decode(&actor_pubkey).expect("hex pubkey");
|
||||
|
||||
let event_a = store_feed_event(
|
||||
&pool,
|
||||
community_a,
|
||||
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
"community-a approval",
|
||||
Some(channel_a),
|
||||
vec![Tag::parse(["p", actor_pubkey.as_str()]).unwrap()],
|
||||
)
|
||||
.await;
|
||||
let event_b = store_feed_event(
|
||||
&pool,
|
||||
community_b,
|
||||
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
"community-b approval",
|
||||
Some(channel_b),
|
||||
vec![Tag::parse(["p", actor_pubkey.as_str()]).unwrap()],
|
||||
)
|
||||
.await;
|
||||
|
||||
let rows = query_needs_action(
|
||||
&pool,
|
||||
community_a,
|
||||
&actor_bytes,
|
||||
&[channel_a, channel_b],
|
||||
None,
|
||||
10,
|
||||
)
|
||||
.await
|
||||
.expect("query needs_action");
|
||||
|
||||
assert!(rows.iter().any(|row| row.event.id == event_a.id));
|
||||
assert!(
|
||||
rows.iter().all(|row| row.event.id != event_b.id),
|
||||
"community B needs_action item must not appear in community A feed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn query_activity_is_scoped_and_empty_channels_are_global_only() {
|
||||
let pool = setup_pool().await;
|
||||
let community_a = CommunityId::from_uuid(make_test_community(&pool).await);
|
||||
let community_b = CommunityId::from_uuid(make_test_community(&pool).await);
|
||||
let channel_a = insert_test_channel(&pool, community_a).await;
|
||||
let channel_b = insert_test_channel(&pool, community_b).await;
|
||||
|
||||
let a_global = store_feed_event(
|
||||
&pool,
|
||||
community_a,
|
||||
KIND_STREAM_MESSAGE,
|
||||
"community-a global",
|
||||
None,
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
let a_channel = store_feed_event(
|
||||
&pool,
|
||||
community_a,
|
||||
KIND_STREAM_MESSAGE,
|
||||
"community-a channel",
|
||||
Some(channel_a),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
let b_global = store_feed_event(
|
||||
&pool,
|
||||
community_b,
|
||||
KIND_STREAM_MESSAGE,
|
||||
"community-b global",
|
||||
None,
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
let b_channel = store_feed_event(
|
||||
&pool,
|
||||
community_b,
|
||||
KIND_STREAM_MESSAGE,
|
||||
"community-b channel",
|
||||
Some(channel_b),
|
||||
vec![],
|
||||
)
|
||||
.await;
|
||||
|
||||
let global_only = query_activity(&pool, community_a, &[], None, 10)
|
||||
.await
|
||||
.expect("query activity global only");
|
||||
assert!(global_only.iter().any(|row| row.event.id == a_global.id));
|
||||
assert!(
|
||||
global_only.iter().all(|row| row.event.id != a_channel.id),
|
||||
"empty accessible channels must not mean all tenant channels"
|
||||
);
|
||||
assert!(global_only.iter().all(|row| row.event.id != b_global.id));
|
||||
assert!(global_only.iter().all(|row| row.event.id != b_channel.id));
|
||||
|
||||
let visible = query_activity(&pool, community_a, &[channel_a, channel_b], None, 10)
|
||||
.await
|
||||
.expect("query visible activity");
|
||||
assert!(visible.iter().any(|row| row.event.id == a_global.id));
|
||||
assert!(visible.iter().any(|row| row.event.id == a_channel.id));
|
||||
assert!(
|
||||
visible
|
||||
.iter()
|
||||
.all(|row| row.event.id != b_global.id && row.event.id != b_channel.id),
|
||||
"community B activity must not appear in community A feed"
|
||||
);
|
||||
}
|
||||
|
||||
// -- Hex encoding of pubkey -----------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn pubkey_hex_encoding_is_lowercase() {
|
||||
let pubkey_bytes = vec![0xAB, 0xCD, 0xEF, 0x01, 0x23, 0x45];
|
||||
let hex = hex::encode(&pubkey_bytes);
|
||||
assert_eq!(hex, "abcdef012345");
|
||||
assert_eq!(hex, hex.to_lowercase());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pubkey_hex_encoding_32_byte_key() {
|
||||
let pubkey_bytes: Vec<u8> = (0u8..32).collect();
|
||||
let hex = hex::encode(&pubkey_bytes);
|
||||
assert_eq!(hex.len(), 64);
|
||||
assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
assert_eq!(hex, hex.to_lowercase());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pubkey_hex_encoding_all_zeros() {
|
||||
let pubkey_bytes = vec![0u8; 32];
|
||||
let hex = hex::encode(&pubkey_bytes);
|
||||
assert_eq!(hex, "0".repeat(64));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pubkey_hex_encoding_all_ff() {
|
||||
let pubkey_bytes = vec![0xFFu8; 32];
|
||||
let hex = hex::encode(&pubkey_bytes);
|
||||
assert_eq!(hex, "f".repeat(64));
|
||||
}
|
||||
|
||||
// -- JSON tag format for tag matching -------------------------------------
|
||||
|
||||
#[test]
|
||||
fn json_tag_format_for_p_tag_mention() {
|
||||
let pubkey_hex = "abc123def456".to_owned();
|
||||
let tag_json = serde_json::json!([["p", pubkey_hex]]).to_string();
|
||||
assert_eq!(tag_json, r#"[["p","abc123def456"]]"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_tag_format_is_compact_not_pretty() {
|
||||
let pubkey_hex = "deadbeef".to_owned();
|
||||
let tag_json = serde_json::json!([["p", pubkey_hex]]).to_string();
|
||||
assert!(
|
||||
!tag_json.contains(' '),
|
||||
"tag JSON must be compact, got: {tag_json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_tag_format_p_tag_is_first_element() {
|
||||
let pubkey_hex = "aabbccdd".to_owned();
|
||||
let tag_json = serde_json::json!([["p", pubkey_hex]]).to_string();
|
||||
assert!(tag_json.starts_with(r#"[["p","#), "got: {tag_json}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_tag_format_round_trips_through_serde() {
|
||||
let pubkey_hex = "cafebabe00112233".to_owned();
|
||||
let tag_json = serde_json::json!([["p", pubkey_hex.clone()]]).to_string();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&tag_json).unwrap();
|
||||
let outer = parsed.as_array().unwrap();
|
||||
assert_eq!(outer.len(), 1, "outer array must have exactly one element");
|
||||
let inner = outer[0].as_array().unwrap();
|
||||
assert_eq!(inner.len(), 2);
|
||||
assert_eq!(inner[0].as_str().unwrap(), "p");
|
||||
assert_eq!(inner[1].as_str().unwrap(), pubkey_hex);
|
||||
}
|
||||
|
||||
// -- Kind number sets -----------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn mentions_query_includes_stream_message_kind() {
|
||||
use buzz_core::kind::{
|
||||
KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2,
|
||||
};
|
||||
let mention_kinds: &[u32] = &[
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_V2,
|
||||
KIND_FORUM_POST,
|
||||
KIND_FORUM_COMMENT,
|
||||
];
|
||||
|
||||
assert!(
|
||||
mention_kinds.contains(&KIND_STREAM_MESSAGE),
|
||||
"stream message kind must be in mentions"
|
||||
);
|
||||
assert!(
|
||||
mention_kinds.contains(&KIND_STREAM_MESSAGE_V2),
|
||||
"stream message v2 kind must be in mentions"
|
||||
);
|
||||
assert!(
|
||||
mention_kinds.contains(&KIND_FORUM_POST),
|
||||
"forum post kind must be in mentions"
|
||||
);
|
||||
assert!(
|
||||
mention_kinds.contains(&KIND_FORUM_COMMENT),
|
||||
"forum comment kind must be in mentions"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_action_query_includes_approval_and_reminder_kinds() {
|
||||
use buzz_core::kind::{KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED};
|
||||
let needs_action_kinds: &[u32] = &[KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_STREAM_REMINDER];
|
||||
|
||||
assert!(
|
||||
needs_action_kinds.contains(&KIND_WORKFLOW_APPROVAL_REQUESTED),
|
||||
"approval request kind must be in needs_action"
|
||||
);
|
||||
assert!(
|
||||
needs_action_kinds.contains(&KIND_STREAM_REMINDER),
|
||||
"reminder kind must be in needs_action"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_query_includes_agent_job_kinds() {
|
||||
use buzz_core::kind::{
|
||||
KIND_FORUM_POST, KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT,
|
||||
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2,
|
||||
};
|
||||
let activity_kinds: &[u32] = &[
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_V2,
|
||||
KIND_FORUM_POST,
|
||||
KIND_JOB_REQUEST,
|
||||
KIND_JOB_PROGRESS,
|
||||
KIND_JOB_RESULT,
|
||||
];
|
||||
|
||||
assert!(
|
||||
activity_kinds.contains(&KIND_JOB_REQUEST),
|
||||
"job request kind must be in activity"
|
||||
);
|
||||
assert!(
|
||||
activity_kinds.contains(&KIND_JOB_PROGRESS),
|
||||
"job progress kind must be in activity"
|
||||
);
|
||||
assert!(
|
||||
activity_kinds.contains(&KIND_JOB_RESULT),
|
||||
"job result kind must be in activity"
|
||||
);
|
||||
assert!(
|
||||
activity_kinds.contains(&KIND_STREAM_MESSAGE),
|
||||
"stream message kind must be in activity"
|
||||
);
|
||||
assert!(
|
||||
activity_kinds.contains(&KIND_FORUM_POST),
|
||||
"forum post kind must be in activity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_query_excludes_workflow_execution_kinds() {
|
||||
use buzz_core::kind::{
|
||||
KIND_FORUM_POST, KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT,
|
||||
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2,
|
||||
};
|
||||
let activity_kinds: &[u32] = &[
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_V2,
|
||||
KIND_FORUM_POST,
|
||||
KIND_JOB_REQUEST,
|
||||
KIND_JOB_PROGRESS,
|
||||
KIND_JOB_RESULT,
|
||||
];
|
||||
|
||||
use buzz_core::kind::{KIND_WORKFLOW_APPROVAL_DENIED, KIND_WORKFLOW_TRIGGERED};
|
||||
for kind in KIND_WORKFLOW_TRIGGERED..=KIND_WORKFLOW_APPROVAL_DENIED {
|
||||
assert!(
|
||||
!activity_kinds.contains(&kind),
|
||||
"workflow execution kind {kind} must NOT be in activity"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_action_kinds_do_not_overlap_with_activity_kinds() {
|
||||
use buzz_core::kind::{
|
||||
KIND_FORUM_POST, KIND_JOB_PROGRESS, KIND_JOB_REQUEST, KIND_JOB_RESULT,
|
||||
KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER,
|
||||
KIND_WORKFLOW_APPROVAL_REQUESTED,
|
||||
};
|
||||
let needs_action_kinds: &[u32] = &[KIND_WORKFLOW_APPROVAL_REQUESTED, KIND_STREAM_REMINDER];
|
||||
let activity_kinds: &[u32] = &[
|
||||
KIND_STREAM_MESSAGE,
|
||||
KIND_STREAM_MESSAGE_V2,
|
||||
KIND_FORUM_POST,
|
||||
KIND_JOB_REQUEST,
|
||||
KIND_JOB_PROGRESS,
|
||||
KIND_JOB_RESULT,
|
||||
];
|
||||
|
||||
for kind in needs_action_kinds {
|
||||
assert!(
|
||||
!activity_kinds.contains(kind),
|
||||
"kind {kind} appears in both needs_action and activity -- check intent"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -- Channel ID filtering logic -------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn channel_id_bytes_encoding_is_correct() {
|
||||
let channel_id = Uuid::parse_str("9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50").unwrap();
|
||||
let bytes = channel_id.as_bytes().to_vec();
|
||||
assert_eq!(bytes.len(), 16);
|
||||
|
||||
let recovered = Uuid::from_slice(&bytes).unwrap();
|
||||
assert_eq!(channel_id, recovered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_channel_ids_produce_distinct_byte_sequences() {
|
||||
let id1 = Uuid::new_v4();
|
||||
let id2 = Uuid::new_v4();
|
||||
|
||||
let bytes1 = id1.as_bytes().to_vec();
|
||||
let bytes2 = id2.as_bytes().to_vec();
|
||||
|
||||
assert_ne!(bytes1, bytes2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nil_uuid_channel_id_bytes_are_all_zeros() {
|
||||
let nil_id = Uuid::nil();
|
||||
let bytes = nil_id.as_bytes().to_vec();
|
||||
assert_eq!(bytes, vec![0u8; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_channel_list_means_global_only() {
|
||||
let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4());
|
||||
let mut qb = build_activity_query(community, &[], None, 10);
|
||||
let query = qb.build();
|
||||
let sql_str = sqlx::Execute::sql(query);
|
||||
let sql = sql_str.as_str();
|
||||
|
||||
assert!(
|
||||
sql.contains("WHERE community_id = "),
|
||||
"activity feed must bind the tenant community: {sql}"
|
||||
);
|
||||
assert!(
|
||||
sql.contains("AND channel_id IS NULL"),
|
||||
"empty accessible-channel list must mean global-only, not all tenant channels: {sql}"
|
||||
);
|
||||
assert!(
|
||||
!sql.contains("channel_id IN"),
|
||||
"empty accessible-channel list must not emit an IN filter: {sql}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_empty_channel_list_includes_global_and_accessible_channels() {
|
||||
let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4());
|
||||
let channel_id = Uuid::new_v4();
|
||||
let mut qb = build_activity_query(community, &[channel_id], None, 10);
|
||||
let query = qb.build();
|
||||
let sql_str = sqlx::Execute::sql(query);
|
||||
let sql = sql_str.as_str();
|
||||
|
||||
assert!(
|
||||
sql.contains("AND (channel_id IS NULL OR channel_id IN ("),
|
||||
"feed should include community-global events plus accessible channels: {sql}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mentions_query_is_tenant_scoped_and_joins_mentions_by_composite_key() {
|
||||
let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4());
|
||||
let pubkey = vec![0x42; 32];
|
||||
let channel_id = Uuid::new_v4();
|
||||
let mut qb = build_mentions_query(community, &pubkey, &[channel_id], None, 10);
|
||||
let query = qb.build();
|
||||
let sql_str = sqlx::Execute::sql(query);
|
||||
let sql = sql_str.as_str();
|
||||
|
||||
assert!(
|
||||
sql.contains("INNER JOIN event_mentions m ON e.community_id = m.community_id AND e.id = m.event_id"),
|
||||
"mentions must join event_mentions on the composite tenant/event key: {sql}"
|
||||
);
|
||||
assert!(
|
||||
sql.contains("WHERE e.community_id = "),
|
||||
"mentions feed must scope events to the tenant community: {sql}"
|
||||
);
|
||||
assert!(
|
||||
sql.contains("AND m.community_id = "),
|
||||
"mentions feed must also bind event_mentions.community_id: {sql}"
|
||||
);
|
||||
assert!(
|
||||
sql.contains(&KIND_GIT_PULL_REQUEST.to_string())
|
||||
&& sql.contains(&KIND_GIT_ISSUE.to_string())
|
||||
&& sql.contains(&KIND_TEXT_NOTE.to_string()),
|
||||
"mentions feed must include Buzz Git roots and comments: {sql}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_action_query_is_tenant_scoped_and_joins_mentions_by_composite_key() {
|
||||
let community = buzz_core::CommunityId::from_uuid(Uuid::new_v4());
|
||||
let pubkey = vec![0x42; 32];
|
||||
let channel_id = Uuid::new_v4();
|
||||
let mut qb = build_needs_action_query(community, &pubkey, &[channel_id], None, 10);
|
||||
let query = qb.build();
|
||||
let sql_str = sqlx::Execute::sql(query);
|
||||
let sql = sql_str.as_str();
|
||||
|
||||
assert!(
|
||||
sql.contains("INNER JOIN event_mentions m ON e.community_id = m.community_id AND e.id = m.event_id"),
|
||||
"needs_action must join event_mentions on the composite tenant/event key: {sql}"
|
||||
);
|
||||
assert!(
|
||||
sql.contains("WHERE e.community_id = "),
|
||||
"needs_action feed must scope events to the tenant community: {sql}"
|
||||
);
|
||||
assert!(
|
||||
sql.contains("AND m.community_id = "),
|
||||
"needs_action feed must also bind event_mentions.community_id: {sql}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_id_list_with_single_entry() {
|
||||
let channel_id = Uuid::new_v4();
|
||||
let accessible = [channel_id];
|
||||
assert_eq!(accessible.len(), 1);
|
||||
let bytes = accessible[0].as_bytes().to_vec();
|
||||
assert_eq!(bytes.len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_id_list_with_multiple_entries_are_distinct() {
|
||||
let ids: Vec<Uuid> = (0..5).map(|_| Uuid::new_v4()).collect();
|
||||
assert_eq!(ids.len(), 5);
|
||||
|
||||
let byte_seqs: Vec<Vec<u8>> = ids.iter().map(|id| id.as_bytes().to_vec()).collect();
|
||||
let unique: std::collections::HashSet<Vec<u8>> = byte_seqs.into_iter().collect();
|
||||
assert_eq!(unique.len(), 5, "all channel IDs must be distinct");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Git repository name registry (NIP-34 kind:30617).
|
||||
//!
|
||||
//! The relay holds no persistent per-repo filesystem state: git reads and
|
||||
//! writes hydrate an ephemeral bare repo from object storage per request, and
|
||||
//! writer serialization is the object-store pointer CAS (see
|
||||
//! `docs/git-on-object-storage.md`, `Inv_NoFork`). Repo-*name* uniqueness is
|
||||
//! the one remaining shared-state need, and it lives here — in Postgres, not on
|
||||
//! local disk — so the relay is stateless and can run multiple replicas without
|
||||
//! a ReadWriteMany volume.
|
||||
//!
|
||||
//! Names are unique **within a community**: the primary key is
|
||||
//! `(community_id, repo_id)`, matching the multi-tenant invariant that every
|
||||
//! tenant-scoped key leads with `community_id`. The PK enforces uniqueness
|
||||
//! atomically via `INSERT … ON CONFLICT DO NOTHING`, which replaces the old
|
||||
//! filesystem `create_dir` race guard. `owner_pubkey` distinguishes an
|
||||
//! idempotent re-announce (same owner) from a collision (different owner), and
|
||||
//! backs the per-pubkey quota via `COUNT`.
|
||||
|
||||
use sqlx::{PgPool, Row as _};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::CommunityId;
|
||||
|
||||
/// Outcome of a name-reservation attempt.
|
||||
///
|
||||
/// The caller (kind:30617 handler) uses this to decide whether to seed the
|
||||
/// manifest pointer and, on seed failure, whether to release the reservation:
|
||||
/// only a `Reserved` (freshly inserted) row should be rolled back — an
|
||||
/// `AlreadyOwned` re-announce must leave the pre-existing reservation intact.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ReserveOutcome {
|
||||
/// The name was newly claimed by this owner (a fresh row was inserted).
|
||||
Reserved,
|
||||
/// The name was already reserved by this same owner — idempotent
|
||||
/// re-announce, a no-op update. No row was inserted; the quota was not
|
||||
/// re-checked (re-announcing an already-owned name never grows the count).
|
||||
AlreadyOwned,
|
||||
/// The name is held by a *different* owner — a collision. No row was
|
||||
/// inserted.
|
||||
TakenByOther,
|
||||
}
|
||||
|
||||
/// Return the current owner pubkey of `repo_id` in `community`, or `None` if
|
||||
/// the name is unreserved. Used to classify an announce (same-owner
|
||||
/// re-announce vs cross-owner collision) and to gate the quota check before a
|
||||
/// fresh claim.
|
||||
pub async fn repo_name_owner(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
repo_id: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let row = sqlx::query(
|
||||
"SELECT owner_pubkey FROM git_repo_names \
|
||||
WHERE community_id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(repo_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
row.map(|r| r.try_get("owner_pubkey"))
|
||||
.transpose()
|
||||
.map_err(crate::error::DbError::from)
|
||||
}
|
||||
|
||||
/// Reserve `repo_id` for `owner_pubkey` within `community`, enforcing a
|
||||
/// per-pubkey quota of `max_repos_per_pubkey`.
|
||||
///
|
||||
/// Semantics (mirrors the previous filesystem registry exactly):
|
||||
/// - already reserved by the same owner → [`ReserveOutcome::AlreadyOwned`]
|
||||
/// (idempotent, no quota check);
|
||||
/// - already reserved by another owner → [`ReserveOutcome::TakenByOther`];
|
||||
/// - otherwise, if the owner is under quota, atomically claim it →
|
||||
/// [`ReserveOutcome::Reserved`]; if a concurrent announce wins the insert
|
||||
/// race, the `ON CONFLICT` re-read resolves it to `AlreadyOwned` (same owner
|
||||
/// racing itself) or `TakenByOther`.
|
||||
///
|
||||
/// Returns `Err` only on backend/database failure — a full quota is *not* an
|
||||
/// error here; the caller enforces the limit against `Reserved` outcomes using
|
||||
/// [`count_repos_for_owner`]. (Kept as a separate call so the handler owns the
|
||||
/// error message and the ordering, matching the old code.)
|
||||
pub async fn reserve_repo_name(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
repo_id: &str,
|
||||
owner_pubkey: &str,
|
||||
) -> Result<ReserveOutcome> {
|
||||
// Atomic claim: insert only if the (community, repo) is free. RETURNING is
|
||||
// non-empty exactly when *this* statement inserted the row, so it cleanly
|
||||
// distinguishes "I claimed it" from "someone already holds it" without a
|
||||
// separate read (TOCTOU-free, the same guarantee `create_dir` gave).
|
||||
let inserted = sqlx::query(
|
||||
"INSERT INTO git_repo_names (community_id, repo_id, owner_pubkey) \
|
||||
VALUES ($1, $2, $3) \
|
||||
ON CONFLICT (community_id, repo_id) DO NOTHING \
|
||||
RETURNING owner_pubkey",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(repo_id)
|
||||
.bind(owner_pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
if inserted.is_some() {
|
||||
return Ok(ReserveOutcome::Reserved);
|
||||
}
|
||||
|
||||
// The row already existed — read the holder to classify same-owner
|
||||
// re-announce vs cross-owner collision.
|
||||
let existing = sqlx::query(
|
||||
"SELECT owner_pubkey FROM git_repo_names \
|
||||
WHERE community_id = $1 AND repo_id = $2",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(repo_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
match existing {
|
||||
Some(row) => {
|
||||
let holder: String = row
|
||||
.try_get("owner_pubkey")
|
||||
.map_err(crate::error::DbError::from)?;
|
||||
if holder == owner_pubkey {
|
||||
Ok(ReserveOutcome::AlreadyOwned)
|
||||
} else {
|
||||
Ok(ReserveOutcome::TakenByOther)
|
||||
}
|
||||
}
|
||||
// Extremely narrow: the conflicting row was deleted between our INSERT
|
||||
// and this SELECT (e.g. a concurrent seed-failure rollback). Treat as
|
||||
// taken-by-other rather than silently granting — the announcer can
|
||||
// retry, and we never hand out a name we didn't atomically claim.
|
||||
None => Ok(ReserveOutcome::TakenByOther),
|
||||
}
|
||||
}
|
||||
|
||||
/// Count the repos currently reserved by `owner_pubkey` in `community`.
|
||||
///
|
||||
/// Backs the per-pubkey quota. Called *before* [`reserve_repo_name`] for a
|
||||
/// not-yet-owned name, so the handler can reject over-quota announces with its
|
||||
/// own error message.
|
||||
pub async fn count_repos_for_owner(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
owner_pubkey: &str,
|
||||
) -> Result<i64> {
|
||||
let row = sqlx::query(
|
||||
"SELECT COUNT(*) AS n FROM git_repo_names \
|
||||
WHERE community_id = $1 AND owner_pubkey = $2",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(owner_pubkey)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
row.try_get("n").map_err(crate::error::DbError::from)
|
||||
}
|
||||
|
||||
/// Release a reservation held by `owner_pubkey` (rollback path).
|
||||
///
|
||||
/// Used only when seeding the manifest pointer fails *after* a fresh
|
||||
/// [`ReserveOutcome::Reserved`], so the announce is all-or-nothing. Scoped to
|
||||
/// `owner_pubkey` so a rollback can never delete a name a *different* owner
|
||||
/// concurrently holds. Returns the number of rows removed (0 or 1).
|
||||
pub async fn release_repo_name(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
repo_id: &str,
|
||||
owner_pubkey: &str,
|
||||
) -> Result<u64> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM git_repo_names \
|
||||
WHERE community_id = $1 AND repo_id = $2 AND owner_pubkey = $3",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(repo_id)
|
||||
.bind(owner_pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use uuid::Uuid;
|
||||
|
||||
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
|
||||
|
||||
async fn setup_pool() -> PgPool {
|
||||
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
||||
PgPool::connect(&database_url)
|
||||
.await
|
||||
.expect("connect to test DB")
|
||||
}
|
||||
|
||||
async fn make_test_community(pool: &PgPool) -> CommunityId {
|
||||
let id = Uuid::new_v4();
|
||||
let host = format!("git-repo-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)
|
||||
}
|
||||
|
||||
fn pk() -> String {
|
||||
format!("{:064x}", Uuid::new_v4().as_u128())
|
||||
}
|
||||
|
||||
/// A fresh name is `Reserved`; re-announcing it as the *same* owner is
|
||||
/// `AlreadyOwned` (idempotent) and never grows the owner's count; a
|
||||
/// *different* owner is `TakenByOther`.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn reserve_classifies_fresh_idempotent_and_collision() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_test_community(&pool).await;
|
||||
let owner = pk();
|
||||
let other = pk();
|
||||
let repo = format!("repo-{}", Uuid::new_v4().simple());
|
||||
|
||||
assert_eq!(
|
||||
reserve_repo_name(&pool, community, &repo, &owner)
|
||||
.await
|
||||
.expect("fresh reserve"),
|
||||
ReserveOutcome::Reserved,
|
||||
"first claim of a free name is Reserved"
|
||||
);
|
||||
assert_eq!(
|
||||
reserve_repo_name(&pool, community, &repo, &owner)
|
||||
.await
|
||||
.expect("re-reserve same owner"),
|
||||
ReserveOutcome::AlreadyOwned,
|
||||
"same-owner re-announce is idempotent AlreadyOwned"
|
||||
);
|
||||
assert_eq!(
|
||||
reserve_repo_name(&pool, community, &repo, &other)
|
||||
.await
|
||||
.expect("re-reserve other owner"),
|
||||
ReserveOutcome::TakenByOther,
|
||||
"a different owner claiming a held name is TakenByOther"
|
||||
);
|
||||
assert_eq!(
|
||||
count_repos_for_owner(&pool, community, &owner)
|
||||
.await
|
||||
.expect("count owner"),
|
||||
1,
|
||||
"re-announce must not double-count the owner's quota"
|
||||
);
|
||||
assert_eq!(
|
||||
count_repos_for_owner(&pool, community, &other)
|
||||
.await
|
||||
.expect("count other"),
|
||||
0,
|
||||
"a failed (TakenByOther) claim must not count toward the loser's quota"
|
||||
);
|
||||
}
|
||||
|
||||
/// `repo_name_owner` returns the holder for a reserved name and `None` for a
|
||||
/// free one, so the handler can classify before claiming.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn repo_name_owner_reflects_reservation() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_test_community(&pool).await;
|
||||
let owner = pk();
|
||||
let repo = format!("repo-{}", Uuid::new_v4().simple());
|
||||
|
||||
assert!(
|
||||
repo_name_owner(&pool, community, &repo)
|
||||
.await
|
||||
.expect("owner of free name")
|
||||
.is_none(),
|
||||
"an unreserved name has no owner"
|
||||
);
|
||||
reserve_repo_name(&pool, community, &repo, &owner)
|
||||
.await
|
||||
.expect("reserve");
|
||||
assert_eq!(
|
||||
repo_name_owner(&pool, community, &repo)
|
||||
.await
|
||||
.expect("owner of reserved name"),
|
||||
Some(owner),
|
||||
"a reserved name resolves to its owner"
|
||||
);
|
||||
}
|
||||
|
||||
/// Release is owner-scoped: it removes the reservation only for the holder,
|
||||
/// freeing the name for a subsequent claim; a release by a *non*-holder is a
|
||||
/// no-op that leaves the reservation intact.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn release_is_owner_scoped_and_frees_the_name() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_test_community(&pool).await;
|
||||
let owner = pk();
|
||||
let stranger = pk();
|
||||
let repo = format!("repo-{}", Uuid::new_v4().simple());
|
||||
|
||||
reserve_repo_name(&pool, community, &repo, &owner)
|
||||
.await
|
||||
.expect("reserve");
|
||||
|
||||
// A non-holder cannot release the name.
|
||||
assert_eq!(
|
||||
release_repo_name(&pool, community, &repo, &stranger)
|
||||
.await
|
||||
.expect("stranger release"),
|
||||
0,
|
||||
"release by a non-holder removes nothing"
|
||||
);
|
||||
assert_eq!(
|
||||
repo_name_owner(&pool, community, &repo)
|
||||
.await
|
||||
.expect("still owned"),
|
||||
Some(owner.clone()),
|
||||
"the reservation survives a stranger's release attempt"
|
||||
);
|
||||
|
||||
// The holder releases it, freeing the name.
|
||||
assert_eq!(
|
||||
release_repo_name(&pool, community, &repo, &owner)
|
||||
.await
|
||||
.expect("owner release"),
|
||||
1,
|
||||
"the holder's release removes exactly the one row"
|
||||
);
|
||||
assert_eq!(
|
||||
reserve_repo_name(&pool, community, &repo, &stranger)
|
||||
.await
|
||||
.expect("reclaim after release"),
|
||||
ReserveOutcome::Reserved,
|
||||
"once released, the name is free for a new owner"
|
||||
);
|
||||
}
|
||||
|
||||
/// Names are unique *within* a community, not globally: the same repo name
|
||||
/// may be independently reserved by different owners in different
|
||||
/// communities without collision.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn names_are_scoped_per_community() {
|
||||
let pool = setup_pool().await;
|
||||
let community_a = make_test_community(&pool).await;
|
||||
let community_b = make_test_community(&pool).await;
|
||||
let owner_a = pk();
|
||||
let owner_b = pk();
|
||||
let repo = format!("repo-{}", Uuid::new_v4().simple());
|
||||
|
||||
assert_eq!(
|
||||
reserve_repo_name(&pool, community_a, &repo, &owner_a)
|
||||
.await
|
||||
.expect("reserve in A"),
|
||||
ReserveOutcome::Reserved
|
||||
);
|
||||
assert_eq!(
|
||||
reserve_repo_name(&pool, community_b, &repo, &owner_b)
|
||||
.await
|
||||
.expect("reserve same name in B"),
|
||||
ReserveOutcome::Reserved,
|
||||
"the same name in a different community is a fresh, independent claim"
|
||||
);
|
||||
assert_eq!(
|
||||
repo_name_owner(&pool, community_a, &repo)
|
||||
.await
|
||||
.expect("owner in A"),
|
||||
Some(owner_a)
|
||||
);
|
||||
assert_eq!(
|
||||
repo_name_owner(&pool, community_b, &repo)
|
||||
.await
|
||||
.expect("owner in B"),
|
||||
Some(owner_b)
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,894 @@
|
||||
//! Community moderation persistence (Phase 1 contract).
|
||||
//!
|
||||
//! Backs the NIP-56 report queue (`moderation_reports`), ban/timeout state
|
||||
//! (`community_bans`), and the moderation audit trail (`moderation_actions`)
|
||||
//! from `migrations/0006_moderation.sql`.
|
||||
//!
|
||||
//! ## Tenant invariant
|
||||
//! Every function takes a [`CommunityId`] and touches exactly one community's
|
||||
//! rows. Report/ban targets are resolved by callers under the requesting
|
||||
//! `TenantContext` **before** they reach this module — no function here may
|
||||
//! perform a cross-community or global lookup (MOD invariants,
|
||||
//! `docs/spec/MultiTenantRelay.tla`).
|
||||
//!
|
||||
//! Lane ownership: L1 (Max). Signatures below are the contract; changes go
|
||||
//! through the integration thread.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{PgPool, Row as _};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::CommunityId;
|
||||
|
||||
/// What a report points at. Exactly one target class per report row.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ReportTarget {
|
||||
/// `e`-tag target: an event that must resolve inside the tenant.
|
||||
Event(Vec<u8>),
|
||||
/// `p`-only target: a community-local report about a pubkey.
|
||||
Pubkey(Vec<u8>),
|
||||
/// `x`-tag target: a media blob sha256, resolved via tenant-scoped refs.
|
||||
Blob(Vec<u8>),
|
||||
}
|
||||
|
||||
/// Insert parameters for a new report row (from an accepted kind:1984 event).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewReport<'a> {
|
||||
/// Signed kind:1984 event id (32 bytes) — idempotency key per community.
|
||||
pub report_event_id: &'a [u8],
|
||||
/// Reporter pubkey bytes. Mod-queue-visible; never revealed to the author.
|
||||
pub reporter_pubkey: &'a [u8],
|
||||
/// Resolved (in-tenant) target.
|
||||
pub target: ReportTarget,
|
||||
/// Channel inferred from an in-tenant target event row, when resolvable.
|
||||
pub channel_id: Option<Uuid>,
|
||||
/// NIP-56 report type (already validated by ingest).
|
||||
pub report_type: &'a str,
|
||||
/// Reporter's optional free-text note.
|
||||
pub note: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// A report row as read back for the moderation queue.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReportRecord {
|
||||
/// Row id (unique within the community).
|
||||
pub id: Uuid,
|
||||
/// Signed kind:1984 event id.
|
||||
pub report_event_id: Vec<u8>,
|
||||
/// Reporter pubkey bytes.
|
||||
pub reporter_pubkey: Vec<u8>,
|
||||
/// Report target.
|
||||
pub target: ReportTarget,
|
||||
/// Inferred channel, if the target resolved to one.
|
||||
pub channel_id: Option<Uuid>,
|
||||
/// NIP-56 report type.
|
||||
pub report_type: String,
|
||||
/// Reporter's note.
|
||||
pub note: Option<String>,
|
||||
/// `open` | `resolved` | `dismissed` | `escalated`.
|
||||
pub status: String,
|
||||
/// Resolving moderator, once resolved.
|
||||
pub resolved_by: Option<Vec<u8>>,
|
||||
/// Resolution timestamp.
|
||||
pub resolved_at: Option<DateTime<Utc>>,
|
||||
/// `moderation_actions` row that resolved this report.
|
||||
pub action_id: Option<Uuid>,
|
||||
/// Report creation time.
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Ban/timeout state for one member in one community.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BanRecord {
|
||||
/// Member pubkey bytes.
|
||||
pub pubkey: Vec<u8>,
|
||||
/// Whether the member is currently banned (check `ban_expires_at`).
|
||||
pub banned: bool,
|
||||
/// Ban expiry; `None` while `banned` ⇒ permanent.
|
||||
pub ban_expires_at: Option<DateTime<Utc>>,
|
||||
/// Moderator-supplied ban reason (private).
|
||||
pub ban_reason: Option<String>,
|
||||
/// Write-block until this timestamp; `None` or past ⇒ not timed out.
|
||||
pub muted_until: Option<DateTime<Utc>>,
|
||||
/// Moderator-supplied timeout reason (private).
|
||||
pub mute_reason: Option<String>,
|
||||
/// Moderator who last modified this row.
|
||||
pub actor_pubkey: Vec<u8>,
|
||||
/// Last modification time.
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Audit action values accepted by the `moderation_actions.action` CHECK in
|
||||
/// migration 0006. Keep this in lockstep with `migrations/0006_moderation.sql`.
|
||||
pub const MODERATION_ACTION_CHECK_VOCAB: &[&str] = &[
|
||||
"delete_message",
|
||||
"kick",
|
||||
"ban",
|
||||
"unban",
|
||||
"timeout",
|
||||
"untimeout",
|
||||
"dismiss_report",
|
||||
"escalate",
|
||||
"resolve:delete",
|
||||
"resolve:kick",
|
||||
"resolve:ban",
|
||||
"resolve:timeout",
|
||||
];
|
||||
|
||||
/// Insert parameters for a moderation audit row.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewAction<'a> {
|
||||
/// Acting moderator pubkey bytes.
|
||||
pub actor_pubkey: &'a [u8],
|
||||
/// `delete_message` | `kick` | `ban` | `unban` | `timeout` | `untimeout`
|
||||
/// | `dismiss_report` | `escalate` | `resolve:*` decision rows (DB CHECK-enforced).
|
||||
pub action: &'a str,
|
||||
/// Actioned member, when the action targets a pubkey.
|
||||
pub target_pubkey: Option<&'a [u8]>,
|
||||
/// Actioned event, when the action targets an event.
|
||||
pub target_event_id: Option<&'a [u8]>,
|
||||
/// Channel context, when known.
|
||||
pub channel_id: Option<Uuid>,
|
||||
/// Machine-readable rule/reason code.
|
||||
pub reason_code: Option<&'a str>,
|
||||
/// Sanitized reason, safe for the public tombstone.
|
||||
pub public_reason: Option<&'a str>,
|
||||
/// Mod-only context; never leaves the audit surface.
|
||||
pub private_reason: Option<&'a str>,
|
||||
/// NIP-OA matched principal (`self` | `owner`) for ban enforcement audit.
|
||||
pub matched_principal: Option<&'a str>,
|
||||
}
|
||||
|
||||
/// An audit row as read back for `buzz moderation audit`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActionRecord {
|
||||
/// Row id.
|
||||
pub id: Uuid,
|
||||
/// Acting moderator pubkey bytes.
|
||||
pub actor_pubkey: Vec<u8>,
|
||||
/// Action name.
|
||||
pub action: String,
|
||||
/// Actioned member.
|
||||
pub target_pubkey: Option<Vec<u8>>,
|
||||
/// Actioned event.
|
||||
pub target_event_id: Option<Vec<u8>>,
|
||||
/// Channel context.
|
||||
pub channel_id: Option<Uuid>,
|
||||
/// Machine-readable rule/reason code.
|
||||
pub reason_code: Option<String>,
|
||||
/// Sanitized public reason.
|
||||
pub public_reason: Option<String>,
|
||||
/// Mod-only reason.
|
||||
pub private_reason: Option<String>,
|
||||
/// NIP-OA principal matched by enforcement, when relevant.
|
||||
pub matched_principal: Option<String>,
|
||||
/// Action time.
|
||||
pub created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Insert a new report row. Idempotent on `(community, report_event_id)`:
|
||||
/// re-ingesting the same signed report is a no-op returning the existing id.
|
||||
pub async fn insert_report(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
report: NewReport<'_>,
|
||||
) -> Result<Uuid> {
|
||||
let (target_kind, target_event_id, target_pubkey, target_blob_sha256) = match &report.target {
|
||||
ReportTarget::Event(id) => ("event", Some(id.as_slice()), None, None),
|
||||
ReportTarget::Pubkey(pubkey) => ("pubkey", None, Some(pubkey.as_slice()), None),
|
||||
ReportTarget::Blob(sha256) => ("blob", None, None, Some(sha256.as_slice())),
|
||||
};
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO moderation_reports (
|
||||
community_id, report_event_id, reporter_pubkey, target_kind,
|
||||
target_event_id, target_pubkey, target_blob_sha256, channel_id,
|
||||
report_type, note
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
ON CONFLICT (community_id, report_event_id) DO UPDATE SET
|
||||
report_event_id = EXCLUDED.report_event_id
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(report.report_event_id)
|
||||
.bind(report.reporter_pubkey)
|
||||
.bind(target_kind)
|
||||
.bind(target_event_id)
|
||||
.bind(target_pubkey)
|
||||
.bind(target_blob_sha256)
|
||||
.bind(report.channel_id)
|
||||
.bind(report.report_type)
|
||||
.bind(report.note)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.try_get("id")?)
|
||||
}
|
||||
|
||||
/// List reports for the moderation queue, newest first.
|
||||
/// `status = None` lists all; `Some("open")` etc. filters.
|
||||
pub async fn list_reports(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
status: Option<&str>,
|
||||
limit: i64,
|
||||
) -> Result<Vec<ReportRecord>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, report_event_id, reporter_pubkey, target_kind, target_event_id,
|
||||
target_pubkey, target_blob_sha256, channel_id, report_type, note,
|
||||
status, resolved_by, resolved_at, action_id, created_at
|
||||
FROM moderation_reports
|
||||
WHERE community_id = $1 AND ($2::text IS NULL OR status = $2)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $3
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(status)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(row_to_report).collect()
|
||||
}
|
||||
|
||||
/// Fetch one report by row id.
|
||||
pub async fn get_report(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
report_id: Uuid,
|
||||
) -> Result<Option<ReportRecord>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, report_event_id, reporter_pubkey, target_kind, target_event_id,
|
||||
target_pubkey, target_blob_sha256, channel_id, report_type, note,
|
||||
status, resolved_by, resolved_at, action_id, created_at
|
||||
FROM moderation_reports
|
||||
WHERE community_id = $1 AND id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(report_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(row_to_report).transpose()
|
||||
}
|
||||
|
||||
/// Fetch one report by signed NIP-56 report event id.
|
||||
pub async fn get_report_by_event(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
report_event_id: &[u8],
|
||||
) -> Result<Option<ReportRecord>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT id, report_event_id, reporter_pubkey, target_kind, target_event_id,
|
||||
target_pubkey, target_blob_sha256, channel_id, report_type, note,
|
||||
status, resolved_by, resolved_at, action_id, created_at
|
||||
FROM moderation_reports
|
||||
WHERE community_id = $1 AND report_event_id = $2
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(report_event_id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(row_to_report).transpose()
|
||||
}
|
||||
|
||||
/// Mark a report resolved/dismissed/escalated, linking the audit action.
|
||||
/// Returns `false` if the report was not found or already closed.
|
||||
pub async fn resolve_report(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
report_id: Uuid,
|
||||
status: &str,
|
||||
resolved_by: &[u8],
|
||||
action_id: Option<Uuid>,
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE moderation_reports
|
||||
SET status = $3, resolved_by = $4, resolved_at = now(), action_id = $5
|
||||
WHERE community_id = $1 AND id = $2 AND status = 'open'
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(report_id)
|
||||
.bind(status)
|
||||
.bind(resolved_by)
|
||||
.bind(action_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Upsert a ban: sets `banned = true` with optional expiry + reason.
|
||||
pub async fn ban_member(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &[u8],
|
||||
actor: &[u8],
|
||||
reason: Option<&str>,
|
||||
expires_at: Option<DateTime<Utc>>,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO community_bans (
|
||||
community_id, pubkey, banned, ban_expires_at, ban_reason, actor_pubkey
|
||||
) VALUES ($1, $2, true, $3, $4, $5)
|
||||
ON CONFLICT (community_id, pubkey) DO UPDATE SET
|
||||
banned = true,
|
||||
ban_expires_at = EXCLUDED.ban_expires_at,
|
||||
ban_reason = EXCLUDED.ban_reason,
|
||||
actor_pubkey = EXCLUDED.actor_pubkey,
|
||||
updated_at = now()
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.bind(expires_at)
|
||||
.bind(reason)
|
||||
.bind(actor)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Lift a ban. Returns `false` if the member was not banned.
|
||||
pub async fn unban_member(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &[u8],
|
||||
actor: &[u8],
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE community_bans
|
||||
SET banned = false, ban_expires_at = NULL, ban_reason = NULL,
|
||||
actor_pubkey = $3, updated_at = now()
|
||||
WHERE community_id = $1 AND pubkey = $2 AND banned = true
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.bind(actor)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Upsert a timeout: sets `muted_until` + reason.
|
||||
pub async fn timeout_member(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &[u8],
|
||||
actor: &[u8],
|
||||
muted_until: DateTime<Utc>,
|
||||
reason: Option<&str>,
|
||||
) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO community_bans (
|
||||
community_id, pubkey, muted_until, mute_reason, actor_pubkey
|
||||
) VALUES ($1, $2, $3, $4, $5)
|
||||
ON CONFLICT (community_id, pubkey) DO UPDATE SET
|
||||
muted_until = EXCLUDED.muted_until,
|
||||
mute_reason = EXCLUDED.mute_reason,
|
||||
actor_pubkey = EXCLUDED.actor_pubkey,
|
||||
updated_at = now()
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.bind(muted_until)
|
||||
.bind(reason)
|
||||
.bind(actor)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Clear a timeout early. Returns `false` if the member was not timed out.
|
||||
pub async fn untimeout_member(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &[u8],
|
||||
actor: &[u8],
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE community_bans
|
||||
SET muted_until = NULL, mute_reason = NULL,
|
||||
actor_pubkey = $3, updated_at = now()
|
||||
WHERE community_id = $1 AND pubkey = $2 AND muted_until > now()
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.bind(actor)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Restriction snapshot consumed by the auth-seam gate (L4) and write gates.
|
||||
///
|
||||
/// One cheap read per check: `banned` already accounts for expiry;
|
||||
/// `muted_until` is returned raw so the caller can render the timestamp in
|
||||
/// the `restricted:` message.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RestrictionState {
|
||||
/// Currently banned (row exists, `banned`, unexpired).
|
||||
pub banned: bool,
|
||||
/// Active timeout expiry, if in the future.
|
||||
pub muted_until: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
/// Fetch the current restriction state for a pubkey in one community.
|
||||
/// Missing row ⇒ `RestrictionState::default()` (unrestricted).
|
||||
pub async fn restriction_state(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &[u8],
|
||||
) -> Result<RestrictionState> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT
|
||||
(banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) AS banned,
|
||||
CASE WHEN muted_until > now() THEN muted_until ELSE NULL END AS muted_until
|
||||
FROM community_bans
|
||||
WHERE community_id = $1 AND pubkey = $2
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
match row {
|
||||
Some(row) => Ok(RestrictionState {
|
||||
banned: row.try_get("banned")?,
|
||||
muted_until: row.try_get("muted_until")?,
|
||||
}),
|
||||
None => Ok(RestrictionState::default()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the full ban/timeout row (moderation queue / audit views).
|
||||
pub async fn get_ban(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
pubkey: &[u8],
|
||||
) -> Result<Option<BanRecord>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT pubkey,
|
||||
(banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) AS banned,
|
||||
ban_expires_at, ban_reason, muted_until,
|
||||
mute_reason, actor_pubkey, updated_at
|
||||
FROM community_bans
|
||||
WHERE community_id = $1 AND pubkey = $2
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(row_to_ban).transpose()
|
||||
}
|
||||
|
||||
/// List currently-restricted members (active ban or timeout) for the queue.
|
||||
pub async fn list_restricted(pool: &PgPool, community: CommunityId) -> Result<Vec<BanRecord>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT pubkey,
|
||||
(banned AND (ban_expires_at IS NULL OR ban_expires_at > now())) AS banned,
|
||||
ban_expires_at, ban_reason, muted_until,
|
||||
mute_reason, actor_pubkey, updated_at
|
||||
FROM community_bans
|
||||
WHERE community_id = $1
|
||||
AND (
|
||||
(banned AND (ban_expires_at IS NULL OR ban_expires_at > now()))
|
||||
OR muted_until > now()
|
||||
)
|
||||
ORDER BY updated_at DESC
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(row_to_ban).collect()
|
||||
}
|
||||
|
||||
/// Insert a moderation audit row, returning its id.
|
||||
pub async fn insert_action(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
action: NewAction<'_>,
|
||||
) -> Result<Uuid> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO moderation_actions (
|
||||
community_id, actor_pubkey, action, target_pubkey, target_event_id,
|
||||
channel_id, reason_code, public_reason, private_reason, matched_principal
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(action.actor_pubkey)
|
||||
.bind(action.action)
|
||||
.bind(action.target_pubkey)
|
||||
.bind(action.target_event_id)
|
||||
.bind(action.channel_id)
|
||||
.bind(action.reason_code)
|
||||
.bind(action.public_reason)
|
||||
.bind(action.private_reason)
|
||||
.bind(action.matched_principal)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.try_get("id")?)
|
||||
}
|
||||
|
||||
/// List audit rows, newest first (`buzz moderation audit`).
|
||||
pub async fn list_actions(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
limit: i64,
|
||||
) -> Result<Vec<ActionRecord>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, actor_pubkey, action, target_pubkey, target_event_id, channel_id,
|
||||
reason_code, public_reason, private_reason, matched_principal, created_at
|
||||
FROM moderation_actions
|
||||
WHERE community_id = $1
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter().map(row_to_action).collect()
|
||||
}
|
||||
|
||||
fn row_to_report(row: sqlx::postgres::PgRow) -> Result<ReportRecord> {
|
||||
let target_kind: String = row.try_get("target_kind")?;
|
||||
let target = match target_kind.as_str() {
|
||||
"event" => ReportTarget::Event(row.try_get("target_event_id")?),
|
||||
"pubkey" => ReportTarget::Pubkey(row.try_get("target_pubkey")?),
|
||||
"blob" => ReportTarget::Blob(row.try_get("target_blob_sha256")?),
|
||||
other => {
|
||||
return Err(crate::error::DbError::InvalidData(format!(
|
||||
"invalid report target_kind: {other}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
|
||||
Ok(ReportRecord {
|
||||
id: row.try_get("id")?,
|
||||
report_event_id: row.try_get("report_event_id")?,
|
||||
reporter_pubkey: row.try_get("reporter_pubkey")?,
|
||||
target,
|
||||
channel_id: row.try_get("channel_id")?,
|
||||
report_type: row.try_get("report_type")?,
|
||||
note: row.try_get("note")?,
|
||||
status: row.try_get("status")?,
|
||||
resolved_by: row.try_get("resolved_by")?,
|
||||
resolved_at: row.try_get("resolved_at")?,
|
||||
action_id: row.try_get("action_id")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_ban(row: sqlx::postgres::PgRow) -> Result<BanRecord> {
|
||||
Ok(BanRecord {
|
||||
pubkey: row.try_get("pubkey")?,
|
||||
banned: row.try_get("banned")?,
|
||||
ban_expires_at: row.try_get("ban_expires_at")?,
|
||||
ban_reason: row.try_get("ban_reason")?,
|
||||
muted_until: row.try_get("muted_until")?,
|
||||
mute_reason: row.try_get("mute_reason")?,
|
||||
actor_pubkey: row.try_get("actor_pubkey")?,
|
||||
updated_at: row.try_get("updated_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
fn row_to_action(row: sqlx::postgres::PgRow) -> Result<ActionRecord> {
|
||||
Ok(ActionRecord {
|
||||
id: row.try_get("id")?,
|
||||
actor_pubkey: row.try_get("actor_pubkey")?,
|
||||
action: row.try_get("action")?,
|
||||
target_pubkey: row.try_get("target_pubkey")?,
|
||||
target_event_id: row.try_get("target_event_id")?,
|
||||
channel_id: row.try_get("channel_id")?,
|
||||
reason_code: row.try_get("reason_code")?,
|
||||
public_reason: row.try_get("public_reason")?,
|
||||
private_reason: row.try_get("private_reason")?,
|
||||
matched_principal: row.try_get("matched_principal")?,
|
||||
created_at: row.try_get("created_at")?,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::Duration;
|
||||
use uuid::Uuid;
|
||||
|
||||
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
|
||||
|
||||
async fn setup_pool() -> PgPool {
|
||||
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
||||
PgPool::connect(&database_url)
|
||||
.await
|
||||
.expect("connect to test DB")
|
||||
}
|
||||
|
||||
async fn make_test_community(pool: &PgPool) -> CommunityId {
|
||||
let id = Uuid::new_v4();
|
||||
let host = format!("moderation-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)
|
||||
}
|
||||
|
||||
fn random_32() -> Vec<u8> {
|
||||
let mut bytes = Vec::with_capacity(32);
|
||||
bytes.extend_from_slice(Uuid::new_v4().as_bytes());
|
||||
bytes.extend_from_slice(Uuid::new_v4().as_bytes());
|
||||
bytes
|
||||
}
|
||||
|
||||
fn new_report<'a>(
|
||||
report_event_id: &'a [u8],
|
||||
reporter_pubkey: &'a [u8],
|
||||
target_event_id: &'a [u8],
|
||||
note: Option<&'a str>,
|
||||
) -> NewReport<'a> {
|
||||
NewReport {
|
||||
report_event_id,
|
||||
reporter_pubkey,
|
||||
target: ReportTarget::Event(target_event_id.to_vec()),
|
||||
channel_id: None,
|
||||
report_type: "spam",
|
||||
note,
|
||||
}
|
||||
}
|
||||
|
||||
/// Community moderation restrictions are tenant-scoped. This guards the same
|
||||
/// mutation class as the TLA⁺ tenant-fence invariant: a ban in community A
|
||||
/// must not restrict the same pubkey in community B, through either the hot
|
||||
/// `restriction_state` read or the queue-facing `list_restricted` read.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn restrictions_are_confined_to_their_community() {
|
||||
let pool = setup_pool().await;
|
||||
let community_a = make_test_community(&pool).await;
|
||||
let community_b = make_test_community(&pool).await;
|
||||
let pubkey = random_32();
|
||||
let actor = random_32();
|
||||
|
||||
ban_member(
|
||||
&pool,
|
||||
community_a,
|
||||
&pubkey,
|
||||
&actor,
|
||||
Some("tenant fence test"),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.expect("ban in community A");
|
||||
|
||||
let state_a = restriction_state(&pool, community_a, &pubkey)
|
||||
.await
|
||||
.expect("restriction_state A");
|
||||
assert!(state_a.banned, "pubkey must be banned in community A");
|
||||
|
||||
let state_b = restriction_state(&pool, community_b, &pubkey)
|
||||
.await
|
||||
.expect("restriction_state B");
|
||||
assert!(
|
||||
!state_b.banned && state_b.muted_until.is_none(),
|
||||
"ban in A must not restrict the same pubkey in community B"
|
||||
);
|
||||
|
||||
let restricted_a = list_restricted(&pool, community_a)
|
||||
.await
|
||||
.expect("list restricted A");
|
||||
assert!(
|
||||
restricted_a.iter().any(|row| row.pubkey == pubkey),
|
||||
"community A restricted list must include the banned pubkey"
|
||||
);
|
||||
|
||||
let restricted_b = list_restricted(&pool, community_b)
|
||||
.await
|
||||
.expect("list restricted B");
|
||||
assert!(
|
||||
restricted_b.iter().all(|row| row.pubkey != pubkey),
|
||||
"community B restricted list must not include community A's ban"
|
||||
);
|
||||
}
|
||||
|
||||
/// Ban expiry is evaluated in SQL, while a live timeout on the same row keeps
|
||||
/// the member restricted for writes. This protects the one-row/two-restriction
|
||||
/// shape used by L4's auth and ingest gates.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn expired_ban_does_not_hide_active_timeout() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_test_community(&pool).await;
|
||||
let pubkey = random_32();
|
||||
let actor = random_32();
|
||||
|
||||
ban_member(
|
||||
&pool,
|
||||
community,
|
||||
&pubkey,
|
||||
&actor,
|
||||
Some("expired ban"),
|
||||
Some(Utc::now() - Duration::hours(1)),
|
||||
)
|
||||
.await
|
||||
.expect("insert expired ban");
|
||||
timeout_member(
|
||||
&pool,
|
||||
community,
|
||||
&pubkey,
|
||||
&actor,
|
||||
Utc::now() + Duration::hours(1),
|
||||
Some("active timeout"),
|
||||
)
|
||||
.await
|
||||
.expect("insert active timeout");
|
||||
|
||||
let state = restriction_state(&pool, community, &pubkey)
|
||||
.await
|
||||
.expect("restriction_state");
|
||||
assert!(!state.banned, "expired ban must evaluate inactive");
|
||||
assert!(
|
||||
state.muted_until.is_some(),
|
||||
"active timeout must survive an expired ban on the same row"
|
||||
);
|
||||
|
||||
let ban = get_ban(&pool, community, &pubkey)
|
||||
.await
|
||||
.expect("get ban")
|
||||
.expect("restriction row exists");
|
||||
assert!(
|
||||
!ban.banned,
|
||||
"get_ban must also evaluate expired ban inactive"
|
||||
);
|
||||
assert!(ban.muted_until.is_some(), "get_ban must preserve timeout");
|
||||
|
||||
let restricted = list_restricted(&pool, community)
|
||||
.await
|
||||
.expect("list restricted");
|
||||
let listed = restricted
|
||||
.iter()
|
||||
.find(|row| row.pubkey == pubkey)
|
||||
.expect("timeout-only row remains listed");
|
||||
assert!(
|
||||
!listed.banned,
|
||||
"list_restricted reports expired ban inactive"
|
||||
);
|
||||
assert!(
|
||||
listed.muted_until.is_some(),
|
||||
"list_restricted preserves timeout"
|
||||
);
|
||||
}
|
||||
|
||||
/// Re-ingesting the same signed report is idempotent by event id and must not
|
||||
/// reopen or otherwise reset a report that a moderator already resolved.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn report_reingest_returns_same_id_and_preserves_resolution() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_test_community(&pool).await;
|
||||
let report_event_id = random_32();
|
||||
let reporter = random_32();
|
||||
let target_event_id = random_32();
|
||||
let resolver = random_32();
|
||||
|
||||
let report = new_report(&report_event_id, &reporter, &target_event_id, Some("first"));
|
||||
let first_id = insert_report(&pool, community, report)
|
||||
.await
|
||||
.expect("insert report");
|
||||
|
||||
assert!(
|
||||
resolve_report(&pool, community, first_id, "resolved", &resolver, None)
|
||||
.await
|
||||
.expect("resolve report"),
|
||||
"first resolve should close the report"
|
||||
);
|
||||
|
||||
let duplicate = new_report(&report_event_id, &reporter, &target_event_id, Some("retry"));
|
||||
let second_id = insert_report(&pool, community, duplicate)
|
||||
.await
|
||||
.expect("re-ingest report");
|
||||
assert_eq!(first_id, second_id, "re-ingest must return the same row id");
|
||||
|
||||
let row = get_report(&pool, community, first_id)
|
||||
.await
|
||||
.expect("get report")
|
||||
.expect("report exists");
|
||||
let row_by_event = get_report_by_event(&pool, community, &report_event_id)
|
||||
.await
|
||||
.expect("get report by event id")
|
||||
.expect("report exists by event id");
|
||||
assert_eq!(
|
||||
row_by_event.id, first_id,
|
||||
"report event id lookup must return the same row"
|
||||
);
|
||||
assert_eq!(
|
||||
row.status, "resolved",
|
||||
"re-ingest must not reopen the report"
|
||||
);
|
||||
assert!(
|
||||
row.resolved_at.is_some(),
|
||||
"resolution timestamp is preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
row.resolved_by.as_deref(),
|
||||
Some(resolver.as_slice()),
|
||||
"resolving moderator is preserved"
|
||||
);
|
||||
}
|
||||
|
||||
/// `resolve_report` is a guarded transition out of `open`; a second resolve
|
||||
/// on a closed report must be a no-op and return `false`.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn resolve_report_returns_false_after_report_is_closed() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_test_community(&pool).await;
|
||||
let report_event_id = random_32();
|
||||
let reporter = random_32();
|
||||
let target_event_id = random_32();
|
||||
let resolver = random_32();
|
||||
|
||||
let report_id = insert_report(
|
||||
&pool,
|
||||
community,
|
||||
new_report(&report_event_id, &reporter, &target_event_id, None),
|
||||
)
|
||||
.await
|
||||
.expect("insert report");
|
||||
|
||||
assert!(
|
||||
resolve_report(&pool, community, report_id, "dismissed", &resolver, None)
|
||||
.await
|
||||
.expect("first resolve"),
|
||||
"first resolve should update the open report"
|
||||
);
|
||||
assert!(
|
||||
!resolve_report(&pool, community, report_id, "resolved", &resolver, None)
|
||||
.await
|
||||
.expect("second resolve"),
|
||||
"second resolve should return false once the report is closed"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Monthly partition manager for `events` and `delivery_log`.
|
||||
//!
|
||||
//! Call `ensure_future_partitions` on startup and monthly via cron.
|
||||
|
||||
use chrono::{Datelike, TimeZone, Utc};
|
||||
use sqlx::{PgPool, Row};
|
||||
use tracing::info;
|
||||
|
||||
use crate::error::{DbError, Result};
|
||||
|
||||
/// Tables that may be partition-managed. Allowlist prevents DDL injection.
|
||||
const PARTITIONED_TABLES: &[&str] = &["events", "delivery_log"];
|
||||
|
||||
/// Ensures monthly partition tables exist for the next `months_ahead` months.
|
||||
pub async fn ensure_future_partitions(pool: &PgPool, months_ahead: u32) -> Result<()> {
|
||||
let now = Utc::now();
|
||||
|
||||
for i in 0..=(months_ahead as i32) {
|
||||
let year = now.year();
|
||||
let month = now.month() as i32 + i;
|
||||
let (target_year, target_month) = if month > 12 {
|
||||
(year + (month - 1) / 12, ((month - 1) % 12 + 1) as u32)
|
||||
} else {
|
||||
(year, month as u32)
|
||||
};
|
||||
|
||||
let (end_year, end_month) = if target_month == 12 {
|
||||
(target_year + 1, 1u32)
|
||||
} else {
|
||||
(target_year, target_month + 1)
|
||||
};
|
||||
|
||||
let start = Utc
|
||||
.with_ymd_and_hms(target_year, target_month, 1, 0, 0, 0)
|
||||
.single()
|
||||
.ok_or_else(|| {
|
||||
DbError::InvalidData(format!("invalid date: {target_year}-{target_month:02}-01"))
|
||||
})?;
|
||||
let end = Utc
|
||||
.with_ymd_and_hms(end_year, end_month, 1, 0, 0, 0)
|
||||
.single()
|
||||
.ok_or_else(|| {
|
||||
DbError::InvalidData(format!("invalid date: {end_year}-{end_month:02}-01"))
|
||||
})?;
|
||||
|
||||
let suffix = format!("{:04}_{:02}", target_year, target_month);
|
||||
let start_str = start.format("%Y-%m-%d").to_string();
|
||||
let end_str = end.format("%Y-%m-%d").to_string();
|
||||
|
||||
for table in PARTITIONED_TABLES {
|
||||
ensure_partition(pool, table, &start_str, &end_str, &suffix).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate that a partition suffix is digits and underscores only.
|
||||
fn validate_partition_suffix(suffix: &str) -> bool {
|
||||
!suffix.is_empty() && suffix.chars().all(|c| c.is_ascii_digit() || c == '_')
|
||||
}
|
||||
|
||||
/// Validate that a date string matches YYYY-MM-DD format.
|
||||
fn validate_date_str(s: &str) -> bool {
|
||||
let bytes = s.as_bytes();
|
||||
bytes.len() == 10
|
||||
&& bytes[4] == b'-'
|
||||
&& bytes[7] == b'-'
|
||||
&& bytes[..4].iter().all(|b| b.is_ascii_digit())
|
||||
&& bytes[5..7].iter().all(|b| b.is_ascii_digit())
|
||||
&& bytes[8..].iter().all(|b| b.is_ascii_digit())
|
||||
}
|
||||
|
||||
async fn ensure_partition(
|
||||
pool: &PgPool,
|
||||
table_name: &str,
|
||||
start_date_str: &str,
|
||||
end_date_str: &str,
|
||||
suffix: &str,
|
||||
) -> Result<()> {
|
||||
// Allowlist check -- parameterized queries cannot be used for DDL identifiers.
|
||||
if !PARTITIONED_TABLES.contains(&table_name) {
|
||||
return Err(DbError::InvalidData(format!(
|
||||
"table not in partition allowlist: {table_name:?}"
|
||||
)));
|
||||
}
|
||||
if !validate_partition_suffix(suffix) {
|
||||
return Err(DbError::InvalidData(format!(
|
||||
"partition suffix contains invalid characters: {suffix:?}"
|
||||
)));
|
||||
}
|
||||
if !validate_date_str(start_date_str) {
|
||||
return Err(DbError::InvalidData(format!(
|
||||
"start_date_str is not YYYY-MM-DD: {start_date_str:?}"
|
||||
)));
|
||||
}
|
||||
if !validate_date_str(end_date_str) {
|
||||
return Err(DbError::InvalidData(format!(
|
||||
"end_date_str is not YYYY-MM-DD: {end_date_str:?}"
|
||||
)));
|
||||
}
|
||||
|
||||
let partition_name = format!("{table_name}_p{suffix}");
|
||||
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM pg_catalog.pg_class c
|
||||
JOIN pg_catalog.pg_namespace n ON n.oid = c.relnamespace
|
||||
WHERE n.nspname = current_schema()
|
||||
AND c.relname = $1
|
||||
AND c.relispartition = true
|
||||
"#,
|
||||
)
|
||||
.bind(&partition_name)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let cnt: i64 = row.try_get("cnt")?;
|
||||
if cnt > 0 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// DDL identifiers cannot be parameterized -- all inputs are validated above.
|
||||
let sql = format!(
|
||||
"CREATE TABLE IF NOT EXISTS {partition_name} PARTITION OF {table_name} \
|
||||
FOR VALUES FROM ('{start_date_str}') TO ('{end_date_str}')"
|
||||
);
|
||||
|
||||
match sqlx::query(sqlx::AssertSqlSafe(sql)).execute(pool).await {
|
||||
Ok(_) => {
|
||||
info!("added partition {partition_name}");
|
||||
Ok(())
|
||||
}
|
||||
Err(sqlx::Error::Database(db_err))
|
||||
if db_err.code().as_deref() == Some("42P17")
|
||||
&& db_err.message().contains("would overlap partition") =>
|
||||
{
|
||||
// Fresh schemas include a right-edge catch-all partition (`*_p_future`).
|
||||
// If it already covers this month, the table is still safe for writes;
|
||||
// treat the overlap as "ensured" rather than failing startup.
|
||||
info!(
|
||||
partition_name,
|
||||
"partition range already covered by an existing partition"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn suffix_validation() {
|
||||
assert!(validate_partition_suffix("2026_03"));
|
||||
assert!(validate_partition_suffix("9999_12"));
|
||||
assert!(!validate_partition_suffix(""));
|
||||
assert!(!validate_partition_suffix("2026-03"));
|
||||
assert!(!validate_partition_suffix("2026_03; DROP TABLE events--"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn date_str_validation() {
|
||||
assert!(validate_date_str("2026-03-01"));
|
||||
assert!(validate_date_str("9999-12-31"));
|
||||
assert!(!validate_date_str("2026-3-01"));
|
||||
assert!(!validate_date_str("2026/03/01"));
|
||||
assert!(!validate_date_str("20260301"));
|
||||
assert!(!validate_date_str("2026-03-01; DROP TABLE events--"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_allowlist() {
|
||||
assert!(PARTITIONED_TABLES.contains(&"events"));
|
||||
assert!(PARTITIONED_TABLES.contains(&"delivery_log"));
|
||||
assert!(!PARTITIONED_TABLES.contains(&"api_tokens"));
|
||||
assert!(!PARTITIONED_TABLES.contains(&"users"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! Persistence for deployment-level Buzz product feedback.
|
||||
//!
|
||||
//! Feedback retains its source [`CommunityId`] as provenance, but is not a
|
||||
//! community moderation concern and is never inserted into the events table.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::Serialize;
|
||||
use sqlx::{PgPool, Row as _};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{error::Result, CommunityId};
|
||||
|
||||
/// Validated fields from an accepted product-feedback event.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewProductFeedback<'a> {
|
||||
/// Signed feedback event id (32 bytes), used for idempotency.
|
||||
pub event_id: &'a [u8],
|
||||
/// Authenticated submitter's Nostr pubkey (32 bytes).
|
||||
pub submitter_pubkey: &'a [u8],
|
||||
/// Optional category from the relay-validated vocabulary.
|
||||
pub category: Option<&'a str>,
|
||||
/// Required free-text feedback body.
|
||||
pub body: &'a str,
|
||||
/// Full validated event tags (attachments and diagnostics metadata included).
|
||||
pub tags: &'a serde_json::Value,
|
||||
/// Timestamp signed into the source event.
|
||||
pub event_created_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Product-feedback row returned to deployment-operator tooling.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProductFeedbackRecord {
|
||||
/// Sidecar row id.
|
||||
pub id: Uuid,
|
||||
/// Source community, retained as provenance only.
|
||||
pub community_id: Uuid,
|
||||
/// Signed source event id.
|
||||
pub event_id: String,
|
||||
/// Signed submitter pubkey.
|
||||
pub submitter_pubkey: String,
|
||||
/// Optional feedback category.
|
||||
pub category: Option<String>,
|
||||
/// Feedback body.
|
||||
pub body: String,
|
||||
/// Full source tags, including attachment and diagnostics metadata.
|
||||
pub tags: serde_json::Value,
|
||||
/// Timestamp signed into the source event.
|
||||
pub event_created_at: DateTime<Utc>,
|
||||
/// Time accepted by this deployment.
|
||||
pub received_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
/// Insert product feedback, idempotent deployment-wide by signed event id.
|
||||
///
|
||||
/// The first accepted submission owns the provenance row. Replaying the exact
|
||||
/// same signed event through another community returns the same row without
|
||||
/// changing its source community; callers intentionally receive the same
|
||||
/// successful acknowledgment in both cases.
|
||||
pub async fn insert(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
feedback: NewProductFeedback<'_>,
|
||||
) -> Result<Uuid> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO product_feedback (
|
||||
community_id, event_id, submitter_pubkey, category, body, tags,
|
||||
event_created_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
ON CONFLICT (event_id) DO UPDATE SET
|
||||
event_id = EXCLUDED.event_id
|
||||
RETURNING id
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(feedback.event_id)
|
||||
.bind(feedback.submitter_pubkey)
|
||||
.bind(feedback.category)
|
||||
.bind(feedback.body)
|
||||
.bind(feedback.tags)
|
||||
.bind(feedback.event_created_at)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.try_get("id")?)
|
||||
}
|
||||
|
||||
/// List feedback across all communities, newest received first.
|
||||
pub async fn list(pool: &PgPool, limit: i64) -> Result<Vec<ProductFeedbackRecord>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT id, community_id, event_id, submitter_pubkey, category, body,
|
||||
tags, event_created_at, received_at
|
||||
FROM product_feedback
|
||||
ORDER BY received_at DESC, id
|
||||
LIMIT $1
|
||||
"#,
|
||||
)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(ProductFeedbackRecord {
|
||||
id: row.try_get("id")?,
|
||||
community_id: row.try_get("community_id")?,
|
||||
event_id: hex::encode(row.try_get::<Vec<u8>, _>("event_id")?),
|
||||
submitter_pubkey: hex::encode(row.try_get::<Vec<u8>, _>("submitter_pubkey")?),
|
||||
category: row.try_get("category")?,
|
||||
body: row.try_get("body")?,
|
||||
tags: row.try_get("tags")?,
|
||||
event_created_at: row.try_get("event_created_at")?,
|
||||
received_at: row.try_get("received_at")?,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires migrated Postgres"]
|
||||
async fn duplicate_event_keeps_first_community_provenance() {
|
||||
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.expect("BUZZ_TEST_DATABASE_URL or DATABASE_URL");
|
||||
let pool = PgPool::connect(&database_url)
|
||||
.await
|
||||
.expect("connect test DB");
|
||||
let first = Uuid::new_v4();
|
||||
let second = Uuid::new_v4();
|
||||
for (id, host) in [
|
||||
(first, format!("feedback-first-{first}.test")),
|
||||
(second, format!("feedback-second-{second}.test")),
|
||||
] {
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||||
.bind(id)
|
||||
.bind(host)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert community");
|
||||
}
|
||||
|
||||
let mut event_id = [0_u8; 32];
|
||||
event_id[..16].copy_from_slice(Uuid::new_v4().as_bytes());
|
||||
event_id[16..].copy_from_slice(Uuid::new_v4().as_bytes());
|
||||
let pubkey = [7_u8; 32];
|
||||
let tags = serde_json::json!([["category", "bug"]]);
|
||||
let feedback = || NewProductFeedback {
|
||||
event_id: &event_id,
|
||||
submitter_pubkey: &pubkey,
|
||||
category: Some("bug"),
|
||||
body: "same signed feedback",
|
||||
tags: &tags,
|
||||
event_created_at: Utc::now(),
|
||||
};
|
||||
|
||||
let first_row = insert(&pool, CommunityId::from_uuid(first), feedback())
|
||||
.await
|
||||
.expect("first insert");
|
||||
let duplicate_row = insert(&pool, CommunityId::from_uuid(second), feedback())
|
||||
.await
|
||||
.expect("duplicate insert");
|
||||
assert_eq!(duplicate_row, first_row);
|
||||
|
||||
let rows: Vec<Uuid> =
|
||||
sqlx::query_scalar("SELECT community_id FROM product_feedback WHERE event_id = $1")
|
||||
.bind(event_id.as_slice())
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("read feedback provenance");
|
||||
assert_eq!(rows, vec![first]);
|
||||
|
||||
sqlx::query("DELETE FROM product_feedback WHERE event_id = $1")
|
||||
.bind(event_id.as_slice())
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("delete feedback");
|
||||
sqlx::query("DELETE FROM communities WHERE id = ANY($1)")
|
||||
.bind(vec![first, second])
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("delete communities");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,418 @@
|
||||
//! Reaction persistence.
|
||||
//!
|
||||
//! One reaction per user per emoji per event. Soft-delete via removed_at.
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{PgPool, Postgres, Row, Transaction};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::CommunityId;
|
||||
|
||||
// -- Public structs -----------------------------------------------------------
|
||||
|
||||
/// A grouped set of reactions for a single emoji on an event.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReactionGroup {
|
||||
/// The emoji character or shortcode used in this reaction group.
|
||||
pub emoji: String,
|
||||
/// Total number of active reactions with this emoji.
|
||||
pub count: i64,
|
||||
/// Individual users who reacted with this emoji.
|
||||
pub users: Vec<ReactionUser>,
|
||||
}
|
||||
|
||||
/// A single user who reacted with a given emoji.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReactionUser {
|
||||
/// Compressed 33-byte public key of the reacting user.
|
||||
pub pubkey: Vec<u8>,
|
||||
/// Optional display name resolved from the users table.
|
||||
pub display_name: Option<String>,
|
||||
/// Nostr event ID of the kind:7 reaction event (raw bytes), if present.
|
||||
/// Clients use this to build signed kind:5 deletion events for reaction removal.
|
||||
pub reaction_event_id: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Bulk reaction entry for embedding in message lists.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BulkReactionEntry {
|
||||
/// The event this reaction entry belongs to.
|
||||
pub event_id: Vec<u8>,
|
||||
/// Partition key timestamp for the event.
|
||||
pub event_created_at: DateTime<Utc>,
|
||||
/// Emoji + count summaries for this event.
|
||||
pub reactions: Vec<ReactionSummary>,
|
||||
}
|
||||
|
||||
/// Emoji + count summary (no user list) for bulk fetches.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ReactionSummary {
|
||||
/// The emoji character or shortcode.
|
||||
pub emoji: String,
|
||||
/// Number of active reactions with this emoji.
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// Active reaction row metadata for a specific actor + emoji + target tuple.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ActiveReactionRecord {
|
||||
/// Nostr event ID of the reaction event, if this row came from a real kind:7 event.
|
||||
pub reaction_event_id: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
// -- Write operations ---------------------------------------------------------
|
||||
|
||||
const ADD_REACTION_SQL: &str = r#"
|
||||
INSERT INTO reactions (community_id, event_created_at, event_id, pubkey, emoji, reaction_event_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
ON CONFLICT (community_id, event_created_at, event_id, pubkey, emoji) DO UPDATE SET
|
||||
created_at = NOW(),
|
||||
removed_at = NULL,
|
||||
reaction_event_id = COALESCE(EXCLUDED.reaction_event_id, reactions.reaction_event_id)
|
||||
WHERE reactions.removed_at IS NOT NULL
|
||||
"#;
|
||||
|
||||
/// Add (or re-activate) a reaction.
|
||||
///
|
||||
/// Returns `Ok(true)` if the reaction was added or re-activated, `Ok(false)` if
|
||||
/// the reaction is already active (duplicate, no change made).
|
||||
///
|
||||
/// Uses `INSERT ... ON CONFLICT DO UPDATE` to eliminate the TOCTOU race where
|
||||
/// two concurrent adds both see no existing row and then race to INSERT.
|
||||
pub async fn add_reaction(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
event_id: &[u8],
|
||||
event_created_at: DateTime<Utc>,
|
||||
pubkey: &[u8],
|
||||
emoji: &str,
|
||||
reaction_event_id: Option<&[u8]>,
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(ADD_REACTION_SQL)
|
||||
.bind(community.as_uuid())
|
||||
.bind(event_created_at)
|
||||
.bind(event_id)
|
||||
.bind(pubkey)
|
||||
.bind(emoji)
|
||||
.bind(reaction_event_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
// Three cases:
|
||||
// (a) New reaction (no existing row): INSERT succeeds → rows_affected = 1 → true.
|
||||
// (b) Reactivating (row exists, removed_at IS NOT NULL): WHERE matches → UPDATE fires
|
||||
// → rows_affected = 1 → true.
|
||||
// (c) Active duplicate (row exists, removed_at IS NULL): WHERE fails → no UPDATE
|
||||
// → rows_affected = 0 → false. Caller should short-circuit and not store the event.
|
||||
Ok(result.rows_affected() != 0)
|
||||
}
|
||||
|
||||
/// Add (or re-activate) a reaction inside an existing transaction.
|
||||
///
|
||||
/// Uses the same `INSERT ... ON CONFLICT DO UPDATE ... WHERE removed_at IS NOT NULL`
|
||||
/// statement as [`add_reaction`], preserving the new / re-activate / active-duplicate
|
||||
/// semantics while letting callers atomically couple the reaction row to other writes.
|
||||
pub(crate) async fn add_reaction_tx(
|
||||
tx: &mut Transaction<'_, Postgres>,
|
||||
community: CommunityId,
|
||||
event_id: &[u8],
|
||||
event_created_at: DateTime<Utc>,
|
||||
pubkey: &[u8],
|
||||
emoji: &str,
|
||||
reaction_event_id: Option<&[u8]>,
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(ADD_REACTION_SQL)
|
||||
.bind(community.as_uuid())
|
||||
.bind(event_created_at)
|
||||
.bind(event_id)
|
||||
.bind(pubkey)
|
||||
.bind(emoji)
|
||||
.bind(reaction_event_id)
|
||||
.execute(&mut **tx)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() != 0)
|
||||
}
|
||||
|
||||
/// Soft-delete a reaction by setting `removed_at`.
|
||||
///
|
||||
/// Returns `true` if a row was updated, `false` if not found or already removed.
|
||||
pub async fn remove_reaction(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
event_id: &[u8],
|
||||
event_created_at: DateTime<Utc>,
|
||||
pubkey: &[u8],
|
||||
emoji: &str,
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE reactions
|
||||
SET removed_at = NOW()
|
||||
WHERE community_id = $1
|
||||
AND event_created_at = $2
|
||||
AND event_id = $3
|
||||
AND pubkey = $4
|
||||
AND emoji = $5
|
||||
AND removed_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(event_created_at)
|
||||
.bind(event_id)
|
||||
.bind(pubkey)
|
||||
.bind(emoji)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Soft-delete a reaction by the reaction event's own ID.
|
||||
///
|
||||
/// Returns `true` if a row was updated, `false` if not found or already removed.
|
||||
pub async fn remove_reaction_by_source_event_id(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
reaction_event_id: &[u8],
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE reactions
|
||||
SET removed_at = NOW()
|
||||
WHERE community_id = $1
|
||||
AND reaction_event_id = $2
|
||||
AND removed_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(reaction_event_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
/// Look up the active reaction row for one actor + emoji + target tuple.
|
||||
pub async fn get_active_reaction_record(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
event_id: &[u8],
|
||||
event_created_at: DateTime<Utc>,
|
||||
pubkey: &[u8],
|
||||
emoji: &str,
|
||||
) -> Result<Option<ActiveReactionRecord>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT reaction_event_id
|
||||
FROM reactions
|
||||
WHERE community_id = $1
|
||||
AND event_id = $2
|
||||
AND event_created_at = $3
|
||||
AND pubkey = $4
|
||||
AND emoji = $5
|
||||
AND removed_at IS NULL
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(event_id)
|
||||
.bind(event_created_at)
|
||||
.bind(pubkey)
|
||||
.bind(emoji)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(|row| -> Result<ActiveReactionRecord> {
|
||||
Ok(ActiveReactionRecord {
|
||||
reaction_event_id: row.try_get("reaction_event_id")?,
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Backfill the source event ID on an active reaction row.
|
||||
///
|
||||
/// Called after the kind:7 event is created and stored, to link the
|
||||
/// reaction row to its source event. Returns `true` if the row was updated.
|
||||
pub async fn set_reaction_event_id(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
event_id: &[u8],
|
||||
event_created_at: DateTime<Utc>,
|
||||
pubkey: &[u8],
|
||||
emoji: &str,
|
||||
reaction_event_id: &[u8],
|
||||
) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE reactions
|
||||
SET reaction_event_id = $1
|
||||
WHERE community_id = $2
|
||||
AND event_created_at = $3
|
||||
AND event_id = $4
|
||||
AND pubkey = $5
|
||||
AND emoji = $6
|
||||
AND removed_at IS NULL
|
||||
"#,
|
||||
)
|
||||
.bind(reaction_event_id)
|
||||
.bind(community.as_uuid())
|
||||
.bind(event_created_at)
|
||||
.bind(event_id)
|
||||
.bind(pubkey)
|
||||
.bind(emoji)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
// -- Read operations ----------------------------------------------------------
|
||||
|
||||
/// Get all active reactions for an event, grouped by emoji.
|
||||
///
|
||||
/// Returns one [`ReactionGroup`] per emoji, each containing the list of reacting
|
||||
/// user pubkeys. Display names are NOT resolved here -- callers should enrich via
|
||||
/// scoped user lookups if needed.
|
||||
///
|
||||
/// `cursor` is reserved for future keyset pagination (currently unused).
|
||||
pub async fn get_reactions(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
event_id: &[u8],
|
||||
event_created_at: DateTime<Utc>,
|
||||
limit: u32,
|
||||
_cursor: Option<&str>,
|
||||
) -> Result<Vec<ReactionGroup>> {
|
||||
// Two-step query: first get the limited set of distinct emoji groups,
|
||||
// then fetch all rows for those groups. This ensures `limit` applies to
|
||||
// emoji groups (the API contract), not raw rows — so one busy emoji
|
||||
// cannot consume the entire page and hide other groups.
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT r.emoji, r.pubkey, r.reaction_event_id
|
||||
FROM reactions r
|
||||
INNER JOIN (
|
||||
SELECT DISTINCT emoji
|
||||
FROM reactions
|
||||
WHERE community_id = $1
|
||||
AND event_id = $2
|
||||
AND event_created_at = $3
|
||||
AND removed_at IS NULL
|
||||
ORDER BY emoji
|
||||
LIMIT $4
|
||||
) g ON g.emoji = r.emoji
|
||||
WHERE r.community_id = $1
|
||||
AND r.event_id = $2
|
||||
AND r.event_created_at = $3
|
||||
AND r.removed_at IS NULL
|
||||
ORDER BY r.emoji, r.created_at
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(event_id)
|
||||
.bind(event_created_at)
|
||||
.bind(limit as i64)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
// Group individual rows by emoji in Rust.
|
||||
let mut groups: Vec<ReactionGroup> = Vec::new();
|
||||
let mut current_emoji: Option<String> = None;
|
||||
let mut current_users: Vec<ReactionUser> = Vec::new();
|
||||
|
||||
for row in &rows {
|
||||
let emoji: String = row.try_get("emoji")?;
|
||||
let pubkey: Vec<u8> = row.try_get("pubkey")?;
|
||||
let reaction_event_id: Option<Vec<u8>> = row.try_get("reaction_event_id")?;
|
||||
|
||||
if current_emoji.as_ref() != Some(&emoji) {
|
||||
if let Some(prev_emoji) = current_emoji.take() {
|
||||
let count = current_users.len() as i64;
|
||||
groups.push(ReactionGroup {
|
||||
emoji: prev_emoji,
|
||||
count,
|
||||
users: std::mem::take(&mut current_users),
|
||||
});
|
||||
}
|
||||
current_emoji = Some(emoji);
|
||||
}
|
||||
|
||||
current_users.push(ReactionUser {
|
||||
pubkey,
|
||||
display_name: None,
|
||||
reaction_event_id,
|
||||
});
|
||||
}
|
||||
|
||||
// Flush the final group.
|
||||
if let Some(emoji) = current_emoji {
|
||||
let count = current_users.len() as i64;
|
||||
groups.push(ReactionGroup {
|
||||
emoji,
|
||||
count,
|
||||
users: current_users,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(groups)
|
||||
}
|
||||
|
||||
/// Batch-fetch emoji counts for a set of (event_id, event_created_at) pairs.
|
||||
///
|
||||
/// Returns one [`BulkReactionEntry`] per input pair that has at least one
|
||||
/// active reaction. Pairs with no reactions are omitted.
|
||||
pub async fn get_reactions_bulk(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
event_ids: &[(&[u8], DateTime<Utc>)],
|
||||
) -> Result<Vec<BulkReactionEntry>> {
|
||||
if event_ids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
// Run one query per event. For typical message-list sizes (<=100 events)
|
||||
// this is acceptable; a single-query approach with dynamic IN clauses over
|
||||
// composite keys can be added later if needed.
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for (event_id, event_created_at) in event_ids {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT emoji, COUNT(*) AS count
|
||||
FROM reactions
|
||||
WHERE community_id = $1
|
||||
AND event_id = $2
|
||||
AND event_created_at = $3
|
||||
AND removed_at IS NULL
|
||||
GROUP BY emoji
|
||||
ORDER BY emoji
|
||||
"#,
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(*event_id)
|
||||
.bind(event_created_at)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
if rows.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut reactions = Vec::with_capacity(rows.len());
|
||||
for row in rows {
|
||||
let emoji: String = row.try_get("emoji")?;
|
||||
let count: i64 = row.try_get("count")?;
|
||||
reactions.push(ReactionSummary { emoji, count });
|
||||
}
|
||||
|
||||
entries.push(BulkReactionEntry {
|
||||
event_id: event_id.to_vec(),
|
||||
event_created_at: *event_created_at,
|
||||
reactions,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
@@ -0,0 +1,671 @@
|
||||
//! Use-limited relay invite persistence (v2 opaque tokens).
|
||||
//!
|
||||
//! Unlike the stateless v1 HMAC invite tokens in `buzz-relay::invite_token`,
|
||||
//! v2 invites are backed by durable rows in `relay_invites`. The table stores
|
||||
//! only `SHA-256(code)` — never the reusable bearer secret — so a leaked
|
||||
//! database does not immediately yield valid invite codes.
|
||||
//!
|
||||
//! Every lookup binds both `(community_id, token_hash)` to prevent cross-tenant
|
||||
//! authorization seams: a code minted on tenant A presented to tenant B returns
|
||||
//! `Invalid`, not a membership.
|
||||
//!
|
||||
//! ## Atomic redemption
|
||||
//!
|
||||
//! `claim_relay_invite` executes the full redemption in one PostgreSQL
|
||||
//! transaction: `SELECT FOR UPDATE` on the invite row, membership insert,
|
||||
//! join-policy evidence insert, and `use_count` increment all commit together.
|
||||
//! `FOR UPDATE` serializes concurrent claims for one invite across relay
|
||||
//! processes — exactly one claimant can win the final slot.
|
||||
|
||||
use buzz_core::invite::{
|
||||
encode_v2_code, hash_v2_code, MAX_INVITE_TTL_SECS, MAX_INVITE_USES, MIN_INVITE_TTL_SECS,
|
||||
V2_SECRET_LEN,
|
||||
};
|
||||
use chrono::{DateTime, Utc};
|
||||
use sqlx::{PgPool, Row as _};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::CommunityId;
|
||||
|
||||
/// Outcome of a v2 invite claim. Expected invalid/expired/exhausted states are
|
||||
/// typed variants so the relay layer can map them to distinct HTTP responses
|
||||
/// without inspecting database errors.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum ClaimOutcome {
|
||||
/// A new relay member was inserted. `use_count` is the post-increment count;
|
||||
/// `uses_remaining` is `None` for unlimited invites.
|
||||
Joined {
|
||||
/// Post-claim use count.
|
||||
use_count: i32,
|
||||
/// Remaining slots, or `None` when the invite is unlimited.
|
||||
uses_remaining: Option<i32>,
|
||||
},
|
||||
/// The claimer was already a member. `use_count` was NOT incremented.
|
||||
AlreadyMember {
|
||||
/// Current use count (unchanged by this claim).
|
||||
use_count: i32,
|
||||
/// Remaining slots, or `None` when the invite is unlimited.
|
||||
uses_remaining: Option<i32>,
|
||||
},
|
||||
/// The invite's `expires_at` has passed.
|
||||
Expired,
|
||||
/// The invite's use budget is fully consumed.
|
||||
Exhausted,
|
||||
/// No invite row matches `(community_id, token_hash)`.
|
||||
Invalid,
|
||||
}
|
||||
|
||||
/// A freshly minted v2 invite, including the plaintext code and metadata.
|
||||
#[derive(Debug)]
|
||||
pub struct MintedInvite {
|
||||
/// The full v2 code string (`v2.<base64url secret>`). Returned to the caller
|
||||
/// exactly once; the database stores only the SHA-256 hash.
|
||||
pub code: String,
|
||||
/// When the invite expires (UTC).
|
||||
pub expires_at: DateTime<Utc>,
|
||||
/// `None` means unlimited; `Some(n)` means at most `n` uses.
|
||||
pub max_uses: Option<i32>,
|
||||
/// Remaining uses at mint time (equals `max_uses` when bounded, `None`
|
||||
/// when unlimited).
|
||||
pub uses_remaining: Option<i32>,
|
||||
/// The invite's database-generated UUID.
|
||||
pub invite_id: uuid::Uuid,
|
||||
}
|
||||
|
||||
fn validate_mint_inputs(ttl_secs: u64, max_uses: Option<i32>) -> Result<()> {
|
||||
if !(MIN_INVITE_TTL_SECS..=MAX_INVITE_TTL_SECS).contains(&ttl_secs) {
|
||||
return Err(crate::error::DbError::InvalidData(format!(
|
||||
"ttl_secs must be between {MIN_INVITE_TTL_SECS} and {MAX_INVITE_TTL_SECS}"
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(max_uses) = max_uses {
|
||||
if !(1..=MAX_INVITE_USES).contains(&max_uses) {
|
||||
return Err(crate::error::DbError::InvalidData(format!(
|
||||
"max_uses must be between 1 and {MAX_INVITE_USES}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Mint a v2 invite: generate a 32-byte random secret, hash it, persist the
|
||||
/// row, and return the plaintext code plus metadata.
|
||||
///
|
||||
/// `ttl_secs` must be in the shared invite lifetime range.
|
||||
/// `max_uses` must be `None` (unlimited) or `Some(1..=10000)`.
|
||||
pub async fn mint_relay_invite(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
created_by: &str,
|
||||
ttl_secs: u64,
|
||||
max_uses: Option<i32>,
|
||||
) -> Result<MintedInvite> {
|
||||
validate_mint_inputs(ttl_secs, max_uses)?;
|
||||
|
||||
// Generate 32 random bytes and encode as base64url — this is the secret.
|
||||
let secret: [u8; V2_SECRET_LEN] = rand::random();
|
||||
let code = encode_v2_code(&secret);
|
||||
let token_hash = hash_v2_code(&code);
|
||||
let now = Utc::now();
|
||||
let expires_at = now + chrono::Duration::seconds(ttl_secs as i64);
|
||||
|
||||
let row = sqlx::query(
|
||||
"INSERT INTO relay_invites (community_id, token_hash, max_uses, expires_at, created_by) \
|
||||
VALUES ($1, $2, $3, $4, $5) \
|
||||
RETURNING id",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(token_hash.as_slice())
|
||||
.bind(max_uses)
|
||||
.bind(expires_at)
|
||||
.bind(created_by)
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
|
||||
let invite_id: uuid::Uuid = row.try_get("id")?;
|
||||
|
||||
Ok(MintedInvite {
|
||||
code,
|
||||
expires_at,
|
||||
max_uses,
|
||||
uses_remaining: max_uses,
|
||||
invite_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn log_claim_outcome(
|
||||
community: CommunityId,
|
||||
invite_id: Option<uuid::Uuid>,
|
||||
outcome: &'static str,
|
||||
max_uses: Option<i32>,
|
||||
use_count: Option<i32>,
|
||||
) {
|
||||
tracing::info!(
|
||||
community = %community,
|
||||
invite_id = ?invite_id,
|
||||
outcome,
|
||||
max_uses = ?max_uses,
|
||||
use_count = ?use_count,
|
||||
"relay invite claim completed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Maximum rows deleted by one retention sweep so cleanup cannot monopolize
|
||||
/// the invite table on a busy deployment.
|
||||
const RETENTION_SWEEP_BATCH_SIZE: i64 = 1_000;
|
||||
|
||||
/// Delete one bounded batch of invite rows expired before `cutoff`.
|
||||
///
|
||||
/// The relay calls this from its leader-only periodic tick. Ordering by the
|
||||
/// expiry index makes old rows drain first without turning cleanup into an
|
||||
/// unbounded transaction.
|
||||
pub async fn reap_expired_relay_invites(pool: &PgPool, cutoff: DateTime<Utc>) -> Result<u64> {
|
||||
let result = sqlx::query(
|
||||
"DELETE FROM relay_invites \
|
||||
WHERE (community_id, id) IN (\
|
||||
SELECT community_id, id FROM relay_invites \
|
||||
WHERE expires_at < $1 \
|
||||
ORDER BY expires_at \
|
||||
LIMIT $2\
|
||||
)",
|
||||
)
|
||||
.bind(cutoff)
|
||||
.bind(RETENTION_SWEEP_BATCH_SIZE)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// Atomically claim a v2 relay invite.
|
||||
///
|
||||
/// Executes the full redemption in one PostgreSQL transaction:
|
||||
/// 1. Hash the presented code.
|
||||
/// 2. `SELECT ... FOR UPDATE` on the invite row scoped by `(community, token_hash)`.
|
||||
/// 3. If no row → `Invalid`.
|
||||
/// 4. If `expires_at <= now()` → `Expired`.
|
||||
/// 5. Check existing membership.
|
||||
/// 6. If already a member → insert policy evidence (if configured), commit,
|
||||
/// return `AlreadyMember` (no increment).
|
||||
/// 7. If `max_uses` is set and `use_count >= max_uses` → `Exhausted`.
|
||||
/// 8. Insert relay member with role `member`, `added_by = 'invite'`.
|
||||
/// 9. Insert join-policy acceptance evidence (if configured).
|
||||
/// 10. Increment `use_count`.
|
||||
/// 11. Commit.
|
||||
///
|
||||
/// `FOR UPDATE` serializes concurrent claims so exactly one claimant wins the
|
||||
/// final slot. Membership insertion, policy evidence, and consumption share
|
||||
/// one commit — a failure in any rolls back all.
|
||||
pub async fn claim_relay_invite(
|
||||
pool: &PgPool,
|
||||
community: CommunityId,
|
||||
token_hash: &[u8; 32],
|
||||
claimer_pubkey: &str,
|
||||
policy_version: Option<&str>,
|
||||
) -> Result<ClaimOutcome> {
|
||||
let mut tx = pool.begin().await?;
|
||||
|
||||
// 2. SELECT FOR UPDATE — lock the invite row for the duration of this txn.
|
||||
let row = sqlx::query(
|
||||
"SELECT id, max_uses, use_count, expires_at \
|
||||
FROM relay_invites \
|
||||
WHERE community_id = $1 AND token_hash = $2 \
|
||||
FOR UPDATE",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(token_hash)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 3. No matching invite.
|
||||
let Some(invite) = row else {
|
||||
tx.rollback().await?;
|
||||
log_claim_outcome(community, None, "invalid", None, None);
|
||||
return Ok(ClaimOutcome::Invalid);
|
||||
};
|
||||
|
||||
let invite_id: uuid::Uuid = invite.try_get("id")?;
|
||||
let max_uses: Option<i32> = invite.try_get("max_uses")?;
|
||||
let use_count: i32 = invite.try_get("use_count")?;
|
||||
let expires_at: DateTime<Utc> = invite.try_get("expires_at")?;
|
||||
|
||||
// Expiry is checked before membership deliberately. An expired bearer must
|
||||
// not authorize fresh policy-acceptance evidence, even for an existing
|
||||
// member; exhausted-but-live invites remain valid for idempotent retries.
|
||||
if expires_at <= Utc::now() {
|
||||
tx.rollback().await?;
|
||||
log_claim_outcome(
|
||||
community,
|
||||
Some(invite_id),
|
||||
"expired",
|
||||
max_uses,
|
||||
Some(use_count),
|
||||
);
|
||||
return Ok(ClaimOutcome::Expired);
|
||||
}
|
||||
|
||||
let uses_remaining = || max_uses.map(|mu| mu - use_count);
|
||||
|
||||
// 5. Check existing membership.
|
||||
let existing =
|
||||
sqlx::query("SELECT 1 FROM relay_members WHERE community_id = $1 AND pubkey = $2")
|
||||
.bind(community.as_uuid())
|
||||
.bind(claimer_pubkey)
|
||||
.fetch_optional(&mut *tx)
|
||||
.await?;
|
||||
|
||||
if existing.is_some() {
|
||||
// 6. Already a member — insert policy evidence but do NOT increment.
|
||||
if let Some(version) = policy_version {
|
||||
sqlx::query(
|
||||
"INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(claimer_pubkey)
|
||||
.bind(version)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
tx.commit().await?;
|
||||
log_claim_outcome(
|
||||
community,
|
||||
Some(invite_id),
|
||||
"already_member",
|
||||
max_uses,
|
||||
Some(use_count),
|
||||
);
|
||||
return Ok(ClaimOutcome::AlreadyMember {
|
||||
use_count,
|
||||
uses_remaining: uses_remaining(),
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Capacity check.
|
||||
if let Some(mu) = max_uses {
|
||||
if use_count >= mu {
|
||||
tx.rollback().await?;
|
||||
log_claim_outcome(
|
||||
community,
|
||||
Some(invite_id),
|
||||
"exhausted",
|
||||
max_uses,
|
||||
Some(use_count),
|
||||
);
|
||||
return Ok(ClaimOutcome::Exhausted);
|
||||
}
|
||||
}
|
||||
|
||||
// 8. Insert relay member. The conflict branch covers a claimant admitted
|
||||
// concurrently through a different invite: only the transaction that
|
||||
// actually inserted membership may consume this invite.
|
||||
let inserted = sqlx::query(
|
||||
"INSERT INTO relay_members (community_id, pubkey, role, added_by) \
|
||||
VALUES ($1, $2, 'member', 'invite') \
|
||||
ON CONFLICT (community_id, pubkey) DO NOTHING",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(claimer_pubkey)
|
||||
.execute(&mut *tx)
|
||||
.await?
|
||||
.rows_affected()
|
||||
> 0;
|
||||
|
||||
// 9. Insert join-policy acceptance evidence. This is required for both a
|
||||
// new member and a claimant whose concurrent membership insert won first.
|
||||
if let Some(version) = policy_version {
|
||||
sqlx::query(
|
||||
"INSERT INTO join_policy_acceptances (community_id, pubkey, policy_version) \
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(claimer_pubkey)
|
||||
.bind(version)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
}
|
||||
|
||||
if !inserted {
|
||||
tx.commit().await?;
|
||||
log_claim_outcome(
|
||||
community,
|
||||
Some(invite_id),
|
||||
"already_member",
|
||||
max_uses,
|
||||
Some(use_count),
|
||||
);
|
||||
return Ok(ClaimOutcome::AlreadyMember {
|
||||
use_count,
|
||||
uses_remaining: uses_remaining(),
|
||||
});
|
||||
}
|
||||
|
||||
// 10. Increment use_count (for every new member, even unlimited).
|
||||
let new_use_count = use_count + 1;
|
||||
sqlx::query("UPDATE relay_invites SET use_count = $1 WHERE community_id = $2 AND id = $3")
|
||||
.bind(new_use_count)
|
||||
.bind(community.as_uuid())
|
||||
.bind(invite_id)
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
// 11. Commit.
|
||||
tx.commit().await?;
|
||||
|
||||
let new_uses_remaining = max_uses.map(|mu| mu - new_use_count);
|
||||
|
||||
log_claim_outcome(
|
||||
community,
|
||||
Some(invite_id),
|
||||
"joined",
|
||||
max_uses,
|
||||
Some(new_use_count),
|
||||
);
|
||||
|
||||
Ok(ClaimOutcome::Joined {
|
||||
use_count: new_use_count,
|
||||
uses_remaining: new_uses_remaining,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::relay_members::is_relay_member;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; // sadscan:disable np.postgres.1
|
||||
|
||||
async fn setup_pool() -> PgPool {
|
||||
let database_url = std::env::var("BUZZ_TEST_DATABASE_URL")
|
||||
.or_else(|_| std::env::var("DATABASE_URL"))
|
||||
.unwrap_or_else(|_| TEST_DB_URL.to_owned());
|
||||
PgPool::connect(&database_url)
|
||||
.await
|
||||
.expect("connect to test DB")
|
||||
}
|
||||
|
||||
async fn make_test_community(pool: &PgPool) -> CommunityId {
|
||||
let id = Uuid::new_v4();
|
||||
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
||||
.bind(id)
|
||||
.bind(format!("relay-invite-test-{}.example", id.simple()))
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert test community");
|
||||
CommunityId::from_uuid(id)
|
||||
}
|
||||
|
||||
async fn delete_test_community(pool: &PgPool, community: CommunityId) {
|
||||
let mut tx = pool.begin().await.expect("begin test cleanup");
|
||||
sqlx::query("DELETE FROM relay_invites WHERE community_id = $1")
|
||||
.bind(community.as_uuid())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("delete test invites");
|
||||
sqlx::query("DELETE FROM relay_members WHERE community_id = $1")
|
||||
.bind(community.as_uuid())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("delete test members");
|
||||
sqlx::query("DELETE FROM communities WHERE id = $1")
|
||||
.bind(community.as_uuid())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.expect("delete test community");
|
||||
tx.commit().await.expect("commit test cleanup");
|
||||
}
|
||||
|
||||
fn test_pubkey() -> String {
|
||||
format!("{:064x}", Uuid::new_v4().as_u128())
|
||||
}
|
||||
|
||||
async fn use_count(pool: &PgPool, community: CommunityId, invite_id: Uuid) -> i32 {
|
||||
sqlx::query_scalar(
|
||||
"SELECT use_count FROM relay_invites WHERE community_id = $1 AND id = $2",
|
||||
)
|
||||
.bind(community.as_uuid())
|
||||
.bind(invite_id)
|
||||
.fetch_one(pool)
|
||||
.await
|
||||
.expect("read invite use_count")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mint_validation_rejects_invalid_bounds_before_database_access() {
|
||||
for (ttl, max_uses) in [
|
||||
(MIN_INVITE_TTL_SECS - 1, None),
|
||||
(MAX_INVITE_TTL_SECS + 1, None),
|
||||
(3600, Some(0)),
|
||||
(3600, Some(-1)),
|
||||
(3600, Some(MAX_INVITE_USES + 1)),
|
||||
] {
|
||||
let error = validate_mint_inputs(ttl, max_uses).expect_err("invalid mint contract");
|
||||
assert!(matches!(error, crate::DbError::InvalidData(_)), "{error:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn bounded_claim_exhausts_and_existing_member_retry_does_not_consume() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_test_community(&pool).await;
|
||||
let first = test_pubkey();
|
||||
let second = test_pubkey();
|
||||
let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1))
|
||||
.await
|
||||
.expect("mint bounded invite");
|
||||
let hash = hash_v2_code(&invite.code);
|
||||
|
||||
assert_eq!(
|
||||
claim_relay_invite(&pool, community, &hash, &first, None)
|
||||
.await
|
||||
.expect("first claim"),
|
||||
ClaimOutcome::Joined {
|
||||
use_count: 1,
|
||||
uses_remaining: Some(0),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
claim_relay_invite(&pool, community, &hash, &first, None)
|
||||
.await
|
||||
.expect("idempotent retry"),
|
||||
ClaimOutcome::AlreadyMember {
|
||||
use_count: 1,
|
||||
uses_remaining: Some(0),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
claim_relay_invite(&pool, community, &hash, &second, None)
|
||||
.await
|
||||
.expect("exhausted claim"),
|
||||
ClaimOutcome::Exhausted
|
||||
);
|
||||
assert_eq!(use_count(&pool, community, invite.invite_id).await, 1);
|
||||
assert!(is_relay_member(&pool, community, &first)
|
||||
.await
|
||||
.expect("first membership"));
|
||||
assert!(!is_relay_member(&pool, community, &second)
|
||||
.await
|
||||
.expect("second membership"));
|
||||
delete_test_community(&pool, community).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn concurrent_claims_serialize_the_final_slot() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_test_community(&pool).await;
|
||||
let first = test_pubkey();
|
||||
let second = test_pubkey();
|
||||
let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1))
|
||||
.await
|
||||
.expect("mint bounded invite");
|
||||
let hash = hash_v2_code(&invite.code);
|
||||
|
||||
let (first_outcome, second_outcome) = tokio::join!(
|
||||
claim_relay_invite(&pool, community, &hash, &first, None),
|
||||
claim_relay_invite(&pool, community, &hash, &second, None),
|
||||
);
|
||||
let outcomes = [
|
||||
first_outcome.expect("first concurrent claim"),
|
||||
second_outcome.expect("second concurrent claim"),
|
||||
];
|
||||
assert_eq!(
|
||||
outcomes
|
||||
.iter()
|
||||
.filter(|outcome| matches!(outcome, ClaimOutcome::Joined { .. }))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
outcomes
|
||||
.iter()
|
||||
.filter(|outcome| matches!(outcome, ClaimOutcome::Exhausted))
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(use_count(&pool, community, invite.invite_id).await, 1);
|
||||
let admitted = is_relay_member(&pool, community, &first)
|
||||
.await
|
||||
.expect("first membership") as u8
|
||||
+ is_relay_member(&pool, community, &second)
|
||||
.await
|
||||
.expect("second membership") as u8;
|
||||
assert_eq!(admitted, 1);
|
||||
delete_test_community(&pool, community).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn expiry_and_tenant_scope_return_typed_failures() {
|
||||
let pool = setup_pool().await;
|
||||
let community_a = make_test_community(&pool).await;
|
||||
let community_b = make_test_community(&pool).await;
|
||||
let invite = mint_relay_invite(&pool, community_a, "owner", 3600, Some(2))
|
||||
.await
|
||||
.expect("mint invite");
|
||||
let hash = hash_v2_code(&invite.code);
|
||||
|
||||
assert_eq!(
|
||||
claim_relay_invite(&pool, community_b, &hash, &test_pubkey(), None)
|
||||
.await
|
||||
.expect("cross-tenant claim"),
|
||||
ClaimOutcome::Invalid
|
||||
);
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE relay_invites SET expires_at = now() - interval '1 second' \
|
||||
WHERE community_id = $1 AND id = $2",
|
||||
)
|
||||
.bind(community_a.as_uuid())
|
||||
.bind(invite.invite_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("expire invite");
|
||||
assert_eq!(
|
||||
claim_relay_invite(&pool, community_a, &hash, &test_pubkey(), None)
|
||||
.await
|
||||
.expect("expired claim"),
|
||||
ClaimOutcome::Expired
|
||||
);
|
||||
assert_eq!(use_count(&pool, community_a, invite.invite_id).await, 0);
|
||||
delete_test_community(&pool, community_a).await;
|
||||
delete_test_community(&pool, community_b).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn retention_sweep_deletes_only_invites_older_than_cutoff() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_test_community(&pool).await;
|
||||
let old = mint_relay_invite(&pool, community, "owner", 3600, Some(1))
|
||||
.await
|
||||
.expect("mint old invite");
|
||||
let recent = mint_relay_invite(&pool, community, "owner", 3600, Some(1))
|
||||
.await
|
||||
.expect("mint recent invite");
|
||||
let cutoff = Utc::now() - chrono::Duration::days(30);
|
||||
|
||||
sqlx::query("UPDATE relay_invites SET expires_at = $1 WHERE community_id = $2 AND id = $3")
|
||||
.bind(cutoff - chrono::Duration::seconds(1))
|
||||
.bind(community.as_uuid())
|
||||
.bind(old.invite_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("age old invite");
|
||||
|
||||
assert_eq!(
|
||||
reap_expired_relay_invites(&pool, cutoff)
|
||||
.await
|
||||
.expect("reap expired invites"),
|
||||
1
|
||||
);
|
||||
let remaining: Vec<Uuid> =
|
||||
sqlx::query_scalar("SELECT id FROM relay_invites WHERE community_id = $1 ORDER BY id")
|
||||
.bind(community.as_uuid())
|
||||
.fetch_all(&pool)
|
||||
.await
|
||||
.expect("read remaining invites");
|
||||
assert_eq!(remaining, vec![recent.invite_id]);
|
||||
|
||||
delete_test_community(&pool, community).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn unlimited_invites_count_each_new_member() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_test_community(&pool).await;
|
||||
let invite = mint_relay_invite(&pool, community, "owner", 3600, None)
|
||||
.await
|
||||
.expect("mint unlimited invite");
|
||||
let hash = hash_v2_code(&invite.code);
|
||||
|
||||
for (expected_count, pubkey) in [(1, test_pubkey()), (2, test_pubkey())] {
|
||||
assert_eq!(
|
||||
claim_relay_invite(&pool, community, &hash, &pubkey, None)
|
||||
.await
|
||||
.expect("unlimited claim"),
|
||||
ClaimOutcome::Joined {
|
||||
use_count: expected_count,
|
||||
uses_remaining: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
assert_eq!(use_count(&pool, community, invite.invite_id).await, 2);
|
||||
delete_test_community(&pool, community).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn policy_evidence_failure_rolls_back_membership_and_consumption() {
|
||||
let pool = setup_pool().await;
|
||||
let community = make_test_community(&pool).await;
|
||||
let pubkey = test_pubkey();
|
||||
let invite = mint_relay_invite(&pool, community, "owner", 3600, Some(1))
|
||||
.await
|
||||
.expect("mint bounded invite");
|
||||
let hash = hash_v2_code(&invite.code);
|
||||
|
||||
let error = claim_relay_invite(&pool, community, &hash, &pubkey, Some("too-short"))
|
||||
.await
|
||||
.expect_err("policy CHECK must reject an invalid version");
|
||||
assert!(matches!(error, crate::DbError::Sqlx(_)), "{error:?}");
|
||||
assert!(!is_relay_member(&pool, community, &pubkey)
|
||||
.await
|
||||
.expect("membership after rollback"));
|
||||
assert_eq!(use_count(&pool, community, invite.invite_id).await, 0);
|
||||
|
||||
assert!(matches!(
|
||||
claim_relay_invite(&pool, community, &hash, &pubkey, None)
|
||||
.await
|
||||
.expect("claim after rollback"),
|
||||
ClaimOutcome::Joined { use_count: 1, .. }
|
||||
));
|
||||
delete_test_community(&pool, community).await;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,722 @@
|
||||
//! Per-community usage rollup queries for Prometheus gauges.
|
||||
//!
|
||||
//! Stock queries (`user_counts`, `channel_counts`, `relay_member_counts`,
|
||||
//! `workflow_counts`, `git_repo_counts`) use `GROUP BY community_id` against
|
||||
//! indexed columns — no per-community loops, no full-table scans.
|
||||
//!
|
||||
//! Event-derived queries (`message_counts`, `active_user_counts`,
|
||||
//! `active_channel_counts`) are exact aggregates over the `events` table.
|
||||
//! At scale these can become recurring partition scans; if that becomes a
|
||||
//! problem, move them to a maintained rollup table and drop the interval.
|
||||
//!
|
||||
//! Returned structs are plain data; the caller (relay poller) maps them
|
||||
//! to Prometheus labels and calls `metrics::gauge!(...).set(...)`.
|
||||
|
||||
use crate::error::Result;
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Total number of communities registered on this relay.
|
||||
pub async fn community_count(pool: &PgPool) -> Result<i64> {
|
||||
let row = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM communities")
|
||||
.fetch_one(pool)
|
||||
.await?;
|
||||
Ok(row)
|
||||
}
|
||||
|
||||
/// Per-community user counts split by human/agent.
|
||||
#[derive(Debug)]
|
||||
pub struct CommunityUserCounts {
|
||||
/// The UUID of the community.
|
||||
pub community_id: Uuid,
|
||||
/// Number of active human users (no `agent_owner_pubkey`).
|
||||
pub human: i64,
|
||||
/// Number of active agent users (`agent_owner_pubkey IS NOT NULL`).
|
||||
pub agent: i64,
|
||||
}
|
||||
|
||||
/// Return active (non-deactivated) user counts per community, split by type.
|
||||
///
|
||||
/// Agent discriminator: `agent_owner_pubkey IS NOT NULL`.
|
||||
pub async fn user_counts(pool: &PgPool) -> Result<Vec<CommunityUserCounts>> {
|
||||
// Single GROUP BY query; two conditional SUMs avoid two round-trips.
|
||||
let rows = sqlx::query_as::<_, (Uuid, i64, i64)>(
|
||||
r#"
|
||||
SELECT
|
||||
community_id,
|
||||
COUNT(*) FILTER (WHERE agent_owner_pubkey IS NULL) AS human,
|
||||
COUNT(*) FILTER (WHERE agent_owner_pubkey IS NOT NULL) AS agent
|
||||
FROM users
|
||||
WHERE deactivated_at IS NULL
|
||||
GROUP BY community_id
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(community_id, human, agent)| CommunityUserCounts {
|
||||
community_id,
|
||||
human,
|
||||
agent,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Per-community channel counts by type.
|
||||
#[derive(Debug)]
|
||||
pub struct CommunityChannelCount {
|
||||
/// The UUID of the community.
|
||||
pub community_id: Uuid,
|
||||
/// Channel type string (e.g. `"stream"`, `"dm"`, `"forum"`, `"workflow"`).
|
||||
pub channel_type: String,
|
||||
/// Number of non-deleted channels of this type.
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// Return non-deleted channel counts per community per type.
|
||||
pub async fn channel_counts(pool: &PgPool) -> Result<Vec<CommunityChannelCount>> {
|
||||
let rows = sqlx::query_as::<_, (Uuid, String, i64)>(
|
||||
r#"
|
||||
SELECT community_id, channel_type::text, COUNT(*) AS count
|
||||
FROM channels
|
||||
WHERE deleted_at IS NULL
|
||||
GROUP BY community_id, channel_type
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(community_id, channel_type, count)| CommunityChannelCount {
|
||||
community_id,
|
||||
channel_type,
|
||||
count,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Per-community message (kind=9) count.
|
||||
#[derive(Debug)]
|
||||
pub struct CommunityMessageCount {
|
||||
/// The UUID of the community.
|
||||
pub community_id: Uuid,
|
||||
/// Number of stored non-deleted kind=9 events.
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// Return non-deleted kind=9 event counts per community.
|
||||
pub async fn message_counts(pool: &PgPool) -> Result<Vec<CommunityMessageCount>> {
|
||||
let rows = sqlx::query_as::<_, (Uuid, i64)>(
|
||||
r#"
|
||||
SELECT community_id, COUNT(*) AS count
|
||||
FROM events
|
||||
WHERE kind = 9 AND deleted_at IS NULL
|
||||
GROUP BY community_id
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(community_id, count)| CommunityMessageCount {
|
||||
community_id,
|
||||
count,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Per-community relay-member counts by role.
|
||||
#[derive(Debug)]
|
||||
pub struct CommunityMemberCount {
|
||||
/// The UUID of the community.
|
||||
pub community_id: Uuid,
|
||||
/// Role string (e.g. `"owner"`, `"admin"`, `"member"`).
|
||||
pub role: String,
|
||||
/// Number of members with this role.
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// Return relay-member counts per community per role.
|
||||
pub async fn relay_member_counts(pool: &PgPool) -> Result<Vec<CommunityMemberCount>> {
|
||||
let rows = sqlx::query_as::<_, (Uuid, String, i64)>(
|
||||
r#"
|
||||
SELECT community_id, role::text, COUNT(*) AS count
|
||||
FROM relay_members
|
||||
GROUP BY community_id, role
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(community_id, role, count)| CommunityMemberCount {
|
||||
community_id,
|
||||
role,
|
||||
count,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Per-community workflow counts by status.
|
||||
#[derive(Debug)]
|
||||
pub struct CommunityWorkflowCount {
|
||||
/// The UUID of the community.
|
||||
pub community_id: Uuid,
|
||||
/// Workflow status string (e.g. `"active"`, `"inactive"`).
|
||||
pub status: String,
|
||||
/// Number of workflows in this status.
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// Return workflow counts per community per status.
|
||||
pub async fn workflow_counts(pool: &PgPool) -> Result<Vec<CommunityWorkflowCount>> {
|
||||
let rows = sqlx::query_as::<_, (Uuid, String, i64)>(
|
||||
r#"
|
||||
SELECT community_id, status::text, COUNT(*) AS count
|
||||
FROM workflows
|
||||
GROUP BY community_id, status
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(community_id, status, count)| CommunityWorkflowCount {
|
||||
community_id,
|
||||
status,
|
||||
count,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Per-community git-repo count.
|
||||
#[derive(Debug)]
|
||||
pub struct CommunityGitRepoCount {
|
||||
/// The UUID of the community.
|
||||
pub community_id: Uuid,
|
||||
/// Number of git repos registered for this community.
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// Return git repo counts per community.
|
||||
pub async fn git_repo_counts(pool: &PgPool) -> Result<Vec<CommunityGitRepoCount>> {
|
||||
let rows = sqlx::query_as::<_, (Uuid, i64)>(
|
||||
r#"
|
||||
SELECT community_id, COUNT(*) AS count
|
||||
FROM git_repo_names
|
||||
GROUP BY community_id
|
||||
"#,
|
||||
)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(community_id, count)| CommunityGitRepoCount {
|
||||
community_id,
|
||||
count,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Per-community active-user counts for a given window (e.g. 1d, 7d, 30d),
|
||||
/// split by human/agent.
|
||||
#[derive(Debug)]
|
||||
pub struct CommunityActiveUsers {
|
||||
/// The UUID of the community.
|
||||
pub community_id: Uuid,
|
||||
/// Distinct human pubkeys that published at least one event in the window.
|
||||
/// A pubkey is human when its `users` row exists and `agent_owner_pubkey IS NULL`.
|
||||
pub human: i64,
|
||||
/// Distinct agent pubkeys that published at least one event in the window.
|
||||
/// A pubkey is an agent when its `users` row exists and `agent_owner_pubkey IS NOT NULL`.
|
||||
pub agent: i64,
|
||||
/// Distinct pubkeys that published at least one event but have no `users` row.
|
||||
/// Ingest does not guarantee a `users` row for every pubkey (profileless posters,
|
||||
/// agents with missing rows). These are not classified and must not be folded into
|
||||
/// `human` to avoid inflating the human count.
|
||||
pub unknown: i64,
|
||||
}
|
||||
|
||||
/// Return distinct-publisher counts for events in `[now - interval, now]`
|
||||
/// per community, split by human/agent/unknown.
|
||||
///
|
||||
/// `interval_sql` must be a trusted literal (e.g. `"1 day"`, `"7 days"`) —
|
||||
/// it is not user-controlled; callers are in the relay process.
|
||||
pub async fn active_user_counts(
|
||||
pool: &PgPool,
|
||||
interval_sql: &'static str,
|
||||
) -> Result<Vec<CommunityActiveUsers>> {
|
||||
// LEFT JOIN users: pubkeys with no row have u.* = NULL.
|
||||
// Three-way classification:
|
||||
// human — row exists (u.pubkey IS NOT NULL) and agent_owner_pubkey IS NULL
|
||||
// agent — row exists and agent_owner_pubkey IS NOT NULL
|
||||
// unknown — no row (u.pubkey IS NULL); not classified, reported separately
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT
|
||||
e.community_id,
|
||||
COUNT(DISTINCT e.pubkey)
|
||||
FILTER (WHERE u.pubkey IS NOT NULL AND u.agent_owner_pubkey IS NULL) AS human,
|
||||
COUNT(DISTINCT e.pubkey)
|
||||
FILTER (WHERE u.pubkey IS NOT NULL AND u.agent_owner_pubkey IS NOT NULL) AS agent,
|
||||
COUNT(DISTINCT e.pubkey)
|
||||
FILTER (WHERE u.pubkey IS NULL) AS unknown
|
||||
FROM events e
|
||||
LEFT JOIN users u
|
||||
ON u.community_id = e.community_id AND u.pubkey = e.pubkey
|
||||
WHERE e.created_at >= NOW() - INTERVAL '{interval_sql}'
|
||||
AND e.deleted_at IS NULL
|
||||
GROUP BY e.community_id
|
||||
"#
|
||||
);
|
||||
let rows = sqlx::query_as::<_, (Uuid, i64, i64, i64)>(sqlx::AssertSqlSafe(sql))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(community_id, human, agent, unknown)| CommunityActiveUsers {
|
||||
community_id,
|
||||
human,
|
||||
agent,
|
||||
unknown,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Per-community active-channel counts for a given window.
|
||||
#[derive(Debug)]
|
||||
pub struct CommunityActiveChannels {
|
||||
/// The UUID of the community.
|
||||
pub community_id: Uuid,
|
||||
/// Distinct channel IDs with ≥1 kind=9 message in the window.
|
||||
pub count: i64,
|
||||
}
|
||||
|
||||
/// Return distinct channel IDs with ≥1 kind=9 message in `[now - interval, now]`.
|
||||
pub async fn active_channel_counts(
|
||||
pool: &PgPool,
|
||||
interval_sql: &'static str,
|
||||
) -> Result<Vec<CommunityActiveChannels>> {
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT community_id, COUNT(DISTINCT channel_id) AS count
|
||||
FROM events
|
||||
WHERE kind = 9
|
||||
AND channel_id IS NOT NULL
|
||||
AND created_at >= NOW() - INTERVAL '{interval_sql}'
|
||||
AND deleted_at IS NULL
|
||||
GROUP BY community_id
|
||||
"#
|
||||
);
|
||||
let rows = sqlx::query_as::<_, (Uuid, i64)>(sqlx::AssertSqlSafe(sql))
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(community_id, count)| CommunityActiveChannels {
|
||||
community_id,
|
||||
count,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Mapping from community UUID to host string, used by the poller to resolve
|
||||
/// Prometheus label values.
|
||||
#[derive(Debug)]
|
||||
pub struct CommunityHost {
|
||||
/// The UUID of the community.
|
||||
pub id: Uuid,
|
||||
/// The canonical host string for this community (used as the Prometheus label value).
|
||||
pub host: String,
|
||||
}
|
||||
|
||||
/// Fetch all community id → host mappings in one query.
|
||||
pub async fn community_hosts(pool: &PgPool) -> Result<Vec<CommunityHost>> {
|
||||
let rows = sqlx::query_as::<_, (Uuid, String)>("SELECT id, host FROM communities")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|(id, host)| CommunityHost { id, host })
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use buzz_core::CommunityId;
|
||||
use nostr::Keys;
|
||||
use sqlx::PgPool;
|
||||
|
||||
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
|
||||
|
||||
async fn get_pool() -> PgPool {
|
||||
PgPool::connect(TEST_DB_URL)
|
||||
.await
|
||||
.expect("connect to test DB")
|
||||
}
|
||||
|
||||
fn random_pubkey() -> Vec<u8> {
|
||||
Keys::generate().public_key().to_bytes().to_vec()
|
||||
}
|
||||
|
||||
async fn make_community(pool: &PgPool) -> (Uuid, CommunityId, String) {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let host = format!("usage-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");
|
||||
(id, CommunityId::from_uuid(id), host)
|
||||
}
|
||||
|
||||
async fn insert_user(pool: &PgPool, community_id: Uuid, pubkey: &[u8], is_agent: bool) {
|
||||
if is_agent {
|
||||
let owner = random_pubkey();
|
||||
// Insert owner first (FK constraint).
|
||||
sqlx::query(
|
||||
"INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(&owner)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert owner");
|
||||
sqlx::query(
|
||||
"INSERT INTO users (community_id, pubkey, agent_owner_pubkey) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(pubkey)
|
||||
.bind(&owner)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert agent user");
|
||||
} else {
|
||||
sqlx::query(
|
||||
"INSERT INTO users (community_id, pubkey) VALUES ($1, $2) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(community_id)
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await
|
||||
.expect("insert human user");
|
||||
}
|
||||
}
|
||||
|
||||
/// user_counts returns correct human/agent split and is scoped per community.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_user_counts_scoped_per_community() {
|
||||
let pool = get_pool().await;
|
||||
let (comm_a_uuid, _, _) = make_community(&pool).await;
|
||||
let (comm_b_uuid, _, _) = make_community(&pool).await;
|
||||
|
||||
// Community A: insert 2 humans first, then 1 agent whose owner is one
|
||||
// of those humans (reuses existing pubkey — no extra human row).
|
||||
let human1 = random_pubkey();
|
||||
let human2 = random_pubkey();
|
||||
let agent_pk = random_pubkey();
|
||||
insert_user(&pool, comm_a_uuid, &human1, false).await;
|
||||
insert_user(&pool, comm_a_uuid, &human2, false).await;
|
||||
// Insert agent with human1 as owner (human1 is already in users).
|
||||
sqlx::query(
|
||||
"INSERT INTO users (community_id, pubkey, agent_owner_pubkey)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(comm_a_uuid)
|
||||
.bind(&agent_pk)
|
||||
.bind(&human1)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert agent user");
|
||||
|
||||
// Community B: 0 human, 1 agent (owner is a fresh human in comm_b).
|
||||
let owner_b = random_pubkey();
|
||||
insert_user(&pool, comm_b_uuid, &owner_b, false).await;
|
||||
let agent_b = random_pubkey();
|
||||
sqlx::query(
|
||||
"INSERT INTO users (community_id, pubkey, agent_owner_pubkey)
|
||||
VALUES ($1, $2, $3) ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(comm_b_uuid)
|
||||
.bind(&agent_b)
|
||||
.bind(&owner_b)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert agent user b");
|
||||
|
||||
let counts = user_counts(&pool).await.expect("user_counts");
|
||||
|
||||
let a = counts.iter().find(|r| r.community_id == comm_a_uuid);
|
||||
let b = counts.iter().find(|r| r.community_id == comm_b_uuid);
|
||||
|
||||
let a = a.expect("community A row");
|
||||
assert_eq!(a.human, 2, "community A: 2 humans");
|
||||
assert_eq!(a.agent, 1, "community A: 1 agent");
|
||||
|
||||
let b = b.expect("community B row");
|
||||
assert_eq!(b.human, 1, "community B: 1 human (the agent owner)");
|
||||
assert_eq!(b.agent, 1, "community B: 1 agent");
|
||||
}
|
||||
|
||||
/// Deactivated users are excluded from user_counts.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_user_counts_excludes_deactivated() {
|
||||
let pool = get_pool().await;
|
||||
let (comm_uuid, _, _) = make_community(&pool).await;
|
||||
|
||||
let active_pk = random_pubkey();
|
||||
let deactivated_pk = random_pubkey();
|
||||
|
||||
insert_user(&pool, comm_uuid, &active_pk, false).await;
|
||||
insert_user(&pool, comm_uuid, &deactivated_pk, false).await;
|
||||
// Deactivate the second user.
|
||||
sqlx::query(
|
||||
"UPDATE users SET deactivated_at = NOW() WHERE community_id = $1 AND pubkey = $2",
|
||||
)
|
||||
.bind(comm_uuid)
|
||||
.bind(&deactivated_pk)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("deactivate user");
|
||||
|
||||
let counts = user_counts(&pool).await.expect("user_counts");
|
||||
let row = counts
|
||||
.iter()
|
||||
.find(|r| r.community_id == comm_uuid)
|
||||
.expect("row");
|
||||
assert_eq!(row.human, 1, "only active user counted");
|
||||
}
|
||||
|
||||
/// channel_counts is scoped per community and excludes deleted channels.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_channel_counts_scoped_and_excludes_deleted() {
|
||||
let pool = get_pool().await;
|
||||
let (comm_uuid, comm_id, _) = make_community(&pool).await;
|
||||
let owner = random_pubkey();
|
||||
insert_user(&pool, comm_uuid, &owner, false).await;
|
||||
|
||||
// Insert a stream and a DM channel.
|
||||
sqlx::query(
|
||||
"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by)
|
||||
VALUES ($1, $2, 'test-stream', 'stream', 'open', $3)",
|
||||
)
|
||||
.bind(uuid::Uuid::new_v4())
|
||||
.bind(comm_uuid)
|
||||
.bind(&owner)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert stream channel");
|
||||
|
||||
let dm_id = uuid::Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by)
|
||||
VALUES ($1, $2, 'test-dm', 'dm', 'private', $3)",
|
||||
)
|
||||
.bind(dm_id)
|
||||
.bind(comm_uuid)
|
||||
.bind(&owner)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert dm channel");
|
||||
|
||||
// Soft-delete the DM.
|
||||
sqlx::query("UPDATE channels SET deleted_at = NOW() WHERE id = $1")
|
||||
.bind(dm_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("delete channel");
|
||||
|
||||
// Use comm_id to satisfy unused import warning.
|
||||
let _ = comm_id;
|
||||
|
||||
let counts = channel_counts(&pool).await.expect("channel_counts");
|
||||
let comm_counts: Vec<_> = counts
|
||||
.iter()
|
||||
.filter(|r| r.community_id == comm_uuid)
|
||||
.collect();
|
||||
|
||||
// Only the stream channel should be counted.
|
||||
assert_eq!(comm_counts.len(), 1);
|
||||
assert_eq!(comm_counts[0].channel_type, "stream");
|
||||
assert_eq!(comm_counts[0].count, 1);
|
||||
}
|
||||
|
||||
/// community_hosts returns id → host mapping.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_community_hosts_returns_mapping() {
|
||||
let pool = get_pool().await;
|
||||
let (id, _, host) = make_community(&pool).await;
|
||||
|
||||
let hosts = community_hosts(&pool).await.expect("community_hosts");
|
||||
let found = hosts.iter().find(|h| h.id == id);
|
||||
assert!(found.is_some(), "inserted community not found");
|
||||
assert_eq!(found.unwrap().host, host);
|
||||
}
|
||||
|
||||
/// community_count reflects newly inserted communities.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_community_count_increases() {
|
||||
let pool = get_pool().await;
|
||||
let before = community_count(&pool).await.expect("count before");
|
||||
make_community(&pool).await;
|
||||
let after = community_count(&pool).await.expect("count after");
|
||||
assert!(after > before, "count should increase after insert");
|
||||
}
|
||||
|
||||
/// git_repo_counts queries git_repo_names (not git_repos) and is scoped per community.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_git_repo_counts_scoped_per_community() {
|
||||
let pool = get_pool().await;
|
||||
let (comm_uuid, _, _) = make_community(&pool).await;
|
||||
let owner = random_pubkey();
|
||||
insert_user(&pool, comm_uuid, &owner, false).await;
|
||||
let owner_hex = hex::encode(&owner);
|
||||
|
||||
// Insert two repos for this community.
|
||||
for repo_id in &["repo-alpha", "repo-beta"] {
|
||||
sqlx::query(
|
||||
"INSERT INTO git_repo_names (community_id, repo_id, owner_pubkey)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(comm_uuid)
|
||||
.bind(repo_id)
|
||||
.bind(&owner_hex)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert git repo");
|
||||
}
|
||||
|
||||
let counts = git_repo_counts(&pool).await.expect("git_repo_counts");
|
||||
let comm_counts: Vec<_> = counts
|
||||
.iter()
|
||||
.filter(|r| r.community_id == comm_uuid)
|
||||
.collect();
|
||||
|
||||
assert_eq!(comm_counts.len(), 1, "one row per community");
|
||||
assert_eq!(comm_counts[0].count, 2, "two repos");
|
||||
}
|
||||
|
||||
/// active_user_counts classifies pubkeys with no users row as "unknown",
|
||||
/// not "human" — the old LEFT JOIN treated NULL.agent_owner_pubkey as human.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_active_user_counts_unknown_bucket_for_profileless_poster() {
|
||||
let pool = get_pool().await;
|
||||
let (comm_uuid, _, _) = make_community(&pool).await;
|
||||
|
||||
// One known human (has a users row).
|
||||
let human_pk = random_pubkey();
|
||||
insert_user(&pool, comm_uuid, &human_pk, false).await;
|
||||
|
||||
// One profileless poster (no users row at all).
|
||||
let profileless_pk = random_pubkey();
|
||||
|
||||
// Insert events for both pubkeys in this community.
|
||||
let event_id1 = random_pubkey(); // 32-byte id
|
||||
let event_id2 = random_pubkey();
|
||||
let sig = vec![0u8; 64];
|
||||
for (pk, eid) in [(&human_pk, &event_id1), (&profileless_pk, &event_id2)] {
|
||||
sqlx::query(
|
||||
"INSERT INTO events \
|
||||
(community_id, id, pubkey, created_at, kind, tags, content, sig, received_at) \
|
||||
VALUES ($1, $2, $3, NOW(), 9, '[]', '', $4, NOW()) \
|
||||
ON CONFLICT DO NOTHING",
|
||||
)
|
||||
.bind(comm_uuid)
|
||||
.bind(eid)
|
||||
.bind(pk)
|
||||
.bind(&sig)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert event");
|
||||
}
|
||||
|
||||
let counts = active_user_counts(&pool, "1 day")
|
||||
.await
|
||||
.expect("active_user_counts");
|
||||
let row = counts.iter().find(|r| r.community_id == comm_uuid);
|
||||
assert!(row.is_some(), "row for community must exist");
|
||||
let row = row.unwrap();
|
||||
assert_eq!(row.human, 1, "known human poster counts as human");
|
||||
assert_eq!(row.agent, 0, "no agents");
|
||||
assert_eq!(
|
||||
row.unknown, 1,
|
||||
"profileless poster must land in unknown, not human"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: channel_counts returns no row for a community once all
|
||||
/// channels of a type are soft-deleted. The poller zero-fills from
|
||||
/// host_map, so absence from this query is the correct "zero" signal.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_channel_counts_drops_to_zero_after_last_channel_deleted() {
|
||||
let pool = get_pool().await;
|
||||
let (comm_uuid, _, _) = make_community(&pool).await;
|
||||
let owner = random_pubkey();
|
||||
insert_user(&pool, comm_uuid, &owner, false).await;
|
||||
|
||||
// Insert one stream channel.
|
||||
let ch_id = uuid::Uuid::new_v4();
|
||||
sqlx::query(
|
||||
"INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by)
|
||||
VALUES ($1, $2, 'only-stream', 'stream', 'open', $3)",
|
||||
)
|
||||
.bind(ch_id)
|
||||
.bind(comm_uuid)
|
||||
.bind(&owner)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("insert channel");
|
||||
|
||||
// Sanity: row present before deletion.
|
||||
let before = channel_counts(&pool).await.expect("channel_counts before");
|
||||
let before_row = before
|
||||
.iter()
|
||||
.find(|r| r.community_id == comm_uuid && r.channel_type == "stream");
|
||||
assert_eq!(
|
||||
before_row.map(|r| r.count),
|
||||
Some(1),
|
||||
"1 stream channel before deletion"
|
||||
);
|
||||
|
||||
// Soft-delete the channel.
|
||||
sqlx::query("UPDATE channels SET deleted_at = NOW() WHERE id = $1")
|
||||
.bind(ch_id)
|
||||
.execute(&pool)
|
||||
.await
|
||||
.expect("soft-delete channel");
|
||||
|
||||
// After deletion: no row for this community+type — query returns nothing.
|
||||
let after = channel_counts(&pool).await.expect("channel_counts after");
|
||||
let after_row = after
|
||||
.iter()
|
||||
.find(|r| r.community_id == comm_uuid && r.channel_type == "stream");
|
||||
assert!(
|
||||
after_row.is_none(),
|
||||
"no stream row after last channel deleted — poller will zero-fill"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
//! User CRUD operations.
|
||||
|
||||
use crate::error::Result;
|
||||
use buzz_core::CommunityId;
|
||||
use sqlx::PgPool;
|
||||
use sqlx::Row;
|
||||
|
||||
/// A user's profile fields.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserProfile {
|
||||
/// Raw 32-byte compressed public key.
|
||||
pub pubkey: Vec<u8>,
|
||||
/// Human-readable display name chosen by the user.
|
||||
pub display_name: Option<String>,
|
||||
/// URL of the user's avatar image.
|
||||
pub avatar_url: Option<String>,
|
||||
/// Short bio or description provided by the user.
|
||||
pub about: Option<String>,
|
||||
/// NIP-05 identifier (user@domain).
|
||||
pub nip05_handle: Option<String>,
|
||||
}
|
||||
|
||||
/// Lightweight user record returned from search.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserSearchProfile {
|
||||
/// Raw 32-byte compressed public key.
|
||||
pub pubkey: Vec<u8>,
|
||||
/// Human-readable display name chosen by the user.
|
||||
pub display_name: Option<String>,
|
||||
/// URL of the user's avatar image.
|
||||
pub avatar_url: Option<String>,
|
||||
/// NIP-05 identifier (user@domain).
|
||||
pub nip05_handle: Option<String>,
|
||||
}
|
||||
|
||||
/// Ensure a user record exists for the given pubkey (upsert).
|
||||
/// Creates with minimal fields if not present; no-op if already exists.
|
||||
///
|
||||
/// Returns `true` if a new row was inserted, `false` if the user already existed.
|
||||
/// The `true` case is the reliable signal for "user was just registered" — used
|
||||
/// by callers to increment `buzz_users_created_total`.
|
||||
pub async fn ensure_user(pool: &PgPool, community_id: CommunityId, pubkey: &[u8]) -> Result<bool> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
INSERT INTO users (community_id, pubkey)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT DO NOTHING
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(result.rows_affected() == 1)
|
||||
}
|
||||
|
||||
/// Get a single user record by pubkey.
|
||||
pub async fn get_user(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
pubkey: &[u8],
|
||||
) -> Result<Option<UserProfile>> {
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
Vec<u8>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
SELECT pubkey, display_name, avatar_url, about, nip05_handle
|
||||
FROM users
|
||||
WHERE community_id = $1 AND pubkey = $2
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(
|
||||
|(pubkey, display_name, avatar_url, about, nip05_handle)| UserProfile {
|
||||
pubkey,
|
||||
display_name,
|
||||
avatar_url,
|
||||
about,
|
||||
nip05_handle,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Update a user's profile fields (display_name, avatar_url, about, nip05_handle).
|
||||
/// Only updates fields that are Some -- None fields are left unchanged.
|
||||
/// At least one field must be Some, otherwise returns Ok(()) without touching the DB.
|
||||
///
|
||||
/// Empty strings are treated as "clear to NULL" -- this is important for kind:0
|
||||
/// absolute-state semantics where absent fields must be cleared, and for the
|
||||
/// `nip05_handle` column which has a UNIQUE constraint (multiple NULLs are allowed,
|
||||
/// but multiple empty strings would violate uniqueness).
|
||||
pub async fn update_user_profile(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
pubkey: &[u8],
|
||||
display_name: Option<&str>,
|
||||
avatar_url: Option<&str>,
|
||||
about: Option<&str>,
|
||||
nip05_handle: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let mut set_parts: Vec<String> = Vec::new();
|
||||
let mut param_idx = 1u32;
|
||||
|
||||
if display_name.is_some() {
|
||||
set_parts.push(format!("display_name = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if avatar_url.is_some() {
|
||||
set_parts.push(format!("avatar_url = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if about.is_some() {
|
||||
set_parts.push(format!("about = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
if nip05_handle.is_some() {
|
||||
set_parts.push(format!("nip05_handle = ${param_idx}"));
|
||||
param_idx += 1;
|
||||
}
|
||||
|
||||
if set_parts.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Helper: convert empty string to None (NULL in DB). This ensures UNIQUE
|
||||
// columns like nip05_handle don't collide on empty strings, and keeps
|
||||
// semantics clean: absent profile data is NULL, not "".
|
||||
fn empty_to_none(val: Option<&str>) -> Option<&str> {
|
||||
val.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE users SET {} WHERE community_id = ${param_idx} AND pubkey = ${}",
|
||||
set_parts.join(", "),
|
||||
param_idx + 1
|
||||
);
|
||||
let mut query = sqlx::query(sqlx::AssertSqlSafe(sql));
|
||||
if display_name.is_some() {
|
||||
query = query.bind(empty_to_none(display_name));
|
||||
}
|
||||
if avatar_url.is_some() {
|
||||
query = query.bind(empty_to_none(avatar_url));
|
||||
}
|
||||
if about.is_some() {
|
||||
query = query.bind(empty_to_none(about));
|
||||
}
|
||||
if nip05_handle.is_some() {
|
||||
query = query.bind(empty_to_none(nip05_handle));
|
||||
}
|
||||
query = query.bind(community_id.as_uuid());
|
||||
query = query.bind(pubkey);
|
||||
query.execute(pool).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Look up a user by their full NIP-05 handle (exact match, case-insensitive).
|
||||
/// Both `local_part` and `domain` must already be lowercased by the caller.
|
||||
pub async fn get_user_by_nip05(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
local_part: &str,
|
||||
domain: &str,
|
||||
) -> Result<Option<UserProfile>> {
|
||||
let handle = format!("{}@{}", local_part, domain);
|
||||
let row = sqlx::query_as::<
|
||||
_,
|
||||
(
|
||||
Vec<u8>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
),
|
||||
>(
|
||||
r#"
|
||||
SELECT pubkey, display_name, avatar_url, about, nip05_handle
|
||||
FROM users
|
||||
WHERE community_id = $1 AND LOWER(nip05_handle) = LOWER($2)
|
||||
LIMIT 1
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(&handle)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(
|
||||
|(pubkey, display_name, avatar_url, about, nip05_handle)| UserProfile {
|
||||
pubkey,
|
||||
display_name,
|
||||
avatar_url,
|
||||
about,
|
||||
nip05_handle,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// Escape SQL LIKE metacharacters (`%`, `_`, `\`) so user input is treated
|
||||
/// as literal text. Used with `ESCAPE '\'` in the query.
|
||||
///
|
||||
/// Without this, a search query of `"%"` would match every row (full table
|
||||
/// scan) and `"_"` would act as a single-character wildcard.
|
||||
fn escape_like(input: &str) -> String {
|
||||
input
|
||||
.replace('\\', "\\\\")
|
||||
.replace('%', "\\%")
|
||||
.replace('_', "\\_")
|
||||
}
|
||||
|
||||
/// Search users by display name, NIP-05 handle, or pubkey prefix.
|
||||
///
|
||||
/// Empty queries return an empty vec and do not hit the database.
|
||||
pub async fn search_users(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
query: &str,
|
||||
limit: u32,
|
||||
) -> Result<Vec<UserSearchProfile>> {
|
||||
let normalized = query.trim().to_lowercase();
|
||||
if normalized.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let escaped = escape_like(&normalized);
|
||||
let contains_pattern = format!("%{escaped}%");
|
||||
let prefix_pattern = format!("{escaped}%");
|
||||
let limit = limit.clamp(1, 500) as i64;
|
||||
|
||||
let rows = sqlx::query_as::<_, (Vec<u8>, Option<String>, Option<String>, Option<String>)>(
|
||||
r#"
|
||||
SELECT pubkey, display_name, avatar_url, nip05_handle
|
||||
FROM users
|
||||
WHERE community_id = $1
|
||||
AND (LOWER(COALESCE(display_name, '')) LIKE $2 ESCAPE '\'
|
||||
OR LOWER(COALESCE(nip05_handle, '')) LIKE $2 ESCAPE '\'
|
||||
OR LOWER(encode(pubkey, 'hex')) LIKE $2 ESCAPE '\')
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN LOWER(COALESCE(display_name, '')) = $3 THEN 0
|
||||
WHEN LOWER(COALESCE(nip05_handle, '')) = $3 THEN 1
|
||||
WHEN LOWER(encode(pubkey, 'hex')) = $3 THEN 2
|
||||
WHEN LOWER(COALESCE(display_name, '')) LIKE $4 ESCAPE '\' THEN 3
|
||||
WHEN LOWER(COALESCE(nip05_handle, '')) LIKE $4 ESCAPE '\' THEN 4
|
||||
WHEN LOWER(encode(pubkey, 'hex')) LIKE $4 ESCAPE '\' THEN 5
|
||||
ELSE 6
|
||||
END,
|
||||
COALESCE(NULLIF(display_name, ''), NULLIF(nip05_handle, ''), LOWER(encode(pubkey, 'hex')))
|
||||
LIMIT $5
|
||||
"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(&contains_pattern)
|
||||
.bind(&normalized)
|
||||
.bind(&prefix_pattern)
|
||||
.bind(limit)
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(
|
||||
|(pubkey, display_name, avatar_url, nip05_handle)| UserSearchProfile {
|
||||
pubkey,
|
||||
display_name,
|
||||
avatar_url,
|
||||
nip05_handle,
|
||||
},
|
||||
)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Set the owner pubkey for an agent user.
|
||||
/// The owner pubkey must already exist in the users table (FK constraint).
|
||||
/// Returns an error if the agent pubkey is not found (rows_affected == 0).
|
||||
/// Atomically set agent owner — only if no owner is currently assigned.
|
||||
///
|
||||
/// Returns Ok(true) if ownership was set, Ok(false) if an owner already exists
|
||||
/// (caller should check whether the existing owner matches). Returns Err if the
|
||||
/// agent pubkey doesn't exist in the users table.
|
||||
pub async fn set_agent_owner(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
agent_pubkey: &[u8],
|
||||
owner_pubkey: &[u8],
|
||||
) -> Result<bool> {
|
||||
// Conditional UPDATE: only set owner if currently NULL. This makes
|
||||
// "first mint wins" atomic — no TOCTOU race between concurrent mints.
|
||||
let result = sqlx::query(
|
||||
r#"UPDATE users SET agent_owner_pubkey = $1 WHERE community_id = $2 AND pubkey = $3 AND agent_owner_pubkey IS NULL"#,
|
||||
)
|
||||
.bind(owner_pubkey)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(agent_pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
if result.rows_affected() == 0 {
|
||||
// Could be: (a) pubkey not found, or (b) owner already set.
|
||||
// Check which case by querying the row.
|
||||
let exists = sqlx::query(r#"SELECT 1 FROM users WHERE community_id = $1 AND pubkey = $2"#)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(agent_pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
if exists.is_none() {
|
||||
return Err(crate::error::DbError::NotFound(
|
||||
"agent pubkey not found in users table".into(),
|
||||
));
|
||||
}
|
||||
// Row exists but owner already set — return false (not an error).
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Get the channel_add_policy and agent_owner_pubkey for a user.
|
||||
/// Returns None if the pubkey is not in the users table.
|
||||
/// Returns Some((policy_str, owner_bytes_or_none)) if found.
|
||||
pub async fn get_agent_channel_policy(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
pubkey: &[u8],
|
||||
) -> Result<Option<(String, Option<Vec<u8>>)>> {
|
||||
let row = sqlx::query(
|
||||
r#"SELECT channel_add_policy::text AS channel_add_policy, agent_owner_pubkey FROM users WHERE community_id = $1 AND pubkey = $2"#,
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
row.map(|r| -> Result<(String, Option<Vec<u8>>)> {
|
||||
let policy: String = r.try_get("channel_add_policy")?;
|
||||
let owner: Option<Vec<u8>> = r.try_get("agent_owner_pubkey").unwrap_or(None);
|
||||
Ok((policy, owner))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Check whether `actor_pubkey` is the `agent_owner_pubkey` of `target_pubkey`.
|
||||
/// Queries `agent_owner_pubkey` directly rather than going through
|
||||
/// `get_agent_channel_policy`, which would fetch unrelated fields.
|
||||
pub async fn is_agent_owner(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
target_pubkey: &[u8],
|
||||
actor_pubkey: &[u8],
|
||||
) -> Result<bool> {
|
||||
let row = sqlx::query_scalar::<_, bool>(
|
||||
"SELECT agent_owner_pubkey = $3 FROM users WHERE community_id = $1 AND pubkey = $2 AND agent_owner_pubkey IS NOT NULL",
|
||||
)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(target_pubkey)
|
||||
.bind(actor_pubkey)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
Ok(row.unwrap_or(false))
|
||||
}
|
||||
|
||||
/// Set the channel_add_policy for a user.
|
||||
/// Returns an error if the pubkey is not found (rows_affected == 0).
|
||||
/// Returns an error if `policy` is not one of the valid ENUM values.
|
||||
pub async fn set_channel_add_policy(
|
||||
pool: &PgPool,
|
||||
community_id: CommunityId,
|
||||
pubkey: &[u8],
|
||||
policy: &str,
|
||||
) -> Result<()> {
|
||||
if !matches!(policy, "anyone" | "owner_only" | "nobody") {
|
||||
return Err(crate::error::DbError::InvalidData(format!(
|
||||
"invalid channel_add_policy: {policy}"
|
||||
)));
|
||||
}
|
||||
let result = sqlx::query(
|
||||
r#"UPDATE users SET channel_add_policy = $1::channel_add_policy WHERE community_id = $2 AND pubkey = $3"#,
|
||||
)
|
||||
.bind(policy)
|
||||
.bind(community_id.as_uuid())
|
||||
.bind(pubkey)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 0 {
|
||||
return Err(crate::error::DbError::NotFound(
|
||||
"pubkey not found in users table".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::Db;
|
||||
use nostr::Keys;
|
||||
|
||||
const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz";
|
||||
|
||||
async fn setup_db() -> Db {
|
||||
let pool = PgPool::connect(TEST_DB_URL)
|
||||
.await
|
||||
.expect("connect to test DB");
|
||||
Db::from_pool(pool)
|
||||
}
|
||||
|
||||
fn random_pubkey() -> Vec<u8> {
|
||||
Keys::generate().public_key().to_bytes().to_vec()
|
||||
}
|
||||
|
||||
async fn make_community(pool: &PgPool) -> CommunityId {
|
||||
let id = uuid::Uuid::new_v4();
|
||||
let host = format!("user-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)
|
||||
}
|
||||
|
||||
/// Setting an agent owner then reading back the policy should return
|
||||
/// the default "anyone" policy and the owner pubkey.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_set_agent_owner_and_get_policy() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let agent_pk = random_pubkey();
|
||||
let owner_pk = random_pubkey();
|
||||
|
||||
ensure_user(&db.pool, community, &agent_pk)
|
||||
.await
|
||||
.expect("ensure agent");
|
||||
ensure_user(&db.pool, community, &owner_pk)
|
||||
.await
|
||||
.expect("ensure owner");
|
||||
|
||||
let was_set = set_agent_owner(&db.pool, community, &agent_pk, &owner_pk)
|
||||
.await
|
||||
.expect("set_agent_owner");
|
||||
assert!(was_set, "first set_agent_owner should return true");
|
||||
|
||||
let result = get_agent_channel_policy(&db.pool, community, &agent_pk)
|
||||
.await
|
||||
.expect("get_agent_channel_policy");
|
||||
|
||||
let (policy, owner) = result.expect("should return Some for known pubkey");
|
||||
assert_eq!(policy, "anyone", "default policy should be 'anyone'");
|
||||
assert_eq!(
|
||||
owner,
|
||||
Some(owner_pk),
|
||||
"owner pubkey should match what was set"
|
||||
);
|
||||
}
|
||||
|
||||
/// set_channel_add_policy should persist each of the three valid policies.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_set_channel_add_policy() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let pk = random_pubkey();
|
||||
ensure_user(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("ensure user");
|
||||
|
||||
// owner_only
|
||||
set_channel_add_policy(&db.pool, community, &pk, "owner_only")
|
||||
.await
|
||||
.expect("set owner_only");
|
||||
let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("get policy")
|
||||
.expect("should be Some");
|
||||
assert_eq!(policy, "owner_only");
|
||||
assert!(owner.is_none(), "no owner was set");
|
||||
|
||||
// nobody
|
||||
set_channel_add_policy(&db.pool, community, &pk, "nobody")
|
||||
.await
|
||||
.expect("set nobody");
|
||||
let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("get policy")
|
||||
.expect("should be Some");
|
||||
assert_eq!(policy, "nobody");
|
||||
assert!(owner.is_none());
|
||||
|
||||
// anyone (reset to default)
|
||||
set_channel_add_policy(&db.pool, community, &pk, "anyone")
|
||||
.await
|
||||
.expect("set anyone");
|
||||
let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("get policy")
|
||||
.expect("should be Some");
|
||||
assert_eq!(policy, "anyone");
|
||||
assert!(owner.is_none());
|
||||
}
|
||||
|
||||
/// get_agent_channel_policy should return None for a pubkey that has
|
||||
/// never been inserted into the users table.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_get_policy_unknown_pubkey() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let pk = random_pubkey();
|
||||
|
||||
let result = get_agent_channel_policy(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("query should not error");
|
||||
|
||||
assert!(result.is_none(), "unknown pubkey should return None");
|
||||
}
|
||||
|
||||
/// set_agent_owner should return Err when the agent pubkey does not exist
|
||||
/// in the users table.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_set_agent_owner_nonexistent_agent() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let agent_pk = random_pubkey();
|
||||
let owner_pk = random_pubkey();
|
||||
|
||||
// Only ensure the owner exists -- agent is intentionally absent.
|
||||
ensure_user(&db.pool, community, &owner_pk)
|
||||
.await
|
||||
.expect("ensure owner");
|
||||
|
||||
let result = set_agent_owner(&db.pool, community, &agent_pk, &owner_pk).await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"should error when agent pubkey is not in users table"
|
||||
);
|
||||
}
|
||||
|
||||
/// set_agent_owner should return Ok(false) when the agent already has an owner.
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_set_agent_owner_already_owned() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let agent_pk = random_pubkey();
|
||||
let owner1 = random_pubkey();
|
||||
let owner2 = random_pubkey();
|
||||
|
||||
ensure_user(&db.pool, community, &agent_pk)
|
||||
.await
|
||||
.expect("ensure agent");
|
||||
ensure_user(&db.pool, community, &owner1)
|
||||
.await
|
||||
.expect("ensure owner1");
|
||||
ensure_user(&db.pool, community, &owner2)
|
||||
.await
|
||||
.expect("ensure owner2");
|
||||
|
||||
let first = set_agent_owner(&db.pool, community, &agent_pk, &owner1)
|
||||
.await
|
||||
.expect("first set");
|
||||
assert!(first, "first set should succeed");
|
||||
|
||||
let second = set_agent_owner(&db.pool, community, &agent_pk, &owner2)
|
||||
.await
|
||||
.expect("second set should not error");
|
||||
assert!(!second, "second set should return false (already owned)");
|
||||
|
||||
// Verify original owner is preserved.
|
||||
let (_, owner) = get_agent_channel_policy(&db.pool, community, &agent_pk)
|
||||
.await
|
||||
.expect("get policy")
|
||||
.expect("should be Some");
|
||||
assert_eq!(owner, Some(owner1), "original owner should be preserved");
|
||||
}
|
||||
|
||||
/// set_channel_add_policy should return Err when the pubkey does not exist
|
||||
/// in the users table (0 rows affected -> NotFound).
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_set_channel_add_policy_nonexistent_user() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let pk = random_pubkey();
|
||||
|
||||
let result = set_channel_add_policy(&db.pool, community, &pk, "nobody").await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"should error when pubkey is not in users table"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_set_channel_add_policy_rejects_invalid() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let pubkey = nostr::Keys::generate().public_key().to_bytes().to_vec();
|
||||
ensure_user(&db.pool, community, &pubkey).await.unwrap();
|
||||
let result = set_channel_add_policy(&db.pool, community, &pubkey, "invalid_policy").await;
|
||||
assert!(result.is_err(), "should reject invalid policy value");
|
||||
}
|
||||
|
||||
// Use the production `escape_like` function directly — no local mirror.
|
||||
use super::escape_like;
|
||||
|
||||
#[test]
|
||||
fn like_escape_percent() {
|
||||
assert_eq!(escape_like("%"), "\\%");
|
||||
assert_eq!(escape_like("100%match"), "100\\%match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn like_escape_underscore() {
|
||||
assert_eq!(escape_like("_"), "\\_");
|
||||
assert_eq!(escape_like("a_b"), "a\\_b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn like_escape_backslash() {
|
||||
assert_eq!(escape_like("\\"), "\\\\");
|
||||
assert_eq!(escape_like("a\\b"), "a\\\\b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn like_escape_combined() {
|
||||
// All three metacharacters in one string
|
||||
assert_eq!(escape_like("%_\\"), "\\%\\_\\\\");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn like_escape_normal_input_unchanged() {
|
||||
assert_eq!(escape_like("alice"), "alice");
|
||||
assert_eq!(escape_like("bob@example.com"), "bob@example.com");
|
||||
assert_eq!(escape_like(""), "");
|
||||
}
|
||||
|
||||
/// A user with "owner_only" policy but no agent_owner_pubkey set should
|
||||
/// return Some(("owner_only", None)).
|
||||
#[tokio::test]
|
||||
#[ignore = "requires Postgres"]
|
||||
async fn test_owner_only_with_no_owner() {
|
||||
let db = setup_db().await;
|
||||
let community = make_community(&db.pool).await;
|
||||
let pk = random_pubkey();
|
||||
ensure_user(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("ensure user");
|
||||
|
||||
set_channel_add_policy(&db.pool, community, &pk, "owner_only")
|
||||
.await
|
||||
.expect("set owner_only");
|
||||
|
||||
let result = get_agent_channel_policy(&db.pool, community, &pk)
|
||||
.await
|
||||
.expect("get policy")
|
||||
.expect("should be Some");
|
||||
|
||||
assert_eq!(result.0, "owner_only");
|
||||
assert!(result.1.is_none(), "owner should be None when never set");
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user