mirror of
https://github.com/block/buzz.git
synced 2026-10-30 22:11:36 +00:00
## Why Database pressure currently collapses several distinct delays into one symptom. This adds the evidence layer needed to distinguish pool acquisition wait, logical database operation time, advisory-lock wait, and selected transaction duration before changing timeout or retry policy. This is the phase 2 Lane A observability bundle for [#26](https://github.com/TheSentinel454/buzz/issues/26), [#28](https://github.com/TheSentinel454/buzz/issues/28), and [#33](https://github.com/TheSentinel454/buzz/issues/33). It is stacked on #6668. ## What - Record explicit reader/writer checkout wait and acquisition outcomes with `buzz_db_pool_acquire_wait_seconds` and `buzz_db_pool_acquisitions_total`. - Extend the compile-time `#[datastore_span(name = "...")]` seam with `buzz_db_operation_duration_seconds`, so operation labels remain static source literals instead of request data. - Route correctness-critical replacement, membership, push-gate, deletion, and migration/schema-safety advisory locks through one observer without changing their SQL, order, scope, or blocking behavior. - Measure six internally owned transaction lifetimes with `buzz_db_transaction_duration_seconds`, starting after `BEGIN` succeeds and ending after explicit commit/rollback or scope exit. - Emit root slow-operation warnings at 500 ms, logging the first slow completion and then 1/100 per call site with only `operation`, `outcome`, and `elapsed_ms`. - Document names, units, fixed label vocabularies, measurement boundaries, and blind spots in this PR description. Fixed labels are deliberately small: - `pool_role`: `writer`, `reader` - `lock_type`: `replacement`, `membership`, `push_gate`, `deletion`, `migration_schema_safety` - `outcome`: `success`, `error`, `timeout` where SQLx/PostgreSQL can distinguish it accurately - `operation`: compile-time datastore names plus the six closed transaction operation names documented in the runbook No metric or slow warning contains community IDs, event IDs, event kinds, coordinates, d-tags, SQL/query text, query IDs, returned errors, or event content. ## Coverage boundaries - Operation duration is the complete annotated logical function body, not pure SQL execution; it may include implicit checkout, lock wait, nested operations, and application work. Cancelled futures do not reach its completion hook. - Pool timing covers explicit helper checkouts, including proved-reader routing and selected writer-owned transactions. Implicit SQLx checkout through `&PgPool` remains folded into operation duration. - Lock timing covers application-side blocking locks in the five named families. Trigger/stored-procedure locks, channel-TTL locking, the usage try-lock, and the audit service session lock remain outside this slice. - Transaction timing covers only the six wholly owned boundaries documented in the runbook. It excludes pool wait, `BEGIN`, asynchronous rollback cleanup after an early return, and caller-owned `Db::begin_transaction` lifetime. ## Relationship to #6229 #6229 is the incident-driven timeout precursor. This PR does not add or change `statement_timeout`, `lock_timeout`, `idle_in_transaction_session_timeout`, retries, audit durability, or client-visible conflicts. It provides the missing distributions needed to evaluate those policies later and intentionally leaves #6229's open audit retry/durability finding untouched. The branches overlap in `crates/buzz-db/src/lib.rs` and `crates/buzz-db/src/migration.rs`, so a later rebase may need textual conflict resolution, but the behavior is complementary rather than duplicated. ## Risk assessment Moderate-low. The primary risk is instrumentation overhead and added static series. Cardinality is source-bounded, slow logs are sampled/redacted root events, and the lock/transaction changes wrap existing awaits without changing policy or ordering. ## Verification Author workstation: `buzz-tornquist-db-pressure-observability` (`2010927`), exact head `d7cf833e26c528adfcde3917ded80daf6f4ddac9`, parent `6f50e6b2b2a996349149af61d35bdd6a355f77fd`. - `cargo fmt --all --check` — passed - `cargo clippy -p buzz-datastore-tracing -p buzz-db -p buzz-audit -p buzz-search -p buzz-relay --all-targets -- -D warnings` — passed - `cargo test -p buzz-datastore-tracing --quiet` — 4 passed - `cargo test -p buzz-db --quiet` — 109 passed, 200 ignored - `cargo test -p buzz-audit -p buzz-search --quiet` — 16 passed, 25 ignored - `cargo test -p buzz-relay --lib --quiet -- --test-threads=1` — 906 passed, 48 ignored - Native PostgreSQL focused tests for pool success/timeout/error, lock success/contention/timeout/error, replacement, membership serialization, push ordering, deletion fencing, migration/schema exclusion, and reader fallback — 8 passed The default-parallel relay run passed once; subsequent runs exposed the existing load-sensitive `api::mesh_demo::tests::demo_join_forwarded_arm_round_trips_echo` 504 at the end of the suite. That test passes in isolation and the full relay suite passes serially. Independent exact-head review workstation: `buzz-tornquist-db-pressure-observability-review` (`2013067`). Formatting, the same all-target clippy command, datastore instrumentation tests, DB unit tests, source privacy guards, and diff/non-goal audits passed; no review findings. Generated with Codex --------- Signed-off-by: tornquist <tornquist@squareup.com>
3426 lines
120 KiB
Rust
3426 lines
120 KiB
Rust
//! Channel CRUD and membership management.
|
|
//!
|
|
//! Channels have two visibility modes:
|
|
//! - `open`: searchable, anyone can join
|
|
//! - `private`: hidden, invite-only
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use sqlx::{PgPool, Postgres, Row, Transaction};
|
|
use uuid::Uuid;
|
|
|
|
use crate::error::{DbError, Result};
|
|
use buzz_core::CommunityId;
|
|
|
|
// Re-export the canonical enum definitions from buzz-core.
|
|
// These live in core (zero I/O deps) so the SDK can share them
|
|
// without pulling in sqlx/tokio.
|
|
pub use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole};
|
|
|
|
/// A channel row as returned from the database.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ChannelRecord {
|
|
/// Unique channel identifier.
|
|
pub id: Uuid,
|
|
/// Human-readable channel name.
|
|
pub name: String,
|
|
/// Channel type string (e.g. `"stream"`, `"forum"`, `"dm"`).
|
|
pub channel_type: String,
|
|
/// Visibility string (`"open"` or `"private"`).
|
|
pub visibility: String,
|
|
/// Optional channel description.
|
|
pub description: Option<String>,
|
|
/// Optional canvas (rich document) content.
|
|
pub canvas: Option<String>,
|
|
/// Compressed public key bytes of the channel creator.
|
|
pub created_by: Vec<u8>,
|
|
/// When the channel was created.
|
|
pub created_at: DateTime<Utc>,
|
|
/// When the channel was last updated.
|
|
pub updated_at: DateTime<Utc>,
|
|
/// When the channel was archived, if applicable.
|
|
pub archived_at: Option<DateTime<Utc>>,
|
|
/// When the channel was soft-deleted, if applicable.
|
|
pub deleted_at: Option<DateTime<Utc>>,
|
|
/// NIP-29 group ID for external Nostr clients.
|
|
pub nip29_group_id: Option<String>,
|
|
/// Whether posts must be associated with a topic.
|
|
pub topic_required: bool,
|
|
/// Optional cap on the number of members.
|
|
pub max_members: Option<i32>,
|
|
/// Current channel topic (short, visible in header).
|
|
pub topic: Option<String>,
|
|
/// Compressed public key bytes of the user who last set the topic.
|
|
pub topic_set_by: Option<Vec<u8>>,
|
|
/// When the topic was last set.
|
|
pub topic_set_at: Option<DateTime<Utc>>,
|
|
/// Channel purpose / description of intent.
|
|
pub purpose: Option<String>,
|
|
/// Compressed public key bytes of the user who last set the purpose.
|
|
pub purpose_set_by: Option<Vec<u8>>,
|
|
/// When the purpose was last set.
|
|
pub purpose_set_at: Option<DateTime<Utc>>,
|
|
/// TTL in seconds for ephemeral channels. `None` means permanent.
|
|
pub ttl_seconds: Option<i32>,
|
|
/// Deadline by which a new message must arrive or the channel is auto-archived.
|
|
pub ttl_deadline: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
/// A channel membership row as returned from the database.
|
|
#[derive(Debug, Clone)]
|
|
pub struct MemberRecord {
|
|
/// The channel this membership belongs to.
|
|
pub channel_id: Uuid,
|
|
/// Compressed public key bytes of the member.
|
|
pub pubkey: Vec<u8>,
|
|
/// Role string (e.g. `"owner"`, `"member"`, `"bot"`).
|
|
pub role: String,
|
|
/// When the member joined.
|
|
pub joined_at: DateTime<Utc>,
|
|
/// Who invited this member, if applicable.
|
|
pub invited_by: Option<Vec<u8>>,
|
|
/// When the member was removed, if applicable.
|
|
pub removed_at: Option<DateTime<Utc>>,
|
|
}
|
|
|
|
/// Creates a new channel, bootstraps the creator as owner, and returns the record.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn create_channel(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
name: &str,
|
|
channel_type: ChannelType,
|
|
visibility: ChannelVisibility,
|
|
description: Option<&str>,
|
|
created_by: &[u8],
|
|
ttl_seconds: Option<i32>,
|
|
) -> Result<ChannelRecord> {
|
|
if created_by.len() != 32 {
|
|
return Err(DbError::InvalidData(format!(
|
|
"pubkey must be 32 bytes, got {}",
|
|
created_by.len()
|
|
)));
|
|
}
|
|
|
|
let name = buzz_core::channel::canonical_channel_name(name);
|
|
if name.trim().is_empty() {
|
|
return Err(DbError::InvalidData("channel name is required".into()));
|
|
}
|
|
|
|
let id = Uuid::new_v4();
|
|
|
|
let mut tx = pool.begin().await?;
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline)
|
|
VALUES ($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8,
|
|
CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END)
|
|
"#,
|
|
)
|
|
.bind(id)
|
|
.bind(community_id.as_uuid())
|
|
.bind(name)
|
|
.bind(channel_type.as_str())
|
|
.bind(visibility.as_str())
|
|
.bind(description)
|
|
.bind(created_by)
|
|
.bind(ttl_seconds)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by)
|
|
VALUES ($1, $2, $3, 'owner', $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(created_by)
|
|
.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,
|
|
ttl_seconds, ttl_deadline
|
|
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)
|
|
}
|
|
|
|
/// Creates a channel with a client-supplied UUID (idempotent via ON CONFLICT DO NOTHING).
|
|
///
|
|
/// Returns `(record, true)` if the channel was newly created, or `(record, false)` if a
|
|
/// channel with `channel_id` already exists (duplicate — caller should reject the event).
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn create_channel_with_id(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
name: &str,
|
|
channel_type: ChannelType,
|
|
visibility: ChannelVisibility,
|
|
description: Option<&str>,
|
|
created_by: &[u8],
|
|
ttl_seconds: Option<i32>,
|
|
) -> Result<(ChannelRecord, bool)> {
|
|
if created_by.len() != 32 {
|
|
return Err(DbError::InvalidData(format!(
|
|
"pubkey must be 32 bytes, got {}",
|
|
created_by.len()
|
|
)));
|
|
}
|
|
|
|
if channel_id.is_nil() {
|
|
return Err(DbError::InvalidData(
|
|
"channel_id must not be nil (reserved for global fan-out)".into(),
|
|
));
|
|
}
|
|
|
|
let name = buzz_core::channel::canonical_channel_name(name);
|
|
if name.trim().is_empty() {
|
|
return Err(DbError::InvalidData("channel name is required".into()));
|
|
}
|
|
|
|
let mut tx = pool.begin().await?;
|
|
|
|
let rows_affected = sqlx::query(
|
|
r#"
|
|
INSERT INTO channels (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline)
|
|
VALUES ($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8,
|
|
CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END)
|
|
ON CONFLICT (community_id, id) DO NOTHING
|
|
"#,
|
|
)
|
|
.bind(channel_id)
|
|
.bind(community_id.as_uuid())
|
|
.bind(name)
|
|
.bind(channel_type.as_str())
|
|
.bind(visibility.as_str())
|
|
.bind(description)
|
|
.bind(created_by)
|
|
.bind(ttl_seconds)
|
|
.execute(&mut *tx)
|
|
.await?
|
|
.rows_affected();
|
|
|
|
let was_created = rows_affected > 0;
|
|
|
|
if was_created {
|
|
// Bootstrap the creator as owner.
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by)
|
|
VALUES ($1, $2, $3, 'owner', $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(channel_id)
|
|
.bind(created_by)
|
|
.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,
|
|
ttl_seconds, ttl_deadline
|
|
FROM channels WHERE community_id = $1 AND id = $2
|
|
"#,
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.fetch_one(&mut *tx)
|
|
.await?;
|
|
|
|
let record = row_to_channel_record(row)?;
|
|
tx.commit().await?;
|
|
Ok((record, was_created))
|
|
}
|
|
|
|
/// Fetches a channel record by `(community_id, id)`. Returns `ChannelNotFound` if missing or deleted.
|
|
pub async fn get_channel(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
) -> Result<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,
|
|
ttl_seconds, ttl_deadline
|
|
FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL
|
|
"#,
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or(DbError::ChannelNotFound(channel_id))?;
|
|
|
|
row_to_channel_record(row)
|
|
}
|
|
|
|
/// Returns the canvas content for a channel, if any.
|
|
pub async fn get_canvas(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
) -> Result<Option<String>> {
|
|
let row = sqlx::query(
|
|
"SELECT canvas FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.fetch_optional(pool)
|
|
.await?
|
|
.ok_or(DbError::ChannelNotFound(channel_id))?;
|
|
Ok(row.try_get("canvas")?)
|
|
}
|
|
|
|
/// Sets or clears the canvas content for a channel.
|
|
pub async fn set_canvas(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
canvas: Option<&str>,
|
|
) -> Result<()> {
|
|
let rows = sqlx::query(
|
|
"UPDATE channels SET canvas = $1 WHERE community_id = $2 AND id = $3 AND deleted_at IS NULL",
|
|
)
|
|
.bind(canvas)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.execute(pool)
|
|
.await?;
|
|
if rows.rows_affected() == 0 {
|
|
return Err(DbError::ChannelNotFound(channel_id));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Namespace for the per-channel membership advisory lock. Serializes the
|
|
/// role-authorization + last-owner-count + write sequences in [`add_member`]
|
|
/// and [`remove_member`] against each other.
|
|
///
|
|
/// Both functions read an owner COUNT and then write a *different* row than the
|
|
/// one they counted, so `READ COMMITTED` snapshot isolation alone permits two
|
|
/// concurrent demotions (or a demotion racing a removal) to each observe two
|
|
/// owners, each pass, and together leave zero — the exact governance loss the
|
|
/// guards exist to prevent. An advisory key rather than `SELECT ... FOR UPDATE`
|
|
/// on the channel row: membership is its own contention domain and must not
|
|
/// serialize against unrelated channel metadata writers (`update_channel`,
|
|
/// `set_topic`, the TTL transition). Distinct key domain from
|
|
/// `buzz_channel_ttl:`.
|
|
const CHANNEL_MEMBERSHIP_LOCK_NAMESPACE: &str = "buzz_channel_membership:";
|
|
|
|
/// Verify that migration 0032's roster fence is active on the partitioned
|
|
/// `events` parent and every attached partition.
|
|
///
|
|
/// New roster publishers depend on this database-side guard to serialize with
|
|
/// legacy publishers during a rolling deployment. If the migration has not
|
|
/// been applied, publishing with the new lock protocol would falsely appear
|
|
/// safe while an old pod could still overwrite it with stale membership.
|
|
pub async fn verify_channel_roster_fence_catalog<'e>(
|
|
executor: impl sqlx::PgExecutor<'e>,
|
|
) -> Result<()> {
|
|
// tgtype bits: 1 = ROW, 2 = BEFORE, 4 = INSERT, 16 = UPDATE, 64 = INSTEAD.
|
|
// Required: ROW + BEFORE + INSERT set; UPDATE + INSTEAD clear.
|
|
let missing: Vec<String> = sqlx::query_scalar(
|
|
r#"
|
|
SELECT n.nspname || '.' || c.relname
|
|
FROM (
|
|
SELECT 'public.events'::regclass AS oid
|
|
UNION ALL
|
|
SELECT inhrelid FROM pg_inherits WHERE inhparent = 'public.events'::regclass
|
|
) rels
|
|
JOIN pg_class c ON c.oid = rels.oid
|
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
|
WHERE NOT EXISTS (
|
|
SELECT 1 FROM pg_trigger t
|
|
WHERE t.tgrelid = rels.oid
|
|
AND t.tgname = 'trg_events_guard_channel_roster_snapshot'
|
|
AND t.tgfoid = to_regprocedure('public.guard_channel_roster_snapshot()')
|
|
AND t.tgenabled IN ('O', 'A')
|
|
AND t.tgtype & 1 = 1 -- row-level
|
|
AND t.tgtype & 2 = 2 -- BEFORE
|
|
AND t.tgtype & 4 = 4 -- fires on INSERT
|
|
AND t.tgtype & 16 = 0 -- not UPDATE
|
|
AND t.tgtype & 64 = 0 -- not INSTEAD OF
|
|
)
|
|
"#,
|
|
)
|
|
.fetch_all(executor)
|
|
.await?;
|
|
if !missing.is_empty() {
|
|
return Err(DbError::InvalidData(format!(
|
|
"channel roster fence trigger missing, disabled, or mis-shaped on: {}",
|
|
missing.join(", ")
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Prove migration 0032's roster fence semantics through the live writer pool.
|
|
///
|
|
/// The catalog check cannot detect a no-op or otherwise corrupted trigger
|
|
/// function. This rolled-back probe verifies that a canonical empty roster is
|
|
/// accepted while a stale roster member is rejected with `check_violation`.
|
|
pub async fn verify_channel_roster_fence_behavior(pool: &sqlx::PgPool) -> Result<()> {
|
|
let mut tx = pool.begin().await?;
|
|
let community_id = Uuid::new_v4();
|
|
let channel_id = Uuid::new_v4();
|
|
sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)")
|
|
.bind(community_id)
|
|
.bind(format!(
|
|
"roster-fence-verify-{}.invalid",
|
|
community_id.simple()
|
|
))
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
let insert = |id: Vec<u8>, tags: serde_json::Value| {
|
|
sqlx::query(
|
|
"INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) \
|
|
VALUES ($1, $2, $3, NOW(), 39002, $4, '', $5, NOW(), $6, $7)",
|
|
)
|
|
.bind(community_id)
|
|
.bind(id)
|
|
.bind(vec![0u8; 32])
|
|
.bind(tags)
|
|
.bind(vec![0u8; 64])
|
|
.bind(channel_id)
|
|
.bind(channel_id.to_string())
|
|
};
|
|
|
|
insert(
|
|
vec![0u8; 32],
|
|
serde_json::json!([["d", channel_id.to_string()]]),
|
|
)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|error| {
|
|
DbError::InvalidData(format!(
|
|
"channel roster fence rejected a canonical probe roster: {error}"
|
|
))
|
|
})?;
|
|
|
|
sqlx::query("SAVEPOINT roster_fence_probe")
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
let stale = insert(
|
|
vec![1u8; 32],
|
|
serde_json::json!([
|
|
["d", channel_id.to_string()],
|
|
["p", hex::encode([2u8; 32]), "", "member"]
|
|
]),
|
|
)
|
|
.execute(&mut *tx)
|
|
.await;
|
|
match stale {
|
|
Err(sqlx::Error::Database(error)) if error.code().as_deref() == Some("23514") => {}
|
|
Ok(_) => {
|
|
return Err(DbError::InvalidData(
|
|
"channel roster fence is inert: a stale probe roster was accepted".into(),
|
|
));
|
|
}
|
|
Err(error) => {
|
|
return Err(DbError::InvalidData(format!(
|
|
"channel roster fence probe failed unexpectedly: {error}"
|
|
)));
|
|
}
|
|
}
|
|
sqlx::query("ROLLBACK TO SAVEPOINT roster_fence_probe")
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
tx.rollback().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Take the per-channel membership lock. MUST be the first statement in the
|
|
/// transaction that then reads roles/owner counts and writes membership, so the
|
|
/// whole check-then-write sequence is atomic against a concurrent one.
|
|
async fn acquire_channel_membership_lock(
|
|
tx: &mut Transaction<'_, Postgres>,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
) -> Result<()> {
|
|
crate::observability::observe_advisory_lock(
|
|
crate::observability::LockType::Membership,
|
|
sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
|
|
.bind(format!(
|
|
"{CHANNEL_MEMBERSHIP_LOCK_NAMESPACE}{}:{}",
|
|
community_id.as_uuid(),
|
|
channel_id
|
|
))
|
|
.execute(&mut **tx),
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// An active member roster captured while holding the channel's membership
|
|
/// serialization lock on one writer connection.
|
|
pub struct LockedMemberSnapshot {
|
|
/// Canonical active members captured behind the lock.
|
|
pub members: Vec<MemberRecord>,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
relay_pubkey: Vec<u8>,
|
|
tx: Transaction<'static, Postgres>,
|
|
}
|
|
|
|
impl LockedMemberSnapshot {
|
|
/// Return the newest relay-authored member snapshot timestamp using this
|
|
/// guard's existing connection.
|
|
pub async fn latest_member_event_timestamp(
|
|
&mut self,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
relay_pubkey: &[u8],
|
|
) -> Result<Option<u64>> {
|
|
let value: Option<chrono::DateTime<Utc>> = sqlx::query_scalar(
|
|
"SELECT created_at FROM events WHERE community_id = $1 AND kind = 39002 AND pubkey = $2 AND channel_id = $3 AND deleted_at IS NULL ORDER BY created_at DESC, id ASC LIMIT 1",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(relay_pubkey)
|
|
.bind(channel_id)
|
|
.fetch_optional(&mut *self.tx)
|
|
.await?;
|
|
Ok(value.map(|timestamp| timestamp.timestamp() as u64))
|
|
}
|
|
|
|
/// Replace the relay-authored member snapshot on this guard's existing
|
|
/// connection. The membership lock therefore spans capture and replacement
|
|
/// without a nested pool checkout.
|
|
pub async fn replace_member_event(
|
|
&mut self,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
event: &nostr::Event,
|
|
) -> Result<(buzz_core::StoredEvent, bool)> {
|
|
if community_id != self.community_id
|
|
|| channel_id != self.channel_id
|
|
|| event.pubkey.to_bytes().as_slice() != self.relay_pubkey.as_slice()
|
|
{
|
|
return Err(DbError::InvalidData(
|
|
"member snapshot replacement does not match its locked coordinate".into(),
|
|
));
|
|
}
|
|
let kind = buzz_core::kind::event_kind_i32(event);
|
|
if kind != 39002 {
|
|
return Err(DbError::InvalidData(
|
|
"member snapshot replacement requires kind 39002".into(),
|
|
));
|
|
}
|
|
let pubkey = event.pubkey.to_bytes();
|
|
let created_at_secs = event.created_at.as_secs() as i64;
|
|
let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0)
|
|
.ok_or(DbError::InvalidTimestamp(created_at_secs))?;
|
|
let existing: Option<(chrono::DateTime<Utc>, Vec<u8>)> = sqlx::query_as(
|
|
"SELECT created_at, id FROM events WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND channel_id = $4 AND deleted_at IS NULL ORDER BY created_at DESC, id ASC LIMIT 1",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(kind)
|
|
.bind(pubkey.as_slice())
|
|
.bind(channel_id)
|
|
.fetch_optional(&mut *self.tx)
|
|
.await?;
|
|
let incoming_id = event.id.as_bytes().as_slice();
|
|
if let Some((existing_ts, existing_id)) = existing {
|
|
if created_at < existing_ts
|
|
|| (created_at == existing_ts && incoming_id >= existing_id.as_slice())
|
|
{
|
|
return Ok((
|
|
buzz_core::StoredEvent::with_received_at(
|
|
event.clone(),
|
|
Utc::now(),
|
|
Some(channel_id),
|
|
false,
|
|
),
|
|
false,
|
|
));
|
|
}
|
|
}
|
|
sqlx::query("UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND channel_id = $4 AND deleted_at IS NULL")
|
|
.bind(community_id.as_uuid()).bind(kind).bind(pubkey.as_slice()).bind(channel_id)
|
|
.execute(&mut *self.tx).await?;
|
|
let received_at = Utc::now();
|
|
let tags = serde_json::to_value(&event.tags)?;
|
|
let sig = event.sig.serialize();
|
|
let inserted = sqlx::query("INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT DO NOTHING")
|
|
.bind(community_id.as_uuid()).bind(event.id.as_bytes().as_slice())
|
|
.bind(pubkey.as_slice()).bind(created_at).bind(kind).bind(tags)
|
|
.bind(&event.content).bind(sig.as_slice()).bind(received_at).bind(channel_id)
|
|
.bind(crate::event::extract_d_tag(event)).execute(&mut *self.tx).await?;
|
|
if inserted.rows_affected() == 0 {
|
|
return Err(DbError::InvalidData(
|
|
"member snapshot event id already exists".into(),
|
|
));
|
|
}
|
|
crate::insert_mentions_in_transaction(&mut self.tx, community_id, event, Some(channel_id))
|
|
.await?;
|
|
Ok((
|
|
buzz_core::StoredEvent::with_received_at(
|
|
event.clone(),
|
|
received_at,
|
|
Some(channel_id),
|
|
true,
|
|
),
|
|
true,
|
|
))
|
|
}
|
|
|
|
/// Commit the replacement and release the membership lock.
|
|
pub async fn release(self) -> Result<()> {
|
|
self.tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Capture all active members while holding the same per-channel lock used by
|
|
/// membership writers.
|
|
///
|
|
/// The returned guard must remain alive through publication. This prevents a
|
|
/// rolling relay from publishing an older roster after a concurrent add or
|
|
/// remove has committed and published newer membership state.
|
|
pub async fn lock_member_snapshot(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
relay_pubkey: &[u8],
|
|
) -> Result<LockedMemberSnapshot> {
|
|
let mut tx = pool.begin().await?;
|
|
// Match the canonical replacement writer's lock order. Old binaries take
|
|
// this key before INSERT; migration 0032 then takes the membership key in
|
|
// the INSERT trigger. Taking both in that order avoids mixed-version
|
|
// duplicate heads without introducing a lock-order inversion.
|
|
let replacement_lock = crate::replaceable::event_replacement_lock_key(
|
|
community_id,
|
|
39002,
|
|
relay_pubkey,
|
|
Some(channel_id.as_bytes()),
|
|
);
|
|
crate::observability::observe_advisory_lock(
|
|
crate::observability::LockType::Replacement,
|
|
sqlx::query("SELECT pg_advisory_xact_lock($1)")
|
|
.bind(replacement_lock)
|
|
.execute(&mut *tx),
|
|
)
|
|
.await?;
|
|
acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?;
|
|
let rows = sqlx::query(
|
|
r#"
|
|
SELECT cm.channel_id, cm.pubkey, cm.role::text AS role, cm.joined_at, cm.invited_by, cm.removed_at
|
|
FROM channel_members cm
|
|
JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL
|
|
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(&mut *tx)
|
|
.await?;
|
|
let members = rows
|
|
.into_iter()
|
|
.map(row_to_member_record)
|
|
.collect::<Result<Vec<_>>>()?;
|
|
Ok(LockedMemberSnapshot {
|
|
members,
|
|
community_id,
|
|
channel_id,
|
|
relay_pubkey: relay_pubkey.to_vec(),
|
|
tx,
|
|
})
|
|
}
|
|
|
|
/// Add a member to a channel.
|
|
///
|
|
/// Role enforcement:
|
|
/// - Open channels: `invited_by` is optional; role is forced to `Member` regardless of
|
|
/// what the caller passes — callers cannot self-assign elevated roles.
|
|
/// - Private channels: requires an `invited_by` who is an active member, or the channel
|
|
/// creator bootstrapping their own first membership. Any active member may add an
|
|
/// ordinary member, guest, or bot; only owners/admins may grant elevated roles.
|
|
/// - Elevated roles (`Owner`, `Admin`) may only be granted by an existing owner/admin,
|
|
/// even on open channels.
|
|
///
|
|
/// The entire check-then-insert sequence runs inside a transaction to prevent TOCTOU
|
|
/// races (e.g. the inviter being removed between the role check and the INSERT).
|
|
pub async fn add_member(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
pubkey: &[u8],
|
|
role: MemberRole,
|
|
invited_by: Option<&[u8]>,
|
|
) -> Result<MemberRecord> {
|
|
if pubkey.len() != 32 {
|
|
return Err(DbError::InvalidData(format!(
|
|
"pubkey must be 32 bytes, got {}",
|
|
pubkey.len()
|
|
)));
|
|
}
|
|
|
|
let mut tx = pool.begin().await?;
|
|
|
|
// First statement: serialize the whole role-check / owner-count / upsert
|
|
// sequence against concurrent membership writes on this channel.
|
|
acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?;
|
|
|
|
let channel = get_channel_tx(&mut tx, community_id, channel_id).await?;
|
|
|
|
let effective_role = if channel.visibility == "private" {
|
|
let inviter = invited_by.ok_or_else(|| {
|
|
DbError::AccessDenied("private channel requires an invite".to_string())
|
|
})?;
|
|
|
|
// Bootstrap: channel creator may add themselves as the first member.
|
|
let is_creator_bootstrap = inviter == pubkey && inviter == channel.created_by.as_slice();
|
|
|
|
if !is_creator_bootstrap {
|
|
let inviter_role_str = get_active_role_tx(&mut tx, community_id, channel_id, inviter)
|
|
.await?
|
|
.ok_or_else(|| {
|
|
DbError::AccessDenied("inviter is not an active member".to_string())
|
|
})?;
|
|
|
|
let inviter_role: MemberRole = inviter_role_str.parse().map_err(|_| {
|
|
DbError::InvalidData(format!("invalid role in database: {inviter_role_str}"))
|
|
})?;
|
|
|
|
// Any active member may extend private-channel access with an
|
|
// ordinary role. Granting owner/admin remains reserved for an
|
|
// existing owner/admin.
|
|
if role.is_elevated() && !inviter_role.is_elevated() {
|
|
return Err(DbError::AccessDenied(
|
|
"only owners/admins may grant elevated roles".to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
role
|
|
} else {
|
|
// Open channel: anyone may join, but only existing owners/admins may grant
|
|
// elevated roles. Self-join always gets Member.
|
|
if role.is_elevated() {
|
|
let granter_role = match invited_by {
|
|
Some(inv) => get_active_role_tx(&mut tx, community_id, channel_id, inv).await?,
|
|
None => None,
|
|
};
|
|
match granter_role.as_deref() {
|
|
Some("owner") | Some("admin") => role,
|
|
_ => {
|
|
return Err(DbError::AccessDenied(
|
|
"only owners/admins may grant elevated roles".to_string(),
|
|
))
|
|
}
|
|
}
|
|
} else {
|
|
role
|
|
}
|
|
};
|
|
|
|
// Changing an *active* member's role is privileged in BOTH directions.
|
|
// Demotion is as consequential as promotion: only owners/admins may grant
|
|
// elevated roles, so a demoted owner cannot restore themselves. Guarding
|
|
// only `role.is_elevated()` above therefore left owner→member demotion
|
|
// unauthorized-by-anyone. Re-adding an active member with the role they
|
|
// already hold stays idempotent and unguarded — the huddle bot-add and
|
|
// kind:9021 join paths rely on that.
|
|
//
|
|
// Deliberately keyed on the *active* role. A soft-removed row's stored role
|
|
// is history, not live authority: `removed_at` says it is no longer in
|
|
// force. Reactivation therefore lands at whatever `effective_role` the
|
|
// checks above already authorized — `Member` for any unprivileged caller,
|
|
// elevated only when a currently-elevated granter asked for it. Inferring
|
|
// current authority from a removed row would make soft-deleted ownership a
|
|
// resurrection token: an owner removed by another owner could self-rejoin
|
|
// via kind:9021 (`Member, None`) and silently regain ownership.
|
|
let current_role = get_active_role_tx(&mut tx, community_id, channel_id, pubkey).await?;
|
|
if let Some(current_role) = current_role.filter(|r| r != effective_role.as_str()) {
|
|
let actor_role = match invited_by {
|
|
Some(inviter) => get_active_role_tx(&mut tx, community_id, channel_id, inviter).await?,
|
|
None => None,
|
|
};
|
|
let actor_role: Option<MemberRole> = actor_role.and_then(|r| r.parse().ok());
|
|
if !actor_role.is_some_and(|r| r.is_elevated()) {
|
|
return Err(DbError::AccessDenied(
|
|
"only owners/admins may change an active member's role".to_string(),
|
|
));
|
|
}
|
|
|
|
// Defense-in-depth, mirroring `remove_member`: a demotion must not
|
|
// strip the channel of its last owner, which would leave nobody able
|
|
// to moderate, edit metadata, or re-grant ownership.
|
|
if current_role == "owner" && effective_role != MemberRole::Owner {
|
|
let row = sqlx::query(
|
|
"SELECT COUNT(*) as cnt FROM channel_members \
|
|
WHERE community_id = $1 AND channel_id = $2 AND role = 'owner' AND removed_at IS NULL",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.fetch_one(&mut *tx)
|
|
.await?;
|
|
let owner_count: i64 = row.try_get("cnt")?;
|
|
if owner_count <= 1 {
|
|
return Err(DbError::AccessDenied(
|
|
"cannot demote the last owner — transfer ownership first".to_string(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by)
|
|
VALUES ($1, $2, $3, $4::member_role, $5)
|
|
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(channel_id)
|
|
.bind(pubkey)
|
|
.bind(effective_role.as_str())
|
|
.bind(invited_by)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
let row = sqlx::query(
|
|
r#"
|
|
SELECT channel_id, pubkey, role::text AS role, joined_at, invited_by, removed_at
|
|
FROM channel_members WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3
|
|
"#,
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.bind(pubkey)
|
|
.fetch_one(&mut *tx)
|
|
.await?;
|
|
|
|
let record = row_to_member_record(row)?;
|
|
tx.commit().await?;
|
|
Ok(record)
|
|
}
|
|
|
|
/// Remove a member from a channel (soft delete).
|
|
///
|
|
/// `actor_pubkey` must be an active owner/admin, the agent's owner, or the member
|
|
/// removing themselves.
|
|
///
|
|
/// Returns `Err(DbError::MemberNotFound)` if the target is not an active member.
|
|
///
|
|
/// The per-channel membership lock is the transaction's first statement, so the
|
|
/// actor's role check, the last-owner count, and the UPDATE are all serialized
|
|
/// against concurrent membership writes — otherwise a concurrent demotion of the
|
|
/// actor could commit after their role was read and this removal would proceed on
|
|
/// a stale elevated role.
|
|
///
|
|
/// The `is_agent_owner` lookup deliberately runs *before* the transaction opens:
|
|
/// it borrows a second connection from `pool`, and issuing it while holding the
|
|
/// lock could deadlock against ourselves on a small pool. That is safe because
|
|
/// `agent_owner_pubkey` is immutable — [`crate::user::set_agent_owner`] only
|
|
/// updates it when it `IS NULL` (first-mint-wins), so its value cannot change
|
|
/// under us and needs no serialization.
|
|
pub async fn remove_member(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
pubkey: &[u8],
|
|
actor_pubkey: &[u8],
|
|
) -> Result<()> {
|
|
let is_self_remove = pubkey == actor_pubkey;
|
|
|
|
// Immutable, and must not be queried while holding the lock (second pool
|
|
// connection). Resolved up front so every *mutable* authorization read can
|
|
// sit behind the serialization point below.
|
|
let actor_is_agent_owner = if is_self_remove {
|
|
false
|
|
} else {
|
|
crate::user::is_agent_owner(pool, community_id, pubkey, actor_pubkey).await?
|
|
};
|
|
|
|
let mut tx = pool.begin().await?;
|
|
|
|
// First statement: serialize the actor-role check, the last-owner count and
|
|
// the UPDATE against concurrent membership writes on this channel (same key
|
|
// as `add_member`).
|
|
acquire_channel_membership_lock(&mut tx, community_id, channel_id).await?;
|
|
|
|
if !is_self_remove {
|
|
let actor_role_str = get_active_role_tx(&mut tx, community_id, channel_id, actor_pubkey)
|
|
.await?
|
|
.ok_or_else(|| DbError::AccessDenied("actor is not an active member".to_string()))?;
|
|
let actor_role: MemberRole = actor_role_str.parse().map_err(|_| {
|
|
DbError::InvalidData(format!("invalid role in database: {actor_role_str}"))
|
|
})?;
|
|
if !actor_role.is_elevated() && !actor_is_agent_owner {
|
|
return Err(DbError::AccessDenied(
|
|
"only owners/admins or the agent's owner may remove other members".to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
// Defense-in-depth: prevent removing the last owner regardless of caller.
|
|
// Callers (REST handlers, NIP-29 handlers) also check this, but the DB
|
|
// layer enforces it as the final safety net.
|
|
let target_role = get_active_role_tx(&mut tx, community_id, channel_id, pubkey).await?;
|
|
if target_role.as_deref() == Some("owner") {
|
|
let row = sqlx::query(
|
|
"SELECT COUNT(*) as cnt FROM channel_members \
|
|
WHERE community_id = $1 AND channel_id = $2 AND role = 'owner' AND removed_at IS NULL",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.fetch_one(&mut *tx)
|
|
.await?;
|
|
let owner_count: i64 = row.try_get("cnt")?;
|
|
if owner_count <= 1 {
|
|
return Err(DbError::AccessDenied(
|
|
"cannot remove the last owner — transfer ownership first".to_string(),
|
|
));
|
|
}
|
|
}
|
|
|
|
let result = sqlx::query(
|
|
r#"
|
|
UPDATE channel_members
|
|
SET removed_at = NOW(), removed_by = $1
|
|
WHERE community_id = $2 AND channel_id = $3 AND pubkey = $4 AND removed_at IS NULL
|
|
"#,
|
|
)
|
|
.bind(actor_pubkey)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.bind(pubkey)
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
|
|
if result.rows_affected() == 0 {
|
|
return Err(DbError::MemberNotFound(channel_id));
|
|
}
|
|
|
|
tx.commit().await?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Returns `true` if the given pubkey is an active member of the channel.
|
|
pub async fn is_member(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
pubkey: &[u8],
|
|
) -> Result<bool> {
|
|
let row = sqlx::query(
|
|
"SELECT COUNT(*) as cnt FROM channel_members cm \
|
|
JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL \
|
|
WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.pubkey = $3 AND cm.removed_at IS NULL",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.bind(pubkey)
|
|
.fetch_one(pool)
|
|
.await?;
|
|
let cnt: i64 = row.try_get("cnt")?;
|
|
Ok(cnt > 0)
|
|
}
|
|
|
|
/// Return which of the given (channel, pubkey) combinations are active
|
|
/// memberships, restricted to non-deleted channels — one statement for any
|
|
/// batch size (T2b). Semantics per pair match [`is_member`].
|
|
pub async fn membership_pairs(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_ids: &[Uuid],
|
|
pubkeys: &[Vec<u8>],
|
|
) -> Result<Vec<(Uuid, Vec<u8>)>> {
|
|
if channel_ids.is_empty() || pubkeys.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let rows = sqlx::query(
|
|
"SELECT cm.channel_id, cm.pubkey FROM channel_members cm \
|
|
JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL \
|
|
WHERE cm.community_id = $1 AND cm.channel_id = ANY($2) AND cm.pubkey = ANY($3) AND cm.removed_at IS NULL",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_ids)
|
|
.bind(pubkeys)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
rows.into_iter()
|
|
.map(|row| Ok((row.try_get("channel_id")?, row.try_get("pubkey")?)))
|
|
.collect()
|
|
}
|
|
|
|
/// Returns all active members of the given channel, ordered by `joined_at`.
|
|
///
|
|
/// The roster is returned in full and is never truncated: callers use it to
|
|
/// build the kind 39002 (NIP-29 group members) snapshot and to resolve actor
|
|
/// roles for admin-event authorization, so a partial list silently hides late
|
|
/// joiners from channel discovery and makes them read as non-members.
|
|
///
|
|
/// Returns an empty list if the channel has been soft-deleted.
|
|
pub async fn get_members(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
) -> Result<Vec<MemberRecord>> {
|
|
let rows = sqlx::query(
|
|
r#"
|
|
SELECT cm.channel_id, cm.pubkey, cm.role::text AS role, cm.joined_at, cm.invited_by, cm.removed_at
|
|
FROM channel_members cm
|
|
JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL
|
|
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?;
|
|
rows.into_iter().map(row_to_member_record).collect()
|
|
}
|
|
|
|
/// Returns active members for multiple channels in a single query.
|
|
///
|
|
/// Designed for small-batch use (e.g. DM participant resolution where each
|
|
/// channel has 2-9 members). For large channel sets, consider pagination.
|
|
/// Returns a flat `Vec<MemberRecord>` ordered by `joined_at`; callers should
|
|
/// group by `channel_id` if per-channel access is needed.
|
|
/// Returns an empty vec immediately when `channel_ids` is empty.
|
|
pub async fn get_members_bulk(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_ids: &[Uuid],
|
|
) -> Result<Vec<MemberRecord>> {
|
|
if channel_ids.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let rows = sqlx::query(
|
|
r#"
|
|
SELECT cm.channel_id, cm.pubkey, cm.role::text AS role, cm.joined_at, cm.invited_by, cm.removed_at
|
|
FROM channel_members cm
|
|
JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL
|
|
WHERE cm.community_id = $1 AND cm.channel_id = ANY($2) AND cm.removed_at IS NULL
|
|
ORDER BY cm.joined_at ASC
|
|
"#,
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_ids)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
rows.into_iter().map(row_to_member_record).collect()
|
|
}
|
|
|
|
/// Get all channel IDs accessible to a pubkey.
|
|
///
|
|
/// Includes channels where the pubkey is an active member AND all open channels.
|
|
/// Open channels must be included in REQ filter resolution.
|
|
pub async fn get_accessible_channel_ids(
|
|
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 cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL
|
|
WHERE cm.community_id = $1 AND cm.pubkey = $2 AND cm.removed_at IS NULL
|
|
UNION
|
|
SELECT id AS channel_id
|
|
FROM channels
|
|
WHERE community_id = $1 AND visibility = 'open' AND deleted_at IS NULL
|
|
"#,
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(pubkey)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
|
|
rows.into_iter()
|
|
.map(|r| {
|
|
let id: Uuid = r.try_get("channel_id")?;
|
|
Ok(id)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// A large channel whose canonical active-member count may need its legacy
|
|
/// discovery snapshot repaired.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct LargeChannelRoster {
|
|
/// Community that owns the channel.
|
|
pub community_id: CommunityId,
|
|
/// Canonical host for the owning community.
|
|
pub host: String,
|
|
/// Channel whose roster snapshot differs from canonical membership.
|
|
pub channel_id: Uuid,
|
|
/// Canonical active-member count.
|
|
pub member_count: i64,
|
|
}
|
|
|
|
/// Returns active channels whose canonical roster exceeds `minimum_members`.
|
|
///
|
|
/// This is an internal cross-community maintenance read. Callers must preserve
|
|
/// the returned community id when reading or rewriting discovery state.
|
|
pub async fn list_large_channel_rosters_needing_reconciliation(
|
|
pool: &PgPool,
|
|
minimum_members: i64,
|
|
relay_pubkey: &[u8],
|
|
) -> Result<Vec<LargeChannelRoster>> {
|
|
let rows = sqlx::query(
|
|
r#"
|
|
WITH large_rosters AS (
|
|
SELECT cm.community_id, cm.channel_id, COUNT(*) AS member_count
|
|
FROM channel_members cm
|
|
JOIN channels ch
|
|
ON ch.community_id = cm.community_id
|
|
AND ch.id = cm.channel_id
|
|
AND ch.deleted_at IS NULL
|
|
WHERE cm.removed_at IS NULL
|
|
GROUP BY cm.community_id, cm.channel_id
|
|
HAVING COUNT(*) > $1
|
|
)
|
|
SELECT lr.community_id, community.host, lr.channel_id, lr.member_count
|
|
FROM large_rosters lr
|
|
JOIN communities community ON community.id = lr.community_id
|
|
JOIN LATERAL (
|
|
SELECT roster.tags
|
|
FROM events roster
|
|
WHERE roster.community_id = lr.community_id
|
|
AND roster.channel_id = lr.channel_id
|
|
AND roster.kind = 39002
|
|
AND roster.pubkey = $2
|
|
AND roster.deleted_at IS NULL
|
|
ORDER BY roster.created_at DESC, roster.id ASC
|
|
LIMIT 1
|
|
) live_roster ON true
|
|
WHERE lr.member_count <> (
|
|
SELECT COUNT(*)
|
|
FROM jsonb_array_elements(live_roster.tags) tag
|
|
WHERE tag->>0 = 'p'
|
|
)
|
|
ORDER BY lr.community_id, lr.channel_id
|
|
"#,
|
|
)
|
|
.bind(minimum_members)
|
|
.bind(relay_pubkey)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
|
|
rows.into_iter()
|
|
.map(|row| {
|
|
Ok(LargeChannelRoster {
|
|
community_id: CommunityId::from_uuid(row.try_get("community_id")?),
|
|
host: row.try_get("host")?,
|
|
channel_id: row.try_get("channel_id")?,
|
|
member_count: row.try_get("member_count")?,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Lists channels in a community, optionally filtered by visibility string.
|
|
pub async fn list_channels(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
visibility: Option<&str>,
|
|
) -> Result<Vec<ChannelRecord>> {
|
|
let rows = if let Some(vis) = visibility {
|
|
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,
|
|
ttl_seconds, ttl_deadline
|
|
FROM channels
|
|
WHERE community_id = $1 AND deleted_at IS NULL AND visibility::text = $2
|
|
ORDER BY created_at DESC
|
|
LIMIT 1000
|
|
"#,
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(vis)
|
|
.fetch_all(pool)
|
|
.await?
|
|
} else {
|
|
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,
|
|
ttl_seconds, ttl_deadline
|
|
FROM channels
|
|
WHERE community_id = $1 AND deleted_at IS NULL
|
|
ORDER BY created_at DESC
|
|
LIMIT 1000
|
|
"#,
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.fetch_all(pool)
|
|
.await?
|
|
};
|
|
|
|
rows.into_iter().map(row_to_channel_record).collect()
|
|
}
|
|
|
|
/// Transaction-aware variant of [`get_active_role_tx`].
|
|
async fn get_active_role_tx(
|
|
tx: &mut Transaction<'_, Postgres>,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
pubkey: &[u8],
|
|
) -> Result<Option<String>> {
|
|
let row = sqlx::query(
|
|
"SELECT role::text AS role FROM channel_members \
|
|
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)
|
|
.fetch_optional(&mut **tx)
|
|
.await?;
|
|
Ok(row.map(|r| r.try_get("role")).transpose()?)
|
|
}
|
|
|
|
/// Transaction-aware variant of [`get_channel`].
|
|
async fn get_channel_tx(
|
|
tx: &mut Transaction<'_, Postgres>,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
) -> Result<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,
|
|
ttl_seconds, ttl_deadline
|
|
FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL
|
|
"#,
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.fetch_optional(&mut **tx)
|
|
.await?
|
|
.ok_or(DbError::ChannelNotFound(channel_id))?;
|
|
row_to_channel_record(row)
|
|
}
|
|
|
|
/// A channel entry returned as part of a bot member record.
|
|
#[derive(Debug, Clone, serde::Deserialize)]
|
|
pub struct BotChannelEntry {
|
|
/// Channel display name.
|
|
pub name: String,
|
|
/// Channel UUID (as string from the DB).
|
|
pub id: String,
|
|
}
|
|
|
|
/// A channel archived by the ephemeral-channel reaper.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ReapedEphemeralChannel {
|
|
/// Community that owns the archived channel.
|
|
pub community_id: CommunityId,
|
|
/// Normalized host mapped to that community.
|
|
pub host: String,
|
|
/// Archived channel UUID.
|
|
pub channel_id: Uuid,
|
|
}
|
|
|
|
/// Bot member record — a user with role=bot, with their channel memberships aggregated.
|
|
#[derive(Debug, Clone)]
|
|
pub struct BotMemberRecord {
|
|
/// Compressed public key bytes of the bot user.
|
|
pub pubkey: Vec<u8>,
|
|
/// Optional display name for the bot.
|
|
pub display_name: Option<String>,
|
|
/// Optional agent type identifier.
|
|
pub agent_type: Option<String>,
|
|
/// Optional JSON capabilities descriptor.
|
|
pub capabilities: Option<serde_json::Value>,
|
|
/// Channel entries with both name and UUID, from json_agg.
|
|
pub channels: Vec<BotChannelEntry>,
|
|
}
|
|
|
|
/// User record for bulk lookup.
|
|
#[derive(Debug, Clone)]
|
|
pub struct UserRecord {
|
|
/// Compressed public key bytes of the user.
|
|
pub pubkey: Vec<u8>,
|
|
/// Optional display name.
|
|
pub display_name: Option<String>,
|
|
/// Optional avatar image URL.
|
|
pub avatar_url: Option<String>,
|
|
/// Optional NIP-05 identifier (e.g. `user@example.com`).
|
|
pub nip05_handle: Option<String>,
|
|
}
|
|
|
|
/// A channel record paired with whether the querying user is an active member.
|
|
#[derive(Debug, Clone)]
|
|
pub struct AccessibleChannel {
|
|
/// The channel record.
|
|
pub channel: ChannelRecord,
|
|
/// Whether the querying user is an active member of this channel.
|
|
pub is_member: bool,
|
|
}
|
|
|
|
/// Returns full channel records for all channels a user can access:
|
|
/// open channels (visible to everyone) plus channels where the user is an active member.
|
|
///
|
|
/// Uses a LEFT JOIN on channel_members (PK: channel_id + pubkey) which produces at
|
|
/// most one row per channel. Results are ordered stream -> forum -> dm, then by name.
|
|
///
|
|
/// If `visibility_filter` is `Some("open")` or `Some("private")`, only channels with
|
|
/// that visibility value are returned. `None` returns all accessible channels.
|
|
pub async fn get_accessible_channels(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
pubkey: &[u8],
|
|
visibility_filter: Option<&str>,
|
|
member_only: Option<bool>,
|
|
) -> Result<Vec<AccessibleChannel>> {
|
|
// When `member_only` is `Some(true)`, restrict to channels where the user
|
|
// has an active membership (cm.channel_id IS NOT NULL). This is a strict
|
|
// subset of the default result set and is pushed into SQL so the LIMIT 1000
|
|
// applies to the filtered set, not the pre-filter set.
|
|
let membership_clause = if member_only == Some(true) {
|
|
"AND cm.channel_id IS NOT NULL"
|
|
} else {
|
|
"AND (c.visibility = 'open' OR cm.channel_id IS NOT NULL)"
|
|
};
|
|
|
|
let base = format!(
|
|
r#"
|
|
SELECT c.id, c.name, c.channel_type::text AS channel_type,
|
|
c.visibility::text AS visibility, c.description, c.canvas,
|
|
c.created_by, c.created_at, c.updated_at, c.archived_at, c.deleted_at,
|
|
c.nip29_group_id, c.topic_required, c.max_members,
|
|
c.topic, c.topic_set_by, c.topic_set_at,
|
|
c.purpose, c.purpose_set_by, c.purpose_set_at,
|
|
c.ttl_seconds, c.ttl_deadline,
|
|
(cm.channel_id IS NOT NULL) AS is_member
|
|
FROM channels c
|
|
LEFT 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
|
|
WHERE c.community_id = $1 AND c.deleted_at IS NULL
|
|
{membership_clause}
|
|
AND (c.channel_type != 'dm' OR cm.hidden_at IS NULL)
|
|
"#
|
|
);
|
|
|
|
let sql = if visibility_filter.is_some() {
|
|
format!("{base} AND c.visibility::text = $3\n ORDER BY array_position(ARRAY['stream','forum','dm']::text[], c.channel_type::text), c.name\n LIMIT 1000")
|
|
} else {
|
|
format!("{base} ORDER BY array_position(ARRAY['stream','forum','dm']::text[], c.channel_type::text), c.name\n LIMIT 1000")
|
|
};
|
|
|
|
let query = sqlx::query(sqlx::AssertSqlSafe(sql))
|
|
.bind(community_id.as_uuid())
|
|
.bind(pubkey);
|
|
let query = if let Some(vis) = visibility_filter {
|
|
query.bind(vis)
|
|
} else {
|
|
query
|
|
};
|
|
|
|
let rows = query.fetch_all(pool).await?;
|
|
rows.into_iter()
|
|
.map(|row| {
|
|
let is_member: bool = row.try_get("is_member").unwrap_or(false);
|
|
let channel = row_to_channel_record(row)?;
|
|
Ok(AccessibleChannel { channel, is_member })
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Returns all bot-role members with their channel memberships in one community.
|
|
///
|
|
/// Channels are returned as a JSON array of `{name, id}` objects via `json_agg`,
|
|
/// preserving the 1:1 name↔UUID pairing. No separate string_agg ordering issues.
|
|
/// Members with no active channel memberships are excluded (INNER JOIN on channels).
|
|
pub async fn get_bot_members(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
) -> Result<Vec<BotMemberRecord>> {
|
|
let rows = sqlx::query(
|
|
r#"
|
|
SELECT cm.pubkey, u.display_name, u.agent_type, u.capabilities,
|
|
COALESCE(json_agg(DISTINCT jsonb_build_object('name', c.name, 'id', c.id::text)), '[]') AS channels_json
|
|
FROM channel_members cm
|
|
LEFT JOIN users u ON cm.community_id = u.community_id AND cm.pubkey = u.pubkey
|
|
JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL
|
|
WHERE cm.community_id = $1 AND cm.role = 'bot' AND cm.removed_at IS NULL
|
|
GROUP BY cm.pubkey, u.display_name, u.agent_type, u.capabilities
|
|
LIMIT 1000
|
|
"#,
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.fetch_all(pool)
|
|
.await?;
|
|
|
|
let mut out = Vec::with_capacity(rows.len());
|
|
for row in rows {
|
|
let capabilities: Option<serde_json::Value> = row.try_get("capabilities")?;
|
|
let channels_json: serde_json::Value = row
|
|
.try_get::<serde_json::Value, _>("channels_json")
|
|
.unwrap_or(serde_json::Value::Array(vec![]));
|
|
let channels: Vec<BotChannelEntry> =
|
|
serde_json::from_value(channels_json).unwrap_or_default();
|
|
out.push(BotMemberRecord {
|
|
pubkey: row.try_get("pubkey")?,
|
|
display_name: row.try_get("display_name")?,
|
|
agent_type: row.try_get("agent_type")?,
|
|
capabilities,
|
|
channels,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
/// Bulk-fetch user records by pubkey inside one community.
|
|
///
|
|
/// Returns only users that exist in the `users` table. Ordering matches input order
|
|
/// is NOT guaranteed — callers should index by pubkey if order matters.
|
|
/// Returns an empty vec immediately when `pubkeys` is empty (no query issued).
|
|
pub async fn get_users_bulk(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
pubkeys: &[Vec<u8>],
|
|
) -> Result<Vec<UserRecord>> {
|
|
if pubkeys.is_empty() {
|
|
return Ok(Vec::new());
|
|
}
|
|
|
|
// Build a parameterised IN clause: ($2, $3, ...); $1 is community_id.
|
|
let placeholders = (2..(pubkeys.len() + 2))
|
|
.map(|i| format!("${i}"))
|
|
.collect::<Vec<_>>()
|
|
.join(", ");
|
|
let sql = format!(
|
|
"SELECT pubkey, display_name, avatar_url, nip05_handle \
|
|
FROM users WHERE community_id = $1 AND pubkey IN ({placeholders})"
|
|
);
|
|
|
|
let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)).bind(community_id.as_uuid());
|
|
for pk in pubkeys {
|
|
q = q.bind(pk);
|
|
}
|
|
|
|
let rows = q.fetch_all(pool).await?;
|
|
|
|
let mut out = Vec::with_capacity(rows.len());
|
|
for row in rows {
|
|
out.push(UserRecord {
|
|
pubkey: row.try_get("pubkey")?,
|
|
display_name: row.try_get("display_name")?,
|
|
avatar_url: row.try_get("avatar_url")?,
|
|
nip05_handle: row.try_get("nip05_handle")?,
|
|
});
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
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")?;
|
|
|
|
// topic/purpose fields are new — use try_get and fall back to None if the
|
|
// column is absent (e.g. queries that don't SELECT these columns yet).
|
|
let topic: Option<String> = row.try_get("topic").unwrap_or(None);
|
|
let topic_set_by: Option<Vec<u8>> = row.try_get("topic_set_by").unwrap_or(None);
|
|
let topic_set_at: Option<DateTime<Utc>> = row.try_get("topic_set_at").unwrap_or(None);
|
|
let purpose: Option<String> = row.try_get("purpose").unwrap_or(None);
|
|
let purpose_set_by: Option<Vec<u8>> = row.try_get("purpose_set_by").unwrap_or(None);
|
|
let purpose_set_at: Option<DateTime<Utc>> = row.try_get("purpose_set_at").unwrap_or(None);
|
|
let ttl_seconds: Option<i32> = row.try_get("ttl_seconds").unwrap_or(None);
|
|
let ttl_deadline: Option<DateTime<Utc>> = row.try_get("ttl_deadline").unwrap_or(None);
|
|
|
|
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,
|
|
topic_set_by,
|
|
topic_set_at,
|
|
purpose,
|
|
purpose_set_by,
|
|
purpose_set_at,
|
|
ttl_seconds,
|
|
ttl_deadline,
|
|
})
|
|
}
|
|
|
|
fn row_to_member_record(row: sqlx::postgres::PgRow) -> Result<MemberRecord> {
|
|
let channel_id: Uuid = row.try_get("channel_id")?;
|
|
|
|
Ok(MemberRecord {
|
|
channel_id,
|
|
pubkey: row.try_get("pubkey")?,
|
|
role: row.try_get("role")?,
|
|
joined_at: row.try_get("joined_at")?,
|
|
invited_by: row.try_get("invited_by")?,
|
|
removed_at: row.try_get("removed_at")?,
|
|
})
|
|
}
|
|
|
|
/// Partial update for channel metadata. Every field is `None` to leave the
|
|
/// column unchanged.
|
|
#[derive(Default)]
|
|
pub struct ChannelUpdate {
|
|
/// New channel name, or `None` to leave unchanged.
|
|
pub name: Option<String>,
|
|
/// New channel description, or `None` to leave unchanged.
|
|
pub description: Option<String>,
|
|
/// New visibility (`"open"`/`"private"`), or `None` to leave unchanged.
|
|
pub visibility: Option<String>,
|
|
/// TTL change: outer `None` leaves it unchanged, `Some(None)` clears the
|
|
/// ephemeral TTL (channel becomes permanent), `Some(Some(secs))` sets it.
|
|
/// On any change the `ttl_deadline` is reset to `NOW() + ttl_seconds`.
|
|
pub ttl_seconds: Option<Option<i32>>,
|
|
}
|
|
|
|
/// Updates channel metadata dynamically.
|
|
///
|
|
/// At least one field must be provided; returns `InvalidData` otherwise.
|
|
/// Returns the updated `ChannelRecord` on success.
|
|
pub async fn update_channel(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
mut updates: ChannelUpdate,
|
|
) -> Result<ChannelRecord> {
|
|
if updates.name.is_none()
|
|
&& updates.description.is_none()
|
|
&& updates.visibility.is_none()
|
|
&& updates.ttl_seconds.is_none()
|
|
{
|
|
return Err(DbError::InvalidData(
|
|
"at least one field must be provided for update".to_string(),
|
|
));
|
|
}
|
|
|
|
if let Some(name) = updates.name.as_mut() {
|
|
*name = buzz_core::channel::canonical_channel_name(name).to_owned();
|
|
if name.is_empty() {
|
|
return Err(DbError::InvalidData("channel name is required".into()));
|
|
}
|
|
}
|
|
|
|
// Build SET clause dynamically — only include fields that are provided.
|
|
// Track parameter index for positional placeholders.
|
|
let mut set_parts: Vec<String> = Vec::new();
|
|
let mut param_idx: usize = 1;
|
|
if updates.name.is_some() {
|
|
set_parts.push(format!("name = ${param_idx}"));
|
|
param_idx += 1;
|
|
}
|
|
if updates.description.is_some() {
|
|
set_parts.push(format!("description = ${param_idx}"));
|
|
param_idx += 1;
|
|
}
|
|
if updates.visibility.is_some() {
|
|
set_parts.push(format!("visibility = ${param_idx}::channel_visibility"));
|
|
param_idx += 1;
|
|
}
|
|
if let Some(ref ttl) = updates.ttl_seconds {
|
|
// Set ttl_seconds, then reset the deadline from now (or clear both).
|
|
set_parts.push(format!("ttl_seconds = ${param_idx}"));
|
|
param_idx += 1;
|
|
match ttl {
|
|
Some(_) => set_parts.push(format!(
|
|
"ttl_deadline = NOW() + (${} || ' seconds')::interval",
|
|
param_idx - 1
|
|
)),
|
|
None => set_parts.push("ttl_deadline = NULL".to_string()),
|
|
}
|
|
}
|
|
let channel_param_idx = param_idx + 1;
|
|
let sql = format!(
|
|
"UPDATE channels SET {}, updated_at = NOW() WHERE community_id = ${param_idx} AND id = ${channel_param_idx} AND deleted_at IS NULL",
|
|
set_parts.join(", ")
|
|
);
|
|
|
|
let mut q = sqlx::query(sqlx::AssertSqlSafe(sql));
|
|
if let Some(ref name) = updates.name {
|
|
q = q.bind(name);
|
|
}
|
|
if let Some(ref desc) = updates.description {
|
|
q = q.bind(desc);
|
|
}
|
|
if let Some(ref vis) = updates.visibility {
|
|
q = q.bind(vis);
|
|
}
|
|
if let Some(ref ttl) = updates.ttl_seconds {
|
|
q = q.bind(*ttl);
|
|
}
|
|
q = q.bind(community_id.as_uuid());
|
|
q = q.bind(channel_id);
|
|
|
|
// T1a repair: a TTL change can flip this channel's event-trigger fast
|
|
// path (migration 0024 reads ttl_seconds under a SHARED per-channel
|
|
// advisory lock). Take the same key EXCLUSIVE before the UPDATE so a
|
|
// concurrent event either sees the committed TTL or strictly precedes
|
|
// this transition — whose own deadline reset is then the latest word.
|
|
// Non-TTL updates don't touch the fast path and skip the lock.
|
|
if updates.ttl_seconds.is_some() {
|
|
let mut tx = pool.begin().await?;
|
|
sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
|
|
.bind(format!(
|
|
"buzz_channel_ttl:{}:{}",
|
|
community_id.as_uuid(),
|
|
channel_id
|
|
))
|
|
.execute(&mut *tx)
|
|
.await?;
|
|
let result = q.execute(&mut *tx).await?;
|
|
if result.rows_affected() == 0 {
|
|
return Err(DbError::ChannelNotFound(channel_id));
|
|
}
|
|
tx.commit().await?;
|
|
} else {
|
|
let result = q.execute(pool).await?;
|
|
if result.rows_affected() == 0 {
|
|
return Err(DbError::ChannelNotFound(channel_id));
|
|
}
|
|
}
|
|
|
|
get_channel(pool, community_id, channel_id).await
|
|
}
|
|
|
|
/// Sets the topic for a channel, recording who set it and when.
|
|
pub async fn set_topic(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
topic: &str,
|
|
set_by: &[u8],
|
|
) -> Result<()> {
|
|
let result = sqlx::query(
|
|
"UPDATE channels SET topic = $1, topic_set_by = $2, topic_set_at = NOW() \
|
|
WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL",
|
|
)
|
|
.bind(topic)
|
|
.bind(set_by)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.execute(pool)
|
|
.await?;
|
|
if result.rows_affected() == 0 {
|
|
return Err(DbError::ChannelNotFound(channel_id));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Sets the purpose for a channel, recording who set it and when.
|
|
pub async fn set_purpose(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
purpose: &str,
|
|
set_by: &[u8],
|
|
) -> Result<()> {
|
|
let result = sqlx::query(
|
|
"UPDATE channels SET purpose = $1, purpose_set_by = $2, purpose_set_at = NOW() \
|
|
WHERE community_id = $3 AND id = $4 AND deleted_at IS NULL",
|
|
)
|
|
.bind(purpose)
|
|
.bind(set_by)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.execute(pool)
|
|
.await?;
|
|
if result.rows_affected() == 0 {
|
|
return Err(DbError::ChannelNotFound(channel_id));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Archives a channel.
|
|
///
|
|
/// Returns `AccessDenied` if the channel is already archived.
|
|
/// Returns `ChannelNotFound` if the channel does not exist or is deleted.
|
|
pub async fn archive_channel(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
) -> Result<()> {
|
|
// First check: does the channel exist and what is its state?
|
|
let row = sqlx::query(
|
|
"SELECT archived_at FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
|
|
match row {
|
|
None => return Err(DbError::ChannelNotFound(channel_id)),
|
|
Some(r) => {
|
|
let archived_at: Option<DateTime<Utc>> = r.try_get("archived_at")?;
|
|
if archived_at.is_some() {
|
|
return Err(DbError::AccessDenied(
|
|
"channel is already archived".to_string(),
|
|
));
|
|
}
|
|
}
|
|
}
|
|
|
|
sqlx::query(
|
|
"UPDATE channels SET archived_at = NOW() \
|
|
WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL AND archived_at IS NULL",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Unarchives a channel.
|
|
///
|
|
/// Returns `AccessDenied` if the channel is not currently archived.
|
|
/// Returns `ChannelNotFound` if the channel does not exist or is deleted.
|
|
pub async fn unarchive_channel(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
) -> Result<()> {
|
|
// First check: does the channel exist and what is its state?
|
|
let row = sqlx::query(
|
|
"SELECT archived_at FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
|
|
match row {
|
|
None => return Err(DbError::ChannelNotFound(channel_id)),
|
|
Some(r) => {
|
|
let archived_at: Option<DateTime<Utc>> = r.try_get("archived_at")?;
|
|
if archived_at.is_none() {
|
|
return Err(DbError::AccessDenied("channel is not archived".to_string()));
|
|
}
|
|
}
|
|
}
|
|
|
|
sqlx::query(
|
|
"UPDATE channels SET archived_at = NULL, \
|
|
ttl_deadline = CASE \
|
|
WHEN ttl_seconds IS NOT NULL THEN NOW() + (ttl_seconds || ' seconds')::interval \
|
|
ELSE ttl_deadline \
|
|
END \
|
|
WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL AND archived_at IS NOT NULL",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Soft-delete a channel by setting `deleted_at = NOW()`.
|
|
///
|
|
/// Returns `Ok(true)` if the channel was deleted, `Ok(false)` if already
|
|
/// deleted or not found.
|
|
pub async fn soft_delete_channel(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
) -> Result<bool> {
|
|
let result = sqlx::query(
|
|
"UPDATE channels SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.execute(pool)
|
|
.await?;
|
|
|
|
Ok(result.rows_affected() > 0)
|
|
}
|
|
|
|
/// Returns the count of active (non-removed) members in a channel.
|
|
pub async fn get_member_count(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
) -> Result<i64> {
|
|
let row = sqlx::query(
|
|
"SELECT COUNT(*) as cnt FROM channel_members WHERE community_id = $1 AND channel_id = $2 AND removed_at IS NULL",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.fetch_one(pool)
|
|
.await?;
|
|
Ok(row.try_get("cnt")?)
|
|
}
|
|
|
|
/// Bulk-fetch member counts for a set of channel IDs.
|
|
///
|
|
/// Returns a map of `channel_id -> count`. Channels with zero members are omitted.
|
|
/// Single query regardless of input size.
|
|
pub async fn get_member_counts_bulk(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_ids: &[Uuid],
|
|
) -> Result<std::collections::HashMap<Uuid, i64>> {
|
|
if channel_ids.is_empty() {
|
|
return Ok(std::collections::HashMap::new());
|
|
}
|
|
|
|
let mut qb: sqlx::QueryBuilder<sqlx::Postgres> = sqlx::QueryBuilder::new(
|
|
"SELECT channel_id, COUNT(*) as cnt FROM channel_members \
|
|
WHERE community_id = ",
|
|
);
|
|
qb.push_bind(community_id.as_uuid());
|
|
qb.push(" AND removed_at IS NULL AND channel_id IN (");
|
|
let mut sep = qb.separated(", ");
|
|
for id in channel_ids {
|
|
sep.push_bind(*id);
|
|
}
|
|
qb.push(") GROUP BY channel_id");
|
|
|
|
let rows = qb.build().fetch_all(pool).await?;
|
|
|
|
let mut map = std::collections::HashMap::with_capacity(rows.len());
|
|
for row in rows {
|
|
let id: Uuid = row.try_get("channel_id")?;
|
|
let cnt: i64 = row.try_get("cnt")?;
|
|
map.insert(id, cnt);
|
|
}
|
|
Ok(map)
|
|
}
|
|
|
|
/// Get the active role of a pubkey in a channel.
|
|
///
|
|
/// Returns `None` if the pubkey is not an active member.
|
|
pub async fn get_member_role(
|
|
pool: &PgPool,
|
|
community_id: CommunityId,
|
|
channel_id: Uuid,
|
|
pubkey: &[u8],
|
|
) -> Result<Option<String>> {
|
|
let row = sqlx::query(
|
|
"SELECT cm.role::text AS role FROM channel_members cm \
|
|
JOIN channels c ON cm.community_id = c.community_id AND cm.channel_id = c.id AND c.deleted_at IS NULL \
|
|
WHERE cm.community_id = $1 AND cm.channel_id = $2 AND cm.pubkey = $3 AND cm.removed_at IS NULL",
|
|
)
|
|
.bind(community_id.as_uuid())
|
|
.bind(channel_id)
|
|
.bind(pubkey)
|
|
.fetch_optional(pool)
|
|
.await?;
|
|
Ok(row.map(|r| r.try_get("role")).transpose()?)
|
|
}
|
|
|
|
/// Archive ephemeral channels whose TTL deadline has passed.
|
|
///
|
|
/// Returns the `(community_id, host, channel_id)` list that was archived. Idempotent — the
|
|
/// `archived_at IS NULL` guard prevents double-archiving even if called
|
|
/// concurrently from multiple relay pods.
|
|
pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result<Vec<ReapedEphemeralChannel>> {
|
|
let rows = sqlx::query(
|
|
"UPDATE channels AS ch SET archived_at = NOW() \
|
|
FROM communities AS c \
|
|
WHERE ch.community_id = c.id \
|
|
AND ch.ttl_seconds IS NOT NULL \
|
|
AND ch.ttl_deadline < NOW() \
|
|
AND ch.archived_at IS NULL \
|
|
AND ch.deleted_at IS NULL \
|
|
AND c.archived_at IS NULL \
|
|
AND community_write_allowed(ch.community_id) \
|
|
RETURNING ch.community_id, c.host, ch.id",
|
|
)
|
|
.fetch_all(pool)
|
|
.await?;
|
|
|
|
rows.into_iter()
|
|
.map(|row| {
|
|
let community_id: Uuid = row.try_get("community_id")?;
|
|
let host: String = row.try_get("host")?;
|
|
let channel_id: Uuid = row.try_get("id")?;
|
|
Ok(ReapedEphemeralChannel {
|
|
community_id: CommunityId::from_uuid(community_id),
|
|
host,
|
|
channel_id,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::user::{ensure_user, set_agent_owner};
|
|
use nostr::Keys;
|
|
use sqlx::postgres::PgPoolOptions;
|
|
|
|
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 {
|
|
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_test_community(pool: &PgPool) -> Uuid {
|
|
let id = Uuid::new_v4();
|
|
let host = format!("channel-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
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
async fn create_test_channel(
|
|
pool: &PgPool,
|
|
community_id: Uuid,
|
|
name: &str,
|
|
channel_type: ChannelType,
|
|
visibility: ChannelVisibility,
|
|
description: Option<&str>,
|
|
created_by: &[u8],
|
|
ttl_seconds: Option<i32>,
|
|
) -> Result<ChannelRecord> {
|
|
let id = Uuid::new_v4();
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO channels
|
|
(id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline)
|
|
VALUES
|
|
($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8,
|
|
CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END)
|
|
"#,
|
|
)
|
|
.bind(id)
|
|
.bind(community_id)
|
|
.bind(name)
|
|
.bind(channel_type.as_str())
|
|
.bind(visibility.as_str())
|
|
.bind(description)
|
|
.bind(created_by)
|
|
.bind(ttl_seconds)
|
|
.execute(pool)
|
|
.await
|
|
.expect("insert test channel");
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by)
|
|
VALUES ($1, $2, $3, 'owner', $4)
|
|
"#,
|
|
)
|
|
.bind(community_id)
|
|
.bind(id)
|
|
.bind(created_by)
|
|
.bind(created_by)
|
|
.execute(pool)
|
|
.await
|
|
.expect("insert owner membership");
|
|
|
|
get_channel(pool, CommunityId::from_uuid(community_id), id).await
|
|
}
|
|
|
|
async fn insert_channel_with_id(
|
|
pool: &PgPool,
|
|
community_id: Uuid,
|
|
id: Uuid,
|
|
name: &str,
|
|
created_by: &[u8],
|
|
) {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO channels
|
|
(id, community_id, name, channel_type, visibility, created_by)
|
|
VALUES
|
|
($1, $2, $3, 'stream', 'open', $4)
|
|
"#,
|
|
)
|
|
.bind(id)
|
|
.bind(community_id)
|
|
.bind(name)
|
|
.bind(created_by)
|
|
.execute(pool)
|
|
.await
|
|
.expect("insert channel with fixed id");
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn get_users_bulk_is_scoped_when_pubkey_exists_in_multiple_communities() {
|
|
let pool = setup_pool().await;
|
|
let community_a = make_test_community(&pool).await;
|
|
let community_b = make_test_community(&pool).await;
|
|
let community_a = CommunityId::from_uuid(community_a);
|
|
let community_b = CommunityId::from_uuid(community_b);
|
|
let pubkey = random_pubkey();
|
|
|
|
sqlx::query(
|
|
"INSERT INTO users (community_id, pubkey, display_name) VALUES ($1, $2, $3), ($4, $5, $6)",
|
|
)
|
|
.bind(community_a.as_uuid())
|
|
.bind(&pubkey)
|
|
.bind("community-a-profile")
|
|
.bind(community_b.as_uuid())
|
|
.bind(&pubkey)
|
|
.bind("community-b-profile")
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert same pubkey in two communities");
|
|
|
|
let users = get_users_bulk(&pool, community_a, std::slice::from_ref(&pubkey))
|
|
.await
|
|
.expect("bulk fetch users");
|
|
|
|
assert_eq!(users.len(), 1);
|
|
assert_eq!(
|
|
users[0].display_name.as_deref(),
|
|
Some("community-a-profile")
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn get_channel_is_scoped_when_channel_uuid_collides_across_communities() {
|
|
let pool = setup_pool().await;
|
|
let community_a = make_test_community(&pool).await;
|
|
let community_b = make_test_community(&pool).await;
|
|
let channel_id = Uuid::new_v4();
|
|
let creator = random_pubkey();
|
|
|
|
insert_channel_with_id(
|
|
&pool,
|
|
community_a,
|
|
channel_id,
|
|
"community-a-channel",
|
|
&creator,
|
|
)
|
|
.await;
|
|
insert_channel_with_id(
|
|
&pool,
|
|
community_b,
|
|
channel_id,
|
|
"community-b-channel",
|
|
&creator,
|
|
)
|
|
.await;
|
|
|
|
let a = get_channel(&pool, CommunityId::from_uuid(community_a), channel_id)
|
|
.await
|
|
.expect("community A channel should resolve");
|
|
let b = get_channel(&pool, CommunityId::from_uuid(community_b), channel_id)
|
|
.await
|
|
.expect("community B channel should resolve");
|
|
|
|
assert_eq!(a.name, "community-a-channel");
|
|
assert_eq!(b.name, "community-b-channel");
|
|
|
|
let listed_a = list_channels(&pool, CommunityId::from_uuid(community_a), None)
|
|
.await
|
|
.expect("list community A channels");
|
|
assert!(listed_a
|
|
.iter()
|
|
.any(|row| row.id == channel_id && row.name == "community-a-channel"));
|
|
assert!(!listed_a
|
|
.iter()
|
|
.any(|row| row.id == channel_id && row.name == "community-b-channel"));
|
|
}
|
|
|
|
/// Agent owner (non-admin) can remove their own bot from a channel.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn test_agent_owner_can_remove_bot() {
|
|
let pool = setup_pool().await;
|
|
let community_id = make_test_community(&pool).await;
|
|
let community = CommunityId::from_uuid(community_id);
|
|
let owner_pk = random_pubkey();
|
|
let agent_pk = random_pubkey();
|
|
|
|
// Create users and set agent ownership
|
|
ensure_user(&pool, community, &owner_pk)
|
|
.await
|
|
.expect("ensure owner");
|
|
ensure_user(&pool, community, &agent_pk)
|
|
.await
|
|
.expect("ensure agent");
|
|
set_agent_owner(&pool, community, &agent_pk, &owner_pk)
|
|
.await
|
|
.expect("set agent owner");
|
|
|
|
// Create a channel owned by someone else entirely
|
|
let channel_owner_pk = random_pubkey();
|
|
ensure_user(&pool, community, &channel_owner_pk)
|
|
.await
|
|
.expect("ensure channel owner");
|
|
let channel = create_test_channel(
|
|
&pool,
|
|
community_id,
|
|
"test-bot-remove",
|
|
ChannelType::Stream,
|
|
ChannelVisibility::Open,
|
|
None,
|
|
&channel_owner_pk,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create channel");
|
|
|
|
// Add owner and agent as regular members
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&owner_pk,
|
|
MemberRole::Member,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("add owner as member");
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&agent_pk,
|
|
MemberRole::Member,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("add agent as member");
|
|
|
|
// Owner should be able to remove their agent
|
|
remove_member(&pool, community, channel.id, &agent_pk, &owner_pk)
|
|
.await
|
|
.expect("agent owner should be able to remove their bot");
|
|
|
|
// Verify the agent is no longer a member
|
|
assert!(
|
|
!is_member(&pool, community, channel.id, &agent_pk)
|
|
.await
|
|
.expect("is_member check"),
|
|
"agent should no longer be a member"
|
|
);
|
|
}
|
|
|
|
/// Unarchiving an expired ephemeral channel renews its TTL lease so the
|
|
/// reaper does not immediately archive it again.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn test_unarchive_expired_ephemeral_channel_renews_ttl_deadline() {
|
|
let pool = setup_pool().await;
|
|
let community_id = make_test_community(&pool).await;
|
|
let community = CommunityId::from_uuid(community_id);
|
|
let owner_pk = random_pubkey();
|
|
ensure_user(&pool, community, &owner_pk)
|
|
.await
|
|
.expect("ensure owner");
|
|
|
|
let channel = create_test_channel(
|
|
&pool,
|
|
community_id,
|
|
"test-unarchive-renews-ttl",
|
|
ChannelType::Stream,
|
|
ChannelVisibility::Open,
|
|
None,
|
|
&owner_pk,
|
|
Some(60),
|
|
)
|
|
.await
|
|
.expect("create ephemeral channel");
|
|
|
|
sqlx::query(
|
|
"UPDATE channels SET archived_at = NOW(), ttl_deadline = NOW() - interval '1 second' WHERE community_id = $1 AND id = $2",
|
|
)
|
|
.bind(community_id)
|
|
.bind(channel.id)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("expire and archive channel");
|
|
|
|
unarchive_channel(&pool, community, channel.id)
|
|
.await
|
|
.expect("unarchive expired ephemeral channel");
|
|
|
|
let channel = get_channel(&pool, community, channel.id)
|
|
.await
|
|
.expect("reload channel");
|
|
assert!(
|
|
channel.archived_at.is_none(),
|
|
"channel should be unarchived"
|
|
);
|
|
assert!(
|
|
channel.ttl_deadline.expect("ttl deadline") > Utc::now(),
|
|
"unarchive should renew ttl_deadline into the future"
|
|
);
|
|
|
|
let reaped = reap_expired_ephemeral_channels(&pool)
|
|
.await
|
|
.expect("run reaper");
|
|
assert!(
|
|
!reaped
|
|
.iter()
|
|
.any(|row| row.community_id == community && row.channel_id == channel.id),
|
|
"reaper should not immediately rearchive renewed channel"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn reap_expired_ephemeral_channels_returns_row_community_and_host() {
|
|
let pool = setup_pool().await;
|
|
let community_id = make_test_community(&pool).await;
|
|
let community = CommunityId::from_uuid(community_id);
|
|
let expected_host: String =
|
|
sqlx::query_scalar("SELECT host FROM communities WHERE id = $1")
|
|
.bind(community_id)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("load community host");
|
|
let owner_pk = random_pubkey();
|
|
ensure_user(&pool, community, &owner_pk)
|
|
.await
|
|
.expect("ensure owner");
|
|
let channel = create_test_channel(
|
|
&pool,
|
|
community_id,
|
|
"test-reaper-host-provenance",
|
|
ChannelType::Stream,
|
|
ChannelVisibility::Open,
|
|
None,
|
|
&owner_pk,
|
|
Some(60),
|
|
)
|
|
.await
|
|
.expect("create ephemeral channel");
|
|
|
|
sqlx::query(
|
|
"UPDATE channels SET ttl_deadline = NOW() - interval '1 second' WHERE community_id = $1 AND id = $2",
|
|
)
|
|
.bind(community_id)
|
|
.bind(channel.id)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("expire channel");
|
|
|
|
let reaped = reap_expired_ephemeral_channels(&pool)
|
|
.await
|
|
.expect("run reaper");
|
|
assert!(
|
|
reaped.iter().any(|row| {
|
|
row.community_id == community
|
|
&& row.host == expected_host
|
|
&& row.channel_id == channel.id
|
|
}),
|
|
"reaper should carry the archived row's community id and host"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn accessible_channel_ids_are_not_truncated_at_one_thousand() {
|
|
let database_url =
|
|
std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string());
|
|
let pool = PgPool::connect(&database_url)
|
|
.await
|
|
.expect("connect to test DB");
|
|
let community_id = make_test_community(&pool).await;
|
|
let community = CommunityId::from_uuid(community_id);
|
|
let viewer = random_pubkey();
|
|
let channel_count = 1_001;
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO channels (id, community_id, name, channel_type, visibility, created_by)
|
|
SELECT gen_random_uuid(), $1, 'high-volume-' || n, 'stream', 'open', $2
|
|
FROM generate_series(1, $3) n
|
|
"#,
|
|
)
|
|
.bind(community_id)
|
|
.bind(&viewer)
|
|
.bind(channel_count)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert high-volume open channels");
|
|
|
|
let channel_ids = get_accessible_channel_ids(&pool, community, &viewer)
|
|
.await
|
|
.expect("load accessible channel ids");
|
|
assert_eq!(channel_ids.len(), channel_count as usize);
|
|
}
|
|
|
|
/// `get_members` must return the complete roster, not a truncated prefix.
|
|
///
|
|
/// The relay builds the kind 39002 (NIP-29 group members) snapshot and every
|
|
/// admin role lookup from this list, so a cap silently hides late joiners:
|
|
/// their clients never discover the channel, and an owner past the cutoff
|
|
/// reads as a non-member.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn get_members_returns_full_roster_beyond_1000() {
|
|
let database_url =
|
|
std::env::var("BUZZ_TEST_DATABASE_URL").unwrap_or_else(|_| TEST_DB_URL.to_string());
|
|
let pool = PgPool::connect(&database_url)
|
|
.await
|
|
.expect("connect to test DB");
|
|
let community_id = make_test_community(&pool).await;
|
|
let community = CommunityId::from_uuid(community_id);
|
|
let creator = random_pubkey();
|
|
|
|
// create_test_channel also inserts the creator as the first (owner) member.
|
|
let channel = create_test_channel(
|
|
&pool,
|
|
community_id,
|
|
"high-volume-roster",
|
|
ChannelType::Stream,
|
|
ChannelVisibility::Open,
|
|
None,
|
|
&creator,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create test channel");
|
|
|
|
// Bulk-insert additional members with strictly increasing `joined_at`, so
|
|
// member N lands at roster position N (the creator holds position 0).
|
|
// The final member is an owner joining well past the old 1000-row cutoff.
|
|
let extra_members = 1_500;
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at)
|
|
SELECT
|
|
$1,
|
|
$2,
|
|
decode(lpad(to_hex(n), 64, '0'), 'hex'),
|
|
(CASE WHEN n = $3 THEN 'owner' ELSE 'member' END)::member_role,
|
|
NOW() + (n || ' seconds')::interval
|
|
FROM generate_series(1, $3) n
|
|
"#,
|
|
)
|
|
.bind(community_id)
|
|
.bind(channel.id)
|
|
.bind(extra_members)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert high-volume channel members");
|
|
|
|
let members = get_members(&pool, community, channel.id)
|
|
.await
|
|
.expect("load channel members");
|
|
|
|
assert_eq!(
|
|
members.len(),
|
|
extra_members as usize + 1,
|
|
"get_members truncated the roster"
|
|
);
|
|
|
|
// The last joiner sits at the final roster position — past any
|
|
// 1000-row cap — which also pins the documented `joined_at` ordering.
|
|
let late_owner = hex::decode(format!("{:064x}", extra_members)).expect("hex pubkey");
|
|
let late = members.last().expect("roster is non-empty");
|
|
assert_eq!(
|
|
late.pubkey, late_owner,
|
|
"member who joined after the 1000th must be present and ordered last"
|
|
);
|
|
assert_eq!(
|
|
late.role, "owner",
|
|
"role of a late-joining owner must resolve correctly"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn large_roster_reconciliation_candidates_respect_snapshot_count_and_signer() {
|
|
let pool = setup_pool().await;
|
|
let community_id = make_test_community(&pool).await;
|
|
let community = CommunityId::from_uuid(community_id);
|
|
let creator = random_pubkey();
|
|
let relay_pubkey = random_pubkey();
|
|
let other_relay_pubkey = random_pubkey();
|
|
let channel = create_test_channel(
|
|
&pool,
|
|
community_id,
|
|
"stale-large-roster",
|
|
ChannelType::Stream,
|
|
ChannelVisibility::Open,
|
|
None,
|
|
&creator,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create test channel");
|
|
|
|
let extra_members = 1_500;
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at)
|
|
SELECT $1, $2, decode(lpad(to_hex(n), 64, '0'), 'hex'), 'member',
|
|
NOW() + (n || ' seconds')::interval
|
|
FROM generate_series(1, $3) n
|
|
"#,
|
|
)
|
|
.bind(community_id)
|
|
.bind(channel.id)
|
|
.bind(extra_members)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert large roster");
|
|
|
|
let stale_tags: Vec<serde_json::Value> =
|
|
std::iter::once(serde_json::json!(["d", channel.id.to_string()]))
|
|
.chain((0..1_000).map(|n| serde_json::json!(["p", format!("{n:064x}")])))
|
|
.collect();
|
|
let complete_tags: Vec<serde_json::Value> =
|
|
std::iter::once(serde_json::json!(["d", channel.id.to_string()]))
|
|
.chain((0..1_501).map(|n| serde_json::json!(["p", format!("{n:064x}")])))
|
|
.collect();
|
|
|
|
// Insert canonical-looking history first, then corrupt the newest row
|
|
// with UPDATE to model a stale snapshot that predates migration 0032's
|
|
// INSERT fence. New stale snapshots cannot be inserted once that fence
|
|
// is deployed.
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO events
|
|
(community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id, d_tag)
|
|
VALUES
|
|
($1, $2, $3, NOW() - INTERVAL '1 minute', 39002, $4, '', $5, $6, $7),
|
|
($1, $8, $3, NOW(), 39002, $4, '', $5, $6, $7)
|
|
"#,
|
|
)
|
|
.bind(community_id)
|
|
.bind(random_pubkey())
|
|
.bind(&relay_pubkey)
|
|
.bind(serde_json::Value::Array(complete_tags.clone()))
|
|
.bind(vec![0u8; 64])
|
|
.bind(channel.id)
|
|
.bind(channel.id.to_string())
|
|
.bind(random_pubkey())
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert historical duplicate snapshots");
|
|
sqlx::query(
|
|
"UPDATE events SET tags = $1 WHERE community_id = $2 AND channel_id = $3 \
|
|
AND kind = 39002 AND pubkey = $4 AND created_at = (SELECT MAX(created_at) \
|
|
FROM events WHERE community_id = $2 AND channel_id = $3 AND kind = 39002 AND pubkey = $4)",
|
|
)
|
|
.bind(serde_json::Value::Array(stale_tags))
|
|
.bind(community_id)
|
|
.bind(channel.id)
|
|
.bind(&relay_pubkey)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("simulate pre-fence stale live snapshot");
|
|
|
|
// The same channel UUID in another tenant is deliberately valid. A
|
|
// complete snapshot there must not mask this tenant's stale head.
|
|
let other_community_id = make_test_community(&pool).await;
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO channels
|
|
(id, community_id, name, channel_type, visibility, created_by)
|
|
VALUES ($1, $2, 'same-id-complete-roster', 'stream', 'open', $3)
|
|
"#,
|
|
)
|
|
.bind(channel.id)
|
|
.bind(other_community_id)
|
|
.bind(&creator)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert same channel id in other tenant");
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at)
|
|
SELECT $1, $2, decode(lpad(to_hex(n), 64, '0'), 'hex'), 'member',
|
|
NOW() + (n || ' seconds')::interval
|
|
FROM generate_series(0, 1500) n
|
|
"#,
|
|
)
|
|
.bind(other_community_id)
|
|
.bind(channel.id)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert complete other-tenant roster");
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO events
|
|
(community_id, id, pubkey, created_at, kind, tags, content, sig, channel_id, d_tag)
|
|
VALUES ($1, $2, $3, NOW(), 39002, $4, '', $5, $6, $7)
|
|
"#,
|
|
)
|
|
.bind(other_community_id)
|
|
.bind(random_pubkey())
|
|
.bind(&relay_pubkey)
|
|
.bind(serde_json::Value::Array(complete_tags.clone()))
|
|
.bind(vec![0u8; 64])
|
|
.bind(channel.id)
|
|
.bind(channel.id.to_string())
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert complete other-tenant snapshot");
|
|
|
|
// Put the stale channel behind the 1,000 newest channels that the old
|
|
// list_channels-based sweep could see. This set-based scan has no such
|
|
// pagination ceiling.
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO channels
|
|
(id, community_id, name, channel_type, visibility, created_by, created_at)
|
|
SELECT gen_random_uuid(), $1, 'newer-decoy-' || n, 'stream', 'open', $2,
|
|
NOW() + (n || ' seconds')::interval
|
|
FROM generate_series(1, 1000) n
|
|
"#,
|
|
)
|
|
.bind(community_id)
|
|
.bind(&creator)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("insert channels beyond old list ceiling");
|
|
|
|
let candidates =
|
|
list_large_channel_rosters_needing_reconciliation(&pool, 1_000, &relay_pubkey)
|
|
.await
|
|
.expect("find stale snapshot");
|
|
assert_eq!(candidates.len(), 1);
|
|
assert_eq!(candidates[0].community_id, community);
|
|
assert_eq!(candidates[0].channel_id, channel.id);
|
|
assert_eq!(candidates[0].member_count, 1_501);
|
|
|
|
let other_signer_candidates =
|
|
list_large_channel_rosters_needing_reconciliation(&pool, 1_000, &other_relay_pubkey)
|
|
.await
|
|
.expect("other signer is isolated from relay-authored snapshot");
|
|
assert!(other_signer_candidates.is_empty());
|
|
|
|
sqlx::query(
|
|
"UPDATE events SET tags = $1, created_at = NOW() + INTERVAL '1 minute' WHERE community_id = $2 AND channel_id = $3 AND kind = 39002 AND pubkey = $4 AND created_at = (SELECT MAX(created_at) FROM events WHERE community_id = $2 AND channel_id = $3 AND kind = 39002 AND pubkey = $4 AND deleted_at IS NULL)",
|
|
)
|
|
.bind(serde_json::Value::Array(complete_tags))
|
|
.bind(community_id)
|
|
.bind(channel.id)
|
|
.bind(&relay_pubkey)
|
|
.execute(&pool)
|
|
.await
|
|
.expect("complete snapshot");
|
|
|
|
let converged =
|
|
list_large_channel_rosters_needing_reconciliation(&pool, 1_000, &relay_pubkey)
|
|
.await
|
|
.expect("check converged snapshot");
|
|
assert!(converged.is_empty());
|
|
}
|
|
|
|
/// A random non-admin, non-owner user cannot remove someone else's bot.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn test_random_user_cannot_remove_bot() {
|
|
let pool = setup_pool().await;
|
|
let community_id = make_test_community(&pool).await;
|
|
let community = CommunityId::from_uuid(community_id);
|
|
let owner_pk = random_pubkey();
|
|
let agent_pk = random_pubkey();
|
|
let random_pk = random_pubkey();
|
|
|
|
// Create users and set agent ownership
|
|
ensure_user(&pool, community, &owner_pk)
|
|
.await
|
|
.expect("ensure owner");
|
|
ensure_user(&pool, community, &agent_pk)
|
|
.await
|
|
.expect("ensure agent");
|
|
ensure_user(&pool, community, &random_pk)
|
|
.await
|
|
.expect("ensure random");
|
|
set_agent_owner(&pool, community, &agent_pk, &owner_pk)
|
|
.await
|
|
.expect("set agent owner");
|
|
|
|
// Create a channel
|
|
let channel_owner_pk = random_pubkey();
|
|
ensure_user(&pool, community, &channel_owner_pk)
|
|
.await
|
|
.expect("ensure channel owner");
|
|
let channel = create_test_channel(
|
|
&pool,
|
|
community_id,
|
|
"test-bot-no-remove",
|
|
ChannelType::Stream,
|
|
ChannelVisibility::Open,
|
|
None,
|
|
&channel_owner_pk,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create channel");
|
|
|
|
// Add random user and agent as regular members
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&random_pk,
|
|
MemberRole::Member,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("add random as member");
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&agent_pk,
|
|
MemberRole::Member,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("add agent as member");
|
|
|
|
// Random user should NOT be able to remove the agent
|
|
let result = remove_member(&pool, community, channel.id, &agent_pk, &random_pk).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"random user should not be able to remove someone else's bot"
|
|
);
|
|
}
|
|
|
|
/// SECURITY REPRO (Dawn, kind:9000 demotion report): an unprivileged plain
|
|
/// member calls add_member with role=Member against the channel OWNER.
|
|
/// If this succeeds, add_member has no demotion authorization and no
|
|
/// last-owner guard.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn repro_unprivileged_member_can_demote_owner() {
|
|
let pool = setup_pool().await;
|
|
let community_id = make_test_community(&pool).await;
|
|
let community = CommunityId::from_uuid(community_id);
|
|
let victim_owner = random_pubkey();
|
|
let attacker = random_pubkey();
|
|
|
|
for pk in [&victim_owner, &attacker] {
|
|
ensure_user(&pool, community, pk)
|
|
.await
|
|
.expect("ensure user");
|
|
}
|
|
|
|
let channel = create_test_channel(
|
|
&pool,
|
|
community_id,
|
|
"repro-demote-owner",
|
|
ChannelType::Stream,
|
|
ChannelVisibility::Open,
|
|
None,
|
|
&victim_owner,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create channel");
|
|
|
|
// create_test_channel already seeds the creator as 'owner', mirroring
|
|
// create_channel's own INSERT (channel.rs:131-145).
|
|
let role_of = |members: Vec<MemberRecord>, pk: Vec<u8>| -> Option<String> {
|
|
members.into_iter().find(|m| m.pubkey == pk).map(|m| m.role)
|
|
};
|
|
let before = role_of(
|
|
get_members(&pool, community, channel.id)
|
|
.await
|
|
.expect("members"),
|
|
victim_owner.clone(),
|
|
);
|
|
assert_eq!(
|
|
before.as_deref(),
|
|
Some("owner"),
|
|
"victim must start as owner"
|
|
);
|
|
|
|
// Attacker: plain member, not owner/admin.
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&attacker,
|
|
MemberRole::Member,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("attacker self-joins open channel");
|
|
|
|
// The attack: attacker is `invited_by` and demotes the owner.
|
|
let res = add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&victim_owner,
|
|
MemberRole::Member,
|
|
Some(&attacker),
|
|
)
|
|
.await;
|
|
|
|
let after = role_of(
|
|
get_members(&pool, community, channel.id)
|
|
.await
|
|
.expect("members"),
|
|
victim_owner.clone(),
|
|
);
|
|
let owners = get_members(&pool, community, channel.id)
|
|
.await
|
|
.expect("members")
|
|
.into_iter()
|
|
.filter(|m| m.role == "owner")
|
|
.count();
|
|
|
|
assert!(
|
|
res.is_err(),
|
|
"unprivileged member must not be able to demote the owner"
|
|
);
|
|
assert_eq!(after.as_deref(), Some("owner"), "owner role must survive");
|
|
assert_eq!(owners, 1, "channel must still have its owner");
|
|
}
|
|
|
|
/// SECURITY REPRO (Dawn): same demotion on a PRIVATE channel, where the
|
|
/// attacker is a plain member. The report claims any member suffices here.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn repro_private_channel_member_can_demote_owner() {
|
|
let pool = setup_pool().await;
|
|
let community_id = make_test_community(&pool).await;
|
|
let community = CommunityId::from_uuid(community_id);
|
|
let victim_owner = random_pubkey();
|
|
let attacker = random_pubkey();
|
|
|
|
for pk in [&victim_owner, &attacker] {
|
|
ensure_user(&pool, community, pk)
|
|
.await
|
|
.expect("ensure user");
|
|
}
|
|
|
|
let channel = create_test_channel(
|
|
&pool,
|
|
community_id,
|
|
"repro-demote-owner-private",
|
|
ChannelType::Stream,
|
|
ChannelVisibility::Private,
|
|
None,
|
|
&victim_owner,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create private channel");
|
|
|
|
// Owner invites the attacker as a plain member (legitimate).
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&attacker,
|
|
MemberRole::Member,
|
|
Some(&victim_owner),
|
|
)
|
|
.await
|
|
.expect("owner invites attacker");
|
|
|
|
// Attack: plain member demotes the owner.
|
|
let res = add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&victim_owner,
|
|
MemberRole::Member,
|
|
Some(&attacker),
|
|
)
|
|
.await;
|
|
|
|
let members = get_members(&pool, community, channel.id)
|
|
.await
|
|
.expect("members");
|
|
let victim_role = members
|
|
.iter()
|
|
.find(|m| m.pubkey == victim_owner)
|
|
.map(|m| m.role.clone());
|
|
let owners = members.iter().filter(|m| m.role == "owner").count();
|
|
|
|
assert!(
|
|
res.is_err(),
|
|
"plain member must not be able to demote the owner on a private channel"
|
|
);
|
|
assert_eq!(
|
|
victim_role.as_deref(),
|
|
Some("owner"),
|
|
"owner role must survive"
|
|
);
|
|
assert_eq!(owners, 1, "channel must still have its owner");
|
|
}
|
|
|
|
/// The fix must not break legitimate role management: an owner demoting a
|
|
/// co-owner (while another owner remains) must still succeed, and promotion
|
|
/// by an owner must still succeed.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn owner_can_still_manage_roles_after_demotion_guard() {
|
|
let pool = setup_pool().await;
|
|
let community_id = make_test_community(&pool).await;
|
|
let community = CommunityId::from_uuid(community_id);
|
|
let owner = random_pubkey();
|
|
let other = random_pubkey();
|
|
|
|
for pk in [&owner, &other] {
|
|
ensure_user(&pool, community, pk)
|
|
.await
|
|
.expect("ensure user");
|
|
}
|
|
|
|
let channel = create_test_channel(
|
|
&pool,
|
|
community_id,
|
|
"roles-still-manageable",
|
|
ChannelType::Stream,
|
|
ChannelVisibility::Open,
|
|
None,
|
|
&owner,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create channel");
|
|
|
|
// Owner promotes `other` to owner — allowed (actor is elevated).
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&other,
|
|
MemberRole::Owner,
|
|
Some(&owner),
|
|
)
|
|
.await
|
|
.expect("owner may promote to owner");
|
|
|
|
// Owner demotes the co-owner back to member — allowed: actor is elevated
|
|
// and another owner remains, so the last-owner guard does not trip.
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&other,
|
|
MemberRole::Member,
|
|
Some(&owner),
|
|
)
|
|
.await
|
|
.expect("owner may demote a co-owner while another owner remains");
|
|
|
|
let members = get_members(&pool, community, channel.id)
|
|
.await
|
|
.expect("members");
|
|
let role_of = |pk: &Vec<u8>| {
|
|
members
|
|
.iter()
|
|
.find(|m| &m.pubkey == pk)
|
|
.map(|m| m.role.clone())
|
|
};
|
|
assert_eq!(role_of(&other).as_deref(), Some("member"));
|
|
assert_eq!(role_of(&owner).as_deref(), Some("owner"));
|
|
|
|
// Idempotent re-add at the SAME role must stay unguarded even from a
|
|
// non-elevated actor — the huddle bot-add path depends on this.
|
|
let bot = random_pubkey();
|
|
ensure_user(&pool, community, &bot)
|
|
.await
|
|
.expect("ensure bot");
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&bot,
|
|
MemberRole::Bot,
|
|
Some(&owner),
|
|
)
|
|
.await
|
|
.expect("add bot");
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&bot,
|
|
MemberRole::Bot,
|
|
Some(&other),
|
|
)
|
|
.await
|
|
.expect("re-adding at the same role must remain idempotent");
|
|
|
|
// But the last owner cannot be demoted, even by themselves.
|
|
let err = add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&owner,
|
|
MemberRole::Member,
|
|
Some(&owner),
|
|
)
|
|
.await
|
|
.expect_err("last owner must not be demotable");
|
|
println!("last-owner demotion rejected: {err}");
|
|
}
|
|
|
|
/// Isolates the actor-authorization guard from the last-owner guard.
|
|
///
|
|
/// `repro_unprivileged_member_can_demote_owner` demotes the *sole* owner, so
|
|
/// the last-owner guard alone is enough to reject it: stubbing out the actor
|
|
/// check leaves that test green and the authorization hole invisible. Here a
|
|
/// second owner remains, so the last-owner guard cannot fire and only the
|
|
/// actor check stands between an unprivileged member and a co-owner's role.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn unprivileged_member_cannot_demote_a_co_owner() {
|
|
let pool = setup_pool().await;
|
|
let community_id = make_test_community(&pool).await;
|
|
let community = CommunityId::from_uuid(community_id);
|
|
let owner = random_pubkey();
|
|
let co_owner = random_pubkey();
|
|
let attacker = random_pubkey();
|
|
|
|
for pk in [&owner, &co_owner, &attacker] {
|
|
ensure_user(&pool, community, pk)
|
|
.await
|
|
.expect("ensure user");
|
|
}
|
|
|
|
let channel = create_test_channel(
|
|
&pool,
|
|
community_id,
|
|
"co-owner-demotion-authz",
|
|
ChannelType::Stream,
|
|
ChannelVisibility::Open,
|
|
None,
|
|
&owner,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create channel");
|
|
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&co_owner,
|
|
MemberRole::Owner,
|
|
Some(&owner),
|
|
)
|
|
.await
|
|
.expect("owner may promote a co-owner");
|
|
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&attacker,
|
|
MemberRole::Member,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("attacker self-joins the open channel");
|
|
|
|
// Two owners remain, so the last-owner guard cannot reject this. Only
|
|
// the actor-authorization check can.
|
|
let err = add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&co_owner,
|
|
MemberRole::Member,
|
|
Some(&attacker),
|
|
)
|
|
.await
|
|
.expect_err("an unprivileged member must not demote a co-owner");
|
|
println!("co-owner demotion by unprivileged actor rejected: {err}");
|
|
|
|
let members = get_members(&pool, community, channel.id)
|
|
.await
|
|
.expect("members");
|
|
let role_of = |pk: &Vec<u8>| {
|
|
members
|
|
.iter()
|
|
.find(|m| &m.pubkey == pk)
|
|
.map(|m| m.role.clone())
|
|
};
|
|
assert_eq!(
|
|
role_of(&co_owner).as_deref(),
|
|
Some("owner"),
|
|
"co-owner must keep their role"
|
|
);
|
|
assert_eq!(
|
|
members.iter().filter(|m| m.role == "owner").count(),
|
|
2,
|
|
"both owners must survive"
|
|
);
|
|
}
|
|
|
|
/// Sets up an open channel with exactly two owners, returning
|
|
/// `(community, channel_id, owner_a, owner_b)`.
|
|
async fn channel_with_two_owners(
|
|
pool: &PgPool,
|
|
name: &str,
|
|
) -> (CommunityId, Uuid, Vec<u8>, Vec<u8>) {
|
|
let community_id = make_test_community(pool).await;
|
|
let community = CommunityId::from_uuid(community_id);
|
|
let owner_a = random_pubkey();
|
|
let owner_b = random_pubkey();
|
|
for pk in [&owner_a, &owner_b] {
|
|
ensure_user(pool, community, pk).await.expect("ensure user");
|
|
}
|
|
|
|
let channel = create_test_channel(
|
|
pool,
|
|
community_id,
|
|
name,
|
|
ChannelType::Stream,
|
|
ChannelVisibility::Open,
|
|
None,
|
|
&owner_a,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create channel");
|
|
|
|
add_member(
|
|
pool,
|
|
community,
|
|
channel.id,
|
|
&owner_b,
|
|
MemberRole::Owner,
|
|
Some(&owner_a),
|
|
)
|
|
.await
|
|
.expect("promote second owner");
|
|
|
|
(community, channel.id, owner_a, owner_b)
|
|
}
|
|
|
|
/// A captured roster holds the same lock as membership writers until the
|
|
/// publisher explicitly releases it. This is the freshness fence used by
|
|
/// rolling-deploy reconciliation.
|
|
#[tokio::test]
|
|
#[ignore = "requires Postgres"]
|
|
async fn locked_member_snapshot_blocks_post_capture_membership_mutation() {
|
|
let pool = setup_pool().await;
|
|
let community_id = make_test_community(&pool).await;
|
|
let community = CommunityId::from_uuid(community_id);
|
|
let owner = random_pubkey();
|
|
let newcomer = random_pubkey();
|
|
let channel = create_test_channel(
|
|
&pool,
|
|
community_id,
|
|
"snapshot-freshness-fence",
|
|
ChannelType::Stream,
|
|
ChannelVisibility::Open,
|
|
None,
|
|
&owner,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("create channel");
|
|
|
|
let snapshot_pool = PgPoolOptions::new()
|
|
.max_connections(1)
|
|
.acquire_timeout(std::time::Duration::from_secs(1))
|
|
.connect(TEST_DB_URL)
|
|
.await
|
|
.expect("connect one-connection pool");
|
|
let relay_keys = Keys::generate();
|
|
let mut snapshot = lock_member_snapshot(
|
|
&snapshot_pool,
|
|
community,
|
|
channel.id,
|
|
&relay_keys.public_key().to_bytes(),
|
|
)
|
|
.await
|
|
.expect("capture locked roster");
|
|
assert_eq!(snapshot.members.len(), 1);
|
|
let event = nostr::EventBuilder::new(nostr::Kind::Custom(39002), "")
|
|
.tags(vec![
|
|
nostr::Tag::parse(["d", &channel.id.to_string()]).expect("d tag"),
|
|
nostr::Tag::parse(["p", &hex::encode(&owner)]).expect("p tag"),
|
|
])
|
|
.sign_with_keys(&relay_keys)
|
|
.expect("sign roster");
|
|
let (_, inserted) = snapshot
|
|
.replace_member_event(community, channel.id, &event)
|
|
.await
|
|
.expect("replace roster on held connection");
|
|
assert!(inserted);
|
|
|
|
let mut contender = pool.begin().await.expect("begin membership writer");
|
|
let acquired: bool =
|
|
sqlx::query_scalar("SELECT pg_try_advisory_xact_lock(hashtextextended($1, 0))")
|
|
.bind(format!(
|
|
"{CHANNEL_MEMBERSHIP_LOCK_NAMESPACE}{}:{}",
|
|
community.as_uuid(),
|
|
channel.id
|
|
))
|
|
.fetch_one(&mut *contender)
|
|
.await
|
|
.expect("try membership writer lock");
|
|
assert!(
|
|
!acquired,
|
|
"membership mutation must wait until the captured roster is published"
|
|
);
|
|
contender.rollback().await.expect("rollback contender");
|
|
|
|
snapshot.release().await.expect("release snapshot fence");
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel.id,
|
|
&newcomer,
|
|
MemberRole::Member,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("membership mutation after publication");
|
|
assert_eq!(
|
|
get_members(&pool, community, channel.id)
|
|
.await
|
|
.expect("fresh roster")
|
|
.len(),
|
|
2
|
|
);
|
|
}
|
|
|
|
/// The lock must be shared with `remove_member`: a demotion racing an owner
|
|
/// removal goes through a separate count/update path, so both must serialize
|
|
/// on the same key or they can jointly empty the owner set.
|
|
///
|
|
/// Deterministic rather than timing-based: an outer transaction takes the
|
|
/// per-channel membership key first, then each membership writer must block
|
|
/// until it is released. Verified by mutation — dropping the lock from either
|
|
/// function makes that call return immediately and fails this test.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn membership_writes_serialize_on_the_shared_channel_lock() {
|
|
let pool = setup_pool().await;
|
|
let (community, channel_id, owner_a, owner_b) =
|
|
channel_with_two_owners(&pool, "membership-lock-shared").await;
|
|
|
|
for label in ["add_member", "remove_member"] {
|
|
// Hold the same advisory key an in-tree membership write would take.
|
|
let mut holder = pool.begin().await.expect("begin lock holder");
|
|
acquire_channel_membership_lock(&mut holder, community, channel_id)
|
|
.await
|
|
.expect("holder acquires membership key");
|
|
|
|
let pool2 = pool.clone();
|
|
let (target, actor) = (owner_a.clone(), owner_b.clone());
|
|
let mut writer = tokio::spawn(async move {
|
|
match label {
|
|
"add_member" => add_member(
|
|
&pool2,
|
|
community,
|
|
channel_id,
|
|
&target,
|
|
MemberRole::Member,
|
|
Some(&actor),
|
|
)
|
|
.await
|
|
.map(|_| ()),
|
|
_ => remove_member(&pool2, community, channel_id, &target, &actor).await,
|
|
}
|
|
});
|
|
|
|
// While the key is held, the writer must make no progress.
|
|
let blocked =
|
|
tokio::time::timeout(std::time::Duration::from_millis(750), &mut writer).await;
|
|
assert!(
|
|
blocked.is_err(),
|
|
"{label} completed while the channel membership key was held — \
|
|
it is not serializing on the shared lock"
|
|
);
|
|
println!("{label} blocked on the held membership key, as required");
|
|
|
|
// Releasing the key lets it proceed.
|
|
holder.rollback().await.expect("release membership key");
|
|
tokio::time::timeout(std::time::Duration::from_secs(10), writer)
|
|
.await
|
|
.expect("writer must proceed once the key is released")
|
|
.expect("writer task panicked")
|
|
.expect("writer must succeed after the key is released");
|
|
|
|
// Restore two owners for the next iteration.
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel_id,
|
|
&owner_a,
|
|
MemberRole::Owner,
|
|
Some(&owner_b),
|
|
)
|
|
.await
|
|
.expect("restore second owner");
|
|
}
|
|
}
|
|
|
|
/// Every *mutable* authorization read must sit behind the membership lock.
|
|
/// A remover that reads its elevated role before acquiring the lock can be
|
|
/// demoted by a concurrent writer and still proceed on the stale role.
|
|
///
|
|
/// Deterministic: the holder takes the key, `remove_member` blocks on it, the
|
|
/// holder then demotes the remover and commits. Once the key is released the
|
|
/// remover must re-read its (now unprivileged) role and be rejected.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn remove_member_rejects_an_actor_demoted_while_it_waited() {
|
|
let pool = setup_pool().await;
|
|
let (community, channel_id, owner_a, owner_b) =
|
|
channel_with_two_owners(&pool, "stale-actor-role").await;
|
|
// owner_b removes a plain member, so the last-owner guard is not what
|
|
// rejects this — only the actor's own role can.
|
|
let victim = random_pubkey();
|
|
ensure_user(&pool, community, &victim)
|
|
.await
|
|
.expect("ensure victim");
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel_id,
|
|
&victim,
|
|
MemberRole::Member,
|
|
Some(&owner_a),
|
|
)
|
|
.await
|
|
.expect("add victim");
|
|
|
|
let mut holder = pool.begin().await.expect("begin lock holder");
|
|
acquire_channel_membership_lock(&mut holder, community, channel_id)
|
|
.await
|
|
.expect("holder acquires membership key");
|
|
|
|
let pool2 = pool.clone();
|
|
let (actor, target) = (owner_b.clone(), victim.clone());
|
|
let mut remover = tokio::spawn(async move {
|
|
remove_member(&pool2, community, channel_id, &target, &actor).await
|
|
});
|
|
|
|
// Must be waiting on the key, not already authorized past it.
|
|
assert!(
|
|
tokio::time::timeout(std::time::Duration::from_millis(750), &mut remover)
|
|
.await
|
|
.is_err(),
|
|
"remove_member must block on the membership key before authorizing"
|
|
);
|
|
|
|
// Demote the waiting actor to a plain member and release the key.
|
|
sqlx::query(
|
|
"UPDATE channel_members SET role = 'member' \
|
|
WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3",
|
|
)
|
|
.bind(community.as_uuid())
|
|
.bind(channel_id)
|
|
.bind(&owner_b)
|
|
.execute(&mut *holder)
|
|
.await
|
|
.expect("demote the waiting actor");
|
|
holder.commit().await.expect("commit demotion");
|
|
|
|
let result = tokio::time::timeout(std::time::Duration::from_secs(10), remover)
|
|
.await
|
|
.expect("remover must proceed once the key is released")
|
|
.expect("remover task panicked");
|
|
|
|
let err = result.expect_err("a demoted actor must not remove another member");
|
|
println!("stale-role removal rejected: {err}");
|
|
|
|
// The victim must still be an active member.
|
|
let members = get_members(&pool, community, channel_id)
|
|
.await
|
|
.expect("members");
|
|
assert!(
|
|
members.iter().any(|m| m.pubkey == victim),
|
|
"victim must not have been removed by a demoted actor"
|
|
);
|
|
}
|
|
|
|
/// A soft-removed row keeps its stored `role`, but that role is history,
|
|
/// not live authority — `removed_at` says it is no longer in force. So
|
|
/// reactivation must land at the baseline the caller was authorized for,
|
|
/// never at the role the row happens to remember.
|
|
///
|
|
/// Regression for the sharper vulnerability the alternative would create:
|
|
/// an owner kicked by another owner self-rejoins through the kind:9021
|
|
/// path (`Member`, no inviter) and must come back as a plain member. If
|
|
/// `add_member` inferred authority from the removed row, soft-deleted
|
|
/// ownership would be a resurrection token.
|
|
///
|
|
/// Two owners on purpose, so the last-owner guard can never be what
|
|
/// decides the outcome — only role resolution can.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn kicked_owner_rejoins_as_member_not_owner() {
|
|
let pool = setup_pool().await;
|
|
let (community, channel_id, owner_a, owner_b) =
|
|
channel_with_two_owners(&pool, "kicked-owner-rejoin").await;
|
|
|
|
// owner_a kicks owner_b (allowed: owner_a remains as the last owner).
|
|
remove_member(&pool, community, channel_id, &owner_b, &owner_a)
|
|
.await
|
|
.expect("an owner may remove another owner");
|
|
|
|
let stored: String = sqlx::query_scalar(
|
|
"SELECT role::text FROM channel_members \
|
|
WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3",
|
|
)
|
|
.bind(community.as_uuid())
|
|
.bind(channel_id)
|
|
.bind(&owner_b)
|
|
.fetch_one(&pool)
|
|
.await
|
|
.expect("stored role survives soft removal");
|
|
assert_eq!(
|
|
stored, "owner",
|
|
"the removed row still remembers `owner` — which is exactly why \
|
|
authorization must not read it"
|
|
);
|
|
|
|
// The kind:9021 self-rejoin path: `Member`, no inviter.
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel_id,
|
|
&owner_b,
|
|
MemberRole::Member,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("a removed member may rejoin an open channel");
|
|
|
|
let rejoined = get_member_role(&pool, community, channel_id, &owner_b)
|
|
.await
|
|
.expect("read role after rejoin");
|
|
assert_eq!(
|
|
rejoined.as_deref(),
|
|
Some("member"),
|
|
"a kicked owner must rejoin at baseline privilege, not regain ownership"
|
|
);
|
|
}
|
|
|
|
/// The other side of the same boundary: reactivation may reach an elevated
|
|
/// role, but only because a *currently* elevated granter asked for it.
|
|
#[tokio::test]
|
|
#[ignore]
|
|
async fn removed_owner_is_restored_only_by_a_current_owner() {
|
|
let pool = setup_pool().await;
|
|
let (community, channel_id, owner_a, owner_b) =
|
|
channel_with_two_owners(&pool, "removed-owner-restore").await;
|
|
|
|
remove_member(&pool, community, channel_id, &owner_b, &owner_a)
|
|
.await
|
|
.expect("an owner may remove another owner");
|
|
|
|
// An unprivileged member cannot re-add them at `owner`.
|
|
let rando = random_pubkey();
|
|
ensure_user(&pool, community, &rando)
|
|
.await
|
|
.expect("ensure rando");
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel_id,
|
|
&rando,
|
|
MemberRole::Member,
|
|
None,
|
|
)
|
|
.await
|
|
.expect("rando self-joins open channel");
|
|
let denied = add_member(
|
|
&pool,
|
|
community,
|
|
channel_id,
|
|
&owner_b,
|
|
MemberRole::Owner,
|
|
Some(&rando),
|
|
)
|
|
.await;
|
|
assert!(
|
|
matches!(denied, Err(DbError::AccessDenied(_))),
|
|
"an unprivileged actor must not re-add anyone at `owner`, got {denied:?}"
|
|
);
|
|
|
|
// The remaining owner can.
|
|
add_member(
|
|
&pool,
|
|
community,
|
|
channel_id,
|
|
&owner_b,
|
|
MemberRole::Owner,
|
|
Some(&owner_a),
|
|
)
|
|
.await
|
|
.expect("a current owner may restore ownership");
|
|
let restored = get_member_role(&pool, community, channel_id, &owner_b)
|
|
.await
|
|
.expect("read role after restore");
|
|
assert_eq!(restored.as_deref(), Some("owner"));
|
|
}
|
|
}
|