Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
8f69d35
feat(core): TenantContext + CommunityId — the server-resolved tenant …
tlongwell-block Jun 27, 2026
d948830
feat(lane0): community_id-native schema + host normalization
tlongwell-block Jun 27, 2026
4b7654a
feat(lane0): fold review round 2 — FTS column, drop stale migrations
tlongwell-block Jun 27, 2026
b7f249c
fix(db): scope relay rows by community
tlongwell-block Jun 27, 2026
785eefb
fix(db): require community scope for row lookups
tlongwell-block Jun 27, 2026
2620e49
fix(db): add tenant-safe query defaults and reaper host provenance
tlongwell-block Jun 27, 2026
c74c22c
feat(auth): community-scope RateLimiter pubkey quotas
tlongwell-block Jun 27, 2026
31e87b5
feat(auth): NIP-98 replay seen-set — shared, community-scoped, atomic
tlongwell-block Jun 27, 2026
aa4bf64
hardening(auth): TTL ceiling, key-case invariant, structured error tr…
tlongwell-block Jun 27, 2026
e43ea25
fence(auth): host-binding side door + access-checker community fence
tlongwell-block Jun 27, 2026
eae8dfc
feat(pubsub): scope redis topics by community
tlongwell-block Jun 27, 2026
433ee1e
test(pubsub): pin tenant-scoped topic refcounts
tlongwell-block Jun 27, 2026
c2477ba
rewrite(search): Postgres FTS, community-scoped, drop Typesense
tlongwell-block Jun 27, 2026
38708b8
rewrite(search): ChannelScope enum closes ChannelLessOnly fence hole
tlongwell-block Jun 27, 2026
81b064f
feat(audit): per-community hash chain on the frozen audit_log DDL
tlongwell-block Jun 27, 2026
fec1834
feat(audit): widen NewAuditEntry.community_id to CommunityId
tlongwell-block Jun 27, 2026
c6ec9a3
feat(relay): row-zero host-binding seam (HostResolver + fail-closed b…
tlongwell-block Jun 27, 2026
be56652
feat(relay): huddle-audio-unavailable guardrail under horizontal scal…
tlongwell-block Jun 27, 2026
787774f
test(conformance): multi-tenant A/B isolation harness skeleton
tlongwell-block Jun 27, 2026
ec22cdb
feat(relay): RelayInfo::build static-input fence (conformance NIP-11 …
tlongwell-block Jun 27, 2026
031b84e
fix(db): scope archived identities by community
tlongwell-block Jun 27, 2026
a8a9dd9
feat(relay): wire relay + admin call sites to community-scoped v3 API
tlongwell-block Jun 27, 2026
46124f3
fix(db): add communities to schema snapshot
tlongwell-block Jun 27, 2026
3e57144
fix(relay-mt): clear clippy -D warnings introduced by tenant threading
tlongwell-block Jun 27, 2026
f1b459b
fix(db): reconcile schema snapshot with mt migration
tlongwell-block Jun 27, 2026
528c4a9
test(e2e): seed localhost:3000 community for multi-tenant e2e suites
tlongwell-block Jun 27, 2026
107aa69
test(e2e): converge e2e host on localhost + seed before relay boot
tlongwell-block Jun 27, 2026
8ff38c0
test(e2e): seed community before relay boot in desktop e2e integration
tlongwell-block Jun 27, 2026
139d6db
fix(relay): preserve relay URL port for deployment binding
Jun 27, 2026
fb0d6a4
fix(relay): use deployment tenant binding for startup membership
Jun 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 50 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -364,12 +364,36 @@ jobs:
with:
name: desktop-e2e-relay
path: target/ci
- name: Apply schema and seed deployment community
# MT: the relay resolves each request's tenant from the communities host
# map and fails closed on an unmapped host. The channel reconciler binds
# the deployment community ONCE at boot (outside its retry loop) and
# exits permanently on an unmapped host, so the 'localhost:3000'
# community MUST exist before the relay starts — the retry loop only
# handles late-seeded channels, not a late-seeded community. The relay
# migrates at boot via BUZZ_AUTO_MIGRATE, but that's too late for the
# pre-boot seed, so apply the schema here first (then drop AUTO_MIGRATE
# below). lower(host) is the unique index → ON CONFLICT target. psql
# isn't on PATH in hermit → exec into the buzz-postgres container.
env:
PGHOST: localhost
PGPORT: "5432"
PGUSER: buzz
PGPASSWORD: buzz_dev
PGDATABASE: buzz
run: |
./bin/pgschema apply --file schema/schema.sql --auto-approve
docker exec -e PGPASSWORD=buzz_dev buzz-postgres \
psql -U buzz -d buzz -qtA -c "
INSERT INTO communities (id, host)
VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000')
ON CONFLICT (lower(host)) DO NOTHING
;"
- name: Start relay
run: |
chmod +x ./target/ci/buzz-relay
nohup env \
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \
BUZZ_AUTO_MIGRATE=true \
REDIS_URL=redis://localhost:6379 \
TYPESENSE_URL=http://localhost:8108 \
TYPESENSE_API_KEY=buzz_dev_key \
Expand Down Expand Up @@ -474,12 +498,36 @@ jobs:
with:
name: desktop-e2e-relay
path: target/ci
- name: Apply schema and seed deployment community
# MT: the relay resolves each request's tenant from the communities host
# map and fails closed on an unmapped host. The reminder scheduler binds
# the deployment community ONCE at boot and exits permanently on an
# unmapped host (no retry, unlike the channel reconciler), so the
# 'localhost:3000' community MUST exist before the relay starts — seeding
# after boot leaves the scheduler dead. The relay migrates at boot via
# BUZZ_AUTO_MIGRATE, but that's too late for the pre-boot seed, so apply
# the schema here first (then drop AUTO_MIGRATE below). lower(host) is the
# unique index → ON CONFLICT target. psql isn't on PATH in hermit → exec
# into the buzz-postgres container.
env:
PGHOST: localhost
PGPORT: "5432"
PGUSER: buzz
PGPASSWORD: buzz_dev
PGDATABASE: buzz
run: |
./bin/pgschema apply --file schema/schema.sql --auto-approve
docker exec -e PGPASSWORD=buzz_dev buzz-postgres \
psql -U buzz -d buzz -qtA -c "
INSERT INTO communities (id, host)
VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000')
ON CONFLICT (lower(host)) DO NOTHING
;"
- name: Start relay
run: |
chmod +x ./target/ci/buzz-relay
nohup env \
DATABASE_URL=postgres://buzz:buzz_dev@localhost:5432/buzz \
BUZZ_AUTO_MIGRATE=true \
REDIS_URL=redis://localhost:6379 \
TYPESENSE_URL=http://localhost:8108 \
TYPESENSE_API_KEY=buzz_dev_key \
Expand Down
9 changes: 2 additions & 7 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion crates/buzz-admin/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ hex = { workspace = true }
deadpool-redis = { workspace = true }
tracing = { workspace = true }
sqlx = { workspace = true }
uuid = { workspace = true }
url = { workspace = true }
clap = { version = "4", features = ["derive"] }
73 changes: 59 additions & 14 deletions crates/buzz-admin/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,9 @@ use std::sync::Arc;

use anyhow::Result;
use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST;
use buzz_core::tenant::{normalize_host, TenantContext};
use buzz_db::{Db, DbConfig};
use buzz_pubsub::PubSubManager;
use buzz_pubsub::{EventTopic, PubSubManager};
use clap::{Parser, Subcommand};
use nostr::{EventBuilder, Keys, Kind, Tag};
use tracing::warn;
Expand Down Expand Up @@ -153,7 +154,8 @@ async fn cmd_add_member(pubkey_arg: String, role: String) -> Result<i32> {
}
}

if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair).await {
let tenant = resolve_admin_tenant(&db).await?;
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await {
eprintln!("warning: member added to DB but list publish failed: {e}");
}

Expand Down Expand Up @@ -209,7 +211,8 @@ async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> R
}
}

if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair).await {
let tenant = resolve_admin_tenant(&db).await?;
if let Err(e) = publish_membership_list_with_bump(&db, &pubsub, &relay_keypair, &tenant).await {
eprintln!("warning: member removed from DB but list publish failed: {e}");
}

Expand Down Expand Up @@ -274,6 +277,7 @@ async fn publish_membership_list_with_bump(
db: &Db,
pubsub: &Arc<PubSubManager>,
relay_keypair: &Keys,
tenant: &TenantContext,
) -> Result<()> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
Expand All @@ -285,7 +289,11 @@ async fn publish_membership_list_with_bump(

// Query the newest existing kind:13534 for this relay's pubkey (channel_id=None).
let newest_ts = db
.get_latest_global_replaceable(KIND_NIP43_MEMBERSHIP_LIST as i32, &relay_pubkey_bytes)
.get_latest_global_replaceable(
tenant.community(),
KIND_NIP43_MEMBERSHIP_LIST as i32,
&relay_pubkey_bytes,
)
.await?
.map(|e| e.event.created_at.as_secs());

Expand Down Expand Up @@ -313,12 +321,17 @@ async fn publish_membership_list_with_bump(
.sign_with_keys(relay_keypair)
.map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?;

let (stored, was_inserted) = db.replace_addressable_event(&event, None).await?;
let (stored, was_inserted) = db
.replace_addressable_event(tenant.community(), &event, None)
.await?;
if was_inserted {
// Publish to Redis so live clients receive the updated roster.
// Uses channel_id=Nil (global scope) matching the relay's own publish path.
let pubsub_channel = uuid::Uuid::nil();
if let Err(e) = pubsub.publish_event(pubsub_channel, &stored.event).await {
// Community-global scope (EventTopic::Global) matches the relay's own
// membership-list publish path; the tenant fixes the community.
if let Err(e) = pubsub
.publish_event(tenant, EventTopic::Global, &stored.event)
.await
{
warn!("Redis publish of kind:13534 failed: {e}");
}
}
Expand Down Expand Up @@ -376,6 +389,37 @@ async fn connect_db() -> Result<Db> {
Ok(db)
}

/// Resolve the deployment's tenant from the configured `RELAY_URL` host.
///
/// `buzz-admin` runs inside the relay container (`compose exec relay
/// buzz-admin …`), so it shares the relay's `RELAY_URL` and resolves the same
/// single community against the durable `communities` host map. This is
/// deliberately NOT a default tenant: an unmapped host fails closed with an
/// error, mirroring the relay's own `bind_community` row-zero seam. The CLI is
/// single-community per invocation — there is no cross-community sweep.
async fn resolve_admin_tenant(db: &Db) -> Result<TenantContext> {
let relay_url =
std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string());
let raw_host = url::Url::parse(
&relay_url
.replace("ws://", "http://")
.replace("wss://", "https://"),
)
.ok()
.and_then(|u| u.host_str().map(|h| h.to_string()))
.unwrap_or_default();
let host = normalize_host(&raw_host);
let record = db.lookup_community_by_host(&host).await?.ok_or_else(|| {
anyhow::anyhow!(
"RELAY_URL host '{host}' is not mapped to a community.\n\
buzz-admin operates on the configured relay's community; ensure the \
relay has started and seeded its community (or set RELAY_URL to a \
mapped host)."
)
})?;
Ok(TenantContext::resolved(record.id, record.host))
}

async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
use buzz_core::kind::KIND_NIP29_GROUP_ADMINS;
use buzz_db::event::EventQuery;
Expand All @@ -399,7 +443,8 @@ async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
}
};

let channels = db.list_channels(None).await?;
let tenant = resolve_admin_tenant(&db).await?;
let channels = db.list_channels(tenant.community(), None).await?;
if channels.is_empty() {
println!("No channels in database.");
return Ok(());
Expand All @@ -417,7 +462,7 @@ async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
kinds: Some(vec![39000]),
d_tag: Some(channel_id_str.clone()),
limit: Some(1),
..Default::default()
..EventQuery::for_community(tenant.community())
})
.await
.unwrap_or_default();
Expand All @@ -427,7 +472,7 @@ async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
continue;
}

let members = db.get_members(channel.id).await?;
let members = db.get_members(tenant.community(), channel.id).await?;

// kind:39000 — channel metadata
{
Expand All @@ -453,7 +498,7 @@ async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?;
db.replace_addressable_event(&event, Some(channel.id))
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}

Expand All @@ -471,7 +516,7 @@ async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?;
db.replace_addressable_event(&event, Some(channel.id))
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}

Expand All @@ -486,7 +531,7 @@ async fn reconcile_channels(relay_key_arg: Option<String>) -> Result<()> {
.tags(tags)
.sign_with_keys(&relay_keys)
.map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?;
db.replace_addressable_event(&event, Some(channel.id))
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
.await?;
}

Expand Down
89 changes: 56 additions & 33 deletions crates/buzz-audit/src/entry.rs
Original file line number Diff line number Diff line change
@@ -1,49 +1,72 @@
use buzz_core::CommunityId;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;

use crate::action::AuditAction;

/// Materialised audit log entry as stored in the DB.
#[derive(Debug, Clone, Serialize, Deserialize)]
/// A materialised audit log entry as stored in `audit_log`.
///
/// Rows are keyed `(community_id, seq)`: `seq` is monotonic *within one
/// community*, and `prev_hash` chains to the previous entry *of the same
/// community*. The chain is independent per tenant.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuditEntry {
/// Monotonically increasing sequence number.
/// Server-resolved community this entry belongs to. Leads the primary key.
pub community_id: Uuid,
/// Sequence number, monotonic within `community_id` (starts at 1).
pub seq: i64,
/// When the entry was recorded.
pub timestamp: DateTime<Utc>,
/// Nostr event ID that triggered this action.
pub event_id: String,
/// Nostr event kind number.
pub event_kind: u32,
/// Hex-encoded Nostr pubkey.
pub actor_pubkey: String,
/// SHA-256 of this entry's fields including `community_id` and `prev_hash`.
pub hash: Vec<u8>,
/// SHA-256 of the previous entry in *this community's* chain, or `None` for
/// the community's first entry (hashed as [`crate::hash::GENESIS_HASH`]).
pub prev_hash: Option<Vec<u8>>,
/// Action that was performed.
pub action: AuditAction,
/// Channel this action applies to, if any.
pub channel_id: Option<Uuid>,
/// Arbitrary JSON context. **Included in hash computation** (serialized with
/// sorted keys for determinism) so that metadata tampering is detectable.
pub metadata: serde_json::Value,
/// SHA-256 hex hash of the previous entry (or [`crate::hash::GENESIS_HASH`] for the first).
pub prev_hash: String,
/// SHA-256 hex hash of this entry's fields including `prev_hash`.
pub hash: String,
/// Raw bytes of the actor's Nostr pubkey, if the action has one.
pub actor_pubkey: Option<Vec<u8>>,
/// Generic identifier of the object acted upon (event id hex, channel UUID,
/// media sha256, …), if any. The relay resolves it under `community_id`;
/// it never names an object in another community.
pub object_id: Option<String>,
/// Arbitrary JSON context. **Included in the hash** (serialized with sorted
/// keys for determinism) so tampering with it is detectable.
pub detail: serde_json::Value,
/// When the entry was recorded.
pub created_at: DateTime<Utc>,
}

/// Input for creating a new audit entry. `seq`, `prev_hash`, `hash` are computed by `AuditService::log`.
#[derive(Debug, Clone, Serialize, Deserialize)]
/// Input for appending a new audit entry. `seq`, `prev_hash`, `hash`, and
/// `created_at` are assigned by [`crate::service::AuditService::log`].
///
/// `community_id` is the **server-resolved** tenant (from the request's
/// `TenantContext`), never a client-supplied value — the same provenance rule
/// the whole multi-tenant model rests on.
///
/// Not `Serialize`/`Deserialize`: this is an in-process input struct (consumed
/// by `AuditService::log`, threaded through the in-memory audit sink), never
/// crossing a wire or DB boundary as a whole. Keeping it non-deserializable
/// reinforces the fence — there is no path by which a client-supplied blob
/// becomes a `NewAuditEntry` (and thus a `CommunityId`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NewAuditEntry {
/// Nostr event ID that triggered this action.
pub event_id: String,
/// Must not be 22242 (NIP-42 AUTH).
pub event_kind: u32,
/// Hex-encoded Nostr pubkey of the actor.
pub actor_pubkey: String,
/// Server-resolved community this entry belongs to. Typed as [`CommunityId`]
/// (not a raw `Uuid`) so the provenance rule is visible in the signature:
/// the only ways to obtain one are host resolution or a server-scoped DB
/// row — never a value parsed from client input.
pub community_id: CommunityId,
/// Action that was performed.
pub action: AuditAction,
/// Channel this action applies to, if any.
pub channel_id: Option<Uuid>,
/// Arbitrary JSON context included in hash computation.
#[serde(default)]
pub metadata: serde_json::Value,
/// Raw bytes of the actor's Nostr pubkey, if the action has one.
pub actor_pubkey: Option<Vec<u8>>,
/// Generic identifier of the object acted upon, if any.
pub object_id: Option<String>,
/// Arbitrary JSON context included in the hash.
///
/// **Never bearer-token material.** This field is opaque to the audit
/// crate and persisted verbatim; callers must not write tokens, passwords,
/// or other secrets here. `AuthSuccess`/`AuthFailure` entries carry only
/// outcome metadata — the token has no slot in this type, and `detail` must
/// not become one.
pub detail: serde_json::Value,
}
Loading
Loading