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 629c4f808a2..842e4d865c1 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`, `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 | +| **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 | @@ -226,20 +226,38 @@ 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 -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. 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 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 @@ -249,10 +267,199 @@ 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 + +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 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: + +```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 message and title are + not published there. +- The relay-signed kind:39000 whose `d` is the landing channel carries a bare + `["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`, `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 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. + +**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 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. + +**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", "<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!\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`, + `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` 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: 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. + +Refusals (`accepted: false`): + +| Condition | `message` | +|---|---| +| 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)` | +| `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` | +| 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` | +| 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` | +| `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 +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. + +**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 +(`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). +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 membership, active + or ended since then, in a shared (stream or forum) channel, other than + 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 + 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. +- Reconnects, new devices and moving the designation never re-enroll anyone. + 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. + +**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 @@ -263,7 +470,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)) | 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 | @@ -354,7 +561,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`. +> 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-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-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..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(), 86); + assert_eq!(migrations.len(), 88); assert!(migrations[83] .sql .as_str() @@ -934,6 +934,25 @@ mod postgres_tests { assert!(crate::test_support::desired_state_schema_sql().contains( "CREATE UNIQUE INDEX idx_channels_listed_name ON channels (community_id, lower(name))" )); + // Landing channel (0087): two nullable community columns, no default, + // so neither the deployed relay nor existing rows notice them. + assert_eq!(migrations[86].version, 87); + let landing = strip_sql_comments(migrations[86].sql.as_str()); + assert!(landing.contains("SET LOCAL lock_timeout = '5s';")); + assert!(landing.contains("ADD COLUMN landing_channel_id UUID")); + assert!(landing.contains("ADD COLUMN welcome_message TEXT")); + assert!(!landing.contains("UPDATE")); + let desired = crate::test_support::desired_state_schema_sql(); + assert!(desired.contains("landing_channel_id UUID,")); + assert!(desired.contains("welcome_message TEXT,")); + // Welcome title (0088): one more nullable community column, the + // same shape. + assert_eq!(migrations[87].version, 88); + let welcome_title = strip_sql_comments(migrations[87].sql.as_str()); + assert!(welcome_title.contains("SET LOCAL lock_timeout = '5s';")); + assert!(welcome_title.contains("ADD COLUMN welcome_title TEXT;")); + assert!(!welcome_title.contains("UPDATE")); + assert!(desired.contains("welcome_title TEXT,")); assert!(migrations[70] .sql .as_str() diff --git a/crates/buzz-db/src/store/channel.rs b/crates/buzz-db/src/store/channel.rs index b868ed8b612..ef3e82002e7 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,17 @@ pub struct ChannelMetadataEdit { /// Set when the actor is the channel's owner or admin. An operator rename /// without it applies only to a listed channel. pub channel_authority: bool, + /// Make the channel the community's landing channel. Decided under the + /// row lock on the state the event leaves behind; a no-op when it already + /// is. + pub landing: bool, + /// Set the community's welcome message (`Some(Some(text))`) or restore + /// the default (`Some(None)`). Accepted only on the landing channel, or + /// with `landing`. + pub welcome: Option<Option<String>>, + /// Set the community's welcome title, or restore the default, as for + /// `welcome`. + pub welcome_title: Option<Option<String>>, } /// The result of [`Db::apply_channel_metadata_event`]. @@ -1634,6 +1654,9 @@ pub struct ChannelMetadataWrite { pub inserted: bool, /// The channel's visibility before this event (`"open"` or `"private"`). pub previous_visibility: String, + /// The channel that stopped being the landing channel when this event + /// moved the designation here. Its kind:39000 needs re-emitting. + pub previous_landing: Option<Uuid>, } pub(crate) async fn apply_channel_metadata_event( @@ -1672,6 +1695,7 @@ pub(crate) async fn apply_channel_metadata_event( stored, inserted, previous_visibility, + previous_landing: None, }); } // Decide the archive state and an operator's right to rename here, @@ -1716,6 +1740,36 @@ pub(crate) async fn apply_channel_metadata_event( )); } } + // `landing`, `welcome` and `welcome_title` are community settings on + // the `communities` row, locked after this channel's row. Eligibility is + // decided here, under the row lock, on the state this event leaves + // behind, so one event cannot designate a channel and archive it. The + // protections (archive, private, TTL) are enforced by the writes below. + let mut previous_landing = None; + if edit.landing || edit.welcome.is_some() || edit.welcome_title.is_some() { + if edit.landing && !landing_eligible_after(&mut locked, &edit).await? { + return Err(DbError::AccessDenied( + crate::landing::LANDING_INELIGIBLE.to_string(), + )); + } + let current = crate::landing::lock_designation(locked.tx()).await?; + if edit.landing { + if current != Some(channel_id) { + crate::landing::set_designation(locked.tx(), channel_id).await?; + previous_landing = current; + } + } else if current != Some(channel_id) { + return Err(DbError::AccessDenied( + crate::landing::WELCOME_NOT_LANDING.to_string(), + )); + } + crate::landing::set_welcome( + locked.tx(), + edit.welcome.as_ref().map(Option::as_deref), + edit.welcome_title.as_ref().map(Option::as_deref), + ) + .await?; + } crate::insert_mentions_in_transaction(locked.tx(), event, Some(channel_id)).await?; let ChannelMetadataEdit { @@ -1725,6 +1779,9 @@ pub(crate) async fn apply_channel_metadata_event( archived, operator_rename: _, channel_authority: _, + landing: _, + welcome: _, + welcome_title: _, } = edit; let actor = event.pubkey.to_bytes(); if !columns.is_empty() { @@ -1753,9 +1810,38 @@ pub(crate) async fn apply_channel_metadata_event( stored, inserted, previous_visibility, + previous_landing, }) } +/// Whether the locked channel may be the landing channel once `edit` +/// applies: an open, unarchived stream without a TTL. +async fn landing_eligible_after( + locked: &mut ChannelSettingsWriter<'_>, + edit: &ChannelMetadataEdit, +) -> Result<bool> { + let channel_id = locked.channel_id; + let community_id = locked.tx.community(); + let (channel_type, ttl_seconds): (String, Option<i32>) = sqlx::query_as( + "SELECT channel_type::text, ttl_seconds FROM channels WHERE community_id = $1 AND id = $2", + ) + .bind(community_id.as_uuid()) + .bind(channel_id) + .fetch_one(locked.tx.conn()) + .await?; + let visibility = edit + .columns + .visibility + .as_deref() + .unwrap_or(locked.previous_visibility()); + let archived = edit.archived.unwrap_or(locked.archived()); + let ttl_seconds = edit.columns.ttl_seconds.unwrap_or(ttl_seconds); + Ok(channel_type == ChannelType::Stream.as_str() + && visibility == ChannelVisibility::Open.as_str() + && !archived + && ttl_seconds.is_none()) +} + /// Soft-delete a channel by setting `deleted_at = NOW()`. /// /// Returns `Ok(true)` if the channel was deleted, `Ok(false)` if already @@ -1833,6 +1919,9 @@ async fn soft_delete_on(w: &mut ChannelSettingsWriter<'_>) -> Result<()> { } let channel_id = w.channel_id; let community_id = w.tx.community(); + // Every delete path ends here, so the landing channel is kept even when + // its designation lands between a caller's check and this write. + crate::landing::refuse_if_landing(w.tx, channel_id).await?; sqlx::query("UPDATE channels SET deleted_at = NOW() WHERE community_id = $1 AND id = $2") .bind(community_id.as_uuid()) .bind(channel_id) diff --git a/crates/buzz-db/src/store/channel_members.rs b/crates/buzz-db/src/store/channel_members.rs index e0487877f9b..115530e41db 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<Option<MemberRecord>> { - 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<DateTime<Utc>>, +) -> Result<Option<MemberRecord>> { + 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<Option<MemberRecord>> { + 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<Uuid>, + admitted_at: Option<DateTime<Utc>>, ) -> Result<Option<MemberRecord>> { 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<DateTime<Utc>>,)> = 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<DateTime<Utc>>, +) -> Result<bool> { + 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,20 @@ impl Db { pubkey: &[u8], pending: Uuid, ) -> Result<Option<MemberRecord>> { - 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 + } + + /// [`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<Vec<Result<PutMemberResult>>> { + 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 @@ -2400,6 +2489,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<DateTime<Utc>>, + ) -> Result<Option<MemberRecord>> { + 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<DateTime<Utc>>, + ) -> Result<bool> { + 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..8de8a8c9b1c 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<String>, + /// Landing channel (NIP-11 `landing_channel`), if designated. + pub landing_channel_id: Option<Uuid>, +} + /// Community row returned by an atomic create-with-owner operation. #[derive(Debug, Clone, PartialEq, Eq)] pub struct CreatedCommunityRecord { @@ -450,28 +459,24 @@ 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. + #[datastore_span(name = "get_community_discovery", system = "postgresql")] + pub async fn get_community_discovery( + &self, + community_id: CommunityId, + ) -> Result<CommunityDiscovery> { + let row: Option<(Option<String>, Option<Uuid>)> = + 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. @@ -1044,7 +1049,7 @@ 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", "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..d8304eb33d7 --- /dev/null +++ b/crates/buzz-db/src/store/landing.rs @@ -0,0 +1,1827 @@ +//! The community's landing channel and its welcome message. +//! +//! 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 +//! 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 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. 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"; + +/// 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<Uuid>, + /// 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]` 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()]]; + 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 + } +} + +/// 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+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, + '\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}' + | '\u{E0100}'..='\u{E01EF}' + ) +} + +/// 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 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 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 + .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 and no Unicode line or paragraph +/// 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 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 + .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 + } +} + +/// What [`Db::ensure_landing_channel`] found or did. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +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 + /// 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 listed channel + /// that cannot be the landing channel (archived, a forum, or with a TTL). + SkippedNameTaken, +} + +impl LandingAdoption { + /// The landing channel after the call, if any. + pub fn channel_id(&self) -> Option<Uuid> { + match *self { + Self::Existing(id) | Self::ExistingIneligible(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::ExistingIneligible(_) => Some("existing_ineligible"), + 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<CommunityLanding> { + 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()), + }) +} + +/// 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<Option<Uuid>> { + 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(()) +} + +/// 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 = 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(()) +} + +/// 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<Option<Uuid>> = + 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<LandingAdoption> { + let current: Option<(Uuid, bool)> = { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::Authorization, + ) + .await?; + // 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? + }; + 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); + } + let Some(steward) = steward(pool, community_id).await? else { + return Ok(LandingAdoption::SkippedNoSteward); + }; + // 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 { + // 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)), + created => created, + } +} + +/// 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(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 NOT ({ELIGIBLE_SQL}))" + ))) + .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( + pool: &PgPool, + community_id: CommunityId, +) -> Result<Option<LandingAdoption>> { + let candidate: Option<Uuid> = { + 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<Option<[u8; 32]>> { + let mut connection = crate::observability::acquire_writer( + pool, + crate::observability::WriterOperation::EventWrite, + ) + .await?; + let pubkey: Option<String> = 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<LandingAdoption> { + 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 and + /// title. + #[datastore_span(name = "get_community_landing", system = "postgresql")] + pub async fn get_community_landing( + &self, + community_id: CommunityId, + ) -> Result<CommunityLanding> { + 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 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. + #[datastore_span(name = "ensure_landing_channel", system = "postgresql")] + pub async fn ensure_landing_channel( + &self, + community_id: CommunityId, + ) -> Result<LandingAdoption> { + 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::<Vec<_>>(); + 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."]), + 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), + 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 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", + ); + let cases: &[(&str, Option<&str>)] = &[ + ("Welcome!", None), + ("Hi all!\nIntroduce yourself here.", None), + (" padded ", None), + (&at_limit, None), + // Markdown source: headings, `buzz://` and `nostr:npub` links and + // tab-indented code are all just text to the relay. + ( + "# Welcome\n\nSee [#rules](buzz://channel/abc) and nostr:npub1xyz.\n\n\tcode", + None, + ), + ("tab\there", None), + // 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)"), + ), + ("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: 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 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), + // 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{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. + ("\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)"), + ), + ("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:?}"); + } + } + + /// 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{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), + ('\u{1D17B}', false, false), + ('\u{DFFFF}', false, false), + ('\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!( + (is_invisible(c), is_bidi_control(c)), + (invisible, bidi), + "U+{:04X}", + u32::from(c) + ); + } + } +} + +#[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<u8> { + 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<i32>, + ) -> 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<ChannelMetadataWrite> { + 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() + } + } + + /// 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: text(welcome), + welcome_title: text(title), + ..Default::default() + } + } + + fn refusal<T>(result: Result<T>) -> 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>, title: Option<&str>) -> CommunityLanding { + CommunityLanding { + channel_id: Some(channel_id), + welcome: welcome.map(str::to_string), + welcome_title: title.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<u8> { + 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 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; 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() { + 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", "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"), Some("Welcome aboard")) + ); + + 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, None), + "empty values restore the defaults" + ); + + for edit in [ + ChannelMetadataEdit { + welcome_title: text("Hello"), + ..Default::default() + }, + ChannelMetadataEdit { + welcome: text("Second"), + ..Default::default() + }, + ] { + apply(&db, community, a, edit) + .await + .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"), Some("Hello")) + ); + + 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"), Some("Hello")), + "the welcome and its title move 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"), Some("Hello")), + "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<i32>, + } + + const fn general() -> Seed { + Seed { + name: "general", + channel_type: ChannelType::Stream, + visibility: ChannelVisibility::Open, + unlisted: false, + archived: false, + ttl_seconds: None, + } + } + + /// 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. + 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; 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() { + let owner = || vec![("owner", 10)]; + let cases: Vec<(&str, Vec<Seed>, RelayMembers, 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); + } + + 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 + .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::<Vec<_>>() { + 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<Utc>, + removed: Option<DateTime<Utc>>, + ) -> 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 + } + + /// 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). + /// + /// 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), + ); + let cases: &[EvidenceCase] = &[ + ("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"); + } + } + + /// 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 + /// 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), + ); + 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), + ( + "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/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/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..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, @@ -181,14 +156,22 @@ 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 ); } + /// 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() @@ -305,7 +288,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 ecbc7d9e0d0..d66593433ad 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<ConnectionState>, 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,355 @@ 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<String> { + state + .db + .get_member_role(community, channel, &keys.public_key().to_bytes()) + .await + .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::<serde_json::Value>(&stored.event.content).ok() + }) + .filter(|content| { + content["type"] == "member_joined" && content["target"] == member_hex + }) + .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, + 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, + /// 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. 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 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() { + 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_pending_relay_member(&state, community, &person).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_pending_relay_member(&state, community, &person).await; + admit_and_settle(&state, community, &person, "roster-readmitted", None).await; + assert_eq!( + 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_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"); + 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"); + // 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(); + 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_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 /// 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/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/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/ingest.rs b/crates/buzz-relay/src/handlers/ingest.rs index 46a1c81f282..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 or auto_join 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(); - k == "archived" || k == "auto_join" + 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 new file mode 100644 index 00000000000..fef07bdf233 --- /dev/null +++ b/crates/buzz-relay/src/handlers/landing.rs @@ -0,0 +1,466 @@ +//! 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]` 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; + +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`, `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, welcome and welcome_title require a relay owner or admin"; + +/// 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 (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 + .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::<Vec<_>>() + }; + 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 { + 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"), + } + } + 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(()), + }, + } +} + +/// 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<AppState>, +) -> anyhow::Result<Option<Uuid>> { + 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::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); + 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<AppState>, 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<AppState>) { + 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 membership write 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. So does a committed row +/// whose announcement failed: the row is entry evidence, so no AUTH would +/// recover the defaults if they were skipped here. +pub(crate) async fn join_landing_on_roster_admission( + tenant: &TenantContext, + state: &Arc<AppState>, + 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::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, + 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 membership write 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 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 +/// 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 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<AppState>, + pubkey: &[u8], + gate: &Arc<SessionAdmissionGate>, +) { + 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<AppState>, + pubkey: &[u8], + gate: &Arc<SessionAdmissionGate>, +) { + 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; + } + }; + // `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 + // 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 membership write on admission failed; default channels wait for the next admission"); + return; + } + } + } + } + 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; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + /// 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")); + EventBuilder::new(Kind::Custom(9002), "") + .tags(tags) + .sign_with_keys(&Keys::generate()) + .expect("sign") + } + + /// 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() { + 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](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: &[(Tags, Result<bool, &str>)] = &[ + (&[&["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)), + (&[&["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"), + ), + ( + &[&["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)"), + ), + ( + &[&["welcome_title"]], + Err("welcome_title tag must have a value (text, or empty to restore the default)"), + ), + ( + &[&["landing", "true"], &["landing", "true"]], + Err(duplicate), + ), + (&[&["welcome", "a"], &["welcome", "b"]], Err(duplicate)), + ( + &[&["welcome_title", "a"], &["welcome_title", "b"]], + Err(duplicate), + ), + ( + &[&["welcome", " "]], + Err(welcome_message_error(" ").expect("blank refused")), + ), + ( + &[&["welcome", long.as_str()]], + 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")), + ), + ( + &[&["landing", "true"], &["welcome_title", "Two\nlines"]], + Err(welcome_title_error("Two\nlines").expect("title line break 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..483ef151a11 --- /dev/null +++ b/crates/buzz-relay/src/handlers/landing_postgres_tests.rs @@ -0,0 +1,394 @@ +//! 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; + +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, IngestError}; +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"]`, then its `welcome` and `welcome_title` texts. +async fn projection( + state: &AppState, + tenant: &TenantContext, + channel_id: Uuid, +) -> (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 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. +async fn advertised(state: &AppState, tenant: &TenantContext) -> Option<String> { + 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`, `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`/`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() { + 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, 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", ""], &["welcome_title", ""]], + 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 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_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!( + 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` 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 `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() { + 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()); + + // 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, + &admin, + a, + &[ + &["landing", "true"], + &["welcome", markdown], + &["welcome_title", "Welcome aboard"], + ], + ) + .await + .expect("designate a"); + assert_eq!( + projection(&state, &tenant, a).await, + (true, some(markdown), some("Welcome aboard")) + ); + assert_eq!(projection(&state, &tenant, b).await, (false, None, None)); + assert_eq!(advertised(&state, &tenant).await, id(a)); + + edit_channel( + &state, + &tenant, + &admin, + a, + &[&["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, None), + "empty values restore both defaults" + ); + assert_eq!(advertised(&state, &tenant).await, id(a)); + + edit_channel( + &state, + &tenant, + &admin, + b, + &[ + &["landing", "true"], + &["welcome", "Moved"], + &["welcome_title", "New home"], + ], + ) + .await + .expect("move to b"); + assert_eq!( + projection(&state, &tenant, b).await, + (true, some("Moved"), some("New home")) + ); + assert_eq!( + projection(&state, &tenant, a).await, + (false, None, 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"); + 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) + .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, 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..dfaab150017 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`/`welcome_title` 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/relay_admin.rs b/crates/buzz-relay/src/handlers/relay_admin.rs index 976a6c8f375..d3a6884c7e2 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 @@ -1065,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 diff --git a/crates/buzz-relay/src/handlers/req.rs b/crates/buzz-relay/src/handlers/req.rs index 39fb6c0fb8d..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,35 @@ 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 +/// 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"). 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. +/// +/// 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 +1952,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 +3769,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/crates/buzz-relay/src/handlers/side_effects.rs b/crates/buzz-relay/src/handlers/side_effects.rs index 53bbbf10574..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)" + "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,21 +825,36 @@ pub async fn validate_admin_event( } } } - if sets_auto_join { + // `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 .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 +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 was decided above by relay role, so an event - // carrying only that tag 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() @@ -930,7 +946,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 +1018,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 +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"]; +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 { @@ -1684,6 +1707,10 @@ pub async fn emit_group_discovery_events( state: &Arc<AppState>, 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 +1721,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::<Result<Vec<_>, _>>()?) @@ -2389,6 +2418,19 @@ 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. 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())) + } _ => {} } } @@ -2507,6 +2549,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`, + // `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"); + } + } Ok((write.stored, true)) } @@ -2937,6 +2987,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::<SelfJoinRefused>() + || matches!( + error.downcast_ref::<buzz_db::DbError>(), + 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 { @@ -2955,20 +3022,41 @@ pub(crate) async fn join_channel_as_self( state: &Arc<AppState>, channel_id: Uuid, pubkey: &[u8], +) -> anyhow::Result<SelfJoinOutcome> { + 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<AppState>, + channel_id: Uuid, + pubkey: &[u8], ) -> anyhow::Result<SelfJoinOutcome> { // Only open channels allow self-join via kind:9021. let channel = state .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. @@ -2986,18 +3074,33 @@ pub(crate) async fn join_channel_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?; - announce_self_join(tenant, state, channel_id, pubkey).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) } /// 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<AppState>, channel_id: Uuid, @@ -3039,9 +3142,29 @@ 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 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. +/// +/// 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<AppState>, @@ -3081,6 +3204,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 +3218,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 +3231,21 @@ 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 membership write 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 +3310,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 +3324,11 @@ 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<AppState>, - pubkey: &[u8], - gate: &Arc<crate::nip_fi_gate::SessionAdmissionGate>, -) { - 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( +/// +/// 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<AppState>, pubkey: &[u8], @@ -3214,34 +3348,52 @@ 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<AppState>, + channel_id: Uuid, + pubkey: &[u8], + gate: &Arc<crate::nip_fi_gate::SessionAdmissionGate>, +) -> 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-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..9a4978b4c9a 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<String>, + /// 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<String>, /// Host-bound atomic read-state snapshot capability; absent on unresolved hosts. #[serde(skip_serializing_if = "Option::is_none")] pub read_state_snapshot: Option<serde_json::Value>, @@ -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,10 @@ 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`/ + // `welcome_title` 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 +299,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 +463,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 +482,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 +550,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<String> { +) -> 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 +795,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 +814,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..e5b22d6d240 100644 --- a/crates/buzz-test-client/tests/e2e_relay.rs +++ b/crates/buzz-test-client/tests/e2e_relay.rs @@ -413,6 +413,191 @@ 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<String>, 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` 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) + .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) +} + +/// Whether `tags` includes one named `name`. +fn has_tag(tags: &[Vec<String>], name: &str) -> bool { + tags.iter() + .any(|t| t.first().map(String::as_str) == Some(name)) +} + +/// 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(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), 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, +/// welcome and title are restored before any failure is reported. +#[tokio::test] +#[ignore] +async fn test_landing_channel_journey() { + use futures_util::FutureExt; + let url = relay_url(); + let owner = Keys::generate(); + seed_relay_owner(&owner).await; + + 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_tag(&original_tags, "landing"), "{original_tags:?}"); + 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 = + 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, &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 { + 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.\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, title).await, + (true, String::new()), + "{attempt}" + ); + } + assert_eq!( + advertised_landing_channel().await.0.as_deref(), + Some(target) + ); + let target_tags = group_metadata_tags(url, owner, target).await; + assert!(has_tag(&target_tags, "landing"), "{target_tags:?}"); + 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!( + ["landing", "welcome", "welcome_title"] + .iter() + .all(|name| !has_tag(&previous_tags, name)), + "the previous landing channel drops all three 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, welcome and welcome_title require a relay owner or admin" + .to_string() + ) + ); +} + #[tokio::test] #[ignore] async fn test_invite_claim_rejects_invalid_code() { @@ -1925,6 +2110,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 +2202,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"); - - match msg { - RelayMessage::Event { event, .. } => { - assert_eq!( - event.kind, - Kind::Custom(44100), - "expected kind:44100, got {}", - event.kind.as_u16() - ); + // Wait for the kind:44100 notification for our channel. + let event = recv_membership_notice_for(&mut agent_client, &channel_id).await; - let tags: Vec<Vec<String>> = 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<Vec<String>> = 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 +2459,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 +2490,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<Vec<String>> = 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<Vec<String>> = 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..10ba459ecbc 100644 --- a/docs/nips/NIP-CL.md +++ b/docs/nips/NIP-CL.md @@ -66,6 +66,9 @@ 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. 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. Later `t` tags are labels. @@ -82,6 +85,29 @@ 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` 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 + 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 @@ -248,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 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/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/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( diff --git a/schema/tables/public/communities.sql b/schema/tables/public/communities.sql index 4c328109f69..3d2abfade95 100644 --- a/schema/tables/public/communities.sql +++ b/schema/tables/public/communities.sql @@ -21,6 +21,15 @@ 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, + -- 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')),