mirror of
https://github.com/block/buzz.git
synced 2026-11-08 02:11:36 +00:00
## Summary Adds a durable, operator-controlled V1 for deleting an entire Buzz community without deleting another tenant's data. The workflow is exposed through `buzz-admin deletions`: - `sweep` records independent fleet storage-taxonomy observations - `submit`, `list`, `inspect`, and `approve` manage a deletion request - `unblock` resumes a fail-closed request after an operator records remediation identity and reason - `run` and `drain` execute bounded work Requests advance through a PostgreSQL-backed state machine and stop at `retention_pending` after logical deletion has been independently verified across PostgreSQL, object storage, and Redis. This PR ships the engine and CLI, not a continuously running worker or Kubernetes packaging. For V1, a cluster/VM administrator invokes `/usr/local/bin/buzz-admin` from the existing relay image, for example with `kubectl exec` or an equivalent container/VM exec path. ## What whole-community V1 removes For the target community, V1 removes: - rows from the allowlisted community-scoped PostgreSQL catalog, including members, profiles, authored events and bodies, DMs, reactions, mentions, memberships, tokens, workflows, moderation, audit, feedback, and rate-limit state - media sidecars and upload-attribution records under `_meta/<community>/` and `_uploads/<community>/` - Git repository pointers under `repos/<community>/` - Redis keys under `buzz:<community>:*` The community row survives as a permanent tombstone, and deletion control-plane records remain as evidence of the request, approval, execution, and result. ## Safety model Deletion is not a broad `DELETE CASCADE` followed by optimistic cleanup. The destructive boundaries are durable and fail closed. ### 1. Inventory and approval - `submit` resolves the target and freezes the schema plus summary-only storage inventory. - Approval is bound to the exact request, community, and frozen inventory digest. - Unsupported manifest versions, malformed keys inside the target's owned prefixes, live scoped-table/write-fence coverage drift, frozen-inventory mismatch, and approval mismatch block execution rather than guessing. Migration and catalog revision numbers are not authorization gates; the executor validates the live safety shape instead. - Storage inventory is server-side prefix scoped to exactly: - `_meta/<community>/` - `_uploads/<community>/` - `repos/<community>/` - The deletion path never lists the whole shared bucket and has no arbitrary per-community object cap. Its listing work is proportional to the target community's bindings, not total fleet storage. - Fleet-wide taxonomy sweeps remain independent observability. They report unknown writer shapes but do not gate deletion submission, fencing, or destructive progress. Maintainers must add deletion taxonomy coverage whenever a new community-owned object-key class is introduced; writer-coverage tests bind the current media and Git writers to that contract. ### 2. Quiesce, fence, and destructive freeze - Writes continue through submission, inventory, and approval. They stop when execution moves the target into `quiescing` and then establishes the durable fence. - Already-admitted external effects finish under heartbeated serving-write leases; the exact admitted lease may renew while the community is quiescing, but new lease acquisition is rejected. The executor drains admitted leases before destructive work. - Invite minting after quiescing begins fails as typed `AccessDenied` (HTTP 503 at the relay boundary) before an invite can be persisted. - Database triggers enforce the community write fence across the complete catalog of community-scoped tables. Startup/readiness and destructive execution validate that catalog so a newly added but unfenced table cannot silently escape. - **Named isolation assumption — fresh write snapshot.** Every writer transaction that can reach a community-fenced relation must use PostgreSQL `READ COMMITTED`; each guarded write therefore observes a statement snapshot no older than acquisition of the community deletion lock. `REPEATABLE READ` and `SERIALIZABLE` can retain a pre-fence snapshot and are unsupported for writers. The writer pool refuses non-`READ COMMITTED` sessions at connection setup, and both SQL fence functions reject an explicit per-transaction isolation override with SQLSTATE `25000`. Configuration-delivered bad isolation can surface through SQLx as a pool-acquire timeout because every `after_connect` attempt is rejected; the precise `community writes require READ COMMITTED isolation` reason remains observable when the SQL guard is reached. Read-only replica transactions are outside this assumption. - Holding the shared advisory lock until the guarded write executes is a separate liveness condition: under `READ COMMITTED`, releasing it early does not permit resurrection because the trigger rechecks the fence, but it can turn a fleet sweep into a statement-wide SQLSTATE `55000` abort. - After the fence closes writers, storage is re-enumerated into chunked side-table rows. Per-prefix counts and digests bind those concrete keys to the destructive manifest. - Manifest chunk insertion, update, and deletion are protected after freeze. This closes the race where an unbound key could otherwise appear after the manifest was committed. ### 3. Checkpointed destruction - Target-owned object bindings are deleted from the frozen destructive manifest in bounded batches with durable progress. - The concrete key list lives in chunked side-table rows rather than one request-row JSON value. It supports large communities, resumable execution, and terminal cleanup. - Missing objects are accepted as idempotent crash-window outcomes; malformed ownership, changed evidence, and unexplained target-prefix drift fail closed. - PostgreSQL purging remains scoped by `community_id`, including the guarded NIP-RS hard-delete path discovered with real Desktop kind `30078` read-state data. - Redis cleanup explicitly scans and `UNLINK`s only `buzz:<community_id>:*`. Natural expiry is insufficient because some keys, including tunnel generation counters used as fencing state, are deliberately persistent. ### 4. Independent verification - PostgreSQL logical absence is checked after purge. - The three target-owned storage prefixes are freshly inventoried again and must be empty. - Redis requires two complete empty namespace scans. - Only after all three stores pass does the request advance through `logically_verified` to `retention_pending`. ## What V1 deliberately does not erase ### Shared content-addressed storage Per-community deletion removes bindings, metadata, attribution records, and Git pointers. It does **not** physically delete fleet-shared CAS bytes that another community may still reference: - media blobs and thumbnails - Git manifests, packs, and indexes (`manifests/`, `packs/`, and `idx/`) Safe reclamation requires a separate fleet-wide reachability and retention GC. Unknown keys elsewhere in the shared bucket do not block one community's deletion; malformed or unrecognized keys inside that community's three owned prefixes still fail closed. ### External retained copies The online logical-deletion proof does not erase object versions/replicas, database backups/WAL, CDN copies, provider retention copies, or observability exports. Those require their own retention and purge controls. ### Member-only erasure This PR erases a whole community. It does not implement the different operation "erase one npub while preserving the community." Removing membership or accepting NIP-09 is not member erasure. A member-only workflow would need to find and selectively remove or redact authored event content and pubkeys, profile data, DMs, reactions, mentions, memberships/roles, tokens, workflows/subscriptions, upload attribution, moderation/audit history, repository attribution, and identity embedded in tags or JSON. It would also need explicit rules for ownership transfer, surviving replies and thread metadata, audit-chain integrity, immutable Git history, and shared-CAS reachability. That requires a pubkey-level fence and selective graph rewrite; it is a separate deletion product, not a safe extension of this whole-tenant worker. ## In scope - migration `0029_community_deletion.sql`: requests, approvals, leases, manifest chunks, checkpoints, tombstones, and the universal write-fence catalog - durable executor leases, generations, heartbeats, retry/block state, and resumable stage transitions - operator-driven `sweep`, `submit`, `list`, `inspect`, `approve`, `unblock`, `run`, and `drain` commands - serving-path fences for database writes and external effects across event ingest, media, Git, workflow, push, invites, mesh/tunnel, and related paths - target-prefix-only storage inventory, summary manifests, post-fence destructive chunks, and bounded batch deletion - exact community Redis namespace purge and two-pass absence verification - cross-community isolation, crash/resume, manifest-integrity, writer-taxonomy, and schema/migration regressions - desired-state `schema/schema.sql` support without requiring a SQLx migration ledger ## Deferred / not covered - dedicated Helm/chart worker Deployment, service account, secrets, probes, resources, and network policy - autonomous `buzz-admin deletions worker` poll loop and worker-only health server - least-privilege separation among migration, relay-serving, and destructive execution roles - fleet-wide shared-CAS physical GC - backup/provider/CDN/observability retention completion - member-only erasure - provider-native conditional-delete improvements - a general force-continue escape hatch; permanent safety failures remain fail closed unless an operator remediates the cause and records an audited `unblock` The removed continuous-worker implementation remains deferred; no remote follow-up branch is claimed by this PR. ## Validation ### Current PR head and repository state Current pushed head: `359d8402ee15f049768f54156f67b953c7a7e2ed`, rebased onto `cc9a2f783375e51a6e8d1f2f9d01d5f7e22813d1` (`origin/main` at push time). The complete PR diff is now 47 files, 9,834 additions, and 517 deletions. The bespoke source-scanner stack was removed to keep this PR scoped to community deletion. Tyler/team requested the underlying fenced-write safety behavior, not `ast-grep`, `crates/buzz-db/tests/community_fenced_writes.rs`, its 27 fixtures, or the new `scripts/lints/community_*.yml` rules. Those scanner-specific files, dependencies, Hermit links, and runner wiring are absent from the current tree. The production database write fence, startup/destructive live-catalog validation, and deletion behavior remain. Source validation on this exact SHA passed: - `cargo fmt --all -- --check` - `bash -n scripts/run-tests.sh` - `cargo nextest run -p buzz-db --all-targets`: 102 passed, 173 skipped, 0 failed - `cargo nextest run -p buzz-deletion --all-targets`: 10 passed, 9 skipped, 0 failed - `cargo nextest run -p buzz-admin --all-targets`: 1 passed, 0 failed - affected-package/all-target Clippy with warnings denied - lockfile consistency - Helm 3.16.4 lint and all 44 chart unit tests - Helm region controls using that fixture: default `BUZZ_S3_REGION=us-east-1`, explicit `eu-west-2` override, and blank-region schema rejection The prior Kubernetes battery below was run against `928992237358a3294621ac0280830b77155abc04`. It remains useful evidence for the patch-equivalent production deletion implementation, but it is **not** claimed as exact-SHA evidence for current head `359d8402ee15f049768f54156f67b953c7a7e2ed`; the current cleanup removes only scanner/test/tooling infrastructure. CI restarted for the new head after the rebase and is pending. Human review remains `CHANGES_REQUESTED`. ### Prior-head live Kubernetes deletion and safety gates The full program used one immutable image, real PostgreSQL, Redis, MinIO, and a three-relay Kubernetes release: - source: `928992237358a3294621ac0280830b77155abc04` (**prior head**) - image: `buzz-e2e:sha-928992237358` - immutable image digest: `sha256:a1a204f4618ac22d9e210be5e5290645a15d79831ae30b0e44379357c8e4a895` - evidence root: `/tmp/buzz-e2e/20260807T033025Z-928992237358-full-gates/` - evidence-manifest digest: `82875c5bc9bea7370b796a7aef3457b3a1c8306c84c59e0f7388bbb5ad30e865` Passed gates at that prior head: - **Chart/operator region:** default `us-east-1`, explicit nondefault propagation, blank-region schema rejection, live in-pod environment, and an in-pod taxonomy sweep over 18 objects with zero unknown. - **Fenced writers and lifecycle:** open-write/fence ordering; 100-attempt anti-starvation; invite, push matcher, and exhausted-reaper bystander isolation; non-`READ-COMMITTED` rejection; manifest/tombstone contracts; eight-failure stage block and audited `unblock`. - **Destructive lifecycle:** submit → approve → run → `retention_pending`; PostgreSQL tombstone and Redis/S3 verification true; zero retries/errors; terminal reruns rejected with exit 5. - **Fresh 10,001-object crash boundary:** exactly two chunks (10,000 + 1). The executor deleted chunk 0 from MinIO while its PostgreSQL stamp was row-lock-blocked, was killed with `SIGKILL`, left one object and both stamps absent, then resumed the same request under generation 2 to zero objects and terminal state. - **Independent dead-owner recovery:** a dedicated executor claimed generation 1, blocked before effects, and was killed through containerd with `SIGKILL` (no TERM cleanup). The request remained owned and unreclaimable before lease expiry; a successor claimed generation 2 after 60 seconds and completed with two attempts and zero retries. - **Three-pod socket isolation:** ordinary NIP-42 and joined huddle-audio target witnesses on every replica received exact `1008 / community deleted`; healthy-tenant witnesses on those pods remained live; deleted-host reconnect returned HTTP 404. - **Health/provenance:** all replicas independently returned ready and retained the exact image digest before/after destructive runs and an audio-enabled rolling restart; PostgreSQL, Redis, and MinIO were healthy at close. Instrument corrections were retained as evidence rather than counted as product failures: a foreground PostgreSQL forward caused an initial `PoolTimedOut`; Kubernetes pod deletion exercised graceful TERM rather than dead-owner recovery; shell-background socket witnesses died with their parent; and the first image build hit the corporate TLS proxy. Detached forwarding/witnesses, containerd `SIGKILL`, and the configured internal CA/Artifactory mirror produced the discriminating runs without weakening product security. ### Prior-head cleanup For the prior-head Kubernetes run, the Helm release was removed, namespace absence was verified, run-owned Screen sessions were absent, and that source worktree remained clean. The evidence manifest was independently recomputed and every indexed artifact passed `shasum -a 256 -c`. The current `359d8402` source worktree is also clean after the scanner-only cleanup and push. --------- Signed-off-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Signed-off-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: Kalvin Chau <kalvin@block.xyz> Signed-off-by: am <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Signed-off-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz> Co-authored-by: npub122y0pqkertljmedu303rl0aqrj3w8pvu43t6jxm6875lzg6f2pwqegc3xc <5288f082d91aff2de5bc8be23fbfa01ca2e3859cac57a91b7a3fa9f12349505c@buzz.block.builderlab.xyz> Co-authored-by: npub1dccv64krpcpse5cmkzfeh998cftungyatw3djt8jwdw6g43f7fyqzzmrf7 <6e30cd56c30e030cd31bb0939b94a7c257c9a09d5ba2d92cf2735da45629f248@buzz.block.builderlab.xyz> Co-authored-by: cid <d9f92a72922bf45c17379a47d64dae84b6020397c2d5a52b5317d512068cd9d3@buzz.block.builderlab.xyz>
593 lines
21 KiB
Rust
593 lines
21 KiB
Rust
#![deny(unsafe_code)]
|
|
|
|
//! Buzz instance administration CLI.
|
|
//!
|
|
//! # Member management (NIP-43)
|
|
//!
|
|
//! ## Why only kind:13534 (membership list), not kind:8000/8001 (deltas)
|
|
//!
|
|
//! CLI intentionally does not emit kind 8000/8001 deltas —
|
|
//! `publish_nip43_delta` is in-process-only (no Redis hop), so a sidecar call
|
|
//! stores but never pushes. The 13534 list snapshot is the authoritative roster
|
|
//! and rides Redis to live clients. Do not wire a delta call that passes
|
|
//! in-process tests and silently no-ops in the deployed `compose exec` path.
|
|
//!
|
|
//! ## Same-second domination guard
|
|
//!
|
|
//! The `custom_created_at = max(now, newest_existing_13534 + 1s)` bump defeats
|
|
//! same-second domination for serial invocations; it does NOT serialize
|
|
//! concurrent CLI processes — two near-simultaneous adds can read the same
|
|
//! newest timestamp and collide on the bumped second. run.sh serialization is
|
|
//! the guard against parallel adds (e.g. `xargs -P`).
|
|
|
|
mod deletions;
|
|
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Result;
|
|
use buzz_core::kind::KIND_NIP43_MEMBERSHIP_LIST;
|
|
use buzz_core::tenant::{relay_url_authority, TenantContext};
|
|
use buzz_db::{Db, DbConfig};
|
|
use buzz_pubsub::{EventTopic, PubSubManager};
|
|
use clap::{Parser, Subcommand};
|
|
use nostr::{EventBuilder, Keys, Kind, Tag};
|
|
use tracing::warn;
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "buzz-admin", about = "Buzz instance administration")]
|
|
struct Cli {
|
|
#[command(subcommand)]
|
|
command: Command,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Command {
|
|
/// Add a pubkey to the relay membership list.
|
|
///
|
|
/// Accepts a bech32 npub or 64-char hex pubkey. After inserting the DB row,
|
|
/// publishes a kind:13534 membership roster via Redis so live clients see
|
|
/// the updated list immediately.
|
|
AddMember {
|
|
/// Nostr public key — bech32 npub or 64-char hex.
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
|
|
/// Role: "admin" or "member" (default: member). Cannot be "owner" —
|
|
/// use RELAY_OWNER_PUBKEY config to set the relay owner.
|
|
#[arg(long, default_value = "member")]
|
|
role: String,
|
|
},
|
|
/// Remove a pubkey from the relay membership list.
|
|
///
|
|
/// Accepts a bech32 npub or 64-char hex pubkey. After removing the DB row,
|
|
/// publishes a kind:13534 membership roster via Redis. Cannot remove the
|
|
/// relay owner — change RELAY_OWNER_PUBKEY config instead.
|
|
RemoveMember {
|
|
/// Nostr public key — bech32 npub or 64-char hex.
|
|
#[arg(long)]
|
|
pubkey: String,
|
|
|
|
/// Only remove if the member's current role matches this value.
|
|
/// Omit to remove regardless of role.
|
|
#[arg(long)]
|
|
role: Option<String>,
|
|
},
|
|
/// List all relay members.
|
|
ListMembers,
|
|
/// Generate a new Nostr keypair (for bootstrapping).
|
|
GenerateKey,
|
|
/// Run pending database migrations.
|
|
Migrate,
|
|
/// Inspect deployment-wide Buzz product feedback.
|
|
ProductFeedback {
|
|
#[command(subcommand)]
|
|
command: ProductFeedbackCommand,
|
|
},
|
|
/// Durable CLI-only whole-community deletion control plane.
|
|
Deletions {
|
|
#[command(subcommand)]
|
|
command: deletions::DeletionsCommand,
|
|
},
|
|
/// Emit kind:39000/39002 events for channels missing them.
|
|
///
|
|
/// Channels created via direct SQL (seed scripts, pre-migration data) won't
|
|
/// have Nostr discovery events. This command creates them so pure-nostr
|
|
/// clients can see those channels. Idempotent — safe to run multiple times.
|
|
ReconcileChannels {
|
|
/// Relay private key (hex) for signing events. Falls back to
|
|
/// BUZZ_RELAY_PRIVATE_KEY env var. If neither is set, generates
|
|
/// an ephemeral key (events will be unverifiable after restart).
|
|
#[arg(long)]
|
|
relay_key: Option<String>,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum ProductFeedbackCommand {
|
|
/// List feedback across every community as JSON.
|
|
List {
|
|
/// Maximum records to return.
|
|
#[arg(long, default_value_t = 100, value_parser = clap::value_parser!(u16).range(1..=1000))]
|
|
limit: u16,
|
|
},
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
// Install the ring CryptoProvider for rustls. The workspace redis TLS
|
|
// feature compiles both aws-lc-rs and ring in transitively, so rustls can't
|
|
// auto-select a provider and would panic on the first rediss:// (ElastiCache)
|
|
// Redis TLS connection without this. Mirrors buzz-relay's main().
|
|
rustls::crypto::ring::default_provider()
|
|
.install_default()
|
|
.expect("failed to install rustls crypto provider");
|
|
|
|
let cli = Cli::parse();
|
|
|
|
let code = match run(cli).await {
|
|
Ok(code) => code,
|
|
Err(e) => {
|
|
eprintln!("error: {e}");
|
|
5
|
|
}
|
|
};
|
|
std::process::exit(code);
|
|
}
|
|
|
|
async fn run(cli: Cli) -> Result<i32> {
|
|
match cli.command {
|
|
Command::GenerateKey => {
|
|
let keys = Keys::generate();
|
|
println!("Public key: {}", keys.public_key().to_hex());
|
|
println!("Secret key: {}", keys.secret_key().display_secret());
|
|
println!("\nSet BUZZ_PRIVATE_KEY to the secret key to use this identity.");
|
|
Ok(0)
|
|
}
|
|
Command::Migrate => {
|
|
let db = connect_db().await?;
|
|
db.migrate().await?;
|
|
println!("Database migrations complete.");
|
|
Ok(0)
|
|
}
|
|
Command::AddMember { pubkey, role } => cmd_add_member(pubkey, role).await,
|
|
Command::RemoveMember { pubkey, role } => cmd_remove_member(pubkey, role).await,
|
|
Command::ListMembers => cmd_list_members().await,
|
|
Command::ProductFeedback {
|
|
command: ProductFeedbackCommand::List { limit },
|
|
} => cmd_list_product_feedback(limit).await,
|
|
Command::Deletions { command } => deletions::run(command).await,
|
|
Command::ReconcileChannels { relay_key } => {
|
|
reconcile_channels(relay_key).await?;
|
|
Ok(0)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn cmd_add_member(pubkey_arg: String, role: String) -> Result<i32> {
|
|
if let Err(msg) = validate_role(&role) {
|
|
eprintln!("error: {msg}");
|
|
return Ok(1);
|
|
}
|
|
|
|
let pubkey_hex = match parse_pubkey_hex(&pubkey_arg) {
|
|
Ok(h) => h,
|
|
Err(msg) => {
|
|
eprintln!("error: {msg}");
|
|
return Ok(1);
|
|
}
|
|
};
|
|
|
|
let (db, pubsub, relay_keypair) = connect_member_services().await?;
|
|
|
|
let tenant = resolve_admin_tenant(&db).await?;
|
|
match db
|
|
.add_relay_member(tenant.community(), &pubkey_hex, &role, None)
|
|
.await
|
|
{
|
|
Ok(true) => println!("added {pubkey_hex} as {role}"),
|
|
Ok(false) => println!("already a member: {pubkey_hex} (no change)"),
|
|
Err(e) => {
|
|
eprintln!("error: DB write failed: {e}");
|
|
return Ok(5);
|
|
}
|
|
}
|
|
|
|
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}");
|
|
}
|
|
|
|
Ok(0)
|
|
}
|
|
|
|
async fn cmd_remove_member(pubkey_arg: String, role_filter: Option<String>) -> Result<i32> {
|
|
if let Some(ref role) = role_filter {
|
|
if let Err(msg) = validate_role(role) {
|
|
eprintln!("error: {msg}");
|
|
return Ok(1);
|
|
}
|
|
}
|
|
|
|
let pubkey_hex = match parse_pubkey_hex(&pubkey_arg) {
|
|
Ok(h) => h,
|
|
Err(msg) => {
|
|
eprintln!("error: {msg}");
|
|
return Ok(1);
|
|
}
|
|
};
|
|
|
|
let (db, pubsub, relay_keypair) = connect_member_services().await?;
|
|
|
|
let tenant = resolve_admin_tenant(&db).await?;
|
|
use buzz_db::relay_members::RemoveResult;
|
|
let result = if let Some(ref role) = role_filter {
|
|
db.remove_relay_member_if_role(tenant.community(), &pubkey_hex, role)
|
|
.await
|
|
} else {
|
|
db.remove_relay_member(tenant.community(), &pubkey_hex)
|
|
.await
|
|
};
|
|
|
|
match result {
|
|
Ok(RemoveResult::Removed) => println!("removed {pubkey_hex}"),
|
|
Ok(RemoveResult::NotFound) => {
|
|
eprintln!("error: member not found: {pubkey_hex}");
|
|
return Ok(2);
|
|
}
|
|
Ok(RemoveResult::IsOwner) => {
|
|
eprintln!(
|
|
"error: cannot remove relay owner: {pubkey_hex}\n\
|
|
To change the owner, update RELAY_OWNER_PUBKEY and restart."
|
|
);
|
|
return Ok(3);
|
|
}
|
|
Ok(RemoveResult::RoleMismatch) => {
|
|
let role_str = role_filter.as_deref().unwrap_or("(unknown)");
|
|
eprintln!("error: role mismatch — {pubkey_hex} is not currently '{role_str}'");
|
|
return Ok(4);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("error: DB write failed: {e}");
|
|
return Ok(5);
|
|
}
|
|
}
|
|
|
|
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}");
|
|
}
|
|
|
|
Ok(0)
|
|
}
|
|
|
|
async fn cmd_list_product_feedback(limit: u16) -> Result<i32> {
|
|
let db = connect_db().await?;
|
|
let feedback = db.list_product_feedback(i64::from(limit)).await?;
|
|
println!("{}", serde_json::to_string_pretty(&feedback)?);
|
|
Ok(0)
|
|
}
|
|
|
|
async fn cmd_list_members() -> Result<i32> {
|
|
let db = connect_db().await?;
|
|
let tenant = resolve_admin_tenant(&db).await?;
|
|
let members = db.list_relay_members(tenant.community()).await?;
|
|
|
|
if members.is_empty() {
|
|
println!("(no relay members)");
|
|
return Ok(0);
|
|
}
|
|
|
|
println!(
|
|
"{:<66} {:<8} {:<66} created_at",
|
|
"pubkey", "role", "added_by"
|
|
);
|
|
println!("{}", "-".repeat(160));
|
|
for m in &members {
|
|
let added_by = m.added_by.as_deref().unwrap_or("-");
|
|
println!(
|
|
"{:<66} {:<8} {:<66} {}",
|
|
m.pubkey,
|
|
m.role,
|
|
added_by,
|
|
m.created_at.format("%Y-%m-%dT%H:%M:%SZ")
|
|
);
|
|
}
|
|
|
|
Ok(0)
|
|
}
|
|
|
|
/// Validate that `role` is `"member"` or `"admin"`. Rejects `"owner"`.
|
|
fn validate_role(role: &str) -> std::result::Result<(), String> {
|
|
match role {
|
|
"member" | "admin" => Ok(()),
|
|
"owner" => {
|
|
Err("role 'owner' cannot be set via CLI — use RELAY_OWNER_PUBKEY config".to_string())
|
|
}
|
|
other => Err(format!(
|
|
"invalid role '{other}': must be 'member' or 'admin'"
|
|
)),
|
|
}
|
|
}
|
|
|
|
/// Parse a bech32 npub or 64-char hex pubkey into lowercase hex.
|
|
fn parse_pubkey_hex(input: &str) -> std::result::Result<String, String> {
|
|
nostr::PublicKey::parse(input)
|
|
.map(|pk| pk.to_hex())
|
|
.map_err(|e| format!("invalid pubkey '{input}': {e}"))
|
|
}
|
|
|
|
/// Publish kind:13534 with `custom_created_at = max(now, newest_existing + 1s)`.
|
|
///
|
|
/// Guarantees the new event is not dominated by a same-second prior invocation,
|
|
/// so `replace_addressable_event` always inserts and dispatches to Redis.
|
|
///
|
|
/// See module-level doc for the TOCTOU caveat on concurrent CLI processes.
|
|
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)
|
|
.unwrap_or_default()
|
|
.as_secs();
|
|
|
|
let relay_pubkey = relay_keypair.public_key();
|
|
let relay_pubkey_bytes = relay_pubkey.to_bytes();
|
|
|
|
// Query the newest existing kind:13534 for this relay's pubkey (channel_id=None).
|
|
let newest_ts = db
|
|
.get_latest_global_replaceable(
|
|
tenant.community(),
|
|
KIND_NIP43_MEMBERSHIP_LIST as i32,
|
|
&relay_pubkey_bytes,
|
|
)
|
|
.await?
|
|
.map(|e| e.event.created_at.as_secs());
|
|
|
|
// custom_created_at = max(now, existing + 1s) — defeats same-second domination.
|
|
let ts = match newest_ts {
|
|
Some(existing) => (existing + 1).max(now),
|
|
None => now,
|
|
};
|
|
|
|
let members = db.list_relay_members(tenant.community()).await?;
|
|
|
|
let mut tags: Vec<Tag> = Vec::with_capacity(members.len() + 1);
|
|
// NIP-70 protected-event marker — prevents re-broadcasting by third parties.
|
|
tags.push(Tag::parse(["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?);
|
|
for member in &members {
|
|
tags.push(
|
|
Tag::parse(["member", &member.pubkey, &member.role])
|
|
.map_err(|e| anyhow::anyhow!("failed to build member tag: {e}"))?,
|
|
);
|
|
}
|
|
|
|
let event = EventBuilder::new(Kind::Custom(KIND_NIP43_MEMBERSHIP_LIST as u16), "")
|
|
.tags(tags)
|
|
.custom_created_at(nostr::Timestamp::from(ts))
|
|
.sign_with_keys(relay_keypair)
|
|
.map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?;
|
|
|
|
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.
|
|
// 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}");
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
member_count = members.len(),
|
|
ts,
|
|
"NIP-43 membership list published by buzz-admin"
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
/// Connect to DB, Redis pub/sub, and load the relay keypair.
|
|
///
|
|
/// `BUZZ_RELAY_PRIVATE_KEY` is required — the CLI signs kind:13534 events.
|
|
async fn connect_member_services() -> Result<(Db, Arc<PubSubManager>, Keys)> {
|
|
let db = connect_db().await?;
|
|
|
|
let relay_keypair = {
|
|
let hex = std::env::var("BUZZ_RELAY_PRIVATE_KEY").map_err(|_| {
|
|
anyhow::anyhow!(
|
|
"BUZZ_RELAY_PRIVATE_KEY is required for add-member/remove-member.\n\
|
|
The relay must have a stable signing key to publish kind:13534 events."
|
|
)
|
|
})?;
|
|
Keys::parse(&hex).map_err(|e| anyhow::anyhow!("invalid BUZZ_RELAY_PRIVATE_KEY: {e}"))?
|
|
};
|
|
|
|
let redis_url =
|
|
std::env::var("REDIS_URL").unwrap_or_else(|_| "redis://localhost:6379".to_string());
|
|
|
|
let redis_pool = {
|
|
let cfg = deadpool_redis::Config::from_url(&redis_url);
|
|
cfg.create_pool(Some(deadpool_redis::Runtime::Tokio1))
|
|
.map_err(|e| anyhow::anyhow!("Redis pool creation failed: {e}"))?
|
|
};
|
|
|
|
let pubsub = Arc::new(
|
|
PubSubManager::new(&redis_url, redis_pool)
|
|
.await
|
|
.map_err(|e| anyhow::anyhow!("PubSub init failed: {e}"))?,
|
|
);
|
|
|
|
Ok((db, pubsub, relay_keypair))
|
|
}
|
|
|
|
async fn connect_db() -> Result<Db> {
|
|
let db_url = std::env::var("DATABASE_URL")
|
|
.unwrap_or_else(|_| "postgres://buzz:buzz_dev@localhost:5432/buzz".to_string());
|
|
let db = Db::new(&DbConfig {
|
|
database_url: db_url,
|
|
..DbConfig::default()
|
|
})
|
|
.await?;
|
|
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());
|
|
// Derive the authority the *same* way startup seeding and live request
|
|
// resolution do (`buzz_core::tenant::relay_url_authority`): host plus an
|
|
// explicit non-default port, IPv6 brackets preserved. A plain
|
|
// `Url::host_str()` drops the port/brackets, so for `ws://localhost:3000`
|
|
// the admin would look up `localhost` while startup seeded `localhost:3000`
|
|
// — and `wss://relay.example:8443` would resolve `relay.example`. Sharing
|
|
// the helper keeps buzz-admin byte-identical to the community startup seeds.
|
|
let host = relay_url_authority(&relay_url);
|
|
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;
|
|
|
|
let db = connect_db().await?;
|
|
|
|
// Resolve relay signing key: arg > env > ephemeral
|
|
let relay_keys = match relay_key_arg.or_else(|| std::env::var("BUZZ_RELAY_PRIVATE_KEY").ok()) {
|
|
Some(key_hex) => {
|
|
Keys::parse(&key_hex).map_err(|e| anyhow::anyhow!("invalid relay key: {e}"))?
|
|
}
|
|
None => {
|
|
let k = Keys::generate();
|
|
eprintln!(
|
|
"Warning: no relay key provided — using ephemeral key {}",
|
|
k.public_key().to_hex()
|
|
);
|
|
eprintln!("Events signed with this key won't be verifiable after this run.");
|
|
eprintln!("Pass --relay-key or set BUZZ_RELAY_PRIVATE_KEY for production use.");
|
|
k
|
|
}
|
|
};
|
|
|
|
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(());
|
|
}
|
|
|
|
let mut reconciled = 0u32;
|
|
let mut skipped = 0u32;
|
|
|
|
for channel in &channels {
|
|
let channel_id_str = channel.id.to_string();
|
|
|
|
// Check if kind:39000 already exists
|
|
let existing = db
|
|
.query_events(&EventQuery {
|
|
kinds: Some(vec![39000]),
|
|
d_tag: Some(channel_id_str.clone()),
|
|
limit: Some(1),
|
|
..EventQuery::for_community(tenant.community())
|
|
})
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
if !existing.is_empty() {
|
|
skipped += 1;
|
|
continue;
|
|
}
|
|
|
|
let members = db.get_members(tenant.community(), channel.id).await?;
|
|
|
|
// kind:39000 — channel metadata
|
|
{
|
|
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
|
|
tags.push(Tag::parse(["name", &channel.name])?);
|
|
if let Some(ref desc) = channel.description {
|
|
if !desc.is_empty() {
|
|
tags.push(Tag::parse(["about", desc])?);
|
|
}
|
|
}
|
|
if channel.visibility == "private" {
|
|
tags.push(Tag::parse(["private"])?);
|
|
} else {
|
|
tags.push(Tag::parse(["public"])?);
|
|
}
|
|
if channel.channel_type == "dm" {
|
|
tags.push(Tag::parse(["hidden"])?);
|
|
}
|
|
tags.push(Tag::parse(["closed"])?);
|
|
tags.push(Tag::parse(["t", &channel.channel_type])?);
|
|
|
|
let event = EventBuilder::new(Kind::Custom(39000), "")
|
|
.tags(tags)
|
|
.sign_with_keys(&relay_keys)
|
|
.map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?;
|
|
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
|
|
.await?;
|
|
}
|
|
|
|
// kind:39001 — admins
|
|
{
|
|
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
|
|
for m in members
|
|
.iter()
|
|
.filter(|m| m.role == "owner" || m.role == "admin")
|
|
{
|
|
let pk = hex::encode(&m.pubkey);
|
|
tags.push(Tag::parse(["p", &pk, &m.role])?);
|
|
}
|
|
let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "")
|
|
.tags(tags)
|
|
.sign_with_keys(&relay_keys)
|
|
.map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?;
|
|
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
|
|
.await?;
|
|
}
|
|
|
|
// kind:39002 — members
|
|
{
|
|
let mut tags: Vec<Tag> = vec![Tag::parse(["d", &channel_id_str])?];
|
|
for m in &members {
|
|
let pk = hex::encode(&m.pubkey);
|
|
tags.push(Tag::parse(["p", &pk, "", &m.role])?);
|
|
}
|
|
let event = EventBuilder::new(Kind::Custom(39002), "")
|
|
.tags(tags)
|
|
.sign_with_keys(&relay_keys)
|
|
.map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?;
|
|
db.replace_addressable_event(tenant.community(), &event, Some(channel.id))
|
|
.await?;
|
|
}
|
|
|
|
reconciled += 1;
|
|
}
|
|
|
|
println!(
|
|
"Reconciled {reconciled} channels ({skipped} already had events, {} total).",
|
|
channels.len()
|
|
);
|
|
Ok(())
|
|
}
|