From 8f69d35810dfacc4a142f05703b0d99d0065a419 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:36:57 -0400 Subject: [PATCH 001/100] =?UTF-8?q?feat(core):=20TenantContext=20+=20Commu?= =?UTF-8?q?nityId=20=E2=80=94=20the=20server-resolved=20tenant=20fence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit buzz-core gets the zero-I/O tenant identity types every scoped layer shares. TenantContext encodes conformance row-zero in the type system: no Default, no Deserialize, no public constructor except resolved(), which is meant to be called only from host resolution. Downstream code holds &TenantContext and can read but not mint a community, so client-chosen-community cannot type-check outside resolution. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- crates/buzz-core/src/lib.rs | 3 + crates/buzz-core/src/tenant.rs | 112 +++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 crates/buzz-core/src/tenant.rs diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index 8ee3d0315..d193ae162 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::{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..32cec5d80 --- /dev/null +++ b/crates/buzz-core/src/tenant.rs @@ -0,0 +1,112 @@ +//! 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`] encodes that invariant in the type system. It has no +//! `Default`, no `Deserialize`, and no public constructor other than +//! [`TenantContext::resolved`], which is meant to be called *only* from the +//! host-resolution path. Downstream code receives `&TenantContext` and can read +//! the community but cannot mint one — so "the client chose this community" +//! cannot type-check anywhere outside resolution. + +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 + } +} + +#[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"); + } +} From d948830c02dffe5b9bb4485af06ddd170bc898a3 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:36:57 -0400 Subject: [PATCH 002/100] feat(lane0): community_id-native schema + host normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The frozen base for the multi-tenant rewrite. Consolidated 0001 schema makes community_id a first-class, server-resolved key on every scoped row, mapped table-by-table to docs/multi-tenant-conformance.md. Schema highlights: - channels PK is (community_id, id): the same channel UUID may legitimately co-exist in two communities; child FKs (channel_members, workflows, thread_metadata) are composite (community_id, channel_id) so a child can never reference a cross-community channel — DB-enforced, not by handler discipline. channels.community_id is immutable (BEFORE UPDATE trigger). - communities.host uniqueness is UNIQUE(lower(host)); normalize_host applies the same rule on the resolution side, so case/dot/default-port variants can never split one tenant into two. - every scoped unique/PK leads with community_id; cross-community dedup of the same signed event is allowed, within-community dup rejected. - new tables: communities (host map), scheduled_workflow_fires (the cron at-most-once claim), audit_log (per-community chain), and an explicit _operator_global_tables registry the migration lint reads. buzz-core: - normalize_host(host): the one shared host-canonicalization rule. - TenantContext fence doc corrected to say plainly it is a lint-and-review fence, not a compiler fence (resolved()/from_uuid are pub) — honest about the guarantee the API actually gives. Schema proven against Postgres with an adversarial fence suite (re-tenant rejected, cross-community FKs rejected, same-UUID/same-event cross-community allowed, host-case collision rejected). buzz-core: 189 tests + 2 doctests green. Folds in review round 1 from Mari (channel global-uniqueness leak, host normalization, fence-claim honesty) and Sami (NIP-98 localhost normalization to be dropped in the auth lane). Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- crates/buzz-core/src/lib.rs | 2 +- crates/buzz-core/src/tenant.rs | 99 +++++++- migrations/0001_initial_schema.sql | 373 +++++++++++++++++++++++------ 3 files changed, 398 insertions(+), 76 deletions(-) diff --git a/crates/buzz-core/src/lib.rs b/crates/buzz-core/src/lib.rs index d193ae162..dee40e988 100644 --- a/crates/buzz-core/src/lib.rs +++ b/crates/buzz-core/src/lib.rs @@ -37,7 +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::{CommunityId, TenantContext}; +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 index 32cec5d80..e7dbb630a 100644 --- a/crates/buzz-core/src/tenant.rs +++ b/crates/buzz-core/src/tenant.rs @@ -10,12 +10,19 @@ //! 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`] encodes that invariant in the type system. It has no -//! `Default`, no `Deserialize`, and no public constructor other than -//! [`TenantContext::resolved`], which is meant to be called *only* from the -//! host-resolution path. Downstream code receives `&TenantContext` and can read -//! the community but cannot mint one — so "the client chose this community" -//! cannot type-check anywhere outside resolution. +//! [`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; @@ -90,6 +97,46 @@ impl TenantContext { } } +/// 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::*; @@ -109,4 +156,44 @@ mod tests { 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/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index 93644cba2..edda65c77 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -1,6 +1,25 @@ --- Buzz initial Postgres schema. +-- Buzz initial Postgres schema — multi-tenant. -- --- This migration is the source of truth for fresh database setup. +-- Source of truth for fresh database setup. This is a clean, from-scratch +-- schema in which `community_id` is a first-class, server-resolved key on +-- every tenant-scoped row. It is NOT additive over the single-community +-- schema; the rewrite replaces it. Existing single-community deployments +-- migrate via the documented backfill migration (0002), which assigns all +-- pre-existing rows to one default community. +-- +-- The governing contract is docs/multi-tenant-conformance.md. Every table +-- below cites the conformance surface it implements. The invariant behind the +-- whole schema (conformance "row zero"): a request's community is resolved +-- from the connection host by the server, never supplied by the client, and +-- every scoped row carries that immutable `community_id`. +-- +-- Migration-lint obligations enforced by the Lane 0 lint harness: +-- 1. Every tenant-scoped table has `community_id NOT NULL`. +-- 2. No UNIQUE / PRIMARY KEY / FK on a scoped table is observable across +-- communities: each leads with `community_id` (or, for child rows whose +-- parent already pins the community, joins carry the community tuple). +-- 3. `channels.community_id` is immutable (trigger below; no UPDATE path). +-- 4. Operator-global tables are named in the explicit allowlist, not implied. CREATE EXTENSION IF NOT EXISTS pgcrypto; @@ -17,10 +36,42 @@ CREATE TYPE subscription_status AS ENUM ('active', 'paused', 'deleted'); CREATE TYPE pause_reason AS ENUM ('user', 'system', 'rate_limit'); CREATE TYPE channel_add_policy AS ENUM ('anyone', 'owner_only', 'nobody'); +-- ── Communities ─────────────────────────────────────────────────────────────── +-- Conformance: row zero (host binding). The host map. `resolve_host(host)` +-- reads exactly one row here to mint the request's TenantContext. This table +-- is OPERATOR-GLOBAL: it is the registry of tenants, not itself tenant-scoped, +-- so it carries no `community_id` of its own (its `id` IS the community key). +-- Listed in the lint allowlist as operator-global. +-- +-- Host normalization (Lane 0 contract): `host` is stored already-normalized — +-- ASCII-lowercased, trailing dot stripped, default port omitted. The UNIQUE is +-- on `lower(host)` belt-and-suspenders so `Relay.Example` and `relay.example` +-- can never become two tenants even if a writer forgets to normalize. +-- `resolve_host()` (buzz-core) applies the identical normalization before +-- lookup, so resolution and storage agree by construction. + +CREATE TABLE communities ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + host VARCHAR(255) NOT NULL, + signing_key BYTEA, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT chk_communities_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid) +); + +CREATE UNIQUE INDEX idx_communities_host ON communities (lower(host)); + -- ── Channels ────────────────────────────────────────────────────────────────── +-- Conformance: "Channels and channel membership". `community_id` immutable. +-- Channel UUIDs stay valid wire identifiers, but they are NOT globally unique: +-- the PK is `(community_id, id)`, so the same UUID may legitimately exist in two +-- communities (conformance lists "same channel UUID collision in two +-- communities" as a required isolation test). Handlers always carry `ctx`, so +-- `(ctx.community, h)` names exactly one channel; a client-supplied `h` can +-- never reach another community's channel. CREATE TABLE channels ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + id UUID NOT NULL DEFAULT gen_random_uuid(), + community_id UUID NOT NULL REFERENCES communities(id), name VARCHAR(255) NOT NULL, channel_type channel_type NOT NULL DEFAULT 'stream', visibility channel_visibility NOT NULL DEFAULT 'open', @@ -31,7 +82,7 @@ CREATE TABLE channels ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), archived_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ, - nip29_group_id VARCHAR(255) UNIQUE, + nip29_group_id VARCHAR(255), topic_required BOOLEAN NOT NULL DEFAULT FALSE, max_members INT, topic TEXT, @@ -43,20 +94,44 @@ CREATE TABLE channels ( participant_hash BYTEA, ttl_seconds INT, ttl_deadline TIMESTAMPTZ, + PRIMARY KEY (community_id, id), CONSTRAINT chk_channels_id_not_nil CHECK (id <> '00000000-0000-0000-0000-000000000000'::uuid) ); -CREATE INDEX idx_channels_type ON channels (channel_type); -CREATE INDEX idx_channels_visibility ON channels (visibility); -CREATE INDEX idx_channels_created_by ON channels (created_by); -CREATE UNIQUE INDEX idx_channels_dm_hash ON channels (participant_hash); +-- nip29 group id and DM participant hash are unique WITHIN a community, not globally. +CREATE UNIQUE INDEX idx_channels_nip29_group ON channels (community_id, nip29_group_id) + WHERE nip29_group_id IS NOT NULL; +CREATE UNIQUE INDEX idx_channels_dm_hash ON channels (community_id, participant_hash) + WHERE participant_hash IS NOT NULL; +CREATE INDEX idx_channels_community_type ON channels (community_id, channel_type); +CREATE INDEX idx_channels_community_visibility ON channels (community_id, visibility); +CREATE INDEX idx_channels_created_by ON channels (community_id, created_by); CREATE INDEX idx_channels_ttl_expiry ON channels (ttl_deadline) WHERE ttl_seconds IS NOT NULL AND archived_at IS NULL AND deleted_at IS NULL; +-- channels.community_id is immutable: a channel can never be re-tenanted. +-- (Conformance: "Migration lint forbids channel re-tenanting except through an +-- explicitly modeled admission path." We have no such path, so: hard block.) +CREATE FUNCTION channels_community_id_immutable() RETURNS TRIGGER AS $$ +BEGIN + IF NEW.community_id IS DISTINCT FROM OLD.community_id THEN + RAISE EXCEPTION 'channels.community_id is immutable (channel % cannot be re-tenanted)', OLD.id + USING ERRCODE = 'check_violation'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER trg_channels_community_id_immutable + BEFORE UPDATE ON channels + FOR EACH ROW EXECUTE FUNCTION channels_community_id_immutable(); + -- ── Channel members ─────────────────────────────────────────────────────────── +-- Conformance: "Channels and channel membership". PK leads with community_id. CREATE TABLE channel_members ( - channel_id UUID NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + community_id UUID NOT NULL REFERENCES communities(id), + channel_id UUID NOT NULL, pubkey BYTEA NOT NULL, role member_role NOT NULL DEFAULT 'member', joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), @@ -64,35 +139,56 @@ CREATE TABLE channel_members ( removed_at TIMESTAMPTZ, removed_by BYTEA, hidden_at TIMESTAMPTZ, - PRIMARY KEY (channel_id, pubkey) + PRIMARY KEY (community_id, channel_id, pubkey), + FOREIGN KEY (community_id, channel_id) + REFERENCES channels (community_id, id) ON DELETE CASCADE ); -CREATE INDEX idx_channel_members_pubkey ON channel_members (pubkey) +CREATE INDEX idx_channel_members_pubkey ON channel_members (community_id, pubkey) WHERE removed_at IS NULL; -- ── Users ───────────────────────────────────────────────────────────────────── +-- Conformance: "Users, profiles, NIP-05, and user search". One profile per +-- (community, pubkey): the same key reposts kind:0 in each community it joins. CREATE TABLE users ( - pubkey BYTEA PRIMARY KEY, - nip05_handle VARCHAR(255) UNIQUE, + community_id UUID NOT NULL REFERENCES communities(id), + pubkey BYTEA NOT NULL, + nip05_handle VARCHAR(255), display_name VARCHAR(255), avatar_url TEXT, about TEXT, agent_type VARCHAR(255), capabilities JSONB, - okta_user_id VARCHAR(255) UNIQUE, + okta_user_id VARCHAR(255), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deactivated_at TIMESTAMPTZ, metadata_event_id BYTEA, - agent_owner_pubkey BYTEA REFERENCES users(pubkey) ON DELETE SET NULL, + agent_owner_pubkey BYTEA, channel_add_policy channel_add_policy NOT NULL DEFAULT 'anyone', - CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32) + PRIMARY KEY (community_id, pubkey), + CONSTRAINT chk_users_pubkey_len CHECK (LENGTH(pubkey) = 32), + -- agent owner is a user in the SAME community. + FOREIGN KEY (community_id, agent_owner_pubkey) + REFERENCES users (community_id, pubkey) ON DELETE SET NULL ); +-- NIP-05 handle and Okta id unique within a community, not globally. +CREATE UNIQUE INDEX idx_users_nip05 ON users (community_id, lower(nip05_handle)) + WHERE nip05_handle IS NOT NULL; +CREATE UNIQUE INDEX idx_users_okta ON users (community_id, okta_user_id) + WHERE okta_user_id IS NOT NULL; + -- ── Events (partitioned by month on created_at) ────────────────────────────── +-- Conformance: "Channel-less global events and DMs". `community_id` leads the +-- PK and every hot-path index. Partition stays BY RANGE (created_at) — the +-- monthly partition manager is unchanged (Max's call, plan §5/Lane0 contract). +-- Cross-community dedup: same signed event may exist in two communities; +-- (community_id, created_at, id) dedupes within one, allows across. CREATE TABLE events ( + community_id UUID NOT NULL REFERENCES communities(id), id BYTEA NOT NULL, pubkey BYTEA NOT NULL, created_at TIMESTAMPTZ NOT NULL, @@ -104,7 +200,9 @@ CREATE TABLE events ( channel_id UUID, deleted_at TIMESTAMPTZ, d_tag TEXT, - PRIMARY KEY (created_at, id) + not_before BIGINT, + delivered_at BIGINT, + PRIMARY KEY (community_id, created_at, id) ) PARTITION BY RANGE (created_at); CREATE TABLE events_p_past PARTITION OF events @@ -124,33 +222,55 @@ CREATE TABLE events_p2026_06 PARTITION OF events CREATE TABLE events_p_future PARTITION OF events FOR VALUES FROM ('2026-07-01') TO (MAXVALUE); -CREATE INDEX idx_events_pubkey_kind_created ON events (pubkey, kind, created_at); -CREATE INDEX idx_events_channel_created ON events (channel_id, created_at); -CREATE INDEX idx_events_kind_created ON events (kind, created_at); -CREATE INDEX idx_events_id ON events (id); -CREATE INDEX idx_events_deleted ON events (deleted_at); -CREATE INDEX idx_events_addressable ON events (kind, pubkey, channel_id, deleted_at); -CREATE INDEX idx_events_parameterized ON events (kind, pubkey, d_tag, deleted_at) WHERE d_tag IS NOT NULL; +-- Direct id lookup: the PK can't serve `WHERE id=$1` because created_at sits +-- between community_id and id. This index makes the scoped form +-- `WHERE community_id=$ AND id=$` index-served, not a partition scan. +CREATE INDEX idx_events_community_id ON events (community_id, id, created_at DESC); +-- Hot-path indexes, all community-leading. +CREATE INDEX idx_events_community_channel_created + ON events (community_id, channel_id, created_at DESC, id); +CREATE INDEX idx_events_community_pubkey_kind_created + ON events (community_id, pubkey, kind, created_at DESC, id); +CREATE INDEX idx_events_community_kind_created + ON events (community_id, kind, created_at DESC, id); +CREATE INDEX idx_events_community_deleted ON events (community_id, deleted_at); +-- Addressable (replaceable) and NIP-33 parameterized lookups. +CREATE INDEX idx_events_addressable + ON events (community_id, kind, pubkey, channel_id, deleted_at); +CREATE INDEX idx_events_parameterized + ON events (community_id, kind, pubkey, d_tag, created_at DESC, id) + WHERE d_tag IS NOT NULL AND deleted_at IS NULL; +CREATE INDEX idx_events_not_before ON events (community_id, not_before) + WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL; -- ── Event mentions ──────────────────────────────────────────────────────────── +-- Conformance: "Channel-less global events and DMs" (#p fan-out). The join to +-- events MUST carry the community tuple (e.community_id = m.community_id AND +-- e.id = m.event_id) — bare e.id = m.event_id would leak cross-community +-- mentions (Max, verified at event.rs:222). CREATE TABLE event_mentions ( + community_id UUID NOT NULL REFERENCES communities(id), pubkey_hex VARCHAR(64) NOT NULL, event_id BYTEA NOT NULL, event_created_at TIMESTAMPTZ NOT NULL, channel_id UUID, event_kind INT, - PRIMARY KEY (pubkey_hex, event_id) + PRIMARY KEY (community_id, pubkey_hex, event_id) ); -CREATE INDEX idx_event_mentions_pubkey_created ON event_mentions (pubkey_hex, event_created_at DESC); -CREATE INDEX idx_event_mentions_pubkey_kind_created ON event_mentions (pubkey_hex, event_kind, event_created_at DESC); +CREATE INDEX idx_event_mentions_pubkey_created + ON event_mentions (community_id, pubkey_hex, event_created_at DESC); +CREATE INDEX idx_event_mentions_pubkey_kind_created + ON event_mentions (community_id, pubkey_hex, event_kind, event_created_at DESC); -- ── Subscriptions ───────────────────────────────────────────────────────────── +-- Conformance: "Mesh, agents, ACP/MCP, and CLI" (persisted subscriptions). CREATE TABLE subscriptions ( - id VARCHAR(255) PRIMARY KEY, - owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey), + community_id UUID NOT NULL REFERENCES communities(id), + id VARCHAR(255) NOT NULL, + owner_pubkey BYTEA NOT NULL, filter_kinds JSONB, filter_authors JSONB, filter_channel_ids JSONB, @@ -163,12 +283,17 @@ CREATE TABLE subscriptions ( delivered_count BIGINT NOT NULL DEFAULT 0, error_count BIGINT NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, id), + FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey) ); -- ── Delivery log (partitioned by month on delivered_at) ────────────────────── +-- Conformance: subscription delivery audit. community_id carried for tenant +-- attribution; child of subscriptions. CREATE TABLE delivery_log ( + community_id UUID NOT NULL REFERENCES communities(id), id BIGINT GENERATED ALWAYS AS IDENTITY, subscription_id VARCHAR(255), event_id BYTEA, @@ -194,28 +319,40 @@ CREATE TABLE delivery_log_p2026_06 PARTITION OF delivery_log CREATE TABLE delivery_log_p_future PARTITION OF delivery_log FOR VALUES FROM ('2026-07-01') TO (MAXVALUE); +CREATE INDEX idx_delivery_log_community_sub ON delivery_log (community_id, subscription_id); + -- ── Workflows ───────────────────────────────────────────────────────────────── +-- Conformance: "Workflows, runs, approvals, webhooks, schedules". Definition's +-- community fixed at create from req.community; runs/approvals inherit it. CREATE TABLE workflows ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), name VARCHAR(255) NOT NULL, - owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey), - channel_id UUID REFERENCES channels(id), + owner_pubkey BYTEA NOT NULL, + channel_id UUID, definition JSONB NOT NULL, definition_hash BYTEA NOT NULL, status workflow_status NOT NULL DEFAULT 'active', enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, id), + FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey), + FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) ); -CREATE INDEX idx_workflows_channel_active ON workflows (channel_id, status, enabled); +CREATE INDEX idx_workflows_channel_active ON workflows (community_id, channel_id, status, enabled); +-- Scheduler scans enabled schedule workflows; community_id returned per row so +-- side effects run under the owning tenant's context (Lane0 contract §4a.5). +CREATE INDEX idx_workflows_enabled ON workflows (enabled, status) WHERE enabled; -- ── Workflow runs ───────────────────────────────────────────────────────────── CREATE TABLE workflow_runs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + workflow_id UUID NOT NULL, status run_status NOT NULL DEFAULT 'pending', trigger_event_id BYTEA, current_step INT NOT NULL DEFAULT 0, @@ -224,18 +361,24 @@ CREATE TABLE workflow_runs ( started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, error_message TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, id), + FOREIGN KEY (community_id, workflow_id) + REFERENCES workflows (community_id, id) ON DELETE CASCADE ); -CREATE INDEX idx_workflow_runs_workflow ON workflow_runs (workflow_id); -CREATE INDEX idx_workflow_runs_status ON workflow_runs (status); +CREATE INDEX idx_workflow_runs_workflow ON workflow_runs (community_id, workflow_id); +CREATE INDEX idx_workflow_runs_status ON workflow_runs (community_id, status); -- ── Workflow approvals ──────────────────────────────────────────────────────── +-- token-hash lookup scoped: approval token grants cannot act on another +-- community's same hash (conformance). CREATE TABLE workflow_approvals ( - token BYTEA PRIMARY KEY, - workflow_id UUID NOT NULL REFERENCES workflows(id) ON DELETE CASCADE, - run_id UUID NOT NULL REFERENCES workflow_runs(id) ON DELETE CASCADE, + community_id UUID NOT NULL REFERENCES communities(id), + token BYTEA NOT NULL, + workflow_id UUID NOT NULL, + run_id UUID NOT NULL, step_id VARCHAR(64) NOT NULL, step_index INT NOT NULL, approver_spec TEXT NOT NULL, @@ -245,19 +388,47 @@ CREATE TABLE workflow_approvals ( granted_at TIMESTAMPTZ, denied_at TIMESTAMPTZ, expires_at TIMESTAMPTZ NOT NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, token), + FOREIGN KEY (community_id, workflow_id) + REFERENCES workflows (community_id, id) ON DELETE CASCADE, + FOREIGN KEY (community_id, run_id) + REFERENCES workflow_runs (community_id, id) ON DELETE CASCADE +); + +CREATE INDEX idx_workflow_approvals_workflow ON workflow_approvals (community_id, workflow_id); +CREATE INDEX idx_workflow_approvals_run ON workflow_approvals (community_id, run_id); +CREATE INDEX idx_workflow_approvals_status ON workflow_approvals (community_id, status); + +-- ── Scheduled workflow fires (cron claim) ───────────────────────────────────── +-- Plan §5: the at-most-once cron fire claim. UNIQUE (community_id, workflow_id, +-- scheduled_for) — only the pod that wins the claim insert creates the run. +-- Restart-safe (DB-durable). community resolved server-side from workflow_id, +-- never a caller-supplied claim parameter (S1 tenant binding). + +CREATE TABLE scheduled_workflow_fires ( + community_id UUID NOT NULL REFERENCES communities(id), + workflow_id UUID NOT NULL, + scheduled_for TIMESTAMPTZ NOT NULL, + claimed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, workflow_id, scheduled_for), + FOREIGN KEY (community_id, workflow_id) + REFERENCES workflows (community_id, id) ON DELETE CASCADE ); -CREATE INDEX idx_workflow_approvals_workflow ON workflow_approvals (workflow_id); -CREATE INDEX idx_workflow_approvals_run ON workflow_approvals (run_id); -CREATE INDEX idx_workflow_approvals_status ON workflow_approvals (status); +-- The interval anchor reads MAX(scheduled_for) per workflow; the janitor prunes +-- by claimed_at globally (operator concern). See plan §5 retention coupling. +CREATE INDEX idx_scheduled_fires_claimed_at ON scheduled_workflow_fires (claimed_at); -- ── API tokens ──────────────────────────────────────────────────────────────── +-- Conformance: "API tokens and NIP-98 replay". token_hash uniqueness scoped to +-- (community_id, token_hash); channel claims reference channels in same community. CREATE TABLE api_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - token_hash BYTEA NOT NULL UNIQUE, - owner_pubkey BYTEA NOT NULL REFERENCES users(pubkey), + community_id UUID NOT NULL REFERENCES communities(id), + id UUID NOT NULL DEFAULT gen_random_uuid(), + token_hash BYTEA NOT NULL, + owner_pubkey BYTEA NOT NULL, name VARCHAR(255) NOT NULL, scopes JSONB NOT NULL, channel_ids JSONB, @@ -267,13 +438,21 @@ CREATE TABLE api_tokens ( revoked_at TIMESTAMPTZ, revoked_by BYTEA, created_by_self_mint BOOLEAN NOT NULL DEFAULT FALSE, + PRIMARY KEY (community_id, id), + FOREIGN KEY (community_id, owner_pubkey) REFERENCES users (community_id, pubkey), CONSTRAINT chk_api_tokens_hash_len CHECK (LENGTH(token_hash) = 32) ); +CREATE UNIQUE INDEX idx_api_tokens_hash ON api_tokens (community_id, token_hash); + -- ── Rate limit violations ───────────────────────────────────────────────────── +-- OPERATOR-GLOBAL: a deployment-health / abuse table, never tenant-observable. +-- Listed in the lint allowlist. Carries community_id as an attribution label +-- only (nullable, no uniqueness over it). CREATE TABLE rate_limit_violations ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + community_id UUID, pubkey BYTEA, violation_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), limit_type VARCHAR(64), @@ -283,11 +462,13 @@ CREATE TABLE rate_limit_violations ( ); -- ── Thread metadata ─────────────────────────────────────────────────────────── +-- Conformance: thread lookups filter by community before event matching. CREATE TABLE thread_metadata ( + community_id UUID NOT NULL REFERENCES communities(id), event_created_at TIMESTAMPTZ NOT NULL, event_id BYTEA NOT NULL, - channel_id UUID NOT NULL REFERENCES channels(id), + channel_id UUID NOT NULL, parent_event_id BYTEA, parent_event_created_at TIMESTAMPTZ, root_event_id BYTEA, @@ -297,17 +478,21 @@ CREATE TABLE thread_metadata ( descendant_count INT NOT NULL DEFAULT 0, last_reply_at TIMESTAMPTZ, broadcast BOOLEAN NOT NULL DEFAULT FALSE, - PRIMARY KEY (event_created_at, event_id) + PRIMARY KEY (community_id, event_created_at, event_id), + FOREIGN KEY (community_id, channel_id) REFERENCES channels (community_id, id) ); -CREATE INDEX idx_thread_metadata_parent ON thread_metadata (parent_event_id); -CREATE INDEX idx_thread_metadata_root ON thread_metadata (root_event_id); -CREATE INDEX idx_thread_metadata_channel_depth ON thread_metadata (channel_id, depth, event_created_at); -CREATE INDEX idx_thread_metadata_event_id ON thread_metadata (event_id); +CREATE INDEX idx_thread_metadata_parent ON thread_metadata (community_id, parent_event_id); +CREATE INDEX idx_thread_metadata_root ON thread_metadata (community_id, root_event_id); +CREATE INDEX idx_thread_metadata_channel_depth + ON thread_metadata (community_id, channel_id, depth, event_created_at); +CREATE INDEX idx_thread_metadata_event_id ON thread_metadata (community_id, event_id); -- ── Reactions ───────────────────────────────────────────────────────────────── +-- Conformance: reactions filter by community before event/pubkey matching. CREATE TABLE reactions ( + community_id UUID NOT NULL REFERENCES communities(id), event_created_at TIMESTAMPTZ NOT NULL, event_id BYTEA NOT NULL, pubkey BYTEA NOT NULL, @@ -315,42 +500,92 @@ CREATE TABLE reactions ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), removed_at TIMESTAMPTZ, reaction_event_id BYTEA, - PRIMARY KEY (event_created_at, event_id, pubkey, emoji) + PRIMARY KEY (community_id, event_created_at, event_id, pubkey, emoji) ); -CREATE INDEX idx_reactions_event ON reactions (event_id, event_created_at); -CREATE INDEX idx_reactions_pubkey ON reactions (pubkey); -CREATE UNIQUE INDEX idx_reactions_source_event ON reactions (reaction_event_id); +CREATE INDEX idx_reactions_event ON reactions (community_id, event_id, event_created_at); +CREATE INDEX idx_reactions_pubkey ON reactions (community_id, pubkey); +-- A reaction's source event id is unique within a community. +CREATE UNIQUE INDEX idx_reactions_source_event ON reactions (community_id, reaction_event_id) + WHERE reaction_event_id IS NOT NULL; -- ── Pubkey allowlist ────────────────────────────────────────────────────────── +-- Conformance: "Relay membership, pubkey allowlist, archived identities". +-- PK becomes (community_id, pubkey). CREATE TABLE pubkey_allowlist ( - pubkey BYTEA PRIMARY KEY, + community_id UUID NOT NULL REFERENCES communities(id), + pubkey BYTEA NOT NULL, added_by BYTEA, added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - note TEXT + note TEXT, + PRIMARY KEY (community_id, pubkey) ); -- ── Relay members (NIP-43) ──────────────────────────────────────────────────── +-- Conformance: membership gate, community-scoped. pubkey stored as hex TEXT +-- (unchanged wire form). PK (community_id, pubkey). -CREATE TABLE IF NOT EXISTS relay_members ( - pubkey TEXT PRIMARY KEY, +CREATE TABLE relay_members ( + community_id UUID NOT NULL REFERENCES communities(id), + pubkey TEXT NOT NULL, role TEXT NOT NULL CHECK (role IN ('owner', 'admin', 'member')), added_by TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, pubkey) ); -CREATE INDEX IF NOT EXISTS idx_relay_members_role ON relay_members(role); +CREATE INDEX idx_relay_members_role ON relay_members (community_id, role); -- ── Archived identities (NIP-IA) ────────────────────────────────────────────── +-- Conformance: archive cannot hide a key in another community. PK scoped. -CREATE TABLE IF NOT EXISTS archived_identities ( - pubkey TEXT PRIMARY KEY, +CREATE TABLE archived_identities ( + community_id UUID NOT NULL REFERENCES communities(id), + pubkey TEXT NOT NULL, consent_path TEXT NOT NULL CHECK (consent_path IN ('self', 'owner', 'admin')), actor TEXT NOT NULL, reason TEXT, replaced_by TEXT, request_event_id TEXT NOT NULL, - archived_at TIMESTAMPTZ NOT NULL DEFAULT now() + archived_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (community_id, pubkey) ); + +-- ── Audit log ───────────────────────────────────────────────────────────────── +-- Conformance: "Audit log and observability". Per-community hash chain: +-- uniqueness (community_id, seq) and (community_id, hash). One chain per tenant. +-- (Lane Audit/Dawn builds the chain logic; Lane 0 fixes the scoped schema.) + +CREATE TABLE audit_log ( + community_id UUID NOT NULL REFERENCES communities(id), + seq BIGINT NOT NULL, + hash BYTEA NOT NULL, + prev_hash BYTEA, + action VARCHAR(64) NOT NULL, + actor_pubkey BYTEA, + object_id TEXT, + detail JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (community_id, seq) +); + +CREATE UNIQUE INDEX idx_audit_log_hash ON audit_log (community_id, hash); + +-- ── Lint allowlist registry ─────────────────────────────────────────────────── +-- The explicit registry of tables that are deliberately operator-global (NOT +-- tenant-scoped). The migration-lint harness reads this: any table NOT listed +-- here MUST carry a NOT NULL community_id and lead its uniques with it. Making +-- the allowlist a DB table (not a hard-coded list in the linter) keeps the +-- registry next to the schema it governs and reviewable in one migration diff. + +CREATE TABLE _operator_global_tables ( + table_name TEXT PRIMARY KEY, + reason TEXT NOT NULL +); + +INSERT INTO _operator_global_tables (table_name, reason) VALUES + ('communities', 'the tenant registry itself; id IS the community key'), + ('rate_limit_violations', 'deployment abuse/health; never tenant-observable; community_id is an attribution label only'), + ('_operator_global_tables', 'the registry table itself'); From 4b7654a1c39f3ee43b20d42c3fd2856ebb4dae06 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:36:57 -0400 Subject: [PATCH 003/100] =?UTF-8?q?feat(lane0):=20fold=20review=20round=20?= =?UTF-8?q?2=20=E2=80=94=20FTS=20column,=20drop=20stale=20migrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last Lane-0 schema items before the frozen base: - events.search_tsv TSVECTOR GENERATED ALWAYS AS to_tsvector('simple', content) STORED + GIN idx_events_search_tsv. The Typesense->Postgres FTS data shape, landed in Lane 0 because it touches the just-locked events table (Quinn option A). GENERATED ALWAYS = single source of truth: proven against PG that a client cannot forge search_tsv out of sync with content (generated_always rejection). Index left minimal single-column GIN; the search lane picks the final spelling after EXPLAIN (Max's caveat). - Delete stale 0002_backfill_d_tag.sql / 0003_event_reminders.sql. In the consolidated-from-scratch model 0001 already carries d_tag, not_before, delivered_at, and idx_events_not_before; re-running the old additive migrations would error (duplicate column / duplicate index name). audit_log DDL shape confirmed for the audit-crate collapse (Dawn's lane): PRIMARY KEY (community_id, seq), UNIQUE (community_id, hash), community_id NOT NULL on every row. 0001 is the single source; buzz-audit drops its own schema.rs / AUDIT_SCHEMA_SQL / ensure_schema() in the audit lane. Re-proven against real Postgres — full fence suite green: T1 re-tenant rejected, T6 cross-community member FK rejected, T6b same-community ok, T7 same channel UUID in two communities allowed, T8 host case-collision rejected, T9 same event id in two communities allowed, plus the FTS generated+GIN match and the forge-rejection. buzz-core: 189 + 2 doctests. Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- migrations/0001_initial_schema.sql | 14 ++++++++++++++ migrations/0002_backfill_d_tag.sql | 17 ----------------- migrations/0003_event_reminders.sql | 17 ----------------- 3 files changed, 14 insertions(+), 34 deletions(-) delete mode 100644 migrations/0002_backfill_d_tag.sql delete mode 100644 migrations/0003_event_reminders.sql diff --git a/migrations/0001_initial_schema.sql b/migrations/0001_initial_schema.sql index edda65c77..041a452da 100644 --- a/migrations/0001_initial_schema.sql +++ b/migrations/0001_initial_schema.sql @@ -195,6 +195,15 @@ CREATE TABLE events ( kind INT NOT NULL, tags JSONB NOT NULL, content TEXT NOT NULL, + -- Full-text search vector (Typesense → Postgres FTS). Generated/STORED so + -- it is a single source of truth — no sidecar indexer to keep coherent + -- (Quinn option A, Lane-0 call). 'simple' config = no stemming/stopwords, + -- matching the existing substring-ish search semantics; the search lane can + -- revisit the config behind evidence. Tenant scoping is by the + -- community-leading btree filters BitmapAnd-ed with the GIN probe, so the + -- GIN index itself stays the minimal `GIN (search_tsv)` (Max's caveat: + -- avoid btree_gin unless EXPLAIN proves it buys something). + search_tsv TSVECTOR GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED, sig BYTEA NOT NULL, received_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), channel_id UUID, @@ -242,6 +251,11 @@ CREATE INDEX idx_events_parameterized WHERE d_tag IS NOT NULL AND deleted_at IS NULL; CREATE INDEX idx_events_not_before ON events (community_id, not_before) WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL; +-- Full-text search. Minimal GIN over the generated tsvector; community scoping +-- is supplied by the community-leading btree filters above (BitmapAnd), so this +-- stays a single-column GIN. The search lane confirms the final spelling with +-- EXPLAIN before its work lands (Quinn option A; Max's index-spelling caveat). +CREATE INDEX idx_events_search_tsv ON events USING GIN (search_tsv); -- ── Event mentions ──────────────────────────────────────────────────────────── -- Conformance: "Channel-less global events and DMs" (#p fan-out). The join to diff --git a/migrations/0002_backfill_d_tag.sql b/migrations/0002_backfill_d_tag.sql deleted file mode 100644 index 74301271f..000000000 --- a/migrations/0002_backfill_d_tag.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Backfill d_tag for existing NIP-33 range events (kind 30000–39999). --- Idempotent: only updates rows where d_tag is still NULL. --- Includes soft-deleted rows so the column is fully populated. --- Run once after adding the d_tag column to the events table. --- --- Managed by sqlx migrations. - -UPDATE events -SET d_tag = COALESCE( - (SELECT elem->>1 - FROM jsonb_array_elements(tags) AS elem - WHERE elem->>0 = 'd' - LIMIT 1), - '' -) -WHERE kind BETWEEN 30000 AND 39999 - AND d_tag IS NULL; diff --git a/migrations/0003_event_reminders.sql b/migrations/0003_event_reminders.sql deleted file mode 100644 index 846928c30..000000000 --- a/migrations/0003_event_reminders.sql +++ /dev/null @@ -1,17 +0,0 @@ --- Add NIP-ER event-reminder columns and due-delivery index to the events table. --- --- `not_before` is the reminder's scheduled delivery time (Unix seconds); --- `delivered_at` records when the scheduler published it. Both are nullable — --- non-reminder events leave them NULL. The partial index covers only --- undelivered, live reminders so the scheduler's due-query stays cheap. --- --- `events` is partitioned by RANGE (created_at); ALTER TABLE on the parent --- cascades the columns to every partition, and CREATE INDEX on the parent --- builds a partitioned index that propagates to each partition. --- --- Managed by sqlx migrations. - -ALTER TABLE events ADD COLUMN not_before BIGINT; -ALTER TABLE events ADD COLUMN delivered_at BIGINT; -CREATE INDEX idx_events_not_before ON events (not_before) - WHERE not_before IS NOT NULL AND deleted_at IS NULL AND delivered_at IS NULL; From b7f249cd20500e50227b7dc7c27eff9f5d1d2ee6 Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:36:57 -0400 Subject: [PATCH 004/100] fix(db): scope relay rows by community Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- crates/buzz-db/src/channel.rs | 239 ++++-- crates/buzz-db/src/event.rs | 112 ++- crates/buzz-db/src/lib.rs | 297 ++++++- crates/buzz-db/src/migration.rs | 793 +++++++++++++----- crates/buzz-db/src/thread.rs | 77 +- crates/buzz-db/src/user.rs | 156 ++-- crates/buzz-db/src/workflow.rs | 414 ++++++++- .../src/handlers/command_executor.rs | 12 +- 8 files changed, 1731 insertions(+), 369 deletions(-) diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index a9cecc141..ef2a1fa8e 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 @@ -84,6 +85,7 @@ pub struct MemberRecord { /// Creates a new channel, bootstraps the creator as owner, and returns the record. pub async fn create_channel( pool: &PgPool, + community_id: CommunityId, name: &str, channel_type: ChannelType, visibility: ChannelVisibility, @@ -104,12 +106,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 +124,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 +148,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 +168,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 +194,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 +218,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 +243,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?; @@ -305,6 +314,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, @@ -330,7 +340,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 +364,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 +382,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 +401,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 +427,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 +436,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 +445,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 +456,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 +478,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) @@ -619,13 +634,15 @@ pub async fn list_channels(pool: &PgPool, visibility: Option<&str>) -> 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) @@ -1235,28 +1252,97 @@ 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 + } + + 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, id).await + } + /// 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,15 +1354,29 @@ 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"); @@ -1295,11 +1395,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,8 +1416,9 @@ 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 @@ -1348,25 +1454,34 @@ mod tests { #[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 +1493,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/event.rs b/crates/buzz-db/src/event.rs index 9cef6c53c..29a23b51f 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). @@ -123,6 +125,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 +153,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 +224,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 { "" }; @@ -842,6 +858,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 +888,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 +917,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 +950,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 +973,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 +994,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 +1007,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?; @@ -1083,7 +1106,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 +1126,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,6 +1135,35 @@ 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::*; diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 9dd4d3e10..6b0a7199c 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,101 @@ 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() + } + /// 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}"); } } @@ -322,15 +419,21 @@ impl Db { /// 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}"); } } @@ -340,6 +443,7 @@ impl Db { /// Creates a new channel, bootstraps the creator as owner, and returns the record. pub async fn create_channel( &self, + community_id: CommunityId, name: &str, channel_type: channel::ChannelType, visibility: channel::ChannelVisibility, @@ -349,6 +453,7 @@ impl Db { ) -> Result { channel::create_channel( &self.pool, + community_id, name, channel_type, visibility, @@ -365,6 +470,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 +481,7 @@ impl Db { ) -> Result<(channel::ChannelRecord, bool)> { channel::create_channel_with_id( &self.pool, + community_id, channel_id, name, channel_type, @@ -404,22 +511,32 @@ impl Db { /// 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. @@ -553,19 +670,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 +717,7 @@ impl Db { ) -> Result<()> { user::update_user_profile( &self.pool, + community_id, pubkey, display_name, avatar_url, @@ -586,43 +730,61 @@ 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. @@ -1088,6 +1250,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 +1259,7 @@ impl Db { ) -> Result { workflow::create_workflow( &self.pool, + community_id, channel_id, owner_pubkey, name, @@ -1133,6 +1297,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, @@ -1487,6 +1696,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 +1711,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 +1745,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 +1777,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 +1795,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 +1828,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 +1857,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 +1872,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 +1901,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 +1928,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 +1944,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 +1975,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}"); } 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..2c7b07912 100644 --- a/crates/buzz-db/src/thread.rs +++ b/crates/buzz-db/src/thread.rs @@ -659,7 +659,7 @@ pub async fn get_thread_metadata_by_event( mod tests { use super::*; use crate::{ - channel::{create_channel, ChannelType, ChannelVisibility}, + channel::{ChannelType, ChannelVisibility}, event::{insert_event_with_thread_metadata, ThreadMetadataParams}, }; use nostr::{EventBuilder, Keys, Kind}; @@ -687,12 +687,76 @@ mod tests { .expect("event timestamp is valid") } + async fn make_test_community(pool: &PgPool) -> Uuid { + let id = Uuid::new_v4(); + let host = format!("thread-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + id + } + + async fn create_test_channel( + pool: &PgPool, + name: &str, + channel_type: ChannelType, + visibility: ChannelVisibility, + description: Option<&str>, + created_by: &[u8], + ttl_seconds: Option, + ) -> crate::error::Result<(crate::channel::ChannelRecord, buzz_core::CommunityId)> { + let id = Uuid::new_v4(); + let community_id = make_test_community(pool).await; + + sqlx::query( + r#" + INSERT INTO channels + (id, community_id, name, channel_type, visibility, description, created_by, ttl_seconds, ttl_deadline) + VALUES + ($1, $2, $3, $4::channel_type, $5::channel_visibility, $6, $7, $8, + CASE WHEN $8 IS NOT NULL THEN NOW() + ($8 || ' seconds')::interval ELSE NULL END) + "#, + ) + .bind(id) + .bind(community_id) + .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"); + + crate::channel::get_channel(pool, id) + .await + .map(|channel| (channel, buzz_core::CommunityId::from_uuid(community_id))) + } + #[tokio::test] #[ignore = "requires Postgres"] async fn get_thread_replies_reconstructs_stored_events() { let pool = setup_pool().await; let author = Keys::generate(); - let channel = create_channel( + let (channel, community) = create_test_channel( &pool, &format!("thread-replies-{}", Uuid::new_v4()), ChannelType::Stream, @@ -706,7 +770,7 @@ mod tests { let root = make_stream_event(&author, "root"); let root_created_at = event_created_at(&root); - insert_event_with_thread_metadata(&pool, &root, Some(channel.id), None) + insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None) .await .expect("insert root event"); @@ -715,6 +779,7 @@ mod tests { let reply_id = reply.id.to_hex(); insert_event_with_thread_metadata( &pool, + community, &reply, Some(channel.id), Some(ThreadMetadataParams { @@ -752,7 +817,7 @@ mod tests { async fn get_thread_replies_skips_unreconstructable_row() { let pool = setup_pool().await; let author = Keys::generate(); - let channel = create_channel( + let (channel, community) = create_test_channel( &pool, &format!("thread-replies-corrupt-{}", Uuid::new_v4()), ChannelType::Stream, @@ -766,7 +831,7 @@ mod tests { let root = make_stream_event(&author, "root"); let root_created_at = event_created_at(&root); - insert_event_with_thread_metadata(&pool, &root, Some(channel.id), None) + insert_event_with_thread_metadata(&pool, community, &root, Some(channel.id), None) .await .expect("insert root event"); @@ -776,6 +841,7 @@ mod tests { let good_created_at = event_created_at(&good); insert_event_with_thread_metadata( &pool, + community, &good, Some(channel.id), Some(ThreadMetadataParams { @@ -797,6 +863,7 @@ mod tests { let bad_created_at = event_created_at(&bad); insert_event_with_thread_metadata( &pool, + community, &bad, Some(channel.id), Some(ThreadMetadataParams { diff --git a/crates/buzz-db/src/user.rs b/crates/buzz-db/src/user.rs index 1e1eaf42a..d4c9395bc 100644 --- a/crates/buzz-db/src/user.rs +++ b/crates/buzz-db/src/user.rs @@ -1,6 +1,7 @@ //! User CRUD operations. use crate::error::Result; +use buzz_core::CommunityId; use sqlx::PgPool; use sqlx::Row; @@ -34,14 +35,15 @@ pub struct UserSearchProfile { /// Ensure a user record exists for the given pubkey (upsert). /// Creates with minimal fields if not present; no-op if already exists. -pub async fn ensure_user(pool: &PgPool, pubkey: &[u8]) -> Result<()> { +pub async fn ensure_user(pool: &PgPool, community_id: CommunityId, pubkey: &[u8]) -> Result<()> { sqlx::query( r#" - INSERT INTO users (pubkey) - VALUES ($1) + INSERT INTO users (community_id, pubkey) + VALUES ($1, $2) ON CONFLICT DO NOTHING "#, ) + .bind(community_id.as_uuid()) .bind(pubkey) .execute(pool) .await?; @@ -49,7 +51,11 @@ pub async fn ensure_user(pool: &PgPool, pubkey: &[u8]) -> Result<()> { } /// Get a single user record by pubkey. -pub async fn get_user(pool: &PgPool, pubkey: &[u8]) -> Result> { +pub async fn get_user( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], +) -> Result> { let row = sqlx::query_as::< _, ( @@ -63,9 +69,10 @@ pub async fn get_user(pool: &PgPool, pubkey: &[u8]) -> Result Result, avatar_url: Option<&str>, @@ -129,8 +137,9 @@ pub async fn update_user_profile( } let sql = format!( - "UPDATE users SET {} WHERE pubkey = ${param_idx}", - set_parts.join(", ") + "UPDATE users SET {} WHERE community_id = ${param_idx} AND pubkey = ${}", + set_parts.join(", "), + param_idx + 1 ); let mut query = sqlx::query(sqlx::AssertSqlSafe(sql)); if display_name.is_some() { @@ -145,6 +154,7 @@ pub async fn update_user_profile( if nip05_handle.is_some() { query = query.bind(empty_to_none(nip05_handle)); } + query = query.bind(community_id.as_uuid()); query = query.bind(pubkey); query.execute(pool).await?; Ok(()) @@ -154,6 +164,7 @@ pub async fn update_user_profile( /// Both `local_part` and `domain` must already be lowercased by the caller. pub async fn get_user_by_nip05( pool: &PgPool, + community_id: CommunityId, local_part: &str, domain: &str, ) -> Result> { @@ -171,10 +182,11 @@ pub async fn get_user_by_nip05( r#" SELECT pubkey, display_name, avatar_url, about, nip05_handle FROM users - WHERE LOWER(nip05_handle) = LOWER($1) + WHERE community_id = $1 AND LOWER(nip05_handle) = LOWER($2) LIMIT 1 "#, ) + .bind(community_id.as_uuid()) .bind(&handle) .fetch_optional(pool) .await?; @@ -207,6 +219,7 @@ fn escape_like(input: &str) -> String { /// Empty queries return an empty vec and do not hit the database. pub async fn search_users( pool: &PgPool, + community_id: CommunityId, query: &str, limit: u32, ) -> Result> { @@ -224,23 +237,25 @@ pub async fn search_users( r#" SELECT pubkey, display_name, avatar_url, nip05_handle FROM users - WHERE LOWER(COALESCE(display_name, '')) LIKE $1 ESCAPE '\' - OR LOWER(COALESCE(nip05_handle, '')) LIKE $1 ESCAPE '\' - OR LOWER(encode(pubkey, 'hex')) LIKE $1 ESCAPE '\' + WHERE community_id = $1 + AND (LOWER(COALESCE(display_name, '')) LIKE $2 ESCAPE '\' + OR LOWER(COALESCE(nip05_handle, '')) LIKE $2 ESCAPE '\' + OR LOWER(encode(pubkey, 'hex')) LIKE $2 ESCAPE '\') ORDER BY CASE - WHEN LOWER(COALESCE(display_name, '')) = $2 THEN 0 - WHEN LOWER(COALESCE(nip05_handle, '')) = $2 THEN 1 - WHEN LOWER(encode(pubkey, 'hex')) = $2 THEN 2 - WHEN LOWER(COALESCE(display_name, '')) LIKE $3 ESCAPE '\' THEN 3 - WHEN LOWER(COALESCE(nip05_handle, '')) LIKE $3 ESCAPE '\' THEN 4 - WHEN LOWER(encode(pubkey, 'hex')) LIKE $3 ESCAPE '\' THEN 5 + WHEN LOWER(COALESCE(display_name, '')) = $3 THEN 0 + WHEN LOWER(COALESCE(nip05_handle, '')) = $3 THEN 1 + WHEN LOWER(encode(pubkey, 'hex')) = $3 THEN 2 + WHEN LOWER(COALESCE(display_name, '')) LIKE $4 ESCAPE '\' THEN 3 + WHEN LOWER(COALESCE(nip05_handle, '')) LIKE $4 ESCAPE '\' THEN 4 + WHEN LOWER(encode(pubkey, 'hex')) LIKE $4 ESCAPE '\' THEN 5 ELSE 6 END, COALESCE(NULLIF(display_name, ''), NULLIF(nip05_handle, ''), LOWER(encode(pubkey, 'hex'))) - LIMIT $4 + LIMIT $5 "#, ) + .bind(community_id.as_uuid()) .bind(&contains_pattern) .bind(&normalized) .bind(&prefix_pattern) @@ -271,15 +286,17 @@ pub async fn search_users( /// agent pubkey doesn't exist in the users table. pub async fn set_agent_owner( pool: &PgPool, + community_id: CommunityId, agent_pubkey: &[u8], owner_pubkey: &[u8], ) -> Result { // Conditional UPDATE: only set owner if currently NULL. This makes // "first mint wins" atomic — no TOCTOU race between concurrent mints. let result = sqlx::query( - r#"UPDATE users SET agent_owner_pubkey = $1 WHERE pubkey = $2 AND agent_owner_pubkey IS NULL"#, + r#"UPDATE users SET agent_owner_pubkey = $1 WHERE community_id = $2 AND pubkey = $3 AND agent_owner_pubkey IS NULL"#, ) .bind(owner_pubkey) + .bind(community_id.as_uuid()) .bind(agent_pubkey) .execute(pool) .await?; @@ -287,7 +304,8 @@ pub async fn set_agent_owner( if result.rows_affected() == 0 { // Could be: (a) pubkey not found, or (b) owner already set. // Check which case by querying the row. - let exists = sqlx::query(r#"SELECT 1 FROM users WHERE pubkey = $1"#) + let exists = sqlx::query(r#"SELECT 1 FROM users WHERE community_id = $1 AND pubkey = $2"#) + .bind(community_id.as_uuid()) .bind(agent_pubkey) .fetch_optional(pool) .await?; @@ -307,11 +325,13 @@ pub async fn set_agent_owner( /// Returns Some((policy_str, owner_bytes_or_none)) if found. pub async fn get_agent_channel_policy( pool: &PgPool, + community_id: CommunityId, pubkey: &[u8], ) -> Result>)>> { let row = sqlx::query( - r#"SELECT channel_add_policy::text AS channel_add_policy, agent_owner_pubkey FROM users WHERE pubkey = $1"#, + r#"SELECT channel_add_policy::text AS channel_add_policy, agent_owner_pubkey FROM users WHERE community_id = $1 AND pubkey = $2"#, ) + .bind(community_id.as_uuid()) .bind(pubkey) .fetch_optional(pool) .await?; @@ -329,12 +349,14 @@ pub async fn get_agent_channel_policy( /// `get_agent_channel_policy`, which would fetch unrelated fields. pub async fn is_agent_owner( pool: &PgPool, + community_id: CommunityId, target_pubkey: &[u8], actor_pubkey: &[u8], ) -> Result { let row = sqlx::query_scalar::<_, bool>( - "SELECT agent_owner_pubkey = $2 FROM users WHERE pubkey = $1 AND agent_owner_pubkey IS NOT NULL", + "SELECT agent_owner_pubkey = $3 FROM users WHERE community_id = $1 AND pubkey = $2 AND agent_owner_pubkey IS NOT NULL", ) + .bind(community_id.as_uuid()) .bind(target_pubkey) .bind(actor_pubkey) .fetch_optional(pool) @@ -345,16 +367,22 @@ pub async fn is_agent_owner( /// Set the channel_add_policy for a user. /// Returns an error if the pubkey is not found (rows_affected == 0). /// Returns an error if `policy` is not one of the valid ENUM values. -pub async fn set_channel_add_policy(pool: &PgPool, pubkey: &[u8], policy: &str) -> Result<()> { +pub async fn set_channel_add_policy( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], + policy: &str, +) -> Result<()> { if !matches!(policy, "anyone" | "owner_only" | "nobody") { return Err(crate::error::DbError::InvalidData(format!( "invalid channel_add_policy: {policy}" ))); } let result = sqlx::query( - r#"UPDATE users SET channel_add_policy = $1::channel_add_policy WHERE pubkey = $2"#, + r#"UPDATE users SET channel_add_policy = $1::channel_add_policy WHERE community_id = $2 AND pubkey = $3"#, ) .bind(policy) + .bind(community_id.as_uuid()) .bind(pubkey) .execute(pool) .await?; @@ -385,28 +413,41 @@ mod tests { Keys::generate().public_key().to_bytes().to_vec() } + async fn make_community(pool: &PgPool) -> CommunityId { + let id = uuid::Uuid::new_v4(); + let host = format!("user-test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(host) + .execute(pool) + .await + .expect("insert test community"); + CommunityId::from_uuid(id) + } + /// Setting an agent owner then reading back the policy should return /// the default "anyone" policy and the owner pubkey. #[tokio::test] #[ignore = "requires Postgres"] async fn test_set_agent_owner_and_get_policy() { let db = setup_db().await; + let community = make_community(&db.pool).await; let agent_pk = random_pubkey(); let owner_pk = random_pubkey(); - ensure_user(&db.pool, &agent_pk) + ensure_user(&db.pool, community, &agent_pk) .await .expect("ensure agent"); - ensure_user(&db.pool, &owner_pk) + ensure_user(&db.pool, community, &owner_pk) .await .expect("ensure owner"); - let was_set = set_agent_owner(&db.pool, &agent_pk, &owner_pk) + let was_set = set_agent_owner(&db.pool, community, &agent_pk, &owner_pk) .await .expect("set_agent_owner"); assert!(was_set, "first set_agent_owner should return true"); - let result = get_agent_channel_policy(&db.pool, &agent_pk) + let result = get_agent_channel_policy(&db.pool, community, &agent_pk) .await .expect("get_agent_channel_policy"); @@ -424,14 +465,17 @@ mod tests { #[ignore = "requires Postgres"] async fn test_set_channel_add_policy() { let db = setup_db().await; + let community = make_community(&db.pool).await; let pk = random_pubkey(); - ensure_user(&db.pool, &pk).await.expect("ensure user"); + ensure_user(&db.pool, community, &pk) + .await + .expect("ensure user"); // owner_only - set_channel_add_policy(&db.pool, &pk, "owner_only") + set_channel_add_policy(&db.pool, community, &pk, "owner_only") .await .expect("set owner_only"); - let (policy, owner) = get_agent_channel_policy(&db.pool, &pk) + let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk) .await .expect("get policy") .expect("should be Some"); @@ -439,10 +483,10 @@ mod tests { assert!(owner.is_none(), "no owner was set"); // nobody - set_channel_add_policy(&db.pool, &pk, "nobody") + set_channel_add_policy(&db.pool, community, &pk, "nobody") .await .expect("set nobody"); - let (policy, owner) = get_agent_channel_policy(&db.pool, &pk) + let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk) .await .expect("get policy") .expect("should be Some"); @@ -450,10 +494,10 @@ mod tests { assert!(owner.is_none()); // anyone (reset to default) - set_channel_add_policy(&db.pool, &pk, "anyone") + set_channel_add_policy(&db.pool, community, &pk, "anyone") .await .expect("set anyone"); - let (policy, owner) = get_agent_channel_policy(&db.pool, &pk) + let (policy, owner) = get_agent_channel_policy(&db.pool, community, &pk) .await .expect("get policy") .expect("should be Some"); @@ -467,9 +511,10 @@ mod tests { #[ignore = "requires Postgres"] async fn test_get_policy_unknown_pubkey() { let db = setup_db().await; + let community = make_community(&db.pool).await; let pk = random_pubkey(); - let result = get_agent_channel_policy(&db.pool, &pk) + let result = get_agent_channel_policy(&db.pool, community, &pk) .await .expect("query should not error"); @@ -482,15 +527,16 @@ mod tests { #[ignore = "requires Postgres"] async fn test_set_agent_owner_nonexistent_agent() { let db = setup_db().await; + let community = make_community(&db.pool).await; let agent_pk = random_pubkey(); let owner_pk = random_pubkey(); // Only ensure the owner exists -- agent is intentionally absent. - ensure_user(&db.pool, &owner_pk) + ensure_user(&db.pool, community, &owner_pk) .await .expect("ensure owner"); - let result = set_agent_owner(&db.pool, &agent_pk, &owner_pk).await; + let result = set_agent_owner(&db.pool, community, &agent_pk, &owner_pk).await; assert!( result.is_err(), "should error when agent pubkey is not in users table" @@ -502,28 +548,33 @@ mod tests { #[ignore = "requires Postgres"] async fn test_set_agent_owner_already_owned() { let db = setup_db().await; + let community = make_community(&db.pool).await; let agent_pk = random_pubkey(); let owner1 = random_pubkey(); let owner2 = random_pubkey(); - ensure_user(&db.pool, &agent_pk) + ensure_user(&db.pool, community, &agent_pk) .await .expect("ensure agent"); - ensure_user(&db.pool, &owner1).await.expect("ensure owner1"); - ensure_user(&db.pool, &owner2).await.expect("ensure owner2"); + ensure_user(&db.pool, community, &owner1) + .await + .expect("ensure owner1"); + ensure_user(&db.pool, community, &owner2) + .await + .expect("ensure owner2"); - let first = set_agent_owner(&db.pool, &agent_pk, &owner1) + let first = set_agent_owner(&db.pool, community, &agent_pk, &owner1) .await .expect("first set"); assert!(first, "first set should succeed"); - let second = set_agent_owner(&db.pool, &agent_pk, &owner2) + let second = set_agent_owner(&db.pool, community, &agent_pk, &owner2) .await .expect("second set should not error"); assert!(!second, "second set should return false (already owned)"); // Verify original owner is preserved. - let (_, owner) = get_agent_channel_policy(&db.pool, &agent_pk) + let (_, owner) = get_agent_channel_policy(&db.pool, community, &agent_pk) .await .expect("get policy") .expect("should be Some"); @@ -536,9 +587,10 @@ mod tests { #[ignore = "requires Postgres"] async fn test_set_channel_add_policy_nonexistent_user() { let db = setup_db().await; + let community = make_community(&db.pool).await; let pk = random_pubkey(); - let result = set_channel_add_policy(&db.pool, &pk, "nobody").await; + let result = set_channel_add_policy(&db.pool, community, &pk, "nobody").await; assert!( result.is_err(), "should error when pubkey is not in users table" @@ -549,9 +601,10 @@ mod tests { #[ignore = "requires Postgres"] async fn test_set_channel_add_policy_rejects_invalid() { let db = setup_db().await; + let community = make_community(&db.pool).await; let pubkey = nostr::Keys::generate().public_key().to_bytes().to_vec(); - ensure_user(&db.pool, &pubkey).await.unwrap(); - let result = set_channel_add_policy(&db.pool, &pubkey, "invalid_policy").await; + ensure_user(&db.pool, community, &pubkey).await.unwrap(); + let result = set_channel_add_policy(&db.pool, community, &pubkey, "invalid_policy").await; assert!(result.is_err(), "should reject invalid policy value"); } @@ -595,14 +648,17 @@ mod tests { #[ignore = "requires Postgres"] async fn test_owner_only_with_no_owner() { let db = setup_db().await; + let community = make_community(&db.pool).await; let pk = random_pubkey(); - ensure_user(&db.pool, &pk).await.expect("ensure user"); + ensure_user(&db.pool, community, &pk) + .await + .expect("ensure user"); - set_channel_add_policy(&db.pool, &pk, "owner_only") + set_channel_add_policy(&db.pool, community, &pk, "owner_only") .await .expect("set owner_only"); - let result = get_agent_channel_policy(&db.pool, &pk) + let result = get_agent_channel_policy(&db.pool, community, &pk) .await .expect("get policy") .expect("should be Some"); diff --git a/crates/buzz-db/src/workflow.rs b/crates/buzz-db/src/workflow.rs index 993dbf19d..f7d64bda3 100644 --- a/crates/buzz-db/src/workflow.rs +++ b/crates/buzz-db/src/workflow.rs @@ -15,6 +15,8 @@ use sha2::{Digest, Sha256}; use sqlx::{PgPool, Row}; use uuid::Uuid; +use buzz_core::CommunityId; + use crate::error::{DbError, Result}; // -- Token hashing ------------------------------------------------------------ @@ -163,6 +165,8 @@ impl FromStr for ApprovalStatus { pub struct WorkflowRecord { /// Unique workflow identifier. pub id: Uuid, + /// Server-resolved community that owns this workflow. + pub community_id: CommunityId, /// Human-readable workflow name. pub name: String, /// Compressed public key bytes of the workflow owner. @@ -211,6 +215,23 @@ pub struct WorkflowRunRecord { pub created_at: DateTime, } +/// A winning scheduled workflow fire claim. +/// +/// The primary identity is `(workflow_id, scheduled_for)`. `community_id` is +/// resolved from the workflow row inside the claim SQL and returned for scoped +/// audit/logging; callers never supply it as a claim. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ScheduledWorkflowFireClaim { + /// Community that owns this scheduled fire. + pub community_id: CommunityId, + /// Workflow definition that should run. + pub workflow_id: Uuid, + /// Authoritative schedule instant this claim represents. + pub scheduled_for: DateTime, + /// Database timestamp for when this pod won the claim. + pub claimed_at: DateTime, +} + /// A pending or resolved approval gate for a workflow step. #[derive(Debug, Clone)] pub struct ApprovalRecord { @@ -244,6 +265,7 @@ pub struct ApprovalRecord { /// New workflows start as `active` and `enabled = TRUE`. pub async fn create_workflow( pool: &PgPool, + community_id: CommunityId, channel_id: Option, owner_pubkey: &[u8], name: &str, @@ -255,11 +277,12 @@ pub async fn create_workflow( sqlx::query( r#" INSERT INTO workflows - (id, name, owner_pubkey, channel_id, definition, definition_hash, status, enabled) - VALUES ($1, $2, $3, $4, $5::jsonb, $6, 'active', TRUE) + (id, community_id, name, owner_pubkey, channel_id, definition, definition_hash, status, enabled) + VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, 'active', TRUE) "#, ) .bind(id) + .bind(community_id.as_uuid()) .bind(name) .bind(owner_pubkey) .bind(channel_id) @@ -275,7 +298,7 @@ pub async fn create_workflow( pub async fn get_workflow(pool: &PgPool, id: Uuid) -> Result { let row = sqlx::query( r#" - SELECT id, name, owner_pubkey, channel_id, definition, definition_hash, + SELECT id, community_id, name, owner_pubkey, channel_id, definition, definition_hash, status::text AS status, enabled, created_at, updated_at FROM workflows WHERE id = $1 @@ -304,7 +327,7 @@ pub async fn list_channel_workflows( let rows = sqlx::query( r#" - SELECT id, name, owner_pubkey, channel_id, definition, definition_hash, + SELECT id, community_id, name, owner_pubkey, channel_id, definition, definition_hash, status::text AS status, enabled, created_at, updated_at FROM workflows WHERE channel_id = $1 @@ -333,7 +356,7 @@ pub async fn list_enabled_channel_workflows( ) -> Result> { let rows = sqlx::query( r#" - SELECT id, name, owner_pubkey, channel_id, definition, definition_hash, + SELECT id, community_id, name, owner_pubkey, channel_id, definition, definition_hash, status::text AS status, enabled, created_at, updated_at FROM workflows WHERE channel_id = $1 @@ -359,7 +382,7 @@ pub async fn list_enabled_channel_workflows( pub async fn list_all_enabled_workflows(pool: &PgPool) -> Result> { let rows = sqlx::query( r#" - SELECT id, name, owner_pubkey, channel_id, definition, definition_hash, + SELECT id, community_id, name, owner_pubkey, channel_id, definition, definition_hash, status::text AS status, enabled, created_at, updated_at FROM workflows WHERE status = 'active' @@ -376,6 +399,125 @@ pub async fn list_all_enabled_workflows(pool: &PgPool) -> Result, +) -> Result> { + let row = sqlx::query( + r#" + INSERT INTO scheduled_workflow_fires (community_id, workflow_id, scheduled_for) + SELECT w.community_id, w.id, $2 + FROM workflows w + WHERE w.id = $1 + ON CONFLICT (community_id, workflow_id, scheduled_for) DO NOTHING + RETURNING community_id, workflow_id, scheduled_for, claimed_at + "#, + ) + .bind(workflow_id) + .bind(scheduled_for) + .fetch_optional(pool) + .await?; + + row.map(|row| { + let community_id: Uuid = row.try_get("community_id")?; + Ok(ScheduledWorkflowFireClaim { + community_id: CommunityId::from_uuid(community_id), + workflow_id: row.try_get("workflow_id")?, + scheduled_for: row.try_get("scheduled_for")?, + claimed_at: row.try_get("claimed_at")?, + }) + }) + .transpose() +} + +/// Fetch the greatest claimed schedule instant for a workflow. +/// +/// Interval schedulers use this as their DB-authoritative `last_fired` anchor. +/// It makes all pods compute the same next interval instant after a successful +/// claim, and preserves the interval clock across pod restarts. This intentionally +/// reads from `scheduled_workflow_fires`, not `workflow_runs`, because the claim +/// row is the source of truth for schedule deduplication. +pub async fn latest_scheduled_workflow_fire( + pool: &PgPool, + workflow_id: Uuid, +) -> Result>> { + let row = sqlx::query( + r#" + SELECT MAX(scheduled_for) AS scheduled_for + FROM scheduled_workflow_fires + WHERE workflow_id = $1 + "#, + ) + .bind(workflow_id) + .fetch_one(pool) + .await?; + + row.try_get("scheduled_for").map_err(Into::into) +} + +/// Link a won scheduled-fire claim to the workflow run it created. +/// +/// This is for ops/audit forensics only; the claim row remains the dedupe +/// boundary. If run creation succeeds, callers should attach the run id before +/// spawning execution. If run creation fails, leaving `workflow_run_id` NULL is +/// intentional: the schedule instant was claimed and must not duplicate later. +pub async fn attach_scheduled_workflow_run( + pool: &PgPool, + workflow_id: Uuid, + scheduled_for: DateTime, + workflow_run_id: Uuid, +) -> Result { + let result = sqlx::query( + r#" + UPDATE scheduled_workflow_fires + SET workflow_run_id = $3 + WHERE workflow_id = $1 + AND scheduled_for = $2 + AND workflow_run_id IS NULL + "#, + ) + .bind(workflow_id) + .bind(scheduled_for) + .bind(workflow_run_id) + .execute(pool) + .await?; + + Ok(result.rows_affected() == 1) +} + +/// Delete old scheduled workflow fire claims for retention. +/// +/// Schedule claim rows are correctness metadata, but they grow with every fire. +/// The relay/ops janitor should retain enough history for audits and interval +/// anchoring: the cutoff must be older than the largest interval schedule the +/// deployment supports, or interval workflows can lose their DB-authoritative +/// anchor after pruning. +pub async fn prune_scheduled_workflow_fires_before( + pool: &PgPool, + older_than: DateTime, +) -> Result { + let result = sqlx::query( + r#" + DELETE FROM scheduled_workflow_fires + WHERE claimed_at < $1 + "#, + ) + .bind(older_than) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} + /// Update a workflow's name, definition, and definition_hash. pub async fn update_workflow( pool: &PgPool, @@ -765,8 +907,11 @@ fn row_to_workflow_record(row: sqlx::postgres::PgRow) -> Result let enabled: bool = row.try_get("enabled")?; + let community_id: Uuid = row.try_get("community_id")?; + Ok(WorkflowRecord { id, + community_id: CommunityId::from_uuid(community_id), name: row.try_get("name")?, owner_pubkey: row.try_get("owner_pubkey")?, channel_id, @@ -831,7 +976,7 @@ pub async fn find_by_owner_and_name( ) -> Result> { let row = sqlx::query( r#" - SELECT id, name, owner_pubkey, channel_id, definition, definition_hash, + SELECT id, community_id, name, owner_pubkey, channel_id, definition, definition_hash, status::text AS status, enabled, created_at, updated_at FROM workflows WHERE owner_pubkey = $1 AND name = $2 @@ -955,8 +1100,11 @@ mod tests { "steps": [{ "id": "s1", "action": "send_message", "text": "hi" }] }); + let community_id = CommunityId::from_uuid(Uuid::new_v4()); + let record = WorkflowRecord { id, + community_id, name: "My Workflow".to_owned(), owner_pubkey: vec![0xab; 32], channel_id: Some(channel_id), @@ -969,6 +1117,7 @@ mod tests { }; assert_eq!(record.id, id); + assert_eq!(record.community_id, community_id); assert_eq!(record.name, "My Workflow"); assert_eq!(record.owner_pubkey, vec![0xab; 32]); assert_eq!(record.channel_id, Some(channel_id)); @@ -985,6 +1134,7 @@ mod tests { let record = WorkflowRecord { id, + community_id: CommunityId::from_uuid(Uuid::new_v4()), name: "Global Workflow".to_owned(), owner_pubkey: vec![0x00; 32], channel_id: None, @@ -1006,6 +1156,7 @@ mod tests { let record = WorkflowRecord { id, + community_id: CommunityId::from_uuid(Uuid::new_v4()), name: "Original".to_owned(), owner_pubkey: vec![0x01; 32], channel_id: None, @@ -1034,6 +1185,7 @@ mod tests { ] { let record = WorkflowRecord { id: Uuid::new_v4(), + community_id: CommunityId::from_uuid(Uuid::new_v4()), name: "Test".to_owned(), owner_pubkey: vec![], channel_id: None, @@ -1053,6 +1205,7 @@ mod tests { let now = Utc::now(); let record = WorkflowRecord { id: Uuid::new_v4(), + community_id: CommunityId::from_uuid(Uuid::new_v4()), name: "Paused".to_owned(), owner_pubkey: vec![], channel_id: None, @@ -1302,4 +1455,251 @@ mod tests { assert_eq!(record.status, ApprovalStatus::Pending); assert_eq!(cloned.status, ApprovalStatus::Granted); } + + // -- F1 / S1 attack surface for scheduled workflow claims ------------------ + // + // These tests pin the locked spec from Eva [13] / Mari [12]: + // + // 1. `workflows.community_id` is row-owned, NOT NULL, immutable. + // 2. Claim resolves `community_id` server-side via `workflow_id`. + // 3. Claim uniqueness is `(workflow_id, scheduled_for)` — `workflow_id` + // is globally unique, so adding `community_id` to the key weakens it. + // 4. `latest_scheduled_workflow_fire` drops caller-supplied community. + // + // Until that lands, `claim_for_workflow_in_other_community_no_ops` is the + // S1 regression lock: today the schema permits a caller in community A to + // claim a workflow owned by community B and have `claimed.community_id` + // come back as A. That is a cross-tenant write surface (Theorem S1, + // `docs/multi-tenant-relay.md:248`: "the claimed community never appears + // in this function — only the resolved one"). + // + // The other two tests are characterization guards: same-window race must + // yield exactly one claim winner, and the retention primitive's docstring + // caveat (pruning below the largest interval breaks `latest_*`) is + // load-bearing for the §5c deployment-config rule Sami flagged. + + use crate::user::ensure_user; + + 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") + } + + /// Insert a community with a unique host. Returns its `CommunityId`. + async fn make_community(pool: &PgPool) -> CommunityId { + let id = Uuid::new_v4(); + let host = format!("test-{}.example", id.simple()); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(&host) + .execute(pool) + .await + .expect("insert community"); + CommunityId::from_uuid(id) + } + + /// Insert a channel under a community. Returns the channel id. + async fn make_channel(pool: &PgPool, community: CommunityId, owner: &[u8]) -> Uuid { + let id = Uuid::new_v4(); + sqlx::query( + r#" + INSERT INTO channels (id, community_id, name, created_by) + VALUES ($1, $2, $3, $4) + "#, + ) + .bind(id) + .bind(community.as_uuid()) + .bind(format!("ch-{}", id.simple())) + .bind(owner) + .execute(pool) + .await + .expect("insert channel"); + id + } + + /// Insert a workflow whose tenant is `community`'s channel. Returns the + /// workflow id and the owning community for callers that want to assert + /// the resolved tenant. + async fn make_workflow_in(pool: &PgPool, community: CommunityId) -> (Uuid, CommunityId) { + let owner = vec![0xa1; 32]; + ensure_user(pool, community, &owner) + .await + .expect("ensure owner"); + let channel_id = make_channel(pool, community, &owner).await; + let workflow_id = create_workflow( + pool, + community, + Some(channel_id), + &owner, + "f1-attack-workflow", + r#"{"trigger":{"on":"schedule"},"steps":[]}"#, + &[0u8; 32], + ) + .await + .expect("create workflow"); + (workflow_id, community) + } + + /// F1 attack: a caller in community A must NOT be able to claim a fire + /// for a workflow owned by community B and have the claim resolve under + /// A's tenant. The resolved community on the returned claim row MUST + /// equal the workflow's actual tenant (B). + /// + /// Post-fix (`1fa3d837f`) the claim signature no longer accepts a caller + /// tenant — the SQL resolves `community_id` from the `workflows` row via + /// `INSERT ... SELECT w.community_id, w.id, $2 FROM workflows w WHERE + /// w.id = $1`. This test still creates an "attacker_community" that the + /// caller is *not* permitted to name on the wire; the assertion is that + /// the resolved tenant equals the workflow owner's community, never the + /// attacker's. With the pre-fix signature this test was RED (the row's + /// `community_id` came back as the attacker's); under the locked spec it + /// must be GREEN. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn claim_for_workflow_in_other_community_no_ops() { + let pool = setup_pool().await; + + // The attacker community exists in the schema but the caller has no + // way to pass it to the claim API anymore — that *is* the S1 fix. + // Keeping the row in this test makes the no-influence invariant + // explicit: even with two real tenants in play, the resolved + // community is the workflow's owner. + let _attacker_community = make_community(&pool).await; + let owner_community = make_community(&pool).await; + let (workflow_id, expected_community) = make_workflow_in(&pool, owner_community).await; + + let scheduled_for = Utc.with_ymd_and_hms(2026, 6, 27, 0, 0, 0).unwrap(); + + let claim = claim_scheduled_workflow_fire(&pool, workflow_id, scheduled_for) + .await + .expect("claim should not error") + .expect("claim should succeed exactly once"); + + assert_eq!( + claim.community_id, + expected_community, + "claim must resolve community from workflow_id (server-side); \ + resolved={resolved:?} expected={expected_community:?}", + resolved = claim.community_id, + ); + assert_eq!(claim.workflow_id, workflow_id); + assert_eq!(claim.scheduled_for, scheduled_for); + } + + /// Same `(workflow_id, scheduled_for)` claimed concurrently by N tasks + /// must yield exactly one `Some` winner. Post-fix the PK is + /// `(workflow_id, scheduled_for)` (`workflow_id` is globally unique, + /// `community_id` is a scoped/audit label only) — exactly the locked + /// spec. Characterization guard: protects the dedup boundary against + /// regressions in the claim SQL. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_same_window_claims_exactly_one_wins() { + let pool = setup_pool().await; + + let community = make_community(&pool).await; + let (workflow_id, _) = make_workflow_in(&pool, community).await; + let scheduled_for = Utc.with_ymd_and_hms(2026, 6, 27, 0, 1, 0).unwrap(); + + const N: usize = 8; + let mut handles = Vec::with_capacity(N); + for _ in 0..N { + let pool = pool.clone(); + handles.push(tokio::spawn(async move { + claim_scheduled_workflow_fire(&pool, workflow_id, scheduled_for).await + })); + } + + let mut winners = 0usize; + for h in handles { + let result = h.await.expect("task did not panic").expect("claim ok"); + if result.is_some() { + winners += 1; + } + } + assert_eq!( + winners, 1, + "exactly one task must win the claim race for (workflow_id, scheduled_for)" + ); + } + + /// Documents the retention-vs-interval coupling Sami flagged for §5c: + /// pruning every claim below the workflow's interval makes + /// `latest_scheduled_workflow_fire` return `None`, which re-introduces the + /// per-pod-clock anchor bug F5 was meant to fix. Test is GREEN today and + /// MUST stay green — it pins the deployment-config rule that the janitor + /// cutoff must exceed `MAX(interval_secs) + safety margin`. If a future + /// change makes `latest_*` resilient to pruning (e.g. by reading the most + /// recent workflow_run instead, or by retaining a sentinel row), this + /// test's assertion encodes the contract that must be updated alongside. + /// + /// Test isolation: the prune primitive is global (filters only on + /// `claimed_at`), so to avoid colliding with parallel claim tests we + /// back-date this workflow's `claimed_at` into the deep past and use a + /// past cutoff that cannot match any other test's `claimed_at = NOW()`. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn latest_after_prune_below_interval_breaks_anchor() { + let pool = setup_pool().await; + + let community = make_community(&pool).await; + let (workflow_id, _) = make_workflow_in(&pool, community).await; + let scheduled_for = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(); + + claim_scheduled_workflow_fire(&pool, workflow_id, scheduled_for) + .await + .expect("claim ok") + .expect("first claim wins"); + + // Backdate this row's `claimed_at` so the global prune below targets + // only this workflow's row and cannot race-delete other tests' rows. + let backdated_claimed_at = Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap(); + sqlx::query( + "UPDATE scheduled_workflow_fires SET claimed_at = $1 \ + WHERE community_id = $2 AND workflow_id = $3 AND scheduled_for = $4", + ) + .bind(backdated_claimed_at) + .bind(community.as_uuid()) + .bind(workflow_id) + .bind(scheduled_for) + .execute(&pool) + .await + .expect("backdate ok"); + + let latest_before = latest_scheduled_workflow_fire(&pool, workflow_id) + .await + .expect("latest ok"); + assert_eq!( + latest_before, + Some(scheduled_for), + "latest must reflect the claim before pruning", + ); + + // Janitor cutoff above only the back-dated row: prunes the anchor row + // without touching anything claimed at wall-clock NOW. + let cutoff = backdated_claimed_at + chrono::Duration::seconds(1); + let pruned = prune_scheduled_workflow_fires_before(&pool, cutoff) + .await + .expect("prune ok"); + assert!( + pruned >= 1, + "expected at least one row pruned, got {pruned}" + ); + + let latest_after = latest_scheduled_workflow_fire(&pool, workflow_id) + .await + .expect("latest ok"); + assert_eq!( + latest_after, None, + "pruning below the largest interval breaks the DB anchor; \ + retention cutoff MUST exceed MAX(interval_secs) + safety margin (§5c)", + ); + } } diff --git a/crates/buzz-relay/src/handlers/command_executor.rs b/crates/buzz-relay/src/handlers/command_executor.rs index eb36f61be..c6b9f972e 100644 --- a/crates/buzz-relay/src/handlers/command_executor.rs +++ b/crates/buzz-relay/src/handlers/command_executor.rs @@ -612,10 +612,20 @@ async fn handle_workflow_def( PersistResult::Inserted(tx) => tx, }; - // 4. Execute: create_workflow + // 4. Execute: create_workflow. The workflow's community is resolved from + // the server-owned channel row, not from the client-supplied event. The DB + // also enforces `(community_id, channel_id)` as a composite FK. + let community_id = state + .db + .community_of_channel(channel_id) + .await + .map_err(|e| IngestError::Internal(format!("error: db channel community lookup: {e}")))? + .ok_or_else(|| IngestError::Rejected("invalid: workflow channel not found".into()))?; + let workflow_id = state .db .create_workflow( + community_id, Some(channel_id), &self_bytes, &workflow_name, From 785eefbd2fdfe6da92a105ffb574c62ded402e5a Mon Sep 17 00:00:00 2001 From: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> Date: Fri, 26 Jun 2026 20:36:57 -0400 Subject: [PATCH 005/100] fix(db): require community scope for row lookups Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co> Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com> --- crates/buzz-db/src/channel.rs | 331 +++++++++++++++++++++++++++------- crates/buzz-db/src/event.rs | 141 +++++++++++++-- crates/buzz-db/src/lib.rs | 225 +++++++++++++++++------ crates/buzz-db/src/thread.rs | 180 +++++++++++++++--- 4 files changed, 708 insertions(+), 169 deletions(-) diff --git a/crates/buzz-db/src/channel.rs b/crates/buzz-db/src/channel.rs index ef2a1fa8e..bdbe812d4 100644 --- a/crates/buzz-db/src/channel.rs +++ b/crates/buzz-db/src/channel.rs @@ -256,8 +256,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, @@ -267,9 +271,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?; @@ -329,7 +349,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(|| { @@ -497,12 +517,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) @@ -514,17 +540,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?; @@ -538,7 +569,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()); } @@ -546,11 +581,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?; @@ -587,8 +628,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#" @@ -600,11 +645,12 @@ pub async fn list_channels(pool: &PgPool, visibility: Option<&str>) -> Result) -> Result, + community_id: CommunityId, channel_id: Uuid, ) -> Result { let row = sqlx::query( @@ -664,9 +712,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? @@ -683,6 +732,15 @@ pub struct BotChannelEntry { pub id: String, } +/// A channel archived by the ephemeral-channel reaper. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ReapedEphemeralChannel { + /// Community that owns the archived channel. + pub community_id: CommunityId, + /// 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 { @@ -730,6 +788,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, @@ -756,20 +815,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 { @@ -786,24 +847,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?; @@ -939,6 +1004,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 { @@ -980,8 +1046,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(", ") ); @@ -998,6 +1065,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?; @@ -1005,17 +1073,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?; @@ -1028,16 +1103,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?; @@ -1051,9 +1128,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?; @@ -1072,8 +1156,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?; @@ -1085,9 +1170,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?; @@ -1108,8 +1200,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?; @@ -1121,9 +1214,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?; @@ -1132,10 +1231,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?; @@ -1148,6 +1252,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() { @@ -1156,8 +1261,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); @@ -1180,14 +1287,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) @@ -1198,11 +1307,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?; @@ -1211,25 +1325,29 @@ 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, 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", + RETURNING community_id, 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 channel_id: Uuid = row.try_get("id")?; + Ok(ReapedEphemeralChannel { + community_id: CommunityId::from_uuid(community_id), + channel_id, + }) }) .collect() } @@ -1311,7 +1429,78 @@ mod tests { .await .expect("insert owner membership"); - get_channel(pool, id).await + 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. @@ -1382,7 +1571,7 @@ mod tests { // 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" @@ -1424,11 +1613,11 @@ mod tests { .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!( @@ -1444,7 +1633,9 @@ 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" ); } diff --git a/crates/buzz-db/src/event.rs b/crates/buzz-db/src/event.rs index 29a23b51f..b8a3adcad 100644 --- a/crates/buzz-db/src/event.rs +++ b/crates/buzz-db/src/event.rs @@ -580,9 +580,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?; @@ -603,14 +609,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) @@ -627,17 +635,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; @@ -646,8 +657,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?; @@ -656,8 +668,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?; @@ -672,13 +685,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?; @@ -695,6 +710,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() { @@ -703,8 +719,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); @@ -727,11 +745,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?; @@ -750,16 +773,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) @@ -778,12 +803,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?; @@ -798,7 +825,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![]); } @@ -806,8 +837,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()); @@ -1169,6 +1202,76 @@ 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") diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 6b0a7199c..efd96f49d 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -345,52 +345,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, @@ -399,21 +412,30 @@ 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. @@ -494,18 +516,31 @@ 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. @@ -540,49 +575,75 @@ impl Db { } /// 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. @@ -593,62 +654,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 } @@ -845,6 +943,7 @@ impl Db { #[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, @@ -857,6 +956,7 @@ impl Db { ) -> Result<()> { thread::insert_thread_metadata( &self.pool, + community_id, event_id, event_created_at, channel_id, @@ -873,25 +973,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>, @@ -900,6 +1011,7 @@ impl Db { ) -> Result> { thread::get_channel_messages_top_level( &self.pool, + community_id, channel_id, limit, before_cursor, @@ -912,18 +1024,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. @@ -1673,13 +1788,15 @@ impl Db { /// 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) diff --git a/crates/buzz-db/src/thread.rs b/crates/buzz-db/src/thread.rs index 2c7b07912..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