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

Signed-off-by: cls_宁波本机 <908705107@qq.com>
This commit is contained in:
2026-08-13 18:34:25 +08:00
parent 61c3fa1df9
commit 9dfa06ffee
3785 changed files with 1085458 additions and 2 deletions
+28
View File
@@ -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 }
+488
View File
@@ -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;
}
}
+522
View File
@@ -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);
}
}
+221
View File
@@ -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
+557
View File
@@ -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);
}
}
+54
View File
@@ -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 2000029999), 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
+889
View File
@@ -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");
}
}
+380
View File
@@ -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
+894
View File
@@ -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"
);
}
}
+182
View File
@@ -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"));
}
}
+188
View File
@@ -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
+418
View File
@@ -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)
}
+671
View File
@@ -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
+722
View File
@@ -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"
);
}
}
+673
View File
@@ -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