diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2711df61..f2fe60ea4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -364,12 +364,36 @@ jobs: with: name: desktop-e2e-relay path: target/ci + - name: Apply schema and seed deployment community + # MT: the relay resolves each request's tenant from the communities host + # map and fails closed on an unmapped host. The channel reconciler binds + # the deployment community ONCE at boot (outside its retry loop) and + # exits permanently on an unmapped host, so the 'localhost:3000' + # community MUST exist before the relay starts — the retry loop only + # handles late-seeded channels, not a late-seeded community. The relay + # migrates at boot via BUZZ_AUTO_MIGRATE, but that's too late for the + # pre-boot seed, so apply the schema here first (then drop AUTO_MIGRATE + # below). lower(host) is the unique index → ON CONFLICT target. psql + # isn't on PATH in hermit → exec into the buzz-postgres container. + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PGPASSWORD: buzz_dev + PGDATABASE: buzz + run: | + ./bin/pgschema apply --file schema/schema.sql --auto-approve + docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -qtA -c " + INSERT INTO communities (id, host) + VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') + ON CONFLICT (lower(host)) DO NOTHING + ;" - name: Start relay run: | chmod +x ./target/ci/buzz-relay nohup env \ DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \ - BUZZ_AUTO_MIGRATE=true \ REDIS_URL=redis://localhost:6379 \ TYPESENSE_URL=http://localhost:8108 \ TYPESENSE_API_KEY=buzz_dev_key \ @@ -474,12 +498,36 @@ jobs: with: name: desktop-e2e-relay path: target/ci + - name: Apply schema and seed deployment community + # MT: the relay resolves each request's tenant from the communities host + # map and fails closed on an unmapped host. The reminder scheduler binds + # the deployment community ONCE at boot and exits permanently on an + # unmapped host (no retry, unlike the channel reconciler), so the + # 'localhost:3000' community MUST exist before the relay starts — seeding + # after boot leaves the scheduler dead. The relay migrates at boot via + # BUZZ_AUTO_MIGRATE, but that's too late for the pre-boot seed, so apply + # the schema here first (then drop AUTO_MIGRATE below). lower(host) is the + # unique index → ON CONFLICT target. psql isn't on PATH in hermit → exec + # into the buzz-postgres container. + env: + PGHOST: localhost + PGPORT: "5432" + PGUSER: buzz + PGPASSWORD: buzz_dev + PGDATABASE: buzz + run: | + ./bin/pgschema apply --file schema/schema.sql --auto-approve + docker exec -e PGPASSWORD=buzz_dev buzz-postgres \ + psql -U buzz -d buzz -qtA -c " + INSERT INTO communities (id, host) + VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') + ON CONFLICT (lower(host)) DO NOTHING + ;" - name: Start relay run: | chmod +x ./target/ci/buzz-relay nohup env \ DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \ - BUZZ_AUTO_MIGRATE=true \ REDIS_URL=redis://localhost:6379 \ TYPESENSE_URL=http://localhost:8108 \ TYPESENSE_API_KEY=buzz_dev_key \ diff --git a/Cargo.lock b/Cargo.lock index f6db8aa57..632f91128 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -754,7 +754,7 @@ dependencies = [ "sqlx", "tokio", "tracing", - "uuid", + "url", ] [[package]] @@ -842,6 +842,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "buzz-conformance" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "buzz-core" version = "0.1.0" @@ -1036,6 +1046,7 @@ dependencies = [ "base64", "buzz-audit", "buzz-auth", + "buzz-conformance", "buzz-core", "buzz-db", "buzz-media", @@ -1096,14 +1107,9 @@ name = "buzz-search" version = "0.1.0" dependencies = [ "buzz-core", - "chrono", - "nostr", - "reqwest 0.13.3", - "serde", - "serde_json", + "sqlx", "thiserror 2.0.18", "tokio", - "tracing", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index c6dd71349..1cb20f65d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,6 +2,7 @@ members = [ "crates/buzz-relay", "crates/buzz-core", + "crates/buzz-conformance", "crates/buzz-db", "crates/buzz-pubsub", "crates/buzz-auth", @@ -108,6 +109,7 @@ schemars = { version = "1", default-features = false } # Internal crates buzz-core = { path = "crates/buzz-core" } +buzz-conformance = { path = "crates/buzz-conformance" } buzz-db = { path = "crates/buzz-db" } buzz-auth = { path = "crates/buzz-auth" } buzz-pubsub = { path = "crates/buzz-pubsub" } diff --git a/crates/buzz-admin/Cargo.toml b/crates/buzz-admin/Cargo.toml index e2a40979c..c890ef24d 100644 --- a/crates/buzz-admin/Cargo.toml +++ b/crates/buzz-admin/Cargo.toml @@ -28,5 +28,5 @@ hex = { workspace = true } deadpool-redis = { workspace = true } tracing = { workspace = true } sqlx = { workspace = true } -uuid = { workspace = true } +url = { workspace = true } clap = { version = "4", features = ["derive"] } diff --git a/crates/buzz-admin/src/main.rs b/crates/buzz-admin/src/main.rs index 4adb94e14..f45e57371 100644 --- a/crates/buzz-admin/src/main.rs +++ b/crates/buzz-admin/src/main.rs @@ -24,8 +24,9 @@ use std::sync::Arc; use anyhow::Result; use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST; +use buzz_core::tenant::{normalize_host, TenantContext}; use buzz_db::{Db, DbConfig}; -use buzz_pubsub::PubSubManager; +use buzz_pubsub::{EventTopic, PubSubManager}; use clap::{Parser, Subcommand}; use nostr::{EventBuilder, Keys, Kind, Tag}; use tracing::warn; @@ -153,7 +154,8 @@ async fn cmd_add_member(pubkey_arg: String, role: String) -> Result { } } - if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair).await { + let tenant = resolve_admin_tenant(&db).await?; + if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await { eprintln!("warning: member added to DB but list publish failed: {e}"); } @@ -209,7 +211,8 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option) -> R } } - if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair).await { + let tenant = resolve_admin_tenant(&db).await?; + if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await { eprintln!("warning: member removed from DB but list publish failed: {e}"); } @@ -274,6 +277,7 @@ async fn publish_membership_list_with_bump( db: &Db, pubsub: &Arc, relay_keypair: &Keys, + tenant: &TenantContext, ) -> Result<()> { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -285,7 +289,11 @@ async fn publish_membership_list_with_bump( // Query the newest existing kind:13534 for this relay's pubkey (channel_id=None). let newest_ts = db - .get_latest_global_replaceable(KIND_NIP43_MEMBERSHIP_LIST as i32, &relay_pubkey_bytes) + .get_latest_global_replaceable( + tenant.community(), + KIND_NIP43_MEMBERSHIP_LIST as i32, + &relay_pubkey_bytes, + ) .await? .map(|e| e.event.created_at.as_secs()); @@ -313,12 +321,17 @@ async fn publish_membership_list_with_bump( .sign_with_keys(relay_keypair) .map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?; - let (stored, was_inserted) = db.replace_addressable_event(&event, None).await?; + let (stored, was_inserted) = db + .replace_addressable_event(tenant.community(), &event, None) + .await?; if was_inserted { // Publish to Redis so live clients receive the updated roster. - // Uses channel_id=Nil (global scope) matching the relay's own publish path. - let pubsub_channel = uuid::Uuid::nil(); - if let Err(e) = pubsub.publish_event(pubsub_channel, &stored.event).await { + // Community-global scope (EventTopic::Global) matches the relay's own + // membership-list publish path; the tenant fixes the community. + if let Err(e) = pubsub + .publish_event(tenant, EventTopic::Global, &stored.event) + .await + { warn!("Redis publish of kind:13534 failed: {e}"); } } @@ -376,6 +389,37 @@ async fn connect_db() -> Result { Ok(db) } +/// Resolve the deployment's tenant from the configured `RELAY_URL` host. +/// +/// `buzz-admin` runs inside the relay container (`compose exec relay +/// buzz-admin …`), so it shares the relay's `RELAY_URL` and resolves the same +/// single community against the durable `communities` host map. This is +/// deliberately NOT a default tenant: an unmapped host fails closed with an +/// error, mirroring the relay's own `bind_community` row-zero seam. The CLI is +/// single-community per invocation — there is no cross-community sweep. +async fn resolve_admin_tenant(db: &Db) -> Result { + let relay_url = + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()); + let raw_host = url::Url::parse( + &relay_url + .replace("ws://", "http://") + .replace("wss://", "https://"), + ) + .ok() + .and_then(|u| u.host_str().map(|h| h.to_string())) + .unwrap_or_default(); + let host = normalize_host(&raw_host); + let record = db.lookup_community_by_host(&host).await?.ok_or_else(|| { + anyhow::anyhow!( + "RELAY_URL host '{host}' is not mapped to a community.\n\ + buzz-admin operates on the configured relay's community; ensure the \ + relay has started and seeded its community (or set RELAY_URL to a \ + mapped host)." + ) + })?; + Ok(TenantContext::resolved(record.id, record.host)) +} + async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { use buzz_core::kind::KIND_NIP29_GROUP_ADMINS; use buzz_db::event::EventQuery; @@ -399,7 +443,8 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { } }; - let channels = db.list_channels(None).await?; + let tenant = resolve_admin_tenant(&db).await?; + let channels = db.list_channels(tenant.community(), None).await?; if channels.is_empty() { println!("No channels in database."); return Ok(()); @@ -417,7 +462,7 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { kinds: Some(vec![39000]), d_tag: Some(channel_id_str.clone()), limit: Some(1), - ..Default::default() + ..EventQuery::for_community(tenant.community()) }) .await .unwrap_or_default(); @@ -427,7 +472,7 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { continue; } - let members = db.get_members(channel.id).await?; + let members = db.get_members(tenant.community(), channel.id).await?; // kind:39000 — channel metadata { @@ -453,7 +498,7 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { .tags(tags) .sign_with_keys(&relay_keys) .map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?; - db.replace_addressable_event(&event, Some(channel.id)) + db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) .await?; } @@ -471,7 +516,7 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { .tags(tags) .sign_with_keys(&relay_keys) .map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?; - db.replace_addressable_event(&event, Some(channel.id)) + db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) .await?; } @@ -486,7 +531,7 @@ async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { .tags(tags) .sign_with_keys(&relay_keys) .map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?; - db.replace_addressable_event(&event, Some(channel.id)) + db.replace_addressable_event(tenant.community(), &event, Some(channel.id)) .await?; } diff --git a/crates/buzz-audit/src/entry.rs b/crates/buzz-audit/src/entry.rs index 3eab2417f..33b51f8cf 100644 --- a/crates/buzz-audit/src/entry.rs +++ b/crates/buzz-audit/src/entry.rs @@ -1,49 +1,72 @@ +use buzz_core::CommunityId; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use uuid::Uuid; use crate::action::AuditAction; -/// Materialised audit log entry as stored in the DB. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// A materialised audit log entry as stored in `audit_log`. +/// +/// Rows are keyed `(community_id, seq)`: `seq` is monotonic *within one +/// community*, and `prev_hash` chains to the previous entry *of the same +/// community*. The chain is independent per tenant. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct AuditEntry { - /// Monotonically increasing sequence number. + /// Server-resolved community this entry belongs to. Leads the primary key. + pub community_id: Uuid, + /// Sequence number, monotonic within `community_id` (starts at 1). pub seq: i64, - /// When the entry was recorded. - pub timestamp: DateTime, - /// Nostr event ID that triggered this action. - pub event_id: String, - /// Nostr event kind number. - pub event_kind: u32, - /// Hex-encoded Nostr pubkey. - pub actor_pubkey: String, + /// SHA-256 of this entry's fields including `community_id` and `prev_hash`. + pub hash: Vec, + /// SHA-256 of the previous entry in *this community's* chain, or `None` for + /// the community's first entry (hashed as [`crate::hash::GENESIS_HASH`]). + pub prev_hash: Option>, /// Action that was performed. pub action: AuditAction, - /// Channel this action applies to, if any. - pub channel_id: Option, - /// Arbitrary JSON context. **Included in hash computation** (serialized with - /// sorted keys for determinism) so that metadata tampering is detectable. - pub metadata: serde_json::Value, - /// SHA-256 hex hash of the previous entry (or [`crate::hash::GENESIS_HASH`] for the first). - pub prev_hash: String, - /// SHA-256 hex hash of this entry's fields including `prev_hash`. - pub hash: String, + /// Raw bytes of the actor's Nostr pubkey, if the action has one. + pub actor_pubkey: Option>, + /// Generic identifier of the object acted upon (event id hex, channel UUID, + /// media sha256, …), if any. The relay resolves it under `community_id`; + /// it never names an object in another community. + pub object_id: Option, + /// Arbitrary JSON context. **Included in the hash** (serialized with sorted + /// keys for determinism) so tampering with it is detectable. + pub detail: serde_json::Value, + /// When the entry was recorded. + pub created_at: DateTime, } -/// Input for creating a new audit entry. `seq`, `prev_hash`, `hash` are computed by `AuditService::log`. -#[derive(Debug, Clone, Serialize, Deserialize)] +/// Input for appending a new audit entry. `seq`, `prev_hash`, `hash`, and +/// `created_at` are assigned by [`crate::service::AuditService::log`]. +/// +/// `community_id` is the **server-resolved** tenant (from the request's +/// `TenantContext`), never a client-supplied value — the same provenance rule +/// the whole multi-tenant model rests on. +/// +/// Not `Serialize`/`Deserialize`: this is an in-process input struct (consumed +/// by `AuditService::log`, threaded through the in-memory audit sink), never +/// crossing a wire or DB boundary as a whole. Keeping it non-deserializable +/// reinforces the fence — there is no path by which a client-supplied blob +/// becomes a `NewAuditEntry` (and thus a `CommunityId`). +#[derive(Debug, Clone, PartialEq, Eq)] pub struct NewAuditEntry { - /// Nostr event ID that triggered this action. - pub event_id: String, - /// Must not be 22242 (NIP-42 AUTH). - pub event_kind: u32, - /// Hex-encoded Nostr pubkey of the actor. - pub actor_pubkey: String, + /// Server-resolved community this entry belongs to. Typed as [`CommunityId`] + /// (not a raw `Uuid`) so the provenance rule is visible in the signature: + /// the only ways to obtain one are host resolution or a server-scoped DB + /// row — never a value parsed from client input. + pub community_id: CommunityId, /// Action that was performed. pub action: AuditAction, - /// Channel this action applies to, if any. - pub channel_id: Option, - /// Arbitrary JSON context included in hash computation. - #[serde(default)] - pub metadata: serde_json::Value, + /// Raw bytes of the actor's Nostr pubkey, if the action has one. + pub actor_pubkey: Option>, + /// Generic identifier of the object acted upon, if any. + pub object_id: Option, + /// Arbitrary JSON context included in the hash. + /// + /// **Never bearer-token material.** This field is opaque to the audit + /// crate and persisted verbatim; callers must not write tokens, passwords, + /// or other secrets here. `AuthSuccess`/`AuthFailure` entries carry only + /// outcome metadata — the token has no slot in this type, and `detail` must + /// not become one. + pub detail: serde_json::Value, } diff --git a/crates/buzz-audit/src/error.rs b/crates/buzz-audit/src/error.rs index 6b99321f3..9fc9debf1 100644 --- a/crates/buzz-audit/src/error.rs +++ b/crates/buzz-audit/src/error.rs @@ -1,45 +1,41 @@ use thiserror::Error; /// Errors that can occur during audit log operations. +/// +/// These are **operator-internal** diagnostics (logged by the audit worker, or +/// returned to an operator-scoped verification call) — they are never relayed to +/// a client on the wire. Even so, no variant embeds a `community_id` or any +/// cross-community object identifier: a `seq` is per-community and meaningless +/// without its chain, and hashes are opaque. An error raised while verifying +/// community A's chain therefore cannot reveal a fact about community B. #[derive(Debug, Error)] pub enum AuditError { /// A database operation failed. #[error("database error: {0}")] Database(#[from] sqlx::Error), - /// Attempted to log a NIP-42 AUTH event (kind 22242), which is forbidden. - #[error("auth events (kind 22242) must never appear in the audit log")] - AuthEventForbidden, - - /// The `prev_hash` of an entry does not match the hash of the preceding entry. + /// The `prev_hash` of an entry does not match the hash of the preceding + /// entry in the same community's chain. #[error( - "hash chain integrity violation at seq {seq}: expected prev_hash {expected}, got {actual}" + "hash chain integrity violation at seq {seq}: prev_hash does not match preceding entry" )] ChainViolation { - /// Sequence number of the offending entry. + /// Per-community sequence number of the offending entry. seq: i64, - /// Hash that was expected based on the previous entry. - expected: String, - /// Hash that was actually found in the entry. - actual: String, }, /// The stored hash of an entry does not match the recomputed hash. - #[error("hash mismatch at seq {seq}: stored {stored}, computed {computed}")] + #[error("hash mismatch at seq {seq}: stored hash does not match recomputed hash")] HashMismatch { - /// Sequence number of the offending entry. + /// Per-community sequence number of the offending entry. seq: i64, - /// Hash value stored in the database. - stored: String, - /// Hash value recomputed from the entry fields. - computed: String, }, /// An unrecognised action string was found in the database. - #[error("unknown audit action in DB: {0:?}")] - UnknownAction(String), + #[error("unknown audit action in database")] + UnknownAction, - /// A JSON serialization error occurred (e.g. while canonicalising metadata). + /// A JSON serialization error occurred (e.g. while canonicalising `detail`). #[error("serialization error: {0}")] Serialization(#[from] serde_json::Error), } diff --git a/crates/buzz-audit/src/hash.rs b/crates/buzz-audit/src/hash.rs index b813093dd..a272d4a02 100644 --- a/crates/buzz-audit/src/hash.rs +++ b/crates/buzz-audit/src/hash.rs @@ -3,41 +3,53 @@ use sha2::{Digest, Sha256}; use crate::entry::AuditEntry; use crate::error::AuditError; -/// Sentinel `prev_hash` value used for the first entry in the chain. -pub const GENESIS_HASH: &str = "0000000000000000000000000000000000000000000000000000000000000000"; +/// The 32-byte sentinel hashed in place of `prev_hash` for a community's first +/// entry. Stored as `prev_hash = NULL`; hashed as all-zero bytes. +pub const GENESIS_HASH: [u8; 32] = [0u8; 32]; -/// SHA-256 over all identity, chain, and context fields. -/// Field order is fixed — changing it invalidates all existing chains. +/// SHA-256 over the entry's identity, chain, and context fields. /// -/// Metadata is serialized via `BTreeMap` to guarantee key ordering across -/// machines and Rust versions. `serde_json::Value` does not guarantee order. +/// Field order is fixed — changing it invalidates all existing chains. The +/// `community_id` is hashed first so chain identity carries the tenant: an entry +/// cannot be lifted out of one community's chain and re-verified inside another. /// -/// Returns `Err(AuditError::Serialization)` if metadata cannot be serialized. -/// Never hashes a default/empty value as a stand-in for a real payload — -/// a serialization failure is a hard error, not a silent degradation. -pub fn compute_hash(entry: &AuditEntry) -> Result { +/// `detail` is serialized via [`canonical_json`] (sorted keys) so the hash is +/// stable across machines and Rust versions. A serialization failure is a hard +/// error, never silently hashed as empty. +pub fn compute_hash(entry: &AuditEntry) -> Result<[u8; 32], AuditError> { let mut hasher = Sha256::new(); + // Tenant binding: community_id leads the hash. + hasher.update(entry.community_id.as_bytes()); hasher.update(entry.seq.to_be_bytes()); - hasher.update(entry.timestamp.to_rfc3339().as_bytes()); - hasher.update(entry.event_id.as_bytes()); - // event_kind is u32 — 4 bytes in big-endian for the hash chain. - hasher.update(entry.event_kind.to_be_bytes()); - hasher.update(entry.actor_pubkey.as_bytes()); + hasher.update(entry.created_at.to_rfc3339().as_bytes()); hasher.update(entry.action.as_str().as_bytes()); - match &entry.channel_id { - Some(id) => hasher.update(id.as_bytes()), - None => hasher.update([0u8; 16]), + match &entry.actor_pubkey { + Some(pk) => { + hasher.update([1u8]); // presence tag — distinguishes Some(empty) from None + hasher.update(pk); + } + None => hasher.update([0u8]), + } + match &entry.object_id { + Some(id) => { + hasher.update([1u8]); + hasher.update(id.as_bytes()); + } + None => hasher.update([0u8]), } - hasher.update(canonical_json(&entry.metadata)?.as_bytes()); - hasher.update(entry.prev_hash.as_bytes()); - Ok(hex::encode(hasher.finalize())) + hasher.update(canonical_json(&entry.detail)?.as_bytes()); + match &entry.prev_hash { + Some(h) => hasher.update(h), + None => hasher.update(GENESIS_HASH), + } + Ok(hasher.finalize().into()) } -/// Serialize a JSON value with sorted keys for deterministic output. +/// Serialize a JSON value with sorted object keys for deterministic output. /// -/// Returns `Err` if any scalar value cannot be serialized. This should never -/// happen for well-formed `serde_json::Value`, but we propagate rather than -/// silently substitute an empty string. +/// Propagates any scalar serialization error rather than substituting a +/// placeholder — a hash must never silently stand in an empty value for a real +/// payload. fn canonical_json(value: &serde_json::Value) -> Result { use serde_json::Value; use std::collections::BTreeMap; @@ -81,21 +93,21 @@ mod tests { use super::*; use crate::{action::AuditAction, entry::AuditEntry}; use chrono::Utc; + use uuid::Uuid; fn sample_entry() -> AuditEntry { AuditEntry { + community_id: Uuid::from_u128(1), seq: 1, - timestamp: chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + hash: Vec::new(), + prev_hash: None, + action: AuditAction::EventCreated, + actor_pubkey: Some(vec![0xab; 32]), + object_id: Some("abc123".into()), + detail: serde_json::Value::Null, + created_at: chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") .unwrap() .with_timezone(&Utc), - event_id: "abc123".to_string(), - event_kind: 1, - actor_pubkey: "pubkey_alice".to_string(), - action: AuditAction::EventCreated, - channel_id: None, - metadata: serde_json::Value::Null, - prev_hash: GENESIS_HASH.to_string(), - hash: String::new(), } } @@ -103,7 +115,17 @@ mod tests { fn deterministic() { let entry = sample_entry(); assert_eq!(compute_hash(&entry).unwrap(), compute_hash(&entry).unwrap()); - assert_eq!(compute_hash(&entry).unwrap().len(), 64); + assert_eq!(compute_hash(&entry).unwrap().len(), 32); + } + + #[test] + fn community_id_is_part_of_identity() { + // The whole point: the same logical entry in two communities hashes + // differently, so a row can't be replayed across chains. + let a = sample_entry(); + let mut b = a.clone(); + b.community_id = Uuid::from_u128(2); + assert_ne!(compute_hash(&a).unwrap(), compute_hash(&b).unwrap()); } #[test] @@ -112,37 +134,44 @@ mod tests { let h0 = compute_hash(&base).unwrap(); let mut e = base.clone(); - e.event_id = "different_event".into(); + e.seq = 2; assert_ne!(h0, compute_hash(&e).unwrap()); let mut e = base.clone(); - e.seq = 2; + e.action = AuditAction::EventDeleted; assert_ne!(h0, compute_hash(&e).unwrap()); let mut e = base.clone(); - e.actor_pubkey = "pubkey_bob".into(); + e.actor_pubkey = Some(vec![0xcd; 32]); assert_ne!(h0, compute_hash(&e).unwrap()); let mut e = base.clone(); - e.channel_id = Some(uuid::Uuid::new_v4()); + e.object_id = Some("different".into()); assert_ne!(h0, compute_hash(&e).unwrap()); - let mut e = base; - e.metadata = serde_json::json!({"key": "value"}); + let mut e = base.clone(); + e.detail = serde_json::json!({"key": "value"}); assert_ne!(h0, compute_hash(&e).unwrap()); + + let mut e = base.clone(); + e.prev_hash = Some(vec![0xff; 32]); + assert_ne!(h0, compute_hash(&e).unwrap()); + } + + #[test] + fn presence_tag_distinguishes_none_from_empty() { + // Some(empty) must not collide with None — the presence tag prevents it. + let mut none = sample_entry(); + none.actor_pubkey = None; + let mut empty = sample_entry(); + empty.actor_pubkey = Some(Vec::new()); + assert_ne!(compute_hash(&none).unwrap(), compute_hash(&empty).unwrap()); } #[test] fn canonical_json_key_order_is_stable() { - // Same keys in different insertion order must produce the same hash. let a = serde_json::json!({"z": 1, "a": 2, "m": 3}); let b = serde_json::json!({"a": 2, "m": 3, "z": 1}); assert_eq!(canonical_json(&a).unwrap(), canonical_json(&b).unwrap()); } - - #[test] - fn genesis_hash_format() { - assert_eq!(GENESIS_HASH.len(), 64); - assert!(GENESIS_HASH.chars().all(|c| c == '0')); - } } diff --git a/crates/buzz-audit/src/lib.rs b/crates/buzz-audit/src/lib.rs index 09f429560..0248a7dfd 100644 --- a/crates/buzz-audit/src/lib.rs +++ b/crates/buzz-audit/src/lib.rs @@ -1,8 +1,21 @@ #![deny(unsafe_code)] #![warn(missing_docs)] -//! Tamper-evident hash-chain audit log. Each entry chains to the previous via -//! SHA-256. Single-writer via Postgres `pg_advisory_lock`. AUTH events (kind 22242) -//! are rejected — they carry bearer tokens. +//! Tamper-evident, **per-community** hash-chain audit log. +//! +//! Each community owns an independent chain: rows are keyed `(community_id, seq)`, +//! `seq` is monotonic *within a community*, and each entry chains to the previous +//! entry *of the same community* via SHA-256. The `community_id` is folded into the +//! hash, so a row lifted out of one community's chain can never verify inside +//! another's — chain identity carries the tenant. This is the audit half of the +//! non-interference floor (`auditHeads[c]` in `MultiTenantRelay.tla`): an audit +//! observation reveals only its own community's head. +//! +//! Writes for a given community are serialized by a **per-community** Postgres +//! advisory lock, so the chain stays consistent across relay processes without one +//! global lock serializing (and timing-coupling) every tenant. +//! +//! The `audit_log` table is owned by the consolidated `0001` migration — this crate +//! is pure chain logic and ships no DDL. /// Audit action types recorded in the log. pub mod action; @@ -12,8 +25,6 @@ pub mod entry; pub mod error; /// SHA-256 hash computation for audit entries. pub mod hash; -/// SQL schema for the audit log table. -pub mod schema; /// Audit log service — append and verify entries. pub mod service; @@ -21,5 +32,4 @@ pub use action::AuditAction; pub use entry::{AuditEntry, NewAuditEntry}; pub use error::AuditError; pub use hash::{compute_hash, GENESIS_HASH}; -pub use schema::AUDIT_SCHEMA_SQL; pub use service::AuditService; diff --git a/crates/buzz-audit/src/schema.rs b/crates/buzz-audit/src/schema.rs deleted file mode 100644 index 1bdfaa969..000000000 --- a/crates/buzz-audit/src/schema.rs +++ /dev/null @@ -1,18 +0,0 @@ -/// DDL for the `audit_log` table. Passed to [`sqlx::raw_sql`] on startup. -pub const AUDIT_SCHEMA_SQL: &str = r#" -CREATE TABLE IF NOT EXISTS audit_log ( - seq BIGINT NOT NULL PRIMARY KEY, - timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), - event_id VARCHAR(255) NOT NULL, - event_kind INT NOT NULL, - actor_pubkey VARCHAR(255) NOT NULL, - action VARCHAR(64) NOT NULL, - channel_id BYTEA, - metadata JSONB NOT NULL, - prev_hash VARCHAR(64) NOT NULL, - hash VARCHAR(64) NOT NULL -); -CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log (timestamp); -CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log (actor_pubkey); -CREATE INDEX IF NOT EXISTS idx_audit_log_channel ON audit_log (channel_id); -"#; diff --git a/crates/buzz-audit/src/service.rs b/crates/buzz-audit/src/service.rs index 0edc2e89b..913131a59 100644 --- a/crates/buzz-audit/src/service.rs +++ b/crates/buzz-audit/src/service.rs @@ -2,24 +2,29 @@ use chrono::{DateTime, Utc}; use futures_util::FutureExt as _; use sqlx::{Acquire, PgPool, Row}; use tracing::{debug, instrument, warn}; +use uuid::Uuid; -use buzz_core::kind::KIND_AUTH; +use buzz_core::CommunityId; use crate::{ action::AuditAction, entry::{AuditEntry, NewAuditEntry}, error::AuditError, - hash::{compute_hash, GENESIS_HASH}, - schema::AUDIT_SCHEMA_SQL, + hash::compute_hash, }; -/// Advisory lock key derived from a stable hash of "buzz_audit". -const AUDIT_LOCK_KEY: i64 = 0x5370_7275_7441_7564; // "SprutAud" as hex +/// Per-community advisory lock key. Derived in Postgres from the community UUID +/// so two communities never serialize each other's audit writes (which would be +/// both a throughput bottleneck and a cross-tenant timing oracle). The lock is +/// taken with `pg_advisory_lock(hashtextextended(...))` — see [`AuditService::log`]. +const AUDIT_LOCK_NAMESPACE: &str = "buzz_audit:"; -/// Append-only audit log service backed by Postgres. +/// Append-only, per-community hash-chain audit log backed by Postgres. /// -/// Serialises writes via `pg_advisory_lock` so the hash chain remains consistent -/// even when multiple relay processes share the same database. +/// Each community has an independent chain keyed `(community_id, seq)`. Writes +/// for one community are serialized by a per-community advisory lock so the chain +/// stays consistent across relay processes; different communities proceed in +/// parallel. pub struct AuditService { pool: PgPool, } @@ -30,40 +35,32 @@ impl AuditService { Self { pool } } - /// Idempotent — safe to call on every startup. - pub async fn ensure_schema(&self) -> Result<(), AuditError> { - sqlx::raw_sql(AUDIT_SCHEMA_SQL).execute(&self.pool).await?; - Ok(()) - } - - /// Append a new entry to the audit log. Single-writer via `pg_advisory_lock`. + /// Append a new entry to the calling community's chain. /// - /// Postgres advisory locks are session-scoped, so we acquire before the - /// transaction and release after commit (or on any error path). + /// Serialized per-community via `pg_advisory_lock`. Postgres advisory locks + /// are session-scoped, so we acquire before the transaction and release + /// after commit (or on any error path). #[instrument(skip(self, entry), fields(action = %entry.action))] pub async fn log(&self, entry: NewAuditEntry) -> Result { - if entry.event_kind == KIND_AUTH { - warn!("rejected attempt to audit AUTH event (kind 22242)"); - return Err(AuditError::AuthEventForbidden); - } - let mut conn = self.pool.acquire().await?; - // Acquire session-level advisory lock (blocks until available). - sqlx::query("SELECT pg_advisory_lock($1)") - .bind(AUDIT_LOCK_KEY) + // Per-community advisory lock: hash the namespaced community id to an + // i64 lock key inside Postgres. Communities lock independently. + let lock_key = format!("{AUDIT_LOCK_NAMESPACE}{}", entry.community_id); + sqlx::query("SELECT pg_advisory_lock(hashtextextended($1, 0))") + .bind(&lock_key) .execute(&mut *conn) .await?; - // Run log_inner and release the lock regardless of outcome. - // We use catch_unwind to handle panics so the lock is always released - // before the connection is returned to the pool. + // Run the chain append and release the lock regardless of outcome. + // catch_unwind so a panic still releases the lock before the connection + // returns to the pool. let result = std::panic::AssertUnwindSafe(self.log_inner(&mut conn, entry)) .catch_unwind() .await; - let _ = sqlx::query("SELECT pg_advisory_unlock($1)") - .bind(AUDIT_LOCK_KEY) + let _ = sqlx::query("SELECT pg_advisory_unlock(hashtextextended($1, 0))") + .bind(&lock_key) .execute(&mut *conn) .await; @@ -80,56 +77,63 @@ impl AuditService { ) -> Result { let mut tx = conn.begin().await?; - let prev_hash: String = sqlx::query("SELECT hash FROM audit_log ORDER BY seq DESC LIMIT 1") - .fetch_optional(&mut *tx) - .await? - .map(|row| row.get::("hash")) - .unwrap_or_else(|| GENESIS_HASH.to_string()); + // The stored row keys on the raw UUID; the typed `CommunityId` on the + // input is the provenance fence, dereferenced here at the DB boundary. + let community_id = *entry.community_id.as_uuid(); - let seq: i64 = - sqlx::query_scalar("SELECT COALESCE(MAX(seq), 0) + 1 AS next_seq FROM audit_log") - .fetch_one(&mut *tx) - .await?; + // Head of THIS community's chain — scoped by community_id. + let head = sqlx::query( + "SELECT seq, hash FROM audit_log + WHERE community_id = $1 + ORDER BY seq DESC LIMIT 1", + ) + .bind(community_id) + .fetch_optional(&mut *tx) + .await?; - let timestamp: DateTime = Utc::now(); + let (prev_seq, prev_hash): (i64, Option>) = match head { + Some(row) => ( + row.get::("seq"), + Some(row.get::, _>("hash")), + ), + None => (0, None), // community's first entry + }; + let seq = prev_seq + 1; - let channel_id_bytes: Option> = entry.channel_id.map(|u| u.as_bytes().to_vec()); + let created_at: DateTime = Utc::now(); let mut audit_entry = AuditEntry { + community_id, seq, - timestamp, - event_id: entry.event_id, - event_kind: entry.event_kind, - actor_pubkey: entry.actor_pubkey, - action: entry.action, - channel_id: entry.channel_id, - metadata: entry.metadata, + hash: Vec::new(), prev_hash, - hash: String::new(), + action: entry.action, + actor_pubkey: entry.actor_pubkey, + object_id: entry.object_id, + detail: entry.detail, + created_at, }; - audit_entry.hash = compute_hash(&audit_entry)?; + audit_entry.hash = compute_hash(&audit_entry)?.to_vec(); - debug!(seq, hash = %audit_entry.hash, "writing audit entry"); + debug!(seq, "writing audit entry"); sqlx::query( r#" INSERT INTO audit_log - (seq, timestamp, event_id, event_kind, actor_pubkey, action, - channel_id, metadata, prev_hash, hash) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + (community_id, seq, hash, prev_hash, action, actor_pubkey, object_id, detail, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) "#, ) + .bind(audit_entry.community_id) .bind(audit_entry.seq) - .bind(audit_entry.timestamp) - .bind(&audit_entry.event_id) - .bind(audit_entry.event_kind as i32) - .bind(&audit_entry.actor_pubkey) - .bind(audit_entry.action.as_str()) - .bind(channel_id_bytes) - .bind(&audit_entry.metadata) - .bind(&audit_entry.prev_hash) .bind(&audit_entry.hash) + .bind(audit_entry.prev_hash.as_deref()) + .bind(audit_entry.action.as_str()) + .bind(audit_entry.actor_pubkey.as_deref()) + .bind(audit_entry.object_id.as_deref()) + .bind(&audit_entry.detail) + .bind(audit_entry.created_at) .execute(&mut *tx) .await?; @@ -138,19 +142,28 @@ impl AuditService { Ok(audit_entry) } - /// Verify the hash chain for `[from_seq, to_seq]`. - /// Returns `Ok(false)` if range is empty, `Ok(true)` if valid. + /// Verify the hash chain for one community over `[from_seq, to_seq]`. + /// + /// Reads exactly that community's chain — it can never observe another + /// community's entries or head. Returns `Ok(false)` if the range is empty, + /// `Ok(true)` if the segment is internally consistent. #[instrument(skip(self))] - pub async fn verify_chain(&self, from_seq: i64, to_seq: i64) -> Result { + pub async fn verify_chain( + &self, + community: CommunityId, + from_seq: i64, + to_seq: i64, + ) -> Result { let rows = sqlx::query( r#" - SELECT seq, timestamp, event_id, event_kind, actor_pubkey, - action, channel_id, metadata, prev_hash, hash + SELECT community_id, seq, hash, prev_hash, action, actor_pubkey, + object_id, detail, created_at FROM audit_log - WHERE seq BETWEEN $1 AND $2 + WHERE community_id = $1 AND seq BETWEEN $2 AND $3 ORDER BY seq ASC "#, ) + .bind(community.as_uuid()) .bind(from_seq) .bind(to_seq) .fetch_all(&self.pool) @@ -160,30 +173,21 @@ impl AuditService { return Ok(false); } - let mut expected_prev: Option = None; + let mut expected_prev: Option> = None; for row in &rows { let entry = row_to_audit_entry(row)?; - let prev_hash = entry.prev_hash.clone(); - let stored_hash = entry.hash.clone(); if let Some(ref expected) = expected_prev { - if &prev_hash != expected { - return Err(AuditError::ChainViolation { - seq: entry.seq, - expected: expected.clone(), - actual: prev_hash, - }); + // The previous entry's hash must equal this entry's prev_hash. + if entry.prev_hash.as_deref() != Some(expected.as_slice()) { + return Err(AuditError::ChainViolation { seq: entry.seq }); } } let computed = compute_hash(&entry)?; - if computed != stored_hash { - return Err(AuditError::HashMismatch { - seq: entry.seq, - stored: stored_hash, - computed, - }); + if computed.as_slice() != entry.hash.as_slice() { + return Err(AuditError::HashMismatch { seq: entry.seq }); } expected_prev = Some(entry.hash); @@ -192,23 +196,27 @@ impl AuditService { Ok(true) } - /// Returns up to `limit` entries starting at `from_seq`, ordered by sequence number. + /// Returns up to `limit` entries from one community's chain starting at + /// `from_seq`, ordered by sequence number. Scoped to `community` — never + /// returns another community's rows. #[instrument(skip(self))] pub async fn get_entries( &self, + community: CommunityId, from_seq: i64, limit: i64, ) -> Result, AuditError> { let rows = sqlx::query( r#" - SELECT seq, timestamp, event_id, event_kind, actor_pubkey, - action, channel_id, metadata, prev_hash, hash + SELECT community_id, seq, hash, prev_hash, action, actor_pubkey, + object_id, detail, created_at FROM audit_log - WHERE seq >= $1 + WHERE community_id = $1 AND seq >= $2 ORDER BY seq ASC - LIMIT $2 + LIMIT $3 "#, ) + .bind(community.as_uuid()) .bind(from_seq) .bind(limit) .fetch_all(&self.pool) @@ -219,34 +227,22 @@ impl AuditService { } fn row_to_audit_entry(row: &sqlx::postgres::PgRow) -> Result { - let seq: i64 = row.get("seq"); let action_str: String = row.get("action"); let action: AuditAction = action_str.parse().map_err(|_| { - warn!(seq, action = %action_str, "unknown action in audit log"); - AuditError::UnknownAction(action_str.clone()) - })?; - - let channel_id_bytes: Option> = row.get("channel_id"); - let channel_id = channel_id_bytes.and_then(|b| b.try_into().ok().map(uuid::Uuid::from_bytes)); - - let raw_kind: i32 = row.get("event_kind"); - let event_kind = u32::try_from(raw_kind).map_err(|_| { - AuditError::Database(sqlx::Error::Protocol(format!( - "event_kind {raw_kind} out of u32 range at seq {seq}" - ))) + warn!("unknown action in audit log"); + AuditError::UnknownAction })?; Ok(AuditEntry { - seq, - timestamp: row.get("timestamp"), - event_id: row.get("event_id"), - event_kind, - actor_pubkey: row.get("actor_pubkey"), - action, - channel_id, - metadata: row.get("metadata"), - prev_hash: row.get("prev_hash"), + community_id: row.get::("community_id"), + seq: row.get("seq"), hash: row.get("hash"), + prev_hash: row.get("prev_hash"), + action, + actor_pubkey: row.get("actor_pubkey"), + object_id: row.get("object_id"), + detail: row.get("detail"), + created_at: row.get("created_at"), }) } @@ -255,10 +251,12 @@ mod tests { use super::*; use crate::action::AuditAction; use crate::entry::NewAuditEntry; - use crate::hash::GENESIS_HASH; use std::sync::OnceLock; use tokio::sync::Mutex; + use uuid::Uuid; + // The per-community advisory lock means different communities don't contend, + // but tests share one table; serialize them so seq assertions are stable. static DB_LOCK: OnceLock> = OnceLock::new(); fn db_lock() -> &'static Mutex<()> { DB_LOCK.get_or_init(|| Mutex::new(())) @@ -270,122 +268,237 @@ mod tests { PgPool::connect(&url).await.ok() } - fn sample_new_entry(kind: u32, action: AuditAction) -> NewAuditEntry { + /// A `community_id` known to exist in `communities` (FK target). Inserts a + /// throwaway community row with a unique host and returns its id. + async fn make_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("test-{id}.example"); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + id + } + + fn new_entry(community_id: Uuid, action: AuditAction) -> NewAuditEntry { NewAuditEntry { - event_id: format!("evt_{}", uuid::Uuid::new_v4()), - event_kind: kind, - actor_pubkey: "deadbeefdeadbeef".into(), + community_id: CommunityId::from_uuid(community_id), action, - channel_id: None, - metadata: serde_json::json!({"test": true}), + actor_pubkey: Some(vec![0xab; 32]), + object_id: Some(format!("obj_{}", Uuid::new_v4())), + detail: serde_json::json!({"test": true}), } } - async fn reset_audit_table(pool: &PgPool) { - sqlx::query("TRUNCATE TABLE audit_log") - .execute(pool) + #[tokio::test] + #[ignore = "requires Postgres"] + async fn community_chain_starts_at_seq_1_with_null_prev() { + let _g = db_lock().lock().await; + let Some(pool) = test_pool().await else { + return; + }; + let svc = AuditService::new(pool.clone()); + let c = make_community(&pool).await; + + let e = svc + .log(new_entry(c, AuditAction::EventCreated)) .await .unwrap(); + assert_eq!(e.seq, 1, "first entry in a community starts at seq 1"); + assert!(e.prev_hash.is_none(), "genesis entry has NULL prev_hash"); + assert_eq!(e.hash.len(), 32); + assert_eq!(e.community_id, c); } #[tokio::test] #[ignore = "requires Postgres"] - async fn genesis_entry() { - let _guard = db_lock().lock().await; + async fn chain_links_within_one_community() { + let _g = db_lock().lock().await; let Some(pool) = test_pool().await else { return; }; let svc = AuditService::new(pool.clone()); - svc.ensure_schema().await.unwrap(); - reset_audit_table(&pool).await; + let c = make_community(&pool).await; - let entry = svc - .log(sample_new_entry(1, AuditAction::EventCreated)) + let e1 = svc + .log(new_entry(c, AuditAction::EventCreated)) + .await + .unwrap(); + let e2 = svc + .log(new_entry(c, AuditAction::ChannelCreated)) + .await + .unwrap(); + let e3 = svc + .log(new_entry(c, AuditAction::MemberAdded)) .await .unwrap(); - assert_eq!(entry.prev_hash, GENESIS_HASH); - assert_eq!(entry.seq, 1); - assert_eq!(entry.hash.len(), 64); + assert_eq!(e1.seq, 1); + assert_eq!(e2.seq, 2); + assert_eq!(e3.seq, 3); + assert!(e1.prev_hash.is_none()); + assert_eq!(e2.prev_hash.as_deref(), Some(e1.hash.as_slice())); + assert_eq!(e3.prev_hash.as_deref(), Some(e2.hash.as_slice())); + assert!(svc + .verify_chain(CommunityId::from_uuid(c), 1, 3) + .await + .unwrap()); } + /// THE isolation property: two communities keep independent chains. Each + /// starts at seq 1; interleaving writes does not link them; verifying one + /// never traverses the other. #[tokio::test] #[ignore = "requires Postgres"] - async fn chain_integrity() { - let _guard = db_lock().lock().await; + async fn chains_are_independent_per_community() { + let _g = db_lock().lock().await; let Some(pool) = test_pool().await else { return; }; let svc = AuditService::new(pool.clone()); - svc.ensure_schema().await.unwrap(); - reset_audit_table(&pool).await; + let a = make_community(&pool).await; + let b = make_community(&pool).await; - let e1 = svc - .log(sample_new_entry(1, AuditAction::EventCreated)) + // Interleave A and B writes. + let a1 = svc + .log(new_entry(a, AuditAction::EventCreated)) .await .unwrap(); - let e2 = svc - .log(sample_new_entry(1, AuditAction::ChannelCreated)) + let b1 = svc + .log(new_entry(b, AuditAction::EventCreated)) .await .unwrap(); - let e3 = svc - .log(sample_new_entry(1, AuditAction::MemberAdded)) + let a2 = svc + .log(new_entry(a, AuditAction::ChannelCreated)) + .await + .unwrap(); + let b2 = svc + .log(new_entry(b, AuditAction::ChannelCreated)) .await .unwrap(); - assert_eq!(e1.prev_hash, GENESIS_HASH); - assert_eq!(e2.prev_hash, e1.hash); - assert_eq!(e3.prev_hash, e2.hash); + // Each community's seq is independent and starts at 1. + assert_eq!((a1.seq, a2.seq), (1, 2)); + assert_eq!((b1.seq, b2.seq), (1, 2)); + + // A's chain links only within A; B's only within B. A2 must NOT chain to + // B1 even though B1 was written between A1 and A2. + assert_eq!(a2.prev_hash.as_deref(), Some(a1.hash.as_slice())); + assert_eq!(b2.prev_hash.as_deref(), Some(b1.hash.as_slice())); + assert_ne!(a2.prev_hash, b1.prev_hash); + + // Verifying A's chain traverses only A; same for B. + assert!(svc + .verify_chain(CommunityId::from_uuid(a), 1, 2) + .await + .unwrap()); + assert!(svc + .verify_chain(CommunityId::from_uuid(b), 1, 2) + .await + .unwrap()); - assert!(svc.verify_chain(e1.seq, e3.seq).await.unwrap()); + // get_entries scoped to A returns only A's rows. + let a_rows = svc + .get_entries(CommunityId::from_uuid(a), 1, 100) + .await + .unwrap(); + assert!( + a_rows.iter().all(|e| e.community_id == a), + "A read leaked another community" + ); + assert_eq!(a_rows.len(), 2); } #[tokio::test] #[ignore = "requires Postgres"] - async fn verify_chain_detects_tampering() { - let _guard = db_lock().lock().await; + async fn verify_detects_tampering_within_a_community() { + let _g = db_lock().lock().await; let Some(pool) = test_pool().await else { return; }; let svc = AuditService::new(pool.clone()); - svc.ensure_schema().await.unwrap(); - reset_audit_table(&pool).await; + let c = make_community(&pool).await; - let e1 = svc - .log(sample_new_entry(1, AuditAction::EventCreated)) + svc.log(new_entry(c, AuditAction::EventCreated)) .await .unwrap(); let e2 = svc - .log(sample_new_entry(1, AuditAction::EventDeleted)) + .log(new_entry(c, AuditAction::EventDeleted)) .await .unwrap(); - let e3 = svc - .log(sample_new_entry(1, AuditAction::ChannelDeleted)) + svc.log(new_entry(c, AuditAction::ChannelDeleted)) .await .unwrap(); - sqlx::query("UPDATE audit_log SET actor_pubkey = 'tampered' WHERE seq = $1") + // Tamper with e2's stored actor_pubkey. + let tampered: Vec = vec![0xff; 32]; + sqlx::query("UPDATE audit_log SET actor_pubkey = $1 WHERE community_id = $2 AND seq = $3") + .bind(tampered) + .bind(c) .bind(e2.seq) .execute(&pool) .await .unwrap(); - let result = svc.verify_chain(e1.seq, e3.seq).await; - assert!(matches!(result, Err(AuditError::HashMismatch { seq, .. }) if seq == e2.seq)); + let r = svc.verify_chain(CommunityId::from_uuid(c), 1, 3).await; + assert!(matches!(r, Err(AuditError::HashMismatch { seq }) if seq == e2.seq)); } + /// A row forged with another community's id cannot pass verification against + /// the chain it was stamped for, because community_id is hashed in. (Models + /// "a row can't be replayed across chains and still verify".) #[tokio::test] #[ignore = "requires Postgres"] - async fn auth_events_rejected() { + async fn cross_community_row_does_not_verify() { + let _g = db_lock().lock().await; let Some(pool) = test_pool().await else { return; }; let svc = AuditService::new(pool.clone()); + let a = make_community(&pool).await; + let b = make_community(&pool).await; - let result = svc - .log(sample_new_entry(KIND_AUTH, AuditAction::AuthSuccess)) - .await; + let a1 = svc + .log(new_entry(a, AuditAction::EventCreated)) + .await + .unwrap(); + + // Forge: copy A's seq-1 row's hash into B's chain at seq 1. + sqlx::query( + "INSERT INTO audit_log (community_id, seq, hash, prev_hash, action, actor_pubkey, object_id, detail, created_at) + VALUES ($1, 1, $2, NULL, $3, $4, $5, $6, NOW())", + ) + .bind(b) + .bind(&a1.hash) // A's hash, which was computed over community_id = A + .bind(a1.action.as_str()) + .bind(a1.actor_pubkey.as_deref()) + .bind(a1.object_id.as_deref()) + .bind(&a1.detail) + .execute(&pool) + .await + .unwrap(); + + // Verifying B's chain recomputes the hash with community_id = B, which + // won't match A's stored hash → HashMismatch. The forge is rejected. + let r = svc.verify_chain(CommunityId::from_uuid(b), 1, 1).await; + assert!(matches!(r, Err(AuditError::HashMismatch { seq: 1 }))); + } - assert!(matches!(result, Err(AuditError::AuthEventForbidden))); + #[tokio::test] + #[ignore = "requires Postgres"] + async fn verify_empty_range_is_false() { + let _g = db_lock().lock().await; + let Some(pool) = test_pool().await else { + return; + }; + let svc = AuditService::new(pool.clone()); + let c = make_community(&pool).await; + // No entries for this fresh community. + assert!(!svc + .verify_chain(CommunityId::from_uuid(c), 1, 100) + .await + .unwrap()); } } diff --git a/crates/buzz-auth/src/access.rs b/crates/buzz-auth/src/access.rs index 1fc1a1ca8..392f6c0e8 100644 --- a/crates/buzz-auth/src/access.rs +++ b/crates/buzz-auth/src/access.rs @@ -6,6 +6,7 @@ use std::collections::HashSet; use std::future::Future; +use buzz_core::TenantContext; use nostr::PublicKey; use uuid::Uuid; @@ -17,24 +18,39 @@ use crate::scope::Scope; /// Implemented by the database layer (`buzz-db`) in production. The `buzz-auth` /// crate defines the trait so it can enforce access rules without a direct dependency /// on `buzz-db`. +/// +/// ## Tenant scoping +/// +/// Every method takes `&TenantContext`. Channel UUIDs are not globally unique under +/// multi-tenant — the frozen schema's `channels` PK is `(community_id, id)`, so the +/// same UUID can legitimately exist in two communities. A bare `WHERE id = $1` +/// implementation would be a cross-community existence oracle and could return +/// `true` for a B-community membership when the request bound community is A. +/// Implementations MUST scope every query by `ctx.community()` (S1 cross-community +/// fence at the access layer). pub trait ChannelAccessChecker: Send + Sync { - /// Return the set of channel UUIDs accessible to `pubkey`. + /// Return the set of channel UUIDs in `ctx`'s community accessible to `pubkey`. + /// + /// Channels in other communities, even with the same UUID, MUST NOT appear. fn accessible_channel_ids( &self, + ctx: &TenantContext, pubkey: &PublicKey, ) -> impl Future, AuthError>> + Send; - /// Returns `true` if `pubkey` is a member of `channel_id`. + /// Returns `true` if `pubkey` is a member of `(ctx.community, channel_id)`. /// - /// Default implementation calls [`Self::accessible_channel_ids`] and checks membership. - /// Implementations may override this with a more efficient point-lookup query. + /// Default implementation calls [`Self::accessible_channel_ids`] and checks + /// membership. Implementations may override this with a more efficient + /// scoped point-lookup query. fn can_access( &self, + ctx: &TenantContext, pubkey: &PublicKey, channel_id: Uuid, ) -> impl Future> + Send { async move { - let ids = self.accessible_channel_ids(pubkey).await?; + let ids = self.accessible_channel_ids(ctx, pubkey).await?; Ok(ids.contains(&channel_id)) } } @@ -52,30 +68,32 @@ pub fn require_scope(scopes: &[Scope], required: Scope) -> Result<(), AuthError> } } -/// Verify read access: scope + membership. +/// Verify read access: scope + membership in `ctx`'s community. pub async fn check_read_access( checker: &impl ChannelAccessChecker, + ctx: &TenantContext, pubkey: &PublicKey, channel_id: Uuid, scopes: &[Scope], ) -> Result<(), AuthError> { require_scope(scopes, Scope::MessagesRead)?; - if checker.can_access(pubkey, channel_id).await? { + if checker.can_access(ctx, pubkey, channel_id).await? { Ok(()) } else { Err(AuthError::ChannelAccessDenied) } } -/// Verify write access: scope + membership. +/// Verify write access: scope + membership in `ctx`'s community. pub async fn check_write_access( checker: &impl ChannelAccessChecker, + ctx: &TenantContext, pubkey: &PublicKey, channel_id: Uuid, scopes: &[Scope], ) -> Result<(), AuthError> { require_scope(scopes, Scope::MessagesWrite)?; - if checker.can_access(pubkey, channel_id).await? { + if checker.can_access(ctx, pubkey, channel_id).await? { Ok(()) } else { Err(AuthError::ChannelAccessDenied) @@ -83,9 +101,12 @@ pub async fn check_write_access( } /// In-memory [`ChannelAccessChecker`] for unit tests. +/// +/// Membership is keyed on the full `(community_id, pubkey, channel_id)` tuple +/// so the mock can't accidentally model a non-tenant-scoped checker. #[cfg(any(test, feature = "test-utils"))] pub struct MockAccessChecker { - allowed: HashSet<(String, Uuid)>, + allowed: HashSet<(uuid::Uuid, String, Uuid)>, } #[cfg(any(test, feature = "test-utils"))] @@ -97,9 +118,10 @@ impl MockAccessChecker { } } - /// Grant `pubkey` access to `channel_id`. - pub fn allow(&mut self, pubkey: &PublicKey, channel_id: Uuid) { - self.allowed.insert((pubkey.to_hex(), channel_id)); + /// Grant `pubkey` access to `channel_id` inside `ctx`'s community. + pub fn allow(&mut self, ctx: &TenantContext, pubkey: &PublicKey, channel_id: Uuid) { + self.allowed + .insert((*ctx.community().as_uuid(), pubkey.to_hex(), channel_id)); } } @@ -112,13 +134,18 @@ impl Default for MockAccessChecker { #[cfg(any(test, feature = "test-utils"))] impl ChannelAccessChecker for MockAccessChecker { - async fn accessible_channel_ids(&self, pubkey: &PublicKey) -> Result, AuthError> { + async fn accessible_channel_ids( + &self, + ctx: &TenantContext, + pubkey: &PublicKey, + ) -> Result, AuthError> { + let community = *ctx.community().as_uuid(); let hex = pubkey.to_hex(); Ok(self .allowed .iter() - .filter(|(pk, _)| pk == &hex) - .map(|(_, id)| *id) + .filter(|(c, pk, _)| *c == community && pk == &hex) + .map(|(_, _, id)| *id) .collect()) } } @@ -126,61 +153,99 @@ impl ChannelAccessChecker for MockAccessChecker { #[cfg(test)] mod tests { use super::*; + use buzz_core::CommunityId; use nostr::Keys; + fn fixture_ctx() -> TenantContext { + TenantContext::resolved(CommunityId::from_uuid(Uuid::new_v4()), "test.example") + } + #[tokio::test] async fn mock_checker_allow_and_deny() { + let ctx = fixture_ctx(); let keys = Keys::generate(); let pk = keys.public_key(); let allowed_ch = Uuid::new_v4(); let denied_ch = Uuid::new_v4(); let mut checker = MockAccessChecker::new(); - checker.allow(&pk, allowed_ch); + checker.allow(&ctx, &pk, allowed_ch); - assert!(checker.can_access(&pk, allowed_ch).await.unwrap()); - assert!(!checker.can_access(&pk, denied_ch).await.unwrap()); + assert!(checker.can_access(&ctx, &pk, allowed_ch).await.unwrap()); + assert!(!checker.can_access(&ctx, &pk, denied_ch).await.unwrap()); } #[tokio::test] async fn read_access_denied_by_scope() { + let ctx = fixture_ctx(); let keys = Keys::generate(); let pk = keys.public_key(); let ch = Uuid::new_v4(); let mut checker = MockAccessChecker::new(); - checker.allow(&pk, ch); + checker.allow(&ctx, &pk, ch); assert!(matches!( - check_read_access(&checker, &pk, ch, &[]).await, + check_read_access(&checker, &ctx, &pk, ch, &[]).await, Err(AuthError::InsufficientScope { .. }) )); } #[tokio::test] async fn read_access_denied_by_membership() { + let ctx = fixture_ctx(); let keys = Keys::generate(); let pk = keys.public_key(); let ch = Uuid::new_v4(); let checker = MockAccessChecker::new(); assert!(matches!( - check_read_access(&checker, &pk, ch, &[Scope::MessagesRead]).await, + check_read_access(&checker, &ctx, &pk, ch, &[Scope::MessagesRead]).await, Err(AuthError::ChannelAccessDenied) )); } #[tokio::test] async fn read_access_granted() { + let ctx = fixture_ctx(); let keys = Keys::generate(); let pk = keys.public_key(); let ch = Uuid::new_v4(); let mut checker = MockAccessChecker::new(); - checker.allow(&pk, ch); + checker.allow(&ctx, &pk, ch); - assert!(check_read_access(&checker, &pk, ch, &[Scope::MessagesRead]) + assert!( + check_read_access(&checker, &ctx, &pk, ch, &[Scope::MessagesRead]) + .await + .is_ok() + ); + } + + #[tokio::test] + async fn access_does_not_cross_communities() { + // S1 fence at the access layer: same pubkey, same channel UUID, two + // communities. A grant in A MUST NOT show up under B's TenantContext. + // This bites the existence-oracle direction a bare `WHERE id=$1` + // checker would have left open. + let ctx_a = fixture_ctx(); + let ctx_b = fixture_ctx(); + let keys = Keys::generate(); + let pk = keys.public_key(); + let ch = Uuid::new_v4(); + + let mut checker = MockAccessChecker::new(); + checker.allow(&ctx_a, &pk, ch); + + assert!(checker.can_access(&ctx_a, &pk, ch).await.unwrap()); + assert!( + !checker.can_access(&ctx_b, &pk, ch).await.unwrap(), + "access in community A must NOT leak into community B for same (pubkey, channel_id)" + ); + assert!(checker + .accessible_channel_ids(&ctx_b, &pk) .await - .is_ok()); + .unwrap() + .is_empty()); } } diff --git a/crates/buzz-auth/src/error.rs b/crates/buzz-auth/src/error.rs index 3ac71b58f..7f8131bc3 100644 --- a/crates/buzz-auth/src/error.rs +++ b/crates/buzz-auth/src/error.rs @@ -30,6 +30,12 @@ pub enum AuthError { #[error("NIP-98 HTTP Auth verification failed: {0}")] Nip98Invalid(String), + /// A NIP-98 event with the same id has already been observed within the + /// replay-prevention window. The event itself was structurally valid; the + /// rejection is on freshness, not validity. + #[error("NIP-98 replay: event id already seen within window")] + Nip98Replay, + /// The pubkey in the auth event does not match the expected identity. #[error("pubkey mismatch: event pubkey does not match authenticated identity")] PubkeyMismatch, diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index fcb39010c..4f971b0ba 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -23,6 +23,8 @@ pub mod error; pub mod nip42; /// NIP-98 HTTP Auth verification (kind:27235). pub mod nip98; +/// NIP-98 replay protection — shared, community-scoped, atomic seen-set. +pub mod nip98_replay; /// Per-connection rate limiting. pub mod rate_limit; /// OAuth scope parsing and enforcement. @@ -32,6 +34,9 @@ pub use access::{check_read_access, check_write_access, require_scope, ChannelAc pub use error::AuthError; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; +pub use nip98_replay::{ + nip98_replay_key, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS, +}; pub use rate_limit::{ ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter, }; @@ -40,6 +45,8 @@ pub use scope::{parse_scopes, Scope}; #[cfg(any(test, feature = "test-utils"))] pub use access::MockAccessChecker; #[cfg(any(test, feature = "test-utils"))] +pub use nip98_replay::AlwaysFreshReplayGuard; +#[cfg(any(test, feature = "test-utils"))] pub use rate_limit::AlwaysAllowRateLimiter; /// How the connection was authenticated. diff --git a/crates/buzz-auth/src/nip98.rs b/crates/buzz-auth/src/nip98.rs index 277cbe27c..74ed8c265 100644 --- a/crates/buzz-auth/src/nip98.rs +++ b/crates/buzz-auth/src/nip98.rs @@ -134,17 +134,19 @@ pub fn verify_nip98_event( /// /// - Lowercases scheme and host (already done by the `url` crate). /// - Strips trailing slash from path. -/// - Treats `localhost` and `::1` as equivalent to `127.0.0.1`. +/// +/// **No loopback aliasing.** `localhost`, `::1`, and `127.0.0.1` are three +/// distinct hosts here. Under multi-tenant the `u`-tag host is the row-zero +/// community binding (`docs/multi-tenant-conformance.md`, NIP-98 row): if +/// `verify_nip98_event` collapses them, an event signed for `localhost` +/// would pass against a `127.0.0.1`-resolved community (or vice versa) — +/// a host-binding side door. Tests reconstruct `expected_url` from their +/// own bound host, the same shape production does. fn normalize_url(raw: &str) -> String { let mut parsed = match Url::parse(raw) { Ok(u) => u, Err(_) => return raw.to_lowercase(), }; - if let Some(host) = parsed.host_str() { - if host == "localhost" || host == "::1" { - let _ = parsed.set_host(Some("127.0.0.1")); - } - } let path = parsed.path().trim_end_matches('/').to_string(); parsed.set_path(&path); parsed.to_string() @@ -284,12 +286,32 @@ mod tests { } #[test] - fn localhost_normalized() { + fn loopback_aliases_are_distinct_hosts() { + // Under multi-tenant, the `u`-tag host is the row-zero community + // binding. An event signed for `localhost` MUST NOT pass against an + // expected URL on `127.0.0.1` (or `::1`) — collapsing the three would + // be a host-check side door. Production reconstructs `expected_url` + // from the community-bound host; tests do the same. let keys = Keys::generate(); let localhost_url = "http://localhost:3000/api/tokens"; let loopback_url = "http://127.0.0.1:3000/api/tokens"; let json = make_nip98_event(&keys, localhost_url, TEST_METHOD, None, None); let result = verify_nip98_event(&json, loopback_url, TEST_METHOD, None); - assert!(result.is_ok()); + assert!( + matches!(result, Err(AuthError::Nip98Invalid(_))), + "localhost u-tag must NOT match a 127.0.0.1 expected_url; got {result:?}" + ); + + // Symmetric: signed-for-127.0.0.1 against expected localhost — same answer. + let json2 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None); + let result2 = verify_nip98_event(&json2, localhost_url, TEST_METHOD, None); + assert!( + matches!(result2, Err(AuthError::Nip98Invalid(_))), + "127.0.0.1 u-tag must NOT match a localhost expected_url; got {result2:?}" + ); + + // And identity still holds — same host on both sides verifies. + let json3 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None); + assert!(verify_nip98_event(&json3, loopback_url, TEST_METHOD, None).is_ok()); } } diff --git a/crates/buzz-auth/src/nip98_replay.rs b/crates/buzz-auth/src/nip98_replay.rs new file mode 100644 index 000000000..aece4b0c7 --- /dev/null +++ b/crates/buzz-auth/src/nip98_replay.rs @@ -0,0 +1,233 @@ +//! NIP-98 replay protection — shared, community-scoped, atomic seen-set. +//! +//! NIP-98 verification ([`crate::nip98::verify_nip98_event`]) is structurally +//! complete: it checks signature, kind, timestamp window, URL, method, and +//! optional body hash. It does **not** check whether the same event id has +//! already been used — that requires shared state. With multiple relay pods +//! ("any pod, any connection" per the rewrite §4 architecture), an in-process +//! cache (moka, DashMap) does not carry the freshness proof across pods, so +//! replay protection is a §5 hard gate. +//! +//! The required shape (§5): +//! +//! - shared state (Redis), atomic set-if-absent, TTL ≥ 120s +//! - community-scoped key — see [`nip98_replay_key`] +//! +//! ## Usage shape +//! +//! Verify first, then mark. Burning a seen-set slot on a forgery would let an +//! attacker who knows a future event id of a victim DoS the legitimate event. +//! +//! ```ignore +//! let pubkey = buzz_auth::verify_nip98_event(json, url, method, body)?; +//! if !replay.try_mark(&ctx, &event_id, buzz_auth::DEFAULT_REPLAY_TTL_SECS).await? { +//! return Err(AuthError::Nip98Replay); +//! } +//! // safe to honor the request as `pubkey` +//! ``` +//! +//! The TTL must cover the verifier's clock-skew tolerance (currently ±60s, so +//! the window over which a duplicate event id is even plausible is 2×60 = 120s). +//! [`DEFAULT_REPLAY_TTL_SECS`] is the floor; deployments may raise it. + +use std::{future::Future, pin::Pin}; + +use buzz_core::TenantContext; +use nostr::EventId; + +use crate::error::AuthError; + +/// Floor for the replay-prevention window, in seconds. +/// +/// Matches the §5 gate ("TTL ≥ 120s") and the doubled NIP-98 timestamp +/// tolerance (±60s window → 120s span). Implementations MAY use a larger TTL +/// for safety margin; they MUST NOT use a smaller one. +pub const DEFAULT_REPLAY_TTL_SECS: u64 = 120; + +/// Ceiling for the replay-prevention window, in seconds. +/// +/// Any TTL beyond an hour is implausible for NIP-98 replay protection: the +/// verifier only accepts events within ±60s, so a same-id replay is only +/// physically possible inside that window plus clock skew. A 1-hour cap is +/// 30× the natural maximum and still keeps Redis values well inside +/// `i64::MAX` seconds (which Redis `EX` requires). Anything larger reaching +/// this code is a config/caller bug; implementations MUST clamp down to it +/// rather than admit values that risk Redis `EX` parse failures or +/// pathologically long-lived seen-set entries. +pub const MAX_REPLAY_TTL_SECS: u64 = 3600; + +/// Shared seen-set for NIP-98 event ids, scoped per community. +/// +/// The production implementation lives in `buzz-pubsub` (Redis `SET NX EX`). +/// A test impl is provided behind `cfg(any(test, feature = "test-utils"))`. +pub trait Nip98ReplayGuard: Send + Sync { + /// Atomically claim `event_id` for `ctx`'s community. + /// + /// Returns `Ok(true)` when the id is newly inserted (proceed) and + /// `Ok(false)` when an entry already exists (the caller MUST reject the + /// request as replay). + /// + /// On `Err` (Redis unreachable, etc.) callers MUST fail closed — reject + /// the request rather than admitting it. The shared seen-set is a + /// correctness fence; degrading to "best effort, allow on error" forfeits + /// the freshness proof. + /// + /// Implementations MUST use an atomic set-if-absent operation; a + /// read-then-write sequence loses to concurrent inserts and forfeits the + /// freshness proof. + /// + /// `ttl_secs` MUST be at least [`DEFAULT_REPLAY_TTL_SECS`]. Implementations + /// MAY clamp a smaller value up to the floor rather than reject; they MUST + /// NOT honor it as-given. + /// + /// `ttl_secs` MUST be clamped down to [`MAX_REPLAY_TTL_SECS`] if larger. + /// The replay window's natural maximum is the verifier's ±60s tolerance; + /// values past an hour are implausible and risk Redis `EX` parse failures + /// (Redis interprets `EX` as a signed 64-bit integer). + fn try_mark<'a>( + &'a self, + ctx: &'a TenantContext, + event_id: &'a EventId, + ttl_secs: u64, + ) -> Pin> + Send + 'a>>; +} + +/// Redis key for a NIP-98 replay marker: +/// `buzz:{community}:nip98:{event_id_hex}`. +/// +/// The community prefix is the S1 isolation fence at the replay layer. +/// Event ids are content-addressed (SHA-256 of the canonical event tuple) so +/// natural cross-community collision is zero, but the gate is fail-closed +/// isolation: a same-id replay across communities must consult two distinct +/// seen-set rows, not one shared row. +pub fn nip98_replay_key(ctx: &TenantContext, event_id: &EventId) -> String { + format!("buzz:{}:nip98:{}", ctx.community(), event_id.to_hex()) +} + +/// Always-fresh seen-set for unit tests — every `try_mark` returns `Ok(true)`. +/// +/// Use only in test code that does not exercise the replay path itself. +#[cfg(any(test, feature = "test-utils"))] +pub struct AlwaysFreshReplayGuard; + +#[cfg(any(test, feature = "test-utils"))] +impl Nip98ReplayGuard for AlwaysFreshReplayGuard { + fn try_mark<'a>( + &'a self, + _ctx: &'a TenantContext, + _event_id: &'a EventId, + _ttl_secs: u64, + ) -> Pin> + Send + 'a>> { + Box::pin(async { Ok(true) }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use buzz_core::CommunityId; + use nostr::{EventBuilder, Keys, Kind}; + use sha2::{Digest, Sha256}; + use uuid::Uuid; + + fn fixture_ctx(host: &str) -> TenantContext { + let bytes = Sha256::digest(host.as_bytes()); + let mut uuid_bytes = [0u8; 16]; + uuid_bytes.copy_from_slice(&bytes[..16]); + let id = CommunityId::from_uuid(Uuid::from_bytes(uuid_bytes)); + TenantContext::resolved(id, host) + } + + fn fixture_event_id() -> EventId { + let keys = Keys::generate(); + EventBuilder::new(Kind::HttpAuth, "") + .sign_with_keys(&keys) + .expect("sign") + .id + } + + #[test] + fn key_includes_community_prefix() { + let ctx = fixture_ctx("relay-a.example"); + let eid = fixture_event_id(); + let key = nip98_replay_key(&ctx, &eid); + let expected_prefix = format!("buzz:{}:nip98:", ctx.community()); + assert!( + key.starts_with(&expected_prefix), + "key {key} should start with {expected_prefix}" + ); + assert!(key.ends_with(&eid.to_hex())); + } + + #[test] + fn key_isolates_communities_for_same_event_id() { + // Belt-and-suspenders: even if a same-id event surfaces in two + // communities (which content-addressing makes implausible), the + // seen-set MUST consult two distinct rows. + let eid = fixture_event_id(); + let ctx_a = fixture_ctx("relay-a.example"); + let ctx_b = fixture_ctx("relay-b.example"); + let key_a = nip98_replay_key(&ctx_a, &eid); + let key_b = nip98_replay_key(&ctx_b, &eid); + assert_ne!( + key_a, key_b, + "same event id in two communities must not share a seen-set key" + ); + } + + #[test] + fn key_components_are_lowercase() { + // Stability/idempotence: if event id hex or community Display ever + // started emitting uppercase, a same logical claim would produce two + // distinct Redis rows → the seen-set would no longer be a seen-set. + let ctx = fixture_ctx("relay-a.example"); + let eid = fixture_event_id(); + let key = nip98_replay_key(&ctx, &eid); + for c in key.chars() { + assert!( + !c.is_ascii_uppercase(), + "nip98 replay key {key} must be all-lowercase ASCII" + ); + } + } + + #[test] + fn default_ttl_meets_gate_floor() { + // §5 gate: TTL ≥ 120s. Drift this constant down and the gate breaks. + // Const-drift tripwire: the assertion is intentionally over a constant. + #[allow(clippy::assertions_on_constants)] + { + assert!(DEFAULT_REPLAY_TTL_SECS >= 120); + } + } + + #[test] + fn ttl_floor_below_ceiling() { + // Sanity: any caller's clamped TTL must end up in [DEFAULT, MAX]. + // If these ever cross, the impl can't satisfy both bounds and the + // contract is broken. + // Const-drift tripwire: the assertion is intentionally over a constant. + #[allow(clippy::assertions_on_constants)] + { + assert!(DEFAULT_REPLAY_TTL_SECS < MAX_REPLAY_TTL_SECS); + } + } + + #[test] + fn max_ttl_fits_in_redis_signed_ex() { + // Redis `EX` is parsed as i64. `MAX_REPLAY_TTL_SECS` must fit so the + // clamp itself can't push us into a Redis-side parse failure. + assert!(MAX_REPLAY_TTL_SECS <= i64::MAX as u64); + } + + #[tokio::test] + async fn always_fresh_returns_true() { + let guard = AlwaysFreshReplayGuard; + let ctx = fixture_ctx("relay-a.example"); + let eid = fixture_event_id(); + assert!(guard + .try_mark(&ctx, &eid, DEFAULT_REPLAY_TTL_SECS) + .await + .unwrap()); + } +} diff --git a/crates/buzz-auth/src/rate_limit.rs b/crates/buzz-auth/src/rate_limit.rs index a77da4fa7..8fd42c50f 100644 --- a/crates/buzz-auth/src/rate_limit.rs +++ b/crates/buzz-auth/src/rate_limit.rs @@ -8,6 +8,7 @@ use std::net::IpAddr; +use buzz_core::TenantContext; use nostr::PublicKey; use serde::{Deserialize, Serialize}; @@ -147,14 +148,32 @@ impl Default for RateLimitConfig { /// The Redis-backed production implementation lives in `buzz-relay` / `buzz-pubsub`. /// A no-op `AlwaysAllowRateLimiter` is provided for unit tests. /// +/// ## Tenant scoping +/// +/// Pubkey-keyed limits ([`check_and_increment`]) take `&TenantContext` and the Redis +/// key is community-prefixed (`buzz:{community}:ratelimit:{pubkey}:{suffix}`). The +/// same pubkey active in two communities consumes two independent quotas — that is +/// the correct behavior under multi-tenant isolation (S1 cross-community fence). +/// +/// IP-keyed limits ([`check_ip_connection`]) are **operator-global** by design. They +/// gate connection acceptance at the network edge, before host→community resolution +/// has completed (or, on resolve failure, instead of it). Threading `&TenantContext` +/// through the connection-rate fence would invert the order of operations. If +/// per-(community, IP) caps are ever needed as a tenant-fairness signal, that +/// belongs in an additive `LimitType` keyed on `(community, ip)`, not in this trait. +/// /// ⚠️ The fixed-window algorithm used by the Redis implementation allows up to 2× /// burst at window boundaries. Upgrade to a sliding window or token bucket if strict /// per-second limiting is required. pub trait RateLimiter: Send + Sync { - /// Increment the counter for `pubkey` + `limit_type` and return whether the - /// request is within the configured `limit` for the given `window_secs`. + /// Increment the per-(community, pubkey) counter for `limit_type` and return + /// whether the request is within `limit` for the given `window_secs`. + /// + /// `ctx` scopes the counter to the resolved community; the same pubkey in two + /// communities is two independent quotas. fn check_and_increment( &self, + ctx: &TenantContext, pubkey: &PublicKey, limit_type: LimitType, window_secs: u64, @@ -162,7 +181,10 @@ pub trait RateLimiter: Send + Sync { ) -> impl std::future::Future> + Send; /// Increment the per-IP connection counter and return whether the connection - /// is within the configured `limit` for the given `window_secs`. + /// is within `limit` for the given `window_secs`. + /// + /// Operator-global — see trait docs. This fence runs before / outside of host + /// resolution and intentionally does not take a `TenantContext`. fn check_ip_connection( &self, ip: &IpAddr, @@ -171,16 +193,23 @@ pub trait RateLimiter: Send + Sync { ) -> impl std::future::Future> + Send; } -/// Redis key for pubkey-based rate limit: `buzz:ratelimit::` -pub fn rate_limit_key(pubkey: &PublicKey, limit_type: &LimitType) -> String { +/// Redis key for pubkey-based rate limit: +/// `buzz:{community}:ratelimit:{pubkey_hex}:{suffix}`. +/// +/// Community-prefixed: the same pubkey in two communities maps to two distinct +/// keys, so quotas don't bleed across the tenancy fence. +pub fn rate_limit_key(ctx: &TenantContext, pubkey: &PublicKey, limit_type: &LimitType) -> String { format!( - "buzz:ratelimit:{}:{}", + "buzz:{}:ratelimit:{}:{}", + ctx.community(), pubkey.to_hex(), limit_type.key_suffix() ) } -/// Redis key for IP-based rate limit: `buzz:ratelimit:ip::conn` +/// Redis key for IP-based rate limit: `buzz:ratelimit:ip:{ip}:conn`. +/// +/// Operator-global by design — see [`RateLimiter`] docs. pub fn ip_rate_limit_key(ip: &IpAddr) -> String { format!("buzz:ratelimit:ip:{}:conn", ip) } @@ -193,6 +222,7 @@ pub struct AlwaysAllowRateLimiter; impl RateLimiter for AlwaysAllowRateLimiter { async fn check_and_increment( &self, + _ctx: &TenantContext, _pubkey: &PublicKey, _limit_type: LimitType, window_secs: u64, @@ -214,19 +244,70 @@ impl RateLimiter for AlwaysAllowRateLimiter { #[cfg(test)] mod tests { use super::*; + use buzz_core::CommunityId; use nostr::Keys; + use sha2::Digest; use std::net::Ipv4Addr; + use uuid::Uuid; + + fn fixture_ctx(host: &str) -> TenantContext { + // Deterministic community id from host so test assertions can name the prefix. + let bytes = sha2::Sha256::digest(host.as_bytes()); + let mut uuid_bytes = [0u8; 16]; + uuid_bytes.copy_from_slice(&bytes[..16]); + let id = CommunityId::from_uuid(Uuid::from_bytes(uuid_bytes)); + TenantContext::resolved(id, host) + } #[test] - fn rate_limit_key_format() { + fn rate_limit_key_includes_community_prefix() { + let ctx = fixture_ctx("relay-a.example"); let keys = Keys::generate(); - let key = rate_limit_key(&keys.public_key(), &LimitType::Messages); - assert!(key.starts_with("buzz:ratelimit:")); + let key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::Messages); + let expected_prefix = format!("buzz:{}:ratelimit:", ctx.community()); + assert!( + key.starts_with(&expected_prefix), + "key {key} should start with {expected_prefix}" + ); assert!(key.ends_with(":msg")); } + #[test] + fn rate_limit_key_isolates_communities_for_same_pubkey() { + // The S1 cross-community isolation fence at the rate-limit key layer: + // same pubkey, two communities -> two distinct Redis keys -> independent quotas. + let keys = Keys::generate(); + let ctx_a = fixture_ctx("relay-a.example"); + let ctx_b = fixture_ctx("relay-b.example"); + let key_a = rate_limit_key(&ctx_a, &keys.public_key(), &LimitType::Messages); + let key_b = rate_limit_key(&ctx_b, &keys.public_key(), &LimitType::Messages); + assert_ne!( + key_a, key_b, + "same pubkey in two communities must not share a rate-limit key" + ); + } + + #[test] + fn rate_limit_key_components_are_lowercase() { + // Stability/idempotence invariant: if pubkey hex or community Display + // ever started emitting uppercase, the same (community, pubkey) would + // produce two distinct Redis keys → effective 2× quota. Pin the + // lowercase property here so the regression surfaces in unit tests, + // not in production traffic. + let ctx = fixture_ctx("relay-a.example"); + let keys = Keys::generate(); + let key = rate_limit_key(&ctx, &keys.public_key(), &LimitType::Messages); + for c in key.chars() { + assert!( + !c.is_ascii_uppercase(), + "rate-limit key {key} must be all-lowercase ASCII" + ); + } + } + #[test] fn ip_rate_limit_key_format() { + // IP fence stays operator-global — no community in the key. let ip = IpAddr::V4(Ipv4Addr::new(192, 168, 1, 1)); assert_eq!(ip_rate_limit_key(&ip), "buzz:ratelimit:ip:192.168.1.1:conn"); } @@ -234,9 +315,10 @@ mod tests { #[tokio::test] async fn always_allow_limiter() { let limiter = AlwaysAllowRateLimiter; + let ctx = fixture_ctx("relay-a.example"); let keys = Keys::generate(); let result = limiter - .check_and_increment(&keys.public_key(), LimitType::Messages, 60, 60) + .check_and_increment(&ctx, &keys.public_key(), LimitType::Messages, 60, 60) .await .unwrap(); assert!(result.allowed); diff --git a/crates/buzz-conformance/Cargo.toml b/crates/buzz-conformance/Cargo.toml new file mode 100644 index 000000000..0d2d1b5d2 --- /dev/null +++ b/crates/buzz-conformance/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "buzz-conformance" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Runtime trace schema + independent replay checker for MultiTenantRelay.tla" + +# Independence rule (skill: skill-runtime-formal-compliance): +# - Depend on NO production buzz crate. The schema carries its own opaque +# `CommunityLabel` UUID newtype rather than reusing `buzz_core::CommunityId` +# so the checker cannot inherit a bug from production type machinery, AND so +# buzz-core's deliberate "no Serde, no From" fence on `CommunityId` +# (the no-parse-from-client rule) is preserved. +# - The relay's emitter module converts at the seam by calling +# `tenant.community().as_uuid()` and wrapping into a `CommunityLabel`. +# - NEVER depend on buzz-db, buzz-relay, buzz-pubsub, buzz-auth, buzz-search, +# buzz-audit, or anything that touches the production reducer / authorization +# / projection helpers. The checker re-implements the spec transition +# relation from scratch so a bug in the production code does not mechanically +# become a bug in the checker. + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +uuid = { workspace = true } diff --git a/crates/buzz-conformance/LIMITS.md b/crates/buzz-conformance/LIMITS.md new file mode 100644 index 000000000..37f0f28d3 --- /dev/null +++ b/crates/buzz-conformance/LIMITS.md @@ -0,0 +1,125 @@ +# Limits of the runtime conformance gate + +The runtime conformance harness is **not a proof.** It says only this: +*for the executions that actually ran with tracing on*, the relay's +ingest/read decisions matched a trace the spec accepts. Coverage is +exactly the set of code paths exercised — no more, no less. + +This file says what the gate **doesn't** catch, so reviewers and +operators don't read more into a green run than is there. + +## Scope + +The harness is wired only at the **ingest/auth/read accept-reject +boundary** in `crates/buzz-relay/src/handlers/{ingest,req,event}.rs`. +That boundary was chosen because: + +1. It is where tenant-derived decisions become observable behavior. +2. The spec's `Next` relation is written in those terms. +3. Every other layer (DB filter SQL, Redis pubsub, S3 metadata) is + downstream of a decision made here. + +Decisions made elsewhere — for example, a buggy SQL `WHERE` clause that +silently returns cross-community rows — surface here only if the +projection reads enough of the row to notice. See §"What it does NOT +catch" below. + +## Coverage is execution coverage + +The gate validates traces from executions you ran. If an unsafe code +path never executes during a CI run, the gate is silent about it. This +is why coverage breach is load-bearing: an entry to a critical seam +that doesn't emit *any* action records `ImplBug`, which fails closed. + +But coverage breach can only fire on **paths the harness was armed +on**. If a new endpoint is added that bypasses `EmitGuard::arm`, the +gate is blind. New endpoints touching the tenant boundary MUST arm a +guard at entry — that's enforced by code review, not by the harness. + +## What it does NOT catch + +- **DB layer leaks the projection doesn't read.** The projection for + `read_message_rows` and `read_by_id_rows` records a `row_community` + per returned row. How the emitter computes that label is the design + question for the held-back req.rs patch — the honest options are + per-row channel→community lookup, or recording the resolved community + uniformly (which makes the gate decorative for read confinement). + The choice is Eva's review call before fixtures land. Until then, + the read-seam half of the gate is **not yet armed**. + +- **Cross-pod leaks.** The harness traces one process. A multi-pod + leak (NIP-98 replay across pods, fanout to the wrong pod) shows up + here only on the pod that observes the leak. Cross-pod attacks are + Sami's adversarial lane, not the conformance gate's. + +- **Time-bounded properties.** The spec is untimed; the gate is + untimed. A bug that only shows up under high concurrency or specific + ordering is in scope for perf/red-team, not for trace conformance + (unless it surfaces as an `Inv_NonInterference` violation in the + trace, which is the only thing the gate watches for). + +- **Pubsub fan-out.** Fan-out is **not** a spec action (see the + docstring in `event.rs`). A leak in fan-out shows up in the + **receiver's** ingest/read trace, not in the publisher's emit. + +- **Type-level fence violations.** `CommunityId` having no `From` + is enforced by the Rust compiler, not by this gate. If somebody adds + `From` for `CommunityId`, the production fence is broken and + this gate won't say so. + +- **Spec bugs.** The checker re-implements the spec; if the spec is + wrong, both pass. Spec correctness is the proof obligation of + `docs/spec/MultiTenantRelay.tla`, machine-checked by TLC. + +## What turning the harness off means + +`Tracer = NoopTracer` (the production default) makes every emit and +guard arm a no-op call. The relay still runs and still decides +correctly because the gate is **observation only** — it does not feed +back into the decision. Turning it off only loses observability. + +The CI command (below) constructs an in-memory tracer and asserts every +recorded trace against `check_trace`. If you bypass the CI command and +run with `NoopTracer`, you get no signal. + +## CI command + +The gate's bite is enforced by three test surfaces that MUST stay green +on every PR: + +```sh +# 1. Schema + checker unit tests (9 tests). Cover the transition rules +# directly — every `TraceAction` variant has a passing case and at +# least one mutation-class bite case. +cargo test -p buzz-conformance --lib + +# 2. Replay fixtures (5 tests). Three JSONL traces in +# crates/buzz-conformance/tests/fixtures/ are committed for reviewer +# visibility. The test reconstructs each from typed Rust, asserts +# the committed file matches byte-for-byte (so a schema-change PR +# must update the fixtures), then replays through `check_trace`: +# +# - good.jsonl → Ok(()) +# - bad_host_channel_mismatch.jsonl → IllegalTransition +# - bad_coverage_breach.jsonl → CoverageBreach +# +# To intentionally refresh fixtures after a schema bump: +# BUZZ_CONFORMANCE_UPDATE=1 cargo test -p buzz-conformance --test replay_fixtures +cargo test -p buzz-conformance --test replay_fixtures + +# 3. EmitGuard coverage-breach self-test (2 tests in +# crates/buzz-relay/src/conformance/mod.rs). Proves the Drop guard +# records `ImplBug` when no emit reaches the tracer, and stays +# silent when an emit did. The seam-name string flows through. +cargo test -p buzz-relay --lib conformance:: + +# Together: 9 + 5 + 2 = 16 tests; mutate-bite proven for the NI, +# IllegalTransition, and CoverageBreach gates. The integration replay +# (live relay → JsonlTracer → check_trace) lands with the read-seam +# patch onto Max's req.rs work. +``` + +The integration replay is the **next** ratchet — once the read-seam +emitter lands on Eva's integration branch the harness will drive the +existing e2e suite with a `JsonlTracer` per request and assert +`check_trace` for every captured trace. diff --git a/crates/buzz-conformance/TRACE_SCHEMA.md b/crates/buzz-conformance/TRACE_SCHEMA.md new file mode 100644 index 000000000..c1dca674d --- /dev/null +++ b/crates/buzz-conformance/TRACE_SCHEMA.md @@ -0,0 +1,163 @@ +# Trace Schema (`buzz-conformance`) + +Schema version: **1** (`SCHEMA_VERSION` in `src/lib.rs`). + +This document is the contract between the relay's emitter and the +independent replay checker. It is grounded in +[`docs/spec/MultiTenantRelay.tla`](../../docs/spec/MultiTenantRelay.tla) +and the runtime-formal-compliance skill. If you change the schema, this +file changes in the same commit. + +## North star + +> Don't ask "did the model pass." Ask "did the running code emit a trace +> the model accepts." + +The relay emits one `TraceStep` per decision at the ingest/auth/read +seam. The checker replays the trace against a Rust re-implementation of +the spec's `Next` relation — it does **not** call any production +reducer. + +## What a step looks like + +```jsonc +{ + "schema": 1, + "action": { /* TraceAction — see below */ }, + "state": { + "resolved_community": "", // from TenantContext::community() + "bound_host": "", // from TenantContext::host() + "actor": "<16 hex>" // first 16 hex of authed pubkey + } +} +``` + +`state` is *projected* state, not raw state. Concretely: + +| Field | What it carries | What it does NOT carry | +|-------|-----------------|------------------------| +| `resolved_community` | server-resolved community UUID | client-claimed `h` tag, event id, payload | +| `bound_host` | opaque host string from the resolver | raw `Host` header bytes | +| `actor` | first 16 hex chars of the authed pubkey | private key, NIP-98 token, signature | + +The `actor` prefix is a *hash already* from the client's POV (Schnorr +X-only) — so the prefix discloses nothing the relay's existing logs +don't already. This avoids dragging a hash dep into observability code. + +## Actions + +The `TraceAction` enum mirrors the spec's `Next` relation +(`MultiTenantRelay.tla:933+`). Each variant is documented with the +exact spec line it grounds in. + +### Write seam + +- **`write_insert { msg_id, channel, claimed_community }`** + spec: `WriteInsert` (line 514). A successful per-channel insert. The + row's community is `ChannelCommunity(channel)` per spec — the checker + looks it up from the model, so there is no `row_community` field on + the action. `claimed_community` is recorded so the checker can bite + when the client's `h` tag disagrees with `ChannelCommunity(channel)`. + +- **`write_insert_global { msg_id, claimed_community }`** + spec: `WriteInsertGlobal` (line 562). Channel-less write (DM, + gift-wrap, etc.). The row's community is derived from `bound_host` + via the host-community map; no `channel` field. `claimed_community` + recorded for the same reason as above. + +- **`write_duplicate { msg_id, channel, claimed_community }`** + spec: `WriteDuplicate` (line 612). The DB returned "already present"; + no row was added. No `row_community` because no row was produced. + +### Read seam + +- **`auth_check { channel, claimed_community, verdict }`** + spec: `AuthCheck` (line 794). M2/M8 target this action. The checker + enforces that `Allow` requires the channel's community == + `resolved_community` (the host-channel fence) AND the actor has scope + for that channel. + +- **`read_message_rows { channel, row_communities }`** + spec: `ReadMessageRows` (line 643). Bulk read returning candidate + rows. `row_communities` is a non-deduped `Vec` — the checker must see + every leaked label, not the set. + +- **`read_by_id_rows { channel, row_communities }`** + spec: `ReadByIdRows` (line 681). The search lane emits this for each + refetched hit. Modeling search as `read_message_rows` (candidates) + + `read_by_id_rows` per hit makes the per-hit re-auth visible to the + checker. + +- **`read_host_feed_rows { row_communities }`** + spec: `ReadHostFeedRows`. Kinds-only feed read derived from + `bound_host`. + +### Error seam + +- **`sanitized_error { reason }`** where `reason ∈ { restricted, + invalid, server_error }`. spec: `Inv_SanitizedErrors`, M6 mutation + (line 778). The alphabet is **closed**: if `IngestError` ever grows a + fourth variant, `sanitized_reason_for` (in + `crates/buzz-relay/src/conformance/mod.rs`) goes non-exhaustive and + CI catches it. + +### Coverage breach + +- **`impl_bug { kind }`** is not a spec action — it's a runtime witness + that a critical seam exited without recording any other action. The + checker treats it as a coverage breach and fails closed. Emitted by + `EmitGuard::Drop` when the seam's counting tracer saw zero emits. + +## Three projection rules that are load-bearing + +These are the places a buggy relay could emit an in-spec trace if you +normalized away the violation. The checker assumes you *did not*. + +1. **`claimed_community` is recorded separately from + `resolved_community`.** If they ever disagree, the spec says + "resolved wins"; the trace must show both so M2 (claimed-driven + auth) can bite. + +2. **`row_communities` is a `Vec`, not a `Set`, and is not filtered to + the resolved tenant.** If two rows in the result set belong to + different communities, the checker must see both labels — otherwise + it cannot fail closed on `Inv_ReadConfinement`. + +3. **`SanitizedReason` is a closed alphabet of three.** The relay's + `IngestError` variants map 1:1 onto it. A fourth variant is a CI + failure, not a silent bucket. + +## Where the emitter lives + +| File | What it emits | +|------|---------------| +| `crates/buzz-relay/src/conformance/mod.rs` | helpers + `EmitGuard` + `sanitized_reason_for` | +| `crates/buzz-relay/src/conformance/tracers.rs` | `NoopTracer` (prod default), `JsonlTracer` | +| `crates/buzz-relay/src/handlers/ingest.rs` | `AuthCheck`, `WriteInsert`, `WriteInsertGlobal`, `WriteDuplicate`, outer-wrapper `SanitizedError` | +| `crates/buzz-relay/src/handlers/req.rs` | **held back** — additive patch for integration onto Max's req.rs work | + +## Where the checker lives + +| File | What it does | +|------|--------------| +| `crates/buzz-conformance/src/lib.rs` | schema + `Tracer` trait | +| `crates/buzz-conformance/src/transitions.rs` | spec `Next` re-implementation | +| `crates/buzz-conformance/src/checker.rs` | replay engine: `IllegalTransition` / `StateMismatch` / `NonInterference` / `CoverageBreach` | + +## Failure modes — what makes the gate bite + +`check_trace` returns `Err(CheckError)` on any of: + +- **`IllegalTransition`** — the action is not permitted from the + current model state (e.g. `AuthCheck { verdict: Allow, claimed != resolved }` + — M2/M8 territory). +- **`StateMismatch`** — `state_after` disagrees with the bootstrapped + model (resolved community / bound host / actor reassigned mid-request). +- **`NonInterference`** — `row_communities` includes a label other than + `resolved_community` (`Inv_NonInterference` / `Inv_ReadConfinement`). +- **`CoverageBreach`** — an `ImplBug` step was recorded, or a + scenario-required action never appeared, or the trace was empty. + +Each failure mode has a unit test in +`crates/buzz-conformance/src/checker.rs::tests` proving the gate bites +when you'd want it to. diff --git a/crates/buzz-conformance/src/checker.rs b/crates/buzz-conformance/src/checker.rs new file mode 100644 index 000000000..ceb4028cb --- /dev/null +++ b/crates/buzz-conformance/src/checker.rs @@ -0,0 +1,337 @@ +//! Replay engine: validate a sequence of [`TraceStep`]s against the spec's +//! transition relation (re-implemented in [`crate::transitions`]). +//! +//! The checker is intentionally minimal — it walks the trace, bootstraps +//! its model on the first step, and runs [`transitions::check_step`] for +//! each subsequent step. The first failure stops the trace (fail-closed). +//! +//! The other half of the checker's job is **coverage breach**: declaring +//! up-front which critical actions a scenario MUST exercise, and failing +//! the trace if any are missing. Without this, a regression that silently +//! removed an emit site would still pass conformance — the trace would +//! just be shorter. The skill is explicit: this mode is mandatory. + +use std::collections::HashSet; + +use crate::{ + transitions::{check_step, ModelState, TransitionError}, + TraceStep, +}; + +/// A scenario the checker is validating: the recorded trace plus the set +/// of critical actions the scenario asserts must appear. +#[derive(Debug, Clone)] +pub struct Scenario { + /// Trace steps in emission order. Stamps and worker ids are NOT + /// modeled — observations are unordered in the spec, so the only + /// invariant the order enforces is "within one request, observations + /// share the same `state_after`". + pub trace: Vec, + /// Action kinds that this scenario must include at least once. If any + /// are missing the checker returns a coverage breach. + /// + /// Use [`crate::TraceAction::kind`] to get the canonical strings: + /// `"write_insert"`, `"write_insert_global"`, `"write_duplicate"`, + /// `"sanitized_error"`, `"auth_check"`, `"read_message_rows"`, + /// `"read_by_id_rows"`, `"read_host_feed_rows"`. + pub required_critical_actions: HashSet, +} + +impl Scenario { + /// Build a scenario with no required actions — used for traces where + /// the only thing being asserted is "every observation is consistent + /// with non-interference". Most ingest fixtures need explicit + /// requirements; this helper is for replays of unstructured traffic. + pub fn unstructured(trace: Vec) -> Self { + Self { + trace, + required_critical_actions: HashSet::new(), + } + } + + /// Builder helper: add a required critical action kind. Returns self + /// for chaining. + pub fn require(mut self, kind: &str) -> Self { + self.required_critical_actions.insert(kind.to_string()); + self + } +} + +/// Check one scenario. Returns `Ok(())` on conformance; returns the first +/// transition error on any failure. +/// +/// Stages: +/// 1. **Bootstrap.** Read the first step's `state_after` as the model. +/// A trace with zero steps fails as a coverage breach (the seam was +/// reached and emitted nothing). +/// 2. **Schema-version check.** Each step's `schema_version` must equal +/// [`crate::SCHEMA_VERSION`] — a divergence means the relay and the +/// checker speak different schemas. We treat that as an illegal +/// transition because no transition rule applies. +/// 3. **Per-step transition check.** [`check_step`] runs on each step. +/// 4. **Coverage check.** After all steps pass, every entry in +/// `required_critical_actions` must appear in the trace. +pub fn check_trace(scenario: &Scenario) -> Result<(), TransitionError> { + if scenario.trace.is_empty() { + return Err(TransitionError::CoverageBreach { + detail: "trace is empty — seam reached without emitting any action; \ + this is the no-trace coverage breach" + .to_string(), + }); + } + + let first = &scenario.trace[0]; + if first.schema_version != crate::SCHEMA_VERSION { + return Err(TransitionError::IllegalTransition { + step_index: 0, + detail: format!( + "trace schema_version={} but checker schema_version={}", + first.schema_version, + crate::SCHEMA_VERSION + ), + }); + } + let model = ModelState::bootstrap(&first.state_after); + + for (i, step) in scenario.trace.iter().enumerate() { + if step.schema_version != crate::SCHEMA_VERSION { + return Err(TransitionError::IllegalTransition { + step_index: i, + detail: format!( + "trace schema_version={} but checker schema_version={}", + step.schema_version, + crate::SCHEMA_VERSION + ), + }); + } + check_step(i, &model, step)?; + } + + // Coverage breach: required actions missing. + let mut seen: HashSet = HashSet::with_capacity(scenario.trace.len()); + for step in &scenario.trace { + seen.insert(step.action.kind().to_string()); + } + let missing: Vec<&String> = scenario + .required_critical_actions + .iter() + .filter(|k| !seen.contains(*k)) + .collect(); + if !missing.is_empty() { + let mut sorted: Vec<&&String> = missing.iter().collect(); + sorted.sort(); + return Err(TransitionError::CoverageBreach { + detail: format!( + "scenario required actions never emitted: {:?}", + sorted.iter().map(|s| s.as_str()).collect::>() + ), + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::CommunityLabel; + use crate::{ + AbstractState, ActorLabel, ChannelLabel, HostLabel, OpaqueId, SanitizedReason, TraceAction, + Verdict, + }; + use uuid::Uuid; + + fn cid(u: u128) -> CommunityLabel { + CommunityLabel::from_uuid(Uuid::from_u128(u)) + } + + fn ch(u: u128) -> ChannelLabel { + ChannelLabel(Uuid::from_u128(u)) + } + + fn state(c: CommunityLabel) -> AbstractState { + AbstractState { + resolved_community: c, + bound_host: HostLabel("h_local".into()), + actor: ActorLabel("a_alice".into()), + } + } + + fn step(action: TraceAction, c: CommunityLabel) -> TraceStep { + TraceStep::new(action, state(c)) + } + + #[test] + fn empty_trace_is_coverage_breach() { + let sc = Scenario::unstructured(vec![]); + let err = check_trace(&sc).unwrap_err(); + assert!(matches!(err, TransitionError::CoverageBreach { .. })); + } + + #[test] + fn write_insert_then_read_with_only_resolved_rows_passes() { + let c = cid(1); + let trace = vec![ + step( + TraceAction::AuthCheck { + channel: ch(10), + claimed_community: Some(c), + verdict: Verdict::Allow, + }, + c, + ), + step( + TraceAction::WriteInsert { + msg_id: OpaqueId("m1".into()), + channel: ch(10), + claimed_community: Some(c), + }, + c, + ), + step( + TraceAction::ReadMessageRows { + channel: Some(ch(10)), + row_communities: vec![c, c], + }, + c, + ), + ]; + let sc = Scenario { + trace, + required_critical_actions: ["auth_check", "write_insert", "read_message_rows"] + .iter() + .map(|s| s.to_string()) + .collect(), + }; + check_trace(&sc).expect("trace should conform"); + } + + #[test] + fn cross_community_row_bites_non_interference() { + let c = cid(1); + let foreign = cid(2); + let trace = vec![step( + TraceAction::ReadMessageRows { + channel: Some(ch(10)), + row_communities: vec![c, foreign], + }, + c, + )]; + let err = check_trace(&Scenario::unstructured(trace)).unwrap_err(); + assert!( + matches!(err, TransitionError::NonInterference { .. }), + "expected NonInterference, got {err:?}" + ); + } + + #[test] + fn auth_allow_with_foreign_claim_bites_m2() { + let c = cid(1); + let foreign = cid(2); + let trace = vec![step( + TraceAction::AuthCheck { + channel: ch(10), + claimed_community: Some(foreign), + verdict: Verdict::Allow, + }, + c, + )]; + let err = check_trace(&Scenario::unstructured(trace)).unwrap_err(); + assert!( + matches!(err, TransitionError::IllegalTransition { .. }), + "expected IllegalTransition for M2 bite, got {err:?}" + ); + } + + #[test] + fn auth_deny_with_foreign_claim_is_fine() { + let c = cid(1); + let foreign = cid(2); + let trace = vec![step( + TraceAction::AuthCheck { + channel: ch(10), + claimed_community: Some(foreign), + verdict: Verdict::Deny, + }, + c, + )]; + check_trace(&Scenario::unstructured(trace)).expect("deny with foreign claim is in-spec"); + } + + #[test] + fn state_after_changing_mid_request_is_state_mismatch() { + let c1 = cid(1); + let c2 = cid(2); + let trace = vec![ + step( + TraceAction::AuthCheck { + channel: ch(10), + claimed_community: Some(c1), + verdict: Verdict::Allow, + }, + c1, + ), + step( + TraceAction::ReadMessageRows { + channel: Some(ch(10)), + row_communities: vec![c2], + }, + c2, + ), + ]; + let err = check_trace(&Scenario::unstructured(trace)).unwrap_err(); + assert!( + matches!(err, TransitionError::StateMismatch { .. }), + "expected StateMismatch, got {err:?}" + ); + } + + #[test] + fn impl_bug_action_bites_coverage_breach() { + let c = cid(1); + let trace = vec![step( + TraceAction::ImplBug { + kind: "ingest_exited_without_trace".into(), + }, + c, + )]; + let err = check_trace(&Scenario::unstructured(trace)).unwrap_err(); + assert!( + matches!(err, TransitionError::CoverageBreach { .. }), + "expected CoverageBreach from ImplBug, got {err:?}" + ); + } + + #[test] + fn required_critical_action_missing_bites_coverage_breach() { + let c = cid(1); + let trace = vec![step( + TraceAction::SanitizedError { + reason: SanitizedReason::Restricted, + }, + c, + )]; + let sc = Scenario { + trace, + required_critical_actions: ["auth_check".to_string()].into_iter().collect(), + }; + let err = check_trace(&sc).unwrap_err(); + assert!( + matches!(err, TransitionError::CoverageBreach { ref detail } if detail.contains("auth_check")), + "expected CoverageBreach naming auth_check, got {err:?}" + ); + } + + #[test] + fn sanitized_error_alone_is_well_formed() { + let c = cid(1); + for reason in [ + SanitizedReason::Restricted, + SanitizedReason::Invalid, + SanitizedReason::ServerError, + ] { + let trace = vec![step(TraceAction::SanitizedError { reason }, c)]; + check_trace(&Scenario::unstructured(trace)).expect("sanitized_error alone is in-spec"); + } + } +} diff --git a/crates/buzz-conformance/src/lib.rs b/crates/buzz-conformance/src/lib.rs new file mode 100644 index 000000000..3e1cfe13e --- /dev/null +++ b/crates/buzz-conformance/src/lib.rs @@ -0,0 +1,327 @@ +//! Runtime trace schema + independent replay checker for +//! `docs/spec/MultiTenantRelay.tla`. +//! +//! North star (from the runtime-formal-compliance skill): don't ask "did the +//! model pass"; ask "did the running code emit a trace the model accepts." +//! +//! ## What this crate is +//! +//! - The **schema** ([`TraceStep`], [`TraceAction`], [`AbstractState`]) that +//! the relay emits at its ingest/read accept-reject boundary. +//! - An **independent** replay checker ([`check_trace`]) that consumes a +//! sequence of `TraceStep`s and validates them against the TLA+ spec's +//! `Next` transition relation. The checker re-implements the relevant +//! spec actions in Rust; it does NOT call any production reducer. +//! +//! ## What this crate is NOT +//! +//! - A proof. Trace conformance only checks executions you ran. Coverage is +//! widened by integration tests, property tests, and adversarial fixtures. +//! - A re-export of production helpers. Sharing normalization helpers between +//! the emitter (which projects implementation state) and the checker (which +//! judges that projection) would let a bug in the helpers hide itself from +//! both — exactly the failure the skill calls out. +//! +//! ## Failure modes (skill §Phase 4) +//! +//! - **Illegal transition** — the traced action is not allowed from the +//! checker's current model state. +//! - **State mismatch** — `state_after.row_labels` includes a community other +//! than the resolved tenant (`Inv_NonInterference`). +//! - **Coverage breach** — an unknown critical action, a critical seam exit +//! without a trace step ([`TraceAction::ImplBug`]), or a scenario-required +//! action that never appeared. +//! +//! Coverage breach is load-bearing. Without it, trace conformance is +//! decorative logging. + +#![deny(unsafe_code)] +#![warn(missing_docs)] + +pub mod checker; +pub mod transitions; + +use serde::{Deserialize, Serialize}; + +/// Opaque community label — the underlying UUID a server-resolved +/// `TenantContext::community()` wraps, carried as a value type in the +/// trace schema. +/// +/// This deliberately does NOT reuse `buzz_core::CommunityId`. Two reasons: +/// +/// 1. **Production fence preservation.** `buzz_core::CommunityId` has no +/// `From`, no `Serialize`, no `Deserialize` — by design, so a +/// `CommunityId` cannot be conjured from client input. Adding Serde to +/// it for our convenience would punch a hole in that fence. Carrying +/// our own newtype keeps that fence intact. +/// 2. **Independence.** The checker re-implements the spec transition +/// relation; the schema sharing zero type machinery with production +/// means a buggy production type cannot launder its bug into the +/// checker mechanically. +/// +/// The relay's emitter module converts at the seam: +/// `CommunityLabel::from_uuid(*tenant.community().as_uuid())`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct CommunityLabel(pub uuid::Uuid); + +impl CommunityLabel { + /// Wrap a UUID into a community label. Unlike `buzz_core::CommunityId` + /// this conversion IS public — but consumers of `CommunityLabel` are + /// the checker and test fixtures, not the relay's request path. The + /// relay only constructs `CommunityLabel` from a `TenantContext` it + /// already resolved. + pub const fn from_uuid(id: uuid::Uuid) -> Self { + Self(id) + } +} + +impl std::fmt::Display for CommunityLabel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + std::fmt::Display::fmt(&self.0, f) + } +} + +/// Trace schema version. Bump on any backwards-incompatible field change. +pub const SCHEMA_VERSION: u32 = 1; + +/// An opaque ID derived from an event id or other secret material. Stable, +/// no payload, no key bytes. Implementations pick a hash; the checker +/// compares strings. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct OpaqueId(pub String); + +/// An opaque host label — produced by the relay from the bound `Host` header +/// via a configured registry, never the raw `Host` string. Mirrors the spec's +/// `Hosts` set abstractly. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct HostLabel(pub String); + +/// An opaque channel label — the channel UUID directly. Channels are not +/// secret; the production code already exposes them in event tags. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ChannelLabel(pub uuid::Uuid); + +/// An opaque actor label — the lower 16 bytes of `blake3(pubkey)`. Stable, +/// non-reversible, secret-free. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(transparent)] +pub struct ActorLabel(pub String); + +/// Auth verdict — the closed alphabet from `AuthCheck` (spec line 794). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Verdict { + /// Authorized. + Allow, + /// Denied. The spec models a single Deny verdict; reason is not exposed + /// at the trace boundary because the spec's error alphabet is closed. + Deny, +} + +/// The sanitized error alphabet (spec `Inv_SanitizedErrors`, M6 mutation). +/// +/// Errors observed by the client must come from this closed set; raw error +/// strings are NOT projected into the trace because the spec requires error +/// observations carry no tenant-derived information. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SanitizedReason { + /// Host/channel/community fence rejected the request (relay-only kind, + /// archived channel, scope-token mismatch, etc.) — spec "restricted". + Restricted, + /// Malformed event — spec "invalid". + Invalid, + /// Server fault — spec "server_error". + ServerError, +} + +/// The abstract state mirrored from `TenantContext`: which community the +/// server resolved, which host bound that resolution. This is what +/// `Inv_NonInterference` checks observations against. +/// +/// Carries deliberately the things that reveal violations (claimed vs. +/// resolved community, opaque host) and deliberately not raw payloads, +/// pubkey bytes, signatures, or wall-clock timestamps. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct AbstractState { + /// The server-resolved community for this request — the label + /// `Inv_NonInterference` validates against. Sourced **only** from + /// `TenantContext::community()`. Never from event tags, never from + /// client input, never from `event.pubkey`. + pub resolved_community: CommunityLabel, + /// The host that bound this request to that community. Sourced from + /// `TenantContext::host()` via a label registry. + pub bound_host: HostLabel, + /// The actor (authenticated pubkey) for this request, opaque-labelled. + pub actor: ActorLabel, +} + +/// One trace step emitted at the ingest/read accept-reject boundary. +/// +/// Action vocabulary (spec actions in parentheses): +/// - [`TraceAction::WriteInsert`] (spec `WriteInsert`, lines 514–550) +/// - [`TraceAction::WriteInsertGlobal`] (spec `WriteInsertGlobal`, lines 559–595) +/// - [`TraceAction::WriteDuplicate`] (spec `WriteDuplicate`, lines 606–637) +/// - [`TraceAction::SanitizedError`] (spec `SanitizedError`, line 778) +/// - [`TraceAction::AuthCheck`] (spec `AuthCheck`, line 794) — M2/M8 target +/// - [`TraceAction::ReadMessageRows`] (spec `ReadMessageRows`, line 643) +/// - [`TraceAction::ReadByIdRows`] (spec `ReadByIdRows`, line 681) +/// - [`TraceAction::ReadHostFeedRows`] (spec `ReadHostFeedRows`, line ~720) +/// - [`TraceAction::ImplBug`] — emitted by the coverage-breach guard when +/// the seam exits without a known action; the checker treats this as a +/// coverage breach. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum TraceAction { + /// Channel-bearing write (spec `WriteInsert`). + WriteInsert { + /// Opaque hash of the event id. + msg_id: OpaqueId, + /// The channel the event targets — the "real" community is + /// `ChannelCommunity(channel)` per spec. + channel: ChannelLabel, + /// The community the client *claimed* via its `h` tag, if any. + /// `None` means the client did not assert one. This stays distinct + /// from `state_after.resolved_community` so M2/M8 mutations are + /// visible in the trace. + claimed_community: Option, + }, + /// Channel-less write resolved purely from the bound host + /// (spec `WriteInsertGlobal`). + WriteInsertGlobal { + /// Opaque hash of the event id. + msg_id: OpaqueId, + /// The community the client *claimed*, if any. Ignored by the + /// resolver but recorded for the audit trail. + claimed_community: Option, + }, + /// Channel-bearing duplicate / no-op write (spec `WriteDuplicate`, + /// `ON CONFLICT (community_id, id)` returning a duplicate result). + WriteDuplicate { + /// Opaque hash of the event id. + msg_id: OpaqueId, + /// The channel the duplicate hit. + channel: ChannelLabel, + /// The community the client *claimed*, if any. + claimed_community: Option, + }, + /// Sanitized error (spec `SanitizedError`). Closed-alphabet reason + /// only; no raw error string is projected. + SanitizedError { + /// One of the closed-alphabet reasons. + reason: SanitizedReason, + }, + /// Per-(channel, actor) authorization decision (spec `AuthCheck`). + /// M2 and M8 explicitly target this action — leaving it out would + /// make the gate blind to those mutations. + AuthCheck { + /// The channel the check is against. + channel: ChannelLabel, + /// The community the client claimed, if any. + claimed_community: Option, + /// The Allow/Deny verdict the implementation produced. + verdict: Verdict, + }, + /// Per-channel-or-channelless row read returning concrete rows + /// (spec `ReadMessageRows`). + ReadMessageRows { + /// Channel filter — `None` means channel-less. + channel: Option, + /// The community label of EACH row returned. NOT deduped to a Set, + /// NOT filtered to "matches resolved": the checker must see every + /// leaked label to fail closed on `Inv_ReadConfinement` / M1/M4/M7. + row_communities: Vec, + }, + /// Direct read by event id list (spec `ReadByIdRows`). The search lane + /// emits this for each refetched hit. + ReadByIdRows { + /// Channel filter — `None` means channel-less. + channel: Option, + /// Per-row community labels, same rules as `ReadMessageRows`. + row_communities: Vec, + }, + /// Kinds-only feed read (spec `ReadHostFeedRows`). The relay derives + /// the community from the bound host and fans out across that + /// community's channel-less rows plus its accessible channels. + ReadHostFeedRows { + /// Per-row community labels. + row_communities: Vec, + }, + /// Coverage-breach guard: the seam exited without a known action. The + /// checker treats this as a coverage breach and fails closed. + ImplBug { + /// A short tag identifying the missing emit site (e.g. + /// `"ingest_exited_without_trace"`). + kind: String, + }, +} + +impl TraceAction { + /// A short stable string identifying the action kind, for fixture + /// declarations and error messages. + pub fn kind(&self) -> &'static str { + match self { + TraceAction::WriteInsert { .. } => "write_insert", + TraceAction::WriteInsertGlobal { .. } => "write_insert_global", + TraceAction::WriteDuplicate { .. } => "write_duplicate", + TraceAction::SanitizedError { .. } => "sanitized_error", + TraceAction::AuthCheck { .. } => "auth_check", + TraceAction::ReadMessageRows { .. } => "read_message_rows", + TraceAction::ReadByIdRows { .. } => "read_by_id_rows", + TraceAction::ReadHostFeedRows { .. } => "read_host_feed_rows", + TraceAction::ImplBug { .. } => "impl_bug", + } + } + + /// Every action at this seam is critical: the spec requires every + /// observation to be labelled. The skill's "coverage breach" mode + /// hinges on every emit site being marked critical. + pub const fn is_critical(&self) -> bool { + true + } +} + +/// One step in the trace stream. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TraceStep { + /// Schema version — bump on backwards-incompatible field changes. + pub schema_version: u32, + /// The action that occurred at the seam. + pub action: TraceAction, + /// The abstract state the implementation observed at action time. + /// The checker compares this against its independently-computed model + /// state. + pub state_after: AbstractState, +} + +impl TraceStep { + /// Build a step at the current schema version. + pub fn new(action: TraceAction, state_after: AbstractState) -> Self { + Self { + schema_version: SCHEMA_VERSION, + action, + state_after, + } + } +} + +/// The emit trait the relay calls. The trait is the *only* surface the +/// production code touches; the schema types stay value types. +pub trait Tracer: Send + Sync { + /// Record one trace step. Implementations MAY be no-ops in production + /// builds and write to JSONL in tests. + fn record(&self, step: TraceStep); +} + +/// A no-op tracer for production. Zero cost: the build can omit emission +/// entirely behind a feature, or simply discard records here. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopTracer; + +impl Tracer for NoopTracer { + fn record(&self, _step: TraceStep) {} +} diff --git a/crates/buzz-conformance/src/transitions.rs b/crates/buzz-conformance/src/transitions.rs new file mode 100644 index 000000000..cabd66e69 --- /dev/null +++ b/crates/buzz-conformance/src/transitions.rs @@ -0,0 +1,330 @@ +//! Independent translation of `docs/spec/MultiTenantRelay.tla`'s `Next` +//! transition relation into Rust. +//! +//! This module is the heart of the conformance gate. It is deliberately +//! **independent** of the production reducer: it reads only the trace +//! schema in [`crate`] and the spec text in `docs/spec/MultiTenantRelay.tla`. +//! It does not import `buzz-relay`, `buzz-db`, `buzz-auth`, or any other +//! production crate that could share a normalization bug with the emitter. +//! +//! ## What an "abstract state" means here +//! +//! The TLA+ spec models the relay as a multi-worker system whose state is +//! the set of accepted rows, projection rows, observations, etc. A runtime +//! trace covers ONE worker handling ONE request — so the model state we +//! carry is much smaller: +//! +//! - `resolved_community` — the server-resolved `TenantContext::community()` +//! for this request. `Inv_NonInterference` requires every row label +//! observed in this request be a subset of `{resolved_community}`. +//! - `bound_host` — the host label `TenantContext::host()` was bound from. +//! `AuthCheck` / channel-less reads require `HostCommunity[host]` agree +//! with the resolved community. +//! +//! The checker rebuilds this state independently from the FIRST trace step +//! it sees and then validates every subsequent step against it. +//! +//! ## Per-action obligations +//! +//! Each action has a triple of obligations distilled from the spec: +//! +//! 1. **State match.** `step.state_after.resolved_community` and +//! `bound_host` agree with the checker's running model (no mid-request +//! tenant flip). +//! 2. **Row-label confinement** (`Inv_NonInterference` line ~983, +//! `Inv_ReadConfinement` line ~1003). Every `row_communities` entry, +//! every accept label, must equal `resolved_community`. A single foreign +//! label fails the trace. +//! 3. **Action-specific guards.** AuthCheck `Allow` requires host/channel +//! agreement; channel-less reads require `HostCommunity[host] = c`; +//! `WriteInsert` claim-vs-resolved is recorded but a mismatch is +//! allowed at the abstract level — the spec ignores it ("host wins"), +//! so the gate that bites mismatches is the row-label confinement on +//! the *next* read. + +use crate::{ + AbstractState, ChannelLabel, CommunityLabel, SanitizedReason, TraceAction, TraceStep, Verdict, +}; + +/// A judgment about a single trace step. The checker walks the trace and +/// returns the first failure verdict (fail-fast); per the skill's "fail +/// closed on the first violation" guidance. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Verdict_ { + /// Reserved — internal placeholder. + Ok, +} + +/// Failure reasons returned by [`check_step`]. The string payload is +/// human-readable; mechanical consumers should match on the variant. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum TransitionError { + /// The traced action is not allowed from the checker's current model + /// state — e.g. an `AuthCheck { verdict: Allow }` with `claimed != real`. + #[error("illegal transition at step {step_index}: {detail}")] + IllegalTransition { + /// 0-based index of the offending step. + step_index: usize, + /// Human-readable detail. + detail: String, + }, + + /// The trace's `state_after` does not match the model state the checker + /// computed independently. Indicates the relay either reassigned the + /// tenant context mid-request, or emitted a step from a context other + /// than `TenantContext`. + #[error("state mismatch at step {step_index}: {detail}")] + StateMismatch { + /// 0-based index of the offending step. + step_index: usize, + /// Human-readable detail. + detail: String, + }, + + /// Row labels include a community other than the resolved tenant — + /// the master `Inv_NonInterference` failure. + #[error("non-interference breach at step {step_index}: {detail}")] + NonInterference { + /// 0-based index of the offending step. + step_index: usize, + /// Human-readable detail. + detail: String, + }, + + /// A coverage breach: ImplBug action, or a fixture-declared + /// `required_critical_actions` entry never appeared. + #[error("coverage breach: {detail}")] + CoverageBreach { + /// Human-readable detail naming the missing or broken coverage rule. + detail: String, + }, +} + +/// The model state the checker carries between steps. +#[derive(Debug, Clone)] +pub struct ModelState { + /// The community the FIRST step's `state_after` told us was resolved. + /// Subsequent steps must agree. + pub resolved_community: CommunityLabel, + /// The host the FIRST step's `state_after` told us was bound. Channel- + /// bearing AuthCheck and channel-less reads enforce + /// `host_community(host) == resolved_community`. The checker does NOT + /// know `HostCommunity[_]` at large; it only knows the spec guarantees + /// `HostCommunity[bound_host] = resolved_community` whenever the relay + /// took the success branch. + pub bound_host: crate::HostLabel, + /// The actor for this request — opaque, equality-checked only. + pub actor: crate::ActorLabel, +} + +impl ModelState { + /// Bootstrap the model from the very first step. Subsequent calls to + /// [`check_step`] return a `StateMismatch` if `state_after` disagrees. + pub fn bootstrap(first: &AbstractState) -> Self { + Self { + resolved_community: first.resolved_community, + bound_host: first.bound_host.clone(), + actor: first.actor.clone(), + } + } +} + +/// Validate one step against the model. Updates nothing (the model is +/// immutable for the lifetime of a single trace); a violation returns the +/// matching [`TransitionError`]. +/// +/// Spec line numbers below refer to `docs/spec/MultiTenantRelay.tla` at the +/// snapshot pinned in this PR's `docs/spec/`. +pub fn check_step( + step_index: usize, + model: &ModelState, + step: &TraceStep, +) -> Result<(), TransitionError> { + // Universal obligation 1: state_after agrees with the bootstrapped model. + if step.state_after.resolved_community != model.resolved_community { + return Err(TransitionError::StateMismatch { + step_index, + detail: format!( + "resolved_community changed mid-request: bootstrap={:?}, step={:?}", + model.resolved_community, step.state_after.resolved_community + ), + }); + } + if step.state_after.bound_host != model.bound_host { + return Err(TransitionError::StateMismatch { + step_index, + detail: format!( + "bound_host changed mid-request: bootstrap={:?}, step={:?}", + model.bound_host, step.state_after.bound_host + ), + }); + } + if step.state_after.actor != model.actor { + return Err(TransitionError::StateMismatch { + step_index, + detail: format!( + "actor changed mid-request: bootstrap={:?}, step={:?}", + model.actor, step.state_after.actor + ), + }); + } + + // Action-specific obligations. + match &step.action { + // --- Spec WriteInsert (lines 514-550) --- + // Resolution: real == ChannelCommunity(ch). + // Success branch requires HostCommunity[host] = real, which the + // emitter guarantees by emitting from inside the success path with + // state_after.resolved_community = real and state_after.bound_host + // = the bound host. The trace records claimed_community separately + // so M2/M8 (host/channel disagreement, claim≠resolved) surface here + // as a state mismatch on the *resolved* side. + // + // What we check at this step: nothing beyond the universal state + // match. The spec ignores claimed_community ("host wins"), so a + // mismatch is allowed at this exact action — the gate that bites + // it is the next read's row labels. + TraceAction::WriteInsert { .. } => Ok(()), + + // --- Spec WriteInsertGlobal (lines 559-595) --- + // resolved == HostCommunity[host]. Same shape as WriteInsert. + TraceAction::WriteInsertGlobal { .. } => Ok(()), + + // --- Spec WriteDuplicate (lines 606-637) --- + // Carries the same host-axis obligation as WriteInsert: an A-host + // presenting a B-channel id must not learn whether the id exists. + // Same observable: state_after.resolved_community must be the + // real ChannelCommunity(ch), enforced by the universal check. + TraceAction::WriteDuplicate { .. } => Ok(()), + + // --- Spec SanitizedError (line 778) --- + // Closed-alphabet reason; labels = {}; carries no row data. The + // emitter must collapse every reject path into one of the three + // SanitizedReason variants. The schema-level type system already + // enforces that — we just check the variant is among the spec's + // closed set (trivially true by construction). + TraceAction::SanitizedError { reason } => match reason { + SanitizedReason::Restricted + | SanitizedReason::Invalid + | SanitizedReason::ServerError => Ok(()), + }, + + // --- Spec AuthCheck (lines 794-810) --- + // real == ChannelCommunity(ch). + // hostAgrees == real ∈ Communities ∧ HostCommunity[host] = real. + // allowed == hostAgrees ∧ ch ∈ ScopedAccessible(real, a). + // verdict == IF allowed THEN Allow ELSE Deny. + // + // The runtime checker cannot recompute ScopedAccessible (that's + // production state). What it CAN check: when verdict = Allow, the + // claimed_community MUST equal resolved_community. This is the + // M2 bite ("auth verdict driven by claimed instead of resolved") + // and the M8 bite ("A-host driving a B-channel verdict") — both + // collapse to "Allow with a foreign label leak". + // + // We deliberately do NOT bite Deny on claim mismatch (Deny with + // any claim is in-spec — the spec models Deny as the catch-all + // for hostAgrees=false or accessibility=false). + TraceAction::AuthCheck { + channel: _, + claimed_community, + verdict, + } => match (verdict, claimed_community) { + (Verdict::Allow, Some(c)) if c != &model.resolved_community => { + Err(TransitionError::IllegalTransition { + step_index, + detail: format!( + "AuthCheck verdict=Allow with claimed_community={:?} != resolved={:?} \ + — M2/M8 (claim or host driving verdict) bite", + c, model.resolved_community + ), + }) + } + _ => Ok(()), + }, + + // --- Spec ReadMessageRows (line 643) / ReadByIdRows (line 681) --- + // The action emits rows; `RowLabels(rows)` is the observation's + // labels and Inv_NonInterference requires labels ⊆ {community}. + // For channel-less (ch = NoChannel) the spec ADDS: + // HostCommunity[host] = c ∧ IsAdmitted(c, a). + // The host-agreement piece is enforced at the universal check + // (state_after.resolved_community is host-derived); IsAdmitted is + // production state we cannot recompute, so it lives in fixture + // assertions rather than this generic checker. + // + // What this checker bites: every row label must equal the + // resolved community. ONE foreign label fails NI. + TraceAction::ReadMessageRows { + channel: _, + row_communities, + } + | TraceAction::ReadByIdRows { + channel: _, + row_communities, + } => check_row_labels(step_index, model, row_communities), + + // --- Spec ReadHostFeedRows (line ~720) --- + // Community is host-derived; same row-label confinement. + TraceAction::ReadHostFeedRows { row_communities } => { + check_row_labels(step_index, model, row_communities) + } + + // --- Coverage breach via the Drop guard --- + // The seam exited without emitting any recognized action. + // Per the skill: this is the load-bearing coverage mode — without + // it, trace conformance is decorative logging. + TraceAction::ImplBug { kind } => Err(TransitionError::CoverageBreach { + detail: format!("ImplBug action emitted by Drop guard: kind={kind:?}"), + }), + } +} + +/// Row-label confinement check shared by all three read actions. +/// +/// `Inv_NonInterference` (spec line ~983): +/// `\A o \in observations : o.labels \subseteq {o.community}`. +/// +/// Translated: every `row_communities` entry must equal `model +/// .resolved_community`. The check is on a `Vec`, not a `Set`, deliberately +/// — if a buggy relay returned the same foreign row twice the checker still +/// bites, and if a buggy emitter de-duped foreign labels to one occurrence +/// the checker still bites. Foreign-label count is unimportant; foreign- +/// label presence is the entire bar. +fn check_row_labels( + step_index: usize, + model: &ModelState, + row_communities: &[CommunityLabel], +) -> Result<(), TransitionError> { + if let Some(foreign) = row_communities + .iter() + .find(|c| **c != model.resolved_community) + { + return Err(TransitionError::NonInterference { + step_index, + detail: format!( + "row labeled {:?} returned in observation scoped to {:?} \ + — Inv_NonInterference breach (foreign row leaked through tenant fence)", + foreign, model.resolved_community + ), + }); + } + Ok(()) +} + +/// Helper: which channel (if any) does the action target? Used by the +/// checker to bind cross-step claims to a stable channel — and by fixtures +/// asserting that a particular channel surfaced at this seam. +pub fn action_channel(action: &TraceAction) -> Option<&ChannelLabel> { + match action { + TraceAction::WriteInsert { channel, .. } => Some(channel), + TraceAction::WriteDuplicate { channel, .. } => Some(channel), + TraceAction::AuthCheck { channel, .. } => Some(channel), + TraceAction::ReadMessageRows { channel, .. } => channel.as_ref(), + TraceAction::ReadByIdRows { channel, .. } => channel.as_ref(), + TraceAction::WriteInsertGlobal { .. } + | TraceAction::ReadHostFeedRows { .. } + | TraceAction::SanitizedError { .. } + | TraceAction::ImplBug { .. } => None, + } +} diff --git a/crates/buzz-conformance/tests/fixtures/bad_coverage_breach.jsonl b/crates/buzz-conformance/tests/fixtures/bad_coverage_breach.jsonl new file mode 100644 index 000000000..8e165eaaa --- /dev/null +++ b/crates/buzz-conformance/tests/fixtures/bad_coverage_breach.jsonl @@ -0,0 +1 @@ +{"schema_version":1,"action":{"type":"impl_bug","kind":"ingest_exited_without_trace"},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} diff --git a/crates/buzz-conformance/tests/fixtures/bad_foreign_row_leak.jsonl b/crates/buzz-conformance/tests/fixtures/bad_foreign_row_leak.jsonl new file mode 100644 index 000000000..8313562d4 --- /dev/null +++ b/crates/buzz-conformance/tests/fixtures/bad_foreign_row_leak.jsonl @@ -0,0 +1 @@ +{"schema_version":1,"action":{"type":"read_message_rows","channel":"cafe0000-0000-0000-0000-000000000010","row_communities":["bbbb0000-0000-0000-0000-000000000002"]},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} diff --git a/crates/buzz-conformance/tests/fixtures/bad_host_channel_mismatch.jsonl b/crates/buzz-conformance/tests/fixtures/bad_host_channel_mismatch.jsonl new file mode 100644 index 000000000..cf4cb26a7 --- /dev/null +++ b/crates/buzz-conformance/tests/fixtures/bad_host_channel_mismatch.jsonl @@ -0,0 +1,2 @@ +{"schema_version":1,"action":{"type":"auth_check","channel":"dead0000-0000-0000-0000-000000000020","claimed_community":"bbbb0000-0000-0000-0000-000000000002","verdict":"allow"},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} +{"schema_version":1,"action":{"type":"write_insert","msg_id":"badbadbad0000000","channel":"dead0000-0000-0000-0000-000000000020","claimed_community":"bbbb0000-0000-0000-0000-000000000002"},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} diff --git a/crates/buzz-conformance/tests/fixtures/good.jsonl b/crates/buzz-conformance/tests/fixtures/good.jsonl new file mode 100644 index 000000000..e18be5656 --- /dev/null +++ b/crates/buzz-conformance/tests/fixtures/good.jsonl @@ -0,0 +1,3 @@ +{"schema_version":1,"action":{"type":"auth_check","channel":"cafe0000-0000-0000-0000-000000000010","claimed_community":"aaaa0000-0000-0000-0000-000000000001","verdict":"allow"},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} +{"schema_version":1,"action":{"type":"write_insert","msg_id":"d34db33fcafef00d","channel":"cafe0000-0000-0000-0000-000000000010","claimed_community":"aaaa0000-0000-0000-0000-000000000001"},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} +{"schema_version":1,"action":{"type":"read_message_rows","channel":"cafe0000-0000-0000-0000-000000000010","row_communities":["aaaa0000-0000-0000-0000-000000000001","aaaa0000-0000-0000-0000-000000000001"]},"state_after":{"resolved_community":"aaaa0000-0000-0000-0000-000000000001","bound_host":"a.example.test","actor":"0123456789abcdef"}} diff --git a/crates/buzz-conformance/tests/replay_fixtures.rs b/crates/buzz-conformance/tests/replay_fixtures.rs new file mode 100644 index 000000000..b823a86a0 --- /dev/null +++ b/crates/buzz-conformance/tests/replay_fixtures.rs @@ -0,0 +1,324 @@ +//! Replay-fixture integration test. +//! +//! These fixtures are the load-bearing evidence that the runtime +//! conformance gate is **not decorative**. Each fixture is one +//! end-to-end JSONL trace, replayed through [`check_trace`], with the +//! expected verdict baked into the assertion. +//! +//! Eva's review (thread `06aaf3f7…`) green-lit cutting these as the +//! visible proof the gate bites. Coverage: +//! +//! - `good.jsonl` — a positive trace shaped like a real ingest: +//! AuthCheck Allow → WriteInsert → ReadMessageRows with rows confined +//! to the resolved community. `check_trace` returns `Ok(())`. +//! - `bad_host_channel_mismatch.jsonl` — a host/channel fence skip: +//! the bound host is for community A, the write targets a channel in +//! community B. The checker fails with `IllegalTransition`. +//! - `bad_coverage_breach.jsonl` — a trace that contains an `ImplBug` +//! action (what `EmitGuard::Drop` emits when a critical seam exits +//! without recording anything). The checker fails with +//! `CoverageBreach`. +//! +//! The JSONL files are committed as "golden" artifacts under +//! `tests/fixtures/` for reviewer visibility, but this test also +//! round-trips: it constructs the trace in Rust, serializes it to a +//! temp file, reads it back, and asserts both the serialized form +//! matches the committed file AND the parsed form gives the expected +//! verdict. That way a schema change cannot silently desync the +//! committed JSONL from what the relay actually emits. + +use std::collections::HashSet; +use std::fs; +use std::path::{Path, PathBuf}; + +use buzz_conformance::checker::{check_trace, Scenario}; +use buzz_conformance::transitions::TransitionError; +use buzz_conformance::{ + AbstractState, ActorLabel, ChannelLabel, CommunityLabel, HostLabel, OpaqueId, TraceAction, + TraceStep, Verdict, +}; +use uuid::Uuid; + +// ---- Stable test-fixture labels ---------------------------------------- +// +// These values are deterministic so the serialized JSONL is reproducible +// across runs. They are NOT secrets and they don't shadow any real +// community — they're test-only constants. + +fn community_a() -> CommunityLabel { + CommunityLabel::from_uuid(Uuid::from_u128(0xAAAA_0000_0000_0000_0000_0000_0000_0001)) +} + +fn community_b() -> CommunityLabel { + CommunityLabel::from_uuid(Uuid::from_u128(0xBBBB_0000_0000_0000_0000_0000_0000_0002)) +} + +fn channel_in_a() -> ChannelLabel { + ChannelLabel(Uuid::from_u128(0xCAFE_0000_0000_0000_0000_0000_0000_0010)) +} + +fn channel_in_b() -> ChannelLabel { + ChannelLabel(Uuid::from_u128(0xDEAD_0000_0000_0000_0000_0000_0000_0020)) +} + +fn state_a() -> AbstractState { + AbstractState { + resolved_community: community_a(), + bound_host: HostLabel("a.example.test".to_string()), + actor: ActorLabel("0123456789abcdef".to_string()), + } +} + +// ---- Trace builders ---------------------------------------------------- + +/// A positive trace: bound to community A, all observations confined. +fn good_trace() -> Vec { + vec![ + TraceStep::new( + TraceAction::AuthCheck { + channel: channel_in_a(), + claimed_community: Some(community_a()), + verdict: Verdict::Allow, + }, + state_a(), + ), + TraceStep::new( + TraceAction::WriteInsert { + msg_id: OpaqueId("d34db33fcafef00d".to_string()), + channel: channel_in_a(), + claimed_community: Some(community_a()), + }, + state_a(), + ), + TraceStep::new( + TraceAction::ReadMessageRows { + channel: Some(channel_in_a()), + row_communities: vec![community_a(), community_a()], + }, + state_a(), + ), + ] +} + +/// A bad trace: the host-channel fence was bypassed. The bound host +/// resolves to community A, but a WriteInsert targets a channel in +/// community B. The spec's `Inv_NonInterference` / channel-host coupling +/// rule rejects this as an illegal transition. +fn bad_host_channel_mismatch_trace() -> Vec { + vec![ + TraceStep::new( + TraceAction::AuthCheck { + channel: channel_in_b(), + // Client claims B, host resolves A, fence was skipped: + // AuthCheck recorded `verdict = Allow` despite the + // mismatch. M2/M8 territory. + claimed_community: Some(community_b()), + verdict: Verdict::Allow, + }, + state_a(), + ), + TraceStep::new( + TraceAction::WriteInsert { + msg_id: OpaqueId("badbadbad0000000".to_string()), + channel: channel_in_b(), + claimed_community: Some(community_b()), + }, + state_a(), + ), + ] +} + +/// A coverage-breach trace: an `ImplBug` step appears, meaning the +/// `EmitGuard` fired on Drop. The checker treats any `ImplBug` as a +/// hard coverage breach. +fn bad_coverage_breach_trace() -> Vec { + vec![TraceStep::new( + TraceAction::ImplBug { + kind: "ingest_exited_without_trace".to_string(), + }, + state_a(), + )] +} + +/// A foreign-row trace: bound to community A but a `ReadMessageRows` +/// returns a row whose community label is community B. This is the +/// (B)-projection negative case Eva requested as the guard-rail for +/// "channel-scoped row masquerading as channel-less": IF the row had +/// been mis-projected as channel-less (and thus defaulted to the +/// resolved community A), the subset check would have passed +/// vacuously. By recording the row's TRUE community (B) — independent +/// of the fetch query's WHERE clause — the `Inv_NonInterference` / +/// `Inv_ReadConfinement` bite surfaces immediately as +/// `NonInterference`. This fixture is the proof artifact that the +/// projection helper's missing-lookup guard-rail is non-vacuous. +fn bad_foreign_row_leak_trace() -> Vec { + vec![TraceStep::new( + TraceAction::ReadMessageRows { + // The query was scoped to a channel in A (the host-resolved + // tenant). The relay's filter said "this row should belong + // to A." But the row's TRUE community is B — surfaced by + // the (B)-strategy projection reading the row's own + // `channel_id` against the channels table. + channel: Some(channel_in_a()), + row_communities: vec![community_b()], + }, + state_a(), + )] +} + +// ---- Fixture round-trip ------------------------------------------------ + +fn fixture_path(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests") + .join("fixtures") + .join(name) +} + +/// Serialize a trace to JSONL (one step per line). +fn to_jsonl(trace: &[TraceStep]) -> String { + let mut out = String::new(); + for step in trace { + let line = serde_json::to_string(step).expect("step serializes"); + out.push_str(&line); + out.push('\n'); + } + out +} + +/// Parse a JSONL string into a trace, surfacing the offending line on +/// error so a misedited fixture is easy to fix. +fn from_jsonl(text: &str) -> Vec { + text.lines() + .enumerate() + .filter(|(_, l)| !l.trim().is_empty()) + .map(|(i, l)| { + serde_json::from_str::(l) + .unwrap_or_else(|e| panic!("fixture line {} did not parse: {e}", i + 1)) + }) + .collect() +} + +/// Assert that the committed JSONL fixture for `name` round-trips to +/// `expected_trace` byte-exactly. Run with `BUZZ_CONFORMANCE_UPDATE=1` +/// to regenerate the fixture (so a schema change is a deliberate +/// re-commit, not a silent break). +fn assert_fixture_matches(name: &str, expected_trace: &[TraceStep]) { + let expected = to_jsonl(expected_trace); + let path = fixture_path(name); + + if std::env::var("BUZZ_CONFORMANCE_UPDATE").is_ok() { + fs::create_dir_all(path.parent().expect("fixture dir")).expect("mkdir fixtures"); + fs::write(&path, &expected).expect("write fixture"); + return; + } + + let actual = fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "fixture {} missing or unreadable ({e}); run with \ + BUZZ_CONFORMANCE_UPDATE=1 to create it", + path.display() + ) + }); + + assert_eq!( + actual, expected, + "committed fixture {} drifted from the typed builder; run with \ + BUZZ_CONFORMANCE_UPDATE=1 to refresh if the change is intentional", + name + ); + + let parsed = from_jsonl(&actual); + assert_eq!(parsed, *expected_trace, "fixture round-trip mismatched"); +} + +// ---- Tests -------------------------------------------------------------- + +#[test] +fn good_trace_passes_check() { + let trace = good_trace(); + assert_fixture_matches("good.jsonl", &trace); + + let scenario = Scenario { + trace, + required_critical_actions: ["auth_check", "write_insert", "read_message_rows"] + .into_iter() + .map(String::from) + .collect::>(), + }; + check_trace(&scenario).expect("the good fixture must replay green"); +} + +#[test] +fn bad_host_channel_mismatch_is_illegal_transition() { + let trace = bad_host_channel_mismatch_trace(); + assert_fixture_matches("bad_host_channel_mismatch.jsonl", &trace); + + let scenario = Scenario::unstructured(trace); + let err = check_trace(&scenario) + .expect_err("host/channel fence skip must be rejected by the checker"); + assert!( + matches!(err, TransitionError::IllegalTransition { .. }), + "host/channel mismatch must surface as IllegalTransition (M2/M8 bite), got {err:?}" + ); +} + +#[test] +fn coverage_breach_is_caught() { + let trace = bad_coverage_breach_trace(); + assert_fixture_matches("bad_coverage_breach.jsonl", &trace); + + let scenario = Scenario::unstructured(trace); + let err = check_trace(&scenario) + .expect_err("ImplBug in the trace must be rejected as a coverage breach"); + assert!( + matches!(err, TransitionError::CoverageBreach { .. }), + "ImplBug must surface as CoverageBreach, got {err:?}" + ); +} + +#[test] +fn foreign_row_leak_is_non_interference() { + let trace = bad_foreign_row_leak_trace(); + assert_fixture_matches("bad_foreign_row_leak.jsonl", &trace); + + let scenario = Scenario::unstructured(trace); + let err = check_trace(&scenario) + .expect_err("foreign row community label must be rejected by Inv_NonInterference"); + assert!( + matches!(err, TransitionError::NonInterference { .. }), + "foreign row label must surface as NonInterference, got {err:?}" + ); +} + +#[test] +fn empty_trace_is_coverage_breach() { + // Independent of the JSONL fixtures: the checker must fail closed on + // an empty trace (no observations from a critical seam). + let scenario = Scenario::unstructured(vec![]); + let err = check_trace(&scenario).expect_err("empty trace must be CoverageBreach"); + assert!( + matches!(err, TransitionError::CoverageBreach { .. }), + "empty trace must be CoverageBreach, got {err:?}" + ); +} + +#[test] +fn missing_required_action_is_coverage_breach() { + // The good trace, but the scenario declares it must include + // `read_by_id_rows` — which it does not. This is what the + // "scenario-required action never appeared" coverage breach catches. + let scenario = Scenario { + trace: good_trace(), + required_critical_actions: ["read_by_id_rows"] + .into_iter() + .map(String::from) + .collect::>(), + }; + let err = check_trace(&scenario) + .expect_err("missing required critical action must be CoverageBreach"); + assert!( + matches!(err, TransitionError::CoverageBreach { .. }), + "missing required action must be CoverageBreach, got {err:?}" + ); +} diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 8ee3d0315..dee40e988 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -28,6 +28,8 @@ pub mod observer; pub mod pairing; /// Presence status types shared across crates. pub mod presence; +/// Tenant identity — the server-resolved community key carried on scoped paths. +pub mod tenant; /// Schnorr signature and event ID verification. pub mod verification; @@ -35,6 +37,7 @@ pub use error::VerificationError; pub use event::StoredEvent; pub use nostr::{Event, EventId, Filter, Keys, Kind, PublicKey}; pub use presence::PresenceStatus; +pub use tenant::{normalize_host, CommunityId, TenantContext}; pub use verification::verify_event; #[cfg(any(test, feature = "test-utils"))] diff --git a/crates/buzz-core/src/tenant.rs b/crates/buzz-core/src/tenant.rs new file mode 100644 index 000000000..e7dbb630a --- /dev/null +++ b/crates/buzz-core/src/tenant.rs @@ -0,0 +1,199 @@ +//! Tenant identity: the server-resolved community key carried on every scoped path. +//! +//! These types live in `buzz-core` (zero I/O deps) so the DB, auth, pub/sub, +//! search, audit, media, and relay-wiring layers all name a community the same +//! way without depending on each other. +//! +//! ## The fence +//! +//! The whole multi-tenant safety story rests on one invariant from the formal +//! model (conformance "row zero"): a request's community is *resolved from the +//! connection host by the server*, never supplied or influenced by the client. +//! +//! [`TenantContext`] expresses that invariant in the type system as far as the +//! type system can carry it: there is no `Default`, no `Deserialize`, and no +//! way to *parse* a community from client input. A `CommunityId` only ever +//! comes from host resolution or from a DB row the server already scoped. +//! +//! This is a **lint-and-review fence, not a compiler fence.** +//! [`TenantContext::resolved`] and [`CommunityId::from_uuid`] are public so the +//! host-resolution path (in another crate) can call them — which means a +//! determined caller elsewhere *could* call them too. The migration-lint +//! harness forbids constructing a `TenantContext` outside host resolution and +//! tests; the type only removes the *accidental* path (deserializing a +//! client-chosen community), and review/lint closes the deliberate one. We say +//! this plainly rather than overclaim a guarantee the `pub` API doesn't give. + +use std::fmt; +use uuid::Uuid; + +/// A community: the first-class tenant key on every scoped row. +/// +/// Opaque UUID newtype. Equality and ordering are the underlying UUID's. +/// There is deliberately no `community_id` parsed from client input anywhere; +/// a `CommunityId` only ever originates from host resolution or from a DB row +/// the server already scoped. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct CommunityId(Uuid); + +impl CommunityId { + /// Wrap a UUID that the server has already established as a community id + /// (e.g. read back from the `communities` table during host resolution). + /// + /// This is intentionally not a parse-from-client entry point: callers must + /// already hold a server-trusted UUID. + pub const fn from_uuid(id: Uuid) -> Self { + Self(id) + } + + /// The underlying UUID, for DB binds and Redis key construction. + pub const fn as_uuid(&self) -> &Uuid { + &self.0 + } +} + +impl fmt::Display for CommunityId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +/// The resolved tenant of an in-flight request, bound once at connection / +/// request establishment before any handler observes tenant data. +/// +/// Carried by reference (`&TenantContext`) through every scoped call. This is +/// the *only* way to name a community downstream, and it cannot be constructed +/// from client input — see the module-level "fence" note. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TenantContext { + community: CommunityId, + host: String, +} + +impl TenantContext { + /// Construct a context from a completed host resolution. + /// + /// Call this *only* from the host-resolution path (the function that maps a + /// connection's host to a `communities` row). Everywhere else takes + /// `&TenantContext` and reads it; nothing else mints one. + pub fn resolved(community: CommunityId, host: impl Into) -> Self { + Self { + community, + host: host.into(), + } + } + + /// The community every scoped operation under this request must use. + pub const fn community(&self) -> CommunityId { + self.community + } + + /// The host that resolved to this community. + /// + /// Authoritative for the NIP-05 domain and audit labelling; never re-derive + /// the community from it downstream — the community is already fixed. + pub fn host(&self) -> &str { + &self.host + } +} + +/// Normalize a connection `Host` into the canonical form used as the community +/// lookup key. +/// +/// This is the *one* normalization rule shared by both sides of the fence: +/// the `communities.host` column is stored already-normalized, and host +/// resolution normalizes the incoming `Host` header with this same function +/// before looking it up. Because both sides agree by construction, +/// `Relay.Example`, `relay.example.`, and `relay.example:443` all resolve to +/// the one community — they can never split into distinct tenants. +/// +/// Rules (host only — the caller has already split off any path/scheme): +/// - ASCII-lowercase (hosts are case-insensitive per RFC 3986); +/// - strip a single trailing dot (the FQDN root label); +/// - strip a default port suffix (`:80`, `:443`) — non-default ports are kept, +/// since a deployment may legitimately serve different communities on +/// different ports of the same name. +/// +/// The input is trimmed of surrounding whitespace. An empty result (e.g. the +/// caller passed `""`) is returned as-is; resolution treats an empty or +/// unmapped host as a fail-closed rejection, never a default tenant. +#[must_use] +pub fn normalize_host(host: &str) -> String { + let host = host.trim(); + let mut host = host.to_ascii_lowercase(); + // Strip default ports. We only touch a `:port` suffix that is exactly a + // default port, so IPv6 literals like `[::1]` (which contain colons but no + // trailing `:80`/`:443`) are left intact. + if let Some(stripped) = host + .strip_suffix(":443") + .or_else(|| host.strip_suffix(":80")) + { + host = stripped.to_string(); + } + // Strip a single trailing FQDN-root dot. + if let Some(stripped) = host.strip_suffix('.') { + host = stripped.to_string(); + } + host +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn community_id_roundtrips_uuid() { + let u = Uuid::from_u128(0x1234_5678_9abc_def0_1122_3344_5566_7788); + let c = CommunityId::from_uuid(u); + assert_eq!(c.as_uuid(), &u); + assert_eq!(c.to_string(), u.to_string()); + } + + #[test] + fn tenant_context_exposes_resolution_inputs() { + let u = Uuid::from_u128(1); + let ctx = TenantContext::resolved(CommunityId::from_uuid(u), "relay.example"); + assert_eq!(ctx.community().as_uuid(), &u); + assert_eq!(ctx.host(), "relay.example"); + } + + #[test] + fn normalize_host_collapses_tenant_split_variants() { + // All of these are the SAME tenant and must normalize identically — + // this is the property that stops accidental split-tenant. + let canonical = "relay.example"; + for variant in [ + "relay.example", + "Relay.Example", + "RELAY.EXAMPLE", + "relay.example.", // trailing FQDN root dot + "relay.example:443", // default https port + "relay.example:80", // default http port + "Relay.Example.:443", + " relay.example ", // surrounding whitespace + ] { + assert_eq!(normalize_host(variant), canonical, "variant {variant:?}"); + } + } + + #[test] + fn normalize_host_keeps_nondefault_port() { + // A non-default port is a legitimate distinct selector — keep it. + assert_eq!(normalize_host("relay.example:8443"), "relay.example:8443"); + assert_eq!(normalize_host("relay.example:3000"), "relay.example:3000"); + } + + #[test] + fn normalize_host_leaves_ipv6_literal_intact() { + // IPv6 literals contain colons but no trailing default-port suffix. + assert_eq!(normalize_host("[::1]"), "[::1]"); + assert_eq!(normalize_host("[::1]:443"), "[::1]"); + } + + #[test] + fn normalize_host_empty_stays_empty() { + // Empty / whitespace-only resolves to empty; resolution fails closed. + assert_eq!(normalize_host(""), ""); + assert_eq!(normalize_host(" "), ""); + } +} diff --git a/crates/buzz-db/src/archived_identities.rs b/crates/buzz-db/src/archived_identities.rs index 00237ccf8..941c0fc73 100644 --- a/crates/buzz-db/src/archived_identities.rs +++ b/crates/buzz-db/src/archived_identities.rs @@ -1,10 +1,11 @@ -//! Relay-scoped archived identity persistence (NIP-IA). +//! Community-scoped archived identity persistence (NIP-IA). //! -//! The `archived_identities` table stores a relay-local UI visibility hint for +//! The `archived_identities` table stores a community-local UI visibility hint for //! identity pubkeys. Archiving is not a ban: it does not affect membership, //! relay access, or repository permissions. //! All pubkey and event ID values are lowercase hex strings. +use buzz_core::CommunityId; use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row as _}; @@ -29,21 +30,26 @@ pub struct ArchivedIdentity { pub archived_at: DateTime, } -/// Returns `true` if `pubkey` (64-char hex) is currently archived. -pub async fn is_archived(pool: &PgPool, pubkey: &str) -> Result { - let row = sqlx::query("SELECT 1 FROM archived_identities WHERE pubkey = $1") - .bind(pubkey) - .fetch_optional(pool) - .await?; +/// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. +pub async fn is_archived(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { + let row = + sqlx::query("SELECT 1 FROM archived_identities WHERE community_id = $1 AND pubkey = $2") + .bind(community_id.as_uuid()) + .bind(pubkey) + .fetch_optional(pool) + .await?; Ok(row.is_some()) } -/// Archives an identity. +/// Archives an identity in `community_id`. /// /// Returns `true` if the row was inserted, `false` if the identity was already -/// archived. Re-archiving is idempotent and does not mutate the existing row. +/// archived in that community. Re-archiving is idempotent and does not mutate +/// the existing row. +#[allow(clippy::too_many_arguments)] pub async fn archive( pool: &PgPool, + community_id: CommunityId, pubkey: &str, consent_path: &str, actor: &str, @@ -53,10 +59,11 @@ pub async fn archive( ) -> Result { let result = sqlx::query( "INSERT INTO archived_identities \ - (pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ - VALUES ($1, $2, $3, $4, $5, $6) \ - ON CONFLICT (pubkey) DO NOTHING", + (community_id, pubkey, consent_path, actor, reason, replaced_by, request_event_id) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) \ + ON CONFLICT (community_id, pubkey) DO NOTHING", ) + .bind(community_id.as_uuid()) .bind(pubkey) .bind(consent_path) .bind(actor) @@ -69,24 +76,31 @@ pub async fn archive( Ok(result.rows_affected() > 0) } -/// Unarchives an identity. +/// Unarchives an identity from `community_id`. /// -/// Returns `true` if a row was deleted, `false` if the identity was not archived. -pub async fn unarchive(pool: &PgPool, pubkey: &str) -> Result { - let result = sqlx::query("DELETE FROM archived_identities WHERE pubkey = $1") - .bind(pubkey) - .execute(pool) - .await?; +/// Returns `true` if a row was deleted, `false` if the identity was not archived +/// in that community. +pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result { + let result = + sqlx::query("DELETE FROM archived_identities WHERE community_id = $1 AND pubkey = $2") + .bind(community_id.as_uuid()) + .bind(pubkey) + .execute(pool) + .await?; Ok(result.rows_affected() > 0) } -/// Returns all archived identities ordered by archive time ascending. -pub async fn list_archived(pool: &PgPool) -> Result> { +/// Returns all identities archived in `community_id`, ordered by archive time ascending. +pub async fn list_archived( + pool: &PgPool, + community_id: CommunityId, +) -> Result> { let rows = sqlx::query( "SELECT pubkey, consent_path, actor, reason, replaced_by, request_event_id, archived_at \ - FROM archived_identities ORDER BY archived_at ASC", + FROM archived_identities WHERE community_id = $1 ORDER BY archived_at ASC", ) + .bind(community_id.as_uuid()) .fetch_all(pool) .await?; @@ -109,3 +123,99 @@ fn row_to_archived_identity( archived_at: row.try_get("archived_at")?, }) } + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + PgPool::connect(TEST_DB_URL) + .await + .expect("connect to test DB") + } + + async fn make_community(pool: &PgPool) -> CommunityId { + let id = uuid::Uuid::new_v4(); + let host = format!("archive-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn archived_identity_state_is_community_scoped() { + let pool = setup_pool().await; + let community_a = make_community(&pool).await; + let community_b = make_community(&pool).await; + let pubkey = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let actor = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let event_a = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; + let event_b = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; + + assert!(archive( + &pool, + community_a, + pubkey, + "self", + actor, + Some("community A"), + None, + event_a, + ) + .await + .expect("archive in community A")); + + assert!(is_archived(&pool, community_a, pubkey) + .await + .expect("is_archived in A")); + assert!(!is_archived(&pool, community_b, pubkey) + .await + .expect("is_archived in B")); + assert_eq!( + list_archived(&pool, community_a) + .await + .expect("list A") + .len(), + 1 + ); + assert!(list_archived(&pool, community_b) + .await + .expect("list B") + .is_empty()); + assert!(!unarchive(&pool, community_b, pubkey) + .await + .expect("unarchive absent B")); + assert!(is_archived(&pool, community_a, pubkey) + .await + .expect("B unarchive must not affect A")); + + assert!(archive( + &pool, + community_b, + pubkey, + "self", + actor, + Some("community B"), + None, + event_b, + ) + .await + .expect("archive same pubkey in community B")); + assert!(unarchive(&pool, community_a, pubkey) + .await + .expect("unarchive A")); + assert!(!is_archived(&pool, community_a, pubkey) + .await + .expect("A removed")); + assert!(is_archived(&pool, community_b, pubkey) + .await + .expect("A unarchive must not affect B")); + } +} diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index a9cecc141..99f202b79 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -9,6 +9,7 @@ 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 @@ -82,8 +83,10 @@ pub struct MemberRecord { } /// 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, @@ -104,12 +107,13 @@ pub async fn create_channel( sqlx::query( r#" - INSERT INTO channels (id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) - VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6, $7, - CASE WHEN $7 IS NOT NULL THEN NOW() + ($7 || ' seconds')::interval ELSE NULL END) + 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()) @@ -121,14 +125,15 @@ pub async fn create_channel( sqlx::query( r#" - INSERT INTO channel_members (channel_id, pubkey, role, invited_by) - VALUES ($1, $2, 'owner', $3) - ON CONFLICT (channel_id, pubkey) DO UPDATE SET + 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) @@ -144,9 +149,10 @@ pub async fn create_channel( topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, ttl_seconds, ttl_deadline - FROM channels WHERE id = $1 + FROM channels WHERE community_id = $1 AND id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(id) .fetch_one(&mut *tx) .await?; @@ -163,6 +169,7 @@ pub async fn create_channel( #[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, @@ -188,13 +195,14 @@ pub async fn create_channel_with_id( let rows_affected = sqlx::query( r#" - INSERT INTO channels (id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) - VALUES ($1, $2, $3::channel_type, $4::channel_visibility, $5, $6, $7, - CASE WHEN $7 IS NOT NULL THEN NOW() + ($7 || ' seconds')::interval ELSE NULL END) - ON CONFLICT (id) DO NOTHING + 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()) @@ -211,14 +219,15 @@ pub async fn create_channel_with_id( // Bootstrap the creator as owner. sqlx::query( r#" - INSERT INTO channel_members (channel_id, pubkey, role, invited_by) - VALUES ($1, $2, 'owner', $3) - ON CONFLICT (channel_id, pubkey) DO UPDATE SET + 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) @@ -235,9 +244,10 @@ pub async fn create_channel_with_id( topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, ttl_seconds, ttl_deadline - FROM channels WHERE id = $1 + FROM channels WHERE community_id = $1 AND id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .fetch_one(&mut *tx) .await?; @@ -247,8 +257,12 @@ pub async fn create_channel_with_id( Ok((record, was_created)) } -/// Fetches a channel record by ID. Returns `ChannelNotFound` if missing or deleted. -pub async fn get_channel(pool: &PgPool, channel_id: Uuid) -> Result { +/// 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 { let row = sqlx::query( r#" SELECT id, name, channel_type::text AS channel_type, visibility::text AS visibility, @@ -258,9 +272,10 @@ pub async fn get_channel(pool: &PgPool, channel_id: Uuid) -> Result Result Result> { - let row = sqlx::query("SELECT canvas FROM channels WHERE id = $1 AND deleted_at IS NULL") - .bind(channel_id) - .fetch_optional(pool) - .await? - .ok_or(DbError::ChannelNotFound(channel_id))?; +pub async fn get_canvas( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, +) -> Result> { + 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, channel_id: Uuid, canvas: Option<&str>) -> Result<()> { - let rows = sqlx::query("UPDATE channels SET canvas = $1 WHERE id = $2 AND deleted_at IS NULL") +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?; @@ -305,6 +335,7 @@ pub async fn set_canvas(pool: &PgPool, channel_id: Uuid, canvas: Option<&str>) - /// 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, @@ -319,7 +350,7 @@ pub async fn add_member( let mut tx = pool.begin().await?; - let channel = get_channel_tx(&mut tx, 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(|| { @@ -330,7 +361,7 @@ pub async fn add_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, channel_id, inviter) + 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()) @@ -354,7 +385,7 @@ pub async fn add_member( // 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, channel_id, inv).await?, + Some(inv) => get_active_role_tx(&mut tx, community_id, channel_id, inv).await?, None => None, }; match granter_role.as_deref() { @@ -372,14 +403,15 @@ pub async fn add_member( sqlx::query( r#" - INSERT INTO channel_members (channel_id, pubkey, role, invited_by) - VALUES ($1, $2, $3::member_role, $4) - ON CONFLICT (channel_id, pubkey) DO UPDATE SET + 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()) @@ -390,9 +422,10 @@ pub async fn add_member( let row = sqlx::query( r#" SELECT channel_id, pubkey, role::text AS role, joined_at, invited_by, removed_at - FROM channel_members WHERE channel_id = $1 AND pubkey = $2 + 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) @@ -415,6 +448,7 @@ pub async fn add_member( /// because `agent_owner_pubkey` is immutable (set once at token mint). pub async fn remove_member( pool: &PgPool, + community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], actor_pubkey: &[u8], @@ -423,7 +457,7 @@ pub async fn remove_member( let is_self_remove = pubkey == actor_pubkey; if !is_self_remove { - let actor_role_str = get_active_role_tx(&mut tx, channel_id, actor_pubkey) + 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(|_| { @@ -432,7 +466,7 @@ pub async fn remove_member( // Safe to query outside the transaction: agent_owner_pubkey is immutable // (set once at token mint, first-mint-wins). if !actor_role.is_elevated() - && !crate::user::is_agent_owner(pool, pubkey, actor_pubkey).await? + && !crate::user::is_agent_owner(pool, community_id, pubkey, actor_pubkey).await? { return Err(DbError::AccessDenied( "only owners/admins or the agent's owner may remove other members".to_string(), @@ -443,12 +477,13 @@ pub async fn remove_member( // 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, channel_id, pubkey).await?; + 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 channel_id = $1 AND role = 'owner' AND removed_at IS NULL", + 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?; @@ -464,10 +499,11 @@ pub async fn remove_member( r#" UPDATE channel_members SET removed_at = NOW(), removed_by = $1 - WHERE channel_id = $2 AND pubkey = $3 AND removed_at IS NULL + 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) @@ -482,12 +518,18 @@ pub async fn remove_member( } /// Returns `true` if the given pubkey is an active member of the channel. -pub async fn is_member(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result { +pub async fn is_member( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result { let row = sqlx::query( "SELECT COUNT(*) as cnt FROM channel_members cm \ - JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL \ - WHERE cm.channel_id = $1 AND cm.pubkey = $2 AND cm.removed_at IS NULL", + 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) @@ -499,17 +541,22 @@ pub async fn is_member(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result /// Returns all active members of the given channel. /// /// Returns an empty list if the channel has been soft-deleted. -pub async fn get_members(pool: &PgPool, channel_id: Uuid) -> Result> { +pub async fn get_members( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, +) -> Result> { 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.channel_id = c.id AND c.deleted_at IS NULL - WHERE cm.channel_id = $1 AND cm.removed_at IS NULL + 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 LIMIT 1000 "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .fetch_all(pool) .await?; @@ -523,7 +570,11 @@ pub async fn get_members(pool: &PgPool, channel_id: Uuid) -> Result` 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, channel_ids: &[Uuid]) -> Result> { +pub async fn get_members_bulk( + pool: &PgPool, + community_id: CommunityId, + channel_ids: &[Uuid], +) -> Result> { if channel_ids.is_empty() { return Ok(Vec::new()); } @@ -531,11 +582,12 @@ pub async fn get_members_bulk(pool: &PgPool, channel_ids: &[Uuid]) -> Result Result Result> { +pub async fn get_accessible_channel_ids( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], +) -> Result> { let rows = sqlx::query( r#" SELECT cm.channel_id FROM channel_members cm - JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL - WHERE cm.pubkey = $1 AND cm.removed_at IS NULL + 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 visibility = 'open' AND deleted_at IS NULL + WHERE community_id = $1 AND visibility = 'open' AND deleted_at IS NULL LIMIT 1000 "#, ) + .bind(community_id.as_uuid()) .bind(pubkey) .fetch_all(pool) .await?; @@ -572,8 +629,12 @@ pub async fn get_accessible_channel_ids(pool: &PgPool, pubkey: &[u8]) -> Result< .collect() } -/// Lists channels, optionally filtered by visibility string. -pub async fn list_channels(pool: &PgPool, visibility: Option<&str>) -> Result> { +/// Lists channels in a community, optionally filtered by visibility string. +pub async fn list_channels( + pool: &PgPool, + community_id: CommunityId, + visibility: Option<&str>, +) -> Result> { let rows = if let Some(vis) = visibility { sqlx::query( r#" @@ -585,11 +646,12 @@ pub async fn list_channels(pool: &PgPool, visibility: Option<&str>) -> Result) -> Result) -> Result, + community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], ) -> Result> { let row = sqlx::query( "SELECT role::text AS role FROM channel_members \ - WHERE channel_id = $1 AND pubkey = $2 AND removed_at IS NULL", + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND removed_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) .fetch_optional(&mut **tx) @@ -636,6 +701,7 @@ async fn get_active_role_tx( /// Transaction-aware variant of [`get_channel`]. async fn get_channel_tx( tx: &mut Transaction<'_, Postgres>, + community_id: CommunityId, channel_id: Uuid, ) -> Result { let row = sqlx::query( @@ -647,9 +713,10 @@ async fn get_channel_tx( topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at, ttl_seconds, ttl_deadline - FROM channels WHERE id = $1 AND deleted_at IS NULL + 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? @@ -666,6 +733,17 @@ pub struct BotChannelEntry { 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 { @@ -713,6 +791,7 @@ pub struct AccessibleChannel { /// 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, @@ -739,20 +818,22 @@ pub async fn get_accessible_channels( (cm.channel_id IS NOT NULL) AS is_member FROM channels c LEFT JOIN channel_members cm - ON c.id = cm.channel_id AND cm.pubkey = $1 AND cm.removed_at IS NULL - WHERE c.deleted_at IS NULL + 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 = $2\n ORDER BY array_position(ARRAY['stream','forum','dm']::text[], c.channel_type::text), c.name\n LIMIT 1000") + 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(pubkey); + 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 { @@ -769,24 +850,28 @@ pub async fn get_accessible_channels( .collect() } -/// Returns all bot-role members with their channel memberships. +/// 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) -> Result> { +pub async fn get_bot_members( + pool: &PgPool, + community_id: CommunityId, +) -> Result> { 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.pubkey = u.pubkey - JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL - WHERE cm.role = 'bot' AND cm.removed_at IS NULL + 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?; @@ -922,6 +1007,7 @@ pub struct ChannelUpdate { /// Returns the updated `ChannelRecord` on success. pub async fn update_channel( pool: &PgPool, + community_id: CommunityId, channel_id: Uuid, updates: ChannelUpdate, ) -> Result { @@ -963,8 +1049,9 @@ pub async fn update_channel( 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 id = ${param_idx} AND deleted_at IS NULL", + "UPDATE channels SET {}, updated_at = NOW() WHERE community_id = ${param_idx} AND id = ${channel_param_idx} AND deleted_at IS NULL", set_parts.join(", ") ); @@ -981,6 +1068,7 @@ pub async fn update_channel( if let Some(ref ttl) = updates.ttl_seconds { q = q.bind(*ttl); } + q = q.bind(community_id.as_uuid()); q = q.bind(channel_id); let result = q.execute(pool).await?; @@ -988,17 +1076,24 @@ pub async fn update_channel( return Err(DbError::ChannelNotFound(channel_id)); } - get_channel(pool, channel_id).await + 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, channel_id: Uuid, topic: &str, set_by: &[u8]) -> Result<()> { +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 id = $3 AND deleted_at IS NULL", + 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?; @@ -1011,16 +1106,18 @@ pub async fn set_topic(pool: &PgPool, channel_id: Uuid, topic: &str, set_by: &[u /// 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 id = $3 AND deleted_at IS NULL", + 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?; @@ -1034,9 +1131,16 @@ pub async fn set_purpose( /// /// 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, channel_id: Uuid) -> Result<()> { +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 id = $1 AND deleted_at IS NULL") + 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?; @@ -1055,8 +1159,9 @@ pub async fn archive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> { sqlx::query( "UPDATE channels SET archived_at = NOW() \ - WHERE id = $1 AND deleted_at IS NULL AND archived_at IS NULL", + 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?; @@ -1068,9 +1173,16 @@ pub async fn archive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> { /// /// 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, channel_id: Uuid) -> Result<()> { +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 id = $1 AND deleted_at IS NULL") + 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?; @@ -1091,8 +1203,9 @@ pub async fn unarchive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> { WHEN ttl_seconds IS NOT NULL THEN NOW() + (ttl_seconds || ' seconds')::interval \ ELSE ttl_deadline \ END \ - WHERE id = $1 AND deleted_at IS NULL AND archived_at IS NOT NULL", + 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?; @@ -1104,9 +1217,15 @@ pub async fn unarchive_channel(pool: &PgPool, channel_id: Uuid) -> Result<()> { /// /// Returns `Ok(true)` if the channel was deleted, `Ok(false)` if already /// deleted or not found. -pub async fn soft_delete_channel(pool: &PgPool, channel_id: Uuid) -> Result { - let result = - sqlx::query("UPDATE channels SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL") +pub async fn soft_delete_channel( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, +) -> Result { + 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?; @@ -1115,10 +1234,15 @@ pub async fn soft_delete_channel(pool: &PgPool, channel_id: Uuid) -> Result Result { +pub async fn get_member_count( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, +) -> Result { let row = sqlx::query( - "SELECT COUNT(*) as cnt FROM channel_members WHERE channel_id = $1 AND removed_at IS NULL", + "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?; @@ -1131,6 +1255,7 @@ pub async fn get_member_count(pool: &PgPool, channel_id: Uuid) -> Result { /// Single query regardless of input size. pub async fn get_member_counts_bulk( pool: &PgPool, + community_id: CommunityId, channel_ids: &[Uuid], ) -> Result> { if channel_ids.is_empty() { @@ -1139,8 +1264,10 @@ pub async fn get_member_counts_bulk( let mut qb: sqlx::QueryBuilder = sqlx::QueryBuilder::new( "SELECT channel_id, COUNT(*) as cnt FROM channel_members \ - WHERE removed_at IS NULL AND channel_id IN (", + 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); @@ -1163,14 +1290,16 @@ pub async fn get_member_counts_bulk( /// 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> { let row = sqlx::query( "SELECT cm.role::text AS role FROM channel_members cm \ - JOIN channels c ON cm.channel_id = c.id AND c.deleted_at IS NULL \ - WHERE cm.channel_id = $1 AND cm.pubkey = $2 AND cm.removed_at IS NULL", + 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) @@ -1181,11 +1310,16 @@ pub async fn get_member_role( /// Bump the TTL deadline for an ephemeral channel after a new message. /// /// No-op for permanent channels or channels that are already archived/deleted. -pub async fn bump_ttl_deadline(pool: &PgPool, channel_id: Uuid) -> Result<()> { +pub async fn bump_ttl_deadline( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, +) -> Result<()> { sqlx::query( "UPDATE channels SET ttl_deadline = NOW() + (ttl_seconds || ' seconds')::interval \ - WHERE id = $1 AND ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL", + WHERE community_id = $1 AND id = $2 AND ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(channel_id) .execute(pool) .await?; @@ -1194,25 +1328,33 @@ pub async fn bump_ttl_deadline(pool: &PgPool, channel_id: Uuid) -> Result<()> { /// Archive ephemeral channels whose TTL deadline has passed. /// -/// Returns the list of channel IDs that were archived. Idempotent — the +/// 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> { +pub async fn reap_expired_ephemeral_channels(pool: &PgPool) -> Result> { let rows = sqlx::query( - "UPDATE channels SET archived_at = NOW() \ - WHERE ttl_seconds IS NOT NULL \ - AND ttl_deadline < NOW() \ - AND archived_at IS NULL \ - AND deleted_at IS NULL \ - RETURNING id", + "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 \ + RETURNING ch.community_id, c.host, ch.id", ) .fetch_all(pool) .await?; rows.into_iter() .map(|row| { - let id: Uuid = row.try_get("id")?; - Ok(id) + 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() } @@ -1235,28 +1377,169 @@ mod tests { 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, + ) -> Result { + 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_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, &owner_pk).await.expect("ensure owner"); - ensure_user(&pool, &agent_pk).await.expect("ensure agent"); - set_agent_owner(&pool, &agent_pk, &owner_pk) + 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, &channel_owner_pk) + ensure_user(&pool, community, &channel_owner_pk) .await .expect("ensure channel owner"); - let channel = create_channel( + let channel = create_test_channel( &pool, + community_id, "test-bot-remove", ChannelType::Stream, ChannelVisibility::Open, @@ -1268,21 +1551,35 @@ mod tests { .expect("create channel"); // Add owner and agent as regular members - add_member(&pool, channel.id, &owner_pk, MemberRole::Member, None) - .await - .expect("add owner as member"); - add_member(&pool, channel.id, &agent_pk, MemberRole::Member, None) - .await - .expect("add agent as member"); + 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, channel.id, &agent_pk, &owner_pk) + 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, channel.id, &agent_pk) + !is_member(&pool, community, channel.id, &agent_pk) .await .expect("is_member check"), "agent should no longer be a member" @@ -1295,11 +1592,16 @@ mod tests { #[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, &owner_pk).await.expect("ensure owner"); + ensure_user(&pool, community, &owner_pk) + .await + .expect("ensure owner"); - let channel = create_channel( + let channel = create_test_channel( &pool, + community_id, "test-unarchive-renews-ttl", ChannelType::Stream, ChannelVisibility::Open, @@ -1311,18 +1613,19 @@ mod tests { .expect("create ephemeral channel"); sqlx::query( - "UPDATE channels SET archived_at = NOW(), ttl_deadline = NOW() - interval '1 second' WHERE id = $1", + "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, channel.id) + unarchive_channel(&pool, community, channel.id) .await .expect("unarchive expired ephemeral channel"); - let channel = get_channel(&pool, channel.id) + let channel = get_channel(&pool, community, channel.id) .await .expect("reload channel"); assert!( @@ -1338,35 +1641,97 @@ mod tests { .await .expect("run reaper"); assert!( - !reaped.contains(&channel.id), + !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" + ); + } + /// 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, &owner_pk).await.expect("ensure owner"); - ensure_user(&pool, &agent_pk).await.expect("ensure agent"); - ensure_user(&pool, &random_pk).await.expect("ensure random"); - set_agent_owner(&pool, &agent_pk, &owner_pk) + 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, &channel_owner_pk) + ensure_user(&pool, community, &channel_owner_pk) .await .expect("ensure channel owner"); - let channel = create_channel( + let channel = create_test_channel( &pool, + community_id, "test-bot-no-remove", ChannelType::Stream, ChannelVisibility::Open, @@ -1378,15 +1743,29 @@ mod tests { .expect("create channel"); // Add random user and agent as regular members - add_member(&pool, channel.id, &random_pk, MemberRole::Member, None) - .await - .expect("add random as member"); - add_member(&pool, channel.id, &agent_pk, MemberRole::Member, None) - .await - .expect("add agent as member"); + 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, channel.id, &agent_pk, &random_pk).await; + 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" diff --git a/crates/buzz-db/src/dm.rs b/crates/buzz-db/src/dm.rs index 7684ac7d2..89e15c702 100644 --- a/crates/buzz-db/src/dm.rs +++ b/crates/buzz-db/src/dm.rs @@ -10,6 +10,7 @@ use uuid::Uuid; use crate::channel::ChannelRecord; use crate::error::{DbError, Result}; +use buzz_core::CommunityId; // -- Public structs ----------------------------------------------------------- @@ -63,6 +64,7 @@ pub fn compute_participant_hash(pubkeys: &[&[u8]]) -> [u8; 32] { /// Returns `None` if no matching DM exists or if it has been deleted. pub async fn find_dm_by_participants( pool: &PgPool, + community_id: CommunityId, participant_hash: &[u8], ) -> Result> { let row = sqlx::query( @@ -74,12 +76,14 @@ pub async fn find_dm_by_participants( topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at FROM channels - WHERE participant_hash = $1 + WHERE community_id = $1 + AND participant_hash = $2 AND channel_type = 'dm' AND deleted_at IS NULL LIMIT 1 "#, ) + .bind(community_id.as_uuid()) .bind(participant_hash) .fetch_optional(pool) .await?; @@ -96,6 +100,7 @@ pub async fn find_dm_by_participants( /// - The operation is idempotent: same participant set -> same channel returned. pub async fn create_dm( pool: &PgPool, + community_id: CommunityId, participants: &[&[u8]], created_by: &[u8], ) -> Result { @@ -132,12 +137,14 @@ pub async fn create_dm( topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at FROM channels - WHERE participant_hash = $1 + WHERE community_id = $1 + AND participant_hash = $2 AND channel_type = 'dm' AND deleted_at IS NULL LIMIT 1 "#, ) + .bind(community_id.as_uuid()) .bind(hash.as_slice()) .fetch_optional(&mut *tx) .await?; @@ -159,11 +166,12 @@ pub async fn create_dm( sqlx::query( r#" INSERT INTO channels - (id, name, channel_type, visibility, created_by, participant_hash) - VALUES ($1, $2, 'dm', 'private', $3, $4) + (id, community_id, name, channel_type, visibility, created_by, participant_hash) + VALUES ($1, $2, $3, 'dm', 'private', $4, $5) "#, ) .bind(id) + .bind(community_id.as_uuid()) .bind(&name) .bind(created_by) .bind(hash.as_slice()) @@ -174,14 +182,15 @@ pub async fn create_dm( for pk in participants { sqlx::query( r#" - INSERT INTO channel_members (channel_id, pubkey, role, invited_by) - VALUES ($1, $2, 'member', $3) - ON CONFLICT (channel_id, pubkey) DO UPDATE SET + INSERT INTO channel_members (community_id, channel_id, pubkey, role, invited_by) + VALUES ($1, $2, $3, 'member', $4) + ON CONFLICT (community_id, channel_id, pubkey) DO UPDATE SET removed_at = NULL, removed_by = NULL, role = EXCLUDED.role "#, ) + .bind(community_id.as_uuid()) .bind(id) .bind(*pk) .bind(created_by) @@ -197,9 +206,10 @@ pub async fn create_dm( nip29_group_id, topic_required, max_members, topic, topic_set_by, topic_set_at, purpose, purpose_set_by, purpose_set_at - FROM channels WHERE id = $1 + FROM channels WHERE community_id = $1 AND id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(id) .fetch_one(&mut *tx) .await?; @@ -215,6 +225,7 @@ pub async fn create_dm( /// using `updated_at` ordering. pub async fn list_dms_for_user( pool: &PgPool, + community_id: CommunityId, pubkey: &[u8], limit: u32, cursor: Option, @@ -223,10 +234,12 @@ pub async fn list_dms_for_user( // Resolve cursor to a timestamp for keyset pagination. let cursor_ts: Option> = if let Some(cid) = cursor { - let row = sqlx::query("SELECT updated_at FROM channels WHERE id = $1") - .bind(cid) - .fetch_optional(pool) - .await?; + let row = + sqlx::query("SELECT updated_at FROM channels WHERE community_id = $1 AND id = $2") + .bind(community_id.as_uuid()) + .bind(cid) + .fetch_optional(pool) + .await?; row.map(|r| r.try_get::, _>("updated_at")) .transpose()? } else { @@ -240,17 +253,20 @@ pub async fn list_dms_for_user( SELECT c.id, c.created_at, c.updated_at FROM channels c JOIN channel_members cm - ON c.id = cm.channel_id - AND cm.pubkey = $1 + ON c.community_id = cm.community_id + AND c.id = cm.channel_id + AND cm.pubkey = $2 AND cm.removed_at IS NULL AND cm.hidden_at IS NULL - WHERE c.channel_type = 'dm' + WHERE c.community_id = $1 + AND c.channel_type = 'dm' AND c.deleted_at IS NULL - AND c.updated_at < $2 + AND c.updated_at < $3 ORDER BY c.updated_at DESC - LIMIT $3 + LIMIT $4 "#, ) + .bind(community_id.as_uuid()) .bind(pubkey) .bind(ts) .bind(limit) @@ -262,16 +278,19 @@ pub async fn list_dms_for_user( SELECT c.id, c.created_at, c.updated_at FROM channels c JOIN channel_members cm - ON c.id = cm.channel_id - AND cm.pubkey = $1 + ON c.community_id = cm.community_id + AND c.id = cm.channel_id + AND cm.pubkey = $2 AND cm.removed_at IS NULL AND cm.hidden_at IS NULL - WHERE c.channel_type = 'dm' + WHERE c.community_id = $1 + AND c.channel_type = 'dm' AND c.deleted_at IS NULL ORDER BY c.updated_at DESC - LIMIT $2 + LIMIT $3 "#, ) + .bind(community_id.as_uuid()) .bind(pubkey) .bind(limit) .fetch_all(pool) @@ -290,12 +309,16 @@ pub async fn list_dms_for_user( r#" SELECT cm.pubkey, cm.role::text AS role, u.display_name FROM channel_members cm - LEFT JOIN users u ON cm.pubkey = u.pubkey - WHERE cm.channel_id = $1 + LEFT JOIN users u + ON u.community_id = cm.community_id + AND u.pubkey = cm.pubkey + WHERE cm.community_id = $1 + AND cm.channel_id = $2 AND cm.removed_at IS NULL ORDER BY cm.joined_at ASC "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .fetch_all(pool) .await?; @@ -332,6 +355,7 @@ pub async fn list_dms_for_user( /// - `was_created = false` -- an existing DM was returned. pub async fn open_dm( pool: &PgPool, + community_id: CommunityId, pubkeys: &[&[u8]], created_by: &[u8], ) -> Result<(ChannelRecord, bool)> { @@ -351,14 +375,14 @@ pub async fn open_dm( let hash = compute_participant_hash(&all); // Check for existing DM first (fast path, no transaction). - if let Some(existing) = find_dm_by_participants(pool, &hash).await? { + if let Some(existing) = find_dm_by_participants(pool, community_id, &hash).await? { // Clear hidden_at for the caller so the DM reappears in their sidebar. - unhide_dm(pool, existing.id, created_by).await?; + unhide_dm(pool, community_id, existing.id, created_by).await?; return Ok((existing, false)); } // Create new DM. - let channel = create_dm(pool, &all, created_by).await?; + let channel = create_dm(pool, community_id, &all, created_by).await?; Ok((channel, true)) } @@ -370,14 +394,20 @@ pub async fn open_dm( /// The DM is not deleted — it can be restored by opening a new DM with the /// same participants (which clears `hidden_at`). Returns an error if the user /// is not an active member of the channel. -pub async fn hide_dm(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result<()> { +pub async fn hide_dm( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result<()> { let result = sqlx::query( r#" UPDATE channel_members SET hidden_at = NOW() - WHERE channel_id = $1 AND pubkey = $2 AND removed_at IS NULL + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND removed_at IS NULL "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) .execute(pool) @@ -396,14 +426,20 @@ pub async fn hide_dm(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result<( /// /// This is called automatically when a user re-opens a DM via [`open_dm`]. /// It is a no-op if the membership is not currently hidden. -pub async fn unhide_dm(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result<()> { +pub async fn unhide_dm( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], +) -> Result<()> { sqlx::query( r#" UPDATE channel_members SET hidden_at = NULL - WHERE channel_id = $1 AND pubkey = $2 AND removed_at IS NULL + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND removed_at IS NULL "#, ) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) .execute(pool) @@ -415,13 +451,20 @@ pub async fn unhide_dm(pool: &PgPool, channel_id: Uuid, pubkey: &[u8]) -> Result /// Return the channel IDs of all DMs the given user currently has hidden /// (`hidden_at IS NOT NULL`) while still being an active member. Used to build /// the relay-signed NIP-DV visibility snapshot. -pub async fn list_hidden_dms(pool: &PgPool, pubkey: &[u8]) -> Result> { +pub async fn list_hidden_dms( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], +) -> Result> { let rows = sqlx::query( r#" SELECT cm.channel_id FROM channel_members cm - JOIN channels c ON c.id = cm.channel_id - WHERE cm.pubkey = $1 + JOIN channels c + ON c.community_id = cm.community_id + AND c.id = cm.channel_id + WHERE cm.community_id = $1 + AND cm.pubkey = $2 AND cm.removed_at IS NULL AND cm.hidden_at IS NOT NULL AND c.channel_type = 'dm' @@ -429,6 +472,7 @@ pub async fn list_hidden_dms(pool: &PgPool, pubkey: &[u8]) -> Result> ORDER BY cm.channel_id "#, ) + .bind(community_id.as_uuid()) .bind(pubkey) .fetch_all(pool) .await?; diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 9cef6c53c..b50b92613 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -12,13 +12,15 @@ use uuid::Uuid; use buzz_core::kind::{ event_kind_i32, is_ephemeral, is_parameterized_replaceable, KIND_AUTH, KIND_EVENT_REMINDER, }; -use buzz_core::StoredEvent; +use buzz_core::{CommunityId, StoredEvent}; use crate::error::{DbError, Result}; /// Optional filters for [`query_events`]. -#[derive(Debug, Default, Clone)] +#[derive(Debug, Clone)] pub struct EventQuery { + /// Server-resolved community scope. + pub community_id: CommunityId, /// Restrict results to this channel. pub channel_id: Option, /// Restrict results to these kind values (stored as `i32` in Postgres). @@ -69,6 +71,36 @@ pub struct EventQuery { pub max_limit: Option, } +impl EventQuery { + /// Construct an unconstrained query inside a server-resolved community. + /// + /// `community_id` has no safe default. This keeps call sites concise while + /// making tenant provenance explicit at construction. + #[must_use] + pub const fn for_community(community_id: CommunityId) -> Self { + Self { + community_id, + channel_id: None, + kinds: None, + pubkey: None, + since: None, + until: None, + limit: None, + offset: None, + p_tag_hex: None, + d_tag: None, + d_tags: None, + before_id: None, + global_only: false, + authors: None, + ids: None, + e_tags: None, + channel_ids: None, + max_limit: None, + } + } +} + /// Maximum length for a `d_tag` value (bytes). NIP-33 d-tags are short identifiers; /// anything beyond this is either a bug or abuse. pub const D_TAG_MAX_LEN: usize = 1024; @@ -123,6 +155,7 @@ pub fn extract_not_before(event: &Event) -> Option { /// Returns `(StoredEvent, was_inserted)` — `was_inserted` is `false` on duplicate. pub async fn insert_event( pool: &PgPool, + community_id: CommunityId, event: &Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { @@ -150,11 +183,12 @@ pub async fn insert_event( let not_before = extract_not_before(event); let result = sqlx::query( r#" - INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(id_bytes.as_slice()) .bind(pubkey_bytes.as_slice()) .bind(created_at) @@ -220,16 +254,22 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result Result { let mut qb: QueryBuilder = if let Some(ref p_hex) = q.p_tag_hex { let mut b = QueryBuilder::new( "SELECT COUNT(*) as cnt FROM events e \ - INNER JOIN event_mentions m ON e.id = m.event_id \ - WHERE e.deleted_at IS NULL AND m.pubkey_hex = ", + INNER JOIN event_mentions m \ + ON e.community_id = m.community_id AND e.id = m.event_id \ + WHERE e.community_id = ", ); + b.push_bind(q.community_id.as_uuid()); + b.push(" AND e.deleted_at IS NULL AND m.pubkey_hex = "); b.push_bind(p_hex.to_ascii_lowercase()); b } else { - QueryBuilder::new("SELECT COUNT(*) as cnt FROM events WHERE deleted_at IS NULL") + let mut b = QueryBuilder::new("SELECT COUNT(*) as cnt FROM events WHERE community_id = "); + b.push_bind(q.community_id.as_uuid()); + b.push(" AND deleted_at IS NULL"); + b }; let col_prefix = if q.p_tag_hex.is_some() { "e." } else { "" }; @@ -564,9 +610,15 @@ pub async fn count_events(pool: &PgPool, q: &EventQuery) -> Result { /// Returns `Ok(true)` if the event was deleted, `Ok(false)` if already deleted /// or not found. Callers are responsible for decrementing thread reply counts /// when the deleted event is a thread reply. -pub async fn soft_delete_event(pool: &PgPool, event_id: &[u8]) -> Result { - let result = - sqlx::query("UPDATE events SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL") +pub async fn soft_delete_event( + pool: &PgPool, + community_id: CommunityId, + event_id: &[u8], +) -> Result { + let result = sqlx::query( + "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) .bind(event_id) .execute(pool) .await?; @@ -587,14 +639,16 @@ pub async fn soft_delete_event(pool: &PgPool, event_id: &[u8]) -> Result { /// (already deleted, or never existed). pub async fn soft_delete_by_coordinate( pool: &PgPool, + community_id: CommunityId, kind: i32, pubkey: &[u8], d_tag: &str, ) -> Result { let result = sqlx::query( "UPDATE events SET deleted_at = NOW() \ - WHERE kind = $1 AND pubkey = $2 AND d_tag = $3 AND deleted_at IS NULL", + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(kind) .bind(pubkey) .bind(d_tag) @@ -611,17 +665,20 @@ pub async fn soft_delete_by_coordinate( /// event was deleted this call. pub async fn soft_delete_event_and_update_thread( pool: &PgPool, + community_id: CommunityId, event_id: &[u8], parent_event_id: Option<&[u8]>, root_event_id: Option<&[u8]>, ) -> Result { let mut tx = pool.begin().await?; - let result = - sqlx::query("UPDATE events SET deleted_at = NOW() WHERE id = $1 AND deleted_at IS NULL") - .bind(event_id) - .execute(&mut *tx) - .await?; + let result = sqlx::query( + "UPDATE events SET deleted_at = NOW() WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL", + ) + .bind(community_id.as_uuid()) + .bind(event_id) + .execute(&mut *tx) + .await?; let deleted = result.rows_affected() > 0; @@ -630,8 +687,9 @@ pub async fn soft_delete_event_and_update_thread( sqlx::query( "UPDATE thread_metadata \ SET reply_count = GREATEST(reply_count - 1, 0) \ - WHERE event_id = $1", + WHERE community_id = $1 AND event_id = $2", ) + .bind(community_id.as_uuid()) .bind(pid) .execute(&mut *tx) .await?; @@ -640,8 +698,9 @@ pub async fn soft_delete_event_and_update_thread( sqlx::query( "UPDATE thread_metadata \ SET descendant_count = GREATEST(descendant_count - 1, 0) \ - WHERE event_id = $1", + WHERE community_id = $1 AND event_id = $2", ) + .bind(community_id.as_uuid()) .bind(root_id) .execute(&mut *tx) .await?; @@ -656,13 +715,15 @@ pub async fn soft_delete_event_and_update_thread( /// Returns the `created_at` timestamp of the most recent non-deleted event in a channel. pub async fn get_last_message_at( pool: &PgPool, + community_id: CommunityId, channel_id: uuid::Uuid, ) -> Result>> { let row = sqlx::query( "SELECT created_at FROM events \ - WHERE channel_id = $1 AND deleted_at IS NULL \ + WHERE community_id = $1 AND channel_id = $2 AND deleted_at IS NULL \ ORDER BY created_at DESC LIMIT 1", ) + .bind(community_id.as_uuid()) .bind(channel_id) .fetch_optional(pool) .await?; @@ -679,6 +740,7 @@ pub async fn get_last_message_at( /// Single query regardless of input size. pub async fn get_last_message_at_bulk( pool: &PgPool, + community_id: CommunityId, channel_ids: &[uuid::Uuid], ) -> Result>> { if channel_ids.is_empty() { @@ -687,8 +749,10 @@ pub async fn get_last_message_at_bulk( let mut qb: QueryBuilder = QueryBuilder::new( "SELECT channel_id, MAX(created_at) as last_at FROM events \ - WHERE deleted_at IS NULL AND channel_id IN (", + WHERE community_id = ", ); + qb.push_bind(community_id.as_uuid()); + qb.push(" AND deleted_at IS NULL AND channel_id IN ("); let mut sep = qb.separated(", "); for id in channel_ids { sep.push_bind(*id); @@ -711,11 +775,16 @@ pub async fn get_last_message_at_bulk( /// Returns `None` if the event does not exist or has been soft-deleted. /// Use [`get_event_by_id_including_deleted`] when you need to inspect /// tombstoned rows (e.g. audit, undelete). -pub async fn get_event_by_id(pool: &PgPool, id_bytes: &[u8]) -> Result> { +pub async fn get_event_by_id( + pool: &PgPool, + community_id: CommunityId, + id_bytes: &[u8], +) -> Result> { let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ - FROM events WHERE id = $1 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1", + FROM events WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 1", ) + .bind(community_id.as_uuid()) .bind(id_bytes) .fetch_optional(pool) .await?; @@ -734,16 +803,18 @@ pub async fn get_event_by_id(pool: &PgPool, id_bytes: &[u8]) -> Result Result> { let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ FROM events \ - WHERE kind = $1 AND pubkey = $2 AND channel_id IS NULL AND deleted_at IS NULL \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND channel_id IS NULL AND deleted_at IS NULL \ ORDER BY created_at DESC, id ASC \ LIMIT 1", ) + .bind(community_id.as_uuid()) .bind(kind) .bind(pubkey_bytes) .fetch_optional(pool) @@ -762,12 +833,14 @@ pub async fn get_latest_global_replaceable( /// audit trails, compliance queries). pub async fn get_event_by_id_including_deleted( pool: &PgPool, + community_id: CommunityId, id_bytes: &[u8], ) -> Result> { let row = sqlx::query( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ - FROM events WHERE id = $1 ORDER BY created_at DESC LIMIT 1", + FROM events WHERE community_id = $1 AND id = $2 ORDER BY created_at DESC LIMIT 1", ) + .bind(community_id.as_uuid()) .bind(id_bytes) .fetch_optional(pool) .await?; @@ -782,7 +855,11 @@ pub async fn get_event_by_id_including_deleted( /// /// Returns events in arbitrary order — callers reorder as needed. /// Uses a single `WHERE id IN (...)` query regardless of input size. -pub async fn get_events_by_ids(pool: &PgPool, ids: &[&[u8]]) -> Result> { +pub async fn get_events_by_ids( + pool: &PgPool, + community_id: CommunityId, + ids: &[&[u8]], +) -> Result> { if ids.is_empty() { return Ok(vec![]); } @@ -790,8 +867,10 @@ pub async fn get_events_by_ids(pool: &PgPool, ids: &[&[u8]]) -> Result = QueryBuilder::new( "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ - FROM events WHERE deleted_at IS NULL AND id IN (", + FROM events WHERE community_id = ", ); + qb.push_bind(community_id.as_uuid()); + qb.push(" AND deleted_at IS NULL AND id IN ("); let mut sep = qb.separated(", "); for id in ids { sep.push_bind(id.to_vec()); @@ -842,6 +921,7 @@ pub struct ThreadMetadataParams<'a> { /// Returns `(StoredEvent, was_inserted)`. pub async fn insert_event_with_thread_metadata( pool: &PgPool, + community_id: CommunityId, event: &Event, channel_id: Option, thread_meta: Option>, @@ -871,11 +951,12 @@ pub async fn insert_event_with_thread_metadata( let result = sqlx::query( r#" - INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(id_bytes.as_slice()) .bind(pubkey_bytes.as_slice()) .bind(created_at) @@ -899,14 +980,15 @@ pub async fn insert_event_with_thread_metadata( let tm_result = sqlx::query( r#" INSERT INTO thread_metadata - (event_created_at, event_id, channel_id, + (community_id, event_created_at, event_id, channel_id, parent_event_id, parent_event_created_at, root_event_id, root_event_created_at, depth, broadcast) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(meta.event_created_at) .bind(meta.event_id) .bind(meta.channel_id) @@ -931,14 +1013,15 @@ pub async fn insert_event_with_thread_metadata( sqlx::query( r#" INSERT INTO thread_metadata - (event_created_at, event_id, channel_id, + (community_id, event_created_at, event_id, channel_id, parent_event_id, parent_event_created_at, root_event_id, root_event_created_at, depth, broadcast) - VALUES ($1, $2, $3, NULL, NULL, NULL, NULL, 0, false) + VALUES ($1, $2, $3, $4, NULL, NULL, NULL, NULL, 0, false) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(parent_ts) .bind(pid) .bind(meta.channel_id) @@ -953,14 +1036,15 @@ pub async fn insert_event_with_thread_metadata( sqlx::query( r#" INSERT INTO thread_metadata - (event_created_at, event_id, channel_id, + (community_id, event_created_at, event_id, channel_id, parent_event_id, parent_event_created_at, root_event_id, root_event_created_at, depth, broadcast) - VALUES ($1, $2, $3, NULL, NULL, NULL, NULL, 0, false) + VALUES ($1, $2, $3, $4, NULL, NULL, NULL, NULL, 0, false) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(root_ts) .bind(root_id) .bind(meta.channel_id) @@ -973,9 +1057,10 @@ pub async fn insert_event_with_thread_metadata( r#" UPDATE thread_metadata SET reply_count = reply_count + 1, last_reply_at = NOW() - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(pid) .execute(&mut *tx) .await?; @@ -985,9 +1070,10 @@ pub async fn insert_event_with_thread_metadata( r#" UPDATE thread_metadata SET descendant_count = descendant_count + 1 - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(root_id) .execute(&mut *tx) .await?; @@ -1008,6 +1094,10 @@ pub async fn insert_event_with_thread_metadata( /// A due reminder row returned by [`query_due_reminders`]. #[derive(Debug)] pub struct DueReminder { + /// Server-resolved community this reminder row belongs to. + pub community_id: CommunityId, + /// Normalized host mapped to that community. + pub host: String, /// The event's raw ID bytes. pub id: Vec, /// The event's pubkey bytes. @@ -1039,15 +1129,16 @@ pub async fn query_due_reminders( let kind_i32 = KIND_EVENT_REMINDER as i32; let rows = sqlx::query( r#" - SELECT DISTINCT ON (pubkey, d_tag) - id, pubkey, created_at, kind, tags, content, sig, channel_id - FROM events - WHERE kind = $1 - AND not_before IS NOT NULL - AND not_before <= $2 - AND deleted_at IS NULL - AND delivered_at IS NULL - ORDER BY pubkey, d_tag, created_at DESC, id ASC + SELECT DISTINCT ON (e.community_id, e.pubkey, e.d_tag) + e.community_id, c.host, e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, e.channel_id + FROM events AS e + JOIN communities AS c ON c.id = e.community_id + WHERE e.kind = $1 + AND e.not_before IS NOT NULL + AND e.not_before <= $2 + AND e.deleted_at IS NULL + AND e.delivered_at IS NULL + ORDER BY e.community_id, e.pubkey, e.d_tag, e.created_at DESC, e.id ASC LIMIT $3 "#, ) @@ -1060,6 +1151,8 @@ pub async fn query_due_reminders( let results = rows .into_iter() .map(|row| DueReminder { + community_id: CommunityId::from_uuid(row.get("community_id")), + host: row.get("host"), id: row.get("id"), pubkey: row.get("pubkey"), created_at: row.get("created_at"), @@ -1083,7 +1176,19 @@ pub async fn claim_due_reminder( event_id: &[u8], event_created_at: DateTime, ) -> Result { - let now_epoch = Utc::now().timestamp(); + claim_due_reminder_with_stamp(pool, event_id, event_created_at, Utc::now().timestamp()).await +} + +/// Atomically claim a due reminder using a caller-supplied delivery stamp. +/// +/// The same stamp should be passed to [`release_due_reminder`] if the publish +/// side effect fails, so rollback can compare-and-clear only this pod's claim. +pub async fn claim_due_reminder_with_stamp( + pool: &PgPool, + event_id: &[u8], + event_created_at: DateTime, + delivery_stamp: i64, +) -> Result { let result = sqlx::query( r#" UPDATE events @@ -1091,7 +1196,7 @@ pub async fn claim_due_reminder( WHERE created_at = $2 AND id = $3 AND delivered_at IS NULL "#, ) - .bind(now_epoch) + .bind(delivery_stamp) .bind(event_created_at) .bind(event_id) .execute(pool) @@ -1100,11 +1205,110 @@ pub async fn claim_due_reminder( Ok(result.rows_affected() > 0) } +/// Release a previously claimed reminder when publish fails. +/// +/// The `delivery_stamp` must be the exact value written by the claiming pod; +/// that compare-and-clear prevents one pod from rolling back another pod's +/// later claim after a retry/race. +pub async fn release_due_reminder( + pool: &PgPool, + event_id: &[u8], + event_created_at: DateTime, + delivery_stamp: i64, +) -> Result { + let result = sqlx::query( + r#" + UPDATE events + SET delivered_at = NULL + WHERE created_at = $1 + AND id = $2 + AND delivered_at = $3 + "#, + ) + .bind(event_created_at) + .bind(event_id) + .bind(delivery_stamp) + .execute(pool) + .await?; + + Ok(result.rows_affected() == 1) +} + #[cfg(test)] mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_pool() -> PgPool { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .or_else(|_| std::env::var("DATABASE_URL")) + .unwrap_or_else(|_| TEST_DB_URL.to_owned()); + + PgPool::connect(&database_url) + .await + .expect("connect to test DB") + } + + async fn make_test_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("event-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 + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn get_event_by_id_is_scoped_when_event_id_collides_across_communities() { + let pool = setup_pool().await; + let community_a = CommunityId::from_uuid(make_test_community(&pool).await); + let community_b = CommunityId::from_uuid(make_test_community(&pool).await); + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::Custom(9), "same signed event") + .sign_with_keys(&keys) + .expect("sign event"); + + insert_event(&pool, community_a, &event, None) + .await + .expect("insert in community A"); + insert_event(&pool, community_b, &event, None) + .await + .expect("insert same event in community B"); + + sqlx::query("UPDATE events SET content = $1 WHERE community_id = $2 AND id = $3") + .bind("community-a-copy") + .bind(community_a.as_uuid()) + .bind(event.id.as_bytes()) + .execute(&pool) + .await + .expect("mark community A row"); + sqlx::query("UPDATE events SET content = $1 WHERE community_id = $2 AND id = $3") + .bind("community-b-copy") + .bind(community_b.as_uuid()) + .bind(event.id.as_bytes()) + .execute(&pool) + .await + .expect("mark community B row"); + + let a = get_event_by_id(&pool, community_a, event.id.as_bytes()) + .await + .expect("lookup community A") + .expect("community A row exists"); + let b = get_event_by_id(&pool, community_b, event.id.as_bytes()) + .await + .expect("lookup community B") + .expect("community B row exists"); + + assert_eq!(a.event.content, "community-a-copy"); + assert_eq!(b.event.content, "community-b-copy"); + } + fn make_event_with_kind_and_tags(kind: u16, tags: Vec) -> nostr::Event { let keys = Keys::generate(); EventBuilder::new(Kind::Custom(kind), "test") @@ -1240,4 +1444,60 @@ mod tests { ); assert_eq!(extract_not_before(&event), None); } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn query_due_reminders_returns_row_community_and_host_per_tenant() { + let pool = setup_pool().await; + let community_a_uuid = make_test_community(&pool).await; + let community_b_uuid = make_test_community(&pool).await; + let community_a = CommunityId::from_uuid(community_a_uuid); + let community_b = CommunityId::from_uuid(community_b_uuid); + let host_a: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1") + .bind(community_a_uuid) + .fetch_one(&pool) + .await + .expect("load host A"); + let host_b: String = sqlx::query_scalar("SELECT host FROM communities WHERE id = $1") + .bind(community_b_uuid) + .fetch_one(&pool) + .await + .expect("load host B"); + + let not_before = Utc::now().timestamp() - 1; + let keys_a = Keys::generate(); + let keys_b = Keys::generate(); + let event_a = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "a") + .tags([ + Tag::parse(["d", "due-reminder-scope-a"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys_a) + .expect("sign A"); + let event_b = EventBuilder::new(Kind::Custom(KIND_EVENT_REMINDER as u16), "b") + .tags([ + Tag::parse(["d", "due-reminder-scope-b"]).unwrap(), + Tag::parse(["not_before", ¬_before.to_string()]).unwrap(), + ]) + .sign_with_keys(&keys_b) + .expect("sign B"); + + insert_event(&pool, community_a, &event_a, None) + .await + .expect("insert A"); + insert_event(&pool, community_b, &event_b, None) + .await + .expect("insert B"); + + let due = query_due_reminders(&pool, Utc::now().timestamp(), 100) + .await + .expect("query due reminders"); + + assert!(due.iter().any(|row| { + row.id == event_a.id.as_bytes() && row.community_id == community_a && row.host == host_a + })); + assert!(due.iter().any(|row| { + row.id == event_b.id.as_bytes() && row.community_id == community_b && row.host == host_b + })); + } } diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9dd4d3e10..fc9c5477e 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -47,7 +47,7 @@ use sqlx::{PgPool, QueryBuilder, Row}; use std::time::Duration; use uuid::Uuid; -use buzz_core::StoredEvent; +use buzz_core::{CommunityId, StoredEvent}; /// Extract p-tag mentions from an event and insert into the `event_mentions` table. /// @@ -55,6 +55,7 @@ use buzz_core::StoredEvent; /// Uses `INSERT ... ON CONFLICT DO NOTHING` so duplicate inserts are silently skipped. pub async fn insert_mentions( pool: &PgPool, + community_id: CommunityId, event: &nostr::Event, channel_id: Option, ) -> Result<()> { @@ -106,11 +107,12 @@ pub async fn insert_mentions( // Single multi-row INSERT ... ON CONFLICT DO NOTHING — one round-trip regardless of mention count. let mut qb: QueryBuilder = QueryBuilder::new( "INSERT INTO event_mentions \ - (pubkey_hex, event_id, event_created_at, channel_id, event_kind) ", + (community_id, pubkey_hex, event_id, event_created_at, channel_id, event_kind) ", ); qb.push_values(&valid_pubkeys, |mut b, pubkey| { - b.push_bind(pubkey.as_str()) + b.push_bind(community_id.as_uuid()) + .push_bind(pubkey.as_str()) .push_bind(event_id_bytes.as_slice()) .push_bind(created_at) .push_bind(channel_id) @@ -162,6 +164,15 @@ impl Default for DbConfig { } } +/// Community host-map row returned by [`Db::lookup_community_by_host`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommunityRecord { + /// Stable server-resolved community id. + pub id: CommunityId, + /// Normalized host that maps to this community. + pub host: String, +} + /// Token summary returned by [`Db::list_active_tokens`]. #[derive(Debug, Clone)] pub struct TokenSummary { @@ -216,15 +227,147 @@ impl Db { self.pool.begin().await.map_err(Into::into) } + /// Returns the community mapped to a normalized request host, if one exists. + /// + /// The caller owns host normalization and turns `None` into the fail-closed + /// request/connection error. buzz-db only reads the durable host map. + pub async fn lookup_community_by_host( + &self, + normalized_host: &str, + ) -> Result> { + let row = sqlx::query( + r#" + SELECT id, host + FROM communities + WHERE host = $1 + "#, + ) + .bind(normalized_host) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + + Ok(CommunityRecord { + id: CommunityId::from_uuid(id), + host, + }) + }) + .transpose() + } + + /// Ensure a configured community host exists and return its row. + /// + /// This is the startup/config seeding path for N=1 deployments. Migrations + /// create the schema only; deployment-specific hosts are not hardcoded into + /// schema history. + pub async fn ensure_configured_community( + &self, + normalized_host: &str, + ) -> Result { + let row = sqlx::query( + r#" + INSERT INTO communities (host) + VALUES ($1) + ON CONFLICT (host) DO UPDATE SET host = EXCLUDED.host + RETURNING id, host + "#, + ) + .bind(normalized_host) + .fetch_one(&self.pool) + .await?; + + let id: Uuid = row.try_get("id")?; + let host: String = row.try_get("host")?; + + Ok(CommunityRecord { + id: CommunityId::from_uuid(id), + host, + }) + } + + /// Returns the community that owns a channel, if the channel exists. + /// + /// Internal relay producers use this to derive tenant context from the row + /// they are acting on, rather than falling back to an implicit default. + pub async fn community_of_channel(&self, channel_id: Uuid) -> Result> { + let row = sqlx::query( + r#" + SELECT community_id + FROM channels + WHERE id = $1 + AND deleted_at IS NULL + "#, + ) + .bind(channel_id) + .fetch_optional(&self.pool) + .await?; + + row.map(|row| { + let id: Uuid = row.try_get("community_id")?; + Ok(CommunityId::from_uuid(id)) + }) + .transpose() + } + + /// Batched version of [`Self::community_of_channel`]: given a list of + /// channel UUIDs, returns a map from channel id → owning community + /// for every channel that exists (soft-deletes excluded). + /// + /// Used by the runtime conformance read-seam emitters in `buzz-relay`: + /// after a `query_events`/`get_events_by_ids` returns N rows, the + /// emitter collects distinct `channel_id`s, calls this once, then + /// projects each row's true community label independently of the + /// fetch query's WHERE clause. That independence is what makes the + /// `Inv_NonInterference` / `Inv_ReadConfinement` gate non-vacuous — + /// a mutation that dropped `community_id = $X` from the fetch query + /// would still let this helper return the row's true label, and the + /// checker would see the mismatch. + /// + /// Channels missing from the result map (deleted or never existed) + /// are intentionally not present rather than mapped to a default — + /// callers MUST treat "channel-id not in map" as a coverage breach, + /// never as "use the resolved community". + pub async fn communities_of_channels( + &self, + channel_ids: &[Uuid], + ) -> Result> { + if channel_ids.is_empty() { + return Ok(std::collections::HashMap::new()); + } + let rows = sqlx::query( + r#" + SELECT id, community_id + FROM channels + WHERE id = ANY($1) + AND deleted_at IS NULL + "#, + ) + .bind(channel_ids) + .fetch_all(&self.pool) + .await?; + + let mut out = std::collections::HashMap::with_capacity(rows.len()); + for row in rows { + let ch: Uuid = row.try_get("id")?; + let cm: Uuid = row.try_get("community_id")?; + out.insert(ch, CommunityId::from_uuid(cm)); + } + Ok(out) + } + /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. pub async fn insert_event( &self, + community_id: CommunityId, event: &nostr::Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { - let result = event::insert_event(&self.pool, event, channel_id).await?; + let result = event::insert_event(&self.pool, community_id, event, channel_id).await?; if result.1 { - if let Err(e) = insert_mentions(&self.pool, event, channel_id).await { + if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } } @@ -248,52 +391,65 @@ impl Db { /// historical duplicate survivors correctly. pub async fn get_latest_global_replaceable( &self, + community_id: CommunityId, kind: i32, pubkey_bytes: &[u8], ) -> Result> { - event::get_latest_global_replaceable(&self.pool, kind, pubkey_bytes).await + event::get_latest_global_replaceable(&self.pool, community_id, kind, pubkey_bytes).await } /// Fetches a single non-deleted event by its raw ID bytes. /// /// Returns `None` if the event does not exist or has been soft-deleted. - pub async fn get_event_by_id(&self, id_bytes: &[u8]) -> Result> { - event::get_event_by_id(&self.pool, id_bytes).await + pub async fn get_event_by_id( + &self, + community_id: CommunityId, + id_bytes: &[u8], + ) -> Result> { + event::get_event_by_id(&self.pool, community_id, id_bytes).await } /// Fetches a single event by its raw ID bytes, **including soft-deleted rows**. pub async fn get_event_by_id_including_deleted( &self, + community_id: CommunityId, id_bytes: &[u8], ) -> Result> { - event::get_event_by_id_including_deleted(&self.pool, id_bytes).await + event::get_event_by_id_including_deleted(&self.pool, community_id, id_bytes).await } /// Soft-deletes an event. Returns `Ok(true)` if deleted, `Ok(false)` if already deleted. - pub async fn soft_delete_event(&self, event_id: &[u8]) -> Result { - event::soft_delete_event(&self.pool, event_id).await + pub async fn soft_delete_event( + &self, + community_id: CommunityId, + event_id: &[u8], + ) -> Result { + event::soft_delete_event(&self.pool, community_id, event_id).await } /// Soft-delete the live row for an addressable coordinate `(kind, pubkey, d_tag)`. /// Used by NIP-09 a-tag deletion for parameterized-replaceable kinds. pub async fn soft_delete_by_coordinate( &self, + community_id: CommunityId, kind: i32, pubkey: &[u8], d_tag: &str, ) -> Result { - event::soft_delete_by_coordinate(&self.pool, kind, pubkey, d_tag).await + event::soft_delete_by_coordinate(&self.pool, community_id, kind, pubkey, d_tag).await } /// Atomically soft-delete an event and decrement thread reply counters. pub async fn soft_delete_event_and_update_thread( &self, + community_id: CommunityId, event_id: &[u8], parent_event_id: Option<&[u8]>, root_event_id: Option<&[u8]>, ) -> Result { event::soft_delete_event_and_update_thread( &self.pool, + community_id, event_id, parent_event_id, root_event_id, @@ -302,35 +458,50 @@ impl Db { } /// Returns the most recent `created_at` for a channel. - pub async fn get_last_message_at(&self, channel_id: Uuid) -> Result>> { - event::get_last_message_at(&self.pool, channel_id).await + pub async fn get_last_message_at( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result>> { + event::get_last_message_at(&self.pool, community_id, channel_id).await } /// Bulk-fetch the most recent `created_at` for a set of channel IDs. pub async fn get_last_message_at_bulk( &self, + community_id: CommunityId, channel_ids: &[Uuid], ) -> Result>> { - event::get_last_message_at_bulk(&self.pool, channel_ids).await + event::get_last_message_at_bulk(&self.pool, community_id, channel_ids).await } /// Batch-fetch non-deleted events by their raw IDs. - pub async fn get_events_by_ids(&self, ids: &[&[u8]]) -> Result> { - event::get_events_by_ids(&self.pool, ids).await + pub async fn get_events_by_ids( + &self, + community_id: CommunityId, + ids: &[&[u8]], + ) -> Result> { + event::get_events_by_ids(&self.pool, community_id, ids).await } /// Atomically insert an event AND its thread metadata in a single transaction. pub async fn insert_event_with_thread_metadata( &self, + community_id: CommunityId, event: &nostr::Event, channel_id: Option, thread_meta: Option>, ) -> Result<(StoredEvent, bool)> { - let result = - event::insert_event_with_thread_metadata(&self.pool, event, channel_id, thread_meta) - .await?; + let result = event::insert_event_with_thread_metadata( + &self.pool, + community_id, + event, + channel_id, + thread_meta, + ) + .await?; if result.1 { - if let Err(e) = insert_mentions(&self.pool, event, channel_id).await { + if let Err(e) = insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } } @@ -338,8 +509,10 @@ impl Db { } /// Creates a new channel, bootstraps the creator as owner, and returns the record. + #[allow(clippy::too_many_arguments)] pub async fn create_channel( &self, + community_id: CommunityId, name: &str, channel_type: channel::ChannelType, visibility: channel::ChannelVisibility, @@ -349,6 +522,7 @@ impl Db { ) -> Result { channel::create_channel( &self.pool, + community_id, name, channel_type, visibility, @@ -365,6 +539,7 @@ impl Db { #[allow(clippy::too_many_arguments)] pub async fn create_channel_with_id( &self, + community_id: CommunityId, channel_id: Uuid, name: &str, channel_type: channel::ChannelType, @@ -375,6 +550,7 @@ impl Db { ) -> Result<(channel::ChannelRecord, bool)> { channel::create_channel_with_id( &self.pool, + community_id, channel_id, name, channel_type, @@ -387,85 +563,134 @@ impl Db { } /// Fetches a channel record by ID. - pub async fn get_channel(&self, channel_id: Uuid) -> Result { - channel::get_channel(&self.pool, channel_id).await + pub async fn get_channel( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result { + channel::get_channel(&self.pool, community_id, channel_id).await } /// Returns the canvas content for a channel, if any. - pub async fn get_canvas(&self, channel_id: Uuid) -> Result> { - channel::get_canvas(&self.pool, channel_id).await + pub async fn get_canvas( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result> { + channel::get_canvas(&self.pool, community_id, channel_id).await } /// Sets or clears the canvas content for a channel. - pub async fn set_canvas(&self, channel_id: Uuid, canvas: Option<&str>) -> Result<()> { - channel::set_canvas(&self.pool, channel_id, canvas).await + pub async fn set_canvas( + &self, + community_id: CommunityId, + channel_id: Uuid, + canvas: Option<&str>, + ) -> Result<()> { + channel::set_canvas(&self.pool, community_id, channel_id, canvas).await } /// Adds a member to a channel. pub async fn add_member( &self, + community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], role: channel::MemberRole, invited_by: Option<&[u8]>, ) -> Result { - channel::add_member(&self.pool, channel_id, pubkey, role, invited_by).await + channel::add_member( + &self.pool, + community_id, + channel_id, + pubkey, + role, + invited_by, + ) + .await } /// Removes a member from a channel. pub async fn remove_member( &self, + community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], actor_pubkey: &[u8], ) -> Result<()> { - channel::remove_member(&self.pool, channel_id, pubkey, actor_pubkey).await + channel::remove_member(&self.pool, community_id, channel_id, pubkey, actor_pubkey).await } /// Returns `true` if the pubkey is an active member. - pub async fn is_member(&self, channel_id: Uuid, pubkey: &[u8]) -> Result { - channel::is_member(&self.pool, channel_id, pubkey).await + pub async fn is_member( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result { + channel::is_member(&self.pool, community_id, channel_id, pubkey).await } /// Returns all active members of a channel. - pub async fn get_members(&self, channel_id: Uuid) -> Result> { - channel::get_members(&self.pool, channel_id).await + pub async fn get_members( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result> { + channel::get_members(&self.pool, community_id, channel_id).await } /// Returns active members for multiple channels in a single query. pub async fn get_members_bulk( &self, + community_id: CommunityId, channel_ids: &[Uuid], ) -> Result> { - channel::get_members_bulk(&self.pool, channel_ids).await + channel::get_members_bulk(&self.pool, community_id, channel_ids).await } /// Get all channel IDs accessible to a pubkey. - pub async fn get_accessible_channel_ids(&self, pubkey: &[u8]) -> Result> { - channel::get_accessible_channel_ids(&self.pool, pubkey).await + pub async fn get_accessible_channel_ids( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + channel::get_accessible_channel_ids(&self.pool, community_id, pubkey).await } /// Lists channels, optionally filtered by visibility. pub async fn list_channels( &self, + community_id: CommunityId, visibility: Option<&str>, ) -> Result> { - channel::list_channels(&self.pool, visibility).await + channel::list_channels(&self.pool, community_id, visibility).await } /// Returns full channel records for all channels a user can access. pub async fn get_accessible_channels( &self, + community_id: CommunityId, pubkey: &[u8], visibility_filter: Option<&str>, member_only: Option, ) -> Result> { - channel::get_accessible_channels(&self.pool, pubkey, visibility_filter, member_only).await + channel::get_accessible_channels( + &self.pool, + community_id, + pubkey, + visibility_filter, + member_only, + ) + .await } - /// Returns all bot-role members with their aggregated channel names. - pub async fn get_bot_members(&self) -> Result> { - channel::get_bot_members(&self.pool).await + /// Returns all bot-role members with their aggregated channel names in one community. + pub async fn get_bot_members( + &self, + community_id: CommunityId, + ) -> Result> { + channel::get_bot_members(&self.pool, community_id).await } /// Bulk-fetch user records by pubkey. @@ -476,62 +701,99 @@ impl Db { /// Updates a channel's name and/or description. pub async fn update_channel( &self, + community_id: CommunityId, channel_id: Uuid, updates: channel::ChannelUpdate, ) -> Result { - channel::update_channel(&self.pool, channel_id, updates).await + channel::update_channel(&self.pool, community_id, channel_id, updates).await } /// Sets the topic for a channel. - pub async fn set_topic(&self, channel_id: Uuid, topic: &str, set_by: &[u8]) -> Result<()> { - channel::set_topic(&self.pool, channel_id, topic, set_by).await + pub async fn set_topic( + &self, + community_id: CommunityId, + channel_id: Uuid, + topic: &str, + set_by: &[u8], + ) -> Result<()> { + channel::set_topic(&self.pool, community_id, channel_id, topic, set_by).await } /// Sets the purpose for a channel. - pub async fn set_purpose(&self, channel_id: Uuid, purpose: &str, set_by: &[u8]) -> Result<()> { - channel::set_purpose(&self.pool, channel_id, purpose, set_by).await + pub async fn set_purpose( + &self, + community_id: CommunityId, + channel_id: Uuid, + purpose: &str, + set_by: &[u8], + ) -> Result<()> { + channel::set_purpose(&self.pool, community_id, channel_id, purpose, set_by).await } /// Archives a channel. - pub async fn archive_channel(&self, channel_id: Uuid) -> Result<()> { - channel::archive_channel(&self.pool, channel_id).await + pub async fn archive_channel(&self, community_id: CommunityId, channel_id: Uuid) -> Result<()> { + channel::archive_channel(&self.pool, community_id, channel_id).await } /// Unarchives a channel. - pub async fn unarchive_channel(&self, channel_id: Uuid) -> Result<()> { - channel::unarchive_channel(&self.pool, channel_id).await + pub async fn unarchive_channel( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result<()> { + channel::unarchive_channel(&self.pool, community_id, channel_id).await } /// Soft-delete a channel. - pub async fn soft_delete_channel(&self, channel_id: Uuid) -> Result { - channel::soft_delete_channel(&self.pool, channel_id).await + pub async fn soft_delete_channel( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result { + channel::soft_delete_channel(&self.pool, community_id, channel_id).await } /// Returns the count of active members in a channel. - pub async fn get_member_count(&self, channel_id: Uuid) -> Result { - channel::get_member_count(&self.pool, channel_id).await + pub async fn get_member_count( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result { + channel::get_member_count(&self.pool, community_id, channel_id).await } /// Bulk-fetch member counts for a set of channel IDs. pub async fn get_member_counts_bulk( &self, + community_id: CommunityId, channel_ids: &[Uuid], ) -> Result> { - channel::get_member_counts_bulk(&self.pool, channel_ids).await + channel::get_member_counts_bulk(&self.pool, community_id, channel_ids).await } /// Get the active role of a pubkey in a channel. - pub async fn get_member_role(&self, channel_id: Uuid, pubkey: &[u8]) -> Result> { - channel::get_member_role(&self.pool, channel_id, pubkey).await + pub async fn get_member_role( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result> { + channel::get_member_role(&self.pool, community_id, channel_id, pubkey).await } /// Bump the TTL deadline for an ephemeral channel after a new message. - pub async fn bump_ttl_deadline(&self, channel_id: Uuid) -> Result<()> { - channel::bump_ttl_deadline(&self.pool, channel_id).await + pub async fn bump_ttl_deadline( + &self, + community_id: CommunityId, + channel_id: Uuid, + ) -> Result<()> { + channel::bump_ttl_deadline(&self.pool, community_id, channel_id).await } /// Archive ephemeral channels whose TTL deadline has passed. - pub async fn reap_expired_ephemeral_channels(&self) -> Result> { + pub async fn reap_expired_ephemeral_channels( + &self, + ) -> Result> { channel::reap_expired_ephemeral_channels(&self.pool).await } @@ -553,19 +815,45 @@ impl Db { event::claim_due_reminder(&self.pool, event_id, event_created_at).await } + /// Atomically claim a due reminder using a caller-supplied delivery stamp. + pub async fn claim_due_reminder_with_stamp( + &self, + event_id: &[u8], + event_created_at: chrono::DateTime, + delivery_stamp: i64, + ) -> Result { + event::claim_due_reminder_with_stamp(&self.pool, event_id, event_created_at, delivery_stamp) + .await + } + + /// Release a claimed due reminder after a publish failure. + pub async fn release_due_reminder( + &self, + event_id: &[u8], + event_created_at: chrono::DateTime, + delivery_stamp: i64, + ) -> Result { + event::release_due_reminder(&self.pool, event_id, event_created_at, delivery_stamp).await + } + /// Ensure a user record exists (upsert). - pub async fn ensure_user(&self, pubkey: &[u8]) -> Result<()> { - user::ensure_user(&self.pool, pubkey).await + pub async fn ensure_user(&self, community_id: CommunityId, pubkey: &[u8]) -> Result<()> { + user::ensure_user(&self.pool, community_id, pubkey).await } /// Get a single user record by pubkey. - pub async fn get_user(&self, pubkey: &[u8]) -> Result> { - user::get_user(&self.pool, pubkey).await + pub async fn get_user( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + user::get_user(&self.pool, community_id, pubkey).await } /// Update a user's profile fields. pub async fn update_user_profile( &self, + community_id: CommunityId, pubkey: &[u8], display_name: Option<&str>, avatar_url: Option<&str>, @@ -574,6 +862,7 @@ impl Db { ) -> Result<()> { user::update_user_profile( &self.pool, + community_id, pubkey, display_name, avatar_url, @@ -586,103 +875,140 @@ impl Db { /// Look up a user by NIP-05 handle. pub async fn get_user_by_nip05( &self, + community_id: CommunityId, local_part: &str, domain: &str, ) -> Result> { - user::get_user_by_nip05(&self.pool, local_part, domain).await + user::get_user_by_nip05(&self.pool, community_id, local_part, domain).await } /// Search users by display name, NIP-05 handle, or pubkey prefix. pub async fn search_users( &self, + community_id: CommunityId, query: &str, limit: u32, ) -> Result> { - user::search_users(&self.pool, query, limit).await + user::search_users(&self.pool, community_id, query, limit).await } /// Atomically set agent owner — only if no owner is currently assigned. /// Returns Ok(true) if set, Ok(false) if an owner already exists. - pub async fn set_agent_owner(&self, agent_pubkey: &[u8], owner_pubkey: &[u8]) -> Result { - user::set_agent_owner(&self.pool, agent_pubkey, owner_pubkey).await + pub async fn set_agent_owner( + &self, + community_id: CommunityId, + agent_pubkey: &[u8], + owner_pubkey: &[u8], + ) -> Result { + user::set_agent_owner(&self.pool, community_id, agent_pubkey, owner_pubkey).await } /// Get the channel_add_policy and agent_owner_pubkey for a user. pub async fn get_agent_channel_policy( &self, + community_id: CommunityId, pubkey: &[u8], ) -> Result>)>> { - user::get_agent_channel_policy(&self.pool, pubkey).await + user::get_agent_channel_policy(&self.pool, community_id, pubkey).await } /// Check whether `actor_pubkey` is the agent owner of `target_pubkey`. - pub async fn is_agent_owner(&self, target_pubkey: &[u8], actor_pubkey: &[u8]) -> Result { - user::is_agent_owner(&self.pool, target_pubkey, actor_pubkey).await + pub async fn is_agent_owner( + &self, + community_id: CommunityId, + target_pubkey: &[u8], + actor_pubkey: &[u8], + ) -> Result { + user::is_agent_owner(&self.pool, community_id, target_pubkey, actor_pubkey).await } /// Set the channel_add_policy for a user. - pub async fn set_channel_add_policy(&self, pubkey: &[u8], policy: &str) -> Result<()> { - user::set_channel_add_policy(&self.pool, pubkey, policy).await + pub async fn set_channel_add_policy( + &self, + community_id: CommunityId, + pubkey: &[u8], + policy: &str, + ) -> Result<()> { + user::set_channel_add_policy(&self.pool, community_id, pubkey, policy).await } /// Find an existing DM by its participant hash. pub async fn find_dm_by_participants( &self, + community_id: CommunityId, participant_hash: &[u8], ) -> Result> { - dm::find_dm_by_participants(&self.pool, participant_hash).await + dm::find_dm_by_participants(&self.pool, community_id, participant_hash).await } /// Create or return an existing DM channel. pub async fn create_dm( &self, + community_id: CommunityId, participants: &[&[u8]], created_by: &[u8], ) -> Result { - dm::create_dm(&self.pool, participants, created_by).await + dm::create_dm(&self.pool, community_id, participants, created_by).await } /// List all DMs for a user. pub async fn list_dms_for_user( &self, + community_id: CommunityId, pubkey: &[u8], limit: u32, cursor: Option, ) -> Result> { - dm::list_dms_for_user(&self.pool, pubkey, limit, cursor).await + dm::list_dms_for_user(&self.pool, community_id, pubkey, limit, cursor).await } /// Open or retrieve a DM for the given participants. pub async fn open_dm( &self, + community_id: CommunityId, pubkeys: &[&[u8]], created_by: &[u8], ) -> Result<(channel::ChannelRecord, bool)> { - dm::open_dm(&self.pool, pubkeys, created_by).await + dm::open_dm(&self.pool, community_id, pubkeys, created_by).await } /// Hide a DM channel for a specific user. /// /// The DM is not deleted — it can be restored by opening a new DM with /// the same participants. - pub async fn hide_dm(&self, channel_id: Uuid, pubkey: &[u8]) -> Result<()> { - dm::hide_dm(&self.pool, channel_id, pubkey).await + pub async fn hide_dm( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result<()> { + dm::hide_dm(&self.pool, community_id, channel_id, pubkey).await } /// Unhide a DM channel for a specific user. - pub async fn unhide_dm(&self, channel_id: Uuid, pubkey: &[u8]) -> Result<()> { - dm::unhide_dm(&self.pool, channel_id, pubkey).await + pub async fn unhide_dm( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + ) -> Result<()> { + dm::unhide_dm(&self.pool, community_id, channel_id, pubkey).await } /// List the channel IDs of all DMs the given user currently has hidden. - pub async fn list_hidden_dms(&self, pubkey: &[u8]) -> Result> { - dm::list_hidden_dms(&self.pool, pubkey).await + pub async fn list_hidden_dms( + &self, + community_id: CommunityId, + pubkey: &[u8], + ) -> Result> { + dm::list_hidden_dms(&self.pool, community_id, pubkey).await } /// Insert thread metadata. #[allow(clippy::too_many_arguments)] pub async fn insert_thread_metadata( &self, + community_id: CommunityId, event_id: &[u8], event_created_at: DateTime, channel_id: Uuid, @@ -695,6 +1021,7 @@ impl Db { ) -> Result<()> { thread::insert_thread_metadata( &self.pool, + community_id, event_id, event_created_at, channel_id, @@ -711,25 +1038,36 @@ impl Db { /// Fetch replies under a root event. pub async fn get_thread_replies( &self, + community_id: CommunityId, root_event_id: &[u8], depth_limit: Option, limit: u32, cursor: Option<&[u8]>, ) -> Result> { - thread::get_thread_replies(&self.pool, root_event_id, depth_limit, limit, cursor).await + thread::get_thread_replies( + &self.pool, + community_id, + root_event_id, + depth_limit, + limit, + cursor, + ) + .await } /// Fetch aggregated thread stats. pub async fn get_thread_summary( &self, + community_id: CommunityId, event_id: &[u8], ) -> Result> { - thread::get_thread_summary(&self.pool, event_id).await + thread::get_thread_summary(&self.pool, community_id, event_id).await } /// Top-level messages for a channel. pub async fn get_channel_messages_top_level( &self, + community_id: CommunityId, channel_id: Uuid, limit: u32, before_cursor: Option>, @@ -738,6 +1076,7 @@ impl Db { ) -> Result> { thread::get_channel_messages_top_level( &self.pool, + community_id, channel_id, limit, before_cursor, @@ -750,18 +1089,21 @@ impl Db { /// Look up a single thread_metadata row by event_id. pub async fn get_thread_metadata_by_event( &self, + community_id: CommunityId, event_id: &[u8], ) -> Result> { - thread::get_thread_metadata_by_event(&self.pool, event_id).await + thread::get_thread_metadata_by_event(&self.pool, community_id, event_id).await } /// Decrement reply counts. pub async fn decrement_reply_count( &self, + community_id: CommunityId, parent_event_id: &[u8], root_event_id: Option<&[u8]>, ) -> Result<()> { - thread::decrement_reply_count(&self.pool, parent_event_id, root_event_id).await + thread::decrement_reply_count(&self.pool, community_id, parent_event_id, root_event_id) + .await } /// Add (or re-activate) a reaction. @@ -1088,6 +1430,7 @@ impl Db { /// Create a new workflow. pub async fn create_workflow( &self, + community_id: CommunityId, channel_id: Option, owner_pubkey: &[u8], name: &str, @@ -1096,6 +1439,7 @@ impl Db { ) -> Result { workflow::create_workflow( &self.pool, + community_id, channel_id, owner_pubkey, name, @@ -1133,6 +1477,51 @@ impl Db { workflow::list_all_enabled_workflows(&self.pool).await } + /// Claim a scheduled workflow fire for an authoritative schedule instant. + /// + /// Returns `Some` only for the first pod to claim `(workflow_id, + /// scheduled_for)`; all other pods must skip creating a run. The claim SQL + /// resolves `community_id` from the workflow row; callers never supply it. + pub async fn claim_scheduled_workflow_fire( + &self, + workflow_id: Uuid, + scheduled_for: chrono::DateTime, + ) -> Result> { + workflow::claim_scheduled_workflow_fire(&self.pool, workflow_id, scheduled_for).await + } + + /// Fetch the latest claimed schedule instant for interval trigger anchoring. + pub async fn latest_scheduled_workflow_fire( + &self, + workflow_id: Uuid, + ) -> Result>> { + workflow::latest_scheduled_workflow_fire(&self.pool, workflow_id).await + } + + /// Attach the workflow run id created from a won scheduled-fire claim. + pub async fn attach_scheduled_workflow_run( + &self, + workflow_id: Uuid, + scheduled_for: chrono::DateTime, + workflow_run_id: Uuid, + ) -> Result { + workflow::attach_scheduled_workflow_run( + &self.pool, + workflow_id, + scheduled_for, + workflow_run_id, + ) + .await + } + + /// Delete old scheduled workflow fire claims before a retention cutoff. + pub async fn prune_scheduled_workflow_fires_before( + &self, + older_than: chrono::DateTime, + ) -> Result { + workflow::prune_scheduled_workflow_fires_before(&self.pool, older_than).await + } + /// Update a workflow's name, definition, and hash. pub async fn update_workflow( &self, @@ -1424,14 +1813,16 @@ impl Db { relay_members::backfill_from_allowlist(&self.pool).await } - /// Returns `true` if `pubkey` (64-char hex) is currently archived. - pub async fn is_archived(&self, pubkey: &str) -> Result { - archived_identities::is_archived(&self.pool, pubkey).await + /// Returns `true` if `pubkey` (64-char hex) is archived in `community_id`. + pub async fn is_archived(&self, community_id: CommunityId, pubkey: &str) -> Result { + archived_identities::is_archived(&self.pool, community_id, pubkey).await } - /// Archives an identity. Returns `true` if inserted, `false` if already archived. + /// Archives an identity in `community_id`. Returns `true` if inserted, `false` if already archived. + #[allow(clippy::too_many_arguments)] pub async fn archive( &self, + community_id: CommunityId, pubkey: &str, consent_path: &str, actor: &str, @@ -1441,6 +1832,7 @@ impl Db { ) -> Result { archived_identities::archive( &self.pool, + community_id, pubkey, consent_path, actor, @@ -1451,26 +1843,31 @@ impl Db { .await } - /// Unarchives an identity. Returns `true` if deleted, `false` if absent. - pub async fn unarchive(&self, pubkey: &str) -> Result { - archived_identities::unarchive(&self.pool, pubkey).await + /// Unarchives an identity from `community_id`. Returns `true` if deleted, `false` if absent. + pub async fn unarchive(&self, community_id: CommunityId, pubkey: &str) -> Result { + archived_identities::unarchive(&self.pool, community_id, pubkey).await } - /// Returns all archived identities ordered by archive time ascending. - pub async fn list_archived(&self) -> Result> { - archived_identities::list_archived(&self.pool).await + /// Returns all identities archived in `community_id`, ordered by archive time ascending. + pub async fn list_archived( + &self, + community_id: CommunityId, + ) -> Result> { + archived_identities::list_archived(&self.pool, community_id).await } /// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey. pub async fn soft_delete_discovery_events( &self, + community_id: CommunityId, channel_id: Uuid, relay_pubkey: &[u8], ) -> Result { let result = sqlx::query( "UPDATE events SET deleted_at = NOW() \ - WHERE channel_id = $1 AND pubkey = $2 AND deleted_at IS NULL AND kind IN (39000, 39001, 39002)", + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 AND deleted_at IS NULL AND kind IN (39000, 39001, 39002)", ) + .bind(community_id.as_uuid()) .bind(channel_id) .bind(relay_pubkey) .execute(&self.pool) @@ -1487,6 +1884,7 @@ impl Db { /// skip fan-out/dispatch when `was_inserted` is false. pub async fn replace_addressable_event( &self, + community_id: CommunityId, event: &nostr::Event, channel_id: Option, ) -> Result<(StoredEvent, bool)> { @@ -1501,6 +1899,10 @@ impl Db { // Collisions cause extra serialization, not incorrect behavior. let lock_key = { let mut h: u64 = 0xcbf29ce484222325; // FNV offset basis + for b in community_id.as_uuid().as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } for b in kind_i32.to_le_bytes() { h ^= b as u64; h = h.wrapping_mul(0x100000001b3); // FNV prime @@ -1531,11 +1933,12 @@ impl Db { // historical data where prior bugs may have left multiple live rows. let existing: Option<(chrono::DateTime, Vec)> = sqlx::query_as( "SELECT created_at, id FROM events \ - WHERE kind = $1 AND pubkey = $2 \ - AND channel_id IS NOT DISTINCT FROM $3 \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND channel_id IS NOT DISTINCT FROM $4 \ AND deleted_at IS NULL \ ORDER BY created_at DESC, id ASC LIMIT 1", ) + .bind(community_id.as_uuid()) .bind(kind_i32) .bind(pubkey_bytes.as_slice()) .bind(channel_id) @@ -1562,10 +1965,11 @@ impl Db { // Soft-delete the old event (if any). IS NOT DISTINCT FROM for NULL safety. sqlx::query( "UPDATE events SET deleted_at = NOW() \ - WHERE kind = $1 AND pubkey = $2 \ - AND channel_id IS NOT DISTINCT FROM $3 \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 \ + AND channel_id IS NOT DISTINCT FROM $4 \ AND deleted_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(kind_i32) .bind(pubkey_bytes.as_slice()) .bind(channel_id) @@ -1579,10 +1983,11 @@ impl Db { let d_tag = crate::event::extract_d_tag(event); let insert_result = sqlx::query( - "INSERT INTO events (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) \ + "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_bytes.as_slice()) .bind(created_at) @@ -1611,7 +2016,7 @@ impl Db { // Mentions are a denormalized index — safe outside the transaction. // insert_event() normally handles this, but we inlined the INSERT above. - if let Err(e) = crate::insert_mentions(&self.pool, event, channel_id).await { + if let Err(e) = crate::insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } @@ -1640,6 +2045,7 @@ impl Db { /// this function instead, where the author's pubkey + d-tag is the natural key. pub async fn replace_parameterized_event( &self, + community_id: CommunityId, event: &nostr::Event, d_tag: &str, channel_id: Option, @@ -1654,6 +2060,10 @@ impl Db { // Same algorithm as replace_addressable_event — deterministic across processes. let lock_key = { let mut h: u64 = 0xcbf29ce484222325; // FNV offset basis + for b in community_id.as_uuid().as_bytes() { + h ^= *b as u64; + h = h.wrapping_mul(0x100000001b3); + } for b in kind_i32.to_le_bytes() { h ^= b as u64; h = h.wrapping_mul(0x100000001b3); @@ -1679,9 +2089,10 @@ impl Db { // Check for existing event with same (kind, pubkey, d_tag). let existing: Option<(chrono::DateTime, Vec)> = sqlx::query_as( "SELECT created_at, id FROM events \ - WHERE kind = $1 AND pubkey = $2 AND d_tag = $3 AND deleted_at IS NULL \ + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL \ ORDER BY created_at DESC, id ASC LIMIT 1", ) + .bind(community_id.as_uuid()) .bind(kind_i32) .bind(pubkey_bytes.as_slice()) .bind(d_tag) @@ -1705,8 +2116,9 @@ impl Db { // Soft-delete the older event(s). sqlx::query( "UPDATE events SET deleted_at = NOW() \ - WHERE kind = $1 AND pubkey = $2 AND d_tag = $3 AND deleted_at IS NULL", + WHERE community_id = $1 AND kind = $2 AND pubkey = $3 AND d_tag = $4 AND deleted_at IS NULL", ) + .bind(community_id.as_uuid()) .bind(kind_i32) .bind(pubkey_bytes.as_slice()) .bind(d_tag) @@ -1720,10 +2132,11 @@ impl Db { let received_at = chrono::Utc::now(); let insert_result = sqlx::query( - "INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) \ - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) \ + "INSERT INTO events (community_id, id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag, not_before) \ + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) \ ON CONFLICT DO NOTHING", ) + .bind(community_id.as_uuid()) .bind(event.id.as_bytes().as_slice()) .bind(pubkey_bytes.as_slice()) .bind(created_at) @@ -1750,7 +2163,7 @@ impl Db { tx.commit().await?; // Mentions are a denormalized index — safe outside the transaction. - if let Err(e) = crate::insert_mentions(&self.pool, event, channel_id).await { + if let Err(e) = crate::insert_mentions(&self.pool, community_id, event, channel_id).await { tracing::warn!(event_id = %event.id, "Failed to insert mentions: {e}"); } @@ -1833,3 +2246,100 @@ fn parse_api_token_row(row: sqlx::postgres::PgRow) -> Result { revoked_at: row.try_get("revoked_at")?, }) } + +#[cfg(test)] +mod tests { + //! Pin the load-bearing contract for `Db::communities_of_channels`: + //! a channel id that does NOT exist MUST be absent from the result + //! map, never mapped to a default. The relay-side read-row emitter + //! relies on this — a missing entry triggers `MissingLookup → + //! ImplBug{row_community_lookup_missing} → CoverageBreach`. If this + //! helper ever started returning a default/zero entry for unknown + //! channels, that fail-closed chain would go blind. + use super::*; + use buzz_core::CommunityId; + use sqlx::PgPool; + use uuid::Uuid; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + async fn setup_db() -> Db { + let pool = PgPool::connect(TEST_DB_URL) + .await + .expect("connect to test DB"); + Db { pool } + } + + async fn make_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("communities-of-channels-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert community"); + id + } + + async fn insert_channel(pool: &PgPool, community_id: Uuid, channel_id: Uuid) { + let creator: Vec = vec![0u8; 32]; + sqlx::query( + r#" + INSERT INTO channels + (id, community_id, name, channel_type, visibility, created_by) + VALUES + ($1, $2, $3, 'stream'::channel_type, 'open'::channel_visibility, $4) + "#, + ) + .bind(channel_id) + .bind(community_id) + .bind(format!("ch-{}", channel_id.simple())) + .bind(&creator) + .execute(pool) + .await + .expect("insert channel"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn communities_of_channels_present_for_existing_absent_for_missing() { + let db = setup_db().await; + let community = make_community(&db.pool).await; + let existing = Uuid::new_v4(); + insert_channel(&db.pool, community, existing).await; + + // Channel that is NOT inserted — the load-bearing case. + let missing = Uuid::new_v4(); + + let result = db + .communities_of_channels(&[existing, missing]) + .await + .expect("communities_of_channels"); + + // (1) Existing channel → present with its true community. + assert_eq!( + result.get(&existing).copied(), + Some(CommunityId::from_uuid(community)), + "existing channel must map to its true community", + ); + + // (2) Missing channel → ABSENT from the map (never defaulted). + // This is the contract the relay-side `MissingLookup → ImplBug` + // fail-closed guard-rail depends on. If this assertion ever + // weakens to `result.get(&missing) != Some(community)`, the + // mutate-bite below stops biting. + assert!( + !result.contains_key(&missing), + "missing channel must be absent from the result map, got {:?}", + result.get(&missing), + ); + + // (3) Map size matches: exactly one entry, the existing one. + assert_eq!( + result.len(), + 1, + "result map must contain only existing channels" + ); + } +} diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index f4f3c9ab3..401d067d8 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -1,9 +1,8 @@ //! Embedded SQLx migrations for Buzz. //! -//! Fresh deployments apply the checked-in SQL files under `migrations/`. -//! Existing pre-SQLx deployments are baselined when core Buzz tables already -//! exist but `_sqlx_migrations` does not, so startup will not try to replay the -//! initial schema over a live database. +//! Fresh deployments apply the checked-in SQL files under `migrations/`. The +//! multi-tenant rewrite owns a clean consolidated `0001`; legacy single-tenant +//! cutover/backfill is a separate operator script, not startup migration state. use sqlx::PgPool; @@ -11,154 +10,619 @@ use crate::Result; static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("../../migrations"); -#[cfg(test)] -static SCHEMA_SQL: &str = include_str!("../../../schema/schema.sql"); - -const BASELINE_MIGRATION_VERSIONS: &[i64] = &[1, 2]; - /// Run all pending Buzz database migrations. pub async fn run_migrations(pool: &PgPool) -> Result<()> { - baseline_existing_database(pool).await?; MIGRATOR.run(pool).await?; Ok(()) } -async fn baseline_existing_database(pool: &PgPool) -> Result<()> { - if migrations_table_exists(pool).await? || !pre_sqlx_schema_exists(pool).await? { - return Ok(()); +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + enum ConstraintKind { + ForeignKey, + PrimaryKey, + Unique, } - ensure_migrations_table(pool).await?; + #[derive(Debug, Clone, PartialEq, Eq)] + struct ConstraintLint { + table: String, + kind: ConstraintKind, + description: String, + columns: Vec, + } - for version in BASELINE_MIGRATION_VERSIONS { - let migration = MIGRATOR + fn migration_sql() -> &'static str { + MIGRATOR .iter() - .find(|migration| migration.version == *version) - .expect("baseline migration version must exist in embedded migrator"); - - sqlx::query( - r#" - INSERT INTO _sqlx_migrations - (version, description, success, checksum, execution_time) - VALUES ($1, $2, TRUE, $3, 0) - ON CONFLICT (version) DO NOTHING - "#, - ) - .bind(migration.version) - .bind(&*migration.description) - .bind(&*migration.checksum) - .execute(pool) - .await?; + .find(|migration| migration.version == 1) + .expect("initial migration must exist") + .sql + .as_str() } - tracing::info!( - versions = ?BASELINE_MIGRATION_VERSIONS, - "Baselined existing Buzz database for SQLx migrations" - ); + fn strip_sql_comments(sql: &str) -> String { + sql.lines() + .map(|line| line.split_once("--").map_or(line, |(before, _)| before)) + .collect::>() + .join("\n") + } - Ok(()) -} + fn normalize_sql(sql: &str) -> String { + strip_sql_comments(sql) + .split_whitespace() + .collect::>() + .join(" ") + .to_ascii_lowercase() + } -async fn migrations_table_exists(pool: &PgPool) -> Result { - let exists = sqlx::query_scalar::<_, bool>( - r#" - SELECT EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = '_sqlx_migrations' - ) - "#, - ) - .fetch_one(pool) - .await?; + fn split_sql_statements(sql: &str) -> Vec { + let sql = strip_sql_comments(sql); + let bytes = sql.as_bytes(); + let mut statements = Vec::new(); + let mut start = 0usize; + let mut idx = 0usize; + let mut in_single_quote = false; + let mut in_dollar_quote = false; + + while idx < bytes.len() { + match bytes[idx] { + b'\'' if !in_dollar_quote => { + in_single_quote = !in_single_quote; + idx += 1; + } + b'$' if !in_single_quote && idx + 1 < bytes.len() && bytes[idx + 1] == b'$' => { + in_dollar_quote = !in_dollar_quote; + idx += 2; + } + b';' if !in_single_quote && !in_dollar_quote => { + let statement = sql[start..idx].trim(); + if !statement.is_empty() { + statements.push(statement.to_owned()); + } + start = idx + 1; + idx += 1; + } + _ => idx += 1, + } + } + + let tail = sql[start..].trim(); + if !tail.is_empty() { + statements.push(tail.to_owned()); + } + + statements + } - Ok(exists) -} + fn find_matching_paren(sql: &str, open: usize) -> Option { + let mut depth = 0usize; + for (offset, byte) in sql.as_bytes()[open..].iter().enumerate() { + match byte { + b'(' => depth += 1, + b')' => { + depth = depth.checked_sub(1)?; + if depth == 0 { + return Some(open + offset); + } + } + _ => {} + } + } + None + } -async fn pre_sqlx_schema_exists(pool: &PgPool) -> Result { - let exists = sqlx::query_scalar::<_, bool>( - r#" - SELECT EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = 'events' - ) AND EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_schema = 'public' - AND table_name = 'channels' - ) - "#, - ) - .fetch_one(pool) - .await?; + fn split_top_level_csv(input: &str) -> Vec { + let mut parts = Vec::new(); + let mut start = 0usize; + let mut depth = 0usize; + for (idx, byte) in input.bytes().enumerate() { + match byte { + b'(' => depth += 1, + b')' => depth = depth.saturating_sub(1), + b',' if depth == 0 => { + parts.push(input[start..idx].trim().to_owned()); + start = idx + 1; + } + _ => {} + } + } + let tail = input[start..].trim(); + if !tail.is_empty() { + parts.push(tail.to_owned()); + } + parts + } - Ok(exists) -} + fn identifier_after_keyword(statement: &str, keyword: &str) -> Option { + let lower = statement.to_ascii_lowercase(); + let keyword_pos = lower.find(keyword)?; + let mut remainder = statement[keyword_pos + keyword.len()..].trim_start(); + for prefix in ["if not exists", "if exists", "only"] { + if remainder.to_ascii_lowercase().starts_with(prefix) { + remainder = remainder[prefix.len()..].trim_start(); + } + } + + let identifier = remainder + .split(|ch: char| ch.is_whitespace() || ch == '(') + .next()? + .trim_matches('"') + .rsplit('.') + .next()? + .trim_matches('"') + .to_ascii_lowercase(); + (!identifier.is_empty()).then_some(identifier) + } -async fn ensure_migrations_table(pool: &PgPool) -> Result<()> { - sqlx::query( - r#" - CREATE TABLE IF NOT EXISTS _sqlx_migrations ( - version BIGINT PRIMARY KEY, - description TEXT NOT NULL, - installed_on TIMESTAMPTZ NOT NULL DEFAULT now(), - success BOOLEAN NOT NULL, - checksum BYTEA NOT NULL, - execution_time BIGINT NOT NULL - ) - "#, - ) - .execute(pool) - .await?; + fn first_parenthesized_columns(input: &str) -> Vec { + let Some(open) = input.find('(') else { + return Vec::new(); + }; + let Some(close) = find_matching_paren(input, open) else { + return Vec::new(); + }; + + split_top_level_csv(&input[open + 1..close]) + .into_iter() + .filter_map(|column| { + let name = column + .trim() + .trim_matches('"') + .split_whitespace() + .next()? + .trim_matches('"') + .to_ascii_lowercase(); + (!name.is_empty()).then_some(name) + }) + .collect() + } - Ok(()) -} + fn column_definition_name(definition: &str) -> Option { + let trimmed = definition.trim(); + let lower = trimmed.to_ascii_lowercase(); + if lower.starts_with("constraint ") + || lower.starts_with("primary key") + || lower.starts_with("foreign key") + || lower.starts_with("unique") + || lower.starts_with("check ") + || lower.starts_with("exclude ") + { + return None; + } + + let name = trimmed + .split_whitespace() + .next()? + .trim_matches('"') + .to_ascii_lowercase(); + (!name.is_empty()).then_some(name) + } -#[cfg(test)] -mod tests { - use super::*; - use sqlx::PgPool; + fn create_table_body(statement: &str) -> Option<(String, Vec)> { + let table = identifier_after_keyword(statement, "create table")?; + let open = statement.find('(')?; + let close = find_matching_paren(statement, open)?; + Some((table, split_top_level_csv(&statement[open + 1..close]))) + } - const TEST_DB_URL: &str = "postgres://buzz:buzz_dev@localhost:5432/buzz"; + fn create_table_definitions(sql: &str) -> Vec<(String, Vec)> { + split_sql_statements(sql) + .into_iter() + .filter_map(|statement| { + let normalized = statement.trim_start().to_ascii_lowercase(); + if !normalized.starts_with("create table") || normalized.contains(" partition of ") + { + return None; + } + create_table_body(&statement) + }) + .collect() + } + + fn create_tables(sql: &str) -> BTreeSet { + create_table_definitions(sql) + .into_iter() + .map(|(table, _)| table) + .collect() + } + + fn table_has_not_null_community_id(definitions: &[String]) -> bool { + definitions.iter().any(|definition| { + column_definition_name(definition).as_deref() == Some("community_id") + && normalize_sql(definition).contains("not null") + }) + } + + fn operator_global_tables(sql: &str) -> BTreeSet { + let mut globals = BTreeSet::new(); + let normalized = normalize_sql(sql); + let Some(insert_pos) = normalized.find("insert into _operator_global_tables") else { + return globals; + }; + + for value in [ + "communities", + "rate_limit_violations", + "_operator_global_tables", + ] { + if normalized[insert_pos..].contains(&format!("'{value}'")) { + globals.insert(value.to_owned()); + } + } + + globals + } + + fn scoped_tables(sql: &str) -> BTreeSet { + let globals = operator_global_tables(sql); + create_tables(sql) + .into_iter() + .filter(|table| !globals.contains(table)) + .collect() + } + + fn constraint_lint_for_definition(table: &str, definition: &str) -> Option { + let normalized = normalize_sql(definition); + let definition_without_name = if normalized.starts_with("constraint ") { + let after_constraint = definition + .trim_start() + .splitn(3, char::is_whitespace) + .nth(2) + .unwrap_or(""); + normalize_sql(after_constraint) + } else { + normalized.clone() + }; + + if definition_without_name.starts_with("primary key") { + Some(ConstraintLint { + table: table.to_owned(), + kind: ConstraintKind::PrimaryKey, + description: definition.to_owned(), + columns: first_parenthesized_columns(&definition_without_name), + }) + } else if definition_without_name.starts_with("unique") { + Some(ConstraintLint { + table: table.to_owned(), + kind: ConstraintKind::Unique, + description: definition.to_owned(), + columns: first_parenthesized_columns(&definition_without_name), + }) + } else if definition_without_name.starts_with("foreign key") { + Some(ConstraintLint { + table: table.to_owned(), + kind: ConstraintKind::ForeignKey, + description: definition.to_owned(), + columns: first_parenthesized_columns(&definition_without_name), + }) + } else if normalized.contains(" primary key") { + column_definition_name(definition).map(|column| ConstraintLint { + table: table.to_owned(), + kind: ConstraintKind::PrimaryKey, + description: definition.to_owned(), + columns: vec![column], + }) + } else if normalized.contains(" references ") { + column_definition_name(definition).map(|column| ConstraintLint { + table: table.to_owned(), + kind: ConstraintKind::ForeignKey, + description: definition.to_owned(), + columns: vec![column], + }) + } else if normalized.contains(" unique") { + column_definition_name(definition).map(|column| ConstraintLint { + table: table.to_owned(), + kind: ConstraintKind::Unique, + description: definition.to_owned(), + columns: vec![column], + }) + } else { + None + } + } + + fn table_constraints(sql: &str, scoped_tables: &BTreeSet) -> Vec { + create_table_definitions(sql) + .into_iter() + .filter(|(table, _)| scoped_tables.contains(table)) + .flat_map(|(table, definitions)| { + definitions.into_iter().filter_map(move |definition| { + constraint_lint_for_definition(&table, &definition) + }) + }) + .collect() + } + + fn alter_table_constraints(sql: &str, scoped_tables: &BTreeSet) -> Vec { + split_sql_statements(sql) + .into_iter() + .filter_map(|statement| { + let normalized = normalize_sql(&statement); + if !normalized.starts_with("alter table") { + return None; + } + + let table = identifier_after_keyword(&statement, "alter table")?; + if !scoped_tables.contains(&table) { + return None; + } + + let add_pos = normalized.find(" add ")?; + let definition = normalized[add_pos + " add ".len()..].trim(); + constraint_lint_for_definition(&table, definition) + }) + .collect() + } + + fn unique_indexes(sql: &str, scoped_tables: &BTreeSet) -> Vec { + split_sql_statements(sql) + .into_iter() + .filter_map(|statement| { + let normalized = normalize_sql(&statement); + if !normalized.starts_with("create unique index") { + return None; + } + + let lower_statement = statement.to_ascii_lowercase(); + let on_pos = lower_statement.find(" on ")?; + let table = statement[on_pos + " on ".len()..] + .trim_start() + .split(|ch: char| ch.is_whitespace() || ch == '(') + .next()? + .trim_matches('"') + .rsplit('.') + .next()? + .trim_matches('"') + .to_ascii_lowercase(); + + scoped_tables.contains(&table).then(|| ConstraintLint { + table, + kind: ConstraintKind::Unique, + description: statement.clone(), + columns: first_parenthesized_columns(&statement[on_pos + " on ".len()..]), + }) + }) + .collect() + } + + fn scoped_constraint_lints(sql: &str, scoped_tables: &BTreeSet) -> Vec { + let mut constraints = table_constraints(sql, scoped_tables); + constraints.extend(alter_table_constraints(sql, scoped_tables)); + constraints.extend(unique_indexes(sql, scoped_tables)); + constraints + } + + fn is_allowed_partition_primary_key_exception(constraint: &ConstraintLint) -> bool { + constraint.table == "delivery_log" + && constraint.kind == ConstraintKind::PrimaryKey + && constraint.columns == ["delivered_at", "id"] + } + + fn scoped_constraint_violations(sql: &str) -> Vec { + let scoped_tables = scoped_tables(sql); + scoped_constraint_lints(sql, &scoped_tables) + .into_iter() + .filter(|constraint| { + if is_allowed_partition_primary_key_exception(constraint) { + return false; + } + constraint.columns.first().map(String::as_str) != Some("community_id") + }) + .collect() + } + + fn has_channels_community_id_immutability_guard(sql: &str) -> bool { + let normalized = normalize_sql(sql); + normalized.contains("create trigger") + && normalized.contains("before update") + && normalized.contains(" on channels") + && normalized.contains("community_id") + && normalized.contains("old.community_id") + && normalized.contains("new.community_id") + && normalized.contains("raise exception") + } + + fn forbidden_channels_community_id_mutations(sql: &str) -> Vec { + split_sql_statements(sql) + .into_iter() + .filter(|statement| { + let normalized = normalize_sql(statement); + let updates_channels = + identifier_after_keyword(statement, "update").as_deref() == Some("channels"); + let mutates_with_update = updates_channels + && normalized.contains(" set ") + && normalized.contains("community_id"); + let alters_channels = identifier_after_keyword(statement, "alter table").as_deref() + == Some("channels"); + let drops_channels = identifier_after_keyword(statement, "drop table").as_deref() + == Some("channels"); + let drops_or_rewrites_column = alters_channels + && (normalized.contains("drop column community_id") + || normalized.contains("alter column community_id") + || normalized.contains("rename column community_id") + || normalized.contains("rename community_id") + || normalized.contains("drop trigger") + || normalized.contains("disable trigger")); + + mutates_with_update || drops_or_rewrites_column || drops_channels + }) + .collect() + } #[test] - fn embedded_migrator_contains_all_schema_migrations() { + fn embedded_migrator_contains_consolidated_initial_schema() { let migrations: Vec<_> = MIGRATOR.iter().collect(); - assert_eq!(migrations.len(), 3); + assert_eq!(migrations.len(), 1); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); - assert!( - migrations[0].sql.as_str().contains("CREATE TABLE channels"), - "initial schema migration should include Buzz core tables" + assert!(migrations[0] + .sql + .as_str() + .contains("CREATE TABLE communities")); + assert!(migrations[0].sql.as_str().contains("CREATE TABLE channels")); + assert!(migrations[0] + .sql + .as_str() + .contains("CREATE TABLE scheduled_workflow_fires")); + assert!(migrations[0] + .sql + .as_str() + .contains("CREATE TABLE audit_log")); + assert!(migrations[0] + .sql + .as_str() + .contains("CREATE TABLE _operator_global_tables")); + assert!(migrations[0] + .sql + .as_str() + .contains("search_tsv TSVECTOR GENERATED ALWAYS")); + } + + #[test] + fn migration_lint_detects_tables_missing_community_id_by_default() { + let sql = r#" + CREATE TABLE communities (id UUID PRIMARY KEY); + CREATE TABLE widgets (id UUID PRIMARY KEY); + CREATE TABLE _operator_global_tables (table_name TEXT PRIMARY KEY, reason TEXT NOT NULL); + INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('communities', 'tenant registry'), + ('_operator_global_tables', 'registry'); + "#; + + let definitions = create_table_definitions(sql); + let scoped = scoped_tables(sql); + let missing = definitions + .into_iter() + .filter(|(table, _)| scoped.contains(table)) + .filter(|(_, definitions)| !table_has_not_null_community_id(definitions)) + .map(|(table, _)| table) + .collect::>(); + + assert_eq!(missing, vec!["widgets"]); + } + + #[test] + fn migration_lint_detects_scoped_key_constraints_not_led_by_community_id() { + let sql = r#" + CREATE TABLE widgets ( + community_id UUID NOT NULL, + id UUID PRIMARY KEY, + channel_id UUID REFERENCES channels(id), + slug TEXT, + CONSTRAINT widgets_name_unique UNIQUE (slug), + CONSTRAINT widgets_parent_fk FOREIGN KEY (channel_id) REFERENCES channels(id) + ); + CREATE UNIQUE INDEX idx_widgets_slug ON widgets (slug); + ALTER TABLE widgets ADD CONSTRAINT widgets_alter_slug_unique UNIQUE (slug); + ALTER TABLE widgets ADD CONSTRAINT widgets_alter_parent_fk FOREIGN KEY (channel_id) REFERENCES channels(id); + CREATE TABLE _operator_global_tables (table_name TEXT PRIMARY KEY, reason TEXT NOT NULL); + INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('_operator_global_tables', 'registry'); + "#; + + let violations = scoped_constraint_violations(sql); + + assert!(violations + .iter() + .any(|violation| violation.kind == ConstraintKind::PrimaryKey)); + assert_eq!( + violations + .iter() + .filter(|violation| violation.kind == ConstraintKind::ForeignKey) + .count(), + 3 + ); + assert_eq!( + violations + .iter() + .filter(|violation| violation.kind == ConstraintKind::Unique) + .count(), + 3 ); + } + + #[test] + fn migration_lint_accepts_scoped_key_constraints_led_by_community_id() { + let sql = r#" + CREATE TABLE widgets ( + community_id UUID NOT NULL, + id UUID NOT NULL, + channel_id UUID NOT NULL, + slug TEXT NOT NULL, + PRIMARY KEY (community_id, id), + UNIQUE (community_id, slug), + FOREIGN KEY (community_id, channel_id) REFERENCES channels(community_id, id) + ); + CREATE UNIQUE INDEX idx_widgets_slug ON widgets (community_id, slug); + ALTER TABLE widgets ADD CONSTRAINT widgets_alter_slug_unique UNIQUE (community_id, slug); + ALTER TABLE widgets ADD CONSTRAINT widgets_alter_parent_fk FOREIGN KEY (community_id, channel_id) REFERENCES channels(community_id, id); + CREATE TABLE _operator_global_tables (table_name TEXT PRIMARY KEY, reason TEXT NOT NULL); + INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('_operator_global_tables', 'registry'); + "#; + + assert!(scoped_constraint_violations(sql).is_empty()); + } + + #[test] + fn all_non_operator_global_tables_have_not_null_community_id() { + let sql = migration_sql(); + let scoped = scoped_tables(sql); + let missing = create_table_definitions(sql) + .into_iter() + .filter(|(table, _)| scoped.contains(table)) + .filter(|(_, definitions)| !table_has_not_null_community_id(definitions)) + .map(|(table, _)| table) + .collect::>(); + assert!( - migrations[0] - .sql - .as_str() - .contains("CREATE TABLE IF NOT EXISTS relay_members"), - "initial schema migration should include relay_members" + missing.is_empty(), + "every table not listed in _operator_global_tables must carry NOT NULL community_id; missing: {}", + missing.join(", ") ); + } + + #[test] + fn scoped_primary_key_unique_and_foreign_key_constraints_lead_with_community_id() { + let sql = migration_sql(); + let violations = scoped_constraint_violations(sql) + .into_iter() + .map(|constraint| { + format!( + "{}. {:?} constraint must lead with community_id: {}", + constraint.table, constraint.kind, constraint.description + ) + }) + .collect::>(); - assert_eq!(migrations[1].version, 2); - assert_eq!(&*migrations[1].description, "backfill d tag"); assert!( - migrations[1].sql.as_str().contains("UPDATE events"), - "second migration should backfill existing event rows" + violations.is_empty(), + "tenant-scoped tables are all tables not listed in _operator_global_tables; primary key, unique/FK constraints, and unique indexes on those tables must lead with community_id:\n{}", + violations.join("\n") ); + } + + #[test] + fn channels_community_id_is_immutable_after_insert() { + let sql = migration_sql(); + let forbidden_mutations = forbidden_channels_community_id_mutations(sql); - assert_eq!(migrations[2].version, 3); - assert_eq!(&*migrations[2].description, "event reminders"); assert!( - migrations[2] - .sql - .as_str() - .contains("ADD COLUMN not_before BIGINT") - && migrations[2].sql.as_str().contains("idx_events_not_before"), - "third migration should add the NIP-ER reminder columns and index" + forbidden_mutations.is_empty(), + "channels.community_id must not be re-tenanted after insert; forbidden migration statements:\n{}", + forbidden_mutations.join("\n---\n") + ); + assert!( + has_channels_community_id_immutability_guard(sql), + "migrations define channels.community_id but no BEFORE UPDATE trigger/function guard that rejects OLD.community_id <> NEW.community_id was found" ); } @@ -192,88 +656,35 @@ mod tests { .expect("read applied migrations") } - /// Returns `schema/schema.sql` with the NIP-ER reminder DDL removed, so it - /// models a pre-stack deployment whose `events` table lacks the reminder - /// columns and index. The strip is asserted: if the snapshot text drifts so - /// these fragments no longer match, the test fails loudly rather than - /// silently loading a snapshot that already carries the reminder columns - /// (which would make migration 0003 collide on re-add). - fn pre_reminder_schema_snapshot() -> String { - const REMINDER_COLUMNS: &str = " not_before BIGINT,\n delivered_at BIGINT,\n"; - const REMINDER_INDEX: &str = "CREATE INDEX idx_events_not_before ON events (not_before)\n WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL;\n"; - - assert!( - SCHEMA_SQL.contains(REMINDER_COLUMNS) && SCHEMA_SQL.contains(REMINDER_INDEX), - "schema.sql reminder DDL drifted; update pre_reminder_schema_snapshot to match" - ); - - SCHEMA_SQL - .replace(REMINDER_COLUMNS, "") - .replace(REMINDER_INDEX, "") - } - #[tokio::test] #[ignore = "requires Postgres"] - async fn run_migrations_applies_embedded_versions_on_fresh_database() { + async fn run_migrations_applies_consolidated_initial_schema_on_fresh_database() { let pool = connect_test_pool().await; reset_public_schema(&pool).await; run_migrations(&pool).await.expect("run migrations"); - assert_eq!(applied_versions(&pool).await, vec![1, 2, 3]); - let events_exists = sqlx::query_scalar::<_, bool>( - "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = 'events')", - ) - .fetch_one(&pool) - .await - .expect("check events table"); - assert!(events_exists); - } - - #[tokio::test] - #[ignore = "requires Postgres"] - async fn run_migrations_baselines_existing_schema_and_preserves_allowlist_backfill_path() { - let pool = connect_test_pool().await; - reset_public_schema(&pool).await; - // Load a pre-stack snapshot (without the NIP-ER reminder DDL) so the - // events table matches a real pre-SQLx deployment, which never had the - // reminder columns. Migration 0003 must then add them — proving the - // genuine prod-upgrade path, not a snapshot that already carries them. - sqlx::raw_sql(sqlx::AssertSqlSafe(pre_reminder_schema_snapshot())) - .execute(&pool) - .await - .expect("load pre-SQLx schema snapshot"); - sqlx::query( - "INSERT INTO pubkey_allowlist (pubkey, added_at) VALUES (decode($1, 'hex'), now())", - ) - .bind("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - .execute(&pool) - .await - .expect("seed legacy allowlist row"); - - run_migrations(&pool).await.expect("baseline migrations"); - - assert_eq!(applied_versions(&pool).await, vec![1, 2, 3]); - let allowlist_count = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM pubkey_allowlist") + assert_eq!(applied_versions(&pool).await, vec![1]); + let tables = create_tables(migration_sql()); + for table in [ + "communities", + "events", + "channels", + "scheduled_workflow_fires", + "audit_log", + ] { + let exists = sqlx::query_scalar::<_, bool>( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1)", + ) + .bind(table) .fetch_one(&pool) .await - .expect("count allowlist rows"); - assert_eq!( - allowlist_count, 1, - "baseline must not drop legacy allowlist rows before relay startup backfills them" - ); - - let inserted = crate::relay_members::backfill_from_allowlist(&pool) - .await - .expect("backfill legacy allowlist rows"); - assert_eq!(inserted, 1); - let relay_member_count = sqlx::query_scalar::<_, i64>( - "SELECT COUNT(*) FROM relay_members WHERE pubkey = $1 AND role = 'member'", - ) - .bind("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") - .fetch_one(&pool) - .await - .expect("count backfilled relay member"); - assert_eq!(relay_member_count, 1); + .unwrap_or_else(|err| panic!("check table {table}: {err}")); + assert!( + tables.contains(table), + "migration parser should see {table}" + ); + assert!(exists, "migration should create {table}"); + } } } diff --git a/crates/buzz-db/src/thread.rs b/crates/buzz-db/src/thread.rs index 4ccd8a248..8281ed9db 100644 --- a/crates/buzz-db/src/thread.rs +++ b/crates/buzz-db/src/thread.rs @@ -9,6 +9,8 @@ use chrono::{DateTime, Utc}; use sqlx::{PgPool, Row}; use uuid::Uuid; +use buzz_core::CommunityId; + use crate::{error::Result, event::row_to_stored_event}; // -- Structs ------------------------------------------------------------------ @@ -110,6 +112,7 @@ pub struct ThreadMetadataRecord { #[allow(clippy::too_many_arguments)] pub async fn insert_thread_metadata( pool: &PgPool, + community_id: CommunityId, event_id: &[u8], event_created_at: DateTime, channel_id: Uuid, @@ -125,14 +128,15 @@ pub async fn insert_thread_metadata( let result = sqlx::query( r#" INSERT INTO thread_metadata - (event_created_at, event_id, channel_id, + (community_id, event_created_at, event_id, channel_id, parent_event_id, parent_event_created_at, root_event_id, root_event_created_at, depth, broadcast) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(event_created_at) .bind(event_id) .bind(channel_id) @@ -156,14 +160,15 @@ pub async fn insert_thread_metadata( sqlx::query( r#" INSERT INTO thread_metadata - (event_created_at, event_id, channel_id, + (community_id, event_created_at, event_id, channel_id, parent_event_id, parent_event_created_at, root_event_id, root_event_created_at, depth, broadcast) - VALUES ($1, $2, $3, NULL, NULL, NULL, NULL, 0, false) + VALUES ($1, $2, $3, $4, NULL, NULL, NULL, NULL, 0, false) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(parent_ts) .bind(pid) .bind(channel_id) @@ -185,6 +190,7 @@ pub async fn insert_thread_metadata( ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(root_ts) .bind(root_id) .bind(channel_id) @@ -199,9 +205,10 @@ pub async fn insert_thread_metadata( UPDATE thread_metadata SET reply_count = reply_count + 1, last_reply_at = NOW() - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(pid) .execute(&mut *tx) .await?; @@ -212,9 +219,10 @@ pub async fn insert_thread_metadata( r#" UPDATE thread_metadata SET descendant_count = descendant_count + 1 - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(root_id) .execute(&mut *tx) .await?; @@ -239,6 +247,7 @@ pub async fn insert_thread_metadata( #[allow(dead_code)] pub async fn increment_reply_count( pool: &PgPool, + community_id: CommunityId, parent_event_id: &[u8], root_event_id: Option<&[u8]>, ) -> Result<()> { @@ -248,9 +257,10 @@ pub async fn increment_reply_count( UPDATE thread_metadata SET reply_count = reply_count + 1, last_reply_at = NOW() - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(parent_event_id) .execute(pool) .await?; @@ -261,9 +271,10 @@ pub async fn increment_reply_count( r#" UPDATE thread_metadata SET descendant_count = descendant_count + 1 - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(root_id) .execute(pool) .await?; @@ -277,6 +288,7 @@ pub async fn increment_reply_count( /// root -- even when root == parent. Mirrors the increment logic exactly. pub async fn decrement_reply_count( pool: &PgPool, + community_id: CommunityId, parent_event_id: &[u8], root_event_id: Option<&[u8]>, ) -> Result<()> { @@ -285,9 +297,10 @@ pub async fn decrement_reply_count( r#" UPDATE thread_metadata SET reply_count = GREATEST(reply_count - 1, 0) - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(parent_event_id) .execute(pool) .await?; @@ -298,9 +311,10 @@ pub async fn decrement_reply_count( r#" UPDATE thread_metadata SET descendant_count = GREATEST(descendant_count - 1, 0) - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 "#, ) + .bind(community_id.as_uuid()) .bind(root_id) .execute(pool) .await?; @@ -320,6 +334,7 @@ pub async fn decrement_reply_count( /// - `limit` -- maximum rows returned (caller should cap this). pub async fn get_thread_replies( pool: &PgPool, + community_id: CommunityId, root_event_id: &[u8], depth_limit: Option, limit: u32, @@ -336,7 +351,7 @@ pub async fn get_thread_replies( // Build the query dynamically based on optional filters. // Track the next positional parameter index. - let mut param_idx = 2u32; // $1 is root_event_id + let mut param_idx = 3u32; // $1 is community_id, $2 is root_event_id let mut sql = String::from( r#" SELECT @@ -357,9 +372,11 @@ pub async fn get_thread_replies( tm.broadcast FROM thread_metadata tm JOIN events e - ON e.created_at = tm.event_created_at + ON e.community_id = tm.community_id + AND e.created_at = tm.event_created_at AND e.id = tm.event_id - WHERE tm.root_event_id = $1 + WHERE tm.community_id = $1 + AND tm.root_event_id = $2 AND e.deleted_at IS NULL "#, ); @@ -377,7 +394,9 @@ pub async fn get_thread_replies( " ORDER BY tm.event_created_at ASC LIMIT ${param_idx}" )); - let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)).bind(root_event_id); + let mut q = sqlx::query(sqlx::AssertSqlSafe(sql)) + .bind(community_id.as_uuid()) + .bind(root_event_id); if let Some(dl) = depth_limit { q = q.bind(dl as i32); @@ -428,15 +447,20 @@ pub async fn get_thread_replies( } /// Fetch aggregated thread stats for a single event, plus up to 10 participant pubkeys. -pub async fn get_thread_summary(pool: &PgPool, event_id: &[u8]) -> Result> { +pub async fn get_thread_summary( + pool: &PgPool, + community_id: CommunityId, + event_id: &[u8], +) -> Result> { let row = sqlx::query( r#" SELECT reply_count, descendant_count, last_reply_at FROM thread_metadata - WHERE event_id = $1 + WHERE community_id = $1 AND event_id = $2 LIMIT 1 "#, ) + .bind(community_id.as_uuid()) .bind(event_id) .fetch_optional(pool) .await?; @@ -457,9 +481,11 @@ pub async fn get_thread_summary(pool: &PgPool, event_id: &[u8]) -> Result