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
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -637,7 +637,7 @@ description. See [PR #803](https://github.com/block/buzz/pull/803).
## Common Gotchas

1. **Kind `39000` for channel metadata, not `41`** — kind 41 is NIP-01 (unused). All kinds defined in `buzz-core/src/kind.rs`.
2. **Relay queries must specify `kinds`** — omitting `kinds` triggers the p-gate (403). Always include explicit kind filters.
2. **Relay queries must specify `kinds`** — omitting `kinds` triggers the p-gate (403, or a `restricted:` CLOSED) unless the filter has `#p=[self]`, `authors=[self]` or explicit `ids`. Always include explicit kind filters.
3. **`messages search` chooses its own supported kinds** — do not add a `--kinds` option; the current command does not accept one. This differs from raw relay filters, which still need explicit kinds.
4. **Worktrees: `cd` in the same command** — shell CWD doesn't persist between tool calls. Use `cd /path && cargo build` as one command.
5. **Desktop crate excluded from root workspace** — `cargo test` at repo root does NOT run desktop tests. Use `cargo test --manifest-path desktop/src-tauri/Cargo.toml` explicitly.
Expand Down
255 changes: 233 additions & 22 deletions NOSTR.md

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion crates/buzz-core/src/kind.rs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,8 @@ pub const RESULT_GATED_KINDS: &[u32] = &[
///
/// The relay enforces this at the filter layer (`p_gated_filters_authorized`):
/// a REQ that can match any kind in this set is closed unless the filter's
/// `#p` values exactly equal the authenticated reader's pubkey. For stored
/// `#p` values, or its `authors`, exactly equal the authenticated reader's
/// pubkey (an event the reader signed is theirs to read). For stored
/// (non-ephemeral) kinds in this set, the storage layer additionally writes a
/// NULL `search_tsv` so the event is unsearchable through NIP-50 FTS
/// (`schema/schema.sql` and `migrations/0001_initial_schema.sql` — drift
Expand Down
2 changes: 1 addition & 1 deletion crates/buzz-db/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ pub(crate) use runtime::{
pub use store::{
admin_moderation, allowlist, api_token, archived_identities, artifact, artifact_sync, channel,
channel_members, community, community_auth, deletion, dm, entity_deletion, event, feed,
git_repo, leader_lock, media, membership_snapshot, moderation, nip_fi_binding,
git_repo, landing, leader_lock, media, membership_snapshot, moderation, nip_fi_binding,
operator_listener, partition, personal_read, product_feedback, push, reaction, read_state,
relay_admin_actions, relay_invite, relay_members, relay_operators, reminder, replaceable,
storage_accounting, thread, thread_window, user, user_deletion, user_deletion_fences, workflow,
Expand Down
21 changes: 20 additions & 1 deletion crates/buzz-db/src/runtime/migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -902,7 +902,7 @@ mod postgres_tests {
let mut migrations: Vec<_> = MIGRATOR.iter().collect();
migrations.sort_by_key(|migration| migration.version);

assert_eq!(migrations.len(), 86);
assert_eq!(migrations.len(), 88);
assert!(migrations[83]
.sql
.as_str()
Expand Down Expand Up @@ -934,6 +934,25 @@ mod postgres_tests {
assert!(crate::test_support::desired_state_schema_sql().contains(
"CREATE UNIQUE INDEX idx_channels_listed_name ON channels (community_id, lower(name))"
));
// Landing channel (0087): two nullable community columns, no default,
// so neither the deployed relay nor existing rows notice them.
assert_eq!(migrations[86].version, 87);
let landing = strip_sql_comments(migrations[86].sql.as_str());
assert!(landing.contains("SET LOCAL lock_timeout = '5s';"));
assert!(landing.contains("ADD COLUMN landing_channel_id UUID"));
assert!(landing.contains("ADD COLUMN welcome_message TEXT"));
assert!(!landing.contains("UPDATE"));
let desired = crate::test_support::desired_state_schema_sql();
assert!(desired.contains("landing_channel_id UUID,"));
assert!(desired.contains("welcome_message TEXT,"));
// Welcome title (0088): one more nullable community column, the
// same shape.
assert_eq!(migrations[87].version, 88);
let welcome_title = strip_sql_comments(migrations[87].sql.as_str());
assert!(welcome_title.contains("SET LOCAL lock_timeout = '5s';"));
assert!(welcome_title.contains("ADD COLUMN welcome_title TEXT;"));
assert!(!welcome_title.contains("UPDATE"));
assert!(desired.contains("welcome_title TEXT,"));
assert!(migrations[70]
.sql
.as_str()
Expand Down
95 changes: 92 additions & 3 deletions crates/buzz-db/src/store/channel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ pub(crate) async fn create_channel_with_event(

/// Inserts the channel row, its owner and its initial members on `tx`.
/// Returns `false`, having written nothing, when `channel_id` already exists.
async fn insert_channel_rows(
pub(crate) async fn insert_channel_rows(
tx: &mut crate::AdmittedTx,
channel_id: Uuid,
channel: NewChannel<'_>,
Expand Down Expand Up @@ -1309,6 +1309,11 @@ async fn update_channel_on(
let channel_id = w.channel_id;
let tx = &mut *w.tx;
let community_id = tx.community();
if updates.visibility.as_deref() == Some(ChannelVisibility::Private.as_str())
|| matches!(updates.ttl_seconds, Some(Some(_)))
{
crate::landing::refuse_if_landing(tx, channel_id).await?;
}
if let Some(name) = updates.name.as_mut() {
*name = buzz_core::channel::canonical_channel_name(name).to_owned();
if name.is_empty() {
Expand Down Expand Up @@ -1555,12 +1560,16 @@ pub async fn unarchive_channel(
}

/// Archives (`archive = true`) or restores the channel on `tx`. Refuses with
/// `AccessDenied` when the channel is already in that state. Restoring an
/// ephemeral channel restarts its TTL deadline.
/// `AccessDenied` when the channel is already in that state, or when it would
/// archive the community's landing channel. Restoring an ephemeral channel
/// restarts its TTL deadline.
async fn set_archived_on(w: &mut ChannelSettingsWriter<'_>, archive: bool) -> Result<()> {
let channel_id = w.channel_id;
let tx = &mut *w.tx;
let community_id = tx.community();
if archive {
crate::landing::refuse_if_landing(tx, channel_id).await?;
}
let row = sqlx::query(
"SELECT archived_at FROM channels WHERE community_id = $1 AND id = $2 AND deleted_at IS NULL",
)
Expand Down Expand Up @@ -1623,6 +1632,17 @@ pub struct ChannelMetadataEdit {
/// Set when the actor is the channel's owner or admin. An operator rename
/// without it applies only to a listed channel.
pub channel_authority: bool,
/// Make the channel the community's landing channel. Decided under the
/// row lock on the state the event leaves behind; a no-op when it already
/// is.
pub landing: bool,
/// Set the community's welcome message (`Some(Some(text))`) or restore
/// the default (`Some(None)`). Accepted only on the landing channel, or
/// with `landing`.
pub welcome: Option<Option<String>>,
/// Set the community's welcome title, or restore the default, as for
/// `welcome`.
pub welcome_title: Option<Option<String>>,
}

/// The result of [`Db::apply_channel_metadata_event`].
Expand All @@ -1634,6 +1654,9 @@ pub struct ChannelMetadataWrite {
pub inserted: bool,
/// The channel's visibility before this event (`"open"` or `"private"`).
pub previous_visibility: String,
/// The channel that stopped being the landing channel when this event
/// moved the designation here. Its kind:39000 needs re-emitting.
pub previous_landing: Option<Uuid>,
}

pub(crate) async fn apply_channel_metadata_event(
Expand Down Expand Up @@ -1672,6 +1695,7 @@ pub(crate) async fn apply_channel_metadata_event(
stored,
inserted,
previous_visibility,
previous_landing: None,
});
}
// Decide the archive state and an operator's right to rename here,
Expand Down Expand Up @@ -1716,6 +1740,36 @@ pub(crate) async fn apply_channel_metadata_event(
));
}
}
// `landing`, `welcome` and `welcome_title` are community settings on
// the `communities` row, locked after this channel's row. Eligibility is
// decided here, under the row lock, on the state this event leaves
// behind, so one event cannot designate a channel and archive it. The
// protections (archive, private, TTL) are enforced by the writes below.
let mut previous_landing = None;
if edit.landing || edit.welcome.is_some() || edit.welcome_title.is_some() {
if edit.landing && !landing_eligible_after(&mut locked, &edit).await? {
return Err(DbError::AccessDenied(
crate::landing::LANDING_INELIGIBLE.to_string(),
));
}
let current = crate::landing::lock_designation(locked.tx()).await?;
if edit.landing {
if current != Some(channel_id) {
crate::landing::set_designation(locked.tx(), channel_id).await?;
previous_landing = current;
}
} else if current != Some(channel_id) {
return Err(DbError::AccessDenied(
crate::landing::WELCOME_NOT_LANDING.to_string(),
));
}
crate::landing::set_welcome(
locked.tx(),
edit.welcome.as_ref().map(Option::as_deref),
edit.welcome_title.as_ref().map(Option::as_deref),
)
.await?;
}
crate::insert_mentions_in_transaction(locked.tx(), event, Some(channel_id)).await?;

let ChannelMetadataEdit {
Expand All @@ -1725,6 +1779,9 @@ pub(crate) async fn apply_channel_metadata_event(
archived,
operator_rename: _,
channel_authority: _,
landing: _,
welcome: _,
welcome_title: _,
} = edit;
let actor = event.pubkey.to_bytes();
if !columns.is_empty() {
Expand Down Expand Up @@ -1753,9 +1810,38 @@ pub(crate) async fn apply_channel_metadata_event(
stored,
inserted,
previous_visibility,
previous_landing,
})
}

/// Whether the locked channel may be the landing channel once `edit`
/// applies: an open, unarchived stream without a TTL.
async fn landing_eligible_after(
locked: &mut ChannelSettingsWriter<'_>,
edit: &ChannelMetadataEdit,
) -> Result<bool> {
let channel_id = locked.channel_id;
let community_id = locked.tx.community();
let (channel_type, ttl_seconds): (String, Option<i32>) = sqlx::query_as(
"SELECT channel_type::text, ttl_seconds FROM channels WHERE community_id = $1 AND id = $2",
)
.bind(community_id.as_uuid())
.bind(channel_id)
.fetch_one(locked.tx.conn())
.await?;
let visibility = edit
.columns
.visibility
.as_deref()
.unwrap_or(locked.previous_visibility());
let archived = edit.archived.unwrap_or(locked.archived());
let ttl_seconds = edit.columns.ttl_seconds.unwrap_or(ttl_seconds);
Ok(channel_type == ChannelType::Stream.as_str()
&& visibility == ChannelVisibility::Open.as_str()
&& !archived
&& ttl_seconds.is_none())
}

/// Soft-delete a channel by setting `deleted_at = NOW()`.
///
/// Returns `Ok(true)` if the channel was deleted, `Ok(false)` if already
Expand Down Expand Up @@ -1833,6 +1919,9 @@ async fn soft_delete_on(w: &mut ChannelSettingsWriter<'_>) -> Result<()> {
}
let channel_id = w.channel_id;
let community_id = w.tx.community();
// Every delete path ends here, so the landing channel is kept even when
// its designation lands between a caller's check and this write.
crate::landing::refuse_if_landing(w.tx, channel_id).await?;
sqlx::query("UPDATE channels SET deleted_at = NOW() WHERE community_id = $1 AND id = $2")
.bind(community_id.as_uuid())
.bind(channel_id)
Expand Down
Loading
Loading