Skip to content

WIP feat(relay): community landing channel and welcome message - #9108

Draft
matt2e wants to merge 13 commits into
mainfrom
primary-welcome-channel
Draft

matt2e wants to merge 13 commits into
mainfrom
primary-welcome-channel

Conversation

@matt2e

@matt2e matt2e commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

This is the relay side of the community welcome experience. Every community gets at most one landing channel: the open stream a person joins first when they enter the community, and the one a client opens when it has no saved selection. The landing channel's kind:39000 also carries the community's custom welcome message and its title. All on-join logic lives in the relay. The client only reads the designation and shows the welcome card.

The relay plan and the relay↔client wire contract are written up in NOSTR.md under Landing channel. That section is the normative contract for clients and covers NIP-11, the kind:39000 tags, the kind:9002 tags with their exact refusal messages, the protections, enrollment and rollout. docs/nips/NIP-CL.md now notes the landing/welcome/welcome_title tags on kind:39000.

What changes, visibly

Landing channel and welcome message

  • Storage is migration 0084: two nullable columns on communities, landing_channel_id and welcome_message. Migration 0085 adds a third, welcome_title. All three have no default, no backfill and no foreign key, so the deployed relay is unaffected. The row is the only source of truth, so there is at most one landing channel by construction.
  • NIP-11 advertises the buzz-landing-channel extension, plus landing_channel (read live, no-store) when a channel is designated. NIP-11 is the authoritative read.
  • The landing channel's relay-signed kind:39000 carries a bare ["landing"] tag, ["welcome", text] while a custom message is set and ["welcome_title", text] while a custom title is set. These tags are a projection of the columns. When the designation moves, the previous landing channel's 39000 is re-signed without them. If the tags and NIP-11 disagree, NIP-11 wins.
  • kind:9002 gets three new tags, ["landing","true"], ["welcome", text | ""] and ["welcome_title", text | ""], and all require a relay owner or admin. API tokens also need the AdminChannels scope. A move is atomic and decided under the channel row lock: the target must be a stream that the event leaves open, unarchived and without a TTL. The client's full-state form [landing, welcome, welcome_title] is idempotent on the current landing channel; the earlier three-tag form [landing, welcome] is still accepted. welcome and welcome_title are partial updates of each other, so the three-tag form keeps the title. "" restores the client default for either. The designation can be moved but not turned off.
  • Welcome body: Markdown source, 1–4000 Unicode scalar values, not blank, with no control characters other than \n and \t (so \r is refused). The relay stores and projects the string as-is 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> for channels and messages, nostr:npub… for people), is a client concern, as is treating the text as untrusted input.
  • Welcome title: one line of plain text, 1–120 Unicode scalar values, not blank, with no control characters at all (\n and \t included) and no U+2028/U+2029 line or paragraph separator. Refusal: invalid: welcome title must be a single line without control characters.
  • Protections: while a channel is designated, the relay refuses to archive it, make it private, give it a TTL or delete it (kind:9008). These checks are backstops in the store, so every path hits them, including the 9002 lock-time path. Renames, about/topic/purpose, labels, unlisted, join_role, auto_join and membership changes are still allowed.

Adoption, including boot-time convergence for the deployment community

  • ensure_landing_channel adopts the oldest eligible general and prefers a listed one. If there is none, it creates a listed general owned by the earliest relay owner, else the earliest admin. It does nothing when there is no steward, or when the name is held by an archived, forum or TTL general. A NULL designation is the retry record.
  • Adoption runs after operator provisioning, at every human AUTH, and at boot for the deployment community only. Boot-time convergence is a best-effort task spawned off the boot path so that a local demo has a landing general before anyone connects. The plan said "never at startup", so this is a deliberate deviation. No other tenants are swept.
  • If a landing channel is made ineligible through an old pod, it stays designated, is counted as buzz_landing_adoption_total{outcome="existing_ineligible"} and logged as a warning. Nobody is enrolled into it until an owner or admin moves the designation.

Enrollment and roster-mode AUTH recovery

  • People are joined to the landing channel first, then to the auto_join defaults, as kind:9021 self-joins. Each join has the usual 40099, 39002 and 44100. Agents are never enrolled.
  • Roster mode: the invite claim and kind:9030 join the landing channel inline. AUTH now recovers a person with no entry evidence since their relay_members.created_at. It joins them to the landing channel and then to the defaults they have not been in since admission. This replaces the roster-free-only spawn gate, and it also enrolls buzz-admin add-member rows at their first AUTH. Recovery uses put_first_member_since, so a leave since admission stands and concurrent admissions announce once. channel_add_policy is not consulted at AUTH recovery, which matches feat(relay): auto-join new community members to default channels #8594's admission join.
  • If the landing join fails for a reason other than a refusal, the defaults are skipped too, so the next AUTH retries both. Once the landing row has committed, the defaults follow even if its announcement failed.

Roster-free evidence limits

  • In roster-free mode, AUTH enrolls only a person with no evidence at all: no stream or forum membership, active or ended, excluding private channels they created themselves (Solo and Bestie chats). Existing members who already belong to any shared channel are not added to the landing channel retroactively.
  • A roster-free community with no relay owner or admin has no steward. A missing general is not created there, but an eligible existing one is still adopted.
  • Reconnects, new devices and moving the designation never re-enroll anyone, and someone who left the landing channel stays out.

New-member read past the #p gate

  • The desktop client tells a new member from an existing one with one read, {authors:[viewer], until: T-600, limit: 1}, sent with no kinds, ids, #h or #p. T is when the client first opened the community on that device. The relay refused this read: POST /query returned 403 and a REQ was closed with restricted:. A filter with no kinds can match the #p-gated kinds, and p_gated_filters_authorized admitted one only when its #p values were all the reader's own pubkey.
  • That gate now also admits a filter whose authors are all the reader's own 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 pull those from another reader's inbox. The per-result reader_authorized_for_event gate still applies to what comes back, so a result-gated kind still reaches only the pubkey in its #p.
  • WS REQ, WS COUNT, POST /query and POST /count all call the same gate, so all four read paths change together. The engram and author-only gates already admitted this filter. The #p rule, the ids exemption, the other gates and the refusal texts are unchanged.
  • NOSTR.md names the rule in the membership-notification subscription constraint and describes the new-member read under Landing channel, saying relays must answer it. NIP-CL notes that such a filter returns no membership notice.

Rollout

  • During a rolling deploy, old pods don't advertise landing_channel, don't enroll anyone at AUTH and don't enforce the protections. They can also re-sign a channel's 39000 without its landing/welcome/welcome_title tags. The stored designation is unaffected, and a newer pod re-signs the tags at the channel's next change.
  • Existing communities converge at their first human AUTH after the upgrade. The deployment community converges at boot.

Shared e2e community

  • The shared e2e community now has a steward-owned general, and every fresh key gets its 44100. Three existing e2e tests that took the first membership notice now match on the channel's h tag instead.

#9045 merge order

Related issue

N/A. Related work and overlaps:

Testing

Status: the tests are written but have not been run locally. The machine was overloaded, so these commits were made and pushed with --no-verify. The only local check was cargo check --tests. CI is pending.

Run locally: the #p gate unit tests in crates/buzz-relay/src/handlers/req.rs (the new table-driven p_gate_admits_authors_self, plus the existing dm_visibility_requires_p_tag_even_with_ids, agent_turn_metric_requires_p_tag_even_with_ids and engram_gate_* tests): 15 passed.

Written, not yet run:

  • buzz-db (Postgres): designation moves and the idempotent full-state form (title alone, partial updates, a refusal elsewhere, the title moving with the designation), the welcome message and title rule tables, ineligible post-states, protections on every path, the adoption rule table, concurrent ensures, the entry-evidence table and the put_first_member_since table.
  • buzz-relay: tag rules (unit, including Markdown, tabs, the 4000 limit and every title receipt), the kind:9002 scope split, the 9002 authorization matrix with exact receipts, the 39000 projection with title and the three-tag form keeping it, move re-emission and NIP-11, protections including 9008, provisioning, and AUTH journeys in both modes. The roster-mode journey includes a step that pauses the default-channel recovery, races a join and a leave, and checks that the leave stands with no second member_joined.
  • e2e: a landing journey on the shared relay, sending the four-tag form with a Markdown body and a title. It restores the original designation, welcome and title and archives its channel under catch_unwind, so a failed assertion can't leave the shared relay moved.

Remaining before this leaves draft (AGENTS.md "Before opening a PR"):

  • CI green, including the Postgres and e2e lanes.
  • Agent exercise against a local relay: NIP-11 landing_channel, kind:9002 landing/welcome/welcome_title moves and refusals, protections, roster-mode claim and AUTH enrollment, and roster-free AUTH enrollment.
  • Human testing against the local relay, through the app or curl.
  • Add the AGENTS.md review-completed attestation to this description once the steps above hold.

🤖 Generated with Claude Code

matt2e added a commit that referenced this pull request Nov 6, 2026
Owners asked for a customizable welcome title and a welcome message that
supports Markdown with channel, message and user links. This adds both to
the landing-channel contract from #9108 (NOSTR.md, "Landing channel").

Title:
- Migration 0085 adds `communities.welcome_title TEXT`: nullable, no
  default, no backfill and no foreign key, so the deployed relay is
  unaffected. The desired schema and the migration pins follow.
- kind:9002 `["welcome_title", text]` has the same authority and
  eligibility as `welcome`: a relay owner or admin, the `AdminChannels`
  scope for API tokens, and only on the landing channel or together with
  `landing=true`. `""` restores the client's default title.
- The title is 1-120 Unicode scalar values, not blank, and has no control
  characters, `\n` and `\t` included.
- The landing channel's kind:39000 carries `["welcome_title", text]` while
  the title is customized. The title moves with the designation, and the
  previous landing channel's re-emission drops it.
- The client's full-state form is `[h, landing, welcome, welcome_title]`.
  The three-tag form stays accepted. `welcome` and `welcome_title` are
  partial updates, so an absent tag leaves its value unchanged. Both are
  written in one UPDATE inside the existing 9002 transaction.

Body:
- The `welcome` cap rises from 1000 to 4000 scalar values, and `\t` is
  allowed beside `\n` for Markdown indentation and code. Other control
  characters, including `\r`, and blank text are still refused.
- The relay stores and projects the body as received. NOSTR.md and
  NIP-CL now say that rendering Markdown and `nostr:` links is a client
  concern, as is treating the text as untrusted input.

Receipt texts (the buzz-app client shows them and does not match on
them):
- Not authorized: `actor not authorized: landing, welcome and
  welcome_title require a relay owner or admin`.
- Duplicate tags: `kind:9002 may include at most one landing, one welcome
  and one welcome_title tag`.
- Not on the landing channel: `welcome and welcome_title can only be set
  on the landing channel`.
- Message control characters: `welcome message must not contain control
  characters other than line breaks and tabs`. The length message is now
  `(max 4000 characters)`.
- New for the title: `welcome_title tag must have a value (text, or empty
  to restore the default)`, `welcome title must not be blank`, `welcome
  title is too long (max 120 characters)` and `welcome title must be a
  single line without control characters`.
- The kind:9002 tag-list message and the kind:9002 scope split in ingest
  now include `welcome_title`.

Tests extended (none run; only `cargo check -p buzz-db -p buzz-relay
-p buzz-test-client --tests`):
- buzz-db: title and message rule tables; the projection with a title;
  in the Postgres journey, title-alone and partial updates, a refusal
  elsewhere, and the title moving with the designation; the 0085 pin.
- buzz-relay: the tag-rule table (Markdown, tabs, the 4000 limit and
  every title receipt); a new kind:9002 scope-split table; the Postgres
  authorization matrix and 39000 projection with title, the three-tag
  form keeping the title, and the move dropping all three tags.
- e2e: the landing journey sends the four-tag form with a Markdown body
  and a title, and restores the original title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
matt2e and others added 8 commits November 7, 2026 00:20
Every community gets at most one landing channel: the open stream a person
joins first when they enter the community, and where the community's
custom welcome message is published. This implements the relay half of
the landing-channel plan (#note:8de4748c §4–§6) and its wire contract in
NOSTR.md.

Storage (migration 0084): two nullable columns on `communities`,
`landing_channel_id` and `welcome_message`. They have no default, no
backfill and no foreign key, so the deployed relay is unaffected. The
row is the single source of truth, so "exactly one" holds by
construction.

Advertisement:
- NIP-11 advertises the `buzz-landing-channel` extension and
  `landing_channel`. NIP-11 is the authoritative read; it reads the icon
  and the designation in one query.
- The landing channel's relay-signed kind:39000 carries a bare
  `["landing"]` tag, plus `["welcome", text]` while a custom message is
  set. This is a projection of the column. A move re-signs the previous
  landing channel's 39000 without both tags. Plan §3.2 said "no landing
  marker tag"; that no longer holds, and NOSTR.md and NIP-CL say so.

kind:9002 `["landing","true"]` and `["welcome", text|""]`:
- Both are relay owner/admin tags, like `auto_join`, and API tokens need
  the `AdminChannels` scope.
- Validation follows the plan's exact receipt texts.
- The write decides eligibility under the channel row lock, on the state
  the event leaves behind: an open, unarchived stream with no TTL.
- The client's full-state form `[landing, welcome]` is accepted both on
  the current landing channel (idempotent, not reported as a move) and
  as a move.
- `welcome` alone is accepted only on the landing channel.

Protections: the landing channel cannot be archived, made private, given
a TTL or deleted. The checks are backstops in `set_archived_on`,
`update_channel_on` and `soft_delete_on`, so every path is covered,
including the 9002 lock-time path. kind:9008 also gets a pre-check,
because its side effect runs after the event is stored.

Adoption (`ensure_landing_channel`):
- Adopt the oldest eligible `general`, listed before unlisted.
- If there is none, create a listed `general` owned by the earliest relay
  owner, else the earliest admin.
- Do nothing when there is no steward, or when the name is held by an
  archived, forum or TTL `general`.
- A NULL designation is the retry record. Adoption runs after operator
  provisioning (both branches) and at every human AUTH.

Enrollment:
- The landing channel is joined first, then the `auto_join` defaults, as
  kind:9021 self-joins. Agents are not joined.
- Roster mode:
  - The invite claim and kind:9030 join the landing channel inside
    `auto_join_default_channels`.
  - AUTH recovers a person with no entry evidence since their
    `relay_members.created_at`, joining the landing channel and then the
    defaults. This replaces the roster-free-only spawn gate.
- Roster-free mode: AUTH joins a person with no evidence at all, then
  the never-joined defaults as before.
- Evidence (`has_entered_community`) is a stream or forum membership,
  excluding private channels the person created themselves (Solo and
  Bestie chats).
- `put_first_member_since` keeps a leave since admission standing.
- A landing join that fails for a reason other than a refusal skips the
  defaults, so the next AUTH retries both. `join_channel_as_self` now
  returns typed refusals so this can tell them apart.

Deliberate deviations and tensions:
- Boot-time convergence for the deployment community: main.rs spawns a
  best-effort ensure after AppState is built. That way a local demo
  (`ensure_deployment_community` plus `bootstrap_owner`) has a landing
  `general` before anyone connects. The plan said "never at startup".
  This is only the deployment community and is off the boot path; there
  is no sweep of other tenants.
- In a roster-free community with no relay owner or admin, there is no
  steward. A missing `general` is not created, and an eligible one is
  still adopted.
- The shared e2e community now has a steward-owned `general`, and every
  fresh key gets its 44100. Three e2e tests that took the first
  membership notice now match on the channel's `h` tag.
- This is independent of the unmerged #9045, as the plan's merge-order
  rules say. 0084 is the next free migration here. If #9045 lands first,
  its pending-work token replaces this change's evidence-gated
  roster-mode recovery of defaults.

Tests:
- buzz-db Postgres: designation moves and the idempotent full-state form,
  ineligible post-states, protections on every path, the adoption rule
  table, concurrent ensures, the entry-evidence table and the
  `put_first_member_since` table.
- buzz-relay: tag rules (unit); the 9002 authorization matrix with exact
  receipts; the 39000 projection, move re-emission and NIP-11;
  protections including 9008; provisioning; AUTH journeys in both modes.
- e2e: a landing journey on the shared relay.
- None of these have been run locally; only `cargo check` was. The
  Postgres and e2e suites run in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Addresses the review of the landing channel change.

- Roster-write landing join: the membership write is now separate from
  its announcement (`put_member_as_self`, then `announce_self_join`).
  Only a failed write skips the default channels, so the next AUTH
  retries both; a committed row whose announcement failed is entry
  evidence, so the defaults follow it rather than being abandoned.
- Roster-mode AUTH recovery of the default channels uses
  `put_first_member_since(channel, admitted_at)` per channel and
  announces only when it wrote a row (`join_default_channels_since`,
  sharing one per-channel body with the roster-free path). Concurrent
  admissions of the same person join and announce once, and a leave
  since admission stands. `join_default_channels` loses its gate
  parameter; only the roster write sites use it now.
- `channel_add_policy` is not consulted at AUTH recovery, matching the
  roster-free admission join from #8594: an admission join is the
  person's own act, as a kind:9021 is, and the policy restricts who
  else may add them. Agents never reach the path. Documented in code and
  NOSTR.md.
- `ensure_landing_channel` reads whether a listed channel holds the
  name `general` (the `idx_channels_listed_name` predicate) before
  `create_general`, so a blocked name returns `SkippedNameTaken`
  without a write transaction. The `ChannelNameTaken` race fallback
  stays.
- NOSTR.md: the default-channels section describes roster-mode AUTH
  enrollment accurately (recovery, `buzz-admin add-member` rows now
  enrolled at first AUTH, policy behaviour, announce-once), and the
  landing section says a stale `landing`/`welcome` tag can come from a
  failed or racing re-emission and that NIP-11 `landing_channel` wins
  when they disagree.
- e2e: the landing journey restores the original designation and
  welcome and archives its channel under `catch_unwind`, so a failed
  assertion no longer leaves the shared relay moved.

None of the tests have been run locally; only `cargo check` was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
No blockers were found; this applies the optional findings.

- `put_member_as_self` writes through `put_members` and reads
  `PutMemberResult.prior_role`: when the upsert changed nothing (a join
  that raced this one, such as an invite claim against the same
  person's AUTH) it returns `AlreadyMember`, so no second kind:40099 or
  kind:44100 is emitted. `Db::put_members` is the new wrapper.
- The roster-mode AUTH journey gains a step that pins
  `put_first_member_since` in the default-channel recovery: paused at
  `before_auto_join_channel`, a racing join and a leave land, and on
  resume the leave stands with no second `member_joined` notice.
- `ensure_landing_channel` reads the designation's eligibility in the
  same query and reports `LandingAdoption::ExistingIneligible`
  (`buzz_landing_adoption_total{outcome="existing_ineligible"}`, a
  warning in the relay) for a landing channel archived, made private,
  deleted or given a TTL past the protections.
- Docs: the "name held" wording includes a TTL (code and NOSTR.md);
  the stale-tag causes cover any lost or reordered re-emission,
  including an old pod during a rolling deploy; the kind:9031
  re-admission sentence is qualified (kind:9031 removes nobody from
  channels, and active memberships always count); NOSTR.md gains a
  rollout note on old pods during deploy.

None of the tests have been run locally; only `cargo check` was.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
Owners asked for a customizable welcome title and a welcome message that
supports Markdown with channel, message and user links. This adds both to
the landing-channel contract from #9108 (NOSTR.md, "Landing channel").

Title:
- Migration 0085 adds `communities.welcome_title TEXT`: nullable, no
  default, no backfill and no foreign key, so the deployed relay is
  unaffected. The desired schema and the migration pins follow.
- kind:9002 `["welcome_title", text]` has the same authority and
  eligibility as `welcome`: a relay owner or admin, the `AdminChannels`
  scope for API tokens, and only on the landing channel or together with
  `landing=true`. `""` restores the client's default title.
- The title is 1-120 Unicode scalar values, not blank, and has no control
  characters, `\n` and `\t` included.
- The landing channel's kind:39000 carries `["welcome_title", text]` while
  the title is customized. The title moves with the designation, and the
  previous landing channel's re-emission drops it.
- The client's full-state form is `[h, landing, welcome, welcome_title]`.
  The three-tag form stays accepted. `welcome` and `welcome_title` are
  partial updates, so an absent tag leaves its value unchanged. Both are
  written in one UPDATE inside the existing 9002 transaction.

Body:
- The `welcome` cap rises from 1000 to 4000 scalar values, and `\t` is
  allowed beside `\n` for Markdown indentation and code. Other control
  characters, including `\r`, and blank text are still refused.
- The relay stores and projects the body as received. NOSTR.md and
  NIP-CL now say that rendering Markdown and `nostr:` links is a client
  concern, as is treating the text as untrusted input.

Receipt texts (the buzz-app client shows them and does not match on
them):
- Not authorized: `actor not authorized: landing, welcome and
  welcome_title require a relay owner or admin`.
- Duplicate tags: `kind:9002 may include at most one landing, one welcome
  and one welcome_title tag`.
- Not on the landing channel: `welcome and welcome_title can only be set
  on the landing channel`.
- Message control characters: `welcome message must not contain control
  characters other than line breaks and tabs`. The length message is now
  `(max 4000 characters)`.
- New for the title: `welcome_title tag must have a value (text, or empty
  to restore the default)`, `welcome title must not be blank`, `welcome
  title is too long (max 120 characters)` and `welcome title must be a
  single line without control characters`.
- The kind:9002 tag-list message and the kind:9002 scope split in ingest
  now include `welcome_title`.

Tests extended (none run; only `cargo check -p buzz-db -p buzz-relay
-p buzz-test-client --tests`):
- buzz-db: title and message rule tables; the projection with a title;
  in the Postgres journey, title-alone and partial updates, a refusal
  elsewhere, and the title moving with the designation; the 0085 pin.
- buzz-relay: the tag-rule table (Markdown, tabs, the 4000 limit and
  every title receipt); a new kind:9002 scope-split table; the Postgres
  authorization matrix and 39000 projection with title, the three-tag
  form keeping the title, and the move dropping all three tags.
- e2e: the landing journey sends the four-tag form with a Markdown body
  and a title, and restores the original title.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
The desktop renderer resolves channels and messages through
`buzz://channel/<id>` and `buzz://message?channel=<id>&id=<hex>` links and
people through `nostr:npub…` identity links; it has no NIP-21 nevent/naddr
handling. NOSTR.md and NIP-CL now describe the welcome body as Markdown
source whose links clients may resolve in those forms, instead of saying
clients turn NIP-21 event links into navigation. The relay stays agnostic
and stores the string as-is. The test fixtures use the same link forms.

Only comments, docs and string literals changed; no checks were run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
U+2028 and U+2029 are line breaks a renderer honours but `char::is_control`
does not flag. The title rule refuses them alongside control characters,
with the same receipt: `welcome title must be a single line without control
characters`. The rule table gains both rows, and NOSTR.md names them in the
limits bullet and the refusal row. Two over-long NOSTR.md lines are
reflowed.

No checks were run beyond rustfmt.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
The welcome title and message blank check was `str::trim`, so a value
made only of invisible characters passed and rendered an empty heading
or card: a zero-width space, a Braille blank, a BOM or a Hangul filler.
Bidi override, embedding and isolate characters were also accepted in
both fields, and they can reorder the displayed text.

buzz-db `landing` now defines the shared sets once:

- `is_invisible`: U+00AD, U+034F, U+061C, U+115F, U+1160, U+17B4,
  U+17B5, U+180B-U+180F, U+200B-U+200F, U+202A-U+202E, U+2060-U+206F,
  U+2800, U+3164, U+FE00-U+FE0F, U+FEFF, U+FFA0, U+FFF9-U+FFFB,
  U+1D173-U+1D17A and U+E0000-U+E007F.
- `is_bidi_control`: U+061C, U+200E, U+200F, U+202A-U+202E and
  U+2066-U+2069. Each is also invisible.
- `is_blank`: every char is Unicode `White_Space` (what `str::trim`
  strips) or invisible.

`welcome_message_error` and `welcome_title_error` apply these rules in
order: blank, then bidi, then length, then control characters (and
U+2028/U+2029 for the title). Invisible characters next to visible text
stay accepted, for example a ZWSP inside a word or the ZWJ in an emoji
sequence. A value made only of bidi controls is refused as blank. The
caller still treats the exact empty string as "restore the default".

New receipts:
- `welcome message must not contain bidirectional control characters`
- `welcome title must not contain bidirectional control characters`

The blank receipts are unchanged, so the existing relay assertions
still hold.

The buzz-app desktop client mirrors both sets character for character.
NIP-CL gains a "Welcome text" paragraph that publishes them. NOSTR.md
describes blank as whitespace and invisible characters and adds the two
bidi refusal rows.

Tests: the `welcome_message_rules` and `welcome_title_rules` tables
gain these rows:
- invisible-only values refused as blank (ZWSP, U+2800, " \u{200B} ",
  BOM, the fillers)
- a lone U+202E refused as blank
- bidi controls among text refused with the new receipts, including
  before the length and control checks
- ZWSP, BOM and ZWJ inside real text accepted
- tabs and newlines in the message still accepted

A new `invisible_and_bidi_sets` table pins both ends of every range
and the code points just outside them. Only `cargo check -p buzz-db
--tests` was run, which passed without warnings. The tests were not
run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…le set

Review follow-up. `is_invisible` gains U+1BCA0-U+1BCA3 (shorthand format
controls) and U+E0100-U+E01EF (variation selectors 17-256), so a welcome
title or message made only of them is refused as blank. The doc comment
and the NIP-CL list name both ranges. NOSTR.md points at NIP-CL for the
set and has no list of its own, so it is unchanged.

NIP-CL now says blank means Unicode `White_Space` without the `str::trim`
aside, and tells clients to test the property rather than trim:
JavaScript's `String.prototype.trim` strips U+FEFF and keeps U+0085.

Tests: `invisible_and_bidi_sets` pins both ends of each new range and
the code points outside them; `welcome_title_rules` refuses "\u{E0100}"
as blank. Only `cargo check -p buzz-db --tests` was run, which passed
without warnings. The tests were not run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
@matt2e
matt2e force-pushed the primary-welcome-channel branch from 1741c2b to af42d82 Compare November 6, 2026 13:24
matt2e and others added 5 commits November 7, 2026 01:03
The desktop client decides whether a viewer is a new community member
with one read, {authors:[viewer], until:T-600, limit:1}, sent with no
kinds, ids, #h or #p. It arrives over POST /query and could arrive as a
WebSocket REQ. The relay refused it with a 403 or a restricted: CLOSED.
A filter without kinds can match the #p-gated kinds (gift wraps,
membership notices, DM visibility snapshots, observer frames and turn
metrics), and p_gated_filters_authorized admitted such a filter only
when its #p values were all the reader's own pubkey. An authors filter
naming only the reader did not count.

It counts now. An event the reader signed is always theirs to read.
Gift wraps are signed by throwaway keys, and membership notices and DM
visibility snapshots by the relay, so authors=[self] can never fish
those out of another reader's inbox. The per-result gate
(reader_authorized_for_event) still applies to whatever the filter
returns, so a result-gated kind is delivered only to the pubkey in its
#p, whichever rule admitted the filter. The #p rule, the ids exemption
and its result-gated exception, and every other gate are unchanged.

The engram and author-only gates already admitted this filter. All four
read paths call the same p_gated_filters_authorized: WS REQ, WS COUNT,
POST /query and POST /count. The change therefore applies to all of
them without touching a call site. Refusal texts are unchanged.

The P_GATED_KINDS doc comment and NOSTR.md's subscription constraint
name the authors rule. NIP-CL notes that it returns no membership
notice. The Landing channel section of NOSTR.md describes the
new-member read and says relays must answer it.

A new table-driven unit test, p_gate_admits_authors_self, covers:
- kindless authors=[self] admitted
- authors=[other] and authors=[self, other] refused
- #p=[self] still admitted
- a gift-wrap filter, admitted with authors=[self] and refused with
  authors=[other]
- kinds without a p-gated kind unaffected
- an unscoped sibling filter still refusing the whole REQ
- the engram and author-only gates admitting the read

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
The previous commit let p_gated_filters_authorized admit a filter whose
authors are all the reader's own pubkey, but every refusal still told the
client only that #p must match its pubkey. REQ said "p-gated events
require #p matching your pubkey" and COUNT, POST /query and POST /count
said "p-gated kinds require #p tag matching your pubkey". A client that
read the message could not learn the other way in.

All four paths now send one text, the new P_GATED_REFUSAL constant in
handlers/req.rs: `restricted: p-gated events require #p or authors
matching only your pubkey`. It keeps the restricted: prefix and names
both rules a scanning read can meet. The ids exemption serves lookups,
not scans, so it stays out of the message. The constant's doc comment
says which paths send it, and the call-site comments in count.rs and
bridge.rs describe the rule the same way.

NOSTR.md's subscription constraint quotes the new text and says which
four paths send it. NIP-CL does not quote the text and is unchanged.
AGENTS.md gotcha #2 now says that omitting kinds triggers the p-gate
unless the filter has #p=[self], authors=[self] or explicit ids. The
mobile relay_session_test fixture that quoted the old REQ text word for
word now quotes the new one; the test's own assertion matched both. The
truncated fixture in observer_subscription_test is still a prefix of the
new text and is unchanged, as is the desktop 0.X e2e bridge mock, which
still implements the old rule and is out of scope here.

The p_gated_filters_authorized doc comment also notes that a kinds-less
authors=[self] read may scan a prolific author's events, because
idx_events_community_pubkey_kind_created orders kind before created_at.
That is accepted for the once-per-device new-member read, and no index
changes.

Checks run: cargo fmt --all --check, cargo check -p buzz-relay --tests
(clean), and the five p-gate unit tests in handlers::req::tests, which
pass. dart format cannot resolve the mobile package config in this
worktree, so I compared its output for the fixture file at HEAD and
after the edit: the only difference is the changed literal, kept on one
line. The Postgres and e2e suites were not run.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
concurrent_ensures_designate_one_general failed in CI (run 42234000163):
of eight concurrent ensure_landing_channel calls on a community with no
general, one returned SkippedNameTaken while the other seven agreed on the
created channel.

The cause was the name pre-read added in the review follow-up. A caller
that found no designation and no adoptable general went on to read whether
a listed channel holds the name general, and returned SkippedNameTaken if
one did. When another caller created and designated general in between,
that fresh, eligible channel counted as a holder, so the late caller
reported the name as taken instead of the new designation.

The pre-read, now general_name_held_by_ineligible, only counts holders
that cannot be adopted: listed and open, but archived, a forum or with a
TTL. An eligible holder falls through to create_general, whose
FOR NO KEY UPDATE designation re-check waits for the racing creator and
returns Existing. A holder that appears after the read is still handled by
the ChannelNameTaken fallback. A community whose name is blocked by an
archived, forum or TTL general still costs one read and no write
transaction, and the read count per ensure is unchanged.

ensure_landing_channel_rules now asserts the pre-read for every row: true
exactly for the NameTaken rows. Counting an eligible holder again makes the
"listed general" row fail on every run, not only when the concurrent test
happens to race.

Checks: cargo clippy -p buzz-db -p buzz-relay --all-targets -D warnings is
clean at the end of this series. The Postgres tests run in CI.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
…annel

Two CI failures from run 42234000163, both in the landing channel code.

Clippy type_complexity under -D warnings (Rust Lint and Windows Rust):

- buzz-relay handlers/landing.rs validate_tags iterated a
  [(&str, &[Option<&str>], fn(&str) -> Option<&'static str>); 2] table.
  The table is now a small validate_text_tag function called once for
  welcome and once for welcome_title. Same rules, same receipt texts.
- The landing_and_welcome_tag_rules test table uses a Tags alias for a
  kind:9002's tag lists.
- buzz-db store/landing.rs tests: ensure_landing_channel_rules seeds
  relay members through a RelayMembers alias, and entry_evidence_rules and
  put_first_member_since_rules take their rows as EvidenceCase and
  PutFirstCase aliases. The aliases' doc comments replace the inline
  column comments. The rows are unchanged.

No allow attributes were added.

p0_pool_acquisitions_use_typed_operation_pairs_without_other (Unit
Tests): the scan allows community.rs exactly one raw fetch_optional on
self.pool, for the out-of-scope NIP-11 metadata read. That read was
get_community_icon. NIP-11 now calls get_community_discovery, which
reads the icon and landing_channel_id in one query, and this branch
added it as a second raw checkout. get_community_icon had no production
caller left, so it is removed and the relay_admin icon test reads
get_community_discovery(...).icon. The NIP-11 read is again the only
raw checkout.

Checks run: cargo fmt --all --check; cargo clippy -p buzz-db -p buzz-relay
--all-targets -- -D warnings (clean); cargo test -p buzz-db --test
observability_source (23 passed); the buzz-db landing and community unit
tests and the relay landing_and_welcome_tag_rules test (pass).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
The rest of the PostgreSQL Tests failures from run 42234000163. All are
test fixes; the relay behaves as the landing contract in NOSTR.md says.
Main's CI at the base commit 32997e2 is green (run 42223420520), so
none of these were pre-existing.

landing_tags_need_a_relay_owner_or_admin_and_a_stream and
landing_designation_is_projected_advertised_and_protected (test): a
kind:9002 or kind:9008 refusal from validate_admin_event reaches the test
as Err(IngestError::Rejected(message)). The shared edit_channel helper
formatted every error with Debug, so the exact-receipt assertions saw
Rejected("…") with escaped quotes. edit_channel now returns a Rejected
error's message, the text a client sees in its OK false. Callers that
match a substring with contains are unaffected. The 9008 delete step had
the same problem behind .expect("ingest 9008"), which CI never reached;
it now matches Err(Rejected) and compares the message.

adding_a_member_auto_joins_default_channels,
claim_auto_joins_default_channels_once and
auto_join_skips_a_default_channel_archived_while_it_joins (test): these
predate the landing channel and counted every kind:44100 for the person
across the community. A kind:9030 add and an invite claim now also create
the landing general (the community has an owner) and join the person to
it, which is one more 44100. The counts now take the channels under test
by their h tag, and the claim test asserts the landing membership too.
This also fixes a latent race in claim_follow_up_survives_a_client_disconnect:
it waited for two notices and then required both default channels. With
the landing join counted, it could stop after landing plus the first
channel, before the second had joined.

roster_auth_recovers_landing_and_defaults_for_a_first_entry (test): a
kind:40099 member_joined is relay-signed at one-second resolution with
fixed content. Two in the same second share an event id, and the second
insert is a no-op. In CI the earlier joins and the racing join all landed
in one second, so the count before the race was 1 and the racing join
added nothing. The test now takes its baseline after the racing join and
releases the paused admission in the next second, so a notice the
admission wrongly sent would be a distinct, counted event.

migration_schema_listed_name_index_validates_a_prebuild and
migration_schema_listed_name_index_needs_the_renames_then_builds_inline
(test): after a full run_migrations they expected the ledger at
BEFORE_INDEX + 1 (0083), which held only while 0083 was the newest
migration. This branch adds 0084 and 0085. They now compare against the
newest migration in the migrations directory, as main's thread_window
test does.

The Postgres suites were not run locally; hosted CI runs them.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Matt Toohey <contact@matttoohey.com>
baxen added a commit to block/buzz-app that referenced this pull request Nov 7, 2026
…2904)

## Summary

A brand-new community has no channels, so its owner lands on a blank
Messages page ("Select a channel to read it.") after onboarding. Until
the relay creates a landing channel (block/buzz#9108), this PR has the
owner's client do it, and nothing more.

`useFirstChannel` (`src/bundled/channels/first-channel.ts`), called once
from `ChannelWorkspace`, acts only when **all** of these hold:

- the session is live (not cached), the channel list is `ready`, and the
viewer has joined no stream or forum channel (Solo chats and DMs don't
count);
- the community roster says the viewer is the community **owner**
(admins and members never act);
- `channels.anyChannel()` (new: `{kinds:[39000], authors:[relay],
"#t":["stream","forum"], limit:20}`, Solo-labelled streams skipped)
finds **no** stream or forum, open, private or archived. DMs, Solo chats
(the Bestie tab opens one first) and system channels don't count. A
failed check does nothing and retries next launch. A full batch of 20
Solo chats counts as not empty: it creates nothing and settles the
marker, so clients stop checking.

It then creates one open `general` through the existing
`channelCreation.create` and flags it as a starter channel with
`auto-join-on`, so the relay (block/buzz#8594) joins new members to it.
Messages opens it through its existing first-channel fallback (#1624).

**Not spamming general channels:**
- The relay's unique open channel names (block/buzz#8787) refuse a
second `general` in the same community.
- A settled view-state marker per community on each device means that
device never checks again once the community has a channel.
- At most one attempt per community per app run. A failure (for example,
the name is already taken) waits for the next launch instead of retrying
on every render.
- It never joins anyone, adds members or posts a message.
- Relay load: the community roster (`13534`, `limit: 1`) is read only
for a viewer with no channel to open, so ordinary clients make no new
reads. The channel check is a single read capped at 20 events, never a
directory scan.

Non-goals: welcome messages, landing-channel designation, server-side
provisioning (block/buzz#9108 / #2623).

## Testing

- `first-channel.test.tsx` (new, jsdom + RTL `renderHook`):
- the owner of an empty community gets exactly one open `general` plus
`auto-join-on`, and a later mount, even in a new run, doesn't read or
create again;
- a community where the relay already holds a channel settles without
creating, and a failed check creates and settles nothing;
- `anyChannel` sends exactly the capped `#t` filter, ignores Solo chats,
counts an archived stream, and treats a full batch of Solo chats as not
empty (store test);
  - admin, member and missing roles do nothing;
- a joined channel, a loading list or a cached session do nothing and
don't read the roster;
  - a refused create isn't retried in the same run.
- Fail-then-pass: loosening the owner check fails 3 cases.
- Full `vitest run` locally: 1022 files / 19379 tests pass; typecheck
and `biome check` clean.
- `vitest run src/bundled/channels src/bundled/moderation`: all pass,
including the existing starter-channel `ChannelsPage` cases. `tsc
--noEmit` and the pre-push hook suite pass.

**Deferred:**
- No agent run against a live relay yet.
- Human test: create a community in onboarding, open Messages, and
#general should appear and open. Reload, and nothing else gets created.
A second member joins and lands in #general.
- Browser suite left to CI.

---------

Signed-off-by: Bradley Axen <baxen@squareup.com>
Co-authored-by: mbot <3754f8729004d95654c46dbab3129e4ab9ef05cc2534e2a3fbfc155983bd637b@buzz.block.builderlab.xyz>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant