From 588620ba77238127b5d3a5ecd609f3b50ab9d8ff Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 6 Nov 2026 02:21:39 +1100 Subject: [PATCH 01/15] feat(relay): community landing channel and welcome message MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every community gets at most one landing channel: the open stream a person joins first when they enter the community, and where the community's custom welcome message is published. This implements the relay half of the landing-channel plan (#note:8de4748c §4–§6) and its wire contract in NOSTR.md. Storage (migration 0084): two nullable columns on `communities`, `landing_channel_id` and `welcome_message`. They have no default, no backfill and no foreign key, so the deployed relay is unaffected. The row is the single source of truth, so "exactly one" holds by construction. Advertisement: - NIP-11 advertises the `buzz-landing-channel` extension and `landing_channel`. NIP-11 is the authoritative read; it reads the icon and the designation in one query. - The landing channel's relay-signed kind:39000 carries a bare `["landing"]` tag, plus `["welcome", text]` while a custom message is set. This is a projection of the column. A move re-signs the previous landing channel's 39000 without both tags. Plan §3.2 said "no landing marker tag"; that no longer holds, and NOSTR.md and NIP-CL say so. kind:9002 `["landing","true"]` and `["welcome", text|""]`: - Both are relay owner/admin tags, like `auto_join`, and API tokens need the `AdminChannels` scope. - Validation follows the plan's exact receipt texts. - The write decides eligibility under the channel row lock, on the state the event leaves behind: an open, unarchived stream with no TTL. - The client's full-state form `[landing, welcome]` is accepted both on the current landing channel (idempotent, not reported as a move) and as a move. - `welcome` alone is accepted only on the landing channel. Protections: the landing channel cannot be archived, made private, given a TTL or deleted. The checks are backstops in `set_archived_on`, `update_channel_on` and `soft_delete_on`, so every path is covered, including the 9002 lock-time path. kind:9008 also gets a pre-check, because its side effect runs after the event is stored. Adoption (`ensure_landing_channel`): - Adopt the oldest eligible `general`, listed before unlisted. - If there is none, create a listed `general` owned by the earliest relay owner, else the earliest admin. - Do nothing when there is no steward, or when the name is held by an archived, forum or TTL `general`. - A NULL designation is the retry record. Adoption runs after operator provisioning (both branches) and at every human AUTH. Enrollment: - The landing channel is joined first, then the `auto_join` defaults, as kind:9021 self-joins. Agents are not joined. - Roster mode: - The invite claim and kind:9030 join the landing channel inside `auto_join_default_channels`. - AUTH recovers a person with no entry evidence since their `relay_members.created_at`, joining the landing channel and then the defaults. This replaces the roster-free-only spawn gate. - Roster-free mode: AUTH joins a person with no evidence at all, then the never-joined defaults as before. - Evidence (`has_entered_community`) is a stream or forum membership, excluding private channels the person created themselves (Solo and Bestie chats). - `put_first_member_since` keeps a leave since admission standing. - A landing join that fails for a reason other than a refusal skips the defaults, so the next AUTH retries both. `join_channel_as_self` now returns typed refusals so this can tell them apart. Deliberate deviations and tensions: - Boot-time convergence for the deployment community: main.rs spawns a best-effort ensure after AppState is built. That way a local demo (`ensure_deployment_community` plus `bootstrap_owner`) has a landing `general` before anyone connects. The plan said "never at startup". This is only the deployment community and is off the boot path; there is no sweep of other tenants. - In a roster-free community with no relay owner or admin, there is no steward. A missing `general` is not created, and an eligible one is still adopted. - The shared e2e community now has a steward-owned `general`, and every fresh key gets its 44100. Three e2e tests that took the first membership notice now match on the channel's `h` tag. - This is independent of the unmerged #9045, as the plan's merge-order rules say. 0084 is the next free migration here. If #9045 lands first, its pending-work token replaces this change's evidence-gated roster-mode recovery of defaults. Tests: - buzz-db Postgres: designation moves and the idempotent full-state form, ineligible post-states, protections on every path, the adoption rule table, concurrent ensures, the entry-evidence table and the `put_first_member_since` table. - buzz-relay: tag rules (unit); the 9002 authorization matrix with exact receipts; the 39000 projection, move re-emission and NIP-11; protections including 9008; provisioning; AUTH journeys in both modes. - e2e: a landing journey on the shared relay. - None of these have been run locally; only `cargo check` was. The Postgres and e2e suites run in CI. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- NOSTR.md | 137 +- crates/buzz-db/src/lib.rs | 2 +- crates/buzz-db/src/runtime/migration.rs | 13 +- crates/buzz-db/src/store/channel.rs | 88 +- crates/buzz-db/src/store/channel_members.rs | 131 +- crates/buzz-db/src/store/community.rs | 30 + crates/buzz-db/src/store/landing.rs | 1434 +++++++++++++++++ crates/buzz-db/src/store/mod.rs | 2 + crates/buzz-relay/src/handlers/auth.rs | 244 ++- .../src/handlers/community_provisioning.rs | 12 +- crates/buzz-relay/src/handlers/ingest.rs | 6 +- crates/buzz-relay/src/handlers/landing.rs | 379 +++++ .../src/handlers/landing_postgres_tests.rs | 318 ++++ crates/buzz-relay/src/handlers/mod.rs | 5 + .../buzz-relay/src/handlers/side_effects.rs | 186 ++- crates/buzz-relay/src/main.rs | 9 + crates/buzz-relay/src/nip11.rs | 60 +- .../tests/e2e_nostr_interop.rs | 6 + crates/buzz-test-client/tests/e2e_relay.rs | 312 +++- docs/nips/NIP-CL.md | 2 + migrations/0087_community_landing_channel.sql | 15 + schema/tables/public/communities.sql | 6 + 22 files changed, 3197 insertions(+), 200 deletions(-) create mode 100644 crates/buzz-db/src/store/landing.rs create mode 100644 crates/buzz-relay/src/handlers/landing.rs create mode 100644 crates/buzz-relay/src/handlers/landing_postgres_tests.rs create mode 100644 migrations/0087_community_landing_channel.sql diff --git a/NOSTR.md b/NOSTR.md index 629c4f808a2..c4fc82a4ef4 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -53,11 +53,11 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **Group creation (kind:9007)** | ✅ | NIP-29; include an `h` tag with the new channel id and a `name` tag, optional `visibility`, `channel_type`, label `t` and `unlisted` tags. The relay refuses a 9007 without `h`. It refuses a listed channel (open, not unlisted, stream or forum) whose name another listed channel already has, ignoring case and including archived ones: `invalid: an open channel named "" already exists`. | | **Add user (kind:9000)** | ✅ | Open: any user, subject to target's `channel_add_policy` (`owner_only`/`nobody` can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. A guest cannot add anyone. A new member with no role tag gets the channel's `join_role`; a non-member adding themselves may ask for `join_role` or a lower role. One event may name up to 100 people: one `p` tag each, with an optional role as the `p` tag's third element (NIP-29), falling back to the event's `role` tag. Each person is checked and added on their own. When the event names several people, the OK message is `members: ` followed by JSON listing the non-empty `added`, `updated` (role changed), `already_member` and `rejected` (`[{"reason", "pubkeys"}]`) results. The event is refused only when it is malformed or nobody it names may be added; then one person keeps the single-add error, and several get `invalid: members: {…}`. The event and its membership changes are stored together, so an `error:` rejection stores nothing and the same event can be resent; a resent stored event returns `duplicate:` without results. Re-adding someone at their current role posts no second `member_joined` notice. | | **Remove user (kind:9001)** | ✅ | Self-remove allowed (with last-owner guard). Removing others: owner/admin only. | -| **Edit group metadata (kind:9002)** | ✅ | `name`/`about`/label `t`/`visibility`/`ttl`/`archived`/`join_role`/`unlisted` tags: owner/admin. `topic`/`purpose` tags: any member except a guest. `auto_join` tag: relay owner/admin (see [Default channels](#default-channels)). A relay operator may also send a 9002 that changes only `name` on a listed channel, archived ones included, whatever its channel role, so that duplicate names can be repaired; the channel stays archived. The relay refuses a `name`, `visibility` or `unlisted` change that leaves a listed channel with a name another listed channel already has: `invalid: an open channel named "" already exists`. Buzz applies 9002 as a partial update: an absent tag leaves that setting unchanged. | +| **Edit group metadata (kind:9002)** | ✅ | `name`/`about`/label `t`/`visibility`/`ttl`/`archived`/`join_role`/`unlisted` tags: owner/admin. `topic`/`purpose` tags: any member except a guest. `auto_join`, `landing` and `welcome` tags: relay owner/admin (see [Default channels](#default-channels) and [Landing channel](#landing-channel)). A relay operator may also send a 9002 that changes only `name` on a listed channel, archived ones included, whatever its channel role, so that duplicate names can be repaired; the channel stays archived. The relay refuses a `name`, `visibility` or `unlisted` change that leaves a listed channel with a name another listed channel already has: `invalid: an open channel named "" already exists`. Buzz applies 9002 as a partial update: an absent tag leaves that setting unchanged. | | **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. | | **Group deletion (kind:9008)** | ✅ | Owner only. | | **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. | -| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `t`, `P`, `created_at`, `join_role`; `public` or `private`; `restricted` and `closed` per the access flags below; `unlisted` when the channel is unlisted; `about` only if description non-empty; `hidden` for DM channels and private system channels; `auto_join` while the channel is a default channel | +| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `t`, `P`, `created_at`, `join_role`; `public` or `private`; `restricted` and `closed` per the access flags below; `unlisted` when the channel is unlisted; `about` only if description non-empty; `hidden` for DM channels and private system channels; `auto_join` while the channel is a default channel; `landing`, and `welcome` when customized, on the landing channel | | **Unlisted channels** | ✅ | kind:9002 `["unlisted", "true"\|"false"]`, owner/admin, or at creation on kind:9007. An unlisted open channel is left out of every read that does not name it, for non-members: channel lists, kind:39000 without `#d`, messages, COUNT, NIP-50 search and artifact queries. Naming it by `#h` (or `#d` on 39000–39003) reads it, and kind:9021 joins it, as for any open channel. See [Unlisted channels](#unlisted-channels). | | **Group admins (kind:39001)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags with roles (`owner`, `admin`) | | **Group members (kind:39002)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags for all members | @@ -226,9 +226,11 @@ body of kind:9021, so the channel's `join_role` applies and the channel gets its `member_joined` kind:40099, kind:39002 and kind:44100. There is no cap on how many channels that is: whatever carries the kind:39000 tag is joined, and the work a new member costs grows with the number of channels an owner or -admin flags. On a community that requires relay membership, the relay does -this once, at that moment, and never again on login or reconnect, so a later -kind:9022 leave stands. A kind:9030 add is +admin flags. The [landing channel](#landing-channel) is joined first. On a +community that requires relay membership, the relay does this at that +moment; a login or reconnect repeats it only for a person with no entry +evidence (see [Landing channel](#landing-channel)), so a later kind:9022 +leave stands. A kind:9030 add is made by someone else, so the new member's `channel_add_policy` applies first, as it would to that actor's kind:9000: if it refuses them, no default channel is joined. The claim response waits for the joins, and a claimer who @@ -254,6 +256,129 @@ with a NIP-OA owner, a NIP-FI agent token, or a stored agent owner joins nothing. A membership write that fails leaves no row, so the next admission tries that channel again. +### Landing channel + +Every community has at most one **landing channel**: the open stream a +person is joined to first when they enter the community, and the channel a +client opens when it has nothing better to show. Its kind:39000 also carries +the community's custom welcome message, if one is set. + +**Designation.** The relay stores the designation on the community, so there +is never more than one. Clients read it from NIP-11: + +```json +{ + "supported_extensions": ["…", "buzz-landing-channel"], + "landing_channel": "6f1c2a4e-0b7d-4c1e-9a52-2f0d8b3c7e11" +} +``` + +- `buzz-landing-channel` in `supported_extensions` means the relay implements + everything in this section. Without it, send none of these tags. +- `landing_channel` is a lowercase hyphenated UUID, omitted (never `null` or + `""`) when no channel is designated. It is read live (`Cache-Control: + no-store`), so a GET after an accepted write reflects it. NIP-11 is + unauthenticated, so the id is public; the welcome text is not published + there. +- The relay-signed kind:39000 whose `d` is the landing channel carries a bare + `["landing"]` tag, and `["welcome", ""]` while a custom message is + set. These are a projection of the stored designation, re-signed whenever + it moves; NIP-11 is the authoritative read. A failed re-emission can leave a + stale tag on the previous landing channel, so a client takes the landing + channel from NIP-11 and ignores `welcome` on any other channel's 39000. The + welcome text is plain text: `\n` is a line break and nothing else is + interpreted. With no custom message, clients show their own default text. + +**Adoption.** With no designation, the relay adopts the oldest open, +unarchived stream named `general` (case-insensitive) without a TTL, +preferring a listed one. If there is none, it creates a listed, open +`general` owned by the community's earliest relay owner, else its earliest +admin; that person gets the channel-creation kind:44100. A private `general` +is never adopted. Nothing is created when the community has no owner or +admin, or when `general` is taken by a channel that cannot be adopted (an +archived `general`, or a forum). The relay tries at community provisioning, +at boot for the deployment community, and at every person's AUTH, so an +existing community converges at its first connection after an upgrade. + +**Changing it.** A kind:9002 on the target channel, from a *relay* owner or +admin (the NIP-43 roster role; the actor need not be a channel member; API +tokens need the `AdminChannels` scope): + +| Tag | Meaning | +|---|---| +| `["landing", "true"]` | Make this channel the landing channel | +| `["welcome", ""]` | Set the custom welcome message | +| `["welcome", ""]` | Restore the client default | + +```json +{"kind":9002,"content":"","tags":[["h",""],["landing","true"]]} +{"kind":9002,"content":"","tags":[["h",""],["landing","true"],["welcome","Hi all!\nIntroduce yourself here."]]} +{"kind":9002,"content":"","tags":[["h",""],["landing","true"],["welcome",""]]} +``` + +- `landing` moves the designation in one transaction; the previous landing + channel loses its tags and its protections. Nobody is added or removed. + It is idempotent: `landing` on the current landing channel changes + nothing, so a client may always send the full state (`landing` plus + `welcome`). +- `landing` needs a stream that the event leaves open, unarchived and + without a TTL, decided under the channel's lock. +- `welcome` is community configuration. It is accepted on the current + landing channel, or with `landing` in the same event, and moves with the + designation. The text is 1–1000 Unicode scalar values, not blank, with no + control characters other than `\n`. The relay stores exactly what it + receives. +- The designation cannot be turned off, only moved. + +Refusals (`accepted: false`): + +| Condition | `message` | +|---|---| +| Sender is not a relay owner or admin | `invalid: actor not authorized: landing and welcome require a relay owner or admin` | +| `landing` on a non-stream channel | `invalid: landing is only available on stream channels` | +| `["landing", "false"]` | `invalid: landing cannot be turned off; make another channel the landing channel` | +| Any other `landing` value | `invalid: invalid landing value: (must be "true")` | +| `landing` with no value | `invalid: landing tag must have a value` | +| `welcome` with no value | `invalid: welcome tag must have a value (text, or empty to restore the default)` | +| More than one `landing` or `welcome` tag | `invalid: kind:9002 may include at most one landing and one welcome tag` | +| Whitespace-only text | `invalid: welcome message must not be blank` | +| More than 1000 characters | `invalid: welcome message is too long (max 1000 characters)` | +| Control characters other than `\n` | `invalid: welcome message must be plain text (line breaks only)` | +| Ineligible target channel | `invalid: landing requires an open, unarchived channel without a ttl` | +| `welcome` on another channel without `landing` | `invalid: welcome can only be set on the landing channel` | +| A protected operation (below) | `invalid: this is the community's landing channel; make another channel the landing channel first` | + +An `error: …` message means the outcome is uncertain: read back before +retrying. + +**Protections.** While a channel is the landing channel, the relay refuses +to archive it, make it private, give it a TTL (kind:9002 `archived`, +`visibility` or `ttl`) or delete it (kind:9008), with the protected-operation +message. Renames, `about`, `topic`, `purpose`, labels, `unlisted`, +`join_role`, `auto_join`, and member adds, removals and moderation stay +allowed. + +**Enrollment.** People are joined to the landing channel first, then to the +[default channels](#default-channels), as kind:9021 self-joins (the +channel's `join_role`, `member_joined` kind:40099, kind:39002 and kind:44100). +Agents (NIP-OA, a NIP-FI agent token, or a stored agent owner) are not +joined to it. + +- On a community that requires relay membership, this happens at the invite + claim or kind:9030 add. A person's AUTH joins them only if they have no + entry evidence since their roster row was created: no channel membership + in a shared (stream or forum) channel, other than private channels they + created themselves. That recovers an admission whose joins did not finish, + and it joins the default channels too. After a kind:9031 removal, the next + admission is a first entry again. +- On a community without a roster, a person's AUTH joins them to the landing + channel if they have no such evidence at all; then the default channels + they have never been in follow, as before. +- Reconnects, new devices and moving the designation never re-enroll anyone. + A person who left the landing channel stays out. A landing join that fails + for a reason other than a refusal (a ban, a channel that cannot be joined) + skips the default channels too, so the next AUTH retries both. + ### Group Discovery The relay emits NIP-29 group state events when channels are created, updated, or membership changes. @@ -263,7 +388,7 @@ All discovery events include a `d` tag set to the channel UUID (NIP-29 addressab | Kind | Tags | Content | |------|------|---------| -| **39000** | `d=`, `name`, `t=`, `P=`, `created_at=`, `join_role` (always); `public` or `private`; `restricted` (private channels, and `join_role=guest`); `closed` (private channels only); `unlisted` (unlisted channels); `about` (if description non-empty); `hidden` (DM channels and private system channels); `auto_join` (open, unarchived channels with the setting on, see [Default channels](#default-channels)) | Group metadata. The access flags follow the channel settings (see [Announce channels](#announce-channels)). | +| **39000** | `d=`, `name`, `t=`, `P=`, `created_at=`, `join_role` (always); `public` or `private`; `restricted` (private channels, and `join_role=guest`); `closed` (private channels only); `unlisted` (unlisted channels); `about` (if description non-empty); `hidden` (DM channels and private system channels); `auto_join` (open, unarchived channels with the setting on, see [Default channels](#default-channels)); `landing` and `welcome` (the landing channel only, see [Landing channel](#landing-channel)) | Group metadata. The access flags follow the channel settings (see [Announce channels](#announce-channels)). | | **39001** | `d=`, `t=`, `P=`, `p` tags with role label (`owner`, `admin`) | Admin list | | **39002** | `d=`, `t=`, `P=`, `p` tags for all members | Member list | diff --git a/crates/buzz-db/src/lib.rs b/crates/buzz-db/src/lib.rs index 92eeb42f882..aa5daa1916a 100644 --- a/crates/buzz-db/src/lib.rs +++ b/crates/buzz-db/src/lib.rs @@ -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, diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index f7f0888e96f..c8944ef3198 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -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(), 87); assert!(migrations[83] .sql .as_str() @@ -934,6 +934,17 @@ 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,")); assert!(migrations[70] .sql .as_str() diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index b868ed8b612..2adc9c39541 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -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<'_>, @@ -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() { @@ -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", ) @@ -1623,6 +1632,14 @@ 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>, } /// The result of [`Db::apply_channel_metadata_event`]. @@ -1634,6 +1651,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, } pub(crate) async fn apply_channel_metadata_event( @@ -1672,6 +1692,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, @@ -1716,6 +1737,33 @@ pub(crate) async fn apply_channel_metadata_event( )); } } + // `landing` and `welcome` 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() { + 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(), + )); + } + if let Some(welcome) = &edit.welcome { + crate::landing::set_welcome(locked.tx(), welcome.as_deref()).await?; + } + } crate::insert_mentions_in_transaction(locked.tx(), event, Some(channel_id)).await?; let ChannelMetadataEdit { @@ -1725,6 +1773,8 @@ pub(crate) async fn apply_channel_metadata_event( archived, operator_rename: _, channel_authority: _, + landing: _, + welcome: _, } = edit; let actor = event.pubkey.to_bytes(); if !columns.is_empty() { @@ -1753,9 +1803,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 { + let channel_id = locked.channel_id; + let community_id = locked.tx.community(); + let (channel_type, ttl_seconds): (String, Option) = 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 @@ -1833,6 +1912,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) diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index e0487877f9b..f675b679f24 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -741,15 +741,47 @@ pub async fn put_first_member( channel_id: Uuid, pubkey: &[u8], ) -> Result> { - put_first_member_with_pending(pool, community_id, channel_id, pubkey, None).await + put_first_member_fenced(pool, community_id, channel_id, pubkey, None, None).await } +/// [`put_first_member`] for an admission that began at `admitted_at`: a +/// membership row counts only if it is active or was removed at or after +/// `admitted_at`. So a person whom the roster admitted again after a +/// removal joins once more, even into a channel they had left before, while +/// a leave since this admission keeps them out. With `None`, every row +/// counts, as in [`put_first_member`]. +pub async fn put_first_member_since( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + admitted_at: Option>, +) -> Result> { + put_first_member_fenced(pool, community_id, channel_id, pubkey, None, admitted_at).await +} + +/// [`put_first_member_since`] for the roster admission whose +/// `auto_join_pending` token is `pending`: the roster row is locked for the +/// write and its `created_at` is the admission time, and a channel that +/// refuses the join is `None` rather than an error, as the pending work +/// is complete for it. async fn put_first_member_with_pending( + pool: &PgPool, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + pending: Uuid, +) -> Result> { + put_first_member_fenced(pool, community_id, channel_id, pubkey, Some(pending), None).await +} + +async fn put_first_member_fenced( pool: &PgPool, community_id: CommunityId, channel_id: Uuid, pubkey: &[u8], pending: Option, + admitted_at: Option>, ) -> Result> { let mut tx = crate::begin_community_write_transaction( pool, @@ -775,26 +807,27 @@ async fn put_first_member_with_pending( }; Some(created_at) } else { - None + admitted_at }; lock_channel_for_member_put(&mut tx, channel_id).await?; - let history: Option<(Option>,)> = sqlx::query_as( - "SELECT removed_at FROM channel_members \ - WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3", + // A row counts when every row counts (no admission time), when it is + // active, or when it was removed at or after the admission: a leave + // during this admission keeps the person out, while a membership they + // left before a re-admission is restored. + let seen: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM channel_members \ + WHERE community_id = $1 AND channel_id = $2 AND pubkey = $3 \ + AND ($4::timestamptz IS NULL OR removed_at IS NULL OR removed_at >= $4))", ) .bind(community_id.as_uuid()) .bind(channel_id) .bind(pubkey) - .fetch_optional(tx.conn()) + .bind(admitted_at) + .fetch_one(tx.conn()) .await?; - if let Some((removed_at,)) = history { - // Ordinary first joins preserve all history. Recovery preserves a - // leave from this admission, but fresh roster admissions retain the - // original behavior of restoring earlier removed memberships. - if admitted_at.is_none() || removed_at.is_none() || removed_at >= admitted_at { - tx.rollback().await?; - return Ok(None); - } + if seen { + tx.rollback().await?; + return Ok(None); } let target = MemberPut { pubkey, @@ -820,6 +853,49 @@ async fn put_first_member_with_pending( } } +/// Whether `pubkey` has entered the community: they hold, or held since +/// `admitted_at`, a membership in a stream or forum channel. The relay +/// enrolls a person into the landing channel only until this is true. +/// +/// A membership counts when it is active or, with `admitted_at` set, began or +/// ended at or after it. So in roster mode (`admitted_at` is the roster +/// row's `created_at`) a person admitted again after a removal is a new +/// entry, while active memberships always count, even ones older than the +/// roster row. With `None` (roster-free mode), any history counts. +/// +/// Memberships in deleted channels still count as history. DMs and workflow +/// and system channels do not, nor private channels the person created +/// themselves: a client creates those chats for its user at first run, which +/// can happen before the relay's post-AUTH enrollment. +pub async fn has_entered_community( + pool: &PgPool, + community_id: CommunityId, + pubkey: &[u8], + admitted_at: Option>, +) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + let entered: bool = sqlx::query_scalar( + "SELECT EXISTS ( \ + SELECT 1 FROM channel_members cm \ + JOIN channels c ON c.community_id = cm.community_id AND c.id = cm.channel_id \ + WHERE cm.community_id = $1 AND cm.pubkey = $2 \ + AND c.channel_type IN ('stream', 'forum') \ + AND NOT (c.visibility = 'private' AND c.created_by = $2) \ + AND (cm.removed_at IS NULL OR $3::timestamptz IS NULL \ + OR cm.joined_at >= $3 OR cm.removed_at >= $3))", + ) + .bind(community_id.as_uuid()) + .bind(pubkey) + .bind(admitted_at) + .fetch_one(&mut *connection) + .await?; + Ok(entered) +} + /// The result of [`Db::put_members_with_event`]. pub enum MemberPutEventWrite { /// The event was stored and at least one person was written. One result @@ -2385,7 +2461,7 @@ impl Db { pubkey: &[u8], pending: Uuid, ) -> Result> { - put_first_member_with_pending(&self.pool, community, channel, pubkey, Some(pending)).await + put_first_member_with_pending(&self.pool, community, channel, pubkey, pending).await } /// Self-joins `pubkey` with the channel's `join_role` only if they have @@ -2400,6 +2476,31 @@ impl Db { put_first_member(&self.pool, community_id, channel_id, pubkey).await } + /// Self-joins `pubkey` with the channel's `join_role` unless a membership + /// row from this admission exists (see [`put_first_member_since`]). + #[datastore_span(name = "put_first_member_since", system = "postgresql")] + pub async fn put_first_member_since( + &self, + community_id: CommunityId, + channel_id: Uuid, + pubkey: &[u8], + admitted_at: Option>, + ) -> Result> { + put_first_member_since(&self.pool, community_id, channel_id, pubkey, admitted_at).await + } + + /// Whether `pubkey` has entered the community (see + /// [`has_entered_community`]). + #[datastore_span(name = "has_entered_community", system = "postgresql")] + pub async fn has_entered_community( + &self, + community_id: CommunityId, + pubkey: &[u8], + admitted_at: Option>, + ) -> Result { + has_entered_community(&self.pool, community_id, pubkey, admitted_at).await + } + /// Stores a kind:9000 `event` and applies its [`put_members`] writes, /// with the event's author as the actor, in one transaction (see /// [`put_members_with_event`]). diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index 50d1b20f456..7eb29da42f3 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -34,6 +34,15 @@ pub struct EnsuredCommunityRecord { pub created: bool, } +/// The community-scoped decorations of a NIP-11 document. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CommunityDiscovery { + /// Workspace icon (NIP-11 `icon`), if set. + pub icon: Option, + /// Landing channel (NIP-11 `landing_channel`), if designated. + pub landing_channel_id: Option, +} + /// Community row returned by an atomic create-with-owner operation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreatedCommunityRecord { @@ -474,6 +483,26 @@ impl Db { .filter(|icon| !icon.is_empty())) } + /// Returns the community's workspace icon and landing channel in one + /// read, for its NIP-11 document. Read live, so a fresh document reflects + /// a committed kind:9002 `landing` or kind:9033. + #[datastore_span(name = "get_community_discovery", system = "postgresql")] + pub async fn get_community_discovery( + &self, + community_id: CommunityId, + ) -> Result { + let row: Option<(Option, Option)> = + sqlx::query_as("SELECT icon, landing_channel_id FROM communities WHERE id = $1") + .bind(community_id.as_uuid()) + .fetch_optional(&self.pool) + .await?; + let (icon, landing_channel_id) = row.unwrap_or_default(); + Ok(CommunityDiscovery { + icon: icon.filter(|icon| !icon.is_empty()), + landing_channel_id, + }) + } + /// Sets or clears (`None`) the community's workspace icon. #[datastore_span(name = "set_community_icon", system = "postgresql")] pub async fn set_community_icon( @@ -1045,6 +1074,7 @@ mod postgres_tests { "list_communities_owned_by", "lookup_community_host", "get_community_icon", + "get_community_discovery", "set_community_icon", "ensure_configured_community", "create_community_with_owner", diff --git a/crates/buzz-db/src/store/landing.rs b/crates/buzz-db/src/store/landing.rs new file mode 100644 index 00000000000..fd075e8ece3 --- /dev/null +++ b/crates/buzz-db/src/store/landing.rs @@ -0,0 +1,1434 @@ +//! The community's landing channel and its welcome message. +//! +//! Both live on the community's `communities` row (`landing_channel_id`, +//! `welcome_message`), so a community has at most one landing channel by +//! construction and a move is a single-row update. NIP-11 `landing_channel` +//! is the authoritative read; the landing channel's kind:39000 carries a +//! projection of it ([`CommunityLanding::group_metadata_tags`]). +//! +//! Lock order: a writer that locks both a channel row and the `communities` +//! row locks the channel row first. Moving the designation to a channel +//! therefore needs that channel's row lock, which every write that could +//! make the channel ineligible also holds. + +use buzz_core::CommunityId; +use buzz_datastore_tracing::datastore_span; +use sqlx::{PgConnection, PgPool}; +use uuid::Uuid; + +use crate::channel::{ChannelType, ChannelUpdate, ChannelVisibility, NewChannel}; +use crate::{AdmittedTx, Db, DbError, Result}; + +/// Refusal for archiving, making private, expiring or deleting the landing +/// channel. +pub const LANDING_PROTECTED: &str = + "this is the community's landing channel; make another channel the landing channel first"; + +/// Refusal for designating a channel that is not an open, unarchived stream +/// without a TTL, judged on the state the request leaves behind. +pub const LANDING_INELIGIBLE: &str = "landing requires an open, unarchived channel without a ttl"; + +/// Refusal for a welcome message sent to a channel that is not, and does not +/// become, the landing channel. +pub const WELCOME_NOT_LANDING: &str = "welcome can only be set on the landing channel"; + +/// The longest welcome message, in Unicode scalar values. +pub const WELCOME_MAX_CHARS: usize = 1000; + +/// The name of the landing channel the relay adopts or creates. +pub const LANDING_CHANNEL_NAME: &str = "general"; + +/// A community's landing designation and custom welcome message. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CommunityLanding { + /// The landing channel, if one is designated. + pub channel_id: Option, + /// The custom welcome message; `None` means the client default. + pub welcome: Option, +} + +impl CommunityLanding { + /// The kind:39000 tags for `channel_id`: a bare `["landing"]` while it is + /// the landing channel, then `["welcome", text]` while a custom message + /// is set. Empty for every other channel, so a re-emission after a move + /// drops both from the previous landing channel. + pub fn group_metadata_tags(&self, channel_id: Uuid) -> Vec> { + if self.channel_id != Some(channel_id) { + return Vec::new(); + } + let mut tags = vec![vec!["landing".to_owned()]]; + if let Some(welcome) = self.welcome.as_deref().filter(|w| !w.is_empty()) { + tags.push(vec!["welcome".to_owned(), welcome.to_owned()]); + } + tags + } +} + +/// Why `text` cannot be a custom welcome message, or `None` when it can. +/// The caller handles the empty string, which restores the default. +pub fn welcome_message_error(text: &str) -> Option<&'static str> { + if text.trim().is_empty() { + Some("welcome message must not be blank") + } else if text.chars().count() > WELCOME_MAX_CHARS { + Some("welcome message is too long (max 1000 characters)") + } else if text.chars().any(|c| c.is_control() && c != '\n') { + Some("welcome message must be plain text (line breaks only)") + } else { + None + } +} + +/// What [`Db::ensure_landing_channel`] found or did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LandingAdoption { + /// A channel was already designated. + Existing(Uuid), + /// An eligible channel named `general` became the landing channel. + Reused(Uuid), + /// The relay created `general`, owned by `steward`, as the landing + /// channel. + Created { + /// The new channel. + channel_id: Uuid, + /// Its owner: the community's earliest owner, else earliest admin. + steward: [u8; 32], + }, + /// No eligible `general` exists and the community has no owner or admin + /// to own a new one. The designation stays empty. + SkippedNoSteward, + /// No eligible `general` exists and the name is held by a channel that + /// cannot be the landing channel (archived, or a forum). + SkippedNameTaken, +} + +impl LandingAdoption { + /// The landing channel after the call, if any. + pub fn channel_id(&self) -> Option { + match *self { + Self::Existing(id) | Self::Reused(id) => Some(id), + Self::Created { channel_id, .. } => Some(channel_id), + Self::SkippedNoSteward | Self::SkippedNameTaken => None, + } + } + + /// The `outcome` label of `buzz_landing_adoption_total`, or `None` when + /// a designation already existed. + pub fn outcome(&self) -> Option<&'static str> { + match self { + Self::Existing(_) => None, + Self::Reused(_) => Some("reused"), + Self::Created { .. } => Some("created"), + Self::SkippedNoSteward => Some("skipped_no_steward"), + Self::SkippedNameTaken => Some("skipped_name_taken"), + } + } +} + +async fn read_landing( + conn: &mut PgConnection, + community_id: CommunityId, +) -> Result { + let row: Option<(Option, Option)> = + sqlx::query_as("SELECT landing_channel_id, welcome_message FROM communities WHERE id = $1") + .bind(community_id.as_uuid()) + .fetch_optional(conn) + .await?; + let (channel_id, welcome) = row.unwrap_or_default(); + Ok(CommunityLanding { + channel_id, + welcome: welcome.filter(|w| !w.is_empty()), + }) +} + +/// Locks the community row on `tx` and returns its landing channel. Taken +/// after any channel row lock (see the module docs). +pub(crate) async fn lock_designation(tx: &mut AdmittedTx) -> Result> { + let community_id = tx.community(); + Ok(sqlx::query_scalar( + "SELECT landing_channel_id FROM communities WHERE id = $1 FOR NO KEY UPDATE", + ) + .bind(community_id.as_uuid()) + .fetch_one(tx.conn()) + .await?) +} + +/// Makes `channel_id` the landing channel on `tx`, which holds the community +/// row lock ([`lock_designation`]). +pub(crate) async fn set_designation(tx: &mut AdmittedTx, channel_id: Uuid) -> Result<()> { + let community_id = tx.community(); + sqlx::query("UPDATE communities SET landing_channel_id = $2 WHERE id = $1") + .bind(community_id.as_uuid()) + .bind(channel_id) + .execute(tx.conn()) + .await?; + Ok(()) +} + +/// Sets (or, with `None`, clears) the welcome message on `tx`, which holds +/// the community row lock ([`lock_designation`]). +pub(crate) async fn set_welcome(tx: &mut AdmittedTx, welcome: Option<&str>) -> Result<()> { + let community_id = tx.community(); + sqlx::query("UPDATE communities SET welcome_message = $2 WHERE id = $1") + .bind(community_id.as_uuid()) + .bind(welcome) + .execute(tx.conn()) + .await?; + Ok(()) +} + +/// Refuses with [`LANDING_PROTECTED`] when `channel_id` is the landing +/// channel. `tx` holds the channel's row lock, so the designation cannot +/// move to the channel meanwhile; a plain read is enough. +pub(crate) async fn refuse_if_landing(tx: &mut AdmittedTx, channel_id: Uuid) -> Result<()> { + let community_id = tx.community(); + let landing: Option> = + sqlx::query_scalar("SELECT landing_channel_id FROM communities WHERE id = $1") + .bind(community_id.as_uuid()) + .fetch_optional(tx.conn()) + .await?; + if landing.flatten() == Some(channel_id) { + return Err(DbError::AccessDenied(LANDING_PROTECTED.to_string())); + } + Ok(()) +} + +/// SQL that is true for a channel that may be the landing channel. +const ELIGIBLE_SQL: &str = "channel_type = 'stream' AND visibility = 'open' \ + AND archived_at IS NULL AND deleted_at IS NULL AND ttl_seconds IS NULL"; + +/// See [`Db::ensure_landing_channel`]. +async fn ensure_landing_channel( + pool: &PgPool, + community_id: CommunityId, +) -> Result { + let current = { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + read_landing(&mut connection, community_id) + .await? + .channel_id + }; + if let Some(channel_id) = current { + return Ok(LandingAdoption::Existing(channel_id)); + } + if let Some(adopted) = adopt_general(pool, community_id).await? { + return Ok(adopted); + } + let Some(steward) = steward(pool, community_id).await? else { + return Ok(LandingAdoption::SkippedNoSteward); + }; + match create_general(pool, community_id, steward).await { + // A concurrent creator, or a rename, took the name first: adopt its + // channel if it is eligible. + Err(DbError::ChannelNameTaken(_)) => Ok(adopt_general(pool, community_id) + .await? + .unwrap_or(LandingAdoption::SkippedNameTaken)), + created => created, + } +} + +/// Designates the oldest eligible `general`, preferring a listed one. +/// `None` when there is none. +async fn adopt_general( + pool: &PgPool, + community_id: CommunityId, +) -> Result> { + let candidate: Option = { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + sqlx::query_scalar(sqlx::AssertSqlSafe(format!( + "SELECT id FROM channels WHERE community_id = $1 AND lower(name) = $2 \ + AND {ELIGIBLE_SQL} ORDER BY unlisted ASC, created_at ASC, id ASC LIMIT 1" + ))) + .bind(community_id.as_uuid()) + .bind(LANDING_CHANNEL_NAME) + .fetch_optional(&mut *connection) + .await? + }; + let Some(candidate) = candidate else { + return Ok(None); + }; + + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let mut locked = match crate::channel::lock_channel_for_settings_write( + &mut tx, + candidate, + &ChannelUpdate::default(), + ) + .await + { + Ok(locked) => locked, + Err(DbError::ChannelNotFound(_)) => return Ok(None), + Err(error) => return Err(error), + }; + // Under the row lock: still eligible, and still named `general`. + let eligible: bool = sqlx::query_scalar(sqlx::AssertSqlSafe(format!( + "SELECT EXISTS (SELECT 1 FROM channels WHERE community_id = $1 AND id = $2 \ + AND lower(name) = $3 AND {ELIGIBLE_SQL})" + ))) + .bind(community_id.as_uuid()) + .bind(candidate) + .bind(LANDING_CHANNEL_NAME) + .fetch_one(locked.tx().conn()) + .await?; + if !eligible { + return Ok(None); + } + if let Some(existing) = lock_designation(locked.tx()).await? { + return Ok(Some(LandingAdoption::Existing(existing))); + } + set_designation(locked.tx(), candidate).await?; + tx.commit().await?; + Ok(Some(LandingAdoption::Reused(candidate))) +} + +/// The community's earliest owner, else its earliest admin. +async fn steward(pool: &PgPool, community_id: CommunityId) -> Result> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let pubkey: Option = sqlx::query_scalar( + "SELECT pubkey FROM relay_members \ + WHERE community_id = $1 AND role IN ('owner', 'admin') \ + ORDER BY role = 'owner' DESC, created_at ASC, pubkey ASC LIMIT 1", + ) + .bind(community_id.as_uuid()) + .fetch_optional(&mut *connection) + .await?; + pubkey + .map(|hex_key| { + hex::decode(&hex_key) + .ok() + .and_then(|bytes| <[u8; 32]>::try_from(bytes).ok()) + .ok_or_else(|| { + DbError::InvalidData(format!( + "relay member pubkey is not 32-byte hex: {hex_key}" + )) + }) + }) + .transpose() +} + +/// Creates a listed, open `general` stream owned by `steward` and designates +/// it, unless a designation appeared meanwhile. +async fn create_general( + pool: &PgPool, + community_id: CommunityId, + steward: [u8; 32], +) -> Result { + let mut tx = crate::begin_community_write_transaction( + pool, + community_id, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + // The community row first: the new channel row is no one else's lock + // yet, so this keeps the channel-then-community order for every + // existing channel. + if let Some(existing) = lock_designation(&mut tx).await? { + return Ok(LandingAdoption::Existing(existing)); + } + let channel_id = Uuid::new_v4(); + crate::channel::insert_channel_rows( + &mut tx, + channel_id, + NewChannel { + name: LANDING_CHANNEL_NAME, + channel_type: ChannelType::Stream, + visibility: ChannelVisibility::Open, + description: None, + created_by: &steward, + ttl_seconds: None, + labels: &[], + unlisted: false, + members: &[], + }, + ) + .await?; + set_designation(&mut tx, channel_id).await?; + tx.commit().await?; + Ok(LandingAdoption::Created { + channel_id, + steward, + }) +} + +impl Db { + /// Returns the community's landing channel and welcome message. + #[datastore_span(name = "get_community_landing", system = "postgresql")] + pub async fn get_community_landing( + &self, + community_id: CommunityId, + ) -> Result { + let mut connection = crate::observability::acquire_writer( + &self.pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + read_landing(&mut connection, community_id).await + } + + /// Makes sure the community has a landing channel and returns what it + /// found or did. Idempotent; costs one primary-key read once a channel + /// is designated. + /// + /// With no designation, the oldest eligible (open, unarchived stream + /// without a TTL) channel named `general` is adopted, a listed one before + /// an unlisted one. With none, a listed, open `general` is created, owned + /// by the community's earliest owner, else earliest admin + /// ([`LandingAdoption::SkippedNoSteward`] when there is neither). If the + /// name is held by a channel that cannot be adopted (an archived + /// `general`, or a forum), nothing is created + /// ([`LandingAdoption::SkippedNameTaken`]). A private `general` is never + /// adopted and does not hold the name. + /// + /// A NULL designation is the retry record: every call that finds one + /// tries again. + #[datastore_span(name = "ensure_landing_channel", system = "postgresql")] + pub async fn ensure_landing_channel( + &self, + community_id: CommunityId, + ) -> Result { + ensure_landing_channel(&self.pool, community_id).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn landing_tags_mark_only_the_landing_channel() { + let (landing, other) = (Uuid::new_v4(), Uuid::new_v4()); + let tag = |parts: &[&str]| parts.iter().map(|p| p.to_string()).collect::>(); + let custom = CommunityLanding { + channel_id: Some(landing), + welcome: Some("Hi all!\nSay hello.".to_string()), + }; + assert_eq!( + custom.group_metadata_tags(landing), + vec![tag(&["landing"]), tag(&["welcome", "Hi all!\nSay hello."])] + ); + assert!(custom.group_metadata_tags(other).is_empty()); + let default_text = CommunityLanding { + channel_id: Some(landing), + welcome: None, + }; + assert_eq!( + default_text.group_metadata_tags(landing), + vec![tag(&["landing"])] + ); + assert!(CommunityLanding::default() + .group_metadata_tags(landing) + .is_empty()); + } + + #[test] + fn welcome_message_rules() { + let at_limit = "a".repeat(WELCOME_MAX_CHARS); + let over_limit = "é".repeat(WELCOME_MAX_CHARS + 1); + let cases: &[(&str, Option<&str>)] = &[ + ("Welcome!", None), + ("Hi all!\nIntroduce yourself here.", None), + (" padded ", None), + (&at_limit, None), + (" ", Some("welcome message must not be blank")), + ("\n\n", Some("welcome message must not be blank")), + ( + &over_limit, + Some("welcome message is too long (max 1000 characters)"), + ), + ( + "tab\there", + Some("welcome message must be plain text (line breaks only)"), + ), + ( + "windows\r\nline", + Some("welcome message must be plain text (line breaks only)"), + ), + ( + "bell\u{7}", + Some("welcome message must be plain text (line breaks only)"), + ), + ]; + for (text, expected) in cases { + assert_eq!(welcome_message_error(text), *expected, "{text:?}"); + } + // The limit counts scalar values, not bytes: 1000 multi-byte + // characters fit. + assert_eq!(welcome_message_error(&"é".repeat(WELCOME_MAX_CHARS)), None); + } +} + +#[cfg(test)] +mod postgres_tests { + use super::*; + use crate::channel::{ChannelMetadataEdit, ChannelMetadataWrite}; + use chrono::{DateTime, Duration, Utc}; + use nostr::Keys; + + async fn setup() -> (Db, CommunityId) { + let pool = PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB"); + let id = Uuid::new_v4(); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(id) + .bind(format!("landing-test-{}.example", id.simple())) + .execute(&pool) + .await + .expect("insert test community"); + (Db::from_pool(pool), CommunityId::from_uuid(id)) + } + + fn key() -> Vec { + Keys::generate().public_key().to_bytes().to_vec() + } + + async fn channel( + db: &Db, + community: CommunityId, + name: &str, + channel_type: ChannelType, + visibility: ChannelVisibility, + created_by: &[u8], + ttl_seconds: Option, + ) -> Uuid { + db.create_channel( + community, + name, + channel_type, + visibility, + None, + created_by, + ttl_seconds, + ) + .await + .expect("create channel") + .id + } + + async fn open_stream(db: &Db, community: CommunityId, name: &str) -> Uuid { + let creator = key(); + channel( + db, + community, + name, + ChannelType::Stream, + ChannelVisibility::Open, + &creator, + None, + ) + .await + } + + /// Applies `edit` as a kind:9002 on `channel_id`, signed by a fresh key. + async fn apply( + db: &Db, + community: CommunityId, + channel_id: Uuid, + edit: ChannelMetadataEdit, + ) -> Result { + let event = nostr::EventBuilder::new(nostr::Kind::Custom(9002), "") + .tags([nostr::Tag::parse(["h", &channel_id.to_string()]).expect("h tag")]) + .sign_with_keys(&Keys::generate()) + .expect("sign kind:9002"); + db.apply_channel_metadata_event(community, channel_id, &event, edit, &[]) + .await + } + + fn designate() -> ChannelMetadataEdit { + ChannelMetadataEdit { + landing: true, + ..Default::default() + } + } + + /// The client's full-state form: `landing` plus `welcome` (`""` + /// restores the default). + fn full_state(welcome: &str) -> ChannelMetadataEdit { + ChannelMetadataEdit { + landing: true, + welcome: Some((!welcome.is_empty()).then(|| welcome.to_string())), + ..Default::default() + } + } + + fn refusal(result: Result) -> String { + match result { + Err(DbError::AccessDenied(reason)) => reason, + Err(other) => panic!("expected a refusal, got {other:?}"), + Ok(_) => panic!("expected a refusal, got success"), + } + } + + fn landing(channel_id: Uuid, welcome: Option<&str>) -> CommunityLanding { + CommunityLanding { + channel_id: Some(channel_id), + welcome: welcome.map(str::to_string), + } + } + + /// Seeds a relay member with `role`, added `seconds_ago`. + async fn relay_member( + db: &Db, + community: CommunityId, + role: &str, + seconds_ago: i64, + ) -> Vec { + let pubkey = key(); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, created_at, updated_at) \ + VALUES ($1, $2, $3, now() - make_interval(secs => $4), now())", + ) + .bind(community.as_uuid()) + .bind(hex::encode(&pubkey)) + .bind(role) + .bind(seconds_ago as f64) + .execute(db.pool()) + .await + .expect("insert relay member"); + pubkey + } + + /// A kind:9002 `landing` designates the channel and moves the + /// designation, carrying the welcome message; the full-state form on + /// the current landing channel is idempotent; `welcome` elsewhere and + /// every ineligible post-state are refused and change nothing. + /// + /// Mutations: report `previous_landing` on a re-designation → RED; + /// check eligibility on the pre-state → the same-event archive, private + /// and ttl rows are accepted → RED; drop the welcome landing check → + /// the welcome on `b` is accepted → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn kind_9002_moves_the_designation_and_accepts_the_full_state_form() { + let (db, community) = setup().await; + let a = open_stream(&db, community, "lobby").await; + let b = open_stream(&db, community, "town-square").await; + + let write = apply(&db, community, a, full_state("Hi all")) + .await + .expect("designate a"); + assert_eq!(write.previous_landing, None); + assert_eq!( + db.get_community_landing(community).await.expect("landing"), + landing(a, Some("Hi all")) + ); + + let write = apply(&db, community, a, full_state("")) + .await + .expect("full state on the landing channel"); + assert!(write.inserted); + assert_eq!(write.previous_landing, None, "re-designating is not a move"); + assert_eq!( + db.get_community_landing(community).await.expect("landing"), + landing(a, None), + "an empty welcome restores the default" + ); + + apply( + &db, + community, + a, + ChannelMetadataEdit { + welcome: Some(Some("Second".into())), + ..Default::default() + }, + ) + .await + .expect("welcome alone on the landing channel"); + assert_eq!( + refusal( + apply( + &db, + community, + b, + ChannelMetadataEdit { + welcome: Some(Some("Elsewhere".into())), + ..Default::default() + }, + ) + .await + ), + WELCOME_NOT_LANDING + ); + assert_eq!( + db.get_community_landing(community).await.expect("landing"), + landing(a, Some("Second")) + ); + + let write = apply(&db, community, b, designate()) + .await + .expect("move to b"); + assert_eq!(write.previous_landing, Some(a)); + assert_eq!( + db.get_community_landing(community).await.expect("landing"), + landing(b, Some("Second")), + "the welcome moves with the designation" + ); + + let private = channel( + &db, + community, + "private-room", + ChannelType::Stream, + ChannelVisibility::Private, + &key(), + None, + ) + .await; + let ephemeral = channel( + &db, + community, + "ephemeral-room", + ChannelType::Stream, + ChannelVisibility::Open, + &key(), + Some(3600), + ) + .await; + let ineligible = [ + ( + a, + ChannelMetadataEdit { + archived: Some(true), + ..designate() + }, + "archived by the same event", + ), + ( + a, + ChannelMetadataEdit { + columns: ChannelUpdate { + visibility: Some("private".into()), + ..Default::default() + }, + ..designate() + }, + "made private by the same event", + ), + ( + a, + ChannelMetadataEdit { + columns: ChannelUpdate { + ttl_seconds: Some(Some(3600)), + ..Default::default() + }, + ..designate() + }, + "given a ttl by the same event", + ), + (private, designate(), "a private channel"), + (ephemeral, designate(), "a channel with a ttl"), + ]; + for (target, edit, label) in ineligible { + assert_eq!( + refusal(apply(&db, community, target, edit).await), + LANDING_INELIGIBLE, + "{label}" + ); + } + assert_eq!( + db.get_community_landing(community).await.expect("landing"), + landing(b, Some("Second")), + "a refused move changes nothing" + ); + let a_after = db.get_channel(community, a).await.expect("a"); + assert!( + a_after.archived_at.is_none(), + "the refused archive rolled back" + ); + assert_eq!(a_after.visibility, "open"); + assert_eq!(a_after.ttl_seconds, None); + } + + /// The landing channel cannot be archived, made private, given a TTL or + /// deleted on any path, while other edits stay allowed; after a move + /// the previous landing channel can be. + /// + /// Mutations: drop the backstop in `set_archived_on`, `update_channel_on` + /// or `soft_delete_on` → that row is accepted → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn the_landing_channel_is_protected_on_every_path() { + let (db, community) = setup().await; + let lobby = open_stream(&db, community, "lobby").await; + let other = open_stream(&db, community, "other").await; + apply(&db, community, lobby, designate()) + .await + .expect("designate"); + + for (edit, label) in [ + ( + ChannelMetadataEdit { + archived: Some(true), + ..Default::default() + }, + "kind:9002 archived", + ), + ( + ChannelMetadataEdit { + columns: ChannelUpdate { + visibility: Some("private".into()), + ..Default::default() + }, + ..Default::default() + }, + "kind:9002 visibility", + ), + ( + ChannelMetadataEdit { + columns: ChannelUpdate { + ttl_seconds: Some(Some(60)), + ..Default::default() + }, + ..Default::default() + }, + "kind:9002 ttl", + ), + ] { + assert_eq!( + refusal(apply(&db, community, lobby, edit).await), + LANDING_PROTECTED, + "{label}" + ); + } + assert_eq!( + refusal(db.archive_channel(community, lobby).await), + LANDING_PROTECTED, + "archive_channel" + ); + assert_eq!( + refusal( + db.update_channel( + community, + lobby, + ChannelUpdate { + visibility: Some("private".into()), + ..Default::default() + }, + ) + .await + ), + LANDING_PROTECTED, + "update_channel visibility" + ); + assert_eq!( + refusal( + db.update_channel( + community, + lobby, + ChannelUpdate { + ttl_seconds: Some(Some(60)), + ..Default::default() + }, + ) + .await + ), + LANDING_PROTECTED, + "update_channel ttl" + ); + assert_eq!( + refusal(db.soft_delete_channel(community, lobby).await), + LANDING_PROTECTED, + "soft_delete_channel" + ); + + db.update_channel( + community, + lobby, + ChannelUpdate { + name: Some("lobby-renamed".into()), + unlisted: Some(true), + auto_join: Some(true), + ttl_seconds: Some(None), + ..Default::default() + }, + ) + .await + .expect("rename, unlisted, auto_join and clearing a ttl stay allowed"); + let after = db.get_channel(community, lobby).await.expect("lobby"); + assert!(after.archived_at.is_none() && after.deleted_at.is_none()); + assert_eq!(after.visibility, "open"); + assert_eq!(after.ttl_seconds, None); + + apply(&db, community, other, designate()) + .await + .expect("move away"); + db.archive_channel(community, lobby) + .await + .expect("the previous landing channel can be archived"); + assert!(db + .soft_delete_channel(community, lobby) + .await + .expect("and deleted")); + } + + struct Seed { + name: &'static str, + channel_type: ChannelType, + visibility: ChannelVisibility, + unlisted: bool, + archived: bool, + ttl_seconds: Option, + } + + const fn general() -> Seed { + Seed { + name: "general", + channel_type: ChannelType::Stream, + visibility: ChannelVisibility::Open, + unlisted: false, + archived: false, + ttl_seconds: None, + } + } + + #[derive(Debug, Clone, Copy)] + enum Expect { + /// The seed at this index is adopted. + Reused(usize), + /// A new `general` is created, owned by the relay member at this + /// index. + Created(usize), + NoSteward, + NameTaken, + } + + /// `ensure_landing_channel`'s rules: which `general` is adopted, when + /// one is created and by whom it is owned, and when nothing is done. + /// A second call returns the same designation. + /// + /// Mutations: drop `unlisted ASC` from the candidate order → "listed + /// before an older unlisted" adopts the unlisted one → RED; adopt + /// without the eligibility predicate → the archived, forum, ttl and + /// private rows adopt them → RED; order the steward by `created_at` + /// alone → "owner before an earlier admin" → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn ensure_landing_channel_rules() { + let owner = || vec![("owner", 10)]; + let cases: Vec<(&str, Vec, Vec<(&str, i64)>, Expect)> = vec![ + ("listed general", vec![general()], vec![], Expect::Reused(0)), + ( + "name is case-insensitive", + vec![Seed { + name: "General", + ..general() + }], + vec![], + Expect::Reused(0), + ), + ( + "unlisted general is reused, not duplicated", + vec![Seed { + unlisted: true, + ..general() + }], + owner(), + Expect::Reused(0), + ), + ( + "listed before an older unlisted", + vec![ + Seed { + unlisted: true, + ..general() + }, + general(), + ], + vec![], + Expect::Reused(1), + ), + ( + "an archived general holds the name", + vec![Seed { + archived: true, + ..general() + }], + owner(), + Expect::NameTaken, + ), + ( + "a forum general holds the name", + vec![Seed { + channel_type: ChannelType::Forum, + ..general() + }], + owner(), + Expect::NameTaken, + ), + ( + "a general with a ttl holds the name", + vec![Seed { + ttl_seconds: Some(3600), + ..general() + }], + owner(), + Expect::NameTaken, + ), + ( + "a private general is left alone", + vec![Seed { + visibility: ChannelVisibility::Private, + ..general() + }], + owner(), + Expect::Created(0), + ), + ( + "other channels do not count", + vec![Seed { + name: "random", + ..general() + }], + owner(), + Expect::Created(0), + ), + ("no owner or admin", vec![], vec![], Expect::NoSteward), + ( + "a member is not a steward", + vec![], + vec![("member", 10)], + Expect::NoSteward, + ), + ( + "an admin stewards without an owner", + vec![], + vec![("member", 100), ("admin", 10)], + Expect::Created(1), + ), + ( + "the owner before an earlier admin", + vec![], + vec![("admin", 100), ("owner", 10)], + Expect::Created(1), + ), + ( + "the earliest owner", + vec![], + vec![("owner", 10), ("owner", 100)], + Expect::Created(1), + ), + ]; + + for (label, seeds, members, expect) in cases { + let (db, community) = setup().await; + let mut seeded = Vec::new(); + for seed in &seeds { + let id = channel( + &db, + community, + seed.name, + seed.channel_type, + seed.visibility, + &key(), + seed.ttl_seconds, + ) + .await; + if seed.unlisted { + db.update_channel( + community, + id, + ChannelUpdate { + unlisted: Some(true), + ..Default::default() + }, + ) + .await + .expect("unlist seed"); + } + if seed.archived { + db.archive_channel(community, id) + .await + .expect("archive seed"); + } + seeded.push(id); + } + let mut stewards = Vec::new(); + for (role, seconds_ago) in members { + stewards.push(relay_member(&db, community, role, seconds_ago).await); + } + + let adoption = db + .ensure_landing_channel(community) + .await + .unwrap_or_else(|e| panic!("{label}: {e:?}")); + match (expect, adoption) { + (Expect::Reused(index), LandingAdoption::Reused(id)) => { + assert_eq!(id, seeded[index], "{label}") + } + ( + Expect::Created(index), + LandingAdoption::Created { + channel_id, + steward, + }, + ) => { + assert_eq!(steward.as_slice(), stewards[index].as_slice(), "{label}"); + assert!(!seeded.contains(&channel_id), "{label}: a new channel"); + let created = db + .get_channel(community, channel_id) + .await + .expect("created"); + assert_eq!(created.name, "general", "{label}"); + assert_eq!( + (created.channel_type.as_str(), created.visibility.as_str()), + ("stream", "open"), + "{label}" + ); + assert!(!created.unlisted, "{label}: listed"); + assert_eq!(created.created_by, stewards[index], "{label}"); + assert_eq!( + db.get_member_role(community, channel_id, &stewards[index]) + .await + .expect("steward role") + .as_deref(), + Some("owner"), + "{label}: the steward owns it" + ); + } + (Expect::NoSteward, LandingAdoption::SkippedNoSteward) + | (Expect::NameTaken, LandingAdoption::SkippedNameTaken) => {} + (expect, got) => panic!("{label}: expected {expect:?}, got {got:?}"), + } + + let designated = db + .get_community_landing(community) + .await + .expect("landing") + .channel_id; + assert_eq!(designated, adoption.channel_id(), "{label}"); + if let Some(id) = designated { + assert_eq!( + db.ensure_landing_channel(community).await.expect("again"), + LandingAdoption::Existing(id), + "{label}: a second call keeps it" + ); + } + for (seed, id) in seeds.iter().zip(&seeded) { + let after = db.get_channel(community, *id).await.expect("seed"); + assert_eq!( + after.visibility, + seed.visibility.to_string(), + "{label}: seed untouched" + ); + } + } + } + + /// Concurrent ensures on a community with no `general` create exactly + /// one and agree on it. + /// + /// Mutation: drop both the locked designation re-check in + /// `create_general` and the `ChannelNameTaken` fallback → a racing + /// creator fails on the name index → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn concurrent_ensures_designate_one_general() { + let (db, community) = setup().await; + relay_member(&db, community, "owner", 10).await; + let runs = (0..8).map(|_| { + let db = db.clone(); + tokio::spawn(async move { db.ensure_landing_channel(community).await }) + }); + let mut adoptions = Vec::new(); + for run in runs.collect::>() { + adoptions.push(run.await.expect("join").expect("ensure")); + } + let ids: std::collections::HashSet<_> = + adoptions.iter().map(LandingAdoption::channel_id).collect(); + assert_eq!(ids.len(), 1, "every caller agrees: {adoptions:?}"); + assert!(ids.iter().all(Option::is_some)); + assert_eq!( + adoptions + .iter() + .filter(|a| matches!(a, LandingAdoption::Created { .. })) + .count(), + 1, + "{adoptions:?}" + ); + let generals: i64 = sqlx::query_scalar( + "SELECT count(*) FROM channels WHERE community_id = $1 AND lower(name) = 'general'", + ) + .bind(community.as_uuid()) + .fetch_one(db.pool()) + .await + .expect("count generals"); + assert_eq!(generals, 1); + } + + #[derive(Debug, Clone, Copy)] + enum Shape { + OpenStream, + Forum, + Dm, + PrivateOwn, + PrivateOther, + DeletedStream, + } + + /// Creates a channel of `shape` with a `person` membership row joined + /// at `joined` and removed at `removed`. + async fn membership( + db: &Db, + community: CommunityId, + person: &[u8], + shape: Shape, + joined: DateTime, + removed: Option>, + ) -> Uuid { + let other = key(); + let (channel_type, visibility, creator) = match shape { + Shape::OpenStream | Shape::DeletedStream => ( + ChannelType::Stream, + ChannelVisibility::Open, + other.as_slice(), + ), + Shape::Forum => ( + ChannelType::Forum, + ChannelVisibility::Open, + other.as_slice(), + ), + Shape::Dm => ( + ChannelType::Dm, + ChannelVisibility::Private, + other.as_slice(), + ), + Shape::PrivateOwn => (ChannelType::Stream, ChannelVisibility::Private, person), + Shape::PrivateOther => ( + ChannelType::Stream, + ChannelVisibility::Private, + other.as_slice(), + ), + }; + let name = format!("evidence-{}", Uuid::new_v4().simple()); + let id = channel( + db, + community, + &name, + channel_type, + visibility, + creator, + None, + ) + .await; + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at, removed_at) \ + VALUES ($1, $2, $3, 'member', $4, $5) \ + ON CONFLICT (community_id, channel_id, pubkey) \ + DO UPDATE SET joined_at = EXCLUDED.joined_at, removed_at = EXCLUDED.removed_at", + ) + .bind(community.as_uuid()) + .bind(id) + .bind(person) + .bind(joined) + .bind(removed) + .execute(db.pool()) + .await + .expect("seed membership"); + if matches!(shape, Shape::DeletedStream) { + sqlx::query( + "UPDATE channels SET deleted_at = now() WHERE community_id = $1 AND id = $2", + ) + .bind(community.as_uuid()) + .bind(id) + .execute(db.pool()) + .await + .expect("delete channel"); + } + id + } + + /// `has_entered_community`: which memberships are entry evidence, with + /// a roster admission time (roster mode) and without (roster-free). + /// + /// Mutations: drop the channel-type filter → the DM row counts → RED; + /// drop the own-private exclusion → the Solo row counts → RED; ignore + /// `admitted_at` → "left before admission" counts in roster mode → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn entry_evidence_rules() { + let admitted = Utc::now() - Duration::hours(1); + let (before, after) = ( + admitted - Duration::hours(1), + admitted + Duration::minutes(10), + ); + // (label, membership, roster-mode evidence, roster-free evidence) + let cases: &[( + &str, + Option<(Shape, DateTime, Option>)>, + bool, + bool, + )] = &[ + ("no memberships", None, false, false), + ( + "active, joined before admission", + Some((Shape::OpenStream, before, None)), + true, + true, + ), + ( + "left before admission", + Some((Shape::OpenStream, before, Some(before))), + false, + true, + ), + ( + "left after admission", + Some((Shape::OpenStream, before, Some(after))), + true, + true, + ), + ( + "joined and left after admission", + Some((Shape::OpenStream, after, Some(after))), + true, + true, + ), + ( + "forum member", + Some((Shape::Forum, before, None)), + true, + true, + ), + ("DM only", Some((Shape::Dm, after, None)), false, false), + ( + "own private chat (Solo)", + Some((Shape::PrivateOwn, after, None)), + false, + false, + ), + ( + "someone else's private channel", + Some((Shape::PrivateOther, after, None)), + true, + true, + ), + ( + "a deleted channel is still history", + Some((Shape::DeletedStream, after, None)), + true, + true, + ), + ]; + for (label, seed, roster, roster_free) in cases { + let (db, community) = setup().await; + let person = key(); + if let Some((shape, joined, removed)) = seed { + membership(&db, community, &person, *shape, *joined, *removed).await; + } + let entered = |admitted_at| { + let db = db.clone(); + let person = person.clone(); + async move { + db.has_entered_community(community, &person, admitted_at) + .await + .expect("evidence") + } + }; + assert_eq!( + entered(Some(admitted)).await, + *roster, + "{label}: roster mode" + ); + assert_eq!(entered(None).await, *roster_free, "{label}: roster-free"); + } + } + + /// `put_first_member_since` writes only when no row stands for this + /// admission: none at all, or one removed before the roster admission. + /// A leave since admission stands, and so does any earlier leave + /// without an admission time. + /// + /// Mutation: drop the `removed_at >= $4` arm → "left after admission" + /// is re-joined → RED; drop the `$4 IS NULL` arm → roster-free + /// re-joins a leave → RED. + #[tokio::test] + #[ignore = "requires Postgres"] + async fn put_first_member_since_rules() { + let admitted = Utc::now() - Duration::hours(1); + let (before, after) = ( + admitted - Duration::hours(1), + admitted + Duration::minutes(10), + ); + // (label, existing row (joined, removed), admitted_at, writes) + let cases: &[( + &str, + Option<(DateTime, Option>)>, + Option>, + bool, + )] = &[ + ("no row, roster", None, Some(admitted), true), + ("no row, roster-free", None, None, true), + ("active row", Some((before, None)), Some(admitted), false), + ( + "left before admission: re-admission restores", + Some((before, Some(before))), + Some(admitted), + true, + ), + ( + "left after admission: the leave stands", + Some((before, Some(after))), + Some(admitted), + false, + ), + ( + "left, roster-free: the leave stands", + Some((before, Some(before))), + None, + false, + ), + ]; + for (label, row, admitted_at, writes) in cases { + let (db, community) = setup().await; + let person = key(); + let lobby = open_stream(&db, community, "lobby").await; + if let Some((joined, removed)) = row { + sqlx::query( + "INSERT INTO channel_members (community_id, channel_id, pubkey, role, joined_at, removed_at) \ + VALUES ($1, $2, $3, 'member', $4, $5)", + ) + .bind(community.as_uuid()) + .bind(lobby) + .bind(&person) + .bind(joined) + .bind(removed) + .execute(db.pool()) + .await + .expect("seed row"); + } + let wrote = db + .put_first_member_since(community, lobby, &person, *admitted_at) + .await + .unwrap_or_else(|e| panic!("{label}: {e:?}")) + .is_some(); + assert_eq!(wrote, *writes, "{label}"); + let active = db + .get_member_role(community, lobby, &person) + .await + .expect("role") + .is_some(); + assert_eq!( + active, + *writes || row.is_some_and(|(_, removed)| removed.is_none()), + "{label}" + ); + } + } +} diff --git a/crates/buzz-db/src/store/mod.rs b/crates/buzz-db/src/store/mod.rs index d1cb647694b..c7e567c1fb9 100644 --- a/crates/buzz-db/src/store/mod.rs +++ b/crates/buzz-db/src/store/mod.rs @@ -29,6 +29,8 @@ mod event_deletion; /// App-owned push enqueue and channel TTL refresh for inserted events. pub(crate) mod event_follow_up; mod event_hydration; +/// The community's landing channel and welcome message. +pub mod landing; /// Per-community NIP-FI issuer bindings. pub mod nip_fi_binding; /// Recent-first passes for newest-first `events` page reads. diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index ecbc7d9e0d0..46f82888394 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -1011,34 +1011,41 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: &conn.tenant, conn.nip_fi_assertion.as_ref(), ); - // A community without a membership roster has no roster insert to - // join default channels at, so a person is joined on admission to - // the ones they have never been in, under this socket's effect - // permits. Agents are not. - let auto_join = !state.config.require_relay_membership && !admitted_agent; + // A person's admission joins them, under this socket's effect + // permits, to the landing channel if they have never entered the + // community, and then to the default channels: in a community + // without a membership roster (which has no roster insert to join + // them at) the ones they have never been in, and in roster mode + // whatever this admission's durable default-channel work has + // left pending. An agent is not joined to the landing channel; in + // roster mode its admission still finishes that pending work. { let state = Arc::clone(&state); let tenant = conn.tenant.clone(); let gate = Arc::clone(&conn.nip_fi_gate); tokio::spawn(async move { - crate::handlers::side_effects::auto_join_default_channels( - &tenant, - &state, - &pubkey.to_hex(), - Some(&gate), - ) - .await; - if auto_join { - crate::handlers::side_effects::auto_join_never_joined_channels( + if admitted_agent { + if state.config.require_relay_membership { + crate::handlers::side_effects::auto_join_default_channels( + &tenant, + &state, + &pubkey.to_hex(), + Some(&gate), + ) + .await; + } + // A test waits on every admission's auto-join outcome, + // so an agent's admission reports here. + #[cfg(test)] + crate::nip_fi_test_hooks::after_auto_join(tenant.community()).await; + } else { + crate::handlers::landing::admission_joins_on_auth( &tenant, &state, &pubkey.to_bytes(), &gate, ) .await; - } else { - #[cfg(test)] - crate::nip_fi_test_hooks::after_auto_join(tenant.community()).await; } }); } @@ -3049,12 +3056,13 @@ mod tests { } /// On a community without a membership roster, a person's admission - /// joins them to the default channels they have never been in. An + /// joins them to the default channels they have never been in (here + /// `general` is also adopted as the landing channel). An /// agent's (NIP-OA) admission runs no join, and a person who left is /// not joined back when they are admitted again. Each check waits for /// that admission's own auto-join outcome (skip or finished task). /// - /// Mutations: delete the `auto_join_never_joined_channels` spawn → + /// Mutations: delete the `admission_joins_on_auth` spawn → /// no outcome arrives (RED); drop `!admitted_agent` → the agent is /// joined (RED); drop the row check in `put_first_member` → the /// leave is undone (RED). @@ -3287,6 +3295,204 @@ mod tests { } } + /// The active role of `keys` in `channel`, if any. + async fn channel_role( + state: &crate::state::AppState, + community: buzz_core::tenant::CommunityId, + channel: uuid::Uuid, + keys: &Keys, + ) -> Option { + state + .db + .get_member_role(community, channel, &keys.public_key().to_bytes()) + .await + .expect("member role") + } + + async fn seed_relay_member( + state: &crate::state::AppState, + community: buzz_core::tenant::CommunityId, + keys: &Keys, + role: &str, + ) { + state + .db + .add_relay_member( + community, + &keys.public_key().to_hex(), + role, + None, + &Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed relay member"); + } + + async fn landing_channel( + state: &crate::state::AppState, + community: buzz_core::tenant::CommunityId, + ) -> uuid::Uuid { + state + .db + .get_community_landing(community) + .await + .expect("landing") + .channel_id + .expect("a landing channel is designated") + } + + /// Without a roster, a person's first admission creates the landing + /// `general` (owned by the relay owner) and joins them to it. Their + /// own private chats (Solo) are not entry evidence; a membership in a + /// shared channel is, and a leave stands at the next admission. + /// + /// Mutations: drop the `has_entered_community` gate → the regular is + /// joined and the leave is undone (RED); drop the own-private + /// exclusion → the Solo user is not joined (RED); drop the landing + /// join → nobody is joined (RED). + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn root_auth_joins_a_first_entry_to_the_landing_channel() { + use buzz_db::channel::{ChannelType, ChannelVisibility}; + let state = auth_test_state_real_db_expect().await; + let community = seeded_community(&state).await; + let (owner, person, regular) = (Keys::generate(), Keys::generate(), Keys::generate()); + seed_relay_member(&state, community, &owner, "owner").await; + for (keys, name, visibility) in [ + (&person, "solo", ChannelVisibility::Private), + (®ular, "lounge", ChannelVisibility::Open), + ] { + state + .db + .create_channel( + community, + name, + ChannelType::Stream, + visibility, + None, + &keys.public_key().to_bytes(), + None, + ) + .await + .expect("create channel"); + } + + admit_and_settle(&state, community, &person, "landing-first", None).await; + let landing = landing_channel(&state, community).await; + let general = state + .db + .get_channel(community, landing) + .await + .expect("general"); + assert_eq!( + (general.name.as_str(), general.visibility.as_str()), + ("general", "open") + ); + assert_eq!( + channel_role(&state, community, landing, &owner) + .await + .as_deref(), + Some("owner"), + "the relay owner owns the created general" + ); + assert_eq!( + channel_role(&state, community, landing, &person) + .await + .as_deref(), + Some("member"), + "a Solo chat is not entry evidence" + ); + + admit_and_settle(&state, community, ®ular, "landing-entered", None).await; + assert_eq!( + channel_role(&state, community, landing, ®ular).await, + None, + "a shared-channel member has entered" + ); + + let person_key = person.public_key().to_bytes(); + state + .db + .remove_member(community, landing, &person_key, &person_key) + .await + .expect("leave"); + admit_and_settle(&state, community, &person, "landing-again", None).await; + assert_eq!( + channel_role(&state, community, landing, &person).await, + None, + "a landing leave stands" + ); + } + + /// With a roster, a roster write that did not join the person (here, + /// a bare roster insert) is recovered at AUTH: the landing channel, + /// then the default channels. Once they have left both, an admission + /// joins nothing; after a kind:9031 removal, re-admission is a first + /// entry again and restores both. + /// + /// Mutations: use `None` for `admitted_at` in roster mode → the + /// re-admission joins nothing (RED); drop the roster-mode defaults + /// recovery → the default channel is not joined (RED); gate the spawn + /// on `!require_relay_membership` again → nothing is joined (RED). + #[tokio::test] + #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] + async fn roster_auth_recovers_landing_and_defaults_for_a_first_entry() { + let pool = sqlx::PgPool::connect(&crate::test_support::database_url()) + .await + .expect("connect to test DB"); + let state = super::state_with_pool(pool, |c| c.require_relay_membership = true).await; + let community = seeded_community(&state).await; + let (owner, person) = (Keys::generate(), Keys::generate()); + seed_relay_member(&state, community, &owner, "owner").await; + let announcements = + default_channels(&state, community, &owner, &["announcements"]).await[0]; + seed_relay_member(&state, community, &person, "member").await; + + admit_and_settle(&state, community, &person, "roster-recover", None).await; + let landing = landing_channel(&state, community).await; + let roles = |label: &'static str| { + let state = std::sync::Arc::clone(&state); + let person = person.clone(); + async move { + [ + channel_role(&state, community, landing, &person).await, + channel_role(&state, community, announcements, &person).await, + ] + .map(|role| (label, role)) + } + }; + let member = |label| (label, Some("member".to_string())); + assert_eq!( + roles("recovered").await, + [member("recovered"), member("recovered")] + ); + + let person_key = person.public_key().to_bytes(); + for channel in [landing, announcements] { + state + .db + .remove_member(community, channel, &person_key, &person_key) + .await + .expect("leave"); + } + admit_and_settle(&state, community, &person, "roster-left", None).await; + assert_eq!(roles("left").await, [("left", None), ("left", None)]); + + state + .db + .remove_relay_member(community, &person.public_key().to_hex(), &Keys::generate()) + .await + .map(|write| write.outcome) + .expect("kind:9031 removal"); + seed_relay_member(&state, community, &person, "member").await; + admit_and_settle(&state, community, &person, "roster-readmitted", None).await; + assert_eq!( + roles("readmitted").await, + [member("readmitted"), member("readmitted")] + ); + } + /// AUTH with NIP-OA records a previously ownerless agent's owner. That /// closes the agent's earlier ownerless socket so it reconnects with /// the owner attached, but not the socket being admitted, even when diff --git a/crates/buzz-relay/src/handlers/community_provisioning.rs b/crates/buzz-relay/src/handlers/community_provisioning.rs index e99f50b8f2c..39da8252077 100644 --- a/crates/buzz-relay/src/handlers/community_provisioning.rs +++ b/crates/buzz-relay/src/handlers/community_provisioning.rs @@ -326,8 +326,11 @@ pub async fn provision_community( &record.host, nip_fi_issuers, ); - state.invalidate_community_host(&TenantContext::resolved(record.id, &record.host)); + let tenant = TenantContext::resolved(record.id, &record.host); + state.invalidate_community_host(&tenant); fan_out_membership_snapshot(state, record.id, &record.host, write.snapshot).await; + // The owner just committed, so this creates the landing `general`. + crate::handlers::landing::converge_landing_channel(&tenant, state).await; return Ok(ProvisionCommunityResponse { community_id: record.id.to_string(), host: record.host, @@ -373,6 +376,13 @@ pub async fn provision_community( } fan_out_membership_snapshot(state, record.id, &record.host, write.snapshot).await; } + // Without an owner or admin this adopts an eligible `general` or skips; + // the next human admission tries again. + crate::handlers::landing::converge_landing_channel( + &TenantContext::resolved(record.id, &record.host), + state, + ) + .await; info!( operator = %operator_hex, diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 46a1c81f282..50241b52ede 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -666,11 +666,11 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), KIND_NIP29_EDIT_METADATA => { - // kind:9002 scope split: archived or auto_join tag → AdminChannels, - // else ChannelsWrite + // kind:9002 scope split: archived, auto_join, landing or welcome + // tag → AdminChannels, else ChannelsWrite let has_admin_tag = event.tags.iter().any(|t| { let k = t.kind().to_string(); - k == "archived" || k == "auto_join" + matches!(k.as_str(), "archived" | "auto_join" | "landing" | "welcome") }); if has_admin_tag { Ok(Scope::AdminChannels) diff --git a/crates/buzz-relay/src/handlers/landing.rs b/crates/buzz-relay/src/handlers/landing.rs new file mode 100644 index 00000000000..0dc3c680dc2 --- /dev/null +++ b/crates/buzz-relay/src/handlers/landing.rs @@ -0,0 +1,379 @@ +//! The community landing channel: the one open stream every person who +//! enters the community is joined to first, and where its welcome message +//! shows. +//! +//! The designation lives on the `communities` row (see +//! [`buzz_db::landing`]); NIP-11 `landing_channel` is its authoritative read +//! and the `["landing"]` / `["welcome", text]` tags on the channel's +//! kind:39000 are a projection. A relay owner or admin moves it with a +//! kind:9002 `landing` tag. With no designation, the relay adopts or creates +//! `general` here, lazily: at provisioning, at boot for the deployment +//! community, and on every human admission. A NULL designation is the retry +//! record. + +use std::sync::Arc; + +use nostr::Event; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +use buzz_core::kind::KIND_MEMBER_ADDED_NOTIFICATION; +use buzz_core::tenant::TenantContext; +use buzz_db::landing::LandingAdoption; + +use super::side_effects::{self, SelfJoinOutcome}; +use crate::nip_fi_gate::SessionAdmissionGate; +use crate::state::AppState; + +/// Refusal for a kind:9002 `landing` or `welcome` tag from an actor who is +/// not a relay owner or admin. +pub(crate) const NOT_AUTHORIZED: &str = + "actor not authorized: landing and welcome require a relay owner or admin"; + +/// Validates a kind:9002's `landing` and `welcome` tags. Returns whether the +/// event carries either; the caller then requires a relay owner or admin and +/// a stream channel. +/// +/// `["landing", "true"]` designates the channel; there is no way to turn the +/// designation off, only to move it. `["welcome", text]` sets the welcome +/// message and `["welcome", ""]` restores the default. Each may appear once. +pub(crate) fn validate_tags(event: &Event) -> anyhow::Result { + let tags = |name: &str| { + event + .tags + .iter() + .filter(move |tag| tag.as_slice().first().map(String::as_str) == Some(name)) + .map(|tag| tag.as_slice().get(1).map(String::as_str)) + .collect::>() + }; + let (landing, welcome) = (tags("landing"), tags("welcome")); + if landing.len() > 1 || welcome.len() > 1 { + anyhow::bail!("kind:9002 may include at most one landing and one welcome tag"); + } + if let Some(value) = landing.first() { + match value { + Some("true") => {} + Some("false") => { + anyhow::bail!( + "landing cannot be turned off; make another channel the landing channel" + ) + } + Some(other) => anyhow::bail!("invalid landing value: {other} (must be \"true\")"), + None => anyhow::bail!("landing tag must have a value"), + } + } + if let Some(value) = welcome.first() { + match value { + None => anyhow::bail!( + "welcome tag must have a value (text, or empty to restore the default)" + ), + Some("") => {} + Some(text) => { + if let Some(error) = buzz_db::landing::welcome_message_error(text) { + anyhow::bail!("{error}"); + } + } + } + } + Ok(!landing.is_empty() || !welcome.is_empty()) +} + +/// Makes sure the community has a landing channel (see +/// [`buzz_db::Db::ensure_landing_channel`]) and announces a new designation: +/// the channel's kind:39000 with its `landing` tag, and for a channel the +/// relay created, the owner's membership notice. Returns the landing +/// channel, `None` when the community has none and cannot get one yet. +/// +/// The designation is committed before any announcement, so a failed +/// announcement is logged and does not fail the call. +pub(crate) async fn ensure_landing_channel( + tenant: &TenantContext, + state: &Arc, +) -> anyhow::Result> { + let adoption = state.db.ensure_landing_channel(tenant.community()).await?; + if let Some(outcome) = adoption.outcome() { + metrics::counter!("buzz_landing_adoption_total", "outcome" => outcome).increment(1); + } + match adoption { + LandingAdoption::Existing(_) => {} + LandingAdoption::Reused(channel_id) => { + info!(channel = %channel_id, "adopted general as the landing channel"); + state.invalidate_all_accessible_channels(tenant); + announce_designation(tenant, state, channel_id).await; + } + LandingAdoption::Created { + channel_id, + steward, + } => { + info!(channel = %channel_id, steward = %hex::encode(steward), "created general as the landing channel"); + state.invalidate_membership(tenant, channel_id, &steward); + state.invalidate_all_accessible_channels(tenant); + announce_designation(tenant, state, channel_id).await; + if let Err(e) = side_effects::emit_membership_notification( + tenant, + state, + channel_id, + &steward, + state.relay_keypair.public_key().as_bytes(), + KIND_MEMBER_ADDED_NOTIFICATION, + ) + .await + { + warn!(channel = %channel_id, error = %e, "landing channel owner notification failed"); + } + } + LandingAdoption::SkippedNoSteward => { + debug!("no landing channel: no eligible general and no owner or admin to create one"); + } + LandingAdoption::SkippedNameTaken => { + debug!("no landing channel: general is taken by a channel that cannot be the landing channel"); + } + } + Ok(adoption.channel_id()) +} + +async fn announce_designation(tenant: &TenantContext, state: &Arc, channel_id: Uuid) { + if let Err(e) = side_effects::emit_group_discovery_events(tenant, state, channel_id).await { + warn!(channel = %channel_id, error = %e, "landing channel discovery emission failed"); + } +} + +/// Best-effort [`ensure_landing_channel`] for a caller that has nothing to +/// do with the result: provisioning and boot. A failure is logged; the NULL +/// designation retries at the next human admission. +pub async fn converge_landing_channel(tenant: &TenantContext, state: &Arc) { + if let Err(e) = ensure_landing_channel(tenant, state).await { + metrics::counter!("buzz_landing_adoption_failures_total").increment(1); + warn!(community = %tenant.host(), error = %e, "landing channel adoption failed"); + } +} + +/// The landing join at a roster write (invite claim, kind:9030), as a +/// kind:9021 self-join: an earlier landing membership is restored, as the +/// default channels' are. Returns whether the default channels should +/// follow: `false` only when the join failed in a way a retry could get +/// past, so the person keeps no entry evidence and their next AUTH joins +/// the landing channel and then the defaults +/// ([`admission_joins_on_auth`]). A refusal (a ban, or a designation left +/// unusable) is final, so the defaults follow it. +pub(crate) async fn join_landing_on_roster_admission( + tenant: &TenantContext, + state: &Arc, + pubkey: &[u8], +) -> bool { + let member = hex::encode(pubkey); + let landing = match ensure_landing_channel(tenant, state).await { + Ok(Some(landing)) => landing, + Ok(None) => return true, + Err(e) => { + metrics::counter!("buzz_landing_join_failures_total").increment(1); + warn!(%member, error = %e, "landing join skipped: adopting the landing channel failed"); + return false; + } + }; + match side_effects::join_channel_as_self(tenant, state, landing, pubkey).await { + Ok(SelfJoinOutcome::Joined) => { + metrics::counter!("buzz_landing_joins_total").increment(1); + info!(channel = %landing, %member, "joined the landing channel"); + true + } + Ok(SelfJoinOutcome::AlreadyMember) => true, + Err(e) if side_effects::is_self_join_refusal(&e) => { + metrics::counter!("buzz_landing_join_refused_total").increment(1); + info!(channel = %landing, %member, error = %e, "landing join refused"); + true + } + Err(e) => { + metrics::counter!("buzz_landing_join_failures_total").increment(1); + warn!(channel = %landing, %member, error = %e, "landing join failed; default channels wait for the next admission"); + false + } + } +} + +/// The joins after a person's WebSocket admission, in both community modes. +/// Agents are not joined, so the caller does not spawn this for one. +/// +/// 1. Make sure the community has a landing channel. +/// 2. A person with no entry evidence +/// ([`buzz_db::Db::has_entered_community`]) is joined to it. In roster +/// mode the evidence counts from their roster row's `created_at`, so a +/// re-admission after kind:9031 is a first entry again; without a roster +/// any history counts. +/// 3. In roster mode, the admission then finishes the durable +/// default-channel work its roster write left pending, if any +/// ([`side_effects::auto_join_default_channels`]). Without a roster, the +/// default channels they have never been in follow on every admission +/// ([`side_effects::join_never_joined_channels`]). +/// +/// Every write holds one of the socket's effect permits, so nothing is +/// joined after the session ends. A leave that commits first stands: +/// `put_first_member_since` writes nothing over a row removed since +/// admission. Failures are logged and counted. +pub(crate) async fn admission_joins_on_auth( + tenant: &TenantContext, + state: &Arc, + pubkey: &[u8], + gate: &Arc, +) { + admission_joins(tenant, state, pubkey, gate).await; + #[cfg(test)] + crate::nip_fi_test_hooks::after_auto_join(tenant.community()).await; +} + +async fn admission_joins( + tenant: &TenantContext, + state: &Arc, + pubkey: &[u8], + gate: &Arc, +) { + let member = hex::encode(pubkey); + let landing = match ensure_landing_channel(tenant, state).await { + Ok(landing) => landing, + Err(e) => { + metrics::counter!("buzz_landing_join_failures_total").increment(1); + warn!(%member, error = %e, "admission joins skipped: adopting the landing channel failed"); + return; + } + }; + let roster = state.config.require_relay_membership; + let admitted_at = if roster { + match state.db.get_relay_member(tenant.community(), &member).await { + Ok(Some(row)) => Some(row.created_at), + // Removed from the roster since admission: the session is + // being closed, so it joins nothing. + Ok(None) => return, + Err(e) => { + metrics::counter!("buzz_landing_join_failures_total").increment(1); + warn!(%member, error = %e, "admission joins skipped: reading the roster row failed"); + return; + } + } + } else { + None + }; + let entered = match state + .db + .has_entered_community(tenant.community(), pubkey, admitted_at) + .await + { + Ok(entered) => entered, + Err(e) => { + metrics::counter!("buzz_landing_join_failures_total").increment(1); + warn!(%member, error = %e, "admission joins skipped: reading entry evidence failed"); + return; + } + }; + if !entered { + if let Some(landing) = landing { + #[cfg(test)] + crate::nip_fi_test_hooks::before_auto_join_channel(tenant.community()).await; + let Ok(_permit) = gate.acquire_effect().await else { + info!(channel = %landing, %member, "landing join stopped: the session ended"); + return; + }; + match state + .db + .put_first_member_since(tenant.community(), landing, pubkey, admitted_at) + .await + { + Ok(None) => {} + Ok(Some(_)) => { + metrics::counter!("buzz_landing_joins_total").increment(1); + info!(channel = %landing, %member, "joined the landing channel on admission"); + if let Err(e) = + side_effects::announce_self_join(tenant, state, landing, pubkey).await + { + warn!(channel = %landing, %member, error = %e, "landing join announcement failed"); + } + } + Err( + e @ (buzz_db::DbError::AccessDenied(_) | buzz_db::DbError::ChannelNotFound(_)), + ) => { + metrics::counter!("buzz_landing_join_refused_total").increment(1); + info!(channel = %landing, %member, error = %e, "landing join refused"); + } + Err(e) => { + metrics::counter!("buzz_landing_join_failures_total").increment(1); + warn!(channel = %landing, %member, error = %e, "landing join on admission failed; default channels wait for the next admission"); + return; + } + } + } + } + if roster { + side_effects::auto_join_default_channels(tenant, state, &member, Some(gate)).await; + } else { + side_effects::join_never_joined_channels(tenant, state, pubkey, gate).await; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn edit(tags: &[&[&str]]) -> Event { + let tags = tags + .iter() + .map(|tag| Tag::parse(tag.iter().copied()).expect("tag")); + EventBuilder::new(Kind::Custom(9002), "") + .tags(tags) + .sign_with_keys(&Keys::generate()) + .expect("sign") + } + + /// The `landing`/`welcome` rules of §3.3, with the exact receipt text a + /// client shows. + #[test] + fn landing_and_welcome_tag_rules() { + let long = "a".repeat(buzz_db::landing::WELCOME_MAX_CHARS + 1); + let cases: &[(&[&[&str]], Result)] = &[ + (&[&["h", "x"], &["name", "n"]], Ok(false)), + (&[&["landing", "true"]], Ok(true)), + (&[&["landing", "true"], &["welcome", ""]], Ok(true)), + ( + &[&["landing", "true"], &["welcome", "Hi!\nWelcome."]], + Ok(true), + ), + (&[&["welcome", "Hi"]], Ok(true)), + ( + &[&["landing", "false"]], + Err("landing cannot be turned off; make another channel the landing channel"), + ), + ( + &[&["landing", "yes"]], + Err("invalid landing value: yes (must be \"true\")"), + ), + (&[&["landing"]], Err("landing tag must have a value")), + ( + &[&["welcome"]], + Err("welcome tag must have a value (text, or empty to restore the default)"), + ), + ( + &[&["landing", "true"], &["landing", "true"]], + Err("kind:9002 may include at most one landing and one welcome tag"), + ), + ( + &[&["welcome", "a"], &["welcome", "b"]], + Err("kind:9002 may include at most one landing and one welcome tag"), + ), + ( + &[&["welcome", " "]], + Err(buzz_db::landing::welcome_message_error(" ").expect("blank refused")), + ), + ( + &[&["welcome", long.as_str()]], + Err(buzz_db::landing::welcome_message_error(&long).expect("long refused")), + ), + ( + &[&["welcome", "tab\there"]], + Err(buzz_db::landing::welcome_message_error("tab\there").expect("control refused")), + ), + ]; + for (tags, expected) in cases { + let got = validate_tags(&edit(tags)).map_err(|e| e.to_string()); + assert_eq!(got, expected.map_err(str::to_string), "tags {tags:?}"); + } + } +} diff --git a/crates/buzz-relay/src/handlers/landing_postgres_tests.rs b/crates/buzz-relay/src/handlers/landing_postgres_tests.rs new file mode 100644 index 00000000000..42a0bf08839 --- /dev/null +++ b/crates/buzz-relay/src/handlers/landing_postgres_tests.rs @@ -0,0 +1,318 @@ +//! Postgres-backed tests for the landing channel: the kind:9002 `landing` and +//! `welcome` tags with their exact receipts, the kind:39000 projection, the +//! protections, the NIP-11 advertisement and provisioning's adoption. + +use std::sync::Arc; + +use buzz_core::TenantContext; +use nostr::{EventBuilder, Keys, Kind, Tag}; +use uuid::Uuid; + +use crate::handlers::group_state_postgres_tests::{ + community, create_channel, edit_channel, group_state_tags, http_auth, state, +}; +use crate::handlers::ingest::ingest_event; +use crate::state::AppState; + +const PROTECTED: &str = + "invalid: this is the community's landing channel; make another channel the landing channel first"; + +async fn relay_member(state: &AppState, tenant: &TenantContext, role: &str) -> Keys { + let keys = Keys::generate(); + state + .db + .add_relay_member( + tenant.community(), + &keys.public_key().to_hex(), + role, + None, + &Keys::generate(), + ) + .await + .map(|write| write.outcome) + .expect("seed relay member"); + keys +} + +/// The landing projection on `channel_id`'s kind:39000: whether it carries +/// `["landing"]`, and its `welcome` text. +async fn projection( + state: &AppState, + tenant: &TenantContext, + channel_id: Uuid, +) -> (bool, Option) { + let tags = group_state_tags(state, tenant, channel_id, 39000).await; + let landing = tags.iter().any(|tag| tag.len() == 1 && tag[0] == "landing"); + let welcome = tags + .iter() + .find(|tag| tag.first().map(String::as_str) == Some("welcome")) + .map(|tag| tag.get(1).cloned().unwrap_or_default()); + (landing, welcome) +} + +/// NIP-11 `landing_channel` for the tenant's host. +async fn advertised(state: &AppState, tenant: &TenantContext) -> Option { + match crate::nip11::nip11_document(state, tenant.host()).await { + Ok(info) => info.landing_channel, + Err(response) => panic!("NIP-11 refused: {}", response.status()), + } +} + +/// Only a relay owner or admin may send `landing` or `welcome`, only on a +/// stream, with valid values; every refusal carries the contract's exact +/// receipt text and designates nothing. +/// +/// Mutations: drop `landing`/`welcome` from the relay-role check → the +/// channel owner's edits are accepted (RED); drop the stream check → the +/// forum is designated (RED). +#[tokio::test] +#[ignore = "requires Postgres"] +async fn landing_tags_need_a_relay_owner_or_admin_and_a_stream() { + let state = state().await; + let tenant = community(&state, "landing-authz").await; + let owner = Keys::generate(); + let admin = relay_member(&state, &tenant, "admin").await; + let member = relay_member(&state, &tenant, "member").await; + let stream = create_channel(&state, &tenant, &owner, &[]).await; + let forum = create_channel(&state, &tenant, &owner, &[&["channel_type", "forum"]]).await; + + let not_authorized = + "invalid: actor not authorized: landing and welcome require a relay owner or admin"; + let cases: &[(&Keys, Uuid, &[&[&str]], &str)] = &[ + (&owner, stream, &[&["landing", "true"]], not_authorized), + (&owner, stream, &[&["welcome", "Hi"]], not_authorized), + ( + &member, + stream, + &[&["landing", "true"], &["welcome", ""]], + not_authorized, + ), + ( + &admin, + forum, + &[&["landing", "true"]], + "invalid: landing is only available on stream channels", + ), + ( + &admin, + stream, + &[&["landing", "false"]], + "invalid: landing cannot be turned off; make another channel the landing channel", + ), + ( + &admin, + stream, + &[&["landing", "1"]], + "invalid: invalid landing value: 1 (must be \"true\")", + ), + ( + &admin, + stream, + &[&["welcome", " "]], + "invalid: welcome message must not be blank", + ), + ( + &admin, + stream, + &[&["landing", "true"], &["welcome", "a\u{7}b"]], + "invalid: welcome message must be plain text (line breaks only)", + ), + ( + &admin, + stream, + &[&["welcome", "Hi"]], + "invalid: welcome can only be set on the landing channel", + ), + ]; + for (keys, channel, tags, expected) in cases { + assert_eq!( + edit_channel(&state, &tenant, keys, *channel, tags) + .await + .expect_err("refused"), + *expected, + "tags {tags:?}" + ); + } + assert_eq!( + state + .db + .get_community_landing(tenant.community()) + .await + .expect("landing"), + buzz_db::landing::CommunityLanding::default(), + "nothing is designated" + ); + assert_eq!(advertised(&state, &tenant).await, None); +} + +/// The client's full-state form designates a channel, is idempotent on the +/// current landing channel, and moves the designation. kind:39000 carries +/// `["landing"]` and the custom `welcome` on the landing channel only, the +/// previous landing channel is re-signed without them, and NIP-11 follows. +/// While designated, archive, private, TTL and kind:9008 delete are refused. +/// +/// Mutations: drop the previous-landing re-emission → `a` keeps its tags +/// (RED); emit the tags for every channel → `b` carries them early (RED); +/// drop the 9008 pre-check → the delete is accepted (RED). +#[tokio::test] +#[ignore = "requires Postgres"] +async fn landing_designation_is_projected_advertised_and_protected() { + let state = state().await; + let tenant = community(&state, "landing-move").await; + let owner = Keys::generate(); + let admin = relay_member(&state, &tenant, "admin").await; + let a = create_channel(&state, &tenant, &owner, &[]).await; + let b = create_channel(&state, &tenant, &owner, &[]).await; + let id = |channel: Uuid| Some(channel.hyphenated().to_string()); + + edit_channel( + &state, + &tenant, + &admin, + a, + &[&["landing", "true"], &["welcome", "Hi all!\nSay hello."]], + ) + .await + .expect("designate a"); + assert_eq!( + projection(&state, &tenant, a).await, + (true, Some("Hi all!\nSay hello.".into())) + ); + assert_eq!(projection(&state, &tenant, b).await, (false, None)); + assert_eq!(advertised(&state, &tenant).await, id(a)); + + edit_channel( + &state, + &tenant, + &admin, + a, + &[&["landing", "true"], &["welcome", ""]], + ) + .await + .expect("the full-state form on the landing channel"); + assert_eq!( + projection(&state, &tenant, a).await, + (true, None), + "an empty welcome restores the default" + ); + assert_eq!(advertised(&state, &tenant).await, id(a)); + + edit_channel( + &state, + &tenant, + &admin, + b, + &[&["landing", "true"], &["welcome", "Moved"]], + ) + .await + .expect("move to b"); + assert_eq!( + projection(&state, &tenant, b).await, + (true, Some("Moved".into())) + ); + assert_eq!( + projection(&state, &tenant, a).await, + (false, None), + "the previous landing channel is re-signed without its tags" + ); + assert_eq!(advertised(&state, &tenant).await, id(b)); + + let protected: [&[&[&str]]; 3] = [ + &[&["archived", "true"]], + &[&["visibility", "private"]], + &[&["ttl", "3600"]], + ]; + for tags in protected { + assert_eq!( + edit_channel(&state, &tenant, &owner, b, tags) + .await + .expect_err("protected"), + PROTECTED, + "tags {tags:?}" + ); + } + let delete = EventBuilder::new(Kind::Custom(9008), "") + .tags([Tag::parse(["h", &b.to_string()]).expect("h tag")]) + .sign_with_keys(&owner) + .expect("sign 9008"); + let result = ingest_event(&state, &tenant, delete, http_auth(&owner)) + .await + .expect("ingest 9008"); + assert!(!result.accepted, "the landing channel is not deleted"); + assert_eq!(result.message, PROTECTED); + let after = state + .db + .get_channel(tenant.community(), b) + .await + .expect("b"); + assert!(after.deleted_at.is_none() && after.archived_at.is_none()); + assert_eq!(after.visibility, "open"); + + // The previous landing channel lost its protections. + edit_channel(&state, &tenant, &owner, a, &[&["archived", "true"]]) + .await + .expect("archive the previous landing channel"); +} + +/// Operator provisioning of a community with an owner creates its landing +/// `general`, owned by that owner, and NIP-11 advertises it. +/// +/// Mutation: drop the `converge_landing_channel` call in the `create_only` +/// branch → no designation (RED). +#[tokio::test] +#[ignore = "requires Postgres"] +async fn provisioning_creates_the_landing_general() { + let operator = Keys::generate(); + let mut base = (*state().await).clone(); + let mut config = (*base.config).clone(); + config.relay_operator_pubkeys = vec![operator.public_key().to_hex()]; + base.config = Arc::new(config); + let state = Arc::new(base); + let owner = Keys::generate(); + let host = format!("landing-provision-{}.test", Uuid::new_v4().simple()); + + let response = crate::handlers::community_provisioning::provision_community( + &state, + &operator.public_key(), + crate::handlers::community_provisioning::ProvisionCommunityRequest { + host: host.clone(), + initial_owner_pubkey: Some(owner.public_key().to_hex()), + create_only: true, + initial_requires_nip_fi: None, + }, + ) + .await + .expect("provision"); + let community = + buzz_core::CommunityId::from_uuid(response.community_id.parse().expect("community id")); + let tenant = TenantContext::resolved(community, &host); + + let landing = state + .db + .get_community_landing(community) + .await + .expect("landing") + .channel_id + .expect("provisioning designates a landing channel"); + let general = state + .db + .get_channel(community, landing) + .await + .expect("general"); + assert_eq!(general.name, "general"); + assert_eq!(general.created_by, owner.public_key().to_bytes().to_vec()); + assert_eq!( + state + .db + .get_member_role(community, landing, &owner.public_key().to_bytes()) + .await + .expect("owner role") + .as_deref(), + Some("owner") + ); + assert_eq!(projection(&state, &tenant, landing).await, (true, None)); + assert_eq!( + advertised(&state, &tenant).await, + Some(landing.hyphenated().to_string()) + ); +} diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index 3a0216ecdd3..49f6d5051f6 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -34,6 +34,11 @@ pub mod identity_archive; pub mod imeta; /// Transport-neutral event ingestion pipeline. pub mod ingest; +/// The community landing channel: adoption, admission joins and kind:9002 +/// `landing`/`welcome` validation. +pub mod landing; +#[cfg(test)] +mod landing_postgres_tests; /// Local media publication authorization. pub mod media_access; #[cfg(test)] diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 53bbbf10574..4353b512691 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -726,7 +726,7 @@ pub async fn validate_admin_event( .any(|t| is_metadata_tag(&t.kind().to_string())); if !has_recognized { return Err(anyhow::anyhow!( - "kind:9002 must include at least one metadata tag (name, about, archived, topic, purpose, visibility, ttl, t, join_role, unlisted, auto_join)" + "kind:9002 must include at least one metadata tag (name, about, archived, topic, purpose, visibility, ttl, t, join_role, unlisted, auto_join, landing, welcome)" )); } @@ -825,21 +825,35 @@ pub async fn validate_admin_event( } } } - if sets_auto_join { + // `landing` and `welcome` are community settings too, with the + // same relay-role authority. Whether the channel may be the + // landing channel, and whether `welcome` targets it, is decided + // in the write, under the channel row lock. + let sets_landing = super::landing::validate_tags(event)?; + if sets_auto_join || sets_landing { let relay_role = state .db .get_relay_member(tenant.community(), &event.pubkey.to_hex()) .await? .map(|m| m.role); if !matches!(relay_role.as_deref(), Some("owner" | "admin")) { - return Err(anyhow::anyhow!( + return Err(anyhow::anyhow!(if sets_landing { + super::landing::NOT_AUTHORIZED + } else { "actor not authorized: auto_join requires a relay owner or admin" - )); + })); } if channel.channel_type != buzz_db::channel::ChannelType::Stream.as_str() { - return Err(anyhow::anyhow!( - "auto_join is only available on stream channels" - )); + if sets_auto_join { + return Err(anyhow::anyhow!( + "auto_join is only available on stream channels" + )); + } + if event.tags.iter().any(|t| t.kind().to_string() == "landing") { + return Err(anyhow::anyhow!( + "landing is only available on stream channels" + )); + } } } @@ -875,8 +889,8 @@ pub async fn validate_admin_event( // name/about/archived/visibility/ttl/labels/join_role/unlisted // require owner/admin; topic/purpose allow any member who may - // post; auto_join was decided above by relay role, so an event - // carrying only that tag needs neither. + // post; auto_join, landing and welcome were decided above by + // relay role, so an event carrying only those needs neither. let has_member_tag = event .tags .iter() @@ -930,7 +944,7 @@ pub async fn validate_admin_event( } else { Err(anyhow::anyhow!("only members can change topic or purpose")) } - } else if sets_auto_join { + } else if sets_auto_join || sets_landing { Ok(()) } else { // Every recognized tag must be authorized by one of the arms @@ -1002,20 +1016,27 @@ pub async fn validate_admin_event( // DELETE_GROUP: owner only, or the owning human of the channel's agent-owner. let members = state.db.get_members(tenant.community(), channel_id).await?; let actor_member = members.iter().find(|m| m.pubkey == actor_bytes); - match actor_member { - Some(m) if m.role == "owner" => Ok(()), + let authorized = match actor_member { + Some(m) if m.role == "owner" => true, + // Allow the owning human of any active owner-role agent in the + // channel, even when the human is not a channel member — + // diverges from kind:9001 intentionally. _ => { - // Allow the owning human of any active owner-role agent in the - // channel, even when the human is not a channel member — - // diverges from kind:9001 intentionally. - if actor_owns_any_owner_agent(state, tenant.community(), &members, &actor_bytes) + actor_owns_any_owner_agent(state, tenant.community(), &members, &actor_bytes) .await? - { - return Ok(()); - } - Err(anyhow::anyhow!("only owner can delete group")) } + }; + if !authorized { + return Err(anyhow::anyhow!("only owner can delete group")); } + // The delete runs after the event is stored, where a refusal is + // only logged, so refuse the landing channel here. The store + // refuses it again under the channel lock (`soft_delete_on`). + let landing = state.db.get_community_landing(tenant.community()).await?; + if landing.channel_id == Some(channel_id) { + return Err(anyhow::anyhow!(buzz_db::landing::LANDING_PROTECTED)); + } + Ok(()) } 9022 => { // LEAVE_REQUEST: must be an active member, and cannot be the last owner. @@ -1473,7 +1494,7 @@ const MEMBER_METADATA_TAGS: &[&str] = &["topic", "purpose"]; /// kind:9002 tags that a relay owner or admin may change, whatever their /// channel role. -const RELAY_ROLE_METADATA_TAGS: &[&str] = &["auto_join"]; +const RELAY_ROLE_METADATA_TAGS: &[&str] = &["auto_join", "landing", "welcome"]; /// True when `kind` is a kind:9002 tag that changes channel metadata. fn is_metadata_tag(kind: &str) -> bool { @@ -1684,6 +1705,10 @@ pub async fn emit_group_discovery_events( state: &Arc, channel_id: Uuid, ) -> anyhow::Result<()> { + // Read before the replacement lock, so a designation committed before + // this emission began is reflected; a move re-emits the previous channel. + let landing = state.db.get_community_landing(tenant.community()).await?; + let relay_pubkey_hex = hex::encode(state.relay_keypair.public_key().to_bytes()); let group_id = channel_id.to_string(); @@ -1694,7 +1719,9 @@ pub async fn emit_group_discovery_events( KIND_NIP29_GROUP_METADATA, &relay_pubkey_hex, |channel, members| { - Ok(buzz_db::channel::group_metadata_tags(channel, members) + let mut tags = buzz_db::channel::group_metadata_tags(channel, members); + tags.extend(landing.group_metadata_tags(channel_id)); + Ok(tags .into_iter() .map(Tag::parse) .collect::, _>>()?) @@ -2389,6 +2416,16 @@ pub(crate) async fn persist_edit_metadata( _ => return Err(anyhow::anyhow!("invalid unlisted value: {val}")), }); } + "landing" => { + // Validated during authorization; only "true" is a value. + if val != "true" { + return Err(anyhow::anyhow!("invalid landing value: {val}")); + } + edit.landing = true; + } + // Validated during authorization; the empty string restores the + // default. + "welcome" => edit.welcome = Some((!val.is_empty()).then(|| val.to_string())), _ => {} } } @@ -2507,6 +2544,14 @@ pub(crate) async fn persist_edit_metadata( if let Err(e) = emit_group_discovery_events(tenant, state, channel_id).await { warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed"); } + // The previous landing channel's kind:39000 drops its `landing` and + // `welcome` tags. Clients read the designation from NIP-11, so a failed + // re-emission leaves only a stale projection. + if let Some(previous) = write.previous_landing { + if let Err(e) = emit_group_discovery_events(tenant, state, previous).await { + warn!(channel = %previous, error = %e, "NIP-29 discovery re-emission for the previous landing channel failed"); + } + } Ok((write.stored, true)) } @@ -2937,6 +2982,23 @@ async fn handle_delete_group( Ok(()) } +/// A self-join the channel refuses: it is missing, private or archived. +/// Displays as the refusal text alone. +#[derive(Debug, thiserror::Error)] +#[error("{0}")] +pub(crate) struct SelfJoinRefused(&'static str); + +/// True when `error` is a self-join the channel's state or the person's +/// standing refuses (a ban, a private, archived or missing channel), as +/// opposed to a failure that a retry could get past. +pub(crate) fn is_self_join_refusal(error: &anyhow::Error) -> bool { + error.is::() + || matches!( + error.downcast_ref::(), + Some(buzz_db::DbError::AccessDenied(_) | buzz_db::DbError::ChannelNotFound(_)) + ) +} + /// What [`join_channel_as_self`] did for the member. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum SelfJoinOutcome { @@ -2961,14 +3023,17 @@ pub(crate) async fn join_channel_as_self( .db .get_channel_for_event_write(tenant.community(), channel_id) .await - .map_err(|_| anyhow::anyhow!("channel not found"))?; + .map_err(|e| match e { + buzz_db::DbError::ChannelNotFound(_) | buzz_db::DbError::NotFound(_) => { + anyhow::Error::new(SelfJoinRefused("channel not found")) + } + other => anyhow::Error::new(other).context("channel not found"), + })?; if channel.visibility != "open" { - return Err(anyhow::anyhow!( - "channel is private — request an invitation" - )); + return Err(SelfJoinRefused("channel is private — request an invitation").into()); } if channel.archived_at.is_some() { - return Err(anyhow::anyhow!("channel is archived")); + return Err(SelfJoinRefused("channel is archived").into()); } // Skip if already an active member — prevents duplicate join notifications. @@ -2997,7 +3062,7 @@ pub(crate) async fn join_channel_as_self( /// The side effects of a committed self-join: the membership cache, the /// `member_joined` kind:40099, kind:39000/39002 and kind:44100. Only the /// system message is fatal, as for kind:9021. -async fn announce_self_join( +pub(crate) async fn announce_self_join( tenant: &TenantContext, state: &Arc, channel_id: Uuid, @@ -3039,9 +3104,27 @@ async fn announce_self_join( Ok(()) } -/// Process the durable default-channel work of this roster admission. +/// Process the durable default-channel work of this roster admission: the +/// landing channel first, then every effective `auto_join` channel, as +/// kind:9021 self-joins. Runs at the roster write sites (invite claim, +/// kind:9030) and again on each admission while the work is pending, so a +/// later kind:9022 leave stands. A community without a roster joins on +/// admission instead ([`super::landing::admission_joins_on_auth`]). +/// /// Failed reads or writes retain the token for a later claim or admission. -/// Leaves made during this admission are preserved. +/// Leaves made during this admission are preserved. A channel archived or +/// made private after the list was read is complete for this admission: +/// `put_member` refuses it under its locks. A landing join that fails for +/// a reason other than a refusal retains the token before the default +/// channels: the person then has no entry evidence, so their next AUTH +/// joins both. +/// +/// Agents, by their stored owner link, are not joined to the landing +/// channel; the default channels apply to them as before. +/// +/// The persisted actor of a third-party add (kind:9030) is asked of the +/// member's `channel_add_policy`, once, as a kind:9000 from the same actor +/// would; a refusal completes the work without joining any channel. pub(crate) async fn auto_join_default_channels( tenant: &TenantContext, state: &Arc, @@ -3081,6 +3164,12 @@ async fn finish_default_channel_joins( return Ok(()); }; let pubkey = hex::decode(pubkey_hex)?; + // One read of the member's user row serves the policy gate and the + // agent check. + let user = state + .db + .get_agent_channel_policy(tenant.community(), &pubkey) + .await?; // Use the persisted original actor even when a retry comes from the member. let actor = admission .added_by @@ -3089,13 +3178,9 @@ async fn finish_default_channel_joins( .map(hex::decode) .transpose()?; if let Some(actor) = actor.as_deref().filter(|actor| *actor != pubkey.as_slice()) { - if let Some((policy, owner)) = state - .db - .get_agent_channel_policy(tenant.community(), &pubkey) - .await? - { + if let Some((policy, owner)) = &user { if let Err(denied) = - channel_authz::decide_channel_add_policy(&policy, owner.as_deref(), actor) + channel_authz::decide_channel_add_policy(policy, owner.as_deref(), actor) { info!(member = %pubkey_hex, error = %denied, "default-channel add refused by member policy"); state @@ -3106,6 +3191,19 @@ async fn finish_default_channel_joins( } } } + let is_agent = matches!(&user, Some((_, Some(_)))); + if !is_agent { + let _permit = match gate { + Some(gate) => match gate.acquire_effect().await { + Ok(permit) => Some(permit), + Err(_) => return Ok(()), // Pending work belongs to the next admission. + }, + None => None, + }; + if !super::landing::join_landing_on_roster_admission(tenant, state, &pubkey).await { + anyhow::bail!("landing join failed; default channels wait for the next admission"); + } + } let channels = state.db.list_auto_join_channels(tenant.community()).await?; let mut failure = None; for channel in channels { @@ -3170,7 +3268,8 @@ async fn finish_default_channel_join( /// (`require_relay_membership = false`) to every effective `auto_join` /// channel they have never been a member of, as kind:9021 self-joins. /// -/// Runs after every human WebSocket admission there, because such a +/// Runs after every human WebSocket admission there, after the landing join +/// (both in [`super::landing::admission_joins_on_auth`]), because such a /// community has no roster insert to run [`auto_join_default_channels`] at. /// "Never a member" is the channel's own membership history: a kind:9022 /// leave or a kind:9001 removal keeps a removed row, so the person is not @@ -3183,18 +3282,7 @@ async fn finish_default_channel_join( /// task stops at the first refusal. A channel it stops before, or whose /// membership write fails, has no row, so the next admission tries it again. /// Failures are logged and counted (`buzz_auto_join_failures_total`). -pub(crate) async fn auto_join_never_joined_channels( - tenant: &TenantContext, - state: &Arc, - pubkey: &[u8], - gate: &Arc, -) { - join_never_joined_channels(tenant, state, pubkey, gate).await; - #[cfg(test)] - crate::nip_fi_test_hooks::after_auto_join(tenant.community()).await; -} - -async fn join_never_joined_channels( +pub(crate) async fn join_never_joined_channels( tenant: &TenantContext, state: &Arc, pubkey: &[u8], diff --git a/crates/buzz-relay/src/main.rs b/crates/buzz-relay/src/main.rs index 532207a3dbc..cbff82876bf 100644 --- a/crates/buzz-relay/src/main.rs +++ b/crates/buzz-relay/src/main.rs @@ -767,6 +767,15 @@ async fn run_relay_main(boot: BootTracker) -> anyhow::Result<()> { startup_membership_list, ) .await; + // Converge the deployment community to a landing `general` at boot, + // owned by its bootstrap owner, so a fresh local relay has one before + // anyone connects. Off the boot path: a failure leaves the NULL + // designation for the next human admission to retry. + let tenant = tenant.clone(); + let landing_state = Arc::clone(&state); + tokio::spawn(async move { + buzz_relay::handlers::landing::converge_landing_channel(&tenant, &landing_state).await; + }); } let startup_partition_attempt = startup_partition_audit .as_ref() diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index 6c1eee3aba1..b8330b92045 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -34,6 +34,11 @@ pub struct RelayInfo { /// admins/owners via the kind:9033 command. Omitted when no icon is set. #[serde(skip_serializing_if = "Option::is_none")] pub icon: Option, + /// The community's landing channel (lowercase hyphenated UUID), read + /// live from the committed designation. Omitted when none is designated. + /// The `buzz-landing-channel` extension advertises the contract. + #[serde(skip_serializing_if = "Option::is_none")] + pub landing_channel: Option, /// Host-bound atomic read-state snapshot capability; absent on unresolved hosts. #[serde(skip_serializing_if = "Option::is_none")] pub read_state_snapshot: Option, @@ -211,7 +216,7 @@ impl RelayInfo { /// `self`. Pass `Some` whenever the relay has a stable signing key. /// /// `icon` is the community's workspace icon (see - /// [`workspace_icon_for_tenant`]) — a host-scoped scalar, pre-fetched by + /// [`community_discovery_for_tenant`]) — a host-scoped scalar, pre-fetched by /// the caller so `build` itself stays static-input. /// /// `advertise_nip43` controls whether NIP-43 (relay membership) is added @@ -262,6 +267,9 @@ impl RelayInfo { // without it ignores the tag beside `visibility`, so a client // checks this before opening a channel as unlisted. "buzz-unlisted-channels".to_string(), + // NIP-11 `landing_channel`, kind:9002 `landing`/`welcome` and the + // landing kind:39000 tags (NOSTR.md, Landing channel). + "buzz-landing-channel".to_string(), ]; let gif = gif_provider.map(|provider| { supported_extensions.push("buzz-gif".to_string()); @@ -290,6 +298,7 @@ impl RelayInfo { name: "Buzz Relay".to_string(), description: "Buzz — private team communication relay".to_string(), icon: icon.filter(|s| !s.is_empty()).map(|s| s.to_string()), + landing_channel: None, read_state_snapshot: None, membership_snapshot: None, membership_public: None, @@ -453,15 +462,15 @@ pub(crate) async fn nip11_document( } let (relay_self, advertise_nip43) = nip11_facts(state); let tenant = policy.tenant(raw_host); - let icon = match tenant.as_ref() { - Some(tenant) => workspace_icon_for_tenant(state, tenant).await, - None => None, + let discovery = match tenant.as_ref() { + Some(tenant) => community_discovery_for_tenant(state, tenant).await, + None => Default::default(), }; let admin_api = admin_api_advertisement(state.config.admin.as_ref()); let advertise_fi = state.config.nip_fi.mode.evaluates() || policy.required; let mut info = RelayInfo::build( relay_self.as_deref(), - icon.as_deref(), + discovery.icon.as_deref(), RelayCapabilityFlags { advertise_nip43, advertise_fi, @@ -472,6 +481,9 @@ pub(crate) async fn nip11_document( state.config.klipy.as_ref().map(|_| "klipy"), ); info.requires_nip_fi = policy.required; + info.landing_channel = discovery + .landing_channel_id + .map(|id| id.hyphenated().to_string()); info.artifacts["incremental"] = crate::api::artifact_sync::capability(); if let Some(tenant) = tenant.as_ref() { if let Some(profile) = crate::verified_profiles::discovery(state, tenant).await { @@ -537,18 +549,17 @@ pub(crate) async fn nip11_document( Ok(info) } -/// Fetch the icon for the same immutable tenant that supplied NIP-11 policy. -/// A decoration lookup failure omits the icon. -async fn workspace_icon_for_tenant( +/// Fetch the icon and landing channel for the same immutable tenant that +/// supplied NIP-11 policy. A decoration lookup failure omits both. +async fn community_discovery_for_tenant( state: &crate::state::AppState, tenant: &buzz_core::TenantContext, -) -> Option { +) -> buzz_db::community::CommunityDiscovery { state .db - .get_community_icon(tenant.community()) + .get_community_discovery(tenant.community()) .await - .ok() - .flatten() + .unwrap_or_default() } /// Derives the two NIP-11 facts that depend on runtime config: @@ -783,10 +794,12 @@ mod tests { } /// Clients refuse NIP-CL labels and system channels, newest-first - /// search, and unlisted channels unless the relay says it supports them, - /// so every document must advertise all three. + /// search, unlisted channels and the landing channel unless the relay + /// says it supports them, so every document must advertise all four. + /// No `landing_channel` is serialized until one is designated: never + /// `null`, never `""`. #[test] - fn advertises_nip_cl_search_sort_and_unlisted_extensions() { + fn advertises_nip_cl_search_sort_unlisted_and_landing_extensions() { let info = RelayInfo::build( None, None, @@ -800,6 +813,23 @@ mod tests { assert!(extensions.contains(&"nip-cl".to_string())); assert!(extensions.contains(&"buzz-search-sort".to_string())); assert!(extensions.contains(&"buzz-unlisted-channels".to_string())); + assert!(extensions.contains(&"buzz-landing-channel".to_string())); + + let mut info = RelayInfo::build( + None, + None, + RelayCapabilityFlags::default(), + DEFAULT_MAX_FRAME_BYTES, + None, + None, + None, + ); + let json = serde_json::to_value(&info).expect("serialize"); + assert!(json.get("landing_channel").is_none(), "omitted, not null"); + let landing = uuid::Uuid::new_v4(); + info.landing_channel = Some(landing.hyphenated().to_string()); + let json = serde_json::to_value(&info).expect("serialize"); + assert_eq!(json["landing_channel"], landing.to_string()); } /// NIP-WP → NIP-11 mirror: a set workspace icon is served in the standard diff --git a/crates/buzz-test-client/tests/e2e_nostr_interop.rs b/crates/buzz-test-client/tests/e2e_nostr_interop.rs index 4aad0365dc8..2b322e13119 100644 --- a/crates/buzz-test-client/tests/e2e_nostr_interop.rs +++ b/crates/buzz-test-client/tests/e2e_nostr_interop.rs @@ -792,6 +792,8 @@ async fn test_dm_discovery_events_emitted() { .await .expect("membership EOSE"); + // The relay may also enroll a fresh key into the landing channel; skip its + // notice by requiring the DM's `h` tag. let membership = membership_events .iter() .find(|e| { @@ -800,6 +802,10 @@ async fn test_dm_discovery_events_emitted() { let p = t.as_slice(); p.len() >= 2 && p[0] == "p" && p[1] == a_pubkey_hex }) + && e.tags.iter().any(|t| { + let p = t.as_slice(); + p.len() >= 2 && p[0] == "h" && p[1] == channel_id + }) }) .expect("kind:44100 membership notification addressed to A"); diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 6a214860a73..9bcc2fc34ac 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -413,6 +413,156 @@ async fn test_invite_claim_auto_joins_default_channel() { ); } +/// NIP-11 `landing_channel` of the shared community, if designated. +async fn advertised_landing_channel() -> (Option, serde_json::Value) { + let info: serde_json::Value = reqwest::Client::new() + .get(format!("{}/info", relay_http_url())) + .send() + .await + .expect("GET /info") + .json() + .await + .expect("NIP-11 JSON"); + let landing = info + .get("landing_channel") + .and_then(serde_json::Value::as_str) + .map(str::to_string); + (landing, info) +} + +/// Submit the client's full-state landing kind:9002 (`landing` plus +/// `welcome`, `""` for the default) and return the OK frame. +async fn set_landing_ws( + url: &str, + channel_id: &str, + signer: &Keys, + welcome: &str, +) -> (bool, String) { + let event = EventBuilder::new(Kind::Custom(9002), "") + .tags([ + Tag::parse(["h", channel_id]).unwrap(), + Tag::parse(["landing", "true"]).unwrap(), + Tag::parse(["welcome", welcome]).unwrap(), + Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(), + ]) + .sign_with_keys(signer) + .unwrap(); + let mut client = BuzzTestClient::connect(url, signer) + .await + .expect("connect kind:9002 actor"); + let ok = client.send_event(event).await.expect("send kind:9002"); + client.disconnect().await.ok(); + (ok.accepted, ok.message) +} + +/// The landing channel journey on the shared relay. A fresh person's AUTH +/// joins them to the advertised landing channel, whose kind:39000 carries +/// `["landing"]`. A relay owner moves the designation with the client's +/// full-state kind:9002 (twice: it is idempotent); NIP-11 and both +/// channels' kind:39000 follow. The landing channel refuses an archive, a +/// non-admin is refused, and the designation moves back. +#[tokio::test] +#[ignore] +async fn test_landing_channel_journey() { + const PROTECTED: &str = "invalid: this is the community's landing channel; make another channel the landing channel first"; + let url = relay_url(); + let owner = Keys::generate(); + seed_relay_owner(&owner).await; + let has = |tags: &[Vec], name: &str| { + tags.iter() + .any(|t| t.first().map(String::as_str) == Some(name)) + }; + let welcome_of = |tags: &[Vec]| { + tags.iter() + .find(|t| t.first().map(String::as_str) == Some("welcome")) + .and_then(|t| t.get(1).cloned()) + }; + + let person = Keys::generate(); + let person_hex = person.public_key().to_hex(); + let person_client = BuzzTestClient::connect(&url, &person) + .await + .expect("connect person"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + let original = loop { + if let (Some(landing), _) = advertised_landing_channel().await { + if member_role(&url, &owner, &landing, &person_hex) + .await + .as_deref() + == Some("member") + { + break landing; + } + } + assert!( + tokio::time::Instant::now() < deadline, + "the person's AUTH did not join them to an advertised landing channel" + ); + tokio::time::sleep(Duration::from_millis(200)).await; + }; + person_client.disconnect().await.ok(); + let (_, info) = advertised_landing_channel().await; + assert!( + info["supported_extensions"] + .as_array() + .is_some_and(|extensions| extensions.iter().any(|e| e == "buzz-landing-channel")), + "NIP-11 advertises the extension: {info}" + ); + let original_tags = group_metadata_tags(&url, &owner, &original).await; + assert!(has(&original_tags, "landing"), "{original_tags:?}"); + let original_welcome = welcome_of(&original_tags).unwrap_or_default(); + + let target = create_test_channel(&owner).await; + let welcome = "Welcome to the e2e community.\nSay hello."; + for attempt in ["move", "idempotent resend"] { + assert_eq!( + set_landing_ws(&url, &target, &owner, welcome).await, + (true, String::new()), + "{attempt}" + ); + } + assert_eq!( + advertised_landing_channel().await.0.as_deref(), + Some(target.as_str()) + ); + let target_tags = group_metadata_tags(&url, &owner, &target).await; + assert!(has(&target_tags, "landing"), "{target_tags:?}"); + assert_eq!(welcome_of(&target_tags).as_deref(), Some(welcome)); + let previous_tags = group_metadata_tags(&url, &owner, &original).await; + assert!( + !has(&previous_tags, "landing") && !has(&previous_tags, "welcome"), + "the previous landing channel drops both tags: {previous_tags:?}" + ); + + assert_eq!( + set_metadata_ws(&url, &target, &owner, "archived", "true").await, + (false, PROTECTED.to_string()) + ); + assert_eq!( + set_landing_ws(&url, &original, &Keys::generate(), "").await, + ( + false, + "invalid: actor not authorized: landing and welcome require a relay owner or admin" + .to_string() + ) + ); + + // Leave the shared relay as found. + assert_eq!( + set_landing_ws(&url, &original, &owner, &original_welcome).await, + (true, String::new()) + ); + assert_eq!( + advertised_landing_channel().await.0.as_deref(), + Some(original.as_str()) + ); + assert_eq!( + set_metadata_ws(&url, &target, &owner, "archived", "true").await, + (true, String::new()), + "the previous landing channel can be archived" + ); +} + #[tokio::test] #[ignore] async fn test_invite_claim_rejects_invalid_code() { @@ -1925,6 +2075,35 @@ async fn test_membership_notification_kind_rejected() { client.disconnect().await.expect("disconnect"); } +/// Receive the next membership notification whose `h` tag is `channel_id`. +/// The relay may also enroll a fresh key into the landing channel; its notice +/// (a different `h`) is skipped. +async fn recv_membership_notice_for(client: &mut BuzzTestClient, channel_id: &str) -> nostr::Event { + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let remaining = deadline + .checked_duration_since(tokio::time::Instant::now()) + .filter(|d| !d.is_zero()) + .expect("timed out waiting for membership notification for our channel"); + match client + .recv_event(remaining) + .await + .expect("recv membership notification") + { + RelayMessage::Event { event, .. } => { + let for_channel = event.tags.iter().any(|t| { + let s = t.as_slice(); + s.len() >= 2 && s[0] == "h" && s[1] == channel_id + }); + if for_channel { + return *event; + } + } + other => panic!("expected EVENT membership notification, got {other:?}"), + } + } +} + /// When a member is added via REST, the relay must emit a kind:44100 notification /// to any subscriber filtering on `#p` = that member's pubkey. #[tokio::test] @@ -1988,45 +2167,29 @@ async fn test_membership_notification_emitted_on_add() { resp.status() ); - // Wait for the kind:44100 notification. - let msg = agent_client - .recv_event(Duration::from_secs(5)) - .await - .expect("recv kind:44100 notification"); + // Wait for the kind:44100 notification for our channel. + let event = recv_membership_notice_for(&mut agent_client, &channel_id).await; - match msg { - RelayMessage::Event { event, .. } => { - assert_eq!( - event.kind, - Kind::Custom(44100), - "expected kind:44100, got {}", - event.kind.as_u16() - ); - - let tags: Vec> = event - .tags - .iter() - .map(|t| t.as_slice().iter().map(|s| s.to_string()).collect()) - .collect(); + assert_eq!( + event.kind, + Kind::Custom(44100), + "expected kind:44100, got {}", + event.kind.as_u16() + ); - let has_p = tags - .iter() - .any(|t| t.len() >= 2 && t[0] == "p" && t[1] == agent_pubkey_hex); - assert!( - has_p, - "kind:44100 missing p tag = agent pubkey. tags: {tags:?}" - ); + let tags: Vec> = event + .tags + .iter() + .map(|t| t.as_slice().iter().map(|s| s.to_string()).collect()) + .collect(); - let has_h = tags - .iter() - .any(|t| t.len() >= 2 && t[0] == "h" && t[1] == channel_id); - assert!( - has_h, - "kind:44100 missing h tag = channel uuid. tags: {tags:?}" - ); - } - other => panic!("expected EVENT kind:44100, got {other:?}"), - } + let has_p = tags + .iter() + .any(|t| t.len() >= 2 && t[0] == "p" && t[1] == agent_pubkey_hex); + assert!( + has_p, + "kind:44100 missing p tag = agent pubkey. tags: {tags:?}" + ); agent_client.disconnect().await.expect("disconnect"); } @@ -2261,21 +2424,13 @@ async fn test_membership_notification_emitted_on_remove() { ); // Consume the kind:44100 add notification before waiting for the remove. - let add_msg = agent_client - .recv_event(Duration::from_secs(5)) - .await - .expect("recv kind:44100 notification"); - match add_msg { - RelayMessage::Event { ref event, .. } => { - assert_eq!( - event.kind, - Kind::Custom(44100), - "expected kind:44100 add notification, got {}", - event.kind.as_u16() - ); - } - other => panic!("expected EVENT kind:44100, got {other:?}"), - } + let add_event_notice = recv_membership_notice_for(&mut agent_client, &channel_id).await; + assert_eq!( + add_event_notice.kind, + Kind::Custom(44100), + "expected kind:44100 add notification, got {}", + add_event_notice.kind.as_u16() + ); // Remove agent from the channel via signed kind:9001 event. let remove_event = EventBuilder::new(Kind::Custom(9001), "") @@ -2300,44 +2455,27 @@ async fn test_membership_notification_emitted_on_remove() { ); // Wait for the kind:44101 remove notification. - let msg = agent_client - .recv_event(Duration::from_secs(5)) - .await - .expect("recv kind:44101 notification"); - - match msg { - RelayMessage::Event { event, .. } => { - assert_eq!( - event.kind, - Kind::Custom(44101), - "expected kind:44101, got {}", - event.kind.as_u16() - ); - - let tags: Vec> = event - .tags - .iter() - .map(|t| t.as_slice().iter().map(|s| s.to_string()).collect()) - .collect(); + let event = recv_membership_notice_for(&mut agent_client, &channel_id).await; + assert_eq!( + event.kind, + Kind::Custom(44101), + "expected kind:44101, got {}", + event.kind.as_u16() + ); - let has_p = tags - .iter() - .any(|t| t.len() >= 2 && t[0] == "p" && t[1] == agent_pubkey_hex); - assert!( - has_p, - "kind:44101 missing p tag = agent pubkey. tags: {tags:?}" - ); + let tags: Vec> = event + .tags + .iter() + .map(|t| t.as_slice().iter().map(|s| s.to_string()).collect()) + .collect(); - let has_h = tags - .iter() - .any(|t| t.len() >= 2 && t[0] == "h" && t[1] == channel_id); - assert!( - has_h, - "kind:44101 missing h tag = channel uuid. tags: {tags:?}" - ); - } - other => panic!("expected EVENT kind:44101, got {other:?}"), - } + let has_p = tags + .iter() + .any(|t| t.len() >= 2 && t[0] == "p" && t[1] == agent_pubkey_hex); + assert!( + has_p, + "kind:44101 missing p tag = agent pubkey. tags: {tags:?}" + ); agent_client.disconnect().await.expect("disconnect"); } diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md index 7892802939f..5c41ede5714 100644 --- a/docs/nips/NIP-CL.md +++ b/docs/nips/NIP-CL.md @@ -66,6 +66,8 @@ Example kind:39000 (signing fields and unrelated tags omitted): | `P` | 39000–39002 | Public key that signed the kind:9007 that created the channel. | | `created_at` | 39000 | Time the channel was created, in unix seconds, as a decimal string. | | `auto_join` | 39000 | Present (bare, no value) while the relay joins new community members into this channel: the channel's `auto_join` setting is on and it is open and unarchived. | +| `landing` | 39000 | Present (bare, no value) on the community's landing channel. A projection of the NIP-11 `landing_channel` designation (extension `buzz-landing-channel`), which is authoritative. | +| `welcome` | 39000 | The community's custom welcome message, on the landing channel only. Plain text; `\n` is a line break. Absent while the client default applies. | **Channel type.** Clients MUST read the channel type from the first `t` tag. Later `t` tags are labels. diff --git a/migrations/0087_community_landing_channel.sql b/migrations/0087_community_landing_channel.sql new file mode 100644 index 00000000000..2a4214a4259 --- /dev/null +++ b/migrations/0087_community_landing_channel.sql @@ -0,0 +1,15 @@ +-- The community's landing channel and its custom welcome message. +-- Only a relay owner or admin sets them, with kind:9002 ["landing", "true"] +-- and ["welcome", text] on the channel; the relay also adopts or creates a +-- `general` landing channel on its own when none is designated. One row per +-- community holds the designation, so "exactly one landing channel" holds by +-- construction and a move is a single-row update. NIP-11 `landing_channel` is +-- the authoritative read; the landing channel's kind:39000 carries a bare +-- `landing` tag (and `welcome` when customized) as a projection. +-- Both columns are nullable with no default, so existing rows need no +-- rewrite and the deployed relay, which never reads them, is unaffected. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE communities + ADD COLUMN landing_channel_id UUID, + ADD COLUMN welcome_message TEXT; diff --git a/schema/tables/public/communities.sql b/schema/tables/public/communities.sql index 4c328109f69..2e4ae7cf04b 100644 --- a/schema/tables/public/communities.sql +++ b/schema/tables/public/communities.sql @@ -21,6 +21,12 @@ CREATE TABLE communities ( -- Per-community workspace icon (NIP-11 `icon`), set via kind:9033. -- Added by migration 0003; kept here so desired-state applies match. icon TEXT, + -- The community's landing channel (NIP-11 `landing_channel`) and its + -- custom welcome message, set via kind:9002 `landing` / `welcome` or + -- adopted by the relay. Added by migration 0087. No foreign key: the + -- relay keeps the designation on an eligible channel itself. + landing_channel_id UUID, + welcome_message TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), archived_at TIMESTAMPTZ, deletion_state TEXT NOT NULL DEFAULT 'active' CHECK (deletion_state IN ('active', 'quiescing', 'fenced', 'tombstone')), From 4e37133ffd83eda6577a3294514a18d9d224d168 Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 6 Nov 2026 02:45:30 +1100 Subject: [PATCH 02/15] fix(relay): review follow-ups for the landing channel enrollment Addresses the review of the landing channel change. - Roster-write landing join: the membership write is now separate from its announcement (`put_member_as_self`, then `announce_self_join`). Only a failed write skips the default channels, so the next AUTH retries both; a committed row whose announcement failed is entry evidence, so the defaults follow it rather than being abandoned. - Roster-mode AUTH recovery of the default channels uses `put_first_member_since(channel, admitted_at)` per channel and announces only when it wrote a row (`join_default_channels_since`, sharing one per-channel body with the roster-free path). Concurrent admissions of the same person join and announce once, and a leave since admission stands. `join_default_channels` loses its gate parameter; only the roster write sites use it now. - `channel_add_policy` is not consulted at AUTH recovery, matching the roster-free admission join from #8594: an admission join is the person's own act, as a kind:9021 is, and the policy restricts who else may add them. Agents never reach the path. Documented in code and NOSTR.md. - `ensure_landing_channel` reads whether a listed channel holds the name `general` (the `idx_channels_listed_name` predicate) before `create_general`, so a blocked name returns `SkippedNameTaken` without a write transaction. The `ChannelNameTaken` race fallback stays. - NOSTR.md: the default-channels section describes roster-mode AUTH enrollment accurately (recovery, `buzz-admin add-member` rows now enrolled at first AUTH, policy behaviour, announce-once), and the landing section says a stale `landing`/`welcome` tag can come from a failed or racing re-emission and that NIP-11 `landing_channel` wins when they disagree. - e2e: the landing journey restores the original designation and welcome and archives its channel under `catch_unwind`, so a failed assertion no longer leaves the shared relay moved. None of the tests have been run locally; only `cargo check` was. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- NOSTR.md | 91 ++++++++++------ crates/buzz-db/src/store/landing.rs | 32 +++++- crates/buzz-relay/src/handlers/auth.rs | 10 +- crates/buzz-relay/src/handlers/landing.rs | 45 +++++--- .../buzz-relay/src/handlers/side_effects.rs | 101 +++++++++++++----- crates/buzz-test-client/tests/e2e_relay.rs | 94 +++++++++------- 6 files changed, 253 insertions(+), 120 deletions(-) diff --git a/NOSTR.md b/NOSTR.md index c4fc82a4ef4..bc1771829d3 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -226,22 +226,37 @@ body of kind:9021, so the channel's `join_role` applies and the channel gets its `member_joined` kind:40099, kind:39002 and kind:44100. There is no cap on how many channels that is: whatever carries the kind:39000 tag is joined, and the work a new member costs grows with the number of channels an owner or -admin flags. The [landing channel](#landing-channel) is joined first. On a -community that requires relay membership, the relay does this at that -moment; a login or reconnect repeats it only for a person with no entry -evidence (see [Landing channel](#landing-channel)), so a later kind:9022 -leave stands. A kind:9030 add is -made by someone else, so the new member's `channel_add_policy` applies first, -as it would to that actor's kind:9000: if it refuses them, no default channel -is joined. The claim response waits for the joins, and a claimer who -disconnects before it arrives does not cut them short. A channel that fails -to join, or an add the member's policy refuses, is logged and counted -(`buzz_auto_join_failures_total`), not retried: the kind:39000 tag is the -record a client reconciles against. That includes a channel archived or made -private while the join runs: the membership write decides both again under -the channel's locks, so the member is not added and nothing is announced. -`buzz-admin add-member` writes the roster row only and -does not join default channels. +admin flags. The [landing channel](#landing-channel) is joined first. A +kind:9030 add is made by someone else, so the new member's +`channel_add_policy` applies first, as it would to that actor's kind:9000: +if it refuses them, no default channel is joined at the add. The claim +response waits for the joins, and a claimer who disconnects before it +arrives does not cut them short. A channel that fails to join, or an add the +member's policy refuses, is logged and counted +(`buzz_auto_join_failures_total`) and not retried at the roster write: the +kind:39000 tag is the record a client reconciles against. That includes a +channel archived or made private while the join runs: the membership write +decides both again under the channel's locks, so the member is not added and +nothing is announced. + +On a community that requires relay membership, a person's WebSocket AUTH +also enrolls them, after the OK, when they have **no entry evidence since +their roster row was created**: no membership, active or ended since then, +in any shared (stream or forum) channel other than a private channel they +created themselves (see [Landing channel](#landing-channel)). Such a person +is joined to the landing channel and then to every effective default channel +they have not been a member of since the roster row was created. This is +the recovery for a roster write whose joins did not finish, and it is how a +member added with `buzz-admin add-member` is enrolled: that command writes +the roster row only, and the person's first AUTH does the joins. A person +with any such evidence is never joined at AUTH, so a kind:9022 leave stands +and a reconnect or new device changes nothing; a member whose kind:9030 add +their `channel_add_policy` refused is enrolled at their first AUTH all the +same, because an admission join is their own act (as a kind:9021 is) and the +policy restricts who else may add them. Each channel's write checks its +membership history again under the channel's lock and is announced only when +it wrote a row, so two admissions racing each other join and announce once. +Agents are never enrolled at AUTH. A community that does not require relay membership has no roster insert to join people at, so there the relay joins a person when it admits their @@ -251,10 +266,11 @@ history: a kind:9022 leave or a kind:9001 removal keeps the row, so the person is not joined back, and a new device or another relay pod finds the row and does nothing. The check runs again under the channel's membership lock, so two admissions of the same person join them once. A channel flagged -later is joined on each person's next admission. Agents are skipped: an AUTH -with a NIP-OA owner, a NIP-FI agent token, or a stored agent owner joins -nothing. A membership write that fails leaves no row, so the next admission -tries that channel again. +later is joined on each person's next admission. `channel_add_policy` is not +consulted, for the reason above. Agents are skipped: an AUTH with a NIP-OA +owner, a NIP-FI agent token, or a stored agent owner joins nothing. A +membership write that fails leaves no row, so the next admission tries that +channel again. ### Landing channel @@ -283,11 +299,16 @@ is never more than one. Clients read it from NIP-11: - The relay-signed kind:39000 whose `d` is the landing channel carries a bare `["landing"]` tag, and `["welcome", ""]` while a custom message is set. These are a projection of the stored designation, re-signed whenever - it moves; NIP-11 is the authoritative read. A failed re-emission can leave a - stale tag on the previous landing channel, so a client takes the landing - channel from NIP-11 and ignores `welcome` on any other channel's 39000. The - welcome text is plain text: `\n` is a line break and nothing else is - interpreted. With no custom message, clients show their own default text. + it moves; NIP-11 is the authoritative read. The re-emission runs after the + designation has committed, so a failed one, or two moves whose + re-emissions race, can leave a stale `landing` or `welcome` tag on a + channel that is no longer the landing channel, or a `landing` tag missing + from the one that is, until that channel's next kind:39000. When the tags + and NIP-11 disagree, NIP-11 is right: a client takes the landing channel + from NIP-11 and ignores `landing` and `welcome` on any other channel's + 39000. The welcome text is plain text: `\n` is a line break and nothing + else is interpreted. With no custom message, clients show their own default + text. **Adoption.** With no designation, the relay adopts the oldest open, unarchived stream named `general` (case-insensitive) without a TTL, @@ -366,18 +387,22 @@ joined to it. - On a community that requires relay membership, this happens at the invite claim or kind:9030 add. A person's AUTH joins them only if they have no - entry evidence since their roster row was created: no channel membership - in a shared (stream or forum) channel, other than private channels they - created themselves. That recovers an admission whose joins did not finish, - and it joins the default channels too. After a kind:9031 removal, the next - admission is a first entry again. + entry evidence since their roster row was created: no membership, active + or ended since then, in a shared (stream or forum) channel, other than + private channels they created themselves. That recovers an admission whose + joins did not finish and enrolls a `buzz-admin add-member` row, and it + joins the default channels they have not been in since then too (see + [Default channels](#default-channels)). After a kind:9031 removal, the + next admission is a first entry again. - On a community without a roster, a person's AUTH joins them to the landing channel if they have no such evidence at all; then the default channels they have never been in follow, as before. - Reconnects, new devices and moving the designation never re-enroll anyone. - A person who left the landing channel stays out. A landing join that fails - for a reason other than a refusal (a ban, a channel that cannot be joined) - skips the default channels too, so the next AUTH retries both. + A person who left the landing channel stays out. A landing membership + write that fails for a reason other than a refusal (a ban, a channel that + cannot be joined) skips the default channels too, so the next AUTH retries + both; once the row has committed, the default channels follow even if its + announcement failed, because the row is entry evidence. ### Group Discovery diff --git a/crates/buzz-db/src/store/landing.rs b/crates/buzz-db/src/store/landing.rs index fd075e8ece3..7d69a1af639 100644 --- a/crates/buzz-db/src/store/landing.rs +++ b/crates/buzz-db/src/store/landing.rs @@ -220,9 +220,16 @@ async fn ensure_landing_channel( let Some(steward) = steward(pool, community_id).await? else { return Ok(LandingAdoption::SkippedNoSteward); }; + // No eligible `general` exists, so a listed holder of the name is one + // that cannot be adopted (archived, a forum, or with a TTL). Read that + // first: the common steady state of such a community is then one read, + // not a write transaction that `idx_channels_listed_name` rolls back. + if general_name_held(pool, community_id).await? { + return Ok(LandingAdoption::SkippedNameTaken); + } match create_general(pool, community_id, steward).await { - // A concurrent creator, or a rename, took the name first: adopt its - // channel if it is eligible. + // A concurrent creator, or a rename, took the name since the read: + // adopt its channel if it is eligible. Err(DbError::ChannelNameTaken(_)) => Ok(adopt_general(pool, community_id) .await? .unwrap_or(LandingAdoption::SkippedNameTaken)), @@ -230,6 +237,27 @@ async fn ensure_landing_channel( } } +/// Whether a listed channel holds the name `general`: the predicate of +/// `idx_channels_listed_name`, which `create_general` would trip. +async fn general_name_held(pool: &PgPool, community_id: CommunityId) -> Result { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let held: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM channels \ + WHERE community_id = $1 AND lower(name) = $2 \ + AND visibility = 'open' AND NOT unlisted \ + AND channel_type IN ('stream', 'forum') AND deleted_at IS NULL)", + ) + .bind(community_id.as_uuid()) + .bind(LANDING_CHANNEL_NAME) + .fetch_one(&mut *connection) + .await?; + Ok(held) +} + /// Designates the oldest eligible `general`, preferring a listed one. /// `None` when there is none. async fn adopt_general( diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 46f82888394..16489ad0fe7 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -3426,10 +3426,12 @@ mod tests { } /// With a roster, a roster write that did not join the person (here, - /// a bare roster insert) is recovered at AUTH: the landing channel, - /// then the default channels. Once they have left both, an admission - /// joins nothing; after a kind:9031 removal, re-admission is a first - /// entry again and restores both. + /// a bare roster insert, as `buzz-admin add-member` makes) is + /// recovered at AUTH: the landing channel, then the default channels + /// they have not been in since admission. Once they have left both, + /// an admission joins nothing; after a kind:9031 removal, + /// re-admission is a first entry again and restores both, through + /// `put_first_member_since` with the new roster row's `created_at`. /// /// Mutations: use `None` for `admitted_at` in roster mode → the /// re-admission joins nothing (RED); drop the roster-mode defaults diff --git a/crates/buzz-relay/src/handlers/landing.rs b/crates/buzz-relay/src/handlers/landing.rs index 0dc3c680dc2..eea746a1f6c 100644 --- a/crates/buzz-relay/src/handlers/landing.rs +++ b/crates/buzz-relay/src/handlers/landing.rs @@ -151,11 +151,13 @@ pub async fn converge_landing_channel(tenant: &TenantContext, state: &Arc, @@ -171,10 +173,13 @@ pub(crate) async fn join_landing_on_roster_admission( return false; } }; - match side_effects::join_channel_as_self(tenant, state, landing, pubkey).await { + match side_effects::put_member_as_self(tenant, state, landing, pubkey).await { Ok(SelfJoinOutcome::Joined) => { metrics::counter!("buzz_landing_joins_total").increment(1); info!(channel = %landing, %member, "joined the landing channel"); + if let Err(e) = side_effects::announce_self_join(tenant, state, landing, pubkey).await { + warn!(channel = %landing, %member, error = %e, "landing join announcement failed"); + } true } Ok(SelfJoinOutcome::AlreadyMember) => true, @@ -185,7 +190,7 @@ pub(crate) async fn join_landing_on_roster_admission( } Err(e) => { metrics::counter!("buzz_landing_join_failures_total").increment(1); - warn!(channel = %landing, %member, error = %e, "landing join failed; default channels wait for the next admission"); + warn!(channel = %landing, %member, error = %e, "landing membership write failed; default channels wait for the next admission"); false } } @@ -202,14 +207,21 @@ pub(crate) async fn join_landing_on_roster_admission( /// any history counts. /// 3. In roster mode, the admission then finishes the durable /// default-channel work its roster write left pending, if any -/// ([`side_effects::auto_join_default_channels`]). Without a roster, the -/// default channels they have never been in follow on every admission +/// ([`side_effects::auto_join_default_channels`]): the recovery for a +/// roster write whose joins did not complete +/// ([`join_landing_on_roster_admission`]). Without a roster, the default +/// channels they have never been in follow on every admission /// ([`side_effects::join_never_joined_channels`]). /// -/// Every write holds one of the socket's effect permits, so nothing is -/// joined after the session ends. A leave that commits first stands: -/// `put_first_member_since` writes nothing over a row removed since -/// admission. Failures are logged and counted. +/// Every write is a `put_first_member_since` under one of the socket's +/// effect permits, announced only when it wrote a row, so nothing is joined +/// after the session ends, two admissions racing each other announce once, +/// and a leave that commits first stands. The person's `channel_add_policy` +/// is not consulted: an admission join is their own act, as a kind:9021 is, +/// and the policy restricts who else may add them (the kind:9030 path checks +/// it against that actor). Failures are logged and counted; a landing +/// membership write that fails skips the defaults, so the next admission +/// retries both, while a failed announcement of a committed row does not. pub(crate) async fn admission_joins_on_auth( tenant: &TenantContext, state: &Arc, @@ -236,8 +248,9 @@ async fn admission_joins( return; } }; - let roster = state.config.require_relay_membership; - let admitted_at = if roster { + // `Some` in roster mode, where the roster row's `created_at` bounds the + // evidence and the default-channel history. + let admitted_at = if state.config.require_relay_membership { match state.db.get_relay_member(tenant.community(), &member).await { Ok(Some(row)) => Some(row.created_at), // Removed from the roster since admission: the session is @@ -295,13 +308,13 @@ async fn admission_joins( } Err(e) => { metrics::counter!("buzz_landing_join_failures_total").increment(1); - warn!(channel = %landing, %member, error = %e, "landing join on admission failed; default channels wait for the next admission"); + warn!(channel = %landing, %member, error = %e, "landing membership write on admission failed; default channels wait for the next admission"); return; } } } } - if roster { + if admitted_at.is_some() { side_effects::auto_join_default_channels(tenant, state, &member, Some(gate)).await; } else { side_effects::join_never_joined_channels(tenant, state, pubkey, gate).await; diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 4353b512691..f4ce40ec703 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -3017,6 +3017,24 @@ pub(crate) async fn join_channel_as_self( state: &Arc, channel_id: Uuid, pubkey: &[u8], +) -> anyhow::Result { + let outcome = put_member_as_self(tenant, state, channel_id, pubkey).await?; + if outcome == SelfJoinOutcome::Joined { + announce_self_join(tenant, state, channel_id, pubkey).await?; + } + Ok(outcome) +} + +/// The membership write of [`join_channel_as_self`] alone: the channel +/// checks and `put_member`, with no announcement. A caller that must know +/// whether the row committed before it announces (the landing join at a +/// roster write) uses this, then [`announce_self_join`] on +/// [`SelfJoinOutcome::Joined`]. +pub(crate) async fn put_member_as_self( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, + pubkey: &[u8], ) -> anyhow::Result { // Only open channels allow self-join via kind:9021. let channel = state @@ -3055,7 +3073,6 @@ pub(crate) async fn join_channel_as_self( .db .put_member(tenant.community(), channel_id, pubkey, None, None) .await?; - announce_self_join(tenant, state, channel_id, pubkey).await?; Ok(SelfJoinOutcome::Joined) } @@ -3114,10 +3131,12 @@ pub(crate) async fn announce_self_join( /// Failed reads or writes retain the token for a later claim or admission. /// Leaves made during this admission are preserved. A channel archived or /// made private after the list was read is complete for this admission: -/// `put_member` refuses it under its locks. A landing join that fails for -/// a reason other than a refusal retains the token before the default -/// channels: the person then has no entry evidence, so their next AUTH -/// joins both. +/// `put_member` refuses it under its locks. A landing membership write that +/// fails for a reason other than a refusal retains the token before the +/// default channels: the person then has no entry evidence, so their next +/// AUTH joins both. Once the landing row has committed, the person has +/// entry evidence, so the defaults always follow it, even if its +/// announcement failed. /// /// Agents, by their stored owner link, are not joined to the landing /// channel; the default channels apply to them as before. @@ -3201,7 +3220,9 @@ async fn finish_default_channel_joins( None => None, }; if !super::landing::join_landing_on_roster_admission(tenant, state, &pubkey).await { - anyhow::bail!("landing join failed; default channels wait for the next admission"); + anyhow::bail!( + "landing membership write failed; default channels wait for the next admission" + ); } } let channels = state.db.list_auto_join_channels(tenant.community()).await?; @@ -3282,6 +3303,10 @@ async fn finish_default_channel_join( /// task stops at the first refusal. A channel it stops before, or whose /// membership write fails, has no row, so the next admission tries it again. /// Failures are logged and counted (`buzz_auto_join_failures_total`). +/// +/// The person's `channel_add_policy` is not consulted: an admission join is +/// their own act, as a kind:9021 or a self kind:9000 is, and the policy +/// restricts who else may add them. Agents never reach this path. pub(crate) async fn join_never_joined_channels( tenant: &TenantContext, state: &Arc, @@ -3302,34 +3327,52 @@ pub(crate) async fn join_never_joined_channels( } }; for channel_id in channels { - #[cfg(test)] - crate::nip_fi_test_hooks::before_auto_join_channel(tenant.community()).await; - let Ok(_permit) = gate.acquire_effect().await else { - info!(channel = %channel_id, %member, "auto-join stopped: the session ended"); + if !join_default_on_admission(tenant, state, channel_id, pubkey, gate).await { return; - }; - let joined = match state - .db - .put_first_member(tenant.community(), channel_id, pubkey) - .await - { - Ok(None) => continue, - Ok(Some(_)) => announce_self_join(tenant, state, channel_id, pubkey).await, - Err(e) => Err(e.into()), - }; - match joined { - Ok(()) => { - metrics::counter!("buzz_auto_join_channels_total").increment(1); - info!(channel = %channel_id, %member, "auto-joined default channel on admission"); - } - Err(e) => { - metrics::counter!("buzz_auto_join_failures_total").increment(1); - warn!(channel = %channel_id, %member, error = %e, "auto-join of default channel on admission failed"); - } } } } +/// One default-channel join on admission: `put_first_member` under an +/// effect permit from `gate`, announced as a self-join only when it wrote a +/// row. Returns `false` when the session has ended and the caller should +/// stop; a failed channel is logged and counted and returns `true`. +async fn join_default_on_admission( + tenant: &TenantContext, + state: &Arc, + channel_id: Uuid, + pubkey: &[u8], + gate: &Arc, +) -> bool { + let member = hex::encode(pubkey); + #[cfg(test)] + crate::nip_fi_test_hooks::before_auto_join_channel(tenant.community()).await; + let Ok(_permit) = gate.acquire_effect().await else { + info!(channel = %channel_id, %member, "auto-join stopped: the session ended"); + return false; + }; + let joined = match state + .db + .put_first_member(tenant.community(), channel_id, pubkey) + .await + { + Ok(None) => return true, + Ok(Some(_)) => announce_self_join(tenant, state, channel_id, pubkey).await, + Err(e) => Err(e.into()), + }; + match joined { + Ok(()) => { + metrics::counter!("buzz_auto_join_channels_total").increment(1); + info!(channel = %channel_id, %member, "auto-joined default channel on admission"); + } + Err(e) => { + metrics::counter!("buzz_auto_join_failures_total").increment(1); + warn!(channel = %channel_id, %member, error = %e, "auto-join of default channel on admission failed"); + } + } + true +} + async fn handle_join_request( tenant: &TenantContext, event: &Event, diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index 9bcc2fc34ac..b45fd2018d9 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -455,28 +455,36 @@ async fn set_landing_ws( (ok.accepted, ok.message) } +/// Whether `tags` includes one named `name`. +fn has_tag(tags: &[Vec], name: &str) -> bool { + tags.iter() + .any(|t| t.first().map(String::as_str) == Some(name)) +} + +/// The `welcome` tag's text, if present. +fn welcome_tag(tags: &[Vec]) -> Option { + tags.iter() + .find(|t| t.first().map(String::as_str) == Some("welcome")) + .and_then(|t| t.get(1).cloned()) +} + /// The landing channel journey on the shared relay. A fresh person's AUTH /// joins them to the advertised landing channel, whose kind:39000 carries /// `["landing"]`. A relay owner moves the designation with the client's /// full-state kind:9002 (twice: it is idempotent); NIP-11 and both /// channels' kind:39000 follow. The landing channel refuses an archive, a /// non-admin is refused, and the designation moves back. +/// +/// The relay is shared with the other e2e tests, so the part that moves the +/// designation runs under `catch_unwind` and the original designation and +/// welcome are restored before any failure is reported. #[tokio::test] #[ignore] async fn test_landing_channel_journey() { - const PROTECTED: &str = "invalid: this is the community's landing channel; make another channel the landing channel first"; + use futures_util::FutureExt; let url = relay_url(); let owner = Keys::generate(); seed_relay_owner(&owner).await; - let has = |tags: &[Vec], name: &str| { - tags.iter() - .any(|t| t.first().map(String::as_str) == Some(name)) - }; - let welcome_of = |tags: &[Vec]| { - tags.iter() - .find(|t| t.first().map(String::as_str) == Some("welcome")) - .and_then(|t| t.get(1).cloned()) - }; let person = Keys::generate(); let person_hex = person.public_key().to_hex(); @@ -509,58 +517,72 @@ async fn test_landing_channel_journey() { "NIP-11 advertises the extension: {info}" ); let original_tags = group_metadata_tags(&url, &owner, &original).await; - assert!(has(&original_tags, "landing"), "{original_tags:?}"); - let original_welcome = welcome_of(&original_tags).unwrap_or_default(); + assert!(has_tag(&original_tags, "landing"), "{original_tags:?}"); + let original_welcome = welcome_tag(&original_tags).unwrap_or_default(); let target = create_test_channel(&owner).await; + let moved = + std::panic::AssertUnwindSafe(landing_move_journey(&url, &owner, &original, &target)) + .catch_unwind() + .await; + + // Leave the shared relay as found, even after a failed assertion. + let restored = set_landing_ws(&url, &original, &owner, &original_welcome).await; + let advertised = advertised_landing_channel().await.0; + let archived = set_metadata_ws(&url, &target, &owner, "archived", "true").await; + if let Err(panic) = moved { + eprintln!( + "landing journey failed; restore: {restored:?}, advertised: {advertised:?}, \ + target archived: {archived:?}" + ); + std::panic::resume_unwind(panic); + } + assert_eq!(restored, (true, String::new())); + assert_eq!(advertised.as_deref(), Some(original.as_str())); + assert_eq!( + archived, + (true, String::new()), + "the previous landing channel can be archived" + ); +} + +/// The part of [`test_landing_channel_journey`] that moves the designation +/// from `original` to `target`; the caller restores it. +async fn landing_move_journey(url: &str, owner: &Keys, original: &str, target: &str) { + const PROTECTED: &str = "invalid: this is the community's landing channel; make another channel the landing channel first"; let welcome = "Welcome to the e2e community.\nSay hello."; for attempt in ["move", "idempotent resend"] { assert_eq!( - set_landing_ws(&url, &target, &owner, welcome).await, + set_landing_ws(url, target, owner, welcome).await, (true, String::new()), "{attempt}" ); } assert_eq!( advertised_landing_channel().await.0.as_deref(), - Some(target.as_str()) + Some(target) ); - let target_tags = group_metadata_tags(&url, &owner, &target).await; - assert!(has(&target_tags, "landing"), "{target_tags:?}"); - assert_eq!(welcome_of(&target_tags).as_deref(), Some(welcome)); - let previous_tags = group_metadata_tags(&url, &owner, &original).await; + let target_tags = group_metadata_tags(url, owner, target).await; + assert!(has_tag(&target_tags, "landing"), "{target_tags:?}"); + assert_eq!(welcome_tag(&target_tags).as_deref(), Some(welcome)); + let previous_tags = group_metadata_tags(url, owner, original).await; assert!( - !has(&previous_tags, "landing") && !has(&previous_tags, "welcome"), + !has_tag(&previous_tags, "landing") && !has_tag(&previous_tags, "welcome"), "the previous landing channel drops both tags: {previous_tags:?}" ); assert_eq!( - set_metadata_ws(&url, &target, &owner, "archived", "true").await, + set_metadata_ws(url, target, owner, "archived", "true").await, (false, PROTECTED.to_string()) ); assert_eq!( - set_landing_ws(&url, &original, &Keys::generate(), "").await, + set_landing_ws(url, original, &Keys::generate(), "").await, ( false, "invalid: actor not authorized: landing and welcome require a relay owner or admin" .to_string() ) ); - - // Leave the shared relay as found. - assert_eq!( - set_landing_ws(&url, &original, &owner, &original_welcome).await, - (true, String::new()) - ); - assert_eq!( - advertised_landing_channel().await.0.as_deref(), - Some(original.as_str()) - ); - assert_eq!( - set_metadata_ws(&url, &target, &owner, "archived", "true").await, - (true, String::new()), - "the previous landing channel can be archived" - ); } #[tokio::test] From 66809d1312dffba06a4d276e01f55d09a47acecf Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 6 Nov 2026 03:03:29 +1100 Subject: [PATCH 03/15] refactor(relay): landing channel polish from the second review No blockers were found; this applies the optional findings. - `put_member_as_self` writes through `put_members` and reads `PutMemberResult.prior_role`: when the upsert changed nothing (a join that raced this one, such as an invite claim against the same person's AUTH) it returns `AlreadyMember`, so no second kind:40099 or kind:44100 is emitted. `Db::put_members` is the new wrapper. - The roster-mode AUTH journey gains a step that pins `put_first_member_since` in the default-channel recovery: paused at `before_auto_join_channel`, a racing join and a leave land, and on resume the leave stands with no second `member_joined` notice. - `ensure_landing_channel` reads the designation's eligibility in the same query and reports `LandingAdoption::ExistingIneligible` (`buzz_landing_adoption_total{outcome="existing_ineligible"}`, a warning in the relay) for a landing channel archived, made private, deleted or given a TTL past the protections. - Docs: the "name held" wording includes a TTL (code and NOSTR.md); the stale-tag causes cover any lost or reordered re-emission, including an old pod during a rolling deploy; the kind:9031 re-admission sentence is qualified (kind:9031 removes nobody from channels, and active memberships always count); NOSTR.md gains a rollout note on old pods during deploy. None of the tests have been run locally; only `cargo check` was. Co-Authored-By: Claude Fable 5.1 Signed-off-by: Matt Toohey --- NOSTR.md | 33 ++++-- crates/buzz-db/src/store/channel_members.rs | 13 +++ crates/buzz-db/src/store/landing.rs | 43 +++++--- crates/buzz-relay/src/handlers/auth.rs | 101 +++++++++++++++++- crates/buzz-relay/src/handlers/landing.rs | 9 +- .../buzz-relay/src/handlers/side_effects.rs | 22 +++- 6 files changed, 194 insertions(+), 27 deletions(-) diff --git a/NOSTR.md b/NOSTR.md index bc1771829d3..f3bcbe1e028 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -300,10 +300,12 @@ is never more than one. Clients read it from NIP-11: `["landing"]` tag, and `["welcome", ""]` while a custom message is set. These are a projection of the stored designation, re-signed whenever it moves; NIP-11 is the authoritative read. The re-emission runs after the - designation has committed, so a failed one, or two moves whose - re-emissions race, can leave a stale `landing` or `welcome` tag on a - channel that is no longer the landing channel, or a `landing` tag missing - from the one that is, until that channel's next kind:39000. When the tags + designation has committed and is not retried, so anything that loses or + reorders it (a failed emission, two moves whose re-emissions race, or a + relay pod from before this feature re-signing a channel's 39000 during a + rolling deploy) can leave a stale `landing` or `welcome` tag on a channel + that is no longer the landing channel, or a `landing` tag missing from the + one that is, until that channel's next kind:39000. When the tags and NIP-11 disagree, NIP-11 is right: a client takes the landing channel from NIP-11 and ignores `landing` and `welcome` on any other channel's 39000. The welcome text is plain text: `\n` is a line break and nothing @@ -316,8 +318,9 @@ preferring a listed one. If there is none, it creates a listed, open `general` owned by the community's earliest relay owner, else its earliest admin; that person gets the channel-creation kind:44100. A private `general` is never adopted. Nothing is created when the community has no owner or -admin, or when `general` is taken by a channel that cannot be adopted (an -archived `general`, or a forum). The relay tries at community provisioning, +admin, or when `general` is taken by a listed channel that cannot be adopted +(an archived `general`, a forum, or one with a TTL); a private or unlisted +`general` does not hold the name. The relay tries at community provisioning, at boot for the deployment community, and at every person's AUTH, so an existing community converges at its first connection after an upgrade. @@ -379,6 +382,17 @@ message. Renames, `about`, `topic`, `purpose`, labels, `unlisted`, `join_role`, `auto_join`, and member adds, removals and moderation stay allowed. +**Rollout.** During a rolling deploy, relay pods from before this feature do +not advertise `landing_channel`, enroll nobody at AUTH, do not enforce the +protections, and re-sign a channel's kind:39000 without its `landing` and +`welcome` tags. The designation itself is unaffected, and a newer pod +re-signs the tags at the channel's next change. A landing channel archived, +made private or given a TTL through an old pod stays designated but +ineligible: the relay counts it +(`buzz_landing_adoption_total{outcome="existing_ineligible"}`) and logs a +warning at each adoption attempt, and nobody is enrolled into it until an +owner or admin moves the designation with kind:9002 `landing`. + **Enrollment.** People are joined to the landing channel first, then to the [default channels](#default-channels), as kind:9021 self-joins (the channel's `join_role`, `member_joined` kind:40099, kind:39002 and kind:44100). @@ -392,8 +406,11 @@ joined to it. private channels they created themselves. That recovers an admission whose joins did not finish and enrolls a `buzz-admin add-member` row, and it joins the default channels they have not been in since then too (see - [Default channels](#default-channels)). After a kind:9031 removal, the - next admission is a first entry again. + [Default channels](#default-channels)). After a kind:9031 removal and a + new roster row, the next admission is a first entry again unless a + shared-channel membership from before is still active: kind:9031 removes + nobody from channels, and an active membership always counts, whenever it + began. - On a community without a roster, a person's AUTH joins them to the landing channel if they have no such evidence at all; then the default channels they have never been in follow, as before. diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index f675b679f24..115530e41db 100644 --- a/crates/buzz-db/src/store/channel_members.rs +++ b/crates/buzz-db/src/store/channel_members.rs @@ -2464,6 +2464,19 @@ impl Db { put_first_member_with_pending(&self.pool, community, channel, pubkey, pending).await } + /// [`Db::put_member`] for several people in one transaction, returning + /// the role each held just before the write (see [`put_members`]). + #[datastore_span(name = "put_members", system = "postgresql")] + pub async fn put_members( + &self, + community_id: CommunityId, + channel_id: Uuid, + targets: &[MemberPut<'_>], + invited_by: Option<&[u8]>, + ) -> Result>> { + put_members(&self.pool, community_id, channel_id, targets, invited_by).await + } + /// Self-joins `pubkey` with the channel's `join_role` only if they have /// never been a member of the channel (see [`put_first_member`]). #[datastore_span(name = "put_first_member", system = "postgresql")] diff --git a/crates/buzz-db/src/store/landing.rs b/crates/buzz-db/src/store/landing.rs index 7d69a1af639..f75a6fa9758 100644 --- a/crates/buzz-db/src/store/landing.rs +++ b/crates/buzz-db/src/store/landing.rs @@ -83,6 +83,11 @@ pub fn welcome_message_error(text: &str) -> Option<&'static str> { pub enum LandingAdoption { /// A channel was already designated. Existing(Uuid), + /// A channel was already designated but is no longer eligible: archived, + /// private, deleted or given a TTL past the relay's protections (a + /// direct write, or a relay from before them). It stays designated; an + /// owner or admin moves it with kind:9002 `landing`. + ExistingIneligible(Uuid), /// An eligible channel named `general` became the landing channel. Reused(Uuid), /// The relay created `general`, owned by `steward`, as the landing @@ -96,8 +101,8 @@ pub enum LandingAdoption { /// No eligible `general` exists and the community has no owner or admin /// to own a new one. The designation stays empty. SkippedNoSteward, - /// No eligible `general` exists and the name is held by a channel that - /// cannot be the landing channel (archived, or a forum). + /// No eligible `general` exists and the name is held by a listed channel + /// that cannot be the landing channel (archived, a forum, or with a TTL). SkippedNameTaken, } @@ -105,7 +110,7 @@ impl LandingAdoption { /// The landing channel after the call, if any. pub fn channel_id(&self) -> Option { match *self { - Self::Existing(id) | Self::Reused(id) => Some(id), + Self::Existing(id) | Self::ExistingIneligible(id) | Self::Reused(id) => Some(id), Self::Created { channel_id, .. } => Some(channel_id), Self::SkippedNoSteward | Self::SkippedNameTaken => None, } @@ -116,6 +121,7 @@ impl LandingAdoption { pub fn outcome(&self) -> Option<&'static str> { match self { Self::Existing(_) => None, + Self::ExistingIneligible(_) => Some("existing_ineligible"), Self::Reused(_) => Some("reused"), Self::Created { .. } => Some("created"), Self::SkippedNoSteward => Some("skipped_no_steward"), @@ -201,18 +207,26 @@ async fn ensure_landing_channel( pool: &PgPool, community_id: CommunityId, ) -> Result { - let current = { + let current: Option<(Uuid, bool)> = { let mut connection = crate::observability::acquire_writer( pool, crate::observability::WriterOperation::Authorization, ) .await?; - read_landing(&mut connection, community_id) - .await? - .channel_id + // The designation and, in the same read, whether it is still eligible. + sqlx::query_as(sqlx::AssertSqlSafe(format!( + "SELECT c.landing_channel_id, EXISTS (SELECT 1 FROM channels \ + WHERE community_id = c.id AND id = c.landing_channel_id AND {ELIGIBLE_SQL}) \ + FROM communities c WHERE c.id = $1 AND c.landing_channel_id IS NOT NULL" + ))) + .bind(community_id.as_uuid()) + .fetch_optional(&mut *connection) + .await? }; - if let Some(channel_id) = current { - return Ok(LandingAdoption::Existing(channel_id)); + match current { + Some((channel_id, true)) => return Ok(LandingAdoption::Existing(channel_id)), + Some((channel_id, false)) => return Ok(LandingAdoption::ExistingIneligible(channel_id)), + None => {} } if let Some(adopted) = adopt_general(pool, community_id).await? { return Ok(adopted); @@ -418,10 +432,13 @@ impl Db { /// an unlisted one. With none, a listed, open `general` is created, owned /// by the community's earliest owner, else earliest admin /// ([`LandingAdoption::SkippedNoSteward`] when there is neither). If the - /// name is held by a channel that cannot be adopted (an archived - /// `general`, or a forum), nothing is created - /// ([`LandingAdoption::SkippedNameTaken`]). A private `general` is never - /// adopted and does not hold the name. + /// name is held by a listed channel that cannot be adopted (an archived + /// `general`, a forum, or one with a TTL), nothing is created + /// ([`LandingAdoption::SkippedNameTaken`]). A private or unlisted + /// `general` does not hold the name, and a private one is never adopted. + /// + /// A designated channel that is no longer eligible is reported + /// ([`LandingAdoption::ExistingIneligible`]) and left designated. /// /// A NULL designation is the retry record: every call that finds one /// tries again. diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 16489ad0fe7..e2fe67f2faa 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -3309,6 +3309,34 @@ mod tests { .expect("member role") } + /// How many `member_joined` kind:40099 notices in `channel` target + /// `keys`. + async fn member_joined_notice_count( + state: &crate::state::AppState, + community: buzz_core::tenant::CommunityId, + channel: uuid::Uuid, + keys: &Keys, + ) -> usize { + let member_hex = keys.public_key().to_hex(); + state + .db + .query_events(&buzz_db::EventQuery { + kinds: Some(vec![40099]), + channel_id: Some(channel), + ..buzz_db::EventQuery::for_community(community) + }) + .await + .expect("query 40099") + .iter() + .filter_map(|stored| { + serde_json::from_str::(&stored.event.content).ok() + }) + .filter(|content| { + content["type"] == "member_joined" && content["target"] == member_hex + }) + .count() + } + async fn seed_relay_member( state: &crate::state::AppState, community: buzz_core::tenant::CommunityId, @@ -3432,11 +3460,17 @@ mod tests { /// an admission joins nothing; after a kind:9031 removal, /// re-admission is a first entry again and restores both, through /// `put_first_member_since` with the new roster row's `created_at`. + /// A recovery paused before the default channel's write, while a + /// racing join (an invite claim) and a leave land, writes nothing: + /// the leave stands and the admission adds no second notice. /// /// Mutations: use `None` for `admitted_at` in roster mode → the /// re-admission joins nothing (RED); drop the roster-mode defaults /// recovery → the default channel is not joined (RED); gate the spawn - /// on `!require_relay_membership` again → nothing is joined (RED). + /// on `!require_relay_membership` again → nothing is joined (RED); + /// recover the defaults with `join_channel_as_self` instead of + /// `put_first_member_since` → the leave is undone and a second + /// `member_joined` notice appears (RED). #[tokio::test] #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] async fn roster_auth_recovers_landing_and_defaults_for_a_first_entry() { @@ -3493,6 +3527,71 @@ mod tests { roles("readmitted").await, [member("readmitted"), member("readmitted")] ); + + // Another first entry, paused before the default channel's write + // while a racing join and a leave land. + use crate::nip_fi_test_hooks::{auto_join_channel_hook, auto_join_done_hook}; + for channel in [landing, announcements] { + state + .db + .remove_member(community, channel, &person_key, &person_key) + .await + .expect("leave"); + } + state + .db + .remove_relay_member(community, &person.public_key().to_hex(), &Keys::generate()) + .await + .map(|write| write.outcome) + .expect("kind:9031 removal"); + seed_relay_member(&state, community, &person, "member").await; + let notices_before = + member_joined_notice_count(&state, community, announcements, &person).await; + let (done, done_release) = auto_join_done_hook::arm(community); + let (reached, release) = auto_join_channel_hook::arm(community); + let (conn, _ctrl) = registered_pending_conn(&state, community, "roster-raced"); + handle_auth( + signed_auth(&person, "roster-raced", None), + std::sync::Arc::clone(&conn), + std::sync::Arc::clone(&state), + ) + .await; + assert!(matches!( + conn.auth_state_snapshot(), + AuthState::Authenticated(_) + )); + reached.await.expect("task reaches the landing channel"); + // Re-arm before the task can run again: it does not yield + // between the release and the arm. + release.notify_one(); + let (reached, release) = auto_join_channel_hook::arm(community); + reached.await.expect("task reaches the default channel"); + crate::handlers::side_effects::join_channel_as_self( + &conn.tenant, + &state, + announcements, + &person_key, + ) + .await + .expect("the racing join"); + state + .db + .remove_member(community, announcements, &person_key, &person_key) + .await + .expect("leave again"); + release.notify_one(); + done.await.expect("task finishes"); + done_release.notify_one(); + assert_eq!( + roles("raced").await, + [member("raced"), ("raced", None)], + "the leave since admission stands" + ); + assert_eq!( + member_joined_notice_count(&state, community, announcements, &person).await, + notices_before + 1, + "the racing join's notice is the admission's only one" + ); } /// AUTH with NIP-OA records a previously ownerless agent's owner. That diff --git a/crates/buzz-relay/src/handlers/landing.rs b/crates/buzz-relay/src/handlers/landing.rs index eea746a1f6c..6c7966572f8 100644 --- a/crates/buzz-relay/src/handlers/landing.rs +++ b/crates/buzz-relay/src/handlers/landing.rs @@ -96,6 +96,9 @@ pub(crate) async fn ensure_landing_channel( } match adoption { LandingAdoption::Existing(_) => {} + LandingAdoption::ExistingIneligible(channel_id) => { + warn!(channel = %channel_id, "the landing channel is no longer eligible (archived, private, deleted or with a ttl); move the designation with a kind:9002 landing tag"); + } LandingAdoption::Reused(channel_id) => { info!(channel = %channel_id, "adopted general as the landing channel"); state.invalidate_all_accessible_channels(tenant); @@ -203,8 +206,10 @@ pub(crate) async fn join_landing_on_roster_admission( /// 2. A person with no entry evidence /// ([`buzz_db::Db::has_entered_community`]) is joined to it. In roster /// mode the evidence counts from their roster row's `created_at`, so a -/// re-admission after kind:9031 is a first entry again; without a roster -/// any history counts. +/// re-admission after kind:9031 is a first entry again unless a shared +/// membership from before is still active (kind:9031 removes nobody +/// from channels, and an active membership always counts); without a +/// roster any history counts. /// 3. In roster mode, the admission then finishes the durable /// default-channel work its roster write left pending, if any /// ([`side_effects::auto_join_default_channels`]): the recovery for a diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index f4ce40ec703..1cba4c3be82 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -3069,10 +3069,26 @@ pub(crate) async fn put_member_as_self( // also decides visibility and archive state again under its locks, so a // channel made private or archived since the checks above is refused // here, with no side effects. - state + let put = state .db - .put_member(tenant.community(), channel_id, pubkey, None, None) - .await?; + .put_members( + tenant.community(), + channel_id, + &[buzz_db::channel_members::MemberPut { + pubkey, + requested: None, + }], + None, + ) + .await? + .pop() + .ok_or_else(|| anyhow::anyhow!("membership put returned no result"))??; + // A join that raced this one (an invite claim against the same person's + // AUTH) wrote the row between the cache read and the lock: the upsert + // changed nothing, so there is nothing to announce a second time. + if put.prior_role.as_deref() == Some(put.member.role.as_str()) { + return Ok(SelfJoinOutcome::AlreadyMember); + } Ok(SelfJoinOutcome::Joined) } From ca13379246ae723db3a4d1340e7be83882a49dec Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Fri, 6 Nov 2026 15:14:43 +1100 Subject: [PATCH 04/15] feat(relay): customizable welcome title and Markdown welcome body Owners asked for a customizable welcome title and a welcome message that supports Markdown with channel, message and user links. This adds both to the landing-channel contract from #9108 (NOSTR.md, "Landing channel"). Title: - Migration 0085 adds `communities.welcome_title TEXT`: nullable, no default, no backfill and no foreign key, so the deployed relay is unaffected. The desired schema and the migration pins follow. - kind:9002 `["welcome_title", text]` has the same authority and eligibility as `welcome`: a relay owner or admin, the `AdminChannels` scope for API tokens, and only on the landing channel or together with `landing=true`. `""` restores the client's default title. - The title is 1-120 Unicode scalar values, not blank, and has no control characters, `\n` and `\t` included. - The landing channel's kind:39000 carries `["welcome_title", text]` while the title is customized. The title moves with the designation, and the previous landing channel's re-emission drops it. - The client's full-state form is `[h, landing, welcome, welcome_title]`. The three-tag form stays accepted. `welcome` and `welcome_title` are partial updates, so an absent tag leaves its value unchanged. Both are written in one UPDATE inside the existing 9002 transaction. Body: - The `welcome` cap rises from 1000 to 4000 scalar values, and `\t` is allowed beside `\n` for Markdown indentation and code. Other control characters, including `\r`, and blank text are still refused. - The relay stores and projects the body as received. NOSTR.md and NIP-CL now say that rendering Markdown and `nostr:` links is a client concern, as is treating the text as untrusted input. Receipt texts (the buzz-app client shows them and does not match on them): - Not authorized: `actor not authorized: landing, welcome and welcome_title require a relay owner or admin`. - Duplicate tags: `kind:9002 may include at most one landing, one welcome and one welcome_title tag`. - Not on the landing channel: `welcome and welcome_title can only be set on the landing channel`. - Message control characters: `welcome message must not contain control characters other than line breaks and tabs`. The length message is now `(max 4000 characters)`. - New for the title: `welcome_title tag must have a value (text, or empty to restore the default)`, `welcome title must not be blank`, `welcome title is too long (max 120 characters)` and `welcome title must be a single line without control characters`. - The kind:9002 tag-list message and the kind:9002 scope split in ingest now include `welcome_title`. Tests extended (none run; only `cargo check -p buzz-db -p buzz-relay -p buzz-test-client --tests`): - buzz-db: title and message rule tables; the projection with a title; in the Postgres journey, title-alone and partial updates, a refusal elsewhere, and the title moving with the designation; the 0085 pin. - buzz-relay: the tag-rule table (Markdown, tabs, the 4000 limit and every title receipt); a new kind:9002 scope-split table; the Postgres authorization matrix and 39000 projection with title, the three-tag form keeping the title, and the move dropping all three tags. - e2e: the landing journey sends the four-tag form with a Markdown body and a title, and restores the original title. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Matt Toohey --- NOSTR.md | 92 +++--- crates/buzz-db/src/runtime/migration.rs | 10 +- crates/buzz-db/src/store/channel.rs | 25 +- crates/buzz-db/src/store/landing.rs | 294 ++++++++++++------ crates/buzz-relay/src/handlers/ingest.rs | 42 ++- crates/buzz-relay/src/handlers/landing.rs | 128 ++++++-- .../src/handlers/landing_postgres_tests.rs | 143 ++++++--- crates/buzz-relay/src/handlers/mod.rs | 2 +- .../buzz-relay/src/handlers/side_effects.rs | 29 +- crates/buzz-relay/src/nip11.rs | 5 +- crates/buzz-test-client/tests/e2e_relay.rs | 49 +-- docs/nips/NIP-CL.md | 3 +- migrations/0088_community_welcome_title.sql | 11 + schema/tables/public/communities.sql | 3 + 14 files changed, 595 insertions(+), 241 deletions(-) create mode 100644 migrations/0088_community_welcome_title.sql diff --git a/NOSTR.md b/NOSTR.md index f3bcbe1e028..0b09f1ff7c8 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -53,11 +53,11 @@ PGPASSWORD=buzz_dev psql -h localhost -U buzz -d buzz -c \ | **Group creation (kind:9007)** | ✅ | NIP-29; include an `h` tag with the new channel id and a `name` tag, optional `visibility`, `channel_type`, label `t` and `unlisted` tags. The relay refuses a 9007 without `h`. It refuses a listed channel (open, not unlisted, stream or forum) whose name another listed channel already has, ignoring case and including archived ones: `invalid: an open channel named "" already exists`. | | **Add user (kind:9000)** | ✅ | Open: any user, subject to target's `channel_add_policy` (`owner_only`/`nobody` can block). Private: owner/admin only. Self-add bypasses agent policy but not private-channel auth. A guest cannot add anyone. A new member with no role tag gets the channel's `join_role`; a non-member adding themselves may ask for `join_role` or a lower role. One event may name up to 100 people: one `p` tag each, with an optional role as the `p` tag's third element (NIP-29), falling back to the event's `role` tag. Each person is checked and added on their own. When the event names several people, the OK message is `members: ` followed by JSON listing the non-empty `added`, `updated` (role changed), `already_member` and `rejected` (`[{"reason", "pubkeys"}]`) results. The event is refused only when it is malformed or nobody it names may be added; then one person keeps the single-add error, and several get `invalid: members: {…}`. The event and its membership changes are stored together, so an `error:` rejection stores nothing and the same event can be resent; a resent stored event returns `duplicate:` without results. Re-adding someone at their current role posts no second `member_joined` notice. | | **Remove user (kind:9001)** | ✅ | Self-remove allowed (with last-owner guard). Removing others: owner/admin only. | -| **Edit group metadata (kind:9002)** | ✅ | `name`/`about`/label `t`/`visibility`/`ttl`/`archived`/`join_role`/`unlisted` tags: owner/admin. `topic`/`purpose` tags: any member except a guest. `auto_join`, `landing` and `welcome` tags: relay owner/admin (see [Default channels](#default-channels) and [Landing channel](#landing-channel)). A relay operator may also send a 9002 that changes only `name` on a listed channel, archived ones included, whatever its channel role, so that duplicate names can be repaired; the channel stays archived. The relay refuses a `name`, `visibility` or `unlisted` change that leaves a listed channel with a name another listed channel already has: `invalid: an open channel named "" already exists`. Buzz applies 9002 as a partial update: an absent tag leaves that setting unchanged. | +| **Edit group metadata (kind:9002)** | ✅ | `name`/`about`/label `t`/`visibility`/`ttl`/`archived`/`join_role`/`unlisted` tags: owner/admin. `topic`/`purpose` tags: any member except a guest. `auto_join`, `landing`, `welcome` and `welcome_title` tags: relay owner/admin (see [Default channels](#default-channels) and [Landing channel](#landing-channel)). A relay operator may also send a 9002 that changes only `name` on a listed channel, archived ones included, whatever its channel role, so that duplicate names can be repaired; the channel stays archived. The relay refuses a `name`, `visibility` or `unlisted` change that leaves a listed channel with a name another listed channel already has: `invalid: an open channel named "" already exists`. Buzz applies 9002 as a partial update: an absent tag leaves that setting unchanged. | | **Admin delete event (kind:9005)** | ✅ | Event author can always delete own. Otherwise owner/admin required. Target must be in same channel. | | **Group deletion (kind:9008)** | ✅ | Owner only. | | **Leave group (kind:9022)** | ✅ | Any member. Last-owner guard prevents orphaned groups. | -| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `t`, `P`, `created_at`, `join_role`; `public` or `private`; `restricted` and `closed` per the access flags below; `unlisted` when the channel is unlisted; `about` only if description non-empty; `hidden` for DM channels and private system channels; `auto_join` while the channel is a default channel; `landing`, and `welcome` when customized, on the landing channel | +| **Group metadata (kind:39000)** | ✅ | Relay-signed; always `d`, `name`, `t`, `P`, `created_at`, `join_role`; `public` or `private`; `restricted` and `closed` per the access flags below; `unlisted` when the channel is unlisted; `about` only if description non-empty; `hidden` for DM channels and private system channels; `auto_join` while the channel is a default channel; `landing`, and `welcome` and `welcome_title` when customized, on the landing channel | | **Unlisted channels** | ✅ | kind:9002 `["unlisted", "true"\|"false"]`, owner/admin, or at creation on kind:9007. An unlisted open channel is left out of every read that does not name it, for non-members: channel lists, kind:39000 without `#d`, messages, COUNT, NIP-50 search and artifact queries. Naming it by `#h` (or `#d` on 39000–39003) reads it, and kind:9021 joins it, as for any open channel. See [Unlisted channels](#unlisted-channels). | | **Group admins (kind:39001)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags with roles (`owner`, `admin`) | | **Group members (kind:39002)** | ✅ | Relay-signed; `d`, channel type `t` and creator `P` tags + `p` tags for all members | @@ -277,7 +277,7 @@ channel again. Every community has at most one **landing channel**: the open stream a person is joined to first when they enter the community, and the channel a client opens when it has nothing better to show. Its kind:39000 also carries -the community's custom welcome message, if one is set. +the community's custom welcome message and its title, when they are set. **Designation.** The relay stores the designation on the community, so there is never more than one. Clients read it from NIP-11: @@ -294,23 +294,29 @@ is never more than one. Clients read it from NIP-11: - `landing_channel` is a lowercase hyphenated UUID, omitted (never `null` or `""`) when no channel is designated. It is read live (`Cache-Control: no-store`), so a GET after an accepted write reflects it. NIP-11 is - unauthenticated, so the id is public; the welcome text is not published - there. + unauthenticated, so the id is public; the welcome message and title are + not published there. - The relay-signed kind:39000 whose `d` is the landing channel carries a bare - `["landing"]` tag, and `["welcome", ""]` while a custom message is - set. These are a projection of the stored designation, re-signed whenever - it moves; NIP-11 is the authoritative read. The re-emission runs after the + `["landing"]` tag, `["welcome", ""]` while a custom message is + set, and `["welcome_title", ""]` while a custom title is set. These + are a projection of the stored designation, re-signed whenever it moves; + NIP-11 is the authoritative read. The re-emission runs after the designation has committed and is not retried, so anything that loses or reorders it (a failed emission, two moves whose re-emissions race, or a relay pod from before this feature re-signing a channel's 39000 during a - rolling deploy) can leave a stale `landing` or `welcome` tag on a channel - that is no longer the landing channel, or a `landing` tag missing from the - one that is, until that channel's next kind:39000. When the tags - and NIP-11 disagree, NIP-11 is right: a client takes the landing channel - from NIP-11 and ignores `landing` and `welcome` on any other channel's - 39000. The welcome text is plain text: `\n` is a line break and nothing - else is interpreted. With no custom message, clients show their own default - text. + rolling deploy) can leave a stale `landing`, `welcome` or `welcome_title` + tag on a channel that is no longer the landing channel, or a `landing` tag + missing from the one that is, until that channel's next kind:39000. When + the tags and NIP-11 disagree, NIP-11 is right: a client takes the landing + channel from NIP-11 and ignores `landing`, `welcome` and `welcome_title` on + any other channel's 39000. +- The welcome message is Markdown source. The relay stores and projects it + exactly as received and interprets none of it: rendering the Markdown, + and turning `nostr:` links (NIP-21) to channels, messages and people into + navigation, is a client concern, as is treating the text as untrusted + input. The title is one line of plain text. With no custom message or + title, clients show their own default for that part, so a client can + customize either alone. **Adoption.** With no designation, the relay adopts the oldest open, unarchived stream named `general` (case-insensitive) without a TTL, @@ -331,45 +337,59 @@ tokens need the `AdminChannels` scope): | Tag | Meaning | |---|---| | `["landing", "true"]` | Make this channel the landing channel | -| `["welcome", "<text>"]` | Set the custom welcome message | -| `["welcome", ""]` | Restore the client default | +| `["welcome", "<message>"]` | Set the custom welcome message (Markdown source) | +| `["welcome", ""]` | Restore the client's default message | +| `["welcome_title", "<title>"]` | Set the custom welcome title | +| `["welcome_title", ""]` | Restore the client's default title | ```json {"kind":9002,"content":"","tags":[["h","<new id>"],["landing","true"]]} -{"kind":9002,"content":"","tags":[["h","<landing id>"],["landing","true"],["welcome","Hi all!\nIntroduce yourself here."]]} -{"kind":9002,"content":"","tags":[["h","<landing id>"],["landing","true"],["welcome",""]]} +{"kind":9002,"content":"","tags":[["h","<landing id>"],["landing","true"],["welcome","# Hi all!\n\nSay hello, then read the **pinned** posts."],["welcome_title","Welcome to Acme"]]} +{"kind":9002,"content":"","tags":[["h","<landing id>"],["landing","true"],["welcome",""],["welcome_title",""]]} +{"kind":9002,"content":"","tags":[["h","<landing id>"],["landing","true"],["welcome","Hi all!"]]} ``` - `landing` moves the designation in one transaction; the previous landing channel loses its tags and its protections. Nobody is added or removed. It is idempotent: `landing` on the current landing channel changes - nothing, so a client may always send the full state (`landing` plus - `welcome`). + nothing, so a client may always send the full state (`landing`, + `welcome` and `welcome_title`, as in the second and third examples). - `landing` needs a stream that the event leaves open, unarchived and without a TTL, decided under the channel's lock. -- `welcome` is community configuration. It is accepted on the current - landing channel, or with `landing` in the same event, and moves with the - designation. The text is 1–1000 Unicode scalar values, not blank, with no - control characters other than `\n`. The relay stores exactly what it - receives. +- `welcome` and `welcome_title` are community configuration. Each is + accepted on the current landing channel, or with `landing` in the same + event, and moves with the designation. Each is a partial update: an + absent tag leaves that value unchanged, so the form without + `welcome_title` (the last example) keeps the title. +- The message is 1–4000 Unicode scalar values, not blank, with no control + characters other than `\n` and `\t` (so `\r` is refused: send `\n` line + endings). +- The title is 1–120 Unicode scalar values, not blank, on one line, with no + control characters at all (`\n` and `\t` included). +- The relay stores both exactly as it receives them and validates nothing + else about them (see the Markdown note above). - The designation cannot be turned off, only moved. Refusals (`accepted: false`): | Condition | `message` | |---|---| -| Sender is not a relay owner or admin | `invalid: actor not authorized: landing and welcome require a relay owner or admin` | +| Sender is not a relay owner or admin | `invalid: actor not authorized: landing, welcome and welcome_title require a relay owner or admin` | | `landing` on a non-stream channel | `invalid: landing is only available on stream channels` | | `["landing", "false"]` | `invalid: landing cannot be turned off; make another channel the landing channel` | | Any other `landing` value | `invalid: invalid landing value: <v> (must be "true")` | | `landing` with no value | `invalid: landing tag must have a value` | | `welcome` with no value | `invalid: welcome tag must have a value (text, or empty to restore the default)` | -| More than one `landing` or `welcome` tag | `invalid: kind:9002 may include at most one landing and one welcome tag` | -| Whitespace-only text | `invalid: welcome message must not be blank` | -| More than 1000 characters | `invalid: welcome message is too long (max 1000 characters)` | -| Control characters other than `\n` | `invalid: welcome message must be plain text (line breaks only)` | +| `welcome_title` with no value | `invalid: welcome_title tag must have a value (text, or empty to restore the default)` | +| More than one `landing`, `welcome` or `welcome_title` tag | `invalid: kind:9002 may include at most one landing, one welcome and one welcome_title tag` | +| Whitespace-only message | `invalid: welcome message must not be blank` | +| Message over 4000 characters | `invalid: welcome message is too long (max 4000 characters)` | +| Message with a control character other than `\n` or `\t` | `invalid: welcome message must not contain control characters other than line breaks and tabs` | +| Whitespace-only title | `invalid: welcome title must not be blank` | +| Title over 120 characters | `invalid: welcome title is too long (max 120 characters)` | +| Title with any control character (`\n` and `\t` included) | `invalid: welcome title must be a single line without control characters` | | Ineligible target channel | `invalid: landing requires an open, unarchived channel without a ttl` | -| `welcome` on another channel without `landing` | `invalid: welcome can only be set on the landing channel` | +| `welcome` or `welcome_title` on another channel without `landing` | `invalid: welcome and welcome_title can only be set on the landing channel` | | A protected operation (below) | `invalid: this is the community's landing channel; make another channel the landing channel first` | An `error: …` message means the outcome is uncertain: read back before @@ -384,8 +404,8 @@ allowed. **Rollout.** During a rolling deploy, relay pods from before this feature do not advertise `landing_channel`, enroll nobody at AUTH, do not enforce the -protections, and re-sign a channel's kind:39000 without its `landing` and -`welcome` tags. The designation itself is unaffected, and a newer pod +protections, and re-sign a channel's kind:39000 without its `landing`, +`welcome` and `welcome_title` tags. The designation itself is unaffected, and a newer pod re-signs the tags at the channel's next change. A landing channel archived, made private or given a TTL through an old pod stays designated but ineligible: the relay counts it @@ -430,7 +450,7 @@ All discovery events include a `d` tag set to the channel UUID (NIP-29 addressab | Kind | Tags | Content | |------|------|---------| -| **39000** | `d=<uuid>`, `name`, `t=<channel_type>`, `P=<creator>`, `created_at=<unix seconds>`, `join_role` (always); `public` or `private`; `restricted` (private channels, and `join_role=guest`); `closed` (private channels only); `unlisted` (unlisted channels); `about` (if description non-empty); `hidden` (DM channels and private system channels); `auto_join` (open, unarchived channels with the setting on, see [Default channels](#default-channels)); `landing` and `welcome` (the landing channel only, see [Landing channel](#landing-channel)) | Group metadata. The access flags follow the channel settings (see [Announce channels](#announce-channels)). | +| **39000** | `d=<uuid>`, `name`, `t=<channel_type>`, `P=<creator>`, `created_at=<unix seconds>`, `join_role` (always); `public` or `private`; `restricted` (private channels, and `join_role=guest`); `closed` (private channels only); `unlisted` (unlisted channels); `about` (if description non-empty); `hidden` (DM channels and private system channels); `auto_join` (open, unarchived channels with the setting on, see [Default channels](#default-channels)); `landing`, `welcome` and `welcome_title` (the landing channel only, see [Landing channel](#landing-channel)) | Group metadata. The access flags follow the channel settings (see [Announce channels](#announce-channels)). | | **39001** | `d=<uuid>`, `t=<channel_type>`, `P=<creator>`, `p` tags with role label (`owner`, `admin`) | Admin list | | **39002** | `d=<uuid>`, `t=<channel_type>`, `P=<creator>`, `p` tags for all members | Member list | diff --git a/crates/buzz-db/src/runtime/migration.rs b/crates/buzz-db/src/runtime/migration.rs index c8944ef3198..9777087882d 100644 --- a/crates/buzz-db/src/runtime/migration.rs +++ b/crates/buzz-db/src/runtime/migration.rs @@ -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(), 87); + assert_eq!(migrations.len(), 88); assert!(migrations[83] .sql .as_str() @@ -945,6 +945,14 @@ mod postgres_tests { 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() diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index 2adc9c39541..ef3e82002e7 100644 --- a/crates/buzz-db/src/store/channel.rs +++ b/crates/buzz-db/src/store/channel.rs @@ -1640,6 +1640,9 @@ pub struct ChannelMetadataEdit { /// 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`]. @@ -1737,13 +1740,13 @@ pub(crate) async fn apply_channel_metadata_event( )); } } - // `landing` and `welcome` 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. + // `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() { + 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(), @@ -1760,9 +1763,12 @@ pub(crate) async fn apply_channel_metadata_event( crate::landing::WELCOME_NOT_LANDING.to_string(), )); } - if let Some(welcome) = &edit.welcome { - crate::landing::set_welcome(locked.tx(), welcome.as_deref()).await?; - } + 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?; @@ -1775,6 +1781,7 @@ pub(crate) async fn apply_channel_metadata_event( channel_authority: _, landing: _, welcome: _, + welcome_title: _, } = edit; let actor = event.pubkey.to_bytes(); if !columns.is_empty() { diff --git a/crates/buzz-db/src/store/landing.rs b/crates/buzz-db/src/store/landing.rs index f75a6fa9758..c114489ae3b 100644 --- a/crates/buzz-db/src/store/landing.rs +++ b/crates/buzz-db/src/store/landing.rs @@ -1,10 +1,11 @@ //! The community's landing channel and its welcome message. //! -//! Both live on the community's `communities` row (`landing_channel_id`, -//! `welcome_message`), so a community has at most one landing channel by -//! construction and a move is a single-row update. NIP-11 `landing_channel` -//! is the authoritative read; the landing channel's kind:39000 carries a -//! projection of it ([`CommunityLanding::group_metadata_tags`]). +//! All three live on the community's `communities` row (`landing_channel_id`, +//! `welcome_message`, `welcome_title`), so a community has at most one +//! landing channel by construction and a move is a single-row update. NIP-11 +//! `landing_channel` is the authoritative read; the landing channel's +//! kind:39000 carries a projection of it +//! ([`CommunityLanding::group_metadata_tags`]). //! //! Lock order: a writer that locks both a channel row and the `communities` //! row locks the channel row first. Moving the designation to a channel @@ -28,12 +29,18 @@ pub const LANDING_PROTECTED: &str = /// without a TTL, judged on the state the request leaves behind. pub const LANDING_INELIGIBLE: &str = "landing requires an open, unarchived channel without a ttl"; -/// Refusal for a welcome message sent to a channel that is not, and does not -/// become, the landing channel. -pub const WELCOME_NOT_LANDING: &str = "welcome can only be set on the landing channel"; +/// Refusal for a welcome message or title sent to a channel that is not, and +/// does not become, the landing channel. +pub const WELCOME_NOT_LANDING: &str = + "welcome and welcome_title can only be set on the landing channel"; -/// The longest welcome message, in Unicode scalar values. -pub const WELCOME_MAX_CHARS: usize = 1000; +/// The longest welcome message, in Unicode scalar values. The message is +/// Markdown source, which the relay stores as-is; rendering it is a client +/// concern. +pub const WELCOME_MAX_CHARS: usize = 4000; + +/// The longest welcome title, in Unicode scalar values. +pub const WELCOME_TITLE_MAX_CHARS: usize = 120; /// The name of the landing channel the relay adopts or creates. pub const LANDING_CHANNEL_NAME: &str = "general"; @@ -43,36 +50,65 @@ pub const LANDING_CHANNEL_NAME: &str = "general"; pub struct CommunityLanding { /// The landing channel, if one is designated. pub channel_id: Option<Uuid>, - /// The custom welcome message; `None` means the client default. + /// The custom welcome message (Markdown source); `None` means the client + /// default. pub welcome: Option<String>, + /// The custom welcome title; `None` means the client default. + pub welcome_title: Option<String>, } impl CommunityLanding { /// The kind:39000 tags for `channel_id`: a bare `["landing"]` while it is - /// the landing channel, then `["welcome", text]` while a custom message - /// is set. Empty for every other channel, so a re-emission after a move - /// drops both from the previous landing channel. + /// the landing channel, then `["welcome", text]` and + /// `["welcome_title", text]` while each is customized. Empty for every + /// other channel, so a re-emission after a move drops all three from the + /// previous landing channel. pub fn group_metadata_tags(&self, channel_id: Uuid) -> Vec<Vec<String>> { if self.channel_id != Some(channel_id) { return Vec::new(); } let mut tags = vec![vec!["landing".to_owned()]]; - if let Some(welcome) = self.welcome.as_deref().filter(|w| !w.is_empty()) { - tags.push(vec!["welcome".to_owned(), welcome.to_owned()]); + for (name, text) in [ + ("welcome", &self.welcome), + ("welcome_title", &self.welcome_title), + ] { + if let Some(text) = text.as_deref().filter(|t| !t.is_empty()) { + tags.push(vec![name.to_owned(), text.to_owned()]); + } } tags } } /// Why `text` cannot be a custom welcome message, or `None` when it can. -/// The caller handles the empty string, which restores the default. +/// The message is Markdown source, so line breaks and tabs are allowed; no +/// other control character is. The caller handles the empty string, which +/// restores the default. pub fn welcome_message_error(text: &str) -> Option<&'static str> { if text.trim().is_empty() { Some("welcome message must not be blank") } else if text.chars().count() > WELCOME_MAX_CHARS { - Some("welcome message is too long (max 1000 characters)") - } else if text.chars().any(|c| c.is_control() && c != '\n') { - Some("welcome message must be plain text (line breaks only)") + Some("welcome message is too long (max 4000 characters)") + } else if text + .chars() + .any(|c| c.is_control() && !matches!(c, '\n' | '\t')) + { + Some("welcome message must not contain control characters other than line breaks and tabs") + } else { + None + } +} + +/// Why `text` cannot be a custom welcome title, or `None` when it can: one +/// line of text, with no control character at all. The caller handles the +/// empty string, which restores the default. +pub fn welcome_title_error(text: &str) -> Option<&'static str> { + if text.trim().is_empty() { + Some("welcome title must not be blank") + } else if text.chars().count() > WELCOME_TITLE_MAX_CHARS { + Some("welcome title is too long (max 120 characters)") + } else if text.chars().any(char::is_control) { + Some("welcome title must be a single line without control characters") } else { None } @@ -134,15 +170,17 @@ async fn read_landing( conn: &mut PgConnection, community_id: CommunityId, ) -> Result<CommunityLanding> { - let row: Option<(Option<Uuid>, Option<String>)> = - sqlx::query_as("SELECT landing_channel_id, welcome_message FROM communities WHERE id = $1") - .bind(community_id.as_uuid()) - .fetch_optional(conn) - .await?; - let (channel_id, welcome) = row.unwrap_or_default(); + let row: Option<(Option<Uuid>, Option<String>, Option<String>)> = sqlx::query_as( + "SELECT landing_channel_id, welcome_message, welcome_title FROM communities WHERE id = $1", + ) + .bind(community_id.as_uuid()) + .fetch_optional(conn) + .await?; + let (channel_id, welcome, welcome_title) = row.unwrap_or_default(); Ok(CommunityLanding { channel_id, welcome: welcome.filter(|w| !w.is_empty()), + welcome_title: welcome_title.filter(|t| !t.is_empty()), }) } @@ -170,15 +208,32 @@ pub(crate) async fn set_designation(tx: &mut AdmittedTx, channel_id: Uuid) -> Re Ok(()) } -/// Sets (or, with `None`, clears) the welcome message on `tx`, which holds -/// the community row lock ([`lock_designation`]). -pub(crate) async fn set_welcome(tx: &mut AdmittedTx, welcome: Option<&str>) -> Result<()> { +/// Writes the welcome message and title on `tx`, which holds the community +/// row lock ([`lock_designation`]), in one statement. Each is a partial +/// update: `None` leaves it unchanged, `Some(None)` restores the default and +/// `Some(Some(text))` sets it. +pub(crate) async fn set_welcome( + tx: &mut AdmittedTx, + message: Option<Option<&str>>, + title: Option<Option<&str>>, +) -> Result<()> { + if message.is_none() && title.is_none() { + return Ok(()); + } let community_id = tx.community(); - sqlx::query("UPDATE communities SET welcome_message = $2 WHERE id = $1") - .bind(community_id.as_uuid()) - .bind(welcome) - .execute(tx.conn()) - .await?; + sqlx::query( + "UPDATE communities SET \ + welcome_message = CASE WHEN $2 THEN $3 ELSE welcome_message END, \ + welcome_title = CASE WHEN $4 THEN $5 ELSE welcome_title END \ + WHERE id = $1", + ) + .bind(community_id.as_uuid()) + .bind(message.is_some()) + .bind(message.flatten()) + .bind(title.is_some()) + .bind(title.flatten()) + .execute(tx.conn()) + .await?; Ok(()) } @@ -409,7 +464,8 @@ async fn create_general( } impl Db { - /// Returns the community's landing channel and welcome message. + /// Returns the community's landing channel and welcome message and + /// title. #[datastore_span(name = "get_community_landing", system = "postgresql")] pub async fn get_community_landing( &self, @@ -462,15 +518,29 @@ mod tests { let custom = CommunityLanding { channel_id: Some(landing), welcome: Some("Hi all!\nSay hello.".to_string()), + welcome_title: Some("Welcome aboard".to_string()), }; assert_eq!( custom.group_metadata_tags(landing), - vec![tag(&["landing"]), tag(&["welcome", "Hi all!\nSay hello."])] + vec![ + tag(&["landing"]), + tag(&["welcome", "Hi all!\nSay hello."]), + tag(&["welcome_title", "Welcome aboard"]), + ] ); assert!(custom.group_metadata_tags(other).is_empty()); + let title_only = CommunityLanding { + welcome: None, + ..custom.clone() + }; + assert_eq!( + title_only.group_metadata_tags(landing), + vec![tag(&["landing"]), tag(&["welcome_title", "Welcome aboard"])] + ); let default_text = CommunityLanding { channel_id: Some(landing), welcome: None, + welcome_title: None, }; assert_eq!( default_text.group_metadata_tags(landing), @@ -485,37 +555,65 @@ mod tests { fn welcome_message_rules() { let at_limit = "a".repeat(WELCOME_MAX_CHARS); let over_limit = "é".repeat(WELCOME_MAX_CHARS + 1); + let control = Some( + "welcome message must not contain control characters other than line breaks and tabs", + ); let cases: &[(&str, Option<&str>)] = &[ ("Welcome!", None), ("Hi all!\nIntroduce yourself here.", None), (" padded ", None), (&at_limit, None), + // Markdown source: headings, links, nostr: references and + // tab-indented code are all just text to the relay. + ( + "# Welcome\n\nSee [#rules](nostr:naddr1abc) and nostr:npub1xyz.\n\n\tcode", + None, + ), + ("tab\there", None), (" ", Some("welcome message must not be blank")), ("\n\n", Some("welcome message must not be blank")), + ("\t\n", Some("welcome message must not be blank")), ( &over_limit, - Some("welcome message is too long (max 1000 characters)"), - ), - ( - "tab\there", - Some("welcome message must be plain text (line breaks only)"), - ), - ( - "windows\r\nline", - Some("welcome message must be plain text (line breaks only)"), - ), - ( - "bell\u{7}", - Some("welcome message must be plain text (line breaks only)"), + Some("welcome message is too long (max 4000 characters)"), ), + ("windows\r\nline", control), + ("bell\u{7}", control), + ("next line\u{85}", control), ]; for (text, expected) in cases { assert_eq!(welcome_message_error(text), *expected, "{text:?}"); } - // The limit counts scalar values, not bytes: 1000 multi-byte + // The limit counts scalar values, not bytes: 4000 multi-byte // characters fit. assert_eq!(welcome_message_error(&"é".repeat(WELCOME_MAX_CHARS)), None); } + + #[test] + fn welcome_title_rules() { + let at_limit = "é".repeat(WELCOME_TITLE_MAX_CHARS); + let over_limit = "a".repeat(WELCOME_TITLE_MAX_CHARS + 1); + let control = Some("welcome title must be a single line without control characters"); + let cases: &[(&str, Option<&str>)] = &[ + ("Welcome to Acme", None), + ("👋 Hi, team!", None), + (" padded ", None), + (&at_limit, None), + ("", Some("welcome title must not be blank")), + (" ", Some("welcome title must not be blank")), + ("\n", Some("welcome title must not be blank")), + ( + &over_limit, + Some("welcome title is too long (max 120 characters)"), + ), + ("Two\nlines", control), + ("tab\there", control), + ("bell\u{7}", control), + ]; + for (text, expected) in cases { + assert_eq!(welcome_title_error(text), *expected, "{text:?}"); + } + } } #[cfg(test)] @@ -602,12 +700,18 @@ mod postgres_tests { } } - /// The client's full-state form: `landing` plus `welcome` (`""` - /// restores the default). - fn full_state(welcome: &str) -> ChannelMetadataEdit { + /// A `welcome` or `welcome_title` tag value: `""` restores the default. + fn text(value: &str) -> Option<Option<String>> { + Some((!value.is_empty()).then(|| value.to_string())) + } + + /// The client's full-state form: `landing` plus `welcome` and + /// `welcome_title`. + fn full_state(welcome: &str, title: &str) -> ChannelMetadataEdit { ChannelMetadataEdit { landing: true, - welcome: Some((!welcome.is_empty()).then(|| welcome.to_string())), + welcome: text(welcome), + welcome_title: text(title), ..Default::default() } } @@ -620,10 +724,11 @@ mod postgres_tests { } } - fn landing(channel_id: Uuid, welcome: Option<&str>) -> CommunityLanding { + fn landing(channel_id: Uuid, welcome: Option<&str>, title: Option<&str>) -> CommunityLanding { CommunityLanding { channel_id: Some(channel_id), welcome: welcome.map(str::to_string), + welcome_title: title.map(str::to_string), } } @@ -650,14 +755,17 @@ mod postgres_tests { } /// A kind:9002 `landing` designates the channel and moves the - /// designation, carrying the welcome message; the full-state form on - /// the current landing channel is idempotent; `welcome` elsewhere and - /// every ineligible post-state are refused and change nothing. + /// designation, carrying the welcome message and title; the full-state + /// form on the current landing channel is idempotent; `welcome` and + /// `welcome_title` are partial updates of each other; either elsewhere, + /// and every ineligible post-state, is refused and changes nothing. /// /// Mutations: report `previous_landing` on a re-designation → RED; /// check eligibility on the pre-state → the same-event archive, private /// and ttl rows are accepted → RED; drop the welcome landing check → - /// the welcome on `b` is accepted → RED. + /// the welcome on `b` is accepted → RED; write `welcome_title` + /// unconditionally in `set_welcome` → the welcome-alone edit clears the + /// title → RED. #[tokio::test] #[ignore = "requires Postgres"] async fn kind_9002_moves_the_designation_and_accepts_the_full_state_form() { @@ -665,55 +773,63 @@ mod postgres_tests { let a = open_stream(&db, community, "lobby").await; let b = open_stream(&db, community, "town-square").await; - let write = apply(&db, community, a, full_state("Hi all")) + let write = apply(&db, community, a, full_state("Hi all", "Welcome aboard")) .await .expect("designate a"); assert_eq!(write.previous_landing, None); assert_eq!( db.get_community_landing(community).await.expect("landing"), - landing(a, Some("Hi all")) + landing(a, Some("Hi all"), Some("Welcome aboard")) ); - let write = apply(&db, community, a, full_state("")) + let write = apply(&db, community, a, full_state("", "")) .await .expect("full state on the landing channel"); assert!(write.inserted); assert_eq!(write.previous_landing, None, "re-designating is not a move"); assert_eq!( db.get_community_landing(community).await.expect("landing"), - landing(a, None), - "an empty welcome restores the default" + landing(a, None, None), + "empty values restore the defaults" ); - apply( - &db, - community, - a, + for edit in [ ChannelMetadataEdit { - welcome: Some(Some("Second".into())), + welcome_title: text("Hello"), ..Default::default() }, - ) - .await - .expect("welcome alone on the landing channel"); - assert_eq!( - refusal( - apply( - &db, - community, - b, - ChannelMetadataEdit { - welcome: Some(Some("Elsewhere".into())), - ..Default::default() - }, - ) + ChannelMetadataEdit { + welcome: text("Second"), + ..Default::default() + }, + ] { + apply(&db, community, a, edit) .await - ), - WELCOME_NOT_LANDING + .expect("welcome or welcome_title alone on the landing channel"); + } + assert_eq!( + db.get_community_landing(community).await.expect("landing"), + landing(a, Some("Second"), Some("Hello")), + "each is a partial update of the other" ); + for edit in [ + ChannelMetadataEdit { + welcome: text("Elsewhere"), + ..Default::default() + }, + ChannelMetadataEdit { + welcome_title: text("Elsewhere"), + ..Default::default() + }, + ] { + assert_eq!( + refusal(apply(&db, community, b, edit).await), + WELCOME_NOT_LANDING + ); + } assert_eq!( db.get_community_landing(community).await.expect("landing"), - landing(a, Some("Second")) + landing(a, Some("Second"), Some("Hello")) ); let write = apply(&db, community, b, designate()) @@ -722,8 +838,8 @@ mod postgres_tests { assert_eq!(write.previous_landing, Some(a)); assert_eq!( db.get_community_landing(community).await.expect("landing"), - landing(b, Some("Second")), - "the welcome moves with the designation" + landing(b, Some("Second"), Some("Hello")), + "the welcome and its title move with the designation" ); let private = channel( @@ -789,7 +905,7 @@ mod postgres_tests { } assert_eq!( db.get_community_landing(community).await.expect("landing"), - landing(b, Some("Second")), + landing(b, Some("Second"), Some("Hello")), "a refused move changes nothing" ); let a_after = db.get_channel(community, a).await.expect("a"); diff --git a/crates/buzz-relay/src/handlers/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 50241b52ede..d4648041a15 100644 --- a/crates/buzz-relay/src/handlers/ingest.rs +++ b/crates/buzz-relay/src/handlers/ingest.rs @@ -666,11 +666,14 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result<Scope, &'static s // profile-publishing user already holds. KIND_IA_ARCHIVE_REQUEST | KIND_IA_UNARCHIVE_REQUEST => Ok(Scope::UsersWrite), KIND_NIP29_EDIT_METADATA => { - // kind:9002 scope split: archived, auto_join, landing or welcome - // tag → AdminChannels, else ChannelsWrite + // kind:9002 scope split: archived, auto_join, landing, welcome or + // welcome_title tag → AdminChannels, else ChannelsWrite let has_admin_tag = event.tags.iter().any(|t| { let k = t.kind().to_string(); - matches!(k.as_str(), "archived" | "auto_join" | "landing" | "welcome") + matches!( + k.as_str(), + "archived" | "auto_join" | "landing" | "welcome" | "welcome_title" + ) }); if has_admin_tag { Ok(Scope::AdminChannels) @@ -4726,6 +4729,39 @@ mod postgres_tests { assert!(is_global_only_kind(KIND_LONG_FORM)); } + /// kind:9002 needs `AdminChannels` for any community-setting tag and + /// `ChannelsWrite` otherwise. + /// + /// Mutation: drop `welcome_title` from the split → the title-only row + /// asks for `ChannelsWrite` (RED). + #[test] + fn edit_metadata_scope_split() { + let cases: &[(&[&[&str]], Scope)] = &[ + (&[&["h", "x"], &["name", "n"]], Scope::ChannelsWrite), + (&[&["h", "x"], &["topic", "t"]], Scope::ChannelsWrite), + (&[&["h", "x"], &["archived", "true"]], Scope::AdminChannels), + (&[&["h", "x"], &["auto_join", "true"]], Scope::AdminChannels), + (&[&["h", "x"], &["landing", "true"]], Scope::AdminChannels), + (&[&["h", "x"], &["welcome", "Hi"]], Scope::AdminChannels), + ( + &[&["h", "x"], &["welcome_title", "Hi"]], + Scope::AdminChannels, + ), + ( + &[&["h", "x"], &["name", "n"], &["welcome_title", ""]], + Scope::AdminChannels, + ), + ]; + for (tags, expected) in cases { + let event = make_event_with_tags(KIND_NIP29_EDIT_METADATA, "", tags); + assert_eq!( + required_scope_for_kind(KIND_NIP29_EDIT_METADATA, &event), + Ok(expected.clone()), + "tags {tags:?}" + ); + } + } + #[test] fn user_status_requires_users_write_scope() { let dummy = make_dummy_event(); diff --git a/crates/buzz-relay/src/handlers/landing.rs b/crates/buzz-relay/src/handlers/landing.rs index 6c7966572f8..af53d3948c7 100644 --- a/crates/buzz-relay/src/handlers/landing.rs +++ b/crates/buzz-relay/src/handlers/landing.rs @@ -4,12 +4,12 @@ //! //! The designation lives on the `communities` row (see //! [`buzz_db::landing`]); NIP-11 `landing_channel` is its authoritative read -//! and the `["landing"]` / `["welcome", text]` tags on the channel's -//! kind:39000 are a projection. A relay owner or admin moves it with a -//! kind:9002 `landing` tag. With no designation, the relay adopts or creates -//! `general` here, lazily: at provisioning, at boot for the deployment -//! community, and on every human admission. A NULL designation is the retry -//! record. +//! and the `["landing"]`, `["welcome", text]` and `["welcome_title", text]` +//! tags on the channel's kind:39000 are a projection. A relay owner or admin +//! moves it with a kind:9002 `landing` tag. With no designation, the relay +//! adopts or creates `general` here, lazily: at provisioning, at boot for +//! the deployment community, and on every human admission. A NULL +//! designation is the retry record. use std::sync::Arc; @@ -25,18 +25,19 @@ use super::side_effects::{self, SelfJoinOutcome}; use crate::nip_fi_gate::SessionAdmissionGate; use crate::state::AppState; -/// Refusal for a kind:9002 `landing` or `welcome` tag from an actor who is -/// not a relay owner or admin. +/// Refusal for a kind:9002 `landing`, `welcome` or `welcome_title` tag from +/// an actor who is not a relay owner or admin. pub(crate) const NOT_AUTHORIZED: &str = - "actor not authorized: landing and welcome require a relay owner or admin"; + "actor not authorized: landing, welcome and welcome_title require a relay owner or admin"; -/// Validates a kind:9002's `landing` and `welcome` tags. Returns whether the -/// event carries either; the caller then requires a relay owner or admin and -/// a stream channel. +/// Validates a kind:9002's `landing`, `welcome` and `welcome_title` tags. +/// Returns whether the event carries any of them; the caller then requires a +/// relay owner or admin and a stream channel. /// /// `["landing", "true"]` designates the channel; there is no way to turn the /// designation off, only to move it. `["welcome", text]` sets the welcome -/// message and `["welcome", ""]` restores the default. Each may appear once. +/// message (Markdown source, stored as-is) and `["welcome_title", text]` its +/// title; `""` restores the client default for either. Each may appear once. pub(crate) fn validate_tags(event: &Event) -> anyhow::Result<bool> { let tags = |name: &str| { event @@ -46,9 +47,11 @@ pub(crate) fn validate_tags(event: &Event) -> anyhow::Result<bool> { .map(|tag| tag.as_slice().get(1).map(String::as_str)) .collect::<Vec<_>>() }; - let (landing, welcome) = (tags("landing"), tags("welcome")); - if landing.len() > 1 || welcome.len() > 1 { - anyhow::bail!("kind:9002 may include at most one landing and one welcome tag"); + let (landing, welcome, title) = (tags("landing"), tags("welcome"), tags("welcome_title")); + if landing.len() > 1 || welcome.len() > 1 || title.len() > 1 { + anyhow::bail!( + "kind:9002 may include at most one landing, one welcome and one welcome_title tag" + ); } if let Some(value) = landing.first() { match value { @@ -62,20 +65,28 @@ pub(crate) fn validate_tags(event: &Event) -> anyhow::Result<bool> { None => anyhow::bail!("landing tag must have a value"), } } - if let Some(value) = welcome.first() { - match value { - None => anyhow::bail!( - "welcome tag must have a value (text, or empty to restore the default)" + let texts: [(&str, &[Option<&str>], fn(&str) -> Option<&'static str>); 2] = [ + ("welcome", &welcome, buzz_db::landing::welcome_message_error), + ( + "welcome_title", + &title, + buzz_db::landing::welcome_title_error, + ), + ]; + for (name, values, text_error) in texts { + match values.first() { + None | Some(Some("")) => {} + Some(None) => anyhow::bail!( + "{name} tag must have a value (text, or empty to restore the default)" ), - Some("") => {} - Some(text) => { - if let Some(error) = buzz_db::landing::welcome_message_error(text) { + Some(Some(text)) => { + if let Some(error) = text_error(text) { anyhow::bail!("{error}"); } } } } - Ok(!landing.is_empty() || !welcome.is_empty()) + Ok(!landing.is_empty() || !welcome.is_empty() || !title.is_empty()) } /// Makes sure the community has a landing channel (see @@ -341,11 +352,23 @@ mod tests { .expect("sign") } - /// The `landing`/`welcome` rules of §3.3, with the exact receipt text a - /// client shows. + /// The `landing`/`welcome`/`welcome_title` rules, with the exact receipt + /// text a client shows. + /// + /// Mutations: drop `'\t'` from the message's allowed controls → the + /// Markdown indentation row is refused (RED); validate the title with + /// the message rule → the title line break row is accepted (RED). #[test] fn landing_and_welcome_tag_rules() { - let long = "a".repeat(buzz_db::landing::WELCOME_MAX_CHARS + 1); + use buzz_db::landing::{ + welcome_message_error, welcome_title_error, WELCOME_MAX_CHARS, WELCOME_TITLE_MAX_CHARS, + }; + let long = "a".repeat(WELCOME_MAX_CHARS + 1); + let at_limit = "a".repeat(WELCOME_MAX_CHARS); + let long_title = "a".repeat(WELCOME_TITLE_MAX_CHARS + 1); + let markdown = "# Hi!\n\n- read [#rules](nostr:naddr1x)\n\tindented code"; + let duplicate = + "kind:9002 may include at most one landing, one welcome and one welcome_title tag"; let cases: &[(&[&[&str]], Result<bool, &str>)] = &[ (&[&["h", "x"], &["name", "n"]], Ok(false)), (&[&["landing", "true"]], Ok(true)), @@ -355,6 +378,26 @@ mod tests { Ok(true), ), (&[&["welcome", "Hi"]], Ok(true)), + (&[&["welcome", markdown]], Ok(true)), + (&[&["welcome", at_limit.as_str()]], Ok(true)), + // The client's full-state form, and its three-tag predecessor. + ( + &[ + &["landing", "true"], + &["welcome", "Hi"], + &["welcome_title", "Welcome aboard"], + ], + Ok(true), + ), + ( + &[ + &["landing", "true"], + &["welcome", ""], + &["welcome_title", ""], + ], + Ok(true), + ), + (&[&["welcome_title", "Hello, team"]], Ok(true)), ( &[&["landing", "false"]], Err("landing cannot be turned off; make another channel the landing channel"), @@ -368,25 +411,42 @@ mod tests { &[&["welcome"]], Err("welcome tag must have a value (text, or empty to restore the default)"), ), + ( + &[&["welcome_title"]], + Err("welcome_title tag must have a value (text, or empty to restore the default)"), + ), ( &[&["landing", "true"], &["landing", "true"]], - Err("kind:9002 may include at most one landing and one welcome tag"), + Err(duplicate), ), + (&[&["welcome", "a"], &["welcome", "b"]], Err(duplicate)), ( - &[&["welcome", "a"], &["welcome", "b"]], - Err("kind:9002 may include at most one landing and one welcome tag"), + &[&["welcome_title", "a"], &["welcome_title", "b"]], + Err(duplicate), ), ( &[&["welcome", " "]], - Err(buzz_db::landing::welcome_message_error(" ").expect("blank refused")), + Err(welcome_message_error(" ").expect("blank refused")), ), ( &[&["welcome", long.as_str()]], - Err(buzz_db::landing::welcome_message_error(&long).expect("long refused")), + Err(welcome_message_error(&long).expect("long refused")), + ), + ( + &[&["welcome", "bell\u{7}"]], + Err(welcome_message_error("bell\u{7}").expect("control refused")), + ), + ( + &[&["welcome", "Hi"], &["welcome_title", " \t "]], + Err(welcome_title_error(" \t ").expect("blank title refused")), + ), + ( + &[&["welcome_title", long_title.as_str()]], + Err(welcome_title_error(&long_title).expect("long title refused")), ), ( - &[&["welcome", "tab\there"]], - Err(buzz_db::landing::welcome_message_error("tab\there").expect("control refused")), + &[&["landing", "true"], &["welcome_title", "Two\nlines"]], + Err(welcome_title_error("Two\nlines").expect("title line break refused")), ), ]; for (tags, expected) in cases { diff --git a/crates/buzz-relay/src/handlers/landing_postgres_tests.rs b/crates/buzz-relay/src/handlers/landing_postgres_tests.rs index 42a0bf08839..b1958b6e08d 100644 --- a/crates/buzz-relay/src/handlers/landing_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/landing_postgres_tests.rs @@ -1,6 +1,7 @@ -//! Postgres-backed tests for the landing channel: the kind:9002 `landing` and -//! `welcome` tags with their exact receipts, the kind:39000 projection, the -//! protections, the NIP-11 advertisement and provisioning's adoption. +//! Postgres-backed tests for the landing channel: the kind:9002 `landing`, +//! `welcome` and `welcome_title` tags with their exact receipts, the +//! kind:39000 projection, the protections, the NIP-11 advertisement and +//! provisioning's adoption. use std::sync::Arc; @@ -35,19 +36,24 @@ async fn relay_member(state: &AppState, tenant: &TenantContext, role: &str) -> K } /// The landing projection on `channel_id`'s kind:39000: whether it carries -/// `["landing"]`, and its `welcome` text. +/// `["landing"]`, then its `welcome` and `welcome_title` texts. async fn projection( state: &AppState, tenant: &TenantContext, channel_id: Uuid, -) -> (bool, Option<String>) { +) -> (bool, Option<String>, Option<String>) { let tags = group_state_tags(state, tenant, channel_id, 39000).await; let landing = tags.iter().any(|tag| tag.len() == 1 && tag[0] == "landing"); - let welcome = tags - .iter() - .find(|tag| tag.first().map(String::as_str) == Some("welcome")) - .map(|tag| tag.get(1).cloned().unwrap_or_default()); - (landing, welcome) + let text = |name: &str| { + tags.iter() + .find(|tag| tag.first().map(String::as_str) == Some(name)) + .map(|tag| tag.get(1).cloned().unwrap_or_default()) + }; + (landing, text("welcome"), text("welcome_title")) +} + +fn some(text: &str) -> Option<String> { + Some(text.to_string()) } /// NIP-11 `landing_channel` for the tenant's host. @@ -58,13 +64,13 @@ async fn advertised(state: &AppState, tenant: &TenantContext) -> Option<String> } } -/// Only a relay owner or admin may send `landing` or `welcome`, only on a -/// stream, with valid values; every refusal carries the contract's exact -/// receipt text and designates nothing. +/// Only a relay owner or admin may send `landing`, `welcome` or +/// `welcome_title`, only on a stream, with valid values; every refusal +/// carries the contract's exact receipt text and designates nothing. /// -/// Mutations: drop `landing`/`welcome` from the relay-role check → the -/// channel owner's edits are accepted (RED); drop the stream check → the -/// forum is designated (RED). +/// Mutations: drop `landing`/`welcome`/`welcome_title` from the relay-role +/// check → the channel owner's edits are accepted (RED); drop the stream +/// check → the forum is designated (RED). #[tokio::test] #[ignore = "requires Postgres"] async fn landing_tags_need_a_relay_owner_or_admin_and_a_stream() { @@ -76,15 +82,17 @@ async fn landing_tags_need_a_relay_owner_or_admin_and_a_stream() { let stream = create_channel(&state, &tenant, &owner, &[]).await; let forum = create_channel(&state, &tenant, &owner, &[&["channel_type", "forum"]]).await; - let not_authorized = - "invalid: actor not authorized: landing and welcome require a relay owner or admin"; + let not_authorized = "invalid: actor not authorized: landing, welcome and welcome_title require a relay owner or admin"; + let not_landing = "invalid: welcome and welcome_title can only be set on the landing channel"; + let long_title = "a".repeat(buzz_db::landing::WELCOME_TITLE_MAX_CHARS + 1); let cases: &[(&Keys, Uuid, &[&[&str]], &str)] = &[ (&owner, stream, &[&["landing", "true"]], not_authorized), (&owner, stream, &[&["welcome", "Hi"]], not_authorized), + (&owner, stream, &[&["welcome_title", "Hi"]], not_authorized), ( &member, stream, - &[&["landing", "true"], &["welcome", ""]], + &[&["landing", "true"], &["welcome", ""], &["welcome_title", ""]], not_authorized, ), ( @@ -115,14 +123,44 @@ async fn landing_tags_need_a_relay_owner_or_admin_and_a_stream() { &admin, stream, &[&["landing", "true"], &["welcome", "a\u{7}b"]], - "invalid: welcome message must be plain text (line breaks only)", + "invalid: welcome message must not contain control characters other than line breaks and tabs", + ), + ( + &admin, + stream, + &[&["welcome_title"]], + "invalid: welcome_title tag must have a value (text, or empty to restore the default)", + ), + ( + &admin, + stream, + &[&["landing", "true"], &["welcome_title", " "]], + "invalid: welcome title must not be blank", + ), + ( + &admin, + stream, + &[&["landing", "true"], &["welcome_title", long_title.as_str()]], + "invalid: welcome title is too long (max 120 characters)", + ), + ( + &admin, + stream, + &[ + &["landing", "true"], + &["welcome", "Hi"], + &["welcome_title", "Two\nlines"], + ], + "invalid: welcome title must be a single line without control characters", ), ( &admin, stream, - &[&["welcome", "Hi"]], - "invalid: welcome can only be set on the landing channel", + &[&["welcome_title", "Hi"], &["welcome_title", "Hello"]], + "invalid: kind:9002 may include at most one landing, one welcome and one welcome_title tag", ), + (&admin, stream, &[&["welcome", "Hi"]], not_landing), + (&admin, stream, &[&["welcome_title", "Hi"]], not_landing), ]; for (keys, channel, tags, expected) in cases { assert_eq!( @@ -147,13 +185,16 @@ async fn landing_tags_need_a_relay_owner_or_admin_and_a_stream() { /// The client's full-state form designates a channel, is idempotent on the /// current landing channel, and moves the designation. kind:39000 carries -/// `["landing"]` and the custom `welcome` on the landing channel only, the -/// previous landing channel is re-signed without them, and NIP-11 follows. -/// While designated, archive, private, TTL and kind:9008 delete are refused. +/// `["landing"]` and the custom `welcome` and `welcome_title` on the landing +/// channel only, the previous landing channel is re-signed without them, and +/// NIP-11 follows. The three-tag form without `welcome_title` keeps the +/// title. While designated, archive, private, TTL and kind:9008 delete are +/// refused. /// /// Mutations: drop the previous-landing re-emission → `a` keeps its tags /// (RED); emit the tags for every channel → `b` carries them early (RED); -/// drop the 9008 pre-check → the delete is accepted (RED). +/// drop `welcome_title` from the persist parse → the title never appears +/// (RED); drop the 9008 pre-check → the delete is accepted (RED). #[tokio::test] #[ignore = "requires Postgres"] async fn landing_designation_is_projected_advertised_and_protected() { @@ -165,20 +206,27 @@ async fn landing_designation_is_projected_advertised_and_protected() { let b = create_channel(&state, &tenant, &owner, &[]).await; let id = |channel: Uuid| Some(channel.hyphenated().to_string()); + // Markdown source with a `nostr:` link and tab indentation, stored + // and projected byte for byte. + let markdown = "# Hi all!\n\nSay hello in [#intros](nostr:naddr1x).\n\n\tindented"; edit_channel( &state, &tenant, &admin, a, - &[&["landing", "true"], &["welcome", "Hi all!\nSay hello."]], + &[ + &["landing", "true"], + &["welcome", markdown], + &["welcome_title", "Welcome aboard"], + ], ) .await .expect("designate a"); assert_eq!( projection(&state, &tenant, a).await, - (true, Some("Hi all!\nSay hello.".into())) + (true, some(markdown), some("Welcome aboard")) ); - assert_eq!(projection(&state, &tenant, b).await, (false, None)); + assert_eq!(projection(&state, &tenant, b).await, (false, None, None)); assert_eq!(advertised(&state, &tenant).await, id(a)); edit_channel( @@ -189,11 +237,29 @@ async fn landing_designation_is_projected_advertised_and_protected() { &[&["landing", "true"], &["welcome", ""]], ) .await + .expect("the three-tag form on the landing channel"); + assert_eq!( + projection(&state, &tenant, a).await, + (true, None, some("Welcome aboard")), + "an empty welcome restores the default and leaves the title" + ); + edit_channel( + &state, + &tenant, + &admin, + a, + &[ + &["landing", "true"], + &["welcome", ""], + &["welcome_title", ""], + ], + ) + .await .expect("the full-state form on the landing channel"); assert_eq!( projection(&state, &tenant, a).await, - (true, None), - "an empty welcome restores the default" + (true, None, None), + "empty values restore both defaults" ); assert_eq!(advertised(&state, &tenant).await, id(a)); @@ -202,17 +268,21 @@ async fn landing_designation_is_projected_advertised_and_protected() { &tenant, &admin, b, - &[&["landing", "true"], &["welcome", "Moved"]], + &[ + &["landing", "true"], + &["welcome", "Moved"], + &["welcome_title", "New home"], + ], ) .await .expect("move to b"); assert_eq!( projection(&state, &tenant, b).await, - (true, Some("Moved".into())) + (true, some("Moved"), some("New home")) ); assert_eq!( projection(&state, &tenant, a).await, - (false, None), + (false, None, None), "the previous landing channel is re-signed without its tags" ); assert_eq!(advertised(&state, &tenant).await, id(b)); @@ -310,7 +380,10 @@ async fn provisioning_creates_the_landing_general() { .as_deref(), Some("owner") ); - assert_eq!(projection(&state, &tenant, landing).await, (true, None)); + assert_eq!( + projection(&state, &tenant, landing).await, + (true, None, None) + ); assert_eq!( advertised(&state, &tenant).await, Some(landing.hyphenated().to_string()) diff --git a/crates/buzz-relay/src/handlers/mod.rs b/crates/buzz-relay/src/handlers/mod.rs index 49f6d5051f6..dfaab150017 100644 --- a/crates/buzz-relay/src/handlers/mod.rs +++ b/crates/buzz-relay/src/handlers/mod.rs @@ -35,7 +35,7 @@ pub mod imeta; /// Transport-neutral event ingestion pipeline. pub mod ingest; /// The community landing channel: adoption, admission joins and kind:9002 -/// `landing`/`welcome` validation. +/// `landing`/`welcome`/`welcome_title` validation. pub mod landing; #[cfg(test)] mod landing_postgres_tests; diff --git a/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 1cba4c3be82..617361004f1 100644 --- a/crates/buzz-relay/src/handlers/side_effects.rs +++ b/crates/buzz-relay/src/handlers/side_effects.rs @@ -726,7 +726,7 @@ pub async fn validate_admin_event( .any(|t| is_metadata_tag(&t.kind().to_string())); if !has_recognized { return Err(anyhow::anyhow!( - "kind:9002 must include at least one metadata tag (name, about, archived, topic, purpose, visibility, ttl, t, join_role, unlisted, auto_join, landing, welcome)" + "kind:9002 must include at least one metadata tag (name, about, archived, topic, purpose, visibility, ttl, t, join_role, unlisted, auto_join, landing, welcome, welcome_title)" )); } @@ -825,10 +825,11 @@ pub async fn validate_admin_event( } } } - // `landing` and `welcome` are community settings too, with the - // same relay-role authority. Whether the channel may be the - // landing channel, and whether `welcome` targets it, is decided - // in the write, under the channel row lock. + // `landing`, `welcome` and `welcome_title` are community + // settings too, with the same relay-role authority. Whether the + // channel may be the landing channel, and whether `welcome` and + // `welcome_title` target it, is decided in the write, under the + // channel row lock. let sets_landing = super::landing::validate_tags(event)?; if sets_auto_join || sets_landing { let relay_role = state @@ -889,8 +890,9 @@ pub async fn validate_admin_event( // name/about/archived/visibility/ttl/labels/join_role/unlisted // require owner/admin; topic/purpose allow any member who may - // post; auto_join, landing and welcome were decided above by - // relay role, so an event carrying only those needs neither. + // post; auto_join, landing, welcome and welcome_title were + // decided above by relay role, so an event carrying only those + // needs neither. let has_member_tag = event .tags .iter() @@ -1494,7 +1496,7 @@ const MEMBER_METADATA_TAGS: &[&str] = &["topic", "purpose"]; /// kind:9002 tags that a relay owner or admin may change, whatever their /// channel role. -const RELAY_ROLE_METADATA_TAGS: &[&str] = &["auto_join", "landing", "welcome"]; +const RELAY_ROLE_METADATA_TAGS: &[&str] = &["auto_join", "landing", "welcome", "welcome_title"]; /// True when `kind` is a kind:9002 tag that changes channel metadata. fn is_metadata_tag(kind: &str) -> bool { @@ -2424,8 +2426,11 @@ pub(crate) async fn persist_edit_metadata( edit.landing = true; } // Validated during authorization; the empty string restores the - // default. + // default. The message is stored exactly as sent. "welcome" => edit.welcome = Some((!val.is_empty()).then(|| val.to_string())), + "welcome_title" => { + edit.welcome_title = Some((!val.is_empty()).then(|| val.to_string())) + } _ => {} } } @@ -2544,9 +2549,9 @@ pub(crate) async fn persist_edit_metadata( if let Err(e) = emit_group_discovery_events(tenant, state, channel_id).await { warn!(channel = %channel_id, error = %e, "NIP-29 group discovery emission failed"); } - // The previous landing channel's kind:39000 drops its `landing` and - // `welcome` tags. Clients read the designation from NIP-11, so a failed - // re-emission leaves only a stale projection. + // The previous landing channel's kind:39000 drops its `landing`, + // `welcome` and `welcome_title` tags. Clients read the designation from + // NIP-11, so a failed re-emission leaves only a stale projection. if let Some(previous) = write.previous_landing { if let Err(e) = emit_group_discovery_events(tenant, state, previous).await { warn!(channel = %previous, error = %e, "NIP-29 discovery re-emission for the previous landing channel failed"); diff --git a/crates/buzz-relay/src/nip11.rs b/crates/buzz-relay/src/nip11.rs index b8330b92045..9a4978b4c9a 100644 --- a/crates/buzz-relay/src/nip11.rs +++ b/crates/buzz-relay/src/nip11.rs @@ -267,8 +267,9 @@ impl RelayInfo { // without it ignores the tag beside `visibility`, so a client // checks this before opening a channel as unlisted. "buzz-unlisted-channels".to_string(), - // NIP-11 `landing_channel`, kind:9002 `landing`/`welcome` and the - // landing kind:39000 tags (NOSTR.md, Landing channel). + // NIP-11 `landing_channel`, kind:9002 `landing`/`welcome`/ + // `welcome_title` and the landing kind:39000 tags (NOSTR.md, + // Landing channel). "buzz-landing-channel".to_string(), ]; let gif = gif_provider.map(|provider| { diff --git a/crates/buzz-test-client/tests/e2e_relay.rs b/crates/buzz-test-client/tests/e2e_relay.rs index b45fd2018d9..e5b22d6d240 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -431,18 +431,21 @@ async fn advertised_landing_channel() -> (Option<String>, serde_json::Value) { } /// Submit the client's full-state landing kind:9002 (`landing` plus -/// `welcome`, `""` for the default) and return the OK frame. +/// `welcome` and `welcome_title`, `""` for a default) and return the OK +/// frame. async fn set_landing_ws( url: &str, channel_id: &str, signer: &Keys, welcome: &str, + title: &str, ) -> (bool, String) { let event = EventBuilder::new(Kind::Custom(9002), "") .tags([ Tag::parse(["h", channel_id]).unwrap(), Tag::parse(["landing", "true"]).unwrap(), Tag::parse(["welcome", welcome]).unwrap(), + Tag::parse(["welcome_title", title]).unwrap(), Tag::parse(["nonce", &uuid::Uuid::new_v4().to_string()]).unwrap(), ]) .sign_with_keys(signer) @@ -461,23 +464,24 @@ fn has_tag(tags: &[Vec<String>], name: &str) -> bool { .any(|t| t.first().map(String::as_str) == Some(name)) } -/// The `welcome` tag's text, if present. -fn welcome_tag(tags: &[Vec<String>]) -> Option<String> { +/// The text of the tag named `name` (`welcome`, `welcome_title`), if present. +fn text_tag(tags: &[Vec<String>], name: &str) -> Option<String> { tags.iter() - .find(|t| t.first().map(String::as_str) == Some("welcome")) + .find(|t| t.first().map(String::as_str) == Some(name)) .and_then(|t| t.get(1).cloned()) } /// The landing channel journey on the shared relay. A fresh person's AUTH /// joins them to the advertised landing channel, whose kind:39000 carries /// `["landing"]`. A relay owner moves the designation with the client's -/// full-state kind:9002 (twice: it is idempotent); NIP-11 and both -/// channels' kind:39000 follow. The landing channel refuses an archive, a -/// non-admin is refused, and the designation moves back. +/// full-state kind:9002 (twice: it is idempotent), carrying a Markdown +/// welcome and a title; NIP-11 and both channels' kind:39000 follow. The +/// landing channel refuses an archive, a non-admin is refused, and the +/// designation moves back. /// /// The relay is shared with the other e2e tests, so the part that moves the -/// designation runs under `catch_unwind` and the original designation and -/// welcome are restored before any failure is reported. +/// designation runs under `catch_unwind` and the original designation, +/// welcome and title are restored before any failure is reported. #[tokio::test] #[ignore] async fn test_landing_channel_journey() { @@ -518,7 +522,8 @@ async fn test_landing_channel_journey() { ); let original_tags = group_metadata_tags(&url, &owner, &original).await; assert!(has_tag(&original_tags, "landing"), "{original_tags:?}"); - let original_welcome = welcome_tag(&original_tags).unwrap_or_default(); + let original_welcome = text_tag(&original_tags, "welcome").unwrap_or_default(); + let original_title = text_tag(&original_tags, "welcome_title").unwrap_or_default(); let target = create_test_channel(&owner).await; let moved = @@ -527,7 +532,8 @@ async fn test_landing_channel_journey() { .await; // Leave the shared relay as found, even after a failed assertion. - let restored = set_landing_ws(&url, &original, &owner, &original_welcome).await; + let restored = + set_landing_ws(&url, &original, &owner, &original_welcome, &original_title).await; let advertised = advertised_landing_channel().await.0; let archived = set_metadata_ws(&url, &target, &owner, "archived", "true").await; if let Err(panic) = moved { @@ -550,10 +556,11 @@ async fn test_landing_channel_journey() { /// from `original` to `target`; the caller restores it. async fn landing_move_journey(url: &str, owner: &Keys, original: &str, target: &str) { const PROTECTED: &str = "invalid: this is the community's landing channel; make another channel the landing channel first"; - let welcome = "Welcome to the e2e community.\nSay hello."; + let welcome = "Welcome to the **e2e** community.\n\n- Say hello\n\tindented"; + let title = "Welcome, testers"; for attempt in ["move", "idempotent resend"] { assert_eq!( - set_landing_ws(url, target, owner, welcome).await, + set_landing_ws(url, target, owner, welcome, title).await, (true, String::new()), "{attempt}" ); @@ -564,11 +571,17 @@ async fn landing_move_journey(url: &str, owner: &Keys, original: &str, target: & ); let target_tags = group_metadata_tags(url, owner, target).await; assert!(has_tag(&target_tags, "landing"), "{target_tags:?}"); - assert_eq!(welcome_tag(&target_tags).as_deref(), Some(welcome)); + assert_eq!(text_tag(&target_tags, "welcome").as_deref(), Some(welcome)); + assert_eq!( + text_tag(&target_tags, "welcome_title").as_deref(), + Some(title) + ); let previous_tags = group_metadata_tags(url, owner, original).await; assert!( - !has_tag(&previous_tags, "landing") && !has_tag(&previous_tags, "welcome"), - "the previous landing channel drops both tags: {previous_tags:?}" + ["landing", "welcome", "welcome_title"] + .iter() + .all(|name| !has_tag(&previous_tags, name)), + "the previous landing channel drops all three tags: {previous_tags:?}" ); assert_eq!( @@ -576,10 +589,10 @@ async fn landing_move_journey(url: &str, owner: &Keys, original: &str, target: & (false, PROTECTED.to_string()) ); assert_eq!( - set_landing_ws(url, original, &Keys::generate(), "").await, + set_landing_ws(url, original, &Keys::generate(), "", "").await, ( false, - "invalid: actor not authorized: landing and welcome require a relay owner or admin" + "invalid: actor not authorized: landing, welcome and welcome_title require a relay owner or admin" .to_string() ) ); diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md index 5c41ede5714..0b06542d59b 100644 --- a/docs/nips/NIP-CL.md +++ b/docs/nips/NIP-CL.md @@ -67,7 +67,8 @@ Example kind:39000 (signing fields and unrelated tags omitted): | `created_at` | 39000 | Time the channel was created, in unix seconds, as a decimal string. | | `auto_join` | 39000 | Present (bare, no value) while the relay joins new community members into this channel: the channel's `auto_join` setting is on and it is open and unarchived. | | `landing` | 39000 | Present (bare, no value) on the community's landing channel. A projection of the NIP-11 `landing_channel` designation (extension `buzz-landing-channel`), which is authoritative. | -| `welcome` | 39000 | The community's custom welcome message, on the landing channel only. Plain text; `\n` is a line break. Absent while the client default applies. | +| `welcome` | 39000 | The community's custom welcome message, on the landing channel only. Markdown source, stored as sent; the client renders it and its `nostr:` links. Absent while the client default applies. | +| `welcome_title` | 39000 | The custom title of the welcome message, on the landing channel only. One line of plain text. Absent while the client default applies. | **Channel type.** Clients MUST read the channel type from the first `t` tag. Later `t` tags are labels. diff --git a/migrations/0088_community_welcome_title.sql b/migrations/0088_community_welcome_title.sql new file mode 100644 index 00000000000..99e47ee6fa4 --- /dev/null +++ b/migrations/0088_community_welcome_title.sql @@ -0,0 +1,11 @@ +-- The custom title of the community's welcome message, beside the message +-- itself (`welcome_message`, migration 0087). Only a relay owner or admin +-- sets it, with kind:9002 ["welcome_title", text] on the landing channel; +-- the landing channel's kind:39000 carries `welcome_title` while it is +-- customized. NULL means the client's default title. +-- Nullable with no default, so existing rows need no rewrite and the +-- deployed relay, which never reads it, is unaffected. +SET LOCAL lock_timeout = '5s'; + +ALTER TABLE communities + ADD COLUMN welcome_title TEXT; diff --git a/schema/tables/public/communities.sql b/schema/tables/public/communities.sql index 2e4ae7cf04b..3d2abfade95 100644 --- a/schema/tables/public/communities.sql +++ b/schema/tables/public/communities.sql @@ -27,6 +27,9 @@ CREATE TABLE communities ( -- relay keeps the designation on an eligible channel itself. landing_channel_id UUID, welcome_message TEXT, + -- The welcome message's custom title, set via kind:9002 + -- `welcome_title`. Added by migration 0088. + welcome_title TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), archived_at TIMESTAMPTZ, deletion_state TEXT NOT NULL DEFAULT 'active' CHECK (deletion_state IN ('active', 'quiescing', 'fenced', 'tombstone')), From c1d100469b891f389b69a71e6800a9323b4fd649 Mon Sep 17 00:00:00 2001 From: Matt Toohey <contact@matttoohey.com> Date: Fri, 6 Nov 2026 15:16:12 +1100 Subject: [PATCH 05/15] docs(relay): describe welcome links as buzz:// and nostr:npub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The desktop renderer resolves channels and messages through `buzz://channel/<id>` and `buzz://message?channel=<id>&id=<hex>` links and people through `nostr:npub…` identity links; it has no NIP-21 nevent/naddr handling. NOSTR.md and NIP-CL now describe the welcome body as Markdown source whose links clients may resolve in those forms, instead of saying clients turn NIP-21 event links into navigation. The relay stays agnostic and stores the string as-is. The test fixtures use the same link forms. Only comments, docs and string literals changed; no checks were run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com> --- NOSTR.md | 7 ++++--- crates/buzz-db/src/store/landing.rs | 4 ++-- crates/buzz-relay/src/handlers/landing.rs | 3 ++- crates/buzz-relay/src/handlers/landing_postgres_tests.rs | 6 +++--- docs/nips/NIP-CL.md | 2 +- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/NOSTR.md b/NOSTR.md index 0b09f1ff7c8..dfdcabf2037 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -312,9 +312,10 @@ is never more than one. Clients read it from NIP-11: any other channel's 39000. - The welcome message is Markdown source. The relay stores and projects it exactly as received and interprets none of it: rendering the Markdown, - and turning `nostr:` links (NIP-21) to channels, messages and people into - navigation, is a client concern, as is treating the text as untrusted - input. The title is one line of plain text. With no custom message or + and resolving whatever links clients agree on (`buzz://channel/<id>` and + `buzz://message?channel=<id>&id=<hex>` links to channels and messages, + `nostr:npub…` links to people), is a client concern, as is treating the + text as untrusted input. The title is one line of plain text. With no custom message or title, clients show their own default for that part, so a client can customize either alone. diff --git a/crates/buzz-db/src/store/landing.rs b/crates/buzz-db/src/store/landing.rs index c114489ae3b..69379d89410 100644 --- a/crates/buzz-db/src/store/landing.rs +++ b/crates/buzz-db/src/store/landing.rs @@ -563,10 +563,10 @@ mod tests { ("Hi all!\nIntroduce yourself here.", None), (" padded ", None), (&at_limit, None), - // Markdown source: headings, links, nostr: references and + // Markdown source: headings, `buzz://` and `nostr:npub` links and // tab-indented code are all just text to the relay. ( - "# Welcome\n\nSee [#rules](nostr:naddr1abc) and nostr:npub1xyz.\n\n\tcode", + "# Welcome\n\nSee [#rules](buzz://channel/abc) and nostr:npub1xyz.\n\n\tcode", None, ), ("tab\there", None), diff --git a/crates/buzz-relay/src/handlers/landing.rs b/crates/buzz-relay/src/handlers/landing.rs index af53d3948c7..b29ce794b97 100644 --- a/crates/buzz-relay/src/handlers/landing.rs +++ b/crates/buzz-relay/src/handlers/landing.rs @@ -366,7 +366,8 @@ mod tests { let long = "a".repeat(WELCOME_MAX_CHARS + 1); let at_limit = "a".repeat(WELCOME_MAX_CHARS); let long_title = "a".repeat(WELCOME_TITLE_MAX_CHARS + 1); - let markdown = "# Hi!\n\n- read [#rules](nostr:naddr1x)\n\tindented code"; + let markdown = + "# Hi!\n\n- read [#rules](buzz://channel/x) with nostr:npub1x\n\tindented code"; let duplicate = "kind:9002 may include at most one landing, one welcome and one welcome_title tag"; let cases: &[(&[&[&str]], Result<bool, &str>)] = &[ diff --git a/crates/buzz-relay/src/handlers/landing_postgres_tests.rs b/crates/buzz-relay/src/handlers/landing_postgres_tests.rs index b1958b6e08d..179b34da7d4 100644 --- a/crates/buzz-relay/src/handlers/landing_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/landing_postgres_tests.rs @@ -206,9 +206,9 @@ async fn landing_designation_is_projected_advertised_and_protected() { let b = create_channel(&state, &tenant, &owner, &[]).await; let id = |channel: Uuid| Some(channel.hyphenated().to_string()); - // Markdown source with a `nostr:` link and tab indentation, stored - // and projected byte for byte. - let markdown = "# Hi all!\n\nSay hello in [#intros](nostr:naddr1x).\n\n\tindented"; + // Markdown source with a `buzz://` channel link and tab indentation, + // stored and projected byte for byte. + let markdown = "# Hi all!\n\nSay hello in [#intros](buzz://channel/x).\n\n\tindented"; edit_channel( &state, &tenant, diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md index 0b06542d59b..f275f49a073 100644 --- a/docs/nips/NIP-CL.md +++ b/docs/nips/NIP-CL.md @@ -67,7 +67,7 @@ Example kind:39000 (signing fields and unrelated tags omitted): | `created_at` | 39000 | Time the channel was created, in unix seconds, as a decimal string. | | `auto_join` | 39000 | Present (bare, no value) while the relay joins new community members into this channel: the channel's `auto_join` setting is on and it is open and unarchived. | | `landing` | 39000 | Present (bare, no value) on the community's landing channel. A projection of the NIP-11 `landing_channel` designation (extension `buzz-landing-channel`), which is authoritative. | -| `welcome` | 39000 | The community's custom welcome message, on the landing channel only. Markdown source, stored as sent; the client renders it and its `nostr:` links. Absent while the client default applies. | +| `welcome` | 39000 | The community's custom welcome message, on the landing channel only. Markdown source, stored as sent; the client renders it and may resolve its `buzz://` channel and message links and `nostr:npub` identity links. Absent while the client default applies. | | `welcome_title` | 39000 | The custom title of the welcome message, on the landing channel only. One line of plain text. Absent while the client default applies. | **Channel type.** Clients MUST read the channel type from the first `t` tag. From 7240e7826739ebe4cf4432de5b191933619881d4 Mon Sep 17 00:00:00 2001 From: Matt Toohey <contact@matttoohey.com> Date: Fri, 6 Nov 2026 15:38:14 +1100 Subject: [PATCH 06/15] fix(relay): refuse Unicode line separators in the welcome title U+2028 and U+2029 are line breaks a renderer honours but `char::is_control` does not flag. The title rule refuses them alongside control characters, with the same receipt: `welcome title must be a single line without control characters`. The rule table gains both rows, and NOSTR.md names them in the limits bullet and the refusal row. Two over-long NOSTR.md lines are reflowed. No checks were run beyond rustfmt. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com> --- NOSTR.md | 21 +++++++++++---------- crates/buzz-db/src/store/landing.rs | 13 ++++++++++--- 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/NOSTR.md b/NOSTR.md index dfdcabf2037..be530cd488f 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -315,9 +315,9 @@ is never more than one. Clients read it from NIP-11: and resolving whatever links clients agree on (`buzz://channel/<id>` and `buzz://message?channel=<id>&id=<hex>` links to channels and messages, `nostr:npub…` links to people), is a client concern, as is treating the - text as untrusted input. The title is one line of plain text. With no custom message or - title, clients show their own default for that part, so a client can - customize either alone. + text as untrusted input. The title is one line of plain text. With no + custom message or title, clients show their own default for that part, so + a client can customize either alone. **Adoption.** With no designation, the relay adopts the oldest open, unarchived stream named `general` (case-insensitive) without a TTL, @@ -365,8 +365,9 @@ tokens need the `AdminChannels` scope): - The message is 1–4000 Unicode scalar values, not blank, with no control characters other than `\n` and `\t` (so `\r` is refused: send `\n` line endings). -- The title is 1–120 Unicode scalar values, not blank, on one line, with no - control characters at all (`\n` and `\t` included). +- The title is 1–120 Unicode scalar values, not blank, on one line: no + control characters at all (`\n` and `\t` included) and no Unicode line or + paragraph separator (U+2028, U+2029). - The relay stores both exactly as it receives them and validates nothing else about them (see the Markdown note above). - The designation cannot be turned off, only moved. @@ -388,7 +389,7 @@ Refusals (`accepted: false`): | Message with a control character other than `\n` or `\t` | `invalid: welcome message must not contain control characters other than line breaks and tabs` | | Whitespace-only title | `invalid: welcome title must not be blank` | | Title over 120 characters | `invalid: welcome title is too long (max 120 characters)` | -| Title with any control character (`\n` and `\t` included) | `invalid: welcome title must be a single line without control characters` | +| Title with any control character (`\n` and `\t` included) or U+2028/U+2029 | `invalid: welcome title must be a single line without control characters` | | Ineligible target channel | `invalid: landing requires an open, unarchived channel without a ttl` | | `welcome` or `welcome_title` on another channel without `landing` | `invalid: welcome and welcome_title can only be set on the landing channel` | | A protected operation (below) | `invalid: this is the community's landing channel; make another channel the landing channel first` | @@ -406,10 +407,10 @@ allowed. **Rollout.** During a rolling deploy, relay pods from before this feature do not advertise `landing_channel`, enroll nobody at AUTH, do not enforce the protections, and re-sign a channel's kind:39000 without its `landing`, -`welcome` and `welcome_title` tags. The designation itself is unaffected, and a newer pod -re-signs the tags at the channel's next change. A landing channel archived, -made private or given a TTL through an old pod stays designated but -ineligible: the relay counts it +`welcome` and `welcome_title` tags. The designation itself is unaffected, +and a newer pod re-signs the tags at the channel's next change. A landing +channel archived, made private or given a TTL through an old pod stays +designated but ineligible: the relay counts it (`buzz_landing_adoption_total{outcome="existing_ineligible"}`) and logs a warning at each adoption attempt, and nobody is enrolled into it until an owner or admin moves the designation with kind:9002 `landing`. diff --git a/crates/buzz-db/src/store/landing.rs b/crates/buzz-db/src/store/landing.rs index 69379d89410..38e1fa54cb8 100644 --- a/crates/buzz-db/src/store/landing.rs +++ b/crates/buzz-db/src/store/landing.rs @@ -100,14 +100,18 @@ pub fn welcome_message_error(text: &str) -> Option<&'static str> { } /// Why `text` cannot be a custom welcome title, or `None` when it can: one -/// line of text, with no control character at all. The caller handles the -/// empty string, which restores the default. +/// line of text, with no control character and no Unicode line or paragraph +/// separator (U+2028, U+2029). The caller handles the empty string, which +/// restores the default. pub fn welcome_title_error(text: &str) -> Option<&'static str> { if text.trim().is_empty() { Some("welcome title must not be blank") } else if text.chars().count() > WELCOME_TITLE_MAX_CHARS { Some("welcome title is too long (max 120 characters)") - } else if text.chars().any(char::is_control) { + } else if text + .chars() + .any(|c| c.is_control() || matches!(c, '\u{2028}' | '\u{2029}')) + { Some("welcome title must be a single line without control characters") } else { None @@ -609,6 +613,9 @@ mod tests { ("Two\nlines", control), ("tab\there", control), ("bell\u{7}", control), + // Not `char::is_control`, but a line break all the same. + ("line\u{2028}separator", control), + ("paragraph\u{2029}separator", control), ]; for (text, expected) in cases { assert_eq!(welcome_title_error(text), *expected, "{text:?}"); From b50f6c28725bed3dd8de51b23ab51318bc046ec4 Mon Sep 17 00:00:00 2001 From: Matt Toohey <contact@matttoohey.com> Date: Fri, 6 Nov 2026 20:26:31 +1100 Subject: [PATCH 07/15] fix(relay): refuse invisible-only and bidi welcome text The welcome title and message blank check was `str::trim`, so a value made only of invisible characters passed and rendered an empty heading or card: a zero-width space, a Braille blank, a BOM or a Hangul filler. Bidi override, embedding and isolate characters were also accepted in both fields, and they can reorder the displayed text. buzz-db `landing` now defines the shared sets once: - `is_invisible`: U+00AD, U+034F, U+061C, U+115F, U+1160, U+17B4, U+17B5, U+180B-U+180F, U+200B-U+200F, U+202A-U+202E, U+2060-U+206F, U+2800, U+3164, U+FE00-U+FE0F, U+FEFF, U+FFA0, U+FFF9-U+FFFB, U+1D173-U+1D17A and U+E0000-U+E007F. - `is_bidi_control`: U+061C, U+200E, U+200F, U+202A-U+202E and U+2066-U+2069. Each is also invisible. - `is_blank`: every char is Unicode `White_Space` (what `str::trim` strips) or invisible. `welcome_message_error` and `welcome_title_error` apply these rules in order: blank, then bidi, then length, then control characters (and U+2028/U+2029 for the title). Invisible characters next to visible text stay accepted, for example a ZWSP inside a word or the ZWJ in an emoji sequence. A value made only of bidi controls is refused as blank. The caller still treats the exact empty string as "restore the default". New receipts: - `welcome message must not contain bidirectional control characters` - `welcome title must not contain bidirectional control characters` The blank receipts are unchanged, so the existing relay assertions still hold. The buzz-app desktop client mirrors both sets character for character. NIP-CL gains a "Welcome text" paragraph that publishes them. NOSTR.md describes blank as whitespace and invisible characters and adds the two bidi refusal rows. Tests: the `welcome_message_rules` and `welcome_title_rules` tables gain these rows: - invisible-only values refused as blank (ZWSP, U+2800, " \u{200B} ", BOM, the fillers) - a lone U+202E refused as blank - bidi controls among text refused with the new receipts, including before the length and control checks - ZWSP, BOM and ZWJ inside real text accepted - tabs and newlines in the message still accepted A new `invisible_and_bidi_sets` table pins both ends of every range and the code points just outside them. Only `cargo check -p buzz-db --tests` was run, which passed without warnings. The tests were not run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com> --- NOSTR.md | 11 +- crates/buzz-db/src/store/landing.rs | 214 ++++++++++++++++++++++++++-- docs/nips/NIP-CL.md | 21 +++ 3 files changed, 231 insertions(+), 15 deletions(-) diff --git a/NOSTR.md b/NOSTR.md index be530cd488f..f5b1e43bbd7 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -368,6 +368,11 @@ tokens need the `AdminChannels` scope): - The title is 1–120 Unicode scalar values, not blank, on one line: no control characters at all (`\n` and `\t` included) and no Unicode line or paragraph separator (U+2028, U+2029). +- Blank means only whitespace and invisible characters, such as a + zero-width space (U+200B) or a Braille blank (U+2800). Neither value may + contain a bidirectional control character (U+061C, U+200E, U+200F, + U+202A–U+202E, U+2066–U+2069). NIP-CL (**Welcome text**) lists the exact + sets, which clients mirror. - The relay stores both exactly as it receives them and validates nothing else about them (see the Markdown note above). - The designation cannot be turned off, only moved. @@ -384,10 +389,12 @@ Refusals (`accepted: false`): | `welcome` with no value | `invalid: welcome tag must have a value (text, or empty to restore the default)` | | `welcome_title` with no value | `invalid: welcome_title tag must have a value (text, or empty to restore the default)` | | More than one `landing`, `welcome` or `welcome_title` tag | `invalid: kind:9002 may include at most one landing, one welcome and one welcome_title tag` | -| Whitespace-only message | `invalid: welcome message must not be blank` | +| Blank message (only whitespace and invisible characters) | `invalid: welcome message must not be blank` | +| Message with a bidirectional control character | `invalid: welcome message must not contain bidirectional control characters` | | Message over 4000 characters | `invalid: welcome message is too long (max 4000 characters)` | | Message with a control character other than `\n` or `\t` | `invalid: welcome message must not contain control characters other than line breaks and tabs` | -| Whitespace-only title | `invalid: welcome title must not be blank` | +| Blank title (only whitespace and invisible characters) | `invalid: welcome title must not be blank` | +| Title with a bidirectional control character | `invalid: welcome title must not contain bidirectional control characters` | | Title over 120 characters | `invalid: welcome title is too long (max 120 characters)` | | Title with any control character (`\n` and `\t` included) or U+2028/U+2029 | `invalid: welcome title must be a single line without control characters` | | Ineligible target channel | `invalid: landing requires an open, unarchived channel without a ttl` | diff --git a/crates/buzz-db/src/store/landing.rs b/crates/buzz-db/src/store/landing.rs index 38e1fa54cb8..066ae797e05 100644 --- a/crates/buzz-db/src/store/landing.rs +++ b/crates/buzz-db/src/store/landing.rs @@ -80,13 +80,75 @@ impl CommunityLanding { } } +/// Whether `c` renders as nothing on its own: format characters, fillers and +/// blank glyphs that are not Unicode `White_Space`. A welcome message or +/// title made only of these and whitespace is blank ([`is_blank`]); among +/// visible text they are allowed, like the zero-width joiner in an emoji +/// sequence. +/// +/// The set is U+00AD, U+034F, U+061C, U+115F, U+1160, U+17B4, U+17B5, +/// U+180B–U+180F, U+200B–U+200F, U+202A–U+202E, U+2060–U+206F, U+2800, +/// U+3164, U+FE00–U+FE0F, U+FEFF, U+FFA0, U+FFF9–U+FFFB, U+1D173–U+1D17A +/// and U+E0000–U+E007F. NIP-CL publishes it and clients mirror it character +/// for character, so change all of them together. +pub fn is_invisible(c: char) -> bool { + matches!( + c, + '\u{00AD}' + | '\u{034F}' + | '\u{061C}' + | '\u{115F}' + | '\u{1160}' + | '\u{17B4}' + | '\u{17B5}' + | '\u{180B}'..='\u{180F}' + | '\u{200B}'..='\u{200F}' + | '\u{202A}'..='\u{202E}' + | '\u{2060}'..='\u{206F}' + | '\u{2800}' + | '\u{3164}' + | '\u{FE00}'..='\u{FE0F}' + | '\u{FEFF}' + | '\u{FFA0}' + | '\u{FFF9}'..='\u{FFFB}' + | '\u{1D173}'..='\u{1D17A}' + | '\u{E0000}'..='\u{E007F}' + ) +} + +/// Whether `c` is a bidirectional formatting control, which can reorder how +/// the text around it is displayed. Welcome messages and titles refuse them +/// anywhere. +/// +/// The set is U+061C, U+200E, U+200F, U+202A–U+202E and U+2066–U+2069. Every +/// one is also [`is_invisible`], so a value of bidi controls alone is +/// refused as blank. NIP-CL publishes the set and clients mirror it character +/// for character, so change all of them together. +pub fn is_bidi_control(c: char) -> bool { + matches!( + c, + '\u{061C}' | '\u{200E}' | '\u{200F}' | '\u{202A}'..='\u{202E}' | '\u{2066}'..='\u{2069}' + ) +} + +/// Whether every character of `text` is Unicode `White_Space` (what +/// [`str::trim`] strips) or [`is_invisible`], so it would render as an empty +/// heading or card. The empty string is blank. +pub fn is_blank(text: &str) -> bool { + text.chars().all(|c| c.is_whitespace() || is_invisible(c)) +} + /// Why `text` cannot be a custom welcome message, or `None` when it can. -/// The message is Markdown source, so line breaks and tabs are allowed; no -/// other control character is. The caller handles the empty string, which -/// restores the default. +/// The rules apply in order: not [`is_blank`], no [`is_bidi_control`], at +/// most [`WELCOME_MAX_CHARS`], then no control characters. The message is +/// Markdown source, so line breaks and tabs are allowed; no other control +/// character is. The caller handles the empty string, which restores the +/// default. pub fn welcome_message_error(text: &str) -> Option<&'static str> { - if text.trim().is_empty() { + if is_blank(text) { Some("welcome message must not be blank") + } else if text.chars().any(is_bidi_control) { + Some("welcome message must not contain bidirectional control characters") } else if text.chars().count() > WELCOME_MAX_CHARS { Some("welcome message is too long (max 4000 characters)") } else if text @@ -101,11 +163,15 @@ pub fn welcome_message_error(text: &str) -> Option<&'static str> { /// Why `text` cannot be a custom welcome title, or `None` when it can: one /// line of text, with no control character and no Unicode line or paragraph -/// separator (U+2028, U+2029). The caller handles the empty string, which -/// restores the default. +/// separator (U+2028, U+2029). The rules apply in order: not [`is_blank`], +/// no [`is_bidi_control`], at most [`WELCOME_TITLE_MAX_CHARS`], then the +/// single-line rule. The caller handles the empty string, which restores the +/// default. pub fn welcome_title_error(text: &str) -> Option<&'static str> { - if text.trim().is_empty() { + if is_blank(text) { Some("welcome title must not be blank") + } else if text.chars().any(is_bidi_control) { + Some("welcome title must not contain bidirectional control characters") } else if text.chars().count() > WELCOME_TITLE_MAX_CHARS { Some("welcome title is too long (max 120 characters)") } else if text @@ -559,6 +625,9 @@ mod tests { fn welcome_message_rules() { let at_limit = "a".repeat(WELCOME_MAX_CHARS); let over_limit = "é".repeat(WELCOME_MAX_CHARS + 1); + let over_limit_bidi = format!("{over_limit}\u{202E}"); + let blank = Some("welcome message must not be blank"); + let bidi = Some("welcome message must not contain bidirectional control characters"); let control = Some( "welcome message must not contain control characters other than line breaks and tabs", ); @@ -574,9 +643,26 @@ mod tests { None, ), ("tab\there", None), - (" ", Some("welcome message must not be blank")), - ("\n\n", Some("welcome message must not be blank")), - ("\t\n", Some("welcome message must not be blank")), + // Invisible characters are fine beside visible text. + ("Hello\u{200B}world", None), + ("\u{200B}Hi\n\tthere\u{FEFF}", None), + ("Family: 👨\u{200D}👩\u{200D}👧", None), + (" ", blank), + ("\n\n", blank), + ("\t\n", blank), + ("\u{200B}", blank), + ("\u{2800}", blank), + (" \u{200B} ", blank), + ("\n\t\u{FEFF}\u{3164}\n", blank), + ("\u{E0020}\u{1D173}\u{FE0F}", blank), + // Bidi controls are invisible too: alone they are blank. + ("\u{202E}", blank), + ("Hello \u{202E}dlrow", bidi), + ("Hi\n\u{2066}there\u{2069}", bidi), + ("mark\u{200F}", bidi), + // Bidi is checked before length and control characters. + (&over_limit_bidi, bidi), + ("\u{202A}bell\u{7}", bidi), ( &over_limit, Some("welcome message is too long (max 4000 characters)"), @@ -597,15 +683,37 @@ mod tests { fn welcome_title_rules() { let at_limit = "é".repeat(WELCOME_TITLE_MAX_CHARS); let over_limit = "a".repeat(WELCOME_TITLE_MAX_CHARS + 1); + let over_limit_bidi = format!("{over_limit}\u{202E}"); + let blank = Some("welcome title must not be blank"); + let bidi = Some("welcome title must not contain bidirectional control characters"); let control = Some("welcome title must be a single line without control characters"); let cases: &[(&str, Option<&str>)] = &[ ("Welcome to Acme", None), ("👋 Hi, team!", None), (" padded ", None), (&at_limit, None), - ("", Some("welcome title must not be blank")), - (" ", Some("welcome title must not be blank")), - ("\n", Some("welcome title must not be blank")), + // Invisible characters are fine beside visible text. + ("Hello\u{200B}world", None), + ("\u{FEFF}Welcome", None), + ("👨\u{200D}👩\u{200D}👧 Welcome", None), + ("", blank), + (" ", blank), + ("\n", blank), + ("\u{200B}", blank), + ("\u{2800}", blank), + (" \u{200B} ", blank), + ("\u{FEFF}", blank), + ("\u{3164}\u{115F}\u{FFA0}", blank), + ("\u{00AD}\u{034F}\u{180E}\u{2060}", blank), + // Bidi controls are invisible too: alone they are blank. + ("\u{202E}", blank), + ("\u{2066}\u{2069}", blank), + ("Hello \u{202E}dlrow", bidi), + ("\u{2067}Acme\u{2069}", bidi), + ("mark\u{061C}", bidi), + // Bidi is checked before length and the single-line rule. + (&over_limit_bidi, bidi), + ("Two\u{200E}\nlines", bidi), ( &over_limit, Some("welcome title is too long (max 120 characters)"), @@ -621,6 +729,86 @@ mod tests { assert_eq!(welcome_title_error(text), *expected, "{text:?}"); } } + + /// Both ends of every range in the invisible and bidi sets, and the + /// neighbours just outside them. Clients mirror these sets character for + /// character, so this pins the exact boundaries. + #[test] + fn invisible_and_bidi_sets() { + // (character, is_invisible, is_bidi_control) + let cases: &[(char, bool, bool)] = &[ + ('a', false, false), + (' ', false, false), + ('\u{00AC}', false, false), + ('\u{00AD}', true, false), + ('\u{00AE}', false, false), + ('\u{034F}', true, false), + ('\u{061B}', false, false), + ('\u{061C}', true, true), + ('\u{061D}', false, false), + ('\u{115E}', false, false), + ('\u{115F}', true, false), + ('\u{1160}', true, false), + ('\u{1161}', false, false), + ('\u{17B3}', false, false), + ('\u{17B4}', true, false), + ('\u{17B5}', true, false), + ('\u{17B6}', false, false), + ('\u{180A}', false, false), + ('\u{180B}', true, false), + ('\u{180F}', true, false), + ('\u{1810}', false, false), + // U+200A HAIR SPACE is whitespace, not invisible. + ('\u{200A}', false, false), + ('\u{200B}', true, false), + ('\u{200D}', true, false), + ('\u{200E}', true, true), + ('\u{200F}', true, true), + ('\u{2010}', false, false), + ('\u{2029}', false, false), + ('\u{202A}', true, true), + ('\u{202E}', true, true), + ('\u{202F}', false, false), + ('\u{205F}', false, false), + ('\u{2060}', true, false), + ('\u{2065}', true, false), + ('\u{2066}', true, true), + ('\u{2069}', true, true), + ('\u{206A}', true, false), + ('\u{206F}', true, false), + ('\u{2070}', false, false), + ('\u{27FF}', false, false), + ('\u{2800}', true, false), + ('\u{2801}', false, false), + ('\u{3164}', true, false), + ('\u{FDFF}', false, false), + ('\u{FE00}', true, false), + ('\u{FE0F}', true, false), + ('\u{FE10}', false, false), + ('\u{FEFF}', true, false), + ('\u{FFA0}', true, false), + ('\u{FFF8}', false, false), + ('\u{FFF9}', true, false), + ('\u{FFFB}', true, false), + ('\u{FFFC}', false, false), + ('\u{1D172}', false, false), + ('\u{1D173}', true, false), + ('\u{1D17A}', true, false), + ('\u{1D17B}', false, false), + ('\u{DFFFF}', false, false), + ('\u{E0000}', true, false), + ('\u{E007F}', true, false), + ('\u{E0080}', false, false), + ]; + for &(c, invisible, bidi) in cases { + assert_eq!( + (is_invisible(c), is_bidi_control(c)), + (invisible, bidi), + "U+{:04X}", + u32::from(c) + ); + } + } } #[cfg(test)] diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md index f275f49a073..83dd9be6758 100644 --- a/docs/nips/NIP-CL.md +++ b/docs/nips/NIP-CL.md @@ -85,6 +85,27 @@ would mix creators with members. **Creation time.** The `created_at` tag does not change when the channel is edited. Use it, not the event's `created_at`, to compare the age of channels. +**Welcome text.** The relay accepts a `welcome` or `welcome_title` value +only when it is not blank and contains no bidirectional control character. +The length and control-character limits are in NOSTR.md. A client that +validates before sending SHOULD apply these two rules exactly: + +- A value is *blank* when every character is Unicode `White_Space` (what + `str::trim` strips) or one of these invisible characters: U+00AD, U+034F, + U+061C, U+115F, U+1160, U+17B4, U+17B5, U+180B–U+180F, U+200B–U+200F, + U+202A–U+202E, U+2060–U+206F, U+2800, U+3164, U+FE00–U+FE0F, U+FEFF, + U+FFA0, U+FFF9–U+FFFB, U+1D173–U+1D17A and U+E0000–U+E007F. A blank value + would render as an empty heading or card. Invisible characters beside + visible text are allowed, for example a zero-width joiner in an emoji + sequence. +- The bidirectional controls U+061C, U+200E, U+200F, U+202A–U+202E and + U+2066–U+2069 are refused anywhere in the value, because they can reorder + how the text around them is displayed. Each is also invisible, so a value + made only of them is refused as blank. + +On kind:9002 an empty value restores the client default; it is not refused +as blank. kind:39000 then omits the tag. + **Older events.** A relay adds these tags only when it publishes a channel-state event. An event that a relay version without this NIP published can lack them, including one published during an upgrade. Such an event stays From df6ee176355ca60854735802611e2034103cf68f Mon Sep 17 00:00:00 2001 From: Matt Toohey <contact@matttoohey.com> Date: Fri, 6 Nov 2026 21:25:54 +1100 Subject: [PATCH 08/15] fix(relay): add shorthand controls and variation selectors to invisible set Review follow-up. `is_invisible` gains U+1BCA0-U+1BCA3 (shorthand format controls) and U+E0100-U+E01EF (variation selectors 17-256), so a welcome title or message made only of them is refused as blank. The doc comment and the NIP-CL list name both ranges. NOSTR.md points at NIP-CL for the set and has no list of its own, so it is unchanged. NIP-CL now says blank means Unicode `White_Space` without the `str::trim` aside, and tells clients to test the property rather than trim: JavaScript's `String.prototype.trim` strips U+FEFF and keeps U+0085. Tests: `invisible_and_bidi_sets` pins both ends of each new range and the code points outside them; `welcome_title_rules` refuses "\u{E0100}" as blank. Only `cargo check -p buzz-db --tests` was run, which passed without warnings. The tests were not run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com> --- crates/buzz-db/src/store/landing.rs | 18 +++++++++++++++--- docs/nips/NIP-CL.md | 18 ++++++++++-------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/crates/buzz-db/src/store/landing.rs b/crates/buzz-db/src/store/landing.rs index 066ae797e05..e7e3a2e187e 100644 --- a/crates/buzz-db/src/store/landing.rs +++ b/crates/buzz-db/src/store/landing.rs @@ -88,9 +88,10 @@ impl CommunityLanding { /// /// The set is U+00AD, U+034F, U+061C, U+115F, U+1160, U+17B4, U+17B5, /// U+180B–U+180F, U+200B–U+200F, U+202A–U+202E, U+2060–U+206F, U+2800, -/// U+3164, U+FE00–U+FE0F, U+FEFF, U+FFA0, U+FFF9–U+FFFB, U+1D173–U+1D17A -/// and U+E0000–U+E007F. NIP-CL publishes it and clients mirror it character -/// for character, so change all of them together. +/// U+3164, U+FE00–U+FE0F, U+FEFF, U+FFA0, U+FFF9–U+FFFB, U+1BCA0–U+1BCA3, +/// U+1D173–U+1D17A, U+E0000–U+E007F and U+E0100–U+E01EF. NIP-CL publishes +/// it and clients mirror it character for character, so change all of them +/// together. pub fn is_invisible(c: char) -> bool { matches!( c, @@ -111,8 +112,10 @@ pub fn is_invisible(c: char) -> bool { | '\u{FEFF}' | '\u{FFA0}' | '\u{FFF9}'..='\u{FFFB}' + | '\u{1BCA0}'..='\u{1BCA3}' | '\u{1D173}'..='\u{1D17A}' | '\u{E0000}'..='\u{E007F}' + | '\u{E0100}'..='\u{E01EF}' ) } @@ -703,6 +706,7 @@ mod tests { ("\u{2800}", blank), (" \u{200B} ", blank), ("\u{FEFF}", blank), + ("\u{E0100}", blank), ("\u{3164}\u{115F}\u{FFA0}", blank), ("\u{00AD}\u{034F}\u{180E}\u{2060}", blank), // Bidi controls are invisible too: alone they are blank. @@ -791,6 +795,10 @@ mod tests { ('\u{FFF9}', true, false), ('\u{FFFB}', true, false), ('\u{FFFC}', false, false), + ('\u{1BC9F}', false, false), + ('\u{1BCA0}', true, false), + ('\u{1BCA3}', true, false), + ('\u{1BCA4}', false, false), ('\u{1D172}', false, false), ('\u{1D173}', true, false), ('\u{1D17A}', true, false), @@ -799,6 +807,10 @@ mod tests { ('\u{E0000}', true, false), ('\u{E007F}', true, false), ('\u{E0080}', false, false), + ('\u{E00FF}', false, false), + ('\u{E0100}', true, false), + ('\u{E01EF}', true, false), + ('\u{E01F0}', false, false), ]; for &(c, invisible, bidi) in cases { assert_eq!( diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md index 83dd9be6758..352f1dc16e7 100644 --- a/docs/nips/NIP-CL.md +++ b/docs/nips/NIP-CL.md @@ -90,14 +90,16 @@ only when it is not blank and contains no bidirectional control character. The length and control-character limits are in NOSTR.md. A client that validates before sending SHOULD apply these two rules exactly: -- A value is *blank* when every character is Unicode `White_Space` (what - `str::trim` strips) or one of these invisible characters: U+00AD, U+034F, - U+061C, U+115F, U+1160, U+17B4, U+17B5, U+180B–U+180F, U+200B–U+200F, - U+202A–U+202E, U+2060–U+206F, U+2800, U+3164, U+FE00–U+FE0F, U+FEFF, - U+FFA0, U+FFF9–U+FFFB, U+1D173–U+1D17A and U+E0000–U+E007F. A blank value - would render as an empty heading or card. Invisible characters beside - visible text are allowed, for example a zero-width joiner in an emoji - sequence. +- A value is *blank* when every character is Unicode `White_Space` or one + of these invisible characters: U+00AD, U+034F, U+061C, U+115F, U+1160, + U+17B4, U+17B5, U+180B–U+180F, U+200B–U+200F, U+202A–U+202E, + U+2060–U+206F, U+2800, U+3164, U+FE00–U+FE0F, U+FEFF, U+FFA0, + U+FFF9–U+FFFB, U+1BCA0–U+1BCA3, U+1D173–U+1D17A, U+E0000–U+E007F and + U+E0100–U+E01EF. A blank value would render as an empty heading or card. + Invisible characters beside visible text are allowed, for example a + zero-width joiner in an emoji sequence. Test the `White_Space` property + itself, not a trim function: JavaScript's `String.prototype.trim` differs + from it, stripping U+FEFF and keeping U+0085. - The bidirectional controls U+061C, U+200E, U+200F, U+202A–U+202E and U+2066–U+2069 are refused anywhere in the value, because they can reorder how the text around them is displayed. Each is also invisible, so a value From b542c58e6cccf18e940dafff2eedd302a077441d Mon Sep 17 00:00:00 2001 From: Matt Toohey <contact@matttoohey.com> Date: Sat, 7 Nov 2026 01:03:28 +1100 Subject: [PATCH 09/15] fix(relay): admit authors=[self] reads past the #p gate The desktop client decides whether a viewer is a new community member with one read, {authors:[viewer], until:T-600, limit:1}, sent with no kinds, ids, #h or #p. It arrives over POST /query and could arrive as a WebSocket REQ. The relay refused it with a 403 or a restricted: CLOSED. A filter without kinds can match the #p-gated kinds (gift wraps, membership notices, DM visibility snapshots, observer frames and turn metrics), and p_gated_filters_authorized admitted such a filter only when its #p values were all the reader's own pubkey. An authors filter naming only the reader did not count. It counts now. An event the reader signed is always theirs to read. Gift wraps are signed by throwaway keys, and membership notices and DM visibility snapshots by the relay, so authors=[self] can never fish those out of another reader's inbox. The per-result gate (reader_authorized_for_event) still applies to whatever the filter returns, so a result-gated kind is delivered only to the pubkey in its #p, whichever rule admitted the filter. The #p rule, the ids exemption and its result-gated exception, and every other gate are unchanged. The engram and author-only gates already admitted this filter. All four read paths call the same p_gated_filters_authorized: WS REQ, WS COUNT, POST /query and POST /count. The change therefore applies to all of them without touching a call site. Refusal texts are unchanged. The P_GATED_KINDS doc comment and NOSTR.md's subscription constraint name the authors rule. NIP-CL notes that it returns no membership notice. The Landing channel section of NOSTR.md describes the new-member read and says relays must answer it. A new table-driven unit test, p_gate_admits_authors_self, covers: - kindless authors=[self] admitted - authors=[other] and authors=[self, other] refused - #p=[self] still admitted - a gift-wrap filter, admitted with authors=[self] and refused with authors=[other] - kinds without a p-gated kind unaffected - an unscoped sibling filter still refusing the whole REQ - the engram and author-only gates admitting the read Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com> --- NOSTR.md | 12 +++ crates/buzz-core/src/kind.rs | 3 +- crates/buzz-relay/src/handlers/req.rs | 129 ++++++++++++++++++++++++++ docs/nips/NIP-CL.md | 4 +- 4 files changed, 146 insertions(+), 2 deletions(-) diff --git a/NOSTR.md b/NOSTR.md index f5b1e43bbd7..e0d8bd24dee 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -450,6 +450,15 @@ joined to it. both; once the row has committed, the default channels follow even if its announcement failed, because the row is entry evidence. +**New members.** To decide whether a viewer is new, and so whether to show +them the welcome, a client sends one read: `{"authors": ["<viewer>"], +"until": <T - 600>, "limit": 1}`, with no `kinds`, `#h` or `#p`. T is when +the client first opened the community on that device; the ten minutes keep +the events the viewer publishes while joining from counting. An empty result +means the viewer is new. Relays must answer this read, not refuse it: a filter whose +`authors` are all the reader's own pubkey passes the `#p` rule for p-gated +kinds (see [Membership notifications](#membership-notifications)). + ### Group Discovery The relay emits NIP-29 group state events when channels are created, updated, or membership changes. @@ -551,6 +560,9 @@ may sign these. > include a `#p` filter where **all** `#p` values match the authenticated pubkey. The relay rejects > subscriptions that omit `#p` or include other pubkeys (prevents eavesdropping on others' membership > changes and DMs). Error: `restricted: p-gated events require #p matching your pubkey`. +> A filter whose `authors` are **all** the authenticated pubkey is also admitted, with or without +> `#p` or `kinds`: an event the reader signed is theirs to read, and these kinds are signed by the +> relay (44100, 44101) or a throwaway key (1059), so such a filter cannot return another reader's. ```bash nak req -k 44100 -k 44101 --tag "p=<your-hex-pubkey>" \ diff --git a/crates/buzz-core/src/kind.rs b/crates/buzz-core/src/kind.rs index 7e65c30f72e..2410d6c1390 100644 --- a/crates/buzz-core/src/kind.rs +++ b/crates/buzz-core/src/kind.rs @@ -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 diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 39fb6c0fb8d..e040c51b39b 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -1918,6 +1918,26 @@ fn extract_channel_id_from_filters(filters: &[Filter]) -> Option<uuid::Uuid> { found_id } +/// Authorize read access for filters that can match a [`P_GATED_KINDS`] kind: +/// gift wraps, membership notices, DM visibility snapshots, agent observer +/// frames and agent turn metrics. A filter with no `kinds` can match all of +/// them. +/// +/// Such a filter is authorized when at least one of these holds: +/// - `authors` is non-empty and every entry equals the authed pubkey. An +/// event the reader signed is always theirs to read. Gift wraps are +/// signed by throwaway keys, and membership notices and DM visibility +/// snapshots by the relay, so `authors=[self]` can never fish those out +/// of another reader's inbox. Clients rely on this to ask whether the +/// reader has signed anything at all (`{authors:[self], until, limit:1}` +/// with no `kinds`; NOSTR.md "Landing channel"). +/// - `ids` is non-empty and the filter names no [`RESULT_GATED_KINDS`] +/// kind (see below). +/// - `#p` is non-empty and every entry equals the authed pubkey. +/// +/// The per-result gate (`reader_authorized_for_event`) still applies to +/// whatever a passing filter returns, so result-gated kinds are delivered +/// only to the pubkey in their `#p`, whichever rule admitted the filter. pub(crate) fn p_gated_filters_authorized(filters: &[Filter], authed_pubkey_hex: &str) -> bool { let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); filters.iter().all(|filter| { @@ -1929,6 +1949,16 @@ pub(crate) fn p_gated_filters_authorized(filters: &[Filter], authed_pubkey_hex: return true; } + let authors_are_self = filter.authors.as_ref().is_some_and(|authors| { + !authors.is_empty() + && authors + .iter() + .all(|a| a.to_hex().eq_ignore_ascii_case(authed_pubkey_hex)) + }); + if authors_are_self { + return true; + } + // The `ids` exemption ("knowing the id implies authorization") is only // safe for kinds whose id is author-bound or whose content is encrypted. // KIND_DM_VISIBILITY is relay-signed (id not author-bound) and exposes @@ -3736,6 +3766,105 @@ mod tests { ); } + /// `authors=[self]` admits a filter that can match p-gated kinds: an event + /// the reader signed is theirs, and gift wraps (throwaway keys) and + /// membership notices (relay key) are never signed by another reader. The + /// kindless form is the client's "has this viewer written anything" read + /// (NOSTR.md "Landing channel"). + #[test] + fn p_gate_admits_authors_self() { + let p_tag = SingleLetterTag::lowercase(Alphabet::P); + let me = nostr::Keys::generate().public_key(); + let other = nostr::Keys::generate().public_key(); + let authed = me.to_hex(); + let until = nostr::Timestamp::from(1_700_000_000); + let gift_wrap = nostr::Kind::Custom(buzz_core::kind::KIND_GIFT_WRAP as u16); + + let cases = [ + ( + "kindless authors=[self] with until and limit", + Filter::new().author(me).until(until).limit(1), + true, + ), + ( + "kindless authors=[other]", + Filter::new().author(other).until(until).limit(1), + false, + ), + ( + "kindless authors=[self, other]", + Filter::new().authors([me, other]).until(until).limit(1), + false, + ), + ( + "kindless with no authors and no #p", + Filter::new().until(until).limit(1), + false, + ), + ( + "kindless #p=[self]", + Filter::new().custom_tags(p_tag, [authed.as_str()]), + true, + ), + ( + "kindless #p=[other]", + Filter::new().custom_tags(p_tag, [other.to_hex()]), + false, + ), + ( + "authors=[other] with #p=[self]", + Filter::new() + .author(other) + .custom_tags(p_tag, [authed.as_str()]), + true, + ), + ( + "gift wrap authors=[self]", + Filter::new().kind(gift_wrap).author(me), + true, + ), + ( + "gift wrap authors=[other]", + Filter::new().kind(gift_wrap).author(other), + false, + ), + ( + "kinds without a p-gated kind, authors=[other]", + Filter::new() + .kinds([nostr::Kind::Custom(9), nostr::Kind::Custom(39000)]) + .author(other), + true, + ), + ( + "kinds without a p-gated kind, no authors", + Filter::new().kind(nostr::Kind::Custom(9)), + true, + ), + ]; + for (name, filter, admitted) in cases { + assert_eq!( + p_gated_filters_authorized(&[filter], &authed), + admitted, + "{name}" + ); + } + + // Every filter in the REQ must pass: an authors=[self] filter does not + // carry an unscoped sibling through. + let mine = Filter::new().author(me).until(until).limit(1); + let unscoped = Filter::new().until(until); + assert!(!p_gated_filters_authorized(&[mine, unscoped], &authed)); + + // The sibling gates already admit the kindless authors=[self] read, so + // the gate chain on REQ, COUNT, /query and /count lets it through. + let mine = Filter::new().author(me).until(until).limit(1); + assert!(engram_filters_authorized( + std::slice::from_ref(&mine), + &authed + )); + assert!(author_only_filters_authorized(&[mine], &authed)); + } + #[test] fn test_mixed_search_and_non_search_detection() { let search_filter = Filter::new().search("hello"); diff --git a/docs/nips/NIP-CL.md b/docs/nips/NIP-CL.md index 352f1dc16e7..10ba459ecbc 100644 --- a/docs/nips/NIP-CL.md +++ b/docs/nips/NIP-CL.md @@ -274,7 +274,9 @@ type. The `#p` rule for 44100/44101 still applies: it must be the reader's own pubkey. An `ids` filter does not lift it: the relay returns, counts or delivers a 44100/44101 only to the pubkey in its `p` tag, even to a reader -who knows the event id. +who knows the event id. A filter whose `authors` are all the reader's own +pubkey is also admitted, with or without `#p` or `kinds`; it returns no +notice, because only the relay signs them. Because the notices are stored, a client that reconnects can set `since` to the newest notice it has seen. One subscription then returns the notices it From 5bd9241d7054b549e08deeb0cc60752a861ebc92 Mon Sep 17 00:00:00 2001 From: Matt Toohey <contact@matttoohey.com> Date: Sat, 7 Nov 2026 01:26:45 +1100 Subject: [PATCH 10/15] fix(relay): name authors=[self] in the p-gate refusal text The previous commit let p_gated_filters_authorized admit a filter whose authors are all the reader's own pubkey, but every refusal still told the client only that #p must match its pubkey. REQ said "p-gated events require #p matching your pubkey" and COUNT, POST /query and POST /count said "p-gated kinds require #p tag matching your pubkey". A client that read the message could not learn the other way in. All four paths now send one text, the new P_GATED_REFUSAL constant in handlers/req.rs: `restricted: p-gated events require #p or authors matching only your pubkey`. It keeps the restricted: prefix and names both rules a scanning read can meet. The ids exemption serves lookups, not scans, so it stays out of the message. The constant's doc comment says which paths send it, and the call-site comments in count.rs and bridge.rs describe the rule the same way. NOSTR.md's subscription constraint quotes the new text and says which four paths send it. NIP-CL does not quote the text and is unchanged. AGENTS.md gotcha #2 now says that omitting kinds triggers the p-gate unless the filter has #p=[self], authors=[self] or explicit ids. The mobile relay_session_test fixture that quoted the old REQ text word for word now quotes the new one; the test's own assertion matched both. The truncated fixture in observer_subscription_test is still a prefix of the new text and is unchanged, as is the desktop 0.X e2e bridge mock, which still implements the old rule and is out of scope here. The p_gated_filters_authorized doc comment also notes that a kinds-less authors=[self] read may scan a prolific author's events, because idx_events_community_pubkey_kind_created orders kind before created_at. That is accepted for the once-per-device new-member read, and no index changes. Checks run: cargo fmt --all --check, cargo check -p buzz-relay --tests (clean), and the five p-gate unit tests in handlers::req::tests, which pass. dart format cannot resolve the mobile package config in this worktree, so I compared its output for the fixture file at HEAD and after the edit: the only difference is the changed literal, kept on one line. The Postgres and e2e suites were not run. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com> --- AGENTS.md | 2 +- NOSTR.md | 9 ++++---- crates/buzz-relay/src/api/bridge.rs | 6 +++--- crates/buzz-relay/src/handlers/count.rs | 7 ++----- crates/buzz-relay/src/handlers/req.rs | 21 +++++++++++-------- .../test/shared/relay/relay_session_test.dart | 2 +- 6 files changed, 24 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ab25292c9ba..52c7f0be712 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/NOSTR.md b/NOSTR.md index e0d8bd24dee..a45fc077831 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -559,10 +559,11 @@ may sign these. > **Subscription constraint:** Global REQs that can match p-gated kinds (44100, 44101, 1059) **must** > include a `#p` filter where **all** `#p` values match the authenticated pubkey. The relay rejects > subscriptions that omit `#p` or include other pubkeys (prevents eavesdropping on others' membership -> changes and DMs). Error: `restricted: p-gated events require #p matching your pubkey`. -> A filter whose `authors` are **all** the authenticated pubkey is also admitted, with or without -> `#p` or `kinds`: an event the reader signed is theirs to read, and these kinds are signed by the -> relay (44100, 44101) or a throwaway key (1059), so such a filter cannot return another reader's. +> changes and DMs). A filter whose `authors` are **all** the authenticated pubkey is also admitted, +> with or without `#p` or `kinds`: an event the reader signed is theirs to read, and these kinds are +> signed by the relay (44100, 44101) or a throwaway key (1059), so such a filter cannot return +> another reader's. Error, on REQ, COUNT, `POST /query` and `POST /count`: +> `restricted: p-gated events require #p or authors matching only your pubkey`. ```bash nak req -k 44100 -k 44101 --tag "p=<your-hex-pubkey>" \ diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index ff7481e8103..c0a7c906ce6 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -1748,12 +1748,12 @@ async fn query_member_events( .map_err(|()| api_error(StatusCode::BAD_REQUEST, "too many explicit channels"))?; // P-gated kinds (gift wraps, member notifications, observer frames) require - // the caller's own pubkey in the #p tag — same enforcement as WS REQ handler. + // #p or authors naming only the caller — same enforcement as WS REQ handler. let authed_pubkey_hex = pubkey.to_hex(); if !crate::handlers::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) { return Err(api_error( StatusCode::FORBIDDEN, - "restricted: p-gated kinds require #p tag matching your pubkey", + crate::handlers::req::P_GATED_REFUSAL, )); } if !crate::handlers::req::engram_filters_authorized(&filters, &authed_pubkey_hex) { @@ -2433,7 +2433,7 @@ async fn count_member_events( if !crate::handlers::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) { return Err(api_error( StatusCode::FORBIDDEN, - "restricted: p-gated kinds require #p tag matching your pubkey", + crate::handlers::req::P_GATED_REFUSAL, )); } if !crate::handlers::req::engram_filters_authorized(&filters, &authed_pubkey_hex) { diff --git a/crates/buzz-relay/src/handlers/count.rs b/crates/buzz-relay/src/handlers/count.rs index 3672ce6b300..4d7fd1cce42 100644 --- a/crates/buzz-relay/src/handlers/count.rs +++ b/crates/buzz-relay/src/handlers/count.rs @@ -42,13 +42,10 @@ pub async fn handle_count( }; // P-gated kinds (gift wraps, member notifications, observer frames) require - // the caller's own pubkey in the #p tag — same enforcement as WS REQ handler. + // #p or authors naming only the caller — same enforcement as WS REQ handler. let authed_pubkey_hex = hex::encode(&pubkey_bytes); if !super::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) { - conn.send(RelayMessage::closed( - &sub_id, - "restricted: p-gated kinds require #p tag matching your pubkey", - )); + conn.send(RelayMessage::closed(&sub_id, super::req::P_GATED_REFUSAL)); access.deny("p_gated"); return; } diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index e040c51b39b..29996c5b28b 100644 --- a/crates/buzz-relay/src/handlers/req.rs +++ b/crates/buzz-relay/src/handlers/req.rs @@ -377,14 +377,8 @@ pub async fn handle_admitted_req( if channel_id.is_none() { let authed_pubkey_hex = hex::encode(&pubkey_bytes); if !p_gated_filters_authorized(&filters, &authed_pubkey_hex) { - super::close::close_if_owner( - &sub_id, - owner, - Some("restricted: p-gated events require #p matching your pubkey"), - &conn, - &state, - ) - .await; + super::close::close_if_owner(&sub_id, owner, Some(P_GATED_REFUSAL), &conn, &state) + .await; access.deny("p_gated"); return; } @@ -1918,6 +1912,12 @@ fn extract_channel_id_from_filters(filters: &[Filter]) -> Option<uuid::Uuid> { found_id } +/// Refusal text every read path (WS REQ, WS COUNT, `POST /query`, +/// `POST /count`) sends when [`p_gated_filters_authorized`] fails. It names the +/// two rules a scanning read can meet; the `ids` exemption serves lookups only. +pub(crate) const P_GATED_REFUSAL: &str = + "restricted: p-gated events require #p or authors matching only your pubkey"; + /// Authorize read access for filters that can match a [`P_GATED_KINDS`] kind: /// gift wraps, membership notices, DM visibility snapshots, agent observer /// frames and agent turn metrics. A filter with no `kinds` can match all of @@ -1930,7 +1930,10 @@ fn extract_channel_id_from_filters(filters: &[Filter]) -> Option<uuid::Uuid> { /// snapshots by the relay, so `authors=[self]` can never fish those out /// of another reader's inbox. Clients rely on this to ask whether the /// reader has signed anything at all (`{authors:[self], until, limit:1}` -/// with no `kinds`; NOSTR.md "Landing channel"). +/// with no `kinds`; NOSTR.md "Landing channel"). Without `kinds`, +/// `idx_events_community_pubkey_kind_created` orders `kind` before +/// `created_at`, so the read may scan a prolific author's events; that +/// is accepted for the once-per-device new-member read. /// - `ids` is non-empty and the filter names no [`RESULT_GATED_KINDS`] /// kind (see below). /// - `#p` is non-empty and every entry equals the authed pubkey. diff --git a/mobile/test/shared/relay/relay_session_test.dart b/mobile/test/shared/relay/relay_session_test.dart index f065bb7dd33..9a6d3b3ebe2 100644 --- a/mobile/test/shared/relay/relay_session_test.dart +++ b/mobile/test/shared/relay/relay_session_test.dart @@ -1299,7 +1299,7 @@ void main() { session.debugHandleMessage([ 'CLOSED', 'l-1', - 'restricted: p-gated events require #p matching your pubkey', + 'restricted: p-gated events require #p or authors matching only your pubkey', ]); await expectLater( From 8e5b86dccb0f06248803728d6a0c5eb06ffb3e25 Mon Sep 17 00:00:00 2001 From: Matt Toohey <contact@matttoohey.com> Date: Sat, 7 Nov 2026 02:23:36 +1100 Subject: [PATCH 11/15] fix(db): let a racing landing ensure find the general another created concurrent_ensures_designate_one_general failed in CI (run 42234000163): of eight concurrent ensure_landing_channel calls on a community with no general, one returned SkippedNameTaken while the other seven agreed on the created channel. The cause was the name pre-read added in the review follow-up. A caller that found no designation and no adoptable general went on to read whether a listed channel holds the name general, and returned SkippedNameTaken if one did. When another caller created and designated general in between, that fresh, eligible channel counted as a holder, so the late caller reported the name as taken instead of the new designation. The pre-read, now general_name_held_by_ineligible, only counts holders that cannot be adopted: listed and open, but archived, a forum or with a TTL. An eligible holder falls through to create_general, whose FOR NO KEY UPDATE designation re-check waits for the racing creator and returns Existing. A holder that appears after the read is still handled by the ChannelNameTaken fallback. A community whose name is blocked by an archived, forum or TTL general still costs one read and no write transaction, and the read count per ensure is unchanged. ensure_landing_channel_rules now asserts the pre-read for every row: true exactly for the NameTaken rows. Counting an eligible holder again makes the "listed general" row fail on every run, not only when the concurrent test happens to race. Checks: cargo clippy -p buzz-db -p buzz-relay --all-targets -D warnings is clean at the end of this series. The Postgres tests run in CI. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com> --- crates/buzz-db/src/store/landing.rs | 38 ++++++++++++++++++++--------- 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/crates/buzz-db/src/store/landing.rs b/crates/buzz-db/src/store/landing.rs index e7e3a2e187e..814550e2af2 100644 --- a/crates/buzz-db/src/store/landing.rs +++ b/crates/buzz-db/src/store/landing.rs @@ -362,11 +362,13 @@ async fn ensure_landing_channel( let Some(steward) = steward(pool, community_id).await? else { return Ok(LandingAdoption::SkippedNoSteward); }; - // No eligible `general` exists, so a listed holder of the name is one - // that cannot be adopted (archived, a forum, or with a TTL). Read that - // first: the common steady state of such a community is then one read, - // not a write transaction that `idx_channels_listed_name` rolls back. - if general_name_held(pool, community_id).await? { + // Read whether a listed channel that cannot be adopted (archived, a + // forum, or with a TTL) holds the name first: the common steady state of + // such a community is then one read, not a write transaction that + // `idx_channels_listed_name` rolls back. An eligible holder is left to + // `create_general`: one that a concurrent ensure created and designated + // since `adopt_general` read is found by its locked designation re-check. + if general_name_held_by_ineligible(pool, community_id).await? { return Ok(LandingAdoption::SkippedNameTaken); } match create_general(pool, community_id, steward).await { @@ -379,20 +381,22 @@ async fn ensure_landing_channel( } } -/// Whether a listed channel holds the name `general`: the predicate of -/// `idx_channels_listed_name`, which `create_general` would trip. -async fn general_name_held(pool: &PgPool, community_id: CommunityId) -> Result<bool> { +/// Whether a listed channel that cannot be the landing channel holds the +/// name `general`: the predicate of `idx_channels_listed_name`, which +/// `create_general` would trip, without the eligible channels. +async fn general_name_held_by_ineligible(pool: &PgPool, community_id: CommunityId) -> Result<bool> { let mut connection = crate::observability::acquire_writer( pool, crate::observability::WriterOperation::EventWrite, ) .await?; - let held: bool = sqlx::query_scalar( + let held: bool = sqlx::query_scalar(sqlx::AssertSqlSafe(format!( "SELECT EXISTS (SELECT 1 FROM channels \ WHERE community_id = $1 AND lower(name) = $2 \ AND visibility = 'open' AND NOT unlisted \ - AND channel_type IN ('stream', 'forum') AND deleted_at IS NULL)", - ) + AND channel_type IN ('stream', 'forum') AND deleted_at IS NULL \ + AND NOT ({ELIGIBLE_SQL}))" + ))) .bind(community_id.as_uuid()) .bind(LANDING_CHANNEL_NAME) .fetch_one(&mut *connection) @@ -1285,7 +1289,10 @@ mod postgres_tests { /// before an older unlisted" adopts the unlisted one → RED; adopt /// without the eligibility predicate → the archived, forum, ttl and /// private rows adopt them → RED; order the steward by `created_at` - /// alone → "owner before an earlier admin" → RED. + /// alone → "owner before an earlier admin" → RED; count an eligible + /// holder in the name pre-read → "listed general" reads as held, which + /// a concurrent ensure that lost the creation race would report as + /// `SkippedNameTaken` → RED. #[tokio::test] #[ignore = "requires Postgres"] async fn ensure_landing_channel_rules() { @@ -1432,6 +1439,13 @@ mod postgres_tests { stewards.push(relay_member(&db, community, role, seconds_ago).await); } + assert_eq!( + general_name_held_by_ineligible(db.pool(), community) + .await + .expect("name read"), + matches!(expect, Expect::NameTaken), + "{label}: only a channel that cannot be adopted holds the name" + ); let adoption = db .ensure_landing_channel(community) .await From dc7ed543f2ea6b60e66ab27514edecd020abf7a1 Mon Sep 17 00:00:00 2001 From: Matt Toohey <contact@matttoohey.com> Date: Sat, 7 Nov 2026 02:23:54 +1100 Subject: [PATCH 12/15] fix(relay): pass clippy and the pool-checkout scan for the landing channel Two CI failures from run 42234000163, both in the landing channel code. Clippy type_complexity under -D warnings (Rust Lint and Windows Rust): - buzz-relay handlers/landing.rs validate_tags iterated a [(&str, &[Option<&str>], fn(&str) -> Option<&'static str>); 2] table. The table is now a small validate_text_tag function called once for welcome and once for welcome_title. Same rules, same receipt texts. - The landing_and_welcome_tag_rules test table uses a Tags alias for a kind:9002's tag lists. - buzz-db store/landing.rs tests: ensure_landing_channel_rules seeds relay members through a RelayMembers alias, and entry_evidence_rules and put_first_member_since_rules take their rows as EvidenceCase and PutFirstCase aliases. The aliases' doc comments replace the inline column comments. The rows are unchanged. No allow attributes were added. p0_pool_acquisitions_use_typed_operation_pairs_without_other (Unit Tests): the scan allows community.rs exactly one raw fetch_optional on self.pool, for the out-of-scope NIP-11 metadata read. That read was get_community_icon. NIP-11 now calls get_community_discovery, which reads the icon and landing_channel_id in one query, and this branch added it as a second raw checkout. get_community_icon had no production caller left, so it is removed and the relay_admin icon test reads get_community_discovery(...).icon. The NIP-11 read is again the only raw checkout. Checks run: cargo fmt --all --check; cargo clippy -p buzz-db -p buzz-relay --all-targets -- -D warnings (clean); cargo test -p buzz-db --test observability_source (23 passed); the buzz-db landing and community unit tests and the relay landing_and_welcome_tag_rules test (pass). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com> --- crates/buzz-db/src/store/community.rs | 25 --------- crates/buzz-db/src/store/landing.rs | 41 +++++++++------ crates/buzz-relay/src/handlers/landing.rs | 52 +++++++++++-------- crates/buzz-relay/src/handlers/relay_admin.rs | 3 +- 4 files changed, 58 insertions(+), 63 deletions(-) diff --git a/crates/buzz-db/src/store/community.rs b/crates/buzz-db/src/store/community.rs index 7eb29da42f3..8de8a8c9b1c 100644 --- a/crates/buzz-db/src/store/community.rs +++ b/crates/buzz-db/src/store/community.rs @@ -459,30 +459,6 @@ impl Db { .transpose() } - /// Returns the community's workspace icon (NIP-11 `icon`), if set. - /// - /// Set by relay admins/owners via the kind:9033 command; the value is - /// validated and size-capped at that write path. - #[datastore_span(name = "get_community_icon", system = "postgresql")] - pub async fn get_community_icon(&self, community_id: CommunityId) -> Result<Option<String>> { - let row = sqlx::query( - r#" - SELECT icon - FROM communities - WHERE id = $1 - "#, - ) - .bind(community_id.as_uuid()) - .fetch_optional(&self.pool) - .await?; - - Ok(row - .map(|row| row.try_get::<Option<String>, _>("icon")) - .transpose()? - .flatten() - .filter(|icon| !icon.is_empty())) - } - /// Returns the community's workspace icon and landing channel in one /// read, for its NIP-11 document. Read live, so a fresh document reflects /// a committed kind:9002 `landing` or kind:9033. @@ -1073,7 +1049,6 @@ mod postgres_tests { "lookup_community_by_host_for_management", "list_communities_owned_by", "lookup_community_host", - "get_community_icon", "get_community_discovery", "set_community_icon", "ensure_configured_community", diff --git a/crates/buzz-db/src/store/landing.rs b/crates/buzz-db/src/store/landing.rs index 814550e2af2..d8304eb33d7 100644 --- a/crates/buzz-db/src/store/landing.rs +++ b/crates/buzz-db/src/store/landing.rs @@ -1270,6 +1270,9 @@ mod postgres_tests { } } + /// Relay members to seed, as (role, seconds since they joined). + type RelayMembers = Vec<(&'static str, i64)>; + #[derive(Debug, Clone, Copy)] enum Expect { /// The seed at this index is adopted. @@ -1297,7 +1300,7 @@ mod postgres_tests { #[ignore = "requires Postgres"] async fn ensure_landing_channel_rules() { let owner = || vec![("owner", 10)]; - let cases: Vec<(&str, Vec<Seed>, Vec<(&str, i64)>, Expect)> = vec![ + let cases: Vec<(&str, Vec<Seed>, RelayMembers, Expect)> = vec![ ("listed general", vec![general()], vec![], Expect::Reused(0)), ( "name is case-insensitive", @@ -1636,6 +1639,16 @@ mod postgres_tests { id } + /// An `entry_evidence_rules` row: its label, the membership to seed (its + /// channel's shape, `joined_at` and `removed_at`), and whether that is + /// entry evidence in roster mode and in roster-free mode. + type EvidenceCase = ( + &'static str, + Option<(Shape, DateTime<Utc>, Option<DateTime<Utc>>)>, + bool, + bool, + ); + /// `has_entered_community`: which memberships are entry evidence, with /// a roster admission time (roster mode) and without (roster-free). /// @@ -1650,13 +1663,7 @@ mod postgres_tests { admitted - Duration::hours(1), admitted + Duration::minutes(10), ); - // (label, membership, roster-mode evidence, roster-free evidence) - let cases: &[( - &str, - Option<(Shape, DateTime<Utc>, Option<DateTime<Utc>>)>, - bool, - bool, - )] = &[ + let cases: &[EvidenceCase] = &[ ("no memberships", None, false, false), ( "active, joined before admission", @@ -1732,6 +1739,16 @@ mod postgres_tests { } } + /// A `put_first_member_since_rules` row: its label, the existing row + /// (`joined_at`, `removed_at`), the roster admission time, and whether + /// the call writes. + type PutFirstCase = ( + &'static str, + Option<(DateTime<Utc>, Option<DateTime<Utc>>)>, + Option<DateTime<Utc>>, + bool, + ); + /// `put_first_member_since` writes only when no row stands for this /// admission: none at all, or one removed before the roster admission. /// A leave since admission stands, and so does any earlier leave @@ -1748,13 +1765,7 @@ mod postgres_tests { admitted - Duration::hours(1), admitted + Duration::minutes(10), ); - // (label, existing row (joined, removed), admitted_at, writes) - let cases: &[( - &str, - Option<(DateTime<Utc>, Option<DateTime<Utc>>)>, - Option<DateTime<Utc>>, - bool, - )] = &[ + let cases: &[PutFirstCase] = &[ ("no row, roster", None, Some(admitted), true), ("no row, roster-free", None, None, true), ("active row", Some((before, None)), Some(admitted), false), diff --git a/crates/buzz-relay/src/handlers/landing.rs b/crates/buzz-relay/src/handlers/landing.rs index b29ce794b97..fef07bdf233 100644 --- a/crates/buzz-relay/src/handlers/landing.rs +++ b/crates/buzz-relay/src/handlers/landing.rs @@ -65,28 +65,33 @@ pub(crate) fn validate_tags(event: &Event) -> anyhow::Result<bool> { None => anyhow::bail!("landing tag must have a value"), } } - let texts: [(&str, &[Option<&str>], fn(&str) -> Option<&'static str>); 2] = [ - ("welcome", &welcome, buzz_db::landing::welcome_message_error), - ( - "welcome_title", - &title, - buzz_db::landing::welcome_title_error, - ), - ]; - for (name, values, text_error) in texts { - match values.first() { - None | Some(Some("")) => {} - Some(None) => anyhow::bail!( - "{name} tag must have a value (text, or empty to restore the default)" - ), - Some(Some(text)) => { - if let Some(error) = text_error(text) { - anyhow::bail!("{error}"); - } - } + validate_text_tag("welcome", &welcome, buzz_db::landing::welcome_message_error)?; + validate_text_tag( + "welcome_title", + &title, + buzz_db::landing::welcome_title_error, + )?; + Ok(!landing.is_empty() || !welcome.is_empty() || !title.is_empty()) +} + +/// Validates the value of the one `name` tag, if any, with `text_error`. +/// `""` restores the client default and always passes; a tag without a value +/// is refused. +fn validate_text_tag( + name: &str, + values: &[Option<&str>], + text_error: fn(&str) -> Option<&'static str>, +) -> anyhow::Result<()> { + match values.first() { + None | Some(Some("")) => Ok(()), + Some(None) => { + anyhow::bail!("{name} tag must have a value (text, or empty to restore the default)") } + Some(Some(text)) => match text_error(text) { + Some(error) => anyhow::bail!("{error}"), + None => Ok(()), + }, } - Ok(!landing.is_empty() || !welcome.is_empty() || !title.is_empty()) } /// Makes sure the community has a landing channel (see @@ -342,7 +347,10 @@ mod tests { use super::*; use nostr::{EventBuilder, Keys, Kind, Tag}; - fn edit(tags: &[&[&str]]) -> Event { + /// A kind:9002's tags, each as its name and values. + type Tags<'a> = &'a [&'a [&'a str]]; + + fn edit(tags: Tags) -> Event { let tags = tags .iter() .map(|tag| Tag::parse(tag.iter().copied()).expect("tag")); @@ -370,7 +378,7 @@ mod tests { "# Hi!\n\n- read [#rules](buzz://channel/x) with nostr:npub1x\n\tindented code"; let duplicate = "kind:9002 may include at most one landing, one welcome and one welcome_title tag"; - let cases: &[(&[&[&str]], Result<bool, &str>)] = &[ + let cases: &[(Tags, Result<bool, &str>)] = &[ (&[&["h", "x"], &["name", "n"]], Ok(false)), (&[&["landing", "true"]], Ok(true)), (&[&["landing", "true"], &["welcome", ""]], Ok(true)), diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 976a6c8f375..115dbff564f 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -812,9 +812,10 @@ mod postgres_tests { async fn stored_icon(state: &Arc<AppState>, tenant: &TenantContext) -> Option<String> { state .db - .get_community_icon(tenant.community()) + .get_community_discovery(tenant.community()) .await .expect("read icon") + .icon } /// Open relay (`require_relay_membership = false`): a rosterless From 1cb12cbddf8e6f282bbaf6657dd77359d9157be2 Mon Sep 17 00:00:00 2001 From: Matt Toohey <contact@matttoohey.com> Date: Sat, 7 Nov 2026 02:24:08 +1100 Subject: [PATCH 13/15] test(relay): fix the landing channel's Postgres test expectations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rest of the PostgreSQL Tests failures from run 42234000163. All are test fixes; the relay behaves as the landing contract in NOSTR.md says. Main's CI at the base commit 32997e2e1 is green (run 42223420520), so none of these were pre-existing. landing_tags_need_a_relay_owner_or_admin_and_a_stream and landing_designation_is_projected_advertised_and_protected (test): a kind:9002 or kind:9008 refusal from validate_admin_event reaches the test as Err(IngestError::Rejected(message)). The shared edit_channel helper formatted every error with Debug, so the exact-receipt assertions saw Rejected("…") with escaped quotes. edit_channel now returns a Rejected error's message, the text a client sees in its OK false. Callers that match a substring with contains are unaffected. The 9008 delete step had the same problem behind .expect("ingest 9008"), which CI never reached; it now matches Err(Rejected) and compares the message. adding_a_member_auto_joins_default_channels, claim_auto_joins_default_channels_once and auto_join_skips_a_default_channel_archived_while_it_joins (test): these predate the landing channel and counted every kind:44100 for the person across the community. A kind:9030 add and an invite claim now also create the landing general (the community has an owner) and join the person to it, which is one more 44100. The counts now take the channels under test by their h tag, and the claim test asserts the landing membership too. This also fixes a latent race in claim_follow_up_survives_a_client_disconnect: it waited for two notices and then required both default channels. With the landing join counted, it could stop after landing plus the first channel, before the second had joined. roster_auth_recovers_landing_and_defaults_for_a_first_entry (test): a kind:40099 member_joined is relay-signed at one-second resolution with fixed content. Two in the same second share an event id, and the second insert is a no-op. In CI the earlier joins and the racing join all landed in one second, so the count before the race was 1 and the racing join added nothing. The test now takes its baseline after the racing join and releases the paused admission in the next second, so a notice the admission wrongly sent would be a distinct, counted event. migration_schema_listed_name_index_validates_a_prebuild and migration_schema_listed_name_index_needs_the_renames_then_builds_inline (test): after a full run_migrations they expected the ledger at BEFORE_INDEX + 1 (0083), which held only while 0083 was the newest migration. This branch adds 0084 and 0085. They now compare against the newest migration in the migrations directory, as main's thread_window test does. The Postgres suites were not run locally; hosted CI runs them. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com> --- crates/buzz-relay/src/api/invites.rs | 41 ++++++++++++++----- .../postgres_tests/recovery_postgres_tests.rs | 4 +- crates/buzz-relay/src/handlers/auth.rs | 14 +++++-- .../handlers/group_state_postgres_tests.rs | 2 + .../src/handlers/landing_postgres_tests.rs | 15 ++++--- crates/buzz-relay/src/handlers/relay_admin.rs | 5 +++ 6 files changed, 59 insertions(+), 22 deletions(-) diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 2f15061a96a..a2a8a7abc7d 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -1286,11 +1286,13 @@ mod postgres_tests { ); } - /// How many kind:44100 notifications name `member_hex` across the - /// community, one per channel the relay joined them to. + /// How many kind:44100 notifications name `member_hex` in `channels`, + /// one per channel the relay joined them to. Scoped to the channels under + /// test, because a roster admission also joins the landing channel. async fn added_notification_count( state: &AppState, community: buzz_core::CommunityId, + channels: &[Uuid], member_hex: &str, ) -> i64 { state @@ -1299,6 +1301,10 @@ mod postgres_tests { kinds: Some(vec![buzz_core::kind::KIND_MEMBER_ADDED_NOTIFICATION as i32]), p_tag_hex: Some(member_hex.to_string()), global_only: true, + tag_values: vec![( + "h".to_owned(), + channels.iter().map(Uuid::to_string).collect(), + )], ..buzz_db::EventQuery::for_community(community) }) .await @@ -1467,7 +1473,7 @@ mod postgres_tests { "{archived_refusal}" ); assert_eq!( - added_notification_count(&state, community, &joiner_hex).await, + added_notification_count(&state, community, &channels, &joiner_hex).await, 0 ); @@ -1494,6 +1500,18 @@ mod postgres_tests { [Some("member".to_string()), None, None, None], "joined to the effective default channel only" ); + let landing = state + .db + .get_community_landing(community) + .await + .expect("landing") + .channel_id + .expect("the claim designates a landing channel"); + assert_eq!( + channel_roles(&state, community, &[landing], &joiner).await, + [Some("member".to_string())], + "and to the landing channel" + ); assert!( group_state_tags(&state, &tenant, flagged, 39002) .await @@ -1502,7 +1520,7 @@ mod postgres_tests { "kind:39002 of the default channel lists the new member" ); assert_eq!( - added_notification_count(&state, community, &joiner_hex).await, + added_notification_count(&state, community, &channels, &joiner_hex).await, 1 ); assert_eq!( @@ -1539,7 +1557,7 @@ mod postgres_tests { "a re-claim must not resurrect a kind:9022 leave" ); assert_eq!( - added_notification_count(&state, community, &joiner_hex).await, + added_notification_count(&state, community, &channels, &joiner_hex).await, 1 ); assert_eq!( @@ -1566,7 +1584,7 @@ mod postgres_tests { [Some("member".to_string()), None, None, None] ); assert_eq!( - added_notification_count(&state, community, &v1_joiner_hex).await, + added_notification_count(&state, community, &channels, &v1_joiner_hex).await, 1 ); } @@ -1673,7 +1691,7 @@ mod postgres_tests { "kind:39002 must not list the new member" ); assert_eq!( - added_notification_count(&state, community, &joiner_hex).await, + added_notification_count(&state, community, &[starter], &joiner_hex).await, 0 ); assert_eq!( @@ -1724,7 +1742,8 @@ mod postgres_tests { .expect("seed owner"); let first = create_channel(&state, &tenant, &owner, &[]).await; let second = create_channel(&state, &tenant, &owner, &[]).await; - for channel_id in [first, second] { + let defaults = [first, second]; + for channel_id in defaults { edit_channel( &state, &tenant, @@ -1777,16 +1796,16 @@ mod postgres_tests { hold.rollback().await.expect("release the held row"); let deadline = std::time::Instant::now() + Duration::from_secs(10); - while added_notification_count(&state, community, &joiner_hex).await < 2 { + while added_notification_count(&state, community, &defaults, &joiner_hex).await < 2 { assert!( std::time::Instant::now() < deadline, "the auto-join stopped with the dropped claim: {:?}", - channel_roles(&state, community, &[first, second], &joiner).await + channel_roles(&state, community, &defaults, &joiner).await ); tokio::time::sleep(Duration::from_millis(10)).await; } assert_eq!( - channel_roles(&state, community, &[first, second], &joiner).await, + channel_roles(&state, community, &defaults, &joiner).await, [Some("member".to_string()), Some("member".to_string())] ); } diff --git a/crates/buzz-relay/src/api/invites/postgres_tests/recovery_postgres_tests.rs b/crates/buzz-relay/src/api/invites/postgres_tests/recovery_postgres_tests.rs index 8481240ea71..366cc25e986 100644 --- a/crates/buzz-relay/src/api/invites/postgres_tests/recovery_postgres_tests.rs +++ b/crates/buzz-relay/src/api/invites/postgres_tests/recovery_postgres_tests.rs @@ -181,7 +181,7 @@ mod postgres_tests { .unwrap() .is_none()); assert_eq!( - added_notification_count(&state, community, &member_hex).await, + added_notification_count(&state, community, &channels, &member_hex).await, 2 ); } @@ -305,7 +305,7 @@ mod postgres_tests { .unwrap() .is_none()); assert_eq!( - added_notification_count(&state, community, &member_hex).await, + added_notification_count(&state, community, &[channel], &member_hex).await, 1 ); let leave = EventBuilder::new(Kind::Custom(9022), "") diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index e2fe67f2faa..d33ef55c526 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -3545,8 +3545,6 @@ mod tests { .map(|write| write.outcome) .expect("kind:9031 removal"); seed_relay_member(&state, community, &person, "member").await; - let notices_before = - member_joined_notice_count(&state, community, announcements, &person).await; let (done, done_release) = auto_join_done_hook::arm(community); let (reached, release) = auto_join_channel_hook::arm(community); let (conn, _ctrl) = registered_pending_conn(&state, community, "roster-raced"); @@ -3579,6 +3577,16 @@ mod tests { .remove_member(community, announcements, &person_key, &person_key) .await .expect("leave again"); + // A kind:40099 is relay-signed at one-second resolution with fixed + // content, so a second `member_joined` in the racing join's second + // would share its event id and be dropped on insert. Resume the + // admission in a later second, so a notice of its own would count. + let notices_after_race = + member_joined_notice_count(&state, community, announcements, &person).await; + let race_second = chrono::Utc::now().timestamp(); + while chrono::Utc::now().timestamp() == race_second { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } release.notify_one(); done.await.expect("task finishes"); done_release.notify_one(); @@ -3589,7 +3597,7 @@ mod tests { ); assert_eq!( member_joined_notice_count(&state, community, announcements, &person).await, - notices_before + 1, + notices_after_race, "the racing join's notice is the admission's only one" ); } diff --git a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs index 010240aeb45..e69ed38e83d 100644 --- a/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/group_state_postgres_tests.rs @@ -266,6 +266,8 @@ pub(crate) async fn edit_channel( match ingest_event(state, tenant, event, http_auth(keys)).await { Ok(result) if result.accepted => Ok(()), Ok(result) => Err(result.message), + // The receipt text a client sees in its `OK false`. + Err(IngestError::Rejected(message)) => Err(message), Err(error) => Err(format!("{error:?}")), } } diff --git a/crates/buzz-relay/src/handlers/landing_postgres_tests.rs b/crates/buzz-relay/src/handlers/landing_postgres_tests.rs index 179b34da7d4..483ef151a11 100644 --- a/crates/buzz-relay/src/handlers/landing_postgres_tests.rs +++ b/crates/buzz-relay/src/handlers/landing_postgres_tests.rs @@ -12,7 +12,7 @@ use uuid::Uuid; use crate::handlers::group_state_postgres_tests::{ community, create_channel, edit_channel, group_state_tags, http_auth, state, }; -use crate::handlers::ingest::ingest_event; +use crate::handlers::ingest::{ingest_event, IngestError}; use crate::state::AppState; const PROTECTED: &str = @@ -305,11 +305,14 @@ async fn landing_designation_is_projected_advertised_and_protected() { .tags([Tag::parse(["h", &b.to_string()]).expect("h tag")]) .sign_with_keys(&owner) .expect("sign 9008"); - let result = ingest_event(&state, &tenant, delete, http_auth(&owner)) - .await - .expect("ingest 9008"); - assert!(!result.accepted, "the landing channel is not deleted"); - assert_eq!(result.message, PROTECTED); + match ingest_event(&state, &tenant, delete, http_auth(&owner)).await { + Err(IngestError::Rejected(message)) => assert_eq!(message, PROTECTED), + Ok(result) => panic!( + "the landing channel is not deleted: accepted={} {}", + result.accepted, result.message + ), + Err(error) => panic!("the landing channel is not deleted: {error:?}"), + } let after = state .db .get_channel(tenant.community(), b) diff --git a/crates/buzz-relay/src/handlers/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 115dbff564f..d3a6884c7e2 100644 --- a/crates/buzz-relay/src/handlers/relay_admin.rs +++ b/crates/buzz-relay/src/handlers/relay_admin.rs @@ -1066,6 +1066,11 @@ mod postgres_tests { kinds: Some(vec![buzz_core::kind::KIND_MEMBER_ADDED_NOTIFICATION as i32]), p_tag_hex: Some(target_hex), global_only: true, + // Not the landing channel's, which the add also joins. + tag_values: vec![( + "h".to_owned(), + vec![flagged.to_string(), control.to_string()], + )], ..buzz_db::EventQuery::for_community(community) }) .await From 16f3ff5d60f66bda09d81f3ded74c451ef68a9ea Mon Sep 17 00:00:00 2001 From: Matt Toohey <contact@matttoohey.com> Date: Mon, 9 Nov 2026 15:40:04 +1100 Subject: [PATCH 14/15] fix(relay): describe and test landing joins beside the durable default-channel recovery Rebasing onto main brought in the durable default-channel recovery (#9045): a roster write records its pending work with the roster row and each later claim, add or AUTH finishes it. The landing join now runs ahead of that work at the roster write sites and at AUTH, and the branch's own AUTH-time recovery of the default channels is gone, so: - NOSTR.md describes roster-mode AUTH as the landing join (from missing entry evidence) followed by the pending default-channel work, with `buzz-admin add-member` rows joined to the landing channel only, as main's roster-only test requires. - roster_auth_recovers_landing_and_defaults_for_a_first_entry seeds the person with a pending roster admission instead of a bare row, and gains a bare-row step that expects the landing channel alone. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com> --- NOSTR.md | 56 +++++++++++---------- crates/buzz-relay/src/handlers/auth.rs | 68 +++++++++++++++++++++----- 2 files changed, 84 insertions(+), 40 deletions(-) diff --git a/NOSTR.md b/NOSTR.md index a45fc077831..842e4d865c1 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -231,32 +231,33 @@ kind:9030 add is made by someone else, so the new member's `channel_add_policy` applies first, as it would to that actor's kind:9000: if it refuses them, no default channel is joined at the add. The claim response waits for the joins, and a claimer who disconnects before it -arrives does not cut them short. A channel that fails to join, or an add the -member's policy refuses, is logged and counted -(`buzz_auto_join_failures_total`) and not retried at the roster write: the -kind:39000 tag is the record a client reconciles against. That includes a -channel archived or made private while the join runs: the membership write -decides both again under the channel's locks, so the member is not added and -nothing is announced. +arrives does not cut them short. The roster write records this work with +the roster row, so a channel that fails to join is logged and counted +(`buzz_auto_join_failures_total`) and the work stays pending: the next claim, +add or WebSocket AUTH of that member finishes it, each channel once, with the +persisted actor's `channel_add_policy` asked again as at the add. An add the +policy refuses completes the work with no joins. A channel archived or made +private while the join runs is complete for that member: the membership +write decides both again under the channel's locks, so the member is not +added and nothing is announced. The kind:39000 tag is the record a client +reconciles against. On a community that requires relay membership, a person's WebSocket AUTH -also enrolls them, after the OK, when they have **no entry evidence since -their roster row was created**: no membership, active or ended since then, -in any shared (stream or forum) channel other than a private channel they -created themselves (see [Landing channel](#landing-channel)). Such a person -is joined to the landing channel and then to every effective default channel -they have not been a member of since the roster row was created. This is -the recovery for a roster write whose joins did not finish, and it is how a -member added with `buzz-admin add-member` is enrolled: that command writes -the roster row only, and the person's first AUTH does the joins. A person -with any such evidence is never joined at AUTH, so a kind:9022 leave stands -and a reconnect or new device changes nothing; a member whose kind:9030 add -their `channel_add_policy` refused is enrolled at their first AUTH all the -same, because an admission join is their own act (as a kind:9021 is) and the -policy restricts who else may add them. Each channel's write checks its -membership history again under the channel's lock and is announced only when -it wrote a row, so two admissions racing each other join and announce once. -Agents are never enrolled at AUTH. +also joins them, after the OK, in two ways. They are joined to the landing +channel when they have **no entry evidence since their roster row was +created**: no membership, active or ended since then, in any shared (stream +or forum) channel other than a private channel they created themselves (see +[Landing channel](#landing-channel)). Then the pending default-channel work +of their roster write, if any, is finished: each channel's write checks its +membership history since the roster row was created again under the +channel's lock and is announced only when it wrote a row, so a kind:9022 +leave since admission stands and two admissions racing each other join and +announce once. Once that work has finished, no later AUTH joins them to a +default channel, so a reconnect or new device changes nothing. `buzz-admin +add-member` writes the roster row only: the person's first AUTH joins them +to the landing channel and to no default channel. Agents are never joined to +the landing channel; their pending default-channel work is finished at AUTH +as a person's is. A community that does not require relay membership has no roster insert to join people at, so there the relay joins a person when it admits their @@ -432,9 +433,10 @@ joined to it. claim or kind:9030 add. A person's AUTH joins them only if they have no entry evidence since their roster row was created: no membership, active or ended since then, in a shared (stream or forum) channel, other than - private channels they created themselves. That recovers an admission whose - joins did not finish and enrolls a `buzz-admin add-member` row, and it - joins the default channels they have not been in since then too (see + private channels they created themselves. That recovers a roster write + whose landing join did not finish and enrolls a `buzz-admin add-member` + row; the default channels are recovered from the roster write's own + pending record, not from this evidence (see [Default channels](#default-channels)). After a kind:9031 removal and a new roster row, the next admission is a first entry again unless a shared-channel membership from before is still active: kind:9031 removes diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index d33ef55c526..d66593433ad 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -3337,6 +3337,27 @@ mod tests { .count() } + /// A roster admission whose default-channel work was recorded but + /// never run, as an invite claim leaves it when the relay stops + /// before the joins. + async fn seed_pending_relay_member( + state: &crate::state::AppState, + community: buzz_core::tenant::CommunityId, + keys: &Keys, + ) { + let _ = state + .db + .add_relay_member_with_default_channels( + community, + &keys.public_key().to_hex(), + "member", + "invite", + &state.relay_keypair, + ) + .await + .expect("seed pending relay member"); + } + async fn seed_relay_member( state: &crate::state::AppState, community: buzz_core::tenant::CommunityId, @@ -3454,23 +3475,26 @@ mod tests { } /// With a roster, a roster write that did not join the person (here, - /// a bare roster insert, as `buzz-admin add-member` makes) is - /// recovered at AUTH: the landing channel, then the default channels - /// they have not been in since admission. Once they have left both, - /// an admission joins nothing; after a kind:9031 removal, - /// re-admission is a first entry again and restores both, through - /// `put_first_member_since` with the new roster row's `created_at`. + /// an admission whose default-channel work was recorded but never + /// run) is recovered at AUTH: the landing channel, from the missing + /// entry evidence, then the default channels, from the pending + /// record. Once they have left both, an admission joins nothing; + /// after a kind:9031 removal, re-admission is a first entry again + /// and restores both, through the new roster row's `created_at`. /// A recovery paused before the default channel's write, while a /// racing join (an invite claim) and a leave land, writes nothing: - /// the leave stands and the admission adds no second notice. + /// the leave stands and the admission adds no second notice. A bare + /// roster row, as `buzz-admin add-member` writes, is joined to the + /// landing channel only. /// /// Mutations: use `None` for `admitted_at` in roster mode → the /// re-admission joins nothing (RED); drop the roster-mode defaults /// recovery → the default channel is not joined (RED); gate the spawn /// on `!require_relay_membership` again → nothing is joined (RED); - /// recover the defaults with `join_channel_as_self` instead of - /// `put_first_member_since` → the leave is undone and a second - /// `member_joined` notice appears (RED). + /// recover the defaults with `join_channel_as_self` instead of the + /// fenced pending write → the leave is undone and a second + /// `member_joined` notice appears (RED); drop the pending-record + /// check → the bare row is joined to the default channel (RED). #[tokio::test] #[ignore = "requires Postgres — runs in postgres-ci nextest lane"] async fn roster_auth_recovers_landing_and_defaults_for_a_first_entry() { @@ -3483,7 +3507,7 @@ mod tests { seed_relay_member(&state, community, &owner, "owner").await; let announcements = default_channels(&state, community, &owner, &["announcements"]).await[0]; - seed_relay_member(&state, community, &person, "member").await; + seed_pending_relay_member(&state, community, &person).await; admit_and_settle(&state, community, &person, "roster-recover", None).await; let landing = landing_channel(&state, community).await; @@ -3521,7 +3545,7 @@ mod tests { .await .map(|write| write.outcome) .expect("kind:9031 removal"); - seed_relay_member(&state, community, &person, "member").await; + seed_pending_relay_member(&state, community, &person).await; admit_and_settle(&state, community, &person, "roster-readmitted", None).await; assert_eq!( roles("readmitted").await, @@ -3544,7 +3568,7 @@ mod tests { .await .map(|write| write.outcome) .expect("kind:9031 removal"); - seed_relay_member(&state, community, &person, "member").await; + seed_pending_relay_member(&state, community, &person).await; let (done, done_release) = auto_join_done_hook::arm(community); let (reached, release) = auto_join_channel_hook::arm(community); let (conn, _ctrl) = registered_pending_conn(&state, community, "roster-raced"); @@ -3600,6 +3624,24 @@ mod tests { notices_after_race, "the racing join's notice is the admission's only one" ); + + // A bare roster row records no default-channel work, so its first + // AUTH joins the landing channel only. + let roster_only = Keys::generate(); + seed_relay_member(&state, community, &roster_only, "member").await; + admit_and_settle(&state, community, &roster_only, "roster-only", None).await; + assert_eq!( + channel_role(&state, community, landing, &roster_only) + .await + .as_deref(), + Some("member"), + "a bare roster row enters through the landing channel" + ); + assert_eq!( + channel_role(&state, community, announcements, &roster_only).await, + None, + "a bare roster row is not enrolled in default channels" + ); } /// AUTH with NIP-OA records a previously ownerless agent's owner. That From 7de6669e663a6eae06d4466a036f3b19a6251007 Mon Sep 17 00:00:00 2001 From: Matt Toohey <contact@matttoohey.com> Date: Mon, 9 Nov 2026 21:04:48 +1100 Subject: [PATCH 15/15] test(relay): cancel the admission's first blocked channel read in the recovery test temporary_listing_failure_is_recovered_for_each_admission_path, from the durable default-channel recovery (#9045), held an ACCESS EXCLUSIVE lock on channels and cancelled the first waiting query whose text matched the default-channel listing. On this branch the landing join runs ahead of the pending default-channel work, so the statement the lock stalls is the landing channel's eligibility read in ensure_landing_channel, whose text never matches. The cancel loop spun for ten seconds and panicked (CI run 42563282559). The relay behaves as the landing contract says: a landing failure other than a refusal retains the token and the next admission retries both joins. The test now captures pg_backend_pid() on the blocker transaction and cancels with the existing cancel_query_blocked_by helper, which targets whatever that lock stalls. The pattern-matching cancel_blocked_query helper had no other caller and is removed. The test is renamed temporary_first_channel_read_failure_is_recovered_for_each_admission_path, and its doc comment says that the listing-failure branch of finish_default_channel_joins is covered only through the shared token-retention path exercised here. Every later assertion in the loop holds unchanged: pending retained, no channel role, the retry joins, pending cleared, one kind:44100 in the default channel, and a leave standing on re-admission. Checks run with Rust 1.98.1: cargo fmt --all --check; cargo clippy -p buzz-relay --all-targets -- -D warnings (clean); and the three recovery_postgres_tests via scripts/postgres-test-run.sh against the docker Postgres, all passing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Signed-off-by: Matt Toohey <contact@matttoohey.com> --- .../postgres_tests/recovery_postgres_tests.rs | 45 ++++++------------- 1 file changed, 14 insertions(+), 31 deletions(-) diff --git a/crates/buzz-relay/src/api/invites/postgres_tests/recovery_postgres_tests.rs b/crates/buzz-relay/src/api/invites/postgres_tests/recovery_postgres_tests.rs index 366cc25e986..11133e01e1d 100644 --- a/crates/buzz-relay/src/api/invites/postgres_tests/recovery_postgres_tests.rs +++ b/crates/buzz-relay/src/api/invites/postgres_tests/recovery_postgres_tests.rs @@ -4,31 +4,6 @@ mod postgres_tests { use crate::handlers::ingest::ingest_event; use crate::handlers::side_effects::auto_join_default_channels; - // Cancel the real listing query only after PostgreSQL reports that it is - // blocked by our table lock. No timer or production-only failure hook. - async fn cancel_blocked_query(pool: &sqlx::PgPool, pattern: &str) { - tokio::time::timeout(Duration::from_secs(10), async { - loop { - let cancelled: Vec<bool> = sqlx::query_scalar( - "SELECT pg_cancel_backend(pid) FROM pg_stat_activity \ - WHERE datname = current_database() AND wait_event_type = 'Lock' \ - AND query LIKE $1", - ) - .bind(pattern) - .fetch_all(pool) - .await - .expect("cancel blocked default-channel listing"); - if !cancelled.is_empty() { - assert!(cancelled.into_iter().all(|cancelled| cancelled)); - return; - } - tokio::task::yield_now().await; - } - }) - .await - .expect("the admission must reach the actual default-channel listing"); - } - async fn recovery_fixture( label: &str, channel_count: usize, @@ -186,9 +161,17 @@ mod postgres_tests { ); } + /// A temporary failure of the admission's first channel read retains the + /// retry record on every admission path, and the next admission finishes + /// the work. The blocker holds `channels` exclusively and the cancel + /// targets whatever that lock stalls. Since the landing join runs ahead of + /// the pending default-channel work, the stalled statement is the landing + /// channel's eligibility read, not the default-channel listing; the + /// listing-failure branch of `finish_default_channel_joins` is covered + /// only through the shared token-retention path exercised here. #[tokio::test] #[ignore = "requires Postgres"] - async fn temporary_listing_failure_is_recovered_for_each_admission_path() { + async fn temporary_first_channel_read_failure_is_recovered_for_each_admission_path() { let host = format!("join-recovery-{}.example", Uuid::new_v4().simple()); let state = invite_test_state(&host).await.expect("test state"); let community = state @@ -256,16 +239,16 @@ mod postgres_tests { } }; let mut blocker = state.db.pool().begin().await.unwrap(); + let blocker_pid: i32 = sqlx::query_scalar("SELECT pg_backend_pid()") + .fetch_one(&mut *blocker) + .await + .unwrap(); sqlx::query("LOCK TABLE channels IN ACCESS EXCLUSIVE MODE") .execute(&mut *blocker) .await .unwrap(); let request = tokio::spawn(admit()); - cancel_blocked_query( - state.db.pool(), - "%FROM channels%WHERE community_id = $1 AND auto_join%", - ) - .await; + cancel_query_blocked_by(state.db.pool(), blocker_pid).await; tokio::time::timeout(Duration::from_secs(10), request) .await .unwrap()