Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ RELAY_URL=ws://localhost:3000
# How often the reaper checks for expired ephemeral channels (default: 60s).
# BUZZ_REAPER_INTERVAL_SECS=5

# Audited channel-owner recovery outbox delivery.
# BUZZ_RECOVERY_AUDIT_INTERVAL_SECS=10
# BUZZ_RECOVERY_AUDIT_BATCH_LIMIT=100

# -----------------------------------------------------------------------------
# Logging / Tracing
# -----------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ A Rust workspace of focused crates. Single source of truth: the relay. See [ARCH

- **[VISION.md](VISION.md)** · **[VISION_SOVEREIGN.md](VISION_SOVEREIGN.md)** · **[VISION_PROJECTS.md](VISION_PROJECTS.md)** · **[VISION_AGENT.md](VISION_AGENT.md)** — the four vision docs
- **[ARCHITECTURE.md](ARCHITECTURE.md)** — system design, kind ranges, subsystem boundaries
- **[Channel-owner recovery](docs/channel-owner-recovery.md)** — exceptional recovery policy, event contract, and relay operations
- **[TESTING.md](TESTING.md)** — multi-agent E2E test suite
- **[CONTRIBUTING.md](CONTRIBUTING.md)** · **[CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)** · **[SECURITY.md](SECURITY.md)** · **[GOVERNANCE.md](GOVERNANCE.md)**

Expand Down
8 changes: 8 additions & 0 deletions crates/buzz-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ buzz channels list
buzz channels create --name "my-channel" --type stream --visibility open
buzz channels join --channel <uuid>
buzz channels topic --channel <uuid> --topic "New topic"
buzz channels recover-owner --channel <uuid> --pubkey <hex> --reason "Prior consent recorded"

# Reactions
buzz reactions add --event <event-id> --emoji "👍"
Expand Down Expand Up @@ -95,6 +96,12 @@ buzz channels list | jq '.[].name'
constraint omitted from the command is removed. `protect list` reports malformed
stored rules in `validation_error` so an owner can remove and repair them.

`channels recover-owner` is an exceptional, promotion-only command. It requires
the target to be a current human member of the same community and channel, and
requires every current human owner to have self-archived with that exact target
as `replaced-by`. It does not recover a lost/deleted key without that prior
evidence. See [Channel-owner recovery](../../docs/channel-owner-recovery.md).

## Commands

| Group | Subcommand | Description |
Expand All @@ -121,6 +128,7 @@ stored rules in `validation_error` so an owner can remove and repair them.
| | `members` | List channel members |
| | `add-member` | Add a member |
| | `remove-member` | Remove a member |
| | `recover-owner` | Promote a prior-consented replacement through the audited recovery path |
| `canvas` | `get` | Get channel canvas |
| | `set` | Set channel canvas |
| `reactions` | `add` | React to a message |
Expand Down
22 changes: 22 additions & 0 deletions crates/buzz-cli/src/commands/channels.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1001,6 +1001,23 @@ pub async fn cmd_remove_channel_member(
Ok(())
}

/// Submit the dedicated, prior-self-consent-only channel-owner recovery event.
pub async fn cmd_recover_channel_owner(
client: &BuzzClient,
channel_id: &str,
target_pubkey: &str,
reason: &str,
) -> Result<(), CliError> {
let channel = parse_uuid(channel_id)?;
validate_hex64(target_pubkey)?;
let builder = buzz_sdk::build_channel_owner_recovery(channel, target_pubkey, reason)
.map_err(|error| CliError::Usage(error.to_string()))?;
let event = client.sign_event(builder)?;
let response = client.submit_event(event).await?;
println!("{}", normalize_write_response(&response));
Ok(())
}

/// Set the channel addition policy — sign and submit a kind:10100 (agent profile) event.
pub async fn cmd_set_add_policy(client: &BuzzClient, policy: &str) -> Result<(), CliError> {
match policy {
Expand Down Expand Up @@ -1161,6 +1178,11 @@ pub async fn dispatch(
ChannelsCmd::RemoveMember { channel, pubkey } => {
cmd_remove_channel_member(client, &channel, &pubkey).await
}
ChannelsCmd::RecoverOwner {
channel,
pubkey,
reason,
} => cmd_recover_channel_owner(client, &channel, &pubkey, &reason).await,
ChannelsCmd::SetAddPolicy { policy } => cmd_set_add_policy(client, &policy).await,
}
}
Expand Down
23 changes: 22 additions & 1 deletion crates/buzz-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -666,6 +666,26 @@ pub enum ChannelsCmd {
#[arg(long)]
pubkey: String,
},
/// Recover an orphaned channel owner using prior durable self-consent.
#[command(
name = "recover-owner",
after_help = "The relay denies this command unless every current human owner \
self-archived and named the target as replacement before becoming unavailable. \
Lost or deleted keys without that durable evidence are out of scope.\n\n\
Example:\n buzz channels recover-owner --channel <UUID> --pubkey <PUBKEY> \
--reason \"All owners recorded replacement consent\""
)]
RecoverOwner {
/// Channel UUID.
#[arg(long)]
channel: String,
/// Existing human member to promote (64-char hex).
#[arg(long)]
pubkey: String,
/// Human-readable immutable audit reason (1-500 bytes).
#[arg(long)]
reason: String,
},
/// Set your channel addition policy
#[command(name = "set-add-policy")]
SetAddPolicy {
Expand Down Expand Up @@ -1904,6 +1924,7 @@ mod tests {
"list",
"members",
"purpose",
"recover-owner",
"remove-member",
"search",
"set-add-policy",
Expand Down Expand Up @@ -1997,7 +2018,7 @@ mod tests {
let expected: Vec<(&str, usize)> = vec![
("agents", 5),
("canvas", 2),
("channels", 16),
("channels", 17),
("dms", 4),
("emoji", 5),
("feed", 1),
Expand Down
7 changes: 7 additions & 0 deletions crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,12 @@ pub const KIND_NIP43_LEAVE_REQUEST: u32 = 28936;
pub const KIND_IA_ARCHIVE_REQUEST: u32 = 9035;
/// NIP-IA: Request that the relay unarchive a target identity.
pub const KIND_IA_UNARCHIVE_REQUEST: u32 = 9036;
/// Buzz: Request audited recovery of an orphaned channel owner.
///
/// This is a dedicated command, separate from generic channel role changes.
pub const KIND_CHANNEL_OWNER_RECOVERY: u32 = 9038;
/// Marker on relay-signed, non-deletable channel-owner recovery audit events.
pub const CHANNEL_OWNER_RECOVERY_AUDIT_MARKER: &str = "channel-owner-recovery-v1";

// NIP-IA identity archival announcement events (relay-signed)
/// NIP-IA: Archived-identity delta (relay-signed).
Expand Down Expand Up @@ -612,6 +618,7 @@ pub const ALL_KINDS: &[u32] = &[
KIND_NIP43_LEAVE_REQUEST,
KIND_IA_ARCHIVE_REQUEST,
KIND_IA_UNARCHIVE_REQUEST,
KIND_CHANNEL_OWNER_RECOVERY,
KIND_IA_ARCHIVED,
KIND_IA_UNARCHIVED,
KIND_IA_ARCHIVED_LIST,
Expand Down
35 changes: 28 additions & 7 deletions crates/buzz-db/src/archived_identities.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,25 @@

use buzz_core::CommunityId;
use chrono::{DateTime, Utc};
use sqlx::{PgPool, Row as _};
use sqlx::{PgPool, Postgres, Row as _, Transaction};

use crate::error::Result;

/// Acquire the transaction-scoped identity lock shared by archive, unarchive,
/// and owner recovery. Callers that lock multiple identities must sort them.
pub(crate) async fn lock_identity(
tx: &mut Transaction<'_, Postgres>,
community_id: CommunityId,
pubkey: &str,
) -> Result<()> {
let key = format!("archived-identity:{community_id}:{pubkey}");
sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
.bind(key)
.execute(&mut **tx)
.await?;
Ok(())
}

/// A single archived identity record.
#[derive(Debug, Clone)]
pub struct ArchivedIdentity {
Expand Down Expand Up @@ -57,6 +72,8 @@ pub async fn archive(
replaced_by: Option<&str>,
request_event_id: &str,
) -> Result<bool> {
let mut tx = pool.begin().await?;
lock_identity(&mut tx, community_id, pubkey).await?;
let result = sqlx::query(
"INSERT INTO archived_identities \
(community_id, pubkey, consent_path, actor, reason, replaced_by, request_event_id) \
Expand All @@ -70,25 +87,29 @@ pub async fn archive(
.bind(reason)
.bind(replaced_by)
.bind(request_event_id)
.execute(pool)
.execute(&mut *tx)
.await?;

Ok(result.rows_affected() > 0)
let changed = result.rows_affected() > 0;
tx.commit().await?;
Ok(changed)
}

/// Unarchives an identity from `community_id`.
///
/// Returns `true` if a row was deleted, `false` if the identity was not archived
/// in that community.
pub async fn unarchive(pool: &PgPool, community_id: CommunityId, pubkey: &str) -> Result<bool> {
let mut tx = pool.begin().await?;
lock_identity(&mut tx, community_id, pubkey).await?;
let result =
sqlx::query("DELETE FROM archived_identities WHERE community_id = $1 AND pubkey = $2")
.bind(community_id.as_uuid())
.bind(pubkey)
.execute(pool)
.execute(&mut *tx)
.await?;

Ok(result.rows_affected() > 0)
let changed = result.rows_affected() > 0;
tx.commit().await?;
Ok(changed)
}

/// Returns all identities archived in `community_id`, ordered by archive time ascending.
Expand Down
17 changes: 17 additions & 0 deletions crates/buzz-db/src/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ use buzz_core::CommunityId;
// without pulling in sqlx/tokio.
pub use buzz_core::channel::{ChannelType, ChannelVisibility, MemberRole};

/// Acquire the transaction-scoped lock shared by every channel membership
/// mutation and the owner-recovery transaction.
pub(crate) async fn lock_channel_membership(
tx: &mut Transaction<'_, Postgres>,
community_id: CommunityId,
channel_id: Uuid,
) -> Result<()> {
let key = format!("channel-membership:{community_id}:{channel_id}");
sqlx::query("SELECT pg_advisory_xact_lock(hashtextextended($1, 0))")
.bind(key)
.execute(&mut **tx)
.await?;
Ok(())
}

/// A channel row as returned from the database.
#[derive(Debug, Clone)]
pub struct ChannelRecord {
Expand Down Expand Up @@ -359,6 +374,7 @@ pub async fn add_member(
}

let mut tx = pool.begin().await?;
lock_channel_membership(&mut tx, community_id, channel_id).await?;

let channel = get_channel_tx(&mut tx, community_id, channel_id).await?;

Expand Down Expand Up @@ -464,6 +480,7 @@ pub async fn remove_member(
actor_pubkey: &[u8],
) -> Result<()> {
let mut tx = pool.begin().await?;
lock_channel_membership(&mut tx, community_id, channel_id).await?;

let is_self_remove = pubkey == actor_pubkey;
if !is_self_remove {
Expand Down
Loading